Trustviceのレシートを確認する最も確実な方法は、ご自身が管理するコードで、ご自身が管理するマシン上で、ご自身で固定した鍵に照らして確認することです。この記事では、その検証ツールをゼロから作ります。最後には、依存関係のない1つのJavaScriptモジュール、それが動くことを証明するテストベクター、そして失敗すべきときに失敗することを証明するケースの一覧が手に入ります。
必要なもの#
Node 20以降、またはcrypto.subtleがEd25519に対応している任意のランタイム:現行のブラウザ、Deno、Bun、Cloudflare Workersはいずれも対象です。インストールするパッケージはありません。Trustviceのアカウントも不要です:以下のテストベクターは、この記事のために生成した使い捨て鍵で署名されています。
ステップ1:テストベクターを保存する#
これをreceipt.jsonとして保存します。その使い捨て鍵で署名したテスト用レシートです。本文はあくまで例示ですが、重要なのは、それが本当に署名されているということです。
{
"version": "1",
"receiptId": "01JTEST0000000000000000000",
"issuedAt": "2026-10-01T00:00:00.000Z",
"type": "example.receipt",
"signatureKeyId": "LfPAIisXFvNmFhD6vBQbBw",
"body": {
"type": "example.receipt",
"contentSha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"contentType": "text/plain",
"byteLength": 4
},
"signature": "w9-IPP1IkiJQYdkfkMd4fQXIJgHw7fLlaCcS_O4dXKv-gOSsaxmMCrVZbmjnHidFmRi-O_9Dt6UHVFssoac_BQ"
}そしてこちらを、対応する鍵セットであるkeys.jsonとして保存します:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "ccD5LEjW7QZxbBMRAWEldWdTDZaQ8BK7OK7zG2ZKgJ0",
"kid": "LfPAIisXFvNmFhD6vBQbBw",
"alg": "EdDSA",
"use": "sig"
}
]
}実際のレシートでは、keys.jsonをhttps://trustvice.com/.well-known/receipt-keys.jsonで公開されている鍵セットに置き換えます。理想的には、ご自身で固定したコピーを使います。手順は変わりません。
ステップ2:正規化する#
署名は、signatureメンバーを除いたレシートの、RFC 8785による正規形を対象にしています。JavaScriptでは、JSON.stringifyがすでにECMAScriptの数値形式と最小限の文字列エスケープを出力します。残るのはオブジェクトのキーを再帰的にUTF-16コード単位で並べ替えることだけで、これはArray.prototype.sortの既定の順序です:
function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]),
);
}
return value;
}
export const jcs = (value) => JSON.stringify(canonicalize(value));テストベクターの場合、正規化された文字列は空白を含まない1行になります:
{"body":{"byteLength":4,"contentSha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08","contentType":"text/plain","type":"example.receipt"},"issuedAt":"2026-10-01T00:00:00.000Z","receiptId":"01JTEST0000000000000000000","signatureKeyId":"LfPAIisXFvNmFhD6vBQbBw","type":"example.receipt","version":"1"}出力がこの文字列と1バイトでも違う場合は、ここで止めて修正してください。以降のすべてのステップは、このバイト列に依存しています。
ステップ3:base64urlをデコードする#
署名と鍵は、パディングなしのbase64urlです。WebCryptoが求めるのはバイト列です:
function base64urlToBytes(text) {
const padded = text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=");
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
}ステップ4:形を確認し、鍵を見つけ、検証する#
いよいよ検証ツール本体です。悪意のある入力に対しても例外を投げることはなく、3つの理由のいずれかを返します。これは検証ページとAPIが使うものと同じです。
export async function verifyReceipt(receipt, keys) {
const body = receipt?.body;
if (
!receipt || receipt.version !== "1" ||
typeof receipt.receiptId !== "string" || typeof receipt.issuedAt !== "string" ||
typeof receipt.type !== "string" || typeof receipt.signatureKeyId !== "string" ||
typeof receipt.signature !== "string" || !body || typeof body !== "object" ||
body.type !== receipt.type
) return { ok: false, reason: "malformed_envelope" };
const key = keys.find((k) => k.kid === receipt.signatureKeyId);
if (!key) return { ok: false, reason: "unknown_key" };
// The signed bytes: every member of the envelope except "signature", canonicalized.
const { signature, ...unsigned } = receipt;
const message = new TextEncoder().encode(jcs(unsigned));
try {
const publicKey = await crypto.subtle.importKey(
"jwk", { kty: key.kty, crv: key.crv, x: key.x }, { name: "Ed25519" }, false, ["verify"],
);
const valid = await crypto.subtle.verify({ name: "Ed25519" }, publicKey, base64urlToBytes(signature), message);
return valid
? { ok: true, signedAt: receipt.issuedAt, signatureKeyId: receipt.signatureKeyId }
: { ok: false, reason: "bad_signature" };
} catch {
return { ok: false, reason: "bad_signature" };
}
}分割代入の行に注目してください。signatureを取り除き、それ以外のすべてを残します。このコードが一度も知らないメンバーも含めてです。これは意図的なものです:signature以外のすべてのメンバーが署名されているため、認識できないメンバーを捨てる検証ツールは、有効なレシートを拒否してしまいます。
3つのスニペットをverify-receipt.mjsにまとめましょう。検証ツールはこれですべてです。
ステップ5:実行する#
小さなコマンドラインのラッパーがファイルを読み込み、有効なレシートなら0、それ以外なら1で終了します。APIが返す{ "receipt": … }のラッパーも受け付けます:
// verify-cli.mjs: node verify-cli.mjs receipt.json [keys.json]
import { readFileSync } from "node:fs";
import { verifyReceipt } from "./verify-receipt.mjs";
const [receiptPath, keysPath] = process.argv.slice(2);
let receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
receipt = receipt?.receipt ?? receipt;
const keySet = keysPath
? JSON.parse(readFileSync(keysPath, "utf8"))
: await (await fetch("https://trustvice.com/.well-known/receipt-keys.json")).json();
const result = await verifyReceipt(receipt, keySet.keys ?? []);
console.log(JSON.stringify(result));
process.exit(result.ok ? 0 : 1);node verify-cli.mjs receipt.json keys.jsonテストベクターに対する出力:
{"ok":true,"signedAt":"2026-10-01T00:00:00.000Z","signatureKeyId":"LfPAIisXFvNmFhD6vBQbBw"}ネットワークから切断して、もう一度実行してください。答えは変わりません。確認のどこにも、当社が必要ないからです。
ステップ6:「いいえ」と言えることを証明する#
常に「有効」としか言わない検証ツールは、ないよりも悪いものです。receipt.jsonを編集して、各ケースを実行してください:
| 変更 | 期待される結果 |
|---|---|
byteLengthを4から5に | bad_signature |
issuedAtまたはreceiptIdの任意の1文字 | bad_signature |
最上位に"note": "x"を追加 | bad_signature(signature以外のすべてのメンバーが署名されているため) |
signatureの最後の1文字 | bad_signature |
signatureKeyIdをAAAAAAAAAAAAAAAAAAAAAAに | unknown_key |
body.typeをtypeとは別の値に | malformed_envelope |
ファイル全体を{"version":"1"}に | malformed_envelope(例外を投げずに) |
これらは自動テストとして、検証ツールの隣に置いておいてください。コストはわずかで、移植で最もよく見かける誤りを捕まえてくれます。
任意:鍵のエントリ自体を確認する#
kidは鍵から導出されます:xの生の32バイトのSHA-256の、先頭16バイトのbase64urlです。テスト鍵の場合、これはLfPAIisXFvNmFhD6vBQbBwになります。これを再計算すれば、鍵ファイル内のエントリが自己整合していることを確認できます:
export async function keyIdOf(x) {
const digest = await crypto.subtle.digest("SHA-256", base64urlToBytes(x));
let bin = "";
for (const b of new Uint8Array(digest).slice(0, 16)) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}Pythonとシェルでの同じ確認#
手順はどこでも同じですが、2つの言語では注意が必要です。
Python。 json.dumpsはJCSではありません:整数値の浮動小数点数を1.0と出力し、指数をゼロ埋めし、既定で非ASCII文字をエスケープします。Pythonへの移植には、キーをUTF-16コード単位で並べ替える小さなRFC 8785シリアライザーが必要で、検証にはcryptographyパッケージのEd25519PublicKeyを使えます。
シェル。 jqとOpenSSL 3があれば、jq -S -c 'del(.signature)'でレシートの正規化されたバイト列が得られます。レシートの本文には、文字列、整数、真偽値、null、配列、オブジェクトしか含まれないからです。汎用のJCS実装ではありませんが、レシートに対しては一致するか失敗するかのどちらかで、通すべきでないものを通すことは決してありません。公開鍵は、生の32バイトの前に固定の12バイトのヘッダーを付けることでDERのSubjectPublicKeyInfoに包み、あとはopenssl pkeyutl -verify -rawinが処理します。macOSに付属するLibreSSLではこれができないため、OpenSSL 3をインストールしてください。
移植でよく見かける誤り#
- 正規形ではなく、整形されたテキストを検証してしまう。
- 型付きモデルにパースして
nullのメンバーを失う、または読み込み時に数値を丸めてしまう。 - 検証ツールが認識しないメンバーを捨ててしまう。
/や非ASCII文字をエスケープするJSONライブラリを使う。- 素のレシートが求められる場所に、APIのラッパー
{ "receipt": … }を貼り付けてしまう。
どれも対処法は同じです:汎用のツリーにパースし、signatureを取り除き、残りを正規化して、検証する。
有効という結果が意味すること#
有効な署名は、出所、完全性、そして署名された時刻を証明します:Trustviceの署名鍵の保持者がまさにこのバイト列に署名し、それ以降何も変わっておらず、TrustviceがissuedAtの時刻を主張している、ということです。お客様が提供した事実が真実であることは証明せず、お客様のコンテンツも含みません。文書がレシートと一致することを示すには、その文書をご自身でハッシュ化して比較してください。こうした境界についてはレシートが証明すること、証明しないことで扱っています。
コードを書かずに手早く確認するには、任意のレシートを/verifyに貼り付けてください。同じ手順がお客様のブラウザ内で実行され、レシートが当社に送られることはありません。