provared 0.6.0 → 0.8.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 +723 -0
- package/README.md +13 -1
- package/bin/provared-check.mjs +20 -9
- package/docs/guide.md +60 -11
- package/docs/threat-model.md +71 -26
- package/net/headers.js +1 -0
- package/package.json +1 -1
- package/page/check-page.js +8 -1
- package/page/index.html +2 -0
- package/page/render.js +27 -2
- package/page/style.css +7 -0
- package/spec/provared-format.md +86 -16
- package/src/book.js +6 -2
- package/src/check.d.ts +1 -0
- package/src/check.js +1 -1
- package/src/denial.js +1 -1
- package/src/der.js +1 -0
- package/src/documents.js +8 -3
- package/src/passkey-browser.d.ts +3 -1
- package/src/passkey-browser.js +31 -2
- package/src/signatures.js +6 -1
- package/src/slip.js +72 -5
- package/src/types.d.ts +15 -2
package/spec/provared-format.md
CHANGED
|
@@ -439,6 +439,39 @@ it MUST also confirm that `issuer.key` is one of them. If it was given
|
|
|
439
439
|
none, it MUST say that the issuer's key was not compared with a known
|
|
440
440
|
key.
|
|
441
441
|
|
|
442
|
+
If the checker was given the addresses of the pages at which it expects
|
|
443
|
+
slips to have been signed (`issuerOrigins`), it MUST confirm that
|
|
444
|
+
`issuer.origin` is one of them, written exactly the same, and refuse the
|
|
445
|
+
slip otherwise (`issuer-origin-not-expected`). It MAY say, on a slip it
|
|
446
|
+
accepts, that the address is one the reader named. Each address given
|
|
447
|
+
MUST be in the form section 4.1 requires of `issuer.origin`; what is
|
|
448
|
+
given is otherwise a problem with the whole check (`bad-field`), never
|
|
449
|
+
set aside: an address with a capital letter or a path would match
|
|
450
|
+
nothing, and every slip would then read as signed elsewhere. At most 64
|
|
451
|
+
addresses may be given. An approval (section 17) and a cancellation
|
|
452
|
+
(section 22) are signed with the slip's passkey, and step 4 holds each to
|
|
453
|
+
the slip's `issuer.origin`, so the slip's address is the one compared.
|
|
454
|
+
Where no address was given, nothing is compared and nothing is said.
|
|
455
|
+
|
|
456
|
+
Why a reader names an address. A passkey signs whatever challenge a page
|
|
457
|
+
under its relying party (`issuer.rpId`) hands it: the browser lets any
|
|
458
|
+
page whose website name is that relying party, or a name under it, ask
|
|
459
|
+
the passkey to sign (W3C Web Authentication Level 3, sections 5.1.3 and
|
|
460
|
+
5.1.4). The challenge for a slip, an approval or a cancellation is a
|
|
461
|
+
fingerprint, which the person cannot read on the device's prompt. So a
|
|
462
|
+
page served under that relying party, or a script running on one, can
|
|
463
|
+
have the passkey sign a record it wrote, by presenting the record's
|
|
464
|
+
fingerprint as an ordinary sign-in challenge: the client data then names
|
|
465
|
+
that page's address, the record written by the same party names the
|
|
466
|
+
same address, and steps 2 to 7 all pass. A passkey used to sign records
|
|
467
|
+
SHOULD therefore be made for a relying party name that serves nothing
|
|
468
|
+
but the signing page (such as `sign.example.org`, never `example.org`),
|
|
469
|
+
the signing page SHOULD show the record before it asks the passkey, and
|
|
470
|
+
a reader who knows where the person signs SHOULD name that address, so
|
|
471
|
+
that a slip signed under the same passkey anywhere else is refused. The
|
|
472
|
+
list of threats (`docs/threat-model.md`, section 3) states what this
|
|
473
|
+
closes and what it does not.
|
|
474
|
+
|
|
442
475
|
The signature counter in the authenticator data is not used: an offline
|
|
443
476
|
checker keeps no state.
|
|
444
477
|
|
|
@@ -2106,8 +2139,9 @@ can then work out the fingerprint again and match the two records,
|
|
|
2106
2139
|
without either side's help. Only an identifier is meant to pick out one
|
|
2107
2140
|
call. A stub may also name fingerprints of some of what was sent and
|
|
2108
2141
|
received (the arguments; an HTTP request's method, address and body; an
|
|
2109
|
-
answer's status and body
|
|
2110
|
-
that it was the same
|
|
2142
|
+
answer's status and body; the name of the tool that was called, and its
|
|
2143
|
+
result); those show that the content matches, not that it was the same
|
|
2144
|
+
call.
|
|
2111
2145
|
|
|
2112
2146
|
So document names (section 5.1) that begin `provared.` are reserved. A
|
|
2113
2147
|
document name that begins `provared.` and is not in this list MUST be
|
|
@@ -2130,9 +2164,33 @@ exactly this text, in UTF-8:
|
|
|
2130
2164
|
| `provared.request-id` | An identifier that the agent's side chose and sent with the call, exactly as sent |
|
|
2131
2165
|
| `provared.event-id` | An identifier that the platform gave for the call, exactly as it gave it |
|
|
2132
2166
|
| `provared.error` | The error text that the agent's software was given, exactly as given |
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2167
|
+
| `provared.tool-name` | The name of the tool, exactly as the agent's software called it: the name the tool server, the framework or the model knows it by, which is not the action name of the slip (several tools may be one action) |
|
|
2168
|
+
| `provared.tool-call-id` | The identifier that the model gave the tool call, exactly as the agent's software received it (such as the `id` of a tool call in the model's answer) |
|
|
2169
|
+
| `provared.tool-result` | The tool's result, written in the one form of RFC 8785, as the arguments are: the whole value the tool gave back to the agent's software. Never the result itself |
|
|
2170
|
+
|
|
2171
|
+
An identifier, an error text or a tool's name MUST be one character or
|
|
2172
|
+
more, and well-formed Unicode. A result that cannot be written in the
|
|
2173
|
+
one form of RFC 8785 (a value that is not JSON, text that is not
|
|
2174
|
+
well-formed Unicode, a whole number that form cannot write exactly) has
|
|
2175
|
+
no fingerprint,
|
|
2176
|
+
and the stub then names no result; whoever writes the stub MAY say so
|
|
2177
|
+
under `provared.error`.
|
|
2178
|
+
|
|
2179
|
+
**The tool's name, the model's identifier and the result.** These three
|
|
2180
|
+
names were added after version 0.6.0 of the libraries. A stub's `action`
|
|
2181
|
+
is a name from the slip's own vocabulary; `provared.tool-name` binds the
|
|
2182
|
+
stub to the tool that was called under it, which is the name that the
|
|
2183
|
+
tool server's or the framework's own log shows. `provared.tool-call-id`
|
|
2184
|
+
binds the stub to the model's request for the call: the identifier that
|
|
2185
|
+
the model's provider wrote into its answer, and so into the record of
|
|
2186
|
+
the conversation that the provider or the agent's software keeps. It is
|
|
2187
|
+
the model's identifier, not the platform's (`provared.event-id`) and not
|
|
2188
|
+
one the agent's side chose and sent (`provared.request-id`): where the
|
|
2189
|
+
agent's software calls the tool twice for one request of the model (a
|
|
2190
|
+
retry), two stubs name one identifier. `provared.tool-result` binds the
|
|
2191
|
+
stub to what came back, as `provared.arguments` binds it to what was
|
|
2192
|
+
asked; an investigator who holds the result can work the fingerprint out
|
|
2193
|
+
again. Each of the three MUST appear at most once in a record.
|
|
2136
2194
|
|
|
2137
2195
|
**How a request or an answer is written.** As the signature base of RFC
|
|
2138
2196
|
9421 (HTTP Message Signatures), section 2.5, with no signature
|
|
@@ -2187,21 +2245,33 @@ with the body `{"hello": "world"}` is written as these four lines:
|
|
|
2187
2245
|
identifier shows that the two records speak of one call, not that the
|
|
2188
2246
|
call did what either says.
|
|
2189
2247
|
- Only `provared.trace-id`, `provared.request-id` and `provared.event-id`
|
|
2190
|
-
are meant to pick out one call
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2248
|
+
are meant to pick out one call; `provared.tool-call-id` picks out one
|
|
2249
|
+
request of the model, which may be carried out more than once.
|
|
2250
|
+
`provared.arguments`, `provared.http-request`,
|
|
2251
|
+
`provared.http-response`, `provared.error`, `provared.tool-name` and
|
|
2252
|
+
`provared.tool-result` show only that the content matches: identical
|
|
2253
|
+
requests, and identical answers (many a `200` with the body `{}`, many
|
|
2254
|
+
a result `"ok"`), share one fingerprint, and every call of one tool
|
|
2255
|
+
shares its name's fingerprint, so a match with one of them does not
|
|
2256
|
+
show that it was the same call.
|
|
2257
|
+
- A fingerprint of an identifier, of arguments, of a tool's name or of a
|
|
2258
|
+
result that is short or easy to guess can be guessed: it hides nothing
|
|
2259
|
+
from someone who can try the likely values. A tool's name is the most
|
|
2260
|
+
guessable (a reader tries the names of the framework's or the server's
|
|
2261
|
+
tools), and a short result (`"ok"`, `{}`, `true`) is little better; so
|
|
2262
|
+
"never the result itself" keeps the result out of the record, and does
|
|
2263
|
+
not hide it.
|
|
2198
2264
|
- A checker cannot see whether a fingerprint was made from the text this
|
|
2199
2265
|
section names: only someone who holds that text can work it out again.
|
|
2200
2266
|
- Records written before this section was added may use names that
|
|
2201
|
-
begin `provared.` that are not in the list, or name one of the
|
|
2267
|
+
begin `provared.` that are not in the list, or name one of the
|
|
2202
2268
|
once-only names twice; such records are refused, so a book that holds
|
|
2203
2269
|
one no longer passes its check. A checker that does not know this
|
|
2204
|
-
section reads a reserved name as a label.
|
|
2270
|
+
section reads a reserved name as a label. A checker that knows the
|
|
2271
|
+
first seven names only (versions 0.5.0 and 0.6.0 of the libraries)
|
|
2272
|
+
refuses a record that names `provared.tool-name`,
|
|
2273
|
+
`provared.tool-call-id` or `provared.tool-result`, as it refuses any
|
|
2274
|
+
name it does not know.
|
|
2205
2275
|
|
|
2206
2276
|
## 28. The denial
|
|
2207
2277
|
|
|
@@ -2233,7 +2303,7 @@ Content:
|
|
|
2233
2303
|
| `rule` | A rule of the software around the agent, outside the slip. |
|
|
2234
2304
|
|
|
2235
2305
|
The format's own list of finding codes, from which `codes` is drawn, is
|
|
2236
|
-
every code a checker or the check before acting gives: `not-json`, `bad-envelope`, `bad-base64url`, `too-large`, `unknown-type`, `bad-signatures-layout`, `payload-not-canonical`, `payload-type-mismatch`, `bad-field`, `bad-key`, `signature-invalid`, `passkey-wrong-type`, `passkey-challenge-mismatch`, `passkey-origin-mismatch`, `passkey-rpid-mismatch`, `passkey-user-not-present`, `passkey-user-not-verified`, `passkey-bad-data`, `issuer-not-expected`, `slip-missing`, `slip-unusable`, `chain-broken`, `time-went-backwards`, `duplicate-id`, `duplicate-slip`, `countersignature-invalid`, `countersignature-wrong-stub`, `countersignature-not-possible`, `line-not-canonical`, `unknown-entry`, `approval-mismatch`, `approval-reused`, `approval-invalid`, `approval-not-found`, `approval-dated-after-stub`, `terms-not-found`, `terms-unusable`, `terms-mismatch`, `pass-missing`, `pass-mismatch`, `pass-too-deep`, `cover-invalid`, `cancellation-invalid`, `cancellation-not-found`, `acknowledgement-mismatch`, `vouching-missing`, `seal-mismatch`, `seal-chain-broken`, `sealer-not-expected`, `book-cut-short`, `seal-not-in-book`, `witness-invalid`, `dated-after-stamp`, `stamp-bad-data`, `stamp-wrong-data`, `stamp-invalid`, `headers-invalid`, `record-not-sound`, `proof-invalid`, `root-mismatch`, `check-failed`, `action-not-allowed`, `party-not-allowed`, `outside-valid-time`, `amount-missing`, `over-limit`, `over-each-limit`, `over-count-limit`, `over-period-limit`, `countersignature-missing`, `approval-missing`, `prohibited`, `pass-not-allowed`, `pass-wider`, `after-cancellation`, `outside-terms`.
|
|
2306
|
+
every code a checker or the check before acting gives: `not-json`, `bad-envelope`, `bad-base64url`, `too-large`, `unknown-type`, `bad-signatures-layout`, `payload-not-canonical`, `payload-type-mismatch`, `bad-field`, `bad-key`, `signature-invalid`, `passkey-wrong-type`, `passkey-challenge-mismatch`, `passkey-origin-mismatch`, `passkey-rpid-mismatch`, `passkey-user-not-present`, `passkey-user-not-verified`, `passkey-bad-data`, `issuer-not-expected`, `issuer-origin-not-expected`, `slip-missing`, `slip-unusable`, `chain-broken`, `time-went-backwards`, `duplicate-id`, `duplicate-slip`, `countersignature-invalid`, `countersignature-wrong-stub`, `countersignature-not-possible`, `line-not-canonical`, `unknown-entry`, `approval-mismatch`, `approval-reused`, `approval-invalid`, `approval-not-found`, `approval-dated-after-stub`, `terms-not-found`, `terms-unusable`, `terms-mismatch`, `pass-missing`, `pass-mismatch`, `pass-too-deep`, `cover-invalid`, `cancellation-invalid`, `cancellation-not-found`, `acknowledgement-mismatch`, `vouching-missing`, `seal-mismatch`, `seal-chain-broken`, `sealer-not-expected`, `book-cut-short`, `seal-not-in-book`, `witness-invalid`, `dated-after-stamp`, `stamp-bad-data`, `stamp-wrong-data`, `stamp-invalid`, `headers-invalid`, `record-not-sound`, `proof-invalid`, `root-mismatch`, `check-failed`, `action-not-allowed`, `party-not-allowed`, `outside-valid-time`, `amount-missing`, `over-limit`, `over-each-limit`, `over-count-limit`, `over-period-limit`, `countersignature-missing`, `approval-missing`, `prohibited`, `pass-not-allowed`, `pass-wider`, `after-cancellation`, `outside-terms`.
|
|
2237
2307
|
A code not on this list MUST be refused (`bad-field`), as MUST `codes`
|
|
2238
2308
|
where `source` is not `check`, and its absence where it is.
|
|
2239
2309
|
|
package/src/book.js
CHANGED
|
@@ -23,7 +23,7 @@ import { MAX_WITNESSES, validateWitnessContent } from './witness.js';
|
|
|
23
23
|
import { requestOf, validateApprovalContent } from './approval.js';
|
|
24
24
|
import { compareWithSlip, newTally, settlePeriods } from './compare.js';
|
|
25
25
|
import { validateRefusalContent, validateTermsContent } from './service.js';
|
|
26
|
-
import { checkPasskeySignature, checkSlip } from './slip.js';
|
|
26
|
+
import { checkOriginsOption, checkPasskeySignature, checkSlip } from './slip.js';
|
|
27
27
|
import { validateCountersignatureContent, validateStubContent } from './stub.js';
|
|
28
28
|
import { checkStamp } from './timestamp.js';
|
|
29
29
|
import { inclusionPath, treeBuilder, treeRoot, verifyInclusion } from './tree.js';
|
|
@@ -456,7 +456,7 @@ function forkReader(reader, whole = false) {
|
|
|
456
456
|
// worked out when it is asked for.
|
|
457
457
|
// The fixed copies made so far. Nothing changes one once it is made.
|
|
458
458
|
const FIXED = new WeakSet();
|
|
459
|
-
const OPTION_NAMES = ['issuerKeys', 'sealKeys', 'stampServices', 'blocks', 'vouchers', 'witnesses', 'disclosures', 'cancellations', 'seals', 'expectedRoot', 'expectedSize', 'withoutMethods'];
|
|
459
|
+
const OPTION_NAMES = ['issuerKeys', 'issuerOrigins', 'sealKeys', 'stampServices', 'blocks', 'vouchers', 'witnesses', 'disclosures', 'cancellations', 'seals', 'expectedRoot', 'expectedSize', 'withoutMethods'];
|
|
460
460
|
|
|
461
461
|
/**
|
|
462
462
|
* The options of a check, fixed: read once and copied, so that nothing done
|
|
@@ -2359,6 +2359,8 @@ function compareTrusted(result, options) {
|
|
|
2359
2359
|
* @param {string} text the book
|
|
2360
2360
|
* @param {object} [options]
|
|
2361
2361
|
* @param {string[]} [options.issuerKeys] thumbprints of the issuer keys the checker expects
|
|
2362
|
+
* @param {string[]} [options.issuerOrigins] the addresses of the pages at which the checker expects every slip to have been signed,
|
|
2363
|
+
* each in the form of section 4.1 (https://, a website name in lower case, a port only if needed). At most 64
|
|
2362
2364
|
* @param {string} [options.expectedRoot] the top fingerprint the checker already trusts
|
|
2363
2365
|
* @param {number} [options.expectedSize] the number of entries the checker already trusts
|
|
2364
2366
|
* @param {string[]} [options.stampServices] the certificate fingerprints of the time-stamp services the checker trusts
|
|
@@ -2422,6 +2424,7 @@ export function startCarried(options = {}) {
|
|
|
2422
2424
|
try {
|
|
2423
2425
|
lookIntoOptions(options);
|
|
2424
2426
|
checkBlocksOption(options);
|
|
2427
|
+
checkOriginsOption(options);
|
|
2425
2428
|
// Every line is read with this option, so one that cannot be read is a
|
|
2426
2429
|
// problem with the whole check, found before any line is read.
|
|
2427
2430
|
Array.isArray(options.withoutMethods);
|
|
@@ -2644,6 +2647,7 @@ export async function checkShow(show, options = {}) {
|
|
|
2644
2647
|
}
|
|
2645
2648
|
lookIntoOptions(options);
|
|
2646
2649
|
checkBlocksOption(options);
|
|
2650
|
+
checkOriginsOption(options);
|
|
2647
2651
|
f.members(show, ['pages', 'root', 'size', 'type'], ['seal'], 'show');
|
|
2648
2652
|
if (show.type !== 'provared.show.v0') throw f.fail('show.type', 'must be provared.show.v0.');
|
|
2649
2653
|
f.wholeNumber(show.size, 'show.size');
|
package/src/check.d.ts
CHANGED
package/src/check.js
CHANGED
|
@@ -8,7 +8,7 @@ export { checkBook, checkShow, openChecker, LIMITS } from './book.js';
|
|
|
8
8
|
export { limitWords, conditionWords, neverWords } from './compare.js';
|
|
9
9
|
export { REFUSAL_REASONS } from './service.js';
|
|
10
10
|
export { DENIAL_SOURCES } from './denial.js';
|
|
11
|
-
export { checkSlip } from './slip.js';
|
|
11
|
+
export { checkSlip, isPageAddress } from './slip.js';
|
|
12
12
|
export { keySetFingerprint } from './seal.js';
|
|
13
13
|
export { blockFingerprint } from './blockstamp.js';
|
|
14
14
|
export { checkHeaderChain, blocksInChain, MIN_BLOCKS_AFTER } from './headers.js';
|
package/src/denial.js
CHANGED
|
@@ -26,7 +26,7 @@ export const FINDING_CODES = Object.freeze([
|
|
|
26
26
|
'not-json', 'bad-envelope', 'bad-base64url', 'too-large', 'unknown-type', 'bad-signatures-layout', 'payload-not-canonical',
|
|
27
27
|
'payload-type-mismatch', 'bad-field', 'bad-key', 'signature-invalid', 'passkey-wrong-type', 'passkey-challenge-mismatch',
|
|
28
28
|
'passkey-origin-mismatch', 'passkey-rpid-mismatch', 'passkey-user-not-present', 'passkey-user-not-verified', 'passkey-bad-data',
|
|
29
|
-
'issuer-not-expected', 'slip-missing', 'slip-unusable', 'chain-broken', 'time-went-backwards', 'duplicate-id', 'duplicate-slip',
|
|
29
|
+
'issuer-not-expected', 'issuer-origin-not-expected', 'slip-missing', 'slip-unusable', 'chain-broken', 'time-went-backwards', 'duplicate-id', 'duplicate-slip',
|
|
30
30
|
'countersignature-invalid', 'countersignature-wrong-stub', 'countersignature-not-possible', 'line-not-canonical', 'unknown-entry',
|
|
31
31
|
'approval-mismatch', 'approval-reused', 'approval-invalid', 'approval-not-found', 'approval-dated-after-stub', 'terms-not-found',
|
|
32
32
|
'terms-unusable', 'terms-mismatch', 'pass-missing', 'pass-mismatch', 'pass-too-deep', 'cover-invalid', 'cancellation-invalid',
|
package/src/der.js
CHANGED
|
@@ -130,6 +130,7 @@ export function timeOf(bytes, element) {
|
|
|
130
130
|
m = /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)Z$/.exec(text);
|
|
131
131
|
if (m) m[1] = (Number(m[1]) < 50 ? '20' : '19') + m[1];
|
|
132
132
|
} else if (element.tag === TAG.GENERALIZED_TIME) {
|
|
133
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- no repetition inside repetition can match the same text two ways (fixed-length digits and one bounded fraction), so matching takes time in proportion to the input.
|
|
133
134
|
m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(?:\.(\d{1,9}))?Z$/.exec(text);
|
|
134
135
|
// DER allows no trailing zero in the fraction.
|
|
135
136
|
if (m && m[7] && m[7].endsWith('0')) m = null;
|
package/src/documents.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// are reserved: each one on the list below says exactly which text its
|
|
7
7
|
// fingerprint is made from, so that an investigator who holds that text
|
|
8
8
|
// (the call's arguments, the HTTP request, the identifier a platform's own
|
|
9
|
-
// log shows for the call
|
|
10
|
-
// the stub to it, without either side's help.
|
|
9
|
+
// log shows for the call, the tool's name and result) can work the
|
|
10
|
+
// fingerprint out again and match the stub to it, without either side's help.
|
|
11
11
|
|
|
12
12
|
import { Refusal, fingerprint, sha256, toBase64url, utf8 } from './encoding.js';
|
|
13
13
|
|
|
@@ -23,6 +23,9 @@ export const DOCUMENT_NAMES = Object.freeze({
|
|
|
23
23
|
'provared.request-id': "An identifier that the agent's side chose and sent with the call, exactly as sent.",
|
|
24
24
|
'provared.event-id': 'An identifier that the platform gave for the call, exactly as it gave it.',
|
|
25
25
|
'provared.error': "The error text that the agent's software was given, exactly as given.",
|
|
26
|
+
'provared.tool-name': "The name of the tool, exactly as the agent's software called it.",
|
|
27
|
+
'provared.tool-call-id': "The identifier that the model gave the tool call, exactly as the agent's software received it.",
|
|
28
|
+
'provared.tool-result': "The tool's result, written in the one form of RFC 8785.",
|
|
26
29
|
});
|
|
27
30
|
|
|
28
31
|
/**
|
|
@@ -135,7 +138,9 @@ export async function httpResponseFingerprint(r) {
|
|
|
135
138
|
/**
|
|
136
139
|
* The fingerprint of an identifier or of an error text, exactly as given:
|
|
137
140
|
* SHA-256 of its UTF-8 bytes. For "provared.trace-id", "provared.request-id",
|
|
138
|
-
* "provared.event-id"
|
|
141
|
+
* "provared.event-id", "provared.error", "provared.tool-name" and
|
|
142
|
+
* "provared.tool-call-id". A tool's result ("provared.tool-result") is
|
|
143
|
+
* fingerprinted as the arguments are, with argumentsFingerprint.
|
|
139
144
|
* @param {string} id
|
|
140
145
|
* @returns {Promise<string>}
|
|
141
146
|
* @throws {Refusal} "bad-field" if it is not text, is empty, or is not well-formed Unicode
|
package/src/passkey-browser.d.ts
CHANGED
|
@@ -17,4 +17,6 @@ export function signSlipWithPasskey(fields: Omit<SlipFields, 'issuer'>, passkey:
|
|
|
17
17
|
export function approveWithPasskey(request: Request & { slip: Fingerprint; when?: number | Date }, passkey: StoredPasskey): Promise<SignedRecord>;
|
|
18
18
|
export function cancelWithPasskey(request: { slip: Fingerprint; when?: number | Date; id?: Base64url }, passkey: StoredPasskey): Promise<SignedRecord>;
|
|
19
19
|
/** Sign the challenge of anything prepared for a passkey; hand the result to the matching assemble function. */
|
|
20
|
-
export function signChallengeWithPasskey(challenge: Uint8Array, passkey: StoredPasskey): Promise<Assertion>;
|
|
20
|
+
export function signChallengeWithPasskey(challenge: Uint8Array, passkey: StoredPasskey): Promise<Assertion & { credentialId: string }>;
|
|
21
|
+
/** Sign a challenge with a passkey the person chooses from those the device holds for the website; check the answer against the key you expect before relying on it. */
|
|
22
|
+
export function signChallengeWithChosenPasskey(challenge: Uint8Array, rpId: string): Promise<Assertion & { credentialId: string }>;
|
package/src/passkey-browser.js
CHANGED
|
@@ -87,15 +87,24 @@ export async function createPasskey({ name, site }) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
// Ask the passkey to sign a challenge: the device asks the person first.
|
|
90
|
-
|
|
90
|
+
// Only where the caller asks for it ("choose"), and names no identifier (a
|
|
91
|
+
// passkey this browser has forgotten), the device offers every passkey it
|
|
92
|
+
// holds for the website and the person chooses; the answer then says which
|
|
93
|
+
// was chosen, and the caller must check it against the key it expects
|
|
94
|
+
// before it relies on it. Everywhere else a passkey with no identifier is
|
|
95
|
+
// refused, so that no caller is offered a choice it did not ask for.
|
|
96
|
+
async function signedBy(passkey, challenge, refused, { choose = false } = {}) {
|
|
91
97
|
const container = credentials();
|
|
98
|
+
if (!choose && (!passkey || typeof passkey.credentialId !== 'string' || !passkey.credentialId)) {
|
|
99
|
+
throw new PasskeyError('cancelled', 'No passkey was named, so nothing was signed.');
|
|
100
|
+
}
|
|
92
101
|
let assertion;
|
|
93
102
|
try {
|
|
94
103
|
assertion = await container.get({
|
|
95
104
|
publicKey: {
|
|
96
105
|
challenge,
|
|
97
106
|
rpId: passkey.rpId,
|
|
98
|
-
allowCredentials: [{ type: 'public-key', id: fromBase64url(passkey.credentialId) }],
|
|
107
|
+
...(!choose ? { allowCredentials: [{ type: 'public-key', id: fromBase64url(passkey.credentialId) }] } : {}),
|
|
99
108
|
userVerification: 'required',
|
|
100
109
|
},
|
|
101
110
|
});
|
|
@@ -107,6 +116,7 @@ async function signedBy(passkey, challenge, refused) {
|
|
|
107
116
|
authenticatorData: new Uint8Array(r.authenticatorData),
|
|
108
117
|
clientDataJSON: new Uint8Array(r.clientDataJSON),
|
|
109
118
|
signature: new Uint8Array(r.signature),
|
|
119
|
+
credentialId: toBase64url(new Uint8Array(assertion.rawId)),
|
|
110
120
|
};
|
|
111
121
|
}
|
|
112
122
|
|
|
@@ -169,3 +179,22 @@ export async function signChallengeWithPasskey(challenge, passkey) {
|
|
|
169
179
|
credentials();
|
|
170
180
|
return signedBy(passkey, challenge, 'Nothing was signed. The request was cancelled or the device refused it.');
|
|
171
181
|
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Sign a challenge with a passkey the person chooses, where this browser
|
|
185
|
+
* has forgotten which passkey it should ask (a passkey made on another
|
|
186
|
+
* device, or this browser's storage cleared). The device offers every
|
|
187
|
+
* passkey it holds for the website, and the person chooses one; nothing
|
|
188
|
+
* new is made. What comes back names the passkey chosen by its identifier,
|
|
189
|
+
* but not its public key: the caller must put the answer into the record
|
|
190
|
+
* and check it with the library's checker against the key it expects, and
|
|
191
|
+
* only then remember the identifier beside that key.
|
|
192
|
+
*
|
|
193
|
+
* @param {Uint8Array} challenge the "challenge" of what was prepared
|
|
194
|
+
* @param {string} rpId the website the passkey belongs to (this page's hostname)
|
|
195
|
+
* @returns {Promise<{authenticatorData: Uint8Array, clientDataJSON: Uint8Array, signature: Uint8Array, credentialId: string}>}
|
|
196
|
+
*/
|
|
197
|
+
export async function signChallengeWithChosenPasskey(challenge, rpId) {
|
|
198
|
+
credentials();
|
|
199
|
+
return signedBy({ rpId }, challenge, 'Nothing was signed. The request was cancelled, or this device holds no passkey for this website.', { choose: true });
|
|
200
|
+
}
|
package/src/signatures.js
CHANGED
|
@@ -181,7 +181,12 @@ const signerShape = (alg) =>
|
|
|
181
181
|
function writtenSlhKey(item, where) {
|
|
182
182
|
let made;
|
|
183
183
|
try {
|
|
184
|
-
|
|
184
|
+
// A Web Crypto CryptoKey of another method is refused here, without
|
|
185
|
+
// handing it to Node.js: createPrivateKey would turn it into a KeyObject
|
|
186
|
+
// of that method, refused below all the same, but handing it a CryptoKey
|
|
187
|
+
// is deprecated (DEP0203) and prints a warning.
|
|
188
|
+
if (typeof globalThis.CryptoKey === 'function' && item instanceof globalThis.CryptoKey && item.algorithm.name !== SLH) made = null;
|
|
189
|
+
else made = nodeCrypto.createPrivateKey(item);
|
|
185
190
|
} catch {
|
|
186
191
|
made = null; // not a private key Node.js can read, or no Node.js here
|
|
187
192
|
}
|
package/src/slip.js
CHANGED
|
@@ -22,21 +22,52 @@ const MAX_PERIOD_SECONDS = 366 * 86400;
|
|
|
22
22
|
// begins or ends with a hyphen, and the last is not all digits. A browser
|
|
23
23
|
// writes a page's address in exactly this form, with a name in another
|
|
24
24
|
// script written in its "xn--" form, which the rule takes as it is.
|
|
25
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- each part has a bounded length, and a full stop or colon separates the parts.
|
|
25
26
|
const ORIGIN = /^(https|http):\/\/([a-z0-9.-]{1,253})(?::([1-9][0-9]{0,4}))?$/;
|
|
27
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- a label has at most 63 characters.
|
|
26
28
|
const LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
27
29
|
|
|
30
|
+
// The scheme and the host of a text in the form above, or null where the
|
|
31
|
+
// text is not in that form.
|
|
32
|
+
function pageAddressParts(text) {
|
|
33
|
+
if (typeof text !== 'string') return null;
|
|
34
|
+
const m = ORIGIN.exec(text);
|
|
35
|
+
if (!m) return null;
|
|
36
|
+
const labels = m[2].split('.');
|
|
37
|
+
const port = m[3] !== undefined ? Number(m[3]) : null;
|
|
38
|
+
if (!labels.every((l) => LABEL.test(l)) || /^[0-9]+$/.test(labels[labels.length - 1]) || (port !== null && (port > 65535 || port === (m[1] === 'https' ? 443 : 80)))) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return { scheme: m[1], host: m[2] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether a text is the address of a page in the one form the format
|
|
46
|
+
* allows for a slip's "issuer.origin" (format description, section 4.1):
|
|
47
|
+
* "https://" (or "http://localhost"), a website name in lower case, a port
|
|
48
|
+
* only where it is needed, and nothing more. The addresses a reader names
|
|
49
|
+
* as the ones a permission must have been signed at (the option
|
|
50
|
+
* "issuerOrigins") must be in this form too, so that an address with a
|
|
51
|
+
* capital letter or a path is refused, and does not quietly match nothing.
|
|
52
|
+
* @param {unknown} text
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function isPageAddress(text) {
|
|
56
|
+
const parts = pageAddressParts(text);
|
|
57
|
+
return parts !== null && (parts.scheme === 'https' || (parts.scheme === 'http' && parts.host === 'localhost'));
|
|
58
|
+
}
|
|
59
|
+
|
|
28
60
|
function checkOrigin(origin, rpId) {
|
|
29
61
|
f.text(origin, 1, 300, 'issuer.origin');
|
|
62
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- a name has at most 253 characters.
|
|
30
63
|
if (typeof rpId !== 'string' || !/^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/.test(rpId)) {
|
|
31
64
|
throw f.fail('issuer.rpId', 'must be a website name in lower case, such as example.org.');
|
|
32
65
|
}
|
|
33
|
-
const
|
|
34
|
-
|
|
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)))) {
|
|
66
|
+
const parts = pageAddressParts(origin);
|
|
67
|
+
if (parts === null) {
|
|
37
68
|
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.');
|
|
38
69
|
}
|
|
39
|
-
const
|
|
70
|
+
const { scheme, host } = parts;
|
|
40
71
|
if (!(scheme === 'https' || (scheme === 'http' && host === 'localhost'))) {
|
|
41
72
|
throw f.fail('issuer.origin', 'must begin https://, or http://localhost.');
|
|
42
73
|
}
|
|
@@ -48,6 +79,29 @@ function checkOrigin(origin, rpId) {
|
|
|
48
79
|
/** Not part of the public interface: for the shared test files. */
|
|
49
80
|
export const checkOriginForTests = checkOrigin;
|
|
50
81
|
|
|
82
|
+
/** The most addresses a reader may name as the ones a permission must have been signed at. */
|
|
83
|
+
export const MAX_ISSUER_ORIGINS = 64;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The addresses a reader names as the ones a permission must have been
|
|
87
|
+
* signed at (the option "issuerOrigins"; format description, section 4.4):
|
|
88
|
+
* a list of at most 64, each in the form isPageAddress accepts. Anything
|
|
89
|
+
* else is refused, never set aside: an address with a capital letter or a
|
|
90
|
+
* path would match nothing, and every permission would then read as signed
|
|
91
|
+
* elsewhere. Every place in the list is read by its number, a gap included.
|
|
92
|
+
* @param {object} options the checker's options
|
|
93
|
+
* @throws {Refusal} "bad-field"
|
|
94
|
+
*/
|
|
95
|
+
export function checkOriginsOption(options) {
|
|
96
|
+
const { issuerOrigins } = options;
|
|
97
|
+
if (issuerOrigins === undefined) return;
|
|
98
|
+
let sound = Array.isArray(issuerOrigins) && issuerOrigins.length <= MAX_ISSUER_ORIGINS;
|
|
99
|
+
for (let i = 0; sound && i < issuerOrigins.length; i++) sound = isPageAddress(issuerOrigins[i]);
|
|
100
|
+
if (!sound) {
|
|
101
|
+
throw f.fail('issuerOrigins', 'must be a list of at most 64 page addresses, each https://, a website name in lower case, a port only if it is needed, and nothing more, such as https://sign.example.org.');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
51
105
|
/**
|
|
52
106
|
* Confirm a list of limits against the actions it may name (format
|
|
53
107
|
* description, section 4.1). A slip and a pass share it.
|
|
@@ -279,6 +333,7 @@ export async function preparePasskeyRecord(kind, content) {
|
|
|
279
333
|
* @param {any} record the slip as a JavaScript object
|
|
280
334
|
* @param {object} [options]
|
|
281
335
|
* @param {string[]} [options.issuerKeys] thumbprints of the issuer keys the checker expects
|
|
336
|
+
* @param {string[]} [options.issuerOrigins] the addresses of the pages at which the checker expects the slip to have been signed, each in the form of section 4.1 (see isPageAddress)
|
|
282
337
|
* @param {string[]} [options.withoutMethods] see verifySignature
|
|
283
338
|
* @param {Record<string, string[]>} [options.disclosures] for covered fields: the disclosures handed over, by the fingerprint of the record they belong to
|
|
284
339
|
* @param {string[]} [disclosures] the disclosures for this slip, where they come with it (a page of a Show)
|
|
@@ -349,6 +404,18 @@ export async function checkSlip(record, options = {}, disclosures) {
|
|
|
349
404
|
} else {
|
|
350
405
|
result.notes.push('The issuer\'s key was not compared with a key you already trust. The name on the slip is only a label.');
|
|
351
406
|
}
|
|
407
|
+
|
|
408
|
+
// The address the slip was signed at, compared with the ones the reader
|
|
409
|
+
// named, where they named any (section 4.4). An approval and a
|
|
410
|
+
// cancellation are signed with the same passkey, and the passkey check
|
|
411
|
+
// holds each to the slip's address, so the slip's is the one compared.
|
|
412
|
+
if (options.issuerOrigins !== undefined) {
|
|
413
|
+
checkOriginsOption(options);
|
|
414
|
+
let named = false;
|
|
415
|
+
for (let i = 0; i < options.issuerOrigins.length; i++) if (options.issuerOrigins[i] === issuer.origin) named = true;
|
|
416
|
+
if (!named) throw new Refusal('issuer-origin-not-expected', 'The slip was signed at a page address other than the ones expected.');
|
|
417
|
+
result.notes.push(`This slip was signed at ${issuer.origin}, an address you named.`);
|
|
418
|
+
}
|
|
352
419
|
} catch (e) {
|
|
353
420
|
result.problems.push(problemFrom(e));
|
|
354
421
|
}
|
package/src/types.d.ts
CHANGED
|
@@ -420,6 +420,13 @@ export interface CheckResult {
|
|
|
420
420
|
export interface CheckOptions {
|
|
421
421
|
/** The thumbprints of the issuer keys the checker expects. */
|
|
422
422
|
issuerKeys?: string[];
|
|
423
|
+
/**
|
|
424
|
+
* The addresses of the pages at which the checker expects every slip to
|
|
425
|
+
* have been signed, each in the form of the format description's section
|
|
426
|
+
* 4.1 (https://, a website name in lower case, a port only if needed). At
|
|
427
|
+
* most 64. A slip signed at any other address is refused.
|
|
428
|
+
*/
|
|
429
|
+
issuerOrigins?: string[];
|
|
423
430
|
/** The key set fingerprints of the recorders the checker expects. */
|
|
424
431
|
sealKeys?: Fingerprint[];
|
|
425
432
|
/** The certificate fingerprints of the time-stamp services the checker trusts. */
|
|
@@ -481,7 +488,13 @@ export interface Checker {
|
|
|
481
488
|
/** The options are fixed when the checker is opened. The book so far may be empty. */
|
|
482
489
|
export function openChecker(text?: string, options?: CheckOptions): Promise<Checker>;
|
|
483
490
|
export function checkShow(show: Show | string, options?: CheckOptions): Promise<CheckResult>;
|
|
484
|
-
export function checkSlip(record: SignedRecord, options?: Pick<CheckOptions, 'issuerKeys' | 'withoutMethods' | 'disclosures'>, disclosures?: string[]): Promise<SlipCheck>;
|
|
491
|
+
export function checkSlip(record: SignedRecord, options?: Pick<CheckOptions, 'issuerKeys' | 'issuerOrigins' | 'withoutMethods' | 'disclosures'>, disclosures?: string[]): Promise<SlipCheck>;
|
|
492
|
+
/**
|
|
493
|
+
* Whether a text is the address of a page in the one form the format allows for a slip's "issuer.origin" (format
|
|
494
|
+
* description, section 4.1), which is also the form each address in "issuerOrigins" must have: https:// (or
|
|
495
|
+
* http://localhost), a website name in lower case, a port only where it is needed, and nothing more.
|
|
496
|
+
*/
|
|
497
|
+
export function isPageAddress(text: unknown): boolean;
|
|
485
498
|
export function methodAvailable(alg: string): Promise<boolean>;
|
|
486
499
|
export function thumbprint(key: PublicKey): Promise<string>;
|
|
487
500
|
export function keySetFingerprint(keys: KeySet | SealKeySet): Promise<Fingerprint>;
|
|
@@ -915,7 +928,7 @@ export const DOCUMENT_NAMES: Readonly<Record<string, string>>;
|
|
|
915
928
|
export function httpRequestFingerprint(r: { method: string; url: string; body?: Uint8Array }): Promise<Fingerprint>;
|
|
916
929
|
/** For "provared.http-response": the status code, and the body's bytes where there is a body. */
|
|
917
930
|
export function httpResponseFingerprint(r: { status: number; body?: Uint8Array }): Promise<Fingerprint>;
|
|
918
|
-
/** For "provared.trace-id", "provared.request-id", "provared.event-id" and "provared.
|
|
931
|
+
/** For "provared.trace-id", "provared.request-id", "provared.event-id", "provared.error", "provared.tool-name" and "provared.tool-call-id": SHA-256 of the text's UTF-8 bytes, exactly as given. A tool's result ("provared.tool-result") is fingerprinted with argumentsFingerprint. */
|
|
919
932
|
export function identifierFingerprint(id: string): Promise<Fingerprint>;
|
|
920
933
|
export const ACTION_KINDS: Record<string, string>;
|
|
921
934
|
export const SHARED_ACTIONS: Record<string, [kind: string, meaning: string]>;
|