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.
- package/package.json +1 -1
- package/src/config/moat.ts +115 -22
- package/src/harbor/index.ts +30 -4
- package/src/harbor/ipc.ts +17 -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/remote/relayClient.ts +24 -2
- package/src/providers/phala/reportBinding.ts +0 -193
|
@@ -1,116 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The ACI digest
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
10
|
+
import { AciFormatError } from './errors';
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* the
|
|
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
|
-
|
|
36
|
-
workloadKeysetDigest: string,
|
|
27
|
+
keysetDigest: string,
|
|
37
28
|
nonce: string | null | undefined,
|
|
38
|
-
):
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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` (§
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
|
|
54
|
-
workloadKeysetDigest: string,
|
|
48
|
+
keysetDigest: string,
|
|
55
49
|
nonce: string | null | undefined,
|
|
56
50
|
): Promise<string> {
|
|
57
|
-
return sha256Hex(
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
* @
|
|
2
|
+
* @phala/aci-verifier — a zero-dependency ACI verifier for the browser and node.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 (
|
|
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
|
|
28
|
-
export {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 (§
|
|
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
|
|
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
|
-
//
|
|
49
|
+
// Receipt verification (§9.3)
|
|
54
50
|
export {
|
|
55
51
|
verifyReceipt,
|
|
56
52
|
findEvent,
|
|
57
53
|
hashBody,
|
|
58
54
|
checkRequestBodyHash,
|
|
59
|
-
|
|
60
|
-
checkResponseCleartextHash,
|
|
55
|
+
checkResponseBodyHash,
|
|
61
56
|
} from './receipt';
|
|
62
57
|
|
|
63
|
-
//
|
|
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
|
|
63
|
+
export { AciError, AciFormatError } from './errors';
|
|
69
64
|
|
|
70
65
|
// Wire & result types
|
|
71
66
|
export type {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
ReceiptSigningKey,
|
|
67
|
+
KeysetKey,
|
|
68
|
+
TlsKeyPin,
|
|
75
69
|
WorkloadKeyset,
|
|
76
|
-
|
|
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
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
*
|
|
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
|
}
|