@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.
@@ -1,11 +1,7 @@
1
1
  /**
2
- * Owner Signatures (v0.7) — the `HAP-mandate-projection` object, the
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 { Mandate, MandatePayload, MandateOwnerEntry, OwnerSignatureBinding, ProfileOwnerSignatureFloor } from './types';
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: '0.7';
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: string;
52
- /** Included together, iff `commitment_mode === "review_above_cap"`. */
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 owner in `review` mode. Signing a
63
- * `reject` matters as much as a `commit`: a rejection the AS can discard is
64
- * a rejection that never happened. */
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: '0.7';
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
- /** What was approved: the ticket's contentHash where the profile binds
73
- * content; otherwise sha256 over the JCS of the proposal's argument set. */
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. Field absence is defined, not incidental:
101
- * `intent_disclosure_hash`, `above_cap_caps`/`above_cap_approvers`, and
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: '0.7',
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 (payload.commitment_mode === 'review_above_cap') {
136
- projection.above_cap_caps = payload.above_cap_caps;
137
- projection.above_cap_approvers = payload.above_cap_approvers;
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. Renamed
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 an owner signs for a per-action approval. */
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('OWNER_SIGNATURE_INVALID', (err as Error).message);
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
- const BINDING_RANK: Record<OwnerSignatureBinding, number> = { raw: 0, webauthn: 1, eudi: 2 };
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 = BINDING_RANK[entry!.binding as OwnerSignatureBinding] ?? 0;
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
+ }