eleven: vault verbs — list/get/set + Touch ID enroll/finish/approve (#968)
* web: the vault lock layer — passkey (Touch ID / Face ID) sealing for Vault values (#946) Values in a locked vault are ECIES-sealed to a dedicated vault public key; the private half exists only in keyring wraps openable by a passkey's WebAuthn PRF output (home's kekFromPRF pattern, fully client-side — zero server change). Adds seal to the public key with no unlock; reads cost one biometric, cached in memory for five minutes. Access moves by request/grant events through the same pure reducer. vaultcrypto.js reuses crypto.js's sealForPub/wrapThreadKey wire shapes, so the Go sidecar and CLI (#947, #948) speak it natively. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * eleven: vault verbs — list/get/set + Touch ID enroll/finish/approve (#947, #948) The CLI folds the same vault events the web reducer folds — the two are pinned to each other by test/event-vectors/vault.json (the Go fold in internal/vault and vault.js both fold every vector; the file is the spec). set seals to the vault public key with no unlock; get opens this machine's keychain wrap behind LocalAuthentication (Touch ID). Values never ride argv — hidden prompt, pipe, or -from-file. macOS gate scope (honest): LAContext + a login-keychain KEK — the Secure-Enclave-bound item needs a signed binary with entitlements, which would break plain go build; signed builds can upgrade later without changing the wrap format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * gofmt vault_test.go Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: live vault round-trip — SendEvent/Events against the real router, multi-page Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
14 files changed,
+1707
−1
eleven/main.go+5 −0eleven/vault.go+481 −0eleven/vaultkek_darwin.go+165 −0eleven/vaultkek_other.go+14 −0eleven_vault_live_test.go+130 −0go.mod+2 −1go.sum+4 −0internal/client/appevents.go+105 −0internal/client/client.go+3 −0internal/vault/vault.go+367 −0internal/vault/vault_test.go+132 −0test/event-vectors.test.mjs+43 −0test/event-vectors/index.js+2 −0test/event-vectors/vault.json+254 −0
diff --git a/eleven/main.go b/eleven/main.go| index d81ed76..931fe5c 100644 |
| --- a/eleven/main.go |
| +++ b/eleven/main.go |
| @@ -56,6 +56,8 @@ func main() { |
| runChat(mustSpaces(*dir, *sessionPath), strings.Join(args[1:], " ")) |
| case "send": |
| runSend(mustSpaces(*dir, *sessionPath), args[1:]) |
| + case "vault": |
| + runVault(mustSpaces(*dir, *sessionPath), args[1:]) |
| case "help", "-h", "--help": |
| fs.Usage() |
| default: |
| @@ -80,6 +82,9 @@ commands: |
| chat [name] open a chat and talk; newest chat when no name. |
| Type to send, Ctrl-C or Ctrl-D to leave |
| send <name> [text] send one message and exit (reads stdin without text) |
| + vault <command> the Vault app from the terminal: list/get/set |
| + secrets, enroll this machine with a Touch ID key |
| + ("eleven vault" shows the full set) |
| Chat names match by substring; prefix with a space to disambiguate |
| ("family/kitchen"). Identities live in ~/.eleven — those files hold your |
diff --git a/eleven/vault.go b/eleven/vault.go| new file mode 100644 |
| index 0000000..d4f5d08 |
| --- /dev/null |
| +++ b/eleven/vault.go |
| @@ -0,0 +1,481 @@ |
| +// 🤖 `eleven vault` — the CLI end of the Vault app (docs/locked-vaults.md). |
| +// The terminal is just another E2EE member: it folds the same vault events the |
| +// web reducer folds (internal/vault, pinned by test/event-vectors/vault.json) |
| +// and emits the same events the web emits. On a LOCKED vault, values seal to |
| +// the vault public key — so `set` never needs an unlock — and `get` opens the |
| +// member's keychain wrap behind Touch ID (vaultkek_darwin.go; other builds |
| +// refuse loudly). |
| +// |
| +// Secret values never ride argv (visible in `ps`/shell history): `set` reads a |
| +// hidden prompt, a pipe, or --from-file. `get` prints the raw value to stdout |
| +// for command substitution; everything else prints to stderr. |
| +package main |
| + |
| +import ( |
| + "crypto/rand" |
| + "encoding/hex" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "os" |
| + "sort" |
| + "strings" |
| + "unicode/utf8" |
| + |
| + "golang.org/x/term" |
| + |
| + "lchat/internal/e2ee" |
| + "lchat/internal/vault" |
| +) |
| + |
| +func runVault(spaces []*space, args []string) { |
| + if len(args) == 0 { |
| + vaultUsage() |
| + os.Exit(2) |
| + } |
| + verb, rest := args[0], args[1:] |
| + fs := flag.NewFlagSet("eleven vault "+verb, flag.ExitOnError) |
| + chat := fs.String("chat", "", "target a group chat's shared Vault (name substring, like `eleven chat`)") |
| + fromFile := fs.String("from-file", "", "`set`: read the value from a file (e.g. an .env) instead of prompting") |
| + label := fs.String("label", "", "`enroll`: how this key is listed in the vault's Access panel") |
| + fs.Usage = vaultUsage |
| + _ = fs.Parse(rest) |
| + rest = fs.Args() |
| + |
| + switch verb { |
| + case "list": |
| + sp, tid, title := vaultTarget(spaces, *chat) |
| + vaultList(title, foldVault(sp, tid)) |
| + case "get": |
| + if len(rest) != 1 { |
| + die("usage: eleven vault get [-chat c] <name>") |
| + } |
| + sp, tid, _ := vaultTarget(spaces, *chat) |
| + vaultGet(sp, tid, foldVault(sp, tid), rest[0]) |
| + case "set": |
| + if len(rest) != 1 { |
| + die("usage: eleven vault set [-chat c] [-from-file f] <name>") |
| + } |
| + sp, tid, _ := vaultTarget(spaces, *chat) |
| + vaultSet(sp, tid, foldVault(sp, tid), rest[0], *fromFile) |
| + case "enroll": |
| + sp, tid, _ := vaultTarget(spaces, *chat) |
| + vaultEnroll(sp, tid, foldVault(sp, tid), *label) |
| + case "finish": |
| + sp, tid, _ := vaultTarget(spaces, *chat) |
| + vaultFinish(sp, tid, foldVault(sp, tid)) |
| + case "approve": |
| + who := strings.Join(rest, " ") |
| + sp, tid, _ := vaultTarget(spaces, *chat) |
| + vaultApprove(sp, tid, foldVault(sp, tid), who) |
| + default: |
| + fmt.Fprintf(os.Stderr, "eleven: unknown vault command %q\n\n", verb) |
| + vaultUsage() |
| + os.Exit(2) |
| + } |
| +} |
| + |
| +func vaultUsage() { |
| + fmt.Fprint(os.Stderr, `usage: eleven vault <command> [flags] |
| + |
| +commands: |
| + list names, who added them, and the vault's lock state |
| + — never the values |
| + get <name> print one value (a locked vault asks for Touch ID) |
| + set [-from-file f] <name> |
| + store/overwrite a value — hidden prompt, piped |
| + stdin, or a file; on a locked vault it seals to the |
| + vault key with no unlock needed |
| + enroll [-label s] ask for access to a locked vault from this machine |
| + (mints a Touch ID-guarded key, posts the request) |
| + finish complete an approved enroll (one Touch ID) |
| + approve [who] approve a pending request (needs your Touch ID key) |
| + |
| +flags: |
| + -chat <needle> target a group chat's shared Vault instead of your |
| + personal one (substring match, like eleven chat) |
| + |
| +Your personal Vault is the default target. Values print to stdout; everything |
| +else goes to stderr, so VAR="$(eleven vault get name)" stays clean. |
| +`) |
| +} |
| + |
| +// vaultTarget picks the vault thread: an explicit -chat needle resolves like |
| +// `eleven chat`; otherwise the member's personal Vault app-thread (there is at |
| +// most one per space — the app id rides inside the encrypted name_blob, so |
| +// only members can even tell which thread that is). |
| +func vaultTarget(spaces []*space, chatNeedle string) (*space, string, string) { |
| + if chatNeedle != "" { |
| + st := resolveThread(spaces, chatNeedle) |
| + return st.sp, st.t.ID, displayName(st.t) |
| + } |
| + var found []spaceThread |
| + for _, st := range allThreads(spaces) { |
| + if st.t.Kind == "self" && st.t.App == "vault" { |
| + found = append(found, st) |
| + } |
| + } |
| + switch len(found) { |
| + case 1: |
| + return found[0].sp, found[0].t.ID, displayName(found[0].t) |
| + case 0: |
| + die("no personal Vault yet — open the Vault app in the web app once, or target a group's vault with -chat") |
| + default: |
| + names := make([]string, len(found)) |
| + for i, st := range found { |
| + names[i] = spaceHost(st.sp.sess) |
| + } |
| + die("you have a personal Vault in several spaces (%s) — pick one with -chat '<space host>/vault'", strings.Join(names, ", ")) |
| + } |
| + panic("unreachable") |
| +} |
| + |
| +func foldVault(sp *space, threadID string) vault.State { |
| + events, err := sp.c.Events(threadID, "vault.") |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + return vault.FoldAll(events) |
| +} |
| + |
| +func vaultList(title string, s vault.State) { |
| + lock := "not locked — values are E2EE to members, with no biometric gate" |
| + if s.Keyring != nil { |
| + keys := "1 key" |
| + if n := len(s.Keyring.Wraps); n != 1 { |
| + keys = fmt.Sprintf("%d keys", n) |
| + } |
| + lock = "LOCKED · " + keys |
| + if n := len(s.Keyring.Requests); n == 1 { |
| + lock += " · 1 pending request" |
| + } else if n > 1 { |
| + lock += fmt.Sprintf(" · %d pending requests", n) |
| + } |
| + } |
| + fmt.Fprintf(os.Stderr, "%s — %s\n", title, lock) |
| + if len(s.Items) == 0 { |
| + fmt.Fprintln(os.Stderr, "no secrets yet — `eleven vault set <name>` stores one") |
| + return |
| + } |
| + items := append([]vault.Item(nil), s.Items...) |
| + sort.Slice(items, func(i, j int) bool { return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name) }) |
| + for _, it := range items { |
| + marker := " " // plaintext (inside the E2EE thread) |
| + if it.Sealed != "" { |
| + marker = "🔒" |
| + } else if s.Keyring != nil { |
| + marker = "!" // a locked vault holding a stale unsealed value |
| + } |
| + line := fmt.Sprintf("%s %-28s added by %s %s", marker, clip(it.Name, 28), orSomeone(it.ByName), relTime(it.At/1000)) |
| + if it.UpdatedAt > 0 { |
| + line += fmt.Sprintf(" · updated by %s %s", orSomeone(it.UpdatedByName), relTime(it.UpdatedAt/1000)) |
| + } |
| + fmt.Println(line) |
| + } |
| + if s.Keyring != nil { |
| + for _, r := range s.Keyring.Requests { |
| + state := "wants access — `eleven vault approve`, or approve in the app" |
| + if r.Granted != "" { |
| + state = "granted — waiting for them to finish" |
| + } |
| + fmt.Fprintf(os.Stderr, "· request from %s: %s\n", orSomeone(r.ByName), state) |
| + } |
| + } |
| +} |
| + |
| +func vaultGet(sp *space, threadID string, s vault.State, name string) { |
| + it := s.FindItem(name) |
| + if it == nil { |
| + die("no secret named %q — `eleven vault list` shows what's there", name) |
| + } |
| + value := it.Value |
| + if it.Sealed != "" { |
| + priv := unlockVaultPriv(sp, threadID, s) |
| + var env struct { |
| + V string `json:"v"` |
| + } |
| + if err := e2ee.OpenSealed(it.Sealed, priv, &env); err != nil { |
| + die("%q won't open with this vault's key — it may have been sealed to an older one", name) |
| + } |
| + value = env.V |
| + } |
| + // The raw value on stdout (everything else rides stderr), newline-terminated |
| + // for the shell; $(…) substitution strips it. |
| + if _, err := io.WriteString(os.Stdout, value); err != nil { |
| + die("%v", err) |
| + } |
| + if !strings.HasSuffix(value, "\n") { |
| + fmt.Println() |
| + } |
| +} |
| + |
| +func vaultSet(sp *space, threadID string, s vault.State, name, fromFile string) { |
| + value := readSecretValue(name, fromFile) |
| + if value == "" { |
| + die("an empty value would be a confusing secret — nothing stored") |
| + } |
| + event := map[string]any{"aid": newID(), "label": name} |
| + if s.Keyring != nil { |
| + sealed, err := e2ee.SealForPub(s.Keyring.Pub, map[string]string{"v": value}) |
| + if err != nil { |
| + die("seal: %v", err) |
| + } |
| + event["sealed"] = sealed |
| + } else { |
| + event["value"] = value |
| + } |
| + verb := "Stored" |
| + if existing := s.FindItem(name); existing != nil { |
| + event["type"], event["id"] = "vault.change", existing.ID |
| + verb = "Updated" |
| + } else { |
| + event["type"], event["id"] = "vault.add", newID() |
| + } |
| + if err := sp.c.SendEvent(threadID, event); err != nil { |
| + die("%v", err) |
| + } |
| + if s.Keyring != nil { |
| + fmt.Fprintf(os.Stderr, "%s %q — sealed to the vault key (reading it back needs an unlock).\n", verb, name) |
| + } else { |
| + fmt.Fprintf(os.Stderr, "%s %q. This vault isn't locked — values are E2EE to members, but any signed-in device can read them. Lock it from the app for the biometric gate.\n", verb, name) |
| + } |
| +} |
| + |
| +// vaultEnroll asks for access from this machine: mint (or reuse) the Touch |
| +// ID-guarded keychain KEK, publish an ephemeral public key to be granted to, |
| +// and keep the ephemeral private half in the request itself — sealed under the |
| +// KEK, so only this machine (plus a biometric) can finish. |
| +func vaultEnroll(sp *space, threadID string, s vault.State, label string) { |
| + if s.Keyring == nil { |
| + die("this vault isn't locked yet — lock it in the app first (the first key is a passkey ceremony)") |
| + } |
| + if label == "" { |
| + host, _ := os.Hostname() |
| + label = strings.TrimSpace(sp.sess.Name + " — CLI @ " + host) |
| + } |
| + kek, err := vaultKEK(kekAccount(sp, threadID), true) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + eph, err := e2ee.GenerateUserKeypair() |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + ephWrapped, err := wrapUnderKEK(kek, eph.PrivateKey) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + err = sp.c.SendEvent(threadID, map[string]any{ |
| + "type": "vault.request", "aid": newID(), "rid": newID(), |
| + "pub": eph.PublicKey, "ephWrapped": ephWrapped, "kind": "keychain", "label": label, |
| + }) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + fmt.Fprintln(os.Stderr, "Access requested. Anyone who can unlock the vault approves it (in the app's Access panel, or `eleven vault approve` on their machine) — then run `eleven vault finish` here.") |
| +} |
| + |
| +// vaultFinish completes an approved request: the keychain KEK (one Touch ID) |
| +// opens the request's ephemeral key, the ephemeral key opens the grant, and a |
| +// durable keychain wrap goes into the keyring. |
| +func vaultFinish(sp *space, threadID string, s vault.State) { |
| + if s.Keyring == nil { |
| + die("this vault isn't locked") |
| + } |
| + me, err := sp.c.Me() |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + var req *vault.Request |
| + for i := range s.Keyring.Requests { |
| + r := &s.Keyring.Requests[i] |
| + if r.By == me && r.Kind == "keychain" { |
| + req = r |
| + } |
| + } |
| + if req == nil { |
| + die("no pending CLI request here — `eleven vault enroll` first") |
| + } |
| + if req.Granted == "" { |
| + die("your request hasn't been approved yet — someone who can unlock the vault has to approve it") |
| + } |
| + kek, err := vaultKEK(kekAccount(sp, threadID), false) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + ephPriv, err := openUnderKEK(kek, req.EphWrapped) |
| + if err != nil { |
| + die("couldn't open the request's key with this machine's keychain — was the request made on another machine?") |
| + } |
| + priv, err := e2ee.UnwrapThreadKey(req.Granted, ephPriv) |
| + if err != nil { |
| + die("the grant doesn't match this request — ask for a fresh approval") |
| + } |
| + wrapped, err := wrapUnderKEK(kek, priv) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + err = sp.c.SendEvent(threadID, map[string]any{ |
| + "type": "vault.enroll", "aid": newID(), "rid": req.ID, |
| + "wrap": map[string]any{"id": newID(), "kind": "keychain", "label": req.Label, "wrapped": wrapped}, |
| + }) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + fmt.Fprintln(os.Stderr, "You're in — this machine now unlocks the vault with Touch ID (`eleven vault get <name>`).") |
| +} |
| + |
| +// vaultApprove grants a pending request: this machine's keychain wrap opens |
| +// the vault key (one Touch ID), which wraps to the requester's ephemeral key. |
| +func vaultApprove(sp *space, threadID string, s vault.State, who string) { |
| + if s.Keyring == nil { |
| + die("this vault isn't locked") |
| + } |
| + var pending []vault.Request |
| + for _, r := range s.Keyring.Requests { |
| + if r.Granted == "" && (who == "" || strings.Contains(strings.ToLower(r.ByName+" "+r.Label), strings.ToLower(who))) { |
| + pending = append(pending, r) |
| + } |
| + } |
| + switch len(pending) { |
| + case 0: |
| + die("no matching pending request — `eleven vault list` shows them") |
| + case 1: |
| + // fall through |
| + default: |
| + names := make([]string, len(pending)) |
| + for i, r := range pending { |
| + names[i] = orSomeone(r.ByName) + " (" + r.Label + ")" |
| + } |
| + die("several pending requests match: %s — name one", strings.Join(names, ", ")) |
| + } |
| + req := pending[0] |
| + priv := unlockVaultPriv(sp, threadID, s) |
| + granted, err := e2ee.WrapThreadKey(priv, req.Pub) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + if err := sp.c.SendEvent(threadID, map[string]any{"type": "vault.grant", "aid": newID(), "rid": req.ID, "granted": granted}); err != nil { |
| + die("%v", err) |
| + } |
| + fmt.Fprintf(os.Stderr, "Granted — %s finishes on their side (`eleven vault finish`, or Finish setup in the app).\n", orSomeone(req.ByName)) |
| +} |
| + |
| +// unlockVaultPriv opens the vault private key with this machine's keychain KEK |
| +// (the Touch ID prompt) by trying it against every keychain wrap — which wrap |
| +// is ours needs no bookkeeping, a wrong KEK simply fails AES-GCM auth. |
| +func unlockVaultPriv(sp *space, threadID string, s vault.State) string { |
| + if s.Keyring == nil { |
| + die("this vault isn't locked — nothing to unlock") |
| + } |
| + var keychainWraps []vault.Wrap |
| + for _, w := range s.Keyring.Wraps { |
| + if w.Kind == "keychain" { |
| + keychainWraps = append(keychainWraps, w) |
| + } |
| + } |
| + if len(keychainWraps) == 0 { |
| + die("no CLI keys are enrolled in this vault yet — `eleven vault enroll` to request one") |
| + } |
| + kek, err := vaultKEK(kekAccount(sp, threadID), false) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + for _, w := range keychainWraps { |
| + if priv, err := openUnderKEK(kek, w.Wrapped); err == nil { |
| + return priv |
| + } |
| + } |
| + die("this machine's keychain key doesn't open any of the vault's CLI wraps — `eleven vault enroll` to request access from here") |
| + panic("unreachable") |
| +} |
| + |
| +// kekAccount names this vault's KEK in the keychain — per space AND per |
| +// thread, so two vaults never share a key. |
| +func kekAccount(sp *space, threadID string) string { |
| + return spaceHost(sp.sess) + "/" + threadID |
| +} |
| + |
| +// wrapUnderKEK/openUnderKEK carry a private key (pkcs8, base64url) under the |
| +// keychain KEK as b64url(iv||ct) — the same wrap format the web's |
| +// wrapVaultPriv/unwrapVaultPriv produce, so wraps are portable across ends. |
| +func wrapUnderKEK(kek []byte, privB64 string) (string, error) { |
| + raw, err := e2ee.B64uDecode(privB64) |
| + if err != nil { |
| + return "", err |
| + } |
| + out, err := e2ee.EncryptBytes(kek, raw) |
| + if err != nil { |
| + return "", err |
| + } |
| + return e2ee.B64uEncode(out), nil |
| +} |
| + |
| +func openUnderKEK(kek []byte, wrappedB64 string) (string, error) { |
| + raw, err := e2ee.B64uDecode(wrappedB64) |
| + if err != nil { |
| + return "", err |
| + } |
| + pt, err := e2ee.DecryptBytes(kek, raw) |
| + if err != nil { |
| + return "", err |
| + } |
| + return e2ee.B64uEncode(pt), nil |
| +} |
| + |
| +// readSecretValue sources a `set` value without ever touching argv: a file, |
| +// piped stdin, or a hidden double-prompt on a TTY. |
| +func readSecretValue(name, fromFile string) string { |
| + if fromFile != "" { |
| + raw, err := os.ReadFile(fromFile) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + if !utf8.Valid(raw) { |
| + die("%s isn't UTF-8 text — binary values aren't supported yet (base64 it for now)", fromFile) |
| + } |
| + return string(raw) |
| + } |
| + if !term.IsTerminal(int(os.Stdin.Fd())) { |
| + raw, err := io.ReadAll(os.Stdin) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + // A pipe usually ends with the shell's newline; one is stripped so |
| + // `echo secret | eleven vault set x` stores exactly "secret". |
| + return strings.TrimSuffix(string(raw), "\n") |
| + } |
| + fmt.Fprintf(os.Stderr, "Value for %q (hidden): ", name) |
| + first, err := term.ReadPassword(int(os.Stdin.Fd())) |
| + fmt.Fprintln(os.Stderr) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + fmt.Fprint(os.Stderr, "Once more to confirm: ") |
| + second, err := term.ReadPassword(int(os.Stdin.Fd())) |
| + fmt.Fprintln(os.Stderr) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + if string(first) != string(second) { |
| + die("the two entries differ — nothing stored") |
| + } |
| + return string(first) |
| +} |
| + |
| +func orSomeone(name string) string { |
| + if name == "" { |
| + return "someone" |
| + } |
| + return name |
| +} |
| + |
| +// newID mints an entity/audit id — 128 random bits, like the web's |
| +// crypto.randomUUID() (folds only ever compare ids, never parse them). |
| +func newID() string { |
| + b := make([]byte, 16) |
| + if _, err := rand.Read(b); err != nil { |
| + die("%v", err) |
| + } |
| + return hex.EncodeToString(b) |
| +} |
diff --git a/eleven/vaultkek_darwin.go b/eleven/vaultkek_darwin.go| new file mode 100644 |
| index 0000000..225789f |
| --- /dev/null |
| +++ b/eleven/vaultkek_darwin.go |
| @@ -0,0 +1,165 @@ |
| +//go:build darwin && cgo |
| + |
| +package main |
| + |
| +// 🤖 The macOS vault KEK (docs/locked-vaults.md): 32 random bytes living in the |
| +// login keychain, released to `eleven vault` only after a LocalAuthentication |
| +// check — Touch ID when the Mac has it, the account password otherwise (that's |
| +// LAPolicyDeviceOwnerAuthentication; a cancelled sheet is a hard failure). |
| +// |
| +// Honest scope: the biometric here is an AUTHENTICATION GATE in this process, |
| +// plus keychain encryption at rest — not a Secure-Enclave-bound key. The |
| +// enclave binding (kSecAccessControlBiometryCurrentSet in the data-protection |
| +// keychain) requires a signed binary carrying an application-identifier |
| +// entitlement, which a plain `go build` doesn't have — and a CLI that only |
| +// works signed would break the anyone-can-build rule. Signed release builds |
| +// can upgrade this to the enclave path (#950's hardening track) without |
| +// changing the wrap format: the KEK is 32 raw bytes either way. |
| + |
| +/* |
| +#cgo CFLAGS: -x objective-c -fobjc-arc |
| +#cgo LDFLAGS: -framework Security -framework CoreFoundation -framework LocalAuthentication -framework Foundation |
| +#include <stdlib.h> |
| +#include <string.h> |
| +#include <dispatch/dispatch.h> |
| +#include <CoreFoundation/CoreFoundation.h> |
| +#include <Security/Security.h> |
| +#import <LocalAuthentication/LocalAuthentication.h> |
| + |
| +// eleven_authenticate runs one LocalAuthentication ceremony (Touch ID or the |
| +// account password) and blocks until it resolves. 1 = authenticated. |
| +static int eleven_authenticate(const char *reason, char **errOut) { |
| + LAContext *ctx = [[LAContext alloc] init]; |
| + NSError *canErr = nil; |
| + if (![ctx canEvaluatePolicy:LAPolicyDeviceOwnerAuthentication error:&canErr]) { |
| + if (errOut && canErr) *errOut = strdup([[canErr localizedDescription] UTF8String]); |
| + return 0; |
| + } |
| + __block int ok = 0; |
| + __block char *msg = NULL; |
| + dispatch_semaphore_t sem = dispatch_semaphore_create(0); |
| + [ctx evaluatePolicy:LAPolicyDeviceOwnerAuthentication |
| + localizedReason:[NSString stringWithUTF8String:reason] |
| + reply:^(BOOL success, NSError *error) { |
| + if (success) { |
| + ok = 1; |
| + } else if (error) { |
| + msg = strdup([[error localizedDescription] UTF8String]); |
| + } |
| + dispatch_semaphore_signal(sem); |
| + }]; |
| + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); |
| + if (!ok && errOut) { |
| + *errOut = msg; |
| + } else if (msg) { |
| + free(msg); |
| + } |
| + return ok; |
| +} |
| + |
| +static CFDictionaryRef eleven_query(const char *service, const char *account, int forAdd, CFDataRef value) { |
| + CFStringRef svc = CFStringCreateWithCString(NULL, service, kCFStringEncodingUTF8); |
| + CFStringRef acct = CFStringCreateWithCString(NULL, account, kCFStringEncodingUTF8); |
| + CFMutableDictionaryRef q = CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); |
| + CFDictionarySetValue(q, kSecClass, kSecClassGenericPassword); |
| + CFDictionarySetValue(q, kSecAttrService, svc); |
| + CFDictionarySetValue(q, kSecAttrAccount, acct); |
| + if (forAdd) { |
| + CFDictionarySetValue(q, kSecValueData, value); |
| + } else { |
| + CFDictionarySetValue(q, kSecReturnData, kCFBooleanTrue); |
| + CFDictionarySetValue(q, kSecMatchLimit, kSecMatchLimitOne); |
| + } |
| + CFRelease(svc); |
| + CFRelease(acct); |
| + return q; |
| +} |
| + |
| +// eleven_keychain_read copies the item's bytes into a malloc'd buffer. |
| +static int eleven_keychain_read(const char *service, const char *account, unsigned char **data, size_t *len) { |
| + CFDictionaryRef q = eleven_query(service, account, 0, NULL); |
| + CFTypeRef out = NULL; |
| + OSStatus st = SecItemCopyMatching(q, &out); |
| + CFRelease(q); |
| + if (st != errSecSuccess) { |
| + return (int)st; |
| + } |
| + CFDataRef d = (CFDataRef)out; |
| + *len = (size_t)CFDataGetLength(d); |
| + *data = (unsigned char *)malloc(*len); |
| + memcpy(*data, CFDataGetBytePtr(d), *len); |
| + CFRelease(out); |
| + return 0; |
| +} |
| + |
| +static int eleven_keychain_add(const char *service, const char *account, const unsigned char *data, size_t len) { |
| + CFDataRef d = CFDataCreate(NULL, data, (CFIndex)len); |
| + CFDictionaryRef q = eleven_query(service, account, 1, d); |
| + OSStatus st = SecItemAdd(q, NULL); |
| + CFRelease(q); |
| + CFRelease(d); |
| + return (int)st; |
| +} |
| +*/ |
| +import "C" |
| + |
| +import ( |
| + "crypto/rand" |
| + "fmt" |
| + "unsafe" |
| +) |
| + |
| +// kekService namespaces every vault KEK this CLI keeps in the login keychain; |
| +// the account (host + thread id) picks the vault. |
| +const kekService = "com.elevenmessenger.eleven.vault-kek" |
| + |
| +const errSecItemNotFound = -25300 |
| + |
| +// vaultKEK returns the 32-byte KEK for one vault, behind a fresh |
| +// LocalAuthentication check. With create, a missing key is minted and stored; |
| +// without, missing means this machine was never enrolled. |
| +func vaultKEK(account string, create bool) ([]byte, error) { |
| + reason := C.CString("unlock the Eleven vault") |
| + defer C.free(unsafe.Pointer(reason)) |
| + var authErr *C.char |
| + if C.eleven_authenticate(reason, &authErr) == 0 { |
| + msg := "authentication failed" |
| + if authErr != nil { |
| + msg = C.GoString(authErr) |
| + C.free(unsafe.Pointer(authErr)) |
| + } |
| + return nil, fmt.Errorf("vault unlock: %s", msg) |
| + } |
| + |
| + service := C.CString(kekService) |
| + acct := C.CString(account) |
| + defer C.free(unsafe.Pointer(service)) |
| + defer C.free(unsafe.Pointer(acct)) |
| + |
| + var data *C.uchar |
| + var length C.size_t |
| + switch st := C.eleven_keychain_read(service, acct, &data, &length); st { |
| + case 0: |
| + kek := C.GoBytes(unsafe.Pointer(data), C.int(length)) |
| + C.free(unsafe.Pointer(data)) |
| + if len(kek) != 32 { |
| + return nil, fmt.Errorf("vault key in the keychain is %d bytes, want 32 — it may be corrupt", len(kek)) |
| + } |
| + return kek, nil |
| + case errSecItemNotFound: |
| + if !create { |
| + return nil, fmt.Errorf("no vault key on this machine yet — `eleven vault enroll` requests access first") |
| + } |
| + default: |
| + return nil, fmt.Errorf("keychain read failed (OSStatus %d)", int(st)) |
| + } |
| + |
| + kek := make([]byte, 32) |
| + if _, err := rand.Read(kek); err != nil { |
| + return nil, err |
| + } |
| + if st := C.eleven_keychain_add(service, acct, (*C.uchar)(unsafe.Pointer(&kek[0])), C.size_t(len(kek))); st != 0 { |
| + return nil, fmt.Errorf("keychain store failed (OSStatus %d)", int(st)) |
| + } |
| + return kek, nil |
| +} |
diff --git a/eleven/vaultkek_other.go b/eleven/vaultkek_other.go| new file mode 100644 |
| index 0000000..e3c8f32 |
| --- /dev/null |
| +++ b/eleven/vaultkek_other.go |
| @@ -0,0 +1,14 @@ |
| +//go:build !(darwin && cgo) |
| + |
| +package main |
| + |
| +import "fmt" |
| + |
| +// 🤖 vaultKEK is the biometric end of `eleven vault` on builds without the |
| +// macOS keychain (non-darwin, or CGO_ENABLED=0): every vault KEK operation |
| +// refuses loudly rather than degrading to an unguarded key on disk — a vault |
| +// KEK that doesn't cost a biometric isn't the feature (docs/locked-vaults.md). |
| +// The darwin+cgo implementation lives in vaultkek_darwin.go. |
| +func vaultKEK(account string, create bool) ([]byte, error) { |
| + return nil, fmt.Errorf("vault unlock needs the macOS keychain (Touch ID) — this build has no biometric store (build with cgo on macOS)") |
| +} |
diff --git a/eleven_vault_live_test.go b/eleven_vault_live_test.go| new file mode 100644 |
| index 0000000..3cce595 |
| --- /dev/null |
| +++ b/eleven_vault_live_test.go |
| @@ -0,0 +1,130 @@ |
| +package main |
| + |
| +// 🤖 Live-wire test for the CLI vault path (docs/locked-vaults.md): two |
| +// members against the real router, one emitting vault events through |
| +// Client.SendEvent, the other folding them back through Client.Events — |
| +// including a >1-page history, so the full-log pager is exercised where a |
| +// silent truncation would fold a vault with no keyring. |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "testing" |
| + |
| + "lchat/internal/e2ee" |
| + "lchat/internal/vault" |
| +) |
| + |
| +func TestElevenVaultEventsRoundTrip(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + // The message brake (burst 30, ~30/min) would turn a 200+-message history |
| + // into a seven-minute test; the brake has its own tests, this one is about |
| + // the pager. |
| + srv.brakes.messages = newModLimiter(100000, 100000) |
| + term := joinForTest(t, ts.URL, "Terminal") |
| + friend := joinForTest(t, ts.URL, "Friend") |
| + termUID, err := term.Me() |
| + if err != nil { |
| + t.Fatalf("Me: %v", err) |
| + } |
| + friendUID, err := friend.Me() |
| + if err != nil { |
| + t.Fatalf("Me: %v", err) |
| + } |
| + |
| + // A shared group, the operator-vault shape: thread key wrapped to both. |
| + tk, err := e2ee.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tkB64 := e2ee.B64uEncode(tk) |
| + wrapTo := func(pub string) string { |
| + w, err := e2ee.WrapThreadKey(tkB64, pub) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return w |
| + } |
| + nameBlob, err := e2ee.EncryptJSON(tk, map[string]string{"name": "ops"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _ := json.Marshal(map[string]any{ |
| + "token": friend.Session.Token, "kind": "group", "name_blob": nameBlob, |
| + "members": []map[string]string{ |
| + {"user_id": friendUID, "wrapped": wrapTo(friend.Session.UserPub)}, |
| + {"user_id": termUID, "wrapped": wrapTo(term.Session.UserPub)}, |
| + }, |
| + }) |
| + resp, err := http.Post(ts.URL+"/api/threads", "application/json", bytes.NewReader(body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var created struct { |
| + ThreadID string `json:"thread_id"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil || created.ThreadID == "" { |
| + t.Fatalf("create thread: %v (id %q)", err, created.ThreadID) |
| + } |
| + resp.Body.Close() |
| + tid := created.ThreadID |
| + |
| + // Terminal writes a vault life: one plain add, the lock re-sealing it, and |
| + // enough sealed adds to push the history past one /api/messages page (200) |
| + // — the pager must fold the FIRST page's lock event or everything below is |
| + // wrong. Blobs are opaque strings to the fold; the wire crypto (thread-key |
| + // AES-GCM) is the real thing via SendEvent. |
| + if err := term.SendEvent(tid, map[string]any{"type": "vault.add", "id": "s0", "aid": "a0", "label": "WiFi", "value": "hunter2"}); err != nil { |
| + t.Fatalf("SendEvent add: %v", err) |
| + } |
| + if err := term.SendEvent(tid, map[string]any{ |
| + "type": "vault.lock", "aid": "a-lock", "pub": "vault-pub", |
| + "wrap": map[string]any{"id": "w1", "kind": "keychain", "label": "Terminal — CLI", "wrapped": "wrap-blob"}, |
| + "sealed": map[string]any{"s0": "sealed-s0"}, |
| + }); err != nil { |
| + t.Fatalf("SendEvent lock: %v", err) |
| + } |
| + // A plain chat line in between must never fold into the vault. |
| + if err := term.Send(tid, "deploying now"); err != nil { |
| + t.Fatalf("Send: %v", err) |
| + } |
| + const extra = 205 |
| + for i := 1; i <= extra; i++ { |
| + ev := map[string]any{ |
| + "type": "vault.add", "id": fmt.Sprintf("s%03d", i), "aid": fmt.Sprintf("a%03d", i), |
| + "label": fmt.Sprintf("KEY_%03d", i), "sealed": fmt.Sprintf("sealed-%03d", i), |
| + } |
| + if err := term.SendEvent(tid, ev); err != nil { |
| + t.Fatalf("SendEvent %d: %v", i, err) |
| + } |
| + } |
| + |
| + // Friend folds the whole thing back — 200-per-page means at least two pages. |
| + events, err := friend.Events(tid, "vault.") |
| + if err != nil { |
| + t.Fatalf("Events: %v", err) |
| + } |
| + if len(events) != extra+2 { |
| + t.Fatalf("folded %d vault events, want %d (partial page walk?)", len(events), extra+2) |
| + } |
| + s := vault.FoldAll(events) |
| + if s.Keyring == nil || s.Keyring.Pub != "vault-pub" || len(s.Keyring.Wraps) != 1 { |
| + t.Fatalf("keyring didn't survive the page walk: %+v", s.Keyring) |
| + } |
| + if len(s.Items) != extra+1 { |
| + t.Fatalf("items %d, want %d", len(s.Items), extra+1) |
| + } |
| + if it := s.FindItem("WiFi"); it == nil || it.Sealed != "sealed-s0" || it.Value != "" { |
| + t.Fatalf("the lock's re-seal was lost across pages: %+v", it) |
| + } |
| + if it := s.FindItem("KEY_205"); it == nil || it.Sealed != "sealed-205" { |
| + t.Fatalf("last item wrong: %+v", it) |
| + } |
| + // The transport injected attribution: the emitter's uid and name, not the |
| + // event author's claims. |
| + if it := s.FindItem("KEY_001"); it == nil || it.By != termUID || it.ByName != "Terminal" { |
| + t.Fatalf("attribution not injected: %+v", it) |
| + } |
| +} |
diff --git a/go.mod b/go.mod| index 5f16f41..a266950 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -68,7 +68,8 @@ require ( |
| golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect |
| golang.org/x/net v0.55.0 // indirect |
| golang.org/x/sync v0.20.0 // indirect |
| - golang.org/x/sys v0.45.0 // indirect |
| + golang.org/x/sys v0.47.0 // indirect |
| + golang.org/x/term v0.45.0 // indirect |
| golang.org/x/text v0.37.0 // indirect |
| google.golang.org/protobuf v1.36.10 // indirect |
| modernc.org/libc v1.67.6 // indirect |
diff --git a/go.sum b/go.sum| index d7d1cc3..378243b 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -247,6 +247,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
| golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
| golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= |
| golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= |
| golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= |
| golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= |
| @@ -256,6 +258,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= |
| golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= |
| golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= |
| golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= |
| +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= |
| +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= |
| golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= |
| golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= |
| golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= |
diff --git a/internal/client/appevents.go b/internal/client/appevents.go| new file mode 100644 |
| index 0000000..2bd95a5 |
| --- /dev/null |
| +++ b/internal/client/appevents.go |
| @@ -0,0 +1,105 @@ |
| +package client |
| + |
| +// 🤖 App-event access: the mini-app layer's events are ordinary encrypted |
| +// messages whose decrypted payload carries a `type` (web/static/apps.js) — the |
| +// chat surfaces skip them, and app folds consume them. Events() lets a CLI |
| +// fold an app's full per-thread log (the Vault, docs/locked-vaults.md); |
| +// SendEvent() posts one event exactly the way the web transport does |
| +// (shell.js setEmitTransport: payload + injected name/ts/uid), so no fold can |
| +// tell where an event was emitted. |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "net/url" |
| + "strings" |
| + "time" |
| + |
| + "lchat/internal/e2ee" |
| +) |
| + |
| +// eventPageLimit matches the server's /api/messages cap. |
| +const eventPageLimit = 200 |
| + |
| +// eventMaxPages bounds a full-history walk. Folding a PARTIAL log would |
| +// silently drop early events (a vault.lock, an add), so past this we refuse |
| +// loudly instead of returning something subtly wrong. |
| +const eventMaxPages = 200 |
| + |
| +// Events returns every decrypted payload in a thread whose type has the given |
| +// prefix (e.g. "vault."), oldest first, walking the thread's whole history. |
| +// Undecryptable blobs (from before this member held the key) are skipped. |
| +func (c *Client) Events(threadID, typePrefix string) ([]map[string]any, error) { |
| + key, err := c.threadKey(threadID) |
| + if err != nil { |
| + return nil, err |
| + } |
| + type rawMsg struct { |
| + ID int64 `json:"id"` |
| + Blob string `json:"blob"` |
| + } |
| + var pages [][]rawMsg |
| + before := int64(1) << 62 |
| + for page := 0; ; page++ { |
| + if page >= eventMaxPages { |
| + return nil, fmt.Errorf("client: thread %s exceeds %d messages — refusing to fold a partial history", threadID, eventPageLimit*eventMaxPages) |
| + } |
| + var ml struct { |
| + Messages []rawMsg `json:"messages"` |
| + } |
| + path := fmt.Sprintf("/api/messages?thread=%s&limit=%d&before=%d", url.QueryEscape(threadID), eventPageLimit, before) |
| + if err := c.get(path, &ml); err != nil { |
| + return nil, err |
| + } |
| + if len(ml.Messages) == 0 { |
| + break |
| + } |
| + pages = append(pages, ml.Messages) |
| + before = ml.Messages[0].ID // each page is ascending; its head is the oldest seen |
| + if len(ml.Messages) < eventPageLimit { |
| + break |
| + } |
| + } |
| + var out []map[string]any |
| + for i := len(pages) - 1; i >= 0; i-- { // pages were collected newest-first |
| + for _, m := range pages[i] { |
| + var p map[string]any |
| + if e2ee.DecryptJSON(key, m.Blob, &p) != nil { |
| + continue |
| + } |
| + if t, _ := p["type"].(string); typePrefix == "" || strings.HasPrefix(t, typePrefix) { |
| + out = append(out, p) |
| + } |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// SendEvent encrypts one typed app event under the thread's key and posts it. |
| +// The transport-owned fields — name, ts, uid — are injected here, overwriting |
| +// anything the event carried, exactly like the web's emit transport (which is |
| +// why app payloads keep their own strings in fields like `label`). |
| +func (c *Client) SendEvent(threadID string, event map[string]any) error { |
| + key, err := c.threadKey(threadID) |
| + if err != nil { |
| + return err |
| + } |
| + payload := make(map[string]any, len(event)+3) |
| + for k, v := range event { |
| + payload[k] = v |
| + } |
| + payload["name"] = c.Session.Name |
| + payload["ts"] = time.Now().UnixMilli() |
| + if uid, err := c.Me(); err == nil && uid != "" { |
| + payload["uid"] = uid |
| + } |
| + blob, err := e2ee.EncryptJSON(key, payload) |
| + if err != nil { |
| + return err |
| + } |
| + body, err := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob}) |
| + if err != nil { |
| + return err |
| + } |
| + return c.postJSON("/api/messages?token="+url.QueryEscape(c.Session.Token), body, nil) |
| +} |
diff --git a/internal/client/client.go b/internal/client/client.go| index d11e025..f110f8d 100644 |
| --- a/internal/client/client.go |
| +++ b/internal/client/client.go |
| @@ -80,6 +80,7 @@ type Thread struct { |
| ID string |
| Kind string // self | dm | group |
| Name string // decrypted display name ("" when no key or no name yet) |
| + App string // a self app-thread's app id (name_blob.app — e.g. "vault"), "" otherwise |
| LastName string // sender of the latest message ("" when none/undecryptable) |
| LastText string |
| LastAt int64 // unix seconds of the latest message (0 when none) |
| @@ -384,9 +385,11 @@ func (c *Client) Threads() ([]Thread, error) { |
| var n struct { |
| Name string `json:"name"` |
| Title string `json:"title"` |
| + App string `json:"app"` |
| } |
| if e2ee.DecryptJSON(entry.key, st.NameBlob, &n) == nil { |
| t.Name = n.Name |
| + t.App = n.App |
| if t.Name == "" { |
| t.Name = n.Title |
| } |
diff --git a/internal/vault/vault.go b/internal/vault/vault.go| new file mode 100644 |
| index 0000000..0661c4e |
| --- /dev/null |
| +++ b/internal/vault/vault.go |
| @@ -0,0 +1,367 @@ |
| +// 🤖 Package vault is the Go twin of the Vault mini-app's reducer |
| +// (web/static/apps/vault.js) plus the lock-layer crypto helpers the eleven CLI |
| +// needs (docs/locked-vaults.md). The fold here and the web reducer are pinned |
| +// to each other by test/event-vectors/vault.json — the vectors are the spec; |
| +// if the two disagree, fix the implementation, never the vector. |
| +// |
| +// Like every app reducer, Fold treats events as UNTRUSTED input: anything |
| +// malformed folds to a no-op, so a hostile member's events are safe inputs. |
| +// No crypto runs in a fold — sealing/unsealing happens in the callers. |
| +package vault |
| + |
| +import "strings" |
| + |
| +// Item is one named secret. Exactly one of Value/Sealed is meaningful: Sealed |
| +// non-empty means the value is ECIES-sealed to the vault public key (a locked |
| +// vault); otherwise Value holds the plaintext (inside the E2EE thread). |
| +type Item struct { |
| + ID string `json:"id"` |
| + Name string `json:"name"` |
| + Value string `json:"value"` |
| + Sealed string `json:"sealed"` |
| + Kind string `json:"kind"` |
| + By string `json:"by"` |
| + ByName string `json:"byName"` |
| + At int64 `json:"at"` |
| + UpdatedBy string `json:"updatedBy"` |
| + UpdatedByName string `json:"updatedByName"` |
| + UpdatedAt int64 `json:"updatedAt"` |
| +} |
| + |
| +// LogEntry is one append-only audit record, deduped by its audit id. |
| +type LogEntry struct { |
| + ID string `json:"id"` // the event's aid |
| + Ref string `json:"ref"` // the item id, "" for keyring actions |
| + Action string `json:"action"` |
| + Name string `json:"name"` |
| + By string `json:"by"` |
| + ByName string `json:"byName"` |
| + At int64 `json:"at"` |
| +} |
| + |
| +// Wrap is the vault private key sealed under one unlocker's KEK — a passkey's |
| +// PRF-derived key in the browser, or a Touch ID-guarded keychain key here. |
| +type Wrap struct { |
| + ID string `json:"id"` |
| + Kind string `json:"kind"` // "passkey" | "keychain" |
| + Label string `json:"label"` |
| + CredID string `json:"credId"` |
| + Wrapped string `json:"wrapped"` |
| +} |
| + |
| +// Request is a member asking in: an ephemeral public key to grant to, with the |
| +// ephemeral private half sealed under the requester's own KEK. |
| +type Request struct { |
| + ID string `json:"id"` // the request id (rid) |
| + Pub string `json:"pub"` |
| + EphWrapped string `json:"ephWrapped"` |
| + Kind string `json:"kind"` |
| + CredID string `json:"credId"` |
| + Label string `json:"label"` |
| + Granted string `json:"granted"` |
| + By string `json:"by"` |
| + ByName string `json:"byName"` |
| + At int64 `json:"at"` |
| +} |
| + |
| +// Keyring marks a LOCKED vault: the vault public key plus every wrap of its |
| +// private half. Nil keyring = plain vault. |
| +type Keyring struct { |
| + V int `json:"v"` |
| + Pub string `json:"pub"` |
| + Wraps []Wrap `json:"wraps"` |
| + Requests []Request `json:"requests"` |
| +} |
| + |
| +// State is the folded vault, exactly the web reducer's { items, log, keyring }. |
| +type State struct { |
| + Items []Item `json:"items"` |
| + Log []LogEntry `json:"log"` |
| + Keyring *Keyring `json:"keyring"` |
| +} |
| + |
| +// Event is one decrypted vault.* payload as generic JSON. |
| +type Event = map[string]any |
| + |
| +// IsVaultEvent reports whether a decrypted payload is one of ours. |
| +func IsVaultEvent(e Event) bool { |
| + return strings.HasPrefix(str(e, "type"), "vault.") |
| +} |
| + |
| +// FoldAll folds a message log's vault events, oldest first, from empty state. |
| +func FoldAll(events []Event) State { |
| + var s State |
| + for _, e := range events { |
| + s = Fold(s, e) |
| + } |
| + return s |
| +} |
| + |
| +// Fold applies one event. It never mutates its input (callers may hold the old |
| +// state) and folds anything malformed to a no-op, mirroring vault.js. |
| +func Fold(s State, e Event) State { |
| + switch str(e, "type") { |
| + case "vault.add": |
| + label := strings.TrimSpace(str(e, "label")) |
| + value, hasValue := e["value"].(string) |
| + sealed := str(e, "sealed") |
| + hasSealed := sealed != "" |
| + if str(e, "id") == "" || str(e, "aid") == "" || label == "" || hasValue == hasSealed { |
| + return s |
| + } |
| + it := Item{ |
| + ID: str(e, "id"), Name: label, Kind: "text", |
| + By: str(e, "uid"), ByName: str(e, "name"), At: i64(e, "ts"), |
| + } |
| + if str(e, "kind") == "file" { |
| + it.Kind = "file" |
| + } |
| + if hasSealed { |
| + it.Sealed = sealed |
| + } else { |
| + it.Value = value |
| + } |
| + return State{Items: addItem(s.Items, it), Log: addLog(s.Log, e, "add", "", label), Keyring: s.Keyring} |
| + case "vault.change": |
| + id := str(e, "id") |
| + idx := findItem(s.Items, id) |
| + if id == "" || str(e, "aid") == "" || idx < 0 { |
| + return s |
| + } |
| + items := append([]Item(nil), s.Items...) |
| + it := items[idx] |
| + it.UpdatedBy, it.UpdatedByName, it.UpdatedAt = str(e, "uid"), str(e, "name"), i64(e, "ts") |
| + if l := strings.TrimSpace(str(e, "label")); l != "" { |
| + it.Name = l |
| + } |
| + // A change carries a new sealed blob (locked vault) or a new plaintext |
| + // value — whichever arrives replaces the other form. |
| + if sealed := str(e, "sealed"); sealed != "" { |
| + it.Sealed, it.Value = sealed, "" |
| + } else if v, ok := e["value"].(string); ok { |
| + it.Value, it.Sealed = v, "" |
| + } |
| + items[idx] = it |
| + return State{Items: items, Log: addLog(s.Log, e, "change", "", it.Name), Keyring: s.Keyring} |
| + case "vault.remove": |
| + id := str(e, "id") |
| + if id == "" || str(e, "aid") == "" { |
| + return s |
| + } |
| + name := "" |
| + if idx := findItem(s.Items, id); idx >= 0 { |
| + name = s.Items[idx].Name |
| + } |
| + items := make([]Item, 0, len(s.Items)) |
| + for _, it := range s.Items { |
| + if it.ID != id { |
| + items = append(items, it) |
| + } |
| + } |
| + return State{Items: items, Log: addLog(s.Log, e, "remove", id, name), Keyring: s.Keyring} |
| + case "vault.lock": |
| + if s.Keyring != nil { |
| + return s // one-way: a keyring is never replaced |
| + } |
| + w := wrapShape(e["wrap"]) |
| + if str(e, "aid") == "" || str(e, "pub") == "" || w == nil { |
| + return s |
| + } |
| + sealedMap, _ := e["sealed"].(map[string]any) |
| + items := make([]Item, len(s.Items)) |
| + for i, it := range s.Items { |
| + if blob, _ := sealedMap[it.ID].(string); blob != "" { |
| + it.Sealed, it.Value = blob, "" |
| + } // uncovered items keep their plaintext; surfaces flag them |
| + items[i] = it |
| + } |
| + return State{ |
| + Items: items, |
| + Log: addLog(s.Log, e, "lock", "", ""), |
| + Keyring: &Keyring{V: 1, Pub: str(e, "pub"), Wraps: []Wrap{*w}, Requests: []Request{}}, |
| + } |
| + case "vault.enroll": |
| + w := wrapShape(e["wrap"]) |
| + if s.Keyring == nil || str(e, "aid") == "" || w == nil { |
| + return s |
| + } |
| + kr := *s.Keyring |
| + kr.Wraps = addWrap(kr.Wraps, *w) |
| + if rid := str(e, "rid"); rid != "" { |
| + kr.Requests = removeRequest(kr.Requests, rid) |
| + } |
| + return State{Items: s.Items, Log: addLog(s.Log, e, "enroll", "", w.Label), Keyring: &kr} |
| + case "vault.revoke": |
| + wrapID := str(e, "wrapId") |
| + if s.Keyring == nil || str(e, "aid") == "" || wrapID == "" { |
| + return s |
| + } |
| + gone := "" |
| + found := false |
| + for _, w := range s.Keyring.Wraps { |
| + if w.ID == wrapID { |
| + gone, found = w.Label, true |
| + } |
| + } |
| + // The LAST wrap is unrevokable — dropping it would strand the vault |
| + // with no openable key, permanently. |
| + if !found || len(s.Keyring.Wraps) <= 1 { |
| + return s |
| + } |
| + kr := *s.Keyring |
| + wraps := make([]Wrap, 0, len(kr.Wraps)) |
| + for _, w := range kr.Wraps { |
| + if w.ID != wrapID { |
| + wraps = append(wraps, w) |
| + } |
| + } |
| + kr.Wraps = wraps |
| + return State{Items: s.Items, Log: addLog(s.Log, e, "revoke", "", gone), Keyring: &kr} |
| + case "vault.request": |
| + req := requestShape(e) |
| + if s.Keyring == nil || str(e, "aid") == "" || req == nil { |
| + return s |
| + } |
| + req.By, req.ByName, req.At = str(e, "uid"), str(e, "name"), i64(e, "ts") |
| + kr := *s.Keyring |
| + kr.Requests = addRequest(kr.Requests, *req) |
| + return State{Items: s.Items, Log: addLog(s.Log, e, "request", "", req.Label), Keyring: &kr} |
| + case "vault.grant": |
| + rid, granted := str(e, "rid"), str(e, "granted") |
| + if s.Keyring == nil || str(e, "aid") == "" || rid == "" || granted == "" { |
| + return s |
| + } |
| + idx := -1 |
| + for i, r := range s.Keyring.Requests { |
| + if r.ID == rid { |
| + idx = i |
| + } |
| + } |
| + if idx < 0 { |
| + return s |
| + } |
| + kr := *s.Keyring |
| + kr.Requests = append([]Request(nil), kr.Requests...) |
| + kr.Requests[idx].Granted = granted |
| + return State{Items: s.Items, Log: addLog(s.Log, e, "grant", "", kr.Requests[idx].Label), Keyring: &kr} |
| + } |
| + return s |
| +} |
| + |
| +// FindItem returns the item with the given name, case-insensitively — the same |
| +// near-duplicate guard the web's addOrUpdate applies. Nil when absent. |
| +func (s State) FindItem(name string) *Item { |
| + n := strings.ToLower(strings.TrimSpace(name)) |
| + for i := range s.Items { |
| + if strings.ToLower(s.Items[i].Name) == n { |
| + return &s.Items[i] |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// ---- shapes (the reducer's input vetting; mirrors vault.js exactly) ---------- |
| + |
| +func wrapShape(v any) *Wrap { |
| + m, _ := v.(map[string]any) |
| + if m == nil { |
| + return nil |
| + } |
| + w := Wrap{ID: str(m, "id"), Kind: str(m, "kind"), Label: str(m, "label"), Wrapped: str(m, "wrapped")} |
| + if w.ID == "" || w.Wrapped == "" || (w.Kind != "passkey" && w.Kind != "keychain") { |
| + return nil |
| + } |
| + w.CredID = str(m, "credId") |
| + return &w |
| +} |
| + |
| +func requestShape(e Event) *Request { |
| + r := Request{ID: str(e, "rid"), Pub: str(e, "pub"), EphWrapped: str(e, "ephWrapped"), Kind: str(e, "kind"), Label: str(e, "label")} |
| + if r.ID == "" || r.Pub == "" || r.EphWrapped == "" || (r.Kind != "passkey" && r.Kind != "keychain") { |
| + return nil |
| + } |
| + r.CredID = str(e, "credId") |
| + return &r |
| +} |
| + |
| +// ---- list helpers (apps.js addItem/updateItem/removeItem twins) -------------- |
| + |
| +func findItem(items []Item, id string) int { |
| + for i, it := range items { |
| + if it.ID == id { |
| + return i |
| + } |
| + } |
| + return -1 |
| +} |
| + |
| +func addItem(items []Item, it Item) []Item { |
| + if findItem(items, it.ID) >= 0 { |
| + return items |
| + } |
| + return append(append([]Item(nil), items...), it) |
| +} |
| + |
| +func addWrap(wraps []Wrap, w Wrap) []Wrap { |
| + for _, x := range wraps { |
| + if x.ID == w.ID { |
| + return wraps |
| + } |
| + } |
| + return append(append([]Wrap(nil), wraps...), w) |
| +} |
| + |
| +func addRequest(reqs []Request, r Request) []Request { |
| + for _, x := range reqs { |
| + if x.ID == r.ID { |
| + return reqs |
| + } |
| + } |
| + return append(append([]Request(nil), reqs...), r) |
| +} |
| + |
| +func removeRequest(reqs []Request, id string) []Request { |
| + out := make([]Request, 0, len(reqs)) |
| + for _, r := range reqs { |
| + if r.ID != id { |
| + out = append(out, r) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// addLog appends one audit record, deduped by aid — a re-fold never double-logs. |
| +func addLog(log []LogEntry, e Event, action, ref, name string) []LogEntry { |
| + aid := str(e, "aid") |
| + for _, l := range log { |
| + if l.ID == aid { |
| + return log |
| + } |
| + } |
| + if ref == "" && (action == "add" || action == "change") { |
| + ref = str(e, "id") |
| + } |
| + return append(append([]LogEntry(nil), log...), LogEntry{ |
| + ID: aid, Ref: ref, Action: action, Name: name, |
| + By: str(e, "uid"), ByName: str(e, "name"), At: i64(e, "ts"), |
| + }) |
| +} |
| + |
| +// ---- generic JSON accessors --------------------------------------------------- |
| + |
| +func str(e map[string]any, k string) string { |
| + v, _ := e[k].(string) |
| + return v |
| +} |
| + |
| +func i64(e map[string]any, k string) int64 { |
| + switch v := e[k].(type) { |
| + case float64: |
| + return int64(v) |
| + case int64: |
| + return v |
| + case int: |
| + return int64(v) |
| + } |
| + return 0 |
| +} |
diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go| new file mode 100644 |
| index 0000000..ba57f46 |
| --- /dev/null |
| +++ b/internal/vault/vault_test.go |
| @@ -0,0 +1,132 @@ |
| +package vault |
| + |
| +import ( |
| + "encoding/json" |
| + "os" |
| + "reflect" |
| + "testing" |
| +) |
| + |
| +// TestVectors folds every event_sequence in test/event-vectors/vault.json and |
| +// compares the canonical result with expected_state. The same file drives the |
| +// web side (test/event-vectors.test.mjs, against the real vault.js reducer) — |
| +// the vectors are the spec; a failure here means Go and web have diverged, and |
| +// the fix goes in an implementation, never the vector. |
| +func TestVectors(t *testing.T) { |
| + raw, err := os.ReadFile("../../test/event-vectors/vault.json") |
| + if err != nil { |
| + t.Fatalf("read vectors: %v", err) |
| + } |
| + var file struct { |
| + Vectors []struct { |
| + Name string `json:"name"` |
| + EventSequence []Event `json:"event_sequence"` |
| + ExpectedState any `json:"expected_state"` |
| + } `json:"vectors"` |
| + } |
| + if err := json.Unmarshal(raw, &file); err != nil { |
| + t.Fatalf("parse vectors: %v", err) |
| + } |
| + if len(file.Vectors) == 0 { |
| + t.Fatal("no vectors found") |
| + } |
| + for _, v := range file.Vectors { |
| + t.Run(v.Name, func(t *testing.T) { |
| + got := canonical(FoldAll(v.EventSequence)) |
| + want := roundTrip(t, v.ExpectedState) |
| + if !reflect.DeepEqual(got, want) { |
| + gotJSON, _ := json.MarshalIndent(got, "", " ") |
| + wantJSON, _ := json.MarshalIndent(want, "", " ") |
| + t.Errorf("fold mismatch\n got: %s\nwant: %s", gotJSON, wantJSON) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// canonical renders a State in the vector file's canonical form (every field |
| +// present, "" / 0 defaults, [] not null) as generic JSON values, so it compares |
| +// exactly against the parsed expected_state. |
| +func canonical(s State) any { |
| + items := make([]any, 0, len(s.Items)) |
| + for _, it := range s.Items { |
| + items = append(items, map[string]any{ |
| + "id": it.ID, "name": it.Name, "value": it.Value, "sealed": it.Sealed, "kind": it.Kind, |
| + "by": it.By, "byName": it.ByName, "at": float64(it.At), |
| + "updatedBy": it.UpdatedBy, "updatedByName": it.UpdatedByName, "updatedAt": float64(it.UpdatedAt), |
| + }) |
| + } |
| + log := make([]any, 0, len(s.Log)) |
| + for _, l := range s.Log { |
| + log = append(log, map[string]any{ |
| + "id": l.ID, "ref": l.Ref, "action": l.Action, "name": l.Name, |
| + "by": l.By, "byName": l.ByName, "at": float64(l.At), |
| + }) |
| + } |
| + var keyring any |
| + if s.Keyring != nil { |
| + wraps := make([]any, 0, len(s.Keyring.Wraps)) |
| + for _, w := range s.Keyring.Wraps { |
| + wraps = append(wraps, map[string]any{"id": w.ID, "kind": w.Kind, "label": w.Label, "credId": w.CredID, "wrapped": w.Wrapped}) |
| + } |
| + reqs := make([]any, 0, len(s.Keyring.Requests)) |
| + for _, r := range s.Keyring.Requests { |
| + reqs = append(reqs, map[string]any{ |
| + "id": r.ID, "pub": r.Pub, "ephWrapped": r.EphWrapped, "kind": r.Kind, "credId": r.CredID, |
| + "label": r.Label, "granted": r.Granted, "by": r.By, "byName": r.ByName, "at": float64(r.At), |
| + }) |
| + } |
| + keyring = map[string]any{"v": float64(s.Keyring.V), "pub": s.Keyring.Pub, "wraps": wraps, "requests": reqs} |
| + } |
| + return map[string]any{"items": items, "log": log, "keyring": keyring} |
| +} |
| + |
| +// roundTrip normalises the vector's expected_state through the same canonical |
| +// shape (filling absent defaults) so sparse vectors compare cleanly. |
| +func roundTrip(t *testing.T, expected any) any { |
| + t.Helper() |
| + raw, err := json.Marshal(expected) |
| + if err != nil { |
| + t.Fatalf("marshal expected: %v", err) |
| + } |
| + var s State |
| + if err := json.Unmarshal(raw, &s); err != nil { |
| + t.Fatalf("unmarshal expected into State: %v", err) |
| + } |
| + return canonical(s) |
| +} |
| + |
| +// TestFoldNeverMutates pins the copy-on-write contract: callers may hold the |
| +// old state across a fold (the web keeps prior states for snapshots). |
| +func TestFoldNeverMutates(t *testing.T) { |
| + s0 := FoldAll([]Event{ |
| + {"type": "vault.add", "id": "s1", "aid": "a1", "label": "WiFi", "value": "hunter2"}, |
| + }) |
| + s1 := Fold(s0, Event{"type": "vault.lock", "aid": "a2", "pub": "p", |
| + "wrap": map[string]any{"id": "w1", "kind": "passkey", "wrapped": "x"}, |
| + "sealed": map[string]any{"s1": "sealed-blob"}}) |
| + if s0.Items[0].Value != "hunter2" || s0.Items[0].Sealed != "" { |
| + t.Fatal("Fold mutated the prior state's item") |
| + } |
| + if s1.Items[0].Sealed != "sealed-blob" || s1.Items[0].Value != "" { |
| + t.Fatal("lock did not seal the covered item") |
| + } |
| + s2 := Fold(s1, Event{"type": "vault.enroll", "aid": "a3", |
| + "wrap": map[string]any{"id": "w2", "kind": "keychain", "wrapped": "y"}}) |
| + if len(s1.Keyring.Wraps) != 1 { |
| + t.Fatal("enroll mutated the prior keyring") |
| + } |
| + if len(s2.Keyring.Wraps) != 2 { |
| + t.Fatal("enroll did not add the wrap") |
| + } |
| +} |
| + |
| +// TestFindItem pins the case-insensitive near-duplicate lookup. |
| +func TestFindItem(t *testing.T) { |
| + s := FoldAll([]Event{{"type": "vault.add", "id": "s1", "aid": "a1", "label": "WiFi", "value": "v"}}) |
| + if it := s.FindItem(" wifi "); it == nil || it.ID != "s1" { |
| + t.Fatalf("FindItem case-insensitive lookup failed: %+v", it) |
| + } |
| + if s.FindItem("nope") != nil { |
| + t.Fatal("FindItem invented an item") |
| + } |
| +} |
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs| index 886f96f..a966e92 100644 |
| --- a/test/event-vectors.test.mjs |
| +++ b/test/event-vectors.test.mjs |
| @@ -7,6 +7,10 @@ import { viewItems, replyTarget } from "../web/static/apps/chat.js"; |
| // Views fold against the REAL app (views.js exports it) — its whole job is |
| // clamping hostile events, so a reconstruction would test the wrong thing. |
| import { viewsApp } from "../web/static/apps/views.js"; |
| +// 🤖 Vault folds against the REAL reducer too — these vectors are the |
| +// cross-IMPLEMENTATION contract with the Go fold (internal/vault), which reads |
| +// the same test/event-vectors/vault.json in its own test suite. |
| +import { app as vaultApp } from "../web/static/apps/vault.js"; |
| // Import chat.js fold handlers — the ones we're testing. |
| // The app.hostEvents from web/static/apps/chat.js. |
| @@ -541,4 +545,43 @@ allVectors.views.forEach((vector) => { |
| }); |
| }); |
| +// 🤖 Vault vectors (docs/locked-vaults.md) — folded by the real reducer and |
| +// compared in CANONICAL form (every field present, ""/0 defaults, keyring null |
| +// when unlocked), the same normalisation the Go side applies, so web and CLI |
| +// pin each other through one file. |
| +function canonVault(state) { |
| + const s = state || {}; |
| + const item = (i) => ({ |
| + id: i.id || "", name: i.name || "", |
| + value: typeof i.value === "string" ? i.value : "", |
| + sealed: typeof i.sealed === "string" ? i.sealed : "", |
| + kind: i.kind || "text", by: i.by || "", byName: i.byName || "", at: i.at || 0, |
| + updatedBy: i.updatedBy || "", updatedByName: i.updatedByName || "", updatedAt: i.updatedAt || 0, |
| + }); |
| + const entry = (l) => ({ id: l.id || "", ref: l.ref || "", action: l.action || "", name: l.name || "", by: l.by || "", byName: l.byName || "", at: l.at || 0 }); |
| + const wrap = (w) => ({ id: w.id || "", kind: w.kind || "", label: w.label || "", credId: w.credId || "", wrapped: w.wrapped || "" }); |
| + const req = (r) => ({ |
| + id: r.id || "", pub: r.pub || "", ephWrapped: r.ephWrapped || "", kind: r.kind || "", |
| + credId: r.credId || "", label: r.label || "", granted: r.granted || "", |
| + by: r.by || "", byName: r.byName || "", at: r.at || 0, |
| + }); |
| + return { |
| + items: (s.items || []).map(item), |
| + log: (s.log || []).map(entry), |
| + keyring: s.keyring |
| + ? { v: s.keyring.v || 0, pub: s.keyring.pub || "", wraps: (s.keyring.wraps || []).map(wrap), requests: (s.keyring.requests || []).map(req) } |
| + : null, |
| + }; |
| +} |
| + |
| +allVectors.vault.forEach((vector) => { |
| + test(`vault: ${vector.name} — folds to the expected state`, () => { |
| + let state; // undefined — reduce seeds initialState on the first event |
| + for (const event of vector.event_sequence) { |
| + state = vaultApp.reduce(state, event); |
| + } |
| + assert.deepStrictEqual(canonVault(state), canonVault(vector.expected_state), "vault state should match the vector exactly"); |
| + }); |
| +}); |
| + |
| if (fails) process.exit(1); |
diff --git a/test/event-vectors/index.js b/test/event-vectors/index.js| index 16dea7c..c7977c8 100644 |
| --- a/test/event-vectors/index.js |
| +++ b/test/event-vectors/index.js |
| @@ -10,6 +10,7 @@ export const remindVectors = JSON.parse(readFileSync(join(__dirname, "remind.jso |
| export const groupAppsVectors = JSON.parse(readFileSync(join(__dirname, "groupapps.json"), "utf8")); |
| export const adminVectors = JSON.parse(readFileSync(join(__dirname, "admin.json"), "utf8")); |
| export const viewsVectors = JSON.parse(readFileSync(join(__dirname, "views.json"), "utf8")); |
| +export const vaultVectors = JSON.parse(readFileSync(join(__dirname, "vault.json"), "utf8")); |
| // Flatten all vectors for easy iteration in tests |
| export const allVectors = { |
| @@ -19,4 +20,5 @@ export const allVectors = { |
| groupapps: groupAppsVectors.vectors || [], |
| admin: adminVectors.vectors || [], |
| views: viewsVectors.vectors || [], |
| + vault: vaultVectors.vectors || [], |
| }; |
diff --git a/test/event-vectors/vault.json b/test/event-vectors/vault.json| new file mode 100644 |
| index 0000000..b356828 |
| --- /dev/null |
| +++ b/test/event-vectors/vault.json |
| @@ -0,0 +1,254 @@ |
| +{ |
| + "description": "🤖 Vault fold vectors — the cross-implementation contract for the Vault app's reducer, INCLUDING the lock layer (docs/locked-vaults.md). The web reducer (web/static/apps/vault.js) and the Go fold (internal/vault) must fold every event_sequence from an empty state to exactly expected_state. States are compared in CANONICAL form: every item/log/keyring field present, \"\" / 0 defaults for absent strings/numbers, keyring null when the vault is unlocked. Vectors are the spec: if one side disagrees, fix the implementation, never the vector.", |
| + "app": "vault", |
| + "source": "web/static/apps/vault.js (events reducer) + internal/vault/vault.go (Fold)", |
| + "state_shape": { |
| + "items": [{ "id": "", "name": "", "value": "", "sealed": "", "kind": "text", "by": "", "byName": "", "at": 0, "updatedBy": "", "updatedByName": "", "updatedAt": 0 }], |
| + "log": [{ "id": "", "ref": "", "action": "add|change|remove|lock|enroll|revoke|request|grant", "name": "", "by": "", "byName": "", "at": 0 }], |
| + "keyring": { "v": 1, "pub": "", "wraps": [{ "id": "", "kind": "passkey|keychain", "label": "", "credId": "", "wrapped": "" }], "requests": [{ "id": "", "pub": "", "ephWrapped": "", "kind": "", "credId": "", "label": "", "granted": "", "by": "", "byName": "", "at": 0 }] } |
| + }, |
| + "vectors": [ |
| + { |
| + "name": "plain add, change, remove — items and audit log", |
| + "event_sequence": [ |
| + { "type": "vault.add", "id": "s1", "aid": "a1", "label": "WiFi", "value": "hunter2", "uid": "u1", "name": "Paul", "ts": 1000 }, |
| + { "type": "vault.change", "id": "s1", "aid": "a2", "value": "hunter3", "uid": "u2", "name": "James", "ts": 2000 }, |
| + { "type": "vault.add", "id": "s2", "aid": "a3", "label": "Door", "value": "0000", "uid": "u1", "name": "Paul", "ts": 3000 }, |
| + { "type": "vault.remove", "id": "s1", "aid": "a4", "uid": "u1", "name": "Paul", "ts": 4000 } |
| + ], |
| + "expected_state": { |
| + "items": [ |
| + { "id": "s2", "name": "Door", "value": "0000", "sealed": "", "kind": "text", "by": "u1", "byName": "Paul", "at": 3000, "updatedBy": "", "updatedByName": "", "updatedAt": 0 } |
| + ], |
| + "log": [ |
| + { "id": "a1", "ref": "s1", "action": "add", "name": "WiFi", "by": "u1", "byName": "Paul", "at": 1000 }, |
| + { "id": "a2", "ref": "s1", "action": "change", "name": "WiFi", "by": "u2", "byName": "James", "at": 2000 }, |
| + { "id": "a3", "ref": "s2", "action": "add", "name": "Door", "by": "u1", "byName": "Paul", "at": 3000 }, |
| + { "id": "a4", "ref": "s1", "action": "remove", "name": "WiFi", "by": "u1", "byName": "Paul", "at": 4000 } |
| + ], |
| + "keyring": null |
| + } |
| + }, |
| + { |
| + "name": "replays dedupe — a re-folded add changes nothing", |
| + "note": "addItem keys items by id and log entries by aid, so folding the same event twice (a reconnect catch-up overlapping a snapshot) is a no-op.", |
| + "event_sequence": [ |
| + { "type": "vault.add", "id": "s1", "aid": "a1", "label": "WiFi", "value": "hunter2", "uid": "u1", "name": "Paul", "ts": 1000 }, |
| + { "type": "vault.add", "id": "s1", "aid": "a1", "label": "WiFi", "value": "hunter2", "uid": "u1", "name": "Paul", "ts": 1000 } |
| + ], |
| + "expected_state": { |
| + "items": [ |
| + { "id": "s1", "name": "WiFi", "value": "hunter2", "sealed": "", "kind": "text", "by": "u1", "byName": "Paul", "at": 1000, "updatedBy": "", "updatedByName": "", "updatedAt": 0 } |
| + ], |
| + "log": [ |
| + { "id": "a1", "ref": "s1", "action": "add", "name": "WiFi", "by": "u1", "byName": "Paul", "at": 1000 } |
| + ], |
| + "keyring": null |
| + } |
| + }, |
| + { |
| + "name": "malformed adds fold to no-ops", |
| + "note": "missing id/aid/label, both value+sealed, neither, and empty sealed are all rejected; a remove of an unknown id still logs (the item may have raced away) but touches no items.", |
| + "event_sequence": [ |
| + { "type": "vault.add", "id": "", "aid": "a1", "label": "X", "value": "v" }, |
| + { "type": "vault.add", "id": "s1", "aid": "", "label": "X", "value": "v" }, |
| + { "type": "vault.add", "id": "s1", "aid": "a1", "label": " ", "value": "v" }, |
| + { "type": "vault.add", "id": "s1", "aid": "a1", "label": "X", "value": "v", "sealed": "s" }, |
| + { "type": "vault.add", "id": "s1", "aid": "a1", "label": "X" }, |
| + { "type": "vault.add", "id": "s1", "aid": "a1", "label": "X", "sealed": "" }, |
| + { "type": "vault.change", "id": "ghost", "aid": "a2", "value": "v" }, |
| + { "type": "vault.remove", "id": "ghost", "aid": "a3", "uid": "u1", "name": "Paul", "ts": 500 } |
| + ], |
| + "expected_state": { |
| + "items": [], |
| + "log": [ |
| + { "id": "a3", "ref": "ghost", "action": "remove", "name": "", "by": "u1", "byName": "Paul", "at": 500 } |
| + ], |
| + "keyring": null |
| + } |
| + }, |
| + { |
| + "name": "lock seals covered items, keeps uncovered plaintext, installs the keyring", |
| + "event_sequence": [ |
| + { "type": "vault.add", "id": "s1", "aid": "a1", "label": "WiFi", "value": "hunter2", "uid": "u1", "name": "Paul", "ts": 1000 }, |
| + { "type": "vault.add", "id": "s2", "aid": "a2", "label": "Door", "value": "0000", "uid": "u1", "name": "Paul", "ts": 2000 }, |
| + { "type": "vault.lock", "aid": "a3", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": { "s1": "sealed-s1" }, "uid": "u1", "name": "Paul", "ts": 3000 } |
| + ], |
| + "expected_state": { |
| + "items": [ |
| + { "id": "s1", "name": "WiFi", "value": "", "sealed": "sealed-s1", "kind": "text", "by": "u1", "byName": "Paul", "at": 1000, "updatedBy": "", "updatedByName": "", "updatedAt": 0 }, |
| + { "id": "s2", "name": "Door", "value": "0000", "sealed": "", "kind": "text", "by": "u1", "byName": "Paul", "at": 2000, "updatedBy": "", "updatedByName": "", "updatedAt": 0 } |
| + ], |
| + "log": [ |
| + { "id": "a1", "ref": "s1", "action": "add", "name": "WiFi", "by": "u1", "byName": "Paul", "at": 1000 }, |
| + { "id": "a2", "ref": "s2", "action": "add", "name": "Door", "by": "u1", "byName": "Paul", "at": 2000 }, |
| + { "id": "a3", "ref": "", "action": "lock", "name": "", "by": "u1", "byName": "Paul", "at": 3000 } |
| + ], |
| + "keyring": { |
| + "v": 1, |
| + "pub": "vault-pub", |
| + "wraps": [{ "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }], |
| + "requests": [] |
| + } |
| + } |
| + }, |
| + { |
| + "name": "lock is one-way; malformed locks fold to no-ops; junk wrap fields are dropped", |
| + "event_sequence": [ |
| + { "type": "vault.lock", "aid": "b1", "pub": "", "wrap": { "id": "w0", "kind": "passkey", "wrapped": "x" } }, |
| + { "type": "vault.lock", "aid": "b2", "pub": "p", "wrap": { "id": "w0", "kind": "usb-fob", "wrapped": "x" } }, |
| + { "type": "vault.lock", "aid": "b3", "pub": "p", "wrap": { "id": "w0", "kind": "passkey", "wrapped": "" } }, |
| + { "type": "vault.lock", "aid": "a1", "pub": "first-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1", "evil": "extra" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 }, |
| + { "type": "vault.lock", "aid": "a2", "pub": "second-pub", "wrap": { "id": "w2", "kind": "passkey", "label": "Mallory", "credId": "cred-2", "wrapped": "wrap-2" }, "sealed": {}, "uid": "u9", "name": "Mallory", "ts": 2000 } |
| + ], |
| + "expected_state": { |
| + "items": [], |
| + "log": [ |
| + { "id": "a1", "ref": "", "action": "lock", "name": "", "by": "u1", "byName": "Paul", "at": 1000 } |
| + ], |
| + "keyring": { |
| + "v": 1, |
| + "pub": "first-pub", |
| + "wraps": [{ "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }], |
| + "requests": [] |
| + } |
| + } |
| + }, |
| + { |
| + "name": "the grant ceremony: request (keychain kind) → grant → enroll clears the request", |
| + "note": "kind \"keychain\" is the eleven CLI's wrap kind — pinned here so the CLI's enroll/finish flow and the web's Approve stay one protocol.", |
| + "event_sequence": [ |
| + { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 }, |
| + { "type": "vault.request", "aid": "a2", "rid": "r1", "pub": "eph-pub", "ephWrapped": "eph-wrapped", "kind": "keychain", "label": "James — CLI", "uid": "u2", "name": "James", "ts": 2000 }, |
| + { "type": "vault.grant", "aid": "a3", "rid": "r1", "granted": "granted-blob", "uid": "u1", "name": "Paul", "ts": 3000 }, |
| + { "type": "vault.enroll", "aid": "a4", "rid": "r1", "wrap": { "id": "w2", "kind": "keychain", "label": "James — CLI", "wrapped": "wrap-2" }, "uid": "u2", "name": "James", "ts": 4000 } |
| + ], |
| + "expected_state": { |
| + "items": [], |
| + "log": [ |
| + { "id": "a1", "ref": "", "action": "lock", "name": "", "by": "u1", "byName": "Paul", "at": 1000 }, |
| + { "id": "a2", "ref": "", "action": "request", "name": "James — CLI", "by": "u2", "byName": "James", "at": 2000 }, |
| + { "id": "a3", "ref": "", "action": "grant", "name": "James — CLI", "by": "u1", "byName": "Paul", "at": 3000 }, |
| + { "id": "a4", "ref": "", "action": "enroll", "name": "James — CLI", "by": "u2", "byName": "James", "at": 4000 } |
| + ], |
| + "keyring": { |
| + "v": 1, |
| + "pub": "vault-pub", |
| + "wraps": [ |
| + { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, |
| + { "id": "w2", "kind": "keychain", "label": "James — CLI", "credId": "", "wrapped": "wrap-2" } |
| + ], |
| + "requests": [] |
| + } |
| + } |
| + }, |
| + { |
| + "name": "revoke drops a wrap but never the last one", |
| + "event_sequence": [ |
| + { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 }, |
| + { "type": "vault.revoke", "aid": "a2", "wrapId": "w1", "uid": "u1", "name": "Paul", "ts": 2000 }, |
| + { "type": "vault.enroll", "aid": "a3", "wrap": { "id": "w2", "kind": "keychain", "label": "Paul — CLI", "wrapped": "wrap-2" }, "uid": "u1", "name": "Paul", "ts": 3000 }, |
| + { "type": "vault.revoke", "aid": "a4", "wrapId": "w1", "uid": "u1", "name": "Paul", "ts": 4000 } |
| + ], |
| + "expected_state": { |
| + "items": [], |
| + "log": [ |
| + { "id": "a1", "ref": "", "action": "lock", "name": "", "by": "u1", "byName": "Paul", "at": 1000 }, |
| + { "id": "a3", "ref": "", "action": "enroll", "name": "Paul — CLI", "by": "u1", "byName": "Paul", "at": 3000 }, |
| + { "id": "a4", "ref": "", "action": "revoke", "name": "Paul", "by": "u1", "byName": "Paul", "at": 4000 } |
| + ], |
| + "keyring": { |
| + "v": 1, |
| + "pub": "vault-pub", |
| + "wraps": [{ "id": "w2", "kind": "keychain", "label": "Paul — CLI", "credId": "", "wrapped": "wrap-2" }], |
| + "requests": [] |
| + } |
| + } |
| + }, |
| + { |
| + "name": "junk requests/grants/enrolls fold to no-ops on a locked vault", |
| + "event_sequence": [ |
| + { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 }, |
| + { "type": "vault.request", "aid": "b1", "rid": "r1", "pub": "p", "ephWrapped": "e", "kind": "usb-fob" }, |
| + { "type": "vault.request", "aid": "b2", "rid": "r2", "pub": "", "ephWrapped": "e", "kind": "passkey" }, |
| + { "type": "vault.request", "aid": "b3", "rid": "r3", "pub": "p", "kind": "passkey" }, |
| + { "type": "vault.grant", "aid": "b4", "rid": "ghost", "granted": "blob" }, |
| + { "type": "vault.grant", "aid": "b5", "rid": "r1", "granted": "" }, |
| + { "type": "vault.enroll", "aid": "b6", "wrap": { "id": "", "kind": "passkey", "wrapped": "x" } } |
| + ], |
| + "expected_state": { |
| + "items": [], |
| + "log": [ |
| + { "id": "a1", "ref": "", "action": "lock", "name": "", "by": "u1", "byName": "Paul", "at": 1000 } |
| + ], |
| + "keyring": { |
| + "v": 1, |
| + "pub": "vault-pub", |
| + "wraps": [{ "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }], |
| + "requests": [] |
| + } |
| + } |
| + }, |
| + { |
| + "name": "keyring events before any lock fold to no-ops", |
| + "event_sequence": [ |
| + { "type": "vault.enroll", "aid": "a1", "wrap": { "id": "w1", "kind": "passkey", "wrapped": "x" } }, |
| + { "type": "vault.request", "aid": "a2", "rid": "r1", "pub": "p", "ephWrapped": "e", "kind": "passkey" }, |
| + { "type": "vault.grant", "aid": "a3", "rid": "r1", "granted": "g" }, |
| + { "type": "vault.revoke", "aid": "a4", "wrapId": "w1" } |
| + ], |
| + "expected_state": { "items": [], "log": [], "keyring": null } |
| + }, |
| + { |
| + "name": "sealed add on a locked vault; a change carrying sealed replaces stale plaintext", |
| + "event_sequence": [ |
| + { "type": "vault.add", "id": "s1", "aid": "a1", "label": "WiFi", "value": "hunter2", "uid": "u1", "name": "Paul", "ts": 1000 }, |
| + { "type": "vault.lock", "aid": "a2", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 2000 }, |
| + { "type": "vault.add", "id": "s2", "aid": "a3", "label": "Token", "sealed": "sealed-s2", "uid": "u2", "name": "James", "ts": 3000 }, |
| + { "type": "vault.change", "id": "s1", "aid": "a4", "sealed": "sealed-s1-now", "uid": "u2", "name": "James", "ts": 4000 } |
| + ], |
| + "expected_state": { |
| + "items": [ |
| + { "id": "s1", "name": "WiFi", "value": "", "sealed": "sealed-s1-now", "kind": "text", "by": "u1", "byName": "Paul", "at": 1000, "updatedBy": "u2", "updatedByName": "James", "updatedAt": 4000 }, |
| + { "id": "s2", "name": "Token", "value": "", "sealed": "sealed-s2", "kind": "text", "by": "u2", "byName": "James", "at": 3000, "updatedBy": "", "updatedByName": "", "updatedAt": 0 } |
| + ], |
| + "log": [ |
| + { "id": "a1", "ref": "s1", "action": "add", "name": "WiFi", "by": "u1", "byName": "Paul", "at": 1000 }, |
| + { "id": "a2", "ref": "", "action": "lock", "name": "", "by": "u1", "byName": "Paul", "at": 2000 }, |
| + { "id": "a3", "ref": "s2", "action": "add", "name": "Token", "by": "u2", "byName": "James", "at": 3000 }, |
| + { "id": "a4", "ref": "s1", "action": "change", "name": "WiFi", "by": "u2", "byName": "James", "at": 4000 } |
| + ], |
| + "keyring": { |
| + "v": 1, |
| + "pub": "vault-pub", |
| + "wraps": [{ "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }], |
| + "requests": [] |
| + } |
| + } |
| + }, |
| + { |
| + "name": "a pending request folds with its actor and survives until granted+enrolled", |
| + "event_sequence": [ |
| + { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 }, |
| + { "type": "vault.request", "aid": "a2", "rid": "r1", "pub": "eph-pub", "ephWrapped": "eph-wrapped", "kind": "passkey", "credId": "cred-2", "label": "James", "uid": "u2", "name": "James", "ts": 2000 }, |
| + { "type": "vault.grant", "aid": "a3", "rid": "r1", "granted": "granted-blob", "uid": "u1", "name": "Paul", "ts": 3000 } |
| + ], |
| + "expected_state": { |
| + "items": [], |
| + "log": [ |
| + { "id": "a1", "ref": "", "action": "lock", "name": "", "by": "u1", "byName": "Paul", "at": 1000 }, |
| + { "id": "a2", "ref": "", "action": "request", "name": "James", "by": "u2", "byName": "James", "at": 2000 }, |
| + { "id": "a3", "ref": "", "action": "grant", "name": "James", "by": "u1", "byName": "Paul", "at": 3000 } |
| + ], |
| + "keyring": { |
| + "v": 1, |
| + "pub": "vault-pub", |
| + "wraps": [{ "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }], |
| + "requests": [ |
| + { "id": "r1", "pub": "eph-pub", "ephWrapped": "eph-wrapped", "kind": "passkey", "credId": "cred-2", "label": "James", "granted": "granted-blob", "by": "u2", "byName": "James", "at": 2000 } |
| + ] |
| + } |
| + } |
| + } |
| + ] |
| +} |