aeca52ea54
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
169 lines
7.1 KiB
Go
169 lines
7.1 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"testing"
|
|
)
|
|
|
|
// R-198 (v0.93.0) — the retention must keep the OFF-SITE data key, not only the PBS key.
|
|
//
|
|
// WHAT WAS BROKEN, and why these tests are the ones that would have caught it: host_escrow_superseded
|
|
// shipped with `blob` (the K-escrow / PBS datastore key) and `identity_blob` was added to host_escrow
|
|
// by a later ALTER and never to the retained table. The restic REPOSITORY password lives inside
|
|
// identity_blob. So every supersession retained the whole-guest key and destroyed the off-site data
|
|
// key — the exact secret the retention exists to preserve — and the destroying act is the escrow
|
|
// ceremony a rebuilt box asks its customer to run.
|
|
//
|
|
// The pre-existing TestSaveHostEscrow_RetainsSuperseded was GREEN throughout, because it asserts the
|
|
// MECHANISM (a retained row exists, with the old K-blob) and not the CONSEQUENCE (the retained row can
|
|
// still yield a repository password). These assert the consequence.
|
|
|
|
// Scenario A — a re-escrow retains BOTH sealed keys.
|
|
// RED-PROOF: drop `identity_blob` from demoteCurrentEscrowTx's INSERT/SELECT (production behaviour up
|
|
// to v0.92.0) → the retained row's identity blob is nil → this FAILS.
|
|
func TestSaveHostEscrow_RetainsIdentityBlob(t *testing.T) {
|
|
st := newTestStore(t)
|
|
const h = "hid-1"
|
|
oldIdentity := []byte("age-blob-sealing-REPO-PASSWORD-OLD")
|
|
newIdentity := []byte("age-blob-sealing-REPO-PASSWORD-NEW")
|
|
|
|
// Generation 1: the K-escrow, then the identity blob — the real order the escrow PUT uses
|
|
// (SaveHostEscrow, then SaveHostDRBundle).
|
|
if _, _, err := st.SaveHostEscrow(h, []byte("k-blob-old"), "fp-old", "zk", "2026-07-09T00:00:00Z", "SHA_OLD"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SaveHostDRBundle(h, oldIdentity); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Generation 2 with a DIFFERENT sealed repo password → supersede.
|
|
sup, prev, err := st.SaveHostEscrow(h, []byte("k-blob-new"), "fp-new", "zk", "2026-07-16T00:00:00Z", "SHA_NEW")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !sup {
|
|
t.Fatal("a different-passphrase upload must supersede")
|
|
}
|
|
if prev != "SHA_OLD" {
|
|
t.Fatalf("prevResticPwSHA256 = %q, want SHA_OLD (R-197 needs the replaced hash)", prev)
|
|
}
|
|
if err := st.SaveHostDRBundle(h, newIdentity); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
retained, err := st.ListSupersededEscrow(h)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(retained) != 1 {
|
|
t.Fatalf("retained rows = %d, want 1", len(retained))
|
|
}
|
|
// THE ASSERTION THIS WHOLE ITEM IS ABOUT: the off-site data key survived the supersession.
|
|
if retained[0].IdentityBlob == nil {
|
|
t.Fatal("R-198: the retained row carries NO identity blob — the off-site repository password " +
|
|
"was destroyed by the ceremony that was supposed to preserve it")
|
|
}
|
|
// And it is the PREVIOUS generation's blob, not the one that replaced it. This pins the ordering
|
|
// dependency named on demoteCurrentEscrowTx: the identity blob is written AFTER SaveHostEscrow, so
|
|
// the demote sees the old one. If that order ever inverts, the retained bytes would silently be
|
|
// the new blob filed under the old hash — recoverable-looking and wrong.
|
|
if !bytes.Equal(retained[0].IdentityBlob, oldIdentity) {
|
|
t.Fatalf("retained identity blob is not the PREVIOUS generation (got %q) — the demote ran after the overwrite",
|
|
retained[0].IdentityBlob)
|
|
}
|
|
if retained[0].ResticPwSHA256 != "SHA_OLD" || string(retained[0].Blob) != "k-blob-old" {
|
|
t.Fatalf("retained row is not the old generation: %+v", retained[0])
|
|
}
|
|
// Current row unchanged in behaviour: the NEW generation, both blobs.
|
|
if bundle, berr := st.GetHostDRBundle(h); berr != nil || bundle == nil || !bytes.Equal(bundle.IdentityBlob, newIdentity) {
|
|
t.Fatalf("current identity blob is not the new one: %+v (%v)", bundle, berr)
|
|
}
|
|
}
|
|
|
|
// Scenario B — deleting a host demotes BOTH blobs too. demoteCurrentEscrowTx is shared by the
|
|
// re-escrow path and the host-delete path; a shared routine proven through one caller is how a fix
|
|
// gets believed on a path nobody exercised.
|
|
// RED-PROOF: fix only the re-escrow caller (e.g. carry the column in SaveHostEscrow's own SQL instead
|
|
// of in the shared routine) → this FAILS while Scenario A passes.
|
|
func TestDeleteHost_DemotesIdentityBlob(t *testing.T) {
|
|
s := newTestStore(t)
|
|
const hostID, cust = "hid-del", "cust-del"
|
|
identity := []byte("age-blob-sealing-REPO-PASSWORD")
|
|
if err := s.UpsertHost(&Host{HostID: hostID, CustomerID: cust, APIKey: "k"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := s.SaveHostEscrow(hostID, []byte("k-blob"), "fp", "zk", "2026-07-16T00:00:00Z", "SHA_A"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.SaveHostDRBundle(hostID, identity); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := s.DeleteHost(hostID, true); err != nil {
|
|
t.Fatalf("DeleteHost: %v", err)
|
|
}
|
|
retained, err := s.ListSupersededEscrow(hostID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(retained) != 1 {
|
|
t.Fatalf("demoted rows = %d, want 1", len(retained))
|
|
}
|
|
if !bytes.Equal(retained[0].IdentityBlob, identity) {
|
|
t.Fatalf("R-198: host delete demoted custody WITHOUT the identity blob (got %q) — the off-site "+
|
|
"repository password was destroyed by a host delete", retained[0].IdentityBlob)
|
|
}
|
|
}
|
|
|
|
// Scenario C — a legacy host whose current escrow has NO identity blob supersedes cleanly. The column
|
|
// is nullable on purpose: a NOT NULL constraint here would make the fix block a ceremony, which is a
|
|
// worse failure than the one it repairs.
|
|
func TestSaveHostEscrow_SupersedesWithoutIdentityBlob(t *testing.T) {
|
|
st := newTestStore(t)
|
|
const h = "hid-legacy"
|
|
if _, _, err := st.SaveHostEscrow(h, []byte("k-old"), "fp", "zk", "2026-07-09T00:00:00Z", "SHA_OLD"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// No SaveHostDRBundle — a slice-7-era upload.
|
|
sup, prev, err := st.SaveHostEscrow(h, []byte("k-new"), "fp", "zk", "2026-07-16T00:00:00Z", "SHA_NEW")
|
|
if err != nil {
|
|
t.Fatalf("a supersession of an identity-less escrow must not fail: %v", err)
|
|
}
|
|
if !sup || prev != "SHA_OLD" {
|
|
t.Fatalf("superseded=%v prev=%q, want true/SHA_OLD", sup, prev)
|
|
}
|
|
retained, err := st.ListSupersededEscrow(h)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(retained) != 1 {
|
|
t.Fatalf("retained rows = %d, want 1", len(retained))
|
|
}
|
|
if retained[0].IdentityBlob != nil {
|
|
t.Fatalf("a legacy row must retain a NULL identity blob, got %q", retained[0].IdentityBlob)
|
|
}
|
|
}
|
|
|
|
// CountCurrentEscrowWithIdentity is the census §8.1 asks for: which hosts hold an identity blob today
|
|
// and are therefore protected from the next ceremony by this fix. Asserted rather than eyeballed,
|
|
// because the report quotes its numbers.
|
|
func TestCountCurrentEscrowWithIdentity(t *testing.T) {
|
|
st := newTestStore(t)
|
|
if _, _, err := st.SaveHostEscrow("with-id", []byte("k"), "fp", "zk", "2026-07-16T00:00:00Z", "SHA1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SaveHostDRBundle("with-id", []byte("age-blob")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := st.SaveHostEscrow("without-id", []byte("k"), "fp", "zk", "2026-07-16T00:00:00Z", "SHA2"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
total, withIdentity, err := st.CountCurrentEscrowWithIdentity()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if total != 2 || withIdentity != 1 {
|
|
t.Fatalf("census = %d/%d, want 2 total / 1 with identity", withIdentity, total)
|
|
}
|
|
}
|