eleven / messenger Public

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>
Paul Campbell e6c7accbe75a5c3ad42d55a94d3f55533530d6dd parent ba08615
14 files changed, +1707 −1
  • eleven/main.go +5 −0
  • eleven/vault.go +481 −0
  • eleven/vaultkek_darwin.go +165 −0
  • eleven/vaultkek_other.go +14 −0
  • eleven_vault_live_test.go +130 −0
  • go.mod +2 −1
  • go.sum +4 −0
  • internal/client/appevents.go +105 −0
  • internal/client/client.go +3 −0
  • internal/vault/vault.go +367 −0
  • internal/vault/vault_test.go +132 −0
  • test/event-vectors.test.mjs +43 −0
  • test/event-vectors/index.js +2 −0
  • test/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 }
+ ]
+ }
+ }
+ }
+ ]
+}