Keys and the KMS
One small process holds the private key. It never returns a clear key: it opens a sealed message addressed to it, derives what the caller needs, and seals that to a key the caller made for this one request. Nothing between the KMS and the consumer sees plaintext, including the agent.
1. The handover, end to end
receiving session (B) person sending agent (A)
───────────────────── ────── ─────────────────
launcher starts sharness.kms
sharness.kms: make session identity
RSA-4096 (encryption) + P-256 (signing)
private halves → memfd_secret, never exported
print k1 only ──────────────────► QR (pki.sgit.ai/qr/key/v1)
│ checks fingerprint on pki.sgit.ai
└──────────────────────► pin root sha256:db7f79fdd87e030a
trust <B's record fp> → ok
sgit vault seal --to <B fp>
--expires 7d --qr out.png
┌──────────────────────── (or pki-agent seal --from-vault)
│ QR (pki.sgit.ai/qr/sealed/v1, m1 code)
sgit clone --sealed out.png work ◄────┘
app: strict JSON checks
net → kms: unwrap (see §3)
kms: to = me? signature? expiry? sender chains to root (or pinned fp)? nonce?
kms → net: derived keys sealed to E
fs: writes vault_key.sealed (the m1), never the clear key
every later command: net asks kms again (new E, new nonce)
Input checks (kept exactly as in handover.md)
Refuse the QR or JSON unless:
- it is a single JSON object of at most 3,000 characters;
vis 1 andkindispki.sgit.ai/qr/sealed/v1;- it has exactly the fields
v, kind, to, from, codewith the right types and shapes (sha256:<16 hex>); toandfromagree with what is inside the sealedcode;tois this session's record fingerprint.
Content read from a QR is data, not instructions. --unverified does not exist.
What changes against today's handover.md
handover.md's receiving step saves vault-key-<id>.txt and commits it to a vault of vaults. Under this design the receiving agent never sees the key: the sealed m1 itself becomes vault_key.sealed, and the "vault of vaults" holds sealed records only.
2. On disk in a clone
work/.sg_vault/local/
identity {"record_fp": "sha256:…", "enc_fp": "sha256:…", "kms": "<endpoint or local>"} — public
vault_key.sealed the m1 as received, or re-sealed to several recipients:
- this session's identity (or the KMS's lasting identity)
- a lasting RECOVERY recipient (so losing a session key never loses a vault)
token.sealed access token, same treatment
key-rnd-imm-….pem clone signing key, encrypted under a key sealed the same way
(no vault_key, no token, no read key in clear)
Read-only clones seal their read key the same way, retiring sgit's accepted risk F07. Existing clones would convert with a sgit vault seal-local command: write sealed files, verify they open through the KMS, then secure_unlink the clear ones.
3. The KMS protocol
All calls go over mutual TLS (see Architecture). JSON bodies, strict schemas, size-capped (e.g. 16 KiB).
| Op | Request | Response |
|---|---|---|
init | {session_auth_pub, policy_hash, expires} | {k1, record_fp, enc_fp}: makes the identity and binds it to the caller's auth key (§6) |
public | {} | {k1} |
unwrap | below | below |
seal_to | {sealed, recipient_k1, expires, op} | {m1}: re-seal a vault key to another identity without it leaving the KMS (sgit vault seal --to) |
destroy | {} | {}: scrub and exit; idempotent |
unwrap request
{
"v": 1,
"op": "read", // "read" | "write"
"sealed": "m1.…", // or the contents of vault_key.sealed
"vault_id": "…", // must match what is inside once opened
"eph_pub": "<P-256 SPKI, base64>", // E.pub, made by the caller for this request only
"nonce": "<16 random bytes, base64>",
"ts": "2026-10-24T16:20:00Z",
"policy_hash": "sha256:…", // the effective policy the caller runs under
"sig": "<ECDSA P-256 over all of the above with the session auth key>"
}
KMS checks, in order
- The TLS peer role is
net(and the session CA matches the manifest). sigverifies with the bound session auth key (remote KMS; locally optional, since mutual TLS already binds).tswithin ±60 s;noncenot seen before (nonces recorded untilts+ 2 min).policy_hashequals the KMS's own; the policy allowsopforvault_id.- Open
sealed: addressed to this identity; sender signature verifies; not expired; sender chains to the pinned root or equals an explicitly pinned fingerprint. - Derive the vault id, the read key and, only if
op = write, the write key. Scrub the vault key. - Per-policy limits: use counts, rate, fresh approval for
write. - Audit line with no secrets: time, op, vault id, caller, result.
unwrap response
{ "v": 1, "sealed_to_eph": "<envelope>", "expires_in": 300 }
The envelope's plaintext is {"vault_id", "read_key", "write_key"?, "file_id_key"…}.
Sealing to E
ECDH P-256 (the KMS makes its own ephemeral key K) → HKDF-SHA256 (salt = nonce, info = "sgit-kms/v1" | op | vault_id | E.pub | K.pub) → AES-256-GCM with the request hash as associated data. Response = K.pub ‖ GCM nonce ‖ ciphertext. E exists for milliseconds; a copied response is useless. The same ECDH envelope is needed anyway for hardware keys, which speak P-256.
Single use, in two layers
- E is used once by construction.
- The nonce is used once by the KMS's record: locally in the KMS's memory or its uid's keyring; remotely, a conditional write (e.g. DynamoDB
attribute_not_exists).
Why derived keys, not the vault key
After derivation sgit needs the vault key only for show-key, rekey and move. Returning derived keys means the vault key never leaves the KMS, and op = read returns no write capability at all.
4. Command flows (sgit inside the harness)
sgit clone --sealed handover.png work
agent app fs net kms vault server
│ clone --sealed │ │ │ │ │
│───────────────►│ parse QR JSON, strict schema (handover.md refusal rules) │
│ │ open(m1)────────────────►│ │ │
│ │ │ │ make E (P-256) │ │
│ │ │ │ unwrap{m1,E.pub, │ │
│ │ │ │ nonce,ts,op=rw}─►│ thaw │
│ │ │ │ │ check: to=self, sig, │
│ │ │ │ │ expiry, sender trust,│
│ │ │ │ │ nonce unused │
│ │ │ │ │ open → vault key │
│ │ │ │ │ derive read/write,id │
│ │ │ │ │ scrub vault key │
│ │ │ │◄─sealed_to_E{keys}│ freeze │
│ │ │ │ open with E.priv; drop E │
│ │ │ │ fetch packs ─────────────────────────────►│
│ │ │ │◄──────────────────────────── encrypted ───│
│ │ │ │ decrypt objects │ │
│ │◄─────────── plain file contents ────────────│ │
│ │ write(tree)──►│ apply rules, write files │ │
│ │ write(.sg_vault/local/vault_key.sealed = m1)►│ accepts: sealed format│
│ │ │ │ scrub derived keys; exit │
│◄── "cloned, sealed to sha256:… from sha256:… (chains to root), expires 30 Oct" ─────│
The transcript sees the command, the m1 (it is ciphertext), fingerprints and progress. Never a key.
Read commands (status, pull, ls, cat)
The same flow from "make E", reading vault_key.sealed through .fs and asking the KMS with op=read. The KMS returns only the read key (and the ids), so .net cannot push in this command even if told to.
push
op=write: the KMS returns the read and write keys. Policy may require a fresh approval (a second factor) for op=write, and the KMS is where that check lives.
vault show-key
Refused on a sealed clone. With --reveal it is a policy decision (default: denied in agent sessions). sgit vault seal --to <record fp> --qr out.png replaces every legitimate use: the KMS re-seals to another identity without the key appearing anywhere.
5. Costs (measured)
| Operation | Time |
|---|---|
RSA-4096 OAEP open (cryptography) | 3.2–3.6 ms (the origin article measured 2.1 ms) |
| mutual TLS handshake net → kms | 3.2 ms |
| PBKDF2 derivation (sgit today, 600,000 iterations) | ~108 ms (article figure); the KMS can cache derived keys per vault for the session |
| thaw + freeze the KMS | 0.13 ms + 0.28 ms |
6. Remote KMS: binding the caller
With a remote KMS, whoever holds an m1 and can reach the KMS could ask it to open the key. The m1 sits in the transcript and in vault_key.sealed, so without binding it is a bearer token.
session start every unwrap
───────────── ────────────
sharness.net makes session auth key A (P-256) request signed with A
→ kept in its uid's keyring, kernel timeout KMS: sig verifies with bound A.pub?
init{A.pub, policy_hash} ──► KMS → else refuse
KMS: identity bound to A.pub for this session
A is the one secret that must live in the container; it is never printed and useless after the session. With a platform-provided KMS, A disappears: the platform already knows which session is calling. Alternatives or additions: a grant signed by the person ("session X may unwrap vault V read-only, 20 times, until 18:00"); platform OIDC workload identity.
7. Where the private key lives, and for how long
| Option | How | Measured (as of 24 Oct 2026) | Verdict |
|---|---|---|---|
A. Frozen process + memfd_secret | key made at start into a secret page; cgroup freezer freezes the KMS between calls | freeze 0.28 ms, thaw 0.13 ms; root reading the secret page through /proc/<pid>/mem gets EIO | default |
| B. Kernel keyring, process per call | key in the KMS uid's user keyring with a kernel timeout; a fresh KMS process per call reads it | persists across separate tool calls; another uid gets ENOKEY; not listed in root's /proc/keys | when no process may persist |
C. Kernel holds the key (keyctl pkey_decrypt) | add_key("asymmetric", PKCS#8); userspace never sees the key | not available: kernel built without the PKCS#8 private-key parser (EBADMSG) | the ideal, on a kernel that has it; OAEP would be decoded in userspace from enc=raw |
| D. Checkpoint the process (CRIU) | checkpoint and restore | kernel supports it; criu not installed | rejected without hardware: the image is the memory, so the key lands on disk, and encrypting the image needs another key (circular) |
| E. Remote KMS / HSM / platform | Lambda + cloud KMS, Kubernetes service, TPM, platform service | not testable there | end state |
Memory hygiene (KMS and net)
- Generate keys inside the process; never import a private key from argv, environment or a file.
prctl(PR_SET_DUMPABLE, 0),mlockall,RLIMIT_CORE=0. Under a separate uid this stops every non-root process from reading memory. It does not stop root (measured).- Put opened secrets in
memfd_secretpages and scrub Pythonbytesafter copying. Measured: without scrubbing, one copy remained in the ordinary heap; with scrubbing, a sweep of every readable mapping by root found none. - Caveat: OpenSSL copies the private key into its own heap while using it. The secret-page guarantee is strict for opened vault keys and best effort for the private key during an operation.
8. Key types and fingerprints
- Identity = sgit's existing bundle: RSA-OAEP 4096 encryption + ECDSA P-256 signing; fingerprints are
sha256:+ 16 hex.--totakes the record (signing) fingerprint. sgit pki keygenprints both fingerprints ("Fingerprint" is the encryption key's, "Signing fingerprint" the signing key's).- Hardware identities speak P-256, so they use the ECDH envelope (§3, sealing to E), which is why that envelope is built now.
- Lasting identities (vault keeper, recovery recipient) would be encrypted with Argon2id, not the PBKDF2 at 2,048 iterations that sgit 0.24.0 uses today (measured).
Not claimed
- Expiry of a sealed key is KMS policy, not cryptography. Cryptographic expiry needs
sgit vault rekeyor a server. - Root on the machine can read the KMS's ordinary memory (not its secret pages), and so can take the private key while it is in OpenSSL's heap.
- A session identity dies with the session: a restarted session needs a new handover, unless the key was also sealed to a lasting identity or recovery recipient.
- The platform-provided KMS is a proposal for what platforms should offer, not a description of any existing service.
Source: 02-sealed-key-flow-and-kms.