@aicent/iqa 1.2.7 → 1.3.1

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.
@@ -1,181 +1,181 @@
1
- // IQA conformance replay — an INDEPENDENT implementation of RFC-009 §10.
2
- //
3
- // This file is written from RFC-009 §10/§11 alone and MUST NEVER import
4
- // ./iqa-uri.mjs. A conformance suite that reuses the code under test only
5
- // proves the code equals itself. The two files share no code, so the rule is
6
- // checkable by reading them.
7
- //
8
- // They ship together for the same reason the Python wheel ships its self-test:
9
- // so a stranger can verify an installed package offline, with no repository
10
- // checkout. Zero dependencies — node:crypto and node:fs only.
11
-
12
- import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
13
- import { readFileSync } from 'node:fs';
14
- import { fileURLToPath } from 'node:url';
15
- import { dirname, join } from 'node:path';
16
-
17
- const DOMAIN = Buffer.from('iqa-attest-v1\n', 'utf8');
18
-
19
- // ---------------------------------------------------------------------------
20
- // Independent parse — single full-match grammar check, deliberately different
21
- // in structure from the streaming charset scanner in iqa-uri.mjs.
22
- // ---------------------------------------------------------------------------
23
-
24
- // authority = subject "." organ "." root ; subject/organ/root over [a-z0-9-]
25
- // (the "." split is done by the regex groups, so empty segments fail);
26
- // path = "/" action at most once; every matched char is lowercase US-ASCII,
27
- // so uppercase, '@', ':', '?', '#', whitespace and all foreign bytes fail.
28
- const GRAMMAR = /^iqa:\/\/([a-z0-9-]+)\.([a-z0-9-]+)\.([a-z0-9-]+)(?:\/([a-z0-9-]+))?$/;
29
-
30
- const ORGANS = ['forge', 'tss', 'gateway']; // §10.1 closed set
31
- const ACTIONS = ['verify', 'audit', 'attest', 'revoke']; // §10.1 closed set
32
- const HASH_LENGTHS = [8, 32, 64]; // §10.2 hash-subject
33
-
34
- export function parseUri(uri) {
35
- const m = GRAMMAR.exec(uri);
36
- if (!m) {
37
- throw new Error(`REJECT (grammar/full-match): ${JSON.stringify(uri)}`);
38
- }
39
- const [, subject, organ, root, action = null] = m;
40
-
41
- if (!ORGANS.includes(organ)) {
42
- throw new Error(`REJECT (organ closed set): ${JSON.stringify(uri)}`);
43
- }
44
- if (action !== null && !ACTIONS.includes(action)) {
45
- throw new Error(`REJECT (action closed set): ${JSON.stringify(uri)}`);
46
- }
47
-
48
- const isAllLowerHex = /^[0-9a-f]+$/.test(subject);
49
- const subjectIsHash = isAllLowerHex && HASH_LENGTHS.includes(subject.length);
50
-
51
- return {
52
- scheme: 'iqa',
53
- subject,
54
- subject_is_hash: subjectIsHash,
55
- subject_hex: subjectIsHash ? subject : null,
56
- organ,
57
- root,
58
- authority: `${subject}.${organ}.${root}`,
59
- action,
60
- action_omitted: action === null,
61
- action_safe: action === null || action === 'verify', // §11.3
62
- canonical_uri: 'iqa://' + `${subject}.${organ}.${root}` + (action ? '/' + action : ''),
63
- route_hex: createHash('sha256').update(`${subject}.${organ}.${root}`, 'ascii')
64
- .digest().subarray(0, 4).toString('hex'),
65
- };
66
- }
67
-
68
- // ---------------------------------------------------------------------------
69
- // Envelope — canonical JSON + Ed25519 over the domain-separated input.
70
- // ---------------------------------------------------------------------------
71
-
72
- function canonicalize(value) {
73
- if (value === null || typeof value !== 'object') return JSON.stringify(value);
74
- if (Array.isArray(value)) return '[' + value.map(canonicalize).join(',') + ']';
75
- const keys = Object.keys(value).sort();
76
- return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalize(value[k])).join(',') + '}';
77
- }
78
-
79
- export function buildSigningInput(env) {
80
- const body = canonicalize({
81
- alg: 'ed25519',
82
- aid: env.aid,
83
- nonce: env.nonce,
84
- payload: env.payload,
85
- pub: env.pub,
86
- ts: env.ts,
87
- });
88
- return Buffer.concat([DOMAIN, Buffer.from(body, 'utf8')]);
89
- }
90
-
91
- function verifyEnvelope(env) {
92
- const input = buildSigningInput(env);
93
- if (input.toString('hex') !== env.signing_input_hex) {
94
- throw new Error('envelope :: signing input mismatch');
95
- }
96
- const aid = createHash('sha256').update(Buffer.from(env.pub, 'hex')).digest('hex');
97
- if (aid !== env.aid) {
98
- throw new Error('envelope :: aid != SHA-256(pub)');
99
- }
100
- // Wrap the raw 32-byte key in the fixed Ed25519 SPKI DER prefix.
101
- const spki = Buffer.concat([
102
- Buffer.from('302a300506032b6570032100', 'hex'),
103
- Buffer.from(env.pub, 'hex'),
104
- ]);
105
- const key = createPublicKey({ key: spki, format: 'der', type: 'spki' });
106
- const ok = cryptoVerify(null, input, key, Buffer.from(env.sig, 'hex'));
107
- if (!ok) throw new Error('envelope :: Ed25519 verification failed');
108
- }
109
-
110
- // ---------------------------------------------------------------------------
111
- // Runner
112
- // ---------------------------------------------------------------------------
113
-
114
- const DEFAULT_VECTORS_PATH = join(dirname(fileURLToPath(import.meta.url)), 'vectors.json');
115
-
116
- export { DEFAULT_VECTORS_PATH };
117
-
118
- export function loadVectors(path = DEFAULT_VECTORS_PATH) {
119
- return JSON.parse(readFileSync(path, 'utf8'));
120
- }
121
-
122
- /**
123
- * Replay a vector set → { checked, failures, passed }.
124
- * The checker accepts any object with the documented groups, so a different
125
- * vector set (e.g. a candidate next revision) can be replayed the same way.
126
- */
127
- export function runConformance(vectors) {
128
- const failures = [];
129
- let checked = 0;
130
-
131
- for (const c of vectors.positive_uris ?? []) {
132
- checked += 1;
133
- try {
134
- const got = parseUri(c.uri);
135
- for (const key of ['canonical_uri', 'subject_is_hash', 'organ', 'root',
136
- 'authority', 'action', 'action_omitted', 'action_safe', 'route_hex']) {
137
- if (got[key] !== c[key]) {
138
- failures.push(`${c.uri} :: ${key} ${JSON.stringify(c[key])} != ${JSON.stringify(got[key])}`);
139
- }
140
- }
141
- } catch (err) {
142
- failures.push(`${c.uri} :: expected ACCEPT but rejected (${err.message})`);
143
- }
144
- }
145
-
146
- for (const c of vectors.negative_uris ?? []) {
147
- checked += 1;
148
- try {
149
- parseUri(c.uri);
150
- failures.push(`${c.uri} :: expected REJECT but parsed`);
151
- } catch {
152
- // required behaviour
153
- }
154
- }
155
-
156
- for (const row of vectors.action_safety ?? []) {
157
- checked += 1;
158
- const uri = row.action === null
159
- ? 'iqa://3f9a1b2c.gateway.iqa'
160
- : `iqa://3f9a1b2c.gateway.iqa/${row.action}`;
161
- try {
162
- const got = parseUri(uri).action_safe;
163
- if (got !== row.safe) {
164
- failures.push(`action_safety ${row.action} :: expected ${row.safe} got ${got}`);
165
- }
166
- } catch (err) {
167
- failures.push(`action_safety ${row.action} :: expected ACCEPT but rejected (${err.message})`);
168
- }
169
- }
170
-
171
- if (vectors.attest_envelope_vector) {
172
- checked += 1;
173
- try {
174
- verifyEnvelope(vectors.attest_envelope_vector);
175
- } catch (err) {
176
- failures.push(err.message);
177
- }
178
- }
179
-
180
- return { checked, failures, passed: checked - failures.length };
181
- }
1
+ // IQA conformance replay -- an INDEPENDENT implementation of AICENT-009 sec. 10.
2
+ //
3
+ // This file is written from AICENT-009 sec. 10/sec. 11 alone and MUST NEVER import
4
+ // ./iqa-uri.mjs. A conformance suite that reuses the code under test only
5
+ // proves the code equals itself. The two files share no code, so the rule is
6
+ // checkable by reading them.
7
+ //
8
+ // They ship together for the same reason the Python wheel ships its self-test:
9
+ // so a stranger can verify an installed package offline, with no repository
10
+ // checkout. Zero dependencies -- node:crypto and node:fs only.
11
+
12
+ import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
13
+ import { readFileSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ const DOMAIN = Buffer.from('iqa-attest-v1\n', 'utf8');
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Independent parse -- single full-match grammar check, deliberately different
21
+ // in structure from the streaming charset scanner in iqa-uri.mjs.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ // authority = subject "." organ "." root ; subject/organ/root over [a-z0-9-]
25
+ // (the "." split is done by the regex groups, so empty segments fail);
26
+ // path = "/" action at most once; every matched char is lowercase US-ASCII,
27
+ // so uppercase, '@', ':', '?', '#', whitespace and all foreign bytes fail.
28
+ const GRAMMAR = /^iqa:\/\/([a-z0-9-]+)\.([a-z0-9-]+)\.([a-z0-9-]+)(?:\/([a-z0-9-]+))?$/;
29
+
30
+ const ORGANS = ['forge', 'tss', 'gateway']; // sec. 10.1 closed set
31
+ const ACTIONS = ['verify', 'audit', 'attest', 'revoke']; // sec. 10.1 closed set
32
+ const HASH_LENGTHS = [8, 32, 64]; // sec. 10.2 hash-subject
33
+
34
+ export function parseUri(uri) {
35
+ const m = GRAMMAR.exec(uri);
36
+ if (!m) {
37
+ throw new Error(`REJECT (grammar/full-match): ${JSON.stringify(uri)}`);
38
+ }
39
+ const [, subject, organ, root, action = null] = m;
40
+
41
+ if (!ORGANS.includes(organ)) {
42
+ throw new Error(`REJECT (organ closed set): ${JSON.stringify(uri)}`);
43
+ }
44
+ if (action !== null && !ACTIONS.includes(action)) {
45
+ throw new Error(`REJECT (action closed set): ${JSON.stringify(uri)}`);
46
+ }
47
+
48
+ const isAllLowerHex = /^[0-9a-f]+$/.test(subject);
49
+ const subjectIsHash = isAllLowerHex && HASH_LENGTHS.includes(subject.length);
50
+
51
+ return {
52
+ scheme: 'iqa',
53
+ subject,
54
+ subject_is_hash: subjectIsHash,
55
+ subject_hex: subjectIsHash ? subject : null,
56
+ organ,
57
+ root,
58
+ authority: `${subject}.${organ}.${root}`,
59
+ action,
60
+ action_omitted: action === null,
61
+ action_safe: action === null || action === 'verify', // sec. 11.3
62
+ canonical_uri: 'iqa://' + `${subject}.${organ}.${root}` + (action ? '/' + action : ''),
63
+ route_hex: createHash('sha256').update(`${subject}.${organ}.${root}`, 'ascii')
64
+ .digest().subarray(0, 4).toString('hex'),
65
+ };
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Envelope -- canonical JSON + Ed25519 over the domain-separated input.
70
+ // ---------------------------------------------------------------------------
71
+
72
+ function canonicalize(value) {
73
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
74
+ if (Array.isArray(value)) return '[' + value.map(canonicalize).join(',') + ']';
75
+ const keys = Object.keys(value).sort();
76
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalize(value[k])).join(',') + '}';
77
+ }
78
+
79
+ export function buildSigningInput(env) {
80
+ const body = canonicalize({
81
+ alg: 'ed25519',
82
+ aid: env.aid,
83
+ nonce: env.nonce,
84
+ payload: env.payload,
85
+ pub: env.pub,
86
+ ts: env.ts,
87
+ });
88
+ return Buffer.concat([DOMAIN, Buffer.from(body, 'utf8')]);
89
+ }
90
+
91
+ function verifyEnvelope(env) {
92
+ const input = buildSigningInput(env);
93
+ if (input.toString('hex') !== env.signing_input_hex) {
94
+ throw new Error('envelope :: signing input mismatch');
95
+ }
96
+ const aid = createHash('sha256').update(Buffer.from(env.pub, 'hex')).digest('hex');
97
+ if (aid !== env.aid) {
98
+ throw new Error('envelope :: aid != SHA-256(pub)');
99
+ }
100
+ // Wrap the raw 32-byte key in the fixed Ed25519 SPKI DER prefix.
101
+ const spki = Buffer.concat([
102
+ Buffer.from('302a300506032b6570032100', 'hex'),
103
+ Buffer.from(env.pub, 'hex'),
104
+ ]);
105
+ const key = createPublicKey({ key: spki, format: 'der', type: 'spki' });
106
+ const ok = cryptoVerify(null, input, key, Buffer.from(env.sig, 'hex'));
107
+ if (!ok) throw new Error('envelope :: Ed25519 verification failed');
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Runner
112
+ // ---------------------------------------------------------------------------
113
+
114
+ const DEFAULT_VECTORS_PATH = join(dirname(fileURLToPath(import.meta.url)), 'vectors.json');
115
+
116
+ export { DEFAULT_VECTORS_PATH };
117
+
118
+ export function loadVectors(path = DEFAULT_VECTORS_PATH) {
119
+ return JSON.parse(readFileSync(path, 'utf8'));
120
+ }
121
+
122
+ /**
123
+ * Replay a vector set -> { checked, failures, passed }.
124
+ * The checker accepts any object with the documented groups, so a different
125
+ * vector set (e.g. a candidate next revision) can be replayed the same way.
126
+ */
127
+ export function runConformance(vectors) {
128
+ const failures = [];
129
+ let checked = 0;
130
+
131
+ for (const c of vectors.positive_uris ?? []) {
132
+ checked += 1;
133
+ try {
134
+ const got = parseUri(c.uri);
135
+ for (const key of ['canonical_uri', 'subject_is_hash', 'organ', 'root',
136
+ 'authority', 'action', 'action_omitted', 'action_safe', 'route_hex']) {
137
+ if (got[key] !== c[key]) {
138
+ failures.push(`${c.uri} :: ${key} ${JSON.stringify(c[key])} != ${JSON.stringify(got[key])}`);
139
+ }
140
+ }
141
+ } catch (err) {
142
+ failures.push(`${c.uri} :: expected ACCEPT but rejected (${err.message})`);
143
+ }
144
+ }
145
+
146
+ for (const c of vectors.negative_uris ?? []) {
147
+ checked += 1;
148
+ try {
149
+ parseUri(c.uri);
150
+ failures.push(`${c.uri} :: expected REJECT but parsed`);
151
+ } catch {
152
+ // required behaviour
153
+ }
154
+ }
155
+
156
+ for (const row of vectors.action_safety ?? []) {
157
+ checked += 1;
158
+ const uri = row.action === null
159
+ ? 'iqa://3f9a1b2c.gateway.iqa'
160
+ : `iqa://3f9a1b2c.gateway.iqa/${row.action}`;
161
+ try {
162
+ const got = parseUri(uri).action_safe;
163
+ if (got !== row.safe) {
164
+ failures.push(`action_safety ${row.action} :: expected ${row.safe} got ${got}`);
165
+ }
166
+ } catch (err) {
167
+ failures.push(`action_safety ${row.action} :: expected ACCEPT but rejected (${err.message})`);
168
+ }
169
+ }
170
+
171
+ if (vectors.attest_envelope_vector) {
172
+ checked += 1;
173
+ try {
174
+ verifyEnvelope(vectors.attest_envelope_vector);
175
+ } catch (err) {
176
+ failures.push(err.message);
177
+ }
178
+ }
179
+
180
+ return { checked, failures, passed: checked - failures.length };
181
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * AE-128 -- the fixed 128-byte attestation envelope (companion draft v0.3).
3
+ * Zero dependencies. Field names are snake_case on purpose: they match the
4
+ * Python implementation (iqa.envelope) and the published vector byte for byte.
5
+ */
6
+
7
+ export declare const SIZE: 128;
8
+ export declare const MAGIC: 'IQAE';
9
+ export declare const VERSION: 1;
10
+ export declare const ALGORITHM_HMAC_SHA256: 0x01;
11
+
12
+ export declare const OFF_MAGIC: 0;
13
+ export declare const OFF_VERSION: 4;
14
+ export declare const OFF_ALGORITHM: 5;
15
+ export declare const OFF_FLAGS: 6;
16
+ export declare const OFF_LEASE: 8;
17
+ export declare const OFF_SHARD: 16;
18
+ export declare const OFF_ATTESTATION: 32;
19
+ export declare const OFF_ORGAN: 64;
20
+ export declare const OFF_NONCE: 72;
21
+ export declare const OFF_STANDING: 80;
22
+ export declare const OFF_RESERVED: 81;
23
+ export declare const OFF_EXTENSION: 96;
24
+
25
+ /** Standing closed set per AICENT-009 sec. 11.1.1. */
26
+ export declare const STANDING_CODES: Readonly<Record<'ghost' | 'probation' | 'radiant' | 'genesis', number>>;
27
+ export declare const STANDING_NAMES: Readonly<Record<number, string>>;
28
+
29
+ export declare class EnvelopeError extends Error {}
30
+
31
+ /**
32
+ * ROUTE_SHARD = SHA-256(ASCII(canonical_authority))[0:16] (I-D sec. 4.4).
33
+ * The same derivation the rttp pulse frame carries at offset 0x36 -- NOT
34
+ * deriveRoute() (the 4-octet IQA_ROUTE address fingerprint).
35
+ */
36
+ export declare function deriveIntentShard(canonicalAuthority: string): Uint8Array;
37
+ export declare function intentShardHex(canonicalAuthority: string): string;
38
+
39
+ export interface EncodeParams {
40
+ /** Organ key material (non-empty). HMAC-SHA256 secret. */
41
+ key: Uint8Array;
42
+ /** The subject's ROUTE_SHARD: 16 bytes or 32 hex characters. */
43
+ intent_shard: Uint8Array | string;
44
+ /** US-ASCII organ tag, 1..8 characters, right-padded with 0x00. */
45
+ organ: string | Uint8Array;
46
+ /** Standing: a closed-set wire code or its lowercase name. */
47
+ standing: number | string;
48
+ /** u64 BE Unix seconds -- verdict valid until (inclusive). */
49
+ lease_expiry: number;
50
+ /** u64 BE -- unique per verdict issued. */
51
+ nonce: number;
52
+ /** Must be 0x0000 in version 1 (unassigned). */
53
+ flags?: number;
54
+ }
55
+
56
+ /** Build one AE-128 frame -- exactly 128 bytes (draft sec. 2 + sec. 3). */
57
+ export declare function encode(params: EncodeParams): Uint8Array;
58
+
59
+ export interface ParsedEnvelope {
60
+ version: number;
61
+ algorithm: number;
62
+ flags: number;
63
+ lease_expiry: number;
64
+ intent_shard: Uint8Array;
65
+ attestation: Uint8Array;
66
+ organ: string;
67
+ nonce: number;
68
+ standing: number;
69
+ standing_name: string;
70
+ }
71
+
72
+ /** Structural decode -- fail closed on every rule. Throws EnvelopeError. */
73
+ export declare function parse(data: Uint8Array): ParsedEnvelope;
74
+
75
+ export interface VerifyResult {
76
+ ok: boolean;
77
+ reason: string;
78
+ /** The parsed frame on success, null on failure. */
79
+ frame: ParsedEnvelope | null;
80
+ }
81
+
82
+ /**
83
+ * Verify one AE-128 frame (draft sec. 4). Constant-time HMAC compare.
84
+ * A failure is a RESULT, never an exception. now defaults to the wall clock;
85
+ * check_lease=false is the archival mode.
86
+ */
87
+ export declare function verify(
88
+ data: Uint8Array,
89
+ key: Uint8Array,
90
+ opts?: { now?: number | null; check_lease?: boolean },
91
+ ): VerifyResult;
92
+
93
+ export declare const VECTOR_1_AUTHORITY: string;
94
+ export declare const VECTOR_1_KEY: Uint8Array;
95
+ export declare const VECTOR_1_LEASE: 1798761600;
96
+ export declare const VECTOR_1_STANDING: 'radiant';
97
+ export declare const VECTOR_1_ORGAN: 'ORG-TEST';
98
+ export declare const VECTOR_1_NONCE: 1;
99
+ /** The full 128-octet published vector, as lowercase hex. */
100
+ export declare const VECTOR_1_FRAME_HEX: string;
101
+
102
+ /** The published vector as bytes -- encode() of the documented parameters. */
103
+ export declare function vector1Frame(): Uint8Array;