Skip to main content

POST /api/keys/export

Export key material -- unwrapped, wrapped to an enrolled principal, or re-wrapped to a page-held key.

Exports the keys named by hiddenLinks. This is the only route on this surface that returns key material, and it has three modes, selected by recipient.

Omit recipient -- UNWRAPPED. Absence is not a third kind of recipient: with none, the key server builds no wrap encryptor at all, and each blob comes back as a 4-byte big-endian cipher NID followed by the key and then the IV, in the clear. Treat the whole response as key material.

kind: "cert-principal" -- WRAPPED BY THE KEY SERVER to an enrolled transport principal's certificate. Not usable without that principal's private key.

kind: "ephemeral-ecdh" -- RE-WRAPPED BY THIS GATEWAY to a P-256 public key you send (ADR-0037). The key server cannot do this: a wrapped export authorizes the recipient too, and a page's keypair is not an enrolled principal, so there is nobody to authorize. The Gateway therefore exports unwrapped and re-wraps in its own process, ECDH to HKDF-SHA256 to AES-256-GCM, with a fresh gateway keypair, salt and IV per key.

WARNING: be precise about what the ephemeral mode protects you from. The raw key is in this Gateway's memory for the duration of the wrap. The wrap buys confidentiality against a passive observer on the network or at a terminating proxy, and against the service that brokered your session -- which arranged your access and never sees the material. It is not a property against the Gateway's operator, and it is not protection against an active attacker; that comes from your session's authentication and its DPoP proof.

In ephemeral mode keys[i] and envelopes[i] are parallel arrays keyed by the same index, and keyBlob is an explicit null -- the ciphertext is in the envelope and nowhere else. There are two IVs and they are different values: keys[i].keyIv is the exported key's own CBC IV, which you need to USE the key; envelopes[i].iv is the GCM IV of the wrap, which you need to UNWRAP it. Confusing them fails the tag.

kind: "trusted-server" is refused by name: it is the server-to-server federated export, whose receiving half this Gateway does not route either.

An empty hiddenLinks array is refused. It is never a successful no-op -- the underlying call throws on one -- and an entry decoding to zero bytes names no key and is not a wildcard.

Authentication

Send the session id as Authorization: Bearer <session id>, and an RFC 9449 DPoP proof in the DPoP header if your session is bound to a key. The two are required together, not as alternatives — see Authenticating and Proving you hold your key.

This surface sets no cookie

There is no session cookie on any response and none is read on any request. Do not set credentials: "include" on a browser fetch.

Request

FieldTypeRequiredDescription
hiddenLinksarray of stringyesCanonical base64
recipientcert-principal or ephemeral-ecdhno

recipientcert-principal

FieldTypeRequiredDescription
kind"cert-principal" (constant)yes
systemIdintegeryesRead it from the server. The export resolves the key owner as a (systemId, principalId) pair, so a wrong one yields a misleading "unknown principal".
principalIdinteger or stringyesA non-negative integer, or a DECIMAL STRING -- which is how an id above 2^53 is sent without losing precision.

recipientephemeral-ecdh

FieldTypeRequiredDescription
kind"ephemeral-ecdh" (constant)yes
publicKeystringyesSTANDARD base64 (not base64url) of your 65-byte uncompressed P-256 point, 0x04 || X || Y -- exactly what crypto.subtle.exportKey("raw", pair.publicKey) gives you.

Example

{
"hiddenLinks": [
"..."
],
"recipient": {
"kind": "cert-principal",
"systemId": 0,
"principalId": 0
}
}

Responses

200

The exported keys. Which shape you get depends on recipient; wrapped and recipient in the response say which.

FieldTypeRequiredDescription
format"NATIVE" (constant)yes
wrappedbooleanyes
recipientobject or nullno
keysarray of objectyes
envelopesarray of objectnoEphemeral-ecdh mode only. Parallel to keys by index.
detailstringyes

keys[] object

FieldTypeRequiredDescription
indexintegerno
keyBlobstring or nullnobase64 of the blob. Explicitly null in ephemeral-ecdh mode -- the ciphertext is in the envelope and NEVER here.
blobBytesintegerno
algidinteger or nullnoThe OpenSSL cipher NID.
algorithmstringnoEphemeral mode only.
keyLenintegernoEphemeral mode only. Told to you so that you never need a cipher table.
keyIvstringnoEphemeral mode only. base64 of the KEY's own CBC IV. NOT the GCM IV of the wrap.

envelopes[] object

FieldTypeRequiredDescription
indexintegerno
gatewayPubKeystringnobase64 65-byte point
saltstringnobase64
ivstringnobase64, 12 bytes. The GCM IV of the wrap. NOT keyIv.
ciphertextstringnobase64.
authTagstringnobase64, 16 bytes. SEPARATE from ciphertext; WebCrypto wants them concatenated in that order with tagLength 128.

Example

{
"format": "NATIVE",
"wrapped": true,
"recipient": {},
"keys": [
{
"index": 0,
"keyBlob": "...",
"blobBytes": 0,
"algid": 0,
"algorithm": "...",
"keyLen": 0,
"keyIv": "..."
}
],
"envelopes": [
{
"index": 0,
"gatewayPubKey": "...",
"salt": "...",
"iv": "...",
"ciphertext": "...",
"authTag": "..."
}
],
"detail": "..."
}

400

BAD_REQUEST -- hiddenLinks absent, not an array, empty, or carrying a zero-length entry; or recipient malformed, of an unknown kind, or naming trusted-server. In ephemeral mode a bad recipient.publicKey gives ONE refusal sentence for all four failure modes (not canonical base64, not 65 bytes, not 0x04-prefixed, not on the curve) -- deliberately, because naming which gate a chosen point failed would answer questions about the curve. Check all four.

401

No live session, or a DPoP proof that did not verify.

SESSION_NOT_FOUND -- the credential names no session this Gateway holds. SESSION_EXPIRED -- it aged out. Both carry reauth: true; obtain a new credential rather than retrying.

PROOF_* -- the sender constraint refused. See the DPoP section of this document for the full table; PROOF_MALFORMED is a 400 and PROOF_REPLAY_CACHE_FULL a 503, and the rest are here.

SCOPED_CREDENTIAL_EXPIRED -- a scoped browser credential passed its fixed lifetime. Use does not extend it: an idle timeout would be refreshed by exactly the traffic a stolen handle produces.

403

OBJECT_NOT_IN_CREDENTIAL_SCOPE or ACCESS_DENIED.

500

A fault rather than a refusal.

OP_FAILED is the catch-all: the key server refused in words that match no classified pattern, so the Gateway cannot say more than that the operation did not happen. Some key server refusals that are conceptually a caller error arrive here rather than as a 4xx, because the wire carries no distinguishing code -- where a route can recognise one from its message it maps it, and each such mapping is documented on the operation.

503

A capacity ceiling or a missing deployment prerequisite. retryable says which.

A scope of session or process names the ceiling that was hit, and the response carries Retry-After.

Error handling

Every failure answers JSON carrying at least code and detail. Match on codedetail is written for a human debugging the call and its wording is not part of the contract. See the error model.

StatusMeaning
400BAD_REQUEST -- hiddenLinks absent, not an array, empty, or carrying a zero-length entry; or recipient malformed, of an unknown kind, or naming trusted-server. In ephemeral mode a bad recipient.publicKey gives ONE refusal sentence for all four failure modes (not canonical base64, not 65 bytes, not 0x04-prefixed, not on the curve) -- deliberately, because naming which gate a chosen point failed would answer questions about the curve. Check all four.
401No live session, or a DPoP proof that did not verify. SESSION_NOT_FOUND -- the credential names no session this Gateway holds. SESSION_EXPIRED -- it aged out. Both carry reauth: true; obtain a new credential rather than retrying. PROOF_* -- the sender constraint refused. See the DPoP section of this document for the full table; PROOF_MALFORMED is a 400 and PROOF_REPLAY_CACHE_FULL a 503, and the rest are here. SCOPED_CREDENTIAL_EXPIRED -- a scoped browser credential passed its fixed lifetime. Use does not extend it: an idle timeout would be refreshed by exactly the traffic a stolen handle produces.
403OBJECT_NOT_IN_CREDENTIAL_SCOPE or ACCESS_DENIED.
500A fault rather than a refusal. OP_FAILED is the catch-all: the key server refused in words that match no classified pattern, so the Gateway cannot say more than that the operation did not happen. Some key server refusals that are conceptually a caller error arrive here rather than as a 4xx, because the wire carries no distinguishing code -- where a route can recognise one from its message it maps it, and each such mapping is documented on the operation.
503A capacity ceiling or a missing deployment prerequisite. retryable says which. A scope of session or process names the ceiling that was hit, and the response carries Retry-After.