@humanagencyp/hap-core 0.12.1 → 0.13.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/README.md +3 -3
- package/dist/index.d.mts +567 -123
- package/dist/index.d.ts +567 -123
- package/dist/index.js +869 -23
- package/dist/index.mjs +829 -22
- package/package.json +2 -1
- package/src/canonicalize.ts +9 -0
- package/src/commitment-modes.ts +21 -9
- package/src/errors.ts +24 -8
- package/src/gatekeeper.ts +52 -8
- package/src/index.ts +5 -1
- package/src/issuance.ts +250 -0
- package/src/mandate.ts +52 -14
- package/src/owner-signature.ts +86 -39
- package/src/profile.ts +45 -0
- package/src/proposal-content.ts +219 -0
- package/src/ticket-projection.ts +167 -0
- package/src/ticket.ts +254 -20
- package/src/types.ts +33 -5
- package/src/verifier-policy.ts +178 -0
- package/src/versions.ts +50 -19
package/src/owner-signature.ts
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Owner Signatures
|
|
3
|
-
* `HAP-approval` object, and their verification.
|
|
4
|
-
*
|
|
5
|
-
* Renamed from `mandate.ts` (protocol.md → *Migration from v0.6*: the
|
|
6
|
-
* owner-signed projection's `typ` moves from `"HAP-mandate"` to
|
|
7
|
-
* `"HAP-mandate-projection"`, disambiguating it from the AS-signed mandate
|
|
8
|
-
* itself, which now legitimately owns the name `"HAP-mandate"`).
|
|
2
|
+
* Owner Signatures — the `HAP-mandate-projection` object, the
|
|
3
|
+
* `HAP-approval` object, and their verification (v0.8; v0.7 artifacts
|
|
4
|
+
* verified under their own field lists).
|
|
9
5
|
*
|
|
10
6
|
* The human signs BEFORE the AS does, so they cannot sign the finished
|
|
11
7
|
* mandate (`mandate_id`/`issued_at` do not exist yet). They sign a mandate
|
|
@@ -15,6 +11,13 @@
|
|
|
15
11
|
* with the key carried in the owner's DID. No side channel, no second
|
|
16
12
|
* fetch, no key directory. See protocol.md → "Owner Signatures".
|
|
17
13
|
*
|
|
14
|
+
* v0.8 adds to the projection what a compromised AS could otherwise restate:
|
|
15
|
+
* `binding` and `signing_surface` (how the owner signed), `subjects_disclosed`
|
|
16
|
+
* (whether the name is attached), and `above_cap_approvals` (how many
|
|
17
|
+
* approvers). The approval gains `approver_did` (who decided) and the
|
|
18
|
+
* operation it is for (`action`, `action_type`), and always signs the
|
|
19
|
+
* proposal's content fingerprint beside the bound subset.
|
|
20
|
+
*
|
|
18
21
|
* Verification here requires NO trust in the AS. What it cannot do is tell
|
|
19
22
|
* the verifier WHOSE key signed — confirming the DID belongs to the
|
|
20
23
|
* expected person is the out-of-band step (protocol.md → "Verification
|
|
@@ -22,7 +25,10 @@
|
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
27
|
import * as ed from '@noble/ed25519';
|
|
25
|
-
import type {
|
|
28
|
+
import type {
|
|
29
|
+
Mandate, MandatePayload, MandateOwnerEntry, OwnerSignatureBinding, ProfileOwnerSignatureFloor,
|
|
30
|
+
ProtocolVersion, SigningSurface, CommitmentMode,
|
|
31
|
+
} from './types';
|
|
26
32
|
import { canonicalize } from './canonicalize';
|
|
27
33
|
import { decodeDidKey } from './did-key';
|
|
28
34
|
import { toBase64Url, fromBase64Url } from './base64url';
|
|
@@ -35,7 +41,7 @@ import type { HapErrorCode } from './errors';
|
|
|
35
41
|
export interface MandateProjection {
|
|
36
42
|
typ: 'HAP-mandate-projection';
|
|
37
43
|
/** Projection/canonicalization version. A verifier MUST pin it. */
|
|
38
|
-
version:
|
|
44
|
+
version: ProtocolVersion;
|
|
39
45
|
profile_id: string;
|
|
40
46
|
/** Covered deliberately: the owner signs the profile's BYTES, not only its id. */
|
|
41
47
|
profile_hash: string;
|
|
@@ -48,30 +54,47 @@ export interface MandateProjection {
|
|
|
48
54
|
/** Included iff the mandate carries one — binds ciphertext AND the
|
|
49
55
|
* frozen approver set, the swap this mechanism exists to stop. */
|
|
50
56
|
intent_disclosure_hash?: string;
|
|
51
|
-
commitment_mode:
|
|
52
|
-
/** Included together, iff
|
|
57
|
+
commitment_mode: CommitmentMode;
|
|
58
|
+
/** Included together, iff the mandate carries them (v0.8: under
|
|
59
|
+
* `review_above_cap`, or `review` in a group with approval-kind caps;
|
|
60
|
+
* v0.7: under `review_above_cap`). */
|
|
53
61
|
above_cap_caps?: Record<string, number>;
|
|
54
62
|
above_cap_approvers?: string[];
|
|
63
|
+
/** v0.8 only, with the other two. */
|
|
64
|
+
above_cap_approvals?: number;
|
|
55
65
|
/** Included iff the mandate narrows the profile's disclose_fields. */
|
|
56
66
|
disclose_fields?: string[];
|
|
57
67
|
/** The replay defence: the human signs how long the authority lives. */
|
|
58
68
|
expires_at: number;
|
|
59
69
|
nonce: string;
|
|
70
|
+
/** v0.8, always present: MUST equal the `mandate_owners` entry's values. */
|
|
71
|
+
binding?: OwnerSignatureBinding | string;
|
|
72
|
+
signing_surface?: SigningSurface | string;
|
|
73
|
+
/** v0.8, always present: `true` exactly when the mandate carries a
|
|
74
|
+
* `subjects` entry for the owner. */
|
|
75
|
+
subjects_disclosed?: boolean;
|
|
60
76
|
}
|
|
61
77
|
|
|
62
|
-
/** Per-action approval, signed by the
|
|
63
|
-
*
|
|
64
|
-
*
|
|
78
|
+
/** Per-action approval, signed by the decider. Signing a `reject` matters as
|
|
79
|
+
* much as a `commit`: a rejection the AS can discard is a rejection that
|
|
80
|
+
* never happened. */
|
|
65
81
|
export interface ApprovalObject {
|
|
66
82
|
typ: 'HAP-approval';
|
|
67
|
-
version:
|
|
83
|
+
version: ProtocolVersion;
|
|
84
|
+
/** v0.8 — who decided: the Mandate Owner in `review`, or an above-cap approver. */
|
|
85
|
+
approver_did?: string;
|
|
68
86
|
proposal_id: string;
|
|
69
|
-
/** Renamed from `attestation_id` in v0.7. */
|
|
70
87
|
mandate_id: string;
|
|
88
|
+
/** v0.8 — the operation the arguments are for, exactly as the ticket carries them. */
|
|
89
|
+
action?: string;
|
|
90
|
+
action_type?: string;
|
|
71
91
|
decision: 'commit' | 'reject';
|
|
72
|
-
/**
|
|
73
|
-
*
|
|
92
|
+
/** The ticket's contentHash where a content binding applies to the action;
|
|
93
|
+
* otherwise equal to `proposal_content_hash` (v0.8) / the JCS hash of the
|
|
94
|
+
* proposal's arguments (v0.7). */
|
|
74
95
|
content_hash: string;
|
|
96
|
+
/** v0.8, always present — the proposal's content fingerprint. */
|
|
97
|
+
proposal_content_hash?: string;
|
|
75
98
|
decided_at: number;
|
|
76
99
|
nonce: string;
|
|
77
100
|
}
|
|
@@ -97,13 +120,12 @@ function decodeOrThrow(signature: string, code: HapErrorCode): Uint8Array {
|
|
|
97
120
|
|
|
98
121
|
/**
|
|
99
122
|
* Rebuild the projection a given `mandate_owners` entry signed, from the
|
|
100
|
-
* mandate's own signed fields
|
|
101
|
-
*
|
|
102
|
-
* `disclose_fields` are included iff the mandate carries them.
|
|
123
|
+
* mandate's own signed fields, under the mandate's own `version`. Field
|
|
124
|
+
* absence is defined, not incidental.
|
|
103
125
|
*/
|
|
104
126
|
export function buildMandateProjection(
|
|
105
127
|
payload: MandatePayload,
|
|
106
|
-
entry: Pick<MandateOwnerEntry, 'did' | 'nonce'>,
|
|
128
|
+
entry: Pick<MandateOwnerEntry, 'did' | 'nonce' | 'binding' | 'signing_surface'>,
|
|
107
129
|
): MandateProjection {
|
|
108
130
|
const {
|
|
109
131
|
profile_id, profile_hash, bounds_hash, scope_hash, execution_context_hash,
|
|
@@ -115,9 +137,10 @@ export function buildMandateProjection(
|
|
|
115
137
|
'mandate projection requires profile_hash, bounds_hash, scope_hash and commitment_mode',
|
|
116
138
|
);
|
|
117
139
|
}
|
|
140
|
+
const version: ProtocolVersion = payload.version === '0.7' ? '0.7' : '0.8';
|
|
118
141
|
const projection: MandateProjection = {
|
|
119
142
|
typ: 'HAP-mandate-projection',
|
|
120
|
-
version
|
|
143
|
+
version,
|
|
121
144
|
profile_id,
|
|
122
145
|
profile_hash,
|
|
123
146
|
owner_did: entry.did,
|
|
@@ -132,23 +155,44 @@ export function buildMandateProjection(
|
|
|
132
155
|
if (payload.intent_disclosure_hash !== undefined) {
|
|
133
156
|
projection.intent_disclosure_hash = payload.intent_disclosure_hash;
|
|
134
157
|
}
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
158
|
+
if (version === '0.7') {
|
|
159
|
+
if (payload.commitment_mode === 'review_above_cap') {
|
|
160
|
+
projection.above_cap_caps = payload.above_cap_caps;
|
|
161
|
+
projection.above_cap_approvers = payload.above_cap_approvers;
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
// v0.8: the three above-cap fields are projection fields exactly when the
|
|
165
|
+
// mandate carries them — review_above_cap, or review in a group with
|
|
166
|
+
// approval-kind caps (Group roles, rule 7) — always together.
|
|
167
|
+
if (payload.above_cap_approvers !== undefined) {
|
|
168
|
+
projection.above_cap_caps = payload.above_cap_caps;
|
|
169
|
+
projection.above_cap_approvers = payload.above_cap_approvers;
|
|
170
|
+
projection.above_cap_approvals = payload.above_cap_approvals;
|
|
171
|
+
}
|
|
138
172
|
}
|
|
139
173
|
if (payload.disclose_fields !== undefined) {
|
|
140
174
|
projection.disclose_fields = payload.disclose_fields;
|
|
141
175
|
}
|
|
176
|
+
if (version === '0.8') {
|
|
177
|
+
if (entry.binding === undefined || entry.signing_surface === undefined) {
|
|
178
|
+
throw new OwnerSignatureError(
|
|
179
|
+
'OWNER_SIGNATURE_INVALID',
|
|
180
|
+
'a v0.8 projection requires the entry\'s binding and signing_surface',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
projection.binding = entry.binding;
|
|
184
|
+
projection.signing_surface = entry.signing_surface;
|
|
185
|
+
projection.subjects_disclosed = (payload.subjects ?? []).some((s) => s.did === entry.did);
|
|
186
|
+
}
|
|
142
187
|
return projection;
|
|
143
188
|
}
|
|
144
189
|
|
|
145
|
-
/** The exact bytes an owner signs: RFC 8785 (JCS) canonical UTF-8.
|
|
146
|
-
* from `mandateSigningBytes` in v0.7. */
|
|
190
|
+
/** The exact bytes an owner signs: RFC 8785 (JCS) canonical UTF-8. */
|
|
147
191
|
export function projectionSigningBytes(projection: MandateProjection): Uint8Array {
|
|
148
192
|
return new TextEncoder().encode(canonicalize(projection));
|
|
149
193
|
}
|
|
150
194
|
|
|
151
|
-
/** The exact bytes
|
|
195
|
+
/** The exact bytes a decider signs for a per-action approval. */
|
|
152
196
|
export function approvalSigningBytes(approval: ApprovalObject): Uint8Array {
|
|
153
197
|
return new TextEncoder().encode(canonicalize(approval));
|
|
154
198
|
}
|
|
@@ -227,11 +271,14 @@ export async function verifyOwnerSignatures(mandate: Mandate): Promise<MandateOw
|
|
|
227
271
|
|
|
228
272
|
/** Verify an approval signature against a signer's key-bearing DID. */
|
|
229
273
|
export async function verifyApproval(approval: ApprovalObject, signature: string, signerDid: string): Promise<void> {
|
|
274
|
+
if (approval.version === '0.8' && approval.approver_did !== signerDid) {
|
|
275
|
+
throw new OwnerSignatureError('APPROVAL_SIGNATURE_INVALID', `approval names approver ${approval.approver_did}, verified under ${signerDid}`);
|
|
276
|
+
}
|
|
230
277
|
let publicKey: Uint8Array;
|
|
231
278
|
try {
|
|
232
279
|
publicKey = decodeDidKey(signerDid);
|
|
233
280
|
} catch (err) {
|
|
234
|
-
throw new OwnerSignatureError('
|
|
281
|
+
throw new OwnerSignatureError('APPROVAL_SIGNATURE_INVALID', (err as Error).message);
|
|
235
282
|
}
|
|
236
283
|
const ok = await ed.verifyAsync(decodeOrThrow(signature, 'APPROVAL_SIGNATURE_INVALID'), approvalSigningBytes(approval), publicKey).catch(() => false);
|
|
237
284
|
if (!ok) {
|
|
@@ -240,8 +287,14 @@ export async function verifyApproval(approval: ApprovalObject, signature: string
|
|
|
240
287
|
}
|
|
241
288
|
|
|
242
289
|
/** Signature-assurance ranking for `OwnerSignatureBinding`, weakest first —
|
|
243
|
-
* protocol.md → *Owner Signatures* → "binding" table ordering (raw < webauthn < eudi).
|
|
244
|
-
|
|
290
|
+
* protocol.md → *Owner Signatures* → "binding" table ordering (raw < webauthn < eudi).
|
|
291
|
+
* A binding the verifier does not know ranks as `raw` (satisfies no minimum
|
|
292
|
+
* above it). */
|
|
293
|
+
export const BINDING_RANK: Record<OwnerSignatureBinding, number> = { raw: 0, webauthn: 1, eudi: 2 };
|
|
294
|
+
|
|
295
|
+
export function bindingRank(binding: string | undefined): number {
|
|
296
|
+
return BINDING_RANK[binding as OwnerSignatureBinding] ?? 0;
|
|
297
|
+
}
|
|
245
298
|
|
|
246
299
|
/**
|
|
247
300
|
* Enforce a profile's `ownerSignature` floor (protocol.md → *Owner
|
|
@@ -249,12 +302,6 @@ const BINDING_RANK: Record<OwnerSignatureBinding, number> = { raw: 0, webauthn:
|
|
|
249
302
|
* checkable without trusting the AS: `profile_id`/`profile_hash` are in the
|
|
250
303
|
* signed payload AND in the owner-signed projection).
|
|
251
304
|
*
|
|
252
|
-
* An unknown `binding` value is treated as rank 0 (weakest) rather than
|
|
253
|
-
* rejected outright — "an AS MUST NOT reject a mandate_owners entry solely
|
|
254
|
-
* because it does not recognize its binding value" extends naturally to a
|
|
255
|
-
* verifier ranking it, since crediting an unrecognized binding with
|
|
256
|
-
* unearned strength would be the more dangerous failure mode.
|
|
257
|
-
*
|
|
258
305
|
* @throws OwnerSignatureError `OWNER_SIGNATURE_REQUIRED` when the floor is
|
|
259
306
|
* unmet — "MUST fail, not warn" (protocol.md, same section).
|
|
260
307
|
*/
|
|
@@ -272,7 +319,7 @@ export function checkOwnerSignatureRequirement(
|
|
|
272
319
|
);
|
|
273
320
|
}
|
|
274
321
|
const minRank = floor.minBinding ? BINDING_RANK[floor.minBinding] : 0;
|
|
275
|
-
const haveRank =
|
|
322
|
+
const haveRank = bindingRank(entry!.binding);
|
|
276
323
|
if (haveRank < minRank) {
|
|
277
324
|
throw new OwnerSignatureError(
|
|
278
325
|
'OWNER_SIGNATURE_REQUIRED',
|
package/src/profile.ts
CHANGED
|
@@ -182,6 +182,9 @@ export function validateProfile(profile: AgentProfile): ProfileValidationError[]
|
|
|
182
182
|
}
|
|
183
183
|
const seen = new Set<unknown>();
|
|
184
184
|
for (const m of declaredModes) {
|
|
185
|
+
// Rule 8: an entry review_above_cap is ignored — the mode follows from
|
|
186
|
+
// automatic alone — so it is neither an error nor an admission.
|
|
187
|
+
if (m === 'review_above_cap') continue;
|
|
185
188
|
if (!(SIGNABLE_COMMITMENT_MODES as readonly string[]).includes(m as string)) {
|
|
186
189
|
errors.push({
|
|
187
190
|
code: 'PROFILE_INVALID',
|
|
@@ -206,6 +209,48 @@ export function validateProfile(profile: AgentProfile): ProfileValidationError[]
|
|
|
206
209
|
});
|
|
207
210
|
}
|
|
208
211
|
|
|
212
|
+
// ── v0.8: maximum, enforcedBy, disclose_fields, content_binding ─────────
|
|
213
|
+
if (boundsSchema) {
|
|
214
|
+
const readFields = new Set<string>();
|
|
215
|
+
for (const [fieldName, fieldDef] of Object.entries(boundsSchema.fields)) {
|
|
216
|
+
const bt = fieldDef.boundType;
|
|
217
|
+
if (bt && (bt.kind === 'per_transaction' || bt.kind === 'cumulative_sum')) readFields.add(bt.of);
|
|
218
|
+
const max = (fieldDef as { maximum?: unknown }).maximum;
|
|
219
|
+
if (max !== undefined && (typeof max !== 'number' || !Number.isFinite(max))) {
|
|
220
|
+
errors.push({
|
|
221
|
+
code: 'PROFILE_INVALID',
|
|
222
|
+
field: fieldName,
|
|
223
|
+
message: `Bounds field "${fieldName}": maximum MUST be a number (Bounds Schema rule 9).`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const enforcedBy = (fieldDef as { enforcedBy?: unknown }).enforcedBy;
|
|
227
|
+
if (enforcedBy !== undefined && enforcedBy !== 'gatekeeper') {
|
|
228
|
+
errors.push({
|
|
229
|
+
code: 'PROFILE_INVALID',
|
|
230
|
+
field: fieldName,
|
|
231
|
+
message: `Bounds field "${fieldName}": enforcedBy MUST be "gatekeeper" when present (Bounds Schema rule 11).`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const f of profile.disclose_fields ?? []) {
|
|
236
|
+
if (!readFields.has(f)) {
|
|
237
|
+
errors.push({
|
|
238
|
+
code: 'PROFILE_INVALID',
|
|
239
|
+
field: 'disclose_fields',
|
|
240
|
+
message: `disclose_fields names "${f}", which no bound reads — only what a ticket carries can be disclosed.`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const binding = profile.content_binding;
|
|
246
|
+
if (binding && String(binding.version) === '2' && binding.kind === 'text') {
|
|
247
|
+
errors.push({
|
|
248
|
+
code: 'PROFILE_INVALID',
|
|
249
|
+
field: 'content_binding',
|
|
250
|
+
message: 'A version-2 content_binding is defined for kind "jcs" only; kind "text" is invalid.',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
209
254
|
return errors;
|
|
210
255
|
}
|
|
211
256
|
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proposal content — what the Authority Server receives of a proposal
|
|
3
|
+
* (protocol.md → *Proposals — what the Authority Server receives*, v0.8).
|
|
4
|
+
*
|
|
5
|
+
* The content is the action's argument object, serialized as RFC 8785 JCS.
|
|
6
|
+
* Its fingerprint, `proposalContentHash`, is what the AS compares, what the
|
|
7
|
+
* ticket carries and what every `HAP-approval` signs. The content itself is
|
|
8
|
+
* encrypted to its readers — the Mandate Owner and every signed decider —
|
|
9
|
+
* with exactly the construction rule 3 pins byte for byte:
|
|
10
|
+
*
|
|
11
|
+
* - a fresh 32-byte content key (CEK) per proposal;
|
|
12
|
+
* - AES-256-GCM under the CEK, random 12-byte IV, no associated data,
|
|
13
|
+
* 16-byte tag; `ciphertext` = IV ‖ GCM ciphertext ‖ tag;
|
|
14
|
+
* - per reader, HPKE (RFC 9180) base mode, DHKEM(X25519, HKDF-SHA256),
|
|
15
|
+
* HKDF-SHA256, AES-256-GCM, empty `info`, the CEK sealed single-shot with
|
|
16
|
+
* empty associated data; `enc` the 32-byte encapsulated key, `ct` the
|
|
17
|
+
* 48-byte sealed CEK;
|
|
18
|
+
* - every binary value base64url without padding; `encrypted_keys` keyed by
|
|
19
|
+
* the reader's DID. The reader key is an X25519 key distinct from the
|
|
20
|
+
* DID's signing key.
|
|
21
|
+
*
|
|
22
|
+
* The conformance vector `proposal-encryption.json` fixes the CEK, the IV
|
|
23
|
+
* and the ephemeral keys so the output is reproducible; real use draws all
|
|
24
|
+
* three at random, and the deterministic hooks exist for that test only.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createHash, type webcrypto } from 'crypto';
|
|
28
|
+
import { CipherSuite, DhkemX25519HkdfSha256, HkdfSha256, Aes256Gcm } from '@hpke/core';
|
|
29
|
+
import { canonicalize } from './canonicalize';
|
|
30
|
+
import { toBase64Url, fromBase64Url } from './base64url';
|
|
31
|
+
import { HapError } from './errors';
|
|
32
|
+
|
|
33
|
+
export interface ProposalContentEnvelope {
|
|
34
|
+
ciphertext: string;
|
|
35
|
+
encrypted_keys: Record<string, { ct: string; enc: string }>;
|
|
36
|
+
readers: string[];
|
|
37
|
+
unreadable: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ReaderKeyPair {
|
|
41
|
+
/** Raw X25519 private key, 32 bytes. */
|
|
42
|
+
privateKey: Uint8Array;
|
|
43
|
+
/** Raw X25519 public key, 32 bytes. */
|
|
44
|
+
publicKey: Uint8Array;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function suite(): CipherSuite {
|
|
48
|
+
return new CipherSuite({ kem: new DhkemX25519HkdfSha256(), kdf: new HkdfSha256(), aead: new Aes256Gcm() });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const webCrypto = (): webcrypto.Crypto => {
|
|
52
|
+
const c = globalThis.crypto as webcrypto.Crypto | undefined;
|
|
53
|
+
if (!c?.subtle) throw new Error('WebCrypto is not available in this runtime');
|
|
54
|
+
return c;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The content's JCS bytes, as a string (rule 1). */
|
|
58
|
+
export function proposalContentJcs(args: Record<string, unknown>): string {
|
|
59
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args)) {
|
|
60
|
+
throw new HapError('PROPOSAL_NOT_ENCRYPTED', 'proposal content is one object — the action\'s argument object');
|
|
61
|
+
}
|
|
62
|
+
return canonicalize(args);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `proposalContentHash` — `"sha256:" + hex(sha256(JCS(content)))` (rule 4). */
|
|
66
|
+
export function computeProposalContentHash(args: Record<string, unknown>): string {
|
|
67
|
+
return `sha256:${createHash('sha256').update(proposalContentJcs(args), 'utf8').digest('hex')}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A fresh reader key pair (X25519). */
|
|
71
|
+
export async function generateReaderKeyPair(): Promise<ReaderKeyPair> {
|
|
72
|
+
const kp = await suite().kem.generateKeyPair();
|
|
73
|
+
return exportKeyPair(kp);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A reader key pair derived from input keying material (RFC 9180
|
|
77
|
+
* DeriveKeyPair) — deterministic; for tests and vectors. */
|
|
78
|
+
export async function deriveReaderKeyPair(ikm: Uint8Array): Promise<ReaderKeyPair> {
|
|
79
|
+
const kp = await suite().kem.deriveKeyPair(ikm);
|
|
80
|
+
return exportKeyPair(kp);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function exportKeyPair(kp: webcrypto.CryptoKeyPair): Promise<ReaderKeyPair> {
|
|
84
|
+
const s = suite();
|
|
85
|
+
return {
|
|
86
|
+
privateKey: new Uint8Array(await s.kem.serializePrivateKey(kp.privateKey)),
|
|
87
|
+
publicKey: new Uint8Array(await s.kem.serializePublicKey(kp.publicKey)),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface EncryptOptions {
|
|
92
|
+
/** Signed deciders for whom no reader key exists; listed, notified, unable to decide. */
|
|
93
|
+
unreadable?: string[];
|
|
94
|
+
/** Deterministic inputs — TESTS AND VECTORS ONLY. */
|
|
95
|
+
deterministic?: {
|
|
96
|
+
contentKey: Uint8Array;
|
|
97
|
+
iv: Uint8Array;
|
|
98
|
+
/** Per reader DID: the ephemeral key material for its HPKE wrap. */
|
|
99
|
+
ephemeralIkm: Record<string, Uint8Array>;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Encrypt a proposal's content to its readers and compute its fingerprint.
|
|
105
|
+
* @param readers Each reader's DID and raw X25519 reader public key.
|
|
106
|
+
*/
|
|
107
|
+
export async function encryptProposalContent(
|
|
108
|
+
args: Record<string, unknown>,
|
|
109
|
+
readers: ReadonlyArray<{ did: string; publicKey: Uint8Array }>,
|
|
110
|
+
opts: EncryptOptions = {},
|
|
111
|
+
): Promise<{ content: ProposalContentEnvelope; proposalContentHash: string }> {
|
|
112
|
+
if (readers.length === 0) {
|
|
113
|
+
throw new HapError('PROPOSAL_NOT_ENCRYPTED', 'a proposal has at least one reader (the Mandate Owner)');
|
|
114
|
+
}
|
|
115
|
+
const jcs = proposalContentJcs(args);
|
|
116
|
+
const proposalContentHash = computeProposalContentHash(args);
|
|
117
|
+
const crypto = webCrypto();
|
|
118
|
+
const s = suite();
|
|
119
|
+
|
|
120
|
+
const cek = opts.deterministic?.contentKey ?? crypto.getRandomValues(new Uint8Array(32));
|
|
121
|
+
const iv = opts.deterministic?.iv ?? crypto.getRandomValues(new Uint8Array(12));
|
|
122
|
+
if (cek.length !== 32 || iv.length !== 12) {
|
|
123
|
+
throw new HapError('PROPOSAL_NOT_ENCRYPTED', 'content key is 32 bytes and IV is 12 bytes');
|
|
124
|
+
}
|
|
125
|
+
const aesKey = await crypto.subtle.importKey('raw', cek, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
|
|
126
|
+
const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, new TextEncoder().encode(jcs)));
|
|
127
|
+
const ciphertext = new Uint8Array(iv.length + sealed.length);
|
|
128
|
+
ciphertext.set(iv, 0);
|
|
129
|
+
ciphertext.set(sealed, iv.length);
|
|
130
|
+
|
|
131
|
+
const encrypted_keys: Record<string, { ct: string; enc: string }> = {};
|
|
132
|
+
for (const { did, publicKey } of readers) {
|
|
133
|
+
const recipientPublicKey = await s.kem.deserializePublicKey(publicKey);
|
|
134
|
+
const ekmIkm = opts.deterministic?.ephemeralIkm[did];
|
|
135
|
+
const sender = await s.createSenderContext({
|
|
136
|
+
recipientPublicKey,
|
|
137
|
+
...(ekmIkm ? { ekm: await s.kem.deriveKeyPair(ekmIkm) } : {}),
|
|
138
|
+
});
|
|
139
|
+
const ct = new Uint8Array(await sender.seal(cek));
|
|
140
|
+
encrypted_keys[did] = { ct: toBase64Url(ct), enc: toBase64Url(new Uint8Array(sender.enc)) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
content: {
|
|
145
|
+
ciphertext: toBase64Url(ciphertext),
|
|
146
|
+
encrypted_keys,
|
|
147
|
+
readers: readers.map((r) => r.did),
|
|
148
|
+
unreadable: [...(opts.unreadable ?? [])],
|
|
149
|
+
},
|
|
150
|
+
proposalContentHash,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Decrypt a proposal's content as one reader. A reader whose HPKE open or
|
|
156
|
+
* GCM decryption fails MUST treat the proposal as unreadable — never as
|
|
157
|
+
* empty or partial content (rule 3); this throws rather than returning.
|
|
158
|
+
* @returns the argument object and its JCS, so the caller recomputes the
|
|
159
|
+
* fingerprint and refuses on a mismatch (rule 8).
|
|
160
|
+
*/
|
|
161
|
+
export async function decryptProposalContent(
|
|
162
|
+
content: ProposalContentEnvelope,
|
|
163
|
+
readerDid: string,
|
|
164
|
+
readerPrivateKey: Uint8Array,
|
|
165
|
+
): Promise<{ arguments: Record<string, unknown>; contentJcs: string; proposalContentHash: string }> {
|
|
166
|
+
const wrap = content.encrypted_keys[readerDid];
|
|
167
|
+
if (!wrap) throw new HapError('PROPOSAL_NOT_ENCRYPTED', `no key wrapped for reader ${readerDid}`);
|
|
168
|
+
const s = suite();
|
|
169
|
+
const crypto = webCrypto();
|
|
170
|
+
let cek: Uint8Array;
|
|
171
|
+
try {
|
|
172
|
+
const recipient = await s.createRecipientContext({
|
|
173
|
+
recipientKey: await s.kem.deserializePrivateKey(readerPrivateKey),
|
|
174
|
+
enc: fromBase64Url(wrap.enc),
|
|
175
|
+
});
|
|
176
|
+
cek = new Uint8Array(await recipient.open(fromBase64Url(wrap.ct)));
|
|
177
|
+
} catch (err) {
|
|
178
|
+
throw new HapError('PROPOSAL_NOT_ENCRYPTED', `proposal content is unreadable for ${readerDid}: ${(err as Error).message}`);
|
|
179
|
+
}
|
|
180
|
+
const bytes = fromBase64Url(content.ciphertext);
|
|
181
|
+
if (bytes.length < 12 + 16) throw new HapError('PROPOSAL_NOT_ENCRYPTED', 'ciphertext is shorter than IV + tag');
|
|
182
|
+
let plaintext: Uint8Array;
|
|
183
|
+
try {
|
|
184
|
+
const aesKey = await crypto.subtle.importKey('raw', cek, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
|
|
185
|
+
plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: bytes.subarray(0, 12) }, aesKey, bytes.subarray(12)));
|
|
186
|
+
} catch (err) {
|
|
187
|
+
throw new HapError('PROPOSAL_NOT_ENCRYPTED', `proposal content is unreadable for ${readerDid}: ${(err as Error).message}`);
|
|
188
|
+
}
|
|
189
|
+
const contentJcs = new TextDecoder().decode(plaintext);
|
|
190
|
+
let args: Record<string, unknown>;
|
|
191
|
+
try {
|
|
192
|
+
args = JSON.parse(contentJcs);
|
|
193
|
+
} catch {
|
|
194
|
+
throw new HapError('PROPOSAL_NOT_ENCRYPTED', 'decrypted content is not JSON');
|
|
195
|
+
}
|
|
196
|
+
return { arguments: args, contentJcs, proposalContentHash: computeProposalContentHash(args) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The reader-set rules an Authority Server applies at submission (rule 2):
|
|
201
|
+
* `readers` ∪ `unreadable` equals the owner plus the signed deciders;
|
|
202
|
+
* `readers` equals the key set of `encrypted_keys`.
|
|
203
|
+
* @throws HapError `PROPOSAL_APPROVERS_INVALID` | `PROPOSAL_NOT_ENCRYPTED`
|
|
204
|
+
*/
|
|
205
|
+
export function checkProposalReaders(
|
|
206
|
+
content: ProposalContentEnvelope,
|
|
207
|
+
expected: { ownerDid: string; deciders: readonly string[] },
|
|
208
|
+
): void {
|
|
209
|
+
const want = new Set([expected.ownerDid, ...expected.deciders]);
|
|
210
|
+
const have = new Set([...content.readers, ...content.unreadable]);
|
|
211
|
+
if (want.size !== have.size || [...want].some((d) => !have.has(d))) {
|
|
212
|
+
throw new HapError('PROPOSAL_APPROVERS_INVALID', 'readers ∪ unreadable differs from the owner plus the signed deciders');
|
|
213
|
+
}
|
|
214
|
+
const keyed = new Set(Object.keys(content.encrypted_keys));
|
|
215
|
+
const readers = new Set(content.readers);
|
|
216
|
+
if (keyed.size !== readers.size || [...readers].some((d) => !keyed.has(d))) {
|
|
217
|
+
throw new HapError('PROPOSAL_NOT_ENCRYPTED', 'readers differs from the key set of encrypted_keys');
|
|
218
|
+
}
|
|
219
|
+
}
|