@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.
- package/README.md +47 -10
- package/dist/browser/evidence-dialog.js +1 -1
- package/dist/browser/index.js +28 -22
- package/dist/core/index.d.ts +67 -4
- package/dist/core/index.js +11 -5
- package/dist/core/routes.d.ts +57 -0
- package/dist/core/routes.js +52 -0
- package/dist/server/domain.d.ts +76 -10
- package/dist/server/domain.js +140 -51
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +65 -27
- package/dist/server/pgp.d.ts +2 -0
- package/dist/server/pgp.js +1 -1
- package/dist/server/service.d.ts +72 -1
- package/dist/server/service.js +233 -22
- package/dist/site/index.js +2 -2
- package/dist/verily.js +88 -34
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/server/service.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
package/dist/server/service.js
CHANGED
|
@@ -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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
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,
|
|
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
|
|
356
|
-
await this.established(id, binding,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
package/dist/site/index.js
CHANGED
|
@@ -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}/
|
|
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}/
|
|
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
|