provared 0.4.0 → 0.6.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 +1083 -0
- package/README.md +6 -3
- package/bin/provared-check.mjs +115 -11
- package/docs/guide.md +464 -15
- package/docs/threat-model.md +315 -9
- package/package.json +2 -2
- package/page/check-page.js +11 -32
- package/page/index.html +4 -0
- package/page/render.js +157 -9
- package/spec/provared-format.md +809 -23
- package/src/book.js +539 -18
- package/src/check.d.ts +3 -0
- package/src/check.js +2 -0
- package/src/denial.js +123 -0
- package/src/der.js +55 -6
- package/src/dev.d.ts +1 -1
- package/src/dev.js +1 -1
- package/src/documents.js +149 -0
- package/src/guard.js +13 -2
- package/src/index.js +18 -3
- package/src/jws.js +8 -0
- package/src/keys.js +48 -1
- package/src/pass.js +1 -1
- package/src/recorder.js +267 -15
- package/src/seal.js +36 -7
- package/src/sealer.js +812 -0
- package/src/service.js +4 -4
- package/src/signatures.js +281 -5
- package/src/standing.js +4 -4
- package/src/stub.js +18 -5
- package/src/timestamp.js +55 -16
- package/src/tools.js +30 -13
- package/src/tree.js +139 -7
- package/src/types.d.ts +314 -20
- package/src/witness.js +63 -0
- package/src/witnessing.js +362 -0
package/src/check.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export {
|
|
|
8
8
|
methodAvailable,
|
|
9
9
|
thumbprint,
|
|
10
10
|
keySetFingerprint,
|
|
11
|
+
verifyConsistency,
|
|
12
|
+
verifyInclusion,
|
|
11
13
|
blockFingerprint,
|
|
12
14
|
checkHeaderChain,
|
|
13
15
|
blocksInChain,
|
|
@@ -17,5 +19,6 @@ export {
|
|
|
17
19
|
neverWords,
|
|
18
20
|
LIMITS,
|
|
19
21
|
REFUSAL_REASONS,
|
|
22
|
+
DENIAL_SOURCES,
|
|
20
23
|
} from './types.js';
|
|
21
24
|
export type * from './types.js';
|
package/src/check.js
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
export { checkBook, checkShow, openChecker, LIMITS } from './book.js';
|
|
8
8
|
export { limitWords, conditionWords, neverWords } from './compare.js';
|
|
9
9
|
export { REFUSAL_REASONS } from './service.js';
|
|
10
|
+
export { DENIAL_SOURCES } from './denial.js';
|
|
10
11
|
export { checkSlip } from './slip.js';
|
|
11
12
|
export { keySetFingerprint } from './seal.js';
|
|
12
13
|
export { blockFingerprint } from './blockstamp.js';
|
|
13
14
|
export { checkHeaderChain, blocksInChain, MIN_BLOCKS_AFTER } from './headers.js';
|
|
14
15
|
export { thumbprint } from './keys.js';
|
|
15
16
|
export { methodAvailable } from './signatures.js';
|
|
17
|
+
export { verifyConsistency, verifyInclusion } from './tree.js';
|
package/src/denial.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The denial: the agent's own side states that it did not take an action
|
|
2
|
+
// that was asked for, and why. It is signed with the agent's two keys, or
|
|
3
|
+
// with the helper agent's under its pass. It is the agent's side's own
|
|
4
|
+
// word, and an agent can simply not write one.
|
|
5
|
+
// Format description, section 28.
|
|
6
|
+
|
|
7
|
+
import { formatTime, randomId } from './encoding.js';
|
|
8
|
+
import * as f from './fields.js';
|
|
9
|
+
import { KINDS } from './jws.js';
|
|
10
|
+
import { signWithKeySet, validateRequest } from './stub.js';
|
|
11
|
+
|
|
12
|
+
/** Who said no. */
|
|
13
|
+
export const DENIAL_SOURCES = {
|
|
14
|
+
check: 'The check before acting (section 20) did not allow the action.',
|
|
15
|
+
person: 'The person refused, or gave no approval when asked.',
|
|
16
|
+
rule: 'A rule of the software around the agent, outside the slip.',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The format's own list of finding codes: every code a checker or the check
|
|
21
|
+
* before acting gives (format description, section 28; the same list is in
|
|
22
|
+
* the list of threats, section 8). A denial whose source is "check" names
|
|
23
|
+
* its reasons from this list.
|
|
24
|
+
*/
|
|
25
|
+
export const FINDING_CODES = Object.freeze([
|
|
26
|
+
'not-json', 'bad-envelope', 'bad-base64url', 'too-large', 'unknown-type', 'bad-signatures-layout', 'payload-not-canonical',
|
|
27
|
+
'payload-type-mismatch', 'bad-field', 'bad-key', 'signature-invalid', 'passkey-wrong-type', 'passkey-challenge-mismatch',
|
|
28
|
+
'passkey-origin-mismatch', 'passkey-rpid-mismatch', 'passkey-user-not-present', 'passkey-user-not-verified', 'passkey-bad-data',
|
|
29
|
+
'issuer-not-expected', 'slip-missing', 'slip-unusable', 'chain-broken', 'time-went-backwards', 'duplicate-id', 'duplicate-slip',
|
|
30
|
+
'countersignature-invalid', 'countersignature-wrong-stub', 'countersignature-not-possible', 'line-not-canonical', 'unknown-entry',
|
|
31
|
+
'approval-mismatch', 'approval-reused', 'approval-invalid', 'approval-not-found', 'approval-dated-after-stub', 'terms-not-found',
|
|
32
|
+
'terms-unusable', 'terms-mismatch', 'pass-missing', 'pass-mismatch', 'pass-too-deep', 'cover-invalid', 'cancellation-invalid',
|
|
33
|
+
'cancellation-not-found', 'acknowledgement-mismatch', 'vouching-missing', 'seal-mismatch', 'seal-chain-broken', 'sealer-not-expected', 'book-cut-short', 'seal-not-in-book', 'witness-invalid',
|
|
34
|
+
'dated-after-stamp', 'stamp-bad-data', 'stamp-wrong-data', 'stamp-invalid', 'headers-invalid', 'record-not-sound', 'proof-invalid',
|
|
35
|
+
'root-mismatch', 'check-failed', 'action-not-allowed', 'party-not-allowed', 'outside-valid-time', 'amount-missing', 'over-limit',
|
|
36
|
+
'over-each-limit', 'over-count-limit', 'over-period-limit', 'countersignature-missing', 'approval-missing', 'prohibited',
|
|
37
|
+
'pass-not-allowed', 'pass-wider', 'after-cancellation', 'outside-terms',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** The most finding codes one denial may name. */
|
|
41
|
+
export const MAX_DENIAL_CODES = 16;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The reasons of a refusal by the check before acting, as a denial names
|
|
45
|
+
* them: the codes of its problems, then of its findings, each once, in that
|
|
46
|
+
* order, from the format's own list, at most 16. Where none is on the list,
|
|
47
|
+
* "check-failed".
|
|
48
|
+
* @param {{problems: {code: string}[], breaches: {code: string}[]}} answer the answer of the check before acting
|
|
49
|
+
* @returns {string[]}
|
|
50
|
+
*/
|
|
51
|
+
export function denialCodes(answer) {
|
|
52
|
+
const codes = [];
|
|
53
|
+
for (const p of [...answer.problems, ...answer.breaches]) {
|
|
54
|
+
if (p && typeof p.code === 'string' && FINDING_CODES.includes(p.code) && !codes.includes(p.code)) codes.push(p.code);
|
|
55
|
+
}
|
|
56
|
+
return codes.length ? codes.slice(0, MAX_DENIAL_CODES) : ['check-failed'];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Confirm every member of a denial's content.
|
|
61
|
+
* @param {any} c
|
|
62
|
+
*/
|
|
63
|
+
export function validateDenialContent(c) {
|
|
64
|
+
f.members(c, ['action', 'id', 'slip', 'source', 'type', 'when'], ['amount', 'codes', 'details', 'pass', 'with'], 'denial');
|
|
65
|
+
f.id(c.id, 'id');
|
|
66
|
+
f.fingerprintText(c.slip, 'slip');
|
|
67
|
+
if (Object.hasOwn(c, 'pass')) f.fingerprintText(c.pass, 'pass');
|
|
68
|
+
// What was asked for is written down as it was asked, as in a refusal: a
|
|
69
|
+
// reserved name that is not on the shared list may be why it was refused.
|
|
70
|
+
f.shortName(c.action, 'action');
|
|
71
|
+
// The amount, the service and the documents as in a stub; the service
|
|
72
|
+
// need not be one the slip names.
|
|
73
|
+
validateRequest({ ...c, action: 'x' });
|
|
74
|
+
if (typeof c.source !== 'string' || !Object.hasOwn(DENIAL_SOURCES, c.source)) throw f.fail('source', 'must be "check", "person" or "rule".');
|
|
75
|
+
if (c.source === 'check') {
|
|
76
|
+
if (!Object.hasOwn(c, 'codes')) throw f.fail('codes', 'is missing: a denial by the check before acting names its reasons.');
|
|
77
|
+
f.list(c.codes, 1, MAX_DENIAL_CODES, 'codes');
|
|
78
|
+
c.codes.forEach((code, i) => {
|
|
79
|
+
if (typeof code !== 'string' || !FINDING_CODES.includes(code)) throw f.fail(`codes[${i}]`, 'must be a code from the format\'s own list of finding codes.');
|
|
80
|
+
});
|
|
81
|
+
if (new Set(c.codes).size !== c.codes.length) throw f.fail('codes', 'a code is repeated.');
|
|
82
|
+
} else if (Object.hasOwn(c, 'codes')) {
|
|
83
|
+
throw f.fail('codes', 'only a denial by the check before acting names codes.');
|
|
84
|
+
}
|
|
85
|
+
f.time(c.when, 'when');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Write and sign a denial: the agent's side states that it did not take an
|
|
90
|
+
* action that was asked for, and why. It is signed with the keys of the
|
|
91
|
+
* agent the slip names or, where the signer is a helper agent, with the
|
|
92
|
+
* keys the pass names.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} fields
|
|
95
|
+
* @param {string} fields.slip the fingerprint of the slip the agent acts under
|
|
96
|
+
* @param {string} [fields.pass] for a helper agent: the fingerprint of the pass it acts under
|
|
97
|
+
* @param {string} fields.action what was asked for
|
|
98
|
+
* @param {{unit: string, value: number}} [fields.amount]
|
|
99
|
+
* @param {string} [fields.with] the service asked for, as asked; it need not be one the slip names
|
|
100
|
+
* @param {{name: string, sha256: string}[]} [fields.details] fingerprints of documents, never the documents
|
|
101
|
+
* @param {'check'|'person'|'rule'} fields.source who said no
|
|
102
|
+
* @param {string[]} [fields.codes] where the source is "check": 1 to 16 finding codes, from FINDING_CODES
|
|
103
|
+
* @param {number|Date} [fields.when] defaults to now
|
|
104
|
+
* @param {any[]} privateKeys the agent's private keys, or signers: Ed25519, then ML-DSA-87
|
|
105
|
+
* @returns {Promise<{record: object, fingerprint: string}>}
|
|
106
|
+
*/
|
|
107
|
+
export async function writeDenial(fields, privateKeys) {
|
|
108
|
+
const content = {
|
|
109
|
+
type: KINDS.denial.type,
|
|
110
|
+
id: fields.id ?? randomId(),
|
|
111
|
+
slip: fields.slip,
|
|
112
|
+
action: fields.action,
|
|
113
|
+
source: fields.source,
|
|
114
|
+
when: formatTime(fields.when ?? Date.now()),
|
|
115
|
+
};
|
|
116
|
+
if (fields.pass) content.pass = fields.pass;
|
|
117
|
+
if (fields.amount) content.amount = fields.amount;
|
|
118
|
+
if (fields.with) content.with = fields.with;
|
|
119
|
+
if (fields.details && fields.details.length) content.details = fields.details;
|
|
120
|
+
if (fields.codes !== undefined && fields.codes !== null) content.codes = fields.codes;
|
|
121
|
+
validateDenialContent(content);
|
|
122
|
+
return signWithKeySet('denial', content, privateKeys);
|
|
123
|
+
}
|
package/src/der.js
CHANGED
|
@@ -81,6 +81,30 @@ export function contentOf(bytes, element) {
|
|
|
81
81
|
return bytes.subarray(element.start, element.end);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Whether the items of a set are in the order DER requires (X.690, section
|
|
86
|
+
* 11.6): their encodings compared as strings of bytes, the shorter padded
|
|
87
|
+
* at its end with zero bytes. Two equal encodings may stand side by side.
|
|
88
|
+
* @param {Uint8Array} bytes
|
|
89
|
+
* @param {{from: number, end: number}[]} items the elements of the set, in the order they appear
|
|
90
|
+
*/
|
|
91
|
+
export function setInOrder(bytes, items) {
|
|
92
|
+
for (let i = 1; i < items.length; i++) {
|
|
93
|
+
const a = items[i - 1];
|
|
94
|
+
const b = items[i];
|
|
95
|
+
const longest = Math.max(a.end - a.from, b.end - b.from);
|
|
96
|
+
for (let k = 0; k < longest; k++) {
|
|
97
|
+
const x = a.from + k < a.end ? bytes[a.from + k] : 0;
|
|
98
|
+
const y = b.from + k < b.end ? bytes[b.from + k] : 0;
|
|
99
|
+
if (x !== y) {
|
|
100
|
+
if (x > y) return false;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
84
108
|
/** The whole element, tag and length included. */
|
|
85
109
|
export function wholeOf(bytes, element) {
|
|
86
110
|
return bytes.subarray(element.from, element.end);
|
|
@@ -147,25 +171,45 @@ export function ecdsaToRaw(der, size) {
|
|
|
147
171
|
/**
|
|
148
172
|
* Walk a whole element and everything inside it, and refuse anything that
|
|
149
173
|
* is not strict DER: a constructed part whose items do not fill it exactly,
|
|
150
|
-
* a
|
|
151
|
-
* a
|
|
152
|
-
*
|
|
153
|
-
*
|
|
174
|
+
* a string (of bits, bytes or characters) or a time written in parts
|
|
175
|
+
* (X.690, section 10.2), a set whose items are not in order (section 11.6;
|
|
176
|
+
* only where "holdSetOrder" is true), a whole number or an object identifier
|
|
177
|
+
* that is not in its shortest form, a truth value that is not written in the
|
|
178
|
+
* one way DER allows, a string of bits whose unused bits are not zero. Parts
|
|
179
|
+
* this checker does not otherwise read are still read here, so that two
|
|
180
|
+
* checkers cannot disagree about whether a time-stamp is well formed. What a
|
|
181
|
+
* part holds as bytes (the value of an octet string) is read as bytes only.
|
|
182
|
+
* A part under a tag that hides its kind (a context tag): written in parts,
|
|
183
|
+
* the parts inside it are read like any other, but the tagged part itself is
|
|
184
|
+
* neither refused as a string written in parts nor held to the order of a
|
|
185
|
+
* set, since its tag does not say which it is; written in one piece, its
|
|
186
|
+
* value is read as bytes only. The order of a set is held only where a
|
|
187
|
+
* standard requires DER; checkStamp says where, and passes false for the
|
|
188
|
+
* rest of a time-stamp.
|
|
154
189
|
* @param {Uint8Array} bytes
|
|
155
190
|
* @param {{tag: number, start: number, end: number}} element
|
|
191
|
+
* @param {boolean} [holdSetOrder=true] whether the items of a set must be in DER order
|
|
156
192
|
*/
|
|
157
|
-
export function validateDer(bytes, element) {
|
|
193
|
+
export function validateDer(bytes, element, holdSetOrder = true) {
|
|
158
194
|
let budget = 4000;
|
|
159
195
|
const walk = (e, depth) => {
|
|
160
196
|
if (--budget < 0 || depth > 24) throw badStamp('it holds too many parts, or parts nested too deeply.');
|
|
161
197
|
const length = e.end - e.start;
|
|
162
198
|
if (e.tag & 0x20) {
|
|
199
|
+
// Of the universal kinds, only a sequence and a set are written in
|
|
200
|
+
// parts; a string or a time is written in one piece (X.690, section 10.2).
|
|
201
|
+
if ((e.tag & 0xc0) === 0 && e.tag !== TAG.SEQUENCE && e.tag !== TAG.SET) {
|
|
202
|
+
throw badStamp('a string of bits, bytes or characters, or a time, is written in parts, which DER does not allow.');
|
|
203
|
+
}
|
|
204
|
+
const items = [];
|
|
163
205
|
let at = e.start;
|
|
164
206
|
while (at < e.end) {
|
|
165
207
|
const child = readElement(bytes, at, e.end);
|
|
166
208
|
walk(child, depth + 1);
|
|
209
|
+
items.push(child);
|
|
167
210
|
at = child.end;
|
|
168
211
|
}
|
|
212
|
+
if (holdSetOrder && e.tag === TAG.SET && !setInOrder(bytes, items)) throw badStamp('the items of a set are not in the order DER requires.');
|
|
169
213
|
return;
|
|
170
214
|
}
|
|
171
215
|
const first = bytes[e.start];
|
|
@@ -185,7 +229,12 @@ export function validateDer(bytes, element) {
|
|
|
185
229
|
} else if (e.tag === TAG.NULL) {
|
|
186
230
|
if (length !== 0) throw badStamp('an empty value is not empty.');
|
|
187
231
|
} else if (e.tag === TAG.BIT_STRING) {
|
|
188
|
-
|
|
232
|
+
// The first byte counts the unused bits of the last byte, 0 to 7; a
|
|
233
|
+
// string of no bits has none; and the unused bits are zero (X.690,
|
|
234
|
+
// sections 8.6.2 and 11.2.1).
|
|
235
|
+
if (length === 0 || first > 7 || (length === 1 && first !== 0) || (length > 1 && (bytes[e.end - 1] & ((1 << first) - 1)) !== 0)) {
|
|
236
|
+
throw badStamp('a string of bits is not written as DER allows.');
|
|
237
|
+
}
|
|
189
238
|
}
|
|
190
239
|
};
|
|
191
240
|
walk(element, 0);
|
package/src/dev.d.ts
CHANGED
|
@@ -50,5 +50,5 @@ export function developmentSlip(fields: DevelopmentSlipFields): Promise<Developm
|
|
|
50
50
|
/** The stub writer opened under a development slip, in one call. */
|
|
51
51
|
export function developmentRecorder(
|
|
52
52
|
fields: DevelopmentSlipFields,
|
|
53
|
-
more?: { options?: CheckOptions; now?: () => number; countersignWithin?: number },
|
|
53
|
+
more?: { options?: CheckOptions; now?: () => number; countersignWithin?: number; signWithin?: number; denials?: boolean; denialsPerHour?: number },
|
|
54
54
|
): Promise<Omit<DevelopmentSlip, 'book'> & { recorder: Recorder }>;
|
package/src/dev.js
CHANGED
|
@@ -110,7 +110,7 @@ export async function developmentSlip(fields = {}) {
|
|
|
110
110
|
* The stub writer, opened under a development slip, in one call: the
|
|
111
111
|
* shortest way to a first record. The same as developmentSlip followed by
|
|
112
112
|
* openRecorder; "more" is passed to openRecorder (options, now,
|
|
113
|
-
* countersignWithin).
|
|
113
|
+
* countersignWithin, signWithin, denials, denialsPerHour).
|
|
114
114
|
*
|
|
115
115
|
* @param {object} fields the members of the slip; "actions" is required
|
|
116
116
|
* @param {object} [more]
|
package/src/documents.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// The reserved names of the documents a stub names (format description,
|
|
2
|
+
// section 27), and the fingerprints they stand for.
|
|
3
|
+
//
|
|
4
|
+
// A stub's "details" names documents by their fingerprints. Most names are
|
|
5
|
+
// labels that whoever wrote the stub chose. Names that begin "provared."
|
|
6
|
+
// are reserved: each one on the list below says exactly which text its
|
|
7
|
+
// fingerprint is made from, so that an investigator who holds that text
|
|
8
|
+
// (the call's arguments, the HTTP request, the identifier a platform's own
|
|
9
|
+
// log shows for the call) can work the fingerprint out again and match
|
|
10
|
+
// the stub to it, without either side's help.
|
|
11
|
+
|
|
12
|
+
import { Refusal, fingerprint, sha256, toBase64url, utf8 } from './encoding.js';
|
|
13
|
+
|
|
14
|
+
/** Every document name that begins with this is reserved for the list below. */
|
|
15
|
+
export const RESERVED_DOCUMENT_PREFIX = 'provared.';
|
|
16
|
+
|
|
17
|
+
/** The reserved document names, and the exact text whose SHA-256 each one gives. */
|
|
18
|
+
export const DOCUMENT_NAMES = Object.freeze({
|
|
19
|
+
'provared.arguments': "The call's arguments, written in the one form of RFC 8785.",
|
|
20
|
+
'provared.http-request': 'The request, written as the signature base of RFC 9421, section 2.5, over "@method", "@target-uri" and, where there is a body, "content-digest" (RFC 9530, with SHA-256). No header is covered.',
|
|
21
|
+
'provared.http-response': 'The answer, written as the signature base of RFC 9421, section 2.5, over "@status" and, where there is a body, "content-digest" (RFC 9530, with SHA-256). No header is covered.',
|
|
22
|
+
'provared.trace-id': 'The trace-id of the W3C Trace Context "traceparent" header that the agent\'s side sent, which that standard writes as 32 lower-case hexadecimal characters. Its form is not checked.',
|
|
23
|
+
'provared.request-id': "An identifier that the agent's side chose and sent with the call, exactly as sent.",
|
|
24
|
+
'provared.event-id': 'An identifier that the platform gave for the call, exactly as it gave it.',
|
|
25
|
+
'provared.error': "The error text that the agent's software was given, exactly as given.",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The reserved names that may appear more than once in one record: an
|
|
30
|
+
* identifier from each of several systems. Every other reserved name may
|
|
31
|
+
* appear once at most.
|
|
32
|
+
*/
|
|
33
|
+
export const REPEATABLE_DOCUMENT_NAMES = Object.freeze(['provared.trace-id', 'provared.request-id', 'provared.event-id']);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a document name is reserved and not on the list: such a name is refused.
|
|
37
|
+
* @param {string} name
|
|
38
|
+
*/
|
|
39
|
+
export function isUnknownReservedDocument(name) {
|
|
40
|
+
return typeof name === 'string' && name.startsWith(RESERVED_DOCUMENT_PREFIX) && !Object.hasOwn(DOCUMENT_NAMES, name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A method is a token of RFC 9110, section 5.6.2: one or more of these.
|
|
44
|
+
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
45
|
+
// An address is taken exactly as it was sent, never rewritten: so it must
|
|
46
|
+
// already be in the form a request carries, an absolute http or https
|
|
47
|
+
// address of visible ASCII characters, with no fragment (which is never
|
|
48
|
+
// sent).
|
|
49
|
+
const ADDRESS = /^https?:\/\/[\x21-\x22\x24-\x7e]+$/;
|
|
50
|
+
|
|
51
|
+
function body(value, path) {
|
|
52
|
+
// Nothing given, or null: the message has no body.
|
|
53
|
+
if (value === undefined || value === null) return undefined;
|
|
54
|
+
if (!(value instanceof Uint8Array)) throw new Refusal('bad-field', `${path}: must be the bytes of the body, as sent.`);
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Base64 with padding (RFC 4648, section 4), as a byte sequence of RFC 8941 is written.
|
|
59
|
+
function base64(bytes) {
|
|
60
|
+
const text = toBase64url(bytes).replace(/-/g, '+').replace(/_/g, '/');
|
|
61
|
+
return text + '='.repeat((4 - (text.length % 4)) % 4);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The Content-Digest field value of RFC 9530, with SHA-256 only.
|
|
65
|
+
async function contentDigest(bytes) {
|
|
66
|
+
return `sha-256=:${base64(await sha256(bytes))}:`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The signature base of RFC 9421, section 2.5: a line for each component,
|
|
70
|
+
// "<identifier>: <value>" and a line feed, then the line
|
|
71
|
+
// "@signature-params" with the list of the components and no parameters,
|
|
72
|
+
// and no line feed after it. Nothing is signed with it: it is only an exact
|
|
73
|
+
// way to write a request or an answer as text.
|
|
74
|
+
function signatureBase(components) {
|
|
75
|
+
const lines = components.map(([id, value]) => `"${id}": ${value}\n`);
|
|
76
|
+
return lines.join('') + `"@signature-params": (${components.map(([id]) => `"${id}"`).join(' ')})`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The text that the reserved name "provared.http-request" is made from.
|
|
81
|
+
* @param {{method: string, url: string, body?: Uint8Array}} r
|
|
82
|
+
* @returns {Promise<string>}
|
|
83
|
+
*/
|
|
84
|
+
export async function httpRequestBase(r) {
|
|
85
|
+
if (r === null || typeof r !== 'object') throw new Refusal('bad-field', 'The request must be an object.');
|
|
86
|
+
const { method, url } = r;
|
|
87
|
+
const given = body(r.body, 'body');
|
|
88
|
+
if (typeof method !== 'string' || !TOKEN.test(method)) throw new Refusal('bad-field', 'method: must be the method of the request as sent, a token such as GET or POST.');
|
|
89
|
+
if (typeof url !== 'string' || !ADDRESS.test(url)) {
|
|
90
|
+
throw new Refusal('bad-field', 'url: must be the address of the request as sent: an absolute http or https address of visible ASCII characters, with no fragment.');
|
|
91
|
+
}
|
|
92
|
+
const components = [
|
|
93
|
+
['@method', method],
|
|
94
|
+
['@target-uri', url],
|
|
95
|
+
];
|
|
96
|
+
if (given !== undefined) components.push(['content-digest', await contentDigest(given)]);
|
|
97
|
+
return signatureBase(components);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The text that the reserved name "provared.http-response" is made from.
|
|
102
|
+
* @param {{status: number, body?: Uint8Array}} r
|
|
103
|
+
* @returns {Promise<string>}
|
|
104
|
+
*/
|
|
105
|
+
export async function httpResponseBase(r) {
|
|
106
|
+
if (r === null || typeof r !== 'object') throw new Refusal('bad-field', 'The answer must be an object.');
|
|
107
|
+
const { status } = r;
|
|
108
|
+
const given = body(r.body, 'body');
|
|
109
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) throw new Refusal('bad-field', 'status: must be the status code of the answer, a whole number from 100 to 599.');
|
|
110
|
+
const components = [['@status', String(status)]];
|
|
111
|
+
if (given !== undefined) components.push(['content-digest', await contentDigest(given)]);
|
|
112
|
+
return signatureBase(components);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The fingerprint of an HTTP request, for the document "provared.http-request".
|
|
117
|
+
* @param {{method: string, url: string, body?: Uint8Array}} r the method and the address exactly as sent, and the body's bytes where there is a body
|
|
118
|
+
* @returns {Promise<string>}
|
|
119
|
+
* @throws {Refusal} "bad-field" if the request is not as set out
|
|
120
|
+
*/
|
|
121
|
+
export async function httpRequestFingerprint(r) {
|
|
122
|
+
return fingerprint(utf8(await httpRequestBase(r)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The fingerprint of an HTTP answer, for the document "provared.http-response".
|
|
127
|
+
* @param {{status: number, body?: Uint8Array}} r the status code, and the body's bytes where there is a body
|
|
128
|
+
* @returns {Promise<string>}
|
|
129
|
+
* @throws {Refusal} "bad-field" if the answer is not as set out
|
|
130
|
+
*/
|
|
131
|
+
export async function httpResponseFingerprint(r) {
|
|
132
|
+
return fingerprint(utf8(await httpResponseBase(r)));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The fingerprint of an identifier or of an error text, exactly as given:
|
|
137
|
+
* SHA-256 of its UTF-8 bytes. For "provared.trace-id", "provared.request-id",
|
|
138
|
+
* "provared.event-id" and "provared.error".
|
|
139
|
+
* @param {string} id
|
|
140
|
+
* @returns {Promise<string>}
|
|
141
|
+
* @throws {Refusal} "bad-field" if it is not text, is empty, or is not well-formed Unicode
|
|
142
|
+
*/
|
|
143
|
+
export async function identifierFingerprint(id) {
|
|
144
|
+
if (typeof id !== 'string' || id.length === 0) throw new Refusal('bad-field', 'The identifier must be text of one character or more.');
|
|
145
|
+
if (typeof id.isWellFormed === 'function' ? !id.isWellFormed() : /[\ud800-\udfff]/.test(id.replace(/[\ud800-\udbff][\udc00-\udfff]/g, ''))) {
|
|
146
|
+
throw new Refusal('bad-field', 'The identifier holds text that is not well-formed Unicode.');
|
|
147
|
+
}
|
|
148
|
+
return fingerprint(utf8(id));
|
|
149
|
+
}
|
package/src/guard.js
CHANGED
|
@@ -21,7 +21,7 @@ const notSound = (why) => new Refusal('record-not-sound', why);
|
|
|
21
21
|
// 131,072 bytes, and an action whose record would not fit is not allowed.
|
|
22
22
|
const STUB_BOUND = 6700 + 600;
|
|
23
23
|
const COUNTERSIGNATURE_BOUND = 6900;
|
|
24
|
-
function lineBound(c, approval) {
|
|
24
|
+
export function lineBound(c, approval) {
|
|
25
25
|
let approvalBytes = 0;
|
|
26
26
|
if (approval !== undefined) {
|
|
27
27
|
try {
|
|
@@ -98,6 +98,8 @@ function onlySealThirdUnchecked(result) {
|
|
|
98
98
|
const third = sigs.length === 3 && by('Ed25519')?.state === 'valid' && by('ML-DSA-87')?.state === 'valid' && by(SEAL_THIRD)?.state === 'unavailable';
|
|
99
99
|
if (third) excused++; else note(sigs);
|
|
100
100
|
stamps(e.stamps ?? []);
|
|
101
|
+
// The witness statements beside it: each of their signatures counts, as for any record.
|
|
102
|
+
for (const w of e.witnesses ?? []) note(w.signatures ?? []);
|
|
101
103
|
} else if (e.kind === 'slip') {
|
|
102
104
|
if (!e.signature || e.signature.state === 'unavailable') missing.add(e.signature ? e.signature.alg : 'passkey');
|
|
103
105
|
else if (e.signature.state === 'unchecked') skipped = true;
|
|
@@ -111,7 +113,7 @@ function onlySealThirdUnchecked(result) {
|
|
|
111
113
|
if (!e.signature) skipped = true;
|
|
112
114
|
else if (e.signature.state === 'unavailable') missing.add(e.signature.alg);
|
|
113
115
|
stamps(e.stamps ?? []);
|
|
114
|
-
} else if (['refusal', 'terms', 'vouching', 'withdrawal', 'pass', 'acknowledgement'].includes(e.kind)) {
|
|
116
|
+
} else if (['refusal', 'terms', 'vouching', 'withdrawal', 'pass', 'acknowledgement', 'denial'].includes(e.kind)) {
|
|
115
117
|
note(e.signatures ?? []);
|
|
116
118
|
} else {
|
|
117
119
|
skipped = true;
|
|
@@ -120,6 +122,15 @@ function onlySealThirdUnchecked(result) {
|
|
|
120
122
|
for (const h of result.held ?? []) {
|
|
121
123
|
stamps(h.stamps ?? []);
|
|
122
124
|
for (const a of h.acknowledgements ?? []) note(a.signatures ?? []);
|
|
125
|
+
// A seal held elsewhere, handed over beside the book: excused as a seal in the book is.
|
|
126
|
+
if (h.kind === 'seal') {
|
|
127
|
+
const sigs = h.signatures ?? [];
|
|
128
|
+
const by = (method) => sigs.find((x) => x.method === method);
|
|
129
|
+
const third = sigs.length === 3 && by('Ed25519')?.state === 'valid' && by('ML-DSA-87')?.state === 'valid' && by(SEAL_THIRD)?.state === 'unavailable';
|
|
130
|
+
if (third) excused++;
|
|
131
|
+
else for (const x of sigs) if (x.state === 'unavailable') missing.add(x.method);
|
|
132
|
+
for (const w of h.witnesses ?? []) note(w.signatures ?? []);
|
|
133
|
+
}
|
|
123
134
|
}
|
|
124
135
|
return !skipped && missing.size === 0 && excused > 0 ? [SEAL_THIRD] : null;
|
|
125
136
|
}
|
package/src/index.js
CHANGED
|
@@ -11,13 +11,22 @@
|
|
|
11
11
|
// writeStub (the agent), countersign, writeRefusal, writeTerms
|
|
12
12
|
// (the service), writeSeal (whoever keeps the book), blockStampItem
|
|
13
13
|
// (a proof from a public blockchain, to put beside a seal),
|
|
14
|
+
// writeWitness (a witness signs for a seal), openWitness (a witness
|
|
15
|
+
// that signs only for a seal that extends the last one it holds),
|
|
16
|
+
// openSealer (a central sealer, which seals books it is sent only
|
|
17
|
+
// the leaf fingerprints of: leafFingerprint),
|
|
14
18
|
// prepareCancellation + assembleCancellation (the person ends a slip),
|
|
15
19
|
// writeAcknowledgement (the agent's side says it was handed a cancellation),
|
|
20
|
+
// writeDenial (the agent's side says it did not take an action, and why),
|
|
16
21
|
// writeVouching, writeWithdrawal (an organisation vouches for a name),
|
|
17
22
|
// writePass (an agent hands part of its permission to a helper agent),
|
|
18
23
|
// writeBook, makeShow
|
|
19
|
-
// Keys: generateKeySet, generateSealKeySet, thumbprint, keySetFingerprint
|
|
20
|
-
//
|
|
24
|
+
// Keys: generateKeySet, generateSealKeySet, thumbprint, keySetFingerprint,
|
|
25
|
+
// signerFromKey (a key in memory as a signer: wherever "privateKeys"
|
|
26
|
+
// is taken, each item may be a key or a signer, an object with
|
|
27
|
+
// "key" and "sign", for a key held elsewhere)
|
|
28
|
+
// Names: the shared list of actions, the kinds of action, the rules of conduct;
|
|
29
|
+
// the reserved document names, and the fingerprints they stand for
|
|
21
30
|
|
|
22
31
|
export * from './check.js';
|
|
23
32
|
export { checkBefore } from './guard.js';
|
|
@@ -29,9 +38,15 @@ export { writeStub, countersign } from './stub.js';
|
|
|
29
38
|
export { writeRefusal, writeTerms } from './service.js';
|
|
30
39
|
export { writePass } from './pass.js';
|
|
31
40
|
export { writeSeal } from './seal.js';
|
|
41
|
+
export { writeWitness, MAX_WITNESSES } from './witness.js';
|
|
42
|
+
export { openWitness, bookIdentity, witnessProof } from './witnessing.js';
|
|
43
|
+
export { openSealer, leafFingerprint } from './sealer.js';
|
|
44
|
+
export { consistencyProof, inclusionPath } from './tree.js';
|
|
32
45
|
export { blockStampItem } from './blockstamp.js';
|
|
33
46
|
export { prepareCancellation, assembleCancellation, writeAcknowledgement, writeVouching, writeWithdrawal, VOUCHED_KINDS } from './standing.js';
|
|
47
|
+
export { writeDenial, denialCodes, FINDING_CODES } from './denial.js';
|
|
34
48
|
export { entryLine, writeBook, makeShow } from './book.js';
|
|
35
|
-
export { generateKeySet, generateSealKeySet } from './signatures.js';
|
|
49
|
+
export { generateKeySet, generateSealKeySet, signerFromKey } from './signatures.js';
|
|
36
50
|
export { ACTION_KINDS, SHARED_ACTIONS, CONDUCT_RULES, RESERVED_PREFIX, actionKind } from './actions.js';
|
|
51
|
+
export { DOCUMENT_NAMES, RESERVED_DOCUMENT_PREFIX, httpRequestFingerprint, httpResponseFingerprint, identifierFingerprint } from './documents.js';
|
|
37
52
|
export { fingerprint, formatTime, randomId, toBase64url, fromBase64url, utf8, sha256 } from './encoding.js';
|
package/src/jws.js
CHANGED
|
@@ -66,6 +66,14 @@ export const KINDS = {
|
|
|
66
66
|
type: 'provared.pass.v0',
|
|
67
67
|
protected: ['{"typ":"vnd.provared.pass.v0+json","alg":"Ed25519"}', '{"typ":"vnd.provared.pass.v0+json","alg":"ML-DSA-87"}'],
|
|
68
68
|
},
|
|
69
|
+
denial: {
|
|
70
|
+
type: 'provared.denial.v0',
|
|
71
|
+
protected: ['{"typ":"vnd.provared.denial.v0+json","alg":"Ed25519"}', '{"typ":"vnd.provared.denial.v0+json","alg":"ML-DSA-87"}'],
|
|
72
|
+
},
|
|
73
|
+
witness: {
|
|
74
|
+
type: 'provared.witness.v0',
|
|
75
|
+
protected: ['{"typ":"vnd.provared.witness.v0+json","alg":"Ed25519"}', '{"typ":"vnd.provared.witness.v0+json","alg":"ML-DSA-87"}'],
|
|
76
|
+
},
|
|
69
77
|
seal: {
|
|
70
78
|
type: 'provared.seal.v0',
|
|
71
79
|
protected: [
|
package/src/keys.js
CHANGED
|
@@ -46,6 +46,50 @@ export function weakEd25519(bytes) {
|
|
|
46
46
|
return y >= P || SMALL_ORDER_Y.has(y);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// The order L of the Ed25519 base point (RFC 8032, section 5.1). The number
|
|
50
|
+
// S of a signature must be below it (RFC 8032, section 5.1.7); libraries
|
|
51
|
+
// differ in whether they check, so a checker checks itself.
|
|
52
|
+
const L = 2n ** 252n + 27742317777372353535851937790883648493n;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether bytes are an Ed25519 signature that can never be valid, whatever
|
|
56
|
+
* the key: not 64 bytes; or its first half (the point R) is one under which
|
|
57
|
+
* anyone can sign or is written in more than one way; or its second half
|
|
58
|
+
* (the number S, little-endian) is not below L.
|
|
59
|
+
* @param {Uint8Array} signature
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function badEd25519Signature(signature) {
|
|
63
|
+
if (signature.length !== 64) return true;
|
|
64
|
+
if (weakEd25519(signature.subarray(0, 32))) return true;
|
|
65
|
+
let s = 0n;
|
|
66
|
+
for (let i = 63; i >= 32; i--) s = (s << 8n) | BigInt(signature[i]);
|
|
67
|
+
return s >= L;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The curve P-256 (FIPS 186-5, section D.1.2.3): y^2 = x^3 - 3x + b over
|
|
71
|
+
// the whole numbers modulo p. A key whose two halves are not a point on it
|
|
72
|
+
// can check no signature, and libraries report it in different ways, so a
|
|
73
|
+
// checker refuses it itself.
|
|
74
|
+
const P256_P = 2n ** 256n - 2n ** 224n + 2n ** 192n + 2n ** 96n - 1n;
|
|
75
|
+
const P256_B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether two halves of 32 bytes each (x and y, big-endian) are a point on
|
|
79
|
+
* the curve P-256, each below p.
|
|
80
|
+
* @param {Uint8Array} x
|
|
81
|
+
* @param {Uint8Array} y
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
export function onP256(x, y) {
|
|
85
|
+
let X = 0n;
|
|
86
|
+
let Y = 0n;
|
|
87
|
+
for (const b of x) X = (X << 8n) | BigInt(b);
|
|
88
|
+
for (const b of y) Y = (Y << 8n) | BigInt(b);
|
|
89
|
+
if (X >= P256_P || Y >= P256_P) return false;
|
|
90
|
+
return (Y * Y) % P256_P === (((X * X * X - 3n * X + P256_B) % P256_P) + P256_P) % P256_P;
|
|
91
|
+
}
|
|
92
|
+
|
|
49
93
|
function bad(where, why) {
|
|
50
94
|
return new Refusal('bad-key', `${where}: ${why}`);
|
|
51
95
|
}
|
|
@@ -94,9 +138,12 @@ export function checkKey(jwk, allowed, where) {
|
|
|
94
138
|
if (bytesOf(jwk, 'pub', where).length !== 64) throw bad(where, 'an SLH-DSA-SHA2-256s public key is 64 bytes.');
|
|
95
139
|
} else if (alg === 'ES256') {
|
|
96
140
|
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') throw bad(where, 'an ES256 key has kty EC and crv P-256.');
|
|
97
|
-
|
|
141
|
+
const x = bytesOf(jwk, 'x', where);
|
|
142
|
+
const y = bytesOf(jwk, 'y', where);
|
|
143
|
+
if (x.length !== 32 || y.length !== 32) {
|
|
98
144
|
throw bad(where, 'the two halves of a P-256 public key are 32 bytes each.');
|
|
99
145
|
}
|
|
146
|
+
if (!onP256(x, y)) throw bad(where, 'a P-256 public key must be a point on the curve, each half below the prime of the curve.');
|
|
100
147
|
} else {
|
|
101
148
|
if (jwk.kty !== 'RSA' || jwk.e !== 'AQAB') throw bad(where, 'an RS256 key has kty RSA and e AQAB.');
|
|
102
149
|
const n = bytesOf(jwk, 'n', where);
|
package/src/pass.js
CHANGED
|
@@ -46,7 +46,7 @@ export function validatePassContent(c) {
|
|
|
46
46
|
* @param {string} fields.validFrom
|
|
47
47
|
* @param {string} fields.validUntil
|
|
48
48
|
* @param {number|Date} [fields.when] defaults to now
|
|
49
|
-
* @param {
|
|
49
|
+
* @param {any[]} privateKeys the private keys of the agent that passes on, or signers: Ed25519, then ML-DSA-87
|
|
50
50
|
* @returns {Promise<{record: object, fingerprint: string}>}
|
|
51
51
|
*/
|
|
52
52
|
export async function writePass(fields, privateKeys) {
|