v0.77.0: fork-4 — escrow the offsite restic repo password under R

IdentityBundle gains ResticRepoPassword (rides existing age-under-R
WrapIdentityBundle; custody spike febdc56 proved a recovered value opens the
real repo). POST /escrow/stage-secret (withGuest, scopedFromBody) transiently
stages the controller-pushed password (0600, atomic, NEVER logged), which the
escrow-create ceremony auto-injects then wipes. Adds AttachResticPassword +
StagedResticPasswordPath + WipeStagedResticPassword; EscrowStagePath injectable
for tests. Tests: bundle carries pw byte-exact + not-in-blob + wrong-R fails
closed; stage 0600 + non-secret ack + cross-guest 403 + value-not-in-log.
Additive; PBS-K escrow untouched. NOT yet live-validated (supervised ceremony).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-09 14:56:09 +02:00
parent 7f07393623
commit 0c22b9bbf3
8 changed files with 320 additions and 1 deletions
+57
View File
@@ -3,7 +3,9 @@ package escrow
import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
@@ -65,6 +67,61 @@ func TestIdentity_RoundTrip(t *testing.T) {
}
}
// fork-4: the IdentityBundle carries the offsite restic repo password under R, byte-exact and encrypted;
// a wrong R fails closed. (The spike proved a recovered value opens the real repo; this guards the field.)
func TestIdentity_RoundTrip_CarriesResticPassword(t *testing.T) {
ensureAge(t)
ctx := context.Background()
const R = "throwaway-correct-horse-battery-staple-fork4"
const pw = "deadbeefcafef00d0123456789abcdef0123456789abcdef0123456789abcdef" // 64 hex, synthetic
bundle := IdentityBundle{TunnelToken: "tt", PBSToken: "pt", ResticRepoPassword: pw}
blob, err := WrapIdentityBundle(ctx, bundle, R)
if err != nil {
t.Fatalf("WrapIdentityBundle: %v", err)
}
if bytes.Contains(blob, []byte(pw)) {
t.Fatal("the blob leaks the restic password plaintext — not encrypted")
}
got, err := UnwrapIdentityBundle(ctx, blob, R)
if err != nil {
t.Fatalf("UnwrapIdentityBundle: %v", err)
}
if got.ResticRepoPassword != pw {
t.Fatalf("recovered restic password not byte-exact: got %q", got.ResticRepoPassword)
}
if got != bundle {
t.Fatalf("recovered bundle = %+v, want %+v", got, bundle)
}
if _, err := UnwrapIdentityBundle(ctx, blob, R+"-WRONG"); err == nil {
t.Fatal("a wrong recovery code must fail closed (no bundle, no restic password)")
}
}
// AttachResticPassword: missing file → clean no-attach; staged file → trimmed value attached; empty → error.
func TestAttachResticPassword(t *testing.T) {
b := &IdentityBundle{}
if ok, err := AttachResticPassword(b, filepath.Join(t.TempDir(), "absent")); ok || err != nil {
t.Fatalf("missing staged file must be a clean no-attach, got ok=%v err=%v", ok, err)
}
f := filepath.Join(t.TempDir(), "pw")
if err := os.WriteFile(f, []byte(" abc123def \n"), 0o600); err != nil {
t.Fatal(err)
}
ok, err := AttachResticPassword(b, f)
if err != nil || !ok {
t.Fatalf("attach from staged file: ok=%v err=%v", ok, err)
}
if b.ResticRepoPassword != "abc123def" {
t.Fatalf("want trimmed value, got %q", b.ResticRepoPassword)
}
if err := os.WriteFile(f, []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := AttachResticPassword(&IdentityBundle{}, f); err == nil {
t.Fatal("an empty staged file must error (an operator would want to know)")
}
}
// Wrong R fails CLOSED — no bundle emitted.
func TestIdentity_WrongRFailsClosed(t *testing.T) {
ensureAge(t)