hub: R-39 core — stamp a secret GENERATION into the pbs_dr descriptor

The fleet half of R-39. An ep0 credential re-issue re-keys the SECRET of an existing
token, so token_id, fingerprint, datastore and namespace all come back byte-identical.
The agent re-applies on the descriptor's CONTENT HASH, so a re-issue was invisible to a
converged box: it short-circuited, never consumed the fresh secret, and served a revoked
credential while reporting `applied` — the N100 failure of 2026-07-18.

host_pbs_secrets gains a monotonic per-host `generation`, advanced by every fresh MINT and
by nothing else, stamped into the descriptor as `secret_generation`. That is now the only
field a re-key moves, and it is what re-arms the agent.

DEVIATION FROM SPEC, deliberate: the brief said to return "the new row's id (int64) …
no schema change". There is no row id — host_pbs_secrets is keyed by host_id and UPSERTed
last-write-wins, so a new row never exists, and created_at collides for two mints in the
same second. An additive counter column is the only monotonic source; it uses the repo's
existing idempotent ALTER-TABLE idiom.

RestageHostPBSSecret deliberately does NOT advance it: a re-stage re-arms the SAME secret,
the descriptor content genuinely has not changed, and a bump would cause a pointless agent
refetch loop (that method's own contract says so).

Also corrects a comment that asserted the re-issue refreshes the descriptor "with the NEW
token_id/fingerprint". That is false for a re-key, and believing it is why the descriptor
was never expected to be identical in the first place.

omitempty is load-bearing: a zero generation must not start emitting a new key into every
pre-existing descriptor, which would itself be a fleet-wide spurious re-apply.

Compatibility: agents below 0.91.0 drop the unknown JSON key and behave exactly as today —
inert, not breaking (Scenario C).

Tests: store-level monotonicity + per-host isolation + restage-leaves-it-alone; descriptor
byte-change, omitempty, and sibling-key round-trip; and a FLOW-level test driving
ReissuePBSDR against a fake that models a real re-key. Red-proof run at the assertion
level (not the compiler): commenting out the stamp makes the flow test fail with both
byte-identical blocks printed.
This commit is contained in:
2026-07-21 09:52:04 +02:00
parent 11ead4be0e
commit c484aa204e
7 changed files with 371 additions and 32 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ func seedHostWithArtifacts(t *testing.T, s *Store, hostID, customerID string) {
if err := s.SaveHostRecoveryCredential(hostID, "root@pam", "recovery-secret"); err != nil {
t.Fatal(err)
}
if err := s.SaveHostPBSSecret(hostID, "pbs-secret"); err != nil {
if _, err := s.SaveHostPBSSecret(hostID, "pbs-secret"); err != nil {
t.Fatal(err)
}
// Agent-scoped log bundle (scope_id = host_id) + a pending request. Order matters:
+33 -7
View File
@@ -12,13 +12,39 @@ import (
// SaveHostPBSSecret stores (last-write-wins) the one-time PBS token secret for a host, resetting
// the consumed flag (a re-issue supersedes any prior unconsumed value). Never logged.
func (s *Store) SaveHostPBSSecret(hostID, value string) error {
_, err := s.db.Exec(`
INSERT INTO host_pbs_secrets (host_id, value, created_at, consumed_at)
VALUES (?, ?, datetime('now'), NULL)
ON CONFLICT(host_id) DO UPDATE SET value = excluded.value, created_at = datetime('now'), consumed_at = NULL`,
hostID, value)
return err
//
// It returns the host's new secret GENERATION — a monotonic counter advanced by exactly this
// mint. The caller stamps it into the pbs_dr descriptor, which is what makes a re-key visible to
// the agent: without it the descriptor is byte-identical across a re-issue (only the side-table
// secret rotates), the converged agent short-circuits on its content hash, and the fresh secret is
// never consumed — the R-39 failure. See the column's note in store.go.
//
// The UPSERT and the read are one statement (RETURNING), so two concurrent mints cannot both
// report the same generation.
func (s *Store) SaveHostPBSSecret(hostID, value string) (int64, error) {
var gen int64
err := s.db.QueryRow(`
INSERT INTO host_pbs_secrets (host_id, value, created_at, consumed_at, generation)
VALUES (?, ?, datetime('now'), NULL, 1)
ON CONFLICT(host_id) DO UPDATE SET
value = excluded.value,
created_at = datetime('now'),
consumed_at = NULL,
generation = host_pbs_secrets.generation + 1
RETURNING generation`,
hostID, value).Scan(&gen)
return gen, err
}
// HostPBSSecretGeneration returns the host's current secret generation (0 = no secret ever stored).
// Read-only; used when refreshing a descriptor without minting.
func (s *Store) HostPBSSecretGeneration(hostID string) (int64, error) {
var gen int64
err := s.db.QueryRow(`SELECT generation FROM host_pbs_secrets WHERE host_id = ?`, hostID).Scan(&gen)
if err == sql.ErrNoRows {
return 0, nil
}
return gen, err
}
// ConsumeHostPBSSecret returns the host's one-time PBS token secret and marks it consumed in the
+71 -4
View File
@@ -16,7 +16,7 @@ func TestRestageHostPBSSecret(t *testing.T) {
}
// Store + consume, then re-stage: the SAME value is served once more.
if err := s.SaveHostPBSSecret("h1", "the-secret"); err != nil {
if _, err := s.SaveHostPBSSecret("h1", "the-secret"); err != nil {
t.Fatalf("save: %v", err)
}
if _, err := s.ConsumeHostPBSSecret("h1"); err != nil {
@@ -48,7 +48,7 @@ func TestRestageHostPBSSecret_NoGenerationBump(t *testing.T) {
if err != nil {
t.Fatalf("set desired: %v", err)
}
if err := s.SaveHostPBSSecret("h1", "s"); err != nil {
if _, err := s.SaveHostPBSSecret("h1", "s"); err != nil {
t.Fatalf("save secret: %v", err)
}
if _, err := s.RestageHostPBSSecret("h1"); err != nil {
@@ -125,7 +125,7 @@ func TestHostPBSSecret_ConsumeOnce(t *testing.T) {
t.Fatalf("consume with nothing stored = %v, want sql.ErrNoRows", err)
}
if err := s.SaveHostPBSSecret("h1", "secret-1"); err != nil {
if _, err := s.SaveHostPBSSecret("h1", "secret-1"); err != nil {
t.Fatalf("save: %v", err)
}
got, err := s.ConsumeHostPBSSecret("h1")
@@ -139,7 +139,7 @@ func TestHostPBSSecret_ConsumeOnce(t *testing.T) {
}
// Re-issue path: a fresh save resets consumption and serves the NEW value once.
if err := s.SaveHostPBSSecret("h1", "secret-2"); err != nil {
if _, err := s.SaveHostPBSSecret("h1", "secret-2"); err != nil {
t.Fatalf("re-save: %v", err)
}
got, err = s.ConsumeHostPBSSecret("h1")
@@ -152,3 +152,70 @@ func TestHostPBSSecret_ConsumeOnce(t *testing.T) {
t.Fatalf("foreign host consume = %v, want sql.ErrNoRows", err)
}
}
// R-39 — the secret GENERATION is what makes a re-key visible to the agent.
//
// A re-issue rotates only the side-table secret: token_id, fingerprint, datastore and namespace all
// come back byte-identical, so without this counter the descriptor never moves, the converged agent
// short-circuits on its content hash, and the fresh secret is never consumed. That is the 2026-07-18
// N100 failure. These assertions are the store half of the guarantee.
func TestSaveHostPBSSecret_GenerationIsMonotonicPerMint(t *testing.T) {
s := newTestStore(t)
// No secret ever stored → generation 0 (not an error).
if g, err := s.HostPBSSecretGeneration("h1"); err != nil || g != 0 {
t.Fatalf("generation with nothing stored = (%d, %v), want (0, nil)", g, err)
}
g1, err := s.SaveHostPBSSecret("h1", "secret-1")
if err != nil {
t.Fatalf("first mint: %v", err)
}
if g1 != 1 {
t.Fatalf("first mint generation = %d, want 1", g1)
}
// THE FIX: a re-key with an identical descriptor still advances the generation.
g2, err := s.SaveHostPBSSecret("h1", "secret-2")
if err != nil {
t.Fatalf("re-key mint: %v", err)
}
if g2 != 2 {
t.Fatalf("re-key generation = %d, want 2 — a re-issue MUST advance it or the agent never re-applies", g2)
}
if g2 <= g1 {
t.Fatalf("generation went backwards or stalled: %d -> %d", g1, g2)
}
if got, err := s.HostPBSSecretGeneration("h1"); err != nil || got != g2 {
t.Fatalf("read-back generation = (%d, %v), want (%d, nil)", got, err, g2)
}
// Per-host, not global: another host starts at 1.
if g, err := s.SaveHostPBSSecret("h2", "other"); err != nil || g != 1 {
t.Fatalf("second host first mint = (%d, %v), want (1, nil) — the counter is per-host", g, err)
}
}
// A RE-STAGE must NOT advance the generation: it re-arms the SAME secret, so the descriptor content
// genuinely has not changed and a bump would cause a pointless agent refetch loop. This is the
// counterpart to TestRestageHostPBSSecret_NoGenerationBump, one level down.
func TestRestageHostPBSSecret_LeavesSecretGenerationAlone(t *testing.T) {
s := newTestStore(t)
g1, err := s.SaveHostPBSSecret("h1", "secret-1")
if err != nil {
t.Fatalf("mint: %v", err)
}
if _, err := s.ConsumeHostPBSSecret("h1"); err != nil {
t.Fatalf("consume: %v", err)
}
if restaged, err := s.RestageHostPBSSecret("h1"); err != nil || !restaged {
t.Fatalf("restage = (%v, %v), want (true, nil)", restaged, err)
}
g2, err := s.HostPBSSecretGeneration("h1")
if err != nil {
t.Fatalf("read generation: %v", err)
}
if g2 != g1 {
t.Fatalf("restage moved the secret generation %d -> %d; a re-stage changes no descriptor content", g1, g2)
}
}
+25 -2
View File
@@ -486,13 +486,35 @@ func (s *Store) migrate() error {
host_id TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
consumed_at DATETIME
consumed_at DATETIME,
generation INTEGER NOT NULL DEFAULT 0
);
`)
if err != nil {
return err
}
// R-39 fleet fix (v0.68.0) — the PBS secret GENERATION: a monotonic per-host counter advanced by
// every fresh MINT and by nothing else. Must be declared AFTER the CREATE above (an ALTER placed
// earlier in this function silently no-ops, because the table does not exist yet).
//
// Why it has to exist. The agent's re-apply trigger is a change in the DESCRIPTOR CONTENT HASH
// (`felhom-agent internal/pbsdr/manager.go` descriptorHash). An ep0 credential re-issue re-keys
// the SECRET of an existing token, so token_id, fingerprint, datastore and namespace all come
// back byte-identical — the descriptor does not move, the converged agent short-circuits, the
// fresh secret is never consumed, and the box serves a revoked credential while reporting
// `applied`. That is the 2026-07-18 N100 failure exactly (R-39). Stamping this counter into the
// descriptor is what finally makes a re-key LOOK different to the agent.
//
// It is deliberately NOT created_at (two mints inside one second collide) and there is no row id
// to borrow: this table is keyed by host_id and UPSERTed last-write-wins, so a "new row" never
// exists. A counter column is the only monotonic source available here.
//
// RestageHostPBSSecret must NOT touch it: a re-stage re-arms the SAME secret, the descriptor
// content genuinely has not changed, and bumping would trigger a pointless agent refetch loop
// (that method's own contract says so).
s.db.Exec(`ALTER TABLE host_pbs_secrets ADD COLUMN generation INTEGER NOT NULL DEFAULT 0`)
// v0.50.0 — customer-claim password arc (DRILL-day0-vm F-4): one row per customer holding the
// ACTIVE claim/reset code state. code_hash is bcrypt(code) — the plaintext exists ONLY inside
// the email send (same custody rule as the retrieval passphrase). generation is monotonic: a
@@ -1560,6 +1582,7 @@ type ManagedFloorDecision struct {
// - manifest MinAgent "" → UNCOUPLED release: serve the floor as-is (no agent gating);
// - agent_version known AND ≥ MinAgent → serve the floor;
// - agent_version below MinAgent, OR unknown/unparseable → HOLD (serve no directive) + flag.
//
// A held box is VISIBLE (the dashboard renders the reason), never silently stale.
func (s *Store) ResolveManagedFloor(customerID string) ManagedFloorDecision {
d := ManagedFloorDecision{Floor: s.EffectiveMinControllerVersion(customerID)}
@@ -2042,7 +2065,7 @@ func (s *Store) CountHostArtifacts(hostID string) (HostArtifacts, error) {
// DeleteHost removes a host and every host-scoped artifact in ONE transaction (v0.47.0
// stale host removal). The online-gate lives in the web handler — the store deletes what
// it is told to. Guards:
// - empty hostID → refused (would DELETE the '' scope rows);
// - empty hostID → refused (would DELETE the scope rows);
// - escrow present without deleteEscrow → ErrHostEscrowPresent, the tx never starts.
//
// The wg_peers delete is INSIDE the tx on purpose — a crash between a host delete and a