47dd0bd244
internal/escrow: zero-knowledge escrow creation. R = 10 EFF-wordlist words (crypto/rand, ~129 bits); wrap K under R via PBS-native key change-passphrase driven over a stdlib pty (x/sys/unix; output discarded so R can't leak, F-A2); self-verify the blob recovers K (fingerprint match) before shipping. Opt-in (b) R-wrapped offline copy + (a) raw paperkey. Live K is byte-unchanged (operates on a copy). --selftest=escrow-create (-storage/-paperkey/-offline/-upload). Posture config field (zero_knowledge default). PBSEncKeyPath helper. Grounded by the escrow spike findings. Tests: R entropy>=128/format/uniqueness; integration round-trip (wrap->unwrap fingerprint match, wrong-R fails, K byte-unchanged) guarded to linux+pbc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
82 lines
2.9 KiB
Go
82 lines
2.9 KiB
Go
// 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
|
|
|
|
// wordlist is the EFF large wordlist (7776 words, 12.92 bits/word) — the diceware standard for
|
|
// human-transcribed passphrases. Parsed once at init.
|
|
var wordlist = 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
|
|
}
|
|
|
|
// RecoveryCodeWords is the number of words in a recovery code R. 10 words from the 7776-word EFF
|
|
// list ≈ 129.2 bits (≥128) — the chosen entropy floor (slice7-escrow-spike-findings.md §4).
|
|
const RecoveryCodeWords = 10
|
|
|
|
// GenerateRecoveryCode returns a fresh recovery code R: RecoveryCodeWords words chosen uniformly
|
|
// (crypto/rand via big.Int — no modulo bias) from the EFF large wordlist, hyphen-joined.
|
|
//
|
|
// SECRET: the returned string is R. Surface it to the customer exactly once; never log or persist it.
|
|
func GenerateRecoveryCode() (string, error) {
|
|
if len(wordlist) < 2 {
|
|
return "", fmt.Errorf("escrow: wordlist not loaded (%d words)", len(wordlist))
|
|
}
|
|
n := big.NewInt(int64(len(wordlist)))
|
|
words := make([]string, RecoveryCodeWords)
|
|
for i := range words {
|
|
idx, err := rand.Int(rand.Reader, n)
|
|
if err != nil {
|
|
return "", fmt.Errorf("escrow: recovery-code rng: %w", err)
|
|
}
|
|
words[i] = wordlist[idx.Int64()]
|
|
}
|
|
return strings.Join(words, "-"), 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 loaded wordlist length (for audit/tests).
|
|
func WordlistSize() int { return len(wordlist) }
|