@bkazemi/verily 0.4.0 → 0.5.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.
@@ -1,4 +1,5 @@
1
1
  import { type Attestations, type Connection, type Evidence, type ExternalAccount, type Flow, type LocalAccount, type Mark, type Provider, type SignedDocument, type SignedEvidence, type Storage, type VerifierKey, type Visibility } from '../core/index.js';
2
+ import { type DnsOptions, type Published } from './domain.js';
2
3
  export declare const secret: () => string;
3
4
  export declare const hash: (value: string) => string;
4
5
  export declare class Unavailable extends Error {
@@ -45,6 +46,17 @@ export interface ServiceOptions {
45
46
  signingKey?: string | (() => Promise<string>);
46
47
  /** Public keys this instance signed with before, so what they signed still checks. */
47
48
  retiredKeys?: VerifierKey[];
49
+ /**
50
+ * Reads what DNS says of this verifier, through the one resolver `dnsProvider()` asks.
51
+ * Two statements are looked for, both TXT records at `_verily.<domain>`. On this
52
+ * verifier's own domain, `verily-key=<fingerprint>` names a signing key, and the pages
53
+ * that name the key then link to that lookup. On the domain of a subject's `profileUrl`,
54
+ * `verily-verifier=<this verifier's host>` names this verifier as the site's own, and
55
+ * the record's local side then carries that lookup as its proof. That one is read when a
56
+ * link is approved and again by `recheck()`, and is not looked for where this verifier
57
+ * is on the site's domain or a subdomain of it. Unset reads nothing.
58
+ */
59
+ dns?: boolean | DnsOptions;
48
60
  now?: () => number;
49
61
  }
50
62
  export declare class VerilyService {
@@ -58,6 +70,42 @@ export declare class VerilyService {
58
70
  private signer;
59
71
  /** Every key a record of this instance may be signed by: the current one first. */
60
72
  keys(): Promise<VerifierKey[]>;
73
+ /**
74
+ * What this verifier's domain publishes to name its keys: one TXT record for each, all
75
+ * under the one name. Nothing where it signs nothing, or is not on a domain at all.
76
+ */
77
+ keyRecords(): Promise<{
78
+ name: string;
79
+ values: string[];
80
+ } | undefined>;
81
+ /**
82
+ * Where a reader can see this verifier's domain name a key, if it was read doing so, and
83
+ * which domain that was. It is always the domain this instance is on now: a record it
84
+ * signed while on another names that one, and nothing here was asked about it. The
85
+ * answer is kept for ten minutes, so a page that asks on every view costs one lookup.
86
+ */
87
+ keyNamed(id: string): Promise<(Pick<Published, 'lookup' | 'dnssec'> & {
88
+ domain: string;
89
+ }) | undefined>;
90
+ /** What each domain was last read publishing, and when. Nothing found is a failed read. */
91
+ private readonly lookups;
92
+ /**
93
+ * What a domain publishes for Verily, read again once the last read is older than
94
+ * `maxAgeMs`. Nothing where it could not be read, which is never the same as nothing
95
+ * published.
96
+ */
97
+ private lookedUp;
98
+ /**
99
+ * The domain whose DNS can say a subject's site chose this verifier, or nothing where
100
+ * there is nothing to ask: no address, or a site this verifier shares a domain with,
101
+ * since whoever holds a domain already holds every name beneath it.
102
+ */
103
+ private siteDomain;
104
+ /**
105
+ * The local side as a site's DNS bears it out: the site's word, with the lookup that
106
+ * found the site naming this verifier. Nothing where it does not, or could not be read.
107
+ */
108
+ private stated;
61
109
  /** The same keys as one armored text, which is what `gpg --import` takes. */
62
110
  armoredKeys(): Promise<string>;
63
111
  /**
@@ -275,7 +323,10 @@ export declare class VerilyService {
275
323
  * subject, and holds whatever the subject's address becomes.
276
324
  */
277
325
  private names;
278
- /** The site is the only authority on its own namespace, so it declares the local subject. */
326
+ /**
327
+ * The site is the only authority on its own namespace, so it declares the local subject.
328
+ * Where the flow read the site's DNS naming this verifier, that goes with it.
329
+ */
279
330
  private declared;
280
331
  /** How the provider established the external account, by whatever method the flow ran. */
281
332
  private attestation;
@@ -295,6 +346,12 @@ export declare class VerilyService {
295
346
  * never included: they must not appear in any directory listing.
296
347
  */
297
348
  published(): Promise<Evidence[]>;
349
+ /**
350
+ * Every record this instance holds, public and unlisted, for whoever runs it. No route
351
+ * of the library serves this: a deployment that shows it must first know the reader is
352
+ * its operator, who can read the storage it comes from anyway. Earliest connected first.
353
+ */
354
+ all(): Promise<Evidence[]>;
298
355
  mine(local: LocalAccount): Promise<Evidence[]>;
299
356
  private owned;
300
357
  revoke(id: string, local: LocalAccount): Promise<void>;
@@ -372,6 +429,20 @@ export declare class VerilyService {
372
429
  * Returns how many were confirmed. A connection revoked here is not one of them.
373
430
  */
374
431
  recheck(budget?: number): Promise<number>;
432
+ /**
433
+ * Asks each site's DNS again whether it names this verifier, and writes the answer to
434
+ * every record of that site. One lookup answers for all of a site's records, and
435
+ * `budget` bounds the sites asked in a run, longest unread first. A site that could not
436
+ * be read is left as it was: its records lose the proof only once it has gone unread
437
+ * past `freshnessMs`, or once an answer comes back without the statement.
438
+ *
439
+ * When each site was last asked is kept in storage, as a record of its own that `prune()`
440
+ * removes only with the site's last record. It is apart from the lookups a flow makes
441
+ * and from anything that expires: an instance restarted or pruned between runs must not
442
+ * start again from the same few sites, and a site whose holders connect often must
443
+ * still have its older records rewritten.
444
+ */
445
+ private restate;
375
446
  /**
376
447
  * The configured method that can ask again about one attestation, or nothing when there
377
448
  * is nothing to ask: no published proof, a method this instance no longer runs, or a
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { fresh, freshnessMs, isArtifactProvider, isCodeProvider, isRedirectProvider, providerMethod, Refused, status, verifySigned, } from '../core/index.js';
3
+ import { domainOf, keyRecord, namedKeys, namedVerifiers, published, } from './domain.js';
3
4
  import { signer } from './signing.js';
4
5
  export const secret = () => randomBytes(32).toString('base64url');
5
6
  export const hash = (value) => createHash('sha256').update(value).digest('base64url');
@@ -64,6 +65,78 @@ export class VerilyService {
64
65
  const current = await this.signer();
65
66
  return [...(current ? [current.key] : []), ...(this.options.retiredKeys ?? [])];
66
67
  }
68
+ /**
69
+ * What this verifier's domain publishes to name its keys: one TXT record for each, all
70
+ * under the one name. Nothing where it signs nothing, or is not on a domain at all.
71
+ */
72
+ async keyRecords() {
73
+ const domain = domainOf(this.baseUrl);
74
+ const keys = await this.keys();
75
+ return domain && keys.length
76
+ ? { name: `_verily.${domain}`, values: keys.map((key) => keyRecord(key.id)) }
77
+ : undefined;
78
+ }
79
+ /**
80
+ * Where a reader can see this verifier's domain name a key, if it was read doing so, and
81
+ * which domain that was. It is always the domain this instance is on now: a record it
82
+ * signed while on another names that one, and nothing here was asked about it. The
83
+ * answer is kept for ten minutes, so a page that asks on every view costs one lookup.
84
+ */
85
+ async keyNamed(id) {
86
+ const domain = domainOf(this.baseUrl);
87
+ const found = domain && this.options.dns ? await this.lookedUp(domain, 600000) : undefined;
88
+ // Read as `dnsKeys()` reads it, so a fingerprint published in lower case counts here too.
89
+ return found && namedKeys(found.values).includes(id.toUpperCase())
90
+ ? { domain: domain, lookup: found.lookup, dnssec: found.dnssec }
91
+ : undefined;
92
+ }
93
+ /** What each domain was last read publishing, and when. Nothing found is a failed read. */
94
+ lookups = new Map();
95
+ /**
96
+ * What a domain publishes for Verily, read again once the last read is older than
97
+ * `maxAgeMs`. Nothing where it could not be read, which is never the same as nothing
98
+ * published.
99
+ */
100
+ async lookedUp(domain, maxAgeMs) {
101
+ const held = this.lookups.get(domain);
102
+ if (held && this.now() - held.at < maxAgeMs)
103
+ return held.found;
104
+ const found = await this.deadline(published(domain, this.options.dns === true ? {} : this.options.dns || {})).catch(() => undefined);
105
+ this.lookups.set(domain, { at: this.now(), found });
106
+ return found;
107
+ }
108
+ /**
109
+ * The domain whose DNS can say a subject's site chose this verifier, or nothing where
110
+ * there is nothing to ask: no address, or a site this verifier shares a domain with,
111
+ * since whoever holds a domain already holds every name beneath it.
112
+ */
113
+ siteDomain(local) {
114
+ const site = this.options.dns && local.profileUrl ? domainOf(local.profileUrl) : undefined;
115
+ const own = new URL(this.baseUrl).hostname.toLowerCase();
116
+ return site && site !== own && !own.endsWith(`.${site}`) && !site.endsWith(`.${own}`)
117
+ ? site
118
+ : undefined;
119
+ }
120
+ /**
121
+ * The local side as a site's DNS bears it out: the site's word, with the lookup that
122
+ * found the site naming this verifier. Nothing where it does not, or could not be read.
123
+ */
124
+ stated(found) {
125
+ const own = new URL(this.baseUrl).host.toLowerCase();
126
+ // Read as `dnsVerifiers()` reads it: a host is the same host in any case. What is kept
127
+ // is the record as published, since that is what a reader finds at the lookup.
128
+ const named = found && namedVerifiers(found.values).find(({ host }) => host === own);
129
+ return found && named
130
+ ? {
131
+ by: 'backend',
132
+ method: 'declared',
133
+ confirmedAt: this.now(),
134
+ artifactUrl: found.lookup,
135
+ expect: named.record,
136
+ ...(found.dnssec ? { dnssec: true } : {}),
137
+ }
138
+ : undefined;
139
+ }
67
140
  /** The same keys as one armored text, which is what `gpg --import` takes. */
68
141
  async armoredKeys() {
69
142
  return (await this.keys()).map((key) => key.publicKey.trim()).join('\n\n') + '\n';
@@ -208,10 +281,28 @@ export class VerilyService {
208
281
  // freedom: the subject's address is the whole claim, so that method states it here
209
282
  // and gives up per-flow uniqueness for a standing link that is read again on a
210
283
  // schedule instead.
211
- if (artifact)
212
- flow.expect = artifact.expect
213
- ? artifact.expect(subject)
214
- : `Verily proof for ${this.siteOf(subject)}: ${secret()}`;
284
+ //
285
+ // Where such a method has nothing of the subject's to state, it publishes a minted
286
+ // string like any other, and then keeps the one it has: a flow the local holder runs
287
+ // on their own record asks for the string already standing, as it would ask for the
288
+ // same address, and not for a new record in DNS every time the link is renewed.
289
+ //
290
+ // Only a minted string is kept this way. A record that published an address the
291
+ // subject no longer has is a proof of that address, and is not asked for again.
292
+ if (artifact) {
293
+ const stated = artifact.expect?.(subject);
294
+ const standing = artifact.expect && connection && ['renew', 'visibility'].includes(kind)
295
+ ? connection.attestations?.external.find((a) => a.method === artifact.method && a.minted)?.expect
296
+ : undefined;
297
+ flow.expect =
298
+ stated ??
299
+ standing ??
300
+ (artifact.mint
301
+ ? artifact.mint(secret())
302
+ : `Verily proof for ${this.siteOf(subject)}: ${secret()}`);
303
+ if (artifact.expect && stated === undefined)
304
+ flow.minted = true;
305
+ }
215
306
  // These kinds are all started by the local holder, whose own record this is. Removal
216
307
  // from the external side is started by anyone, and is told nothing.
217
308
  if (artifact?.known && ['connect', 'renew', 'visibility'].includes(kind)) {
@@ -277,13 +368,13 @@ export class VerilyService {
277
368
  async offered(id, binding, provider, expect, suggested) {
278
369
  try {
279
370
  const handed = provider.resolve?.(suggested) ?? suggested;
280
- const external = await this.deadline(provider.verify({ artifact: handed, expect }));
371
+ const found = await this.deadline(read(provider, { artifact: handed, expect }));
281
372
  await this.transaction(async (tx) => {
282
373
  const flow = await this.bound(tx, id, binding);
283
374
  flow.phase = 'exchanging';
284
375
  await tx.put('flows', id, flow);
285
376
  });
286
- await this.established(id, binding, external, handed);
377
+ await this.established(id, binding, found.account, handed, found.dnssec);
287
378
  }
288
379
  catch {
289
380
  await this.transaction(async (tx) => {
@@ -352,8 +443,8 @@ export class VerilyService {
352
443
  // Kept as the address that was read, never as the shorthand that named it: it is
353
444
  // where a reader is sent, and what is read again later.
354
445
  const handed = provider.resolve?.(artifact) ?? artifact;
355
- const external = await provider.verify({ artifact: handed, expect });
356
- await this.established(id, binding, external, handed);
446
+ const found = await read(provider, { artifact: handed, expect });
447
+ await this.established(id, binding, found.account, handed, found.dnssec);
357
448
  }
358
449
  catch (error) {
359
450
  await this.refused(id, error, artifact);
@@ -587,7 +678,7 @@ export class VerilyService {
587
678
  * existing connection must still be the same account on the same provider. No binding
588
679
  * means a confirmation link got here, which is held to the link and not to a browser.
589
680
  */
590
- async established(id, binding, external, artifact) {
681
+ async established(id, binding, external, artifact, dnssec = false) {
591
682
  if (typeof external.id !== 'string' ||
592
683
  !external.id ||
593
684
  typeof external.handle !== 'string' ||
@@ -619,12 +710,19 @@ export class VerilyService {
619
710
  // without the holder choosing that method or handing anything back. Read while the
620
711
  // flow is still exchanging: approval is shown once, and must show all it will record.
621
712
  const shown = await this.transaction(accepted);
622
- const standing = ['connect', 'renew'].includes(shown.kind) ? await this.standing(shown) : [];
713
+ const recorded = ['connect', 'renew'].includes(shown.kind);
714
+ const standing = recorded ? await this.standing(shown) : [];
715
+ // The site's own DNS is read here for the same reason: outside a transaction, and
716
+ // before the approval that records what it said. A read a few minutes old will do.
717
+ const site = recorded ? this.siteDomain(shown.local) : undefined;
718
+ const stated = site ? this.stated(await this.lookedUp(site, 300000)) : undefined;
623
719
  await this.transaction(async (tx) => {
624
720
  const flow = await accepted(tx);
625
721
  flow.artifact = artifact;
626
722
  flow.reason = undefined;
627
723
  flow.authenticatedAt = this.now();
724
+ flow.dnssec = dnssec || undefined;
725
+ flow.stated = stated;
628
726
  if (standing.length)
629
727
  flow.standing = standing;
630
728
  flow.phase = 'approval';
@@ -656,10 +754,11 @@ export class VerilyService {
656
754
  try {
657
755
  const expect = provider.expect(flow.local);
658
756
  const known = provider.known(flow.external);
659
- if (!known)
757
+ // A minted string is found nowhere the holder was not asked to put it.
758
+ if (!known || expect === undefined)
660
759
  continue;
661
760
  const handed = provider.resolve?.(known) ?? known;
662
- const external = await this.deadline(provider.verify({ artifact: handed, expect }));
761
+ const { account: external, dnssec } = await this.deadline(read(provider, { artifact: handed, expect }));
663
762
  if (!sameAccount(flow.external, external))
664
763
  continue;
665
764
  found.push({
@@ -668,6 +767,7 @@ export class VerilyService {
668
767
  confirmedAt: this.now(),
669
768
  artifactUrl: handed,
670
769
  expect,
770
+ ...(dnssec ? { dnssec } : {}),
671
771
  });
672
772
  }
673
773
  catch {
@@ -780,7 +880,7 @@ export class VerilyService {
780
880
  connectedAt: this.now(),
781
881
  expiresAt: this.now() + (this.options.validityMs ?? 30 * 86400000),
782
882
  attestations: {
783
- local: this.declared(),
883
+ local: this.declared(flow),
784
884
  external: [this.attestation(flow, provider, connectionId)],
785
885
  },
786
886
  proof: hosted(provider) ? flow.artifact : undefined,
@@ -876,8 +976,12 @@ export class VerilyService {
876
976
  const [main, ...rest] = connection.attestations.external;
877
977
  // A retired record is as it stood when it was retired, and nothing is read after that.
878
978
  const at = connection.retiredAt ?? this.now();
979
+ const { local } = connection.attestations;
879
980
  return {
880
- ...connection.attestations,
981
+ // A site's DNS that has gone unread no longer bears its word out, and the word stands.
982
+ local: fresh(local, at, this.freshness)
983
+ ? local
984
+ : { by: local.by, method: local.method, confirmedAt: local.confirmedAt },
881
985
  external: [main, ...rest.filter((a) => fresh(a, at, this.freshness))],
882
986
  };
883
987
  }
@@ -967,7 +1071,7 @@ export class VerilyService {
967
1071
  // about the new proof and compares ids exactly, and an address read with different
968
1072
  // case names the same profile under a different id.
969
1073
  connection.external = flow.external;
970
- connection.attestations = { local: this.declared(), external: [attestation, ...rest] };
1074
+ connection.attestations = { local: this.declared(flow), external: [attestation, ...rest] };
971
1075
  }
972
1076
  else {
973
1077
  // A proof that named the subject's old address proves nothing about its new one,
@@ -979,7 +1083,7 @@ export class VerilyService {
979
1083
  others.push(attestation);
980
1084
  else
981
1085
  others[at] = attestation;
982
- connection.attestations = { local: this.declared(), external: [main, ...others] };
1086
+ connection.attestations = { local: this.declared(flow), external: [main, ...others] };
983
1087
  }
984
1088
  if (hosted(provider))
985
1089
  connection.proof = flow.artifact;
@@ -994,8 +1098,10 @@ export class VerilyService {
994
1098
  const provider = this.providers.find((p) => p.id === connection.provider && providerMethod(p) === attestation.method);
995
1099
  if (attestation.expect === undefined || !provider || !isArtifactProvider(provider))
996
1100
  return true;
997
- if (!provider.expect)
1101
+ if (!provider.expect || attestation.minted)
998
1102
  return true;
1103
+ // A subject with nothing to state gives nothing back, which no published address
1104
+ // equals: a proof of the address it used to have does not name it now.
999
1105
  try {
1000
1106
  return provider.expect(local) === attestation.expect;
1001
1107
  }
@@ -1003,9 +1109,12 @@ export class VerilyService {
1003
1109
  return false;
1004
1110
  }
1005
1111
  }
1006
- /** The site is the only authority on its own namespace, so it declares the local subject. */
1007
- declared() {
1008
- return { by: 'backend', method: 'declared', confirmedAt: this.now() };
1112
+ /**
1113
+ * The site is the only authority on its own namespace, so it declares the local subject.
1114
+ * Where the flow read the site's DNS naming this verifier, that goes with it.
1115
+ */
1116
+ declared(flow) {
1117
+ return flow.stated ?? { by: 'backend', method: 'declared', confirmedAt: this.now() };
1009
1118
  }
1010
1119
  /** How the provider established the external account, by whatever method the flow ran. */
1011
1120
  attestation(flow, provider, connectionId) {
@@ -1024,6 +1133,10 @@ export class VerilyService {
1024
1133
  ? `${this.baseUrl}/connections/${connectionId}/proof`
1025
1134
  : flow.artifact;
1026
1135
  external.expect = flow.expect;
1136
+ if (flow.minted)
1137
+ external.minted = true;
1138
+ if (flow.dnssec)
1139
+ external.dnssec = true;
1027
1140
  if (hosted)
1028
1141
  external.hosted = true;
1029
1142
  }
@@ -1067,6 +1180,16 @@ export class VerilyService {
1067
1180
  .map((c) => this.evidence(c))
1068
1181
  .filter((e) => e.status === 'verified'));
1069
1182
  }
1183
+ /**
1184
+ * Every record this instance holds, public and unlisted, for whoever runs it. No route
1185
+ * of the library serves this: a deployment that shows it must first know the reader is
1186
+ * its operator, who can read the storage it comes from anyway. Earliest connected first.
1187
+ */
1188
+ async all() {
1189
+ return this.transaction(async (tx) => (await tx.list('connections'))
1190
+ .map((c) => this.evidence(c))
1191
+ .sort((a, b) => a.connectedAt - b.connectedAt));
1192
+ }
1070
1193
  async mine(local) {
1071
1194
  return this.transaction(async (tx) => (await tx.list('connections'))
1072
1195
  .filter((c) => c.local.id === local.id)
@@ -1253,8 +1376,72 @@ export class VerilyService {
1253
1376
  // open across a fetch would stall every other request behind the slowest provider.
1254
1377
  for (const item of due)
1255
1378
  confirmed += await this.reread(item);
1379
+ await this.restate(budget);
1256
1380
  return confirmed;
1257
1381
  }
1382
+ /**
1383
+ * Asks each site's DNS again whether it names this verifier, and writes the answer to
1384
+ * every record of that site. One lookup answers for all of a site's records, and
1385
+ * `budget` bounds the sites asked in a run, longest unread first. A site that could not
1386
+ * be read is left as it was: its records lose the proof only once it has gone unread
1387
+ * past `freshnessMs`, or once an answer comes back without the statement.
1388
+ *
1389
+ * When each site was last asked is kept in storage, as a record of its own that `prune()`
1390
+ * removes only with the site's last record. It is apart from the lookups a flow makes
1391
+ * and from anything that expires: an instance restarted or pruned between runs must not
1392
+ * start again from the same few sites, and a site whose holders connect often must
1393
+ * still have its older records rewritten.
1394
+ */
1395
+ async restate(budget) {
1396
+ if (!this.options.dns)
1397
+ return;
1398
+ const interval = this.options.recheckMs ?? 86400000;
1399
+ const now = this.now();
1400
+ const live = (c) => !!c?.attestations &&
1401
+ c.revokedAt === undefined &&
1402
+ c.retiredAt === undefined &&
1403
+ c.expiresAt > this.now();
1404
+ const due = await this.transaction(async (tx) => {
1405
+ const sites = new Set((await tx.list('connections'))
1406
+ .filter(live)
1407
+ .map((c) => this.siteDomain(c.local))
1408
+ .filter((site) => site !== undefined));
1409
+ const reads = new Map((await tx.list('sites')).map((read) => [read.id, read]));
1410
+ // A site never asked about goes first, then the rest by how long ago they were. A
1411
+ // site that gave no answer is due again sooner, and still waits behind every site
1412
+ // asked before it, so a zone that never answers cannot hold up the others.
1413
+ const at = (site) => reads.get(site)?.readAt ?? -Infinity;
1414
+ const wait = (site) => reads.get(site)?.answered === false ? Math.ceil(interval / 4) : interval;
1415
+ return [...sites]
1416
+ .filter((site) => at(site) + wait(site) <= now)
1417
+ .sort((a, b) => at(a) - at(b))
1418
+ .slice(0, budget);
1419
+ });
1420
+ for (const site of due) {
1421
+ // Always asked afresh: a read some flow made says nothing of the records before it.
1422
+ const found = await this.lookedUp(site, 0);
1423
+ await this.transaction((tx) => tx.put('sites', site, { id: site, readAt: this.now(), answered: !!found }));
1424
+ if (!found)
1425
+ continue;
1426
+ const stated = this.stated(found);
1427
+ await this.transaction(async (tx) => {
1428
+ for (const connection of await tx.list('connections')) {
1429
+ if (!live(connection) || this.siteDomain(connection.local) !== site)
1430
+ continue;
1431
+ const { local } = connection.attestations;
1432
+ // Nothing was stated before and nothing is now, so there is nothing to write.
1433
+ if (!stated && !local.artifactUrl)
1434
+ continue;
1435
+ connection.attestations.local = stated ?? {
1436
+ by: local.by,
1437
+ method: local.method,
1438
+ confirmedAt: local.confirmedAt,
1439
+ };
1440
+ await tx.put('connections', connection.id, connection);
1441
+ }
1442
+ });
1443
+ }
1444
+ }
1258
1445
  /**
1259
1446
  * The configured method that can ask again about one attestation, or nothing when there
1260
1447
  * is nothing to ask: no published proof, a method this instance no longer runs, or a
@@ -1275,11 +1462,14 @@ export class VerilyService {
1275
1462
  const { artifactUrl, expect, method } = attestation;
1276
1463
  const main = attestation === connection.attestations.external[0];
1277
1464
  let withdrawn = false;
1465
+ let dnssec = false;
1278
1466
  try {
1279
1467
  if (provider.artifact === 'document')
1280
1468
  withdrawn = await this.deadline(provider.withdrawn(connection.external, connection.proof));
1281
1469
  else {
1282
- const external = await this.deadline(provider.verify({ artifact: artifactUrl, expect: expect }));
1470
+ const found = await this.deadline(read(provider, { artifact: artifactUrl, expect: expect }));
1471
+ const external = found.account;
1472
+ dnssec = found.dnssec === true;
1283
1473
  // The proof must still be the same holder's. An account that changed hands has
1284
1474
  // not reproved anything, whatever is published at the old address.
1285
1475
  if (main
@@ -1314,6 +1504,11 @@ export class VerilyService {
1314
1504
  return 0;
1315
1505
  }
1316
1506
  held.confirmedAt = this.now();
1507
+ // A zone can start or stop being signed, so the flag is as the last read found it.
1508
+ if (dnssec)
1509
+ held.dnssec = true;
1510
+ else
1511
+ delete held.dnssec;
1317
1512
  await tx.put('connections', connection.id, current);
1318
1513
  return 1;
1319
1514
  });
@@ -1345,14 +1540,20 @@ export class VerilyService {
1345
1540
  if (limit.expiresAt <= now)
1346
1541
  await tx.delete('limits', limit.id);
1347
1542
  const audit = await tx.list('audit');
1543
+ // The sites that still have a record here, whose place in the recheck order is kept.
1544
+ const sites = new Set();
1348
1545
  for (const connection of await tx.list('connections')) {
1349
1546
  const ended = connection.revokedAt ??
1350
1547
  (connection.retiredAt === undefined ? connection.expiresAt : Infinity);
1351
1548
  if (ended + retentionMs <= now) {
1352
1549
  await tx.delete('connections', connection.id);
1353
1550
  await tx.delete('shares', connection.id);
1551
+ continue;
1354
1552
  }
1355
- else if (connection.connectedAt === undefined) {
1553
+ const site = this.siteDomain(connection.local);
1554
+ if (site)
1555
+ sites.add(site);
1556
+ if (connection.connectedAt === undefined) {
1356
1557
  // A record from before its first connection was kept. The audit trail still has
1357
1558
  // the moment, for as long as it retains it, and that is the answer. Only past
1358
1559
  // that is the earliest time left on the record used, which is a guess: a sign-in
@@ -1365,6 +1566,11 @@ export class VerilyService {
1365
1566
  await tx.put('connections', connection.id, connection);
1366
1567
  }
1367
1568
  }
1569
+ // When a site was last asked goes only with its last record, never by age: it is
1570
+ // what orders the sites `recheck()` asks, and losing it would put the site first again.
1571
+ for (const read of await tx.list('sites'))
1572
+ if (!sites.has(read.id))
1573
+ await tx.delete('sites', read.id);
1368
1574
  for (const event of audit)
1369
1575
  if (event.at + retentionMs <= now)
1370
1576
  await tx.delete('audit', event.id);
@@ -1408,6 +1614,11 @@ function revives(connection, provider) {
1408
1614
  return (connection.retiredAt === undefined ||
1409
1615
  (connection.attestations?.external[0].method ?? 'oauth') === providerMethod(provider));
1410
1616
  }
1617
+ /** One proof waiting to be read again, and the method that reads it. */
1618
+ /** Whose a proof is and what else its method learned, whichever of the two it offers. */
1619
+ async function read(provider, input) {
1620
+ return provider.prove ? provider.prove(input) : { account: await provider.verify(input) };
1621
+ }
1411
1622
  /**
1412
1623
  * Whether two methods named the same account. Where both carry an id their provider
1413
1624
  * issued, that id settles it. A method that learns only an address, as a link back does,
@@ -78,7 +78,7 @@ export function createSiteClient(options) {
78
78
  * have. The instance sends them on to this site's authorize endpoint with a `state`.
79
79
  */
80
80
  beginUrl(purpose = 'connect') {
81
- return `${instance.origin}/begin?${new URLSearchParams({ site: options.site, purpose })}`;
81
+ return `${instance.origin}/handoff/request?${new URLSearchParams({ site: options.site, purpose })}`;
82
82
  },
83
83
  /**
84
84
  * For the authorize endpoint: where to redirect a signed-in user, with a handoff that
@@ -98,7 +98,7 @@ export function createSiteClient(options) {
98
98
  // Under the five minutes the instance allows, so a slow clock does not overrun it.
99
99
  exp: seconds() + 240,
100
100
  });
101
- return { url: `${instance.origin}/start?${new URLSearchParams({ token })}`, txn };
101
+ return { url: `${instance.origin}/handoff/accept?${new URLSearchParams({ token })}`, txn };
102
102
  },
103
103
  /**
104
104
  * For a page that shows the connect pill with `handoff-url`: a token that vouches for