diff --git a/main.go b/main.go index 60e4d6b0..c51d0143 100644 --- a/main.go +++ b/main.go @@ -1764,47 +1764,28 @@ func startValidator( // Phase 9 write-back is part of every proof cycle (RB3-F75): the principal, the signer and the // submitter are required, and a validator that cannot build them does not start. There is no // disabled mode - it used to run every cycle with its results written nowhere - no null submitter, - // and no fallback to the validator's key for a malformed write-back key. + // and no fallback to the validator's key: the write-back key is required (RB4-F50). var accSubmitter execution.AccumulateSubmitter - accWritebackPrincipal := os.Getenv("ACCUMULATE_RESULTS_PRINCIPAL") - accSignerURL := os.Getenv("ACCUMULATE_SIGNER_URL") if v := os.Getenv("FF_UNIFIED_TABLES"); v != "" && v != "true" { return nil, nil, fmt.Errorf("FF_UNIFIED_TABLES=%s is not supported: every proof cycle stores its evidence", v) } - if v := os.Getenv("PROOF_CYCLE_WRITEBACK"); v != "" && v != "true" { - return nil, nil, fmt.Errorf("PROOF_CYCLE_WRITEBACK=%s is not supported: proof cycles always write their results back", v) - } + wb, err := writebackSettingsFromEnv() + if err != nil { + return nil, nil, err + } { - if accWritebackPrincipal == "" || accSignerURL == "" { - return nil, nil, fmt.Errorf("write-back requires ACCUMULATE_RESULTS_PRINCIPAL and ACCUMULATE_SIGNER_URL " + - "(write-back cannot run without them)") - } log.Printf("📝 [Phase 9] Configuring Accumulate write-back:") - log.Printf(" - Principal: %s", accWritebackPrincipal) - log.Printf(" - Signer: %s", accSignerURL) - - // An optional dedicated write-back key. If it is set it must be valid: a malformed key is a - // configuration error, not a reason to sign with the validator's own key instead. - writebackPrivKey := privateKey - if writebackKeyHex := os.Getenv("ACCUMULATE_WRITEBACK_PRIV_KEY"); writebackKeyHex != "" { - keyBytes, err := hex.DecodeString(strings.TrimSpace(writebackKeyHex)) - if err != nil { - return nil, nil, fmt.Errorf("ACCUMULATE_WRITEBACK_PRIV_KEY is not valid hex: %w", err) - } - if len(keyBytes) != ed25519.PrivateKeySize { - return nil, nil, fmt.Errorf("ACCUMULATE_WRITEBACK_PRIV_KEY is %d bytes, want %d", len(keyBytes), ed25519.PrivateKeySize) - } - writebackPrivKey = ed25519.PrivateKey(keyBytes) - log.Printf(" - Using the dedicated write-back key from ACCUMULATE_WRITEBACK_PRIV_KEY") - } + log.Printf(" - Principal: %s", wb.principal) + log.Printf(" - Signer: %s", wb.signer) + log.Printf(" - Key: %x", wb.key.Public()) submitterCfg := &execution.AccumulateSubmitterConfig{ Client: liteClientAdapter, - PrivateKey: writebackPrivKey, - AccountURL: accWritebackPrincipal, - SignerURL: accSignerURL, + PrivateKey: wb.key, + AccountURL: wb.principal, + SignerURL: wb.signer, KeyPageIndex: 1, KeyIndex: 0, ConfirmationTimeout: 2 * time.Minute, @@ -1887,7 +1868,7 @@ func startValidator( AttestationPeers: cfg.AttestationPeers, AttestationRequiredCount: cfg.AttestationRequiredCount, AccumulateClient: accSubmitter, - ResultsPrincipal: accWritebackPrincipal, + ResultsPrincipal: wb.principal, Ed25519Key: privateKey, EnableMultiChain: cfg.EnableMultiChain, ProofGenerator: proofGenAdapter, @@ -2192,6 +2173,45 @@ func bftTimeoutFromEnv() (time.Duration, error) { } // checkpointSettings is the block-checkpoint anchor's configuration, all of it. +type writebackSettings struct { + principal, signer string + key ed25519.PrivateKey +} + +// writebackSettingsFromEnv reads Phase 9 write-back's settings, refusing any that is missing or malformed. +// The key is required: a validator without ACCUMULATE_WRITEBACK_PRIV_KEY used to sign its write-backs with +// its own consensus key, which is not on the signer's key page (RB4-F50). +func writebackSettingsFromEnv() (writebackSettings, error) { + wb := writebackSettings{ + principal: strings.TrimSpace(os.Getenv("ACCUMULATE_RESULTS_PRINCIPAL")), + signer: strings.TrimSpace(os.Getenv("ACCUMULATE_SIGNER_URL")), + } + if v := os.Getenv("PROOF_CYCLE_WRITEBACK"); v != "" && v != "true" { + return wb, fmt.Errorf("PROOF_CYCLE_WRITEBACK=%s is not supported: proof cycles always write their results back", v) + } + keyHex := strings.TrimSpace(os.Getenv("ACCUMULATE_WRITEBACK_PRIV_KEY")) + var missing []string + for name, v := range map[string]string{ + "ACCUMULATE_RESULTS_PRINCIPAL": wb.principal, "ACCUMULATE_SIGNER_URL": wb.signer, + "ACCUMULATE_WRITEBACK_PRIV_KEY": keyHex, + } { + if v == "" { + missing = append(missing, name) + } + } + if len(missing) > 0 { + sort.Strings(missing) + return wb, fmt.Errorf("proof cycles always write their results back, but %s is not set", strings.Join(missing, ", ")) + } + kb, err := hex.DecodeString(keyHex) + if err != nil || len(kb) != ed25519.PrivateKeySize { + // The value is a secret: named, never printed. + return wb, fmt.Errorf("ACCUMULATE_WRITEBACK_PRIV_KEY is not a %d-byte hex ed25519 private key", ed25519.PrivateKeySize) + } + wb.key = ed25519.PrivateKey(kb) + return wb, nil +} + type checkpointSettings struct { writer, account, signer string key ed25519.PrivateKey @@ -2249,6 +2269,7 @@ func checkEnvironment() error { func() error { _, err := bftTimeoutFromEnv(); return err }, func() error { _, err := envvar.Bool("MIGRATE_ON_START", false); return err }, func() error { _, err := accumulate.LogLevelFromEnv(); return err }, + func() error { _, err := writebackSettingsFromEnv(); return err }, func() error { enabled, err := envvar.Bool("CHECKPOINT_ANCHOR_ENABLED", false) if err != nil || !enabled { diff --git a/main_env_test.go b/main_env_test.go index 39eddc86..c8b90a79 100644 --- a/main_env_test.go +++ b/main_env_test.go @@ -44,6 +44,8 @@ func TestBootAcceptsProductionsValues(t *testing.T) { for k, v := range map[string]string{ "BATCH_PERIOD_BLOCKS": "100", "CERTEN_ALLOW_CONTRACT_CALLS": "true", "CERTEN_BLOCK_RETENTION": "0", "CERTEN_ENTITLEMENT_REFRESH_SEC": "10", "ON_DEMAND_INTENT_KEYED": "true", "CHECKPOINT_ANCHOR_ENABLED": "false", + "PROOF_CYCLE_WRITEBACK": "true", "ACCUMULATE_RESULTS_PRINCIPAL": "acc://certen-protocol.acme/proof-results", + "ACCUMULATE_SIGNER_URL": "acc://certen-protocol.acme/book/1", "ACCUMULATE_WRITEBACK_PRIV_KEY": hex.EncodeToString(make([]byte, 64)), } { t.Setenv(k, v) } @@ -87,3 +89,36 @@ func TestCheckpointAnchorIsConfiguredWholeOrRefused(t *testing.T) { t.Fatalf("an enabled, unconfigured checkpoint anchor passed the boot check: %v", err) } } + +// RB4-F50: without ACCUMULATE_WRITEBACK_PRIV_KEY a validator signed its write-backs with its own key. +func TestWritebackIsConfiguredWholeOrRefused(t *testing.T) { + key := hex.EncodeToString(make([]byte, 64)) + set := func(principal, signer, wbKey string) { + t.Setenv("ACCUMULATE_RESULTS_PRINCIPAL", principal) + t.Setenv("ACCUMULATE_SIGNER_URL", signer) + t.Setenv("ACCUMULATE_WRITEBACK_PRIV_KEY", wbKey) + } + set("acc://x.acme/results", "acc://x.acme/book/1", key) + wb, err := writebackSettingsFromEnv() + if err != nil || wb.signer != "acc://x.acme/book/1" || len(wb.key) != 64 { + t.Fatalf("a whole configuration was refused: %+v %v", wb, err) + } + for name, args := range map[string][3]string{ + "no principal": {"", "acc://x.acme/book/1", key}, + "no signer": {"acc://x.acme/results", "", key}, + "no write-back key": {"acc://x.acme/results", "acc://x.acme/book/1", ""}, + "malformed key": {"acc://x.acme/results", "acc://x.acme/book/1", "zz"}, + "short key": {"acc://x.acme/results", "acc://x.acme/book/1", key[:64]}, + } { + set(args[0], args[1], args[2]) + if _, err := writebackSettingsFromEnv(); err == nil { + t.Errorf("%s: accepted", name) + } else if strings.Contains(err.Error(), key[:64]) { + t.Errorf("%s: the refusal printed the key", name) + } + } + set("acc://x.acme/results", "acc://x.acme/book/1", "") + if err := checkEnvironment(); err == nil || !strings.Contains(err.Error(), "ACCUMULATE_WRITEBACK_PRIV_KEY") { + t.Fatalf("a validator without its write-back key passed the boot check: %v", err) + } +} diff --git a/main_startup_test.go b/main_startup_test.go index e857c088..b896929e 100644 --- a/main_startup_test.go +++ b/main_startup_test.go @@ -26,7 +26,7 @@ func TestValidatorRefusesToStartWithoutItsDatabase(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestValidatorRefusesToStartWithoutItsDatabase$") - cmd.Env = append(os.Environ(), + cmd.Env = append(append(os.Environ(), writebackEnv()...), "CERTEN_STARTUP_UNDER_TEST=1", "VALIDATOR_ID=validator-startup-test", "DATABASE_URL=postgres://certen@127.0.0.1:1/none?sslmode=disable&connect_timeout=2", @@ -59,7 +59,7 @@ func TestValidatorRefusesTheDatabaseOptionalSetting(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestValidatorRefusesTheDatabaseOptionalSetting$") - cmd.Env = append(os.Environ(), + cmd.Env = append(append(os.Environ(), writebackEnv()...), "CERTEN_STARTUP_UNDER_TEST=2", "VALIDATOR_ID=validator-startup-test", "DATABASE_URL=postgres://certen@127.0.0.1:1/none?sslmode=disable&connect_timeout=2", @@ -72,6 +72,39 @@ func TestValidatorRefusesTheDatabaseOptionalSetting(t *testing.T) { } } +// RB4-F50: a validator without ACCUMULATE_WRITEBACK_PRIV_KEY used to sign its write-backs with its own +// key. It refuses to start, naming the value. +func TestValidatorRefusesToStartWithoutItsWritebackKey(t *testing.T) { + if os.Getenv("CERTEN_STARTUP_UNDER_TEST") == "3" { + os.Args = []string{"validator"} + main() + return + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestValidatorRefusesToStartWithoutItsWritebackKey$") + cmd.Env = append(append(os.Environ(), writebackEnv()...), + "CERTEN_STARTUP_UNDER_TEST=3", + "VALIDATOR_ID=validator-startup-test", + "ACCUMULATE_WRITEBACK_PRIV_KEY=", + ) + out, err := cmd.CombinedOutput() + log := string(out) + if ctx.Err() != nil || err == nil || !strings.Contains(log, "ACCUMULATE_WRITEBACK_PRIV_KEY is not set") { + t.Fatalf("a validator without its write-back key must refuse to start by name (err=%v):\n%s", err, tail(log)) + } +} + +// writebackEnv is a whole write-back configuration, so a startup test reaches the check it is about. +func writebackEnv() []string { + return []string{ + "PROOF_CYCLE_WRITEBACK=true", + "ACCUMULATE_RESULTS_PRINCIPAL=acc://startup-test.acme/results", + "ACCUMULATE_SIGNER_URL=acc://startup-test.acme/book/1", + "ACCUMULATE_WRITEBACK_PRIV_KEY=" + strings.Repeat("00", 64), + } +} + func tail(s string) string { lines := strings.Split(strings.TrimSpace(s), "\n") if len(lines) > 25 { diff --git a/readme.md b/readme.md index 83137257..711fc853 100644 --- a/readme.md +++ b/readme.md @@ -267,7 +267,8 @@ See [Proof Classes](#proof-classes) for what these control. | Variable | Required | Default | Description | |----------|----------|---------|-------------| -| `PROOF_CYCLE_WRITEBACK` | No | — | Only `true` is accepted; any other value is refused. Proof cycles always write their results back (requires `ACCUMULATE_RESULTS_PRINCIPAL`, `ACCUMULATE_SIGNER_URL`) | +| `PROOF_CYCLE_WRITEBACK` | No | — | Only `true` is accepted; any other value is refused. Proof cycles always write their results back (requires `ACCUMULATE_RESULTS_PRINCIPAL`, `ACCUMULATE_SIGNER_URL`, `ACCUMULATE_WRITEBACK_PRIV_KEY`) | +| `ACCUMULATE_WRITEBACK_PRIV_KEY` | Yes | — | Hex ed25519 private key (64 bytes) on `ACCUMULATE_SIGNER_URL`'s key page that signs write-backs. The validator refuses to start without it (it used to sign with its own key) | | `FIRESTORE_ENABLED` | No | false | Enable Firestore real-time sync | | `FIREBASE_PROJECT_ID` | No | - | Firebase project ID | | `GOOGLE_APPLICATION_CREDENTIALS` | No | - | Service account JSON path |