301c84d9b5
HashResticPassword = sha256 hex over the trimmed password (pinned cross-repo vector). escrowUploadRequest gains restic_pw_sha256,omitempty — set only when a staged password was sealed into the blob. Contract test updated; hub mirrors next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
156 lines
5.5 KiB
Go
156 lines
5.5 KiB
Go
package escrow
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
)
|
|
|
|
func ageAvailable() bool {
|
|
if runtime.GOOS != "linux" {
|
|
return false
|
|
}
|
|
if _, err := exec.LookPath("age"); err == nil {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ensureAge(t *testing.T) {
|
|
t.Helper()
|
|
if !ageAvailable() {
|
|
t.Skip("skipping: the `age` CLI + linux required (runs on the demo/build host)")
|
|
}
|
|
if p, err := exec.LookPath("age"); err == nil {
|
|
ageBinary = p
|
|
}
|
|
}
|
|
|
|
func TestIdentity_InputValidation(t *testing.T) {
|
|
ctx := context.Background()
|
|
if _, err := WrapIdentity(ctx, nil, "R"); err == nil {
|
|
t.Error("empty bundle must error")
|
|
}
|
|
if _, err := WrapIdentity(ctx, []byte("x"), ""); err == nil {
|
|
t.Error("empty R must error")
|
|
}
|
|
if _, err := UnwrapIdentity(ctx, nil, "R"); err == nil {
|
|
t.Error("empty blob must error")
|
|
}
|
|
}
|
|
|
|
// Round-trip: a bundle wraps under R and recovers byte-identical (the identity analog of K-escrow).
|
|
func TestIdentity_RoundTrip(t *testing.T) {
|
|
ensureAge(t)
|
|
ctx := context.Background()
|
|
const R = "throwaway-correct-horse-battery-staple-words"
|
|
bundle := IdentityBundle{TunnelToken: "eyJhIjoidGVzdCIsInQiOiJ4In0", PBSToken: "felhom@pbs!n100:deadbeefcafe"}
|
|
|
|
blob, err := WrapIdentityBundle(ctx, bundle, R)
|
|
if err != nil {
|
|
t.Fatalf("WrapIdentityBundle: %v", err)
|
|
}
|
|
// the blob is opaque ciphertext, not the bundle.
|
|
if bytes.Contains(blob, []byte(bundle.TunnelToken)) || bytes.Contains(blob, []byte(bundle.PBSToken)) {
|
|
t.Fatal("the blob leaks plaintext token bytes — not encrypted")
|
|
}
|
|
got, err := UnwrapIdentityBundle(ctx, blob, R)
|
|
if err != nil {
|
|
t.Fatalf("UnwrapIdentityBundle: %v", err)
|
|
}
|
|
if got != bundle {
|
|
t.Errorf("recovered bundle = %+v, want %+v", got, bundle)
|
|
}
|
|
}
|
|
|
|
// 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)")
|
|
}
|
|
}
|
|
|
|
// PINNED CROSS-REPO TEST VECTOR (SLICE 3): the same vector is asserted in felhom-controller — if either
|
|
// side drifts (trailing newline, encoding, trim behavior), its half of this test fails and auto-confirm
|
|
// can never silently mismatch. Convention: sha256 hex over the TRIMMED password string.
|
|
func TestHashResticPassword_PinnedVector(t *testing.T) {
|
|
const vector = "cafef00ddeadbeef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
const want = "dbfc02f987e1ac0c91911d5761267089b1144628745a3343e4d96194e43c08e4"
|
|
if got := HashResticPassword(vector); got != want {
|
|
t.Fatalf("pinned vector drift: got %s want %s", got, want)
|
|
}
|
|
// trim convention: surrounding whitespace/newlines do not change the hash (both sides trim)
|
|
if got := HashResticPassword(" " + vector + "\n"); got != want {
|
|
t.Fatalf("whitespace must not change the hash (trim convention), got %s", got)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
ctx := context.Background()
|
|
blob, err := WrapIdentity(ctx, []byte(`{"tunnel_token":"a","pbs_token":"b"}`), "the-correct-code")
|
|
if err != nil {
|
|
t.Fatalf("WrapIdentity: %v", err)
|
|
}
|
|
if _, err := UnwrapIdentity(ctx, blob, "DEFINITELY-the-wrong-code"); err == nil {
|
|
t.Fatal("a wrong recovery code must fail closed (no bundle)")
|
|
}
|
|
// the blob is unchanged / retryable: the RIGHT code still works after a wrong attempt.
|
|
if _, err := UnwrapIdentity(ctx, blob, "the-correct-code"); err != nil {
|
|
t.Errorf("the blob was not retryable after a wrong-R attempt: %v", err)
|
|
}
|
|
}
|