Skip to content

Export a mnemonic or private key, sealed

Sealed mnemonic and private-key export.

Updated View as Markdown

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
Navigation

Type to search…

↑↓ navigate↵ selectEsc close