// Package escrow creates the PBS recovery-code escrow (doc 03 §8a, slice 7): an R-wrapped, // zero-knowledge copy of the live PBS client encryption key K. It is the FIRST code that touches // K and introduces the customer recovery code R. // // Secret discipline (overriding): // - R (recovery code) is generated with crypto/rand, ≥128 bits, word-list form; surfaced to the // customer EXACTLY ONCE (by the caller); NEVER logged, persisted, or returned in a struct that // gets logged. GenerateRecoveryCode returns it as a bare string the caller must handle with care. // - K (PBS key) is read by location; the live unencrypted K is NEVER modified — Wrap operates on // a COPY. K is never logged/printed. // - The wrapped blob is opaque ciphertext (a PBS scrypt key file); the hub stores it and never // decrypts it (it has no R). // // It shells out to `proxmox-backup-client key` (the PBS-native passphrase KDF — no bespoke crypto; // see documentation/tests/slice7-escrow-spike-findings.md). That binary is a runtime dependency. package escrow import ( "bufio" "bytes" "crypto/rand" _ "embed" "fmt" "math" "math/big" "strings" ) //go:embed eff_large_wordlist.txt var wordlistRaw []byte // RecoveryCodeSep joins the words of a recovery code R. It is ALSO the reason for the // joinSafe filter below: a word that itself contains the separator makes the joined code // ambiguous to segment by eye, which is unaffordable in the one situation R exists for — a // customer transcribing it during a disaster. Do not change it: R is consumed as a whole // passphrase (see Wrap/Unwrap), so the separator is a transcription aid, not a parsed delimiter. const RecoveryCodeSep = "-" // wordlist is the EFF large wordlist (the diceware standard for human-transcribed passphrases), // minus the handful of entries that contain RecoveryCodeSep. Parsed and filtered once at init. // Sizes are asserted in wordlist_test.go so a wordlist swap cannot silently move the entropy floor. var wordlist = joinSafe(parseWordlist(wordlistRaw)) // wordlistRawSize is the unfiltered parse length, kept for audit (see WordlistFilteredOut). var wordlistRawSize = len(parseWordlist(wordlistRaw)) func parseWordlist(raw []byte) []string { var w []string sc := bufio.NewScanner(bytes.NewReader(raw)) for sc.Scan() { if t := strings.TrimSpace(sc.Text()); t != "" { w = append(w, t) } } return w } // joinSafe drops every word containing RecoveryCodeSep, so that a generated code always segments // back into exactly RecoveryCodeWords words. In the EFF large list this removes exactly 4 entries // (drop-down, felt-tip, t-shirt, yo-yo) of 7776, costing ~0.0007 bits/word — the floor still holds // (asserted in the tests). Generation-time only: codes already issued remain valid, because R is // verified as a whole passphrase and is never re-split. func joinSafe(words []string) []string { out := make([]string, 0, len(words)) for _, w := range words { if strings.Contains(w, RecoveryCodeSep) { continue } out = append(out, w) } return out } // RecoveryCodeWords is the number of words in a recovery code R. 10 words from the filtered EFF // list (7772 words) ≈ 129.2 bits (≥128) — the chosen entropy floor (slice7-escrow-spike-findings.md §4). const RecoveryCodeWords = 10 // generateWords draws RecoveryCodeWords words uniformly (crypto/rand via big.Int — no modulo bias) // from list. Split out from GenerateRecoveryCode so tests can drive an unfiltered list and prove // the filter is what keeps a code segmentable. func generateWords(list []string) ([]string, error) { if len(list) < 2 { return nil, fmt.Errorf("escrow: wordlist not loaded (%d words)", len(list)) } n := big.NewInt(int64(len(list))) words := make([]string, RecoveryCodeWords) for i := range words { idx, err := rand.Int(rand.Reader, n) if err != nil { return nil, fmt.Errorf("escrow: recovery-code rng: %w", err) } words[i] = list[idx.Int64()] } return words, nil } // GenerateRecoveryCode returns a fresh recovery code R: RecoveryCodeWords words chosen uniformly // from the filtered EFF large wordlist, joined with RecoveryCodeSep. // // SECRET: the returned string is R. Surface it to the customer exactly once; never log or persist it. func GenerateRecoveryCode() (string, error) { words, err := generateWords(wordlist) if err != nil { return "", err } return strings.Join(words, RecoveryCodeSep), nil } // RecoveryCodeEntropyBits is the approximate entropy of a generated code, for display/audit only // (never the code itself). func RecoveryCodeEntropyBits() float64 { if len(wordlist) < 2 { return 0 } return float64(RecoveryCodeWords) * math.Log2(float64(len(wordlist))) } // WordlistSize is the effective (filtered) wordlist length — the draw space. For audit/tests. func WordlistSize() int { return len(wordlist) } // WordlistFilteredOut is how many parsed entries joinSafe removed. For audit/tests. func WordlistFilteredOut() int { return wordlistRawSize - len(wordlist) }