@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.
- package/README.md +71 -0
- package/dist/index.d.mts +499 -0
- package/dist/index.d.ts +499 -0
- package/dist/index.js +676 -0
- package/dist/index.mjs +613 -0
- package/package.json +40 -0
- package/src/attestation.ts +170 -0
- package/src/frame.ts +245 -0
- package/src/gatekeeper.ts +577 -0
- package/src/index.ts +11 -0
- package/src/profiles/index.ts +29 -0
- package/src/types.ts +333 -0
|
@@ -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
|
+
}
|