provared 0.2.0 → 0.4.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.
@@ -58,10 +58,10 @@ keys. People decide what it means.
58
58
  under a seal, whose three signatures include two quantum-safe ones.
59
59
  The outside time-stamps themselves are of today's kind.
60
60
  - **A signature that cannot be checked is not a pass.** Where a device
61
- has no built-in ML-DSA, the checker says so, and never reports the
62
- record as intact. An entry none of whose signatures the device could
63
- check is not treated as evidence: what it says is not shown, and it is
64
- not compared with its slip.
61
+ has no built-in ML-DSA, or no SLH-DSA for a seal, the checker says
62
+ so, and never reports the record as intact. An entry none of whose
63
+ signatures the device could check is not treated as evidence: what it
64
+ says is not shown, and it is not compared with its slip.
65
65
  - **A record does not show that an action was wise, lawful or wanted.**
66
66
  - **A badly written slip is faithfully recorded as a badly written slip.**
67
67
 
@@ -450,7 +450,12 @@ within 5 minutes for a time-stamp from a service, and to within 2 hours
450
450
  and 5 minutes for a block time-stamp). A6 cannot make a
451
451
  seal: it needs the recorder's three keys. A8 must break Ed25519, ML-DSA-87
452
452
  and SLH-DSA together to forge a seal; the third rests only on fingerprint
453
- functions. Nobody can move a time-stamp to another seal: it is made over
453
+ functions. One exception, stated: the check before acting on a device
454
+ without SLH-DSA (the Python version, every browser) goes on after a seal
455
+ whose first two signatures check there, so on that device a seal rests on
456
+ Ed25519 and ML-DSA-87 alone, and a third signature of the right length
457
+ that would not check is not seen. A device with SLH-DSA, such as the
458
+ command-line checker, sees it. Nobody can move a time-stamp to another seal: it is made over
454
459
  the seal's fingerprint.
455
460
 
456
461
  A block time-stamp is a second witness of the time, which rests on no
@@ -490,8 +495,12 @@ person checking gives.
490
495
  does not read what a certificate says its key is for. It checks that
491
496
  the stated time lies within the certificate's dates, and leaves the
492
497
  rest to the person who chose to trust it.
493
- - **A browser.** No browser offers SLH-DSA or ML-DSA to a page yet. A
494
- checker there reports a seal as not fully checked, never as a pass.
498
+ - **A browser.** Tried on the author's computer on 13 October 2026:
499
+ Chrome 155, Edge 155 and Brave 155 offer ML-DSA to a page; none of the
500
+ three offers SLH-DSA. So a checker in a browser
501
+ reports a seal as not fully checked, never as a pass, and counts no
502
+ time-stamp beside it. In a browser without ML-DSA, every record signed
503
+ with ML-DSA is reported as not fully checked too.
495
504
  - **A few bytes of a time-stamp's wrapper** are not covered by the
496
505
  service's signature (the list of fingerprint methods, and the lines
497
506
  that name the certificate by its issuer). Changing them changes nothing
package/package.json CHANGED
@@ -1,13 +1,32 @@
1
1
  {
2
2
  "name": "provared",
3
- "version": "0.2.0",
4
- "description": "Proof of what an AI agent was allowed to do, and what it then did. Evidence, not a verdict.",
3
+ "version": "0.4.0",
4
+ "description": "Proof of what an AI agent was allowed to do, and what it then did. A passkey-signed permission with limits a machine can check, a signed receipt for each action, and a free checker. Evidence, not a verdict.",
5
5
  "author": "Pavel Izmaylov",
6
6
  "license": "Apache-2.0 AND CC-BY-4.0",
7
+ "homepage": "https://prova.red",
8
+ "bugs": "https://github.com/provared/provared/issues",
7
9
  "repository": {
8
10
  "type": "git",
9
11
  "url": "git+https://github.com/provared/provared.git"
10
12
  },
13
+ "keywords": [
14
+ "ai-agent",
15
+ "agent",
16
+ "audit-trail",
17
+ "permission",
18
+ "authorization",
19
+ "receipt",
20
+ "tamper-evident",
21
+ "passkey",
22
+ "webauthn",
23
+ "jws",
24
+ "ed25519",
25
+ "ml-dsa",
26
+ "post-quantum",
27
+ "evidence",
28
+ "langchain"
29
+ ],
11
30
  "files": [
12
31
  "src/",
13
32
  "bin/",
@@ -38,6 +57,10 @@
38
57
  "types": "./src/passkey-browser.d.ts",
39
58
  "default": "./src/passkey-browser.js"
40
59
  },
60
+ "./dev": {
61
+ "types": "./src/dev.d.ts",
62
+ "default": "./src/dev.js"
63
+ },
41
64
  "./package.json": "./package.json"
42
65
  },
43
66
  "bin": {
package/page/index.html CHANGED
@@ -8,7 +8,7 @@
8
8
  </head>
9
9
  <body>
10
10
  <main>
11
- <h1>Check a record</h1>
11
+ <h1><svg class="mark" viewBox="0 0 64 64" width="32" height="32" aria-hidden="true"><circle cx="32" cy="32" r="32" fill="#8f000f"/><path d="M20 15 H37 L45 23 V49 H20 Z" fill="#ffffff"/><path d="M37 15 V23 H45" fill="none" stroke="#8f000f" stroke-width="2.5" stroke-linejoin="round"/><path d="M25 29 H40 M25 35 H36" stroke="#8f000f" stroke-width="2.5" stroke-linecap="round"/><path d="M24.5 43 C27 39, 29 46, 31.5 42.5 C34 39, 36 46, 40.5 41.5" fill="none" stroke="#8f000f" stroke-width="3" stroke-linecap="round"/></svg>Check a record</h1>
12
12
  <p class="motto">Provared, draft version 0. Evidence, not a verdict.</p>
13
13
  <p>Choose a record file. It is checked here, in this browser. Nothing is sent anywhere.</p>
14
14
  <p>
package/page/style.css CHANGED
@@ -148,3 +148,6 @@ details.more summary { cursor: pointer; font-weight: 600; }
148
148
  details.more label { display: block; margin-top: 0.7rem; }
149
149
  details.more input[type="file"] { display: block; max-width: 100%; margin-top: 0.3rem; color: var(--text); }
150
150
  details.more textarea { width: 100%; box-sizing: border-box; font-family: ui-monospace, monospace; font-size: 0.85rem; color: var(--text); background: var(--page); border: 1px solid var(--line); border-radius: 0.3rem; }
151
+
152
+ /* The mark: a permission slip with a signature, beside a heading or the site's name. */
153
+ .mark { vertical-align: -0.2em; margin-right: 0.4em; }
@@ -226,10 +226,22 @@ Because the slip names both keys, taking the quantum-safe signature off a
226
226
  stub does not leave a valid stub.
227
227
 
228
228
  A checker on a device with no built-in ML-DSA MUST report that the
229
- quantum-safe signature could not be checked, and MUST NOT report the
230
- record as intact. An entry none of whose signatures the device could
231
- check MUST NOT be treated as evidence: what it says is not shown as fact,
232
- and it is not compared with its slip.
229
+ quantum-safe signature could not be checked (unless it is of the wrong
230
+ length, below), and MUST NOT report the record as intact. An entry none
231
+ of whose signatures the device could check MUST NOT be treated as
232
+ evidence: what it says is not shown as fact, and it is not compared with
233
+ its slip.
234
+
235
+ **A signature of the wrong length.** A signature whose length is not its
236
+ method's (64 bytes for Ed25519, 4,627 for ML-DSA-87, 29,792 for
237
+ SLH-DSA-SHA2-256s) can never check. A checker MUST report it as invalid,
238
+ whether or not the device has the method: it is a problem with the
239
+ record, not a signature left unchecked. This holds for every signature of
240
+ a record, the passkey's Ed25519 signature on a slip among them, and so
241
+ for the check of a whole book or of a Show as for the check before acting
242
+ (section 20). It does not reach the signature inside a time-stamp from a
243
+ service (section 21.2): a device without that time-stamp's method does
244
+ not check it, whatever its length, and does not count it.
233
245
 
234
246
  A recorder is known by a key set of exactly three keys: Ed25519,
235
247
  ML-DSA-87, then SLH-DSA-SHA2-256s (FIPS 205, the plain method with an
@@ -238,8 +250,8 @@ order, and counts as signed only if all three check. The third rests only
238
250
  on fingerprint functions, so it would stand even if the mathematics
239
251
  behind ML-DSA were broken. When this draft was written, no browser
240
252
  offered it to a page, and Node.js offered it outside the standard
241
- interface: a checker in a browser reports it as not checked, which is
242
- never a pass.
253
+ interface: a checker in a browser reports it as not checked (or, if it is
254
+ of the wrong length, as invalid), which is never a pass.
243
255
 
244
256
  The issuer's passkey signature on a slip is of today's kind only. No
245
257
  device offers a quantum-safe passkey.
@@ -918,7 +930,18 @@ holds too much. A checker reports nothing new for it, because the
918
930
  finding stands with the later stub. The answer MUST be that nothing is allowed
919
931
  (`record-not-sound`) if the book so far has a problem, if any signature
920
932
  in it could not be checked on that device, or if the check was not told
921
- which issuer keys it trusts. An action dated before the last stub of
933
+ which issuer keys it trusts. One exception is allowed: where every
934
+ signature that could not be checked is the third signature of a seal
935
+ (section 21.1) whose first two signatures were checked on that device,
936
+ and nothing else was left unchecked, the check MAY go on, and its answer
937
+ MUST then name the method it did not check. A seal widens no permission;
938
+ a device without that method would otherwise be refused everything once
939
+ the book holds a seal. On such a device a seal rests on its first two
940
+ signatures only: a third signature of the right length that would not
941
+ check is not seen there. A third signature of the wrong length is not
942
+ left unchecked: as section 3.9 sets out, it is invalid on every device,
943
+ so the seal is a problem there and nothing is allowed. An action dated
944
+ before the last stub of
922
945
  its own chain (the agent's, or the helper's under its pass; section 7)
923
946
  is refused (`time-went-backwards`).
924
947
 
package/src/dev.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ // Type declarations for "provared/dev": a development stand-in for a
2
+ // passkey. It is not a passkey, and no person confirms anything through
3
+ // it. Every slip it signs carries " (development)" in its issuer's name.
4
+
5
+ import type { Assertion, KeySet, Limit, Recorder, CheckOptions, Es256Key, Fingerprint, SignedRecord, SlipFields } from './types.js';
6
+
7
+ /** The words every development slip carries in its issuer's name: "(development)". */
8
+ export const DEVELOPMENT_MARK: string;
9
+
10
+ export interface DevelopmentPasskey {
11
+ key: Es256Key;
12
+ rpId: 'localhost';
13
+ origin: 'http://localhost';
14
+ /** The three values a passkey returns, for a challenge from prepareSlip, prepareApproval or prepareCancellation. */
15
+ sign(challenge: Uint8Array): Promise<Assertion>;
16
+ }
17
+
18
+ /** A development stand-in for a passkey (ES256), signing for http://localhost. */
19
+ export function developmentPasskey(): Promise<DevelopmentPasskey>;
20
+
21
+ export interface DevelopmentSlip {
22
+ slip: SignedRecord;
23
+ slipFingerprint: Fingerprint;
24
+ /** A book holding the slip alone: what openRecorder takes. */
25
+ book: string;
26
+ /** The thumbprint of the stand-in passkey, to hand to a checker as the passkey you trust. */
27
+ issuerKeys: string[];
28
+ agent: { keys: KeySet; privateKeys: CryptoKey[] };
29
+ passkey: DevelopmentPasskey;
30
+ }
31
+
32
+ /**
33
+ * The members of a development slip: as for prepareSlip, except that the
34
+ * issuer's key and page address, and the agent's keys, are filled in.
35
+ */
36
+ export type DevelopmentSlipFields = Omit<SlipFields, 'issuer' | 'agent' | 'validFrom' | 'validUntil' | 'purpose' | 'with' | 'limits'> & {
37
+ issuer?: { name?: string };
38
+ agent?: { name?: string; software?: SlipFields['agent']['software'] };
39
+ /** Defaults to an empty list. */
40
+ limits?: Limit[];
41
+ validFrom?: string;
42
+ validUntil?: string;
43
+ purpose?: string;
44
+ with?: SlipFields['with'];
45
+ };
46
+
47
+ /** A slip signed with a development stand-in for a passkey, with new keys for the agent. */
48
+ export function developmentSlip(fields: DevelopmentSlipFields): Promise<DevelopmentSlip>;
49
+
50
+ /** The stub writer opened under a development slip, in one call. */
51
+ export function developmentRecorder(
52
+ fields: DevelopmentSlipFields,
53
+ more?: { options?: CheckOptions; now?: () => number; countersignWithin?: number },
54
+ ): Promise<Omit<DevelopmentSlip, 'book'> & { recorder: Recorder }>;
package/src/dev.js ADDED
@@ -0,0 +1,123 @@
1
+ // A development stand-in for a passkey, and a stub writer opened under a
2
+ // slip it signed: for trying the library in an evening, and for tests.
3
+ //
4
+ // It is NOT a passkey. Its private key is an ordinary key made in this
5
+ // process's memory, and no person confirmed anything. It returns the same
6
+ // three values a passkey returns (the authenticator data, the client data,
7
+ // the signature), in the forms the W3C Web Authentication standard sets
8
+ // out, so that every record it signs passes the checker exactly as a real
9
+ // one would. To keep such a record from being mistaken for a person's, the
10
+ // name of the issuer in every slip it signs ends with " (development)",
11
+ // and the checker prints that name beside the slip.
12
+ //
13
+ // A real slip is signed by the person, in a browser, with a real passkey:
14
+ // see passkey-browser.js and the checking page. Nothing in this file is
15
+ // part of the library's checker, and nothing here reaches the network.
16
+
17
+ import { concatBytes, fingerprint, formatTime, fromBase64url, sha256, toBase64url, utf8 } from './encoding.js';
18
+ import { thumbprint } from './keys.js';
19
+ import { openRecorder } from './recorder.js';
20
+ import { generateKeySet } from './signatures.js';
21
+ import { assembleSlip, prepareSlip } from './slip.js';
22
+ import { writeBook } from './book.js';
23
+
24
+ const subtle = globalThis.crypto.subtle;
25
+
26
+ /** The words every development slip carries in its issuer's name. */
27
+ export const DEVELOPMENT_MARK = '(development)';
28
+
29
+ // Two numbers of 32 bytes each, side by side, to the ASN.1 DER form a
30
+ // passkey returns for an ECDSA signature.
31
+ function ecdsaRawToDer(raw) {
32
+ const part = (bytes) => {
33
+ let start = 0;
34
+ while (start < bytes.length - 1 && bytes[start] === 0) start++;
35
+ let digits = bytes.subarray(start);
36
+ if (digits[0] & 0x80) digits = concatBytes(new Uint8Array([0]), digits);
37
+ return concatBytes(new Uint8Array([0x02, digits.length]), digits);
38
+ };
39
+ const body = concatBytes(part(raw.subarray(0, 32)), part(raw.subarray(32)));
40
+ return concatBytes(new Uint8Array([0x30, body.length]), body);
41
+ }
42
+
43
+ /**
44
+ * A development stand-in for a passkey (ES256, the method most passkeys
45
+ * use). It signs on this computer, for the page address http://localhost,
46
+ * which the format allows for development only.
47
+ *
48
+ * @returns {Promise<{key: object, rpId: string, origin: string, sign: (challenge: Uint8Array) => Promise<{authenticatorData: Uint8Array, clientDataJSON: Uint8Array, signature: Uint8Array}>}>}
49
+ */
50
+ export async function developmentPasskey() {
51
+ const rpId = 'localhost';
52
+ const origin = 'http://localhost';
53
+ const pair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
54
+ const j = await subtle.exportKey('jwk', pair.publicKey);
55
+ const key = { alg: 'ES256', crv: 'P-256', kty: 'EC', x: j.x, y: j.y };
56
+ return {
57
+ key,
58
+ rpId,
59
+ origin,
60
+ async sign(challenge) {
61
+ const clientDataJSON = utf8(JSON.stringify({ type: 'webauthn.get', challenge: toBase64url(challenge), origin, crossOrigin: false }));
62
+ // Flags 0x05: "user present" and "user verified". Nobody was: this is a stand-in.
63
+ const authenticatorData = concatBytes(await sha256(utf8(rpId)), new Uint8Array([0x05, 0, 0, 0, 1]));
64
+ const signed = concatBytes(authenticatorData, await sha256(clientDataJSON));
65
+ const signature = ecdsaRawToDer(new Uint8Array(await subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, pair.privateKey, signed)));
66
+ return { authenticatorData, clientDataJSON, signature };
67
+ },
68
+ };
69
+ }
70
+
71
+ // The issuer's name always ends with the development mark, whatever name is given.
72
+ function markedName(name) {
73
+ const given = typeof name === 'string' && name.trim() ? name.trim() : 'Development passkey';
74
+ return given.endsWith(DEVELOPMENT_MARK) ? given : `${given} ${DEVELOPMENT_MARK}`;
75
+ }
76
+
77
+ /**
78
+ * A slip signed with a development stand-in for a passkey, and new keys for
79
+ * the agent. Give the slip's actions, limits, conditions and the rest as for
80
+ * prepareSlip; "issuer" and "agent.keys" are filled in, and the issuer's
81
+ * name is made to end with " (development)". By default the slip runs from
82
+ * a minute ago for one day, and its purpose says that it is for development.
83
+ *
84
+ * @param {object} fields the members of the slip; "actions" is required
85
+ * @returns {Promise<{slip: object, slipFingerprint: string, book: string, issuerKeys: string[], agent: {keys: object[], privateKeys: CryptoKey[]}, passkey: object}>}
86
+ */
87
+ export async function developmentSlip(fields = {}) {
88
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields)) throw new TypeError('developmentSlip: give the members of the slip as an object, with at least "actions".');
89
+ const passkey = await developmentPasskey();
90
+ const agent = await generateKeySet();
91
+ const now = Date.now();
92
+ const issuer = fields.issuer && typeof fields.issuer === 'object' ? fields.issuer : {};
93
+ const agentGiven = fields.agent && typeof fields.agent === 'object' ? fields.agent : {};
94
+ const prepared = await prepareSlip({
95
+ validFrom: formatTime(now - 60 * 1000),
96
+ validUntil: formatTime(now + 24 * 3600 * 1000),
97
+ purpose: 'For development: a record made with a stand-in for a passkey, which no person signed.',
98
+ limits: [],
99
+ with: [],
100
+ ...fields,
101
+ issuer: { name: markedName(issuer.name), key: passkey.key, rpId: passkey.rpId, origin: passkey.origin },
102
+ agent: { name: typeof agentGiven.name === 'string' ? agentGiven.name : 'Development agent', ...agentGiven, keys: agent.keys },
103
+ });
104
+ const slip = assembleSlip(prepared, await passkey.sign(prepared.challenge));
105
+ const slipFingerprint = await fingerprint(fromBase64url(slip.payload));
106
+ return { slip, slipFingerprint, book: writeBook([{ slip }]), issuerKeys: [await thumbprint(passkey.key)], agent, passkey };
107
+ }
108
+
109
+ /**
110
+ * The stub writer, opened under a development slip, in one call: the
111
+ * shortest way to a first record. The same as developmentSlip followed by
112
+ * openRecorder; "more" is passed to openRecorder (options, now,
113
+ * countersignWithin).
114
+ *
115
+ * @param {object} fields the members of the slip; "actions" is required
116
+ * @param {object} [more]
117
+ * @returns {Promise<{recorder: object, slip: object, slipFingerprint: string, issuerKeys: string[], agent: {keys: object[], privateKeys: CryptoKey[]}, passkey: object}>}
118
+ */
119
+ export async function developmentRecorder(fields = {}, more = {}) {
120
+ const made = await developmentSlip(fields);
121
+ const recorder = await openRecorder({ book: made.book, slip: made.slipFingerprint, privateKeys: made.agent.privateKeys, issuerKeys: made.issuerKeys, ...more });
122
+ return { recorder, slip: made.slip, slipFingerprint: made.slipFingerprint, issuerKeys: made.issuerKeys, agent: made.agent, passkey: made.passkey };
123
+ }
package/src/guard.js CHANGED
@@ -50,9 +50,11 @@ function lineBound(c, approval) {
50
50
  * @param {object} options the same options as checkBook
51
51
  * @param {string[]} options.issuerKeys the thumbprints of the passkeys this check trusts. It must be given:
52
52
  * a slip signed by any other key allows nothing
53
- * @returns {Promise<{allowed: boolean, problems: {code: string, message: string}[], breaches: {code: string, message: string}[], needs: string[]}>}
53
+ * @returns {Promise<{allowed: boolean, problems: {code: string, message: string}[], breaches: {code: string, message: string}[], needs: string[], notChecked?: string[]}>}
54
54
  * "allowed" is true only if the record so far is sound, this device
55
- * checked every signature in it, and the action would be inside the slip.
55
+ * checked every signature in it (or every one but the third signature of
56
+ * a seal whose first two it checked, which "notChecked" then names), and
57
+ * the action would be inside the slip.
56
58
  * "needs" lists what the slip asks to go with the action. A needed
57
59
  * approval must be handed over, or the action is not allowed. A needed
58
60
  * countersignature cannot be known beforehand: the action is allowed, and
@@ -76,6 +78,54 @@ export function checkBeforeRead(read, proposal, options = {}) {
76
78
  return decide(read, proposal, options);
77
79
  }
78
80
 
81
+ // The methods not checked on this device, if the only thing not checked is
82
+ // the third signature of one or more seals whose Ed25519 and ML-DSA-87
83
+ // signatures were checked here; otherwise null. It counts as the checker
84
+ // counts for "fully checked" (book.js): a signature whose method is not
85
+ // built in, or an entry with nothing to check, is not checked; a time-stamp
86
+ // from a service the reader did not name is not "unchecked", it is uncounted.
87
+ function onlySealThirdUnchecked(result) {
88
+ if (result.summary.fullyChecked) return null;
89
+ let skipped = false;
90
+ let excused = 0;
91
+ const missing = new Set();
92
+ const note = (list) => { if (list.length === 0) skipped = true; for (const x of list) if (x.state === 'unavailable') missing.add(x.method); };
93
+ const stamps = (list) => { for (const x of list) if (x.state === 'unavailable') missing.add('stamp'); };
94
+ for (const e of result.entries) {
95
+ if (e.kind === 'seal') {
96
+ const sigs = e.signatures ?? [];
97
+ const by = (method) => sigs.find((x) => x.method === method);
98
+ const third = sigs.length === 3 && by('Ed25519')?.state === 'valid' && by('ML-DSA-87')?.state === 'valid' && by(SEAL_THIRD)?.state === 'unavailable';
99
+ if (third) excused++; else note(sigs);
100
+ stamps(e.stamps ?? []);
101
+ } else if (e.kind === 'slip') {
102
+ if (!e.signature || e.signature.state === 'unavailable') missing.add(e.signature ? e.signature.alg : 'passkey');
103
+ else if (e.signature.state === 'unchecked') skipped = true;
104
+ } else if (e.kind === 'stub') {
105
+ note(e.signatures ?? []);
106
+ const counter = e.countersignature ?? { state: 'absent', signatures: [] };
107
+ if (counter.state !== 'absent') note(counter.signatures ?? []);
108
+ const approval = e.approval ?? { state: 'absent' };
109
+ if (approval.state === 'unavailable') missing.add(approval.alg);
110
+ } else if (e.kind === 'cancellation') {
111
+ if (!e.signature) skipped = true;
112
+ else if (e.signature.state === 'unavailable') missing.add(e.signature.alg);
113
+ stamps(e.stamps ?? []);
114
+ } else if (['refusal', 'terms', 'vouching', 'withdrawal', 'pass', 'acknowledgement'].includes(e.kind)) {
115
+ note(e.signatures ?? []);
116
+ } else {
117
+ skipped = true;
118
+ }
119
+ }
120
+ for (const h of result.held ?? []) {
121
+ stamps(h.stamps ?? []);
122
+ for (const a of h.acknowledgements ?? []) note(a.signatures ?? []);
123
+ }
124
+ return !skipped && missing.size === 0 && excused > 0 ? [SEAL_THIRD] : null;
125
+ }
126
+
127
+ const SEAL_THIRD = 'SLH-DSA-SHA2-256s';
128
+
79
129
  async function decide(read, proposal, options) {
80
130
  const answer = { allowed: false, problems: [], breaches: [], needs: [] };
81
131
  try {
@@ -85,15 +135,33 @@ async function decide(read, proposal, options) {
85
135
  }
86
136
  const { result, state, beside } = await read();
87
137
  if (result.summary.problemFound) throw notSound('A problem was found in the record so far. It must be looked at before the agent acts again.');
88
- // A record this device could only partly check is not a pass, here either.
89
- if (!result.summary.fullyChecked) throw notSound('This device could not check every signature in the record so far.');
138
+ // A record this device could only partly check is not a pass, here
139
+ // either, with one stated exception: a seal's third signature
140
+ // (SLH-DSA), where its first two were checked here. A seal widens no
141
+ // permission, and a device without that method (the Python version,
142
+ // every browser) would otherwise be refused everything once the book
143
+ // holds a seal. The answer names what was not checked.
144
+ const notChecked = onlySealThirdUnchecked(result);
145
+ if (!result.summary.fullyChecked && !notChecked) throw notSound('This device could not check every signature in the record so far.');
146
+ if (notChecked) answer.notChecked = notChecked;
90
147
  if (!result.entries.every((e) => (e.kind === 'stub' || e.kind === 'pass' ? e.compared === true : true))) {
91
148
  throw notSound('This device could not compare every stub and every pass in the record so far with its slip.');
92
149
  }
93
150
  if (result.size >= MAX_ENTRIES) throw new Refusal('too-large', 'The book is full: it holds 100,000 entries.');
94
151
 
95
152
  f.members(proposal, ['action', 'slip'], ['amount', 'approval', 'details', 'pass', 'terms', 'when', 'with'], 'proposal');
96
- const c = { action: proposal.action, when: formatTime(proposal.when ?? Date.now()) };
153
+ // The time, as the stub writer takes it: a number of milliseconds or a
154
+ // Date. Text is read by rules that differ between devices.
155
+ const given = proposal.when ?? Date.now();
156
+ if (typeof given !== 'number' && !(given instanceof Date)) throw new Refusal('bad-field', 'when: must be a number of milliseconds, or a Date.');
157
+ let ms;
158
+ try {
159
+ ms = new Date(given).getTime();
160
+ } catch {
161
+ ms = NaN; // a Date wrapped in a Proxy, which Date cannot read
162
+ }
163
+ if (!Number.isFinite(ms)) throw new Refusal('bad-field', 'when: does not give a time.');
164
+ const c = { action: proposal.action, when: formatTime(ms) };
97
165
  for (const name of ['amount', 'details', 'with']) if (proposal[name] !== undefined) c[name] = proposal[name];
98
166
  validateRequest(c);
99
167
  const when = f.time(c.when, 'when');
package/src/recorder.js CHANGED
@@ -70,10 +70,18 @@ const notWritten = (why) => new Refusal('record-not-sound', why);
70
70
 
71
71
  // A time handed in: a number of milliseconds, or a Date. Text, true or a
72
72
  // list would be read by rules that differ from one device to another (text
73
- // with no time zone is taken as local time), so they are refused.
73
+ // with no time zone is taken as local time), so they are refused. A Date
74
+ // that cannot be read (one wrapped in a Proxy) gives no time, as a Date
75
+ // that is not a time gives none, and the check before acting refuses it.
74
76
  function timeOf(when) {
75
77
  if (typeof when === 'number') return new Date(when).getTime();
76
- if (when instanceof Date) return when.getTime();
78
+ if (when instanceof Date) {
79
+ try {
80
+ return when.getTime();
81
+ } catch {
82
+ return NaN;
83
+ }
84
+ }
77
85
  throw new Refusal('bad-field', 'when: must be a number of milliseconds, or a Date.');
78
86
  }
79
87
 
@@ -775,6 +783,8 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
775
783
  return calling(request, more, async (call) => {
776
784
  await writeWaiting(now());
777
785
  const when = call.when ?? now();
786
+ // In the words of the check before acting, which "act" and "before" give.
787
+ if (!Number.isFinite(when)) throw new Refusal('bad-field', 'when: does not give a time.');
778
788
  if (aheadOfClock(when)) throw notWritten('The stub was not written: it is dated ahead of the clock.');
779
789
  const made = await composeStub(call, when);
780
790
  const tried = await fitsBook(made, 'The stub was not written: with it, the book would not pass its check.');
package/src/signatures.js CHANGED
@@ -16,6 +16,10 @@
16
16
  import { importPublicKey, weakEd25519 } from './keys.js';
17
17
 
18
18
  const SLH = 'SLH-DSA-SHA2-256s';
19
+
20
+ // The length of a signature, in bytes, by method (RFC 8032, FIPS 204,
21
+ // FIPS 205).
22
+ const SIGNATURE_BYTES = { Ed25519: 64, 'ML-DSA-87': 4627, [SLH]: 29792 };
19
23
  const nodeCrypto = globalThis.process && typeof globalThis.process.getBuiltinModule === 'function' ? globalThis.process.getBuiltinModule('node:crypto') : null;
20
24
 
21
25
  function slhKey(pub) {
@@ -80,6 +84,9 @@ export function methodAvailable(alg) {
80
84
  * @returns {Promise<SignatureState>}
81
85
  */
82
86
  export async function verifySignature(jwk, signature, data, without = []) {
87
+ // A signature of another length than its method's can never check, so it
88
+ // is refused on every device, whether or not this one has the method.
89
+ if (Object.hasOwn(SIGNATURE_BYTES, jwk.alg) && signature.length !== SIGNATURE_BYTES[jwk.alg]) return 'invalid';
83
90
  if (jwk.alg === SLH) {
84
91
  if (without.includes(SLH) || !(await methodAvailable(SLH))) return 'unavailable';
85
92
  try {
package/src/types.d.ts CHANGED
@@ -431,6 +431,8 @@ export interface BeforeAnswer {
431
431
  problems: Finding[];
432
432
  breaches: Finding[];
433
433
  needs: ('approval' | 'countersignature')[];
434
+ /** Present only where the device went on without checking a seal's third signature (SLH-DSA-SHA2-256s): the methods not checked. */
435
+ notChecked?: string[];
434
436
  }
435
437
  /** "issuerKeys" must be given: a slip signed by any other key allows nothing. */
436
438
  export function checkBefore(book: string, proposal: Proposal, options: CheckOptions & { issuerKeys: string[] }): Promise<BeforeAnswer>;