eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.
10package vault
11
12import "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).
17type 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.
32type 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.)
50type 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.
61type 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 }.
68type 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.
75type Event = map[string]any
76
77// IsVaultEvent reports whether a decrypted payload is one of ours.
78func 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.
83func 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.
93func 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.
251func (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
263func 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
279func 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
288func 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
295func 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.
305func 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
323func str(e map[string]any, k string) string {
324 v, _ := e[k].(string)
325 return v
326}
327
328func 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