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
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// provared-check: check a book or a Show from the command line.
|
|
3
|
+
//
|
|
4
|
+
// provared-check <file> [--issuer <thumbprint>]... [--sealer <fingerprint>]... [--stamp-service <fingerprint>]...
|
|
5
|
+
// [--block <fingerprint>]... [--voucher <fingerprint>]... [--disclosures <file>] [--cancellation <file>]...
|
|
6
|
+
// [--root <top fingerprint>] [--size <entries>] [--json]
|
|
7
|
+
//
|
|
8
|
+
// Exit codes:
|
|
9
|
+
// 0 the record is intact (no problem found, every signature checked),
|
|
10
|
+
// shows nothing outside the slip, and was checked against keys you
|
|
11
|
+
// named: the passkey you trust and, where the record has a seal, the
|
|
12
|
+
// recorder you expect
|
|
13
|
+
// 1 no problem was found, but the record shows the agent outside its
|
|
14
|
+
// slip, or not every signature could be checked on this device, or
|
|
15
|
+
// you did not name whom you trust
|
|
16
|
+
// 2 a problem was found in the record, it could not be read, or what
|
|
17
|
+
// you gave on the command line could not be used
|
|
18
|
+
//
|
|
19
|
+
// It reads one file and writes to the terminal. It makes no network request.
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { MIN_BLOCKS_AFTER, REFUSAL_REASONS, blocksInChain, checkBook, checkHeaderChain, checkShow, conditionWords, limitWords, neverWords } from '../src/check.js';
|
|
23
|
+
|
|
24
|
+
// Node.js marks its ML-DSA and SLH-DSA as experimental and says so on every
|
|
25
|
+
// run. The checker does not repeat that warning; any other warning is shown.
|
|
26
|
+
process.removeAllListeners('warning');
|
|
27
|
+
process.on('warning', (w) => {
|
|
28
|
+
if (w.name !== 'ExperimentalWarning') console.error(`${w.name}: ${w.message}`);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const USAGE = `Usage: provared-check <file> [--issuer <thumbprint>]... [--sealer <fingerprint>]... [--stamp-service <fingerprint>]...
|
|
32
|
+
[--block <fingerprint>]... [--voucher <fingerprint>]... [--disclosures <file>] [--cancellation <file>]...
|
|
33
|
+
[--root <top fingerprint>] [--size <entries>] [--headers <file>] [--json]
|
|
34
|
+
|
|
35
|
+
<file> a book (one entry to a line) or a Show (one JSON object)
|
|
36
|
+
--issuer the thumbprint of an issuer key you already trust; may be repeated
|
|
37
|
+
--sealer the fingerprint of the key set of a recorder you expect to have sealed the book; may be repeated
|
|
38
|
+
--stamp-service the fingerprint of the certificate of a time-stamp service you trust; may be repeated
|
|
39
|
+
--block the fingerprint (64 hex characters) of a block of a public blockchain that you trust to be part of the chain;
|
|
40
|
+
a block time-stamp counts only if it leads to such a block; may be repeated
|
|
41
|
+
--voucher the fingerprint of the key set of an organisation whose vouching for names you trust; may be repeated
|
|
42
|
+
--disclosures a JSON file of the disclosures you were handed for covered fields: {"<fingerprint of a slip>": ["<disclosure>", ...]}
|
|
43
|
+
--cancellation a JSON file of the person's own copy of a cancellation, as the entry it would be in a book:
|
|
44
|
+
{"cancellation": {...}, "stamps": [...]}. It counts even where the book leaves it out; may be repeated.
|
|
45
|
+
It may hold "acknowledgements": what the agent's side signed when it was handed the cancellation
|
|
46
|
+
--root the top fingerprint you already trust
|
|
47
|
+
--size the number of entries you already trust; keep it with the top fingerprint
|
|
48
|
+
--headers a file of Bitcoin block headers, as provared-headers keeps it. It is checked here, from the chain's
|
|
49
|
+
first block, and a block time-stamp counts if its block is in that chain with at least six blocks after it
|
|
50
|
+
--json print the whole result as JSON, including what failed entries say
|
|
51
|
+
|
|
52
|
+
Evidence, not a verdict.`;
|
|
53
|
+
|
|
54
|
+
// A fingerprint or a thumbprint: SHA-256 in base64url, 43 characters. A
|
|
55
|
+
// block's fingerprint: 64 hexadecimal characters.
|
|
56
|
+
const FINGERPRINT = /^[A-Za-z0-9_-]{43}$/;
|
|
57
|
+
const BLOCK = /^[0-9a-f]{64}$/;
|
|
58
|
+
|
|
59
|
+
// Options that may be repeated, and those that may be given once.
|
|
60
|
+
const LISTS = { '--issuer': 'issuerKeys', '--sealer': 'sealKeys', '--stamp-service': 'stampServices', '--block': 'blocks', '--voucher': 'vouchers', '--cancellation': 'cancellations' };
|
|
61
|
+
const ONCE = { '--disclosures': 'disclosures', '--root': 'expectedRoot', '--size': 'expectedSize', '--headers': 'headers' };
|
|
62
|
+
|
|
63
|
+
// The arguments, or the reason they cannot be used. A value that is not a
|
|
64
|
+
// fingerprint is refused, never set aside: a typing error must not quietly
|
|
65
|
+
// leave a time-stamp or a name uncounted.
|
|
66
|
+
function parseArguments(argv) {
|
|
67
|
+
const out = { file: null, issuerKeys: [], sealKeys: [], stampServices: [], blocks: [], vouchers: [], cancellations: [], disclosures: undefined, expectedRoot: undefined, expectedSize: undefined, json: false };
|
|
68
|
+
for (let i = 0; i < argv.length; i++) {
|
|
69
|
+
const a = argv[i];
|
|
70
|
+
if (a === '--help' || a === '-h') return { help: true };
|
|
71
|
+
if (a === '--json') {
|
|
72
|
+
if (out.json) return { error: '--json may be given once.' };
|
|
73
|
+
out.json = true;
|
|
74
|
+
} else if (Object.hasOwn(LISTS, a) || Object.hasOwn(ONCE, a)) {
|
|
75
|
+
if (i + 1 >= argv.length) return { error: `${a} needs a value.` };
|
|
76
|
+
const value = argv[++i];
|
|
77
|
+
if (Object.hasOwn(ONCE, a) && out[ONCE[a]] !== undefined) return { error: `${a} may be given once.` };
|
|
78
|
+
if (['--issuer', '--sealer', '--stamp-service', '--voucher', '--root'].includes(a) && !FINGERPRINT.test(value)) {
|
|
79
|
+
return { error: `The value of ${a} is not a fingerprint. A fingerprint is 43 letters, digits, hyphens or underscores, with no spaces.` };
|
|
80
|
+
}
|
|
81
|
+
if (a === '--block' && !BLOCK.test(value)) return { error: 'The value of --block is not the fingerprint of a block: 64 characters, the digits 0 to 9 and the letters a to f.' };
|
|
82
|
+
if (a === '--size' && !/^\d{1,6}$/.test(value)) return { error: 'The value of --size is not a number of entries.' };
|
|
83
|
+
if (Object.hasOwn(LISTS, a)) out[LISTS[a]].push(value);
|
|
84
|
+
else out[ONCE[a]] = a === '--size' ? Number(value) : value;
|
|
85
|
+
} else if (!a.startsWith('-') && out.file === null) out.file = a;
|
|
86
|
+
else return { error: `${a.startsWith('-') ? 'Unknown option' : 'More than one file'}: ${safe(a)}` };
|
|
87
|
+
}
|
|
88
|
+
return out.file === null ? { error: 'No file was named.' } : out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Anything from a record is untrusted text. Control characters, characters
|
|
92
|
+
// that cannot be seen (zero-width characters, soft hyphens, line separators,
|
|
93
|
+
// fillers, variation selectors, the blank Braille pattern) and characters
|
|
94
|
+
// that change the direction of text are shown as a visible mark, so that a
|
|
95
|
+
// record cannot move the cursor, hide text, reorder it, or make two
|
|
96
|
+
// different labels look the same. \p{DI} is every character that Unicode
|
|
97
|
+
// says is to be left out when text is drawn.
|
|
98
|
+
const UNSAFE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{Cs}\p{DI}\u2800]/gu;
|
|
99
|
+
|
|
100
|
+
// Marks that are drawn on top of the character before them (accents, for
|
|
101
|
+
// one): more than three in a row can be drawn over the lines above and
|
|
102
|
+
// below, so the rest of such a run is shown as one mark.
|
|
103
|
+
const STACKED = /(?<=\p{M}{3})\p{M}+/gu;
|
|
104
|
+
|
|
105
|
+
function safe(text) {
|
|
106
|
+
// Every run of spaces becomes one space, so that a label padded with
|
|
107
|
+
// spaces cannot wrap onto a line of its own and look like the checker's words.
|
|
108
|
+
return String(text).replace(UNSAFE, '\ufffd').replace(STACKED, '\ufffd').replace(/\p{Zs}+/gu, ' ');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// In JSON the same characters are written as escapes, which keeps the JSON
|
|
112
|
+
// exact and the terminal safe. JSON.stringify already escapes everything
|
|
113
|
+
// below U+0020 inside a string; the line feeds it adds between members are
|
|
114
|
+
// its own. So is each mark of a run of more than three.
|
|
115
|
+
const UNSAFE_IN_JSON = /(?![\n])[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{DI}\u2800]|(?<=\p{M}{3})\p{M}/gu;
|
|
116
|
+
|
|
117
|
+
function escaped(character) {
|
|
118
|
+
let out = '';
|
|
119
|
+
for (let i = 0; i < character.length; i++) out += '\\u' + character.charCodeAt(i).toString(16).padStart(4, '0');
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function safeJson(value) {
|
|
124
|
+
return JSON.stringify(value, null, 2).replace(UNSAFE_IN_JSON, escaped);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A name or a purpose from a record is free text of up to hundreds of
|
|
128
|
+
// characters. It is shown inside quotation marks, and any quotation mark
|
|
129
|
+
// inside it is shown as an apostrophe, so that a reader can see where the
|
|
130
|
+
// record's words end and the checker's begin.
|
|
131
|
+
const QUOTES = /["\p{Pi}\p{Pf}\uff02\u2033\u2036\u02ba\u02dd\u02ee\u301d\u301e\u301f\u275d\u275e\u2e42]/gu;
|
|
132
|
+
const label = (text) => `"${safe(text).replace(QUOTES, "'")}"`;
|
|
133
|
+
|
|
134
|
+
const yesNo = (value) => (value ? 'yes' : 'NO');
|
|
135
|
+
|
|
136
|
+
// What an entry says is printed only if the entry passed its check and this
|
|
137
|
+
// device could confirm at least one of its signatures. --json gives
|
|
138
|
+
// everything, for whoever must find out what went wrong.
|
|
139
|
+
function hidden(e) {
|
|
140
|
+
if (e.problems.length) return ' What this entry says is not shown, because the entry did not pass its check.';
|
|
141
|
+
if (e.verified === 'none') return ' What this entry says is not shown, because this device could not check its signature.';
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// What stands behind a name: an organisation the reader trusts, an
|
|
146
|
+
// organisation the reader did not name, or nothing.
|
|
147
|
+
const COVERED = '(covered: not shown to you)';
|
|
148
|
+
// A name or a purpose is read only where the record itself holds it.
|
|
149
|
+
const has = (object, name) => Object.hasOwn(object, name);
|
|
150
|
+
const nameOf = (object, name) => (has(object, name) ? label(object[name]) : COVERED);
|
|
151
|
+
// Who vouches for a service: only what the checker put there for that id.
|
|
152
|
+
const vouchedService = (e, id) => (Object.hasOwn(e.vouched.services, id) ? e.vouched.services[id] : null);
|
|
153
|
+
|
|
154
|
+
function standing(v) {
|
|
155
|
+
if (v && v.counted) return `vouched for by ${label(v.by)}, key set ${v.keys}`;
|
|
156
|
+
if (v) return `a label, not checked; ${label(v.by)} vouches for it, which you did not name as trusted`;
|
|
157
|
+
return 'a label, not checked';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function printSlip(e) {
|
|
161
|
+
console.log(`\nEntry ${e.index}: SLIP ${e.fingerprint ?? ''}`);
|
|
162
|
+
if (hidden(e)) console.log(hidden(e));
|
|
163
|
+
else {
|
|
164
|
+
const c = e.content;
|
|
165
|
+
console.log(` Issuer: ${nameOf(c.issuer, 'name')}${has(c.issuer, 'name') ? ` (${standing(e.vouched.issuer)})` : ''}`);
|
|
166
|
+
console.log(` Issuer's key thumbprint: ${e.issuerKey}`);
|
|
167
|
+
console.log(` Signed on: ${safe(c.issuer.origin)}`);
|
|
168
|
+
console.log(` Agent: ${nameOf(c.agent, 'name')}${has(c.agent, 'name') ? ` (${standing(e.vouched.agent)})` : ''}`);
|
|
169
|
+
for (const s of has(c.agent, 'software') ? c.agent.software : []) console.log(` Agent's software, as stated: ${label(s.name)} (fingerprint ${safe(s.sha256)}). A statement, not proof of what ran.`);
|
|
170
|
+
console.log(` May do: ${c.actions.map(safe).join(', ')}`);
|
|
171
|
+
for (const l of c.limits) console.log(` Limit: ${safe(limitWords(l))}`);
|
|
172
|
+
for (const r of c.requires) console.log(` Condition: ${safe(conditionWords(r))}`);
|
|
173
|
+
for (const n of c.never) console.log(` Stated rule: ${safe(neverWords(n))}`);
|
|
174
|
+
if (c.with.length === 0) console.log(' With: nobody named');
|
|
175
|
+
for (const s of c.with) console.log(` With: ${nameOf(s, 'name')} [${safe(s.id)}]${has(s, 'name') ? ` (${standing(vouchedService(e, s.id))})` : ''}`);
|
|
176
|
+
if (has(c, 'passes')) console.log(` May be passed on: ${c.passes === 1 ? 'once, to a helper agent' : `${c.passes} times in a row, to helper agents`}`);
|
|
177
|
+
console.log(` From, until: ${c.validFrom}, ${c.validUntil}`);
|
|
178
|
+
console.log(` Purpose: ${nameOf(c, 'purpose')}`);
|
|
179
|
+
if (e.covered.length) console.log(` Covered fields: ${e.covered.length}. They are part of what was signed, and are not shown to you.`);
|
|
180
|
+
}
|
|
181
|
+
if (e.signature) console.log(` Signature, ${e.signature.method}: ${e.signature.state}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function printStub(e) {
|
|
185
|
+
console.log(`\nEntry ${e.index}: STUB ${e.fingerprint ?? ''}`);
|
|
186
|
+
if (hidden(e)) console.log(hidden(e));
|
|
187
|
+
else {
|
|
188
|
+
const c = e.content;
|
|
189
|
+
const amount = c.amount ? `, ${c.amount.value} ${safe(c.amount.unit)}` : '';
|
|
190
|
+
console.log(` Number ${c.seq} under ${c.pass ? `pass ${c.pass}, by a helper agent, under ` : ''}slip ${c.slip}`);
|
|
191
|
+
console.log(` ${c.when}: ${safe(c.action)}${amount}${c.with ? `, with [${safe(c.with)}]` : ''}`);
|
|
192
|
+
for (const d of c.details ?? []) console.log(` Document: ${label(d.name)} (fingerprint ${d.sha256})`);
|
|
193
|
+
if (c.terms) console.log(` Relies on the service's terms for agents: ${c.terms}`);
|
|
194
|
+
}
|
|
195
|
+
if (e.signatures && e.signatures.length) {
|
|
196
|
+
console.log(` Agent's signatures: ${e.signatures.map((s) => `${s.method} ${s.state}`).join('; ')}`);
|
|
197
|
+
}
|
|
198
|
+
if (e.countersignature && !e.problems.length) {
|
|
199
|
+
console.log(
|
|
200
|
+
e.countersignature.state === 'absent'
|
|
201
|
+
? e.content && !e.content.with
|
|
202
|
+
? ' No other party is named. This shows what the agent\'s side said.'
|
|
203
|
+
: ' ONE-SIDED: no countersignature. This shows what the agent\'s side said.'
|
|
204
|
+
: ` Countersignature: ${e.countersignature.signatures.map((s) => `${s.method} ${s.state}`).join('; ')}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (e.approval && e.approval.state !== 'absent' && !e.problems.length) {
|
|
208
|
+
console.log(` Approved by the person, with the passkey the slip names: ${e.approval.state} (dated ${e.approval.when}, the person's device's own word)`);
|
|
209
|
+
}
|
|
210
|
+
if (e.running && !hidden(e)) console.log(` Running total: ${e.running.total} of ${e.running.max} ${safe(e.running.unit)}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function printRefusal(e) {
|
|
214
|
+
console.log(`\nEntry ${e.index}: REFUSAL ${e.fingerprint ?? ''}`);
|
|
215
|
+
if (hidden(e)) console.log(hidden(e));
|
|
216
|
+
else {
|
|
217
|
+
const c = e.content;
|
|
218
|
+
const amount = c.amount ? `, ${c.amount.value} ${safe(c.amount.unit)}` : '';
|
|
219
|
+
const who = e.service ? `the service the slip names as [${safe(e.service)}]` : 'a party the slip does not name';
|
|
220
|
+
console.log(` Under slip ${c.slip}`);
|
|
221
|
+
console.log(` ${c.when}: refused ${safe(c.action)}${amount}`);
|
|
222
|
+
console.log(` Reason given: ${REFUSAL_REASONS[c.reason]}`);
|
|
223
|
+
console.log(` Signed by ${who}, which calls itself (a label, not checked): ${label(c.by.name)}`);
|
|
224
|
+
console.log(` Signed with the key set: ${e.signer}`);
|
|
225
|
+
console.log(' This shows what the service\'s side said, not what the agent did.');
|
|
226
|
+
}
|
|
227
|
+
if (e.signatures && e.signatures.length) console.log(` Service's signatures: ${e.signatures.map((s) => `${s.method} ${s.state}`).join('; ')}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The time-stamps beside a record: from a time-stamp service, or a block
|
|
231
|
+
// of a public blockchain.
|
|
232
|
+
function printStamps(stamps) {
|
|
233
|
+
for (const s of stamps) {
|
|
234
|
+
if (s.kind === 'block') {
|
|
235
|
+
const block = `block ${s.authority} (number ${s.height}, as the proof states)`;
|
|
236
|
+
const states = `The block states the time ${s.when}; that is taken as right to within two hours.`;
|
|
237
|
+
if (s.state === 'valid') console.log(` BLOCK TIME-STAMP: in ${block}, ${whyCounted(s.authority)}. ${states}`);
|
|
238
|
+
else if (s.state === 'untrusted') console.log(` Block time-stamp, NOT COUNTED: in ${block}, which you did not name as trusted. ${states}`);
|
|
239
|
+
} else if (s.state === 'valid') console.log(` OUTSIDE TIME-STAMP: ${s.when}, from a service you named as trusted (certificate ${s.authority})`);
|
|
240
|
+
else if (s.state === 'untrusted') console.log(` Outside time-stamp, NOT COUNTED: ${s.when}, from a service you did not name as trusted (certificate ${s.authority})`);
|
|
241
|
+
else if (s.state === 'unavailable') console.log(' Outside time-stamp, NOT CHECKED: this device cannot check the service\'s signing method');
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function printSeal(e) {
|
|
246
|
+
console.log(`\nEntry ${e.index}: SEAL ${e.fingerprint ?? ''}`);
|
|
247
|
+
if (hidden(e)) console.log(hidden(e));
|
|
248
|
+
else {
|
|
249
|
+
const c = e.content;
|
|
250
|
+
console.log(` Covers the first ${c.size} entries, with the top fingerprint ${c.root}`);
|
|
251
|
+
console.log(` Sealed at (the recorder's own word): ${c.when}`);
|
|
252
|
+
console.log(` Recorder: ${label(c.by.name)} (${standing(e.vouched)})`);
|
|
253
|
+
console.log(` Recorder's key set fingerprint: ${e.sealer}`);
|
|
254
|
+
printStamps(e.stamps);
|
|
255
|
+
if (e.stamps.length === 0) console.log(' No outside time-stamp: the time is only the recorder\'s own word.');
|
|
256
|
+
}
|
|
257
|
+
if (e.signatures && e.signatures.length) console.log(` Recorder's signatures: ${e.signatures.map((s) => `${s.method} ${s.state}`).join('; ')}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function printCancellation(e) {
|
|
261
|
+
console.log(`\nEntry ${e.index}: CANCELLATION ${e.fingerprint ?? ''}`);
|
|
262
|
+
if (hidden(e)) console.log(hidden(e));
|
|
263
|
+
else {
|
|
264
|
+
console.log(` The person cancels slip ${e.content.slip}`);
|
|
265
|
+
console.log(` Dated (the person's device's own word): ${e.content.when}`);
|
|
266
|
+
printStamps(e.stamps);
|
|
267
|
+
if (e.stampedAt) console.log(` Counts as cancelled at: ${e.stampedAt} (the earliest its time-stamps allow)`);
|
|
268
|
+
}
|
|
269
|
+
if (e.signature) console.log(` Signature, ${e.signature.method}: ${e.signature.state}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// An acknowledgement: the agent's side says it was handed a cancellation.
|
|
273
|
+
function printAcknowledgement(e) {
|
|
274
|
+
console.log(`\nEntry ${e.index}: ACKNOWLEDGEMENT ${e.fingerprint ?? ''}`);
|
|
275
|
+
if (hidden(e)) console.log(hidden(e));
|
|
276
|
+
else {
|
|
277
|
+
const c = e.content;
|
|
278
|
+
console.log(` ${c.pass ? `The helper agent of pass ${c.pass}` : 'The slip\'s own agent'} states that it was handed the person's cancellation`);
|
|
279
|
+
console.log(` Of slip ${c.slip}`);
|
|
280
|
+
console.log(` The cancellation: ${c.cancellation}${e.cancellation === undefined ? '' : `, at entry ${e.cancellation}`}`);
|
|
281
|
+
console.log(` Handed over at (the agent's side's own word): ${c.when}`);
|
|
282
|
+
console.log(' This shows what the agent\'s side said, not when the person cancelled.');
|
|
283
|
+
}
|
|
284
|
+
if (e.signatures && e.signatures.length) console.log(` Agent's signatures: ${e.signatures.map((s) => `${s.method} ${s.state}`).join('; ')}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// A cancellation the person kept, handed over beside the book.
|
|
288
|
+
function printHeld(h, n) {
|
|
289
|
+
console.log(`\nHanded over beside the book, ${n}: CANCELLATION ${h.fingerprint ?? ''}`);
|
|
290
|
+
if (h.problems.length) {
|
|
291
|
+
console.log(' It did not pass its check, so it was not used. See the problems at the top.');
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// What a copy says is shown only where this device confirmed who signed it.
|
|
295
|
+
if (h.signature.state !== 'valid') {
|
|
296
|
+
console.log(' What this copy says is not shown, because this device could not check its signature.');
|
|
297
|
+
for (const n of h.notes) console.log(` Note: ${n}`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
console.log(` The person cancels slip ${h.slip}`);
|
|
301
|
+
console.log(` Dated (the person's device's own word): ${h.content.when}`);
|
|
302
|
+
printStamps(h.stamps);
|
|
303
|
+
if (h.stampedAt) console.log(` Counts as cancelled at: ${h.stampedAt} (the earliest its time-stamps allow)`);
|
|
304
|
+
console.log(h.inBook === null ? ' In the book: NO' : ` In the book: yes, at entry ${h.inBook}`);
|
|
305
|
+
for (const a of h.acknowledgements) {
|
|
306
|
+
const who = a.by === 'helper' ? `the helper agent of pass ${a.pass}` : 'the slip\'s own agent';
|
|
307
|
+
if (a.state === 'valid') console.log(` Acknowledged by ${who} at: ${a.when} (the agent's side's own word)${a.inBook === null ? '' : `; the book holds the acknowledgement at entry ${a.inBook}`}`);
|
|
308
|
+
else console.log(` An acknowledgement from ${who} was handed over with it. This device could not check it: ${a.signatures.map((s) => `${s.method} ${s.state}`).join('; ')}`);
|
|
309
|
+
}
|
|
310
|
+
console.log(` Signature, ${h.signature.method}: ${h.signature.state}`);
|
|
311
|
+
for (const n of h.notes) console.log(` Note: ${n}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function printPass(e) {
|
|
315
|
+
console.log(`\nEntry ${e.index}: PASS ${e.fingerprint ?? ''}`);
|
|
316
|
+
if (hidden(e)) console.log(hidden(e));
|
|
317
|
+
else {
|
|
318
|
+
const c = e.content;
|
|
319
|
+
console.log(` Under slip ${c.slip}${c.from ? `, handed on from pass ${c.from}` : ', handed on by the slip\'s own agent'}`);
|
|
320
|
+
console.log(` To the helper agent: ${label(c.to.name)} (${standing(e.vouched)})`);
|
|
321
|
+
console.log(` May do: ${c.actions.map(safe).join(', ')}`);
|
|
322
|
+
for (const l of c.limits) console.log(` Limit: ${safe(limitWords(l))}`);
|
|
323
|
+
console.log(` From, until: ${c.validFrom}, ${c.validUntil}`);
|
|
324
|
+
}
|
|
325
|
+
if (e.signatures && e.signatures.length) console.log(` Signatures of the agent that passes on: ${e.signatures.map((s) => `${s.method} ${s.state}`).join('; ')}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function printVouching(e) {
|
|
329
|
+
console.log(`\nEntry ${e.index}: VOUCHING RECORD ${e.fingerprint ?? ''}`);
|
|
330
|
+
if (hidden(e)) console.log(hidden(e));
|
|
331
|
+
else {
|
|
332
|
+
const c = e.content;
|
|
333
|
+
console.log(` ${label(c.by.name)} (a label; key set ${e.signer}) states:`);
|
|
334
|
+
console.log(` the ${c.for.kind} named ${label(c.for.name)} holds the key or keys given in this record`);
|
|
335
|
+
console.log(` From, until: ${c.validFrom}, ${c.validUntil}`);
|
|
336
|
+
console.log(e.counted ? ' COUNTED: you named this organisation as trusted.' : ' NOT COUNTED: you did not name this organisation as trusted.');
|
|
337
|
+
}
|
|
338
|
+
if (e.signatures && e.signatures.length) console.log(` Organisation's signatures: ${e.signatures.map((s) => `${s.method} ${s.state}`).join('; ')}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function printWithdrawal(e) {
|
|
342
|
+
console.log(`\nEntry ${e.index}: WITHDRAWAL ${e.fingerprint ?? ''}`);
|
|
343
|
+
if (hidden(e)) console.log(hidden(e));
|
|
344
|
+
else {
|
|
345
|
+
console.log(` The organisation with key set ${e.signer} withdraws its vouching record ${e.content.vouching}`);
|
|
346
|
+
console.log(` Dated (its own word): ${e.content.when}. From this entry on, that record vouches for nothing.`);
|
|
347
|
+
}
|
|
348
|
+
if (e.signatures && e.signatures.length) console.log(` Organisation's signatures: ${e.signatures.map((s) => `${s.method} ${s.state}`).join('; ')}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function printTerms(e) {
|
|
352
|
+
console.log(`\nEntry ${e.index}: TERMS FOR AGENTS ${e.fingerprint ?? ''}`);
|
|
353
|
+
if (hidden(e)) console.log(hidden(e));
|
|
354
|
+
else {
|
|
355
|
+
const c = e.content;
|
|
356
|
+
console.log(` From (a label, not checked): ${label(c.by.name)}`);
|
|
357
|
+
console.log(` Signed with the key set: ${e.signer}. These terms count for a stub only if its slip gives these keys for the service.`);
|
|
358
|
+
console.log(` Accepts from agents: ${c.accepts.map(safe).join(', ')}`);
|
|
359
|
+
for (const n of c.never) console.log(` Asks of agents: ${safe(neverWords(n))}`);
|
|
360
|
+
console.log(` From, until: ${c.validFrom}, ${c.validUntil}`);
|
|
361
|
+
}
|
|
362
|
+
if (e.signatures && e.signatures.length) console.log(` Service's signatures: ${e.signatures.map((s) => `${s.method} ${s.state}`).join('; ')}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function print(result, isShow) {
|
|
366
|
+
console.log(`Provared ${isShow ? 'Show' : 'book'}, draft version 0. Evidence, not a verdict.`);
|
|
367
|
+
console.log(`Entries in the book: ${result.size}. Top fingerprint: ${result.root ?? 'none'}`);
|
|
368
|
+
for (const p of result.problems) console.log(`\nPROBLEM [${p.code}] ${safe(p.message)}`);
|
|
369
|
+
for (const e of result.entries) {
|
|
370
|
+
if (e.kind === 'slip') printSlip(e);
|
|
371
|
+
else if (e.kind === 'stub') printStub(e);
|
|
372
|
+
else if (e.kind === 'refusal') printRefusal(e);
|
|
373
|
+
else if (e.kind === 'terms') printTerms(e);
|
|
374
|
+
else if (e.kind === 'seal') printSeal(e);
|
|
375
|
+
else if (e.kind === 'cancellation') printCancellation(e);
|
|
376
|
+
else if (e.kind === 'acknowledgement') printAcknowledgement(e);
|
|
377
|
+
else if (e.kind === 'pass') printPass(e);
|
|
378
|
+
else if (e.kind === 'vouching') printVouching(e);
|
|
379
|
+
else if (e.kind === 'withdrawal') printWithdrawal(e);
|
|
380
|
+
else console.log(`\nEntry ${e.index}: could not be read`);
|
|
381
|
+
for (const p of e.problems) console.log(` PROBLEM [${p.code}] ${safe(p.message)}`);
|
|
382
|
+
for (const b of e.breaches) console.log(` OUTSIDE THE SLIP [${b.code}] ${safe(b.message)}`);
|
|
383
|
+
for (const n of e.notes ?? []) console.log(` Note: ${n}`);
|
|
384
|
+
}
|
|
385
|
+
(result.held ?? []).forEach((h, i) => printHeld(h, i + 1));
|
|
386
|
+
for (const n of result.notes ?? []) console.log(`\nNote: ${n}`);
|
|
387
|
+
|
|
388
|
+
const s = result.summary;
|
|
389
|
+
console.log('\nSummary');
|
|
390
|
+
const intact = s.intact ? 'yes' : s.problemFound ? 'NO: a problem was found' : 'NOT CONFIRMED: no problem was found, but not every signature was checked';
|
|
391
|
+
console.log(` Is the record intact? ${intact}`);
|
|
392
|
+
const why = s.methodsMissing.length
|
|
393
|
+
? ` (this device has no built-in ${s.methodsMissing.join(' or ')})`
|
|
394
|
+
: ' (an entry that cannot be read or used cannot have its signatures checked)';
|
|
395
|
+
console.log(` Was every signature checked? ${yesNo(s.fullyChecked)}${s.fullyChecked ? '' : why}`);
|
|
396
|
+
if (!s.problemFound) {
|
|
397
|
+
const outside = s.firstBreach !== null ? `first at entry ${s.firstBreach}` : null;
|
|
398
|
+
const unknown = 'NOT CHECKED: an entry this device cannot confirm cannot be compared with its slip';
|
|
399
|
+
if (isShow) {
|
|
400
|
+
const answer = outside ? `YES: ${outside}` : s.withinSlips ? 'no (single pages cannot show whether a limit was kept)' : unknown;
|
|
401
|
+
console.log(` Do these pages show the agent outside its slip? ${answer}`);
|
|
402
|
+
} else {
|
|
403
|
+
const answer = outside ? `NO: ${outside}` : s.withinSlips ? 'yes, as far as this record shows' : unknown;
|
|
404
|
+
console.log(` Did the agent stay within its slip? ${answer}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (!s.problemFound) {
|
|
408
|
+
const stamped = s.sealed ? `yes: the first ${s.sealed.entries} entries existed by ${s.sealed.when}` : 'no';
|
|
409
|
+
console.log(` Time-stamped in a way you trust? ${stamped}`);
|
|
410
|
+
console.log(` Checked against keys you named? ${trustWords(s)}`);
|
|
411
|
+
}
|
|
412
|
+
console.log(` Slips ${s.counts.slips}, stubs ${s.counts.stubs} (countersigned ${s.counts.countersigned}, one-sided ${s.counts.oneSided})${s.counts.seals ? `, seals ${s.counts.seals}` : ''}`);
|
|
413
|
+
if (s.counts.cancellations || s.counts.acknowledgements || s.counts.vouchings || s.counts.passes) {
|
|
414
|
+
console.log(` Cancellations ${s.counts.cancellations}, acknowledgements ${s.counts.acknowledgements}, vouching records ${s.counts.vouchings}, passes ${s.counts.passes}`);
|
|
415
|
+
}
|
|
416
|
+
if (s.counts.approved || s.counts.refusals || s.counts.terms) {
|
|
417
|
+
console.log(` Approved by the person ${s.counts.approved}, refusals ${s.counts.refusals}, terms for agents ${s.counts.terms}`);
|
|
418
|
+
}
|
|
419
|
+
console.log('\nWhat this check does not show');
|
|
420
|
+
for (const l of s.limits) console.log(` - ${l}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Whether the check rests on keys the person named: the passkey they trust
|
|
424
|
+
// and, where the record has a seal, the recorder they expect. Without them a
|
|
425
|
+
// record agrees only with the keys inside it, which anyone could have made.
|
|
426
|
+
function named(s) {
|
|
427
|
+
return args.issuerKeys.length > 0 && (s.counts.seals === 0 || args.sealKeys.length > 0);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function trustWords(s) {
|
|
431
|
+
if (named(s)) return s.counts.seals ? 'yes: the passkey you trust, and the recorder you expect' : 'yes: the passkey you trust';
|
|
432
|
+
if (args.issuerKeys.length === 0) return 'NO: you named no passkey you trust (--issuer), so anyone could have made this record. This is not a pass.';
|
|
433
|
+
return 'NO: the record has a seal, and you named no recorder you expect (--sealer), so anyone could have sealed it. This is not a pass.';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Why a block counts: the person named it, or the chain of headers they
|
|
437
|
+
// checked holds it with enough blocks after it.
|
|
438
|
+
function whyCounted(block) {
|
|
439
|
+
if (args.blocks.includes(block)) return 'which you named as trusted';
|
|
440
|
+
const found = fromChain ? fromChain.found.find((f) => f.block === block) : null;
|
|
441
|
+
return found && found.counted ? `which the chain of headers you checked holds, with ${found.after} blocks after it` : 'which is trusted';
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// What the chain of block headers showed about the blocks the record's
|
|
445
|
+
// block time-stamps lead to.
|
|
446
|
+
function printChain(c, from) {
|
|
447
|
+
console.log('\nThe chain of block headers');
|
|
448
|
+
console.log(` Checked on this device from the first block to block ${c.height} (${c.tip}), dated ${c.tipWhen}.`);
|
|
449
|
+
if (from.found.length === 0) console.log(' No block time-stamp in this record leads to a block.');
|
|
450
|
+
for (const f of from.found) {
|
|
451
|
+
const named = args.blocks.includes(f.block) ? ' It counts all the same, because you named it with --block.' : '';
|
|
452
|
+
if (f.height === null) console.log(` Block ${f.block}: NOT in this chain.${named || ' A block time-stamp that leads to it is not counted.'}`);
|
|
453
|
+
else if (!f.counted) {
|
|
454
|
+
console.log(` Block ${f.block}: number ${f.height}, with only ${f.after} blocks after it.${named || ` It counts once ${MIN_BLOCKS_AFTER} have followed: fetch the headers again later.`}`);
|
|
455
|
+
} else {
|
|
456
|
+
const stated = f.stated === f.height ? '' : ` The proof states the number ${f.stated}.`;
|
|
457
|
+
console.log(` Block ${f.block}: number ${f.height}, with ${f.after} blocks after it. Counted as part of the chain.${stated}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const args = parseArguments(process.argv.slice(2));
|
|
463
|
+
if (args.help) {
|
|
464
|
+
console.log(USAGE);
|
|
465
|
+
process.exit(0);
|
|
466
|
+
}
|
|
467
|
+
if (args.error) {
|
|
468
|
+
console.error(`${args.error}\n\n${USAGE}`);
|
|
469
|
+
process.exit(2);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
let text;
|
|
473
|
+
try {
|
|
474
|
+
text = readFileSync(args.file, 'utf8');
|
|
475
|
+
} catch {
|
|
476
|
+
console.error(`The file could not be read: ${args.file}`);
|
|
477
|
+
process.exit(2);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const options = {};
|
|
481
|
+
if (args.issuerKeys.length) options.issuerKeys = args.issuerKeys;
|
|
482
|
+
if (args.sealKeys.length) options.sealKeys = args.sealKeys;
|
|
483
|
+
if (args.stampServices.length) options.stampServices = args.stampServices;
|
|
484
|
+
if (args.blocks.length) options.blocks = args.blocks;
|
|
485
|
+
if (args.vouchers.length) options.vouchers = args.vouchers;
|
|
486
|
+
if (args.disclosures !== undefined) {
|
|
487
|
+
try {
|
|
488
|
+
options.disclosures = JSON.parse(readFileSync(args.disclosures, 'utf8'));
|
|
489
|
+
} catch {
|
|
490
|
+
console.error(`The file of disclosures could not be read: ${args.disclosures}`);
|
|
491
|
+
process.exit(2);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (args.cancellations.length) {
|
|
495
|
+
options.cancellations = [];
|
|
496
|
+
for (const file of args.cancellations) {
|
|
497
|
+
try {
|
|
498
|
+
options.cancellations.push(JSON.parse(readFileSync(file, 'utf8')));
|
|
499
|
+
} catch {
|
|
500
|
+
console.error(`The file of a cancellation could not be read: ${file}`);
|
|
501
|
+
process.exit(2);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (args.expectedRoot !== undefined) options.expectedRoot = args.expectedRoot;
|
|
506
|
+
if (args.expectedSize !== undefined) options.expectedSize = args.expectedSize;
|
|
507
|
+
|
|
508
|
+
// A Show is one JSON object with a "type"; a book is entries, one to a line.
|
|
509
|
+
let isShow = false;
|
|
510
|
+
try {
|
|
511
|
+
const whole = JSON.parse(text);
|
|
512
|
+
isShow = whole !== null && typeof whole === 'object' && Object.hasOwn(whole, 'type');
|
|
513
|
+
} catch {
|
|
514
|
+
isShow = false;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// A chain of block headers the person fetched: checked here, in full,
|
|
518
|
+
// before it is used. A file that is not such a chain stops the check.
|
|
519
|
+
let chain = null;
|
|
520
|
+
if (args.headers !== undefined) {
|
|
521
|
+
let bytes;
|
|
522
|
+
try {
|
|
523
|
+
bytes = new Uint8Array(readFileSync(args.headers));
|
|
524
|
+
} catch {
|
|
525
|
+
console.error(`The file of block headers could not be read: ${args.headers}`);
|
|
526
|
+
process.exit(2);
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
chain = await checkHeaderChain(bytes);
|
|
530
|
+
} catch (e) {
|
|
531
|
+
console.error(`The chain of block headers in ${args.headers} cannot be used: ${e.message}`);
|
|
532
|
+
process.exit(2);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// What the chain of headers showed: set once the record has been checked with it.
|
|
537
|
+
let fromChain = null;
|
|
538
|
+
|
|
539
|
+
let code = 2;
|
|
540
|
+
try {
|
|
541
|
+
let result = isShow ? await checkShow(text, options) : await checkBook(text, options);
|
|
542
|
+
// The blocks the record's block time-stamps lead to that the chain holds,
|
|
543
|
+
// with enough blocks after them, are named as trusted, and the record is
|
|
544
|
+
// checked again with them.
|
|
545
|
+
if (chain) {
|
|
546
|
+
fromChain = blocksInChain(result, chain);
|
|
547
|
+
const added = fromChain.blocks.filter((b) => !(options.blocks ?? []).includes(b));
|
|
548
|
+
if (added.length) {
|
|
549
|
+
options.blocks = [...(options.blocks ?? []), ...added];
|
|
550
|
+
result = isShow ? await checkShow(text, options) : await checkBook(text, options);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// With --json, what the chain of headers showed is given beside the result.
|
|
554
|
+
if (args.json) console.log(safeJson(chain ? { ...result, headers: { file: args.headers, height: chain.height, tip: chain.tip, tipWhen: chain.tipWhen, found: fromChain.found } } : result));
|
|
555
|
+
else {
|
|
556
|
+
print(result, isShow);
|
|
557
|
+
if (chain) printChain(chain, fromChain);
|
|
558
|
+
}
|
|
559
|
+
const s = result.summary;
|
|
560
|
+
code = s.problemFound ? 2 : s.intact && s.withinSlips && named(s) ? 0 : 1;
|
|
561
|
+
} catch {
|
|
562
|
+
// The checker is written never to throw. If it ever does, that is not a pass.
|
|
563
|
+
console.error('The check could not be completed. This is not a pass.');
|
|
564
|
+
}
|
|
565
|
+
process.exit(code);
|