Secret Sealing
An installation's administrator types a secret into their OWN portal. For the secret to reach the control instance (to be written to the vault by a governed action) it must travel through nodes and signed events. It does so as ciphertext only: the installation's portal seals it to a public key the control instance publishes, and only the control instance opens it.
The envelope
SecretEnvelope (MeshWeaver.Mesh.Contract), version 1, .NET built-ins only:
- ECDH P-256 with a fresh ephemeral sender key per seal,
- HKDF-SHA256 over the shared secret (random 16-byte salt, info
mw-secret-envelope v1), - AES-256-GCM with a random 12-byte nonce.
The associated data is mw-secret-envelope v1, then the key id, the context and the ephemeral public key (its SPKI
bytes exactly as transmitted in epk), each as a 4-byte big-endian byte length followed by its bytes (UTF-8 for the
key id and the context). The length prefixes make it unambiguous: moving a | from the key id into the context
(k1|a + b against k1 + a|b) is different associated data and does not authenticate. The ephemeral key is
bound because ECDH alone does not see it: the negated point (x, p − y) is another valid P-256 point with the same
shared x-coordinate, hence the same AES key, so a rewritten epk would otherwise still authenticate. Seal
refuses a key id its own reader would refuse (1 to 64 characters). For a vault write the context is
{deployment}|{object}, so a sealed value cannot be replayed onto another deployment or object: unsealing
with a different context fails authentication, like a wrong key, a wrong key id, a different version or any changed
byte. Unseal answers null for every failure, never an exception that could say which.
Wire form: { "v":1, "kid":"…", "epk":"…", "salt":"…", "nonce":"…", "ct":"…", "tag":"…" }.
Hostile input is refused before anything is decoded. TryParse reads at most 16 384 characters, and both
TryParse and Unseal check every field against what a sealed envelope can carry (a P-256 SPKI key, 16/12/16 random
bytes, 1..8192 bytes of ciphertext), so an envelope with a bad tag cannot make the control instance allocate for it.
The control instance's key
SecretSealingKeyService (MeshWeaver.PluginCatalog) owns one node, Admin/SecretSealingKey/current, minted by the
control plane itself (never by a request) and read back so replicas agree. It holds the public key in the clear and the private key only as enc: ciphertext of the
platform key protector, refused when no master key exists.
Rotation: every 90 days, or on demand. Each outgoing key stays accepted for 7 days from its own rotation, so a
value sealed just before the rotation still opens; after that only the newer keys do. Every retired key inside its
window is kept (SecretSealingKey.Retired), so two rotations within one window never drop the first outgoing key
early. A key retires once, and its first deadline holds: two rotations computed from the same key at different
times can both land a retirement for it (the owner's list merge keeps both, since they differ as elements), so the
earliest entry for a key id decides whether it still opens, and the next rotation collapses the duplicates to it.
An expired retirement is tombstoned, never dropped: the next rotation clears its protected private key and keeps
the entry with its deadline. The owner merges the list by element, so a writer removes only the entries its mirror
held; dropping the first entry would let a later one for the same key (one that writer never saw, or one a delayed
rotation adds after the cleanup) become the earliest and open the expired key again.
The due-check and the rotation happen together, inside ONE GetMeshNodeStream(path).Update. The lambda runs on the
writer's mirror, and what reaches the owning hub is the merge patch of what it changed plus the writer's base values,
which the owner merges three-way. A request whose mirror was behind is therefore refused on the current key's fields
(they changed on the owner since its base), re-reads, finds the rotated key not due and adopts it; every caller
answers with the key as stored, never its own proposal. Retired is never null, so it always carries a base and
the owner merges it as a list: a rotation delayed behind two others appends to the retired keys and never replaces
them.
Opening and rotating are authorized as the caller, then executed as System
(Authorize as the caller, execute as System). The service is a mesh
singleton, so any code holding a hub can resolve it, in-mesh NodeType source included. Unseal requires Execute
and Rotate requires Update on Admin/SecretSealingKey/current, checked for the caller's own identity before
anything is elevated. A caller without the right gets UnauthorizedAccessException. A check that could not be
decided fails closed.
Publishing the public key
GET /api/instances/seal-key is anonymous and read-only: it is a public key, and an installation that holds only its
announcement key (the control-inbox credential) must be able to fetch it. It answers { keyId, publicKey, algorithm, version }, or 404 until the control plane has minted the pair, or a logged 503 when the key cannot be read. Only a
read that answers absent counts as "no key"; a delete in flight or an unanswered read is a fault, so no caller ever
mints a second key over a stored one. It
authenticates nobody, so an answer from it is never an authentication boundary. A request can never provoke the mint, and nothing else
leaves the control instance. What the key is for is gated elsewhere: a value sealed to it is accepted only inside an
event signed with the deployment's own key.
Who uses it
The governed secret write for an installation's own record (Hosting, instance-secret-write): the installation seals
the value for {deployment}|{vault object}, the signed event carries the envelope, and the control plane unseals it
into the ONE operator Job's environment, scrubbing the ciphertext when the run ends.