@humanagencyp/hap-core 0.8.3 → 0.9.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/src/receipt.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Execution receipts (HAP v0.5/v0.6) — the wire type and holder-side
3
+ * verification. Until v0.6 this library had no receipt surface at all; every
4
+ * verifier hand-assembled the strip→canonicalize→verify dance. This module is
5
+ * that dance, once.
6
+ *
7
+ * Receipt payloads are camelCase on the wire (unlike the snake_case
8
+ * attestation payload) — a shipped inconsistency v0.6 chose to document
9
+ * rather than break (changelog.md → "Renames and terminology").
10
+ */
11
+
12
+ import * as ed from '@noble/ed25519';
13
+ import type { ContentBinding, Subject } from './types';
14
+ import { canonicalize } from './canonicalize';
15
+
16
+ /** Signed execution receipt — protocol.md → "Receipt Payload Schema". */
17
+ export interface ReceiptPayload {
18
+ id: string;
19
+ groupId?: string | null;
20
+ userId?: string;
21
+ boundsHash: string;
22
+ profileId: string;
23
+ /** Downstream tool name. Audit metadata only — never a dispatch key. */
24
+ action: string;
25
+ /** Semantic category — drives cumulative bucketing and bounds dispatch. */
26
+ actionType: string;
27
+ executionContext: Record<string, unknown>;
28
+ cumulativeState?: Record<string, unknown>;
29
+ limits?: Record<string, unknown>;
30
+ timestamp: number;
31
+ /** v0.6 — hash of the bound content, computed by the Gatekeeper, copied
32
+ * verbatim by the AS. The AS never sees the content. */
33
+ contentHash?: string;
34
+ /** v0.6 — the binding declaration echoed into the signed receipt, so a
35
+ * verifier knows exactly what `contentHash` covers. */
36
+ contentBinding?: Pick<ContentBinding, 'version' | 'kind' | 'fields' | 'required_fields' | 'appliesTo'>;
37
+ /** v0.6 — disclosed subset of the attestation's identity overlay. */
38
+ subjects?: Subject[];
39
+ /** v0.6 — review path: the proposal this receipt executed. */
40
+ proposalId?: string;
41
+ /** v0.6 — review path: the owner's HAP-approval signature (or its sha256)
42
+ * for the executed proposal. */
43
+ approvalSignature?: string;
44
+ /** Ed25519 over the JCS-canonical payload (this field excluded), base64url. */
45
+ signature: string;
46
+ }
47
+
48
+ /**
49
+ * Verify a receipt's AS signature: strip `signature`, JCS-canonicalize the
50
+ * rest, Ed25519-verify against the AS public key (hex). This is holder-side
51
+ * verification — it needs the COMPLETE receipt; a redacted public view cannot
52
+ * be re-verified this way (protocol.md → "Receipt Verification").
53
+ *
54
+ * Content is checked separately: where `contentHash` is present, recompute it
55
+ * from the held artifact via the content-binding module and compare.
56
+ *
57
+ * @throws Error prefixed `INVALID_SIGNATURE:` when verification fails.
58
+ */
59
+ export async function verifyReceiptSignature(receipt: ReceiptPayload, publicKeyHex: string): Promise<void> {
60
+ const { signature, ...unsigned } = receipt;
61
+ if (!signature) {
62
+ throw new Error('INVALID_SIGNATURE: receipt carries no signature');
63
+ }
64
+ const bytes = new TextEncoder().encode(canonicalize(unsigned));
65
+ const base64 = signature.replace(/-/g, '+').replace(/_/g, '/');
66
+ const padding = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
67
+ const sigBytes = new Uint8Array(Buffer.from(base64 + padding, 'base64'));
68
+ const publicKeyBytes = new Uint8Array(Buffer.from(publicKeyHex, 'hex'));
69
+
70
+ const ok = await ed.verifyAsync(sigBytes, bytes, publicKeyBytes).catch(() => false);
71
+ if (!ok) {
72
+ throw new Error('INVALID_SIGNATURE: receipt signature verification failed');
73
+ }
74
+ }
package/src/types.ts CHANGED
@@ -19,7 +19,7 @@ export interface ResolvedDomain {
19
19
 
20
20
  export interface AttestationPayload {
21
21
  attestation_id: string;
22
- version: '0.3' | '0.4' | '0.5';
22
+ version: '0.3' | '0.4' | '0.5' | '0.6';
23
23
  profile_id: string;
24
24
  /** v0.3 (deprecated) — hash of the authorization frame */
25
25
  frame_hash?: string;
@@ -67,12 +67,55 @@ export interface AttestationPayload {
67
67
  *
68
68
  * Cryptographically bound in the signed payload so a compromised SP cannot
69
69
  * silently flip 'review' → 'automatic'. Absent on v0.3 attestations.
70
+ * `review_above_cap` (v0.5): automatic below the signed caps, proposal above.
70
71
  */
71
- commitment_mode?: 'automatic' | 'review';
72
+ commitment_mode?: 'automatic' | 'review' | 'review_above_cap';
73
+ /**
74
+ * v0.6 (Owner Mandate Signatures) — the Decision Owner's own signature(s)
75
+ * over the mandate projection ({@link MandateProjection}), one entry per
76
+ * co-signing owner. Carried INSIDE the AS-signed payload, so the AS attests
77
+ * to having received it and cannot strip it without invalidating its own
78
+ * signature. Optional and additive: an attestation without it behaves as
79
+ * before (the v0.5 posture — the AS asserts, nobody co-signs).
80
+ */
81
+ owner_mandates?: OwnerMandate[];
72
82
  issued_at: number;
73
83
  expires_at: number;
74
84
  }
75
85
 
86
+ /** Mandate-assurance axis — what custody signed the mandate. Independent of
87
+ * identity assurance ({@link Subject.method}). */
88
+ export type MandateBinding = 'raw' | 'webauthn' | 'eudi';
89
+
90
+ /** Which surface showed the owner what they signed. A DECLARATION, not a proof
91
+ * — no verifier can check it; see protocol.md → Owner Mandate Signatures. */
92
+ export type SigningSurface = 'gatekeeper_local' | 'as_web' | 'wallet_display';
93
+
94
+ /**
95
+ * v0.6 — one owner's signature over the mandate projection.
96
+ *
97
+ * There is deliberately NO `public_key` field, and it MUST NOT be added: the
98
+ * verification key is carried in the DID itself (key-bearing `did:key`), so a
99
+ * non-key-bearing DID fails STRUCTURALLY instead of validating against a key
100
+ * the AS could have substituted. `alg` is kept only as curve-confusion
101
+ * hygiene; if it disagrees with the DID's key type, the DID is authoritative.
102
+ */
103
+ export interface OwnerMandate {
104
+ /** The signing owner's key-bearing DID. MUST be a member of `resolved_owners`. */
105
+ did: string;
106
+ /** Signature algorithm hint. The DID's multicodec wins on any disagreement. */
107
+ alg: 'EdDSA' | 'ES256';
108
+ /** base64url (no padding) signature over the JCS bytes of the mandate projection. */
109
+ signature: string;
110
+ /** When the owner signed (unix seconds). */
111
+ signed_at: number;
112
+ /** Defence-in-depth against duplicate issuance by an HONEST AS only —
113
+ * AS-side nonce enforcement is no defence against the AS itself. */
114
+ nonce: string;
115
+ binding: MandateBinding;
116
+ signing_surface?: SigningSurface;
117
+ }
118
+
76
119
  /**
77
120
  * v0.6 Identity Assurance — a signed overlay binding a Decision Owner's verified
78
121
  * real-world identity to the attestation, gated by HOW the identity was verified.
@@ -98,8 +141,11 @@ export interface Subject {
98
141
  /** When the underlying verification was performed (unix seconds). */
99
142
  verified_at?: number;
100
143
  /**
101
- * eudi only (Phase 2) — the owner's per-event wallet signature, making the
102
- * identity claim non-repudiable independent of the AS. Null/absent otherwise.
144
+ * @deprecated v0.6 — replaced by {@link AttestationPayload.owner_mandates}.
145
+ * This field signed the wrong object (the identity claim, not what was
146
+ * committed to) and was welded to one method. Implementations MUST NOT emit
147
+ * it; verifiers MAY ignore it on artifacts that carry it. Kept readable for
148
+ * audit of pre-0.6 artifacts only.
103
149
  */
104
150
  owner_signature?: string | null;
105
151
  }
@@ -340,6 +386,15 @@ export interface ProfileContextField {
340
386
  description?: string;
341
387
  displayName?: string;
342
388
  format?: 'email' | 'domain' | 'url' | 'currency';
389
+ /**
390
+ * v0.6 — what this field NAMES on the read path. `counterparty` = the other
391
+ * party to a communication (matched against an item's participants);
392
+ * `resource` = the container an item belongs to (a direct attribute match —
393
+ * and a resource scope enforced on writes MUST also bind reads). Absent →
394
+ * implementations MAY infer `counterparty` from `format: email|domain`
395
+ * (transitional; the explicit declaration is normative).
396
+ */
397
+ scopeKind?: 'counterparty' | 'resource';
343
398
  constraint?: FieldConstraint;
344
399
  enum?: string[];
345
400
  }
@@ -636,7 +691,28 @@ export interface GatekeeperRequest {
636
691
  * Structured error from Gatekeeper verification.
637
692
  */
638
693
  export interface GatekeeperError {
639
- code: 'BOUND_EXCEEDED' | 'CUMULATIVE_LIMIT_EXCEEDED' | 'INVALID_SIGNATURE' | 'TTL_EXPIRED' | 'FRAME_MISMATCH' | 'BOUNDS_MISMATCH' | 'CONTEXT_MISMATCH' | 'DOMAIN_NOT_COVERED' | 'INVALID_PROFILE' | 'MALFORMED_ATTESTATION';
694
+ code:
695
+ | 'BOUND_EXCEEDED'
696
+ | 'CUMULATIVE_LIMIT_EXCEEDED'
697
+ | 'INVALID_SIGNATURE'
698
+ | 'TTL_EXPIRED'
699
+ | 'FRAME_MISMATCH'
700
+ | 'BOUNDS_MISMATCH'
701
+ | 'CONTEXT_MISMATCH'
702
+ | 'DOMAIN_NOT_COVERED'
703
+ | 'INVALID_PROFILE'
704
+ | 'MALFORMED_ATTESTATION'
705
+ // v0.5/v0.6 receipt-path codes a Gatekeeper must recognize as definitive
706
+ // rejections (protocol.md → Pre-flight Receipt Request):
707
+ | 'INVALID_ACTION_TYPE'
708
+ | 'ATTESTATION_REVOKED'
709
+ | 'APPROVAL_REQUIRED'
710
+ | 'IDEMPOTENCY_MISMATCH'
711
+ // v0.6 owner-mandate codes (protocol.md → Owner Mandate Signatures):
712
+ | 'MANDATE_SIGNATURE_REQUIRED'
713
+ | 'MANDATE_SIGNATURE_INVALID'
714
+ | 'APPROVAL_SIGNATURE_REQUIRED'
715
+ | 'APPROVAL_SIGNATURE_INVALID';
640
716
  field?: string;
641
717
  message: string;
642
718
  bound?: string | number;