eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// πŸ€– `eleven vault` β€” the CLI end of the Vault app (docs/locked-vaults.md).
2// The terminal is just another E2EE member: it folds the same vault events the
3// web reducer folds (internal/vault, pinned by test/event-vectors/vault.json)
4// and emits the same events the web emits. On a LOCKED vault, values seal to
5// the vault public key β€” so `set` never needs an unlock β€” and `get` opens the
6// member's keychain wrap behind Touch ID (vaultkek_darwin.go; other builds
7// refuse loudly).
8//
9// Secret values never ride argv (visible in `ps`/shell history): `set` reads a
10// hidden prompt, a pipe, or --from-file. `get` prints the raw value to stdout
11// for command substitution; everything else prints to stderr.
12package main
13
14import (
15 "crypto/rand"
16 "encoding/hex"
17 "flag"
18 "fmt"
19 "io"
20 "os"
21 "sort"
22 "strings"
23 "unicode/utf8"
24
25 "golang.org/x/term"
26
27 "lchat/internal/client"
28 "lchat/internal/e2ee"
29 "lchat/internal/vault"
30)
31
32func runVault(spaces []*space, args []string) {
33 if len(args) == 0 {
34 vaultUsage()
35 os.Exit(2)
36 }
37 verb, rest := args[0], args[1:]
38 fs := flag.NewFlagSet("eleven vault "+verb, flag.ExitOnError)
39 chat := fs.String("chat", "", "target a group chat's shared Vault (name substring, like `eleven chat`)")
40 fromFile := fs.String("from-file", "", "`set`: read the value from a file (e.g. an .env) instead of prompting")
41 label := fs.String("label", "", "`enroll`: how this key is listed in the vault's Access panel")
42 dbPath := fs.String("db", "", "read a raw instance DB file OFFLINE (a copy of /data/<host>.db or an S3 replica restore) β€” no server involved; list/get only")
43 outFile := fs.String("o", "", "`get`: write the value to this file (mode 600) instead of stdout β€” the deploy/DR-safe form (#997)")
44 fs.Usage = vaultUsage
45 _ = fs.Parse(rest)
46 rest = fs.Args()
47
48 // Offline mode is deliberately read-only: writes belong on the live thread.
49 if *dbPath != "" && verb != "list" && verb != "get" {
50 die("only list and get work offline (-db) β€” %s needs the live space", verb)
51 }
52 opts := getOpts{outFile: *outFile}
53
54 switch verb {
55 case "list":
56 if *dbPath != "" {
57 _, _, title, s := vaultOffline(spaces, *dbPath, *chat)
58 vaultList(title, s)
59 return
60 }
61 sp, tid, title := vaultTarget(spaces, *chat)
62 vaultList(title, foldVault(sp, tid))
63 case "get":
64 if len(rest) != 1 {
65 die("usage: eleven vault get [-chat c] [-db f] [-o file] <name>")
66 }
67 if *dbPath != "" {
68 host, tid, _, s := vaultOffline(spaces, *dbPath, *chat)
69 vaultGet(host, tid, s, rest[0], opts)
70 return
71 }
72 sp, tid, _ := vaultTarget(spaces, *chat)
73 vaultGet(spaceHost(sp.sess), tid, foldVault(sp, tid), rest[0], opts)
74 case "set":
75 if len(rest) != 1 {
76 die("usage: eleven vault set [-chat c] [-from-file f] <name>")
77 }
78 sp, tid, _ := vaultTarget(spaces, *chat)
79 vaultSet(sp, tid, foldVault(sp, tid), rest[0], *fromFile)
80 case "enroll":
81 sp, tid, _ := vaultTarget(spaces, *chat)
82 vaultEnroll(sp, tid, foldVault(sp, tid), *label)
83 case "give":
84 who := strings.Join(rest, " ")
85 sp, tid, _ := vaultTarget(spaces, *chat)
86 vaultGive(sp, tid, foldVault(sp, tid), who)
87 default:
88 fmt.Fprintf(os.Stderr, "eleven: unknown vault command %q\n\n", verb)
89 vaultUsage()
90 os.Exit(2)
91 }
92}
93
94func vaultUsage() {
95 fmt.Fprint(os.Stderr, `usage: eleven vault <command> [flags]
96
97commands:
98 list names, who added them, and the vault's lock state
99 β€” never the values
100 get [-o f] <name> print one value (a locked vault asks for Touch ID);
101 -o writes it to a mode-600 file instead of stdout
102 set [-from-file f] <name>
103 store/overwrite a value β€” hidden prompt, piped
104 stdin, or a file; on a locked vault it seals to the
105 vault key with no unlock needed
106 enroll [-label s] set up THIS machine's key from your handoff β€” the
107 vault key was sealed to you when the vault was locked
108 (or given to you); mints a Touch ID-guarded key. No
109 approval: being in the thread is the entitlement.
110 give <who> give a thread member (who has no key yet) access β€”
111 seals the vault key to them (needs your Touch ID key)
112
113flags:
114 -chat <needle> target a group chat's shared Vault instead of your
115 personal one (substring match, like eleven chat)
116 -db <file> OFFLINE list/get from a raw instance DB β€” a copy of
117 /data/<host>.db or an S3 replica restore. No server
118 anywhere.
119 -o <file> get: write the value to a mode-600 file, never
120 stdout β€” the deploy/DR-safe form (use this in any
121 logged or Claude-driven shell).
122
123Your personal Vault is the default target. Bare "get" prints to stdout for
124VAR="$(eleven vault get name)"; NEVER use that form in a logged/Claude shell β€”
125use -o. There is no passphrase back door: access is your enrolled keys β€”
126lose them all and it's the other members (or a synced passkey) that let you
127back in.
128`)
129}
130
131// vaultTarget picks the vault thread: an explicit -chat needle resolves like
132// `eleven chat`; otherwise the member's personal Vault app-thread (there is at
133// most one per space β€” the app id rides inside the encrypted name_blob, so
134// only members can even tell which thread that is).
135func vaultTarget(spaces []*space, chatNeedle string) (*space, string, string) {
136 if chatNeedle != "" {
137 st := resolveThread(spaces, chatNeedle)
138 return st.sp, st.t.ID, displayName(st.t)
139 }
140 var found []spaceThread
141 for _, st := range allThreads(spaces) {
142 if st.t.Kind == "self" && st.t.App == "vault" {
143 found = append(found, st)
144 }
145 }
146 switch len(found) {
147 case 1:
148 return found[0].sp, found[0].t.ID, displayName(found[0].t)
149 case 0:
150 die("no personal Vault yet β€” open the Vault app in the web app once, or target a group's vault with -chat")
151 default:
152 names := make([]string, len(found))
153 for i, st := range found {
154 names[i] = spaceHost(st.sp.sess)
155 }
156 die("you have a personal Vault in several spaces (%s) β€” pick one with -chat '<space host>/vault'", strings.Join(names, ", "))
157 }
158 panic("unreachable")
159}
160
161func foldVault(sp *space, threadID string) vault.State {
162 events, err := sp.c.Events(threadID, "vault.")
163 if err != nil {
164 die("%v", err)
165 }
166 return vault.FoldAll(events)
167}
168
169// vaultOffline is the -db path (docs/locked-vaults.md "Offline restore β€” the
170// hard gate"): resolve and fold a vault from a raw instance DB file with no
171// server anywhere. The identity comes from ~/.eleven exactly as online β€” the
172// DB tells us which session file is a member (users.public_key); that
173// session's private key unwraps the thread key; Touch ID still gates sealed
174// values through the same keychain account the live path uses.
175func vaultOffline(spaces []*space, dbPath, chatNeedle string) (host, threadID, title string, s vault.State) {
176 o, err := vault.OpenOffline(dbPath)
177 if err != nil {
178 die("%v", err)
179 }
180 defer o.Close()
181 type hit struct {
182 sp *space
183 uid string
184 }
185 var hits []hit
186 for _, sp := range spaces {
187 uid, err := o.UserID(sp.sess.UserPub)
188 if err != nil {
189 die("%v", err)
190 }
191 if uid != "" {
192 hits = append(hits, hit{sp, uid})
193 }
194 }
195 switch len(hits) {
196 case 1:
197 // exactly one identity opens this DB β€” the normal case
198 case 0:
199 die("none of this terminal's identities are members of %s β€” its users table has no matching public key", dbPath)
200 default:
201 die("several identities match %s β€” scope to one with -session", dbPath)
202 }
203 sp, uid := hits[0].sp, hits[0].uid
204 threads, err := o.Threads(uid, sp.sess.UserPriv)
205 if err != nil {
206 die("%v", err)
207 }
208 needle := strings.ToLower(strings.TrimSpace(chatNeedle))
209 var found []vault.OfflineThread
210 for _, t := range threads {
211 if needle == "" {
212 if t.Kind == "self" && t.App == "vault" {
213 found = append(found, t)
214 }
215 } else if strings.Contains(strings.ToLower(t.Name), needle) || t.ID == chatNeedle {
216 found = append(found, t)
217 }
218 }
219 switch len(found) {
220 case 1:
221 // fall through
222 case 0:
223 if needle == "" {
224 die("no personal Vault in %s β€” a group's vault needs -chat", dbPath)
225 }
226 die("no thread matches %q in %s", chatNeedle, dbPath)
227 default:
228 die("%q matches several threads in %s β€” be more specific", chatNeedle, dbPath)
229 }
230 t := found[0]
231 state, err := o.FoldVault(t.ID, t.Key)
232 if err != nil {
233 die("%v", err)
234 }
235 name := t.Name
236 if name == "" {
237 name = "Vault"
238 }
239 return spaceHost(sp.sess), t.ID, name + " (offline: " + dbPath + ")", state
240}
241
242func vaultList(title string, s vault.State) {
243 lock := "not locked β€” values are E2EE to members, with no biometric gate"
244 if s.Keyring != nil {
245 // Count durable holders separately from members still holding a handoff
246 // (invited, not yet set up) β€” a `member` wrap can't open the vault yet.
247 durable, pending := 0, 0
248 for _, w := range s.Keyring.Wraps {
249 if w.Kind == "member" {
250 pending++
251 } else {
252 durable++
253 }
254 }
255 keys := "1 key"
256 if durable != 1 {
257 keys = fmt.Sprintf("%d keys", durable)
258 }
259 lock = "LOCKED Β· " + keys
260 if pending == 1 {
261 lock += " Β· 1 invited, not yet set up"
262 } else if pending > 1 {
263 lock += fmt.Sprintf(" Β· %d invited, not yet set up", pending)
264 }
265 }
266 fmt.Fprintf(os.Stderr, "%s β€” %s\n", title, lock)
267 if len(s.Items) == 0 {
268 fmt.Fprintln(os.Stderr, "no secrets yet β€” `eleven vault set <name>` stores one")
269 return
270 }
271 items := append([]vault.Item(nil), s.Items...)
272 sort.Slice(items, func(i, j int) bool { return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name) })
273 for _, it := range items {
274 marker := " " // plaintext (inside the E2EE thread)
275 if it.Sealed != "" {
276 marker = "πŸ”’"
277 } else if s.Keyring != nil {
278 marker = "!" // a locked vault holding a stale unsealed value
279 }
280 line := fmt.Sprintf("%s %-28s added by %s %s", marker, clip(it.Name, 28), orSomeone(it.ByName), relTime(it.At/1000))
281 if it.UpdatedAt > 0 {
282 line += fmt.Sprintf(" Β· updated by %s %s", orSomeone(it.UpdatedByName), relTime(it.UpdatedAt/1000))
283 }
284 fmt.Println(line)
285 }
286 if s.Keyring != nil {
287 for _, w := range s.Keyring.Wraps {
288 if w.Kind == "member" {
289 fmt.Fprintf(os.Stderr, "Β· %s was given access but hasn't set up a key yet\n", orSomeone(w.Label))
290 }
291 }
292 }
293}
294
295// getOpts carries the `get` flags: outFile (write the value to a mode-600 file
296// instead of stdout β€” the deploy/DR-safe form that keeps secrets out of shell
297// history / a transcript, #997).
298type getOpts struct {
299 outFile string
300}
301
302func vaultGet(host, threadID string, s vault.State, name string, opts getOpts) {
303 it := s.FindItem(name)
304 if it == nil {
305 die("no secret named %q β€” `eleven vault list` shows what's there", name)
306 }
307 value := it.Value
308 if it.Sealed != "" {
309 priv := unlockVaultPriv(host, threadID, s) // Touch ID
310 var env struct {
311 V string `json:"v"`
312 }
313 if err := e2ee.OpenSealed(it.Sealed, priv, &env); err != nil {
314 die("%q won't open with this vault's key β€” it may have been sealed to an older one", name)
315 }
316 value = env.V
317 }
318 if opts.outFile != "" {
319 // Root-only (0600), never stdout β€” safe under a Claude-driven or logged
320 // shell, mirroring `sops -d`'s write-to-file. Overwrite deliberately.
321 if err := writeSecretFile(opts.outFile, value); err != nil {
322 die("%v", err)
323 }
324 fmt.Fprintf(os.Stderr, "Wrote %q to %s (mode 600).\n", name, opts.outFile)
325 return
326 }
327 // The raw value on stdout (everything else rides stderr), newline-terminated
328 // for the shell; $(…) substitution strips it. NEVER run this form in a
329 // Claude-driven / logged shell β€” use -o (#997).
330 if _, err := io.WriteString(os.Stdout, value); err != nil {
331 die("%v", err)
332 }
333 if !strings.HasSuffix(value, "\n") {
334 fmt.Println()
335 }
336}
337
338// writeSecretFile writes a secret to path with mode 0600, forcing the perms even
339// if the file pre-existed with looser ones.
340func writeSecretFile(path, value string) error {
341 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
342 if err != nil {
343 return err
344 }
345 defer f.Close()
346 if err := f.Chmod(0o600); err != nil {
347 return err
348 }
349 _, err = io.WriteString(f, value)
350 return err
351}
352
353func vaultSet(sp *space, threadID string, s vault.State, name, fromFile string) {
354 value := readSecretValue(name, fromFile)
355 if value == "" {
356 die("an empty value would be a confusing secret β€” nothing stored")
357 }
358 event := map[string]any{"aid": newID(), "label": name}
359 if s.Keyring != nil {
360 sealed, err := e2ee.SealForPub(s.Keyring.Pub, map[string]string{"v": value})
361 if err != nil {
362 die("seal: %v", err)
363 }
364 event["sealed"] = sealed
365 } else {
366 event["value"] = value
367 }
368 verb := "Stored"
369 if existing := s.FindItem(name); existing != nil {
370 event["type"], event["id"] = "vault.change", existing.ID
371 verb = "Updated"
372 } else {
373 event["type"], event["id"] = "vault.add", newID()
374 }
375 if err := sp.c.SendEvent(threadID, event); err != nil {
376 die("%v", err)
377 }
378 if s.Keyring != nil {
379 fmt.Fprintf(os.Stderr, "%s %q β€” sealed to the vault key (reading it back needs an unlock).\n", verb, name)
380 } else {
381 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)
382 }
383}
384
385// vaultEnroll sets up THIS machine's key. The normal path: the vault key was
386// sealed to my roster key when the vault was locked (or given to me), so I open
387// that handoff with my session private key β€” no biometric, it's sealed to my
388// identity β€” then re-wrap the vault key under this machine's Touch ID-guarded
389// keychain KEK and enrol it. That drops my `member` handoff (the reducer keys
390// the drop by uid). No approval: being in the thread is the entitlement.
391func vaultEnroll(sp *space, threadID string, s vault.State, label string) {
392 if s.Keyring == nil {
393 die("this vault isn't locked yet β€” lock it in the app first (the first key is a passkey ceremony)")
394 }
395 me, err := sp.c.Me()
396 if err != nil {
397 die("%v", err)
398 }
399 var handoff *vault.Wrap
400 for i := range s.Keyring.Wraps {
401 w := &s.Keyring.Wraps[i]
402 if w.Kind == "keychain" && w.UID == me {
403 die("this machine's identity already has a key here β€” `eleven vault get <name>` reads values")
404 }
405 if w.Kind == "member" && w.UID == me {
406 handoff = w
407 }
408 }
409 if handoff == nil {
410 die("you don't have a handoff for this vault yet β€” ask someone already in to `eleven vault give %q` (or Give access in the app)", orSomeone(sp.sess.Name))
411 }
412 // Open the handoff with my session (roster) private key β†’ the vault key.
413 var env struct {
414 K string `json:"k"`
415 }
416 if err := e2ee.OpenSealed(handoff.Wrapped, sp.sess.UserPriv, &env); err != nil || env.K == "" {
417 die("couldn't open your vault key β€” ask someone in the vault to `give` you access again")
418 }
419 vaultPriv := env.K
420 // Integrity: the key must actually open this vault. If any value is sealed, it
421 // has to decrypt β€” a mismatched/hostile handoff fails HERE, before it's
422 // wrapped under the keychain.
423 for _, it := range s.Items {
424 if it.Sealed != "" {
425 var probe struct {
426 V string `json:"v"`
427 }
428 if err := e2ee.OpenSealed(it.Sealed, vaultPriv, &probe); err != nil {
429 die("that key doesn't match this vault β€” ask for access again")
430 }
431 break
432 }
433 }
434 if label == "" {
435 host, _ := os.Hostname()
436 label = strings.TrimSpace(sp.sess.Name + " β€” CLI @ " + host)
437 }
438 kek, err := vaultKEK(kekAccount(spaceHost(sp.sess), threadID), true)
439 if err != nil {
440 die("%v", err)
441 }
442 wrapped, err := wrapUnderKEK(kek, vaultPriv)
443 if err != nil {
444 die("%v", err)
445 }
446 err = sp.c.SendEvent(threadID, map[string]any{
447 "type": "vault.enroll", "aid": newID(),
448 "wrap": map[string]any{"id": newID(), "kind": "keychain", "uid": me, "label": label, "wrapped": wrapped},
449 })
450 if err != nil {
451 die("%v", err)
452 }
453 fmt.Fprintln(os.Stderr, "You're in β€” this machine now unlocks the vault with Touch ID (`eleven vault get <name>`).")
454}
455
456// vaultGive hands a thread member (who has no key yet) access: unlock the vault
457// key with this machine's keychain (one Touch ID), then seal it to that member's
458// roster key as a `member` handoff β€” which they turn into their own key with
459// `eleven vault enroll` (or Set up your passkey in the app). An insider GIVING,
460// never an outsider asking.
461func vaultGive(sp *space, threadID string, s vault.State, who string) {
462 if s.Keyring == nil {
463 die("this vault isn't locked")
464 }
465 if strings.TrimSpace(who) == "" {
466 die("who? β€” `eleven vault give <name>` names a thread member to give access to")
467 }
468 me, err := sp.c.Me()
469 if err != nil {
470 die("%v", err)
471 }
472 members, err := sp.c.Roster()
473 if err != nil {
474 die("%v", err)
475 }
476 held := map[string]bool{}
477 for _, w := range s.Keyring.Wraps {
478 if w.UID != "" {
479 held[w.UID] = true
480 }
481 }
482 needle := strings.ToLower(strings.TrimSpace(who))
483 var match []client.RosterMember
484 for _, m := range members {
485 if m.UserID == me || m.IsAgent || m.PublicKey == "" || held[m.UserID] {
486 continue // me, a bot, keyless, or already holding a key/handoff
487 }
488 if strings.Contains(strings.ToLower(m.Name), needle) {
489 match = append(match, m)
490 }
491 }
492 switch len(match) {
493 case 0:
494 die("no thread member without a key matches %q β€” `eleven vault list` shows who's in", who)
495 case 1:
496 // fall through
497 default:
498 names := make([]string, len(match))
499 for i, m := range match {
500 names[i] = orSomeone(m.Name)
501 }
502 die("several members match %q: %s β€” be more specific", who, strings.Join(names, ", "))
503 }
504 target := match[0]
505 priv := unlockVaultPriv(spaceHost(sp.sess), threadID, s) // Touch ID β†’ the vault key
506 wrapped, err := e2ee.SealForPub(target.PublicKey, map[string]string{"k": priv})
507 if err != nil {
508 die("%v", err)
509 }
510 err = sp.c.SendEvent(threadID, map[string]any{
511 "type": "vault.give", "aid": newID(),
512 "wrap": map[string]any{"id": newID(), "kind": "member", "uid": target.UserID, "label": target.Name, "wrapped": wrapped},
513 })
514 if err != nil {
515 die("%v", err)
516 }
517 fmt.Fprintf(os.Stderr, "%s can now set up their key (`eleven vault enroll`, or Set up your passkey in the app).\n", orSomeone(target.Name))
518}
519
520// unlockVaultPriv opens the vault private key with this machine's keychain KEK
521// (the Touch ID prompt) by trying it against every keychain wrap β€” which wrap
522// is ours needs no bookkeeping, a wrong KEK simply fails AES-GCM auth.
523func unlockVaultPriv(host, threadID string, s vault.State) string {
524 if s.Keyring == nil {
525 die("this vault isn't locked β€” nothing to unlock")
526 }
527 var keychainWraps []vault.Wrap
528 for _, w := range s.Keyring.Wraps {
529 if w.Kind == "keychain" {
530 keychainWraps = append(keychainWraps, w)
531 }
532 }
533 if len(keychainWraps) == 0 {
534 die("no CLI keys are enrolled in this vault yet β€” `eleven vault enroll` to request one")
535 }
536 kek, err := vaultKEK(kekAccount(host, threadID), false)
537 if err != nil {
538 die("%v", err)
539 }
540 for _, w := range keychainWraps {
541 if priv, err := openUnderKEK(kek, w.Wrapped); err == nil {
542 return priv
543 }
544 }
545 die("this machine's keychain key doesn't open any of the vault's CLI wraps β€” `eleven vault enroll` to request access from here")
546 panic("unreachable")
547}
548
549// kekAccount names this vault's KEK in the keychain β€” per space host AND per
550// thread, so two vaults never share a key, and an OFFLINE read of the same
551// vault (same host + thread) finds the same key.
552func kekAccount(host, threadID string) string {
553 return host + "/" + threadID
554}
555
556// wrapUnderKEK/openUnderKEK carry a private key (pkcs8, base64url) under the
557// keychain KEK as b64url(iv||ct) β€” the same wrap format the web's
558// wrapVaultPriv/unwrapVaultPriv produce, so wraps are portable across ends.
559func wrapUnderKEK(kek []byte, privB64 string) (string, error) {
560 raw, err := e2ee.B64uDecode(privB64)
561 if err != nil {
562 return "", err
563 }
564 out, err := e2ee.EncryptBytes(kek, raw)
565 if err != nil {
566 return "", err
567 }
568 return e2ee.B64uEncode(out), nil
569}
570
571func openUnderKEK(kek []byte, wrappedB64 string) (string, error) {
572 raw, err := e2ee.B64uDecode(wrappedB64)
573 if err != nil {
574 return "", err
575 }
576 pt, err := e2ee.DecryptBytes(kek, raw)
577 if err != nil {
578 return "", err
579 }
580 return e2ee.B64uEncode(pt), nil
581}
582
583// readSecretValue sources a `set` value without ever touching argv: a file,
584// piped stdin, or a hidden double-prompt on a TTY.
585func readSecretValue(name, fromFile string) string {
586 if fromFile != "" {
587 raw, err := os.ReadFile(fromFile)
588 if err != nil {
589 die("%v", err)
590 }
591 if !utf8.Valid(raw) {
592 die("%s isn't UTF-8 text β€” binary values aren't supported yet (base64 it for now)", fromFile)
593 }
594 return string(raw)
595 }
596 if !term.IsTerminal(int(os.Stdin.Fd())) {
597 raw, err := io.ReadAll(os.Stdin)
598 if err != nil {
599 die("%v", err)
600 }
601 // A pipe usually ends with the shell's newline; one is stripped so
602 // `echo secret | eleven vault set x` stores exactly "secret".
603 return strings.TrimSuffix(string(raw), "\n")
604 }
605 fmt.Fprintf(os.Stderr, "Value for %q (hidden): ", name)
606 first, err := term.ReadPassword(int(os.Stdin.Fd()))
607 fmt.Fprintln(os.Stderr)
608 if err != nil {
609 die("%v", err)
610 }
611 fmt.Fprint(os.Stderr, "Once more to confirm: ")
612 second, err := term.ReadPassword(int(os.Stdin.Fd()))
613 fmt.Fprintln(os.Stderr)
614 if err != nil {
615 die("%v", err)
616 }
617 if string(first) != string(second) {
618 die("the two entries differ β€” nothing stored")
619 }
620 return string(first)
621}
622
623func orSomeone(name string) string {
624 if name == "" {
625 return "someone"
626 }
627 return name
628}
629
630// newID mints an entity/audit id β€” 128 random bits, like the web's
631// crypto.randomUUID() (folds only ever compare ids, never parse them).
632func newID() string {
633 b := make([]byte, 16)
634 if _, err := rand.Read(b); err != nil {
635 die("%v", err)
636 }
637 return hex.EncodeToString(b)
638}
639