Auth: workspace token. Scope: export on both routes.
Neither route returns plaintext key material. Both return a blob encrypted to an X25519 public key your operator registered by hand.
Requesting the export scope without a registered key fails at mint time
with 403 and export_disabled — you cannot hold a token these routes accept.
POST /v1/accounts/{slug}/export
Export an account’s mnemonic. HD accounts only. Returns 200.
{
"kind": "mnemonic",
"account": "treasury-a1b2",
"sealed": {
"alg": "x25519-hkdf-sha256-aes256gcm",
"recipientFingerprint": "9f86d081884c7d65",
"ephemeralPublicKey": "base64…",
"nonce": "base64…",
"ciphertext": "base64…",
"tag": "base64…"
}
}A PK account has no mnemonic — returns 422 with unsupported_for_kind.
POST /v1/accounts/{slug}/wallets/{id}/export
Export one wallet’s private key. Returns 200.
Query parameters
| Parameter | Required | Values |
|---|---|---|
vm |
yes | evm or svm |
Any other value returns 400 with invalid_parameter. A VM the wallet has no
address for returns 422 with unsupported_for_kind. The response echoes the
selection back:
{
"kind": "privateKey",
"account": "treasury-a1b2",
"walletId": 1,
"vm": "evm",
"sealed": { }
}The sealed blob
| Field | Meaning |
|---|---|
alg |
Sealing algorithm identifier. Currently always x25519-hkdf-sha256-aes256gcm |
recipientFingerprint |
First 16 hex characters of SHA-256 over the recipient’s raw public key |
ephemeralPublicKey |
Base64 raw X25519 public key of a single-use sender key |
nonce |
Base64 AES-GCM nonce, 12 bytes |
ciphertext |
Base64 sealed payload. Decrypts to UTF-8 |
tag |
Base64 GCM authentication tag, 16 bytes. Kept separate, not appended to ciphertext |
Check recipientFingerprint against the key you expect before attempting
decryption — a mismatch means the blob was sealed for someone else.
Opening it requires the corresponding X25519 private key, which the service never sees and never holds.
Decrypting a sealed blob
The scheme is ephemeral-static ECDH over X25519, HKDF-SHA256 to derive an AES-256-GCM key, with the sender’s public key bound as additional authenticated data. Every parameter you need is below — the blob carries no others, and none are negotiable.
| Step | Parameter | Value |
|---|---|---|
| 1 | Sender public key | ephemeralPublicKey, base64-decoded — 32 raw bytes |
| 2 | Shared secret | X25519(your private key, sender public key) |
| 3 | HKDF hash | SHA-256 |
| 3 | HKDF salt | sender public key ‖ your public key — both raw, 64 bytes, in that order |
| 3 | HKDF info | the ASCII string tee-docker:export:x25519-hkdf-sha256-aes256gcm |
| 3 | HKDF output | 32 bytes — the AES key |
| 4 | Cipher | AES-256-GCM |
| 4 | Nonce | nonce, base64-decoded — 12 bytes |
| 4 | AAD | the sender public key again — the same 32 raw bytes from step 1 |
| 4 | Auth tag | tag, base64-decoded — 16 bytes |
| 5 | Plaintext | UTF-8 |
All base64 in the blob is standard, with padding — not base64url.
import { createDecipheriv, createPublicKey, diffieHellman, hkdfSync } from "node:crypto";
// DER prefix for a raw X25519 public key, so the 32 bytes can be imported.
const X25519_SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex");
export function unseal(sealed, recipientPrivateKey) {
const senderRaw = Buffer.from(sealed.ephemeralPublicKey, "base64");
const senderKey = createPublicKey({
key: Buffer.concat([X25519_SPKI_PREFIX, senderRaw]),
format: "der",
type: "spki",
});
// Your own public key, raw — the second half of the HKDF salt.
const recipientRaw = createPublicKey(recipientPrivateKey)
.export({ format: "der", type: "spki" })
.subarray(-32);
const shared = diffieHellman({ privateKey: recipientPrivateKey, publicKey: senderKey });
// Bind the info string to the constant, not to `sealed.alg` — the blob is
// input, and reading a key-derivation parameter out of it lets a sender
// choose your key schedule.
const aesKey = Buffer.from(
hkdfSync(
"sha256",
shared,
Buffer.concat([senderRaw, recipientRaw]),
"tee-docker:export:x25519-hkdf-sha256-aes256gcm",
32,
),
);
const decipher = createDecipheriv("aes-256-gcm", aesKey, Buffer.from(sealed.nonce, "base64"));
decipher.setAAD(senderRaw);
decipher.setAuthTag(Buffer.from(sealed.tag, "base64"));
return Buffer.concat([
decipher.update(Buffer.from(sealed.ciphertext, "base64")),
decipher.final(),
]).toString("utf8");
}Check alg before decrypting and refuse anything you do not recognise. A new
algorithm identifier means a new procedure, not a variation on this one.
Auditing
Every attempt that reaches the handler is logged — ATTEMPT, then SUCCESS
or FAILURE — with the tenant, workspace, account, and target kind, plus the
wallet id and VM for private-key exports. Failures are logged as loudly as
successes.
A refusal for a missing export scope is recorded too, as a fourth outcome:
DENIED. It carries the tenant, workspace, and target kind, plus the scope the
route required — but not the account slug, wallet id, or vm. At the moment
a scope check refuses, those are still unvalidated caller input, so they are
deliberately kept out of the record. A denial tells you someone reached for key
export and was turned away; it does not tell you which account they named.
The other pre-handler rejections leave no record at all: session_expired is
refused by the token guard, and invalid_slug and invalid_parameter are
refused during argument parsing.
Errors
| Status | Code |
|---|---|
| 400 | invalid_slug, invalid_parameter |
| 401 | session_expired |
| 403 | scope_denied, export_disabled |
| 404 | account_not_found |
| 422 | unsupported_for_kind |
| 423 | account_locked |
Related
- Export key material — the task walkthrough
- Scopes and permissions — why export is gated apart