@aicent/iqa 1.2.8 → 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.
- package/README.md +293 -258
- package/bin/iqa.mjs +59 -59
- package/package.json +9 -2
- package/src/ae128-vectors-v1.json +112 -0
- package/src/conformance.d.mts +29 -29
- package/src/conformance.mjs +181 -181
- package/src/envelope.d.mts +103 -0
- package/src/envelope.mjs +309 -0
- package/src/iqa-uri.d.mts +42 -42
- package/src/iqa-uri.mjs +169 -169
package/src/iqa-uri.mjs
CHANGED
|
@@ -1,169 +1,169 @@
|
|
|
1
|
-
// iqa:// URI codec -- validate, canonicalise, derive (
|
|
2
|
-
//
|
|
3
|
-
// Authority boundaries:
|
|
4
|
-
// Grammar .........
|
|
5
|
-
// Closed sets ..... sec. 10.1 (organ and action are CLOSED sets)
|
|
6
|
-
// Case discipline . sec. 10.3 (lowercase US-ASCII only) + sec. 12 #8 (fail closed)
|
|
7
|
-
// Deref safety .... sec. 11.3 (action safety classes)
|
|
8
|
-
// Route derivation SPEC/IQA-URI-ATTEST-v1.2.6.md sec. 3 (IQA_ROUTE)
|
|
9
|
-
//
|
|
10
|
-
// Zero dependencies. Node 18+. No build step -- the published src/ is what runs.
|
|
11
|
-
|
|
12
|
-
import { createHash } from 'node:crypto';
|
|
13
|
-
|
|
14
|
-
export const SCHEME = 'iqa';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
export const ORGANS = Object.freeze(['forge', 'tss', 'gateway']);
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
*
|
|
21
|
-
* from `rttp`, whose verb set is open: here anything outside the four verbs
|
|
22
|
-
* is rejected.
|
|
23
|
-
*/
|
|
24
|
-
export const ACTIONS = Object.freeze(['verify', 'audit', 'attest', 'revoke']);
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
export const STANDINGS = Object.freeze(['ghost', 'probation', 'radiant', 'genesis']);
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
export const HASH_LENGTHS = Object.freeze([8, 32, 64]);
|
|
31
|
-
|
|
32
|
-
const SAFE = new Set([null, 'verify']); // sec. 11.3: omitted = standing read; verify = read-only
|
|
33
|
-
const LOWHEX = new Set('0123456789abcdef');
|
|
34
|
-
const NAME_CHARS = new Set('abcdefghijklmnopqrstuvwxyz0123456789-');
|
|
35
|
-
const ALLOWED = new Set([...LOWHEX, ...NAME_CHARS, '.', '/']);
|
|
36
|
-
const FORBIDDEN = new Set(['@', ':', '?', '#', '[', ']']);
|
|
37
|
-
|
|
38
|
-
export class IqaUriError extends Error {
|
|
39
|
-
constructor(message) {
|
|
40
|
-
super(message);
|
|
41
|
-
this.name = 'IqaUriError';
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const fail = (uri, reason) => new IqaUriError(`${JSON.stringify(uri)}: ${reason}`);
|
|
46
|
-
|
|
47
|
-
/** sec. 10.2 action test -- true only for the closed four-verb set. */
|
|
48
|
-
export function isValidAction(value) {
|
|
49
|
-
return ACTIONS.includes(value);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* IQA_ROUTE = SHA-256(ASCII(canonical_authority))[0:4] (SPEC sec. 3, decision D1).
|
|
54
|
-
*
|
|
55
|
-
* Pure computation, DNS-free (
|
|
56
|
-
* rule so both schemes of the same addressing family derive the same way.
|
|
57
|
-
* NOTE: this is the *address* fingerprint -- NOT the subject hash, which is a
|
|
58
|
-
* routing hint for the *AID*. Different numbers about different things.
|
|
59
|
-
*/
|
|
60
|
-
export function deriveRoute(authority) {
|
|
61
|
-
if (!authority) throw new IqaUriError('deriveRoute: authority is empty');
|
|
62
|
-
return createHash('sha256').update(authority, 'ascii').digest().subarray(0, 4);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** The same four bytes, as 8 lowercase hex characters. */
|
|
66
|
-
export function deriveRouteHex(authority) {
|
|
67
|
-
return deriveRoute(authority).toString('hex');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Validate, canonicalise and derive. Throws IqaUriError on any violation.
|
|
72
|
-
*
|
|
73
|
-
* Returned fields are snake_case ON PURPOSE so this output and the Python
|
|
74
|
-
* package's output compare directly against the published vectors -- field
|
|
75
|
-
* for field, byte for byte.
|
|
76
|
-
*/
|
|
77
|
-
export function parse(uri) {
|
|
78
|
-
if (typeof uri !== 'string') throw fail(uri, 'input must be a string');
|
|
79
|
-
|
|
80
|
-
const prefix = SCHEME + '://';
|
|
81
|
-
if (!uri.startsWith(prefix)) {
|
|
82
|
-
throw fail(uri, `scheme must be exactly ${JSON.stringify(prefix)} - no other `
|
|
83
|
-
+ 'scheme, no case variant, no fallback (
|
|
84
|
-
}
|
|
85
|
-
const rest = uri.slice(prefix.length);
|
|
86
|
-
|
|
87
|
-
// Charset gate: lowercase US-ASCII only (sec. 10.3). One scan rejects uppercase
|
|
88
|
-
// anywhere, userinfo '@', port ':', query '?', fragment '#', whitespace and
|
|
89
|
-
// every other foreign byte.
|
|
90
|
-
for (const ch of rest) {
|
|
91
|
-
if (!ALLOWED.has(ch)) {
|
|
92
|
-
if (FORBIDDEN.has(ch)) {
|
|
93
|
-
throw fail(uri, `${JSON.stringify(ch)} is not defined by the scheme (
|
|
94
|
-
}
|
|
95
|
-
if (/\s/.test(ch)) {
|
|
96
|
-
throw fail(uri, 'whitespace is not allowed (
|
|
97
|
-
}
|
|
98
|
-
throw fail(uri, `character ${JSON.stringify(ch)} is outside the allowed set `
|
|
99
|
-
+ "(lowercase US-ASCII, digits, '-', '.', '/')");
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Path: at most one '/', and the action behind it must be non-empty.
|
|
104
|
-
let authority = rest;
|
|
105
|
-
let action = null;
|
|
106
|
-
const slash = rest.indexOf('/');
|
|
107
|
-
if (slash !== -1) {
|
|
108
|
-
authority = rest.slice(0, slash);
|
|
109
|
-
action = rest.slice(slash + 1);
|
|
110
|
-
if (action === '') {
|
|
111
|
-
throw fail(uri, "trailing '/' with empty action - action is a closed-set "
|
|
112
|
-
+ 'verb, not omissible by empty string (sec. 10.2)');
|
|
113
|
-
}
|
|
114
|
-
if (action.includes('/')) {
|
|
115
|
-
throw fail(uri, "second path segment - path is a single '/<action>' (sec. 10.2)");
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Authority: exactly three segments.
|
|
120
|
-
const segments = authority.split('.');
|
|
121
|
-
if (segments.length !== 3) {
|
|
122
|
-
throw fail(uri, `authority has ${segments.length} segment(s), must be `
|
|
123
|
-
+ '<subject>.<organ>.<root> (
|
|
124
|
-
}
|
|
125
|
-
const [subject, organ, root] = segments;
|
|
126
|
-
if (subject === '' || root === '') {
|
|
127
|
-
throw fail(uri, 'authority segments must be non-empty (
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Subject: hash form (8/32/64 lowercase hex) or name form. Ordered choice
|
|
131
|
-
// per the ABNF: all-lowerhex input is tried as hash-subject first; a
|
|
132
|
-
// lowercase-hex string of another length is therefore a NAME subject,
|
|
133
|
-
// exactly as the grammar says. Documented, not "fixed".
|
|
134
|
-
let subjectIsHash = false;
|
|
135
|
-
let subjectHex = null;
|
|
136
|
-
if ([...subject].every((c) => LOWHEX.has(c)) && HASH_LENGTHS.includes(subject.length)) {
|
|
137
|
-
subjectIsHash = true;
|
|
138
|
-
subjectHex = subject;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Organ -- CLOSED set, case-sensitive (sec. 10.1). 'Forge', 'forgery', '' all fail.
|
|
142
|
-
if (!ORGANS.includes(organ)) {
|
|
143
|
-
throw fail(uri, `organ ${JSON.stringify(organ)} is outside the closed set `
|
|
144
|
-
+ `[${ORGANS.join(', ')}] (
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Action -- CLOSED set (sec. 10.1). The deliberate difference from rttp.
|
|
148
|
-
if (action !== null && !ACTIONS.includes(action)) {
|
|
149
|
-
throw fail(uri, `action ${JSON.stringify(action)} is outside the closed set `
|
|
150
|
-
+ `[${ACTIONS.join(', ')}] (
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const canonicalUri = SCHEME + '://' + authority + (action ? '/' + action : '');
|
|
154
|
-
|
|
155
|
-
return {
|
|
156
|
-
scheme: SCHEME,
|
|
157
|
-
subject,
|
|
158
|
-
subject_is_hash: subjectIsHash,
|
|
159
|
-
subject_hex: subjectHex,
|
|
160
|
-
organ,
|
|
161
|
-
root,
|
|
162
|
-
authority,
|
|
163
|
-
action,
|
|
164
|
-
action_omitted: action === null,
|
|
165
|
-
action_safe: SAFE.has(action), // sec. 11.3
|
|
166
|
-
canonical_uri: canonicalUri,
|
|
167
|
-
route_hex: deriveRouteHex(authority), // SPEC sec. 3
|
|
168
|
-
};
|
|
169
|
-
}
|
|
1
|
+
// iqa:// URI codec -- validate, canonicalise, derive (AICENT-009 sec. 10/sec. 11).
|
|
2
|
+
//
|
|
3
|
+
// Authority boundaries:
|
|
4
|
+
// Grammar ......... AICENT-009 sec. 10.2 ABNF (sole authority)
|
|
5
|
+
// Closed sets ..... sec. 10.1 (organ and action are CLOSED sets)
|
|
6
|
+
// Case discipline . sec. 10.3 (lowercase US-ASCII only) + sec. 12 #8 (fail closed)
|
|
7
|
+
// Deref safety .... sec. 11.3 (action safety classes)
|
|
8
|
+
// Route derivation SPEC/IQA-URI-ATTEST-v1.2.6.md sec. 3 (IQA_ROUTE)
|
|
9
|
+
//
|
|
10
|
+
// Zero dependencies. Node 18+. No build step -- the published src/ is what runs.
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
export const SCHEME = 'iqa';
|
|
15
|
+
|
|
16
|
+
/** AICENT-009 sec. 10.1 -- organ is a CLOSED set (AICENT-009-A/-B/-C). */
|
|
17
|
+
export const ORGANS = Object.freeze(['forge', 'tss', 'gateway']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* AICENT-009 sec. 10.1 -- action is a CLOSED set. This is the deliberate difference
|
|
21
|
+
* from `rttp`, whose verb set is open: here anything outside the four verbs
|
|
22
|
+
* is rejected.
|
|
23
|
+
*/
|
|
24
|
+
export const ACTIONS = Object.freeze(['verify', 'audit', 'attest', 'revoke']);
|
|
25
|
+
|
|
26
|
+
/** AICENT-009-C sec. 3 -- standings an Organ may render (closed set, lowercase). */
|
|
27
|
+
export const STANDINGS = Object.freeze(['ghost', 'probation', 'radiant', 'genesis']);
|
|
28
|
+
|
|
29
|
+
/** AICENT-009 sec. 10.2 -- hash-subject lengths: routing short form / 128-bit / 256-bit. */
|
|
30
|
+
export const HASH_LENGTHS = Object.freeze([8, 32, 64]);
|
|
31
|
+
|
|
32
|
+
const SAFE = new Set([null, 'verify']); // sec. 11.3: omitted = standing read; verify = read-only
|
|
33
|
+
const LOWHEX = new Set('0123456789abcdef');
|
|
34
|
+
const NAME_CHARS = new Set('abcdefghijklmnopqrstuvwxyz0123456789-');
|
|
35
|
+
const ALLOWED = new Set([...LOWHEX, ...NAME_CHARS, '.', '/']);
|
|
36
|
+
const FORBIDDEN = new Set(['@', ':', '?', '#', '[', ']']);
|
|
37
|
+
|
|
38
|
+
export class IqaUriError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'IqaUriError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fail = (uri, reason) => new IqaUriError(`${JSON.stringify(uri)}: ${reason}`);
|
|
46
|
+
|
|
47
|
+
/** sec. 10.2 action test -- true only for the closed four-verb set. */
|
|
48
|
+
export function isValidAction(value) {
|
|
49
|
+
return ACTIONS.includes(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* IQA_ROUTE = SHA-256(ASCII(canonical_authority))[0:4] (SPEC sec. 3, decision D1).
|
|
54
|
+
*
|
|
55
|
+
* Pure computation, DNS-free (AICENT-009 sec. 12 #9). Mirrors the RTTP ROUTE_SHARD
|
|
56
|
+
* rule so both schemes of the same addressing family derive the same way.
|
|
57
|
+
* NOTE: this is the *address* fingerprint -- NOT the subject hash, which is a
|
|
58
|
+
* routing hint for the *AID*. Different numbers about different things.
|
|
59
|
+
*/
|
|
60
|
+
export function deriveRoute(authority) {
|
|
61
|
+
if (!authority) throw new IqaUriError('deriveRoute: authority is empty');
|
|
62
|
+
return createHash('sha256').update(authority, 'ascii').digest().subarray(0, 4);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The same four bytes, as 8 lowercase hex characters. */
|
|
66
|
+
export function deriveRouteHex(authority) {
|
|
67
|
+
return deriveRoute(authority).toString('hex');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate, canonicalise and derive. Throws IqaUriError on any violation.
|
|
72
|
+
*
|
|
73
|
+
* Returned fields are snake_case ON PURPOSE so this output and the Python
|
|
74
|
+
* package's output compare directly against the published vectors -- field
|
|
75
|
+
* for field, byte for byte.
|
|
76
|
+
*/
|
|
77
|
+
export function parse(uri) {
|
|
78
|
+
if (typeof uri !== 'string') throw fail(uri, 'input must be a string');
|
|
79
|
+
|
|
80
|
+
const prefix = SCHEME + '://';
|
|
81
|
+
if (!uri.startsWith(prefix)) {
|
|
82
|
+
throw fail(uri, `scheme must be exactly ${JSON.stringify(prefix)} - no other `
|
|
83
|
+
+ 'scheme, no case variant, no fallback (AICENT-009 sec. 10.3, sec. 12 #8)');
|
|
84
|
+
}
|
|
85
|
+
const rest = uri.slice(prefix.length);
|
|
86
|
+
|
|
87
|
+
// Charset gate: lowercase US-ASCII only (sec. 10.3). One scan rejects uppercase
|
|
88
|
+
// anywhere, userinfo '@', port ':', query '?', fragment '#', whitespace and
|
|
89
|
+
// every other foreign byte.
|
|
90
|
+
for (const ch of rest) {
|
|
91
|
+
if (!ALLOWED.has(ch)) {
|
|
92
|
+
if (FORBIDDEN.has(ch)) {
|
|
93
|
+
throw fail(uri, `${JSON.stringify(ch)} is not defined by the scheme (AICENT-009 sec. 10.3)`);
|
|
94
|
+
}
|
|
95
|
+
if (/\s/.test(ch)) {
|
|
96
|
+
throw fail(uri, 'whitespace is not allowed (AICENT-009 sec. 10.3 lowercase US-ASCII only)');
|
|
97
|
+
}
|
|
98
|
+
throw fail(uri, `character ${JSON.stringify(ch)} is outside the allowed set `
|
|
99
|
+
+ "(lowercase US-ASCII, digits, '-', '.', '/')");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Path: at most one '/', and the action behind it must be non-empty.
|
|
104
|
+
let authority = rest;
|
|
105
|
+
let action = null;
|
|
106
|
+
const slash = rest.indexOf('/');
|
|
107
|
+
if (slash !== -1) {
|
|
108
|
+
authority = rest.slice(0, slash);
|
|
109
|
+
action = rest.slice(slash + 1);
|
|
110
|
+
if (action === '') {
|
|
111
|
+
throw fail(uri, "trailing '/' with empty action - action is a closed-set "
|
|
112
|
+
+ 'verb, not omissible by empty string (sec. 10.2)');
|
|
113
|
+
}
|
|
114
|
+
if (action.includes('/')) {
|
|
115
|
+
throw fail(uri, "second path segment - path is a single '/<action>' (sec. 10.2)");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Authority: exactly three segments.
|
|
120
|
+
const segments = authority.split('.');
|
|
121
|
+
if (segments.length !== 3) {
|
|
122
|
+
throw fail(uri, `authority has ${segments.length} segment(s), must be `
|
|
123
|
+
+ '<subject>.<organ>.<root> (AICENT-009 sec. 10.2)');
|
|
124
|
+
}
|
|
125
|
+
const [subject, organ, root] = segments;
|
|
126
|
+
if (subject === '' || root === '') {
|
|
127
|
+
throw fail(uri, 'authority segments must be non-empty (AICENT-009 sec. 10.2)');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Subject: hash form (8/32/64 lowercase hex) or name form. Ordered choice
|
|
131
|
+
// per the ABNF: all-lowerhex input is tried as hash-subject first; a
|
|
132
|
+
// lowercase-hex string of another length is therefore a NAME subject,
|
|
133
|
+
// exactly as the grammar says. Documented, not "fixed".
|
|
134
|
+
let subjectIsHash = false;
|
|
135
|
+
let subjectHex = null;
|
|
136
|
+
if ([...subject].every((c) => LOWHEX.has(c)) && HASH_LENGTHS.includes(subject.length)) {
|
|
137
|
+
subjectIsHash = true;
|
|
138
|
+
subjectHex = subject;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Organ -- CLOSED set, case-sensitive (sec. 10.1). 'Forge', 'forgery', '' all fail.
|
|
142
|
+
if (!ORGANS.includes(organ)) {
|
|
143
|
+
throw fail(uri, `organ ${JSON.stringify(organ)} is outside the closed set `
|
|
144
|
+
+ `[${ORGANS.join(', ')}] (AICENT-009 sec. 10.1/sec. 10.3)`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Action -- CLOSED set (sec. 10.1). The deliberate difference from rttp.
|
|
148
|
+
if (action !== null && !ACTIONS.includes(action)) {
|
|
149
|
+
throw fail(uri, `action ${JSON.stringify(action)} is outside the closed set `
|
|
150
|
+
+ `[${ACTIONS.join(', ')}] (AICENT-009 sec. 10.1)`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const canonicalUri = SCHEME + '://' + authority + (action ? '/' + action : '');
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
scheme: SCHEME,
|
|
157
|
+
subject,
|
|
158
|
+
subject_is_hash: subjectIsHash,
|
|
159
|
+
subject_hex: subjectHex,
|
|
160
|
+
organ,
|
|
161
|
+
root,
|
|
162
|
+
authority,
|
|
163
|
+
action,
|
|
164
|
+
action_omitted: action === null,
|
|
165
|
+
action_safe: SAFE.has(action), // sec. 11.3
|
|
166
|
+
canonical_uri: canonicalUri,
|
|
167
|
+
route_hex: deriveRouteHex(authority), // SPEC sec. 3
|
|
168
|
+
};
|
|
169
|
+
}
|