レシートの役割はただ1つです:何年も先に、誰であっても、当社が書いたのではないコードで確認できること。この要件が、形式に関するあらゆる判断を形作りました。この記事では、エンベロープ、正規化、署名、鍵セットを順に取り上げ、それぞれの選択の背景をご説明します。
1つのエンベロープ#
この形式のレシートは1つのエンベロープを共有し、レシートの種類ごとに違うのはbodyだけです。Proof-of-Erasureのような署名付きエビデンスの記録は、bodyの代わりにsubjectとclaimsを持つ兄弟のエンベロープを使いますが、正規化、署名、鍵セットは以下で説明するものと同じです。
{
"version": "1",
"receiptId": "01K8QZ3M5V0X7R2N9T4W6YBCDE",
"issuedAt": "2026-10-24T14:03:21.482Z",
"type": "example.receipt",
"signatureKeyId": "7sXkP2nVdQ4yTb1LhWc9Ew",
"body": {
"type": "example.receipt",
"contentSha256": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
"contentType": "text/plain",
"sizeBytes": 5
},
"signature": "base64url-ed25519-signature"
}各メンバーを簡単に説明します:
| メンバー | 内容 |
|---|---|
version | 常に"1"。APIのバージョンではなく、レシート形式のバージョンです。 |
receiptId | 26文字のULIDで、作成時刻順に並べ替えられます。 |
issuedAt | ミリ秒付きのUTCによるISO 8601の時刻で、署名時にTrustviceが設定します。 |
type | レシートの種類。body.typeは常にこれと等しくなります。 |
signatureKeyId | 公開鍵セット内の署名鍵のkid:base64urlで22文字です。 |
body | 種類固有の事実。 |
signature | Ed25519署名で、パディングなしのbase64url:86文字です。 |
署名の作られ方から2つの性質が導かれ、どちらも検証ツールを書くすべての人にとって重要です。
signature以外のすべてのメンバーが署名されています。 これには、将来のバージョンで追加されるあらゆるメンバーも含まれます。検証ツールは、確認の前に認識できないメンバーを捨ててはなりません。捨てれば、有効なレシートが失敗します。バージョン1の範囲内では新しい任意フィールドは追加のみで行われるため、読み手は知らないものを無視すべきですが、検証の際にはそれを残しておく必要があります。
レシートは、お客様のワークスペースやアカウントを示しません。 ワークスペースはレシートを一覧・取得する際のスコープですが、署名されたエンベロープには書き込まれません。レシートは、ある事実についての自己完結した記述であり、誰がそれを求めたかの証明ではありません。
レシートIDはULID#
レシートIDはULIDです:CrockfordのBase32による26文字です。最初の10文字は48ビットのミリ秒単位のタイムスタンプで、残りの16文字はランダムです。そのためIDは作成時刻順に並びます。
ID内の時刻は、issuedAtから数ミリ秒ずれて生成されます。issuedAtを署名された時刻として、IDを識別子として扱ってください。
なぜ正規化JSONなのか#
署名が対象にするのはバイト列であり、意味ではありません。JSONには、同じ値をバイトレベルで表す書き方がいくつもあります:キーの順序は自由、空白は任意、4でも4.0でもよく、"é"でも"\u00e9"でも構いません。たまたま送ったバイト列に署名していたら、応答のインデントを付け直すプロキシや、保存時にキーを並べ替えるライブラリによって、まったく有効なレシートが壊れてしまいます。
そこで署名は、RFC 8785のJSON Canonicalization Scheme(JCS)で定義される正規形を対象にしています。正しい実装であればどれも、同じパース済みの値を同じバイト列に変換します。レシートについては、規則は次の5つに集約されます:
- キーを並べ替える。入れ子のすべての階層で、UTF-16コード単位の順に。
- トークンの間に空白を入れない。
- 数値はECMAScriptの数値から文字列への変換形式を使う:整数は小数点も指数も付けずに出力されます。レシートの本文に含まれるのは整数だけです。
- 文字列は最小限のJSONエスケープを使う。非ASCII文字は
\uXXXXではなく生のUTF-8として出力し、スラッシュはエスケープしません。 - 配列は順序を保つ。
nullとfalseの値は捨てずに残します。
署名されるメッセージは、signatureメンバーを取り除いたエンベロープのJCS形式を、UTF-8でエンコードしたものです。署名する側では、NaN、無限大、undefined、bigintのように、どこでも同一に表現できない値は、変換されずに拒否されます。
お客様にとっての実際的な帰結はこうです:レシートを整形して表示しても、キーを並べ替えるデータベースに保存しても、値を保持する任意のJSONツールに通しても、それは引き続き検証できます。できないのは、値を変えること、nullのメンバーを取り除くこと、数値を丸めることです。これらはまさに、検証ツールが捕まえるべき変更です。
なぜEd25519なのか#
署名は、RFC 8032で規定された純粋なEd25519です:事前ハッシュもコンテキスト文字列もありません。小さく、高速で、決定的であり、人々がすでに持っている標準ライブラリに実装されているという理由で選びました。Node 20以降、現行のブラウザ、Deno、Bun、Cloudflare WorkersはWebCryptoだけで検証でき、Pythonはcryptographyパッケージで、OpenSSL 3はシェルから検証できます。Trustviceのレシートの検証ツールが、Trustviceのライブラリを必要とすることはあってはなりません。
署名は64バイトで、これがエンベロープ内のbase64urlの86文字です。公開鍵は32バイトです。
TrustviceはAWS KMSのハードウェアセキュリティモジュールで秘密鍵を保管し(データリージョンごとに1つの鍵)、鍵がそこから出ることはありません。署名処理は正規化されたバイト列をKMSに送り、署名を受け取ります。署名はできても、鍵をエクスポートすることはできません。
鍵IDは割り当てではなく導出される#
レシート内のsignatureKeyIdは、公開鍵セット内のある鍵のkidと一致します。そのkidは、当社が選んだラベルではありません。鍵から計算されます:
kid = base64url( first 16 bytes of SHA-256( the 32 raw bytes of x ) )パディングを付けないので、22文字になります。ここから2つの有用な帰結が導かれます。
第一に、鍵セットのエントリは自己検証可能です:誰でもxからkidを再計算でき、それが一致しないエントリは有効なエントリではありません。JavaScriptでは:
const raw = Uint8Array.from(atob(x.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(x.length / 4) * 4, "=")), (c) => c.charCodeAt(0));
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", raw)).slice(0, 16);
const kid = btoa(String.fromCharCode(...digest)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");第二に、signatureKeyIdは署名されたバイト列の内側にあるため、レシートを無効にすることなく、別の鍵を指すように変えることはできません。そしてkidはxから導出されるため、すでに手元にあるkidに対してxが変わっていれば、それが正当であることはありえません。
公開鍵セット#
公開鍵は、https://trustvice.com/.well-known/receipt-keys.jsonにあるJSON Web Key Setで、HTTP 308によりhttps://trustvice.com/api/v1/receipts/keysにリダイレクトされます。JSONとして配信され、1時間キャッシュ可能で、Access-Control-Allow-Origin: *が付いているため、お客様自身のドメイン上のページからも取得して、ブラウザ内でレシートを検証できます。
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "…",
"kid": "…",
"alg": "EdDSA",
"use": "sig"
}
]
}検証に必要なのはkid、kty、crv、xだけです。このセットには複数の鍵が含まれることがあり、廃止された鍵も公開され続けます。1つのワークスペースのレシートでも、時期によって異なる鍵IDを持つことがあるため、鍵は必ずレシートごとに調べてください。
鍵セットは公開された設定から構築されるため、検証がAWSへの呼び出しに依存することは決してありません。そこに現れるのは公開フィールドだけです。
信頼する鍵を固定する#
既定では、検証ツールは、問い合わせた時点で鍵エンドポイントが返すものを何でも信頼します。その強さは、そのTLS接続の強さにとどまります。重要な証拠のためには、鍵セットを固定してください:信頼できるネットワークから一度ダウンロードし、各kidをそのxから再計算し、そのペアをレビューを経なければ変わらない場所に保存して、そのファイルに照らして検証します。定期的に更新し、新しい鍵は追加する前にレビューしてください。
証拠として保管するレシートの隣には、使用した鍵セットも保存してください。レシートと、それを検証した鍵セットがあれば、それだけで検証できます。
ローテーションについて、率直に#
レシートは署名した鍵を示しているため、鍵セットに鍵を追加しても、古いレシートが影響を受けることはありません。現時点では、自動ローテーションも鍵の有効期限もありません。ローテーションは手動の、帯域外で行う手続きであり、鍵セットには廃止や失効を示す印もありません。ローテーションは追加のみで行われます。廃止された鍵も、それが署名したレシートが検証可能であり続けるよう、公開され続けます。これはポリシーであって、鍵エンドポイントが強制するものではありません。ご自身で固定したコピーを持っておくべき理由が、ここにもあります。
失効リストもありません。万一署名鍵が侵害された場合、対処はその公開をやめることになり、その鍵で署名されたすべてのレシートが、本物も含めてunknown_keyで失敗するようになります。レシートを受け入れた時点の鍵セットのコピーを自ら保持し、ご自身が管理するシステムにレシートを記録しておけば、いずれにしても証拠を使える状態に保てます。
次に読むもの#
- Receiptsのアクションでは、レシート向けのAgentic APIアクションを一覧にしています。
- レシートの検証では、同じ確認をお使いのブラウザ内で実行し、判定のために当社へ何かが送られることはありません。
- レシートをオフラインで検証するでは、そのベクターから動作する検証ツールを一歩ずつ作ります。
