Files
felhom.eu/hub/internal/store/escrow_identity_retention_test.go
T

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)
}
}