// 🤖 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 one keyring entry (docs/locked-vaults.md). Kinds: // passkey / keychain — a member's DURABLE biometric wrap: the vault private // key sealed under a PRF-derived (browser) or Touch ID keychain (CLI) KEK. // member — a HANDOFF: the vault key sealed to a member's ROSTER // public key, opened with their session key alone; dropped once they // self-enrol a durable wrap. UID says whose it is. // (A passphrase-derived "recovery" kind existed briefly, #995; removed // 2026-07-25 — recovery is a passkey, never a passphrase.) type Wrap struct { ID string `json:"id"` Kind string `json:"kind"` Label string `json:"label"` UID string `json:"uid"` // the member this wrap belongs to CredID string `json:"credId"` Wrapped string `json:"wrapped"` } // 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"` } // 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 } // Besides the locker's own wrap, fold each `member` handoff — the vault // key sealed to another member's roster key, so they can self-enrol. A // non-member entry in handoffs is dropped (a handoff must be a handoff). wraps := []Wrap{*w} if hs, ok := e["handoffs"].([]any); ok { for _, h := range hs { if hw := wrapShape(h); hw != nil && hw.Kind == "member" { wraps = addWrap(wraps, *hw) } } } return State{ Items: items, Log: addLog(s.Log, e, "lock", "", ""), Keyring: &Keyring{V: 1, Pub: str(e, "pub"), Wraps: wraps}, } case "vault.enroll": w := wrapShape(e["wrap"]) // An enroll wrap must be DURABLE (never a `member` handoff — that would // re-add the soft key the enroll is meant to replace). if s.Keyring == nil || str(e, "aid") == "" || w == nil || w.Kind == "member" { return s } uid := w.UID if uid == "" { uid = str(e, "uid") } kr := *s.Keyring // Drop this member's soft `member` handoff — from here on they need their // durable wrap. Then add it. wraps := make([]Wrap, 0, len(kr.Wraps)) for _, x := range kr.Wraps { if x.Kind == "member" && uid != "" && x.UID == uid { continue } wraps = append(wraps, x) } kr.Wraps = addWrap(wraps, *w) return State{Items: s.Items, Log: addLog(s.Log, e, "enroll", "", w.Label), Keyring: &kr} case "vault.give": // An already-unlocked insider hands a thread member (who has no key yet) // a `member` handoff — the vault key sealed to their roster key, which // they self-enrol from. The give replaces request/grant: an insider // GIVING, never an outsider asking. UID (the recipient) is required. w := wrapShape(e["wrap"]) if s.Keyring == nil || str(e, "aid") == "" || w == nil || w.Kind != "member" || w.UID == "" { return s } kr := *s.Keyring kr.Wraps = addWrap(kr.Wraps, *w) return State{Items: s.Items, Log: addLog(s.Log, e, "give", "", 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 — it would strand the vault keyless. 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} } 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"), UID: str(m, "uid"), Wrapped: str(m, "wrapped")} // "member" = a soft handoff of the vault key to a member's roster key. if w.ID == "" || w.Wrapped == "" || (w.Kind != "passkey" && w.Kind != "keychain" && w.Kind != "member") { return nil } w.CredID = str(m, "credId") return &w } // ---- 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) } // 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 }