Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/e2a/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
11 changes: 9 additions & 2 deletions cmd/e2a/sending_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions cmd/e2a/sending_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
Expand Down Expand Up @@ -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")
}
}
29 changes: 29 additions & 0 deletions docs/runbooks/sending-policy-activation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
134 changes: 134 additions & 0 deletions internal/sendingpolicy/key_inventory.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading