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.
- package/CHANGELOG.md +324 -0
- package/README.md +134 -653
- package/bin/provared-check.mjs +39 -2
- package/docs/guide.md +745 -0
- package/docs/threat-model.md +16 -7
- package/package.json +25 -2
- package/page/index.html +1 -1
- package/page/style.css +3 -0
- package/spec/provared-format.md +30 -7
- package/src/dev.d.ts +54 -0
- package/src/dev.js +123 -0
- package/src/guard.js +73 -5
- package/src/recorder.js +12 -2
- package/src/signatures.js +7 -0
- package/src/types.d.ts +2 -0
package/docs/threat-model.md
CHANGED
|
@@ -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,
|
|
62
|
-
record as intact. An entry none of whose
|
|
63
|
-
check is not treated as evidence: what it
|
|
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.
|
|
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.**
|
|
494
|
-
|
|
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.
|
|
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; }
|
package/spec/provared-format.md
CHANGED
|
@@ -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
|
|
230
|
-
record as intact. An entry none
|
|
231
|
-
|
|
232
|
-
and it is not compared with
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
89
|
-
|
|
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
|
-
|
|
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)
|
|
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>;
|