TILLSECRETS · POST-QUANTUM

Post-quantum keys, made in the vault

Beta

TillSecrets 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.

01Types

Which key to make

--typeWhat it isUse it forPublic key · signature
ml-dsa-65ML-DSA-65, FIPS 204Signing: releases, tokens, documents1,952 B · 3,309 B
slh-dsaSLH-DSA, FIPS 205 (hash-based)Signing where you want the most conservative assumption: firmware, long-lived roots32–64 B · 7.8–49.9 KB
ml-kem-768ML-KEM-768, FIPS 203Receiving data others encrypt to you1,184 B · (ciphertext 1,088 B)
x-wingX-Wing: ML-KEM-768 + X25519Key encapsulation that stays safe if either half holds1,216 B · (ciphertext 1,120 B)
ml-dsa-65-ed25519ML-DSA-65 + Ed25519 hybridSigning that stays safe if either half holds1,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.

02Make

Making one

terminal
# 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
output
✓ 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.

03Formats

How the keys are stored

--formatStored private keyReturned public key
pemPKCS#8 PEM. ML-DSA and ML-KEM keys hold only their seed, as the standards recommend.SubjectPublicKeyInfo PEM
rawBase64 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-DSABase64 of the raw public key
  • pem is the default. X-Wing has no standard PEM form yet, so it is raw only.
  • 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 as ssh-keygen -l.
04Use

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:

terminal
# 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.bin

With Node.js:

sign.ts
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 sharedKey

X-Wing isn't in OpenSSL. @noble/post-quantum implements it, and matches the draft's test vectors:

seal.ts
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 sharedSecret
05Hybrid

The 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:

hybrid.ts
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) }
The draft isn’t final
The composite-signature draft has an assigned algorithm identifier but isn't an RFC yet. If you want your signatures to follow the draft's exact construction, use a library that implements it rather than the two separate signatures above.
06Rotate

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.

07Limits

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.
08API

API

request
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.