provared 0.1.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/CHANGELOG.md +1062 -0
- package/LICENSE +202 -0
- package/LICENSE-CC-BY-4.0.txt +396 -0
- package/README.md +689 -0
- package/bin/provared-check.mjs +565 -0
- package/bin/provared-headers.mjs +110 -0
- package/docs/threat-model.md +997 -0
- package/net/headers.js +403 -0
- package/package.json +57 -0
- package/page/check-page.js +162 -0
- package/page/index.html +47 -0
- package/page/render.js +530 -0
- package/page/style.css +150 -0
- package/samples/office-supplies.expected.json +82 -0
- package/samples/office-supplies.jsonl +10 -0
- package/samples/office-supplies.show.json +59 -0
- package/spec/provared-format.md +1672 -0
- package/src/actions.js +90 -0
- package/src/approval.js +73 -0
- package/src/blockstamp.js +198 -0
- package/src/book.js +2232 -0
- package/src/check.d.ts +21 -0
- package/src/check.js +15 -0
- package/src/compare.js +276 -0
- package/src/cover.js +198 -0
- package/src/der.js +192 -0
- package/src/encoding.js +286 -0
- package/src/fields.js +94 -0
- package/src/guard.js +171 -0
- package/src/headers.js +380 -0
- package/src/index.d.ts +2 -0
- package/src/index.js +37 -0
- package/src/jws.js +235 -0
- package/src/keys.js +149 -0
- package/src/pass.js +67 -0
- package/src/passkey-browser.d.ts +20 -0
- package/src/passkey-browser.js +171 -0
- package/src/recorder.js +1115 -0
- package/src/seal.js +133 -0
- package/src/service.js +124 -0
- package/src/signatures.js +147 -0
- package/src/slip.js +401 -0
- package/src/standing.js +181 -0
- package/src/stub.js +140 -0
- package/src/timestamp.js +351 -0
- package/src/tools.js +192 -0
- package/src/tree.js +143 -0
- package/src/types.d.ts +628 -0
- package/src/webauthn.js +119 -0
package/src/der.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// A small, strict reader for DER, the byte layout that time-stamps and
|
|
2
|
+
// certificates use (ITU-T X.690). It reads; it never writes. Anything not in
|
|
3
|
+
// the one form DER allows is refused.
|
|
4
|
+
|
|
5
|
+
import { Refusal } from './encoding.js';
|
|
6
|
+
|
|
7
|
+
export function badStamp(why) {
|
|
8
|
+
return new Refusal('stamp-bad-data', `A time-stamp is not laid out as its standard sets out: ${why}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The tags this reader meets. */
|
|
12
|
+
export const TAG = {
|
|
13
|
+
BOOLEAN: 0x01,
|
|
14
|
+
INTEGER: 0x02,
|
|
15
|
+
BIT_STRING: 0x03,
|
|
16
|
+
OCTET_STRING: 0x04,
|
|
17
|
+
NULL: 0x05,
|
|
18
|
+
OID: 0x06,
|
|
19
|
+
UTC_TIME: 0x17,
|
|
20
|
+
GENERALIZED_TIME: 0x18,
|
|
21
|
+
SEQUENCE: 0x30,
|
|
22
|
+
SET: 0x31,
|
|
23
|
+
CONTEXT_0: 0xa0,
|
|
24
|
+
CONTEXT_1: 0xa1,
|
|
25
|
+
CONTEXT_3: 0xa3,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read the element that begins at "at".
|
|
30
|
+
* @param {Uint8Array} bytes
|
|
31
|
+
* @param {number} at
|
|
32
|
+
* @param {number} [end] the element must lie before this
|
|
33
|
+
* @returns {{tag: number, from: number, start: number, end: number}} "from" is the first byte of the element, "start" and "end" bound its content
|
|
34
|
+
*/
|
|
35
|
+
export function readElement(bytes, at, end = bytes.length) {
|
|
36
|
+
if (at + 2 > end) throw badStamp('it is cut short.');
|
|
37
|
+
const tag = bytes[at];
|
|
38
|
+
if ((tag & 0x1f) === 0x1f) throw badStamp('it holds a tag of a kind that is not used here.');
|
|
39
|
+
let length = bytes[at + 1];
|
|
40
|
+
let start = at + 2;
|
|
41
|
+
if (length & 0x80) {
|
|
42
|
+
const count = length & 0x7f;
|
|
43
|
+
// Never the indefinite form, and never more than four bytes of length.
|
|
44
|
+
if (count === 0 || count > 4) throw badStamp('it holds a length in a form that is not allowed.');
|
|
45
|
+
if (start + count > end) throw badStamp('it is cut short.');
|
|
46
|
+
length = 0;
|
|
47
|
+
for (let i = 0; i < count; i++) length = length * 256 + bytes[start + i];
|
|
48
|
+
if (bytes[start] === 0 || length < 128) throw badStamp('it holds a length that is not in its shortest form.');
|
|
49
|
+
start += count;
|
|
50
|
+
}
|
|
51
|
+
if (length > end - start) throw badStamp('a part is longer than what holds it.');
|
|
52
|
+
return { tag, from: at, start, end: start + length };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The elements inside a constructed element, in order.
|
|
57
|
+
* @param {Uint8Array} bytes
|
|
58
|
+
* @param {{start: number, end: number}} parent
|
|
59
|
+
* @param {number} [most] refuse more children than this
|
|
60
|
+
*/
|
|
61
|
+
export function children(bytes, parent, most = 32) {
|
|
62
|
+
const out = [];
|
|
63
|
+
let at = parent.start;
|
|
64
|
+
while (at < parent.end) {
|
|
65
|
+
if (out.length >= most) throw badStamp('a part holds too many items.');
|
|
66
|
+
const child = readElement(bytes, at, parent.end);
|
|
67
|
+
out.push(child);
|
|
68
|
+
at = child.end;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Confirm an element's tag, and hand the element back. */
|
|
74
|
+
export function expect(element, tag, what) {
|
|
75
|
+
if (!element || element.tag !== tag) throw badStamp(`${what} is missing or of the wrong kind.`);
|
|
76
|
+
return element;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The content bytes of an element. */
|
|
80
|
+
export function contentOf(bytes, element) {
|
|
81
|
+
return bytes.subarray(element.start, element.end);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The whole element, tag and length included. */
|
|
85
|
+
export function wholeOf(bytes, element) {
|
|
86
|
+
return bytes.subarray(element.from, element.end);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The content of an element as lower-case hexadecimal: how object identifiers are compared here. */
|
|
90
|
+
export function hexOf(bytes, element) {
|
|
91
|
+
let out = '';
|
|
92
|
+
for (let i = element.start; i < element.end; i++) out += bytes[i].toString(16).padStart(2, '0');
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A time, from the two forms DER allows: "YYMMDDHHMMSSZ" (UTCTime) and
|
|
98
|
+
* "YYYYMMDDHHMMSS[.f]Z" (GeneralizedTime).
|
|
99
|
+
* @returns {number} milliseconds
|
|
100
|
+
*/
|
|
101
|
+
export function timeOf(bytes, element) {
|
|
102
|
+
let text = '';
|
|
103
|
+
for (let i = element.start; i < element.end; i++) text += String.fromCharCode(bytes[i]);
|
|
104
|
+
let m;
|
|
105
|
+
if (element.tag === TAG.UTC_TIME) {
|
|
106
|
+
m = /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)Z$/.exec(text);
|
|
107
|
+
if (m) m[1] = (Number(m[1]) < 50 ? '20' : '19') + m[1];
|
|
108
|
+
} else if (element.tag === TAG.GENERALIZED_TIME) {
|
|
109
|
+
m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(?:\.(\d{1,9}))?Z$/.exec(text);
|
|
110
|
+
// DER allows no trailing zero in the fraction.
|
|
111
|
+
if (m && m[7] && m[7].endsWith('0')) m = null;
|
|
112
|
+
}
|
|
113
|
+
if (!m) throw badStamp('a time is not written in the form DER allows.');
|
|
114
|
+
const [, y, mo, d, h, mi, s] = m.map(Number);
|
|
115
|
+
const ms = Date.UTC(y, mo - 1, d, h, mi, s) + (m[7] ? Math.floor(Number('0.' + m[7]) * 1000) : 0);
|
|
116
|
+
const check = new Date(ms);
|
|
117
|
+
if (check.getUTCFullYear() !== y || check.getUTCMonth() !== mo - 1 || check.getUTCDate() !== d || h > 23 || mi > 59 || s > 59) {
|
|
118
|
+
throw badStamp('a time names a moment that does not exist.');
|
|
119
|
+
}
|
|
120
|
+
return ms;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* An ECDSA signature from its DER form (two whole numbers) to the two
|
|
125
|
+
* numbers side by side, each of "size" bytes, as Web Crypto wants them.
|
|
126
|
+
* @param {Uint8Array} der
|
|
127
|
+
* @param {number} size 32 for P-256, 48 for P-384
|
|
128
|
+
*/
|
|
129
|
+
export function ecdsaToRaw(der, size) {
|
|
130
|
+
const outer = expect(readElement(der, 0), TAG.SEQUENCE, 'the signature');
|
|
131
|
+
if (outer.end !== der.length) throw badStamp('a signature has bytes left over.');
|
|
132
|
+
const parts = children(der, outer, 2);
|
|
133
|
+
if (parts.length !== 2) throw badStamp('a signature does not hold two numbers.');
|
|
134
|
+
const out = new Uint8Array(size * 2);
|
|
135
|
+
parts.forEach((part, i) => {
|
|
136
|
+
expect(part, TAG.INTEGER, 'a number of the signature');
|
|
137
|
+
let digits = contentOf(der, part);
|
|
138
|
+
if (digits.length === 0 || digits[0] & 0x80) throw badStamp('a signature holds a number that is not positive.');
|
|
139
|
+
if (digits.length > 1 && digits[0] === 0 && !(digits[1] & 0x80)) throw badStamp('a signature holds a number not in its shortest form.');
|
|
140
|
+
if (digits[0] === 0) digits = digits.subarray(1);
|
|
141
|
+
if (digits.length > size) throw badStamp('a signature holds a number that is too large.');
|
|
142
|
+
out.set(digits, i * size + (size - digits.length));
|
|
143
|
+
});
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Walk a whole element and everything inside it, and refuse anything that
|
|
149
|
+
* is not strict DER: a constructed part whose items do not fill it exactly,
|
|
150
|
+
* a whole number or an object identifier that is not in its shortest form,
|
|
151
|
+
* a truth value that is not written in the one way DER allows. Parts this
|
|
152
|
+
* checker does not otherwise read are still read here, so that two checkers
|
|
153
|
+
* cannot disagree about whether a time-stamp is well formed.
|
|
154
|
+
* @param {Uint8Array} bytes
|
|
155
|
+
* @param {{tag: number, start: number, end: number}} element
|
|
156
|
+
*/
|
|
157
|
+
export function validateDer(bytes, element) {
|
|
158
|
+
let budget = 4000;
|
|
159
|
+
const walk = (e, depth) => {
|
|
160
|
+
if (--budget < 0 || depth > 24) throw badStamp('it holds too many parts, or parts nested too deeply.');
|
|
161
|
+
const length = e.end - e.start;
|
|
162
|
+
if (e.tag & 0x20) {
|
|
163
|
+
let at = e.start;
|
|
164
|
+
while (at < e.end) {
|
|
165
|
+
const child = readElement(bytes, at, e.end);
|
|
166
|
+
walk(child, depth + 1);
|
|
167
|
+
at = child.end;
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const first = bytes[e.start];
|
|
172
|
+
const second = bytes[e.start + 1];
|
|
173
|
+
if (e.tag === TAG.INTEGER) {
|
|
174
|
+
if (length === 0) throw badStamp('a whole number is empty.');
|
|
175
|
+
if (length > 1 && ((first === 0x00 && !(second & 0x80)) || (first === 0xff && second & 0x80))) {
|
|
176
|
+
throw badStamp('a whole number is not in its shortest form.');
|
|
177
|
+
}
|
|
178
|
+
} else if (e.tag === TAG.OID) {
|
|
179
|
+
if (length === 0 || bytes[e.end - 1] & 0x80) throw badStamp('an object identifier is empty or cut short.');
|
|
180
|
+
for (let i = e.start; i < e.end; i++) {
|
|
181
|
+
if (bytes[i] === 0x80 && (i === e.start || !(bytes[i - 1] & 0x80))) throw badStamp('an object identifier is not in its shortest form.');
|
|
182
|
+
}
|
|
183
|
+
} else if (e.tag === TAG.BOOLEAN) {
|
|
184
|
+
if (length !== 1 || (first !== 0x00 && first !== 0xff)) throw badStamp('a truth value is not written as DER allows.');
|
|
185
|
+
} else if (e.tag === TAG.NULL) {
|
|
186
|
+
if (length !== 0) throw badStamp('an empty value is not empty.');
|
|
187
|
+
} else if (e.tag === TAG.BIT_STRING) {
|
|
188
|
+
if (length === 0 || first > 7) throw badStamp('a string of bits is not written as DER allows.');
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
walk(element, 0);
|
|
192
|
+
}
|
package/src/encoding.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Bytes, base64url, the canonical form of JSON, fingerprints and times.
|
|
2
|
+
//
|
|
3
|
+
// Runs unchanged in a browser and in Node.js: it uses only Web Crypto and
|
|
4
|
+
// the standard text encoders. Nothing here is specific to a kind of record.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A refusal: a named reason why a record does not check. A failed check is
|
|
8
|
+
* a normal answer, not a crash, so callers catch this and report its code.
|
|
9
|
+
*/
|
|
10
|
+
export class Refusal extends Error {
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} code one of the codes in docs/threat-model.md, section 8
|
|
13
|
+
* @param {string} message a plain sentence for a person
|
|
14
|
+
*/
|
|
15
|
+
constructor(code, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'Refusal';
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Turn anything thrown while checking into a problem to report. A Refusal
|
|
24
|
+
* keeps its code. Anything else means the checker itself met something it
|
|
25
|
+
* did not expect: that is reported as "check-failed" and is never a pass.
|
|
26
|
+
* @param {unknown} e
|
|
27
|
+
* @returns {{code: string, message: string}}
|
|
28
|
+
*/
|
|
29
|
+
export function problemFrom(e) {
|
|
30
|
+
if (e instanceof Refusal) return { code: e.code, message: e.message };
|
|
31
|
+
return { code: 'check-failed', message: 'The checker met something it did not expect here. This is treated as not intact.' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The largest whole number the format allows (2^53 - 1). */
|
|
35
|
+
export const MAX_NUMBER = Number.MAX_SAFE_INTEGER;
|
|
36
|
+
|
|
37
|
+
/** How deep the content of a record or a line of a book may nest. */
|
|
38
|
+
export const MAX_DEPTH = 8;
|
|
39
|
+
|
|
40
|
+
function subtle() {
|
|
41
|
+
const s = globalThis.crypto && globalThis.crypto.subtle;
|
|
42
|
+
if (!s) {
|
|
43
|
+
throw new Error('Web Crypto is not available here. In a browser, open the page over https or on localhost.');
|
|
44
|
+
}
|
|
45
|
+
return s;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const encoder = new TextEncoder();
|
|
49
|
+
// fatal: malformed UTF-8 is refused. ignoreBOM: a byte order mark is kept in
|
|
50
|
+
// the text, where the JSON parser then refuses it.
|
|
51
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
52
|
+
|
|
53
|
+
/** @param {string} text @returns {Uint8Array} */
|
|
54
|
+
export function utf8(text) {
|
|
55
|
+
return encoder.encode(text);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Strict UTF-8 decoding.
|
|
60
|
+
* @param {Uint8Array} bytes
|
|
61
|
+
* @param {string} code the refusal code to use if the bytes are not UTF-8
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function fromUtf8(bytes, code = 'not-json') {
|
|
65
|
+
try {
|
|
66
|
+
return decoder.decode(bytes);
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Refusal(code, 'The bytes are not valid UTF-8 text.');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @param {...Uint8Array} parts @returns {Uint8Array} */
|
|
73
|
+
export function concatBytes(...parts) {
|
|
74
|
+
let length = 0;
|
|
75
|
+
for (const p of parts) length += p.length;
|
|
76
|
+
const out = new Uint8Array(length);
|
|
77
|
+
let at = 0;
|
|
78
|
+
for (const p of parts) {
|
|
79
|
+
out.set(p, at);
|
|
80
|
+
at += p.length;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param {Uint8Array} a @param {Uint8Array} b @returns {boolean} */
|
|
86
|
+
export function equalBytes(a, b) {
|
|
87
|
+
if (a.length !== b.length) return false;
|
|
88
|
+
let diff = 0;
|
|
89
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
90
|
+
return diff === 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** SHA-256 (FIPS 180-4). @param {Uint8Array} bytes @returns {Promise<Uint8Array>} */
|
|
94
|
+
export async function sha256(bytes) {
|
|
95
|
+
return new Uint8Array(await subtle().digest('SHA-256', bytes));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- base64url (RFC 4648 section 5, no padding; RFC 7515 section 2) ---
|
|
99
|
+
|
|
100
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
101
|
+
const LOOKUP = new Int8Array(128).fill(-1);
|
|
102
|
+
for (let i = 0; i < ALPHABET.length; i++) LOOKUP[ALPHABET.charCodeAt(i)] = i;
|
|
103
|
+
|
|
104
|
+
/** @param {Uint8Array} bytes @returns {string} */
|
|
105
|
+
export function toBase64url(bytes) {
|
|
106
|
+
let out = '';
|
|
107
|
+
let i = 0;
|
|
108
|
+
for (; i + 2 < bytes.length; i += 3) {
|
|
109
|
+
const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
|
|
110
|
+
out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + ALPHABET[n & 63];
|
|
111
|
+
}
|
|
112
|
+
if (i + 1 === bytes.length) {
|
|
113
|
+
const n = bytes[i] << 16;
|
|
114
|
+
out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63];
|
|
115
|
+
} else if (i + 2 === bytes.length) {
|
|
116
|
+
const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
|
|
117
|
+
out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63];
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Strict decoding: refuses any character outside the alphabet, padding, an
|
|
124
|
+
* impossible length, and stray bits in the last character. So one value has
|
|
125
|
+
* exactly one text form.
|
|
126
|
+
* @param {unknown} text
|
|
127
|
+
* @returns {Uint8Array}
|
|
128
|
+
*/
|
|
129
|
+
export function fromBase64url(text) {
|
|
130
|
+
if (typeof text !== 'string') throw new Refusal('bad-base64url', 'A base64url value must be text.');
|
|
131
|
+
if (text.length % 4 === 1) throw new Refusal('bad-base64url', 'A base64url value has an impossible length.');
|
|
132
|
+
const out = new Uint8Array(Math.floor((text.length * 3) / 4));
|
|
133
|
+
let acc = 0;
|
|
134
|
+
let bits = 0;
|
|
135
|
+
let at = 0;
|
|
136
|
+
for (let i = 0; i < text.length; i++) {
|
|
137
|
+
const c = text.charCodeAt(i);
|
|
138
|
+
const v = c < 128 ? LOOKUP[c] : -1;
|
|
139
|
+
if (v < 0) throw new Refusal('bad-base64url', 'A base64url value holds a character that is not allowed.');
|
|
140
|
+
acc = (acc << 6) | v;
|
|
141
|
+
bits += 6;
|
|
142
|
+
if (bits >= 8) {
|
|
143
|
+
bits -= 8;
|
|
144
|
+
out[at++] = (acc >> bits) & 0xff;
|
|
145
|
+
acc &= (1 << bits) - 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (acc !== 0) throw new Refusal('bad-base64url', 'A base64url value has stray bits in its last character.');
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- the canonical form (RFC 8785, narrowed as the format description says) ---
|
|
153
|
+
|
|
154
|
+
function wellFormed(text) {
|
|
155
|
+
if (typeof text.isWellFormed === 'function') return text.isWellFormed();
|
|
156
|
+
for (let i = 0; i < text.length; i++) {
|
|
157
|
+
const c = text.charCodeAt(i);
|
|
158
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
159
|
+
const next = text.charCodeAt(i + 1);
|
|
160
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
|
|
161
|
+
i++;
|
|
162
|
+
} else if (c >= 0xdc00 && c <= 0xdfff) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Write a value in the canonical form: RFC 8785, narrowed to objects, lists,
|
|
171
|
+
* strings and whole numbers from 0 to 2^53 - 1. For those types RFC 8785 is
|
|
172
|
+
* exactly: members sorted by name (by UTF-16 code unit, which is how
|
|
173
|
+
* JavaScript sorts strings), strings as JSON.stringify writes them, no
|
|
174
|
+
* spaces.
|
|
175
|
+
* @param {unknown} value
|
|
176
|
+
* @param {number} [depth]
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
export function canonicalJson(value, depth = 0) {
|
|
180
|
+
if (typeof value === 'string') {
|
|
181
|
+
if (!wellFormed(value)) throw new Refusal('payload-not-canonical', 'A string is not well-formed Unicode.');
|
|
182
|
+
return JSON.stringify(value);
|
|
183
|
+
}
|
|
184
|
+
if (typeof value === 'number') {
|
|
185
|
+
// Object.is refuses -0, which JSON.parse gives for the text "-0".
|
|
186
|
+
if (!Number.isSafeInteger(value) || value < 0 || Object.is(value, -0)) {
|
|
187
|
+
throw new Refusal('payload-not-canonical', 'A number must be a whole number from 0 to 9,007,199,254,740,991.');
|
|
188
|
+
}
|
|
189
|
+
return String(value);
|
|
190
|
+
}
|
|
191
|
+
if (value === null || typeof value !== 'object') {
|
|
192
|
+
throw new Refusal('payload-not-canonical', 'Only objects, lists, strings and whole numbers are allowed.');
|
|
193
|
+
}
|
|
194
|
+
if (depth >= MAX_DEPTH) throw new Refusal('payload-not-canonical', 'The content nests too deeply.');
|
|
195
|
+
if (Array.isArray(value)) {
|
|
196
|
+
// A list with a gap in it is not JSON: "map" would skip the gap and write nothing in its place.
|
|
197
|
+
const items = [];
|
|
198
|
+
for (let i = 0; i < value.length; i++) {
|
|
199
|
+
if (!Object.hasOwn(value, i)) throw new Refusal('payload-not-canonical', 'A list has a gap in it.');
|
|
200
|
+
items.push(canonicalJson(value[i], depth + 1));
|
|
201
|
+
}
|
|
202
|
+
return '[' + items.join(',') + ']';
|
|
203
|
+
}
|
|
204
|
+
const names = Object.keys(value).sort();
|
|
205
|
+
return '{' + names.map((n) => canonicalJson(n, depth + 1) + ':' + canonicalJson(value[n], depth + 1)).join(',') + '}';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Read JSON text that must already be in the canonical form. The text is
|
|
210
|
+
* parsed, written again canonically, and refused if the two differ. This
|
|
211
|
+
* refuses a repeated member name, stray spaces and unusual number forms.
|
|
212
|
+
* @param {string} text
|
|
213
|
+
* @param {string} code the refusal code for text that is not canonical
|
|
214
|
+
* @returns {any}
|
|
215
|
+
*/
|
|
216
|
+
export function parseCanonical(text, code = 'payload-not-canonical') {
|
|
217
|
+
let value;
|
|
218
|
+
try {
|
|
219
|
+
value = JSON.parse(text);
|
|
220
|
+
} catch {
|
|
221
|
+
throw new Refusal(code === 'payload-not-canonical' ? code : 'not-json', 'The text is not JSON.');
|
|
222
|
+
}
|
|
223
|
+
let again;
|
|
224
|
+
try {
|
|
225
|
+
again = canonicalJson(value);
|
|
226
|
+
} catch (e) {
|
|
227
|
+
if (e instanceof Refusal) throw new Refusal(code, e.message);
|
|
228
|
+
// Nesting so deep that the engine itself gives up.
|
|
229
|
+
throw new Refusal(code, 'The content nests too deeply.');
|
|
230
|
+
}
|
|
231
|
+
if (again !== text) {
|
|
232
|
+
throw new Refusal(code, 'The JSON is not in the canonical form (a repeated name, stray spaces, or members out of order).');
|
|
233
|
+
}
|
|
234
|
+
return value;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* How many characters a text holds, counted as Unicode code points, so that
|
|
239
|
+
* every implementation counts the same way.
|
|
240
|
+
* @param {string} text
|
|
241
|
+
* @returns {number}
|
|
242
|
+
*/
|
|
243
|
+
export function countCharacters(text) {
|
|
244
|
+
let count = 0;
|
|
245
|
+
for (let i = 0; i < text.length; i++) {
|
|
246
|
+
const c = text.charCodeAt(i);
|
|
247
|
+
// The second half of a pair does not count again.
|
|
248
|
+
if (!(c >= 0xdc00 && c <= 0xdfff)) count++;
|
|
249
|
+
}
|
|
250
|
+
return count;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The fingerprint of a record: SHA-256 of its content bytes, in base64url.
|
|
255
|
+
* @param {Uint8Array} contentBytes
|
|
256
|
+
* @returns {Promise<string>}
|
|
257
|
+
*/
|
|
258
|
+
export async function fingerprint(contentBytes) {
|
|
259
|
+
return toBase64url(await sha256(contentBytes));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- times (RFC 3339, UTC, to the second) ---
|
|
263
|
+
|
|
264
|
+
const TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {unknown} text
|
|
268
|
+
* @returns {number} milliseconds since 1970, or NaN if the text is not a time in the one accepted form
|
|
269
|
+
*/
|
|
270
|
+
export function parseTime(text) {
|
|
271
|
+
if (typeof text !== 'string' || !TIME.test(text)) return NaN;
|
|
272
|
+
const ms = Date.parse(text);
|
|
273
|
+
if (Number.isNaN(ms)) return NaN;
|
|
274
|
+
// Refuses dates that do not exist, such as 30 February.
|
|
275
|
+
return formatTime(ms) === text ? ms : NaN;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** @param {number|Date} when @returns {string} */
|
|
279
|
+
export function formatTime(when) {
|
|
280
|
+
return new Date(when).toISOString().slice(0, 19) + 'Z';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 16 random bytes in base64url: the unique number of a record. @returns {string} */
|
|
284
|
+
export function randomId() {
|
|
285
|
+
return toBase64url(globalThis.crypto.getRandomValues(new Uint8Array(16)));
|
|
286
|
+
}
|
package/src/fields.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Small checks on the fields of a record's content. Each throws a Refusal
|
|
2
|
+
// with the code "bad-field" and says which field and why.
|
|
3
|
+
|
|
4
|
+
import { isUnknownReserved } from './actions.js';
|
|
5
|
+
import { MAX_NUMBER, Refusal, countCharacters, fromBase64url, parseTime } from './encoding.js';
|
|
6
|
+
|
|
7
|
+
export function fail(path, why) {
|
|
8
|
+
return new Refusal('bad-field', `${path}: ${why}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* An object must hold every required member, and no member that is neither
|
|
13
|
+
* required nor optional. An unknown member is refused.
|
|
14
|
+
*/
|
|
15
|
+
export function members(value, required, optional, path) {
|
|
16
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw fail(path, 'must be an object.');
|
|
17
|
+
for (const name of required) {
|
|
18
|
+
if (!Object.hasOwn(value, name)) throw fail(path, `"${name}" is missing.`);
|
|
19
|
+
}
|
|
20
|
+
for (const name of Object.keys(value)) {
|
|
21
|
+
if (!required.includes(name) && !optional.includes(name)) throw fail(path, 'holds a member that is not known.');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function text(value, min, max, path) {
|
|
26
|
+
// The quick test on the raw length keeps a huge text from being counted.
|
|
27
|
+
if (typeof value !== 'string' || value.length > max * 2 || countCharacters(value) < min || countCharacters(value) > max) {
|
|
28
|
+
throw fail(path, `must be text of ${min} to ${max} characters.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function wholeNumber(value, path) {
|
|
33
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_NUMBER) throw fail(path, 'must be a whole number, 0 or more.');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function list(value, min, max, path) {
|
|
37
|
+
if (!Array.isArray(value) || value.length < min || value.length > max) {
|
|
38
|
+
throw fail(path, `must be a list of ${min} to ${max} items.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
43
|
+
const UNIT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,15}$/;
|
|
44
|
+
|
|
45
|
+
/** An action name, or the id of a service. */
|
|
46
|
+
export function shortName(value, path) {
|
|
47
|
+
if (typeof value !== 'string' || !NAME.test(value)) {
|
|
48
|
+
throw fail(path, 'must be 1 to 64 lower-case letters, digits, full stops, hyphens or underscores.');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An action name. Names that begin "provared." must be on the shared list. */
|
|
53
|
+
export function actionName(value, path) {
|
|
54
|
+
shortName(value, path);
|
|
55
|
+
if (isUnknownReserved(value)) throw fail(path, 'names that begin "provared." are reserved, and this one is not on the shared list.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function unit(value, path) {
|
|
59
|
+
if (typeof value !== 'string' || !UNIT.test(value)) {
|
|
60
|
+
throw fail(path, 'must be 1 to 16 letters, digits, full stops, hyphens or underscores, with no spaces.');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A label chosen by whoever wrote the record. Never checked against anything. */
|
|
65
|
+
export function label(value, path) {
|
|
66
|
+
text(value, 1, 200, path);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function base64urlOfLength(value, bytes, path, what) {
|
|
70
|
+
let decoded;
|
|
71
|
+
try {
|
|
72
|
+
decoded = fromBase64url(value);
|
|
73
|
+
} catch {
|
|
74
|
+
throw fail(path, `must be ${what}.`);
|
|
75
|
+
}
|
|
76
|
+
if (decoded.length !== bytes) throw fail(path, `must be ${what}.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A unique number: 16 bytes in base64url. */
|
|
80
|
+
export function id(value, path) {
|
|
81
|
+
base64urlOfLength(value, 16, path, 'a unique number of 16 bytes in base64url');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A fingerprint: SHA-256 in base64url. */
|
|
85
|
+
export function fingerprintText(value, path) {
|
|
86
|
+
base64urlOfLength(value, 32, path, 'a SHA-256 fingerprint in base64url');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @returns {number} the time in milliseconds */
|
|
90
|
+
export function time(value, path) {
|
|
91
|
+
const ms = parseTime(value);
|
|
92
|
+
if (Number.isNaN(ms)) throw fail(path, 'must be a time written as YYYY-MM-DDTHH:MM:SSZ.');
|
|
93
|
+
return ms;
|
|
94
|
+
}
|