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.
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.84.1.patch +321 -9
- package/src/config/moat.ts +115 -22
- package/src/engine/errors.ts +7 -0
- package/src/mcp/catalog.ts +41 -0
- package/src/providers/phala/aci-verifier/VENDORED.md +54 -30
- package/src/providers/phala/aci-verifier/crypto.ts +35 -27
- package/src/providers/phala/aci-verifier/digest.ts +31 -95
- package/src/providers/phala/aci-verifier/e2ee-channel.ts +3 -1
- package/src/providers/phala/aci-verifier/errors.ts +2 -21
- package/src/providers/phala/aci-verifier/index.ts +29 -35
- package/src/providers/phala/aci-verifier/jcs.ts +8 -3
- package/src/providers/phala/aci-verifier/receipt.ts +96 -88
- package/src/providers/phala/aci-verifier/report.ts +62 -85
- package/src/providers/phala/aci-verifier/session.ts +40 -0
- package/src/providers/phala/aci-verifier/types.ts +90 -80
- package/src/providers/phalaSeal.ts +8 -9
- package/src/providers/phala/reportBinding.ts +0 -193
|
@@ -1,92 +1,110 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
30
|
+
document: ReceiptEnvelope,
|
|
29
31
|
keyset: WorkloadKeyset,
|
|
32
|
+
establishedDigest: string,
|
|
30
33
|
): Promise<ReceiptVerification> {
|
|
31
34
|
const checks: Check[] = [];
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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: `
|
|
45
|
+
detail: `key_id "${document.key_id}" not in receipt_signing_keys`,
|
|
59
46
|
});
|
|
60
|
-
} else if (
|
|
61
|
-
//
|
|
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: `
|
|
52
|
+
detail: `unsupported signature algo "${keyEntry.algo}"`,
|
|
66
53
|
});
|
|
67
54
|
} else {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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(
|
|
84
|
-
|
|
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 (
|
|
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
|
-
* §
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
-
|
|
120
|
+
payload: ReceiptPayload,
|
|
103
121
|
requestBody: Uint8Array | string,
|
|
104
122
|
): Promise<boolean> {
|
|
105
|
-
|
|
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
|
-
* §
|
|
113
|
-
*
|
|
114
|
-
* Returns false when the event
|
|
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
|
|
117
|
-
|
|
132
|
+
export async function checkResponseBodyHash(
|
|
133
|
+
payload: ReceiptPayload,
|
|
118
134
|
responseBody: Uint8Array | string,
|
|
119
135
|
): Promise<boolean> {
|
|
120
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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(
|
|
146
|
+
return (await hashBody(body)) === expected;
|
|
139
147
|
}
|
|
@@ -1,49 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
|
26
|
-
* Defaults to the local clock
|
|
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`
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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: '
|
|
79
|
+
name: 'not_after',
|
|
77
80
|
ok: false,
|
|
78
|
-
detail:
|
|
81
|
+
detail: 'keyset has no numeric not_after',
|
|
79
82
|
});
|
|
80
83
|
} else {
|
|
81
|
-
const ok =
|
|
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: '
|
|
86
|
+
name: 'not_after',
|
|
90
87
|
ok,
|
|
91
|
-
...(ok ? {} : { detail:
|
|
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 {
|
|
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
|
+
}
|