@onchaindiligence/sdk 0.2.0 → 0.3.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,621 @@
1
+ /**
2
+ * Zero-network verification for existing OnChainDiligence attestations.
3
+ *
4
+ * This module deliberately has no HTTP client and never discovers keys. The
5
+ * caller supplies the exact key records it has independently chosen to trust.
6
+ * Online discovery is implemented separately in index.ts as a convenience
7
+ * wrapper around this core.
8
+ */
9
+ export const ATTESTATION_V2_SCHEMA = 'onchaindiligence.attestation.v2';
10
+ export const DEFAULT_ATTESTATION_ISSUER = 'https://api.onchaindiligence.com';
11
+ export const COMPLIANCE_ATTESTATION_PURPOSE = 'compliance-screening-result';
12
+ export const FIXTURE_ATTESTATION_PURPOSE = 'verification-fixture';
13
+ const KEY_ID_PATTERN = /^ed25519-[A-Za-z0-9_-]{16}$/;
14
+ const SIGNATURE_PATTERN = /^[A-Za-z0-9_-]{86}$/;
15
+ const notChecked = (code, message) => ({
16
+ state: 'NOT_CHECKED',
17
+ code,
18
+ message,
19
+ });
20
+ function initialComponents() {
21
+ return {
22
+ structure: notChecked('not_checked', 'Envelope structure was not checked.'),
23
+ signature: notChecked('not_checked', 'Signature was not checked.'),
24
+ identity: notChecked('not_checked', 'Signer identity was not checked.'),
25
+ lifecycle: notChecked('not_checked', 'Key lifecycle was not checked.'),
26
+ timestamp: notChecked('not_checked', 'Signed timestamp was not checked.'),
27
+ key_window: notChecked('not_checked', 'Key validity window was not checked.'),
28
+ freshness: notChecked('not_requested', 'No freshness policy was requested.'),
29
+ anchor: notChecked('not_requested', 'Anchor status is independent and was not supplied to this verifier.'),
30
+ };
31
+ }
32
+ function result(state, code, reason, components, details = {}) {
33
+ return {
34
+ state,
35
+ valid: state === 'VALID',
36
+ code,
37
+ reason,
38
+ components,
39
+ warnings: [],
40
+ ...details,
41
+ };
42
+ }
43
+ function isObject(value) {
44
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
45
+ }
46
+ /** RFC 8785 canonical JSON for values in the I-JSON data model. */
47
+ export function canonicalizeJson(value) {
48
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') {
49
+ return JSON.stringify(value);
50
+ }
51
+ if (typeof value === 'number') {
52
+ if (!Number.isFinite(value))
53
+ throw new TypeError('attestation contains a non-finite number');
54
+ return JSON.stringify(value);
55
+ }
56
+ if (Array.isArray(value))
57
+ return `[${value.map(canonicalizeJson).join(',')}]`;
58
+ if (isObject(value)) {
59
+ return `{${Object.keys(value)
60
+ .sort()
61
+ .map((key) => `${JSON.stringify(key)}:${canonicalizeJson(value[key])}`)
62
+ .join(',')}}`;
63
+ }
64
+ throw new TypeError(`value of type ${typeof value} is not valid JSON`);
65
+ }
66
+ /**
67
+ * Parse JSON while rejecting duplicate object member names. JSON.parse keeps
68
+ * the last duplicate, which is unsafe for signed formats because another
69
+ * implementation may keep the first.
70
+ */
71
+ export function parseJsonNoDuplicateKeys(text, maxDepth = 64) {
72
+ const parsed = JSON.parse(text);
73
+ let index = 0;
74
+ const whitespace = () => {
75
+ while (/\s/.test(text[index] ?? ''))
76
+ index += 1;
77
+ };
78
+ const stringToken = () => {
79
+ const start = index;
80
+ index += 1;
81
+ while (index < text.length) {
82
+ if (text[index] === '\\') {
83
+ index += 2;
84
+ }
85
+ else if (text[index] === '"') {
86
+ index += 1;
87
+ return text.slice(start, index);
88
+ }
89
+ else {
90
+ index += 1;
91
+ }
92
+ }
93
+ throw new SyntaxError('unterminated JSON string');
94
+ };
95
+ const value = (depth) => {
96
+ if (depth > maxDepth)
97
+ throw new SyntaxError(`JSON exceeds maximum depth ${maxDepth}`);
98
+ whitespace();
99
+ const ch = text[index];
100
+ if (ch === '{') {
101
+ index += 1;
102
+ whitespace();
103
+ const keys = new Set();
104
+ if (text[index] === '}') {
105
+ index += 1;
106
+ return;
107
+ }
108
+ while (true) {
109
+ whitespace();
110
+ if (text[index] !== '"')
111
+ throw new SyntaxError('expected JSON object key');
112
+ const token = stringToken();
113
+ const key = JSON.parse(token);
114
+ if (keys.has(key))
115
+ throw new SyntaxError(`duplicate JSON object key: ${key}`);
116
+ keys.add(key);
117
+ whitespace();
118
+ if (text[index] !== ':')
119
+ throw new SyntaxError('expected colon after JSON object key');
120
+ index += 1;
121
+ value(depth + 1);
122
+ whitespace();
123
+ if (text[index] === '}') {
124
+ index += 1;
125
+ return;
126
+ }
127
+ if (text[index] !== ',')
128
+ throw new SyntaxError('expected comma in JSON object');
129
+ index += 1;
130
+ }
131
+ }
132
+ if (ch === '[') {
133
+ index += 1;
134
+ whitespace();
135
+ if (text[index] === ']') {
136
+ index += 1;
137
+ return;
138
+ }
139
+ while (true) {
140
+ value(depth + 1);
141
+ whitespace();
142
+ if (text[index] === ']') {
143
+ index += 1;
144
+ return;
145
+ }
146
+ if (text[index] !== ',')
147
+ throw new SyntaxError('expected comma in JSON array');
148
+ index += 1;
149
+ }
150
+ }
151
+ if (ch === '"') {
152
+ stringToken();
153
+ return;
154
+ }
155
+ const match = text.slice(index).match(/^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/);
156
+ if (!match)
157
+ throw new SyntaxError('invalid JSON value');
158
+ if (/^-?(?:0|[1-9]\d*)$/.test(match[0])) {
159
+ const integer = BigInt(match[0]);
160
+ if (integer > BigInt(Number.MAX_SAFE_INTEGER) || integer < BigInt(Number.MIN_SAFE_INTEGER)) {
161
+ throw new SyntaxError('JSON integer exceeds the interoperable safe-integer range; encode it as a string');
162
+ }
163
+ }
164
+ index += match[0].length;
165
+ };
166
+ value(0);
167
+ whitespace();
168
+ if (index !== text.length)
169
+ throw new SyntaxError('unexpected data after JSON value');
170
+ return parsed;
171
+ }
172
+ function pemToDer(pem) {
173
+ const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '');
174
+ if (!b64)
175
+ throw new TypeError('public key PEM is empty');
176
+ const bin = atob(b64);
177
+ const out = new Uint8Array(bin.length);
178
+ for (let i = 0; i < bin.length; i++)
179
+ out[i] = bin.charCodeAt(i);
180
+ return out;
181
+ }
182
+ function b64urlToBytes(value) {
183
+ const b64 = value
184
+ .replace(/-/g, '+')
185
+ .replace(/_/g, '/')
186
+ .padEnd(Math.ceil(value.length / 4) * 4, '=');
187
+ const bin = atob(b64);
188
+ const out = new Uint8Array(bin.length);
189
+ for (let i = 0; i < bin.length; i++)
190
+ out[i] = bin.charCodeAt(i);
191
+ return out;
192
+ }
193
+ function bytesToBase64url(bytes) {
194
+ let binary = '';
195
+ for (const byte of bytes)
196
+ binary += String.fromCharCode(byte);
197
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
198
+ }
199
+ function toArrayBuffer(bytes) {
200
+ return Uint8Array.from(bytes).buffer;
201
+ }
202
+ function parseExactIso(value) {
203
+ if (typeof value !== 'string')
204
+ return null;
205
+ const timestamp = Date.parse(value);
206
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value
207
+ ? timestamp
208
+ : null;
209
+ }
210
+ function parseNow(value) {
211
+ if (value == null)
212
+ return Date.now();
213
+ const timestamp = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value);
214
+ if (!Number.isFinite(timestamp))
215
+ throw new TypeError('verification now value is invalid');
216
+ return timestamp;
217
+ }
218
+ export async function deriveAttestationKeyId(publicKeyPem) {
219
+ const subtle = globalThis.crypto?.subtle;
220
+ if (!subtle)
221
+ throw new Error('WebCrypto (crypto.subtle) is unavailable');
222
+ const digest = await subtle.digest('SHA-256', toArrayBuffer(pemToDer(publicKeyPem)));
223
+ return `ed25519-${bytesToBase64url(new Uint8Array(digest)).slice(0, 16)}`;
224
+ }
225
+ /** Verify using only the supplied envelope and caller-trusted key records. */
226
+ export async function verifyAttestationOffline(input, trustMaterial, options = {}) {
227
+ const components = initialComponents();
228
+ let envelope;
229
+ try {
230
+ envelope = typeof input === 'string' ? parseJsonNoDuplicateKeys(input) : input;
231
+ }
232
+ catch (error) {
233
+ components.structure = {
234
+ state: 'FAIL',
235
+ code: 'malformed_json',
236
+ message: error instanceof Error ? error.message : 'Input is not valid JSON.',
237
+ };
238
+ return result('INVALID', 'malformed_json', components.structure.message, components);
239
+ }
240
+ if (!isObject(envelope) ||
241
+ !Object.hasOwn(envelope, 'data') ||
242
+ !isObject(envelope.attestation)) {
243
+ components.structure = {
244
+ state: 'FAIL',
245
+ code: 'malformed_envelope',
246
+ message: 'Expected an object with data and attestation fields.',
247
+ };
248
+ return result('INVALID', 'malformed_envelope', components.structure.message, components);
249
+ }
250
+ const att = envelope.attestation;
251
+ if (att.signed !== true ||
252
+ att.algorithm !== 'ed25519' ||
253
+ typeof att.key_id !== 'string' ||
254
+ !KEY_ID_PATTERN.test(att.key_id) ||
255
+ typeof att.signature !== 'string' ||
256
+ !SIGNATURE_PATTERN.test(att.signature) ||
257
+ b64urlToBytes(att.signature).length !== 64 ||
258
+ typeof att.issued_at !== 'string') {
259
+ components.structure = {
260
+ state: 'FAIL',
261
+ code: 'malformed_attestation',
262
+ message: 'Attestation metadata or Ed25519 signature encoding is invalid.',
263
+ };
264
+ return result('INVALID', 'malformed_attestation', components.structure.message, components);
265
+ }
266
+ components.structure = {
267
+ state: 'PASS',
268
+ code: 'well_formed',
269
+ message: 'The attestation envelope is structurally valid.',
270
+ };
271
+ const keyId = att.key_id;
272
+ const hasVersion = Object.hasOwn(att, 'schema_version');
273
+ const schemaVersion = hasVersion ? String(att.schema_version) : 'legacy-v1';
274
+ const expectedIssuer = options.expectedIssuer ?? DEFAULT_ATTESTATION_ISSUER;
275
+ const allowedPurposes = options.allowedPurposes ?? [
276
+ COMPLIANCE_ATTESTATION_PURPOSE,
277
+ FIXTURE_ATTESTATION_PURPOSE,
278
+ ];
279
+ let signingInput;
280
+ const warnings = [];
281
+ try {
282
+ if (hasVersion) {
283
+ if (att.schema_version !== ATTESTATION_V2_SCHEMA) {
284
+ components.identity = {
285
+ state: 'UNKNOWN',
286
+ code: 'unsupported_version',
287
+ message: `Unsupported attestation schema: ${String(att.schema_version)}.`,
288
+ };
289
+ return result('UNVERIFIABLE', 'unsupported_version', components.identity.message, components, {
290
+ keyId,
291
+ schemaVersion,
292
+ });
293
+ }
294
+ if (att.issuer !== expectedIssuer) {
295
+ components.identity = {
296
+ state: 'FAIL',
297
+ code: 'wrong_issuer',
298
+ message: `Expected issuer ${expectedIssuer}; received ${String(att.issuer)}.`,
299
+ };
300
+ return result('INVALID', 'wrong_issuer', components.identity.message, components, {
301
+ keyId,
302
+ schemaVersion,
303
+ });
304
+ }
305
+ if (typeof att.purpose !== 'string' || !allowedPurposes.includes(att.purpose)) {
306
+ components.identity = {
307
+ state: 'FAIL',
308
+ code: 'wrong_purpose',
309
+ message: `Attestation purpose is not allowed: ${String(att.purpose)}.`,
310
+ };
311
+ return result('INVALID', 'wrong_purpose', components.identity.message, components, {
312
+ keyId,
313
+ schemaVersion,
314
+ });
315
+ }
316
+ if (att.canonicalization !== 'RFC8785') {
317
+ components.structure = {
318
+ state: 'FAIL',
319
+ code: 'wrong_canonicalization',
320
+ message: 'Version 2 requires RFC8785 canonicalization.',
321
+ };
322
+ return result('INVALID', 'wrong_canonicalization', components.structure.message, components, {
323
+ keyId,
324
+ schemaVersion,
325
+ });
326
+ }
327
+ signingInput = canonicalizeJson({
328
+ schema_version: att.schema_version,
329
+ issuer: att.issuer,
330
+ purpose: att.purpose,
331
+ data: envelope.data,
332
+ issued_at: att.issued_at,
333
+ key_id: keyId,
334
+ });
335
+ components.identity = {
336
+ state: 'PASS',
337
+ code: 'v2_domain_match',
338
+ message: 'Signed issuer and purpose match the verifier policy.',
339
+ };
340
+ }
341
+ else {
342
+ signingInput = JSON.stringify({
343
+ data: envelope.data,
344
+ issued_at: att.issued_at,
345
+ key_id: keyId,
346
+ });
347
+ warnings.push('Legacy v1 has no signed schema, issuer, purpose, or canonicalization fields.');
348
+ components.identity = {
349
+ state: 'PASS',
350
+ code: 'legacy_exact_key_trust',
351
+ message: 'Legacy identity is bound only through the caller-trusted exact key.',
352
+ };
353
+ }
354
+ }
355
+ catch (error) {
356
+ components.structure = {
357
+ state: 'FAIL',
358
+ code: 'invalid_json_model',
359
+ message: error instanceof Error ? error.message : 'Signed data is not canonicalizable JSON.',
360
+ };
361
+ return result('INVALID', 'invalid_json_model', components.structure.message, components, {
362
+ keyId,
363
+ schemaVersion,
364
+ });
365
+ }
366
+ const issuedAt = parseExactIso(att.issued_at);
367
+ if (issuedAt == null) {
368
+ components.timestamp = {
369
+ state: 'FAIL',
370
+ code: 'invalid_issued_at',
371
+ message: 'issued_at must be an exact UTC ISO-8601 timestamp.',
372
+ };
373
+ return result('INVALID', 'invalid_issued_at', components.timestamp.message, components, {
374
+ keyId,
375
+ schemaVersion,
376
+ warnings,
377
+ });
378
+ }
379
+ const now = parseNow(options.now);
380
+ const maxFutureSkewMs = options.maxFutureSkewMs ?? 5 * 60 * 1000;
381
+ if (issuedAt > now + maxFutureSkewMs) {
382
+ components.timestamp = {
383
+ state: 'FAIL',
384
+ code: 'future_issued_at',
385
+ message: 'issued_at is beyond the allowed future clock skew.',
386
+ };
387
+ return result('INVALID', 'future_issued_at', components.timestamp.message, components, {
388
+ keyId,
389
+ schemaVersion,
390
+ warnings,
391
+ });
392
+ }
393
+ components.timestamp = {
394
+ state: 'PASS',
395
+ code: 'signed_time_well_formed',
396
+ message: "The signature authenticates the signer's timestamp assertion; it is not external time proof.",
397
+ };
398
+ const matchingKeys = Array.isArray(trustMaterial?.keys)
399
+ ? trustMaterial.keys.filter((key) => key?.key_id === keyId)
400
+ : [];
401
+ if (matchingKeys.length === 0) {
402
+ components.identity = {
403
+ state: 'UNKNOWN',
404
+ code: 'unknown_key',
405
+ message: `Caller-supplied trust material has no exact record for ${keyId}.`,
406
+ };
407
+ return result('UNVERIFIABLE', 'unknown_key', components.identity.message, components, {
408
+ keyId,
409
+ schemaVersion,
410
+ warnings,
411
+ });
412
+ }
413
+ if (matchingKeys.length !== 1) {
414
+ components.identity = {
415
+ state: 'UNKNOWN',
416
+ code: 'duplicate_trusted_key',
417
+ message: `Caller-supplied trust material contains duplicate records for ${keyId}.`,
418
+ };
419
+ return result('UNVERIFIABLE', 'duplicate_trusted_key', components.identity.message, components, {
420
+ keyId,
421
+ schemaVersion,
422
+ warnings,
423
+ });
424
+ }
425
+ const keyRecord = matchingKeys[0];
426
+ if (keyRecord.algorithm !== 'ed25519' || typeof keyRecord.public_key_pem !== 'string') {
427
+ components.identity = {
428
+ state: 'UNKNOWN',
429
+ code: 'unusable_trust_material',
430
+ message: 'Trusted key metadata is missing a usable Ed25519 public key.',
431
+ };
432
+ return result('UNVERIFIABLE', 'unusable_trust_material', components.identity.message, components, {
433
+ keyId,
434
+ schemaVersion,
435
+ keyStatus: keyRecord.status,
436
+ warnings,
437
+ });
438
+ }
439
+ const subtle = globalThis.crypto?.subtle;
440
+ if (!subtle) {
441
+ components.signature = {
442
+ state: 'UNKNOWN',
443
+ code: 'webcrypto_unavailable',
444
+ message: 'WebCrypto Ed25519 verification is unavailable in this runtime.',
445
+ };
446
+ return result('UNVERIFIABLE', 'webcrypto_unavailable', components.signature.message, components, {
447
+ keyId,
448
+ schemaVersion,
449
+ keyStatus: keyRecord.status,
450
+ warnings,
451
+ });
452
+ }
453
+ let der;
454
+ let publicKey;
455
+ try {
456
+ der = pemToDer(keyRecord.public_key_pem);
457
+ const derivedKeyId = await deriveAttestationKeyId(keyRecord.public_key_pem);
458
+ if (derivedKeyId !== keyId) {
459
+ components.identity = {
460
+ state: 'FAIL',
461
+ code: 'key_id_spki_mismatch',
462
+ message: `Key ID ${keyId} does not match the supplied SPKI public key.`,
463
+ };
464
+ return result('INVALID', 'key_id_spki_mismatch', components.identity.message, components, {
465
+ keyId,
466
+ schemaVersion,
467
+ keyStatus: keyRecord.status,
468
+ warnings,
469
+ });
470
+ }
471
+ publicKey = await subtle.importKey('spki', toArrayBuffer(der), { name: 'Ed25519' }, false, ['verify']);
472
+ }
473
+ catch (error) {
474
+ components.identity = {
475
+ state: 'UNKNOWN',
476
+ code: 'unusable_public_key',
477
+ message: error instanceof Error ? error.message : 'The supplied public key is unusable.',
478
+ };
479
+ return result('UNVERIFIABLE', 'unusable_public_key', components.identity.message, components, {
480
+ keyId,
481
+ schemaVersion,
482
+ keyStatus: keyRecord.status,
483
+ warnings,
484
+ });
485
+ }
486
+ const signatureValid = await subtle.verify('Ed25519', publicKey, toArrayBuffer(b64urlToBytes(att.signature)), toArrayBuffer(new TextEncoder().encode(signingInput)));
487
+ components.signature = signatureValid
488
+ ? { state: 'PASS', code: 'signature_valid', message: 'Ed25519 signature matches.' }
489
+ : { state: 'FAIL', code: 'signature_mismatch', message: 'Ed25519 signature does not match.' };
490
+ if (!signatureValid) {
491
+ return result('INVALID', 'signature_mismatch', components.signature.message, components, {
492
+ keyId,
493
+ schemaVersion,
494
+ keyStatus: keyRecord.status,
495
+ cryptographicallyValid: false,
496
+ trusted: false,
497
+ warnings,
498
+ });
499
+ }
500
+ if (keyRecord.status === 'compromised' || keyRecord.status === 'revoked') {
501
+ components.lifecycle = {
502
+ state: 'FAIL',
503
+ code: `key_${keyRecord.status}`,
504
+ message: `The exact signing key is marked ${keyRecord.status} and fails closed.`,
505
+ };
506
+ return result('INVALID', `key_${keyRecord.status}`, components.lifecycle.message, components, {
507
+ keyId,
508
+ schemaVersion,
509
+ keyStatus: keyRecord.status,
510
+ cryptographicallyValid: true,
511
+ trusted: false,
512
+ warnings,
513
+ });
514
+ }
515
+ if (keyRecord.status !== 'active' && keyRecord.status !== 'retired') {
516
+ components.lifecycle = {
517
+ state: 'UNKNOWN',
518
+ code: 'unknown_key_status',
519
+ message: `Unsupported key lifecycle status: ${String(keyRecord.status)}.`,
520
+ };
521
+ return result('UNVERIFIABLE', 'unknown_key_status', components.lifecycle.message, components, {
522
+ keyId,
523
+ schemaVersion,
524
+ keyStatus: keyRecord.status,
525
+ cryptographicallyValid: true,
526
+ trusted: false,
527
+ warnings,
528
+ });
529
+ }
530
+ components.lifecycle = {
531
+ state: 'PASS',
532
+ code: keyRecord.status === 'active' ? 'key_active' : 'key_retired_historical',
533
+ message: keyRecord.status === 'active'
534
+ ? 'The exact key is active.'
535
+ : 'The exact key is normally retired and may verify historical signatures in its interval.',
536
+ };
537
+ const requireValidFrom = options.requireValidFrom ?? true;
538
+ const validFrom = keyRecord.valid_from == null ? null : parseExactIso(keyRecord.valid_from);
539
+ const validUntil = keyRecord.valid_until == null ? null : parseExactIso(keyRecord.valid_until);
540
+ if (requireValidFrom && validFrom == null) {
541
+ components.key_window = {
542
+ state: 'UNKNOWN',
543
+ code: 'missing_valid_from',
544
+ message: 'Trusted key metadata lacks the required valid_from boundary.',
545
+ };
546
+ return result('UNVERIFIABLE', 'missing_valid_from', components.key_window.message, components, {
547
+ keyId,
548
+ schemaVersion,
549
+ keyStatus: keyRecord.status,
550
+ cryptographicallyValid: true,
551
+ trusted: false,
552
+ warnings,
553
+ });
554
+ }
555
+ if ((keyRecord.valid_from != null && validFrom == null) ||
556
+ (keyRecord.valid_until != null && validUntil == null) ||
557
+ (validFrom != null && validUntil != null && validUntil < validFrom) ||
558
+ (keyRecord.status === 'retired' && validUntil == null)) {
559
+ components.key_window = {
560
+ state: 'UNKNOWN',
561
+ code: 'unsafe_key_interval',
562
+ message: 'Trusted key metadata contains an incomplete or incoherent validity interval.',
563
+ };
564
+ return result('UNVERIFIABLE', 'unsafe_key_interval', components.key_window.message, components, {
565
+ keyId,
566
+ schemaVersion,
567
+ keyStatus: keyRecord.status,
568
+ cryptographicallyValid: true,
569
+ trusted: false,
570
+ warnings,
571
+ });
572
+ }
573
+ if ((validFrom != null && issuedAt < validFrom) || (validUntil != null && issuedAt > validUntil)) {
574
+ components.key_window = {
575
+ state: 'FAIL',
576
+ code: 'outside_key_validity',
577
+ message: 'issued_at falls outside the exact key validity interval.',
578
+ };
579
+ return result('INVALID', 'outside_key_validity', components.key_window.message, components, {
580
+ keyId,
581
+ schemaVersion,
582
+ keyStatus: keyRecord.status,
583
+ cryptographicallyValid: true,
584
+ trusted: false,
585
+ warnings,
586
+ });
587
+ }
588
+ components.key_window = {
589
+ state: 'PASS',
590
+ code: 'inside_key_validity',
591
+ message: 'issued_at falls inside the exact key validity interval.',
592
+ };
593
+ if (options.maxAgeMs != null) {
594
+ if (!Number.isFinite(options.maxAgeMs) || options.maxAgeMs < 0) {
595
+ throw new TypeError('maxAgeMs must be a non-negative finite number');
596
+ }
597
+ const age = now - issuedAt;
598
+ components.freshness =
599
+ age <= options.maxAgeMs
600
+ ? { state: 'PASS', code: 'fresh', message: 'Attestation satisfies the requested max age.' }
601
+ : { state: 'FAIL', code: 'stale', message: 'Attestation exceeds the requested max age.' };
602
+ if (components.freshness.state === 'FAIL') {
603
+ return result('INVALID', 'stale', components.freshness.message, components, {
604
+ keyId,
605
+ schemaVersion,
606
+ keyStatus: keyRecord.status,
607
+ cryptographicallyValid: true,
608
+ trusted: true,
609
+ warnings,
610
+ });
611
+ }
612
+ }
613
+ return result('VALID', 'valid', 'Attestation is valid under the supplied trust policy.', components, {
614
+ keyId,
615
+ schemaVersion,
616
+ keyStatus: keyRecord.status,
617
+ cryptographicallyValid: true,
618
+ trusted: true,
619
+ warnings,
620
+ });
621
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onchaindiligence/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Typed client for the OnchainDiligence compliance API — pay-per-call sanctions, OFAC name, and UK company checks, with the 402 payment flow handled for you.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -10,13 +10,23 @@
10
10
  ".": {
11
11
  "types": "./dist/index.d.ts",
12
12
  "import": "./dist/index.js"
13
+ },
14
+ "./commerce": {
15
+ "types": "./dist/commerce/index.d.ts",
16
+ "import": "./dist/commerce/index.js"
17
+ },
18
+ "./commerce/node": {
19
+ "types": "./dist/commerce/nodeFileRecoveryStore.d.ts",
20
+ "import": "./dist/commerce/nodeFileRecoveryStore.js"
13
21
  }
14
22
  },
15
23
  "files": [
16
- "dist"
24
+ "dist",
25
+ "conformance/*.json"
17
26
  ],
18
27
  "scripts": {
19
28
  "build": "tsc",
29
+ "test": "npm run build && node --test test/*.test.mjs",
20
30
  "prepublishOnly": "npm run build"
21
31
  },
22
32
  "keywords": [
@@ -33,9 +43,15 @@
33
43
  "license": "MIT",
34
44
  "peerDependencies": {
35
45
  "mppx": "^0.7.0",
36
- "viem": "^2.53.1"
46
+ "viem": "^2.56.0"
47
+ },
48
+ "dependencies": {
49
+ "@x402/core": "^2.17.0",
50
+ "@x402/evm": "^2.17.0",
51
+ "@x402/fetch": "^2.17.0"
37
52
  },
38
53
  "devDependencies": {
54
+ "@types/node": "^22.0.0",
39
55
  "typescript": "^5.9.3"
40
56
  }
41
57
  }