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,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
|
+
}
|
|
@@ -1,139 +1,149 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wire shapes for the ACI artifacts this verifier reads, plus the result types
|
|
3
|
-
* it returns. These mirror spec/aci.md §
|
|
3
|
+
* it returns. These mirror spec/aci.md §3, §4, §7, §8; only the fields the
|
|
4
4
|
* verifier touches are typed precisely, with an index signature left open so
|
|
5
|
-
*
|
|
5
|
+
* extension fields (Appendix B) are visible to callers.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export interface PublicKey {
|
|
8
|
+
/** A keyed public-key entry (§3.1) — receipt signing and E2EE keys. */
|
|
9
|
+
export interface KeysetKey {
|
|
10
|
+
key_id: string;
|
|
12
11
|
algo: string;
|
|
13
12
|
public_key: string;
|
|
14
|
-
[key: string]:
|
|
13
|
+
[key: string]: unknown;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
/**
|
|
18
|
-
export interface
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
[key: string]:
|
|
16
|
+
/** A TLS pin entry (§3.1): the certificate SPKI digest, optionally domain-scoped. */
|
|
17
|
+
export interface TlsKeyPin {
|
|
18
|
+
spki_sha256: string;
|
|
19
|
+
domain?: string;
|
|
20
|
+
[key: string]: unknown;
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
[key: string]: JcsValue | undefined;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** The workload keyset (§4.2). Digested with JCS to yield `workload_keyset_digest`. */
|
|
23
|
+
/**
|
|
24
|
+
* The workload keyset (§3.1) — the unit of workload identity. It travels as
|
|
25
|
+
* `workload_keyset`; its digest is the SHA-256 of the JCS form of the
|
|
26
|
+
* parsed object (Appendix A).
|
|
27
|
+
*/
|
|
33
28
|
export interface WorkloadKeyset {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
receipt_signing_keys:
|
|
37
|
-
e2ee_public_keys
|
|
38
|
-
tls_public_keys?:
|
|
39
|
-
[key: string]:
|
|
29
|
+
subject?: string | null;
|
|
30
|
+
not_after: number;
|
|
31
|
+
receipt_signing_keys: KeysetKey[];
|
|
32
|
+
e2ee_public_keys: KeysetKey[];
|
|
33
|
+
tls_public_keys?: TlsKeyPin[];
|
|
34
|
+
[key: string]: unknown;
|
|
40
35
|
}
|
|
41
36
|
|
|
42
|
-
/**
|
|
43
|
-
export interface
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
/** Source provenance (§4.1); each field is `null` when unknown. */
|
|
38
|
+
export interface SourceProvenance {
|
|
39
|
+
repo_url?: string | null;
|
|
40
|
+
repo_commit?: string | null;
|
|
41
|
+
image_digest?: string | null;
|
|
42
|
+
image_provenance?: unknown;
|
|
43
|
+
[key: string]: unknown;
|
|
48
44
|
}
|
|
49
45
|
|
|
50
|
-
/**
|
|
51
|
-
export interface
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
/** The `attestation` object of a report (§4.1). `evidence` is policy-defined (§4.2). */
|
|
47
|
+
export interface Attestation {
|
|
48
|
+
tee_type: string;
|
|
49
|
+
workload_keyset: unknown;
|
|
50
|
+
report_data: string;
|
|
51
|
+
source_provenance?: SourceProvenance | null;
|
|
52
|
+
evidence?: unknown;
|
|
53
|
+
[key: string]: unknown;
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
/** An
|
|
58
|
-
export interface
|
|
56
|
+
/** An attestation report (§4.1). */
|
|
57
|
+
export interface AttestationReport {
|
|
59
58
|
api_version: string;
|
|
60
|
-
receipt_id: string;
|
|
61
|
-
workload_id: string;
|
|
62
59
|
workload_keyset_digest: string;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
[key: string]:
|
|
60
|
+
attestation: Attestation;
|
|
61
|
+
service_capabilities?: { supported_e2ee_versions?: string[]; [key: string]: unknown };
|
|
62
|
+
[key: string]: unknown;
|
|
66
63
|
}
|
|
67
64
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The receipt document served by `GET /v1/aci/receipts/{id}` (§7.2): the
|
|
67
|
+
* §7.3 payload members plus `key_id` and `signature`. The signature covers
|
|
68
|
+
* JCS(document minus `signature`).
|
|
69
|
+
*/
|
|
70
|
+
export interface ReceiptEnvelope {
|
|
71
|
+
key_id: string;
|
|
72
|
+
signature: string;
|
|
73
|
+
[key: string]: unknown;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
/**
|
|
76
|
-
export interface
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
freshness?: { fetched_at?: number; stale_after?: number; [key: string]: JcsValue | undefined };
|
|
81
|
-
[key: string]: JcsValue | undefined;
|
|
76
|
+
/** A receipt event (§7.3): `type` plus type-specific fields; order is array order. */
|
|
77
|
+
export interface ReceiptEvent {
|
|
78
|
+
type: string;
|
|
79
|
+
body_hash?: string;
|
|
80
|
+
[key: string]: unknown;
|
|
82
81
|
}
|
|
83
82
|
|
|
84
|
-
/**
|
|
85
|
-
export interface
|
|
83
|
+
/** The receipt payload the envelope signs (§7.3). */
|
|
84
|
+
export interface ReceiptPayload {
|
|
86
85
|
api_version: string;
|
|
87
|
-
|
|
86
|
+
receipt_id: string;
|
|
87
|
+
chat_id?: string | null;
|
|
88
|
+
model?: string | null;
|
|
88
89
|
workload_keyset_digest: string;
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
endpoint: string;
|
|
91
|
+
method: string;
|
|
92
|
+
served_at: number;
|
|
93
|
+
event_log: ReceiptEvent[];
|
|
94
|
+
[key: string]: unknown;
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
/** A
|
|
97
|
+
/** A session evidence block (§8.2): a base64 data URI plus the digest of its decoded bytes. */
|
|
94
98
|
export interface SessionEvidence {
|
|
95
|
-
digest
|
|
99
|
+
digest: string;
|
|
96
100
|
data?: string;
|
|
97
|
-
[key: string]:
|
|
101
|
+
[key: string]: unknown;
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
/**
|
|
101
|
-
* An attested session record (§
|
|
102
|
-
*
|
|
103
|
-
* are restored as JSON `null` in the content-addressing material.
|
|
105
|
+
* An attested session record (§8.2). Its id is the SHA-256 of the JCS form
|
|
106
|
+
* of the parsed document ({@link computeSessionId}).
|
|
104
107
|
*/
|
|
105
108
|
export interface SessionRecord {
|
|
109
|
+
api_version: string;
|
|
106
110
|
upstream_name: string;
|
|
107
111
|
endpoint?: string | null;
|
|
108
112
|
verifier_id: string;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
established_at: number;
|
|
114
|
+
expires_at: number;
|
|
115
|
+
identity?: unknown;
|
|
116
|
+
channel_binding: unknown[];
|
|
117
|
+
claims: unknown;
|
|
118
|
+
evidence: SessionEvidence;
|
|
119
|
+
[key: string]: unknown;
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
/** Outcome of one named verification check. */
|
|
117
123
|
export interface Check {
|
|
118
|
-
/** Stable machine-readable id, e.g. `signature`, `
|
|
124
|
+
/** Stable machine-readable id, e.g. `signature`, `report_data`. */
|
|
119
125
|
name: string;
|
|
120
126
|
ok: boolean;
|
|
121
127
|
/** Human-readable detail, present when the check fails. */
|
|
122
128
|
detail?: string;
|
|
123
129
|
}
|
|
124
130
|
|
|
125
|
-
/** Result of {@link verifyReceipt}: overall pass plus the individual §
|
|
131
|
+
/** Result of {@link verifyReceipt}: overall pass plus the individual §9.3 checks. */
|
|
126
132
|
export interface ReceiptVerification {
|
|
127
133
|
ok: boolean;
|
|
128
134
|
checks: Check[];
|
|
135
|
+
/** The receipt document read as its §7.3 payload members. */
|
|
136
|
+
payload?: ReceiptPayload;
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Result of {@link verifyReportBinding}: overall pass, the checks, and the
|
|
141
|
+
* keyset established from the report — the digest is recomputed over the
|
|
142
|
+
* served keyset object's JCS form (§3.1).
|
|
143
|
+
*/
|
|
132
144
|
export interface ReportVerification {
|
|
133
145
|
ok: boolean;
|
|
134
146
|
checks: Check[];
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
/** `workload_keyset_digest` recomputed from the report's keyset (§4.2). */
|
|
138
|
-
workloadKeysetDigest: string;
|
|
147
|
+
workloadKeysetDigest?: string;
|
|
148
|
+
keyset?: WorkloadKeyset;
|
|
139
149
|
}
|
|
@@ -8,13 +8,16 @@
|
|
|
8
8
|
// (`${server}/api/sealed/phala`, treeview/server/routes/sealed.js) injects PHALA_API_KEY
|
|
9
9
|
// and forwards ciphertext — it can't read prompts or responses.
|
|
10
10
|
//
|
|
11
|
-
// Crypto is the vendored
|
|
11
|
+
// Crypto is the vendored aci-verifier (./phala/aci-verifier), pure Web Crypto
|
|
12
12
|
// (X25519/HKDF/AES-GCM/Ed25519). Node ≥ 22 provides all of it on globalThis.crypto —
|
|
13
13
|
// no polyfills, unlike the RN app.
|
|
14
14
|
//
|
|
15
15
|
// Two-layer attestation, fail-secure:
|
|
16
|
-
// (1)
|
|
17
|
-
//
|
|
16
|
+
// (1) verifyReportBinding — the report's crypto binding (§9.1 checks 2–3): the
|
|
17
|
+
// served keyset canonicalizes to the digest that the attestation statement for
|
|
18
|
+
// OUR nonce hashes into report_data, and the keyset has not expired. Every key
|
|
19
|
+
// we go on to use — the X25519 key we seal to above all — is a member of that
|
|
20
|
+
// one object, so the binding covers them without a separate signature.
|
|
18
21
|
// (2) verifyHardwareQuote — the hardware root: @phala/dcap-qvl verifies the TDX quote
|
|
19
22
|
// against Intel collateral and binds the quote's report_data to (1)'s statement
|
|
20
23
|
// digest. requireQuote defaults TRUE; PRIVATEER_PHALA_REQUIRE_QUOTE=0 drops it
|
|
@@ -23,17 +26,13 @@
|
|
|
23
26
|
import type { Report } from "@phala/dcap-qvl";
|
|
24
27
|
import {
|
|
25
28
|
openE2eeChannel,
|
|
29
|
+
verifyReportBinding,
|
|
26
30
|
toHex,
|
|
27
31
|
fromHex,
|
|
28
32
|
type AttestationReport,
|
|
29
33
|
type ReportVerification,
|
|
30
34
|
type E2eeChannel,
|
|
31
35
|
} from "./phala/aci-verifier/index.ts";
|
|
32
|
-
// Not the vendored verifyReportBinding directly: the deployed gateway signs its
|
|
33
|
-
// keyset endorsement with ecdsa-secp256k1, which upstream's Web-Crypto-only verifier
|
|
34
|
-
// refuses. This wrapper delegates ed25519 to it unchanged and adds the secp256k1 arm
|
|
35
|
-
// the spec allows (§4.3), leaving aci-verifier/ pristine for re-pulls.
|
|
36
|
-
import { verifyAciReportBinding } from "./phala/reportBinding.ts";
|
|
37
36
|
import {
|
|
38
37
|
parseEventLog,
|
|
39
38
|
appIdentityFrom,
|
|
@@ -153,7 +152,7 @@ async function establishAttestation(): Promise<VerifiedAttestation> {
|
|
|
153
152
|
if (!res.ok) throw new Error(`phala attestation HTTP ${res.status}`);
|
|
154
153
|
const report = (await res.json()) as AttestationReport;
|
|
155
154
|
|
|
156
|
-
const verification = await
|
|
155
|
+
const verification = await verifyReportBinding(report, nonce);
|
|
157
156
|
if (!verification.ok) {
|
|
158
157
|
const failed = verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ");
|
|
159
158
|
throw new Error(`phala attestation binding failed: ${failed}`);
|
|
@@ -383,6 +383,11 @@ export class RelayClient {
|
|
|
383
383
|
private reconnectDelay = RECONNECT_MS;
|
|
384
384
|
// Last refusal reason reported, so a 4xx is logged once instead of on every retry.
|
|
385
385
|
private refusal: string | null = null;
|
|
386
|
+
// Why the last connection attempt failed, kept for connectionStatus(). A refusal
|
|
387
|
+
// (4xx) outranks it — that one is a decision, not a hiccup — but without either,
|
|
388
|
+
// every un-connected relay reports as a bare "connecting…", which is the same
|
|
389
|
+
// sentence whether the socket opens in two seconds or never opens again.
|
|
390
|
+
private lastFailure: string | null = null;
|
|
386
391
|
// Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
|
|
387
392
|
private bufKind: "text" | "reasoning" | null = null;
|
|
388
393
|
private buf = "";
|
|
@@ -518,6 +523,7 @@ export class RelayClient {
|
|
|
518
523
|
ws.on("open", () => {
|
|
519
524
|
opened = true;
|
|
520
525
|
this.refusal = null; // a later refusal is news again
|
|
526
|
+
this.lastFailure = null; // whatever kept us out is history
|
|
521
527
|
this.reconnectDelay = RECONNECT_MS; // reachable again — next blip retries fast
|
|
522
528
|
this.connectedAt = Date.now();
|
|
523
529
|
this.startHeartbeat(ws);
|
|
@@ -537,6 +543,9 @@ export class RelayClient {
|
|
|
537
543
|
// attach/detach frame said. Re-learned on the next attach or inbound frame.
|
|
538
544
|
this.controllerHere = false;
|
|
539
545
|
this.cb.onDisconnected?.();
|
|
546
|
+
// A socket that never opened failed for a reason worth reporting; one that
|
|
547
|
+
// opened and dropped is an ordinary blip the reconnect handles.
|
|
548
|
+
this.lastFailure = opened ? null : lastErr || "the relay connection closed before it opened";
|
|
540
549
|
if (!this.closed) {
|
|
541
550
|
this.cb.onStatus?.(
|
|
542
551
|
opened
|
|
@@ -560,6 +569,7 @@ export class RelayClient {
|
|
|
560
569
|
const status = (err as { status?: number })?.status;
|
|
561
570
|
const refused = typeof status === "number" && status >= 400 && status < 500;
|
|
562
571
|
if (refused) {
|
|
572
|
+
this.lastFailure = msg;
|
|
563
573
|
this.settleFirstConnect(err instanceof Error ? err : new Error(msg));
|
|
564
574
|
// A refusal is a decision, not a hiccup: hammering the same request every few
|
|
565
575
|
// seconds can't change it, and for a harbor — whose onStatus goes to a log file,
|
|
@@ -578,6 +588,7 @@ export class RelayClient {
|
|
|
578
588
|
}
|
|
579
589
|
// Transient (network/route/5xx): stay on the fast retry.
|
|
580
590
|
this.refusal = null;
|
|
591
|
+
this.lastFailure = msg;
|
|
581
592
|
this.cb.onStatus?.(`Remote access couldn't reach the relay (${msg}) — retrying…`);
|
|
582
593
|
this.scheduleReconnect();
|
|
583
594
|
} finally {
|
|
@@ -970,8 +981,19 @@ export class RelayClient {
|
|
|
970
981
|
// quiet for longer than the server's 25s ping cadence is the shape of the half-open
|
|
971
982
|
// failure the watchdog exists to catch, so it is worth showing rather than a bare
|
|
972
983
|
// "connected".
|
|
973
|
-
connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number } {
|
|
974
|
-
|
|
984
|
+
connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number; detail?: string; refused?: boolean } {
|
|
985
|
+
// Not connected: say WHY when we know. A standing refusal (the plan's agent cap,
|
|
986
|
+
// a rejected ticket) is the answer that matters most — it will not clear on its
|
|
987
|
+
// own, and it's the one case a caller should present as a decision rather than a
|
|
988
|
+
// wait — so it wins over the last transient error and is flagged as itself.
|
|
989
|
+
if (!this.isConnected()) {
|
|
990
|
+
const detail = this.refusal ?? this.lastFailure;
|
|
991
|
+
return {
|
|
992
|
+
connected: false,
|
|
993
|
+
...(detail ? { detail } : {}),
|
|
994
|
+
...(this.refusal ? { refused: true } : {}),
|
|
995
|
+
};
|
|
996
|
+
}
|
|
975
997
|
const now = Date.now();
|
|
976
998
|
return {
|
|
977
999
|
connected: true,
|