# Operating clustered secrets How to run `ZB.MOM.WW.Secrets` across more than one node. Covers the shared SQL-Server store, the SQL-Server hub, and Akka peer-to-peer replication. Companion: [`kek-rotation.md`](kek-rotation.md). --- ## 0. The one rule that breaks everything if you get it wrong **Every node must resolve the same master KEK.** Rows are ciphertext everywhere — in the database, in a backup, on the wire. Only the KEK decrypts them, and it never leaves the node. So if node B has a different master key than node A: - replication still "works" — rows arrive and are stored; - every resolve on B of a secret written on A **fails closed** with a `kek_id` mismatch. The symptom looks like data corruption and is not. Before enabling any topology below, confirm the same key material is present on every node: ```bash # On each node — compare the digests, never print the key itself. sha256sum /run/secrets/zb-kek # or, for the environment source: printf '%s' "$ZB_SECRETS_MASTER_KEY" | sha256sum ``` --- ## 1. Shared SQL-Server store One database, one copy of each row. Prefer this unless a node must keep resolving secrets while partitioned from the database. ### Provision The schema is created automatically by the first node that starts (idempotent, and safe if several start at once). You only need a database and a login: ```sql CREATE DATABASE ZbSecrets; GO USE ZbSecrets; CREATE LOGIN zbsecrets WITH PASSWORD = ''; CREATE USER zbsecrets FOR LOGIN zbsecrets; GO -- The app creates and owns the zbsecrets schema; it needs permission to do so. GRANT CREATE SCHEMA, CREATE TABLE TO zbsecrets; ALTER ROLE db_owner ADD MEMBER zbsecrets; -- or grant on the schema once it exists GO ``` To pre-create the schema instead (so the app can run with narrower rights), run the DDL from `SqlServerSecretsSchema.CreateSchemaDdl("zbsecrets")` and then grant only `SELECT, INSERT, UPDATE` on `[zbsecrets].[secret]` and `[zbsecrets].[schema_version]`. > `DELETE` is deliberately **not** required. Deletes are tombstones — an `UPDATE` — so a compromised > app credential cannot destroy secret history. ### Configure ```jsonc "Secrets": { "MasterKey": { "Source": "File", "FilePath": "/run/secrets/zb-kek" }, "SqlServer": { "ConnectionString": "Server=sql01;Database=ZbSecrets;User Id=zbsecrets;Password=${secret:sql/zbsecrets-password};TrustServerCertificate=False", "SchemaName": "zbsecrets", "CommandTimeout": "00:00:30" } } ``` The connection string is itself a credential — deliver it by `${secret:}` reference, an orchestrator secret, or an environment variable. Never commit it. ### Verify ```bash secret set smoke/test hello --description "delete me" # on node A secret get smoke/test # on node B — expect: hello secret rm smoke/test ``` ### Rotation Run `rewrap-all` **once**, against the shared store, with resolve traffic quiesced. --- ## 2. SQL-Server hub replication Each node keeps its local SQLite store and syncs against a shared hub. Same provisioning as topology 1. Configure with `AddZbSecretsSqlServerReplication` and: ```jsonc "Secrets": { "SqlitePath": "/var/lib/zb/secrets.db", "MasterKey": { "Source": "File", "FilePath": "/run/secrets/zb-kek" }, "SqlServer": { "ConnectionString": "...", "SyncInterval": "00:00:30", "SyncOnStartup": true } } ``` `SyncInterval` is the convergence bound, not the propagation latency — a live write reaches the hub in milliseconds; the interval governs how fast a *dropped* write or a rejoining node is repaired. ### Rotation Run `rewrap-all` against the hub **and** against every node's local SQLite store. A re-wrap does not bump the revision (by design — otherwise every node's independent re-wrap would churn replication), so it does **not** propagate. ### Verifying convergence ``` Secret hub sync converged: pulled N row(s) from the hub, pushed M row(s) to it. ``` Logged at Information only when something actually moved. A healthy steady-state cluster logs nothing. Continuous non-zero pushes on every tick means two nodes are fighting over the same secret — look for a script writing the same name on a loop. --- ## 3. Akka cluster peer-to-peer No shared database. Requires the application to register its `ActorSystem` in DI. ```jsonc "Secrets": { "SqlitePath": "/var/lib/zb/secrets.db", "MasterKey": { "Source": "File", "FilePath": "/run/secrets/zb-kek" }, "Replication": { "AnnounceInterval": "00:00:30", "ActorName": "zb-secret-replication" } } ``` Merge the serializer config when creating the actor system: ```csharp Config config = AkkaSecretsReplication.SerializationConfig.WithFallback(myAppConfig); ActorSystem.Create("my-system", config); ``` Optional but recommended: without it the protocol still round-trips under Akka's default JSON serializer, but the serializer carrying secret ciphertext becomes an inherited default rather than a deliberate choice. ### Securing the transport Rows on the wire are ciphertext and the KEK never travels, so a passive eavesdropper learns nothing but secret *names*, revisions, and timestamps. That is still metadata worth protecting, and an unauthenticated cluster port is a much larger problem than secret replication — configure Akka.Remote TLS and cluster authentication as you would for any other cluster traffic. ### Rotation Run `rewrap-all` on **each node's** local store. Re-wraps do not replicate (see above). ### Announce traffic One manifest per node per interval: names, revisions, timestamps, tombstone flags — no ciphertext. Cost grows as O(nodes × secrets), so a large cluster with a large secret set should lengthen `AnnounceInterval` rather than shorten it. --- ## Choosing between them | | Shared store | SQL hub | Akka peer-to-peer | |---|---|---|---| | Resolves while cut off from the DB | ✗ (cache TTL only) | ✓ | ✓ (no DB at all) | | Distributed failure modes | none | LWW, sweep lag | LWW, sweep lag | | Rotation runs | once | hub + each node | each node | | Needs a shared database | ✓ | ✓ | ✗ | | Needs an Akka cluster | ✗ | ✗ | ✓ | Start at the left and move right only when a stated requirement forces it. --- ## Troubleshooting **`SecretDecryptionException` / `kek_id` mismatch after enabling replication.** The KEKs differ between nodes. Compare digests as in §0. Do not "fix" it by re-writing the secret on the failing node — that hides the mismatch until the next secret. **A secret written on node A never appears on node B.** Check, in order: (1) are both nodes actually in the cluster / pointed at the same hub database and schema; (2) has one `AnnounceInterval` / `SyncInterval` elapsed; (3) is there a warning-level log about a failed publish or a failed sweep. A dropped publish is expected and self-repairing — a sweep that fails every interval is not. **A deleted secret came back.** Deletes are tombstones and propagate as newer rows, so this should not happen through replication. It does happen if a node's local store was restored from a backup taken before the delete: the restore reintroduces the row with an old timestamp, loses to LWW, and the tombstone re-wins. If instead the row is *live* again, someone re-created it — check the audit log for the write. **The migration refuses to run.** `SecretStoreMigrationException` naming a version newer than supported means another node is running a newer build against the same store. Upgrade this node; do not downgrade the store.