provared 0.6.0 → 0.7.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.
@@ -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); those show that the content matches, not
2110
- that it was the same call.
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
- An identifier or an error text MUST be one character or more, and
2135
- well-formed Unicode.
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. `provared.arguments`,
2191
- `provared.http-request`, `provared.http-response` and `provared.error`
2192
- show only that the content matches: identical requests, and identical
2193
- answers (many a `200` with the body `{}`), share one fingerprint, so a
2194
- match with one of them does not show that it was the same call.
2195
- - A fingerprint of an identifier, or of arguments, that is short or easy
2196
- to guess can be guessed: it hides nothing from someone who can try
2197
- the likely values.
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 four
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
@@ -5,6 +5,7 @@ export {
5
5
  checkShow,
6
6
  openChecker,
7
7
  checkSlip,
8
+ isPageAddress,
8
9
  methodAvailable,
9
10
  thumbprint,
10
11
  keySetFingerprint,
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) can work the fingerprint out again and match
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" and "provared.error".
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/signatures.js CHANGED
@@ -181,7 +181,12 @@ const signerShape = (alg) =>
181
181
  function writtenSlhKey(item, where) {
182
182
  let made;
183
183
  try {
184
- made = nodeCrypto.createPrivateKey(item);
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 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)))) {
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 [, scheme, host] = m;
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.error": SHA-256 of the text's UTF-8 bytes, exactly as given. */
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]>;