Protocolo
PROTOCOL — o contrato entre o SDK e o cofre
O contrato de fio normativo entre o SDK diagnos e o cofre: identidade, relógio, assinaturas, enrollment, envelopes e erros.
Nesta página
- 0. Convenções
- 1. Identidade
- 2. Relógio
- 3. Assinatura da requisição
- 4. Chaves de sessão e a semente da resposta
- 5. Enrollment
- 6. HybridSeal — X25519 + ML-KEM-768
- 7. Envelope de chave e de conteúdo
- Rótulos congelados
- 8. Documentos versionados
- 9. Arquivos e pastas (nós)
- 10. SSE-C
- 11. Auto-unseal com OpenBao
- 12. Erros
- 13. Limites
English · Português (Brasil)
Normativo. Todo formato abaixo está travado por apps/sdk/tests/vectors/*.json,
gerados a partir da implementação de referência do cofre. Quando este
documento e um vetor discordarem, o vetor vence e este documento tem um bug.
O documento inteiro corresponde a vault.diagnos.health e ao app web como
eles rodam hoje. Questões ainda em aberto do lado do cofre — nenhuma muda um
byte do que o SDK manda — ficam em COMPATIBILITY.pt-BR.md.
0. Convenções#
b64url= RFC 4648 §5 sem padding.hex= minúsculo. JSON é UTF-8. Ids são strings opacas. Tempo:expires_at/*_atnuméricos são segundos Unix (_ms= milissegundos);created_at/updated_at/completed_atem índices e nós são strings ISO-8601.- Base:
https://vault.diagnos.health. Audiência do SDK:/api/external/v1. - Toda resposta é um envelope:
Decida por{ "success": true, "status": "success", "status_code": 200, "result": {…}, "docs": "https://vault.diagnos.health/docs" } { "success": false, "status": "fail", "status_code": 4xx, "errors": [{ "code": "QuotaExceeded", "message": "…", "trace_id": null }], "docs": "…" }code, nunca pormessage.X-Request-Idvem em toda resposta.GET /time(§2) é a única exceção: responde um{"result": …}cru, sem envelope.
1. Identidade#
DIAGNOS_API_TOKEN é apikey-<JWT EdDSA>, emitido por um admin do
workspace. Claims que o SDK lê: sub (id da chave, rotacionável),
account_id, workspace_id, name (slug@<workspace_id>.diagnos.health).
O token também carrega iss, o emissor do cofre — não conferido pelo SDK,
já que o SDK nunca verifica a assinatura do JWT: ele não tem a chave para
isso, e só o cofre a tem. workspace_id é lido do payload só para montar
URLs; não há claim exp, e a revogação é do lado do servidor, aparecendo
como 403 ServiceAccountRevoked na próxima chamada assinada.
Header em toda requisição: Authorization: Bearer apikey-<jwt>.
2. Relógio#
Assinaturas levam um timestamp que o cofre confere dentro de ±120 s do
relógio dele. Antes da primeira requisição assinada: GET /time, sem auth,
sem envelope → {"result": <ms_do_servidor>}. Offset = ms_servidor − ms_local; use a mediana de três idas e voltas. Em 401 SignatureTimestampSkew, ressincronize e tente uma vez.
3. Assinatura da requisição#
Toda requisição exceto GET /time e session/registry (tanto o POST
quanto o GET do poll) leva três headers:
X-Signature-Timestamp: <segundos unix, decimal, relógio do servidor>
X-Signature-Nonce: <≥16 bytes aleatórios, b64url; único por requisição>
X-Signature-Hmac: <hex(HMAC-SHA512(sign_key, canonical))>
canonical são seis campos unidos por \n (0x0A), em UTF-8:
METHOD \n PATH \n QUERY \n TIMESTAMP \n NONCE \n sha256hex(BODY)
METHODmaiúsculo ASCII.PATHexatamente como enviado no fio, percent-encoded, nunca decodificado (/api/external/v1/workspaces/ws%201/patients).QUERYsem?; divida em&, ordene os paresk=vcrus por ponto de código, junte com&; string vazia quando não há query.TIMESTAMPo texto exato do header.NONCEo texto exato do header.- SHA-256 em hex dos bytes crus do corpo; corpo vazio vira o hash da
string vazia (
e3b0c442…b855).
Um par (timestamp, nonce) é de uso único: o cofre responde
409 ReplayDetected a um reenvio idêntico. Retentativas geram um par novo.
Vetor: request_signature.json.
4. Chaves de sessão e a semente da resposta#
Uma sessão é session_id, sign_key (32 B, HMAC) e enc_key (32 B,
AES-256-GCM), válida até expires_at.
Toda resposta a uma requisição assinada leva random_seed no envelope
JSON:
{ "random_seed": { "nonce": "<b64url>", "ciphertext": "<b64url>" }, "result": {…}, … }
random_seed = AES-256-GCM(enc_key, iv = nonce (12 B), AAD =
UTF-8(session_id)) sobre o JSON {"seed": "<32 B b64url>"}. Ela viaja no
corpo, não num header, para ficar fora de log de proxy e de trace de APM,
que registram headers com muito mais frequência que corpos.
O SDK mistura a semente à própria entropia em vez de confiar nela sozinha:
estado = SHA-256(semente)
bloco = SHA-256(os_random(32) ‖ estado ‖ contador) # contador reinicia em 0 a cada semente nova
os_random(32) sempre domina cada bloco, então uma semente de puros zeros
degrada graciosamente para aleatoriedade pura do SO, nunca para algo mais
fraco — a semente aumenta o os.urandom, nunca o substitui. O SDK de
referência abre, lê e decodifica random_seed dentro do enclave de memória
(apps/sdk/native/README.pt-BR.md); ela nunca existe como objeto Python.
O cofre também aceita um campo random_seed opcional, contribuído pelo
cliente, no próprio corpo de uma requisição assinada, para o SDK devolver
entropia. Este SDK ainda não manda um — só consome o do cofre.
Vetor: aes_gcm_envelope.json cobre o AEAD; hybrid_seal.json cobre o selo
que entrega sign_key/enc_key em primeiro lugar (§6).
5. Enrollment#
POST /api/external/v1/workspaces/{workspace_id}/session/registry (só Bearer, sem assinatura)
{ "public_keys": { "x25519": "<32 B b64url>", "mlkem768": "<1184 B b64url>" },
"runtime": { "sdk_name": "diagnos-python", "sdk_version": "0.1.0", "language": "python 3.12",
"os": "linux", "arch": "x86_64", "hostname"?: "…", "user"?: "…",
"container": true, "cloud"?: "aws" } }
→ 201 { "enrollment_id", "code": "123456", "approval_url", "expires_at", "poll_interval_seconds" }
Imprima approval_url e code no terminal. Um admin do workspace abre o
link, lê a descrição do runtime, digita o código e escolhe os security
groups. Faça poll a cada poll_interval_seconds até expires_at:
GET /api/external/v1/workspaces/{workspace_id}/session/registry/{enrollment_id} (só Bearer)
→ { "status": "pending" } | { "status": "denied" }
| { "status": "approved", "approval": { "session_id", "session_expires_at",
"sealed_session": <HybridSeal>, "sealed_group_keys": { "<security_group_id>": <HybridSeal>, … } } }
sealed_sessionabre (AAD =enrollment_id) para o JSON{"session_id","sign_key":"<32 B b64url>","enc_key":"<32 B b64url>","expires_at"}.- Cada
sealed_group_keys[sg]abre (AAD =enrollment_id) para a DEK crua de 32 bytes daquele security group — não JSON, não base64. POST /api/external/v1/session/lock(assinado, sem corpo) encerra a sessão.
Um 404 no poll depois de expires_at (SdkEnrollmentNotFound) significa
o mesmo que a checagem de prazo do próprio cliente: a janela de aprovação
está fechada de qualquer jeito.
6. HybridSeal — X25519 + ML-KEM-768#
A mesma construção sela a sessão (cofre → SDK) e as DEKs de grupo (app web → SDK).
eph = X25519.keygen()
ss1 = X25519(eph.secret, recipient.x25519) 32 B
(kem_ct, ss2) = ML-KEM-768.encaps(recipient.mlkem768) 1088 B, 32 B
encapsulation = eph.public ‖ kem_ct 1120 B
key = HKDF-SHA256(ikm = ss1 ‖ ss2, salt = ∅, info = utf8("imgexam-sdk-hybrid-seal-v1") ‖ encapsulation, L = 32)
nonce = random(12)
ct = AES-256-GCM(key, nonce, plaintext, aad = utf8(aad)) (tag anexada, 16 B)
wire = { "salt": b64url(encapsulation), "nonce": b64url(nonce), "ciphertext": b64url(ct) }
imgexam-sdk-hybrid-seal-v1 é um rótulo congelado — veja
Rótulos congelados.
Abertura: separe salt em eph.public[0:32] e kem_ct[32:], derive
ss1 = X25519(sk.x25519, eph.public) e ss2 = ML-KEM-768.decaps(sk.mlkem768, kem_ct),
mesmo HKDF, decifre com AES-GCM. Os dois segredos precisam estar presentes:
o desenho sobrevive a qualquer uma das primitivas cair sozinha — uma
quebra futura do X25519 por computação quântica ainda precisa do ML-KEM-768
quebrado também, e um defeito na implementação mais nova do ML-KEM ainda
deixa o X25519 de pé.
Vetor: hybrid_seal.json (contém as chaves secretas do destinatário).
7. Envelope de chave e de conteúdo#
EncryptedPayload ({salt, nonce, ciphertext}, tudo b64url) é o envelope
simétrico de chaves e de payloads JSON pequenos.
salt = random(16)
derived = HKDF-SHA256(ikm = wrapping_key, salt = salt, info = utf8(info), L = 32)
nonce = random(12)
ct = AES-256-GCM(derived, nonce, plaintext, aad = ∅) (tag no fim)
wrapKey (o texto claro é uma chave) e encryptContent (o texto claro é
dado) são os mesmos bytes; só o info muda. As strings de info são
versionadas e nunca compartilhadas entre propósitos — a lista completa e
congelada está em Rótulos congelados abaixo.
Objetos guardados (uma versão de documento, uma cabeça de rascunho) nunca
usam uma chave de longa duração direto. Cada objeto ganha a própria chave
de conteúdo, derivada da DEK do documento e do security_context que o
cofre devolve junto de toda URL assinada de upload/download — opaco para o
cliente, amarrado pelo cofre ao endereço real do objeto:
content_key = HKDF-SHA256(ikm = dek, salt = utf8(key_id), info = utf8(security_context.value), L = 32)
key_id = version_id (uma versão confirmada)
= "draft" | "draft:<fluxo>" (a cabeça de rascunho; "draft:<fluxo>" em recurso de vários fluxos)
O corpo do objeto é binário cru — sem JSON, sem base64 — selado com a mesma primitiva do envelope acima:
body = salt(16) ‖ nonce(12) ‖ AES-256-GCM(HKDF-SHA256(content_key, salt, utf8(info)), nonce, plaintext)
info = "imgexam-document-version-v1" (uma versão) | "imgexam-document-draft-v1" (um rascunho)
content_length = len(plaintext) + 44 (declarado antes de selar; o PUT assinado o trava)
Vetores: aes_gcm_envelope.json (HKDF, wrapKey, encryptContent);
document_content.json (a DEK de um documento, a chave de conteúdo, uma
versão selada, um rascunho selado e os dois resumos encrypted_index —
selados pelo próprio código do app web).
Rótulos congelados#
Todo rótulo de HKDF/AAD abaixo mantém o prefixo histórico imgexam- de
propósito. O nome é anterior ao diagnos, mas essas strings são constantes
de fio embutidas em todo ciphertext já gravado — renomear uma tornaria dado
existente ilegível. Nunca reaproveite um rótulo para um significado novo;
uma derivação alterada ganha uma string nova, versionada à parte (-v2).
Código que usa um destes aponta de volta para cá pelo nome — ver
apps/sdk/src/diagnos/crypto/keys.py, content.py, hybrid.py e hkdf.py.
| Rótulo | Propósito | Seção |
|---|---|---|
imgexam-sdk-hybrid-seal-v1 |
info do HKDF do selo híbrido (selagem de sessão e de chave de grupo) |
§6 |
imgexam-patient-dek-v1 |
embrulha a DEK de todo documento (pacientes, exames, modelos) sob a chave do security group — o app web usa este único rótulo para os três | §7, §8 |
imgexam-patient-index-v1 |
sela o encrypted_index de um paciente sob a DEK |
§8 |
imgexam-exam-index-v1 |
sela o encrypted_index de um exame |
§8 |
imgexam-template-index-v1 |
sela o encrypted_index de um modelo de laudo |
§8 |
imgexam-document-version-v1 |
sela o corpo de uma versão confirmada sob a chave de conteúdo dela | §7, §8 |
imgexam-document-draft-v1 |
sela o corpo de uma cabeça de rascunho sob a chave de conteúdo dela | §7, §8 |
imgexam-node-dek-v1 |
embrulha a DEK do próprio nó sob a chave do security group dele | §9 |
imgexam-node-name-v1 |
sela o nome de um nó sob a DEK dele | §9 |
|sse-c-v1 |
sufixo somado a security_context.value para derivar a chave de SSE-C de um nó |
§10 |
8. Documentos versionados#
Pacientes, exames e modelos de laudo compartilham um modelo: um índice
no Firestore que a API expõe (fluxos de versões, a DEK embrulhada, um resumo
selado, meta em claro) e, por versão, um objeto selado no R2 que o SDK lê
e grava por URLs assinadas. Só clientes veem texto claro. A API externa
serve pacientes e exames; modelos existem só no app web.
Fluxos. Um documento tem um ou mais fluxos independentes de versões.
Exames (e modelos) têm um, data. Pacientes têm dois: data (o registro
estruturado) e file (o documento rico do editor web, Lexical + Yjs, não
exposto pelo SDK). Isso decide a forma das rotas de versão: um recurso de
vários fluxos leva /streams/{fluxo}, um de fluxo único não.
Índice (result.document, a mesma forma em toda resposta):
{ "document_id", "workspace_id", "resource": "patients",
"security_group_id": "sg1",
"encrypted_keys": { "sg1": <EncryptedPayload> },
"encrypted_index": <EncryptedPayload>,
"streams": {
"data": { "latest_version_id", "versions": [{ "version_id", "size", "created_at", "created_by" }],
"pending_version_id", "draft"?: { "rev", "size", "updated_at", "updated_by" } },
"file": { … } },
"meta"?: { … }, "created_at", "created_by", "updated_at", "updated_by"?, "is_archived", "is_deleted" }
Um documento pertence a exatamente um security group: compartilhar um paciente com outra equipe é copiá-lo, nunca compartilhar a chave.
Chaves:
dek= 32 bytes aleatórios, um por documento, compartilhado por todo fluxo e versão.encrypted_keys[security_group_id]=wrapKey(group_key, dek, "imgexam-patient-dek-v1")— para todo recurso.- O corpo de cada versão é selado sob a própria chave de conteúdo (§7), com
key_id = version_id; uma cabeça de rascunho comkey_id = "draft"(fluxo único) ou"draft:<fluxo>"(vários fluxos). encrypted_index=encryptContent(dek, utf8(json(resumo)), "imgexam-<recurso>-index-v1"), regravado a cada versão, para listas abrirem sem baixar nenhuma.
Registros (JSON antes de selar; campos ausentes ficam de fora):
patients(fluxodata) —legal_name,display_name,identifiers?: [{ name, value }](cadavalueésecret:v1:…, selado pela rota de dado sensível do cofre; abrir um é auditado),external_id?,birth_date?,biological_sex?(MALE|FEMALE|INTERSEX|UNDEFINED),gender_identity?,race_identity?,email?,phone?,address?: { postal_code?, street?, number?, complement?, district?, city?, state?, country? },internal_notes?: string[],custom_attributes?: object.exams—title?,modality?,exam_date?,report_lexical?(o estado do editor, a fonte da verdade),report_html?(derivado dele),custom_attributes?: object.
Resumos (o texto claro de encrypted_index): pacientes
{ display_name, legal_name, external_id?, birth_date?, tags: string[] } —
nunca documentos de identidade; exames { title?, modality?, exam_date? }.
Datas são instantes ISO 8601 em UTC (Date.toISOString()), truncados na
precisão de anonimização do workspace (month|day|hour|minute|second)
antes de selar.
meta (em claro, o que o próprio cofre lê): pacientes { specialist_ids? } ·
exames { patient_id, modality?, report_status?, published_at?, published_by? } — o app web grava só patient_id; todo dado clínico fica
selado.
Rotas ({base} = /api/external/v1/workspaces/{workspace_id}/{patients|exams},
{s} = /streams/{fluxo} em pacientes, vazio em exames):
GET {base}?limit=&cursor=&security_group_id=&include_deleted=true → { items: [índice…], next_cursor }
POST {base} { security_group_id, encrypted_keys, content_length, encrypted_index, stream: "data", meta? }
→ 201 { document, stream, version_id, security_context: { value, kid },
upload: { url, method: "PUT", headers: { "content-length", … }, client_headers, expires_at } }
PUT upload.url (corpo = objeto selado, content-length exatamente como assinado)
POST {base}/{id}{s}/versions/{version_id}/commit → { document } (idempotente ao reenviar)
GET {base}/{id}?stream=&version_id= → { document, stream, version, security_context, download }
POST {base}/{id}{s}/versions { content_length, encrypted_index?, meta?, expected_latest_version_id? }
→ 201 { staged: true, document, stream, version_id, security_context, upload } depois PUT, depois commit
POST {base}/{id}{s}/versions { is_archived?, is_deleted? } (sem content_length: só patch)
→ 200 { staged: false, document }
GET {base}/{id}{s}/draft → { download, security_context, draft_rev, draft_size, updated_at } | null
PUT {base}/{id}{s}/draft { content_length, draft_rev? } → { upload, security_context, draft_rev } (o autosave do editor web)
Regras:
- Uma mudança é sempre uma versão nova e completa; não existe atualização parcial. Arquivar e apagar são flags ligadas por uma reserva só de patch — sem versão nova, sem upload — e apagar nunca é um apagar de verdade.
- Uma versão pendente por fluxo: uma segunda reserva responde
409 DocumentVersionPendingaté a primeira ser confirmada ou expirar (retente por pouco tempo).expected_latest_version_idresponde409 DocumentVersionMismatchquando outra versão foi confirmada depois. Um commit antes de o objeto ser enviado responde400 DocumentObjectNotFound. - Leitura: a cabeça de rascunho vence quando existe e o
updated_atdela é posterior aocreated_atda versão corrente; senão a versão corrente vence (um commit não apaga o rascunho, só o supera). - O cofre lista headers de SSE-C junto das URLs de documento, mas o app web
não usa SSE-C em documento — nem no
PUTnem noGET— então o SDK manda só ocontent-lengthassinado e lê com umGETsimples. Uma segunda camada que só um lado mandasse tornaria o objeto ilegível para o outro.
9. Arquivos e pastas (nós)#
Todo arquivo (DICOM, imagem, vídeo, PDF) e toda pasta de um workspace é um
nó em {base} = /api/external/v1/workspaces/{workspace_id}/nodes.
Um nó pertence a um security group; ler um precisa só do id — o cofre
autoriza contra o grupo que o próprio nó declara.
Chaves, como o pipeline de upload do app web (@repo/magic-files) as monta:
node_dek = random(32) (uma por nó, arquivo ou pasta)
encrypted_keys = { <security_group_id>: wrapKey(group_key, node_dek, "imgexam-node-dek-v1") }
encrypted_name = encryptContent(node_dek, utf8(nome), "imgexam-node-name-v1")
content_key = HKDF-SHA256(ikm = node_dek, salt = utf8(node_id), info = utf8(security_context.value), L = 32)
A chave de conteúdo é a derivação dos documentos (§7) com
key_id = node_id — o cofre também devolve esse id como version_id, já
que nós não são versionados. O security_context volta na reserva, antes
de o primeiro byte ser selado, e de novo junto de toda URL de download. O
nome é o que quem subiu escolheu: o app web sela um caminho relativo
(exames/2024/IM-0001.dcm), então quem lê nunca deve usá-lo como caminho
local sem tratar.
Corpo (secretstream.json, node_content.json): libsodium
crypto_secretstream_xchacha20poly1305 sob content_key, enquadrado como
header(24) ‖ (len_u32_be ‖ frame)*. O texto claro vai em pedaços de
1 MiB (1048576), todo pedaço cheio como mensagem comum; o resto —
vazio quando o tamanho é múltiplo exato — sempre vai por último, como um
frame TAG_FINAL próprio. Então, para n bytes de texto claro:
size = 24 + (⌊n / 1048576⌋ + 1) × (4 + 17) + n
size é declarado na reserva e o cofre assina o PUT para exatamente essa
quantidade de bytes, então ele é calculado antes de cifrar; o cofre também
cobra o workspace por ele. Um upload multipart corta o fluxo de bytes
enquadrado em offsets fixos (part_size), nunca nas bordas dos frames.
Rotas:
POST {base}/uploads { security_group_id, exam_id?, parent_id?, files: [entrada] (≤ 100) }
entrada = { client_ref (≤ 64 caracteres), encrypted_name, encrypted_keys, size, mime_type? } um arquivo
| { kind: "folder", client_ref, encrypted_name, encrypted_keys } uma pasta
→ 201 { items: [{ client_ref, node_id, version_id, security_context, kind,
mode?: "single" | "multipart", upload?, part_size?, part_count?, upload_id? }] }
PUT upload.url com upload.headers + SSE-C (§10) single: size ≤ 64 MiB
POST {base}/uploads/complete { node_ids (≤ 200) } → { ready: [nó…], missing: [node_id…] }
POST {base}/{node_id}/multipart → { upload_id, part_size (32 MiB), part_count }
POST {base}/{node_id}/multipart/parts { part_numbers (≤ 200) } → { parts: [{ part_number, url, expires_at }] }
PUT part.url com SSE-C (§10); o ETag volta como header da resposta
POST {base}/{node_id}/multipart/complete { parts: [{ part_number, etag }] } → { node }
POST {base}/{node_id}/multipart/abort → { aborted: true }
GET {base}?security_group_id=&exam_id=&parent_id=&include_pending=&limit=&cursor= → { items: [nó…], next_cursor }
GET {base}/{node_id} → { node, security_context, download }
- Reserva idempotente. O cofre deduplica uma reserva por
(workspace, client_ref): reenviar a mesma ref (mesmo chamador, mesmo grupo, ainda pendente) devolve o mesmo nó e cobra uma vez só. Um client sorteia uma ref por arquivo e a reusa quando tenta de novo. - Pastas ficam prontas na hora — não há conteúdo a subir.
parent_idprecisa ser uma pasta pronta do mesmo grupo. - Arquivos pequenos (≤ 64 MiB) sobem cada um num
PUTassinado e são confirmados juntos;missingnomeia os nós cujo objeto nunca chegou. - Arquivos grandes sobem por partes. A reserva em geral já abre o
upload multipart (
upload_id); só quando não abriu o client chama{node_id}/multipart. Um upload abortado deixa o nófailed; uma reserva abandonada expira depois de 6 h. - Leituras.
GET {base}/{node_id}responde404para uma pasta ou um upload inacabado — não há o que baixar. Uma listagem mostra só nós prontos, a menos queinclude_pending=true.
10. SSE-C#
A criptografia do lado do servidor do R2 com chave do cliente é uma segunda
camada em cima da cifragem ponta a ponta da §9; o cofre nunca vê a chave. O
app web a deriva como irmã da chave de conteúdo e a manda no PUT único, em
toda parte de multipart e no GET — um objeto gravado com SSE-C só pode ser
lido com a mesma chave:
sse_c_key = HKDF-SHA256(ikm = node_dek, salt = utf8(node_id), info = utf8(security_context.value ‖ "|sse-c-v1"), L = 32)
x-amz-server-side-encryption-customer-algorithm: AES256
x-amz-server-side-encryption-customer-key: base64(sse_c_key) (base64 padrão, com padding)
x-amz-server-side-encryption-customer-key-md5: base64(MD5(sse_c_key))
upload.headers e download.headers levam os valores que o cofre fixa
(content-length, content-type, o algoritmo) e precisam ser mandados como
vieram; client_headers nomeia os dois cujos valores só o client conhece.
Documentos versionados (§8) não usam SSE-C. As questões ainda em aberto do
lado do cofre sobre esta camada estão em
COMPATIBILITY.pt-BR.md.
11. Auto-unseal com OpenBao#
Opcional. Com OPENBAO_ADDR e OPENBAO_TOKEN definidas, o SDK salva o
estado desbloqueado depois de um enrollment bem-sucedido e o restaura ao
subir, sem humano. Isso move deliberadamente as DEKs de grupo da RAM para o
armazenamento cifrado do OpenBao: quem conseguir ler aquele path lê o
workspace. Restrinja o token ao path.
KV v2 · mount = OPENBAO_MOUNT (padrão "secret"), path OPENBAO_PATH_PREFIX (padrão "diagnos")/{workspace_id}/{account_id}:
{ "v": 1, "enrollment_id", "x25519_secret", "mlkem768_secret", "session_id", "sign_key", "enc_key",
"session_expires_at", "group_keys": { "<sg>": "<32 B b64url>" }, "saved_at" }
Restaure só enquanto session_expires_at − agora > 60 s; senão, faça um
novo enrollment.
OPENBAO_TOKEN_FILE pode nomear um arquivo com o token em vez de
OPENBAO_TOKEN (a forma que orquestradores de container preferem). Escrever
este documento é o único momento em que as chaves do SDK de referência saem
da memória travada: são reveladas em buffers zerados logo depois de a
requisição ser montada.
12. Erros#
code |
HTTP | Exceção do SDK |
|---|---|---|
ValidationError, DocumentTooLarge, DocumentObjectNotFound, DriveBatchTooLarge, DriveDuplicateClientRef, DriveFileTooLarge, DriveInvalidParent, DriveObjectNotFound |
400 | ValidationError |
Unauthorized, SessionNotFound, SignatureInvalid, SignatureMissing |
401 | AuthenticationError (sessão sumiu → refaça o enrollment) |
SignatureTimestampSkew |
401 | ressincroniza o relógio, tenta uma vez, depois AuthenticationError |
QuotaExceeded, BudgetNotProvisioned |
402 | QuotaError |
ServiceAccountRevoked, DocumentAccessDenied, InsufficientPermission, NotAWorkspaceMember, DriveUploadNotOwned |
403 | DiagnosPermissionError |
DocumentNotFound, DocumentVersionNotFound, DriveNodeNotFound, SdkEnrollmentNotFound, NotFound |
404 | NotFoundError |
DocumentVersionPending, DocumentVersionNotPending, DocumentVersionMismatch, DocumentDraftMismatch, ReplayDetected, DriveNodeNotPending, UploadIncomplete |
409 | ConflictError (ReplayDetected é retentado uma vez, com nonce novo; DocumentVersionPending numa reserva é retentado depois de 1,5 s e 3 s) |
RateLimitExceeded |
429 | RateLimitError (respeita Retry-After se vier; senão backoff com jitter, até 3 tentativas) |
RequestBodyTooLarge |
413 | ValidationError |
5xx / InternalServerError, MultipartUploadFailed |
5xx | VaultError (carrega trace_id; uma retentativa depois de um backoff fixo) |
Uma resposta que não é JSON válido (uma página de erro de proxy, um corpo
truncado) não carrega code nenhum; o SDK lança VaultError com o código
sintético InvalidResponse em vez de deixar vazar um erro de parse cru.
SessionExpiredError é um erro puramente local — o SDK não tem sessão viva
para assinar com — e nunca vem de uma resposta do cofre. ProtocolError é
lançado quando uma resposta bem formada quebra o protocolo (um tamanho
assinado que não é o do corpo selado, uma reserva de versão respondida como
só-patch, uma parte no armazenamento sem ETag). UploadIncomplete é lançado
pelo SDK, não pelo cofre: uploads/complete listou um nó em missing,
então o PUT dele nunca chegou — suba aquele arquivo de novo. Um commit que
falha na rede ou com 5xx é reenviado com backoff (0,5 s, 1 s): commits são
idempotentes. Um upload multipart que falha no meio é abortado antes de o
erro ser lançado.
13. Limites#
Corpo de API 1 MiB · versão de documento ≤ 64 MiB · arquivo ≤ 50 GiB · ≤ 100 arquivos por reserva · ≤ 200 ids de nó por confirmação · PUT único ≤ 64 MiB · parte de multipart 32 MiB, ≤ 200 URLs de parte por chamada · página de lista ≤ 200 · um upload pendente expira depois de 6 h, as URLs assinadas dele depois de 1 h.