privateer-agent 0.12.19 → 0.12.21

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,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 E2EE7.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
  }
@@ -1,49 +1,41 @@
1
1
  /**
2
- * Level 2 report-binding checks (§10.1 checks 2–6), minus the hardware root of
3
- * trust. This verifies the *cryptographic binding* of the report that its
4
- * `workload_id`, keyset digest, `report_data`, and endorsement are internally
5
- * consistent and endorsed by the identity key for the nonce the client
6
- * supplied. It does NOT do §10.1 check 1 (the TEE quote verifies to the vendor
7
- * root) or the "hardware evidence binds `report_data`" half of check 4: parsing
8
- * and checking a TDX/SEV-SNP quote is verifier-profile territory and needs
9
- * primitives outside the Web Crypto API. Compose this with a quote verifier and
10
- * the custody/provenance/channel checks (§10.1 checks 1, 7–10) for full Level 2.
2
+ * Report binding checks a verifier can run with pure Web Crypto — §9.1 check 2
3
+ * (binding and freshness: keyset bytes digest statement `report_data`)
4
+ * and check 3 (expiry), plus the aci/1 protocol gate. Check 1 (the hardware
5
+ * quote verifies to the vendor root and binds `report_data`) is done by
6
+ * ../../phalaSeal.ts with @phala/dcap-qvl; checks 5–6 (custody, channel) stay
7
+ * policy / caller territory.
8
+ *
9
+ * Local omission (see VENDORED.md): upstream's `verifyQuote` and
10
+ * `verifyComposeMeasurement` live in this file too. They are not carried here
11
+ * phalaSeal.ts owns the quote (it also gates TCB status and pins measurements)
12
+ * and measurements.ts owns the event-log replay across all four RTMRs. Leaving
13
+ * them out keeps this tree dependency-free and @phala/dcap-qvl off the import
14
+ * path of every startup.
11
15
  */
12
16
 
13
- import {
14
- computeWorkloadId,
15
- computeKeysetDigest,
16
- computeReportData,
17
- keysetEndorsementPayload,
18
- } from './digest';
19
- import { verifySignature, fromHex } from './crypto';
20
- import type { AttestationReport, Check, ReportVerification } from './types';
17
+ import { computeKeysetDigest, computeReportData } from './digest';
18
+ import type { AttestationReport, Check, ReportVerification, WorkloadKeyset } from './types';
21
19
 
22
20
  /** Options for {@link verifyReportBinding}. */
23
21
  export interface ReportBindingOptions {
24
22
  /**
25
- * Current time in Unix seconds for the freshness check (§10.1 check 6).
26
- * Defaults to the local clock. Pass an explicit value for deterministic tests.
23
+ * Current time in Unix seconds for the expiry check (§9.1 check 3).
24
+ * Defaults to the local clock; pass an explicit value for deterministic tests.
27
25
  */
28
26
  now?: number;
29
- /**
30
- * Whether the profile trusts the platform's declared validity window
31
- * (`freshness.fetched_at`/`stale_after`, §5.1). Off by default — recency comes
32
- * from the nonce binding, and `fetched_at`/`stale_after` need a securely
33
- * synced TEE clock to mean anything.
34
- */
35
- trustPlatformClock?: boolean;
36
27
  }
37
28
 
38
29
  /**
39
- * Verify the report's cryptographic bindings for `nonce` (§10.1 checks 2–6).
40
- * `nonce` is the value the verifier supplied to `GET /v1/aci/attestation`, or
41
- * `null`/`undefined` when it requested no nonce (§4.4).
30
+ * Verify the report's cryptographic bindings for `nonce` the value this
31
+ * client sent to `GET /v1/aci/attestation`, or `null`/`undefined` when it sent
32
+ * none (§3.2). One recomputation establishes that the keyset is exactly what
33
+ * the quote bound and that the quote postdates the challenge (§9.1 check 2).
42
34
  *
43
- * Returns a per-check result and the identity recomputed from the report's
44
- * keyset; a failed check is `ok: false`, never thrown. Throws
45
- * {@link UnsupportedAlgorithmError} when the identity key algorithm is outside
46
- * Web Crypto scope (e.g. `ecdsa-secp256k1`).
35
+ * Returns per-check results plus the established keyset (digest, exact bytes,
36
+ * parsed form); a failed check on the served report is `ok: false`, never
37
+ * thrown. The one exception is the caller's own input: a nonce that is not
38
+ * 64 lowercase hex throws {@link AciFormatError} (§3.2).
47
39
  */
48
40
  export async function verifyReportBinding(
49
41
  report: AttestationReport,
@@ -53,71 +45,56 @@ export async function verifyReportBinding(
53
45
  const now = options.now ?? Math.floor(Date.now() / 1000);
54
46
  const checks: Check[] = [];
55
47
 
56
- const keyset = report.attestation.workload_keyset;
57
- const identityKey = keyset.workload_identity.public_key;
58
-
59
- // Check 2: workload_id == digest of the identity public key in the report's keyset.
60
- const workloadId = await computeWorkloadId(identityKey);
61
- pushEqual(checks, 'workload_id', report.workload_id, workloadId);
48
+ // Protocol gate (Appendix B): artifacts with another version are rejected.
49
+ const versionOk = report.api_version === 'aci/1';
50
+ checks.push({
51
+ name: 'api_version',
52
+ ok: versionOk,
53
+ ...(versionOk ? {} : { detail: `api_version "${report.api_version}" is not "aci/1"` }),
54
+ });
62
55
 
63
- // Check 3: workload_keyset_digest == digest of the report's keyset.
64
- const workloadKeysetDigest = await computeKeysetDigest(keyset);
65
- pushEqual(checks, 'workload_keyset_digest', report.workload_keyset_digest, workloadKeysetDigest);
56
+ const keysetValue = report.attestation.workload_keyset;
57
+ if (keysetValue === null || typeof keysetValue !== 'object' || Array.isArray(keysetValue)) {
58
+ const detail = 'workload_keyset is not a JSON object';
59
+ for (const name of ['workload_keyset_digest', 'report_data', 'not_after']) {
60
+ checks.push({ name, ok: false, detail });
61
+ }
62
+ return { ok: false, checks };
63
+ }
66
64
 
67
- // Check 4 (binding half): report_data == the §4.4 statement digest for this nonce.
68
- // The hardware-evidence-binds-report_data half is out of scope (see file header).
69
- const expectedReportData = await computeReportData(workloadId, workloadKeysetDigest, nonce);
65
+ // §9.1 check 2: recompute the whole chain from the served keyset object —
66
+ // canonicalize exactly what was parsed, unknown members included. The
67
+ // recomputed digest is authoritative (Appendix A) — the report's restated copy is
68
+ // checked for consistency but never feeds the statement.
69
+ const digest = await computeKeysetDigest(keysetValue);
70
+ pushEqual(checks, 'workload_keyset_digest', report.workload_keyset_digest, digest);
71
+ const expectedReportData = await computeReportData(digest, nonce);
70
72
  pushEqual(checks, 'report_data', report.attestation.report_data, expectedReportData);
71
73
 
72
- // Check 5: keyset endorsement verifies under the identity key, algo matching.
73
- const endorsement = report.attestation.keyset_endorsement;
74
- if (endorsement.algo !== identityKey.algo) {
74
+ const keyset = keysetValue as WorkloadKeyset;
75
+
76
+ // §9.1 check 3: now < not_after in the decoded keyset.
77
+ if (typeof keyset.not_after !== 'number') {
75
78
  checks.push({
76
- name: 'keyset_endorsement',
79
+ name: 'not_after',
77
80
  ok: false,
78
- detail: `endorsement.algo "${endorsement.algo}" != identity key algo "${identityKey.algo}"`,
81
+ detail: 'keyset has no numeric not_after',
79
82
  });
80
83
  } else {
81
- const ok = await verifySignature(
82
- identityKey.algo,
83
- fromHex(identityKey.public_key),
84
- fromHex(endorsement.value),
85
- keysetEndorsementPayload(workloadKeysetDigest),
86
- 'keyset endorsement (§4.3)',
87
- );
84
+ const ok = now < keyset.not_after;
88
85
  checks.push({
89
- name: 'keyset_endorsement',
86
+ name: 'not_after',
90
87
  ok,
91
- ...(ok ? {} : { detail: 'endorsement signature failed under identity key' }),
92
- });
93
- }
94
-
95
- // Check 6: freshness. Nonce binding is check 4; here bound the epoch and,
96
- // when trusted, the declared validity window.
97
- const notAfter = keyset.keyset_epoch.not_after;
98
- const epochOk = now < notAfter;
99
- checks.push({
100
- name: 'keyset_epoch.not_after',
101
- ok: epochOk,
102
- ...(epochOk ? {} : { detail: `now ${now} >= not_after ${notAfter}` }),
103
- });
104
- if (options.trustPlatformClock) {
105
- const freshness = report.attestation.freshness;
106
- const fetchedAt = freshness?.fetched_at;
107
- const staleAfter = freshness?.stale_after;
108
- const windowOk =
109
- typeof fetchedAt === 'number' &&
110
- typeof staleAfter === 'number' &&
111
- fetchedAt <= now &&
112
- now < staleAfter;
113
- checks.push({
114
- name: 'freshness_window',
115
- ok: windowOk,
116
- ...(windowOk ? {} : { detail: `now ${now} outside [${fetchedAt}, ${staleAfter})` }),
88
+ ...(ok ? {} : { detail: `now ${now} >= not_after ${keyset.not_after}` }),
117
89
  });
118
90
  }
119
91
 
120
- return { ok: checks.every((c) => c.ok), checks, workloadId, workloadKeysetDigest };
92
+ return {
93
+ ok: checks.every((c) => c.ok),
94
+ checks,
95
+ workloadKeysetDigest: digest,
96
+ keyset,
97
+ };
121
98
  }
122
99
 
123
100
  function pushEqual(checks: Check[], name: string, actual: string, expected: string): void {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Attested-session helpers (§8, §9.3). A session is content-addressed: its id
3
+ * is the SHA-256 of the exact served document bytes, and the signed receipt
4
+ * commits to that id — there is no session signature.
5
+ */
6
+
7
+ import { sha256Hex, sha256Prefixed, fromBase64 } from './crypto';
8
+ import { jcsBytes } from './jcs';
9
+ import type { SessionRecord, SessionEvidence } from './types';
10
+
11
+ /** `session_id` (§8): bare 64-hex sha256 of the JCS form of the parsed document. */
12
+ export async function computeSessionId(record: unknown): Promise<string> {
13
+ return sha256Hex(jcsBytes(record));
14
+ }
15
+
16
+ /** Appendix B: reject session documents whose `api_version` is not `aci/1`. */
17
+ export function checkSessionApiVersion(record: Pick<SessionRecord, 'api_version'>): boolean {
18
+ return record.api_version === 'aci/1';
19
+ }
20
+
21
+ /**
22
+ * §9.2(2): `evidence.data` decodes and hashes to `evidence.digest`.
23
+ * Returns false when the data URI is absent, malformed, or does not hash.
24
+ */
25
+ export async function checkSessionEvidence(evidence: SessionEvidence): Promise<boolean> {
26
+ if (evidence == null || typeof evidence !== 'object') return false;
27
+ const { digest, data } = evidence;
28
+ if (typeof digest !== 'string' || typeof data !== 'string') return false;
29
+ const comma = data.indexOf(',');
30
+ if (!data.startsWith('data:') || comma < 0 || !data.slice(0, comma).endsWith(';base64')) {
31
+ return false;
32
+ }
33
+ let bytes: Uint8Array;
34
+ try {
35
+ bytes = fromBase64(data.slice(comma + 1));
36
+ } catch {
37
+ return false;
38
+ }
39
+ return (await sha256Prefixed(bytes)) === digest;
40
+ }