Key Pairs Held for Their Owners
Live from the API: β¦ keys across both stores, β¦ of key tables. Loading live figuresβ¦
System Overview
How Keys Get There
-
POST /keys/generatewith"store": true - Or automatically, for a signed-in caller whose inline answer would exceed 1 MiB (a Classic McEliece pair is 3.75 MB of base64), if that caller may list and revoke vault keys: the vault keeps nothing its owner could not find and remove. Other callers get the keys inline
- The answer is a
key_id;/encryptand/decrypttake it - Private keys never leave the vault; only the public half can be read
- Callers with no account get the pair inline, as before
Two Stores, One key_id
/keys/import; sealed under the at-rest key
key_id opens the caller's key from either store, checked for owner and algorithm
What Runs
Technical Architecture
Request
key_id on /encrypt, /decryptVault (Rust, pqcrypta-api)
Storage (PostgreSQL)
sealed_keypair = "PQVR" | 0x02 | nonce (12) | AES-256-GCM(data key, nonce, aad, zstd(key pair JSON)) | tag (16)
aad = "PQVR" | 0x02 | key id | owner | algorithm (each length-prefixed)
wrapped_dek = data key sealed with AES-256-GCM under HKDF-SHA256(vault key, salt, "pqcrypta/keys/vault/dek/v2"),
bound to key id and owner
data key, nonce, salts = SP 800-90A CTR_DRBG (AES-256); the data key drawn with prediction resistance
Identity
key_vaultkey_id UUID PRIMARY KEY
owner_id VARCHAR(255)
algorithm VARCHAR(255)
lineage_id UUID
Sealed Material
key_vaultsealed_keypair BYTEA
wrapped_dek BYTEA
plain_bytes BIGINT
record_version SMALLINT
Lifecycle
key_vaultexpires_at / revoked_at TIMESTAMPTZ
rotation_policy, retention_policy
rotated_from / rotated_to / retired_at
shredded_at, shred_reason
Use
key_vaultusage_count, max_usage_count BIGINT
last_used_at TIMESTAMPTZ
access_policy JSONB
keygen_parameters JSONB
Chain
key_vault_access_logseq BIGINT UNIQUE
prev_hash, entry_hash BYTEA
success, error_code
Context
key_vault_access_logaccessed_by, owner_id
ip_address INET, api_key_prefix
duration_us, bytes_processed
Performance, Measured
Storage (live)
Retrieval, Last 7 Days (live)
Every use of a vault key records how long fetching, unwrapping, decrypting, decompressing and parsing it took.
| Engine | Uses | p50 | p95 | Pair |
|---|---|---|---|---|
| Loading⦠| ||||
On-Host Benchmark (live)
The API measures the vault on its own host weekly; loading the latest runβ¦
| Engine | Pair | Store /s | Retrieve /s | Use /s | Retrieve, 8 tasks /s | Retrieve p50 |
|---|
Reference Measurement
Measured 2026-10-02 on Intel(R) Xeon(R) Processor @ 2.10GHz, 4 cores available with PostgreSQL 16 on the same host, 5 iterations per operation, release build (key_vault_bench). Operations per second are one stream's (1000 / mean ms) except the 8-task column. Store seals and inserts a pair with its audit row; retrieve opens it for decryption with its audit row; use is what /encrypt then /decrypt by key_id cost without HTTP: two retrievals plus the engine's own encryption and decryption, which is most of it for the engines with heavy stacks (post-zk-homomorphic, the multi-layer stacks), not the vault.
| Engine | Pair JSON | Stored | Store /s | Retrieve /s | Use /s | Retrieve, 8 tasks /s | Retrieve p50 |
|---|---|---|---|---|---|---|---|
| ai-synthesized-crypto-agile | 17.35 KiB | 11.39 KiB | 307 | 405 | 195.0 | 768 | 2.28 ms |
| classic-mceliece | 1.35 MiB | 1.01 MiB | 61 | 67 | 40.7 | 317 | 13.63 ms |
| classical | 1.83 KiB | 1002 B | 581 | 389 | 172.5 | 1,045 | 2.57 ms |
| entropy-orchestrated | 12.47 KiB | 7.79 KiB | 358 | 334 | 165.5 | 1,103 | 2.91 ms |
| fn-dsa-1024-security | 13.67 KiB | 8.25 KiB | 478 | 393 | 166.3 | 1,142 | 2.52 ms |
| fn-dsa-512-compact | 11.18 KiB | 6.29 KiB | 541 | 421 | 186.6 | 967 | 2.30 ms |
| fn-dsa-dual-signature | 13.32 KiB | 8.26 KiB | 493 | 344 | 1.6 | 1,055 | 2.93 ms |
| fn-dsa-fp-hardened | 12.94 KiB | 8.04 KiB | 500 | 382 | 161.7 | 1,072 | 2.54 ms |
| fn-dsa-transition-stack | 16.70 KiB | 9.96 KiB | 432 | 283 | 124.2 | 989 | 3.22 ms |
| fn-dsa-zk-stack | 134.80 KiB | 84.73 KiB | 223 | 225 | 11.7 | 780 | 4.45 ms |
| frodokem-1344 | 84.96 KiB | 50.32 KiB | 369 | 291 | 26.0 | 1,032 | 3.57 ms |
| frodokem-976 | 61.93 KiB | 22.61 KiB | 377 | 318 | 52.9 | 1,127 | 3.09 ms |
| hqc-128 | 6.97 KiB | 5.17 KiB | 539 | 339 | 63.5 | 1,131 | 2.72 ms |
| hqc-192 | 12.90 KiB | 9.64 KiB | 405 | 310 | 28.7 | 1,107 | 3.17 ms |
| hqc-256 | 20.00 KiB | 7.93 KiB | 479 | 289 | 18.2 | 1,105 | 2.76 ms |
| hybrid | 17.95 KiB | 11.61 KiB | 526 | 363 | 133.2 | 941 | 2.59 ms |
| lattice-code-hybrid | 357.08 KiB | 266.26 KiB | 161 | 157 | 1.5 | 503 | 6.33 ms |
| max-secure-crypto-agile | 13.44 KiB | 8.21 KiB | 527 | 393 | 164.3 | 1,082 | 2.54 ms |
| max-secure-hybrid-transition | 26.42 KiB | 17.96 KiB | 537 | 412 | 167.0 | 969 | 2.38 ms |
| max-secure-lightweight | 8.32 KiB | 4.18 KiB | 478 | 211 | 114.6 | 888 | 4.47 ms |
| max-secure-pqc-zk | 8.61 KiB | 4.59 KiB | 519 | 326 | 1.0 | 1,165 | 2.93 ms |
| max-secure-pure-pq | 7.07 KiB | 3.77 KiB | 574 | 316 | 149.7 | 1,230 | 3.20 ms |
| max-secure-stateless | 8.80 KiB | 4.55 KiB | 477 | 343 | 1.6 | 979 | 2.93 ms |
| ml-kem-1024 | 6.99 KiB | 3.72 KiB | 514 | 363 | 147.4 | 1,215 | 2.68 ms |
| multi-algorithm | 17.55 KiB | 11.48 KiB | 447 | 352 | 1.5 | 1,068 | 2.88 ms |
| multi-kem | 7.78 KiB | 4.13 KiB | 486 | 358 | 1.5 | 1,127 | 2.77 ms |
| multi-kem-triple | 13.97 KiB | 8.80 KiB | 524 | 371 | 1.5 | 1,200 | 2.71 ms |
| post-quantum | 17.34 KiB | 11.26 KiB | 539 | 325 | 148.5 | 1,021 | 2.76 ms |
| post-zk-homomorphic | 15.74 KiB | 9.67 KiB | 345 | 300 | 0.7 | 966 | 3.44 ms |
| pq3-stack | 17.51 KiB | 11.39 KiB | 479 | 359 | 156.7 | 1,045 | 2.65 ms |
| quad-layer | 24.88 KiB | 14.97 KiB | 407 | 302 | 1.6 | 842 | 3.26 ms |
| quantum-lattice-fusion | 97.42 KiB | 58.07 KiB | 378 | 284 | 28.2 | 792 | 3.47 ms |
| quantum-resistant-consensus | 29.15 KiB | 14.92 KiB | 502 | 367 | 159.4 | 825 | 2.66 ms |
| x25519-mlkem768 | 5.59 KiB | 3.03 KiB | 553 | 367 | 155.1 | 1,489 | 2.56 ms |
Security
Access Control
- Owner isolation: another account's key reads as "no such key", and the attempt is logged where its owner sees it
- JSONB access policy: operations, networks (CIDR), API keys (id or prefix) or sessions, time window
- Expiry and usage limits; the use is counted in the same UPDATE that reads the key
- Managing a key (policy, rotation, revocation) stays its owner's, whatever its policy
Tamper-Evident Audit
- Every access and refusal is a row; the log refuses UPDATE, DELETE and TRUNCATE
- The API's database role can only read and append: the log, its roots and its trigger functions belong to another role, so the API cannot disable or replace them, and only a superuser can drop them
- SHA-256 hash chain: each row's hash covers the previous one and all its fields
- Hourly RFC 6962 Merkle roots, HMAC-SHA256'd with a key the database does not hold
- Verification walks it all; owners get inclusion proofs
- Live: β¦
Lifecycle
- Rotation policies: manual, 1, 3, 6 or 12 months, enforced hourly
- A rotated key's id keeps working: encryption uses the lineage's newest key, and ciphertexts name the key that sealed them
- Retention for rotated-out keys: 90 days, 1 year, 3 years or permanent; they decrypt only
- Revocation is final
Cryptographic Erase
- NIST SP 800-88 Rev. 2's method for encrypted storage: the record's data key is destroyed
- Revoked, expired and retention-ended keys erased hourly, 100 per batch
- The row stays (its audit trail keeps a subject); its sealed material is gone
- Limits: old row versions linger until VACUUM; backups taken earlier keep the sealed data key while the vault key exists
π§ Guarded Memory
- Opened records and private keys live in their own mappings: pages locked in RAM (mlock), PROT_NONE guard pages either side, MADV_DONTDUMP
- Read-only (or unreadable) while not being written; a write faults
- A canary before the data, checked on free: an out-of-bounds write aborts the process
- Wiped 0x00, 0xFF, 0x00 (volatile writes, compiler fence), then unlocked and unmapped
- Live: β¦
π² SP 800-90A DRBG
- CTR_DRBG with AES-256, no derivation function: AWS-LC's implementation (the DRBG of its FIPS 140-3 module, linked outside the validated boundary)
- Seeded with 384 bits from getrandom(2); reseeded every 65,536 requests and before every data key (prediction resistance)
- Known-answer test of instantiate, generate and reseed before first use; a failure is a permanent error state
- Draws the vault's data keys, nonces, salts and key ids; key pairs come from each engine's generator over getrandom
- Live: β¦
API Integration
Key Generation
POST /keys/generate
{
"algorithm": "classic-mceliece",
"rotation_policy": "12-month",
"store": true
}
key_id
β
the stored pair's id
Encryption
POST /encrypt
{
"algorithm": "classic-mceliece",
"data": "SGVsbG8...",
"key_id": "β¦"
}
data
β
ciphertext naming its key
Decryption
POST /decrypt
{
"algorithm": "classic-mceliece",
"data": "β¦",
"key_id": "β¦"
}
data
β
the plaintext
Endpoints
Keys
| GET /key-vault/keys | The caller's keys: state, dates, policies, usage, sizes |
| GET /key-vault/keys/{key_id} | One key and its latest audit entries |
| GET β¦/{key_id}/public | The public half (not a counted use) |
| PUT β¦/{key_id}/policy | Access policy, rotation, retention, usage limit, expiry |
| POST β¦/{key_id}/rotate | New pair now; the old one decrypts only |
| POST β¦/{key_id}/revoke | Final; erased now (erase_now) or within the hour |
Audit and Figures
| GET /key-vault/audit | The caller's entries, with chain links and inclusion proofs |
| GET /key-vault/audit/verify | The log walked: hashes, links, roots, MACs (?scope=recent from the latest root) |
| GET /key-vault/stats | Everything live on this page (public) |
Infrastructure
| Database | PostgreSQL 14 or later (exact microsecond time in the chain) |
| Driver | tokio-postgres through deadpool-postgres |
| Connections | β¦ |
| Inline limit | β¦ |
FHE Keys: the Vault's Largest Pairs
A post-zk-homomorphic pair made with its evaluation key carries a seeded TFHE server key of 106 MB (tfhe-rs 1.8 integer, KS32-PBS with ciphertext compression; 281 MB expanded), so /keys/generate/post-zk-homomorphic always keeps it here and answers a key_id. The client key stays sealed in its record for the owner (only /fhe/decrypt and /decrypt read it). Public keys of 8 MiB or more are sealed beside the record under its data key (key_vault_parts; the record's authenticated data names each one's place and length, so it opens only with exactly its parts), and a use reads them only if it needs them: decrypting opens a 61 KB record, not 142 MB. Evaluation needs only public material: /fhe/evaluate asks the vault to authorize each use (owner, state, access policy, usage limit, audit row, the same checks as opening the record) and keeps the expanded server key of the most recently used pair in memory (more with PQCRYPTA_FHE_KEY_CACHE_BYTES), so the server key is read once per key, not per evaluation. Access policies can allow or refuse evaluate separately from decrypt. Before 2026-10-04 the engine made ClientKey-only pairs (16 KB) that could not be evaluated on, and this page described FHE keys that did not exist.