privateer-agent 0.7.0 → 0.8.0
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 +4 -2
- package/src/channels/run.ts +11 -2
- package/src/cli/chat.ts +10 -2
- package/src/harbor/index.ts +45 -8
- package/src/harbor/ipc.ts +82 -34
- package/src/mcp/catalog.ts +106 -0
- package/src/providers/account.ts +57 -10
- package/src/providers/catalog.ts +8 -6
- package/src/providers/phala/aci-verifier/VENDORED.md +23 -0
- package/src/providers/phala/aci-verifier/crypto.ts +95 -0
- package/src/providers/phala/aci-verifier/digest.ts +116 -0
- package/src/providers/phala/aci-verifier/e2ee-channel.ts +242 -0
- package/src/providers/phala/aci-verifier/e2ee.ts +73 -0
- package/src/providers/phala/aci-verifier/errors.ts +41 -0
- package/src/providers/phala/aci-verifier/index.ts +87 -0
- package/src/providers/phala/aci-verifier/jcs.ts +69 -0
- package/src/providers/phala/aci-verifier/receipt.ts +139 -0
- package/src/providers/phala/aci-verifier/report.ts +126 -0
- package/src/providers/phala/aci-verifier/types.ts +139 -0
- package/src/providers/phala/sse.ts +43 -0
- package/src/providers/phala/webcrypto-globals.d.ts +17 -0
- package/src/providers/phalaSeal.ts +200 -0
- package/src/providers/sealedShim.ts +295 -0
- package/src/remote/liveTaskSession.ts +11 -3
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptographic primitives, all via the Web Crypto API (`globalThis.crypto`) so
|
|
3
|
+
* the same code runs in browsers and in Node 20+ with no third-party deps.
|
|
4
|
+
* Only SHA-256 and Ed25519 verification are needed for Level 1.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { AciFormatError, UnsupportedAlgorithmError } from './errors';
|
|
8
|
+
|
|
9
|
+
const subtle = globalThis.crypto.subtle;
|
|
10
|
+
|
|
11
|
+
/** Lowercase-hex encode bytes. */
|
|
12
|
+
export function toHex(bytes: Uint8Array): string {
|
|
13
|
+
let out = '';
|
|
14
|
+
for (const b of bytes) out += b.toString(16).padStart(2, '0');
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Decode hex (optionally `0x`-prefixed) to bytes. */
|
|
19
|
+
export function fromHex(hex: string): Uint8Array {
|
|
20
|
+
const h = hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex;
|
|
21
|
+
if (h.length % 2 !== 0) {
|
|
22
|
+
throw new AciFormatError(`hex string has odd length: ${hex.length} chars`);
|
|
23
|
+
}
|
|
24
|
+
const out = new Uint8Array(h.length / 2);
|
|
25
|
+
for (let i = 0; i < out.length; i++) {
|
|
26
|
+
const byte = Number.parseInt(h.substr(i * 2, 2), 16);
|
|
27
|
+
if (Number.isNaN(byte)) {
|
|
28
|
+
throw new AciFormatError(`invalid hex at offset ${i * 2}: "${h.substr(i * 2, 2)}"`);
|
|
29
|
+
}
|
|
30
|
+
out[i] = byte;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** SHA-256 of the given bytes. */
|
|
36
|
+
export async function sha256(bytes: Uint8Array): Promise<Uint8Array> {
|
|
37
|
+
return new Uint8Array(await subtle.digest('SHA-256', bytes as BufferSource));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Lowercase-hex SHA-256 of the given bytes. */
|
|
41
|
+
export async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
|
42
|
+
return toHex(await sha256(bytes));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `sha256:<lowercase-hex>` digest string of the given bytes — the ACI digest
|
|
47
|
+
* form (§3) used for `workload_id`, keyset digests, and body hashes.
|
|
48
|
+
*/
|
|
49
|
+
export async function sha256Prefixed(bytes: Uint8Array): Promise<string> {
|
|
50
|
+
return 'sha256:' + (await sha256Hex(bytes));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Verify an Ed25519 signature (RFC 8032, §4.3/§8.5) over `message`.
|
|
55
|
+
* `publicKeyRaw` is the 32-byte raw key; `signature` the 64-byte value.
|
|
56
|
+
* Returns false on a bad signature or malformed key — never throws for those.
|
|
57
|
+
*/
|
|
58
|
+
export async function verifyEd25519(
|
|
59
|
+
publicKeyRaw: Uint8Array,
|
|
60
|
+
signature: Uint8Array,
|
|
61
|
+
message: Uint8Array,
|
|
62
|
+
): Promise<boolean> {
|
|
63
|
+
let key: CryptoKey;
|
|
64
|
+
try {
|
|
65
|
+
key = await subtle.importKey('raw', publicKeyRaw as BufferSource, { name: 'Ed25519' }, false, [
|
|
66
|
+
'verify',
|
|
67
|
+
]);
|
|
68
|
+
} catch {
|
|
69
|
+
// A key that will not import cannot verify anything.
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
return await subtle.verify({ name: 'Ed25519' }, key, signature as BufferSource, message as BufferSource);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Verify a signature by ACI signature `algo`, dispatching on the algorithm the
|
|
81
|
+
* attested keyset entry declares. Only `ed25519` is verifiable here; every other
|
|
82
|
+
* algorithm (including `ecdsa-secp256k1`) raises {@link UnsupportedAlgorithmError}.
|
|
83
|
+
*/
|
|
84
|
+
export async function verifySignature(
|
|
85
|
+
algo: string,
|
|
86
|
+
publicKeyRaw: Uint8Array,
|
|
87
|
+
signature: Uint8Array,
|
|
88
|
+
message: Uint8Array,
|
|
89
|
+
context: string,
|
|
90
|
+
): Promise<boolean> {
|
|
91
|
+
if (algo === 'ed25519') {
|
|
92
|
+
return verifyEd25519(publicKeyRaw, signature, message);
|
|
93
|
+
}
|
|
94
|
+
throw new UnsupportedAlgorithmError(algo, context);
|
|
95
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
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.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { jcsBytes } from './jcs';
|
|
8
|
+
import type { JcsValue } from './jcs';
|
|
9
|
+
import { sha256Hex, sha256Prefixed } from './crypto';
|
|
10
|
+
import type { PublicKey, WorkloadKeyset, Receipt, SessionRecord } from './types';
|
|
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
|
+
}
|
|
19
|
+
|
|
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));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
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.
|
|
33
|
+
*/
|
|
34
|
+
export function attestationStatement(
|
|
35
|
+
workloadId: string,
|
|
36
|
+
workloadKeysetDigest: string,
|
|
37
|
+
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
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
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).
|
|
51
|
+
*/
|
|
52
|
+
export async function computeReportData(
|
|
53
|
+
workloadId: string,
|
|
54
|
+
workloadKeysetDigest: string,
|
|
55
|
+
nonce: string | null | undefined,
|
|
56
|
+
): 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))));
|
|
116
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2EE channel to a *verified* workload (§7). `openE2eeChannel` refuses unless
|
|
3
|
+
* the report passed {@link verifyReportBinding} — you cannot encrypt to a key
|
|
4
|
+
* that is not in a verified, endorsed keyset. `seal` encrypts the request's
|
|
5
|
+
* content fields to the attested X25519 key and returns the `X-E2EE-*` headers;
|
|
6
|
+
* `open` decrypts a buffered response and `openChunk` decrypts one streamed SSE
|
|
7
|
+
* chunk. All crypto is Web Crypto (X25519, HKDF, AES-GCM) — no dependencies,
|
|
8
|
+
* runs in the browser. secp256k1 is a separate extension (not in Web Crypto).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { requestAad, responseAad } from './e2ee';
|
|
12
|
+
import { toHex, fromHex } from './crypto';
|
|
13
|
+
import type { AttestationReport, ReportVerification } from './types';
|
|
14
|
+
|
|
15
|
+
const ALGO = 'x25519-aes-256-gcm-hkdf-sha256';
|
|
16
|
+
const subtle = globalThis.crypto.subtle;
|
|
17
|
+
const enc = new TextEncoder();
|
|
18
|
+
const dec = new TextDecoder();
|
|
19
|
+
const HKDF_INFO = enc.encode('aci.e2ee.v2.x25519');
|
|
20
|
+
|
|
21
|
+
type Json = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
/** An encrypted channel bound to one verified workload. */
|
|
24
|
+
export interface E2eeChannel {
|
|
25
|
+
/** Encrypt a request's content fields; returns the body and `X-E2EE-*` headers. */
|
|
26
|
+
seal(request: Json): Promise<{ body: Json; headers: Record<string, string> }>;
|
|
27
|
+
/** Decrypt a buffered response produced for the most recent `seal`. */
|
|
28
|
+
open(response: Json): Promise<Json>;
|
|
29
|
+
/** Decrypt one streamed SSE chunk (a `chat.completion.chunk` / completion chunk). */
|
|
30
|
+
openChunk(chunk: Json): Promise<Json>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Open an E2EE channel to the workload `report` describes, once `verification`
|
|
35
|
+
* (from {@link verifyReportBinding} for that report) has passed.
|
|
36
|
+
*/
|
|
37
|
+
export async function openE2eeChannel(
|
|
38
|
+
report: AttestationReport,
|
|
39
|
+
verification: ReportVerification,
|
|
40
|
+
): Promise<E2eeChannel> {
|
|
41
|
+
if (!verification.ok || verification.workloadKeysetDigest !== report.workload_keyset_digest) {
|
|
42
|
+
throw new Error('openE2eeChannel: report is not verified — call verifyReportBinding and check .ok');
|
|
43
|
+
}
|
|
44
|
+
const keys = (report.attestation.workload_keyset.e2ee_public_keys ?? []) as Array<{
|
|
45
|
+
algo: string;
|
|
46
|
+
public_key: string;
|
|
47
|
+
}>;
|
|
48
|
+
const service = keys.find((k) => k?.algo === ALGO);
|
|
49
|
+
if (!service) throw new Error(`openE2eeChannel: no attested ${ALGO} key in the keyset`);
|
|
50
|
+
const serviceRaw = fromHex(service.public_key);
|
|
51
|
+
|
|
52
|
+
// Static client key: responses are encrypted to it, and we decrypt with its private half.
|
|
53
|
+
const client = (await subtle.generateKey({ name: 'X25519' }, true, ['deriveBits'])) as CryptoKeyPair;
|
|
54
|
+
const clientPubHex = toHex(new Uint8Array(await subtle.exportKey('raw', client.publicKey)));
|
|
55
|
+
|
|
56
|
+
let sent: { model: string; nonce: string; ts: number } | undefined;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
async seal(request) {
|
|
60
|
+
const model = request.model;
|
|
61
|
+
if (typeof model !== 'string') throw new Error('seal: request.model must be a string');
|
|
62
|
+
const nonce = toHex(crypto.getRandomValues(new Uint8Array(32)));
|
|
63
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
64
|
+
sent = { model, nonce, ts };
|
|
65
|
+
const encField = (text: string, field: string) =>
|
|
66
|
+
sealField(serviceRaw, enc.encode(text), requestAad({ algo: ALGO, model, field, nonce, ts }));
|
|
67
|
+
|
|
68
|
+
const body: Json = { ...request };
|
|
69
|
+
if (Array.isArray(request.messages)) {
|
|
70
|
+
// Whole-content encryption (§7.2) — the universal form for any modality.
|
|
71
|
+
body.messages = await Promise.all(
|
|
72
|
+
(request.messages as Json[]).map(async (m, i) => {
|
|
73
|
+
if (m?.content == null) return m;
|
|
74
|
+
const text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
|
|
75
|
+
return { ...m, content: await encField(text, `messages.${i}.content`) };
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
} else if (request.prompt !== undefined) {
|
|
79
|
+
body.prompt = await sealStringOrArray(request.prompt, 'prompt', encField); // completions
|
|
80
|
+
} else if (request.input !== undefined) {
|
|
81
|
+
body.input = await sealStringOrArray(request.input, 'input', encField); // embeddings
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
body,
|
|
85
|
+
headers: {
|
|
86
|
+
'X-E2EE-Version': '2',
|
|
87
|
+
'X-Client-Pub-Key': clientPubHex,
|
|
88
|
+
'X-Model-Pub-Key': service.public_key,
|
|
89
|
+
'X-E2EE-Nonce': nonce,
|
|
90
|
+
'X-E2EE-Timestamp': String(ts),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async open(response) {
|
|
96
|
+
const decField = responseDecryptor(client.privateKey, sent, textFrom(response.id));
|
|
97
|
+
const body: Json = { ...response };
|
|
98
|
+
if (Array.isArray(response.choices)) {
|
|
99
|
+
body.choices = await Promise.all(
|
|
100
|
+
(response.choices as Json[]).map(async (c, pos) => {
|
|
101
|
+
const i = indexOf(c, pos);
|
|
102
|
+
const out: Json = { ...c };
|
|
103
|
+
if (out.message && typeof out.message === 'object') {
|
|
104
|
+
const m: Json = { ...(out.message as Json) };
|
|
105
|
+
await openStr(m, 'content', `choices.${i}.message.content`, decField);
|
|
106
|
+
await openStr(m, 'reasoning_content', `choices.${i}.message.reasoning_content`, decField);
|
|
107
|
+
if (m.audio && typeof m.audio === 'object') {
|
|
108
|
+
const a: Json = { ...(m.audio as Json) };
|
|
109
|
+
await openStr(a, 'data', `choices.${i}.message.audio.data`, decField);
|
|
110
|
+
m.audio = a;
|
|
111
|
+
}
|
|
112
|
+
out.message = m;
|
|
113
|
+
} else {
|
|
114
|
+
await openStr(out, 'text', `choices.${i}.text`, decField); // completions
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (Array.isArray(response.data)) {
|
|
121
|
+
// Embeddings: the value is serialized compactly then encrypted (§7.2).
|
|
122
|
+
body.data = await Promise.all(
|
|
123
|
+
(response.data as Json[]).map(async (d, pos) => {
|
|
124
|
+
const i = indexOf(d, pos);
|
|
125
|
+
const out: Json = { ...d };
|
|
126
|
+
if (typeof out.embedding === 'string') {
|
|
127
|
+
out.embedding = JSON.parse(await decField(out.embedding, `data.${i}.embedding`));
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return body;
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async openChunk(chunk) {
|
|
137
|
+
const decField = responseDecryptor(client.privateKey, sent, textFrom(chunk.id));
|
|
138
|
+
const body: Json = { ...chunk };
|
|
139
|
+
if (Array.isArray(chunk.choices)) {
|
|
140
|
+
body.choices = await Promise.all(
|
|
141
|
+
(chunk.choices as Json[]).map(async (c, pos) => {
|
|
142
|
+
const i = indexOf(c, pos);
|
|
143
|
+
const out: Json = { ...c };
|
|
144
|
+
if (out.delta && typeof out.delta === 'object') {
|
|
145
|
+
const d: Json = { ...(out.delta as Json) };
|
|
146
|
+
await openStr(d, 'content', `choices.${i}.delta.content`, decField);
|
|
147
|
+
await openStr(d, 'reasoning_content', `choices.${i}.delta.reasoning_content`, decField);
|
|
148
|
+
out.delta = d;
|
|
149
|
+
} else {
|
|
150
|
+
await openStr(out, 'text', `choices.${i}.text`, decField); // completions stream
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return body;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A field decryptor bound to the request context (§7.3) and the response `id`. */
|
|
162
|
+
function responseDecryptor(
|
|
163
|
+
clientPriv: CryptoKey,
|
|
164
|
+
sent: { model: string; nonce: string; ts: number } | undefined,
|
|
165
|
+
id: string,
|
|
166
|
+
): (blobHex: string, field: string) => Promise<string> {
|
|
167
|
+
if (!sent) throw new Error('open: call seal first');
|
|
168
|
+
const { model, nonce, ts } = sent;
|
|
169
|
+
return async (blobHex, field) =>
|
|
170
|
+
dec.decode(await openField(clientPriv, blobHex, responseAad({ algo: ALGO, model, id, field, nonce, ts })));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `choices`/`data` index is the entry's `index` member, else its array position (§7.2). */
|
|
174
|
+
function indexOf(entry: Json, position: number): number {
|
|
175
|
+
return typeof entry.index === 'number' ? entry.index : position;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function textFrom(v: unknown): string {
|
|
179
|
+
return typeof v === 'string' ? v : '';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Decrypt string member `key` of `obj` at `field`, in place; leave non-strings untouched. */
|
|
183
|
+
async function openStr(
|
|
184
|
+
obj: Json,
|
|
185
|
+
key: string,
|
|
186
|
+
field: string,
|
|
187
|
+
decField: (blobHex: string, field: string) => Promise<string>,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
if (typeof obj[key] === 'string') obj[key] = await decField(obj[key] as string, field);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Encrypt a string, or each string element of an array at `name.{i}` (§7.2). */
|
|
193
|
+
async function sealStringOrArray(
|
|
194
|
+
value: unknown,
|
|
195
|
+
name: string,
|
|
196
|
+
encField: (text: string, field: string) => Promise<string>,
|
|
197
|
+
): Promise<unknown> {
|
|
198
|
+
if (typeof value === 'string') return encField(value, name);
|
|
199
|
+
if (Array.isArray(value)) {
|
|
200
|
+
return Promise.all(value.map((v, i) => (typeof v === 'string' ? encField(v, `${name}.${i}`) : v)));
|
|
201
|
+
}
|
|
202
|
+
return value;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// `Uint8Array` → `BufferSource` (Web Crypto typings friction; see crypto.ts).
|
|
206
|
+
const bs = (u: Uint8Array): BufferSource => u as BufferSource;
|
|
207
|
+
|
|
208
|
+
/** Derive the AES-256-GCM key from a raw X25519 shared secret (spec §7.1). */
|
|
209
|
+
async function aesKey(shared: Uint8Array, usage: KeyUsage): Promise<CryptoKey> {
|
|
210
|
+
const hk = await subtle.importKey('raw', bs(shared), 'HKDF', false, ['deriveKey']);
|
|
211
|
+
return subtle.deriveKey(
|
|
212
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: bs(new Uint8Array(0)), info: bs(HKDF_INFO) },
|
|
213
|
+
hk,
|
|
214
|
+
{ name: 'AES-GCM', length: 256 },
|
|
215
|
+
false,
|
|
216
|
+
[usage],
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Encrypt one field to `serviceRaw` with a fresh ephemeral key → wire hex. */
|
|
221
|
+
async function sealField(serviceRaw: Uint8Array, plaintext: Uint8Array, aad: Uint8Array): Promise<string> {
|
|
222
|
+
const eph = (await subtle.generateKey({ name: 'X25519' }, true, ['deriveBits'])) as CryptoKeyPair;
|
|
223
|
+
const ephPub = new Uint8Array(await subtle.exportKey('raw', eph.publicKey));
|
|
224
|
+
const service = await subtle.importKey('raw', bs(serviceRaw), { name: 'X25519' }, false, []);
|
|
225
|
+
const shared = new Uint8Array(await subtle.deriveBits({ name: 'X25519', public: service }, eph.privateKey, 256));
|
|
226
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
227
|
+
const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv: bs(iv), additionalData: bs(aad) }, await aesKey(shared, 'encrypt'), bs(plaintext)));
|
|
228
|
+
const blob = new Uint8Array(ephPub.length + iv.length + ct.length);
|
|
229
|
+
blob.set(ephPub);
|
|
230
|
+
blob.set(iv, ephPub.length);
|
|
231
|
+
blob.set(ct, ephPub.length + iv.length);
|
|
232
|
+
return toHex(blob);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Decrypt one field addressed to the client static key. */
|
|
236
|
+
async function openField(clientPriv: CryptoKey, blobHex: string, aad: Uint8Array): Promise<Uint8Array> {
|
|
237
|
+
const blob = fromHex(blobHex);
|
|
238
|
+
const ephPub = await subtle.importKey('raw', bs(blob.slice(0, 32)), { name: 'X25519' }, false, []);
|
|
239
|
+
const shared = new Uint8Array(await subtle.deriveBits({ name: 'X25519', public: ephPub }, clientPriv, 256));
|
|
240
|
+
const pt = await subtle.decrypt({ name: 'AES-GCM', iv: bs(blob.slice(32, 44)), additionalData: bs(aad) }, await aesKey(shared, 'decrypt'), bs(blob.slice(44)));
|
|
241
|
+
return new Uint8Array(pt);
|
|
242
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2EE associated-data (AAD) builders (§7.3). The AAD binds each ciphertext to
|
|
3
|
+
* its field path and request context; it is the JCS of a purpose-tagged object,
|
|
4
|
+
* so no component needs bespoke escaping. Provided here because clients that
|
|
5
|
+
* encrypt fields need the exact bytes — the verifier itself does not decrypt.
|
|
6
|
+
* The X25519 and secp256k1 suites share these builders; `algo` is just a field.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { canonicalize, jcsBytes } from './jcs';
|
|
10
|
+
|
|
11
|
+
/** Inputs shared by request and response AAD (§7.3). */
|
|
12
|
+
export interface AadCommon {
|
|
13
|
+
/** `algo` of the selected service E2EE key. */
|
|
14
|
+
algo: string;
|
|
15
|
+
/** The request's top-level `model`, byte-exact. */
|
|
16
|
+
model: string;
|
|
17
|
+
/** The encrypted location's field path, e.g. `messages.0.content` (§7.2). */
|
|
18
|
+
field: string;
|
|
19
|
+
/** The request's `X-E2EE-Nonce` (string). */
|
|
20
|
+
nonce: string;
|
|
21
|
+
/** The request's `X-E2EE-Timestamp` (Unix seconds, integer). */
|
|
22
|
+
ts: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Request AAD (§7.3), tag `aci.e2ee.request.v2`. Returns the canonical JCS string. */
|
|
26
|
+
export function requestAadString(params: AadCommon): string {
|
|
27
|
+
return canonicalize({
|
|
28
|
+
purpose: 'aci.e2ee.request.v2',
|
|
29
|
+
algo: params.algo,
|
|
30
|
+
model: params.model,
|
|
31
|
+
field: params.field,
|
|
32
|
+
nonce: params.nonce,
|
|
33
|
+
ts: params.ts,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Request AAD as UTF-8 bytes — the value passed to AES-GCM. */
|
|
38
|
+
export function requestAad(params: AadCommon): Uint8Array {
|
|
39
|
+
return jcsBytes({
|
|
40
|
+
purpose: 'aci.e2ee.request.v2',
|
|
41
|
+
algo: params.algo,
|
|
42
|
+
model: params.model,
|
|
43
|
+
field: params.field,
|
|
44
|
+
nonce: params.nonce,
|
|
45
|
+
ts: params.ts,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Response AAD (§7.3), tag `aci.e2ee.response.v2`. Adds the response `id` (`""` when none). */
|
|
50
|
+
export function responseAadString(params: AadCommon & { id: string }): string {
|
|
51
|
+
return canonicalize({
|
|
52
|
+
purpose: 'aci.e2ee.response.v2',
|
|
53
|
+
algo: params.algo,
|
|
54
|
+
model: params.model,
|
|
55
|
+
id: params.id,
|
|
56
|
+
field: params.field,
|
|
57
|
+
nonce: params.nonce,
|
|
58
|
+
ts: params.ts,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Response AAD as UTF-8 bytes — the value passed to AES-GCM. */
|
|
63
|
+
export function responseAad(params: AadCommon & { id: string }): Uint8Array {
|
|
64
|
+
return jcsBytes({
|
|
65
|
+
purpose: 'aci.e2ee.response.v2',
|
|
66
|
+
algo: params.algo,
|
|
67
|
+
model: params.model,
|
|
68
|
+
id: params.id,
|
|
69
|
+
field: params.field,
|
|
70
|
+
nonce: params.nonce,
|
|
71
|
+
ts: params.ts,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors raised by the verifier for conditions that are *not* ordinary
|
|
3
|
+
* verification failures. A failed check (bad signature, wrong hash) is reported
|
|
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".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Base class for every error this package throws. */
|
|
10
|
+
export class AciError extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'AciError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A JCS input violated the ACI subset (e.g. a non-integer number) or a hex/field value would not parse. */
|
|
18
|
+
export class AciFormatError extends AciError {
|
|
19
|
+
constructor(message: string) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'AciFormatError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dstack/aci-verifier — a zero-dependency ACI Level 1 verifier.
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Canonicalization (§3)
|
|
13
|
+
export { canonicalize, jcsBytes } from './jcs';
|
|
14
|
+
export type { JcsValue } from './jcs';
|
|
15
|
+
|
|
16
|
+
// Crypto primitives (Web Crypto only)
|
|
17
|
+
export {
|
|
18
|
+
sha256,
|
|
19
|
+
sha256Hex,
|
|
20
|
+
sha256Prefixed,
|
|
21
|
+
verifyEd25519,
|
|
22
|
+
verifySignature,
|
|
23
|
+
toHex,
|
|
24
|
+
fromHex,
|
|
25
|
+
} from './crypto';
|
|
26
|
+
|
|
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';
|
|
39
|
+
|
|
40
|
+
// E2EE AAD builders (§7.3)
|
|
41
|
+
export {
|
|
42
|
+
requestAad,
|
|
43
|
+
requestAadString,
|
|
44
|
+
responseAad,
|
|
45
|
+
responseAadString,
|
|
46
|
+
} from './e2ee';
|
|
47
|
+
export type { AadCommon } from './e2ee';
|
|
48
|
+
|
|
49
|
+
// E2EE channel to a verified workload — encrypt requests, decrypt replies (§7)
|
|
50
|
+
export { openE2eeChannel } from './e2ee-channel';
|
|
51
|
+
export type { E2eeChannel } from './e2ee-channel';
|
|
52
|
+
|
|
53
|
+
// Level 1 receipt verification (§10.2)
|
|
54
|
+
export {
|
|
55
|
+
verifyReceipt,
|
|
56
|
+
findEvent,
|
|
57
|
+
hashBody,
|
|
58
|
+
checkRequestBodyHash,
|
|
59
|
+
checkResponseWireHash,
|
|
60
|
+
checkResponseCleartextHash,
|
|
61
|
+
} from './receipt';
|
|
62
|
+
|
|
63
|
+
// Level 2 report-binding checks (§10.1 checks 2–6, no hardware quote)
|
|
64
|
+
export { verifyReportBinding } from './report';
|
|
65
|
+
export type { ReportBindingOptions } from './report';
|
|
66
|
+
|
|
67
|
+
// Errors
|
|
68
|
+
export { AciError, AciFormatError, UnsupportedAlgorithmError } from './errors';
|
|
69
|
+
|
|
70
|
+
// Wire & result types
|
|
71
|
+
export type {
|
|
72
|
+
PublicKey,
|
|
73
|
+
WorkloadIdentity,
|
|
74
|
+
ReceiptSigningKey,
|
|
75
|
+
WorkloadKeyset,
|
|
76
|
+
ReceiptSignature,
|
|
77
|
+
ReceiptEvent,
|
|
78
|
+
Receipt,
|
|
79
|
+
Endorsement,
|
|
80
|
+
Attestation,
|
|
81
|
+
AttestationReport,
|
|
82
|
+
SessionEvidence,
|
|
83
|
+
SessionRecord,
|
|
84
|
+
Check,
|
|
85
|
+
ReceiptVerification,
|
|
86
|
+
ReportVerification,
|
|
87
|
+
} from './types';
|