provared 0.1.0 → 0.3.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 +291 -2
- package/README.md +116 -645
- package/bin/provared-check.mjs +36 -1
- package/docs/guide.md +736 -0
- package/docs/threat-model.md +12 -0
- package/package.json +25 -2
- package/spec/provared-format.md +33 -4
- package/src/compare.js +10 -7
- package/src/dev.d.ts +54 -0
- package/src/dev.js +123 -0
- package/src/encoding.js +9 -1
- package/src/keys.js +24 -1
- package/src/recorder.js +32 -4
- package/src/signatures.js +5 -1
- package/src/slip.js +24 -11
- package/src/timestamp.js +59 -16
- package/src/tools.js +7 -1
package/docs/threat-model.md
CHANGED
|
@@ -568,6 +568,18 @@ person checking gives.
|
|
|
568
568
|
not fail, only the one certificate it names is tried, and an RSA key
|
|
569
569
|
must have a small public number, so that a small file cannot cost much
|
|
570
570
|
work.
|
|
571
|
+
- **A key under which anyone can sign.** An RSA key with the public
|
|
572
|
+
number 1 lets anyone make a signature that checks. Such a key, and one
|
|
573
|
+
with an even public number, is refused, even in a certificate the
|
|
574
|
+
person named as trusted. So is an Ed25519 key of small order, there and
|
|
575
|
+
in every record (a passkey, an agent, a service, a recorder, an
|
|
576
|
+
organisation): libraries differ in whether they accept signatures under
|
|
577
|
+
such a key, so the checker refuses the key itself. For the same reason
|
|
578
|
+
it refuses an Ed25519 signature whose point R is of small order, under
|
|
579
|
+
any key: the key's own holder could make one for any message, which one
|
|
580
|
+
library accepts and another refuses. The key in a
|
|
581
|
+
certificate must be written exactly as its standard sets out, so that
|
|
582
|
+
two checkers cannot disagree about whether it can be read.
|
|
571
583
|
|
|
572
584
|
**Knowing that a block is part of the chain.** The person checking can
|
|
573
585
|
name the blocks they trust, or hand the checker a chain of block headers
|
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.3.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/spec/provared-format.md
CHANGED
|
@@ -197,6 +197,15 @@ one of these shapes, with no other member:
|
|
|
197
197
|
| Issuer's passkey | `{"alg":"RS256","e":"AQAB","kty":"RSA","n":"…"}` (RFC 7518, section 6.3; `n` is 2,048 to 8,192 bits) |
|
|
198
198
|
| Issuer's passkey | `{"alg":"Ed25519","crv":"Ed25519","kty":"OKP","x":"…"}` |
|
|
199
199
|
|
|
200
|
+
An Ed25519 key, wherever it stands, MUST NOT be one of the eight points
|
|
201
|
+
of small order, under which anyone can make a signature that checks, and
|
|
202
|
+
the number y it holds MUST be less than p = 2^255 - 19, so that it is
|
|
203
|
+
written in only one way. RFC 8032 does not say to refuse such keys, and
|
|
204
|
+
libraries differ in whether they do; a checker refuses them itself
|
|
205
|
+
(`bad-key`), so that two checkers cannot disagree. For the same reason,
|
|
206
|
+
an Ed25519 signature whose first 32 bytes (the point R) are such a point,
|
|
207
|
+
or hold a y not less than p, is invalid, whatever the key.
|
|
208
|
+
|
|
200
209
|
A key is named for people by its **thumbprint**: the JSON Web Key
|
|
201
210
|
Thumbprint with SHA-256 (RFC 7638; for ML-DSA keys, RFC 9964 section 6).
|
|
202
211
|
|
|
@@ -262,7 +271,15 @@ is the address of the page the slip was signed on, for example
|
|
|
262
271
|
`https://sign.example.org`. The host in `origin` MUST be `rpId` or end
|
|
263
272
|
with a full stop and `rpId`. `origin` MUST use `https`, or be
|
|
264
273
|
`http://localhost` with or without a port number. It holds nothing after
|
|
265
|
-
the host and the port.
|
|
274
|
+
the host and the port. It is written exactly as a browser writes a
|
|
275
|
+
page's address, so that a checker needs no reader of web addresses and
|
|
276
|
+
no tables of Unicode: the scheme, `://`, the host, and a port only where
|
|
277
|
+
it is not the scheme's own (443 for `https`, 80 for `http`), written in
|
|
278
|
+
digits with no leading zero, from 1 to 65535. The host is one or more
|
|
279
|
+
labels, separated by full stops, each of 1 to 63 lower-case letters,
|
|
280
|
+
digits and hyphens, neither beginning nor ending with a hyphen; the last
|
|
281
|
+
label is not all digits. A name in another script stands in its `xn--`
|
|
282
|
+
form, which the rule takes as it is.
|
|
266
283
|
|
|
267
284
|
A **name** is text of 1 to 200 characters. It is a label chosen by
|
|
268
285
|
whoever wrote the slip. It is not checked against anything, unless a
|
|
@@ -1002,7 +1019,13 @@ otherwise report a problem:
|
|
|
1002
1019
|
does not otherwise use: a whole number or an object identifier that is
|
|
1003
1020
|
not in its shortest form, a part that does not fill what holds it, or a
|
|
1004
1021
|
part the standards do not allow in that place, is refused. The signed
|
|
1005
|
-
data is of version 3, with exactly one signer.
|
|
1022
|
+
data is of version 3, with exactly one signer. The public key in the
|
|
1023
|
+
certificate that signed is written exactly as its own standard sets
|
|
1024
|
+
out, with no unused bits: RSA as RFC 3279, section 2.3.1 (the
|
|
1025
|
+
parameters present and NULL, two positive whole numbers in strict DER
|
|
1026
|
+
and nothing after them), ECDSA as RFC 5480, section 2.2 (an
|
|
1027
|
+
uncompressed point), Ed25519 as RFC 8410, section 4 (no parameters,
|
|
1028
|
+
32 bytes).
|
|
1006
1029
|
2. What it stamps is this seal's fingerprint (`stamp-wrong-data`).
|
|
1007
1030
|
3. Its signed attributes name a time-stamp statement, hold the
|
|
1008
1031
|
fingerprint of the statement, and name the certificate that signed, by
|
|
@@ -1015,8 +1038,14 @@ otherwise report a problem:
|
|
|
1015
1038
|
signer names (RFC 5652, section 5.4): SHA-256, SHA-384 or SHA-512. A
|
|
1016
1039
|
signing method that names another one is refused (`stamp-invalid`).
|
|
1017
1040
|
The signing methods are RSA (PKCS #1 version 1.5; keys of 2,048 to
|
|
1018
|
-
8,192 bits, with
|
|
1019
|
-
|
|
1041
|
+
8,192 bits, with an odd public number from 3 to 2^32 - 1: never 1,
|
|
1042
|
+
under which anyone can make a signature that checks), ECDSA on the
|
|
1043
|
+
curves P-256 and P-384, and Ed25519 (not a key of small order, as
|
|
1044
|
+
section 3.8 sets out). A key of any other kind, or on another curve,
|
|
1045
|
+
is refused (`stamp-invalid`). The checker does not try to find out
|
|
1046
|
+
whether an RSA modulus can be factored: a service's key is the
|
|
1047
|
+
service's business, and a checker trusts a service only by the
|
|
1048
|
+
fingerprint of its certificate.
|
|
1020
1049
|
5. The time it states lies within the time that certificate is in force
|
|
1021
1050
|
(`stamp-invalid`).
|
|
1022
1051
|
6. It does not say that its time is exact to no better than more than 300
|
package/src/compare.js
CHANGED
|
@@ -63,10 +63,13 @@ function periodWords(seconds) {
|
|
|
63
63
|
|
|
64
64
|
const allowedIn = (limit) => BigInt(Object.hasOwn(limit, 'count') ? limit.count : limit.max);
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
// "proposed": an action that is only proposed (a check before acting), of
|
|
67
|
+
// which no stub exists yet.
|
|
68
|
+
function overPeriod(limit, total, W, proposed = false) {
|
|
69
|
+
const [With, are, is] = proposed ? ['With this action', 'there would be', 'would be'] : ['With this stub', 'there are', 'is'];
|
|
67
70
|
const message = Object.hasOwn(limit, 'count')
|
|
68
|
-
?
|
|
69
|
-
:
|
|
71
|
+
? `${With} ${are} ${total} such actions in ${periodWords(limit.per)}. ${W} allows ${limit.count}.`
|
|
72
|
+
: `${With} the total in ${periodWords(limit.per)} ${is} ${total} ${limit.unit}. ${W}'s limit is ${limit.max} ${limit.unit}.`;
|
|
70
73
|
return { code: 'over-period-limit', message };
|
|
71
74
|
}
|
|
72
75
|
|
|
@@ -175,7 +178,7 @@ export function compareWithSlip(s, tally, c, facts, commit = true) {
|
|
|
175
178
|
return;
|
|
176
179
|
}
|
|
177
180
|
const worst = periodWith(items, when, limit.per * 1000, value);
|
|
178
|
-
if (worst > allowedIn(limit)) breaches.push(overPeriod(limit, worst, W));
|
|
181
|
+
if (worst > allowedIn(limit)) breaches.push(overPeriod(limit, worst, W, true));
|
|
179
182
|
};
|
|
180
183
|
|
|
181
184
|
if (!s.actions.includes(c.action)) {
|
|
@@ -209,7 +212,7 @@ export function compareWithSlip(s, tally, c, facts, commit = true) {
|
|
|
209
212
|
const amountIn = (unit) => {
|
|
210
213
|
if (c.amount && c.amount.unit === unit) return true;
|
|
211
214
|
if (!missing) {
|
|
212
|
-
breaches.push({ code: 'amount-missing', message: `${W} sets a limit or a condition in "${unit}" for this action, and the stub gives no amount in that unit.` });
|
|
215
|
+
breaches.push({ code: 'amount-missing', message: `${W} sets a limit or a condition in "${unit}" for this action, and the ${commit ? 'stub' : 'action'} gives no amount in that unit.` });
|
|
213
216
|
missing = true;
|
|
214
217
|
}
|
|
215
218
|
return false;
|
|
@@ -225,7 +228,7 @@ export function compareWithSlip(s, tally, c, facts, commit = true) {
|
|
|
225
228
|
const n = (tally.counts.get(i) ?? 0) + 1;
|
|
226
229
|
if (commit) tally.counts.set(i, n);
|
|
227
230
|
if (n > limit.count) {
|
|
228
|
-
breaches.push({ code: 'over-count-limit', message:
|
|
231
|
+
breaches.push({ code: 'over-count-limit', message: `${commit ? 'This is' : 'This would be'} action number ${n} of this kind under ${w}. ${W} allows ${limit.count}.` });
|
|
229
232
|
}
|
|
230
233
|
} else {
|
|
231
234
|
inPeriod(i, limit, 1n);
|
|
@@ -246,7 +249,7 @@ export function compareWithSlip(s, tally, c, facts, commit = true) {
|
|
|
246
249
|
if (total > BigInt(limit.max)) {
|
|
247
250
|
breaches.push({
|
|
248
251
|
code: 'over-limit',
|
|
249
|
-
message:
|
|
252
|
+
message: `${commit ? 'With this stub the total is' : 'With this action the total would be'} ${total} ${limit.unit}. ${W}'s limit is ${limit.max} ${limit.unit}: over by ${total - BigInt(limit.max)}.`,
|
|
250
253
|
});
|
|
251
254
|
}
|
|
252
255
|
} else if (value > BigInt(limit.max)) {
|
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/encoding.js
CHANGED
|
@@ -275,8 +275,16 @@ export function parseTime(text) {
|
|
|
275
275
|
return formatTime(ms) === text ? ms : NaN;
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
-
/**
|
|
278
|
+
/**
|
|
279
|
+
* A time, written in the one accepted form. Only a number of milliseconds
|
|
280
|
+
* or a Date is read: text, true or a list would be read by rules that
|
|
281
|
+
* differ from one device to another (text with no time zone is taken as
|
|
282
|
+
* local time), so they are refused.
|
|
283
|
+
* @param {number|Date} when
|
|
284
|
+
* @returns {string}
|
|
285
|
+
*/
|
|
279
286
|
export function formatTime(when) {
|
|
287
|
+
if (typeof when !== 'number' && !(when instanceof Date)) throw new RangeError('Invalid time value');
|
|
280
288
|
return new Date(when).toISOString().slice(0, 19) + 'Z';
|
|
281
289
|
}
|
|
282
290
|
|
package/src/keys.js
CHANGED
|
@@ -25,6 +25,27 @@ const SHAPES = {
|
|
|
25
25
|
RS256: ['alg', 'e', 'kty', 'n'],
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
// The Ed25519 public keys under which anyone can make a signature that
|
|
29
|
+
// checks: the eight points of small order. RFC 8032 does not say to refuse
|
|
30
|
+
// them, and libraries differ, so a checker refuses them itself, with any
|
|
31
|
+
// key whose number y is not below p (written in more than one way), so
|
|
32
|
+
// that two checkers cannot disagree.
|
|
33
|
+
const P = 2n ** 255n - 19n;
|
|
34
|
+
const ORDER_8_Y = 0x7a03ac9277fdc74ec6cc392cfa53202a0f67100d760b3cba4fd84d3d706a17c7n;
|
|
35
|
+
const SMALL_ORDER_Y = new Set([0n, 1n, P - 1n, ORDER_8_Y, P - ORDER_8_Y]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether 32 bytes are an Ed25519 public key under which anyone can sign,
|
|
39
|
+
* or one written in more than one way.
|
|
40
|
+
* @param {Uint8Array} bytes
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function weakEd25519(bytes) {
|
|
44
|
+
let y = 0n;
|
|
45
|
+
for (let i = 31; i >= 0; i--) y = (y << 8n) | BigInt(i === 31 ? bytes[i] & 0x7f : bytes[i]);
|
|
46
|
+
return y >= P || SMALL_ORDER_Y.has(y);
|
|
47
|
+
}
|
|
48
|
+
|
|
28
49
|
function bad(where, why) {
|
|
29
50
|
return new Refusal('bad-key', `${where}: ${why}`);
|
|
30
51
|
}
|
|
@@ -60,7 +81,9 @@ export function checkKey(jwk, allowed, where) {
|
|
|
60
81
|
}
|
|
61
82
|
if (alg === 'Ed25519') {
|
|
62
83
|
if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') throw bad(where, 'an Ed25519 key has kty OKP and crv Ed25519.');
|
|
63
|
-
|
|
84
|
+
const x = bytesOf(jwk, 'x', where);
|
|
85
|
+
if (x.length !== 32) throw bad(where, 'an Ed25519 public key is 32 bytes.');
|
|
86
|
+
if (weakEd25519(x)) throw bad(where, 'this Ed25519 public key is one under which anyone can sign, and is not accepted.');
|
|
64
87
|
} else if (alg === 'ML-DSA-87') {
|
|
65
88
|
if (jwk.kty !== 'AKP') throw bad(where, 'an ML-DSA-87 key has kty AKP.');
|
|
66
89
|
// FIPS 204, table 2: an ML-DSA-87 public key is 2,592 bytes.
|
package/src/recorder.js
CHANGED
|
@@ -68,10 +68,38 @@ function within(promise, ms) {
|
|
|
68
68
|
|
|
69
69
|
const notWritten = (why) => new Refusal('record-not-sound', why);
|
|
70
70
|
|
|
71
|
+
// A time handed in: a number of milliseconds, or a Date. Text, true or a
|
|
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.
|
|
74
|
+
function timeOf(when) {
|
|
75
|
+
if (typeof when === 'number') return new Date(when).getTime();
|
|
76
|
+
if (when instanceof Date) return when.getTime();
|
|
77
|
+
throw new Refusal('bad-field', 'when: must be a number of milliseconds, or a Date.');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A copy that nothing else holds, as structuredClone makes one, refused if
|
|
81
|
+
// it nests more than MAX_COPY_DEPTH levels deep: far more than a record
|
|
82
|
+
// holds. structuredClone's own limit depends on how much of the stack is in
|
|
83
|
+
// use, so without this two runs could give two answers. The depth is
|
|
84
|
+
// measured on the copy, so the caller's value is read once.
|
|
85
|
+
function clone(value) {
|
|
86
|
+
const copy = structuredClone(value);
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
const stack = [[copy, 0]];
|
|
89
|
+
while (stack.length) {
|
|
90
|
+
const [v, depth] = stack.pop();
|
|
91
|
+
if (v === null || typeof v !== 'object' || seen.has(v)) continue;
|
|
92
|
+
seen.add(v);
|
|
93
|
+
if (depth > MAX_COPY_DEPTH) throw new TypeError('the value nests too deeply');
|
|
94
|
+
for (const item of Object.values(v)) stack.push([item, depth + 1]);
|
|
95
|
+
}
|
|
96
|
+
return copy;
|
|
97
|
+
}
|
|
98
|
+
|
|
71
99
|
// A copy that nothing else holds.
|
|
72
100
|
function plain(value, what) {
|
|
73
101
|
try {
|
|
74
|
-
return
|
|
102
|
+
return clone(value);
|
|
75
103
|
} catch {
|
|
76
104
|
throw new Refusal('bad-field', `${what} must be plain data: text, numbers, lists and objects.`);
|
|
77
105
|
}
|
|
@@ -373,7 +401,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
373
401
|
if (more === null || typeof more !== 'object') more = {};
|
|
374
402
|
const call = {
|
|
375
403
|
request: plain(request, 'The request'),
|
|
376
|
-
when: more.when === undefined || more.when === null ? undefined :
|
|
404
|
+
when: more.when === undefined || more.when === null ? undefined : timeOf(more.when),
|
|
377
405
|
terms: more.terms,
|
|
378
406
|
countersign: more.countersign,
|
|
379
407
|
hasApproval: more.approval !== undefined && more.approval !== null,
|
|
@@ -381,7 +409,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
381
409
|
};
|
|
382
410
|
if (call.hasApproval) {
|
|
383
411
|
try {
|
|
384
|
-
call.approval =
|
|
412
|
+
call.approval = clone(more.approval.record);
|
|
385
413
|
} catch {
|
|
386
414
|
call.approval = undefined; // not a record: refused when the stub is written
|
|
387
415
|
}
|
|
@@ -479,7 +507,7 @@ export async function openRecorder({ book, slip, privateKeys, issuerKeys, pass,
|
|
|
479
507
|
// What it hands back is copied at once. The copy is what is checked, and what joins the stub.
|
|
480
508
|
let kept;
|
|
481
509
|
try {
|
|
482
|
-
kept =
|
|
510
|
+
kept = clone(given);
|
|
483
511
|
} catch {
|
|
484
512
|
throw new Refusal('countersignature-invalid', 'What the other side handed back is not a signed record.');
|
|
485
513
|
}
|
package/src/signatures.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// this file still loads in a browser. In a browser the method is reported as
|
|
14
14
|
// not built in, which is never a pass.
|
|
15
15
|
|
|
16
|
-
import { importPublicKey } from './keys.js';
|
|
16
|
+
import { importPublicKey, weakEd25519 } from './keys.js';
|
|
17
17
|
|
|
18
18
|
const SLH = 'SLH-DSA-SHA2-256s';
|
|
19
19
|
const nodeCrypto = globalThis.process && typeof globalThis.process.getBuiltinModule === 'function' ? globalThis.process.getBuiltinModule('node:crypto') : null;
|
|
@@ -91,6 +91,10 @@ export async function verifySignature(jwk, signature, data, without = []) {
|
|
|
91
91
|
if (!Object.hasOwn(VERIFY_PARAMS, jwk.alg)) return 'invalid';
|
|
92
92
|
const params = VERIFY_PARAMS[jwk.alg];
|
|
93
93
|
if (without.includes(jwk.alg) || !(await methodAvailable(jwk.alg))) return 'unavailable';
|
|
94
|
+
// An Ed25519 signature whose first half is a point of small order is
|
|
95
|
+
// refused here, whatever the device's own library would say: libraries
|
|
96
|
+
// differ on it (format description, section 3.8).
|
|
97
|
+
if (jwk.alg === 'Ed25519' && (signature.length !== 64 || weakEd25519(signature.subarray(0, 32)))) return 'invalid';
|
|
94
98
|
try {
|
|
95
99
|
const key = await importPublicKey(jwk);
|
|
96
100
|
return (await globalThis.crypto.subtle.verify(params, key, signature, data)) ? 'valid' : 'invalid';
|
package/src/slip.js
CHANGED
|
@@ -14,28 +14,40 @@ const LIMIT_KINDS = ['count', 'each', 'max'];
|
|
|
14
14
|
/** The longest period a limit may name: 366 days. */
|
|
15
15
|
const MAX_PERIOD_SECONDS = 366 * 86400;
|
|
16
16
|
|
|
17
|
+
// The address of the page a slip was signed on, by an exact rule that any
|
|
18
|
+
// checker can follow without a reader of web addresses or tables of
|
|
19
|
+
// Unicode: "https://" (or "http://" for localhost), a host, a port only if
|
|
20
|
+
// it is not the scheme's own, and nothing more. A host is labels of
|
|
21
|
+
// lower-case letters, digits and hyphens, separated by full stops; no label
|
|
22
|
+
// begins or ends with a hyphen, and the last is not all digits. A browser
|
|
23
|
+
// writes a page's address in exactly this form, with a name in another
|
|
24
|
+
// script written in its "xn--" form, which the rule takes as it is.
|
|
25
|
+
const ORIGIN = /^(https|http):\/\/([a-z0-9.-]{1,253})(?::([1-9][0-9]{0,4}))?$/;
|
|
26
|
+
const LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
27
|
+
|
|
17
28
|
function checkOrigin(origin, rpId) {
|
|
18
29
|
f.text(origin, 1, 300, 'issuer.origin');
|
|
19
30
|
if (typeof rpId !== 'string' || !/^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/.test(rpId)) {
|
|
20
31
|
throw f.fail('issuer.rpId', 'must be a website name in lower case, such as example.org.');
|
|
21
32
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
throw f.fail('issuer.origin', 'must be the address of a page, such as https://sign.example.org.');
|
|
33
|
+
const m = ORIGIN.exec(origin);
|
|
34
|
+
const labels = m ? m[2].split('.') : [];
|
|
35
|
+
const port = m && m[3] !== undefined ? Number(m[3]) : null;
|
|
36
|
+
if (!m || !labels.every((l) => LABEL.test(l)) || /^[0-9]+$/.test(labels[labels.length - 1]) || (port !== null && (port > 65535 || port === (m[1] === 'https' ? 443 : 80)))) {
|
|
37
|
+
throw f.fail('issuer.origin', 'must be the address of a page: https://, a website name in lower case, a port only if it is needed, and nothing more, such as https://sign.example.org.');
|
|
27
38
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (url.origin !== origin) throw f.fail('issuer.origin', 'must be a bare address with no path, such as https://sign.example.org.');
|
|
31
|
-
if (!(url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost'))) {
|
|
39
|
+
const [, scheme, host] = m;
|
|
40
|
+
if (!(scheme === 'https' || (scheme === 'http' && host === 'localhost'))) {
|
|
32
41
|
throw f.fail('issuer.origin', 'must begin https://, or http://localhost.');
|
|
33
42
|
}
|
|
34
|
-
if (!(
|
|
43
|
+
if (!(host === rpId || host.endsWith('.' + rpId))) {
|
|
35
44
|
throw f.fail('issuer.origin', 'must be on the website that issuer.rpId names.');
|
|
36
45
|
}
|
|
37
46
|
}
|
|
38
47
|
|
|
48
|
+
/** Not part of the public interface: for the shared test files. */
|
|
49
|
+
export const checkOriginForTests = checkOrigin;
|
|
50
|
+
|
|
39
51
|
/**
|
|
40
52
|
* Confirm a list of limits against the actions it may name (format
|
|
41
53
|
* description, section 4.1). A slip and a pass share it.
|
|
@@ -324,7 +336,8 @@ export async function checkSlip(record, options = {}, disclosures) {
|
|
|
324
336
|
result.issuerKey = await thumbprint(issuer.key);
|
|
325
337
|
|
|
326
338
|
result.signature.state = 'invalid';
|
|
327
|
-
|
|
339
|
+
// As everywhere else, only a list counts as a list of methods to treat as not built in.
|
|
340
|
+
result.signature.state = await checkPasskeySignature(parsed, issuer, Array.isArray(options.withoutMethods) ? options.withoutMethods : []);
|
|
328
341
|
if (result.signature.state === 'unavailable') {
|
|
329
342
|
result.notes.push(`This device cannot check the passkey's signing method (${issuer.key.alg}).`);
|
|
330
343
|
}
|