Trustvice está em beta restrito. Ao se cadastrar ou entrar, você precisa informar a senha de beta tester que recebeu ou solicitar uma em preview@trustvice.com. Obrigado.
Por dentro do formato do recibo: JSON canônico, Ed25519 e chaves publicadas
Um tour campo a campo pelo envelope do recibo Trustvice, por que assinamos uma forma canônica RFC 8785 e não os bytes que trafegam, como os key IDs derivam das próprias chaves e o que o conjunto de chaves publicado promete.
Por The Trustvice team··Leitura 6 min
Um recibo tem uma única função: ser conferido, por qualquer pessoa, daqui a anos, com código que não escrevemos. Esse requisito moldou cada decisão do formato. Este post percorre o envelope, a canonicalização, a assinatura e o conjunto de chaves, e explica as escolhas por trás de cada um.
Os recibos neste formato compartilham um envelope; só o body muda de um tipo de recibo para outro. Registros de evidência assinada, como um Proof-of-Erasure, usam um envelope irmão, com subject e claims no lugar de body, sob a mesma canonicalização, assinatura e conjunto de chaves descritos abaixo.
Sempre "1". A versão do formato do recibo, não uma versão de API.
receiptId
Um ULID de 26 caracteres, ordenável pela hora de criação.
issuedAt
Instante ISO 8601 em UTC com milissegundos, definido pela Trustvice ao assinar.
type
O tipo do recibo. body.type é sempre igual a ele.
signatureKeyId
O kid da chave de assinatura no conjunto publicado: 22 caracteres base64url.
body
Os fatos específicos do tipo.
signature
A assinatura Ed25519, em base64url sem padding: 86 caracteres.
Duas propriedades decorrem de como a assinatura é feita, e ambas importam a quem escreve um verificador.
Todo membro exceto signature é assinado. Isso inclui qualquer membro que uma versão futura acrescente. Um verificador não deve descartar membros que não reconhece antes de checar, ou recibos válidos vão falhar. Novos campos opcionais são aditivos dentro da versão 1, então leitores devem ignorar o que não conhecem, mas mantê-lo ao verificar.
O recibo não cita seu workspace nem sua conta. O workspace é o escopo em que você lista e busca recibos, mas não está escrito no envelope assinado. Um recibo é uma declaração autocontida sobre um fato, não prova de quem a pediu.
Um ID de recibo é um ULID: 26 caracteres em base32 de Crockford. Os dez primeiros são um timestamp de 48 bits em milissegundos e os dezesseis restantes são aleatórios. Por isso os IDs se ordenam pela hora de criação.
A hora dentro do ID é gerada a poucos milissegundos de issuedAt. Trate issuedAt como a hora assinada e o ID como identificador.
Uma assinatura cobre bytes, não significado. JSON tem muitas grafias em bytes para o mesmo valor: chaves em qualquer ordem, espaços opcionais, 4 ou 4.0, "é" ou "\u00e9". Se assinássemos os bytes que por acaso enviamos, um proxy que reindenta a resposta, ou uma biblioteca que reordena chaves ao salvar, quebraria um recibo perfeitamente válido.
Por isso a assinatura cobre uma forma canônica, definida pela RFC 8785, o JSON Canonicalization Scheme (JCS). Toda implementação correta transforma o mesmo valor lido nos mesmos bytes. Para um recibo, as regras se resumem a cinco:
Chaves ordenadas em todo nível de aninhamento, por unidade de código UTF-16.
Sem espaços entre tokens.
Números usam a forma número-para-string do ECMAScript: inteiros saem sem ponto decimal nem expoente. Corpos de recibo só contêm inteiros.
Strings usam o escape JSON mínimo; caracteres não ASCII saem como UTF-8 cru, nunca como \uXXXX, e a barra não é escapada.
Arrays mantêm a ordem, e valores null e false são mantidos, não descartados.
A mensagem assinada é a forma JCS do envelope sem o membro signature, codificada em UTF-8. No lado da assinatura, valores que não se representam igual em todo lugar, como NaN, infinitos, undefined ou bigint, são rejeitados em vez de convertidos.
A consequência prática para você: dá para formatar um recibo de forma legível, guardá-lo num banco que reordena chaves ou passá-lo por qualquer ferramenta JSON que preserve valores, e ele continua verificando. O que você não pode é mudar um valor, remover um membro null ou arredondar um número. São exatamente as mudanças que um verificador deve pegar.
A assinatura é Ed25519 puro, como na RFC 8032: sem pré-hash e sem string de contexto. Escolhemos porque é pequeno, rápido, determinístico e implementado nas bibliotecas padrão que as pessoas já têm. Node 20 ou superior, navegadores atuais, Deno, Bun e Cloudflare Workers verificam só com WebCrypto; Python, com o pacote cryptography; OpenSSL 3, pelo shell. Um verificador de recibo Trustvice nunca deveria precisar de uma biblioteca Trustvice.
Uma assinatura tem 64 bytes, que são os 86 caracteres base64url do envelope. Uma chave pública tem 32 bytes.
A Trustvice guarda as chaves privadas em HSMs do AWS KMS, uma chave por região de dados, e elas nunca saem de lá. O assinador envia os bytes canônicos ao KMS e recebe uma assinatura; ele assina, mas não exporta chaves.
O signatureKeyId de um recibo corresponde ao kid de uma chave do conjunto publicado. Esse kid não é um rótulo que escolhemos. Ele é calculado a partir da chave:
text
kid = base64url( first 16 bytes of SHA-256( the 32 raw bytes of x ) )
sem padding, o que dá 22 caracteres. Disso decorrem duas consequências úteis.
Primeiro, uma entrada do conjunto de chaves é autoverificável: qualquer um recalcula o kid a partir de x, e uma entrada em que eles não batem não é válida. Em JavaScript:
Segundo, como o signatureKeyId fica dentro dos bytes assinados, um recibo não pode ser apontado para outra chave sem se invalidar. E como o kid deriva de x, um x diferente para um kid que você já tem não pode ser legítimo.
As chaves públicas formam um JSON Web Key Set em https://trustvice.com/.well-known/receipt-keys.json, que redireciona com HTTP 308 para https://trustvice.com/api/v1/receipts/keys. Ele é servido como JSON, em cache por uma hora e com Access-Control-Allow-Origin: *, para que uma página no seu próprio domínio o busque e verifique recibos no navegador.
Para verificar você só precisa de kid, kty, crv e x. O conjunto pode conter várias chaves, e as chaves aposentadas continuam publicadas. Recibos de um mesmo workspace podem trazer key IDs diferentes com o tempo, então sempre busque a chave por recibo.
O conjunto é montado a partir de configuração publicada, então a verificação nunca depende de uma chamada à AWS. Só campos públicos aparecem nele.
Por padrão, um verificador confia no que o endpoint de chaves devolver no momento da consulta, o que é tão forte quanto aquela conexão TLS. Para evidência que importa, fixe o conjunto: baixe-o uma vez de uma rede confiável, recalcule cada kid a partir de seu x, guarde os pares num lugar que só muda por revisão e verifique contra esse arquivo. Atualize-o periodicamente e revise chaves novas antes de incluí-las.
Guarde o conjunto de chaves usado ao lado de cada recibo que você mantém como evidência. Um recibo mais o conjunto de chaves contra o qual foi verificado é verificável por si só.
Um recibo cita a chave que o assinou, então incluir uma chave no conjunto nunca afeta recibos antigos. Hoje não há rotação automática, e as chaves não expiram. A rotação é uma cerimônia manual e fora de banda, e o conjunto não traz marcador de aposentada ou revogada. A rotação é aditiva: uma chave aposentada segue publicada para que os recibos que ela assinou sigam verificáveis. Isso é uma política, não algo que o endpoint de chaves impõe, e é mais um motivo para manter sua própria cópia fixada.
Também não há lista de revogação. Se uma chave de assinatura fosse comprometida, o remédio seria parar de publicá-la, o que faria todo recibo dela falhar com unknown_key, inclusive os genuínos. Ter sua própria cópia do conjunto de chaves da época em que aceitou um recibo, e registrar o recibo num sistema que você controla, mantém sua evidência utilizável de qualquer forma.