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.
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
| Field | Type | Required | Description |
|---|---|---|---|
hiddenLinks | array of string | yes | Canonical base64 |
recipient | cert-principal or ephemeral-ecdh | no |
recipient — cert-principal
| Field | Type | Required | Description |
|---|---|---|---|
kind | "cert-principal" (constant) | yes | |
systemId | integer | yes | Read it from the server. The export resolves the key owner as a (systemId, principalId) pair, so a wrong one yields a misleading "unknown principal". |
principalId | integer or string | yes | A non-negative integer, or a DECIMAL STRING -- which is how an id above 2^53 is sent without losing precision. |
recipient — ephemeral-ecdh
| Field | Type | Required | Description |
|---|---|---|---|
kind | "ephemeral-ecdh" (constant) | yes | |
publicKey | string | yes | STANDARD 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.
| Field | Type | Required | Description |
|---|---|---|---|
format | "NATIVE" (constant) | yes | |
wrapped | boolean | yes | |
recipient | object or null | no | |
keys | array of object | yes | |
envelopes | array of object | no | Ephemeral-ecdh mode only. Parallel to keys by index. |
detail | string | yes |
keys[] object
| Field | Type | Required | Description |
|---|---|---|---|
index | integer | no | |
keyBlob | string or null | no | base64 of the blob. Explicitly null in ephemeral-ecdh mode -- the ciphertext is in the envelope and NEVER here. |
blobBytes | integer | no | |
algid | integer or null | no | The OpenSSL cipher NID. |
algorithm | string | no | Ephemeral mode only. |
keyLen | integer | no | Ephemeral mode only. Told to you so that you never need a cipher table. |
keyIv | string | no | Ephemeral mode only. base64 of the KEY's own CBC IV. NOT the GCM IV of the wrap. |
envelopes[] object
| Field | Type | Required | Description |
|---|---|---|---|
index | integer | no | |
gatewayPubKey | string | no | base64 65-byte point |
salt | string | no | base64 |
iv | string | no | base64, 12 bytes. The GCM IV of the wrap. NOT keyIv. |
ciphertext | string | no | base64. |
authTag | string | no | base64, 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 code — detail is written for a human debugging the call and its wording is not part of the contract. See the error model.
| Status | Meaning |
|---|---|
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. |