provared 0.4.0 → 0.5.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 +101 -0
- package/README.md +2 -2
- package/docs/guide.md +179 -3
- package/docs/threat-model.md +73 -0
- package/package.json +1 -1
- package/spec/provared-format.md +123 -1
- package/src/dev.d.ts +1 -1
- package/src/dev.js +1 -1
- package/src/documents.js +149 -0
- package/src/guard.js +1 -1
- package/src/index.js +8 -3
- package/src/pass.js +1 -1
- package/src/recorder.js +137 -11
- package/src/seal.js +4 -3
- package/src/service.js +4 -4
- package/src/signatures.js +276 -1
- package/src/standing.js +4 -4
- package/src/stub.js +18 -5
- package/src/types.d.ts +58 -12
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 {
|
package/src/index.js
CHANGED
|
@@ -16,8 +16,12 @@
|
|
|
16
16
|
// writeVouching, writeWithdrawal (an organisation vouches for a name),
|
|
17
17
|
// writePass (an agent hands part of its permission to a helper agent),
|
|
18
18
|
// writeBook, makeShow
|
|
19
|
-
// Keys: generateKeySet, generateSealKeySet, thumbprint, keySetFingerprint
|
|
20
|
-
//
|
|
19
|
+
// Keys: generateKeySet, generateSealKeySet, thumbprint, keySetFingerprint,
|
|
20
|
+
// signerFromKey (a key in memory as a signer: wherever "privateKeys"
|
|
21
|
+
// is taken, each item may be a key or a signer, an object with
|
|
22
|
+
// "key" and "sign", for a key held elsewhere)
|
|
23
|
+
// Names: the shared list of actions, the kinds of action, the rules of conduct;
|
|
24
|
+
// the reserved document names, and the fingerprints they stand for
|
|
21
25
|
|
|
22
26
|
export * from './check.js';
|
|
23
27
|
export { checkBefore } from './guard.js';
|
|
@@ -32,6 +36,7 @@ export { writeSeal } from './seal.js';
|
|
|
32
36
|
export { blockStampItem } from './blockstamp.js';
|
|
33
37
|
export { prepareCancellation, assembleCancellation, writeAcknowledgement, writeVouching, writeWithdrawal, VOUCHED_KINDS } from './standing.js';
|
|
34
38
|
export { entryLine, writeBook, makeShow } from './book.js';
|
|
35
|
-
export { generateKeySet, generateSealKeySet } from './signatures.js';
|
|
39
|
+
export { generateKeySet, generateSealKeySet, signerFromKey } from './signatures.js';
|
|
36
40
|
export { ACTION_KINDS, SHARED_ACTIONS, CONDUCT_RULES, RESERVED_PREFIX, actionKind } from './actions.js';
|
|
41
|
+
export { DOCUMENT_NAMES, RESERVED_DOCUMENT_PREFIX, httpRequestFingerprint, httpResponseFingerprint, identifierFingerprint } from './documents.js';
|
|
37
42
|
export { fingerprint, formatTime, randomId, toBase64url, fromBase64url, utf8, sha256 } from './encoding.js';
|
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) {
|
package/src/recorder.js
CHANGED
|
@@ -32,16 +32,30 @@
|
|
|
32
32
|
// objects changes nothing.
|
|
33
33
|
|
|
34
34
|
import { MAX_LINE_BYTES, answerOf, carryOn, checkCountersignature, entryLine, fixedOptions, forkCarried, latestWhen, promoteCarried, startCarried } from './book.js';
|
|
35
|
-
import { Refusal, canonicalJson, fingerprint, fromBase64url, parseTime, problemFrom, utf8 } from './encoding.js';
|
|
36
|
-
import { checkBeforeRead } from './guard.js';
|
|
35
|
+
import { Refusal, canonicalJson, fingerprint, formatTime, fromBase64url, parseTime, problemFrom, utf8 } from './encoding.js';
|
|
36
|
+
import { checkBeforeRead, lineBound } from './guard.js';
|
|
37
|
+
import { KEY_SET_METHODS } from './keys.js';
|
|
37
38
|
import { CLOCK_ALLOWANCE_MS, MAX_STAMPS } from './seal.js';
|
|
39
|
+
import { holdSigningKeys } from './signatures.js';
|
|
38
40
|
import { writeAcknowledgement } from './standing.js';
|
|
39
|
-
import { writeStub } from './stub.js';
|
|
41
|
+
import { validateRequest, writeStub } from './stub.js';
|
|
40
42
|
|
|
41
43
|
/** How long the other side is given to hand back its countersignature, unless the caller says otherwise: 30 seconds. */
|
|
42
44
|
export const COUNTERSIGN_WITHIN_MS = 30000;
|
|
45
|
+
/** How long a signer is given for each signature, unless the caller says otherwise: 30 seconds. */
|
|
46
|
+
export const SIGN_WITHIN_MS = 30000;
|
|
43
47
|
// The longest wait a timer can hold.
|
|
44
48
|
const MAX_WAIT_MS = 2 ** 31 - 1;
|
|
49
|
+
// "detailsAfter" in "act": the most documents it may add, and the most the
|
|
50
|
+
// request may name beside them, so that a stub never names more than 32.
|
|
51
|
+
const MAX_DETAILS_AFTER = 4;
|
|
52
|
+
const MAX_DETAILS_BEFORE = 32 - MAX_DETAILS_AFTER;
|
|
53
|
+
// The room kept for them: the largest documents a stub may name, a name of
|
|
54
|
+
// 200 characters that are each written as six (a control character, which
|
|
55
|
+
// the one form of RFC 8785 writes as "\u00XX"), with a fingerprint.
|
|
56
|
+
const ROOM_DOCUMENT = Object.freeze({ name: '\u0001'.repeat(200), sha256: 'A'.repeat(43) });
|
|
57
|
+
// The stub with the further documents could not be made or signed.
|
|
58
|
+
class NotAdded extends Error {}
|
|
45
59
|
// The most cancellations a check may be handed beside a book (book.js).
|
|
46
60
|
const MAX_HANDED = 16;
|
|
47
61
|
|
|
@@ -204,17 +218,20 @@ function plainHeld(copy) {
|
|
|
204
218
|
* @param {object} o
|
|
205
219
|
* @param {string} o.book the book so far: at least the slip
|
|
206
220
|
* @param {string} o.slip the fingerprint of the slip
|
|
207
|
-
* @param {
|
|
221
|
+
* @param {any[]} o.privateKeys the agent's private keys, or signers (signatures.js): Ed25519, then ML-DSA-87
|
|
208
222
|
* @param {string[]} o.issuerKeys the thumbprints of the passkeys this agent's software trusts; a slip from any other key allows nothing
|
|
209
223
|
* @param {string} [o.pass] for a helper agent: the fingerprint of the pass it acts under
|
|
210
224
|
* @param {object} [o.options] further options for the checker (sealKeys, stampServices, blocks, vouchers, cancellations).
|
|
211
225
|
* They are read once, as a whole check reads them, and fixed
|
|
212
226
|
* @param {() => number} [o.now] the clock, in milliseconds; defaults to the device's. "act" takes an action at the clock's time, and nothing is written that is dated more than 300 seconds ahead of it
|
|
213
227
|
* @param {number} [o.countersignWithin] how long the other side is given to countersign, in milliseconds; defaults to 30,000
|
|
228
|
+
* @param {number} [o.signWithin] how long a signer is given for each signature, in milliseconds; defaults to 30,000.
|
|
229
|
+
* After that the action is not taken and nothing is written; a late answer is not used
|
|
214
230
|
* @returns {Promise<object>} the writer: before, record, act, book, check, cancellations, add
|
|
215
|
-
* @throws {Refusal} "record-not-sound" if the book so far has a problem
|
|
231
|
+
* @throws {Refusal} "record-not-sound" if the book so far has a problem; "bad-field" or "bad-key" if an item of
|
|
232
|
+
* privateKeys is neither a key nor a signer, or is a signer that holds another key than the slip, or the pass, names
|
|
216
233
|
*/
|
|
217
|
-
export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass, options = {}, now = Date.now, countersignWithin = COUNTERSIGN_WITHIN_MS }) {
|
|
234
|
+
export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass, options = {}, now = Date.now, countersignWithin = COUNTERSIGN_WITHIN_MS, signWithin = SIGN_WITHIN_MS }) {
|
|
218
235
|
if (typeof now !== 'function') throw new Refusal('bad-field', 'now: must be a function that gives the time in milliseconds.');
|
|
219
236
|
// A clock that gives anything but a time is a clock that failed: no comparison with it would hold.
|
|
220
237
|
const device = now;
|
|
@@ -226,6 +243,9 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
226
243
|
if (!(Number.isFinite(countersignWithin) && countersignWithin >= 1 && countersignWithin <= MAX_WAIT_MS)) {
|
|
227
244
|
throw new Refusal('bad-field', 'countersignWithin: must be a number of milliseconds from 1 to 2,147,483,647.');
|
|
228
245
|
}
|
|
246
|
+
if (!(Number.isFinite(signWithin) && signWithin >= 1 && signWithin <= MAX_WAIT_MS)) {
|
|
247
|
+
throw new Refusal('bad-field', 'signWithin: must be a number of milliseconds from 1 to 2,147,483,647.');
|
|
248
|
+
}
|
|
229
249
|
// Whom the writer trusts is fixed when it is opened: read once, as a whole
|
|
230
250
|
// check reads it, and copied. The passkeys it trusts are its own
|
|
231
251
|
// "issuerKeys", whatever the options say, read through the same fixed
|
|
@@ -403,6 +423,17 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
403
423
|
}
|
|
404
424
|
};
|
|
405
425
|
|
|
426
|
+
// The agent's keys, or signers (signatures.js). A signer is read once,
|
|
427
|
+
// here, and must hold the keys that the slip, or the pass this writer
|
|
428
|
+
// acts under, names for its agent. Each of its signatures is waited for
|
|
429
|
+
// "signWithin" at most, under the same mark as an action, so that a
|
|
430
|
+
// signer that calls the writer is refused at once; after that nothing is
|
|
431
|
+
// written, and a late answer is not used. Keys of the platform are used
|
|
432
|
+
// as they always were.
|
|
433
|
+
const passEntry = pass ? result.entries.find((e) => e.kind === 'pass' && e.fingerprint === pass) : undefined;
|
|
434
|
+
const ownKeys = pass ? passEntry?.content?.to?.keys : slipEntry?.content?.agent?.keys;
|
|
435
|
+
const signing = await holdSigningKeys(privateKeys, KEY_SET_METHODS, ownKeys, signWithin, runInside);
|
|
436
|
+
|
|
406
437
|
// What a call hands in, copied at the moment of the call.
|
|
407
438
|
const asked = (request, more) => {
|
|
408
439
|
if (request === null || typeof request !== 'object') throw new Refusal('bad-field', 'The request must be an object.');
|
|
@@ -412,6 +443,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
412
443
|
when: more.when === undefined || more.when === null ? undefined : timeOf(more.when),
|
|
413
444
|
terms: more.terms,
|
|
414
445
|
countersign: more.countersign,
|
|
446
|
+
detailsAfter: more.detailsAfter,
|
|
415
447
|
hasApproval: more.approval !== undefined && more.approval !== null,
|
|
416
448
|
approval: undefined,
|
|
417
449
|
};
|
|
@@ -448,6 +480,20 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
448
480
|
if (call.hasApproval) proposal.approval = call.approval;
|
|
449
481
|
return proposal;
|
|
450
482
|
};
|
|
483
|
+
// Whether the stub for this request, with the countersignature the check
|
|
484
|
+
// before acting keeps room for, would still fit a line with the largest
|
|
485
|
+
// further documents that "detailsAfter" may add: the same bound as the
|
|
486
|
+
// check before acting uses, over the request with those documents.
|
|
487
|
+
const roomForMore = (request, when) => {
|
|
488
|
+
try {
|
|
489
|
+
const c = { action: request.action, when: formatTime(when) };
|
|
490
|
+
for (const name of ['amount', 'with']) if (request[name] !== undefined) c[name] = request[name];
|
|
491
|
+
c.details = [...(Array.isArray(request.details) ? request.details : []), ...Array(MAX_DETAILS_AFTER).fill(ROOM_DOCUMENT)];
|
|
492
|
+
return lineBound(c, undefined) <= MAX_LINE_BYTES;
|
|
493
|
+
} catch {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
};
|
|
451
497
|
const aheadOfClock = (when) => when > now() + CLOCK_ALLOWANCE_MS;
|
|
452
498
|
const behindClock = (when) => when < now() - CLOCK_ALLOWANCE_MS;
|
|
453
499
|
|
|
@@ -491,7 +537,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
491
537
|
pass,
|
|
492
538
|
when,
|
|
493
539
|
},
|
|
494
|
-
|
|
540
|
+
signing,
|
|
495
541
|
);
|
|
496
542
|
const entry = { stub: stub.record };
|
|
497
543
|
if (call.hasApproval) entry.approval = call.approval;
|
|
@@ -644,7 +690,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
644
690
|
}
|
|
645
691
|
let made;
|
|
646
692
|
try {
|
|
647
|
-
made = (await writeAcknowledgement({ slip, cancellation, pass, when },
|
|
693
|
+
made = (await writeAcknowledgement({ slip, cancellation, pass, when }, signing)).record;
|
|
648
694
|
} catch {
|
|
649
695
|
return undefined;
|
|
650
696
|
}
|
|
@@ -808,10 +854,28 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
808
854
|
* If taking the action never ends, the writer waits: it cannot know
|
|
809
855
|
* what happened.
|
|
810
856
|
*
|
|
857
|
+
* Where an identifier or a document comes only with what the action
|
|
858
|
+
* gives back (the identifier a platform gave the call, say), "detailsAfter"
|
|
859
|
+
* names it: a function of what the action gave, that gives up to four
|
|
860
|
+
* further documents ({name, sha256} each). The stub is then signed again
|
|
861
|
+
* after the action, with them, still in this call's turn, so that no
|
|
862
|
+
* other call comes between; it keeps the time the action began. The
|
|
863
|
+
* request may then name at most 28 documents, and no approval may be
|
|
864
|
+
* given (an approval covers exactly the documents the person saw).
|
|
865
|
+
* "detailsAfter" is given as long as the other side is given to
|
|
866
|
+
* countersign. If it fails, gives no answer in time, gives something
|
|
867
|
+
* that is not a list of at most four documents, or gives documents with
|
|
868
|
+
* which the stub would not pass the check of the book, the stub made
|
|
869
|
+
* before the action is written, without them: an action that was taken
|
|
870
|
+
* always gets its stub. "detailsAfter" in the answer says what was added
|
|
871
|
+
* and, where nothing was, why.
|
|
872
|
+
*
|
|
811
873
|
* @param {{action: string, amount?: object, with?: string, details?: object[]}} request
|
|
812
874
|
* @param {() => Promise<any>} perform takes the action; it must not call this writer
|
|
813
875
|
* @param {object} [more] as for "record"; "when" defaults to the clock and must be within 300 seconds of it
|
|
814
|
-
* @
|
|
876
|
+
* @param {(result: any) => object[]|Promise<object[]>} [more.detailsAfter] further documents, from what the action gave; it must not call this writer
|
|
877
|
+
* @returns {Promise<{done: boolean, answer: object, result?: any, stub?: object, detailsAfter?: {added: number, problem: object|null}}>}
|
|
878
|
+
* "detailsAfter" is there only where it was given and the action was taken
|
|
815
879
|
*/
|
|
816
880
|
act(request, perform, more = {}) {
|
|
817
881
|
return calling(request, more, async (call) => {
|
|
@@ -837,6 +901,19 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
837
901
|
message: 'The slip asks for the other side to countersign this action, and the stub writer was given no way to ask for a countersignature.',
|
|
838
902
|
});
|
|
839
903
|
}
|
|
904
|
+
const later = call.detailsAfter;
|
|
905
|
+
const hasLater = later !== undefined && later !== null;
|
|
906
|
+
if (hasLater) {
|
|
907
|
+
const named = Array.isArray(call.request.details) ? call.request.details.length : 0;
|
|
908
|
+
if (typeof later !== 'function') stop({ code: 'bad-field', message: 'detailsAfter: must be a function of what the action gives.' });
|
|
909
|
+
else if (call.hasApproval) {
|
|
910
|
+
stop({ code: 'bad-field', message: 'detailsAfter: cannot be used with an approval. An approval covers exactly the documents the person saw, so no document may be added to the stub after the action.' });
|
|
911
|
+
} else if (named > MAX_DETAILS_BEFORE) {
|
|
912
|
+
stop({ code: 'bad-field', message: `detailsAfter: the request may then name at most ${MAX_DETAILS_BEFORE} documents, so that there is room for ${MAX_DETAILS_AFTER} more.` });
|
|
913
|
+
} else if (answer.allowed && !roomForMore(call.request, when)) {
|
|
914
|
+
stop({ code: 'too-large', message: 'With room for the documents that "detailsAfter" may add, the record of this action would be longer than a line of a book may be.' });
|
|
915
|
+
}
|
|
916
|
+
}
|
|
840
917
|
let made;
|
|
841
918
|
let tried;
|
|
842
919
|
if (answer.allowed) {
|
|
@@ -849,8 +926,57 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
849
926
|
}
|
|
850
927
|
if (!answer.allowed) return { done: false, answer };
|
|
851
928
|
const outcome = await runInside(perform);
|
|
852
|
-
|
|
853
|
-
|
|
929
|
+
if (!hasLater) {
|
|
930
|
+
const stub = await commit(await countersigned(made, call), made, tried);
|
|
931
|
+
return { done: true, answer, result: outcome, stub };
|
|
932
|
+
}
|
|
933
|
+
// The further documents, from what the action gave back. The stub
|
|
934
|
+
// with them is signed now, still in this turn, and keeps the time
|
|
935
|
+
// the action began. If anything about them fails, the stub made
|
|
936
|
+
// before the action, which is known to fit, is the one written: an
|
|
937
|
+
// action that was taken always gets its stub.
|
|
938
|
+
const further = { added: 0, problem: null };
|
|
939
|
+
let chosen = made;
|
|
940
|
+
let chosenTried = tried;
|
|
941
|
+
try {
|
|
942
|
+
const given = await runInside(() => within(Promise.resolve(later(outcome)), countersignWithin));
|
|
943
|
+
let documents;
|
|
944
|
+
try {
|
|
945
|
+
documents = given === undefined || given === null ? [] : clone(given);
|
|
946
|
+
} catch {
|
|
947
|
+
throw new Refusal('bad-field', 'detailsAfter: must give a list of documents, as plain data.');
|
|
948
|
+
}
|
|
949
|
+
if (!Array.isArray(documents)) throw new Refusal('bad-field', 'detailsAfter: must give a list of documents.');
|
|
950
|
+
if (documents.length > MAX_DETAILS_AFTER) throw new Refusal('bad-field', `detailsAfter: gave ${documents.length} documents; it may give ${MAX_DETAILS_AFTER} at most.`);
|
|
951
|
+
if (documents.length > 0) {
|
|
952
|
+
const base = Array.isArray(call.request.details) ? call.request.details : [];
|
|
953
|
+
const all = [...base, ...documents];
|
|
954
|
+
// The documents are checked first, so that a refusal of them says why in its own words.
|
|
955
|
+
validateRequest({ action: call.request.action, details: all });
|
|
956
|
+
const withThem = { ...call, request: { ...call.request, details: all } };
|
|
957
|
+
let next;
|
|
958
|
+
try {
|
|
959
|
+
next = await composeStub(withThem, when);
|
|
960
|
+
} catch {
|
|
961
|
+
// The stub made before the action is the one written: say so, whatever failed.
|
|
962
|
+
throw new NotAdded('The stub with the further documents could not be made or signed, so the stub made before the action was written, without them.');
|
|
963
|
+
}
|
|
964
|
+
chosenTried = await fitsBook(next, 'With the documents that "detailsAfter" gave, the stub would not pass the check of the book.');
|
|
965
|
+
chosen = next;
|
|
966
|
+
further.added = documents.length;
|
|
967
|
+
}
|
|
968
|
+
} catch (e) {
|
|
969
|
+
chosen = made;
|
|
970
|
+
chosenTried = tried;
|
|
971
|
+
further.problem =
|
|
972
|
+
e instanceof NotAdded
|
|
973
|
+
? { code: 'details-not-added', message: e.message }
|
|
974
|
+
: e instanceof Refusal
|
|
975
|
+
? problemFrom(e)
|
|
976
|
+
: { code: 'details-not-added', message: 'detailsAfter failed, or gave no answer in time, so the stub was written without further documents.' };
|
|
977
|
+
}
|
|
978
|
+
const stub = await commit(await countersigned(chosen, call), chosen, chosenTried);
|
|
979
|
+
return { done: true, answer, result: outcome, stub, detailsAfter: further };
|
|
854
980
|
});
|
|
855
981
|
},
|
|
856
982
|
|
package/src/seal.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Refusal, fingerprint, formatTime, fromBase64url, randomId, toBase64url,
|
|
|
6
6
|
import * as f from './fields.js';
|
|
7
7
|
import { KINDS, encodeContent, protectedHeaders, signingInput, withinSize } from './jws.js';
|
|
8
8
|
import { SEAL_METHODS, checkSealKeySet } from './keys.js';
|
|
9
|
-
import {
|
|
9
|
+
import { signingKeys } from './signatures.js';
|
|
10
10
|
import { MAX_STAMP_BYTES } from './timestamp.js';
|
|
11
11
|
import { treeRoot } from './tree.js';
|
|
12
12
|
|
|
@@ -104,7 +104,7 @@ export async function keySetFingerprint(keys) {
|
|
|
104
104
|
* @param {{keys: object[], name: string}} fields.by the recorder's public key set and its name (a label)
|
|
105
105
|
* @param {string} [fields.previous] the fingerprint of the seal before this one in the book, if there is one
|
|
106
106
|
* @param {number|Date} [fields.when] defaults to now
|
|
107
|
-
* @param {any[]} privateKeys the recorder's private keys, in the order of the key set
|
|
107
|
+
* @param {any[]} privateKeys the recorder's private keys, or signers (signatures.js), in the order of the key set. A signer must hold the key "by" names in its place
|
|
108
108
|
* @returns {Promise<{record: object, fingerprint: string, fingerprintBytes: Uint8Array}>}
|
|
109
109
|
* "fingerprintBytes" is what a time-stamp service is asked to stamp
|
|
110
110
|
*/
|
|
@@ -123,9 +123,10 @@ export async function writeSeal(book, fields, privateKeys) {
|
|
|
123
123
|
validateSealContent(content);
|
|
124
124
|
const { contentBytes, payloadB64 } = encodeContent(content);
|
|
125
125
|
const headers = protectedHeaders('seal');
|
|
126
|
+
const signers = await signingKeys(privateKeys, SEAL_METHODS, content.by.keys);
|
|
126
127
|
const signatures = [];
|
|
127
128
|
for (let i = 0; i < SEAL_METHODS.length; i++) {
|
|
128
|
-
const signature = await
|
|
129
|
+
const signature = await signers[i](signingInput(headers[i], payloadB64));
|
|
129
130
|
signatures.push({ protected: headers[i], signature: toBase64url(signature) });
|
|
130
131
|
}
|
|
131
132
|
const name = await fingerprint(contentBytes);
|
package/src/service.js
CHANGED
|
@@ -79,7 +79,7 @@ export function validateTermsContent(c) {
|
|
|
79
79
|
* @param {{unit: string, value: number}} [fields.amount]
|
|
80
80
|
* @param {string} fields.reason one of REFUSAL_REASONS
|
|
81
81
|
* @param {number|Date} [fields.when] defaults to now
|
|
82
|
-
* @param {
|
|
82
|
+
* @param {any[]} privateKeys the service's private keys, or signers: Ed25519, then ML-DSA-87. A signer must hold the keys "by" names
|
|
83
83
|
* @returns {Promise<{record: object, fingerprint: string}>}
|
|
84
84
|
*/
|
|
85
85
|
export async function writeRefusal(fields, privateKeys) {
|
|
@@ -94,7 +94,7 @@ export async function writeRefusal(fields, privateKeys) {
|
|
|
94
94
|
};
|
|
95
95
|
if (fields.amount) content.amount = fields.amount;
|
|
96
96
|
validateRefusalContent(content);
|
|
97
|
-
return signWithKeySet('refusal', content, privateKeys);
|
|
97
|
+
return signWithKeySet('refusal', content, privateKeys, content.by.keys);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
/**
|
|
@@ -106,7 +106,7 @@ export async function writeRefusal(fields, privateKeys) {
|
|
|
106
106
|
* @param {string[]} [fields.never] kinds of action and rules of conduct the service asks agents to keep
|
|
107
107
|
* @param {string} fields.validFrom
|
|
108
108
|
* @param {string} fields.validUntil
|
|
109
|
-
* @param {
|
|
109
|
+
* @param {any[]} privateKeys the service's private keys, or signers: Ed25519, then ML-DSA-87. A signer must hold the keys "by" names
|
|
110
110
|
* @returns {Promise<{record: object, fingerprint: string}>}
|
|
111
111
|
*/
|
|
112
112
|
export async function writeTerms(fields, privateKeys) {
|
|
@@ -120,5 +120,5 @@ export async function writeTerms(fields, privateKeys) {
|
|
|
120
120
|
validUntil: fields.validUntil,
|
|
121
121
|
};
|
|
122
122
|
validateTermsContent(content);
|
|
123
|
-
return signWithKeySet('terms', content, privateKeys);
|
|
123
|
+
return signWithKeySet('terms', content, privateKeys, content.by.keys);
|
|
124
124
|
}
|