privateer-agent 0.12.18 → 0.12.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,116 +1,52 @@
1
1
  /**
2
- * The ACI digest and canonical-signing-bytes constructions (§4.1, §4.2, §4.3,
3
- * §4.4, §4.7, §8.5, §9.2). Each returns the exact bytes/strings the spec pins in
4
- * spec/test-vectors.md, so they double as the byte-for-byte reference.
2
+ * The ACI digest constructions (Appendix A, §3.1, §3.2). Artifacts the service builds
3
+ * are hashed as the exact served bytes; the attestation statement is the one
4
+ * report payload a verifier constructs itself, as a fixed byte template whose
5
+ * inputs are restricted so no JSON escaping is ever needed.
5
6
  */
6
7
 
7
8
  import { jcsBytes } from './jcs';
8
- import type { JcsValue } from './jcs';
9
9
  import { sha256Hex, sha256Prefixed } from './crypto';
10
- import type { PublicKey, WorkloadKeyset, Receipt, SessionRecord } from './types';
10
+ import { AciFormatError } from './errors';
11
11
 
12
- /**
13
- * `workload_id` — the stable name of a workload (§4.1):
14
- * `"sha256:" || hex(sha256(JCS(public_key)))`.
15
- */
16
- export async function computeWorkloadId(publicKey: PublicKey): Promise<string> {
17
- return sha256Prefixed(jcsBytes({ algo: publicKey.algo, public_key: publicKey.public_key }));
18
- }
12
+ const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
13
+ const NONCE_RE = /^[0-9a-f]{64}$/;
19
14
 
20
- /**
21
- * `workload_keyset_digest` (§4.2): `"sha256:" || hex(sha256(JCS(keyset)))`,
22
- * over the whole keyset object as given.
23
- */
24
- export async function computeKeysetDigest(keyset: WorkloadKeyset): Promise<string> {
25
- return sha256Prefixed(jcsBytes(keyset as JcsValue));
15
+ /** `workload_keyset_digest` (§3.1): sha256 over the keyset's JCS form. */
16
+ export async function computeKeysetDigest(keyset: unknown): Promise<string> {
17
+ return sha256Prefixed(jcsBytes(keyset));
26
18
  }
27
19
 
28
20
  /**
29
- * The attestation statement (§4.4) whose JCS is hashed into `report_data`.
30
- * `nonce` is the request's decoded value, or JSON `null` when the query
31
- * parameter was omitted (never the string `"null"`); pass `undefined`/`null` for
32
- * the omitted case.
21
+ * The exact attestation-statement bytes (§3.2) for a keyset digest and the
22
+ * nonce the client sent — `null`/`undefined` when the query parameter was
23
+ * omitted, which puts the JSON literal `null` in the template. Inputs outside
24
+ * the spec-pinned formats throw {@link AciFormatError}.
33
25
  */
34
26
  export function attestationStatement(
35
- workloadId: string,
36
- workloadKeysetDigest: string,
27
+ keysetDigest: string,
37
28
  nonce: string | null | undefined,
38
- ): JcsValue {
39
- return {
40
- purpose: 'aci.report_data.v1',
41
- workload_id: workloadId,
42
- workload_keyset_digest: workloadKeysetDigest,
43
- nonce: nonce ?? null,
44
- };
29
+ ): Uint8Array {
30
+ if (!DIGEST_RE.test(keysetDigest)) {
31
+ throw new AciFormatError(`keyset digest is not sha256:<64-hex>: "${keysetDigest}"`);
32
+ }
33
+ if (nonce != null && !NONCE_RE.test(nonce)) {
34
+ throw new AciFormatError('nonce must be exactly 64 lowercase hex characters (§3.2)');
35
+ }
36
+ const noncePart = nonce == null ? 'null' : `"${nonce}"`;
37
+ return new TextEncoder().encode(
38
+ `{"keyset_digest":"${keysetDigest}","nonce":${noncePart},"purpose":"aci.report_data.v1"}`,
39
+ );
45
40
  }
46
41
 
47
42
  /**
48
- * `report_data` (§4.4): `hex(sha256(JCS(attestation_statement)))` — the raw
49
- * 32-byte digest as lowercase hex, with no `sha256:` prefix (it names a bare
50
- * report-data slot, not an ACI digest string).
43
+ * `report_data` (§3.2): SHA-256 of the attestation statement, as bare lowercase
44
+ * hex (it fills a report-data slot, not an ACI digest string). The TEE places
45
+ * these 32 bytes zero-padded to 64 in the quote's report-data field.
51
46
  */
52
47
  export async function computeReportData(
53
- workloadId: string,
54
- workloadKeysetDigest: string,
48
+ keysetDigest: string,
55
49
  nonce: string | null | undefined,
56
50
  ): Promise<string> {
57
- return sha256Hex(jcsBytes(attestationStatement(workloadId, workloadKeysetDigest, nonce)));
58
- }
59
-
60
- /** JCS bytes of the keyset endorsement payload (§4.3), signed by the identity key. */
61
- export function keysetEndorsementPayload(workloadKeysetDigest: string): Uint8Array {
62
- return jcsBytes({
63
- purpose: 'aci.keyset.endorsement.v1',
64
- workload_keyset_digest: workloadKeysetDigest,
65
- });
66
- }
67
-
68
- /** JCS bytes of the keyset revocation payload (§4.7), signed by the identity key. */
69
- export function keysetRevocationPayload(workloadKeysetDigest: string): Uint8Array {
70
- return jcsBytes({
71
- purpose: 'aci.keyset.revocation.v1',
72
- workload_keyset_digest: workloadKeysetDigest,
73
- });
74
- }
75
-
76
- /**
77
- * Canonical bytes a receipt signature covers (§8.5): the JCS of the whole
78
- * receipt with only `signature.value` removed (`algo` and `key_id`, and any
79
- * other signature fields, are retained). Unknown top-level fields and events are
80
- * preserved by canonicalizing the object as given (§3.2).
81
- */
82
- export function receiptSigningBytes(receipt: Receipt): Uint8Array {
83
- const { value: _omitted, ...signatureWithoutValue } = receipt.signature;
84
- const forSigning: JcsValue = {
85
- ...(receipt as unknown as { [k: string]: JcsValue }),
86
- signature: signatureWithoutValue as unknown as JcsValue,
87
- };
88
- return jcsBytes(forSigning);
89
- }
90
-
91
- /**
92
- * The content-addressing material for a session id (§9.2). The wire record omits
93
- * absent optional fields; the material restores `endpoint`, `identity`, and
94
- * `evidence.digest` as JSON `null`, and timestamps / raw evidence bytes are
95
- * excluded entirely.
96
- */
97
- export function sessionMaterial(record: SessionRecord): JcsValue {
98
- return {
99
- upstream_name: record.upstream_name,
100
- endpoint: record.endpoint ?? null,
101
- verifier_id: record.verifier_id,
102
- identity: record.identity ?? null,
103
- channel_binding: record.channel_binding,
104
- claims: record.claims,
105
- evidence_digest: record.evidence?.digest ?? null,
106
- };
107
- }
108
-
109
- /**
110
- * `session_id` (§9.2): `"as_" || hex(sha256(JCS(material)))`. Recomputing this
111
- * from a fetched record and comparing it to the id the signed receipt committed
112
- * to is what makes the session tamper-evident — there is no session signature.
113
- */
114
- export async function computeSessionId(record: SessionRecord): Promise<string> {
115
- return 'as_' + (await sha256Hex(jcsBytes(sessionMaterial(record))));
51
+ return sha256Hex(attestationStatement(keysetDigest, nonce));
116
52
  }
@@ -41,7 +41,9 @@ export async function openE2eeChannel(
41
41
  if (!verification.ok || verification.workloadKeysetDigest !== report.workload_keyset_digest) {
42
42
  throw new Error('openE2eeChannel: report is not verified — call verifyReportBinding and check .ok');
43
43
  }
44
- const keys = (report.attestation.workload_keyset.e2ee_public_keys ?? []) as Array<{
44
+ // Read the keys off the ESTABLISHED keyset — the object whose JCS the
45
+ // verifier hashed to the digest the quote signed — not the report's copy.
46
+ const keys = (verification.keyset?.e2ee_public_keys ?? []) as Array<{
45
47
  algo: string;
46
48
  public_key: string;
47
49
  }>;
@@ -2,8 +2,7 @@
2
2
  * Errors raised by the verifier for conditions that are *not* ordinary
3
3
  * verification failures. A failed check (bad signature, wrong hash) is reported
4
4
  * as `ok: false` in the result objects — never thrown — so callers cannot ignore
5
- * it by forgetting a try/catch. These errors mean "the input is malformed or the
6
- * algorithm is outside this verifier's Level 1 / Web Crypto scope".
5
+ * it by forgetting a try/catch. These errors mean "the input is malformed".
7
6
  */
8
7
 
9
8
  /** Base class for every error this package throws. */
@@ -14,28 +13,10 @@ export class AciError extends Error {
14
13
  }
15
14
  }
16
15
 
17
- /** A JCS input violated the ACI subset (e.g. a non-integer number) or a hex/field value would not parse. */
16
+ /** An input value would not parse (hex, base64, JSON) or violates a spec-pinned format. */
18
17
  export class AciFormatError extends AciError {
19
18
  constructor(message: string) {
20
19
  super(message);
21
20
  this.name = 'AciFormatError';
22
21
  }
23
22
  }
24
-
25
- /**
26
- * A signature or identity algorithm that ACI defines but this Web-Crypto-only
27
- * verifier cannot check. `ecdsa-secp256k1` is the expected case: the curve is
28
- * absent from the Web Crypto API, so verify it against the reference
29
- * implementation or a Level 2 verifier profile instead.
30
- */
31
- export class UnsupportedAlgorithmError extends AciError {
32
- readonly algorithm: string;
33
- constructor(algorithm: string, context: string) {
34
- super(
35
- `unsupported algorithm "${algorithm}" for ${context}: this verifier supports only ed25519 via the Web Crypto API. ` +
36
- `secp256k1 is out of scope — verify it against the reference implementation or a Level 2 profile.`,
37
- );
38
- this.name = 'UnsupportedAlgorithmError';
39
- this.algorithm = algorithm;
40
- }
41
- }
@@ -1,43 +1,39 @@
1
1
  /**
2
- * @dstack/aci-verifier — a zero-dependency ACI Level 1 verifier.
2
+ * @phala/aci-verifier — a zero-dependency ACI verifier for the browser and node.
3
3
  *
4
- * Level 1 (receipt verification, §10.2) is fully implemented against an
5
- * established keyset. {@link verifyReportBinding} adds the cryptographic-binding
6
- * checks of Level 2 (§10.1 checks 2–6); the hardware quote, key custody, and
7
- * provenance checks (§10.1 checks 1, 7–10) are verifier-profile territory and
8
- * out of scope here. All crypto is Web Crypto (Ed25519, SHA-256); `ecdsa-secp256k1`
9
- * is unsupported (not in the Web Crypto API) and raises a clear error.
4
+ * Report binding (§9.1 checks 2–3) establishes the workload keyset: the served
5
+ * keyset canonicalizes to the digest the attestation statement hashes into
6
+ * `report_data`, which the hardware quote signs. Everything downstream — the
7
+ * E2EE key we seal to, receipt signing keys, TLS pins — is a member of that one
8
+ * quote-bound object, so nothing else needs its own signature. Receipt
9
+ * verification (§9.3) runs against an established keyset. All crypto here is
10
+ * Web Crypto (Ed25519, SHA-256/384); the quote itself is ../../phalaSeal.ts.
10
11
  */
11
12
 
12
- // Canonicalization (§3)
13
+ // Canonicalization (Appendix A)
13
14
  export { canonicalize, jcsBytes } from './jcs';
14
15
  export type { JcsValue } from './jcs';
15
16
 
16
17
  // Crypto primitives (Web Crypto only)
17
18
  export {
18
19
  sha256,
20
+ sha384,
19
21
  sha256Hex,
20
22
  sha256Prefixed,
21
23
  verifyEd25519,
22
- verifySignature,
23
24
  toHex,
24
25
  fromHex,
26
+ toBase64,
27
+ fromBase64,
25
28
  } from './crypto';
26
29
 
27
- // Digest & canonical-signing-bytes constructions (§4, §8.5, §9.2)
28
- export {
29
- computeWorkloadId,
30
- computeKeysetDigest,
31
- attestationStatement,
32
- computeReportData,
33
- keysetEndorsementPayload,
34
- keysetRevocationPayload,
35
- receiptSigningBytes,
36
- sessionMaterial,
37
- computeSessionId,
38
- } from './digest';
30
+ // Digest constructions (Appendix A, §3.1, §3.2)
31
+ export { computeKeysetDigest, attestationStatement, computeReportData } from './digest';
32
+
33
+ // Attested sessions: content addressing and evidence (§8, §9.3)
34
+ export { computeSessionId, checkSessionApiVersion, checkSessionEvidence } from './session';
39
35
 
40
- // E2EE AAD builders (§7.3)
36
+ // E2EE v2 AAD builders (spec/e2ee-v2.md §6)
41
37
  export {
42
38
  requestAad,
43
39
  requestAadString,
@@ -46,39 +42,37 @@ export {
46
42
  } from './e2ee';
47
43
  export type { AadCommon } from './e2ee';
48
44
 
49
- // E2EE channel to a verified workload — encrypt requests, decrypt replies (§7)
45
+ // E2EE v2 channel to a verified workload — encrypt requests, decrypt replies
50
46
  export { openE2eeChannel } from './e2ee-channel';
51
47
  export type { E2eeChannel } from './e2ee-channel';
52
48
 
53
- // Level 1 receipt verification (§10.2)
49
+ // Receipt verification (§9.3)
54
50
  export {
55
51
  verifyReceipt,
56
52
  findEvent,
57
53
  hashBody,
58
54
  checkRequestBodyHash,
59
- checkResponseWireHash,
60
- checkResponseCleartextHash,
55
+ checkResponseBodyHash,
61
56
  } from './receipt';
62
57
 
63
- // Level 2 report-binding checks (§10.1 checks 2–6, no hardware quote)
58
+ // Report binding (§9.1 checks 2–3)
64
59
  export { verifyReportBinding } from './report';
65
60
  export type { ReportBindingOptions } from './report';
66
61
 
67
62
  // Errors
68
- export { AciError, AciFormatError, UnsupportedAlgorithmError } from './errors';
63
+ export { AciError, AciFormatError } from './errors';
69
64
 
70
65
  // Wire & result types
71
66
  export type {
72
- PublicKey,
73
- WorkloadIdentity,
74
- ReceiptSigningKey,
67
+ KeysetKey,
68
+ TlsKeyPin,
75
69
  WorkloadKeyset,
76
- ReceiptSignature,
77
- ReceiptEvent,
78
- Receipt,
79
- Endorsement,
70
+ SourceProvenance,
80
71
  Attestation,
81
72
  AttestationReport,
73
+ ReceiptEnvelope,
74
+ ReceiptEvent,
75
+ ReceiptPayload,
82
76
  SessionEvidence,
83
77
  SessionRecord,
84
78
  Check,
@@ -63,7 +63,12 @@ function serializeObject(obj: { [key: string]: JcsValue | undefined }): string {
63
63
  return out + '}';
64
64
  }
65
65
 
66
- /** Canonicalize and encode to UTF-8 bytes — the form fed to SHA-256 and signatures. */
67
- export function jcsBytes(value: JcsValue): Uint8Array {
68
- return new TextEncoder().encode(canonicalize(value));
66
+ /**
67
+ * Canonicalize and encode to UTF-8 bytes — the form fed to SHA-256 and
68
+ * signatures. Takes `unknown` because the values that get canonicalized are
69
+ * parsed server JSON (a keyset, a receipt document); anything outside the ACI
70
+ * subset is rejected by {@link canonicalize} rather than mis-serialized.
71
+ */
72
+ export function jcsBytes(value: unknown): Uint8Array {
73
+ return new TextEncoder().encode(canonicalize(value as JcsValue));
69
74
  }
@@ -1,92 +1,110 @@
1
1
  /**
2
- * Level 1 receipt verification (§10.2 checks 1–2) and helpers for the body-hash
3
- * checks (§10.2 checks 3–4). "Established identity and keyset" means a keyset the
4
- * caller already trusts — from a Level 2 report verification, or published by a
5
- * party the client trusts. The recomputed `workload_id` and keyset digest of
6
- * that keyset are the values the receipt must match.
2
+ * Receipt verification (§7, §9.3). A receipt is one JSON document; its
3
+ * `signature` is Ed25519 over JCS(document minus `signature`) under a key
4
+ * the established keyset lists. "Established" means a keyset whose digest
5
+ * the caller verified — through {@link verifyReportBinding}, or published
6
+ * by a party the client trusts (§9.3).
7
7
  */
8
8
 
9
- import { receiptSigningBytes, computeWorkloadId, computeKeysetDigest } from './digest';
10
- import { verifySignature, sha256Prefixed } from './crypto';
11
- import { fromHex } from './crypto';
12
- import type { Receipt, ReceiptEvent, WorkloadKeyset, Check, ReceiptVerification } from './types';
9
+ import { verifyEd25519, sha256Prefixed, fromHex } from './crypto';
10
+ import { jcsBytes } from './jcs';
11
+ import type {
12
+ Check,
13
+ ReceiptEnvelope,
14
+ ReceiptEvent,
15
+ ReceiptPayload,
16
+ ReceiptVerification,
17
+ WorkloadKeyset,
18
+ } from './types';
13
19
 
14
20
  /**
15
- * Verify a receipt against an established keyset — §10.2 checks 1 and 2:
21
+ * §9.3 checks 1–2: the `signature` member verifies over JCS(document minus
22
+ * `signature`) under the keyset entry `key_id` names, and the document's
23
+ * `workload_keyset_digest` equals the established digest. Documents whose
24
+ * `api_version` is not `aci/1` are rejected (Appendix B).
16
25
  *
17
- * 1. `signature.key_id` names a key in the keyset's `receipt_signing_keys`,
18
- * `signature.algo` matches that key, and the signature verifies over the
19
- * §8.5 canonical bytes under that key.
20
- * 2. The receipt's `workload_id` and `workload_keyset_digest` equal the values
21
- * recomputed from the established keyset (§4.1, §4.2).
22
- *
23
- * Returns a per-check result — a failed check is `ok: false`, never thrown.
24
- * Throws {@link UnsupportedAlgorithmError} only when the signing algorithm is
25
- * outside Web Crypto scope (e.g. `ecdsa-secp256k1`).
26
+ * Returns per-check results plus the document for the body-hash checks; a
27
+ * failed check is `ok: false`, never thrown.
26
28
  */
27
29
  export async function verifyReceipt(
28
- receipt: Receipt,
30
+ document: ReceiptEnvelope,
29
31
  keyset: WorkloadKeyset,
32
+ establishedDigest: string,
30
33
  ): Promise<ReceiptVerification> {
31
34
  const checks: Check[] = [];
32
35
 
33
- const establishedWorkloadId = await computeWorkloadId(keyset.workload_identity.public_key);
34
- const establishedDigest = await computeKeysetDigest(keyset);
35
-
36
- // Check 2: self-described identity matches the established keyset.
37
- checks.push({
38
- name: 'workload_id',
39
- ok: receipt.workload_id === establishedWorkloadId,
40
- ...(receipt.workload_id === establishedWorkloadId
41
- ? {}
42
- : { detail: `receipt ${receipt.workload_id} != established ${establishedWorkloadId}` }),
43
- });
44
- checks.push({
45
- name: 'workload_keyset_digest',
46
- ok: receipt.workload_keyset_digest === establishedDigest,
47
- ...(receipt.workload_keyset_digest === establishedDigest
48
- ? {}
49
- : { detail: `receipt ${receipt.workload_keyset_digest} != established ${establishedDigest}` }),
50
- });
51
-
52
- // Check 1: signature under a named receipt signing key.
53
- const keyEntry = keyset.receipt_signing_keys.find((k) => k.key_id === receipt.signature.key_id);
36
+ // §9.3 check 1: Ed25519 over JCS(document minus `signature`).
37
+ const signingKeys = Array.isArray(keyset.receipt_signing_keys)
38
+ ? keyset.receipt_signing_keys
39
+ : [];
40
+ const keyEntry = signingKeys.find((k) => k.key_id === document.key_id);
54
41
  if (!keyEntry) {
55
42
  checks.push({
56
43
  name: 'signature',
57
44
  ok: false,
58
- detail: `signature.key_id "${receipt.signature.key_id}" not in receipt_signing_keys`,
45
+ detail: `key_id "${document.key_id}" not in receipt_signing_keys`,
59
46
  });
60
- } else if (receipt.signature.algo !== keyEntry.algo) {
61
- // §3.1: the attested key decides the algorithm; the receipt may not override it.
47
+ } else if (keyEntry.algo !== 'ed25519') {
48
+ // Appendix B: ed25519 is the only defined signature algorithm; reject others.
62
49
  checks.push({
63
50
  name: 'signature',
64
51
  ok: false,
65
- detail: `signature.algo "${receipt.signature.algo}" != keyset entry algo "${keyEntry.algo}"`,
52
+ detail: `unsupported signature algo "${keyEntry.algo}"`,
66
53
  });
67
54
  } else {
68
- const message = receiptSigningBytes(receipt);
69
- const ok = await verifySignature(
70
- keyEntry.algo,
71
- fromHex(keyEntry.public_key),
72
- fromHex(receipt.signature.value),
73
- message,
74
- 'receipt signature (§8.5)',
75
- );
76
- checks.push({ name: 'signature', ok, ...(ok ? {} : { detail: 'Ed25519 verification failed' }) });
55
+ const { signature, ...unsigned } = document;
56
+ let ok = false;
57
+ try {
58
+ ok = await verifyEd25519(
59
+ fromHex(keyEntry.public_key),
60
+ fromHex(signature),
61
+ jcsBytes(unsigned),
62
+ );
63
+ } catch {
64
+ // Malformed hex is a failed verification, not a thrown one.
65
+ }
66
+ checks.push({
67
+ name: 'signature',
68
+ ok,
69
+ ...(ok ? {} : { detail: `ed25519 verification failed under "${document.key_id}"` }),
70
+ });
77
71
  }
78
72
 
79
- return { ok: checks.every((c) => c.ok), checks };
73
+ const payload = document as unknown as ReceiptPayload;
74
+ // Appendix B: reject receipts with a foreign api_version.
75
+ const versionOk = payload.api_version === 'aci/1';
76
+ checks.push({
77
+ name: 'api_version',
78
+ ok: versionOk,
79
+ ...(versionOk ? {} : { detail: `api_version "${payload.api_version}" is not "aci/1"` }),
80
+ });
81
+ // §9.3 check 2: the document binds back to the established keyset.
82
+ const ok = payload.workload_keyset_digest === establishedDigest;
83
+ checks.push({
84
+ name: 'workload_keyset_digest',
85
+ ok,
86
+ ...(ok
87
+ ? {}
88
+ : { detail: `document ${payload.workload_keyset_digest} != established ${establishedDigest}` }),
89
+ });
90
+
91
+ return {
92
+ ok: checks.every((c) => c.ok),
93
+ checks,
94
+ payload,
95
+ };
80
96
  }
81
97
 
82
- /** Find the first event of a given type in a receipt's event log. */
83
- export function findEvent(receipt: Receipt, type: string): ReceiptEvent | undefined {
84
- return receipt.event_log.find((e) => e.type === type);
98
+ /** Find the first event of a given type in a receipt payload's event log. */
99
+ export function findEvent(payload: ReceiptPayload, type: string): ReceiptEvent | undefined {
100
+ // Server-supplied JSON: a malformed document is a failed lookup, not a throw.
101
+ if (!Array.isArray(payload.event_log)) return undefined;
102
+ return payload.event_log.find((e) => e.type === type);
85
103
  }
86
104
 
87
105
  /**
88
- * `sha256:<hex>` of raw body bytes — the form ACI body hashes use (§3). Accepts a
89
- * string (UTF-8 encoded) or raw bytes.
106
+ * `sha256:<hex>` of raw body bytes — the form ACI body hashes use (Appendix A). Accepts
107
+ * a string (UTF-8 encoded) or raw bytes.
90
108
  */
91
109
  export async function hashBody(body: Uint8Array | string): Promise<string> {
92
110
  const bytes = typeof body === 'string' ? new TextEncoder().encode(body) : body;
@@ -94,46 +112,36 @@ export async function hashBody(body: Uint8Array | string): Promise<string> {
94
112
  }
95
113
 
96
114
  /**
97
- * §10.2 check 3: the request bytes the client sent match `request.received.body_hash`.
98
- * For E2EE requests, pass the decrypted body as the service observed it (§8.3, §12).
99
- * Returns false when the event or its hash is absent.
115
+ * §9.3 check 3: `request.received.body_hash` matches the plaintext wire body,
116
+ * or the compact post-decryption JSON body for E2EE (§7.4). Returns false when
117
+ * the event or its hash is absent.
100
118
  */
101
119
  export async function checkRequestBodyHash(
102
- receipt: Receipt,
120
+ payload: ReceiptPayload,
103
121
  requestBody: Uint8Array | string,
104
122
  ): Promise<boolean> {
105
- const event = findEvent(receipt, 'request.received');
106
- const expected = event?.body_hash;
107
- if (typeof expected !== 'string') return false;
108
- return (await hashBody(requestBody)) === expected;
123
+ return eventHashMatches(payload, 'request.received', requestBody);
109
124
  }
110
125
 
111
126
  /**
112
- * §10.2 check 4: the response bytes the client received match
113
- * `response.returned.wire_hash` — for a stream, the in-order raw SSE bytes.
114
- * Returns false when the event or its hash is absent.
127
+ * §9.3 check 4: `response.returned.body_hash` matches the response bytes this
128
+ * client received off the wire — the in-order raw SSE bytes for a stream,
129
+ * including encrypted E2EE field values (§7.4). Returns false when the event
130
+ * or its hash is absent.
115
131
  */
116
- export async function checkResponseWireHash(
117
- receipt: Receipt,
132
+ export async function checkResponseBodyHash(
133
+ payload: ReceiptPayload,
118
134
  responseBody: Uint8Array | string,
119
135
  ): Promise<boolean> {
120
- const event = findEvent(receipt, 'response.returned');
121
- const expected = event?.wire_hash;
122
- if (typeof expected !== 'string') return false;
123
- return (await hashBody(responseBody)) === expected;
136
+ return eventHashMatches(payload, 'response.returned', responseBody);
124
137
  }
125
138
 
126
- /**
127
- * For E2EE responses, check the decrypted response bytes match
128
- * `response.returned.cleartext_hash` (§10.2 check 4, §12). Only meaningful when
129
- * the client can reproduce the service's pre-encryption serialization.
130
- */
131
- export async function checkResponseCleartextHash(
132
- receipt: Receipt,
133
- cleartextBody: Uint8Array | string,
139
+ async function eventHashMatches(
140
+ payload: ReceiptPayload,
141
+ type: string,
142
+ body: Uint8Array | string,
134
143
  ): Promise<boolean> {
135
- const event = findEvent(receipt, 'response.returned');
136
- const expected = event?.cleartext_hash;
144
+ const expected = findEvent(payload, type)?.body_hash;
137
145
  if (typeof expected !== 'string') return false;
138
- return (await hashBody(cleartextBody)) === expected;
146
+ return (await hashBody(body)) === expected;
139
147
  }