@humanagencyp/hap-core 0.11.1 → 0.12.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.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Version negotiation — protocol.md → *Version negotiation (v0.7)*.
3
+ *
4
+ * "Gatekeepers and Authority Servers MUST reject unknown or untrusted
5
+ * versions" is a safety rule, not a transition plan; this module is the
6
+ * transition plan. A mandate request carries `supported_versions`; the AS
7
+ * issues at the highest version both sides support, or refuses with
8
+ * `VERSION_UNSUPPORTED` where there is none. A request without the field is
9
+ * read as `["0.6"]` (rule 1) — but this package issues and verifies `0.7`
10
+ * only (CLAUDE.md "Decided by the owner": no backward verify path), so that
11
+ * default exists to make the refusal message honest, not to accept the
12
+ * mandate.
13
+ */
14
+ import { HapError } from './errors';
15
+
16
+ /** The protocol version this package issues and verifies. */
17
+ export const PROTOCOL_VERSION = '0.7';
18
+
19
+ /** The protocol versions this package can verify and enforce. */
20
+ export const SUPPORTED_VERSIONS: readonly string[] = ['0.7'];
21
+
22
+ /**
23
+ * The highest version both the requester and this implementation support.
24
+ *
25
+ * @param requested `supported_versions` from the mandate request. Absent →
26
+ * read as `["0.6"]` (protocol.md rule 1) — which, against this package's
27
+ * `SUPPORTED_VERSIONS`, has no overlap and refuses honestly rather than
28
+ * silently assuming `0.7`.
29
+ * @throws HapError `VERSION_UNSUPPORTED` when no version is common to both.
30
+ */
31
+ export function negotiateVersion(
32
+ requested: readonly string[] | undefined,
33
+ supported: readonly string[] = SUPPORTED_VERSIONS,
34
+ ): string {
35
+ const requestedVersions = requested ?? ['0.6'];
36
+ // Highest-first so a requester offering several supported versions gets the
37
+ // newest one both sides agree on, not merely the first common element.
38
+ const common = supported.filter((v) => requestedVersions.includes(v)).sort().reverse();
39
+ if (common.length === 0) {
40
+ throw new HapError(
41
+ 'VERSION_UNSUPPORTED',
42
+ `no protocol version in [${requestedVersions.join(', ')}] is supported by this implementation ` +
43
+ `(supports [${supported.join(', ')}])`,
44
+ );
45
+ }
46
+ return common[0];
47
+ }
@@ -1,173 +0,0 @@
1
- /**
2
- * Attestation Encoding, Decoding, and Verification
3
- *
4
- * Supports both v0.3 (frame_hash) and v0.4 (bounds_hash + context_hash).
5
- */
6
-
7
- import { createHash } from 'crypto';
8
- import * as ed from '@noble/ed25519';
9
- import type { Attestation, AttestationPayload } from './types';
10
- import { canonicalize } from './canonicalize';
11
-
12
- /**
13
- * Decodes a base64url-encoded attestation blob.
14
- */
15
- export function decodeAttestationBlob(blob: string): Attestation {
16
- try {
17
- const base64 = blob.replace(/-/g, '+').replace(/_/g, '/');
18
- const padding = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
19
- const json = Buffer.from(base64 + padding, 'base64').toString('utf8');
20
- return JSON.parse(json);
21
- } catch {
22
- throw new Error('MALFORMED_ATTESTATION: Failed to decode attestation blob');
23
- }
24
- }
25
-
26
- /**
27
- * Encodes an attestation as a base64url blob (no padding).
28
- */
29
- export function encodeAttestationBlob(attestation: Attestation): string {
30
- const json = JSON.stringify(attestation);
31
- const base64 = Buffer.from(json, 'utf8').toString('base64');
32
- return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
33
- }
34
-
35
- /**
36
- * Computes the attestation ID (hash of the blob).
37
- */
38
- export function attestationId(blob: string): string {
39
- const hash = createHash('sha256').update(blob, 'utf8').digest('hex');
40
- return `sha256:${hash}`;
41
- }
42
-
43
- /**
44
- * Verifies an attestation signature using the SP public key.
45
- *
46
- * @returns true if valid
47
- * @throws Error with code prefix if invalid
48
- */
49
- export async function verifyAttestationSignature(
50
- attestation: Attestation,
51
- publicKeyHex: string
52
- ): Promise<void> {
53
- try {
54
- // JCS-canonical bytes (RFC 8785) — must match the canonicalization the
55
- // signer used. The AS signs the same canonical form (see keys.ts).
56
- const payloadJson = canonicalize(attestation.payload);
57
- const payloadBytes = new TextEncoder().encode(payloadJson);
58
- const signatureBytes = Buffer.from(attestation.signature, 'base64');
59
- const publicKeyBytes = Buffer.from(publicKeyHex, 'hex');
60
-
61
- const isValid = await ed.verifyAsync(signatureBytes, payloadBytes, publicKeyBytes);
62
-
63
- if (!isValid) {
64
- throw new Error('INVALID_SIGNATURE: Attestation signature verification failed');
65
- }
66
- } catch (error) {
67
- if (error instanceof Error && error.message.startsWith('INVALID_SIGNATURE')) throw error;
68
- throw new Error(`INVALID_SIGNATURE: Signature verification error: ${error}`);
69
- }
70
- }
71
-
72
- /**
73
- * Checks if an attestation has expired.
74
- *
75
- * @throws Error if expired
76
- */
77
- export function checkAttestationExpiry(
78
- payload: AttestationPayload,
79
- now: number = Math.floor(Date.now() / 1000)
80
- ): void {
81
- if (payload.expires_at <= now) {
82
- throw new Error(
83
- `TTL_EXPIRED: Attestation expired at ${payload.expires_at}, current time is ${now}`
84
- );
85
- }
86
- }
87
-
88
- /**
89
- * Verifies that the frame hash in the attestation matches the expected hash (v0.3).
90
- *
91
- * @throws Error if frame hash doesn't match
92
- */
93
- export function verifyFrameHash(attestation: Attestation, expectedFrameHash: string): void {
94
- if (attestation.payload.frame_hash !== expectedFrameHash) {
95
- throw new Error('FRAME_MISMATCH: Frame hash mismatch');
96
- }
97
- }
98
-
99
- /**
100
- * Detects whether an attestation is v0.4 (has bounds_hash + context_hash)
101
- * or v0.3 (has frame_hash).
102
- *
103
- * Migration rule: if attestation has frame_hash but not bounds_hash, it is v0.3.
104
- */
105
- export function isV4Attestation(attestation: Attestation): boolean {
106
- return attestation.payload.bounds_hash !== undefined;
107
- }
108
-
109
- /**
110
- * Verifies that the bounds hash in the attestation matches the expected hash (v0.4).
111
- *
112
- * @throws Error if bounds hash doesn't match
113
- */
114
- export function verifyBoundsHash(attestation: Attestation, expectedBoundsHash: string): void {
115
- // Migration: if attestation has frame_hash but not bounds_hash, treat frame_hash as bounds_hash
116
- const attestedHash = attestation.payload.bounds_hash ?? attestation.payload.frame_hash;
117
- if (attestedHash !== expectedBoundsHash) {
118
- throw new Error('BOUNDS_MISMATCH: Bounds hash mismatch');
119
- }
120
- }
121
-
122
- /**
123
- * Verifies that the context hash in the attestation matches the expected hash (v0.4).
124
- *
125
- * @throws Error if context hash doesn't match
126
- */
127
- export function verifyContextHash(attestation: Attestation, expectedContextHash: string): void {
128
- if (attestation.payload.context_hash !== expectedContextHash) {
129
- throw new Error('CONTEXT_MISMATCH: Context hash mismatch');
130
- }
131
- }
132
-
133
- /**
134
- * Full attestation verification (signature + expiry + frame hash) — v0.3.
135
- *
136
- * @returns The decoded attestation payload
137
- * @throws Error on any validation failure
138
- */
139
- export async function verifyAttestation(
140
- blob: string,
141
- publicKeyHex: string,
142
- expectedFrameHash: string
143
- ): Promise<AttestationPayload> {
144
- const attestation = decodeAttestationBlob(blob);
145
-
146
- await verifyAttestationSignature(attestation, publicKeyHex);
147
- checkAttestationExpiry(attestation.payload);
148
- verifyFrameHash(attestation, expectedFrameHash);
149
-
150
- return attestation.payload;
151
- }
152
-
153
- /**
154
- * Full attestation verification for v0.4 (signature + expiry + bounds_hash + context_hash).
155
- *
156
- * @returns The decoded attestation payload
157
- * @throws Error on any validation failure
158
- */
159
- export async function verifyAttestationV4(
160
- blob: string,
161
- publicKeyHex: string,
162
- expectedBoundsHash: string,
163
- expectedContextHash: string
164
- ): Promise<AttestationPayload> {
165
- const attestation = decodeAttestationBlob(blob);
166
-
167
- await verifyAttestationSignature(attestation, publicKeyHex);
168
- checkAttestationExpiry(attestation.payload);
169
- verifyBoundsHash(attestation, expectedBoundsHash);
170
- verifyContextHash(attestation, expectedContextHash);
171
-
172
- return attestation.payload;
173
- }
package/src/receipt.ts DELETED
@@ -1,74 +0,0 @@
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
- }