| 1 | // π€ Package vault is the Go twin of the Vault mini-app's reducer |
| 2 | // (web/static/apps/vault.js) plus the lock-layer crypto helpers the eleven CLI |
| 3 | // needs (docs/locked-vaults.md). The fold here and the web reducer are pinned |
| 4 | // to each other by test/event-vectors/vault.json β the vectors are the spec; |
| 5 | // if the two disagree, fix the implementation, never the vector. |
| 6 | // |
| 7 | // Like every app reducer, Fold treats events as UNTRUSTED input: anything |
| 8 | // malformed folds to a no-op, so a hostile member's events are safe inputs. |
| 9 | // No crypto runs in a fold β sealing/unsealing happens in the callers. |
| 10 | package vault |
| 11 | |
| 12 | import "strings" |
| 13 | |
| 14 | // Item is one named secret. Exactly one of Value/Sealed is meaningful: Sealed |
| 15 | // non-empty means the value is ECIES-sealed to the vault public key (a locked |
| 16 | // vault); otherwise Value holds the plaintext (inside the E2EE thread). |
| 17 | type Item struct { |
| 18 | ID string `json:"id"` |
| 19 | Name string `json:"name"` |
| 20 | Value string `json:"value"` |
| 21 | Sealed string `json:"sealed"` |
| 22 | Kind string `json:"kind"` |
| 23 | By string `json:"by"` |
| 24 | ByName string `json:"byName"` |
| 25 | At int64 `json:"at"` |
| 26 | UpdatedBy string `json:"updatedBy"` |
| 27 | UpdatedByName string `json:"updatedByName"` |
| 28 | UpdatedAt int64 `json:"updatedAt"` |
| 29 | } |
| 30 | |
| 31 | // LogEntry is one append-only audit record, deduped by its audit id. |
| 32 | type LogEntry struct { |
| 33 | ID string `json:"id"` // the event's aid |
| 34 | Ref string `json:"ref"` // the item id, "" for keyring actions |
| 35 | Action string `json:"action"` |
| 36 | Name string `json:"name"` |
| 37 | By string `json:"by"` |
| 38 | ByName string `json:"byName"` |
| 39 | At int64 `json:"at"` |
| 40 | } |
| 41 | |
| 42 | // Wrap is one keyring entry (docs/locked-vaults.md). Kinds: |
| 43 | // passkey / keychain β a member's DURABLE biometric wrap: the vault private |
| 44 | // key sealed under a PRF-derived (browser) or Touch ID keychain (CLI) KEK. |
| 45 | // member β a HANDOFF: the vault key sealed to a member's ROSTER |
| 46 | // public key, opened with their session key alone; dropped once they |
| 47 | // self-enrol a durable wrap. UID says whose it is. |
| 48 | // (A passphrase-derived "recovery" kind existed briefly, #995; removed |
| 49 | // 2026-07-25 β recovery is a passkey, never a passphrase.) |
| 50 | type Wrap struct { |
| 51 | ID string `json:"id"` |
| 52 | Kind string `json:"kind"` |
| 53 | Label string `json:"label"` |
| 54 | UID string `json:"uid"` // the member this wrap belongs to |
| 55 | CredID string `json:"credId"` |
| 56 | Wrapped string `json:"wrapped"` |
| 57 | } |
| 58 | |
| 59 | // Keyring marks a LOCKED vault: the vault public key plus every wrap of its |
| 60 | // private half. Nil keyring = plain vault. |
| 61 | type Keyring struct { |
| 62 | V int `json:"v"` |
| 63 | Pub string `json:"pub"` |
| 64 | Wraps []Wrap `json:"wraps"` |
| 65 | } |
| 66 | |
| 67 | // State is the folded vault, exactly the web reducer's { items, log, keyring }. |
| 68 | type State struct { |
| 69 | Items []Item `json:"items"` |
| 70 | Log []LogEntry `json:"log"` |
| 71 | Keyring *Keyring `json:"keyring"` |
| 72 | } |
| 73 | |
| 74 | // Event is one decrypted vault.* payload as generic JSON. |
| 75 | type Event = map[string]any |
| 76 | |
| 77 | // IsVaultEvent reports whether a decrypted payload is one of ours. |
| 78 | func IsVaultEvent(e Event) bool { |
| 79 | return strings.HasPrefix(str(e, "type"), "vault.") |
| 80 | } |
| 81 | |
| 82 | // FoldAll folds a message log's vault events, oldest first, from empty state. |
| 83 | func FoldAll(events []Event) State { |
| 84 | var s State |
| 85 | for _, e := range events { |
| 86 | s = Fold(s, e) |
| 87 | } |
| 88 | return s |
| 89 | } |
| 90 | |
| 91 | // Fold applies one event. It never mutates its input (callers may hold the old |
| 92 | // state) and folds anything malformed to a no-op, mirroring vault.js. |
| 93 | func Fold(s State, e Event) State { |
| 94 | switch str(e, "type") { |
| 95 | case "vault.add": |
| 96 | label := strings.TrimSpace(str(e, "label")) |
| 97 | value, hasValue := e["value"].(string) |
| 98 | sealed := str(e, "sealed") |
| 99 | hasSealed := sealed != "" |
| 100 | if str(e, "id") == "" || str(e, "aid") == "" || label == "" || hasValue == hasSealed { |
| 101 | return s |
| 102 | } |
| 103 | it := Item{ |
| 104 | ID: str(e, "id"), Name: label, Kind: "text", |
| 105 | By: str(e, "uid"), ByName: str(e, "name"), At: i64(e, "ts"), |
| 106 | } |
| 107 | if str(e, "kind") == "file" { |
| 108 | it.Kind = "file" |
| 109 | } |
| 110 | if hasSealed { |
| 111 | it.Sealed = sealed |
| 112 | } else { |
| 113 | it.Value = value |
| 114 | } |
| 115 | return State{Items: addItem(s.Items, it), Log: addLog(s.Log, e, "add", "", label), Keyring: s.Keyring} |
| 116 | case "vault.change": |
| 117 | id := str(e, "id") |
| 118 | idx := findItem(s.Items, id) |
| 119 | if id == "" || str(e, "aid") == "" || idx < 0 { |
| 120 | return s |
| 121 | } |
| 122 | items := append([]Item(nil), s.Items...) |
| 123 | it := items[idx] |
| 124 | it.UpdatedBy, it.UpdatedByName, it.UpdatedAt = str(e, "uid"), str(e, "name"), i64(e, "ts") |
| 125 | if l := strings.TrimSpace(str(e, "label")); l != "" { |
| 126 | it.Name = l |
| 127 | } |
| 128 | // A change carries a new sealed blob (locked vault) or a new plaintext |
| 129 | // value β whichever arrives replaces the other form. |
| 130 | if sealed := str(e, "sealed"); sealed != "" { |
| 131 | it.Sealed, it.Value = sealed, "" |
| 132 | } else if v, ok := e["value"].(string); ok { |
| 133 | it.Value, it.Sealed = v, "" |
| 134 | } |
| 135 | items[idx] = it |
| 136 | return State{Items: items, Log: addLog(s.Log, e, "change", "", it.Name), Keyring: s.Keyring} |
| 137 | case "vault.remove": |
| 138 | id := str(e, "id") |
| 139 | if id == "" || str(e, "aid") == "" { |
| 140 | return s |
| 141 | } |
| 142 | name := "" |
| 143 | if idx := findItem(s.Items, id); idx >= 0 { |
| 144 | name = s.Items[idx].Name |
| 145 | } |
| 146 | items := make([]Item, 0, len(s.Items)) |
| 147 | for _, it := range s.Items { |
| 148 | if it.ID != id { |
| 149 | items = append(items, it) |
| 150 | } |
| 151 | } |
| 152 | return State{Items: items, Log: addLog(s.Log, e, "remove", id, name), Keyring: s.Keyring} |
| 153 | case "vault.lock": |
| 154 | if s.Keyring != nil { |
| 155 | return s // one-way: a keyring is never replaced |
| 156 | } |
| 157 | w := wrapShape(e["wrap"]) |
| 158 | if str(e, "aid") == "" || str(e, "pub") == "" || w == nil { |
| 159 | return s |
| 160 | } |
| 161 | sealedMap, _ := e["sealed"].(map[string]any) |
| 162 | items := make([]Item, len(s.Items)) |
| 163 | for i, it := range s.Items { |
| 164 | if blob, _ := sealedMap[it.ID].(string); blob != "" { |
| 165 | it.Sealed, it.Value = blob, "" |
| 166 | } // uncovered items keep their plaintext; surfaces flag them |
| 167 | items[i] = it |
| 168 | } |
| 169 | // Besides the locker's own wrap, fold each `member` handoff β the vault |
| 170 | // key sealed to another member's roster key, so they can self-enrol. A |
| 171 | // non-member entry in handoffs is dropped (a handoff must be a handoff). |
| 172 | wraps := []Wrap{*w} |
| 173 | if hs, ok := e["handoffs"].([]any); ok { |
| 174 | for _, h := range hs { |
| 175 | if hw := wrapShape(h); hw != nil && hw.Kind == "member" { |
| 176 | wraps = addWrap(wraps, *hw) |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | return State{ |
| 181 | Items: items, |
| 182 | Log: addLog(s.Log, e, "lock", "", ""), |
| 183 | Keyring: &Keyring{V: 1, Pub: str(e, "pub"), Wraps: wraps}, |
| 184 | } |
| 185 | case "vault.enroll": |
| 186 | w := wrapShape(e["wrap"]) |
| 187 | // An enroll wrap must be DURABLE (never a `member` handoff β that would |
| 188 | // re-add the soft key the enroll is meant to replace). |
| 189 | if s.Keyring == nil || str(e, "aid") == "" || w == nil || w.Kind == "member" { |
| 190 | return s |
| 191 | } |
| 192 | uid := w.UID |
| 193 | if uid == "" { |
| 194 | uid = str(e, "uid") |
| 195 | } |
| 196 | kr := *s.Keyring |
| 197 | // Drop this member's soft `member` handoff β from here on they need their |
| 198 | // durable wrap. Then add it. |
| 199 | wraps := make([]Wrap, 0, len(kr.Wraps)) |
| 200 | for _, x := range kr.Wraps { |
| 201 | if x.Kind == "member" && uid != "" && x.UID == uid { |
| 202 | continue |
| 203 | } |
| 204 | wraps = append(wraps, x) |
| 205 | } |
| 206 | kr.Wraps = addWrap(wraps, *w) |
| 207 | return State{Items: s.Items, Log: addLog(s.Log, e, "enroll", "", w.Label), Keyring: &kr} |
| 208 | case "vault.give": |
| 209 | // An already-unlocked insider hands a thread member (who has no key yet) |
| 210 | // a `member` handoff β the vault key sealed to their roster key, which |
| 211 | // they self-enrol from. The give replaces request/grant: an insider |
| 212 | // GIVING, never an outsider asking. UID (the recipient) is required. |
| 213 | w := wrapShape(e["wrap"]) |
| 214 | if s.Keyring == nil || str(e, "aid") == "" || w == nil || w.Kind != "member" || w.UID == "" { |
| 215 | return s |
| 216 | } |
| 217 | kr := *s.Keyring |
| 218 | kr.Wraps = addWrap(kr.Wraps, *w) |
| 219 | return State{Items: s.Items, Log: addLog(s.Log, e, "give", "", w.Label), Keyring: &kr} |
| 220 | case "vault.revoke": |
| 221 | wrapID := str(e, "wrapId") |
| 222 | if s.Keyring == nil || str(e, "aid") == "" || wrapID == "" { |
| 223 | return s |
| 224 | } |
| 225 | gone := "" |
| 226 | found := false |
| 227 | for _, w := range s.Keyring.Wraps { |
| 228 | if w.ID == wrapID { |
| 229 | gone, found = w.Label, true |
| 230 | } |
| 231 | } |
| 232 | // The LAST wrap is unrevokable β it would strand the vault keyless. |
| 233 | if !found || len(s.Keyring.Wraps) <= 1 { |
| 234 | return s |
| 235 | } |
| 236 | kr := *s.Keyring |
| 237 | wraps := make([]Wrap, 0, len(kr.Wraps)) |
| 238 | for _, w := range kr.Wraps { |
| 239 | if w.ID != wrapID { |
| 240 | wraps = append(wraps, w) |
| 241 | } |
| 242 | } |
| 243 | kr.Wraps = wraps |
| 244 | return State{Items: s.Items, Log: addLog(s.Log, e, "revoke", "", gone), Keyring: &kr} |
| 245 | } |
| 246 | return s |
| 247 | } |
| 248 | |
| 249 | // FindItem returns the item with the given name, case-insensitively β the same |
| 250 | // near-duplicate guard the web's addOrUpdate applies. Nil when absent. |
| 251 | func (s State) FindItem(name string) *Item { |
| 252 | n := strings.ToLower(strings.TrimSpace(name)) |
| 253 | for i := range s.Items { |
| 254 | if strings.ToLower(s.Items[i].Name) == n { |
| 255 | return &s.Items[i] |
| 256 | } |
| 257 | } |
| 258 | return nil |
| 259 | } |
| 260 | |
| 261 | // ---- shapes (the reducer's input vetting; mirrors vault.js exactly) ---------- |
| 262 | |
| 263 | func wrapShape(v any) *Wrap { |
| 264 | m, _ := v.(map[string]any) |
| 265 | if m == nil { |
| 266 | return nil |
| 267 | } |
| 268 | w := Wrap{ID: str(m, "id"), Kind: str(m, "kind"), Label: str(m, "label"), UID: str(m, "uid"), Wrapped: str(m, "wrapped")} |
| 269 | // "member" = a soft handoff of the vault key to a member's roster key. |
| 270 | if w.ID == "" || w.Wrapped == "" || (w.Kind != "passkey" && w.Kind != "keychain" && w.Kind != "member") { |
| 271 | return nil |
| 272 | } |
| 273 | w.CredID = str(m, "credId") |
| 274 | return &w |
| 275 | } |
| 276 | |
| 277 | // ---- list helpers (apps.js addItem/updateItem/removeItem twins) -------------- |
| 278 | |
| 279 | func findItem(items []Item, id string) int { |
| 280 | for i, it := range items { |
| 281 | if it.ID == id { |
| 282 | return i |
| 283 | } |
| 284 | } |
| 285 | return -1 |
| 286 | } |
| 287 | |
| 288 | func addItem(items []Item, it Item) []Item { |
| 289 | if findItem(items, it.ID) >= 0 { |
| 290 | return items |
| 291 | } |
| 292 | return append(append([]Item(nil), items...), it) |
| 293 | } |
| 294 | |
| 295 | func addWrap(wraps []Wrap, w Wrap) []Wrap { |
| 296 | for _, x := range wraps { |
| 297 | if x.ID == w.ID { |
| 298 | return wraps |
| 299 | } |
| 300 | } |
| 301 | return append(append([]Wrap(nil), wraps...), w) |
| 302 | } |
| 303 | |
| 304 | // addLog appends one audit record, deduped by aid β a re-fold never double-logs. |
| 305 | func addLog(log []LogEntry, e Event, action, ref, name string) []LogEntry { |
| 306 | aid := str(e, "aid") |
| 307 | for _, l := range log { |
| 308 | if l.ID == aid { |
| 309 | return log |
| 310 | } |
| 311 | } |
| 312 | if ref == "" && (action == "add" || action == "change") { |
| 313 | ref = str(e, "id") |
| 314 | } |
| 315 | return append(append([]LogEntry(nil), log...), LogEntry{ |
| 316 | ID: aid, Ref: ref, Action: action, Name: name, |
| 317 | By: str(e, "uid"), ByName: str(e, "name"), At: i64(e, "ts"), |
| 318 | }) |
| 319 | } |
| 320 | |
| 321 | // ---- generic JSON accessors --------------------------------------------------- |
| 322 | |
| 323 | func str(e map[string]any, k string) string { |
| 324 | v, _ := e[k].(string) |
| 325 | return v |
| 326 | } |
| 327 | |
| 328 | func i64(e map[string]any, k string) int64 { |
| 329 | switch v := e[k].(type) { |
| 330 | case float64: |
| 331 | return int64(v) |
| 332 | case int64: |
| 333 | return v |
| 334 | case int: |
| 335 | return int64(v) |
| 336 | } |
| 337 | return 0 |
| 338 | } |
| 339 | |