@humanagencyp/hap-core 0.4.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,170 @@
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
+
11
+ /**
12
+ * Decodes a base64url-encoded attestation blob.
13
+ */
14
+ export function decodeAttestationBlob(blob: string): Attestation {
15
+ try {
16
+ const base64 = blob.replace(/-/g, '+').replace(/_/g, '/');
17
+ const padding = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
18
+ const json = Buffer.from(base64 + padding, 'base64').toString('utf8');
19
+ return JSON.parse(json);
20
+ } catch {
21
+ throw new Error('MALFORMED_ATTESTATION: Failed to decode attestation blob');
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Encodes an attestation as a base64url blob (no padding).
27
+ */
28
+ export function encodeAttestationBlob(attestation: Attestation): string {
29
+ const json = JSON.stringify(attestation);
30
+ const base64 = Buffer.from(json, 'utf8').toString('base64');
31
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
32
+ }
33
+
34
+ /**
35
+ * Computes the attestation ID (hash of the blob).
36
+ */
37
+ export function attestationId(blob: string): string {
38
+ const hash = createHash('sha256').update(blob, 'utf8').digest('hex');
39
+ return `sha256:${hash}`;
40
+ }
41
+
42
+ /**
43
+ * Verifies an attestation signature using the SP public key.
44
+ *
45
+ * @returns true if valid
46
+ * @throws Error with code prefix if invalid
47
+ */
48
+ export async function verifyAttestationSignature(
49
+ attestation: Attestation,
50
+ publicKeyHex: string
51
+ ): Promise<void> {
52
+ try {
53
+ const payloadJson = JSON.stringify(attestation.payload);
54
+ const payloadBytes = new TextEncoder().encode(payloadJson);
55
+ const signatureBytes = Buffer.from(attestation.signature, 'base64');
56
+ const publicKeyBytes = Buffer.from(publicKeyHex, 'hex');
57
+
58
+ const isValid = await ed.verifyAsync(signatureBytes, payloadBytes, publicKeyBytes);
59
+
60
+ if (!isValid) {
61
+ throw new Error('INVALID_SIGNATURE: Attestation signature verification failed');
62
+ }
63
+ } catch (error) {
64
+ if (error instanceof Error && error.message.startsWith('INVALID_SIGNATURE')) throw error;
65
+ throw new Error(`INVALID_SIGNATURE: Signature verification error: ${error}`);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Checks if an attestation has expired.
71
+ *
72
+ * @throws Error if expired
73
+ */
74
+ export function checkAttestationExpiry(
75
+ payload: AttestationPayload,
76
+ now: number = Math.floor(Date.now() / 1000)
77
+ ): void {
78
+ if (payload.expires_at <= now) {
79
+ throw new Error(
80
+ `TTL_EXPIRED: Attestation expired at ${payload.expires_at}, current time is ${now}`
81
+ );
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Verifies that the frame hash in the attestation matches the expected hash (v0.3).
87
+ *
88
+ * @throws Error if frame hash doesn't match
89
+ */
90
+ export function verifyFrameHash(attestation: Attestation, expectedFrameHash: string): void {
91
+ if (attestation.payload.frame_hash !== expectedFrameHash) {
92
+ throw new Error('FRAME_MISMATCH: Frame hash mismatch');
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Detects whether an attestation is v0.4 (has bounds_hash + context_hash)
98
+ * or v0.3 (has frame_hash).
99
+ *
100
+ * Migration rule: if attestation has frame_hash but not bounds_hash, it is v0.3.
101
+ */
102
+ export function isV4Attestation(attestation: Attestation): boolean {
103
+ return attestation.payload.bounds_hash !== undefined;
104
+ }
105
+
106
+ /**
107
+ * Verifies that the bounds hash in the attestation matches the expected hash (v0.4).
108
+ *
109
+ * @throws Error if bounds hash doesn't match
110
+ */
111
+ export function verifyBoundsHash(attestation: Attestation, expectedBoundsHash: string): void {
112
+ // Migration: if attestation has frame_hash but not bounds_hash, treat frame_hash as bounds_hash
113
+ const attestedHash = attestation.payload.bounds_hash ?? attestation.payload.frame_hash;
114
+ if (attestedHash !== expectedBoundsHash) {
115
+ throw new Error('BOUNDS_MISMATCH: Bounds hash mismatch');
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Verifies that the context hash in the attestation matches the expected hash (v0.4).
121
+ *
122
+ * @throws Error if context hash doesn't match
123
+ */
124
+ export function verifyContextHash(attestation: Attestation, expectedContextHash: string): void {
125
+ if (attestation.payload.context_hash !== expectedContextHash) {
126
+ throw new Error('CONTEXT_MISMATCH: Context hash mismatch');
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Full attestation verification (signature + expiry + frame hash) — v0.3.
132
+ *
133
+ * @returns The decoded attestation payload
134
+ * @throws Error on any validation failure
135
+ */
136
+ export async function verifyAttestation(
137
+ blob: string,
138
+ publicKeyHex: string,
139
+ expectedFrameHash: string
140
+ ): Promise<AttestationPayload> {
141
+ const attestation = decodeAttestationBlob(blob);
142
+
143
+ await verifyAttestationSignature(attestation, publicKeyHex);
144
+ checkAttestationExpiry(attestation.payload);
145
+ verifyFrameHash(attestation, expectedFrameHash);
146
+
147
+ return attestation.payload;
148
+ }
149
+
150
+ /**
151
+ * Full attestation verification for v0.4 (signature + expiry + bounds_hash + context_hash).
152
+ *
153
+ * @returns The decoded attestation payload
154
+ * @throws Error on any validation failure
155
+ */
156
+ export async function verifyAttestationV4(
157
+ blob: string,
158
+ publicKeyHex: string,
159
+ expectedBoundsHash: string,
160
+ expectedContextHash: string
161
+ ): Promise<AttestationPayload> {
162
+ const attestation = decodeAttestationBlob(blob);
163
+
164
+ await verifyAttestationSignature(attestation, publicKeyHex);
165
+ checkAttestationExpiry(attestation.payload);
166
+ verifyBoundsHash(attestation, expectedBoundsHash);
167
+ verifyContextHash(attestation, expectedContextHash);
168
+
169
+ return attestation.payload;
170
+ }
package/src/frame.ts ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Frame Canonicalization for Agent Profiles
3
+ *
4
+ * Agent profiles support mixed-type fields (strings and numbers).
5
+ * Canonical form: all values are converted to strings via String(value).
6
+ * Keys are ordered according to the profile's keyOrder.
7
+ *
8
+ * v0.3: frameSchema
9
+ * v0.4: boundsSchema + contextSchema (separate hashes)
10
+ */
11
+
12
+ import { createHash } from 'crypto';
13
+ import type { AgentFrameParams, AgentBoundsParams, AgentContextParams, AgentProfile } from './types';
14
+
15
+ // ─── v0.3 Frame Functions ─────────────────────────────────────────────────────
16
+
17
+ /**
18
+ * Validates frame parameters against the profile's frame schema.
19
+ */
20
+ export function validateFrameParams(
21
+ params: AgentFrameParams,
22
+ profile: AgentProfile
23
+ ): { valid: boolean; errors: string[] } {
24
+ const errors: string[] = [];
25
+
26
+ if (!profile.frameSchema) {
27
+ return { valid: false, errors: ['Profile does not have a frameSchema'] };
28
+ }
29
+
30
+ // Check all required fields are present
31
+ for (const [fieldName, fieldDef] of Object.entries(profile.frameSchema.fields)) {
32
+ if (fieldDef.required && !(fieldName in params)) {
33
+ errors.push(`Missing required field: ${fieldName}`);
34
+ }
35
+ }
36
+
37
+ // Validate each provided field
38
+ for (const [field, value] of Object.entries(params)) {
39
+ const fieldDef = profile.frameSchema.fields[field];
40
+ if (!fieldDef) {
41
+ errors.push(`Unknown field "${field}" not defined in profile ${profile.id}`);
42
+ continue;
43
+ }
44
+
45
+ // Type check
46
+ if (fieldDef.type === 'number' && typeof value !== 'number') {
47
+ errors.push(`Field "${field}" must be a number, got ${typeof value}`);
48
+ }
49
+ if (fieldDef.type === 'string' && typeof value !== 'string') {
50
+ errors.push(`Field "${field}" must be a string, got ${typeof value}`);
51
+ }
52
+ }
53
+
54
+ return { valid: errors.length === 0, errors };
55
+ }
56
+
57
+ /**
58
+ * Builds the canonical frame string from parameters.
59
+ * All values are converted to strings. Keys are ordered per profile's keyOrder.
60
+ *
61
+ * @throws Error if any field fails validation
62
+ */
63
+ export function canonicalFrame(params: AgentFrameParams, profile: AgentProfile): string {
64
+ const validation = validateFrameParams(params, profile);
65
+ if (!validation.valid) {
66
+ throw new Error(`Invalid frame parameters: ${validation.errors.join('; ')}`);
67
+ }
68
+
69
+ const lines = profile.frameSchema!.keyOrder.map(
70
+ (key) => `${key}=${String(params[key])}`
71
+ );
72
+
73
+ return lines.join('\n');
74
+ }
75
+
76
+ /**
77
+ * Computes the frame hash from a canonical frame string.
78
+ *
79
+ * @returns Hash in format "sha256:<64 hex chars>"
80
+ */
81
+ export function frameHash(canonicalFrameString: string): string {
82
+ const hash = createHash('sha256').update(canonicalFrameString, 'utf8').digest('hex');
83
+ return `sha256:${hash}`;
84
+ }
85
+
86
+ /**
87
+ * Convenience: builds canonical frame and computes hash in one step.
88
+ */
89
+ export function computeFrameHash(params: AgentFrameParams, profile: AgentProfile): string {
90
+ return frameHash(canonicalFrame(params, profile));
91
+ }
92
+
93
+ // ─── v0.4 Bounds Functions ────────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Validates bounds parameters against the profile's boundsSchema (v0.4).
97
+ */
98
+ export function validateBoundsParams(
99
+ params: AgentBoundsParams,
100
+ profile: AgentProfile
101
+ ): { valid: boolean; errors: string[] } {
102
+ const errors: string[] = [];
103
+
104
+ if (!profile.boundsSchema) {
105
+ return { valid: false, errors: ['Profile does not have a boundsSchema'] };
106
+ }
107
+
108
+ // Check all required fields are present
109
+ for (const [fieldName, fieldDef] of Object.entries(profile.boundsSchema.fields)) {
110
+ if (fieldDef.required && !(fieldName in params)) {
111
+ errors.push(`Missing required field: ${fieldName}`);
112
+ }
113
+ }
114
+
115
+ // Validate each provided field
116
+ for (const [field, value] of Object.entries(params)) {
117
+ const fieldDef = profile.boundsSchema.fields[field];
118
+ if (!fieldDef) {
119
+ errors.push(`Unknown field "${field}" not defined in boundsSchema of profile ${profile.id}`);
120
+ continue;
121
+ }
122
+
123
+ // Type check
124
+ if (fieldDef.type === 'number' && typeof value !== 'number') {
125
+ errors.push(`Field "${field}" must be a number, got ${typeof value}`);
126
+ }
127
+ if (fieldDef.type === 'string' && typeof value !== 'string') {
128
+ errors.push(`Field "${field}" must be a string, got ${typeof value}`);
129
+ }
130
+ }
131
+
132
+ return { valid: errors.length === 0, errors };
133
+ }
134
+
135
+ /**
136
+ * Validates context parameters against the profile's contextSchema (v0.4).
137
+ */
138
+ export function validateContextParams(
139
+ params: AgentContextParams,
140
+ profile: AgentProfile
141
+ ): { valid: boolean; errors: string[] } {
142
+ const errors: string[] = [];
143
+
144
+ if (!profile.contextSchema) {
145
+ // No contextSchema is valid — empty context
146
+ if (Object.keys(params).length > 0) {
147
+ errors.push('Profile does not have a contextSchema but context params were provided');
148
+ }
149
+ return { valid: errors.length === 0, errors };
150
+ }
151
+
152
+ // Check all required fields are present
153
+ for (const [fieldName, fieldDef] of Object.entries(profile.contextSchema.fields)) {
154
+ if (fieldDef.required && !(fieldName in params)) {
155
+ errors.push(`Missing required field: ${fieldName}`);
156
+ }
157
+ }
158
+
159
+ // Validate each provided field
160
+ for (const [field, value] of Object.entries(params)) {
161
+ const fieldDef = profile.contextSchema.fields[field];
162
+ if (!fieldDef) {
163
+ errors.push(`Unknown field "${field}" not defined in contextSchema of profile ${profile.id}`);
164
+ continue;
165
+ }
166
+
167
+ // Type check
168
+ if (fieldDef.type === 'number' && typeof value !== 'number') {
169
+ errors.push(`Field "${field}" must be a number, got ${typeof value}`);
170
+ }
171
+ if (fieldDef.type === 'string' && typeof value !== 'string') {
172
+ errors.push(`Field "${field}" must be a string, got ${typeof value}`);
173
+ }
174
+ }
175
+
176
+ return { valid: errors.length === 0, errors };
177
+ }
178
+
179
+ /**
180
+ * Builds the canonical bounds string from parameters.
181
+ * All values are converted to strings. Keys are ordered per profile's boundsSchema.keyOrder.
182
+ *
183
+ * @throws Error if any field fails validation
184
+ */
185
+ export function canonicalBounds(params: AgentBoundsParams, profile: AgentProfile): string {
186
+ const validation = validateBoundsParams(params, profile);
187
+ if (!validation.valid) {
188
+ throw new Error(`Invalid bounds parameters: ${validation.errors.join('; ')}`);
189
+ }
190
+
191
+ const lines = profile.boundsSchema!.keyOrder.map(
192
+ (key) => `${key}=${String(params[key])}`
193
+ );
194
+
195
+ return lines.join('\n');
196
+ }
197
+
198
+ /**
199
+ * Builds the canonical context string from parameters.
200
+ * All values are converted to strings. Keys are ordered per profile's contextSchema.keyOrder.
201
+ * For empty context (no contextSchema or no fields), returns "".
202
+ *
203
+ * @throws Error if any field fails validation
204
+ */
205
+ export function canonicalContext(params: AgentContextParams, profile: AgentProfile): string {
206
+ // No contextSchema or no fields → empty context
207
+ if (!profile.contextSchema || Object.keys(profile.contextSchema.fields).length === 0) {
208
+ return '';
209
+ }
210
+
211
+ const validation = validateContextParams(params, profile);
212
+ if (!validation.valid) {
213
+ throw new Error(`Invalid context parameters: ${validation.errors.join('; ')}`);
214
+ }
215
+
216
+ const lines = profile.contextSchema.keyOrder.map(
217
+ (key) => `${key}=${String(params[key])}`
218
+ );
219
+
220
+ return lines.join('\n');
221
+ }
222
+
223
+ /**
224
+ * Computes the bounds hash from bounds parameters (v0.4).
225
+ *
226
+ * @returns Hash in format "sha256:<64 hex chars>"
227
+ */
228
+ export function computeBoundsHash(params: AgentBoundsParams, profile: AgentProfile): string {
229
+ const canonical = canonicalBounds(params, profile);
230
+ const hash = createHash('sha256').update(canonical, 'utf8').digest('hex');
231
+ return `sha256:${hash}`;
232
+ }
233
+
234
+ /**
235
+ * Computes the context hash from context parameters (v0.4).
236
+ * For empty context {}, returns the sha256 of "":
237
+ * "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
238
+ *
239
+ * @returns Hash in format "sha256:<64 hex chars>"
240
+ */
241
+ export function computeContextHash(params: AgentContextParams, profile: AgentProfile): string {
242
+ const canonical = canonicalContext(params, profile);
243
+ const hash = createHash('sha256').update(canonical, 'utf8').digest('hex');
244
+ return `sha256:${hash}`;
245
+ }