diff --git a/cmd/e2a/main.go b/cmd/e2a/main.go index 8187fb09b..5b17dc77b 100644 --- a/cmd/e2a/main.go +++ b/cmd/e2a/main.go @@ -109,6 +109,7 @@ func main() { // Sending-protection local operator commands (Task 2). Each runs after // migrations and exits without starting the server. var spFlags sendingProtectionFlags + flag.BoolVar(&spFlags.inventory, "sending-protection-key-inventory", false, "print non-secret retained key references and permanent recipient registry as JSON, then exit") flag.BoolVar(&spFlags.inspect, "sending-protection-policy", false, "print the stored runtime policy state and the hash this config's policy would activate, then exit") flag.BoolVar(&spFlags.activate, "activate-sending-protection-policy", false, "CAS-activate the selected sending-protection policy (file, or config if absent; requires -expected-generation, -expected-policy-sha256, -reason), then exit") flag.StringVar(&spFlags.policyFile, "sending-protection-policy-file", "", "reviewed runtime-policy JSON file for inspection or activation (maximum 64 KiB; no config/env overrides)") diff --git a/cmd/e2a/sending_policy.go b/cmd/e2a/sending_policy.go index b34dcb8d5..033e1a2c9 100644 --- a/cmd/e2a/sending_policy.go +++ b/cmd/e2a/sending_policy.go @@ -24,6 +24,7 @@ import ( // invocation; each runs after migrations and exits without starting the // server, following the -bootstrap-email pattern. type sendingProtectionFlags struct { + inventory bool inspect bool activate bool register bool @@ -65,7 +66,7 @@ type sendingProtectionFlags struct { } func (f *sendingProtectionFlags) commandRequested() bool { - return f.inspect || f.activate || f.register || f.attest || f.capabilities || f.reconcile || + return f.inventory || f.inspect || f.activate || f.register || f.attest || f.capabilities || f.reconcile || f.inspectExternal || f.approveExternal || f.revokeExternal || f.declineExternal || f.listExternal || f.pauseAccount || f.resumeAccount || f.inspectPause } @@ -85,7 +86,7 @@ func (f *sendingProtectionFlags) validateStandalone() error { func (f *sendingProtectionFlags) selectedCount() int { n := 0 - for _, set := range []bool{f.inspect, f.activate, f.register, f.attest, f.capabilities, f.reconcile, + for _, set := range []bool{f.inventory, f.inspect, f.activate, f.register, f.attest, f.capabilities, f.reconcile, f.inspectExternal, f.approveExternal, f.revokeExternal, f.declineExternal, f.listExternal, f.pauseAccount, f.resumeAccount, f.inspectPause} { if set { @@ -147,6 +148,12 @@ func runSendingProtectionCommand(ctx context.Context, cfg *config.Config, pool * module := sendingpolicy.NewModule(pool, secrets) switch { + case f.inventory: + inventory, err := module.KeyInventory(ctx) + if err != nil { + return err + } + return json.NewEncoder(stdout).Encode(inventory) case f.inspect: if err := runPolicyInspect(ctx, module, source, policy, stdout); err != nil { return err diff --git a/cmd/e2a/sending_policy_test.go b/cmd/e2a/sending_policy_test.go index 60cb221b4..5528e2b9c 100644 --- a/cmd/e2a/sending_policy_test.go +++ b/cmd/e2a/sending_policy_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "encoding/json" "errors" "fmt" "os" @@ -607,3 +608,33 @@ func TestListExternalSendingRequestsReportsTruncation(t *testing.T) { t.Fatal("-all must keep the newest requests when truncating") } } + +func TestSendingKeyInventoryCommand(t *testing.T) { + ctx := context.Background() + pool := testutil.TestDB(t) + var out bytes.Buffer + flags := &sendingProtectionFlags{inventory: true} + if !flags.commandRequested() || flags.selectedCount() != 1 { + t.Fatal("inventory is not a standalone command") + } + if err := runSendingProtectionCommand(ctx, spTestConfig(), pool, sendingpolicy.Secrets{}, flags, &out); err != nil { + t.Fatal(err) + } + var inventory sendingpolicy.KeyInventory + if err := json.Unmarshal(out.Bytes(), &inventory); err != nil { + t.Fatalf("not a JSON contract: %v", err) + } + if inventory.SchemaVersion != 1 || inventory.RequiredHMACVersions == nil || inventory.RecipientRegistry == nil { + t.Fatalf("invalid inventory: %+v", inventory) + } + flags.inspect = true + out.Reset() + if err := runSendingProtectionCommand(ctx, spTestConfig(), pool, sendingpolicy.Secrets{}, flags, &out); err == nil || out.Len() != 0 { + t.Fatal("multiple commands accepted") + } + flags.inspect = false + flags.policyFile = "unused.json" + if err := flags.validateStandalone(); err == nil { + t.Fatal("policy file accepted for inventory") + } +} diff --git a/docs/runbooks/sending-policy-activation.md b/docs/runbooks/sending-policy-activation.md index f852d8c10..406ee572e 100644 --- a/docs/runbooks/sending-policy-activation.md +++ b/docs/runbooks/sending-policy-activation.md @@ -86,3 +86,32 @@ readiness immediately. `/api/health` remains shallow liveness. Config-source deployments do not depend on stored policy or registry rows for readiness. This preserves the existing self-host defaults and the explicit config-source rollback path. + +## Read retained key requirements before a rotation + +`e2a -config config.yaml -sending-protection-key-inventory` prints a single +schema-versioned JSON object. Schema 1 includes the stored policy generation, +hash and selected recipient version, distinct HMAC versions needed by unexpired +feedback and owner-notice authorizations, distinct operator versions needed by +unexpired notice authorizations, and the permanent recipient registry. Current +authorized attempts continue to pin their keys beyond nominal expiry until they +are superseded or redeemed, matching ledger retention. Lists +are sorted; empty lists are `[]`. It emits no account/message identifiers, +addresses, secret bytes or audit actors. Capabilities advertise +`sending_key_inventory_schema: 1` when the command is supported. + +The read runs in one consistent read-only transaction, with a ten-second +operation timeout and a 4,096-entry limit for registry and combined reference +sets. A corrupt policy, ambiguous orphaned notice authorization, query failure, +or exceeded bound returns a fixed error and no partial JSON. Startup migrations +still precede the operator command. It never registers or repairs records. + +Inventory is evidence, not a lock against new authorizations. During deployment, +retain both the old and new active signing keys in both slots, and retain every +policy-selected recipient as well as outstanding references. Add keys to both +slots before selecting a new signer. Serialize operator policy/secret changes +with deployment; a snapshot does not make concurrent selector changes safe. +Compare every configured operator commitment against permanent history even +when its original secret payload has been retired. An absent registry entry for +a selected version requires explicit registration before permitting rotation. +Deployment tooling must recheck before cutover and rerun on manual rollback. diff --git a/internal/sendingpolicy/key_inventory.go b/internal/sendingpolicy/key_inventory.go new file mode 100644 index 000000000..3f150cf29 --- /dev/null +++ b/internal/sendingpolicy/key_inventory.go @@ -0,0 +1,134 @@ +package sendingpolicy + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +// RecipientRegistration is a permanent, non-secret binding. Retiring a secret +// payload must never erase this evidence or permit reuse of its logical version. +type RecipientRegistration struct { + Version int `json:"version"` + CommitmentKeyID string `json:"commitment_key_id"` + Commitment string `json:"commitment"` +} + +// KeyInventory is a read-only, consistent deployment snapshot. It contains no +// account/message identifiers, addresses, key material, or audit actors. +// Version sets are sorted and non-nil. SchemaVersion versions this CLI contract. +type KeyInventory struct { + SchemaVersion int `json:"schema_version"` + PolicyGeneration int64 `json:"policy_generation"` + PolicySHA256 string `json:"policy_sha256"` + SelectedRecipientVersion int `json:"selected_recipient_version"` + RequiredHMACVersions []int `json:"required_hmac_versions"` + RequiredRecipientVersions []int `json:"required_recipient_versions"` + RecipientRegistry []RecipientRegistration `json:"recipient_registry"` +} + +const inventoryLimit = 4096 + +// KeyInventory reads policy, permanent registry and unexpired feedback/notice +// references in one repeatable-read snapshot. Errors return no partial result. +// The snapshot does not fence concurrent writers: deploy gates must additionally +// retain keys and selectors that any still-running slot can use for new work. +// Normal operator-command startup migrations precede this read-only operation. +func (m *Module) KeyInventory(ctx context.Context) (KeyInventory, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + tx, err := m.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if err != nil { + return KeyInventory{}, errors.New("sendingpolicy: key inventory unavailable") + } + defer func() { + cleanup, done := context.WithTimeout(context.Background(), time.Second) + defer done() + _ = tx.Rollback(cleanup) + }() + out, err := readKeyInventory(ctx, tx) + if err == nil { + err = tx.Commit(ctx) + } + if err != nil { + return KeyInventory{}, errors.New("sendingpolicy: key inventory unavailable") + } + return out, nil +} + +func readKeyInventory(ctx context.Context, tx pgx.Tx) (KeyInventory, error) { + snapshot, err := scanPolicy(tx.QueryRow(ctx, policySelect)) + if err != nil { + return KeyInventory{}, err + } + out := KeyInventory{SchemaVersion: 1, PolicyGeneration: snapshot.Generation, PolicySHA256: snapshot.PolicySHA256, SelectedRecipientVersion: snapshot.Policy.OperatorNoticeRecipientVersion, RequiredHMACVersions: []int{}, RequiredRecipientVersions: []int{}, RecipientRegistry: []RecipientRegistration{}} + rows, err := tx.Query(ctx, `SELECT logical_version,commitment_key_id,recipient_commitment FROM sending_operator_recipient_versions ORDER BY logical_version LIMIT 4097`) + if err != nil { + return out, err + } + for rows.Next() { + var r RecipientRegistration + if err := rows.Scan(&r.Version, &r.CommitmentKeyID, &r.Commitment); err != nil { + rows.Close() + return out, err + } + out.RecipientRegistry = append(out.RecipientRegistry, r) + } + err = rows.Err() + rows.Close() + if err != nil { + return out, err + } + if len(out.RecipientRegistry) > inventoryLimit { + return out, errors.New("registry exceeds inventory bound") + } + // The same notice version column holds two different namespaces. Audience, + // not purpose, determines which one. Keep even started attempts until expiry. + // Current authorizations remain redeemable beyond nominal expiry (the same + // conservative boundary as ledger retention). An orphaned notice reference is ambiguous and must block rather than vanish. + rows, err = tx.Query(ctx, ` + SELECT DISTINCT 'hmac',r.hmac_key_version + FROM sending_feedback_recipients r JOIN sending_feedback_correlations c USING(correlation_id) + WHERE c.expires_at IS NULL OR c.expires_at>now() + UNION + SELECT DISTINCT CASE d.audience WHEN 'owner' THEN 'hmac' WHEN 'operator' THEN 'recipient' ELSE 'unknown' END,r.notice_recipient_version + FROM sending_budget_reservations r + LEFT JOIN sending_protection_notice_deliveries d ON d.current_operation_id=r.operation_id + LEFT JOIN sending_provider_operations o ON o.operation_id=r.operation_id + WHERE r.notice_recipient_version IS NOT NULL AND r.call_state IN ('authorized','started') + AND (r.expires_at>now() OR (r.call_state='authorized' AND r.submission_attempt>=o.current_attempt)) + ORDER BY 1,2 LIMIT 4097`) + if err != nil { + return out, err + } + count := 0 + for rows.Next() { + var kind string + var version int + if err := rows.Scan(&kind, &version); err != nil { + rows.Close() + return out, err + } + count++ + switch kind { + case "hmac": + out.RequiredHMACVersions = append(out.RequiredHMACVersions, version) + case "recipient": + out.RequiredRecipientVersions = append(out.RequiredRecipientVersions, version) + default: + rows.Close() + return out, errors.New("unresolved notice key reference") + } + } + err = rows.Err() + rows.Close() + if err != nil { + return out, err + } + if count > inventoryLimit { + return out, errors.New("references exceed inventory bound") + } + return out, nil +} diff --git a/internal/sendingpolicy/key_inventory_test.go b/internal/sendingpolicy/key_inventory_test.go new file mode 100644 index 000000000..ef8fe7aa8 --- /dev/null +++ b/internal/sendingpolicy/key_inventory_test.go @@ -0,0 +1,144 @@ +package sendingpolicy_test + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestKeyInventoryRetainedReferencesAndPermanentRegistry(t *testing.T) { + m, pool := newModule(t) + ctx := context.Background() + // No account or message is needed: retained security evidence outlives both. + for _, q := range []string{ + `INSERT INTO sending_feedback_correlations (correlation_id,operation_id,submission_attempt,policy_subject_ref,purpose,tenant_mode,expires_at) VALUES ('corr_live','op_live',1,'subject_test','customer_message','none',NULL),('corr_expired','op_expired',1,'subject_test','customer_message','none',now()-interval '1 second')`, + `INSERT INTO sending_feedback_recipients (correlation_id,recipient_hmac,hmac_key_version) VALUES ('corr_live','a',7),('corr_expired','b',8)`, + } { + if _, err := pool.Exec(ctx, q); err != nil { + t.Fatal(err) + } + } + before := mustInspect(t, m) + got, err := m.KeyInventory(ctx) + if err != nil { + t.Fatal(err) + } + if got.SchemaVersion != 1 || got.PolicyGeneration != before.Generation || got.PolicySHA256 != before.PolicySHA256 || got.SelectedRecipientVersion != 1 { + t.Fatalf("invalid policy snapshot: %+v", got) + } + if !reflect.DeepEqual(got.RequiredHMACVersions, []int{7}) { + t.Fatalf("retained keys = %v", got.RequiredHMACVersions) + } + if len(got.RecipientRegistry) != 1 || got.RecipientRegistry[0].Version != 1 { + t.Fatalf("registry = %+v", got.RecipientRegistry) + } + raw, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + for _, private := range []string{"corr_live", "op_live", "@", "created_by"} { + if strings.Contains(string(raw), private) { + t.Fatalf("inventory leaks %q", private) + } + } + if _, err = pool.Exec(ctx, `UPDATE sending_feedback_correlations SET expires_at=now()-interval '1 second'`); err != nil { + t.Fatal(err) + } + got, err = m.KeyInventory(ctx) + if err != nil { + t.Fatal(err) + } + if len(got.RequiredHMACVersions) != 0 || len(got.RecipientRegistry) != 1 { + t.Fatalf("expiration erased registry or retained expired key: %+v", got) + } + after := mustInspect(t, m) + if before.Generation != after.Generation || before.PolicySHA256 != after.PolicySHA256 { + t.Fatal("inventory mutated policy") + } +} + +func TestKeyInventoryCorruptPolicyFailsClosed(t *testing.T) { + m, pool := newModule(t) + if _, err := pool.Exec(context.Background(), `UPDATE sending_protection_runtime_policy SET policy_sha256=repeat('0',64)`); err != nil { + t.Fatal(err) + } + if _, err := m.KeyInventory(context.Background()); err == nil { + t.Fatal("corrupt policy accepted") + } +} + +func TestKeyInventoryNoticeNamespacesAndUnresolvedReference(t *testing.T) { + m, pool := newModule(t) + ctx := context.Background() + for _, q := range []string{ + `INSERT INTO sending_protection_notice_events(id,account_ref,kind,reason_code,budget_scope,ledger_day,expires_at) VALUES('notice_test','account_test','budget_violation','budget_limit','account_daily',current_date,now()+interval '1 day')`, + `INSERT INTO sending_protection_notice_deliveries(event_id,audience,current_operation_id) VALUES('notice_test','owner','owner_op'),('notice_test','operator','operator_op')`, + `INSERT INTO sending_budget_reservations(operation_id,submission_attempt,policy_subject_ref,purpose,day,units,probation,state,call_state,authorization_nonce,notice_recipient_version,notice_recipient_commitment) VALUES ('owner_op',1,'subject_test','violation_operational',current_date,1,false,'confirmed','authorized','nonce_owner',23,'owner_hmac'),('operator_op',1,'subject_test','violation_operational',current_date,1,false,'confirmed','authorized','nonce_operator',42,'operator_commitment')`, + } { + if _, err := pool.Exec(ctx, q); err != nil { + t.Fatal(err) + } + } + got, err := m.KeyInventory(ctx) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got.RequiredHMACVersions, []int{23}) || !reflect.DeepEqual(got.RequiredRecipientVersions, []int{42}) { + t.Fatalf("namespaces mixed: %+v", got) + } + if _, err = pool.Exec(ctx, `DELETE FROM sending_protection_notice_deliveries WHERE audience='owner'`); err != nil { + t.Fatal(err) + } + if _, err = m.KeyInventory(ctx); err == nil { + t.Fatal("ambiguous reference silently omitted") + } + if _, err = pool.Exec(ctx, `UPDATE sending_budget_reservations SET expires_at=now()-interval '1 second' WHERE operation_id='owner_op'`); err != nil { + t.Fatal(err) + } + got, err = m.KeyInventory(ctx) + if err != nil { + t.Fatal(err) + } + if len(got.RequiredHMACVersions) != 0 || !reflect.DeepEqual(got.RequiredRecipientVersions, []int{42}) { + t.Fatalf("expiration = %+v", got) + } +} + +func TestKeyInventoryBoundAndRedaction(t *testing.T) { + m, pool := newModule(t) + ctx := context.Background() + if _, err := pool.Exec(ctx, `INSERT INTO sending_operator_recipient_versions(logical_version,commitment_key_id,recipient_commitment,created_by) SELECT v,repeat('a',64),repeat('b',64),'PRIVATE_ACTOR_SENTINEL' FROM generate_series(10000,14096) v`); err != nil { + t.Fatal(err) + } + got, err := m.KeyInventory(ctx) + if err == nil { + t.Fatal("oversized registry accepted") + } + if err.Error() != "sendingpolicy: key inventory unavailable" || got.SchemaVersion != 0 { + t.Fatalf("partial result or unsafe error: %+v %v", got, err) + } +} + +func TestKeyInventoryPinsExpiredCurrentAuthorization(t *testing.T) { + m, pool := newModule(t) + ctx := context.Background() + for _, q := range []string{ + `INSERT INTO sending_protection_notice_events(id,account_ref,kind,reason_code,budget_scope,ledger_day,expires_at) VALUES('notice_expired','account_test','budget_violation','budget_limit','account_daily',current_date,now()+interval '1 day')`, + `INSERT INTO sending_protection_notice_deliveries(event_id,audience,current_operation_id) VALUES('notice_expired','owner','expired_op')`, + `INSERT INTO sending_provider_operations(operation_id,policy_subject_ref,purpose,current_attempt,expires_at) VALUES('expired_op','subject_test','violation_operational',2,now()-interval '1 day')`, + `INSERT INTO sending_budget_reservations(operation_id,submission_attempt,policy_subject_ref,purpose,day,units,probation,state,call_state,authorization_nonce,notice_recipient_version,notice_recipient_commitment,expires_at) VALUES ('expired_op',1,'subject_test','violation_operational',current_date,1,false,'confirmed','authorized','nonce_old',23,'old_hmac',now()-interval '1 day'),('expired_op',2,'subject_test','violation_operational',current_date,1,false,'confirmed','authorized','nonce_current',24,'current_hmac',now()-interval '1 day')`, + } { + if _, err := pool.Exec(ctx, q); err != nil { + t.Fatal(err) + } + } + got, err := m.KeyInventory(ctx) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got.RequiredHMACVersions, []int{24}) { + t.Fatalf("expired current authorization must pin key, superseded must not: %v", got.RequiredHMACVersions) + } +} diff --git a/internal/sendingpolicy/secrets.go b/internal/sendingpolicy/secrets.go index 8ec3c3f6e..7ed5f5f0d 100644 --- a/internal/sendingpolicy/secrets.go +++ b/internal/sendingpolicy/secrets.go @@ -86,6 +86,7 @@ func LoadSecretsFromEnv(source PolicySource, policy RuntimePolicy) (Secrets, err // gate. It carries commitments only: no addresses, no key material, no // customer data. type Capabilities struct { + KeyInventorySchema int `json:"sending_key_inventory_schema"` SendingProtectionContract int `json:"sending_protection_contract"` RuntimePolicySource string `json:"runtime_policy_source"` OperatorCommitments map[string]string `json:"operator_notice_recipient_commitments"` @@ -116,6 +117,7 @@ func BuildCapabilities(source PolicySource, secrets Secrets) Capabilities { commitments = secrets.Recipients.Commitments() } return Capabilities{ + KeyInventorySchema: 1, SendingProtectionContract: ContractLevel, RuntimePolicySource: string(source), OperatorCommitments: commitments,