@bakobo/fiki 0.0.1 → 0.7.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 CHANGED
@@ -1,3 +1,72 @@
1
- # fiki (placeholder)
1
+ # fiki (JavaScript)
2
2
 
3
- Name reserved for fiki: RFC 9421 HTTP message signatures with an Ed25519 key or a KERI AID as the identifier. This 0.0.1 is an empty placeholder; the first real release follows. Source: https://github.com/bakobo/fiki
3
+ [![JavaScript](https://github.com/bakobo/fiki/actions/workflows/ci-js.yml/badge.svg)](https://github.com/bakobo/fiki/actions/workflows/ci-js.yml)
4
+
5
+ The JavaScript implementation of [fiki](../README.md). Runs in browsers and in Node 20 or newer, with **no dependencies at all** — Ed25519, SHA-256 and randomness come from WebCrypto, and the RFC 8941 structured-fields subset RFC 9421 needs is a few hundred lines in `src/sfv.js`.
6
+
7
+ ## From a fresh clone to passing tests
8
+
9
+ ```sh
10
+ cd js
11
+ npm test
12
+ ```
13
+
14
+ There is nothing to install. The suite runs under `node:test`, and `npm run test:coverage` adds the same 100% branch gate the Python port holds — separately, because Node's coverage thresholds need 22 or newer while the library itself runs on 20. Both commands run the shared `vectors/` at the repository root, so this implementation and the Python one are held to the same bytes.
15
+
16
+ ## Signing a request
17
+
18
+ ```js
19
+ import { Key, signRequest } from '@bakobo/fiki';
20
+
21
+ const key = await Key.generate(); // non-extractable; see below
22
+ console.log(key.aid); // register this once with whoever you call
23
+
24
+ const url = 'https://api.example.com/things?limit=1';
25
+ const body = new TextEncoder().encode(JSON.stringify({ hello: 'world' }));
26
+
27
+ const signed = await signRequest({ key, method: 'POST', url, body });
28
+ await fetch(url, { method: 'POST', body, headers: signed });
29
+ ```
30
+
31
+ By default the signature binds the method, the host, the path, the query string, and a digest of the body. Pass the body wherever you pass the URL: fiki covers a body it is given, or refuses to sign — but it cannot cover one it never sees.
32
+
33
+ ## Verifying a request
34
+
35
+ ```js
36
+ import { verifyRequest } from '@bakobo/fiki';
37
+
38
+ const { aid, covered } = await verifyRequest({
39
+ method: request.method,
40
+ url: request.url, // a full URL, or a path plus a Host header
41
+ headers: request.headers,
42
+ body: await request.bytes(),
43
+ maxAge: 300, // seconds, or null to decline the check
44
+ });
45
+ ```
46
+
47
+ `maxAge` has no default and must be given. Both defaults would be wrong: a number guesses at somebody else's clock skew and replay window, and skipping the check silently is the thing the argument exists to prevent. An `expires` the signer declared is enforced either way.
48
+
49
+ ## Keys in a browser
50
+
51
+ `Key.generate()` returns a **non-extractable** key: JavaScript cannot read its private half, so an XSS bug cannot exfiltrate it. Store the object itself in IndexedDB, which persists a `CryptoKey` without ever exposing the bytes. The cost is that the identity belongs to that browser profile — a new device registers a new AID, and `key.seed` throws.
52
+
53
+ When an identity has to outlive the profile, ask for it:
54
+
55
+ ```js
56
+ const key = await Key.generate({ extractable: true });
57
+ await save(key.seed); // 32 bytes, and now your problem to protect
58
+ const same = await Key.fromSeed(await load());
59
+ ```
60
+
61
+ The safe shape is the default and the portable one is explicit, because the two runtimes have genuinely different threat models and a browser should not inherit a server's.
62
+
63
+ ## Differences from the Python port
64
+
65
+ Everything is async. WebCrypto's `sign`, `verify`, `digest` and `importKey` all return promises, so `signRequest`, `signResponse`, `verifyRequest`, `verifyResponse` and the `Key` constructors do too, where the Python versions are synchronous. Names are otherwise the same in camelCase — `signatureBase`, `responseSignatureBase`, `verifyingKey`, `Key.fromSeed`, `key.aid`, `expectedKeyid` — so the two read as one library. Four more differences are deliberate (`this.i` @9enyfktu):
66
+
67
+ - A `resolve` function may return the key or a promise of it, and verification awaits either, because a KERI resolver usually reads key state from storage or a network.
68
+ - The request a response answers is a plain object, `{ method, url, headers, body }`, rather than an exported `Request` class.
69
+ - A mistake in the call rather than the message — `expectedAid` together with `resolve`, a `minimum` smaller than the profile's, a missing `maxAge`, a response binding the request's digest verified without the request body — is a `TypeError`. Python raises `TypeError` for some of these and `ValueError` for others; JavaScript has no `ValueError`. None of them is a `FikiError`.
70
+ - `KERI_VECTORS_FORMAT` is exported beside `VECTORS_FORMAT`, so the KERI profile's contract (`vectors/keri/`) can be read the same way as the shared one.
71
+
72
+ URLs are split as sent, as Python's `urlsplit` does, rather than parsed with `new URL`, which normalizes the path that RFC 9421 and the KERI profile sign unnormalized (`this.i` @90y0gsfx).
package/package.json CHANGED
@@ -1,9 +1,37 @@
1
1
  {
2
2
  "name": "@bakobo/fiki",
3
- "version": "0.0.1",
4
- "description": "Name reserved for fiki (RFC 9421 HTTP message signatures). Empty placeholder; see the repository.",
3
+ "version": "0.7.0",
4
+ "description": "Sign and verify HTTP requests with a bare Ed25519 key as the identifier. Standard RFC 9421, no KERI dependencies.",
5
+ "keywords": [
6
+ "rfc9421",
7
+ "http-message-signatures",
8
+ "ed25519",
9
+ "keri",
10
+ "aid"
11
+ ],
5
12
  "license": "Apache-2.0",
13
+ "author": "Bakobo",
6
14
  "homepage": "https://github.com/bakobo/fiki",
7
- "repository": { "type": "git", "url": "git+https://github.com/bakobo/fiki.git" },
8
- "files": ["README.md"]
15
+ "bugs": {
16
+ "url": "https://github.com/bakobo/fiki/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/bakobo/fiki.git",
21
+ "directory": "js"
22
+ },
23
+ "type": "module",
24
+ "exports": {
25
+ ".": "./src/index.js"
26
+ },
27
+ "files": [
28
+ "src"
29
+ ],
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "scripts": {
34
+ "test": "node --test",
35
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-branches=100 --test-coverage-lines=100 --test-coverage-functions=100 --test-coverage-exclude='test/**'"
36
+ }
9
37
  }
package/src/base.js ADDED
@@ -0,0 +1,325 @@
1
+ // The RFC 9421 signature base, section 2.5 (`this.i` @2hwvpm42, @2q9gv70t, @7f28p7xk).
2
+ //
3
+ // Public surface, not an internal detail. When two implementations disagree about a signature,
4
+ // the base is where they disagree, and a caller debugging an interop failure needs to see the
5
+ // bytes both sides actually hashed.
6
+ //
7
+ // Derived components fiki builds: @method, @authority, @path, @query in a request, and @status in
8
+ // a response, which may also name its request's components with the `req` parameter of section
9
+ // 2.4. Anything else raises rather than being skipped — a component silently dropped from the base
10
+ // is a component the caller believes is covered and is not.
11
+
12
+ import { utf8 } from './bytes.js';
13
+ import { DuplicateComponent, MissingComponent, SignatureMismatch, UnsupportedComponent } from './errors.js';
14
+ import { parseItem, serializeInnerList, serializeItem } from './sfv.js';
15
+
16
+ export const DERIVED = ['@method', '@authority', '@path', '@query'];
17
+
18
+ // The one derived component a response has of its own (RFC 9421 section 2.2.9). Every request
19
+ // component reaches a response only through `req`.
20
+ export const RESPONSE_DERIVED = ['@status'];
21
+
22
+ // @method, @authority, @path, @query — plus content-digest whenever there is a body (@2hwvpm42).
23
+ // This closes the query, host, and body gaps that heti's KERI dialect leaves open and
24
+ // structurally cannot close. `created` is a signature parameter rather than a component.
25
+ export const DEFAULT_COVERED = ['@method', '@authority', '@path', '@query'];
26
+
27
+ export const CONTENT_DIGEST = 'content-digest';
28
+
29
+ // The only component parameter fiki supports, and only in a response (RFC 9421 section 2.4).
30
+ const REQ = 'req';
31
+
32
+ // Order is the signer's choice — a verifier reserializes whatever it received — so fiki fixes one
33
+ // order and keeps it, which makes its own output reproducible.
34
+ const PARAM_ORDER = ['created', 'expires', 'nonce', 'alg', 'keyid', 'tag'];
35
+
36
+ // A Map, because it is keyed by the caller's scheme, and an object literal would answer
37
+ // "constructor" from Object.prototype (Copilot review of PR #5, C4).
38
+ const DEFAULT_PORTS = new Map([
39
+ ['http', '80'],
40
+ ['https', '443'],
41
+ ['ws', '80'],
42
+ ['wss', '443'],
43
+ ]);
44
+
45
+ /** A component identifier from a caller's spelling of it.
46
+ *
47
+ * A plain name (`"@method"`, `"Content-Digest"`) or its RFC 8941 serialization with parameters
48
+ * (`'"@method";req'`). Names are lowercased as a convenience to a local caller; a name parsed from
49
+ * the wire is never lowercased, and is refused instead when it is not already.
50
+ */
51
+ export function component(spec) {
52
+ if (!spec.startsWith('"')) return { value: spec.toLowerCase(), params: new Map() };
53
+ let item;
54
+ try {
55
+ item = parseItem(spec);
56
+ } catch {
57
+ item = null;
58
+ }
59
+ // A spec that opens with a quote can only parse as a string, so the parse is the whole test.
60
+ if (item === null) {
61
+ throw new UnsupportedComponent(
62
+ `fiki cannot read ${spec} as a component identifier; name a component plainly, as "@path", ` +
63
+ `or in its serialized form, as '"@path";req'.`,
64
+ { component: spec, supported: [...DERIVED, ...RESPONSE_DERIVED].join(', ') },
65
+ );
66
+ }
67
+ return { value: item.value.toLowerCase(), params: item.params };
68
+ }
69
+
70
+ /** The spelling of a request component named from a response: `req('@path')` is `'"@path";req'`. */
71
+ export const req = (name) => serializeItem({ value: name.toLowerCase(), params: new Map([[REQ, true]]) });
72
+
73
+ /** The inverse of `component`: a plain name when it has no parameters. */
74
+ export const specOf = (item) => (item.params.size > 0 ? serializeItem(item) : item.value);
75
+
76
+ /** What two identifiers must share to be the same component. Parameter order is not it. */
77
+ export const identity = (item) => serializeItem({ value: item.value, params: new Map([...item.params].sort()) });
78
+
79
+ /** Refuse a covered list fiki cannot build faithfully: duplicates first, then the unsupported.
80
+ *
81
+ * That order is the KERI profile's section 9, so a list that is both has one correct refusal.
82
+ */
83
+ export function checkCovered(items, { response }) {
84
+ const seen = new Set();
85
+ for (const item of items) {
86
+ if (seen.has(identity(item))) {
87
+ throw new DuplicateComponent(
88
+ `The covered components name ${specOf(item)} twice, so the signature base would not be ` +
89
+ 'what either copy says it is.',
90
+ { component: specOf(item) },
91
+ );
92
+ }
93
+ seen.add(identity(item));
94
+ }
95
+
96
+ for (const item of items) {
97
+ const isReq = item.params.get(REQ) === true;
98
+ const others = [...item.params.keys()].some((name) => name !== REQ);
99
+ if (others || (item.params.has(REQ) && !(isReq && response))) {
100
+ throw new UnsupportedComponent(
101
+ `fiki does not support the component ${specOf(item)}: the only component parameter it ` +
102
+ `supports is "${REQ}", and only in a response.`,
103
+ { component: specOf(item), supported: REQ },
104
+ );
105
+ }
106
+ if (item.value.startsWith('@')) {
107
+ const supported = isReq || !response ? DERIVED : RESPONSE_DERIVED;
108
+ if (!supported.includes(item.value)) {
109
+ throw new UnsupportedComponent(
110
+ `fiki does not build the derived component ${specOf(item)} in a ` +
111
+ `${response ? 'response' : 'request'}; it builds ${supported.join(', ')}.`,
112
+ { component: specOf(item), supported: supported.join(', ') },
113
+ );
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ /** A URL split as sent, per RFC 3986 section 3, with nothing decoded or normalized.
120
+ *
121
+ * Not `new URL`, which follows the WHATWG URL standard: it resolves dot segments and
122
+ * percent-encodes characters such as a space, so its pathname is not the path that was sent. The
123
+ * KERI profile requires @path "in its encoded form, percent-encoding included and unnormalized"
124
+ * (section 2), and so does RFC 9421 section 2.2.6, which is what fiki-py's urlsplit gives.
125
+ */
126
+ export function splitUrl(url) {
127
+ const match = /^(?:([A-Za-z][A-Za-z0-9+.-]*):)?(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?/.exec(url);
128
+ const [, scheme = '', netloc, path, query = ''] = match;
129
+ return { scheme: scheme.toLowerCase(), netloc: netloc ?? '', path, query };
130
+ }
131
+
132
+ function authority(parts, headers) {
133
+ // RFC 9421 section 2.2.3: lowercase host, default port omitted. A relative URL falls back to the
134
+ // Host header, which in HTTP/1.1 *is* the authority — the shape a server-side verifier actually
135
+ // holds. Nothing is normalized away there, because without a scheme no port is a default port.
136
+ if (parts.netloc) {
137
+ const hostport = parts.netloc.slice(parts.netloc.lastIndexOf('@') + 1).toLowerCase();
138
+ const [, host, port = ''] = /^(\[[^\]]*\]|[^:]*)(?::(.*))?$/.exec(hostport);
139
+ if (port === '') return host;
140
+ // RFC 3986 section 3.2.3: port = *DIGIT, so any run of ASCII digits, leading zeros and all, and
141
+ // the value is the number: "000080" is 80 and is the default, as urlsplit reads it. The range
142
+ // is checked on the digits that remain, so a long run of zeros cannot hide an overflow.
143
+ const digits = /^[0-9]+$/.test(port) ? port.replace(/^0+(?=[0-9])/, '') : null;
144
+ if (digits === null || digits.length > 5 || Number(digits) > 65535) {
145
+ throw new TypeError(`The URL's port "${port}" is not a port number between 0 and 65535.`);
146
+ }
147
+ if (digits === DEFAULT_PORTS.get(parts.scheme)) return host;
148
+ return `${host}:${digits}`;
149
+ }
150
+ const host = headers.get('host');
151
+ if (host === undefined) {
152
+ throw new MissingComponent(
153
+ 'The signature covers "@authority", but the URL carries no authority and the request has ' +
154
+ 'no Host header, so there is nothing to derive it from.',
155
+ { component: '@authority' },
156
+ );
157
+ }
158
+ return ows(checked(host, '"@authority"')).toLowerCase();
159
+ }
160
+
161
+ /** Headers with every name lowercased, refusing two names that are one field (D-Q9ZT).
162
+ *
163
+ * Field names are case-insensitive, so `X-Role` beside `x-role` is two values for one field, and
164
+ * keeping either would let a signer cover one while the application reads the other. Only a caller
165
+ * can build such an object, so it is a TypeError. Values are kept exactly as given.
166
+ */
167
+ export function canonicalHeaders(headers, name = 'headers') {
168
+ // Null-prototype, so a field named "__proto__" is an own property like any other rather than an
169
+ // assignment to the prototype that silently drops it (PR #5 hostile review, H1).
170
+ const out = Object.create(null);
171
+ for (const [field, value] of Object.entries(headers ?? {})) {
172
+ const lower = field.toLowerCase();
173
+ if (Object.hasOwn(out, lower)) {
174
+ throw new TypeError(
175
+ `${name} names the field "${lower}" twice in different case, so it holds two values for one ` +
176
+ 'field; pass one.',
177
+ );
178
+ }
179
+ out[lower] = value;
180
+ }
181
+ return out;
182
+ }
183
+
184
+ function lowered(headers) {
185
+ // Header field names are case-insensitive and appear lowercased in the base (section 2.1). Values
186
+ // are kept exactly as received here: they are checked for forbidden characters before any
187
+ // whitespace is trimmed, or a trailing CR LF would be trimmed into the value that was signed.
188
+ const map = new Map();
189
+ for (const [name, value] of Object.entries(canonicalHeaders(headers))) map.set(name, String(value));
190
+ return map;
191
+ }
192
+
193
+ // Leading and trailing field whitespace, which RFC 9110 section 5.5 defines as SP and HTAB only.
194
+ const ows = (value) => value.replace(/^[ \t]+|[ \t]+$/g, '');
195
+
196
+ /** A value refused when it has no single serialization both sides agree on.
197
+ *
198
+ * A line break inside a value would forge a line of the base, and a byte outside visible ASCII is
199
+ * encoded differently by different stacks. The KERI profile names such a base unbuildable, and so
200
+ * a signature mismatch (@2f227n4r).
201
+ */
202
+ function checked(value, spec) {
203
+ if (!/^[\t\x20-\x7e]*$/.test(value)) {
204
+ throw new SignatureMismatch(
205
+ `The value of ${spec} contains a line break, a control character or a non-ASCII ` +
206
+ 'character, so there is no signature base both sides would build from it.',
207
+ );
208
+ }
209
+ return value;
210
+ }
211
+
212
+ export function requestMessage(method, url, headers) {
213
+ // A method is the caller's to supply, and an absent one would otherwise be signed as the string
214
+ // "undefined". Required here, where every request and every response's request passes.
215
+ if (typeof method !== 'string' || method === '') {
216
+ throw new TypeError(`A request needs its method as a non-empty string, as sent; got ${String(method) || 'an empty string'}.`);
217
+ }
218
+ return { headers: lowered(headers), method, parts: splitUrl(url) };
219
+ }
220
+
221
+ export const responseMessage = (status, headers, request) => ({
222
+ headers: lowered(headers),
223
+ status,
224
+ request: request ? requestMessage(request.method, request.url, request.headers) : null,
225
+ });
226
+
227
+ function componentValue(item, message) {
228
+ let source = message;
229
+ if (item.params.get(REQ) === true) {
230
+ if (message.request === null) {
231
+ throw new MissingComponent(
232
+ `The signature covers ${specOf(item)}, which is read from the request this response ` +
233
+ 'answers, and no request was supplied.',
234
+ { component: specOf(item) },
235
+ );
236
+ }
237
+ source = message.request;
238
+ }
239
+ const name = item.value;
240
+ if (name === '@status') {
241
+ // Section 2.2.9: the three-digit status code. Anything else is not a status this component
242
+ // can carry, so there is no value to sign or to check.
243
+ const { status } = source;
244
+ if (!Number.isInteger(status) || status < 100 || status > 999) {
245
+ throw new MissingComponent(
246
+ `The signature covers @status, and ${String(status)} is not a three-digit HTTP status ` +
247
+ 'code, so there is no status line to build.',
248
+ { component: '@status' },
249
+ );
250
+ }
251
+ return String(status);
252
+ }
253
+ // Section 2.2.1: the method as sent, with no case transformation (@22g0xkr8).
254
+ if (name === '@method') return source.method;
255
+ if (name === '@authority') return authority(source.parts, source.headers);
256
+ // An empty path is the "/" the origin server would have received.
257
+ if (name === '@path') return source.parts.path || '/';
258
+ // Section 2.2.7: the whole query string including the leading "?", percent-encoding preserved,
259
+ // and a bare "?" when the request carries no query at all.
260
+ if (name === '@query') return `?${source.parts.query}`;
261
+ const value = source.headers.get(name);
262
+ if (value === undefined) {
263
+ throw new MissingComponent(
264
+ `The signature covers ${specOf(item)}, but the message carries no value for it, so the ` +
265
+ 'signature base cannot be built.',
266
+ { component: specOf(item) },
267
+ );
268
+ }
269
+ // Checked as received, then trimmed of field whitespace only.
270
+ return ows(checked(value, specOf(item)));
271
+ }
272
+
273
+ /** A component's value, refused when it has no single serialization both sides agree on. */
274
+ export const valueOf = (item, message) => checked(componentValue(item, message), specOf(item));
275
+
276
+ /** Every line of the signature base except the trailing `@signature-params`. */
277
+ export const linesFor = (items, message) => items.map((item) => `${serializeItem(item)}: ${valueOf(item, message)}`);
278
+
279
+ /** Every line of a request's signature base except the trailing `@signature-params`.
280
+ *
281
+ * Split out because the verify side cannot call `signatureBase`: it must reserialize the
282
+ * parameters exactly as they arrived, in the order they arrived, rather than in fiki's own fixed
283
+ * order — a verifier that reorders what it received computes a different base and rejects a good
284
+ * signature.
285
+ */
286
+ export function componentLines({ method, url, headers, covered }) {
287
+ const items = covered.map(component);
288
+ checkCovered(items, { response: false });
289
+ return linesFor(items, requestMessage(method, url, headers));
290
+ }
291
+
292
+ /** The whole base for already-checked items: the component lines, then fiki's own parameters. */
293
+ export function finishBase(items, message, values) {
294
+ const lines = linesFor(items, message);
295
+ const params = new Map();
296
+ for (const name of PARAM_ORDER) {
297
+ if (values[name] !== undefined && values[name] !== null) params.set(name, values[name]);
298
+ }
299
+ lines.push(`"@signature-params": ${serializeInnerList({ items, params })}`);
300
+ return utf8(lines.join('\n'));
301
+ }
302
+
303
+ /** Build the RFC 9421 signature base for a request.
304
+ *
305
+ * Throws DuplicateComponent for a component named twice, UnsupportedComponent for a derived
306
+ * component outside DERIVED or a component parameter, and MissingComponent for a covered header
307
+ * the request does not carry.
308
+ */
309
+ export function signatureBase({ method, url, headers, covered, created, keyid, alg, expires, nonce, tag }) {
310
+ const items = covered.map(component);
311
+ checkCovered(items, { response: false });
312
+ return finishBase(items, requestMessage(method, url, headers), { created, expires, nonce, alg, keyid, tag });
313
+ }
314
+
315
+ /** Build the RFC 9421 signature base for a response (sections 2.2.9 and 2.4).
316
+ *
317
+ * `request` is the request being answered, `{method, url, headers}`, which `req` components are
318
+ * read from — spelled `req('@path')` or `'"@path";req'`. Without one, a `req` component is a
319
+ * MissingComponent.
320
+ */
321
+ export function responseSignatureBase({ status, headers, covered, created, keyid, request, alg, expires, nonce, tag }) {
322
+ const items = covered.map(component);
323
+ checkCovered(items, { response: true });
324
+ return finishBase(items, responseMessage(status, headers, request), { created, expires, nonce, alg, keyid, tag });
325
+ }
package/src/bytes.js ADDED
@@ -0,0 +1,32 @@
1
+ // Base64 without Buffer (`this.i` @2q9gv70t).
2
+ //
3
+ // `Buffer` is Node's, and reaching for it is the easiest way to write a "browser" library that
4
+ // only runs on a server. `btoa`/`atob` are in every browser and in Node 20+, so these four
5
+ // functions are the whole of fiki's binary-to-text needs and the port stays honest about its
6
+ // target.
7
+
8
+ const toBinaryString = (bytes) => {
9
+ let out = '';
10
+ // One character at a time rather than String.fromCharCode(...bytes): spreading a large array
11
+ // into a call blows the argument limit, and a signature base's digest is small but a body is
12
+ // not, so the shape that works for both is the one to use everywhere.
13
+ for (const byte of bytes) out += String.fromCharCode(byte);
14
+ return out;
15
+ };
16
+
17
+ const fromBinaryString = (text) => Uint8Array.from(text, (char) => char.charCodeAt(0));
18
+
19
+ export const toBase64 = (bytes) => btoa(toBinaryString(bytes));
20
+
21
+ export const fromBase64 = (text) => fromBinaryString(atob(text));
22
+
23
+ export const toBase64Url = (bytes) => toBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
24
+
25
+ export const fromBase64Url = (text) => {
26
+ const padded = text.replace(/-/g, '+').replace(/_/g, '/');
27
+ return fromBase64(padded + '='.repeat((4 - (padded.length % 4)) % 4));
28
+ };
29
+
30
+ export const utf8 = (text) => new TextEncoder().encode(text);
31
+
32
+ export const equal = (a, b) => a.length === b.length && a.every((byte, i) => byte === b[i]);
package/src/errors.js ADDED
@@ -0,0 +1,91 @@
1
+ // fiki's exception taxonomy (`this.i` @8zw78n0v), one class per condition the Python port names.
2
+ //
3
+ // The class NAMES are the contract, not an implementation detail: `refusals.json` records the name
4
+ // fiki raises for each refusal, and this port's vector driver asserts on it. So a port that folds
5
+ // two conditions together fails a vector rather than passing quietly, and heti — which maps these
6
+ // onto its own codes — can be told what happened by a client in any language.
7
+ //
8
+ // The granularity comes from heti's code taxonomy, which distinguishes a missing header from an
9
+ // unparsable one, per header. That was built deliberately so a sender can be told which header to
10
+ // fix rather than handed the pair.
11
+
12
+ export class FikiError extends Error {
13
+ constructor(message, fields = {}) {
14
+ super(message);
15
+ this.name = new.target.name;
16
+ Object.assign(this, fields);
17
+ }
18
+ }
19
+
20
+ /* --- something the request needs is absent --- */
21
+
22
+ export class MissingSignature extends FikiError {}
23
+ export class MissingSignatureInput extends FikiError {}
24
+ export class MissingSignatureLabel extends FikiError {}
25
+ export class MissingKey extends FikiError {}
26
+ export class MissingComponent extends FikiError {}
27
+
28
+ /** A resolver was supplied and does not know the signature's keyid (`this.i` @6g9zjsv9).
29
+ *
30
+ * Never answered by decoding the keyid as a key instead: a basic transferable prefix embeds its
31
+ * inception key, and reading it would accept a key that has been rotated away.
32
+ */
33
+ export class UnknownKey extends FikiError {}
34
+
35
+ /** The keyid's key state has no single key that satisfies its threshold alone (@2f227n4r).
36
+ *
37
+ * fiki never decides this itself, since it knows nothing of key state: a resolver throws it, and
38
+ * fiki carries it out unchanged, so the refusal keeps its own class rather than becoming
39
+ * UnknownKey.
40
+ */
41
+ export class UnsupportedSigner extends FikiError {}
42
+
43
+ /** An unsigned 401 answered the request (@2f227n4r).
44
+ *
45
+ * A server that refuses a request before it knows which agent it is cannot sign the refusal, so
46
+ * an unsigned 401 is an authentication failure whose body is not to be trusted, rather than a
47
+ * response that is missing its signature.
48
+ */
49
+ export class Unauthenticated extends FikiError {}
50
+
51
+ /* --- something the request carries cannot be read --- */
52
+
53
+ export class MalformedSignature extends FikiError {}
54
+ export class MalformedSignatureInput extends FikiError {}
55
+ export class MalformedSignatureLabel extends FikiError {}
56
+ export class MalformedSignatureValue extends FikiError {}
57
+ export class MalformedKey extends FikiError {}
58
+ export class MalformedDigest extends FikiError {}
59
+
60
+ /* --- fiki understood the request and will not handle it --- */
61
+
62
+ export class UnsupportedComponent extends FikiError {}
63
+
64
+ /** The covered list names the same component twice, whatever the order of its parameters. */
65
+ export class DuplicateComponent extends FikiError {}
66
+
67
+ /** The signature verifies, and covers less than the verifier's stated minimum (@7f28p7xk).
68
+ *
69
+ * Includes a message with a body whose `content-digest` is not covered. A signature over too
70
+ * little is refused even when it is valid, because a valid signature over the wrong things is
71
+ * exactly what an intermediary wants.
72
+ */
73
+ export class InsufficientCoverage extends FikiError {}
74
+ export class UnsupportedAlgorithm extends FikiError {}
75
+
76
+ /** The request carries a body and the covered set does not include `content-digest`.
77
+ *
78
+ * Raised at signing time rather than warned about, because a verifier has no way to discover
79
+ * after the fact that a body was never covered (@2hwvpm42).
80
+ */
81
+ export class UncoveredBody extends FikiError {}
82
+
83
+ /* --- the request is signed and a stated policy refuses it anyway (@67shl6c5) --- */
84
+
85
+ export class SignatureExpired extends FikiError {}
86
+ export class SignatureTooOld extends FikiError {}
87
+
88
+ /* --- the request was read, and it does not hold up --- */
89
+
90
+ export class DigestMismatch extends FikiError {}
91
+ export class SignatureMismatch extends FikiError {}
package/src/index.js ADDED
@@ -0,0 +1,42 @@
1
+ // fiki — sign and verify HTTP requests with a bare Ed25519 key as the identifier.
2
+ //
3
+ // Standard RFC 9421, with one lens: an Ed25519 public key is rendered as a non-transferable AID
4
+ // (CESR Ed25519N, a 44-character `B…` string), so the identifier is the verifying key and a
5
+ // verifier resolves nothing — unless the caller names another keyid, such as a KERI AID, and
6
+ // supplies the resolver for it (@6g9zjsv9). Zero dependencies; everything cryptographic comes
7
+ // from WebCrypto, which is why the API is async where the Python port's is not (@2q9gv70t).
8
+
9
+ // The conformance contract this port satisfies (`this.i` @4fhrre0m). Two artifacts interoperate
10
+ // when their declared vectors format matches, whatever their own version numbers say — so this is
11
+ // the number to compare, not the release. Monotonic, because a conformance contract has no
12
+ // meaningful minor: an implementation either satisfies the vectors or it does not.
13
+ export const VECTORS_FORMAT = 1;
14
+
15
+ // The KERI profile's own conformance contract, vectors/keri/ (`this.i` @8vwrexxc, @9enyfktu). A
16
+ // separate number from VECTORS_FORMAT, because the two sets answer to different authorities and
17
+ // move independently.
18
+ export const KERI_VECTORS_FORMAT = 3;
19
+
20
+ export * as errors from './errors.js';
21
+ export { FikiError } from './errors.js';
22
+ export { Key, toAid, verifyingKey, verifySignature } from './keys.js';
23
+ export {
24
+ CONTENT_DIGEST,
25
+ DEFAULT_COVERED,
26
+ DERIVED,
27
+ componentLines,
28
+ req,
29
+ responseSignatureBase,
30
+ signatureBase,
31
+ } from './base.js';
32
+ export {
33
+ ALG,
34
+ DEFAULT_SKEW,
35
+ REQUEST_MINIMUM,
36
+ RESPONSE_MINIMUM,
37
+ contentDigest,
38
+ signRequest,
39
+ signResponse,
40
+ verifyRequest,
41
+ verifyResponse,
42
+ } from './messages.js';