Post-quantum keys, made in the vault
BetaTillSecrets can make key pairs for the NIST post-quantum standards, plus two hybrids that pair a post-quantum algorithm with a classical one. Like every generated secret, the private key is made in the vault and stored there. You get the public key and its fingerprint.
Which key to make
| --type | What it is | Use it for | Public key · signature |
|---|---|---|---|
ml-dsa-65 | ML-DSA-65, FIPS 204 | Signing: releases, tokens, documents | 1,952 B · 3,309 B |
slh-dsa | SLH-DSA, FIPS 205 (hash-based) | Signing where you want the most conservative assumption: firmware, long-lived roots | 32–64 B · 7.8–49.9 KB |
ml-kem-768 | ML-KEM-768, FIPS 203 | Receiving data others encrypt to you | 1,184 B · (ciphertext 1,088 B) |
x-wing | X-Wing: ML-KEM-768 + X25519 | Key encapsulation that stays safe if either half holds | 1,216 B · (ciphertext 1,120 B) |
ml-dsa-65-ed25519 | ML-DSA-65 + Ed25519 hybrid | Signing that stays safe if either half holds | 1,984 B · both signatures |
slh-dsa takes --slh-set: sha2- or shake-, then 128, 192 or 256, then s (smaller signatures, slower signing) or f (faster signing, larger signatures). The default is sha2-128s.
Making one
# Signing
tilldev secrets gen RELEASE_SIGNING_KEY --env prod --type ml-dsa-65
tilldev secrets gen AUDIT_SIGNING_KEY --env prod --type slh-dsa --slh-set sha2-128s
tilldev secrets gen CA_KEY --env prod --type ml-dsa-65-ed25519
# Key encapsulation
tilldev secrets gen INBOX_KEY --env prod --type ml-kem-768
tilldev secrets gen SEAL_KEY --env prod --type x-wing
# Keep the public key in a file
tilldev secrets gen RELEASE_SIGNING_KEY --env prod --type ml-dsa-65 --json | jq -r .generated.publicKey > release.pub.pem✓ Generated RELEASE_SIGNING_KEY → v1 — minted in the vault; the value never touched this machine
type ML-DSA-65 (FIPS 204), signing
fingerprint SHA256:iQFIRzo1M3/F17ZzE1lflfnaau2ZOI6tkLWzcVZ/dls
public key (pem):
-----BEGIN PUBLIC KEY-----
MIIHsjALBglghkgBZQMEAxIDggehAN…
-----END PUBLIC KEY-----
The private half is sealed in the vault. Reveal it only if a consumer truly needs the raw key.In the dashboard, open an environment, choose Generate in vault, and pick a type under Post-quantum. Making a key is a new version of the secret, audited as secret.generate with the public key and fingerprint.
How the keys are stored
| --format | Stored private key | Returned public key |
|---|---|---|
pem | PKCS#8 PEM. ML-DSA and ML-KEM keys hold only their seed, as the standards recommend. | SubjectPublicKeyInfo PEM |
raw | Base64 of the seed: 32 bytes for ML-DSA and X-Wing, 64 for ML-KEM (d then z) and the hybrid, the FIPS 205 secret key for SLH-DSA | Base64 of the raw public key |
pemis the default. X-Wing has no standard PEM form yet, so it israwonly.- The PEM forms follow RFC 9881 (ML-DSA), RFC 9909 (SLH-DSA) and the IETF profile for ML-KEM. OpenSSL 3.5 and later read them, and so does Node.js 24.7 or later.
- The hybrid follows the IETF composite-signature draft for ML-DSA-65 + Ed25519: the ML-DSA seed, then the Ed25519 seed, and a public key in the same order.
- The fingerprint is
SHA256:and the unpadded base64 SHA-256 of the raw public key, the same style asssh-keygen -l.
Using the keys
Signing and decapsulating happen in your code, after it loads the key with tilldev secrets exec, an SDK or a pull at boot. With OpenSSL:
# Anyone with the public key can check a signature (OpenSSL 3.5 or later)
openssl pkeyutl -verify -pubin -inkey release.pub.pem -rawin -in app.tar.gz -sigfile app.tar.gz.sig
# In the job that holds the private key
tilldev secrets exec --env prod -- sh -c \
'printf %s "$RELEASE_SIGNING_KEY" | openssl pkeyutl -sign -inkey /dev/stdin -rawin -in app.tar.gz -out app.tar.gz.sig'
# ML-KEM: a sender encapsulates to the public key; the holder of the private key decapsulates
openssl pkeyutl -encap -pubin -inkey inbox.pub.pem -out ct.bin -secret shared.binWith Node.js:
import { createPrivateKey, createPublicKey, sign, verify, encapsulate, decapsulate } from 'node:crypto'
// ML-DSA-65 or SLH-DSA: sign in the service that pulls the key at boot
const key = createPrivateKey(process.env.RELEASE_SIGNING_KEY!)
const signature = sign(null, artifact, key)
verify(null, artifact, createPublicKey(releasePublicPem), signature) // true
// ML-KEM-768
const { sharedKey, ciphertext } = encapsulate(createPublicKey(inboxPublicPem))
const same = decapsulate(createPrivateKey(process.env.INBOX_KEY!), ciphertext) // equals sharedKeyX-Wing isn't in OpenSSL. @noble/post-quantum implements it, and matches the draft's test vectors:
import { ml_kem768_x25519 } from '@noble/post-quantum/hybrid.js'
// A sender only needs the public key
const { cipherText, sharedSecret } = ml_kem768_x25519.encapsulate(Buffer.from(sealPublicKey, 'base64'))
// The holder stores the 32-byte seed; the key pair is derived from it
const { secretKey } = ml_kem768_x25519.keygen(Buffer.from(process.env.SEAL_KEY!, 'base64'))
const shared = ml_kem768_x25519.decapsulate(cipherText, secretKey) // equals sharedSecretThe ML-DSA-65 + Ed25519 key
A hybrid signature needs both halves to verify, so it holds as long as either ML-DSA or Ed25519 does. Libraries that implement the composite-signature draft can load the key as it is. OpenSSL can't yet, but each half is an ordinary key:
import { createPrivateKey, sign } from 'node:crypto'
// The private key is 64 bytes: the ML-DSA-65 seed, then the Ed25519 seed.
const pem = process.env.CA_KEY!
const der = Buffer.from(pem.replace(/-----[A-Z ]+-----|\s/g, ''), 'base64')
const priv = der.subarray(der.length - 64)
const mldsa = createPrivateKey({
key: Buffer.concat([Buffer.from('3034020100300b060960864801650304031204228020', 'hex'), priv.subarray(0, 32)]),
format: 'der',
type: 'pkcs8',
})
const ed25519 = createPrivateKey({
key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), priv.subarray(32)]),
format: 'der',
type: 'pkcs8',
})
// Sign with both halves; a verifier accepts only if both verify.
const signature = { mldsa: sign(null, message, mldsa), ed25519: sign(null, message, ed25519) }Rotating
tilldev secrets rotate <secret>, or Rotate… on its row, makes a new key pair with the same type, format and parameter set, and shows the new public key. A post-quantum key can also rotate itself when its private key turns up in a push or a TillPulse event. Anything that trusts the old public key needs the new one.
What this does and doesn’t change
- These are keys for your systems. TillSecrets' own storage is unchanged (AES-256-GCM). Its answers to pulls and leases are encrypted to an X-Wing key inside TLS: see Encrypted delivery, and Cryptography for what protects what across TillDev.
- The vault makes and stores the key, but doesn't sign or decapsulate with it. Your code does, so it needs to pull the key.
- Post-quantum keys can't be used with the SSH agent: OpenSSH has no post-quantum signature keys yet.
- The small-signature SLH-DSA sets (the ones ending in
s) take up to a few seconds to make. Every other key takes milliseconds.
API
POST /api/secrets/environments/{envId}/secrets/generate
{"key": "RELEASE_SIGNING_KEY", "type": "ml-dsa-65", "format": "pem"}
{"key": "AUDIT_SIGNING_KEY", "type": "slh-dsa", "slhSet": "shake-128f"}
→ {"secret": {"id": "…", "key": "RELEASE_SIGNING_KEY", "version": 1},
"generated": {"type": "ml-dsa-65", "algorithm": "ml-dsa-65", "format": "pem",
"publicKey": "-----BEGIN PUBLIC KEY-----\n…", "fingerprint": "SHA256:…"}}Owners and admins can generate. See the API reference.