@ghostlytools/cli 1.3.0 → 1.3.1
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 +9 -3
- package/dist/assets/{api-CrxVxtY1.js → api-Dq2QXQDk.js} +141 -40
- package/dist/assets/api-Dq2QXQDk.js.map +1 -0
- package/dist/assets/{api-BiaX6l6f.js → api-TiB5QSG9.js} +2 -2
- package/dist/assets/{apiKit-CajiDxdK.js → apiKit-CA2x5GJF.js} +7 -3
- package/dist/assets/apiKit-CA2x5GJF.js.map +1 -0
- package/dist/assets/{apps-CbfiCSIw.js → apps-BxV9i_Y4.js} +3 -4
- package/dist/assets/{apps-CbfiCSIw.js.map → apps-BxV9i_Y4.js.map} +1 -1
- package/dist/assets/{apps-t23gkc28.js → apps-C-T8jeji.js} +1 -1
- package/dist/assets/{arkade-D4BVJFEQ.js → arkade-C_qDmHnm.js} +2 -2
- package/dist/assets/{arkade-D4BVJFEQ.js.map → arkade-C_qDmHnm.js.map} +1 -1
- package/dist/assets/{backup-Cg52WeQi.js → backup-Bugnn3Ue.js} +1 -1
- package/dist/assets/{backup-BAMi2lAq.js → backup-CnMN2oUK.js} +8 -8
- package/dist/assets/backup-CnMN2oUK.js.map +1 -0
- package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
- package/dist/assets/bitcoind-CZchoh43.js +349 -0
- package/dist/assets/bitcoind-CZchoh43.js.map +1 -0
- package/dist/assets/bitcoindProvider-DGw3xBSa.js +97 -0
- package/dist/assets/bitcoindProvider-DGw3xBSa.js.map +1 -0
- package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
- package/dist/assets/buttons-Bj7Vf7Pn.js +2 -0
- package/dist/assets/{buttons-CX1pKB0j.js → buttons-CvNBzh2S.js} +4 -5
- package/dist/assets/{buttons-CX1pKB0j.js.map → buttons-CvNBzh2S.js.map} +1 -1
- package/dist/assets/{endpoint-Dn7H8qE1.js → endpoint-BOTFMpfS.js} +44 -12
- package/dist/assets/endpoint-BOTFMpfS.js.map +1 -0
- package/dist/assets/{envelope-BvU0fLEt.js → envelope-WjingYSc.js} +2 -2
- package/dist/assets/{envelope-BvU0fLEt.js.map → envelope-WjingYSc.js.map} +1 -1
- package/dist/assets/{errors-my6VJR_0.js → errors-DO5z6h_l.js} +29 -1
- package/dist/assets/errors-DO5z6h_l.js.map +1 -0
- package/dist/assets/groupFiles-IEUW3DQz.js +3202 -0
- package/dist/assets/groupFiles-IEUW3DQz.js.map +1 -0
- package/dist/assets/{host-Cmd205Dc.js → host-CWt-jtqz.js} +41 -16
- package/dist/assets/host-CWt-jtqz.js.map +1 -0
- package/dist/assets/{hyperdhtRelay-CEmBpT0Q.js → hyperdhtRelay-CPYwSLLS.js} +2 -3
- package/dist/assets/{hyperdhtRelay-CEmBpT0Q.js.map → hyperdhtRelay-CPYwSLLS.js.map} +1 -1
- package/dist/assets/{irohWeb--YyEA-hX.js → irohWeb-4oChIOIp.js} +2 -3
- package/dist/assets/{irohWeb--YyEA-hX.js.map → irohWeb-4oChIOIp.js.map} +1 -1
- package/dist/assets/{main-BzNN6bkx.js → main-DbqcdShN.js} +142 -35
- package/dist/assets/main-DbqcdShN.js.map +1 -0
- package/dist/assets/{mainline-D5D5C-jT.js → mainline-ZkiOnevs.js} +2 -2
- package/dist/assets/{mainline-D5D5C-jT.js.map → mainline-ZkiOnevs.js.map} +1 -1
- package/dist/assets/{networkReason-DxZWHw6v.js → networkReason-CetKkTPo.js} +2 -2
- package/dist/assets/{networkReason-DxZWHw6v.js.map → networkReason-CetKkTPo.js.map} +1 -1
- package/dist/assets/nwc-CgkqyvsE.js +612 -0
- package/dist/assets/nwc-CgkqyvsE.js.map +1 -0
- package/dist/assets/nwcProvider-4eVz_8HP.js +77 -0
- package/dist/assets/nwcProvider-4eVz_8HP.js.map +1 -0
- package/dist/assets/{profiles-B6F4oa9K.js → profiles-BHc9pEYg.js} +2 -2
- package/dist/assets/{profiles-B6F4oa9K.js.map → profiles-BHc9pEYg.js.map} +1 -1
- package/dist/assets/registry-DlAKO0Fy.js +3793 -0
- package/dist/assets/registry-DlAKO0Fy.js.map +1 -0
- package/dist/assets/registry-OBJBnHVQ.js +2 -0
- package/dist/assets/resolve-CouHhCpJ.js +591 -0
- package/dist/assets/resolve-CouHhCpJ.js.map +1 -0
- package/dist/assets/{server-CBCEYKMN.js → server-Ss0gJ-nw.js} +184 -1181
- package/dist/assets/server-Ss0gJ-nw.js.map +1 -0
- package/dist/assets/{src-DhfGgAUu.js → src-DDzSERyc.js} +2249 -72
- package/dist/assets/src-DDzSERyc.js.map +1 -0
- package/dist/assets/{turnRefs-ChEfgbSX.js → turnRefs-B87dtrHM.js} +2 -2
- package/dist/assets/{turnRefs-ChEfgbSX.js.map → turnRefs-B87dtrHM.js.map} +1 -1
- package/dist/assets/types-C4_KafL_.js +122 -0
- package/dist/assets/types-C4_KafL_.js.map +1 -0
- package/dist/assets/{usdt-C1Xao2xH.js → usdt-Fj8CQO2z.js} +3 -3
- package/dist/assets/{usdt-C1Xao2xH.js.map → usdt-Fj8CQO2z.js.map} +1 -1
- package/dist/ghostly.mjs +1 -1
- package/package.json +1 -1
- package/dist/assets/api-CrxVxtY1.js.map +0 -1
- package/dist/assets/apiKit-CajiDxdK.js.map +0 -1
- package/dist/assets/backup-BAMi2lAq.js.map +0 -1
- package/dist/assets/buttons-CWl2yNJu.js +0 -2
- package/dist/assets/endpoint-Dn7H8qE1.js.map +0 -1
- package/dist/assets/errors-my6VJR_0.js.map +0 -1
- package/dist/assets/groupFiles-BNTSDaoq.js +0 -7685
- package/dist/assets/groupFiles-BNTSDaoq.js.map +0 -1
- package/dist/assets/groupFiles-DBbjOLuD.js +0 -2088
- package/dist/assets/groupFiles-DBbjOLuD.js.map +0 -1
- package/dist/assets/host-Cmd205Dc.js.map +0 -1
- package/dist/assets/main-BzNN6bkx.js.map +0 -1
- package/dist/assets/server-CBCEYKMN.js.map +0 -1
- package/dist/assets/src-DhfGgAUu.js.map +0 -1
|
@@ -0,0 +1,3793 @@
|
|
|
1
|
+
import { Ai as normalizeDomain, An as pubkyProofPath, At as verifyAtprotoRecordProof, Bt as didWebDocumentUrl, Cn as PUBKY_PROOF_MAX_BYTES, Ct as atprotoProofRkey, Dn as normalizePubkyKey, Ei as domainWellKnownFile, En as newPubkyProofFolder, Et as normalizeAtprotoHandle, Fi as wellKnownUrl, Ft as checkDidJws, Gt as normalizeDid, Ht as didWebFileNames, It as checkDidSignature, Jt as parseDidSignaturePaste, Kt as parseDid, Lt as didGhostlyServiceEntry, Mn as pubkyHomeserverOf, Mt as DID_FILE_MAX_BYTES, On as pubkyProofCapability, Ot as shortAtprotoDid, Pt as DidError, Rt as didJwsAlg, St as atprotoProofRecord, Ti as domainTxtRecord, Tn as isPubkyProofFolder, Tt as isAtprotoDid, Ui as IdentityCheckUnavailable, Ut as didWebFileUrl, Vt as didWebFile, Wt as didWebHost, Xt as staticDidDocument, Yt as signingKeys, Zt as strictBase64Url, _l as randomBytes, _t as ATPROTO_DECLARED_SCOPE, bl as utf8Decode, bt as ATPROTO_PROOF_SCOPE, ct as SshSigError, dt as sameSshKey, ft as sshSignCommand, gl as fromBase64Url, gt as decodeAnyBitcoinAddress, ht as BitcoinAddressError, jn as homeserverWebEndpoint, jt as DID_DOCUMENT_MAX_BYTES, kn as pubkyProofFile, kt as AtprotoProofError, lt as parseSshPublicKey, mt as verifyBitcoinMessage, nn as resolveDidDht, ol as openRelayPayload, pt as verifySshSignature, qt as parseDidDocument, rl as RELAY_PAYLOAD_MAX_BYTES, st as SSHSIG_NAMESPACE, ut as parseSshSignature, vl as toBase64Url, vt as ATPROTO_FULL_SCOPE, wi as dnsProofName, wn as isPubkyKey, wt as checkAtprotoProofRecord, xl as utf8Encode, yt as ATPROTO_PROOF_COLLECTION, zt as didSignedMessages } from "./src-DDzSERyc.js";
|
|
2
|
+
import { _ as reserveAdapterIds, a as DomainCheckError, c as assertPublicHost, d as ownDid, i as verifiedAtprotoHandle, l as chosenResolver, m as registeredIdentityProviders, n as resolveAtprotoDid, o as assertDomainRecord, r as resolveAtprotoHandle, s as assertNip05, t as assertPublicServer, u as resolverById, v as boundedIdentityFetch } from "./resolve-CouHhCpJ.js";
|
|
3
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
4
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
5
|
+
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
6
|
+
import { generateSecretKey } from "nostr-tools/pure";
|
|
7
|
+
import { decode, npubEncode } from "nostr-tools/nip19";
|
|
8
|
+
//#region ../browser/src/proofs/bitcoinWallets.ts
|
|
9
|
+
/** What a proof proves and what it does not; shown next to every Bitcoin proof, given or received. */
|
|
10
|
+
var BITCOIN_PROOF_LIMITS = [
|
|
11
|
+
"Proves: whoever made this signature could sign with the key behind this address when they signed.",
|
|
12
|
+
"Does not prove the address holds any balance, now or ever.",
|
|
13
|
+
"Does not prove they sent or received any past payment.",
|
|
14
|
+
"Does not prove they would pay you, or spend from this address at all."
|
|
15
|
+
];
|
|
16
|
+
var BITCOIN_WALLET_GUIDES = [
|
|
17
|
+
{
|
|
18
|
+
id: "sparrow",
|
|
19
|
+
name: "Sparrow Wallet",
|
|
20
|
+
scripts: [
|
|
21
|
+
"p2wpkh",
|
|
22
|
+
"p2tr",
|
|
23
|
+
"p2pkh"
|
|
24
|
+
],
|
|
25
|
+
format: "bip322",
|
|
26
|
+
steps: [
|
|
27
|
+
"Open the wallet that holds the address, then Tools → Sign/Verify Message.",
|
|
28
|
+
"Address: paste the address you are proving.",
|
|
29
|
+
"Message: paste the statement Ghostly shows, exactly as it is.",
|
|
30
|
+
"Format: BIP322 (Simple) for a bc1q… or bc1p… address (tb1… on test networks). For a legacy 1… address, Standard (Electrum).",
|
|
31
|
+
"Sign Message, then copy the whole Signature (it starts with smp…) and paste it into Ghostly."
|
|
32
|
+
],
|
|
33
|
+
testnet: "Tools → Restart In → Testnet or Signet, with a wallet on that network.",
|
|
34
|
+
note: "Sparrow offers BIP322 (Simple) for software wallets only; a connected hardware wallet signs the Standard (Electrum) format, which Ghostly accepts only for a legacy 1… address.",
|
|
35
|
+
docs: "https://sparrowwallet.com/docs/"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "bitcoin-core",
|
|
39
|
+
name: "Bitcoin Core",
|
|
40
|
+
scripts: ["p2pkh"],
|
|
41
|
+
format: "legacy",
|
|
42
|
+
steps: [
|
|
43
|
+
"Bitcoin Core signs messages in the legacy format only, for a legacy address (1… on mainnet, m… or n… on test networks). Make one in the Receive tab with the Base58 (Legacy) type, or with `getnewaddress \"\" legacy`.",
|
|
44
|
+
"GUI: File → Sign message…; paste the address, paste the statement, Sign Message, copy the signature.",
|
|
45
|
+
"Command line: bitcoin-cli signmessage \"<address>\" '<statement>' (single quotes keep the statement's double quotes intact)."
|
|
46
|
+
],
|
|
47
|
+
testnet: "Run it with -signet, -testnet4 or -regtest; add the same flag to bitcoin-cli.",
|
|
48
|
+
docs: "https://developer.bitcoin.org/reference/rpc/signmessage.html"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "electrum",
|
|
52
|
+
name: "Electrum",
|
|
53
|
+
scripts: ["p2pkh"],
|
|
54
|
+
format: "legacy",
|
|
55
|
+
steps: [
|
|
56
|
+
"Electrum signs in the legacy format, which Ghostly accepts only for a legacy 1… address (m…/n… on test networks); its default bc1… wallets need another wallet.",
|
|
57
|
+
"Addresses tab (View → Show Addresses), right-click the address → Sign/verify message.",
|
|
58
|
+
"Paste the statement into Message, click Sign, copy the Signature and paste it into Ghostly."
|
|
59
|
+
],
|
|
60
|
+
testnet: "Start Electrum with --testnet or --signet.",
|
|
61
|
+
docs: "https://electrum.readthedocs.io/"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "coldcard",
|
|
65
|
+
name: "COLDCARD",
|
|
66
|
+
scripts: [
|
|
67
|
+
"p2wpkh",
|
|
68
|
+
"p2tr",
|
|
69
|
+
"p2pkh"
|
|
70
|
+
],
|
|
71
|
+
format: "bip322",
|
|
72
|
+
steps: [
|
|
73
|
+
"Follow COLDCARD's BIP-322 guide for your firmware: it signs the statement for one of its addresses via MicroSD, NFC or QR, depending on the model.",
|
|
74
|
+
"Give it the statement exactly as Ghostly shows it and the address you are proving.",
|
|
75
|
+
"Paste the signature it produces into Ghostly."
|
|
76
|
+
],
|
|
77
|
+
note: "Older firmware signs only the legacy format (Ghostly accepts that for a legacy 1… address) and limits message length and characters.",
|
|
78
|
+
docs: "https://coldcard.com/docs/bip322/"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "trezor",
|
|
82
|
+
name: "Trezor Suite",
|
|
83
|
+
scripts: ["p2pkh"],
|
|
84
|
+
format: "legacy",
|
|
85
|
+
steps: [
|
|
86
|
+
"Trezor signs messages in the legacy format; Ghostly accepts it for an address of a Legacy account (1…).",
|
|
87
|
+
"Open the Legacy account and choose Sign & verify in its menu.",
|
|
88
|
+
"Paste the statement into Message, pick the address, Sign, confirm on the device, copy the signature into Ghostly."
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "other",
|
|
93
|
+
name: "Another wallet",
|
|
94
|
+
scripts: [
|
|
95
|
+
"p2wpkh",
|
|
96
|
+
"p2tr",
|
|
97
|
+
"p2sh",
|
|
98
|
+
"p2pkh"
|
|
99
|
+
],
|
|
100
|
+
format: "bip322",
|
|
101
|
+
steps: [
|
|
102
|
+
"Look for \"Sign message\" in the wallet. Choose BIP-322 when it asks for a format.",
|
|
103
|
+
"Sign the statement exactly as Ghostly shows it, with the address you are proving.",
|
|
104
|
+
"A nested SegWit address (3…, or 2… on test networks) needs a full BIP-322 signature (it starts with ful…).",
|
|
105
|
+
"Paste the signature into Ghostly; it tells you right away if it does not match."
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
];
|
|
109
|
+
/** The guides that can sign for an address of this type. */
|
|
110
|
+
function guidesFor(script) {
|
|
111
|
+
return BITCOIN_WALLET_GUIDES.filter((g) => g.scripts.includes(script));
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region ../browser/src/proofs/providers/bitcoin.ts
|
|
115
|
+
var MAX_SIGNATURE = 8192;
|
|
116
|
+
var SIGNATURE = /^(smp|ful|pof)?[A-Za-z0-9+/]+={0,2}$/;
|
|
117
|
+
var SCRIPT_NAMES = {
|
|
118
|
+
p2wpkh: "native SegWit (bc1q…)",
|
|
119
|
+
p2tr: "Taproot (bc1p…)",
|
|
120
|
+
p2sh: "nested SegWit (3…)",
|
|
121
|
+
p2pkh: "legacy (1…)",
|
|
122
|
+
p2wsh: "script / multisig (P2WSH)",
|
|
123
|
+
"witness-unknown": "future SegWit version"
|
|
124
|
+
};
|
|
125
|
+
function normalizeAddress(input) {
|
|
126
|
+
const value = input.trim();
|
|
127
|
+
let info;
|
|
128
|
+
try {
|
|
129
|
+
info = decodeAnyBitcoinAddress(value);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
throw Object.assign(new Error(e instanceof BitcoinAddressError ? e.message : "Enter a Bitcoin address"), { cause: e });
|
|
132
|
+
}
|
|
133
|
+
if (info.type === "p2wsh" || info.type === "witness-unknown") throw new Error("Ghostly can check proofs for single-key addresses only (bc1q…, bc1p…, 3…, 1…), not multisig or script addresses");
|
|
134
|
+
return info.witnessVersion === void 0 ? value : value.toLowerCase();
|
|
135
|
+
}
|
|
136
|
+
var isTestAddress = (address) => {
|
|
137
|
+
try {
|
|
138
|
+
return decodeAnyBitcoinAddress(address).network === "testnet";
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
function shortAddress(address) {
|
|
144
|
+
return `${address.slice(0, address.startsWith("bcrt1") ? 9 : 7)}…${address.slice(-4)}${isTestAddress(address) ? " · test network" : ""}`;
|
|
145
|
+
}
|
|
146
|
+
/** Wallet steps for this statement's address: the guide's own, or why this wallet cannot sign for it. */
|
|
147
|
+
function walletInstructions(guide, statement) {
|
|
148
|
+
const address = statement.binding.subject;
|
|
149
|
+
const info = decodeAnyBitcoinAddress(address);
|
|
150
|
+
const steps = [];
|
|
151
|
+
if (!guide.scripts.includes(info.type)) {
|
|
152
|
+
const others = guidesFor(info.type).filter((g) => g.id !== "other").map((g) => g.name);
|
|
153
|
+
steps.push({ text: `${guide.name} cannot sign for a ${SCRIPT_NAMES[info.type]} address in a format Ghostly accepts.${others.length ? ` Try ${others.join(" or ")}, or another wallet that signs BIP-322.` : " Use a wallet that signs BIP-322."}` });
|
|
154
|
+
} else {
|
|
155
|
+
steps.push({
|
|
156
|
+
text: "Sign exactly this statement, as one line, without adding a line break:",
|
|
157
|
+
copy: statement.text
|
|
158
|
+
});
|
|
159
|
+
steps.push({
|
|
160
|
+
text: "With this address:",
|
|
161
|
+
copy: address
|
|
162
|
+
});
|
|
163
|
+
for (const text of guide.steps) steps.push({ text });
|
|
164
|
+
if (guide.id === "bitcoin-core") steps.push({
|
|
165
|
+
text: `Command line${info.network === "testnet" ? " (add -signet, -testnet4 or -regtest)" : ""}:`,
|
|
166
|
+
copy: `bitcoin-cli signmessage "${address}" '${statement.text}'`
|
|
167
|
+
});
|
|
168
|
+
if (info.network === "testnet" && guide.testnet) steps.push({ text: `Test networks: ${guide.testnet}` });
|
|
169
|
+
if (guide.note) steps.push({ text: guide.note });
|
|
170
|
+
}
|
|
171
|
+
steps.push({ text: "This shows you can sign for the address. It does not show a balance, a past payment, or that you would pay." });
|
|
172
|
+
return {
|
|
173
|
+
steps,
|
|
174
|
+
paste: {
|
|
175
|
+
label: "Signature",
|
|
176
|
+
placeholder: "smp… (BIP-322) or the base64 signature your wallet shows",
|
|
177
|
+
multiline: true
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function cleanSignature(pasted) {
|
|
182
|
+
const signature = pasted.replace(/\s+/g, "");
|
|
183
|
+
if (!signature) throw new Error("Paste the signature your wallet made");
|
|
184
|
+
if (signature.length > MAX_SIGNATURE || !SIGNATURE.test(signature)) throw new Error("That is not a Bitcoin message signature: paste the whole signature, as the wallet shows it");
|
|
185
|
+
return signature;
|
|
186
|
+
}
|
|
187
|
+
function check(statement, signature) {
|
|
188
|
+
const address = statement.binding.subject;
|
|
189
|
+
const network = decodeAnyBitcoinAddress(address).network;
|
|
190
|
+
const verdict = verifyBitcoinMessage({
|
|
191
|
+
address,
|
|
192
|
+
message: statement.text,
|
|
193
|
+
signature,
|
|
194
|
+
network
|
|
195
|
+
});
|
|
196
|
+
if (verdict.state !== "valid") throw new Error(verdict.reason);
|
|
197
|
+
return {
|
|
198
|
+
verdict,
|
|
199
|
+
network
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function walletSigner(guide) {
|
|
203
|
+
return {
|
|
204
|
+
id: guide.id,
|
|
205
|
+
kind: "external-tool",
|
|
206
|
+
label: guide.name,
|
|
207
|
+
description: guide.format === "legacy" ? "Legacy message signature: for a legacy 1… address only." : "BIP-322 message signature.",
|
|
208
|
+
instructions: (statement) => walletInstructions(guide, statement),
|
|
209
|
+
parse(pasted, statement) {
|
|
210
|
+
const signature = cleanSignature(pasted);
|
|
211
|
+
check(statement, signature);
|
|
212
|
+
return { signature };
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
var bitcoin = {
|
|
217
|
+
id: "bitcoin",
|
|
218
|
+
label: "Bitcoin address",
|
|
219
|
+
category: "self-custodied",
|
|
220
|
+
summary: "Sign a message in your own wallet",
|
|
221
|
+
description: "Proves you can sign with the key behind a Bitcoin address: your wallet signs the statement and you paste the signature.",
|
|
222
|
+
limits: "It does not prove a balance, a past payment, or that you would pay.",
|
|
223
|
+
platforms: [
|
|
224
|
+
"web",
|
|
225
|
+
"extension",
|
|
226
|
+
"desktop"
|
|
227
|
+
],
|
|
228
|
+
subject: {
|
|
229
|
+
label: "Bitcoin address",
|
|
230
|
+
placeholder: "bc1q… (tb1… on test networks)",
|
|
231
|
+
help: BITCOIN_PROOF_LIMITS.join(" "),
|
|
232
|
+
normalize: normalizeAddress,
|
|
233
|
+
short: shortAddress
|
|
234
|
+
},
|
|
235
|
+
publicUri: (address) => `bitcoin:${address}`,
|
|
236
|
+
validity: {
|
|
237
|
+
defaultDays: 90,
|
|
238
|
+
maxDays: 365
|
|
239
|
+
},
|
|
240
|
+
privacy: "Nothing: the signature is checked on this device, with no blockchain lookup. Contacts you share it with learn the address.",
|
|
241
|
+
signers: BITCOIN_WALLET_GUIDES.map(walletSigner),
|
|
242
|
+
parseEvidence(raw) {
|
|
243
|
+
const e = raw;
|
|
244
|
+
if (!e || typeof e !== "object" || Array.isArray(e) || Object.keys(e).join(",") !== "signature" || typeof e.signature !== "string" || e.signature.length > MAX_SIGNATURE || !SIGNATURE.test(e.signature)) throw new Error("That is not a Bitcoin message signature");
|
|
245
|
+
return { signature: e.signature };
|
|
246
|
+
},
|
|
247
|
+
async verify(statement, evidence) {
|
|
248
|
+
const { verdict, network } = check(statement, evidence.signature);
|
|
249
|
+
const format = verdict.format === "legacy" ? "Legacy signmessage" : verdict.format === "bip322-full" ? "BIP-322 full" : "BIP-322 simple";
|
|
250
|
+
const script = {
|
|
251
|
+
p2pkh: "P2PKH",
|
|
252
|
+
p2wpkh: "P2WPKH",
|
|
253
|
+
"p2sh-p2wpkh": "P2SH-P2WPKH",
|
|
254
|
+
p2tr: "P2TR"
|
|
255
|
+
}[verdict.script];
|
|
256
|
+
return {
|
|
257
|
+
subject: statement.binding.subject,
|
|
258
|
+
source: `${format} signature, ${script}${network === "testnet" ? ", test network" : ""}`
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region ../browser/src/proofs/nostr.ts
|
|
264
|
+
function extensionSigner() {
|
|
265
|
+
return globalThis.nostr;
|
|
266
|
+
}
|
|
267
|
+
/** No NIP-05 lookup, arbitrary fetch, private key import or automatic relay switching. */
|
|
268
|
+
function parseProofBunker(input) {
|
|
269
|
+
if (input.length > 4096) throw new Error("Bunker URL too long");
|
|
270
|
+
let uri;
|
|
271
|
+
try {
|
|
272
|
+
uri = new URL(input);
|
|
273
|
+
} catch {
|
|
274
|
+
throw new Error("Enter a bunker:// connection URL from your signer");
|
|
275
|
+
}
|
|
276
|
+
const relays = uri.searchParams.getAll("relay");
|
|
277
|
+
if (uri.protocol !== "bunker:" || !/^[a-f0-9]{64}$/.test(uri.hostname) || uri.username || uri.password || uri.port || uri.hash || uri.pathname && uri.pathname !== "/" || relays.length < 1 || relays.length > 3) throw new Error("Invalid bunker connection URL");
|
|
278
|
+
for (const address of relays) {
|
|
279
|
+
const relay = new URL(address);
|
|
280
|
+
if (relay.username || relay.password || relay.hash || !(relay.protocol === "wss:" || relay.protocol === "ws:" && [
|
|
281
|
+
"127.0.0.1",
|
|
282
|
+
"localhost",
|
|
283
|
+
"[::1]"
|
|
284
|
+
].includes(relay.hostname))) throw new Error("Use secure wss:// relays (ws:// is allowed only on loopback for local testing)");
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
pubkey: uri.hostname,
|
|
288
|
+
relays,
|
|
289
|
+
secret: uri.searchParams.get("secret")
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Runs `work` with the person's Nostr signer: the page's NIP-07 extension, or a NIP-46 bunker. `permissions`
|
|
294
|
+
* is what the bunker is asked for (NIP-46 `connect`): the identity proof's `sign_event:30078` by default;
|
|
295
|
+
* the social layer asks for the kinds it publishes.
|
|
296
|
+
*/
|
|
297
|
+
async function withNostrSigner(options, work) {
|
|
298
|
+
options.signal.throwIfAborted();
|
|
299
|
+
let signer;
|
|
300
|
+
let pool;
|
|
301
|
+
let clientKey;
|
|
302
|
+
let timer;
|
|
303
|
+
let abort;
|
|
304
|
+
let closed = false;
|
|
305
|
+
const guard = {
|
|
306
|
+
getPublicKey: async () => {
|
|
307
|
+
if (closed) throw new Error("Signer request cancelled");
|
|
308
|
+
const key = await active.getPublicKey();
|
|
309
|
+
if (closed) throw new Error("Signer request cancelled");
|
|
310
|
+
return key;
|
|
311
|
+
},
|
|
312
|
+
signEvent: async (event) => {
|
|
313
|
+
if (closed) throw new Error("Signer request cancelled");
|
|
314
|
+
const signed = await active.signEvent(event);
|
|
315
|
+
if (closed) throw new Error("Signer request cancelled");
|
|
316
|
+
return signed;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
let active;
|
|
320
|
+
try {
|
|
321
|
+
const timeout = new Promise((_, reject) => {
|
|
322
|
+
abort = () => {
|
|
323
|
+
closed = true;
|
|
324
|
+
reject(/* @__PURE__ */ new Error("Signing cancelled"));
|
|
325
|
+
};
|
|
326
|
+
options.signal.addEventListener("abort", abort, { once: true });
|
|
327
|
+
timer = setTimeout(() => {
|
|
328
|
+
closed = true;
|
|
329
|
+
reject(/* @__PURE__ */ new Error("Signer timed out. Your Ghostly identity is unchanged."));
|
|
330
|
+
}, 12e4);
|
|
331
|
+
});
|
|
332
|
+
const action = async () => {
|
|
333
|
+
if (options.bunker) {
|
|
334
|
+
const bp = parseProofBunker(options.bunker);
|
|
335
|
+
const [{ BunkerSigner }, { SimplePool }] = await Promise.all([import("nostr-tools/nip46"), import("nostr-tools/pool")]);
|
|
336
|
+
if (closed) throw new Error("Signer request cancelled");
|
|
337
|
+
pool = new SimplePool();
|
|
338
|
+
clientKey = generateSecretKey();
|
|
339
|
+
signer = BunkerSigner.fromBunker(clientKey, bp, {
|
|
340
|
+
pool,
|
|
341
|
+
skipSwitchRelays: true,
|
|
342
|
+
onauth: (url) => {
|
|
343
|
+
try {
|
|
344
|
+
const u = new URL(url);
|
|
345
|
+
if (!closed && u.protocol === "https:" && !u.username && !u.password) options.onAuth(u.href);
|
|
346
|
+
} catch {}
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
await signer.sendRequest("connect", [
|
|
350
|
+
bp.pubkey,
|
|
351
|
+
bp.secret ?? "",
|
|
352
|
+
options.permissions ?? "sign_event:30078",
|
|
353
|
+
JSON.stringify({ name: options.appName ?? "Ghostly — optional conversation proof" })
|
|
354
|
+
]);
|
|
355
|
+
active = signer;
|
|
356
|
+
} else {
|
|
357
|
+
active = extensionSigner();
|
|
358
|
+
if (!active) throw new Error("No NIP-07 signer in this window. Use a bunker connection from your Nostr signer.");
|
|
359
|
+
}
|
|
360
|
+
if (closed) throw new Error("Signer request cancelled");
|
|
361
|
+
return work(guard);
|
|
362
|
+
};
|
|
363
|
+
return await Promise.race([action(), timeout]);
|
|
364
|
+
} finally {
|
|
365
|
+
closed = true;
|
|
366
|
+
if (timer) clearTimeout(timer);
|
|
367
|
+
if (abort) options.signal.removeEventListener("abort", abort);
|
|
368
|
+
await signer?.close();
|
|
369
|
+
pool?.destroy();
|
|
370
|
+
clientKey?.fill(0);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
//#endregion
|
|
374
|
+
//#region ../browser/src/proofs/providers/nostr.ts
|
|
375
|
+
/**
|
|
376
|
+
* NIP-78 application data (kind 30078): no new kind is claimed. Signers show it as such; it is never
|
|
377
|
+
* published to a relay — the event travels only to the contacts the person shares it with.
|
|
378
|
+
*/
|
|
379
|
+
var NOSTR_PROOF_KIND = 30078;
|
|
380
|
+
var HEX64$1 = /^[a-f0-9]{64}$/;
|
|
381
|
+
/** The exact event a signer is asked to sign: the statement as content, dated at issue, expiring with it. */
|
|
382
|
+
function nostrProofTemplate(statement) {
|
|
383
|
+
const { issuedAt, expiresAt } = statement.binding;
|
|
384
|
+
return {
|
|
385
|
+
kind: NOSTR_PROOF_KIND,
|
|
386
|
+
created_at: issuedAt,
|
|
387
|
+
content: statement.text,
|
|
388
|
+
tags: [["d", "ghostly-identity-proof"], ["expiration", String(expiresAt)]]
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
var hex$1 = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
392
|
+
var unhex$1 = (s) => Uint8Array.from(s.match(/../g), (x) => parseInt(x, 16));
|
|
393
|
+
function normalizeNostrKey(input) {
|
|
394
|
+
const value = input.trim();
|
|
395
|
+
if (HEX64$1.test(value.toLowerCase())) return value.toLowerCase();
|
|
396
|
+
if (value.startsWith("npub1")) try {
|
|
397
|
+
const decoded = decode(value);
|
|
398
|
+
if (decoded.type === "npub") return decoded.data;
|
|
399
|
+
} catch {}
|
|
400
|
+
throw new Error("Enter a Nostr public key: npub1… or 64 hex characters");
|
|
401
|
+
}
|
|
402
|
+
function nostrSigner(id) {
|
|
403
|
+
return {
|
|
404
|
+
id,
|
|
405
|
+
kind: "in-app",
|
|
406
|
+
label: id === "nip07" ? "Browser extension (NIP-07)" : "Remote signer (NIP-46)",
|
|
407
|
+
description: id === "nip07" ? "Approve in the Nostr extension of this browser." : "Paste the bunker:// link from your signer app, then approve there.",
|
|
408
|
+
fields: id === "nip46" ? [{
|
|
409
|
+
name: "bunker",
|
|
410
|
+
label: "Signer connection link",
|
|
411
|
+
kind: "secret",
|
|
412
|
+
placeholder: "bunker://…"
|
|
413
|
+
}] : void 0,
|
|
414
|
+
platforms: id === "nip07" ? ["web", "desktop"] : void 0,
|
|
415
|
+
available: id === "nip07" ? () => !!extensionSigner() : void 0,
|
|
416
|
+
run: (ctx, work) => withNostrSigner({
|
|
417
|
+
bunker: id === "nip46" ? ctx.values.bunker : void 0,
|
|
418
|
+
signal: ctx.signal,
|
|
419
|
+
onAuth: ctx.onAuthUrl
|
|
420
|
+
}, (signer) => {
|
|
421
|
+
return work({
|
|
422
|
+
subject: async () => normalizeNostrKey(await signer.getPublicKey()),
|
|
423
|
+
sign: async (statement) => {
|
|
424
|
+
ctx.onProgress("Approve the Ghostly identity statement in your signer.");
|
|
425
|
+
return await signer.signEvent(nostrProofTemplate(statement));
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
})
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
var nostr = {
|
|
432
|
+
id: "nostr",
|
|
433
|
+
label: "Nostr",
|
|
434
|
+
category: "self-custodied",
|
|
435
|
+
summary: "Sign once with your Nostr signer",
|
|
436
|
+
description: "Proves you hold a Nostr key: your signer signs the statement, your key never leaves it.",
|
|
437
|
+
platforms: [
|
|
438
|
+
"web",
|
|
439
|
+
"extension",
|
|
440
|
+
"desktop"
|
|
441
|
+
],
|
|
442
|
+
subject: {
|
|
443
|
+
label: "Public key",
|
|
444
|
+
placeholder: "npub1…",
|
|
445
|
+
normalize: normalizeNostrKey,
|
|
446
|
+
short: (key) => {
|
|
447
|
+
const npub = npubEncode(key);
|
|
448
|
+
return `${npub.slice(0, 10)}…${npub.slice(-4)}`;
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
publicUri: (key) => `nostr:${npubEncode(key)}`,
|
|
452
|
+
validity: {
|
|
453
|
+
defaultDays: 90,
|
|
454
|
+
maxDays: 365
|
|
455
|
+
},
|
|
456
|
+
privacy: "Nothing: the signature is checked on this device. The proof is never published to a relay.",
|
|
457
|
+
signers: [nostrSigner("nip07"), nostrSigner("nip46")],
|
|
458
|
+
parseEvidence(raw) {
|
|
459
|
+
const e = raw;
|
|
460
|
+
if (!e || typeof e !== "object" || Array.isArray(e) || Object.keys(e).sort().join(",") !== "content,created_at,id,kind,pubkey,sig,tags" || typeof e.id !== "string" || !HEX64$1.test(e.id) || typeof e.pubkey !== "string" || !HEX64$1.test(e.pubkey) || typeof e.sig !== "string" || !/^[a-f0-9]{128}$/.test(e.sig) || !Number.isSafeInteger(e.kind) || !Number.isSafeInteger(e.created_at) || typeof e.content !== "string" || e.content.length > 2048 || !Array.isArray(e.tags) || e.tags.length > 4 || !e.tags.every((t) => Array.isArray(t) && t.length <= 4 && t.every((x) => typeof x === "string" && x.length <= 128))) throw new Error("That is not a signed Nostr event");
|
|
461
|
+
return {
|
|
462
|
+
id: e.id,
|
|
463
|
+
pubkey: e.pubkey,
|
|
464
|
+
created_at: e.created_at,
|
|
465
|
+
kind: e.kind,
|
|
466
|
+
tags: e.tags.map((t) => [...t]),
|
|
467
|
+
content: e.content,
|
|
468
|
+
sig: e.sig
|
|
469
|
+
};
|
|
470
|
+
},
|
|
471
|
+
async verify(statement, event) {
|
|
472
|
+
const expected = nostrProofTemplate(statement);
|
|
473
|
+
if (event.pubkey !== statement.binding.subject) throw new Error("Signed by another Nostr key");
|
|
474
|
+
if (event.kind !== expected.kind || event.created_at !== expected.created_at || event.content !== expected.content || JSON.stringify(event.tags) !== JSON.stringify(expected.tags)) throw new Error("The signer did not sign the exact Ghostly statement");
|
|
475
|
+
const id = hex$1(sha256(utf8Encode(JSON.stringify([
|
|
476
|
+
0,
|
|
477
|
+
event.pubkey,
|
|
478
|
+
event.created_at,
|
|
479
|
+
event.kind,
|
|
480
|
+
event.tags,
|
|
481
|
+
event.content
|
|
482
|
+
]))));
|
|
483
|
+
if (event.id !== id) throw new Error("The Nostr event id does not match its content");
|
|
484
|
+
let valid;
|
|
485
|
+
try {
|
|
486
|
+
valid = schnorr.verify(unhex$1(event.sig), unhex$1(id), unhex$1(event.pubkey));
|
|
487
|
+
} catch {
|
|
488
|
+
valid = false;
|
|
489
|
+
}
|
|
490
|
+
if (!valid) throw new Error("Invalid Nostr signature");
|
|
491
|
+
return {
|
|
492
|
+
subject: event.pubkey,
|
|
493
|
+
source: "Nostr signature (NIP-01, BIP-340)"
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
//#endregion
|
|
498
|
+
//#region ../browser/src/proofs/providers/domain.ts
|
|
499
|
+
var recordFor = (s) => ({
|
|
500
|
+
key: s.binding.key,
|
|
501
|
+
proof: s.id
|
|
502
|
+
});
|
|
503
|
+
var exactKeys$2 = (raw, keys) => !!raw && typeof raw === "object" && !Array.isArray(raw) && Object.keys(raw).sort().join(",") === keys;
|
|
504
|
+
var dnsSigner = {
|
|
505
|
+
id: "dns",
|
|
506
|
+
kind: "publish",
|
|
507
|
+
label: "DNS TXT record",
|
|
508
|
+
description: "Add a TXT record at your DNS host. Contacts' apps look it up through a DNS-over-HTTPS resolver.",
|
|
509
|
+
instructions(statement) {
|
|
510
|
+
const domain = statement.binding.subject;
|
|
511
|
+
return { steps: [
|
|
512
|
+
{
|
|
513
|
+
text: `Open the DNS settings of ${domain} at your registrar or DNS host and add a TXT record with this name. Some hosts add the domain themselves: then enter only the part before ${domain}.`,
|
|
514
|
+
copy: dnsProofName(domain)
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
text: "Value (one string, exactly as shown):",
|
|
518
|
+
copy: domainTxtRecord(recordFor(statement))
|
|
519
|
+
},
|
|
520
|
+
{ text: "Set the TTL to 300 seconds or less, so removing the record takes effect quickly. Then press Check: DNS changes can take a few minutes to appear." },
|
|
521
|
+
{ text: "The record stays public while the proof is in use. Removing it withdraws this proof from every contact at their next check." }
|
|
522
|
+
] };
|
|
523
|
+
},
|
|
524
|
+
evidence: () => ({ method: "dns" })
|
|
525
|
+
};
|
|
526
|
+
var httpsSigner = {
|
|
527
|
+
id: "https",
|
|
528
|
+
kind: "publish",
|
|
529
|
+
label: "File on your website",
|
|
530
|
+
description: "Upload a small file to your website. Contacts' apps download it from your server.",
|
|
531
|
+
instructions(statement) {
|
|
532
|
+
const domain = statement.binding.subject;
|
|
533
|
+
return { steps: [
|
|
534
|
+
{
|
|
535
|
+
text: `Create this file on the web server of ${domain}:`,
|
|
536
|
+
copy: wellKnownUrl(domain)
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
text: "With exactly this content:",
|
|
540
|
+
copy: domainWellKnownFile([recordFor(statement)])
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
text: "Serve it over HTTPS at exactly that address, without any redirect (not even to www), and with this header so browsers may read it:",
|
|
544
|
+
copy: "Access-Control-Allow-Origin: *"
|
|
545
|
+
},
|
|
546
|
+
{ text: "Then press Check. The file stays public while the proof is in use; deleting it withdraws this proof from every contact at their next check." }
|
|
547
|
+
] };
|
|
548
|
+
},
|
|
549
|
+
evidence: () => ({ method: "https" })
|
|
550
|
+
};
|
|
551
|
+
var domainField = {
|
|
552
|
+
name: "domain",
|
|
553
|
+
label: "Domain",
|
|
554
|
+
kind: "text",
|
|
555
|
+
placeholder: "example.com",
|
|
556
|
+
help: "Its /.well-known/nostr.json must name this Nostr key for \"_\" (the domain itself)."
|
|
557
|
+
};
|
|
558
|
+
/** NIP-05: the statement is signed by the Nostr key the domain's nostr.json names for itself. */
|
|
559
|
+
function nip05Signer(id) {
|
|
560
|
+
const extension = id === "nip05-nip07";
|
|
561
|
+
return {
|
|
562
|
+
id,
|
|
563
|
+
kind: "in-app",
|
|
564
|
+
label: extension ? "NIP-05 with a browser extension" : "NIP-05 with a remote signer",
|
|
565
|
+
description: "Already run NIP-05? Sign with the Nostr key your domain's nostr.json names for itself. No new record needed.",
|
|
566
|
+
fields: extension ? [domainField] : [domainField, {
|
|
567
|
+
name: "bunker",
|
|
568
|
+
label: "Signer connection link",
|
|
569
|
+
kind: "secret",
|
|
570
|
+
placeholder: "bunker://…"
|
|
571
|
+
}],
|
|
572
|
+
platforms: extension ? ["web", "desktop"] : void 0,
|
|
573
|
+
available: extension ? () => !!extensionSigner() : void 0,
|
|
574
|
+
run: (ctx, work) => {
|
|
575
|
+
const domain = normalizeDomain(ctx.values.domain ?? "");
|
|
576
|
+
return withNostrSigner({
|
|
577
|
+
bunker: extension ? void 0 : ctx.values.bunker,
|
|
578
|
+
signal: ctx.signal,
|
|
579
|
+
onAuth: ctx.onAuthUrl
|
|
580
|
+
}, (signer) => {
|
|
581
|
+
return work({
|
|
582
|
+
subject: async () => domain,
|
|
583
|
+
sign: async (statement) => {
|
|
584
|
+
ctx.onProgress("Approve the Ghostly identity statement in your signer.");
|
|
585
|
+
return {
|
|
586
|
+
method: "nip05",
|
|
587
|
+
event: await signer.signEvent(nostrProofTemplate(statement))
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
var domain = {
|
|
596
|
+
id: "domain",
|
|
597
|
+
label: "Domain",
|
|
598
|
+
category: "self-custodied",
|
|
599
|
+
summary: "A DNS record or a file on your site",
|
|
600
|
+
description: "Proves you control a domain name: publish a DNS TXT record or a file on its website, once. Contacts' apps look it up when you share it and can check again later.",
|
|
601
|
+
limits: "Does not prove who runs the domain, only that whoever added the proof could publish under it.",
|
|
602
|
+
platforms: [
|
|
603
|
+
"web",
|
|
604
|
+
"extension",
|
|
605
|
+
"desktop"
|
|
606
|
+
],
|
|
607
|
+
subject: {
|
|
608
|
+
label: "Domain",
|
|
609
|
+
placeholder: "example.com",
|
|
610
|
+
help: "A domain or subdomain you control. A proof for alice.example.com is published under alice.example.com.",
|
|
611
|
+
normalize: normalizeDomain
|
|
612
|
+
},
|
|
613
|
+
publicUri: (domain) => `https://${domain}`,
|
|
614
|
+
validity: {
|
|
615
|
+
defaultDays: 90,
|
|
616
|
+
maxDays: 365
|
|
617
|
+
},
|
|
618
|
+
recheck: { afterSeconds: 86400 },
|
|
619
|
+
privacy: "DNS: the contact's app asks the DNS-over-HTTPS resolver chosen in its settings (Quad9 by default; the next of Quad9, Cloudflare and Google when it cannot be reached) for the record, so that resolver learns the domain was checked. Website or NIP-05: the resolver learns the domain, and the domain's web server sees the contact's IP address.",
|
|
620
|
+
signers: [
|
|
621
|
+
dnsSigner,
|
|
622
|
+
httpsSigner,
|
|
623
|
+
nip05Signer("nip05-nip07"),
|
|
624
|
+
nip05Signer("nip05-nip46")
|
|
625
|
+
],
|
|
626
|
+
parseEvidence(raw) {
|
|
627
|
+
const e = raw;
|
|
628
|
+
if (exactKeys$2(raw, "method") && (e.method === "dns" || e.method === "https")) return { method: e.method };
|
|
629
|
+
if (exactKeys$2(raw, "event,method") && e.method === "nip05") return {
|
|
630
|
+
method: "nip05",
|
|
631
|
+
event: nostr.parseEvidence(e.event)
|
|
632
|
+
};
|
|
633
|
+
throw new Error("That is not domain proof evidence");
|
|
634
|
+
},
|
|
635
|
+
async verify(statement, evidence, ctx) {
|
|
636
|
+
const subject = statement.binding.subject;
|
|
637
|
+
if (normalizeDomain(subject) !== subject) throw new Error("The domain is not in canonical form");
|
|
638
|
+
const resolver = chosenResolver();
|
|
639
|
+
const options = {
|
|
640
|
+
fetch: ctx.fetch,
|
|
641
|
+
signal: ctx.signal,
|
|
642
|
+
resolver: resolver.id,
|
|
643
|
+
now: () => ctx.now * 1e3
|
|
644
|
+
};
|
|
645
|
+
if (evidence.method === "nip05") {
|
|
646
|
+
await nostr.verify({
|
|
647
|
+
...statement,
|
|
648
|
+
binding: {
|
|
649
|
+
...statement.binding,
|
|
650
|
+
subject: evidence.event.pubkey
|
|
651
|
+
}
|
|
652
|
+
}, evidence.event, ctx);
|
|
653
|
+
await assertNip05(subject, evidence.event.pubkey, options);
|
|
654
|
+
return {
|
|
655
|
+
subject,
|
|
656
|
+
source: `Nostr signature by the key ${wellKnownUrl(subject, "nip05")} names for ${subject}`
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
const found = await assertDomainRecord(subject, evidence.method, recordFor(statement), options);
|
|
660
|
+
return {
|
|
661
|
+
subject,
|
|
662
|
+
source: evidence.method === "dns" ? `DNS TXT record at ${dnsProofName(subject)}, via ${resolverById(found.resolver).name}${found.dnssec ? ", DNSSEC validated" : ""}` : `File at ${wellKnownUrl(subject)}`
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
//#endregion
|
|
667
|
+
//#region ../browser/src/proofs/openpgp.ts
|
|
668
|
+
/**
|
|
669
|
+
* OpenPGP identity proofs (RFC 9580, and the RFC 4880 v4 keys people actually have).
|
|
670
|
+
*
|
|
671
|
+
* The person signs a Ghostly statement once — the binding by which their
|
|
672
|
+
* OpenPGP key authorizes a Ghostly proof key (see PROOFS.md); each chat
|
|
673
|
+
* then gets a presentation the app signs itself. They sign with their own tooling —
|
|
674
|
+
* `gpg --clearsign`, `gpg --detach-sign --armor`, a YubiKey or another OpenPGP
|
|
675
|
+
* card behind gpg — and pastes the result with their public key. No secret key
|
|
676
|
+
* ever enters Ghostly: a pasted private key is refused before it is parsed.
|
|
677
|
+
*
|
|
678
|
+
* What is shared with a contact is small and self-contained: one signature
|
|
679
|
+
* packet and a minimal copy of the public key (the primary key, its user IDs
|
|
680
|
+
* with their self-signatures, the subkey that signed). The contact verifies it
|
|
681
|
+
* locally with the statement their own app builds; nothing is fetched.
|
|
682
|
+
*
|
|
683
|
+
* A key's user IDs are claims its holder wrote. Possession of the key proves
|
|
684
|
+
* control of the key, not that a name or an email in a user ID belongs to the
|
|
685
|
+
* person — the UI must say so wherever user IDs are shown.
|
|
686
|
+
*
|
|
687
|
+
* OpenPGP.js is loaded lazily (the "lightweight" build: ~233 KB minified,
|
|
688
|
+
* ~64 KB gzipped, plus a ~46 KB curve chunk only for ECDSA keys), so nobody
|
|
689
|
+
* pays for it until an OpenPGP proof is opened.
|
|
690
|
+
*/
|
|
691
|
+
var PGP_SCHEME = "openpgp/1";
|
|
692
|
+
/** Strict size limits. Pasted input is the person's own; shared input comes from a peer. */
|
|
693
|
+
var PGP_LIMITS = {
|
|
694
|
+
/** A clearsigned statement or an armored detached signature. RSA-4096 with the statement is ~1.5 KB. */
|
|
695
|
+
pastedSignature: 16384,
|
|
696
|
+
/** An armored public key as exported, before it is minimized. Flooded keys are refused, not trimmed. */
|
|
697
|
+
pastedKey: 262144,
|
|
698
|
+
/** One signature packet, binary. RSA-4096 with the usual subpackets is ~600 bytes. */
|
|
699
|
+
sharedSignature: 1536,
|
|
700
|
+
/** The minimal key, binary. Both fit, base64url, in the contract's 16 KiB of evidence JSON. */
|
|
701
|
+
sharedKey: 9216,
|
|
702
|
+
/** User IDs kept in the minimal key and shown. */
|
|
703
|
+
userIds: 16,
|
|
704
|
+
/** Bytes per user ID. */
|
|
705
|
+
userIdLength: 256,
|
|
706
|
+
/** A keyserver response. */
|
|
707
|
+
keyserverResponse: 262144
|
|
708
|
+
};
|
|
709
|
+
var loading;
|
|
710
|
+
/** The only place OpenPGP.js is imported: a separate chunk, fetched on first use. */
|
|
711
|
+
function loadOpenPgp() {
|
|
712
|
+
loading ??= import("./openpgp-BBFr-zHQ.js").then((m) => m).catch((e) => {
|
|
713
|
+
loading = void 0;
|
|
714
|
+
throw e;
|
|
715
|
+
});
|
|
716
|
+
return loading;
|
|
717
|
+
}
|
|
718
|
+
function config(pgp) {
|
|
719
|
+
const { hash, publicKey, curve } = pgp.enums;
|
|
720
|
+
return {
|
|
721
|
+
minRSABits: 2048,
|
|
722
|
+
maxUserIDLength: PGP_LIMITS.userIdLength,
|
|
723
|
+
enableParsingV5Entities: false,
|
|
724
|
+
ignoreUnsupportedPackets: true,
|
|
725
|
+
ignoreMalformedPackets: false,
|
|
726
|
+
rejectHashAlgorithms: /* @__PURE__ */ new Set([hash.md5, hash.ripemd]),
|
|
727
|
+
rejectMessageHashAlgorithms: /* @__PURE__ */ new Set([
|
|
728
|
+
hash.md5,
|
|
729
|
+
hash.ripemd,
|
|
730
|
+
hash.sha1
|
|
731
|
+
]),
|
|
732
|
+
rejectPublicKeyAlgorithms: /* @__PURE__ */ new Set([publicKey.elgamal, publicKey.dsa]),
|
|
733
|
+
rejectCurves: /* @__PURE__ */ new Set([curve.secp256k1])
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
/** The allowlist, checked on the primary key and on the key that signs. */
|
|
737
|
+
function allowedAlgorithm(packet, signing) {
|
|
738
|
+
const { algorithm, bits, curve } = packet.getAlgorithmInfo();
|
|
739
|
+
const names = {
|
|
740
|
+
nistP256: "P-256",
|
|
741
|
+
nistP384: "P-384",
|
|
742
|
+
nistP521: "P-521",
|
|
743
|
+
brainpoolP256r1: "brainpoolP256r1",
|
|
744
|
+
brainpoolP384r1: "brainpoolP384r1",
|
|
745
|
+
brainpoolP512r1: "brainpoolP512r1"
|
|
746
|
+
};
|
|
747
|
+
if ((algorithm === "rsaEncryptSign" || algorithm === "rsaSign" && signing) && bits && bits >= 2048) return `RSA ${bits}`;
|
|
748
|
+
if (algorithm === "ed25519" || algorithm === "eddsaLegacy" && curve === "ed25519Legacy") return "Ed25519";
|
|
749
|
+
if (algorithm === "ed448") return "Ed448";
|
|
750
|
+
if (algorithm === "ecdsa" && curve && names[curve]) return `ECDSA ${names[curve]}`;
|
|
751
|
+
throw new PgpProofError(`This key uses ${algorithm.startsWith("rsa") ? `RSA ${bits ?? "?"}` : curve ? `${algorithm} ${curve}` : algorithm}, which Ghostly does not accept. Use Ed25519, ECDSA (P-256, P-384, P-521, brainpool) or RSA with at least 2048 bits.`);
|
|
752
|
+
}
|
|
753
|
+
var ALLOWED_HASHES = /* @__PURE__ */ new Set([
|
|
754
|
+
8,
|
|
755
|
+
9,
|
|
756
|
+
10,
|
|
757
|
+
11,
|
|
758
|
+
12,
|
|
759
|
+
14
|
|
760
|
+
]);
|
|
761
|
+
var PgpProofError = class extends Error {
|
|
762
|
+
name = "PgpProofError";
|
|
763
|
+
};
|
|
764
|
+
/** Armored blocks in pasted text; more BEGIN lines than any real paste has are refused before the regex scans. */
|
|
765
|
+
function armorBlocks(text) {
|
|
766
|
+
if ((text.match(/-----BEGIN PGP /g) ?? []).length > 4) throw new PgpProofError("Paste only what gpg printed: one signature and your public key.");
|
|
767
|
+
return [...text.matchAll(ARMOR)];
|
|
768
|
+
}
|
|
769
|
+
var ARMOR = /-----BEGIN PGP SIGNED MESSAGE-----[\s\S]*?-----END PGP SIGNATURE-----|-----BEGIN PGP ([A-Z ,/0-9]+)-----[\s\S]*?-----END PGP \1-----/g;
|
|
770
|
+
/** The single armored block in pasted text (terminal output may surround it). */
|
|
771
|
+
function armoredBlock(text, what) {
|
|
772
|
+
if (/PRIVATE KEY BLOCK/.test(text)) throw new PgpProofError("This is a PRIVATE key. Never paste it anywhere: Ghostly only needs your public key (gpg --armor --export).");
|
|
773
|
+
const blocks = armorBlocks(text);
|
|
774
|
+
if (blocks.length === 0) throw new PgpProofError(what === "key" ? "Paste an armored public key: it starts with -----BEGIN PGP PUBLIC KEY BLOCK-----." : "Paste the whole signed text: it starts with -----BEGIN PGP SIGNED MESSAGE----- (or -----BEGIN PGP SIGNATURE----- for a detached signature).");
|
|
775
|
+
if (blocks.length > 1) throw new PgpProofError(`Paste only one ${what === "key" ? "public key" : "signature"}.`);
|
|
776
|
+
return {
|
|
777
|
+
type: blocks[0][1] ?? "SIGNED MESSAGE",
|
|
778
|
+
block: blocks[0][0]
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
function secondsOf(date) {
|
|
782
|
+
if (date === null || date === Infinity) return null;
|
|
783
|
+
return Math.floor((date instanceof Date ? date.getTime() : date) / 1e3);
|
|
784
|
+
}
|
|
785
|
+
var hexOf = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
786
|
+
var nowSeconds = () => Math.floor(Date.now() / 1e3);
|
|
787
|
+
/** Clearsigned text as it is hashed: trailing whitespace dropped, no final newline. */
|
|
788
|
+
function canonicalStatement(statement) {
|
|
789
|
+
return statement.replace(/\r\n?/g, "\n").split("\n").map((l) => l.replace(/[ \t]+$/, "")).join("\n").replace(/\n+$/, "");
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* One paste holding what gpg printed: the signed statement (clearsigned, or a detached signature) and,
|
|
793
|
+
* optionally, the public key. Anything around the blocks (prompts, blank lines) is ignored.
|
|
794
|
+
*/
|
|
795
|
+
function splitPgpPaste(pasted) {
|
|
796
|
+
if (typeof pasted !== "string" || pasted.length > PGP_LIMITS.pastedKey + PGP_LIMITS.pastedSignature) throw new PgpProofError("This is too large. Paste only what gpg printed.");
|
|
797
|
+
if (/PRIVATE KEY BLOCK/.test(pasted)) throw new PgpProofError("This is a PRIVATE key. Never paste it anywhere: Ghostly only needs your public key (gpg --armor --export).");
|
|
798
|
+
const blocks = armorBlocks(pasted).map((m) => ({
|
|
799
|
+
type: m[1] ?? "SIGNED MESSAGE",
|
|
800
|
+
block: m[0]
|
|
801
|
+
}));
|
|
802
|
+
const keys = blocks.filter((b) => b.type === "PUBLIC KEY BLOCK"), others = blocks.filter((b) => b.type !== "PUBLIC KEY BLOCK");
|
|
803
|
+
if (keys.length > 1) throw new PgpProofError("Paste only one public key.");
|
|
804
|
+
if (others.length === 0) throw new PgpProofError("Paste the signed statement too: it starts with -----BEGIN PGP SIGNED MESSAGE-----.");
|
|
805
|
+
if (others.length > 1) throw new PgpProofError("Paste only one signature.");
|
|
806
|
+
return {
|
|
807
|
+
signature: others[0].block,
|
|
808
|
+
publicKey: keys[0]?.block
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
/** Refuses a statement that `gpg --clearsign` would alter (dash-escaping, trailing spaces) or that is not plain text. */
|
|
812
|
+
function assertSignableStatement(statement) {
|
|
813
|
+
if (!statement || statement.length > 4096 || statement !== canonicalStatement(statement) || /^-/m.test(statement) || /[^\x20-\x7e\n]/.test(statement)) throw new PgpProofError("Statement is not signable as plain text");
|
|
814
|
+
}
|
|
815
|
+
/** Why a key or a subkey is not usable now, in words the person can act on. */
|
|
816
|
+
async function explainInvalidKey(key, keyId, at, cfg, when) {
|
|
817
|
+
const past = when === "signing" ? " when it signed" : "";
|
|
818
|
+
if (await key.isRevoked(void 0, void 0, at, cfg).catch(() => false)) throw new PgpProofError(`This key has been revoked${past}. A revoked key proves nothing.`);
|
|
819
|
+
try {
|
|
820
|
+
await key.verifyPrimaryKey(at, void 0, cfg);
|
|
821
|
+
} catch (e) {
|
|
822
|
+
const expiry = await key.getExpirationTime(void 0, cfg).catch(() => null);
|
|
823
|
+
if (expiry instanceof Date && expiry <= at) throw new PgpProofError(`This key expired on ${expiry.toISOString().slice(0, 10)}${past}. Extend its expiry (gpg --quick-set-expire) and export it again.`);
|
|
824
|
+
throw new PgpProofError(`This key is not valid${past}: ${message(e)}`);
|
|
825
|
+
}
|
|
826
|
+
const sub = keyId ? key.getSubkeys(keyId)[0] : void 0;
|
|
827
|
+
if (sub) {
|
|
828
|
+
if (sub.revocationSignatures.length && await sub.verify(at, cfg).then(() => false, (e) => /revoked/i.test(message(e)))) throw new PgpProofError(`The subkey that signed (${sub.getFingerprint().toUpperCase()}) has been revoked${past}.`);
|
|
829
|
+
const expiry = await sub.getExpirationTime(at, cfg).catch(() => null);
|
|
830
|
+
if (expiry instanceof Date && expiry <= at) throw new PgpProofError(`The subkey that signed expired on ${expiry.toISOString().slice(0, 10)}${past}.`);
|
|
831
|
+
throw new PgpProofError(`The subkey that signed is not a valid signing subkey${past}.`);
|
|
832
|
+
}
|
|
833
|
+
throw new PgpProofError(`The key that signed is not a valid signing key${past}.`);
|
|
834
|
+
}
|
|
835
|
+
var message = (e) => e instanceof Error ? e.message : String(e);
|
|
836
|
+
/** Parses exactly one public key; refuses private keys, v3/v5 keys and key rings. */
|
|
837
|
+
async function readOneKey(pgp, input) {
|
|
838
|
+
const cfg = config(pgp);
|
|
839
|
+
let keys;
|
|
840
|
+
try {
|
|
841
|
+
keys = "armored" in input ? await pgp.readKeys({
|
|
842
|
+
armoredKeys: input.armored,
|
|
843
|
+
config: cfg
|
|
844
|
+
}) : await pgp.readKeys({
|
|
845
|
+
binaryKeys: input.binary,
|
|
846
|
+
config: cfg
|
|
847
|
+
});
|
|
848
|
+
} catch (e) {
|
|
849
|
+
throw new PgpProofError(`Could not read this public key: ${message(e)}`);
|
|
850
|
+
}
|
|
851
|
+
if (keys.length !== 1) throw new PgpProofError("Paste one public key, not a key ring.");
|
|
852
|
+
const [key] = keys;
|
|
853
|
+
if (key.isPrivate()) throw new PgpProofError("This is a PRIVATE key. Ghostly only needs your public key (gpg --armor --export).");
|
|
854
|
+
const version = key.keyPacket.version;
|
|
855
|
+
if (version !== 4 && version !== 6) throw new PgpProofError(`OpenPGP v${version} keys are not accepted; use a v4 (RFC 4880) or v6 (RFC 9580) key.`);
|
|
856
|
+
return key;
|
|
857
|
+
}
|
|
858
|
+
/** Valid, unrevoked, self-certified user IDs at `at`. */
|
|
859
|
+
async function validUserIds(key, at, cfg) {
|
|
860
|
+
const valid = [];
|
|
861
|
+
for (const user of key.users) {
|
|
862
|
+
if (!user.userID || valid.length >= PGP_LIMITS.userIds) continue;
|
|
863
|
+
if (await user.verify(at, cfg).then(() => true, () => false)) valid.push(user);
|
|
864
|
+
}
|
|
865
|
+
return valid;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* The transferable key a contact receives: primary key and its direct/revocation
|
|
869
|
+
* signatures, valid user IDs with self-certifications only (third-party
|
|
870
|
+
* certifications and photos dropped), and the one subkey that signed.
|
|
871
|
+
*/
|
|
872
|
+
async function minimalKey(pgp, key, signer, at, cfg) {
|
|
873
|
+
const packets = new pgp.PacketList();
|
|
874
|
+
packets.push(key.keyPacket, ...key.revocationSignatures);
|
|
875
|
+
const direct = key.directSignatures ?? [];
|
|
876
|
+
packets.push(...direct);
|
|
877
|
+
for (const user of await validUserIds(key, at, cfg)) packets.push(user.userID, ...user.selfCertifications, ...user.revocationSignatures);
|
|
878
|
+
if (signer !== key) {
|
|
879
|
+
const sub = signer;
|
|
880
|
+
packets.push(sub.keyPacket, ...sub.bindingSignatures, ...sub.revocationSignatures);
|
|
881
|
+
}
|
|
882
|
+
const bytes = new pgp.PublicKey(packets).write();
|
|
883
|
+
if (bytes.length > PGP_LIMITS.sharedKey) throw new PgpProofError("This key is too large to share, even without third-party certifications. Remove old user IDs or use a smaller key.");
|
|
884
|
+
return bytes;
|
|
885
|
+
}
|
|
886
|
+
async function keyInfo(key, signer, algorithm, at, cfg) {
|
|
887
|
+
const expiry = await key.getExpirationTime(void 0, cfg).catch(() => null);
|
|
888
|
+
const subExpiry = signer !== key ? await signer.getExpirationTime(at, cfg).catch(() => null) : null;
|
|
889
|
+
const expires = [secondsOf(expiry), secondsOf(subExpiry)].filter((x) => x !== null);
|
|
890
|
+
return {
|
|
891
|
+
fingerprint: key.getFingerprint().toUpperCase(),
|
|
892
|
+
signingFingerprint: signer.getFingerprint().toUpperCase(),
|
|
893
|
+
version: key.keyPacket.version,
|
|
894
|
+
algorithm,
|
|
895
|
+
createdAt: secondsOf(key.getCreationTime()),
|
|
896
|
+
expiresAt: expires.length ? Math.min(...expires) : null,
|
|
897
|
+
userIds: (await validUserIds(key, at, cfg)).map((u) => u.userID.userID)
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
/** What a pasted public key is, before anything is signed: fingerprint and user IDs to show. */
|
|
901
|
+
async function readPgpPublicKey(armored, options = {}) {
|
|
902
|
+
if (typeof armored !== "string" || armored.length > PGP_LIMITS.pastedKey) throw new PgpProofError("This public key is too large. Export it with --export-options export-minimal.");
|
|
903
|
+
const { type, block } = armoredBlock(armored, "key");
|
|
904
|
+
if (type !== "PUBLIC KEY BLOCK") throw new PgpProofError("Paste a public key block (gpg --armor --export).");
|
|
905
|
+
const pgp = await loadOpenPgp();
|
|
906
|
+
const cfg = config(pgp);
|
|
907
|
+
const key = await readOneKey(pgp, { armored: block });
|
|
908
|
+
const at = /* @__PURE__ */ new Date((options.now ?? nowSeconds()) * 1e3);
|
|
909
|
+
allowedAlgorithm(key.keyPacket, false);
|
|
910
|
+
let signer;
|
|
911
|
+
try {
|
|
912
|
+
signer = await key.getSigningKey(void 0, at, void 0, cfg);
|
|
913
|
+
} catch {
|
|
914
|
+
return explainInvalidKey(key, void 0, at, cfg, "now");
|
|
915
|
+
}
|
|
916
|
+
return keyInfo(key, signer, allowedAlgorithm(signer.keyPacket, true), at, cfg);
|
|
917
|
+
}
|
|
918
|
+
async function readSignaturePacket(pgp, input) {
|
|
919
|
+
let sig;
|
|
920
|
+
try {
|
|
921
|
+
sig = "armored" in input ? await pgp.readSignature({
|
|
922
|
+
armoredSignature: input.armored,
|
|
923
|
+
config: config(pgp)
|
|
924
|
+
}) : await pgp.readSignature({
|
|
925
|
+
binarySignature: input.binary,
|
|
926
|
+
config: config(pgp)
|
|
927
|
+
});
|
|
928
|
+
} catch (e) {
|
|
929
|
+
throw new PgpProofError(`Could not read this signature: ${message(e)}`);
|
|
930
|
+
}
|
|
931
|
+
const packets = sig.packets.filterByTag(pgp.enums.packet.signature);
|
|
932
|
+
if (sig.packets.length !== 1 || packets.length !== 1) throw new PgpProofError("Sign with one key only: this signature has several signers.");
|
|
933
|
+
return packets[0];
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* The core check, shared by the person (on paste) and the contact (on receipt):
|
|
937
|
+
* one signature over the statement, by a key or signing subkey of `key` that is
|
|
938
|
+
* valid both when it signed and now, with allowed algorithms and hash.
|
|
939
|
+
*/
|
|
940
|
+
async function verifyPacket(pgp, key, sig, statement, options) {
|
|
941
|
+
const cfg = config(pgp);
|
|
942
|
+
const now = options.now ?? nowSeconds();
|
|
943
|
+
const at = /* @__PURE__ */ new Date(now * 1e3);
|
|
944
|
+
const { signature } = pgp.enums;
|
|
945
|
+
if (sig.signatureType !== signature.text && sig.signatureType !== signature.binary) throw new PgpProofError("This is not a signature over a text or a file.");
|
|
946
|
+
if (sig.hashAlgorithm === null || !ALLOWED_HASHES.has(sig.hashAlgorithm)) throw new PgpProofError("This signature uses a weak hash (SHA-1 or older). Sign again with gpg's default (SHA-256 or stronger): --digest-algo SHA256.");
|
|
947
|
+
if (!sig.created) throw new PgpProofError("This signature has no creation time.");
|
|
948
|
+
const signedAt = secondsOf(sig.created);
|
|
949
|
+
if (signedAt > now + 300) throw new PgpProofError("This signature is dated in the future. Check the clock of the machine that signed.");
|
|
950
|
+
if (options.expectedFingerprint !== void 0 && key.getFingerprint().toUpperCase() !== options.expectedFingerprint.toUpperCase()) throw new PgpProofError("This key is not the one the statement names.");
|
|
951
|
+
const issuer = key.getKeys(sig.issuerKeyID)[0];
|
|
952
|
+
if (!issuer) throw new PgpProofError(`This signature was made by another key (${sig.issuerKeyID.toHex().toUpperCase()}), not the public key given.`);
|
|
953
|
+
if (sig.issuerFingerprint && hexOf(sig.issuerFingerprint) !== issuer.getFingerprint().toUpperCase()) throw new PgpProofError("This signature was made by another key, not the public key given.");
|
|
954
|
+
allowedAlgorithm(key.keyPacket, false);
|
|
955
|
+
let signer;
|
|
956
|
+
try {
|
|
957
|
+
signer = await key.getSigningKey(sig.issuerKeyID, at, void 0, cfg);
|
|
958
|
+
} catch {
|
|
959
|
+
return explainInvalidKey(key, sig.issuerKeyID, at, cfg, "now");
|
|
960
|
+
}
|
|
961
|
+
const algorithm = allowedAlgorithm(signer.keyPacket, true);
|
|
962
|
+
const created = /* @__PURE__ */ new Date(Math.min(signedAt, now) * 1e3);
|
|
963
|
+
try {
|
|
964
|
+
await key.getSigningKey(sig.issuerKeyID, created, void 0, cfg);
|
|
965
|
+
} catch {
|
|
966
|
+
return explainInvalidKey(key, sig.issuerKeyID, created, cfg, "signing");
|
|
967
|
+
}
|
|
968
|
+
if (options.signedAfter !== void 0 && signedAt < options.signedAfter - 300) throw new PgpProofError("This signature is older than the statement it should sign.");
|
|
969
|
+
const text = canonicalStatement(statement);
|
|
970
|
+
const packets = new pgp.PacketList();
|
|
971
|
+
packets.push(sig);
|
|
972
|
+
const detached = new pgp.Signature(packets);
|
|
973
|
+
let verified = false;
|
|
974
|
+
for (const candidate of [
|
|
975
|
+
text,
|
|
976
|
+
`${text}\n`,
|
|
977
|
+
`${text}\r\n`
|
|
978
|
+
]) {
|
|
979
|
+
const message = await pgp.createMessage({ binary: utf8Encode(candidate) });
|
|
980
|
+
const result = await pgp.verify({
|
|
981
|
+
message,
|
|
982
|
+
signature: detached,
|
|
983
|
+
verificationKeys: key,
|
|
984
|
+
date: /* @__PURE__ */ new Date(Math.max(now, signedAt) * 1e3),
|
|
985
|
+
config: cfg
|
|
986
|
+
});
|
|
987
|
+
if (result.signatures.length === 1 && await result.signatures[0].verified.then(() => true, () => false)) {
|
|
988
|
+
verified = true;
|
|
989
|
+
break;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (!verified) throw new PgpProofError("This signature is not over the Ghostly statement (it signs other text, or it was changed).");
|
|
993
|
+
return {
|
|
994
|
+
info: {
|
|
995
|
+
...await keyInfo(key, signer, algorithm, at, cfg),
|
|
996
|
+
signedAt
|
|
997
|
+
},
|
|
998
|
+
signer
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* The person's side: checks what they pasted against the statement and
|
|
1003
|
+
* produces the evidence to share. Either a clearsigned statement or an
|
|
1004
|
+
* armored detached signature is accepted.
|
|
1005
|
+
*/
|
|
1006
|
+
async function preparePgpEvidence(input, options = {}) {
|
|
1007
|
+
if (typeof input.signature !== "string" || input.signature.length > PGP_LIMITS.pastedSignature) throw new PgpProofError("This signature is too large. Paste only the output of gpg --clearsign.");
|
|
1008
|
+
if (typeof input.publicKey !== "string" || input.publicKey.length > PGP_LIMITS.pastedKey) throw new PgpProofError("This public key is too large. Export it with --export-options export-minimal.");
|
|
1009
|
+
const { type, block } = armoredBlock(input.signature, "signature");
|
|
1010
|
+
const keyBlock = armoredBlock(input.publicKey, "key");
|
|
1011
|
+
if (keyBlock.type !== "PUBLIC KEY BLOCK") throw new PgpProofError("Paste a public key block (gpg --armor --export).");
|
|
1012
|
+
if (type === "MESSAGE") throw new PgpProofError("This is a signed message (gpg --sign). Use gpg --clearsign, or --detach-sign --armor.");
|
|
1013
|
+
if (type === "PUBLIC KEY BLOCK") throw new PgpProofError("This is a public key; paste it in the public key field and the signature here.");
|
|
1014
|
+
const pgp = await loadOpenPgp();
|
|
1015
|
+
const key = await readOneKey(pgp, { armored: keyBlock.block });
|
|
1016
|
+
let sig;
|
|
1017
|
+
if (type === "SIGNED MESSAGE") {
|
|
1018
|
+
let cleartext;
|
|
1019
|
+
try {
|
|
1020
|
+
cleartext = await pgp.readCleartextMessage({
|
|
1021
|
+
cleartextMessage: block,
|
|
1022
|
+
config: config(pgp)
|
|
1023
|
+
});
|
|
1024
|
+
} catch (e) {
|
|
1025
|
+
throw new PgpProofError(`Could not read this signed text: ${message(e)}`);
|
|
1026
|
+
}
|
|
1027
|
+
if (canonicalStatement(cleartext.getText()) !== canonicalStatement(input.statement)) throw new PgpProofError("This signs different text. Sign exactly the statement shown, without editing it.");
|
|
1028
|
+
const packets = cleartext.signature.packets;
|
|
1029
|
+
if (packets.length !== 1 || !(packets[0] instanceof pgp.SignaturePacket)) throw new PgpProofError("Sign with one key only: this signature has several signers.");
|
|
1030
|
+
sig = packets[0];
|
|
1031
|
+
} else if (type === "SIGNATURE") sig = await readSignaturePacket(pgp, { armored: block });
|
|
1032
|
+
else throw new PgpProofError("Paste the output of gpg --clearsign (or gpg --detach-sign --armor).");
|
|
1033
|
+
const { info, signer } = await verifyPacket(pgp, key, sig, input.statement, options);
|
|
1034
|
+
const single = new pgp.PacketList();
|
|
1035
|
+
single.push(sig);
|
|
1036
|
+
const signature = new pgp.Signature(single).write();
|
|
1037
|
+
if (signature.length > PGP_LIMITS.sharedSignature) throw new PgpProofError("This signature is too large to share.");
|
|
1038
|
+
const at = /* @__PURE__ */ new Date((options.now ?? nowSeconds()) * 1e3);
|
|
1039
|
+
return {
|
|
1040
|
+
evidence: {
|
|
1041
|
+
scheme: PGP_SCHEME,
|
|
1042
|
+
key: toBase64Url(await minimalKey(pgp, key, signer, at, config(pgp))),
|
|
1043
|
+
signature: toBase64Url(signature)
|
|
1044
|
+
},
|
|
1045
|
+
verified: info
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
var b64 = /^[A-Za-z0-9_-]+$/;
|
|
1049
|
+
/** Untrusted evidence off the wire: exact shape, bounded, canonical base64url. Checked before any OpenPGP parsing. */
|
|
1050
|
+
function parsePgpEvidence(raw) {
|
|
1051
|
+
const e = raw;
|
|
1052
|
+
if (!e || typeof e !== "object" || Array.isArray(e) || e.scheme !== "openpgp/1" || Object.keys(e).sort().join(",") !== "key,scheme,signature" || typeof e.key !== "string" || typeof e.signature !== "string" || !b64.test(e.key) || !b64.test(e.signature) || e.key.length > Math.ceil(PGP_LIMITS.sharedKey * 4 / 3) || e.signature.length > Math.ceil(PGP_LIMITS.sharedSignature * 4 / 3)) throw new PgpProofError("Invalid OpenPGP proof");
|
|
1053
|
+
let canonical;
|
|
1054
|
+
try {
|
|
1055
|
+
canonical = toBase64Url(fromBase64Url(e.key)) === e.key && toBase64Url(fromBase64Url(e.signature)) === e.signature;
|
|
1056
|
+
} catch {
|
|
1057
|
+
canonical = false;
|
|
1058
|
+
}
|
|
1059
|
+
if (!canonical) throw new PgpProofError("Invalid OpenPGP proof encoding");
|
|
1060
|
+
return {
|
|
1061
|
+
scheme: PGP_SCHEME,
|
|
1062
|
+
key: e.key,
|
|
1063
|
+
signature: e.signature
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
/** The contact's side: verifies shared evidence against the statement their own app built. */
|
|
1067
|
+
async function verifyPgpEvidence(raw, statement, options = {}) {
|
|
1068
|
+
const evidence = parsePgpEvidence(raw);
|
|
1069
|
+
const keyBytes = fromBase64Url(evidence.key), sigBytes = fromBase64Url(evidence.signature);
|
|
1070
|
+
const pgp = await loadOpenPgp();
|
|
1071
|
+
return (await verifyPacket(pgp, await readOneKey(pgp, { binary: keyBytes }), await readSignaturePacket(pgp, { binary: sigBytes }), statement, options)).info;
|
|
1072
|
+
}
|
|
1073
|
+
/** Groups of four, as gpg prints fingerprints. */
|
|
1074
|
+
function formatFingerprint(fingerprint) {
|
|
1075
|
+
return fingerprint.replace(/(.{4})/g, "$1 ").trim();
|
|
1076
|
+
}
|
|
1077
|
+
/** keys.openpgp.org publishes a user ID only after its owner confirms the email. */
|
|
1078
|
+
var KEYSERVER = "https://keys.openpgp.org";
|
|
1079
|
+
/**
|
|
1080
|
+
* Fetches a public key from keys.openpgp.org. Only ever called when the person
|
|
1081
|
+
* asks for it, after the UI said this contacts that server.
|
|
1082
|
+
*/
|
|
1083
|
+
async function fetchKeyFromKeyserver(query, options = {}) {
|
|
1084
|
+
const q = query.trim().replace(/\s+/g, "").replace(/^0x/i, "");
|
|
1085
|
+
let path;
|
|
1086
|
+
if (/^[A-Fa-f0-9]{40}$/.test(q) || /^[A-Fa-f0-9]{64}$/.test(q)) path = `/vks/v1/by-fingerprint/${q.toUpperCase()}`;
|
|
1087
|
+
else if (/^[^\s@<>]{1,64}@[^\s@<>]{1,190}$/.test(query.trim())) path = `/vks/v1/by-email/${encodeURIComponent(query.trim())}`;
|
|
1088
|
+
else throw new PgpProofError("Enter a full fingerprint (40 hex characters) or an email address.");
|
|
1089
|
+
const response = await (options.fetch ?? fetch)(`${KEYSERVER}${path}`, {
|
|
1090
|
+
signal: options.signal,
|
|
1091
|
+
credentials: "omit",
|
|
1092
|
+
referrerPolicy: "no-referrer",
|
|
1093
|
+
redirect: "error"
|
|
1094
|
+
});
|
|
1095
|
+
if (response.status === 404) throw new PgpProofError("keys.openpgp.org has no key for this. Paste your public key instead.");
|
|
1096
|
+
if (!response.ok) throw new PgpProofError(`keys.openpgp.org answered ${response.status}. Paste your public key instead.`);
|
|
1097
|
+
if (Number(response.headers.get("content-length") ?? 0) > PGP_LIMITS.keyserverResponse) throw new PgpProofError("The key from keys.openpgp.org is too large.");
|
|
1098
|
+
const armored = await response.text();
|
|
1099
|
+
if (armored.length > PGP_LIMITS.keyserverResponse) throw new PgpProofError("The key from keys.openpgp.org is too large.");
|
|
1100
|
+
return {
|
|
1101
|
+
armored,
|
|
1102
|
+
source: KEYSERVER,
|
|
1103
|
+
fetchedAt: options.now ?? nowSeconds()
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Emails keys.openpgp.org vouches for on this key: it publishes a user ID only
|
|
1108
|
+
* once the email's owner confirmed it, so every user ID in its copy is one the
|
|
1109
|
+
* keyserver checked. That is the keyserver's check, and says nothing about a name.
|
|
1110
|
+
*/
|
|
1111
|
+
async function keyserverVerifiedEmails(keyserverArmored, fingerprint, options = {}) {
|
|
1112
|
+
const info = await readPgpPublicKey(keyserverArmored, options);
|
|
1113
|
+
if (info.fingerprint !== fingerprint.toUpperCase()) throw new PgpProofError("keys.openpgp.org returned a different key.");
|
|
1114
|
+
const emails = info.userIds.map((u) => /<([^<>\s]+@[^<>\s]+)>\s*$/.exec(u)?.[1] ?? (/^[^<>\s]+@[^<>\s]+$/.test(u) ? u : void 0));
|
|
1115
|
+
return [...new Set(emails.filter((e) => !!e).map((e) => e.toLowerCase()))];
|
|
1116
|
+
}
|
|
1117
|
+
//#endregion
|
|
1118
|
+
//#region ../browser/src/proofs/providers/openpgp.ts
|
|
1119
|
+
/**
|
|
1120
|
+
* OpenPGP keys, signed with the person's own gpg — a YubiKey or another OpenPGP card works unchanged,
|
|
1121
|
+
* since gpg drives it. The subject is the primary key's fingerprint. See docs/wisps/305-openpgp.md.
|
|
1122
|
+
*/
|
|
1123
|
+
/** A v4 (40 hex) or v6 (64 hex) fingerprint, as gpg prints it or not: spaces and 0x are dropped. */
|
|
1124
|
+
function normalizeFingerprint(input) {
|
|
1125
|
+
const value = input.trim().replace(/^0x/i, "").replace(/\s+/g, "").toUpperCase();
|
|
1126
|
+
if (/^[A-F0-9]{40}$/.test(value) || /^[A-F0-9]{64}$/.test(value)) return value;
|
|
1127
|
+
if (/^[A-F0-9]{8}$|^[A-F0-9]{16}$/.test(value)) throw new PgpProofError("Use the full fingerprint, not a key ID: gpg --fingerprint shows it.");
|
|
1128
|
+
throw new PgpProofError("Enter your key's fingerprint: 40 hex characters (gpg --fingerprint).");
|
|
1129
|
+
}
|
|
1130
|
+
/** The user ID is its holder's own words: shown with that caveat, never as a checked name. */
|
|
1131
|
+
var USER_ID_SOURCE = "User ID on the key, written by its holder: not proof that the name or email is theirs";
|
|
1132
|
+
var KEYSERVER_SOURCE = "Email confirmed to keys.openpgp.org by its owner: the keyserver's check, not Ghostly's";
|
|
1133
|
+
function steps(statement, exportKey) {
|
|
1134
|
+
assertSignableStatement(statement.text);
|
|
1135
|
+
if (statement.text.includes("'")) throw new PgpProofError("Statement is not signable as plain text");
|
|
1136
|
+
const fpr = normalizeFingerprint(statement.binding.subject);
|
|
1137
|
+
const sign = `gpg --local-user ${fpr} --clearsign --output - ghostly-identity.txt`;
|
|
1138
|
+
return {
|
|
1139
|
+
steps: [
|
|
1140
|
+
{
|
|
1141
|
+
text: "Save the statement to a file (it is one line, with no newline at the end):",
|
|
1142
|
+
copy: `printf '%s' '${statement.text}' > ghostly-identity.txt`
|
|
1143
|
+
},
|
|
1144
|
+
exportKey ? {
|
|
1145
|
+
text: "Sign it and export your public key, then copy everything this prints:",
|
|
1146
|
+
copy: `${sign} && gpg --armor --export --export-options export-minimal ${fpr}`
|
|
1147
|
+
} : {
|
|
1148
|
+
text: "Sign it, then copy everything this prints:",
|
|
1149
|
+
copy: sign
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
text: "On a YubiKey or another OpenPGP card, gpg asks for the card's PIN, and for a touch if the card requires one. Without a shell, save the statement below in a text file and sign that file.",
|
|
1153
|
+
copy: statement.text
|
|
1154
|
+
}
|
|
1155
|
+
],
|
|
1156
|
+
paste: {
|
|
1157
|
+
label: exportKey ? "Signed statement and public key" : "Signed statement",
|
|
1158
|
+
placeholder: "-----BEGIN PGP SIGNED MESSAGE-----",
|
|
1159
|
+
multiline: true
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
async function evidenceFrom(statement, signature, publicKey) {
|
|
1164
|
+
const { evidence } = await preparePgpEvidence({
|
|
1165
|
+
statement: statement.text,
|
|
1166
|
+
signature,
|
|
1167
|
+
publicKey
|
|
1168
|
+
}, {
|
|
1169
|
+
expectedFingerprint: normalizeFingerprint(statement.binding.subject),
|
|
1170
|
+
signedAfter: statement.binding.issuedAt
|
|
1171
|
+
});
|
|
1172
|
+
return evidence;
|
|
1173
|
+
}
|
|
1174
|
+
var openpgp = {
|
|
1175
|
+
id: "openpgp",
|
|
1176
|
+
label: "OpenPGP key",
|
|
1177
|
+
category: "self-custodied",
|
|
1178
|
+
summary: "Sign with gpg and paste the result",
|
|
1179
|
+
description: "Proves you hold an OpenPGP key: sign the statement with gpg (a YubiKey works too) and paste the result.",
|
|
1180
|
+
limits: "Holding a key does not prove the names or emails in its user IDs.",
|
|
1181
|
+
platforms: [
|
|
1182
|
+
"web",
|
|
1183
|
+
"extension",
|
|
1184
|
+
"desktop"
|
|
1185
|
+
],
|
|
1186
|
+
subject: {
|
|
1187
|
+
label: "Key fingerprint",
|
|
1188
|
+
placeholder: "40 hex characters, from gpg --fingerprint",
|
|
1189
|
+
help: "The primary key's full fingerprint. Holding a key does not prove the names or emails in its user IDs.",
|
|
1190
|
+
normalize: normalizeFingerprint,
|
|
1191
|
+
short: (fpr) => `…${formatFingerprint(fpr.slice(-16))}`
|
|
1192
|
+
},
|
|
1193
|
+
publicUri: (fpr) => `openpgp4fpr:${fpr}`,
|
|
1194
|
+
validity: {
|
|
1195
|
+
defaultDays: 90,
|
|
1196
|
+
maxDays: 365
|
|
1197
|
+
},
|
|
1198
|
+
privacy: `Nothing: the signature is checked on this device. ${KEYSERVER.replace("https://", "")} is contacted only if you choose it for your key, or ask it to confirm emails.`,
|
|
1199
|
+
signers: [{
|
|
1200
|
+
id: "gpg",
|
|
1201
|
+
kind: "external-tool",
|
|
1202
|
+
label: "gpg",
|
|
1203
|
+
description: "Sign with gpg and paste what it prints, public key included. Nothing is looked up.",
|
|
1204
|
+
instructions: (statement) => steps(statement, true),
|
|
1205
|
+
async parse(pasted, statement) {
|
|
1206
|
+
const { signature, publicKey } = splitPgpPaste(pasted);
|
|
1207
|
+
if (!publicKey) throw new PgpProofError("Paste your public key too: the second command prints it (-----BEGIN PGP PUBLIC KEY BLOCK-----).");
|
|
1208
|
+
return evidenceFrom(statement, signature, publicKey);
|
|
1209
|
+
}
|
|
1210
|
+
}, {
|
|
1211
|
+
id: "gpg-keys-openpgp-org",
|
|
1212
|
+
kind: "external-tool",
|
|
1213
|
+
label: "gpg, key from keys.openpgp.org",
|
|
1214
|
+
description: "Sign with gpg and paste the signature; Ghostly then fetches your public key from keys.openpgp.org, which learns which key was looked up.",
|
|
1215
|
+
instructions: (statement) => steps(statement, false),
|
|
1216
|
+
async parse(pasted, statement) {
|
|
1217
|
+
const { signature, publicKey } = splitPgpPaste(pasted);
|
|
1218
|
+
return evidenceFrom(statement, signature, publicKey ?? (await fetchKeyFromKeyserver(statement.binding.subject)).armored);
|
|
1219
|
+
}
|
|
1220
|
+
}],
|
|
1221
|
+
lookupLabel: "Check emails with keys.openpgp.org",
|
|
1222
|
+
parseEvidence: (raw) => parsePgpEvidence(raw),
|
|
1223
|
+
async verify(statement, evidence, ctx) {
|
|
1224
|
+
ctx.signal.throwIfAborted();
|
|
1225
|
+
const { binding } = statement;
|
|
1226
|
+
const verified = await verifyPgpEvidence(evidence, statement.text, {
|
|
1227
|
+
now: ctx.now,
|
|
1228
|
+
signedAfter: binding.issuedAt,
|
|
1229
|
+
expectedFingerprint: normalizeFingerprint(binding.subject)
|
|
1230
|
+
});
|
|
1231
|
+
const subkey = verified.signingFingerprint !== verified.fingerprint;
|
|
1232
|
+
const [userId] = verified.userIds;
|
|
1233
|
+
return {
|
|
1234
|
+
subject: verified.fingerprint,
|
|
1235
|
+
source: `OpenPGP signature (${verified.algorithm}${subkey ? " signing subkey" : ""}, v${verified.version} key)`,
|
|
1236
|
+
...verified.expiresAt !== null && verified.expiresAt < binding.expiresAt ? { expiresAt: verified.expiresAt } : {},
|
|
1237
|
+
...userId ? { display: {
|
|
1238
|
+
name: userId,
|
|
1239
|
+
source: USER_ID_SOURCE,
|
|
1240
|
+
fetchedAt: ctx.now
|
|
1241
|
+
} } : {}
|
|
1242
|
+
};
|
|
1243
|
+
},
|
|
1244
|
+
/** On request only: which emails on this key keys.openpgp.org confirmed, and whether it holds a revocation. */
|
|
1245
|
+
async lookupDisplay(subject, { signal }) {
|
|
1246
|
+
const fpr = normalizeFingerprint(subject);
|
|
1247
|
+
const found = await fetchKeyFromKeyserver(fpr, { signal });
|
|
1248
|
+
const emails = await keyserverVerifiedEmails(found.armored, fpr);
|
|
1249
|
+
if (!emails.length) return void 0;
|
|
1250
|
+
return {
|
|
1251
|
+
name: emails[0],
|
|
1252
|
+
url: `${KEYSERVER}/search?q=${fpr}`,
|
|
1253
|
+
source: KEYSERVER_SOURCE,
|
|
1254
|
+
fetchedAt: found.fetchedAt
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
//#endregion
|
|
1259
|
+
//#region ../browser/src/proofs/sshForges.ts
|
|
1260
|
+
var SSH_FORGE_LABEL = {
|
|
1261
|
+
github: "GitHub",
|
|
1262
|
+
gitlab: "GitLab"
|
|
1263
|
+
};
|
|
1264
|
+
/** The only host each forge lookup contacts, for the privacy line. */
|
|
1265
|
+
var SSH_FORGE_HOST = {
|
|
1266
|
+
github: "api.github.com",
|
|
1267
|
+
gitlab: "gitlab.com"
|
|
1268
|
+
};
|
|
1269
|
+
var MAX_BODY = 262144;
|
|
1270
|
+
var MAX_KEYS = 200;
|
|
1271
|
+
function validForgeLogin(forge, login) {
|
|
1272
|
+
if (typeof login !== "string") return false;
|
|
1273
|
+
return forge === "github" ? /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/.test(login) : /^[A-Za-z0-9_.][A-Za-z0-9_.-]{0,63}$/.test(login) && !/\.(?:git|atom)$/i.test(login);
|
|
1274
|
+
}
|
|
1275
|
+
async function get(fetcher, url, signal) {
|
|
1276
|
+
const response = await fetcher(url, {
|
|
1277
|
+
maxBytes: MAX_BODY,
|
|
1278
|
+
signal
|
|
1279
|
+
});
|
|
1280
|
+
if (response.status === 404) return { status: 404 };
|
|
1281
|
+
if (response.status === 403 || response.status === 429) throw new Error(`${new URL(url).host} is limiting lookups; check again later`);
|
|
1282
|
+
if (response.status !== 200) throw new Error(`${new URL(url).host} answered HTTP ${response.status}`);
|
|
1283
|
+
try {
|
|
1284
|
+
return {
|
|
1285
|
+
status: 200,
|
|
1286
|
+
body: JSON.parse(response.text)
|
|
1287
|
+
};
|
|
1288
|
+
} catch {
|
|
1289
|
+
throw new Error(`Unexpected answer from ${new URL(url).host}`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
function keysFrom(body) {
|
|
1293
|
+
if (!Array.isArray(body)) throw new Error("Unexpected key list");
|
|
1294
|
+
const keys = [];
|
|
1295
|
+
for (const entry of body.slice(0, MAX_KEYS)) {
|
|
1296
|
+
const line = entry?.key;
|
|
1297
|
+
if (typeof line !== "string") continue;
|
|
1298
|
+
try {
|
|
1299
|
+
keys.push(parseSshPublicKey(line));
|
|
1300
|
+
} catch {}
|
|
1301
|
+
}
|
|
1302
|
+
return keys;
|
|
1303
|
+
}
|
|
1304
|
+
/** The account's published keys, or null when there is no such account. */
|
|
1305
|
+
async function listKeys(forge, login, fetcher, signal) {
|
|
1306
|
+
if (forge === "github") {
|
|
1307
|
+
const r = await get(fetcher, `https://api.github.com/users/${encodeURIComponent(login)}/keys?per_page=100`, signal);
|
|
1308
|
+
return r.status === 404 ? null : keysFrom(r.body);
|
|
1309
|
+
}
|
|
1310
|
+
const users = await get(fetcher, `https://gitlab.com/api/v4/users?username=${encodeURIComponent(login)}`, signal);
|
|
1311
|
+
const user = (users.status === 200 && Array.isArray(users.body) ? users.body : []).find((u) => typeof u?.username === "string" && u.username.toLowerCase() === login.toLowerCase());
|
|
1312
|
+
if (!user) return null;
|
|
1313
|
+
if (!Number.isSafeInteger(user.id) || user.id <= 0) throw new Error("Unexpected answer from gitlab.com");
|
|
1314
|
+
const r = await get(fetcher, `https://gitlab.com/api/v4/users/${user.id}/keys?per_page=100`, signal);
|
|
1315
|
+
return r.status === 404 ? null : keysFrom(r.body);
|
|
1316
|
+
}
|
|
1317
|
+
var inFlight = /* @__PURE__ */ new WeakMap();
|
|
1318
|
+
/** Whether `key` is one of `login`'s published SSH keys. Throws when the forge cannot be asked. */
|
|
1319
|
+
async function checkSshForge(forge, login, key, fetcher, signal) {
|
|
1320
|
+
if (!validForgeLogin(forge, login)) throw new Error(`Not a ${SSH_FORGE_LABEL[forge]} username`);
|
|
1321
|
+
let pending = inFlight.get(fetcher);
|
|
1322
|
+
if (!pending) inFlight.set(fetcher, pending = /* @__PURE__ */ new Map());
|
|
1323
|
+
const map = pending, id = `${forge}:${login.toLowerCase()}`;
|
|
1324
|
+
let lookup = map.get(id);
|
|
1325
|
+
if (!lookup) {
|
|
1326
|
+
lookup = listKeys(forge, login, fetcher, signal).finally(() => map.delete(id));
|
|
1327
|
+
map.set(id, lookup);
|
|
1328
|
+
}
|
|
1329
|
+
const keys = await lookup;
|
|
1330
|
+
if (!keys) return "no-account";
|
|
1331
|
+
return keys.some((k) => sameSshKey(k, key)) ? "linked" : "not-listed";
|
|
1332
|
+
}
|
|
1333
|
+
//#endregion
|
|
1334
|
+
//#region ../browser/src/proofs/providers/ssh.ts
|
|
1335
|
+
var FINGERPRINT = /^SHA256:[A-Za-z0-9+/]{43}$/;
|
|
1336
|
+
function normalizeKeySubject(input) {
|
|
1337
|
+
const value = input.trim();
|
|
1338
|
+
if (FINGERPRINT.test(value)) return value;
|
|
1339
|
+
try {
|
|
1340
|
+
return parseSshPublicKey(value).fingerprint;
|
|
1341
|
+
} catch (e) {
|
|
1342
|
+
if (e instanceof SshSigError && /^(Unsupported SSH key type|RSA keys must)/.test(e.message)) throw Object.assign(new Error(e.message), { cause: e });
|
|
1343
|
+
throw Object.assign(/* @__PURE__ */ new Error("Paste your public key (the one line in ~/.ssh/id_ed25519.pub) or its SHA256: fingerprint"), { cause: e });
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
function normalizeLogin(forge, input) {
|
|
1347
|
+
const login = input.trim().replace(/^@/, "").toLowerCase();
|
|
1348
|
+
if (!validForgeLogin(forge, login)) throw new Error(`Enter a ${SSH_FORGE_LABEL[forge]} username`);
|
|
1349
|
+
return login;
|
|
1350
|
+
}
|
|
1351
|
+
/** The armor, re-wrapped canonically, so evidence is the same whatever the clipboard did to it. */
|
|
1352
|
+
function canonicalArmor(pasted) {
|
|
1353
|
+
const parsed = parseSshSignature(pasted);
|
|
1354
|
+
if (parsed.namespace !== "ghostly") throw new Error(`That signature is for "${parsed.namespace}", not "${SSHSIG_NAMESPACE}": run the command with -n ${SSHSIG_NAMESPACE}`);
|
|
1355
|
+
return `-----BEGIN SSH SIGNATURE-----\n${pasted.replace(/\r\n/g, "\n").trim().split("\n").map((l) => l.trim()).slice(1, -1).join("").match(/.{1,70}/g).join("\n")}\n-----END SSH SIGNATURE-----\n`;
|
|
1356
|
+
}
|
|
1357
|
+
/** Exactly the statement; or with one trailing newline, which an editor adds when the statement is
|
|
1358
|
+
* saved to a file and signed there (the Windows route). Nothing else is accepted. */
|
|
1359
|
+
async function verifyStatementSignature(statement, evidence) {
|
|
1360
|
+
try {
|
|
1361
|
+
return await verifySshSignature(evidence.signature, statement.bytes);
|
|
1362
|
+
} catch (e) {
|
|
1363
|
+
if (!(e instanceof SshSigError) || !/does not match this statement/.test(e.message)) throw e;
|
|
1364
|
+
try {
|
|
1365
|
+
return await verifySshSignature(evidence.signature, utf8Encode(`${statement.text}\n`));
|
|
1366
|
+
} catch (again) {
|
|
1367
|
+
throw Object.assign(/* @__PURE__ */ new Error("The signature is not over this statement: copy the command again and sign exactly what it shows"), { cause: again });
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
var keyKind = (c) => c.securityKey ? `security key, touch${c.securityKey.userVerified ? " and PIN" : ""} confirmed` : c.key.type === "ssh-rsa" ? `RSA ${c.key.bits}` : c.key.type.replace(/^ssh-|^ecdsa-sha2-/, "");
|
|
1372
|
+
function sshKeygenSigner(forAccount) {
|
|
1373
|
+
return {
|
|
1374
|
+
id: "ssh-keygen",
|
|
1375
|
+
kind: "external-tool",
|
|
1376
|
+
label: "ssh-keygen",
|
|
1377
|
+
description: forAccount ? `Sign with an SSH key that your ${SSH_FORGE_LABEL[forAccount]} account lists (Settings → SSH keys).` : "Sign with OpenSSH's ssh-keygen (8.1 or later): a key file, ssh-agent, or a FIDO security key.",
|
|
1378
|
+
instructions: (statement) => {
|
|
1379
|
+
let command;
|
|
1380
|
+
try {
|
|
1381
|
+
command = sshSignCommand(statement.text);
|
|
1382
|
+
} catch {
|
|
1383
|
+
command = void 0;
|
|
1384
|
+
}
|
|
1385
|
+
return {
|
|
1386
|
+
steps: [
|
|
1387
|
+
...command ? [{
|
|
1388
|
+
text: "Run this in a terminal (macOS, Linux, or Git Bash/WSL on Windows). If your key is not ~/.ssh/id_ed25519, change the path after -f; a .pub path works when the private key is in ssh-agent or on a security key.",
|
|
1389
|
+
copy: command
|
|
1390
|
+
}] : [],
|
|
1391
|
+
{
|
|
1392
|
+
text: `${command ? "Or save" : "Save"} this statement to a file, run ssh-keygen -Y sign -n ${SSHSIG_NAMESPACE} -f <your key> <file>, and paste the <file>.sig it writes.`,
|
|
1393
|
+
copy: statement.text
|
|
1394
|
+
},
|
|
1395
|
+
{ text: "Paste the whole signature it prints, from -----BEGIN SSH SIGNATURE----- to -----END SSH SIGNATURE-----. Your private key never leaves your computer." }
|
|
1396
|
+
],
|
|
1397
|
+
paste: {
|
|
1398
|
+
label: "SSH signature",
|
|
1399
|
+
placeholder: "-----BEGIN SSH SIGNATURE-----",
|
|
1400
|
+
multiline: true
|
|
1401
|
+
}
|
|
1402
|
+
};
|
|
1403
|
+
},
|
|
1404
|
+
parse(pasted, statement) {
|
|
1405
|
+
const signature = canonicalArmor(pasted);
|
|
1406
|
+
if (!forAccount) {
|
|
1407
|
+
const { key } = parseSshSignature(signature);
|
|
1408
|
+
if (key.fingerprint !== statement.binding.subject) throw new Error(`That signature was made by ${key.fingerprint}, not the key you entered (${statement.binding.subject})`);
|
|
1409
|
+
}
|
|
1410
|
+
return { signature };
|
|
1411
|
+
}
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
function parseSshEvidence(raw) {
|
|
1415
|
+
const e = raw;
|
|
1416
|
+
if (!e || typeof e !== "object" || Array.isArray(e) || Object.keys(e).join(",") !== "signature" || typeof e.signature !== "string" || e.signature.length > 6144) throw new Error("That is not an SSH signature");
|
|
1417
|
+
return { signature: e.signature };
|
|
1418
|
+
}
|
|
1419
|
+
var shortFingerprint = (f) => `${f.slice(0, 15)}…${f.slice(-4)}`;
|
|
1420
|
+
var ssh = {
|
|
1421
|
+
id: "ssh",
|
|
1422
|
+
label: "SSH key",
|
|
1423
|
+
category: "self-custodied",
|
|
1424
|
+
summary: "Sign once with ssh-keygen",
|
|
1425
|
+
description: "Proves you hold an SSH key, including FIDO security keys: sign the statement once with ssh-keygen and paste the signature.",
|
|
1426
|
+
platforms: [
|
|
1427
|
+
"web",
|
|
1428
|
+
"extension",
|
|
1429
|
+
"desktop"
|
|
1430
|
+
],
|
|
1431
|
+
subject: {
|
|
1432
|
+
label: "Public key",
|
|
1433
|
+
placeholder: "ssh-ed25519 AAAA… (or SHA256:…)",
|
|
1434
|
+
help: "Your public key, the one line in the .pub file. Contacts see its fingerprint.",
|
|
1435
|
+
normalize: normalizeKeySubject,
|
|
1436
|
+
short: shortFingerprint
|
|
1437
|
+
},
|
|
1438
|
+
validity: {
|
|
1439
|
+
defaultDays: 90,
|
|
1440
|
+
maxDays: 365
|
|
1441
|
+
},
|
|
1442
|
+
privacy: "Nothing: the signature is checked on this device. The key's fingerprint links every chat you share it in.",
|
|
1443
|
+
signers: [sshKeygenSigner()],
|
|
1444
|
+
parseEvidence: parseSshEvidence,
|
|
1445
|
+
async verify(statement, evidence) {
|
|
1446
|
+
const check = await verifyStatementSignature(statement, evidence);
|
|
1447
|
+
if (check.key.fingerprint !== statement.binding.subject) throw new Error("Signed by another SSH key");
|
|
1448
|
+
return {
|
|
1449
|
+
subject: statement.binding.subject,
|
|
1450
|
+
source: `SSH signature (${keyKind(check)})`
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
};
|
|
1454
|
+
function forgeProvider(forge) {
|
|
1455
|
+
const label = SSH_FORGE_LABEL[forge], host = SSH_FORGE_HOST[forge];
|
|
1456
|
+
return {
|
|
1457
|
+
id: `ssh-${forge}`,
|
|
1458
|
+
label: `${label} (SSH key)`,
|
|
1459
|
+
category: "self-custodied",
|
|
1460
|
+
summary: `A key your ${label} account lists`,
|
|
1461
|
+
description: `Proves you hold an SSH key your ${label} account publishes: sign the statement once with ssh-keygen. No ${label} login.`,
|
|
1462
|
+
limits: `Does not prove a ${label} login: only that a key the account lists is yours, for as long as it stays listed.`,
|
|
1463
|
+
platforms: [
|
|
1464
|
+
"web",
|
|
1465
|
+
"extension",
|
|
1466
|
+
"desktop"
|
|
1467
|
+
],
|
|
1468
|
+
subject: {
|
|
1469
|
+
label: `${label} username`,
|
|
1470
|
+
placeholder: forge === "github" ? "octocat" : "username",
|
|
1471
|
+
normalize: (input) => normalizeLogin(forge, input)
|
|
1472
|
+
},
|
|
1473
|
+
publicUri: (login) => `https://${forge === "github" ? "github.com" : "gitlab.com"}/${login}`,
|
|
1474
|
+
validity: {
|
|
1475
|
+
defaultDays: 90,
|
|
1476
|
+
maxDays: 365
|
|
1477
|
+
},
|
|
1478
|
+
recheck: { afterSeconds: 600 },
|
|
1479
|
+
privacy: `Your contact's app asks ${host} for this account's public SSH keys, when you share it and on each re-check: ${label} learns the account was looked up, and from which IP address. So does this app when you add it.`,
|
|
1480
|
+
signers: [sshKeygenSigner(forge)],
|
|
1481
|
+
parseEvidence: parseSshEvidence,
|
|
1482
|
+
async verify(statement, evidence, ctx) {
|
|
1483
|
+
const login = statement.binding.subject;
|
|
1484
|
+
if (normalizeLogin(forge, login) !== login) throw new Error(`Not a ${label} username`);
|
|
1485
|
+
const check = await verifyStatementSignature(statement, evidence);
|
|
1486
|
+
const status = await checkSshForge(forge, login, check.key, ctx.fetch, ctx.signal);
|
|
1487
|
+
if (status === "no-account") throw new Error(`${label} has no account ${login}`);
|
|
1488
|
+
if (status === "not-listed") throw new Error(`${label}: ${login} does not list the key that signed (${check.key.fingerprint})`);
|
|
1489
|
+
return {
|
|
1490
|
+
subject: login,
|
|
1491
|
+
source: `${label}: ${login} (via published SSH key)`
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
var sshGithub = forgeProvider("github");
|
|
1497
|
+
var sshGitlab = forgeProvider("gitlab");
|
|
1498
|
+
//#endregion
|
|
1499
|
+
//#region ../browser/src/host.ts
|
|
1500
|
+
var current = null;
|
|
1501
|
+
function getBrowserHost() {
|
|
1502
|
+
if (!current) throw new Error("No browser host configured");
|
|
1503
|
+
return current;
|
|
1504
|
+
}
|
|
1505
|
+
//#endregion
|
|
1506
|
+
//#region ../browser/src/proofs/oidc/providers.ts
|
|
1507
|
+
var TEST_ISSUER = /^https:\/\/[a-z0-9-]+(\.[a-z0-9-]+)*\.test$/;
|
|
1508
|
+
var MICROSOFT_TENANT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
1509
|
+
var OIDC_PROVIDERS = {
|
|
1510
|
+
google: {
|
|
1511
|
+
id: "google",
|
|
1512
|
+
name: "Google",
|
|
1513
|
+
issuer: "https://accounts.google.com",
|
|
1514
|
+
issuerAliases: ["accounts.google.com"],
|
|
1515
|
+
discovery: "https://accounts.google.com/.well-known/openid-configuration",
|
|
1516
|
+
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
1517
|
+
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
|
|
1518
|
+
algs: ["RS256"],
|
|
1519
|
+
responseType: "id_token",
|
|
1520
|
+
reveal: {
|
|
1521
|
+
email: { scope: "email" },
|
|
1522
|
+
profile: { scope: "profile" }
|
|
1523
|
+
},
|
|
1524
|
+
alwaysIncluded: "A Google account identifier.",
|
|
1525
|
+
extraParams: { prompt: "select_account" },
|
|
1526
|
+
clientIds: {
|
|
1527
|
+
web: "",
|
|
1528
|
+
extension: ""
|
|
1529
|
+
}
|
|
1530
|
+
},
|
|
1531
|
+
microsoft: {
|
|
1532
|
+
id: "microsoft",
|
|
1533
|
+
name: "Microsoft",
|
|
1534
|
+
issuer: "https://login.microsoftonline.com/{tenantid}/v2.0",
|
|
1535
|
+
discovery: "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
|
|
1536
|
+
authorizationEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
|
1537
|
+
jwksUri: "https://login.microsoftonline.com/common/discovery/v2.0/keys",
|
|
1538
|
+
algs: ["RS256"],
|
|
1539
|
+
responseType: "id_token",
|
|
1540
|
+
reveal: {
|
|
1541
|
+
email: { scope: "email" },
|
|
1542
|
+
profile: { scope: "profile" }
|
|
1543
|
+
},
|
|
1544
|
+
alwaysIncluded: "A Microsoft account identifier and the directory (tenant) the account belongs to.",
|
|
1545
|
+
extraParams: {
|
|
1546
|
+
prompt: "select_account",
|
|
1547
|
+
response_mode: "fragment"
|
|
1548
|
+
},
|
|
1549
|
+
clientIds: {
|
|
1550
|
+
web: "",
|
|
1551
|
+
extension: ""
|
|
1552
|
+
}
|
|
1553
|
+
},
|
|
1554
|
+
apple: {
|
|
1555
|
+
id: "apple",
|
|
1556
|
+
name: "Apple",
|
|
1557
|
+
issuer: "https://appleid.apple.com",
|
|
1558
|
+
discovery: "https://appleid.apple.com/.well-known/openid-configuration",
|
|
1559
|
+
authorizationEndpoint: "https://appleid.apple.com/auth/authorize",
|
|
1560
|
+
jwksUri: "https://appleid.apple.com/auth/keys",
|
|
1561
|
+
algs: ["RS256"],
|
|
1562
|
+
responseType: "code id_token",
|
|
1563
|
+
reveal: {},
|
|
1564
|
+
alwaysIncluded: "An Apple ID identifier that only Ghostly sees. Apple cannot add your email here without a Ghostly server.",
|
|
1565
|
+
extraParams: { response_mode: "fragment" },
|
|
1566
|
+
clientIds: {
|
|
1567
|
+
web: "",
|
|
1568
|
+
extension: ""
|
|
1569
|
+
}
|
|
1570
|
+
},
|
|
1571
|
+
gitlab: {
|
|
1572
|
+
id: "gitlab",
|
|
1573
|
+
name: "GitLab",
|
|
1574
|
+
issuer: "https://gitlab.com",
|
|
1575
|
+
discovery: "https://gitlab.com/.well-known/openid-configuration",
|
|
1576
|
+
authorizationEndpoint: "https://gitlab.com/oauth/authorize",
|
|
1577
|
+
tokenEndpoint: "https://gitlab.com/oauth/token",
|
|
1578
|
+
revocationEndpoint: "https://gitlab.com/oauth/revoke",
|
|
1579
|
+
jwksUri: "https://gitlab.com/oauth/discovery/keys",
|
|
1580
|
+
algs: ["RS256"],
|
|
1581
|
+
responseType: "code",
|
|
1582
|
+
reveal: {
|
|
1583
|
+
email: { scope: "email" },
|
|
1584
|
+
profile: { scope: "profile" }
|
|
1585
|
+
},
|
|
1586
|
+
alwaysIncluded: "A GitLab.com user ID and the groups you belong to directly: GitLab adds them to every ID token.",
|
|
1587
|
+
clientIds: {
|
|
1588
|
+
web: "",
|
|
1589
|
+
extension: ""
|
|
1590
|
+
}
|
|
1591
|
+
},
|
|
1592
|
+
twitch: {
|
|
1593
|
+
id: "twitch",
|
|
1594
|
+
name: "Twitch",
|
|
1595
|
+
issuer: "https://id.twitch.tv/oauth2",
|
|
1596
|
+
discovery: "https://id.twitch.tv/oauth2/.well-known/openid-configuration",
|
|
1597
|
+
authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize",
|
|
1598
|
+
jwksUri: "https://id.twitch.tv/oauth2/keys",
|
|
1599
|
+
algs: ["RS256"],
|
|
1600
|
+
responseType: "id_token",
|
|
1601
|
+
reveal: { profile: { claims: ["preferred_username"] } },
|
|
1602
|
+
alwaysIncluded: "A Twitch user ID.",
|
|
1603
|
+
extraParams: { force_verify: "true" },
|
|
1604
|
+
clientIds: {
|
|
1605
|
+
web: "",
|
|
1606
|
+
extension: ""
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
};
|
|
1610
|
+
/**
|
|
1611
|
+
* The local issuer the e2e suite runs (e2e/support/oidcIssuer.ts). It exists
|
|
1612
|
+
* only in a build made with VITE_OIDC_TEST_ISSUER, and only on a `.test` host
|
|
1613
|
+
* (a name reserved for testing, never a real issuer); a release build has no
|
|
1614
|
+
* such provider, so a token from it is refused like any unknown issuer.
|
|
1615
|
+
*/
|
|
1616
|
+
function testProvider(issuer) {
|
|
1617
|
+
if (!TEST_ISSUER.test(issuer)) throw new Error("A test issuer lives on a .test host");
|
|
1618
|
+
const base = issuer;
|
|
1619
|
+
return {
|
|
1620
|
+
id: "test",
|
|
1621
|
+
name: "Test issuer",
|
|
1622
|
+
issuer: base,
|
|
1623
|
+
discovery: `${base}/.well-known/openid-configuration`,
|
|
1624
|
+
authorizationEndpoint: `${base}/authorize`,
|
|
1625
|
+
jwksUri: `${base}/jwks`,
|
|
1626
|
+
algs: ["RS256", "ES256"],
|
|
1627
|
+
responseType: "id_token",
|
|
1628
|
+
reveal: {
|
|
1629
|
+
email: { scope: "email" },
|
|
1630
|
+
profile: { scope: "profile" }
|
|
1631
|
+
},
|
|
1632
|
+
alwaysIncluded: "A test account identifier.",
|
|
1633
|
+
clientIds: {
|
|
1634
|
+
web: "ghostly-e2e",
|
|
1635
|
+
extension: "ghostly-e2e",
|
|
1636
|
+
desktop: "ghostly-e2e"
|
|
1637
|
+
}
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
function configuredTestIssuer() {
|
|
1641
|
+
try {
|
|
1642
|
+
return;
|
|
1643
|
+
} catch {
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
/** Every provider this build knows, configured or not. */
|
|
1648
|
+
function oidcProviders(testIssuer = configuredTestIssuer()) {
|
|
1649
|
+
return [...Object.values(OIDC_PROVIDERS), ...testIssuer ? [testProvider(testIssuer)] : []];
|
|
1650
|
+
}
|
|
1651
|
+
/** Every client ID this provider has, on any platform: a contact may use another platform than the prover. */
|
|
1652
|
+
function audiences(provider) {
|
|
1653
|
+
return [...new Set(Object.values(provider.clientIds).filter((id) => !!id))];
|
|
1654
|
+
}
|
|
1655
|
+
function clientIdFor(provider, platform) {
|
|
1656
|
+
return provider.clientIds[platform] || provider.clientIds.web || "";
|
|
1657
|
+
}
|
|
1658
|
+
/** A provider is offered only once its client ID for this platform exists. */
|
|
1659
|
+
function isConfigured(provider, platform) {
|
|
1660
|
+
return !!clientIdFor(provider, platform);
|
|
1661
|
+
}
|
|
1662
|
+
/** The exact issuer a token from this provider must carry, or undefined when its claims do not name one. */
|
|
1663
|
+
function issuerFor(provider, claims) {
|
|
1664
|
+
if (provider.id !== "microsoft") return [provider.issuer, ...provider.issuerAliases ?? []];
|
|
1665
|
+
return typeof claims.tid === "string" && MICROSOFT_TENANT.test(claims.tid) ? [provider.issuer.replace("{tenantid}", claims.tid)] : [];
|
|
1666
|
+
}
|
|
1667
|
+
/** The provider a proof's subject (an issuer, as `OidcProvider.issuer` spells it) names. */
|
|
1668
|
+
function providerForIssuer(issuer, list = oidcProviders()) {
|
|
1669
|
+
return list.find((p) => p.issuer === issuer);
|
|
1670
|
+
}
|
|
1671
|
+
//#endregion
|
|
1672
|
+
//#region ../browser/src/proofs/oidc/jwks.ts
|
|
1673
|
+
var JWKS_MAX_BYTES = 65536;
|
|
1674
|
+
var MIN_TTL_MS = 3e5;
|
|
1675
|
+
var MAX_TTL_MS = 864e5;
|
|
1676
|
+
var DEFAULT_TTL_MS = 36e5;
|
|
1677
|
+
function allowedUrl(url) {
|
|
1678
|
+
try {
|
|
1679
|
+
const u = new URL(url);
|
|
1680
|
+
return u.protocol === "https:" && !u.username && !u.password && !u.hash;
|
|
1681
|
+
} catch {
|
|
1682
|
+
return false;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
function ttl(cacheControl) {
|
|
1686
|
+
const maxAge = /(?:^|[,\s])max-age=(\d+)/i.exec(cacheControl ?? "")?.[1];
|
|
1687
|
+
const ms = maxAge ? Number(maxAge) * 1e3 : DEFAULT_TTL_MS;
|
|
1688
|
+
return Math.min(MAX_TTL_MS, Math.max(MIN_TTL_MS, ms));
|
|
1689
|
+
}
|
|
1690
|
+
function parseKeys(text) {
|
|
1691
|
+
if (text.length > 65536) throw new Error("Provider key set too large");
|
|
1692
|
+
let doc;
|
|
1693
|
+
try {
|
|
1694
|
+
doc = JSON.parse(text);
|
|
1695
|
+
} catch {
|
|
1696
|
+
throw new Error("Invalid provider key set");
|
|
1697
|
+
}
|
|
1698
|
+
if (!doc || !Array.isArray(doc.keys) || doc.keys.length > 32) throw new Error("Invalid provider key set");
|
|
1699
|
+
return doc.keys.filter((k) => !!k && typeof k === "object" && typeof k.kty === "string");
|
|
1700
|
+
}
|
|
1701
|
+
var JwksCache = class {
|
|
1702
|
+
options;
|
|
1703
|
+
entries = /* @__PURE__ */ new Map();
|
|
1704
|
+
inflight = /* @__PURE__ */ new Map();
|
|
1705
|
+
constructor(options = {}) {
|
|
1706
|
+
this.options = options;
|
|
1707
|
+
}
|
|
1708
|
+
now() {
|
|
1709
|
+
return (this.options.now ?? Date.now)();
|
|
1710
|
+
}
|
|
1711
|
+
load(url, fetcher) {
|
|
1712
|
+
const running = this.inflight.get(url);
|
|
1713
|
+
if (running) return running;
|
|
1714
|
+
const task = (async () => {
|
|
1715
|
+
if (!allowedUrl(url)) throw new Error("Provider key URL refused");
|
|
1716
|
+
const response = await fetcher(url, { maxBytes: JWKS_MAX_BYTES });
|
|
1717
|
+
if (response.status !== 200) throw new Error(`Provider keys unavailable (${response.status})`);
|
|
1718
|
+
const keys = parseKeys(response.text);
|
|
1719
|
+
const fetched = this.now();
|
|
1720
|
+
const entry = {
|
|
1721
|
+
keys,
|
|
1722
|
+
fetched,
|
|
1723
|
+
expires: fetched + ttl(response.cacheControl)
|
|
1724
|
+
};
|
|
1725
|
+
this.entries.set(url, entry);
|
|
1726
|
+
return entry;
|
|
1727
|
+
})();
|
|
1728
|
+
this.inflight.set(url, task);
|
|
1729
|
+
return task.finally(() => this.inflight.delete(url));
|
|
1730
|
+
}
|
|
1731
|
+
/** The key with this `kid`, refetching once when it is unknown (rotation) or the cache expired. */
|
|
1732
|
+
async key(url, kid, fetcher) {
|
|
1733
|
+
let entry = this.entries.get(url);
|
|
1734
|
+
if (!entry || entry.expires <= this.now()) entry = await this.load(url, fetcher);
|
|
1735
|
+
let found = entry.keys.find((k) => k.kid === kid);
|
|
1736
|
+
if (!found && this.now() - entry.fetched >= 6e4) {
|
|
1737
|
+
entry = await this.load(url, fetcher);
|
|
1738
|
+
found = entry.keys.find((k) => k.kid === kid);
|
|
1739
|
+
}
|
|
1740
|
+
return found;
|
|
1741
|
+
}
|
|
1742
|
+
clear() {
|
|
1743
|
+
this.entries.clear();
|
|
1744
|
+
}
|
|
1745
|
+
};
|
|
1746
|
+
/** One cache per app: every contact's proofs share the providers' keys. */
|
|
1747
|
+
var sharedJwksCache = new JwksCache();
|
|
1748
|
+
/** An ID token that claims to live longer than a day is not a sign-in result. */
|
|
1749
|
+
var MAX_LIFETIME = 86400;
|
|
1750
|
+
var SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
1751
|
+
var REFUSED_HEADERS = [
|
|
1752
|
+
"crit",
|
|
1753
|
+
"jku",
|
|
1754
|
+
"jwk",
|
|
1755
|
+
"x5u",
|
|
1756
|
+
"x5c",
|
|
1757
|
+
"zip",
|
|
1758
|
+
"enc"
|
|
1759
|
+
];
|
|
1760
|
+
var OidcVerificationError = class extends Error {
|
|
1761
|
+
constructor(message) {
|
|
1762
|
+
super(message);
|
|
1763
|
+
this.name = "OidcVerificationError";
|
|
1764
|
+
}
|
|
1765
|
+
};
|
|
1766
|
+
var refuse = (message) => {
|
|
1767
|
+
throw new OidcVerificationError(message);
|
|
1768
|
+
};
|
|
1769
|
+
function segment(value) {
|
|
1770
|
+
if (!SEGMENT.test(value)) refuse("Malformed ID token");
|
|
1771
|
+
let bytes;
|
|
1772
|
+
try {
|
|
1773
|
+
bytes = fromBase64Url(value);
|
|
1774
|
+
} catch {
|
|
1775
|
+
return refuse("Malformed ID token");
|
|
1776
|
+
}
|
|
1777
|
+
if (toBase64Url(bytes) !== value) refuse("Malformed ID token");
|
|
1778
|
+
return bytes;
|
|
1779
|
+
}
|
|
1780
|
+
function json(value) {
|
|
1781
|
+
let parsed;
|
|
1782
|
+
try {
|
|
1783
|
+
parsed = JSON.parse(utf8Decode(segment(value)));
|
|
1784
|
+
} catch (e) {
|
|
1785
|
+
if (e instanceof OidcVerificationError) throw e;
|
|
1786
|
+
return refuse("Malformed ID token");
|
|
1787
|
+
}
|
|
1788
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) refuse("Malformed ID token");
|
|
1789
|
+
return parsed;
|
|
1790
|
+
}
|
|
1791
|
+
var text = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max ? value : void 0;
|
|
1792
|
+
/** Splits and decodes without verifying anything. Only for display of a token already verified. */
|
|
1793
|
+
function decodeIdToken(token) {
|
|
1794
|
+
if (typeof token !== "string" || token.length > 6144) refuse("ID token missing or too large");
|
|
1795
|
+
const parts = token.split(".");
|
|
1796
|
+
if (parts.length !== 3) refuse("Not a signed ID token");
|
|
1797
|
+
return {
|
|
1798
|
+
header: json(parts[0]),
|
|
1799
|
+
claims: json(parts[1]),
|
|
1800
|
+
signature: segment(parts[2]),
|
|
1801
|
+
signed: utf8Encode(`${parts[0]}.${parts[1]}`)
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
async function verifySignature(alg, jwk, signed, signature) {
|
|
1805
|
+
if (jwk.use !== void 0 && jwk.use !== "sig") refuse("Provider key is not a signing key");
|
|
1806
|
+
if (jwk.alg !== void 0 && jwk.alg !== alg) refuse("Provider key is for another algorithm");
|
|
1807
|
+
if (jwk.key_ops !== void 0 && !(Array.isArray(jwk.key_ops) && jwk.key_ops.includes("verify"))) refuse("Provider key is not a signing key");
|
|
1808
|
+
let key;
|
|
1809
|
+
if (alg === "RS256") {
|
|
1810
|
+
if (jwk.kty !== "RSA" || typeof jwk.n !== "string" || typeof jwk.e !== "string") return refuse("Provider key does not match the token algorithm");
|
|
1811
|
+
const modulus = segment(jwk.n);
|
|
1812
|
+
const bits = (modulus.length - (modulus[0] === 0 ? 1 : 0)) * 8;
|
|
1813
|
+
if (bits < 2048 || bits > 8192) refuse("Provider key size refused");
|
|
1814
|
+
key = await crypto.subtle.importKey("jwk", {
|
|
1815
|
+
kty: "RSA",
|
|
1816
|
+
n: jwk.n,
|
|
1817
|
+
e: jwk.e,
|
|
1818
|
+
ext: true
|
|
1819
|
+
}, {
|
|
1820
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
1821
|
+
hash: "SHA-256"
|
|
1822
|
+
}, false, ["verify"]);
|
|
1823
|
+
return crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, signed);
|
|
1824
|
+
}
|
|
1825
|
+
if (jwk.kty !== "EC" || jwk.crv !== "P-256" || typeof jwk.x !== "string" || typeof jwk.y !== "string") return refuse("Provider key does not match the token algorithm");
|
|
1826
|
+
if (signature.length !== 64) refuse("Invalid ID token signature");
|
|
1827
|
+
key = await crypto.subtle.importKey("jwk", {
|
|
1828
|
+
kty: "EC",
|
|
1829
|
+
crv: "P-256",
|
|
1830
|
+
x: jwk.x,
|
|
1831
|
+
y: jwk.y,
|
|
1832
|
+
ext: true
|
|
1833
|
+
}, {
|
|
1834
|
+
name: "ECDSA",
|
|
1835
|
+
namedCurve: "P-256"
|
|
1836
|
+
}, false, ["verify"]);
|
|
1837
|
+
return crypto.subtle.verify({
|
|
1838
|
+
name: "ECDSA",
|
|
1839
|
+
hash: "SHA-256"
|
|
1840
|
+
}, key, signature, signed);
|
|
1841
|
+
}
|
|
1842
|
+
/** Whether `iss` is this provider's, as it words it (Microsoft: with the token's own tenant). */
|
|
1843
|
+
function issuerMatches(provider, claims) {
|
|
1844
|
+
const iss = text(claims.iss, 512);
|
|
1845
|
+
return !!iss && issuerFor(provider, claims).includes(iss);
|
|
1846
|
+
}
|
|
1847
|
+
async function verifyIdToken(token, options) {
|
|
1848
|
+
const { provider, nonce } = options;
|
|
1849
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
1850
|
+
const { header, claims, signature, signed } = decodeIdToken(token);
|
|
1851
|
+
const alg = header.alg;
|
|
1852
|
+
if (typeof alg !== "string" || !provider.algs.includes(alg)) refuse(`Algorithm ${typeof alg === "string" ? alg : "missing"} is not accepted for ${provider.name}`);
|
|
1853
|
+
if (header.typ !== void 0 && (typeof header.typ !== "string" || header.typ.toUpperCase() !== "JWT")) refuse("Not an ID token");
|
|
1854
|
+
for (const name of REFUSED_HEADERS) if (name in header) refuse("ID token header refused");
|
|
1855
|
+
const kid = text(header.kid, 256) ?? refuse("ID token names no provider key");
|
|
1856
|
+
if (!issuerMatches(provider, claims)) refuse(`Token was not issued by ${provider.name}`);
|
|
1857
|
+
const iss = claims.iss;
|
|
1858
|
+
const expected = audiences(provider);
|
|
1859
|
+
if (!expected.length) refuse(`${provider.name} is not configured in this Ghostly build`);
|
|
1860
|
+
const aud = typeof claims.aud === "string" ? [claims.aud] : Array.isArray(claims.aud) && claims.aud.every((a) => typeof a === "string") ? claims.aud : [];
|
|
1861
|
+
const audience = aud.find((a) => expected.includes(a)) ?? refuse("Token was issued to another app");
|
|
1862
|
+
if (aud.length > 1 && claims.azp !== audience) refuse("Token was issued to another app");
|
|
1863
|
+
if (claims.azp !== void 0 && claims.azp !== audience) refuse("Token was issued to another app");
|
|
1864
|
+
const iat = claims.iat, exp = claims.exp, nbf = claims.nbf;
|
|
1865
|
+
if (typeof iat !== "number" || typeof exp !== "number" || !Number.isFinite(iat) || !Number.isFinite(exp)) return refuse("Token has no validity window");
|
|
1866
|
+
if (exp <= iat || exp - iat > MAX_LIFETIME) refuse("Token validity window refused");
|
|
1867
|
+
if (iat > now + 60) refuse("Token is not valid yet");
|
|
1868
|
+
if (options.issuedWithin) {
|
|
1869
|
+
const [from, to] = options.issuedWithin;
|
|
1870
|
+
if (iat < from || iat > to) refuse("The sign-in does not belong to this proof: it happened at another time");
|
|
1871
|
+
} else {
|
|
1872
|
+
if (typeof nbf === "number" && nbf > now + 60) refuse("Token is not valid yet");
|
|
1873
|
+
if (exp + 60 < now) refuse("Token expired");
|
|
1874
|
+
}
|
|
1875
|
+
if (typeof claims.nonce !== "string" || claims.nonce !== nonce) refuse("Token answers another proof request");
|
|
1876
|
+
const subject = text(claims.sub, 255) ?? refuse("Token names no account");
|
|
1877
|
+
const jwk = await (options.jwks ?? sharedJwksCache).key(provider.jwksUri, kid, options.fetch) ?? refuse(`${provider.name} does not publish the key that signed this token`);
|
|
1878
|
+
if (provider.id === "microsoft" && jwk.issuer !== void 0 && jwk.issuer !== provider.issuer && jwk.issuer !== iss) refuse("Provider key belongs to another issuer");
|
|
1879
|
+
let valid = false;
|
|
1880
|
+
try {
|
|
1881
|
+
valid = await verifySignature(alg, jwk, signed, signature);
|
|
1882
|
+
} catch (e) {
|
|
1883
|
+
if (e instanceof OidcVerificationError) throw e;
|
|
1884
|
+
refuse("Invalid ID token signature");
|
|
1885
|
+
}
|
|
1886
|
+
if (!valid) refuse("Invalid ID token signature");
|
|
1887
|
+
const verified = claims.email_verified === true || claims.email_verified === "true" ? true : claims.email_verified === false || claims.email_verified === "false" ? false : void 0;
|
|
1888
|
+
return {
|
|
1889
|
+
provider: provider.id,
|
|
1890
|
+
issuer: iss,
|
|
1891
|
+
subject,
|
|
1892
|
+
audience,
|
|
1893
|
+
issuedAt: Math.floor(iat),
|
|
1894
|
+
expiresAt: Math.floor(exp),
|
|
1895
|
+
email: text(claims.email, 320),
|
|
1896
|
+
emailVerified: text(claims.email, 320) ? verified : void 0,
|
|
1897
|
+
name: text(claims.name, 200),
|
|
1898
|
+
username: text(claims.preferred_username, 200) ?? text(claims.nickname, 200),
|
|
1899
|
+
tenant: provider.id === "microsoft" ? text(claims.tid, 64) : void 0
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
//#endregion
|
|
1903
|
+
//#region ../browser/src/proofs/oidc/flow.ts
|
|
1904
|
+
/** Optional platform prefix (desktop `d.<port>.`, Android deep link `a.`), then 32 random bytes. */
|
|
1905
|
+
var STATE = /^(?:d\.\d{1,5}\.|a\.)?[A-Za-z0-9_-]{43}$/;
|
|
1906
|
+
function scopeFor$1(provider, reveal) {
|
|
1907
|
+
const scopes = provider.id === "apple" ? [] : ["openid"];
|
|
1908
|
+
const claims = [];
|
|
1909
|
+
for (const r of reveal) {
|
|
1910
|
+
const option = provider.reveal[r];
|
|
1911
|
+
if (!option) continue;
|
|
1912
|
+
if (option.scope) scopes.push(option.scope);
|
|
1913
|
+
if (option.claims) claims.push(...option.claims);
|
|
1914
|
+
}
|
|
1915
|
+
return {
|
|
1916
|
+
scope: scopes.join(" "),
|
|
1917
|
+
claims: claims.length ? JSON.stringify({ id_token: Object.fromEntries(claims.map((c) => [c, null])) }) : void 0
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
async function authorizationRequest(options) {
|
|
1921
|
+
const { provider } = options;
|
|
1922
|
+
const clientId = clientIdFor(provider, options.platform);
|
|
1923
|
+
if (!clientId) throw new Error(`${provider.name} sign-in is not set up in this Ghostly build yet.`);
|
|
1924
|
+
const { nonce } = options;
|
|
1925
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) throw new Error("Invalid sign-in nonce");
|
|
1926
|
+
const state = `${options.statePrefix ?? ""}${toBase64Url(randomBytes(32))}`;
|
|
1927
|
+
const url = new URL(provider.authorizationEndpoint);
|
|
1928
|
+
const { scope, claims } = scopeFor$1(provider, options.reveal);
|
|
1929
|
+
const params = {
|
|
1930
|
+
client_id: clientId,
|
|
1931
|
+
redirect_uri: options.redirectUri,
|
|
1932
|
+
response_type: provider.responseType,
|
|
1933
|
+
nonce,
|
|
1934
|
+
state,
|
|
1935
|
+
...scope ? { scope } : {},
|
|
1936
|
+
...claims ? { claims } : {},
|
|
1937
|
+
...provider.extraParams
|
|
1938
|
+
};
|
|
1939
|
+
let verifier;
|
|
1940
|
+
if (provider.responseType === "code") {
|
|
1941
|
+
verifier = toBase64Url(randomBytes(32));
|
|
1942
|
+
params.code_challenge = toBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8Encode(verifier))));
|
|
1943
|
+
params.code_challenge_method = "S256";
|
|
1944
|
+
}
|
|
1945
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
1946
|
+
return {
|
|
1947
|
+
url: url.toString(),
|
|
1948
|
+
state,
|
|
1949
|
+
nonce,
|
|
1950
|
+
redirectUri: options.redirectUri,
|
|
1951
|
+
clientId,
|
|
1952
|
+
verifier
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
/** Parameters from the redirect: the fragment for implicit responses, the query for `code`. */
|
|
1956
|
+
function callbackParams$1(redirected, redirectUri) {
|
|
1957
|
+
const url = new URL(redirected), expected = new URL(redirectUri);
|
|
1958
|
+
if (url.origin !== expected.origin || url.pathname !== expected.pathname) throw new Error("Sign-in returned to an unexpected address.");
|
|
1959
|
+
const params = new URLSearchParams(url.hash.slice(1));
|
|
1960
|
+
for (const [k, v] of new URLSearchParams(url.search)) if (!params.has(k)) params.set(k, v);
|
|
1961
|
+
return params;
|
|
1962
|
+
}
|
|
1963
|
+
var PROVIDER_ERRORS = {
|
|
1964
|
+
access_denied: "Sign-in was cancelled.",
|
|
1965
|
+
login_required: "Sign in to your account and try again.",
|
|
1966
|
+
consent_required: "Consent was not given.",
|
|
1967
|
+
interaction_required: "The provider needs you to finish signing in.",
|
|
1968
|
+
unauthorized_client: "This provider has not accepted Ghostly’s sign-in yet.",
|
|
1969
|
+
invalid_request: "The provider refused the sign-in request."
|
|
1970
|
+
};
|
|
1971
|
+
async function exchangeCode(provider, request, code, doFetch) {
|
|
1972
|
+
if (!provider.tokenEndpoint || !request.verifier) throw new Error("This provider cannot finish sign-in here.");
|
|
1973
|
+
const response = await doFetch(provider.tokenEndpoint, {
|
|
1974
|
+
method: "POST",
|
|
1975
|
+
redirect: "error",
|
|
1976
|
+
credentials: "omit",
|
|
1977
|
+
referrerPolicy: "no-referrer",
|
|
1978
|
+
cache: "no-store",
|
|
1979
|
+
headers: {
|
|
1980
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1981
|
+
accept: "application/json"
|
|
1982
|
+
},
|
|
1983
|
+
body: new URLSearchParams({
|
|
1984
|
+
grant_type: "authorization_code",
|
|
1985
|
+
code,
|
|
1986
|
+
redirect_uri: request.redirectUri,
|
|
1987
|
+
client_id: request.clientId,
|
|
1988
|
+
code_verifier: request.verifier
|
|
1989
|
+
})
|
|
1990
|
+
});
|
|
1991
|
+
const body = await response.json().catch(() => ({}));
|
|
1992
|
+
for (const token of [body.refresh_token, body.access_token]) if (typeof token === "string" && provider.revocationEndpoint) doFetch(provider.revocationEndpoint, {
|
|
1993
|
+
method: "POST",
|
|
1994
|
+
redirect: "error",
|
|
1995
|
+
credentials: "omit",
|
|
1996
|
+
referrerPolicy: "no-referrer",
|
|
1997
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1998
|
+
body: new URLSearchParams({
|
|
1999
|
+
token,
|
|
2000
|
+
client_id: request.clientId
|
|
2001
|
+
})
|
|
2002
|
+
}).catch(() => {});
|
|
2003
|
+
if (!response.ok || typeof body.id_token !== "string") throw new Error(`${provider.name} did not return an ID token.`);
|
|
2004
|
+
return body.id_token;
|
|
2005
|
+
}
|
|
2006
|
+
/** Turns the redirect into the ID token: state, provider error, code exchange if needed, and that it answers this request. */
|
|
2007
|
+
async function completeSignIn(provider, request, redirected, options = {}) {
|
|
2008
|
+
const params = callbackParams$1(redirected, request.redirectUri);
|
|
2009
|
+
const state = params.get("state");
|
|
2010
|
+
if (!state || !STATE.test(state) || state !== request.state) throw new Error("Sign-in answer does not belong to this request.");
|
|
2011
|
+
const error = params.get("error");
|
|
2012
|
+
if (error) throw new Error(PROVIDER_ERRORS[error] ?? "The provider refused the sign-in.");
|
|
2013
|
+
let idToken;
|
|
2014
|
+
if (provider.responseType === "code") {
|
|
2015
|
+
const code = params.get("code");
|
|
2016
|
+
if (!code || code.length > 2048) throw new Error(`${provider.name} did not return a sign-in code.`);
|
|
2017
|
+
idToken = await exchangeCode(provider, request, code, options.fetch ?? fetch);
|
|
2018
|
+
} else idToken = params.get("id_token");
|
|
2019
|
+
if (!idToken) throw new Error(`${provider.name} did not return an ID token.`);
|
|
2020
|
+
const { claims } = decodeIdToken(idToken);
|
|
2021
|
+
if (claims.nonce !== request.nonce || !issuerMatches(provider, claims)) throw new Error(`${provider.name} answered another sign-in request.`);
|
|
2022
|
+
return idToken;
|
|
2023
|
+
}
|
|
2024
|
+
/** Everything the prover does: sign in on this platform and return the ID token for this nonce. */
|
|
2025
|
+
async function signInForProof(options) {
|
|
2026
|
+
const { window: w } = options;
|
|
2027
|
+
try {
|
|
2028
|
+
options.signal.throwIfAborted();
|
|
2029
|
+
const request = await authorizationRequest({
|
|
2030
|
+
...options,
|
|
2031
|
+
redirectUri: w.redirectUri,
|
|
2032
|
+
statePrefix: w.statePrefix
|
|
2033
|
+
});
|
|
2034
|
+
const redirected = await w.authorize(request.url, options.signal);
|
|
2035
|
+
options.signal.throwIfAborted();
|
|
2036
|
+
return await completeSignIn(options.provider, request, redirected, options);
|
|
2037
|
+
} finally {
|
|
2038
|
+
w.close();
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
//#endregion
|
|
2042
|
+
//#region ../browser/src/proofs/providers/oidc.ts
|
|
2043
|
+
/** How long after the statement the sign-in may have happened: time to pick an account and approve. */
|
|
2044
|
+
var SIGN_IN_WINDOW = 900;
|
|
2045
|
+
var TOKEN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
2046
|
+
var SUB = /^[\x21-\x7e]{1,255}$/;
|
|
2047
|
+
function currentHost$1() {
|
|
2048
|
+
try {
|
|
2049
|
+
return getBrowserHost().oidc;
|
|
2050
|
+
} catch {
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
var hostOf$1 = (issuer) => {
|
|
2055
|
+
try {
|
|
2056
|
+
return new URL(issuer).host;
|
|
2057
|
+
} catch {
|
|
2058
|
+
return issuer;
|
|
2059
|
+
}
|
|
2060
|
+
};
|
|
2061
|
+
function createOidcIdentityProvider(options = {}) {
|
|
2062
|
+
const providers = options.providers ?? (() => oidcProviders());
|
|
2063
|
+
const host = options.host ?? currentHost$1;
|
|
2064
|
+
const configured = () => providers().filter((p) => audiences(p).length > 0);
|
|
2065
|
+
const signer = (id, label, description, reveal) => ({
|
|
2066
|
+
id,
|
|
2067
|
+
kind: "redirect",
|
|
2068
|
+
label,
|
|
2069
|
+
description,
|
|
2070
|
+
available: () => {
|
|
2071
|
+
const h = host();
|
|
2072
|
+
return !!h && providers().some((p) => isConfigured(p, h.platform));
|
|
2073
|
+
},
|
|
2074
|
+
start(statement, ctx) {
|
|
2075
|
+
const h = host();
|
|
2076
|
+
const opening = h?.open();
|
|
2077
|
+
return (async () => {
|
|
2078
|
+
if (!h || !opening) throw new Error("Signing in with a provider is not available here.");
|
|
2079
|
+
const provider = providerForIssuer(statement.binding.subject, providers());
|
|
2080
|
+
if (!provider || !isConfigured(provider, h.platform)) {
|
|
2081
|
+
opening.then((w) => w.close(), () => {});
|
|
2082
|
+
throw new Error("This provider is not set up in this Ghostly build.");
|
|
2083
|
+
}
|
|
2084
|
+
ctx.onProgress(`Sign in with ${provider.name} in the window that opened.`);
|
|
2085
|
+
return { token: await signInForProof({
|
|
2086
|
+
provider,
|
|
2087
|
+
platform: h.platform,
|
|
2088
|
+
nonce: statement.id,
|
|
2089
|
+
window: await opening,
|
|
2090
|
+
signal: ctx.signal,
|
|
2091
|
+
fetch: options.fetch,
|
|
2092
|
+
reveal: reveal.filter((r) => provider.reveal[r])
|
|
2093
|
+
}) };
|
|
2094
|
+
})();
|
|
2095
|
+
}
|
|
2096
|
+
});
|
|
2097
|
+
return {
|
|
2098
|
+
id: "oidc",
|
|
2099
|
+
label: "Account at a provider",
|
|
2100
|
+
category: "provider-attested",
|
|
2101
|
+
summary: "Sign in with Google, Microsoft, Apple…",
|
|
2102
|
+
description: "Sign in with Google, Microsoft, Apple, GitLab or Twitch: the provider vouches to Ghostly, with a signed token, that the account is yours.",
|
|
2103
|
+
limits: "Attested by the provider, not by a key you hold: your contact trusts that company.",
|
|
2104
|
+
platforms: [
|
|
2105
|
+
"web",
|
|
2106
|
+
"extension",
|
|
2107
|
+
"desktop"
|
|
2108
|
+
],
|
|
2109
|
+
subject: {
|
|
2110
|
+
label: "Provider",
|
|
2111
|
+
help: "Your contact sees your account’s identifier at that provider, plus what you choose to add. GitLab also adds the groups you belong to directly; Apple never gives Ghostly your email.",
|
|
2112
|
+
get options() {
|
|
2113
|
+
return configured().map((p) => ({
|
|
2114
|
+
value: p.issuer,
|
|
2115
|
+
label: p.name
|
|
2116
|
+
}));
|
|
2117
|
+
},
|
|
2118
|
+
normalize(input) {
|
|
2119
|
+
const provider = providerForIssuer(input.trim(), configured());
|
|
2120
|
+
if (!provider) throw new Error("Choose a provider");
|
|
2121
|
+
return provider.issuer;
|
|
2122
|
+
},
|
|
2123
|
+
short: (subject) => providerForIssuer(subject, providers())?.name ?? hostOf$1(subject)
|
|
2124
|
+
},
|
|
2125
|
+
validity: {
|
|
2126
|
+
defaultDays: 7,
|
|
2127
|
+
maxDays: 14
|
|
2128
|
+
},
|
|
2129
|
+
privacy: "Your contact’s app downloads the provider’s public keys (for example from www.googleapis.com), so the provider sees that some app checked a sign-in, not whose. Your contact sees the account identifier and what you chose to share.",
|
|
2130
|
+
signers: [
|
|
2131
|
+
signer("oidc-account", "Sign in: share the account only", "The token carries the provider’s identifier for your account, nothing else.", []),
|
|
2132
|
+
signer("oidc-email", "Sign in: share the account and email", "Adds your email address, where the provider offers it.", ["email"]),
|
|
2133
|
+
signer("oidc-profile", "Sign in: share the account, email and name", "Adds your email address and name or username, where the provider offers them.", ["email", "profile"])
|
|
2134
|
+
],
|
|
2135
|
+
parseEvidence(raw) {
|
|
2136
|
+
const e = raw;
|
|
2137
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).join(",") !== "token" || typeof e.token !== "string" || e.token.length > 6144 || !TOKEN.test(e.token)) throw new Error("Invalid sign-in token");
|
|
2138
|
+
return { token: e.token };
|
|
2139
|
+
},
|
|
2140
|
+
async verify(statement, { token }, ctx) {
|
|
2141
|
+
const provider = providerForIssuer(statement.binding.subject, providers());
|
|
2142
|
+
if (!provider) throw new Error("This app does not know this provider");
|
|
2143
|
+
const id = await verifyIdToken(token, {
|
|
2144
|
+
provider,
|
|
2145
|
+
nonce: statement.id,
|
|
2146
|
+
now: ctx.now,
|
|
2147
|
+
jwks: options.jwks ?? sharedJwksCache,
|
|
2148
|
+
fetch: async (url, { maxBytes }) => {
|
|
2149
|
+
const r = await ctx.fetch(url, {
|
|
2150
|
+
maxBytes,
|
|
2151
|
+
signal: ctx.signal
|
|
2152
|
+
});
|
|
2153
|
+
return {
|
|
2154
|
+
status: r.status,
|
|
2155
|
+
text: r.text
|
|
2156
|
+
};
|
|
2157
|
+
},
|
|
2158
|
+
issuedWithin: [statement.binding.issuedAt - 300, statement.binding.issuedAt + SIGN_IN_WINDOW]
|
|
2159
|
+
});
|
|
2160
|
+
if (!SUB.test(id.subject)) throw new Error("The provider’s account identifier cannot be shown");
|
|
2161
|
+
const attester = hostOf$1(provider.issuer);
|
|
2162
|
+
const person = id.name ?? id.username;
|
|
2163
|
+
const name = id.email ? person && `${person} · ${id.email}`.length <= 64 ? `${person} · ${id.email}` : id.email : person;
|
|
2164
|
+
const url = id.username && /^[A-Za-z0-9_.-]{1,64}$/.test(id.username) ? provider.id === "gitlab" ? `https://gitlab.com/${id.username}` : provider.id === "twitch" ? `https://www.twitch.tv/${id.username}` : void 0 : void 0;
|
|
2165
|
+
return {
|
|
2166
|
+
subject: `${provider.id === "microsoft" ? id.issuer : provider.issuer}#${id.subject}`,
|
|
2167
|
+
attester,
|
|
2168
|
+
source: `ID token signed by ${attester}`,
|
|
2169
|
+
...name || url ? { display: {
|
|
2170
|
+
...name ? { name } : {},
|
|
2171
|
+
...url ? { url } : {},
|
|
2172
|
+
fetchedAt: id.issuedAt,
|
|
2173
|
+
source: `Signed by ${provider.name}${id.email ? id.emailVerified ? `; email verified by ${provider.name}` : `; email not verified by ${provider.name}` : ""}`
|
|
2174
|
+
} } : {}
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
var oidc = createOidcIdentityProvider();
|
|
2180
|
+
//#endregion
|
|
2181
|
+
//#region ../browser/src/proofs/did.ts
|
|
2182
|
+
/** Resolutions are shared for this long: back-to-back checks and the add dialog's preview, never a stale key for long. */
|
|
2183
|
+
var CACHE_MS = 3e4;
|
|
2184
|
+
var cache = /* @__PURE__ */ new Map();
|
|
2185
|
+
/** The last document seen per DID, without expiry, for the signer's instructions (which cannot wait). */
|
|
2186
|
+
var seen = /* @__PURE__ */ new Map();
|
|
2187
|
+
/** What this app last resolved for `did`, if anything: the add dialog's instructions name its keys. */
|
|
2188
|
+
var lastDidResolution = (did) => seen.get(did);
|
|
2189
|
+
async function fetchDidWeb(did, url, maxBytes, options) {
|
|
2190
|
+
const host = didWebHost(did);
|
|
2191
|
+
try {
|
|
2192
|
+
await assertPublicHost(host, {
|
|
2193
|
+
fetch: options.fetch,
|
|
2194
|
+
signal: options.signal,
|
|
2195
|
+
resolver: options.resolver ?? chosenResolver().id,
|
|
2196
|
+
now: options.now
|
|
2197
|
+
});
|
|
2198
|
+
} catch (e) {
|
|
2199
|
+
throw e instanceof DomainCheckError ? new DidError(e.message) : e;
|
|
2200
|
+
}
|
|
2201
|
+
let response;
|
|
2202
|
+
try {
|
|
2203
|
+
response = await options.fetch(url, {
|
|
2204
|
+
headers: { accept: "application/did+json, application/json" },
|
|
2205
|
+
maxBytes,
|
|
2206
|
+
signal: options.signal,
|
|
2207
|
+
redirect: "error"
|
|
2208
|
+
});
|
|
2209
|
+
} catch (e) {
|
|
2210
|
+
if (options.signal?.aborted) throw e;
|
|
2211
|
+
throw new DidError(/too large/i.test(String(e)) ? `${url} is too large` : `${host} could not be reached. It must serve ${url} over HTTPS, without a redirect, with the header Access-Control-Allow-Origin: *`);
|
|
2212
|
+
}
|
|
2213
|
+
if (response.status === 404 || response.status === 410) return void 0;
|
|
2214
|
+
if (response.status >= 300 && response.status < 400) throw new DidError(`${host} answered with a redirect; it must serve exactly ${url}`);
|
|
2215
|
+
if (response.status !== 200) throw new DidError(`${host} answered ${response.status} for ${url}`);
|
|
2216
|
+
try {
|
|
2217
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(response.bytes);
|
|
2218
|
+
} catch {
|
|
2219
|
+
throw new DidError(`${url} is not text`);
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
async function resolve(did, method, options) {
|
|
2223
|
+
if (method === "key" || method === "jwk") return {
|
|
2224
|
+
method,
|
|
2225
|
+
document: staticDidDocument(did)
|
|
2226
|
+
};
|
|
2227
|
+
if (method === "web") {
|
|
2228
|
+
const documentUrl = didWebDocumentUrl(did);
|
|
2229
|
+
const text = await fetchDidWeb(did, documentUrl, DID_DOCUMENT_MAX_BYTES, options);
|
|
2230
|
+
if (text === void 0) throw new DidError(`No DID document at ${documentUrl}`);
|
|
2231
|
+
let raw;
|
|
2232
|
+
try {
|
|
2233
|
+
raw = JSON.parse(text);
|
|
2234
|
+
} catch {
|
|
2235
|
+
throw new DidError(`${documentUrl} is not JSON`);
|
|
2236
|
+
}
|
|
2237
|
+
return {
|
|
2238
|
+
method,
|
|
2239
|
+
document: parseDidDocument(did, raw),
|
|
2240
|
+
host: didWebHost(did),
|
|
2241
|
+
documentUrl
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
const found = await (options.dht ?? resolveDidDht)(did, {
|
|
2245
|
+
fetch: options.fetch,
|
|
2246
|
+
signal: options.signal
|
|
2247
|
+
});
|
|
2248
|
+
if (found.metadata.deactivated) throw new DidError("This DID is deactivated: its owner retired it");
|
|
2249
|
+
return {
|
|
2250
|
+
method,
|
|
2251
|
+
document: parseDidDocument(did, found.document),
|
|
2252
|
+
relay: found.relay
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
/** A DID's document as far as Ghostly uses it. Concurrent and back-to-back resolutions share one lookup. */
|
|
2256
|
+
async function resolveDid(input, options) {
|
|
2257
|
+
const { did, method } = parseDid(input);
|
|
2258
|
+
const now = options.now ?? Date.now;
|
|
2259
|
+
const hit = cache.get(did);
|
|
2260
|
+
if (hit && hit.until > now()) return hit.result;
|
|
2261
|
+
const entry = {
|
|
2262
|
+
until: now() + CACHE_MS,
|
|
2263
|
+
result: resolve(did, method, options)
|
|
2264
|
+
};
|
|
2265
|
+
cache.set(did, entry);
|
|
2266
|
+
if (cache.size > 64) cache.delete(cache.keys().next().value);
|
|
2267
|
+
try {
|
|
2268
|
+
const result = await entry.result;
|
|
2269
|
+
seen.delete(did);
|
|
2270
|
+
seen.set(did, result);
|
|
2271
|
+
if (seen.size > 16) seen.delete(seen.keys().next().value);
|
|
2272
|
+
return result;
|
|
2273
|
+
} catch (e) {
|
|
2274
|
+
if (cache.get(did) === entry) cache.delete(did);
|
|
2275
|
+
throw e;
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
/** The statement file a did:web publishes for proof `proofId`; undefined when the server says it is not there. */
|
|
2279
|
+
async function fetchDidWebFile(did, proofId, options) {
|
|
2280
|
+
return fetchDidWeb(did, didWebFileUrl(did, proofId), DID_FILE_MAX_BYTES, options);
|
|
2281
|
+
}
|
|
2282
|
+
//#endregion
|
|
2283
|
+
//#region ../browser/src/proofs/providers/did.ts
|
|
2284
|
+
var VM = /^did:[a-z0-9]+:[\x21-\x7e]+#[\x21-\x7e]+$/;
|
|
2285
|
+
var SIG = /^[A-Za-z0-9_-]{86}$/;
|
|
2286
|
+
var exactKeys$1 = (raw, keys) => !!raw && typeof raw === "object" && !Array.isArray(raw) && Object.keys(raw).sort().join(",") === keys;
|
|
2287
|
+
var validVm = (vm) => typeof vm === "string" && vm.length <= 640 && VM.test(vm);
|
|
2288
|
+
/** The add dialog's checks run in the UI, with the same bounded fetch the engine uses. */
|
|
2289
|
+
var uiFetch = boundedIdentityFetch({ online: () => globalThis.navigator?.onLine !== false });
|
|
2290
|
+
/** `#key-1`, shortened when a multibase fragment is long. */
|
|
2291
|
+
var fragment = (id) => {
|
|
2292
|
+
const f = id.slice(id.indexOf("#"));
|
|
2293
|
+
return f.length > 20 ? `${f.slice(0, 11)}…${f.slice(-4)}` : f;
|
|
2294
|
+
};
|
|
2295
|
+
function shortDid(did) {
|
|
2296
|
+
if (did.length <= 30) return did;
|
|
2297
|
+
const { method, id } = parseDidSafe(did);
|
|
2298
|
+
if (method === "web") return `did:web:${id.split(":")[0]}${id.includes(":") ? ":…" : ""}`;
|
|
2299
|
+
return `did:${method}:${id.slice(0, 8)}…${id.slice(-4)}`;
|
|
2300
|
+
}
|
|
2301
|
+
var parseDidSafe = (did) => {
|
|
2302
|
+
try {
|
|
2303
|
+
return parseDid(did);
|
|
2304
|
+
} catch {
|
|
2305
|
+
return {
|
|
2306
|
+
did,
|
|
2307
|
+
method: did.split(":")[1] ?? "",
|
|
2308
|
+
id: did.split(":").slice(2).join(":")
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
};
|
|
2312
|
+
/** Keys the instructions can name before anything is fetched: the identifier's own, or what the preview resolved. */
|
|
2313
|
+
function knownKeys(did) {
|
|
2314
|
+
const { method } = parseDid(did);
|
|
2315
|
+
if (method === "key" || method === "jwk") return signingKeys(staticDidDocument(did));
|
|
2316
|
+
const seen = lastDidResolution(did);
|
|
2317
|
+
return seen ? signingKeys(seen.document) : [];
|
|
2318
|
+
}
|
|
2319
|
+
var NOBLE = {
|
|
2320
|
+
Ed25519: {
|
|
2321
|
+
name: "ed25519",
|
|
2322
|
+
module: "ed25519.js"
|
|
2323
|
+
},
|
|
2324
|
+
secp256k1: {
|
|
2325
|
+
name: "secp256k1",
|
|
2326
|
+
module: "secp256k1.js"
|
|
2327
|
+
},
|
|
2328
|
+
"P-256": {
|
|
2329
|
+
name: "p256",
|
|
2330
|
+
module: "nist.js"
|
|
2331
|
+
}
|
|
2332
|
+
};
|
|
2333
|
+
function joseScript(statement, kid) {
|
|
2334
|
+
return [
|
|
2335
|
+
"// npm install jose; then: node sign.mjs your-private-key.jwk.json",
|
|
2336
|
+
"import { readFileSync } from \"node:fs\";",
|
|
2337
|
+
"import { CompactSign, importJWK } from \"jose\";",
|
|
2338
|
+
`const statement = ${JSON.stringify(statement)};`,
|
|
2339
|
+
"const jwk = JSON.parse(readFileSync(process.argv[2], \"utf8\"));",
|
|
2340
|
+
"const alg = { Ed25519: \"EdDSA\", \"P-256\": \"ES256\" }[jwk.crv];",
|
|
2341
|
+
"const jws = await new CompactSign(new TextEncoder().encode(statement))",
|
|
2342
|
+
` .setProtectedHeader({ alg${kid ? `, kid: ${JSON.stringify(kid)}` : ""} })`,
|
|
2343
|
+
" .sign(await importJWK(jwk, alg));",
|
|
2344
|
+
"console.log(jws);"
|
|
2345
|
+
].join("\n");
|
|
2346
|
+
}
|
|
2347
|
+
function nobleScript(statement, curve) {
|
|
2348
|
+
const lib = NOBLE[curve];
|
|
2349
|
+
return [
|
|
2350
|
+
"// npm install @noble/curves; then: node sign.mjs your-private-key.hex",
|
|
2351
|
+
"import { readFileSync } from \"node:fs\";",
|
|
2352
|
+
`import { ${lib.name} } from "@noble/curves/${lib.module}";`,
|
|
2353
|
+
`const statement = new TextEncoder().encode(${JSON.stringify(statement)});`,
|
|
2354
|
+
"const secret = Uint8Array.from(Buffer.from(readFileSync(process.argv[2], \"utf8\").trim(), \"hex\"));",
|
|
2355
|
+
`console.log(Buffer.from(${lib.name}.sign(statement, secret)).toString("hex"));`
|
|
2356
|
+
].join("\n");
|
|
2357
|
+
}
|
|
2358
|
+
function opensslCommand(statement, curve) {
|
|
2359
|
+
const save = `printf '%s' '${statement}' > statement.txt`;
|
|
2360
|
+
return curve === "Ed25519" ? `${save} && openssl pkeyutl -sign -rawin -inkey key.pem -in statement.txt | xxd -p | tr -d '\\n'` : `${save} && openssl dgst -sha256 -sign key.pem statement.txt | xxd -p | tr -d '\\n'`;
|
|
2361
|
+
}
|
|
2362
|
+
var signer = {
|
|
2363
|
+
id: "sign",
|
|
2364
|
+
kind: "external-tool",
|
|
2365
|
+
label: "Sign with a key of the DID",
|
|
2366
|
+
description: "Sign the statement with a key your DID document lists for authentication or assertionMethod, and paste a JWS or the raw signature. The private key never enters Ghostly.",
|
|
2367
|
+
instructions(statement) {
|
|
2368
|
+
const did = statement.binding.subject;
|
|
2369
|
+
if (statement.text.includes("'")) throw new DidError("Statement is not signable as plain text");
|
|
2370
|
+
const keys = knownKeys(did);
|
|
2371
|
+
const key = keys.length === 1 ? keys[0] : void 0;
|
|
2372
|
+
const curve = key?.curve ?? keys[0]?.curve ?? "Ed25519";
|
|
2373
|
+
return {
|
|
2374
|
+
steps: [
|
|
2375
|
+
{
|
|
2376
|
+
text: `Sign this statement with the private key of ${keys.length ? keys.map((k) => `${fragment(k.id)} (${k.curve})`).join(", ") : "a key listed under authentication or assertionMethod"}. It is one line, with no newline at the end:`,
|
|
2377
|
+
copy: statement.text
|
|
2378
|
+
},
|
|
2379
|
+
...curve === "secp256k1" ? [] : [{
|
|
2380
|
+
text: `With jose (Node.js 20 or later), a JWS: save this as sign.mjs and run it with your ${curve} private key as a JWK file.`,
|
|
2381
|
+
copy: joseScript(statement.text, key?.id)
|
|
2382
|
+
}],
|
|
2383
|
+
{
|
|
2384
|
+
text: `With @noble/curves (Node.js), a raw signature: save this as sign.mjs and run it with your ${curve} private key in hex.`,
|
|
2385
|
+
copy: nobleScript(statement.text, curve)
|
|
2386
|
+
},
|
|
2387
|
+
{
|
|
2388
|
+
text: `Or with OpenSSL 3 and your ${curve} key in PEM:`,
|
|
2389
|
+
copy: opensslCommand(statement.text, curve)
|
|
2390
|
+
},
|
|
2391
|
+
{ text: `Paste what it prints: a compact JWS (${didJwsAlg(curve)}, attached or detached), or the signature in hex or base64. If your DID lists several keys, you may write the key id first: #key-1 <signature>.` }
|
|
2392
|
+
],
|
|
2393
|
+
paste: {
|
|
2394
|
+
label: "JWS or signature",
|
|
2395
|
+
placeholder: "eyJhbGciOi… or the signature in hex",
|
|
2396
|
+
multiline: true
|
|
2397
|
+
}
|
|
2398
|
+
};
|
|
2399
|
+
},
|
|
2400
|
+
async parse(pasted, statement) {
|
|
2401
|
+
const did = statement.binding.subject;
|
|
2402
|
+
const paste = parseDidSignaturePaste(pasted);
|
|
2403
|
+
const { document } = await resolveDid(did, { fetch: uiFetch });
|
|
2404
|
+
const messages = didSignedMessages(statement.text);
|
|
2405
|
+
if (paste.kind === "jws") return {
|
|
2406
|
+
method: "jws",
|
|
2407
|
+
vm: checkDidJws(paste.jws, messages, document).key.id,
|
|
2408
|
+
jws: paste.jws
|
|
2409
|
+
};
|
|
2410
|
+
const check = checkDidSignature(paste.signature, messages, document, paste.vm);
|
|
2411
|
+
return {
|
|
2412
|
+
method: "sig",
|
|
2413
|
+
vm: check.key.id,
|
|
2414
|
+
sig: toBase64Url(check.signature)
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
};
|
|
2418
|
+
var webOnly = (statement) => {
|
|
2419
|
+
const did = statement.binding.subject;
|
|
2420
|
+
if (parseDid(did).method !== "web") throw new DidError("Publishing works for did:web only: sign the statement instead");
|
|
2421
|
+
return did;
|
|
2422
|
+
};
|
|
2423
|
+
var fileSigner = {
|
|
2424
|
+
id: "file",
|
|
2425
|
+
kind: "publish",
|
|
2426
|
+
label: "Publish a file beside did.json",
|
|
2427
|
+
description: "did:web only: upload a small file next to your did.json. Contacts' apps download both from your server.",
|
|
2428
|
+
instructions(statement) {
|
|
2429
|
+
const did = webOnly(statement);
|
|
2430
|
+
return { steps: [
|
|
2431
|
+
{
|
|
2432
|
+
text: "Create this file on your web server, beside your did.json:",
|
|
2433
|
+
copy: didWebFileUrl(did, statement.id)
|
|
2434
|
+
},
|
|
2435
|
+
{
|
|
2436
|
+
text: "With exactly this content:",
|
|
2437
|
+
copy: didWebFile(did, statement.text)
|
|
2438
|
+
},
|
|
2439
|
+
{
|
|
2440
|
+
text: "Serve it over HTTPS at exactly that address, without any redirect, and with this header so browsers may read it (your did.json needs it too):",
|
|
2441
|
+
copy: "Access-Control-Allow-Origin: *"
|
|
2442
|
+
},
|
|
2443
|
+
{ text: "Then press Check. The file stays public while the proof is in use; deleting it withdraws this proof from every contact at their next check." }
|
|
2444
|
+
] };
|
|
2445
|
+
},
|
|
2446
|
+
evidence: () => ({ method: "file" })
|
|
2447
|
+
};
|
|
2448
|
+
var serviceSigner = {
|
|
2449
|
+
id: "service",
|
|
2450
|
+
kind: "publish",
|
|
2451
|
+
label: "Add a service to did.json",
|
|
2452
|
+
description: "did:web only: add one entry to the service list of your did.json. Contacts' apps read it there.",
|
|
2453
|
+
instructions(statement) {
|
|
2454
|
+
const did = webOnly(statement);
|
|
2455
|
+
const seen = lastDidResolution(did);
|
|
2456
|
+
return { steps: [{
|
|
2457
|
+
text: `Add this entry to the "service" list of your did.json${seen?.documentUrl ? ` (${seen.documentUrl})` : ""}:`,
|
|
2458
|
+
copy: JSON.stringify(didGhostlyServiceEntry(did, statement.binding.key, statement.id), null, 2)
|
|
2459
|
+
}, { text: "Then press Check. Removing the entry withdraws this proof from every contact at their next check." }] };
|
|
2460
|
+
},
|
|
2461
|
+
evidence: () => ({ method: "service" })
|
|
2462
|
+
};
|
|
2463
|
+
function previewOf(r) {
|
|
2464
|
+
const keys = signingKeys(r.document);
|
|
2465
|
+
const unusable = r.document.keys.length - keys.length + r.document.skipped;
|
|
2466
|
+
if (!keys.length && r.method !== "web") throw new DidError("This DID lists no Ed25519, secp256k1 or P-256 key for authentication or assertionMethod, so it cannot sign");
|
|
2467
|
+
const facts = [{
|
|
2468
|
+
label: "Method",
|
|
2469
|
+
value: `did:${r.method}${r.method === "key" || r.method === "jwk" ? ", the key is the identifier" : ""}`
|
|
2470
|
+
}];
|
|
2471
|
+
if (r.host) facts.push({
|
|
2472
|
+
label: "Domain",
|
|
2473
|
+
value: r.host
|
|
2474
|
+
});
|
|
2475
|
+
if (r.documentUrl) facts.push({
|
|
2476
|
+
label: "Document",
|
|
2477
|
+
value: r.documentUrl
|
|
2478
|
+
});
|
|
2479
|
+
if (r.relay) facts.push({
|
|
2480
|
+
label: "Relay",
|
|
2481
|
+
value: r.relay.replace(/^https:\/\//, "")
|
|
2482
|
+
});
|
|
2483
|
+
facts.push({
|
|
2484
|
+
label: keys.length === 1 ? "Key" : "Keys",
|
|
2485
|
+
value: keys.length ? keys.map((k) => `${fragment(k.id)} (${k.curve})`).join(", ") : "none that can sign: publish instead"
|
|
2486
|
+
});
|
|
2487
|
+
if (unusable > 0) facts.push({
|
|
2488
|
+
label: "Not usable",
|
|
2489
|
+
value: `${unusable} ${unusable === 1 ? "key" : "keys"}: another curve, or not for authentication or assertionMethod`
|
|
2490
|
+
});
|
|
2491
|
+
return {
|
|
2492
|
+
facts,
|
|
2493
|
+
signers: r.method === "web" ? [
|
|
2494
|
+
...keys.length ? ["sign"] : [],
|
|
2495
|
+
"file",
|
|
2496
|
+
"service"
|
|
2497
|
+
] : ["sign"]
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
var did = {
|
|
2501
|
+
id: "did",
|
|
2502
|
+
label: "DID",
|
|
2503
|
+
category: "self-custodied",
|
|
2504
|
+
summary: "did:key, did:jwk, did:dht or did:web",
|
|
2505
|
+
description: "Proves you control a decentralized identifier (W3C DID): sign the statement with a key its DID document lists, or, for did:web, publish it beside your did.json.",
|
|
2506
|
+
limits: "Does not prove who controls the DID, only that whoever added the proof held one of its keys (or its website).",
|
|
2507
|
+
platforms: [
|
|
2508
|
+
"web",
|
|
2509
|
+
"extension",
|
|
2510
|
+
"desktop"
|
|
2511
|
+
],
|
|
2512
|
+
advanced: true,
|
|
2513
|
+
experimental: true,
|
|
2514
|
+
subject: {
|
|
2515
|
+
label: "DID",
|
|
2516
|
+
placeholder: "did:key:z6Mk… or did:web:example.com",
|
|
2517
|
+
help: "did:key, did:jwk, did:dht or did:web. Contacts see the whole DID, and the key that signed.",
|
|
2518
|
+
normalize(input) {
|
|
2519
|
+
const canonical = normalizeDid(input);
|
|
2520
|
+
if (canonical === ownDid()) throw new DidError("That is this profile's own Ghostly DID: your contacts already see your Ghostly identity. Add another DID.");
|
|
2521
|
+
return canonical;
|
|
2522
|
+
},
|
|
2523
|
+
short: shortDid,
|
|
2524
|
+
preview: async (subject, { signal }) => previewOf(await resolveDid(subject, {
|
|
2525
|
+
fetch: uiFetch,
|
|
2526
|
+
signal
|
|
2527
|
+
}))
|
|
2528
|
+
},
|
|
2529
|
+
publicUri: (subject) => subject,
|
|
2530
|
+
validity: {
|
|
2531
|
+
defaultDays: 90,
|
|
2532
|
+
maxDays: 365
|
|
2533
|
+
},
|
|
2534
|
+
recheck: { afterSeconds: 86400 },
|
|
2535
|
+
privacy: "did:key and did:jwk: nothing, checked on this device. did:web: the contact's app asks its DNS-over-HTTPS resolver for your domain and downloads your did.json, so your web server sees the contact's IP address. did:dht: a Pkarr relay learns which DID was looked up.",
|
|
2536
|
+
signers: [
|
|
2537
|
+
signer,
|
|
2538
|
+
fileSigner,
|
|
2539
|
+
serviceSigner
|
|
2540
|
+
],
|
|
2541
|
+
parseEvidence(raw) {
|
|
2542
|
+
const e = raw;
|
|
2543
|
+
if (exactKeys$1(raw, "jws,method,vm") && e.method === "jws" && typeof e.jws === "string" && e.jws.length <= 4096 && validVm(e.vm)) return {
|
|
2544
|
+
method: "jws",
|
|
2545
|
+
vm: e.vm,
|
|
2546
|
+
jws: e.jws
|
|
2547
|
+
};
|
|
2548
|
+
if (exactKeys$1(raw, "method,sig,vm") && e.method === "sig" && typeof e.sig === "string" && SIG.test(e.sig) && validVm(e.vm)) return {
|
|
2549
|
+
method: "sig",
|
|
2550
|
+
vm: e.vm,
|
|
2551
|
+
sig: e.sig
|
|
2552
|
+
};
|
|
2553
|
+
if (exactKeys$1(raw, "method") && (e.method === "file" || e.method === "service")) return { method: e.method };
|
|
2554
|
+
throw new Error("That is not DID proof evidence");
|
|
2555
|
+
},
|
|
2556
|
+
async verify(statement, evidence, ctx) {
|
|
2557
|
+
const subject = statement.binding.subject;
|
|
2558
|
+
const { method } = parseDid(subject);
|
|
2559
|
+
if (normalizeDid(subject) !== subject) throw new DidError("The DID is not in canonical form");
|
|
2560
|
+
if ((evidence.method === "file" || evidence.method === "service") && method !== "web") throw new DidError("Only a did:web can publish its proof");
|
|
2561
|
+
const options = {
|
|
2562
|
+
fetch: ctx.fetch,
|
|
2563
|
+
signal: ctx.signal,
|
|
2564
|
+
now: () => ctx.now * 1e3
|
|
2565
|
+
};
|
|
2566
|
+
const { document, host } = await resolveDid(subject, options);
|
|
2567
|
+
const messages = didSignedMessages(statement.text);
|
|
2568
|
+
if (evidence.method === "jws" || evidence.method === "sig") {
|
|
2569
|
+
const check = evidence.method === "jws" ? checkDidJws(evidence.jws, messages, document) : checkDidSignature(strictBase64Url(evidence.sig), messages, document, evidence.vm);
|
|
2570
|
+
if (check.key.id !== evidence.vm) throw new DidError("Signed by another key than the proof names");
|
|
2571
|
+
return {
|
|
2572
|
+
subject,
|
|
2573
|
+
source: `did:${method} · ${evidence.method === "jws" ? `${check.alg} JWS` : `${check.alg} signature`} by ${fragment(check.key.id)}`
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
if (evidence.method === "service") {
|
|
2577
|
+
if (!document.services.some((s) => s.proof === statement.id && s.key === statement.binding.key)) throw new DidError("The did.json has no Ghostly service for this proof: it was removed, or not added yet");
|
|
2578
|
+
return {
|
|
2579
|
+
subject,
|
|
2580
|
+
source: `did:web · service in the did.json of ${host}`
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
const text = await fetchDidWebFile(subject, statement.id, options);
|
|
2584
|
+
if (text === void 0) throw new DidError(`${didWebFileUrl(subject, statement.id)} was not found: it was deleted, or not uploaded yet`);
|
|
2585
|
+
if (!didWebFileNames(text, subject, statement.text)) throw new DidError(`${didWebFileUrl(subject, statement.id)} is not the statement of this proof`);
|
|
2586
|
+
return {
|
|
2587
|
+
subject,
|
|
2588
|
+
source: `did:web · statement file beside the did.json of ${host}`
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
};
|
|
2592
|
+
//#endregion
|
|
2593
|
+
//#region ../browser/src/proofs/atproto/oauth.ts
|
|
2594
|
+
/**
|
|
2595
|
+
* The prover's half of AT Protocol proofs: AT Protocol OAuth (PAR + PKCE + DPoP, through the official
|
|
2596
|
+
* `@atproto/oauth-client`) against the person's own server, then one record written or deleted. Every
|
|
2597
|
+
* flow gets a fresh client whose stores live in memory only: the DPoP key (a non-extractable WebCrypto
|
|
2598
|
+
* key), the PKCE verifier and the tokens exist for this one flow, are never logged or persisted, and the
|
|
2599
|
+
* session is revoked (signOut) as soon as the record is written. Nothing is kept for later: removing
|
|
2600
|
+
* the proof asks the person again.
|
|
2601
|
+
*
|
|
2602
|
+
* The client is identified by the URL of its metadata document (no registration with anyone):
|
|
2603
|
+
* https://ghostly.tools/oauth/client-metadata.json, which is ATPROTO_CLIENT_METADATA verbatim
|
|
2604
|
+
* (apps/website/public/oauth/client-metadata.json; a unit test keeps the two equal). A web app served from
|
|
2605
|
+
* a loopback address (development, the e2e suite) uses AT Protocol's `http://localhost` development
|
|
2606
|
+
* client instead, which needs no document.
|
|
2607
|
+
*/
|
|
2608
|
+
var ATPROTO_CLIENT_ID = "https://ghostly.tools/oauth/client-metadata.json";
|
|
2609
|
+
var ATPROTO_REDIRECTS = {
|
|
2610
|
+
/** The web app's static callback page (shared with OpenID Connect): the answer stays in the fragment. */
|
|
2611
|
+
web: "https://app.ghostly.tools/oidc-callback.html",
|
|
2612
|
+
/** chrome.identity.launchWebAuthFlow catches it without loading it. */
|
|
2613
|
+
extension: `https://nbedaagicniejlmfcncndfjcejaidbcf.chromiumapp.org/atproto`,
|
|
2614
|
+
/** The desktop app's one-shot listener on 127.0.0.1; the port is not part of the match. */
|
|
2615
|
+
desktop: "http://127.0.0.1/oidc-callback"
|
|
2616
|
+
};
|
|
2617
|
+
/**
|
|
2618
|
+
* `native` because the desktop app's loopback redirect is only allowed for native clients; HTTPS
|
|
2619
|
+
* redirects (web, extension) are allowed for them too. No `refresh_token` grant: Ghostly never keeps
|
|
2620
|
+
* a session, so it never gets a refresh token to lose.
|
|
2621
|
+
*/
|
|
2622
|
+
var ATPROTO_CLIENT_METADATA = {
|
|
2623
|
+
client_id: ATPROTO_CLIENT_ID,
|
|
2624
|
+
client_name: "Ghostly",
|
|
2625
|
+
client_uri: "https://ghostly.tools",
|
|
2626
|
+
logo_uri: "https://ghostly.tools/icon-192.png",
|
|
2627
|
+
application_type: "native",
|
|
2628
|
+
grant_types: ["authorization_code"],
|
|
2629
|
+
response_types: ["code"],
|
|
2630
|
+
scope: ATPROTO_DECLARED_SCOPE,
|
|
2631
|
+
token_endpoint_auth_method: "none",
|
|
2632
|
+
dpop_bound_access_tokens: true,
|
|
2633
|
+
redirect_uris: [
|
|
2634
|
+
ATPROTO_REDIRECTS.web,
|
|
2635
|
+
ATPROTO_REDIRECTS.extension,
|
|
2636
|
+
ATPROTO_REDIRECTS.desktop
|
|
2637
|
+
]
|
|
2638
|
+
};
|
|
2639
|
+
/** AT Protocol's development client: no document, loopback redirects only, any port. */
|
|
2640
|
+
function loopbackClientMetadata(redirectUri) {
|
|
2641
|
+
const registered = new URL(redirectUri);
|
|
2642
|
+
registered.port = "";
|
|
2643
|
+
return {
|
|
2644
|
+
client_id: `http://localhost?${new URLSearchParams({
|
|
2645
|
+
redirect_uri: registered.href,
|
|
2646
|
+
scope: ATPROTO_DECLARED_SCOPE
|
|
2647
|
+
})}`,
|
|
2648
|
+
application_type: "native",
|
|
2649
|
+
grant_types: ["authorization_code"],
|
|
2650
|
+
response_types: ["code"],
|
|
2651
|
+
scope: ATPROTO_DECLARED_SCOPE,
|
|
2652
|
+
token_endpoint_auth_method: "none",
|
|
2653
|
+
dpop_bound_access_tokens: true,
|
|
2654
|
+
redirect_uris: [registered.href]
|
|
2655
|
+
};
|
|
2656
|
+
}
|
|
2657
|
+
var isLoopback = (url) => {
|
|
2658
|
+
try {
|
|
2659
|
+
const u = new URL(url);
|
|
2660
|
+
return u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "[::1]");
|
|
2661
|
+
} catch {
|
|
2662
|
+
return false;
|
|
2663
|
+
}
|
|
2664
|
+
};
|
|
2665
|
+
var withoutPort = (url) => {
|
|
2666
|
+
const u = new URL(url);
|
|
2667
|
+
u.port = "";
|
|
2668
|
+
return u.href;
|
|
2669
|
+
};
|
|
2670
|
+
/**
|
|
2671
|
+
* The client for a redirect: the published one, or the development client on a loopback web app. A
|
|
2672
|
+
* loopback redirect is registered without its port (servers match any port), but the library checks
|
|
2673
|
+
* the exact address locally, so the one in use is added to the copy it gets; the server only ever sees
|
|
2674
|
+
* the client id and reads the registered addresses itself.
|
|
2675
|
+
*/
|
|
2676
|
+
function clientMetadataFor(platform, redirectUri) {
|
|
2677
|
+
const base = platform === "web" && isLoopback(redirectUri) ? loopbackClientMetadata(redirectUri) : ATPROTO_CLIENT_METADATA;
|
|
2678
|
+
const registered = base.redirect_uris;
|
|
2679
|
+
if (!(registered.includes(redirectUri) || isLoopback(redirectUri) && registered.includes(withoutPort(redirectUri)))) throw new Error("This Ghostly build cannot sign in to an AT Protocol server here (its return address is not registered).");
|
|
2680
|
+
return {
|
|
2681
|
+
...base,
|
|
2682
|
+
redirect_uris: [.../* @__PURE__ */ new Set([redirectUri, ...registered])]
|
|
2683
|
+
};
|
|
2684
|
+
}
|
|
2685
|
+
var scopeFor = (access) => access === "full" ? ATPROTO_FULL_SCOPE : ATPROTO_PROOF_SCOPE;
|
|
2686
|
+
/** The server refused the narrow permission: it predates granular scopes. */
|
|
2687
|
+
var AtprotoScopeError = class extends Error {};
|
|
2688
|
+
/** The account a handle (or a DID typed as is) names, with its document. */
|
|
2689
|
+
async function lookupAtprotoAccount(input, options) {
|
|
2690
|
+
const value = input.trim();
|
|
2691
|
+
if (value.startsWith("did:")) return resolveAtprotoDid(value, options);
|
|
2692
|
+
const handle = options.normalize(value);
|
|
2693
|
+
if (!handle) throw new Error("That is not a handle (like alice.bsky.social)");
|
|
2694
|
+
const did = await resolveAtprotoHandle(handle, options);
|
|
2695
|
+
if (!did) throw new Error(`${handle} does not point to an AT Protocol account`);
|
|
2696
|
+
const doc = await resolveAtprotoDid(did, options);
|
|
2697
|
+
if (doc.handle !== handle) throw new Error(`${handle} points to an account that names another handle`);
|
|
2698
|
+
return doc;
|
|
2699
|
+
}
|
|
2700
|
+
var MESSAGES = {
|
|
2701
|
+
access_denied: "You declined on your server. Nothing was published.",
|
|
2702
|
+
login_required: "Log in on your server and try again.",
|
|
2703
|
+
consent_required: "Your server needs your approval to continue."
|
|
2704
|
+
};
|
|
2705
|
+
/** The redirect's parameters: the fragment (Ghostly asks for fragment responses), then the query. */
|
|
2706
|
+
function callbackParams(redirected, redirectUri) {
|
|
2707
|
+
const url = new URL(redirected), expected = new URL(redirectUri);
|
|
2708
|
+
if (url.origin !== expected.origin || url.pathname !== expected.pathname) throw new Error("Sign-in returned to an unexpected address.");
|
|
2709
|
+
const params = new URLSearchParams(url.hash.slice(1));
|
|
2710
|
+
for (const [k, v] of new URLSearchParams(url.search)) if (!params.has(k)) params.set(k, v);
|
|
2711
|
+
return params;
|
|
2712
|
+
}
|
|
2713
|
+
var pdsHost = (pds) => new URL(pds).host;
|
|
2714
|
+
/** Runs OAuth for `account`, hands `work` a session scoped to this flow, and always revokes it afterwards. */
|
|
2715
|
+
async function withSession(options, work) {
|
|
2716
|
+
const { window: w, account, signal } = options;
|
|
2717
|
+
signal.throwIfAborted();
|
|
2718
|
+
const [{ OAuthClient }, { WebcryptoKey }] = await Promise.all([import("@atproto/oauth-client"), import("@atproto/jwk-webcrypto")]);
|
|
2719
|
+
const states = /* @__PURE__ */ new Map(), sessions = /* @__PURE__ */ new Map();
|
|
2720
|
+
let state;
|
|
2721
|
+
const memory = (map, onSet) => ({
|
|
2722
|
+
async get(key) {
|
|
2723
|
+
return map.get(key);
|
|
2724
|
+
},
|
|
2725
|
+
async set(key, value) {
|
|
2726
|
+
onSet?.(key);
|
|
2727
|
+
map.set(key, value);
|
|
2728
|
+
},
|
|
2729
|
+
async del(key) {
|
|
2730
|
+
map.delete(key);
|
|
2731
|
+
}
|
|
2732
|
+
});
|
|
2733
|
+
const client = new OAuthClient({
|
|
2734
|
+
responseMode: "fragment",
|
|
2735
|
+
clientMetadata: clientMetadataFor(options.platform, w.redirectUri),
|
|
2736
|
+
stateStore: memory(states, (key) => {
|
|
2737
|
+
state = key;
|
|
2738
|
+
}),
|
|
2739
|
+
sessionStore: memory(sessions),
|
|
2740
|
+
runtimeImplementation: {
|
|
2741
|
+
createKey: (algs) => WebcryptoKey.generate(algs),
|
|
2742
|
+
getRandomValues: (length) => crypto.getRandomValues(new Uint8Array(length)),
|
|
2743
|
+
digest: async (data, { name }) => new Uint8Array(await crypto.subtle.digest(`SHA-${name.slice(3)}`, data))
|
|
2744
|
+
},
|
|
2745
|
+
identityResolver: { async resolve(input) {
|
|
2746
|
+
const did = input.startsWith("did:") ? input : await resolveAtprotoHandle(input, options.resolve);
|
|
2747
|
+
if (!did) throw new Error("The account could not be resolved");
|
|
2748
|
+
const doc = await fetchDidJson(did, options.resolve);
|
|
2749
|
+
return {
|
|
2750
|
+
did,
|
|
2751
|
+
didDoc: doc.json,
|
|
2752
|
+
handle: doc.parsed.handle ?? "handle.invalid"
|
|
2753
|
+
};
|
|
2754
|
+
} },
|
|
2755
|
+
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
|
|
2756
|
+
allowHttp: false
|
|
2757
|
+
});
|
|
2758
|
+
let url;
|
|
2759
|
+
try {
|
|
2760
|
+
url = await client.authorize(account.did, {
|
|
2761
|
+
scope: scopeFor(options.access),
|
|
2762
|
+
redirect_uri: w.redirectUri,
|
|
2763
|
+
signal
|
|
2764
|
+
});
|
|
2765
|
+
} catch (e) {
|
|
2766
|
+
const text = String(e?.error ?? e?.message ?? e);
|
|
2767
|
+
if (/invalid_scope/i.test(text) || /scope/i.test(String(e?.errorDescription ?? ""))) throw new AtprotoScopeError("Your server does not offer a permission limited to Ghostly's records: it only offers full access to your account. Choose \"Your server, full access\" to continue anyway; Ghostly still only writes this one record.");
|
|
2768
|
+
throw new Error(`Your server (${pdsHost(account.pds)}) refused to start the sign-in.`);
|
|
2769
|
+
}
|
|
2770
|
+
if (!state) throw new Error("The sign-in could not start.");
|
|
2771
|
+
options.onProgress(`Approve on ${pdsHost(account.pds)} in the window that opened…`);
|
|
2772
|
+
const redirected = await w.authorize(url.href, state, signal);
|
|
2773
|
+
signal.throwIfAborted();
|
|
2774
|
+
const params = callbackParams(redirected, w.redirectUri);
|
|
2775
|
+
const error = params.get("error");
|
|
2776
|
+
if (error) throw new Error(MESSAGES[error] ?? "Your server refused the sign-in.");
|
|
2777
|
+
let session;
|
|
2778
|
+
try {
|
|
2779
|
+
({session} = await client.callback(params, { redirect_uri: w.redirectUri }));
|
|
2780
|
+
} catch {
|
|
2781
|
+
throw new Error("The sign-in could not be completed. Nothing was published.");
|
|
2782
|
+
}
|
|
2783
|
+
try {
|
|
2784
|
+
if (session.did !== account.did) throw new Error("You logged in to another account than the one you typed. Nothing was published.");
|
|
2785
|
+
return await work(session);
|
|
2786
|
+
} finally {
|
|
2787
|
+
await session.signOut().catch(() => {});
|
|
2788
|
+
sessions.clear();
|
|
2789
|
+
states.clear();
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
/** A DID's document, checked, and the JSON the library reads its services from. */
|
|
2793
|
+
async function fetchDidJson(did, options) {
|
|
2794
|
+
let json;
|
|
2795
|
+
const recording = async (url, init) => {
|
|
2796
|
+
const response = await options.fetch(url, init);
|
|
2797
|
+
try {
|
|
2798
|
+
json = JSON.parse(response.text);
|
|
2799
|
+
} catch {}
|
|
2800
|
+
return response;
|
|
2801
|
+
};
|
|
2802
|
+
return {
|
|
2803
|
+
parsed: await resolveAtprotoDid(did, {
|
|
2804
|
+
...options,
|
|
2805
|
+
fetch: recording
|
|
2806
|
+
}),
|
|
2807
|
+
json
|
|
2808
|
+
};
|
|
2809
|
+
}
|
|
2810
|
+
async function xrpcError(response) {
|
|
2811
|
+
const body = await response.json().catch(() => ({}));
|
|
2812
|
+
return `${body.error ?? response.status}${body.message ? `: ${body.message}` : ""}`;
|
|
2813
|
+
}
|
|
2814
|
+
/** Writes the proof record (rkey = the proof key) and returns once the server has it. */
|
|
2815
|
+
async function publishAtprotoProof(statement, options) {
|
|
2816
|
+
await withSession(options, async (session) => {
|
|
2817
|
+
options.onProgress(`Publishing the proof on ${pdsHost(options.account.pds)}…`);
|
|
2818
|
+
const response = await session.fetchHandler("/xrpc/com.atproto.repo.createRecord", {
|
|
2819
|
+
method: "POST",
|
|
2820
|
+
headers: { "content-type": "application/json" },
|
|
2821
|
+
body: JSON.stringify({
|
|
2822
|
+
repo: session.did,
|
|
2823
|
+
collection: ATPROTO_PROOF_COLLECTION,
|
|
2824
|
+
rkey: atprotoProofRkey(statement.binding.key),
|
|
2825
|
+
record: atprotoProofRecord(statement, /* @__PURE__ */ new Date())
|
|
2826
|
+
})
|
|
2827
|
+
});
|
|
2828
|
+
if (response.status === 403) throw new AtprotoScopeError("Your server did not let Ghostly write its record. Choose \"Your server, full access\" to try again.");
|
|
2829
|
+
if (!response.ok) throw new Error(`Your server did not publish the record (${await xrpcError(response)}).`);
|
|
2830
|
+
});
|
|
2831
|
+
}
|
|
2832
|
+
/** Deletes the proof record. Deleting a record that is not there succeeds. */
|
|
2833
|
+
async function unpublishAtprotoProof(proofKey, options) {
|
|
2834
|
+
await withSession(options, async (session) => {
|
|
2835
|
+
options.onProgress(`Deleting the record on ${pdsHost(options.account.pds)}…`);
|
|
2836
|
+
const response = await session.fetchHandler("/xrpc/com.atproto.repo.deleteRecord", {
|
|
2837
|
+
method: "POST",
|
|
2838
|
+
headers: { "content-type": "application/json" },
|
|
2839
|
+
body: JSON.stringify({
|
|
2840
|
+
repo: session.did,
|
|
2841
|
+
collection: ATPROTO_PROOF_COLLECTION,
|
|
2842
|
+
rkey: atprotoProofRkey(proofKey)
|
|
2843
|
+
})
|
|
2844
|
+
});
|
|
2845
|
+
if (response.status === 403) throw new AtprotoScopeError("Your server did not let Ghostly delete its record.");
|
|
2846
|
+
if (!response.ok) throw new Error(`Your server did not delete the record (${await xrpcError(response)}).`);
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2849
|
+
//#endregion
|
|
2850
|
+
//#region ../browser/src/proofs/providers/atproto.ts
|
|
2851
|
+
/** The proof CAR: a commit, the tree path and one small record. Real repositories' paths are a few KiB. */
|
|
2852
|
+
var RECORD_PROOF_MAX_BYTES = 262144;
|
|
2853
|
+
function currentHost() {
|
|
2854
|
+
try {
|
|
2855
|
+
return getBrowserHost().atproto;
|
|
2856
|
+
} catch {
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
var hostOf = (url) => new URL(url).host;
|
|
2861
|
+
function createAtprotoIdentityProvider(options = {}) {
|
|
2862
|
+
const host = options.host ?? currentHost;
|
|
2863
|
+
const uiFetch = () => options.fetch ?? boundedIdentityFetch({ online: () => true });
|
|
2864
|
+
const resolveOptions = (fetch, signal) => ({
|
|
2865
|
+
fetch,
|
|
2866
|
+
signal,
|
|
2867
|
+
resolver: chosenResolver().id,
|
|
2868
|
+
...options.plc ? { plc: options.plc } : {}
|
|
2869
|
+
});
|
|
2870
|
+
/** Opens the window from the click, looks the account up, then runs OAuth for `work`; always closes the window. */
|
|
2871
|
+
function signIn(input, access, ctx, work) {
|
|
2872
|
+
const h = host();
|
|
2873
|
+
const opening = h?.open();
|
|
2874
|
+
return (async () => {
|
|
2875
|
+
if (!h || !opening) throw new Error("Signing in to an AT Protocol server is not available here.");
|
|
2876
|
+
const w = await opening;
|
|
2877
|
+
try {
|
|
2878
|
+
const resolve = resolveOptions(uiFetch(), ctx.signal);
|
|
2879
|
+
ctx.onProgress(`Looking up ${input.trim()}…`);
|
|
2880
|
+
const account = await lookupAtprotoAccount(input, {
|
|
2881
|
+
...resolve,
|
|
2882
|
+
normalize: normalizeAtprotoHandle
|
|
2883
|
+
});
|
|
2884
|
+
ctx.signal.throwIfAborted();
|
|
2885
|
+
return await work({
|
|
2886
|
+
platform: h.platform,
|
|
2887
|
+
window: w,
|
|
2888
|
+
account,
|
|
2889
|
+
access,
|
|
2890
|
+
signal: ctx.signal,
|
|
2891
|
+
resolve,
|
|
2892
|
+
onProgress: ctx.onProgress,
|
|
2893
|
+
fetch: options.oauthFetch
|
|
2894
|
+
});
|
|
2895
|
+
} finally {
|
|
2896
|
+
w.close();
|
|
2897
|
+
}
|
|
2898
|
+
})();
|
|
2899
|
+
}
|
|
2900
|
+
const signer = (id, access, label, description) => ({
|
|
2901
|
+
id,
|
|
2902
|
+
kind: "in-app",
|
|
2903
|
+
label,
|
|
2904
|
+
description,
|
|
2905
|
+
action: access === "full" ? "Continue on your server (full access)" : "Continue on your server",
|
|
2906
|
+
available: () => !!host(),
|
|
2907
|
+
fields: [{
|
|
2908
|
+
name: "handle",
|
|
2909
|
+
label: "Handle",
|
|
2910
|
+
kind: "text",
|
|
2911
|
+
placeholder: "alice.bsky.social",
|
|
2912
|
+
help: "Your Bluesky handle, your own domain if you use one, or your DID."
|
|
2913
|
+
}],
|
|
2914
|
+
run(ctx, work) {
|
|
2915
|
+
return signIn(ctx.values.handle ?? "", access, ctx, (flow) => work({
|
|
2916
|
+
subject: async () => flow.account.did,
|
|
2917
|
+
sign: async (statement) => {
|
|
2918
|
+
await publishAtprotoProof(statement, flow);
|
|
2919
|
+
return {};
|
|
2920
|
+
}
|
|
2921
|
+
}));
|
|
2922
|
+
}
|
|
2923
|
+
});
|
|
2924
|
+
const fullAccessServers = /* @__PURE__ */ new Set();
|
|
2925
|
+
return {
|
|
2926
|
+
id: "atproto",
|
|
2927
|
+
label: "Bluesky / AT Protocol",
|
|
2928
|
+
category: "self-custodied",
|
|
2929
|
+
summary: "Publish a record on your Bluesky server",
|
|
2930
|
+
description: "Proves you control a Bluesky (AT Protocol) account: approve on your server, and Ghostly publishes one record in your account's repository. It is public, like the rest of your repository: anyone can see this account made a Ghostly proof, not who you share it with. Contacts check it without logging in.",
|
|
2931
|
+
limits: "Signed with your account's key, which your server usually holds. It proves control of the account, not who you are; the handle is shown only while it points back to the account.",
|
|
2932
|
+
platforms: [
|
|
2933
|
+
"web",
|
|
2934
|
+
"extension",
|
|
2935
|
+
"desktop"
|
|
2936
|
+
],
|
|
2937
|
+
subject: {
|
|
2938
|
+
label: "Account (DID)",
|
|
2939
|
+
placeholder: "did:plc:…",
|
|
2940
|
+
normalize(input) {
|
|
2941
|
+
const did = input.trim();
|
|
2942
|
+
if (!isAtprotoDid(did)) throw new Error("Not an AT Protocol account (did:plc or did:web)");
|
|
2943
|
+
return did;
|
|
2944
|
+
},
|
|
2945
|
+
short: shortAtprotoDid
|
|
2946
|
+
},
|
|
2947
|
+
publicUri: (did) => `at://${did}`,
|
|
2948
|
+
validity: {
|
|
2949
|
+
defaultDays: 90,
|
|
2950
|
+
maxDays: 365
|
|
2951
|
+
},
|
|
2952
|
+
recheck: { afterSeconds: 3600 },
|
|
2953
|
+
privacy: "Your contact's app asks the PLC directory (or your did:web host) for your account's document, your server for the record, and its DNS-over-HTTPS resolver or your handle's website for your handle, so they learn this account was checked.",
|
|
2954
|
+
signers: [signer("atproto-oauth", "proof-records", "Your server (Bluesky or another PDS)", "Opens your server's login page. Ghostly asks only to create and delete its own proof records: never to post, read your messages or edit your profile."), signer("atproto-oauth-full", "full", "Your server, full access (older servers)", "Only if your server says it cannot limit Ghostly's permission: it then asks for access to your whole account. Ghostly still only writes this one record, and gives the access back right away.")],
|
|
2955
|
+
parseEvidence(raw) {
|
|
2956
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).length) throw new Error("Invalid AT Protocol evidence");
|
|
2957
|
+
return {};
|
|
2958
|
+
},
|
|
2959
|
+
async verify(statement, _evidence, ctx) {
|
|
2960
|
+
const did = statement.binding.subject;
|
|
2961
|
+
if (!isAtprotoDid(did)) throw new Error("Not an AT Protocol account");
|
|
2962
|
+
const resolve = resolveOptions(ctx.fetch, ctx.signal);
|
|
2963
|
+
const doc = await resolveAtprotoDid(did, resolve);
|
|
2964
|
+
await assertPublicServer(hostOf(doc.pds).replace(/:\d+$/, ""), resolve);
|
|
2965
|
+
const rkey = atprotoProofRkey(statement.binding.key);
|
|
2966
|
+
const url = `${doc.pds}/xrpc/com.atproto.sync.getRecord?${new URLSearchParams({
|
|
2967
|
+
did,
|
|
2968
|
+
collection: ATPROTO_PROOF_COLLECTION,
|
|
2969
|
+
rkey
|
|
2970
|
+
})}`;
|
|
2971
|
+
let response;
|
|
2972
|
+
try {
|
|
2973
|
+
response = await ctx.fetch(url, {
|
|
2974
|
+
headers: { accept: "application/vnd.ipld.car" },
|
|
2975
|
+
maxBytes: RECORD_PROOF_MAX_BYTES,
|
|
2976
|
+
signal: ctx.signal,
|
|
2977
|
+
redirect: "error"
|
|
2978
|
+
});
|
|
2979
|
+
} catch (e) {
|
|
2980
|
+
throw new Error(/too large/i.test(String(e)) ? "The account's server sent a proof that is too large" : `The account's server (${hostOf(doc.pds)}) could not be reached`);
|
|
2981
|
+
}
|
|
2982
|
+
if (response.status !== 200) {
|
|
2983
|
+
let code = "";
|
|
2984
|
+
try {
|
|
2985
|
+
code = String(JSON.parse(response.text).error ?? "");
|
|
2986
|
+
} catch {}
|
|
2987
|
+
if (code === "RecordNotFound") throw new Error("The record is no longer in the account's repository");
|
|
2988
|
+
if (/^Repo(NotFound|Deactivated|Takendown|Suspended)$/.test(code)) throw new Error("The account is no longer on its server");
|
|
2989
|
+
throw new Error(`The account's server (${hostOf(doc.pds)}) did not send the record`);
|
|
2990
|
+
}
|
|
2991
|
+
let proof;
|
|
2992
|
+
try {
|
|
2993
|
+
proof = verifyAtprotoRecordProof(response.bytes, {
|
|
2994
|
+
did,
|
|
2995
|
+
key: doc.key,
|
|
2996
|
+
collection: ATPROTO_PROOF_COLLECTION,
|
|
2997
|
+
rkey
|
|
2998
|
+
});
|
|
2999
|
+
} catch (e) {
|
|
3000
|
+
throw new Error(e instanceof AtprotoProofError ? `The account's repository does not prove the record: ${e.message}` : "The account's repository could not be read");
|
|
3001
|
+
}
|
|
3002
|
+
if (!proof.record) throw new Error("The record is no longer in the account's repository");
|
|
3003
|
+
checkAtprotoProofRecord(proof.record.value, statement);
|
|
3004
|
+
const handle = await verifiedAtprotoHandle(doc, resolve);
|
|
3005
|
+
return {
|
|
3006
|
+
subject: did,
|
|
3007
|
+
source: `Record signed with the account's key, from ${hostOf(doc.pds)}`,
|
|
3008
|
+
display: {
|
|
3009
|
+
...handle ? { name: `@${handle}` } : {},
|
|
3010
|
+
url: `https://bsky.app/profile/${did}`,
|
|
3011
|
+
source: handle ? "Handle checked both ways: it points to this account" : "The account's handle does not point back to it, so it is not shown",
|
|
3012
|
+
fetchedAt: ctx.now
|
|
3013
|
+
}
|
|
3014
|
+
};
|
|
3015
|
+
},
|
|
3016
|
+
unpublish: {
|
|
3017
|
+
description: "Also deletes the record on your server: your server asks you to approve.",
|
|
3018
|
+
run(proof, ctx) {
|
|
3019
|
+
return signIn(proof.subject, "proof-records", ctx, async (flow) => {
|
|
3020
|
+
const access = fullAccessServers.has(flow.account.pds) ? "full" : "proof-records";
|
|
3021
|
+
try {
|
|
3022
|
+
await unpublishAtprotoProof(proof.key, {
|
|
3023
|
+
...flow,
|
|
3024
|
+
access
|
|
3025
|
+
});
|
|
3026
|
+
} catch (e) {
|
|
3027
|
+
if (e instanceof AtprotoScopeError && access !== "full") {
|
|
3028
|
+
fullAccessServers.add(flow.account.pds);
|
|
3029
|
+
throw new Error("Your server only offers full access to your account. Try again to approve that; Ghostly only deletes this one record.");
|
|
3030
|
+
}
|
|
3031
|
+
throw e;
|
|
3032
|
+
}
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
};
|
|
3037
|
+
}
|
|
3038
|
+
var atproto = createAtprotoIdentityProvider();
|
|
3039
|
+
//#endregion
|
|
3040
|
+
//#region ../browser/src/proofs/pubky.ts
|
|
3041
|
+
/**
|
|
3042
|
+
* Pubky, browser side: reading a key's records and files the way a contact's app does (through the proof
|
|
3043
|
+
* contract's bounded `ctx.fetch` only), and asking the person to approve one Pubky auth request in Pubky Ring or
|
|
3044
|
+
* Pubky Passport (https://github.com/pubky/pubky-passport/blob/main/docs/integration.md). See WISP 302.
|
|
3045
|
+
*/
|
|
3046
|
+
/** Pkarr relays the records are read from: Pubky's own, the ones the Pubky SDK uses. */
|
|
3047
|
+
var PUBKY_RELAYS = ["https://pkarr.pubky.org", "https://pkarr.pubky.app"];
|
|
3048
|
+
/**
|
|
3049
|
+
* The newest DNS packet the relays hold for `key`, its signature checked against `key`. Every relay is asked; one
|
|
3050
|
+
* that is down, answers garbage or holds an older packet does not count. Throws when none has a valid one.
|
|
3051
|
+
*/
|
|
3052
|
+
async function pubkyRecords(key, fetch, signal, relays = PUBKY_RELAYS) {
|
|
3053
|
+
const failed = [];
|
|
3054
|
+
const newest = (await Promise.all(relays.map(async (relay) => {
|
|
3055
|
+
let r;
|
|
3056
|
+
try {
|
|
3057
|
+
r = await fetch(`${relay.replace(/\/+$/, "")}/${key}`, {
|
|
3058
|
+
maxBytes: RELAY_PAYLOAD_MAX_BYTES,
|
|
3059
|
+
signal
|
|
3060
|
+
});
|
|
3061
|
+
} catch (e) {
|
|
3062
|
+
failed.push(e);
|
|
3063
|
+
return;
|
|
3064
|
+
}
|
|
3065
|
+
try {
|
|
3066
|
+
return r.status === 200 ? openRelayPayload(key, r.bytes) : void 0;
|
|
3067
|
+
} catch {
|
|
3068
|
+
return;
|
|
3069
|
+
}
|
|
3070
|
+
}))).filter((a) => !!a).sort((a, b) => b.seq > a.seq ? 1 : b.seq < a.seq ? -1 : 0)[0];
|
|
3071
|
+
if (!newest && failed.length === relays.length && failed[0] instanceof Error) throw failed[0];
|
|
3072
|
+
if (!newest) throw new Error("This Pubky key has no records on the Pkarr relays");
|
|
3073
|
+
return newest.dnsPacket;
|
|
3074
|
+
}
|
|
3075
|
+
/** Where `key`'s homeserver answers browsers: its `_pubky` record, then the homeserver's own HTTPS record. */
|
|
3076
|
+
async function pubkyHomeserver(key, fetch, signal, relays) {
|
|
3077
|
+
const homeserver = pubkyHomeserverOf(await pubkyRecords(key, fetch, signal, relays), key);
|
|
3078
|
+
if (!homeserver) throw new Error("This Pubky key names no homeserver");
|
|
3079
|
+
const endpoint = homeserverWebEndpoint(await pubkyRecords(homeserver, fetch, signal, relays), homeserver);
|
|
3080
|
+
if (!endpoint) throw new Error("The homeserver of this Pubky key publishes no address a browser can reach");
|
|
3081
|
+
return {
|
|
3082
|
+
homeserver,
|
|
3083
|
+
endpoint
|
|
3084
|
+
};
|
|
3085
|
+
}
|
|
3086
|
+
/**
|
|
3087
|
+
* A public file of `key` at `path`, from the homeserver its own records name: bounded, no redirects, the owner in the
|
|
3088
|
+
* `pubky-host` header the way the SDK addresses it. `undefined` when the homeserver says it is not there.
|
|
3089
|
+
*
|
|
3090
|
+
* The key's owner chooses that host, so it is contacted only on port 443 and only once its addresses are all public
|
|
3091
|
+
* (the name is resolved first through the chosen DNS-over-HTTPS resolver): a proof never makes a contact's app knock
|
|
3092
|
+
* on its own network. Failures on the way there are IdentityCheckUnavailable, which the contact is not told about.
|
|
3093
|
+
*/
|
|
3094
|
+
async function readPubkyFile(key, path, fetch, signal, relays, resolver = chosenResolver().id) {
|
|
3095
|
+
const { endpoint: { host, port } } = await pubkyHomeserver(key, fetch, signal, relays);
|
|
3096
|
+
if (port !== void 0) throw new IdentityCheckUnavailable(`The homeserver ${host} answers on port ${port}; only port 443 is contacted`);
|
|
3097
|
+
try {
|
|
3098
|
+
await assertPublicHost(host, {
|
|
3099
|
+
fetch,
|
|
3100
|
+
signal,
|
|
3101
|
+
resolver
|
|
3102
|
+
});
|
|
3103
|
+
} catch (e) {
|
|
3104
|
+
throw new IdentityCheckUnavailable(`The homeserver ${host} was not contacted: ${e instanceof Error ? e.message : String(e)}`);
|
|
3105
|
+
}
|
|
3106
|
+
let r;
|
|
3107
|
+
try {
|
|
3108
|
+
r = await fetch(`https://${host}${path}`, {
|
|
3109
|
+
headers: { "pubky-host": key },
|
|
3110
|
+
maxBytes: PUBKY_PROOF_MAX_BYTES,
|
|
3111
|
+
signal
|
|
3112
|
+
});
|
|
3113
|
+
} catch (e) {
|
|
3114
|
+
if (/too large/i.test(String(e))) throw e;
|
|
3115
|
+
throw new IdentityCheckUnavailable(`The homeserver ${host} could not be reached`);
|
|
3116
|
+
}
|
|
3117
|
+
if (r.status === 404) return {
|
|
3118
|
+
text: void 0,
|
|
3119
|
+
host
|
|
3120
|
+
};
|
|
3121
|
+
if (r.status !== 200) throw new IdentityCheckUnavailable(`The homeserver ${host} answered ${r.status}`);
|
|
3122
|
+
return {
|
|
3123
|
+
text: r.text,
|
|
3124
|
+
host
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3127
|
+
/** The app identifier Pubky Ring and Passport list the grant under. */
|
|
3128
|
+
var PUBKY_CLIENT_ID = "ghostly.tools";
|
|
3129
|
+
var PASSPORT_ORIGIN = "https://passport.pubky.app";
|
|
3130
|
+
/** How long one request waits: Pubky's relay keeps a message about five minutes. */
|
|
3131
|
+
var APPROVAL_TIMEOUT = 3e5;
|
|
3132
|
+
var POLL_EVERY = 1e3;
|
|
3133
|
+
/**
|
|
3134
|
+
* Passport's authorize page for a request. The authorization URL carries the relay secret: it goes in the fragment
|
|
3135
|
+
* (never sent to a server), encoded exactly once, and is never logged.
|
|
3136
|
+
*/
|
|
3137
|
+
var passportUrl = (authorizationUrl) => `${PASSPORT_ORIGIN}/authorize#d=${encodeURIComponent(authorizationUrl)}`;
|
|
3138
|
+
/** Messages from the SDK may quote the request: nothing that looks like one reaches the UI or a log. */
|
|
3139
|
+
var redact = (text) => text.replace(/pubkyauth:\/\/\S*/gi, "(request)").replace(/#d=\S*/g, "#d=(request)");
|
|
3140
|
+
var failure = (what, e) => /* @__PURE__ */ new Error(`${what}${e instanceof Error && e.message ? `: ${redact(e.message)}` : ""}`);
|
|
3141
|
+
/**
|
|
3142
|
+
* Opens a Passport window from the click that calls it. It must run synchronously in the click handler, before
|
|
3143
|
+
* anything is awaited, or the browser blocks the popup; the desktop app opens the system browser instead.
|
|
3144
|
+
*/
|
|
3145
|
+
function openPassportWindow(url) {
|
|
3146
|
+
let host;
|
|
3147
|
+
try {
|
|
3148
|
+
host = getBrowserHost();
|
|
3149
|
+
} catch {
|
|
3150
|
+
host = void 0;
|
|
3151
|
+
}
|
|
3152
|
+
if (host?.openPubkyPassport) {
|
|
3153
|
+
host.openPubkyPassport(url).catch(() => {});
|
|
3154
|
+
return;
|
|
3155
|
+
}
|
|
3156
|
+
return window.open(url, "pubky-passport", "popup,width=520,height=760");
|
|
3157
|
+
}
|
|
3158
|
+
/** A 401 or 403 from the homeserver: the session it was given is not one it accepts. */
|
|
3159
|
+
var refused = (e) => {
|
|
3160
|
+
const status = e?.data?.statusCode;
|
|
3161
|
+
return status === 401 || status === 403;
|
|
3162
|
+
};
|
|
3163
|
+
/**
|
|
3164
|
+
* Polls every request until one of them is approved and resolves with that session and its request; a cancel, the
|
|
3165
|
+
* deadline or a failed poll ends the wait for all of them. Each request is freed once its last poll has settled
|
|
3166
|
+
* (never while one is in flight), and a session that lands after the end is signed out at once; `onSettled` runs
|
|
3167
|
+
* once all of that is done.
|
|
3168
|
+
*/
|
|
3169
|
+
function firstApproval(requests, signal, timeoutMs, onSettled) {
|
|
3170
|
+
return new Promise((resolve, reject) => {
|
|
3171
|
+
let over = false;
|
|
3172
|
+
let polling = requests.length;
|
|
3173
|
+
const wakers = /* @__PURE__ */ new Set();
|
|
3174
|
+
const end = (settle) => {
|
|
3175
|
+
if (over) return;
|
|
3176
|
+
over = true;
|
|
3177
|
+
clearTimeout(timer);
|
|
3178
|
+
signal.removeEventListener("abort", cancel);
|
|
3179
|
+
for (const wake of wakers) wake();
|
|
3180
|
+
settle();
|
|
3181
|
+
};
|
|
3182
|
+
const cancel = () => end(() => reject(signal.reason));
|
|
3183
|
+
const timer = setTimeout(() => end(() => reject(/* @__PURE__ */ new Error("Nobody approved the request in time. Start again for a new one."))), timeoutMs);
|
|
3184
|
+
if (signal.aborted) cancel();
|
|
3185
|
+
else signal.addEventListener("abort", cancel, { once: true });
|
|
3186
|
+
const pause = () => new Promise((done) => {
|
|
3187
|
+
const wake = () => {
|
|
3188
|
+
clearTimeout(t);
|
|
3189
|
+
wakers.delete(wake);
|
|
3190
|
+
done();
|
|
3191
|
+
};
|
|
3192
|
+
const t = setTimeout(wake, POLL_EVERY);
|
|
3193
|
+
wakers.add(wake);
|
|
3194
|
+
});
|
|
3195
|
+
const poll = async (request) => {
|
|
3196
|
+
try {
|
|
3197
|
+
while (!over) {
|
|
3198
|
+
let session;
|
|
3199
|
+
try {
|
|
3200
|
+
session = await request.tryPollOnce();
|
|
3201
|
+
} catch (e) {
|
|
3202
|
+
end(() => reject(failure("The Pubky request failed", e)));
|
|
3203
|
+
return;
|
|
3204
|
+
}
|
|
3205
|
+
if (session && over) {
|
|
3206
|
+
try {
|
|
3207
|
+
await session.signout();
|
|
3208
|
+
} catch {} finally {
|
|
3209
|
+
session.free();
|
|
3210
|
+
}
|
|
3211
|
+
return;
|
|
3212
|
+
}
|
|
3213
|
+
if (session) {
|
|
3214
|
+
const approved = session;
|
|
3215
|
+
end(() => resolve({
|
|
3216
|
+
session: approved,
|
|
3217
|
+
request
|
|
3218
|
+
}));
|
|
3219
|
+
return;
|
|
3220
|
+
}
|
|
3221
|
+
if (!over) await pause();
|
|
3222
|
+
}
|
|
3223
|
+
} finally {
|
|
3224
|
+
request.free();
|
|
3225
|
+
if (--polling === 0) onSettled();
|
|
3226
|
+
}
|
|
3227
|
+
};
|
|
3228
|
+
for (const request of requests) poll(request);
|
|
3229
|
+
});
|
|
3230
|
+
}
|
|
3231
|
+
/**
|
|
3232
|
+
* A request of a cookie session (Ring's approval) to its homeserver: its `/session` or a file of its storage
|
|
3233
|
+
* (`/storage/<key>/pub/…`, or `/pub/…` where the homeserver does not address storage by path), sent with the
|
|
3234
|
+
* browser's credentials. A grant session's requests carry an `Authorization` header instead, and the relay's
|
|
3235
|
+
* and the Pkarr relays' are elsewhere: those stay the page's.
|
|
3236
|
+
*/
|
|
3237
|
+
var cookieSessionRequest = (request) => request.credentials === "include" && !request.headers.has("authorization") && /^\/(?:session$|storage\/|pub\/)/.test(new URL(request.url).pathname);
|
|
3238
|
+
/** Statuses a Response carries no body with. */
|
|
3239
|
+
var NO_BODY = /* @__PURE__ */ new Set([
|
|
3240
|
+
204,
|
|
3241
|
+
205,
|
|
3242
|
+
304
|
|
3243
|
+
]);
|
|
3244
|
+
/**
|
|
3245
|
+
* Sends the SDK's cookie-session requests through `transport` until the returned function runs. The SDK fetches with
|
|
3246
|
+
* the global fetch, so this wraps it; everything else goes on to the page's fetch as it was. The cookie the
|
|
3247
|
+
* homeserver sets stays in the transport: the page, and so the SDK, never sees it.
|
|
3248
|
+
*/
|
|
3249
|
+
function routeCookieSession(transport) {
|
|
3250
|
+
const pageFetch = globalThis.fetch;
|
|
3251
|
+
let closed = false;
|
|
3252
|
+
const routed = async (input, init) => {
|
|
3253
|
+
const request = input instanceof Request && init === void 0 ? input : new Request(input, init);
|
|
3254
|
+
if (closed || !cookieSessionRequest(request)) return pageFetch(request);
|
|
3255
|
+
request.signal.throwIfAborted();
|
|
3256
|
+
const body = request.body ? new Uint8Array(await request.arrayBuffer()) : null;
|
|
3257
|
+
let answer;
|
|
3258
|
+
try {
|
|
3259
|
+
answer = await transport.fetch({
|
|
3260
|
+
url: request.url,
|
|
3261
|
+
method: request.method,
|
|
3262
|
+
headers: [...request.headers],
|
|
3263
|
+
body
|
|
3264
|
+
});
|
|
3265
|
+
} catch (e) {
|
|
3266
|
+
throw Object.assign(new TypeError(e instanceof Error ? e.message : String(e)), { cause: e });
|
|
3267
|
+
}
|
|
3268
|
+
const response = new Response(NO_BODY.has(answer.status) ? null : answer.body, {
|
|
3269
|
+
status: answer.status,
|
|
3270
|
+
headers: answer.headers
|
|
3271
|
+
});
|
|
3272
|
+
Object.defineProperty(response, "url", { value: request.url });
|
|
3273
|
+
return response;
|
|
3274
|
+
};
|
|
3275
|
+
globalThis.fetch = routed;
|
|
3276
|
+
return () => {
|
|
3277
|
+
if (closed) return;
|
|
3278
|
+
closed = true;
|
|
3279
|
+
if (globalThis.fetch === routed) globalThis.fetch = pageFetch;
|
|
3280
|
+
transport.close();
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
3283
|
+
/** The host's way around a page that drops the homeserver's cookie, if it has one. */
|
|
3284
|
+
function cookieSessionTransport() {
|
|
3285
|
+
try {
|
|
3286
|
+
return getBrowserHost().pubkyCookieSession?.();
|
|
3287
|
+
} catch {
|
|
3288
|
+
return;
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
/**
|
|
3292
|
+
* Starts one request for `capability` in each of the two forms Pubky signers read (a grant request for Passport, a
|
|
3293
|
+
* cookie request for Ring's QR code: see below), shows both on one screen, and waits for whichever is approved
|
|
3294
|
+
* first, then runs `work` with that session. The session lives only in this renderer's memory while `work` runs,
|
|
3295
|
+
* and is signed out afterwards; nothing about it is stored. An approval that arrives after the end is signed out.
|
|
3296
|
+
*/
|
|
3297
|
+
async function withPubkyApproval(options, work) {
|
|
3298
|
+
const { signal } = options;
|
|
3299
|
+
signal.throwIfAborted();
|
|
3300
|
+
const { AuthFlow, GrantAuthFlow, AuthFlowKind } = await import("@synonymdev/pubky");
|
|
3301
|
+
signal.throwIfAborted();
|
|
3302
|
+
const relay = options.relay ?? null;
|
|
3303
|
+
let grant, cookie;
|
|
3304
|
+
try {
|
|
3305
|
+
grant = GrantAuthFlow.start(options.capability, AuthFlowKind.signin(), {
|
|
3306
|
+
clientId: PUBKY_CLIENT_ID,
|
|
3307
|
+
relay,
|
|
3308
|
+
xCallback: { xSource: "Ghostly" }
|
|
3309
|
+
});
|
|
3310
|
+
} catch (e) {
|
|
3311
|
+
throw failure("Could not start a Pubky request", e);
|
|
3312
|
+
}
|
|
3313
|
+
try {
|
|
3314
|
+
cookie = AuthFlow.start(options.capability, AuthFlowKind.signin(), relay, { xSource: "Ghostly" });
|
|
3315
|
+
} catch (e) {
|
|
3316
|
+
grant.free();
|
|
3317
|
+
throw failure("Could not start a Pubky request", e);
|
|
3318
|
+
}
|
|
3319
|
+
const opened = {};
|
|
3320
|
+
options.onApproval({
|
|
3321
|
+
open: {
|
|
3322
|
+
label: "Approve in your browser (Pubky Passport)",
|
|
3323
|
+
description: "Opens Pubky Passport. No Passport identity yet? It offers “Continue with Google” and makes one.",
|
|
3324
|
+
run: () => {
|
|
3325
|
+
const window = (options.openPassport ?? openPassportWindow)(passportUrl(grant.authorizationUrl));
|
|
3326
|
+
opened.window = window ?? null;
|
|
3327
|
+
if (window === null) options.onProgress("Your browser blocked the Passport window: allow pop-ups for Ghostly and press the button again, or scan the code with Pubky Ring.");
|
|
3328
|
+
}
|
|
3329
|
+
},
|
|
3330
|
+
qr: {
|
|
3331
|
+
value: cookie.authorizationUrl,
|
|
3332
|
+
label: "Or scan with Pubky Ring"
|
|
3333
|
+
},
|
|
3334
|
+
notes: ["An identity Passport makes with Google is recovered with Google plus Passport: both are needed, neither alone can.", "The code is your request: scan it yourself, and do not share it."]
|
|
3335
|
+
});
|
|
3336
|
+
const transport = cookieSessionTransport();
|
|
3337
|
+
const unroute = transport ? routeCookieSession(transport) : () => {};
|
|
3338
|
+
let settled;
|
|
3339
|
+
const polled = new Promise((done) => {
|
|
3340
|
+
settled = done;
|
|
3341
|
+
});
|
|
3342
|
+
try {
|
|
3343
|
+
return await approveAndWork(options, work, {
|
|
3344
|
+
grant,
|
|
3345
|
+
cookie,
|
|
3346
|
+
opened,
|
|
3347
|
+
settled,
|
|
3348
|
+
cookieKept: !!transport
|
|
3349
|
+
});
|
|
3350
|
+
} finally {
|
|
3351
|
+
polled.then(unroute);
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
/**
|
|
3355
|
+
* The rest of `withPubkyApproval`, once both requests are shown: the wait, the check, and the work. `cookieKept`: the
|
|
3356
|
+
* host keeps a cookie session's cookie, so a refusal is not the page's cookie policy.
|
|
3357
|
+
*/
|
|
3358
|
+
async function approveAndWork(options, work, { grant, cookie, opened, settled, cookieKept }) {
|
|
3359
|
+
const { signal } = options;
|
|
3360
|
+
let session, viaCookie;
|
|
3361
|
+
try {
|
|
3362
|
+
const first = await firstApproval([grant, cookie], signal, options.timeoutMs ?? APPROVAL_TIMEOUT, settled);
|
|
3363
|
+
session = first.session;
|
|
3364
|
+
viaCookie = first.request === cookie;
|
|
3365
|
+
} finally {
|
|
3366
|
+
options.onApproval(null);
|
|
3367
|
+
try {
|
|
3368
|
+
if (opened.window && !opened.window.closed) opened.window.close();
|
|
3369
|
+
} catch {}
|
|
3370
|
+
}
|
|
3371
|
+
options.onProgress("Approved. Checking what Pubky granted…");
|
|
3372
|
+
let key;
|
|
3373
|
+
try {
|
|
3374
|
+
const info = session.info;
|
|
3375
|
+
const publicKey = info.publicKey;
|
|
3376
|
+
try {
|
|
3377
|
+
key = publicKey.z32();
|
|
3378
|
+
const granted = info.capabilities;
|
|
3379
|
+
if (granted.length !== 1 || granted[0] !== options.capability) throw new Error("The signer granted different permissions than Ghostly asked for (one proof folder). Nothing was written.");
|
|
3380
|
+
} finally {
|
|
3381
|
+
publicKey.free();
|
|
3382
|
+
info.free();
|
|
3383
|
+
}
|
|
3384
|
+
} catch (e) {
|
|
3385
|
+
await session.signout().catch(() => {});
|
|
3386
|
+
session.free();
|
|
3387
|
+
throw e;
|
|
3388
|
+
}
|
|
3389
|
+
const fail = (what) => (e) => {
|
|
3390
|
+
if (viaCookie && !cookieKept && refused(e)) throw new Error("Pubky Ring approved, but this browser blocks your homeserver's sign-in cookie (Vanadium and Safari block cookies from other sites), so nothing was written. Approve with Pubky Passport instead, or use Firefox or the Ghostly desktop app.");
|
|
3391
|
+
throw failure(what, e);
|
|
3392
|
+
};
|
|
3393
|
+
const approved = session;
|
|
3394
|
+
try {
|
|
3395
|
+
return await work({
|
|
3396
|
+
key,
|
|
3397
|
+
put: (path, text) => approved.storage.putText(path, text).catch(fail("Could not write to your homeserver")),
|
|
3398
|
+
delete: (path) => approved.storage.delete(path).catch(fail("Could not delete from your homeserver"))
|
|
3399
|
+
});
|
|
3400
|
+
} finally {
|
|
3401
|
+
try {
|
|
3402
|
+
await approved.signout();
|
|
3403
|
+
} catch {}
|
|
3404
|
+
approved.free();
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
//#endregion
|
|
3408
|
+
//#region ../browser/src/proofs/providers/pubky.ts
|
|
3409
|
+
var short = (key) => `pubky${key.slice(0, 4)}…${key.slice(-4)}`;
|
|
3410
|
+
/** Strict: exactly `{ folder }`, 64 hex characters. Nothing else, so no evidence can name a host or another path. */
|
|
3411
|
+
function parseEvidence(raw) {
|
|
3412
|
+
const e = raw;
|
|
3413
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).join(",") !== "folder" || !isPubkyProofFolder(e.folder)) throw new Error("That is not Pubky proof evidence");
|
|
3414
|
+
return { folder: e.folder };
|
|
3415
|
+
}
|
|
3416
|
+
function createPubkyIdentityProvider(options = {}) {
|
|
3417
|
+
const approve = (capability, ctx, work) => {
|
|
3418
|
+
if (!ctx.onApproval) throw new Error("This screen cannot show a Pubky request");
|
|
3419
|
+
return withPubkyApproval({
|
|
3420
|
+
...options.approval,
|
|
3421
|
+
capability,
|
|
3422
|
+
signal: ctx.signal,
|
|
3423
|
+
onApproval: ctx.onApproval,
|
|
3424
|
+
onProgress: ctx.onProgress
|
|
3425
|
+
}, work);
|
|
3426
|
+
};
|
|
3427
|
+
return {
|
|
3428
|
+
id: "pubky",
|
|
3429
|
+
label: "Pubky",
|
|
3430
|
+
category: "self-custodied",
|
|
3431
|
+
summary: "Approve in Pubky Ring or Passport",
|
|
3432
|
+
description: "Proves you hold a Pubky key: approve one request in Pubky Ring or Pubky Passport, and Ghostly writes a small proof file to your homeserver. Contacts’ apps read it back through your key’s own records.",
|
|
3433
|
+
limits: "The file is on your homeserver: whoever runs it could write there too. It does not prove who holds the key.",
|
|
3434
|
+
platforms: [
|
|
3435
|
+
"web",
|
|
3436
|
+
"extension",
|
|
3437
|
+
"desktop"
|
|
3438
|
+
],
|
|
3439
|
+
subject: {
|
|
3440
|
+
label: "Pubky key",
|
|
3441
|
+
placeholder: "pubky…",
|
|
3442
|
+
help: "Comes from Pubky Ring or Passport when you approve.",
|
|
3443
|
+
normalize: normalizePubkyKey,
|
|
3444
|
+
short
|
|
3445
|
+
},
|
|
3446
|
+
validity: {
|
|
3447
|
+
defaultDays: 90,
|
|
3448
|
+
maxDays: 365
|
|
3449
|
+
},
|
|
3450
|
+
recheck: { afterSeconds: 86400 },
|
|
3451
|
+
privacy: "Your contact’s app reads your key’s records from Pubky’s Pkarr relays (pkarr.pubky.org, pkarr.pubky.app), looks up your homeserver’s address through its DNS-over-HTTPS resolver (or the next one when that cannot be reached; it only contacts a public address, on port 443), and reads the proof file from your homeserver, which sees the contact’s IP address.",
|
|
3452
|
+
signers: [{
|
|
3453
|
+
id: "pubky-auth",
|
|
3454
|
+
kind: "in-app",
|
|
3455
|
+
label: "Pubky Ring or Pubky Passport",
|
|
3456
|
+
action: "Continue",
|
|
3457
|
+
description: "One request: approve it in Pubky Passport in your browser, or scan it with Pubky Ring. Ghostly only asks to write one small file to a new folder of your homeserver.",
|
|
3458
|
+
run(ctx, work) {
|
|
3459
|
+
const folder = newPubkyProofFolder();
|
|
3460
|
+
return approve(pubkyProofCapability(folder), ctx, async (session) => {
|
|
3461
|
+
let written;
|
|
3462
|
+
try {
|
|
3463
|
+
return await work({
|
|
3464
|
+
subject: async () => session.key,
|
|
3465
|
+
async sign(statement) {
|
|
3466
|
+
if (statement.binding.subject !== session.key) throw new Error("That is another Pubky identity");
|
|
3467
|
+
ctx.onProgress("Writing the proof to your homeserver…");
|
|
3468
|
+
const path = pubkyProofPath(folder, statement.id);
|
|
3469
|
+
await session.put(path, pubkyProofFile(statement));
|
|
3470
|
+
written = path;
|
|
3471
|
+
return { folder };
|
|
3472
|
+
}
|
|
3473
|
+
});
|
|
3474
|
+
} catch (e) {
|
|
3475
|
+
if (written) await session.delete(written).catch(() => {});
|
|
3476
|
+
throw e;
|
|
3477
|
+
}
|
|
3478
|
+
});
|
|
3479
|
+
}
|
|
3480
|
+
}],
|
|
3481
|
+
publicUri: (key) => `pubky://${key}`,
|
|
3482
|
+
parseEvidence,
|
|
3483
|
+
async verify(statement, { folder }, ctx) {
|
|
3484
|
+
const key = statement.binding.subject;
|
|
3485
|
+
if (!isPubkyKey(key)) throw new Error("That is not a Pubky key");
|
|
3486
|
+
const { text, host } = await readPubkyFile(key, pubkyProofPath(folder, statement.id), ctx.fetch, ctx.signal, options.relays);
|
|
3487
|
+
if (text === void 0) throw new Error("The proof file is not on the homeserver");
|
|
3488
|
+
if (text !== pubkyProofFile(statement)) throw new Error("The file on the homeserver is not this proof");
|
|
3489
|
+
return {
|
|
3490
|
+
subject: key,
|
|
3491
|
+
source: `File on the homeserver ${host}, found through the key’s own Pkarr records`
|
|
3492
|
+
};
|
|
3493
|
+
},
|
|
3494
|
+
unpublish: {
|
|
3495
|
+
description: "Also deletes the proof file on your homeserver: that takes one more approval in Pubky Ring or Passport, since Ghostly kept no access to it.",
|
|
3496
|
+
async run({ id, subject, evidence }, ctx) {
|
|
3497
|
+
const { folder } = parseEvidence(evidence);
|
|
3498
|
+
const path = pubkyProofPath(folder, id);
|
|
3499
|
+
return approve(pubkyProofCapability(folder), ctx, async (session) => {
|
|
3500
|
+
if (session.key !== subject) throw new Error("That is another Pubky identity: approve with the one this proof is for");
|
|
3501
|
+
ctx.onProgress("Deleting the proof file from your homeserver…");
|
|
3502
|
+
await session.delete(path);
|
|
3503
|
+
});
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
};
|
|
3507
|
+
}
|
|
3508
|
+
var pubky = createPubkyIdentityProvider();
|
|
3509
|
+
//#endregion
|
|
3510
|
+
//#region ../browser/src/proofs/testing.ts
|
|
3511
|
+
/**
|
|
3512
|
+
* Fake identity providers, for unit tests and e2e. No network, no real accounts. They are in the
|
|
3513
|
+
* registry only when `localStorage["ghostly-test-identities"] === "1"` (or a test passes them itself).
|
|
3514
|
+
*
|
|
3515
|
+
* - fake-key self-custodied: an Ed25519 key held by a fake in-app signer, or signed "outside" and pasted.
|
|
3516
|
+
* - fake-account provider-attested: a fake issuer signs a token whose nonce is the statement id (like OIDC).
|
|
3517
|
+
* - fake-record self-custodied, published: a record the verifier fetches through `ctx.fetch` (like DNS).
|
|
3518
|
+
*/
|
|
3519
|
+
var TEST_IDENTITIES_FLAG = "ghostly-test-identities";
|
|
3520
|
+
var testIdentitiesEnabled = () => {
|
|
3521
|
+
try {
|
|
3522
|
+
return globalThis.localStorage?.getItem(TEST_IDENTITIES_FLAG) === "1";
|
|
3523
|
+
} catch {
|
|
3524
|
+
return false;
|
|
3525
|
+
}
|
|
3526
|
+
};
|
|
3527
|
+
var HEX64 = /^[a-f0-9]{64}$/;
|
|
3528
|
+
var hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
3529
|
+
var unhex = (s) => Uint8Array.from(s.match(/../g), (x) => parseInt(x, 16));
|
|
3530
|
+
var normalizeHexKey = (input) => {
|
|
3531
|
+
const v = input.trim().toLowerCase();
|
|
3532
|
+
if (!HEX64.test(v)) throw new Error("Enter 64 hex characters");
|
|
3533
|
+
return v;
|
|
3534
|
+
};
|
|
3535
|
+
var exactKeys = (raw, keys) => !!raw && typeof raw === "object" && !Array.isArray(raw) && Object.keys(raw).sort().join(",") === keys;
|
|
3536
|
+
/** The key behind the fake in-app signer. Tests may replace it. */
|
|
3537
|
+
var fakeKeyring = { seed: ed25519.utils.randomSecretKey() };
|
|
3538
|
+
var fakeKeySubject = (seed = fakeKeyring.seed) => hex(ed25519.getPublicKey(seed));
|
|
3539
|
+
var fakeKeySign = (statement, seed = fakeKeyring.seed) => ({ sig: toBase64Url(ed25519.sign(statement.bytes, seed)) });
|
|
3540
|
+
var fakeKey = {
|
|
3541
|
+
id: "fake-key",
|
|
3542
|
+
label: "Test key",
|
|
3543
|
+
category: "self-custodied",
|
|
3544
|
+
summary: "Sign with the test key",
|
|
3545
|
+
description: "A test identity: an Ed25519 key held by this page. For tests only.",
|
|
3546
|
+
platforms: [
|
|
3547
|
+
"web",
|
|
3548
|
+
"extension",
|
|
3549
|
+
"desktop"
|
|
3550
|
+
],
|
|
3551
|
+
subject: {
|
|
3552
|
+
label: "Public key",
|
|
3553
|
+
placeholder: "64 hex characters",
|
|
3554
|
+
normalize: normalizeHexKey,
|
|
3555
|
+
short: (k) => `${k.slice(0, 8)}…${k.slice(-4)}`
|
|
3556
|
+
},
|
|
3557
|
+
publicUri: (k) => `urn:ghostly-test:${k}`,
|
|
3558
|
+
validity: {
|
|
3559
|
+
defaultDays: 30,
|
|
3560
|
+
maxDays: 365
|
|
3561
|
+
},
|
|
3562
|
+
privacy: "Nothing: checked on this device.",
|
|
3563
|
+
signers: [{
|
|
3564
|
+
id: "fake-signer",
|
|
3565
|
+
kind: "in-app",
|
|
3566
|
+
label: "Test signer",
|
|
3567
|
+
fields: [{
|
|
3568
|
+
name: "refuse",
|
|
3569
|
+
label: "Refuse",
|
|
3570
|
+
kind: "text",
|
|
3571
|
+
optional: true
|
|
3572
|
+
}],
|
|
3573
|
+
run: async (ctx, work) => {
|
|
3574
|
+
ctx.signal.throwIfAborted();
|
|
3575
|
+
if (ctx.values.refuse) throw new Error("The test signer refused");
|
|
3576
|
+
return work({
|
|
3577
|
+
subject: async () => fakeKeySubject(),
|
|
3578
|
+
sign: async (statement) => fakeKeySign(statement)
|
|
3579
|
+
});
|
|
3580
|
+
}
|
|
3581
|
+
}, {
|
|
3582
|
+
id: "fake-tool",
|
|
3583
|
+
kind: "external-tool",
|
|
3584
|
+
label: "Sign outside Ghostly",
|
|
3585
|
+
instructions: (statement) => ({
|
|
3586
|
+
steps: [{
|
|
3587
|
+
text: "Sign this statement with your test key:",
|
|
3588
|
+
copy: statement.text
|
|
3589
|
+
}],
|
|
3590
|
+
paste: { label: "Signature (base64url)" }
|
|
3591
|
+
}),
|
|
3592
|
+
parse: (pasted) => {
|
|
3593
|
+
const sig = pasted.trim();
|
|
3594
|
+
if (!/^[A-Za-z0-9_-]{86}$/.test(sig)) throw new Error("That is not a test signature");
|
|
3595
|
+
return { sig };
|
|
3596
|
+
}
|
|
3597
|
+
}],
|
|
3598
|
+
parseEvidence(raw) {
|
|
3599
|
+
const e = raw;
|
|
3600
|
+
if (!exactKeys(raw, "sig") || typeof e.sig !== "string" || !/^[A-Za-z0-9_-]{86}$/.test(e.sig)) throw new Error("Invalid test evidence");
|
|
3601
|
+
return { sig: e.sig };
|
|
3602
|
+
},
|
|
3603
|
+
async verify(statement, evidence) {
|
|
3604
|
+
let ok;
|
|
3605
|
+
try {
|
|
3606
|
+
ok = ed25519.verify(fromBase64Url(evidence.sig), statement.bytes, unhex(statement.binding.subject));
|
|
3607
|
+
} catch {
|
|
3608
|
+
ok = false;
|
|
3609
|
+
}
|
|
3610
|
+
if (!ok) throw new Error("Invalid test signature");
|
|
3611
|
+
return {
|
|
3612
|
+
subject: statement.binding.subject,
|
|
3613
|
+
source: "Test signature (Ed25519)"
|
|
3614
|
+
};
|
|
3615
|
+
}
|
|
3616
|
+
};
|
|
3617
|
+
/** The fake issuer's key. Its public half is what fake-account trusts, like a provider's JWKS. */
|
|
3618
|
+
var ISSUER_SEED = unhex("7a".repeat(32));
|
|
3619
|
+
var FAKE_ISSUER = "https://issuer.ghostly.test";
|
|
3620
|
+
/** What the fake login page signs: iss, sub, nonce (= statement id), iat. */
|
|
3621
|
+
function fakeAccountToken(statement, account = "alice", seed = ISSUER_SEED, iat = statement.binding.issuedAt) {
|
|
3622
|
+
const payload = toBase64Url(utf8Encode(JSON.stringify({
|
|
3623
|
+
iss: FAKE_ISSUER,
|
|
3624
|
+
sub: account,
|
|
3625
|
+
nonce: statement.id,
|
|
3626
|
+
iat
|
|
3627
|
+
})));
|
|
3628
|
+
return { token: `${payload}.${toBase64Url(ed25519.sign(utf8Encode(payload), seed))}` };
|
|
3629
|
+
}
|
|
3630
|
+
var fakeAccount = {
|
|
3631
|
+
id: "fake-account",
|
|
3632
|
+
label: "Test account",
|
|
3633
|
+
category: "provider-attested",
|
|
3634
|
+
summary: "Log in to the test issuer",
|
|
3635
|
+
description: "A test login provider that vouches for an account. For tests only.",
|
|
3636
|
+
platforms: [
|
|
3637
|
+
"web",
|
|
3638
|
+
"extension",
|
|
3639
|
+
"desktop"
|
|
3640
|
+
],
|
|
3641
|
+
subject: {
|
|
3642
|
+
label: "Provider",
|
|
3643
|
+
options: [{
|
|
3644
|
+
value: FAKE_ISSUER,
|
|
3645
|
+
label: "Test issuer"
|
|
3646
|
+
}],
|
|
3647
|
+
normalize: (input) => {
|
|
3648
|
+
if (input.trim() !== "https://issuer.ghostly.test") throw new Error("Unknown test issuer");
|
|
3649
|
+
return FAKE_ISSUER;
|
|
3650
|
+
}
|
|
3651
|
+
},
|
|
3652
|
+
validity: {
|
|
3653
|
+
defaultDays: 30,
|
|
3654
|
+
maxDays: 90
|
|
3655
|
+
},
|
|
3656
|
+
privacy: "Nothing: the issuer's signature is checked on this device.",
|
|
3657
|
+
signers: [{
|
|
3658
|
+
id: "fake-login",
|
|
3659
|
+
kind: "redirect",
|
|
3660
|
+
label: "Log in to the test issuer",
|
|
3661
|
+
start: async (statement, ctx) => {
|
|
3662
|
+
ctx.signal.throwIfAborted();
|
|
3663
|
+
return fakeAccountToken(statement, ctx.values.account || "alice");
|
|
3664
|
+
}
|
|
3665
|
+
}],
|
|
3666
|
+
parseEvidence(raw) {
|
|
3667
|
+
const e = raw;
|
|
3668
|
+
if (!exactKeys(raw, "token") || typeof e.token !== "string" || e.token.length > 2048 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{86}$/.test(e.token)) throw new Error("Invalid test token");
|
|
3669
|
+
return { token: e.token };
|
|
3670
|
+
},
|
|
3671
|
+
async verify(statement, { token }) {
|
|
3672
|
+
const [payload, sig] = token.split(".");
|
|
3673
|
+
if (!ed25519.verify(fromBase64Url(sig), utf8Encode(payload), ed25519.getPublicKey(ISSUER_SEED))) throw new Error("Not signed by the test issuer");
|
|
3674
|
+
const claims = JSON.parse(utf8Decode(fromBase64Url(payload)));
|
|
3675
|
+
if (claims.iss !== statement.binding.subject) throw new Error("Token from another issuer");
|
|
3676
|
+
if (claims.nonce !== statement.id) throw new Error("Token for another statement");
|
|
3677
|
+
if (typeof claims.sub !== "string" || !/^[a-z0-9]{1,64}$/.test(claims.sub)) throw new Error("Invalid account");
|
|
3678
|
+
return {
|
|
3679
|
+
subject: `${claims.iss}#${claims.sub}`,
|
|
3680
|
+
attester: "issuer.ghostly.test",
|
|
3681
|
+
source: "Token signed by the test issuer",
|
|
3682
|
+
display: {
|
|
3683
|
+
name: claims.sub,
|
|
3684
|
+
source: "Test issuer profile",
|
|
3685
|
+
fetchedAt: statement.binding.issuedAt
|
|
3686
|
+
}
|
|
3687
|
+
};
|
|
3688
|
+
}
|
|
3689
|
+
};
|
|
3690
|
+
/** fake-record's records: subject → published text. The verifier reads `https://records.ghostly.test/<subject>`. */
|
|
3691
|
+
var FAKE_RECORD_HOST = "https://records.ghostly.test/";
|
|
3692
|
+
var fakeRecordText = (statement) => `ghostly-proof=${statement.id}`;
|
|
3693
|
+
var FAKE_IDENTITY_PROVIDERS = [
|
|
3694
|
+
fakeKey,
|
|
3695
|
+
fakeAccount,
|
|
3696
|
+
{
|
|
3697
|
+
id: "fake-record",
|
|
3698
|
+
label: "Test record",
|
|
3699
|
+
category: "self-custodied",
|
|
3700
|
+
summary: "Publish a test record",
|
|
3701
|
+
description: "A test record published where the verifier looks it up, like DNS. For tests only.",
|
|
3702
|
+
platforms: [
|
|
3703
|
+
"web",
|
|
3704
|
+
"extension",
|
|
3705
|
+
"desktop"
|
|
3706
|
+
],
|
|
3707
|
+
subject: {
|
|
3708
|
+
label: "Name",
|
|
3709
|
+
placeholder: "alice",
|
|
3710
|
+
normalize: (input) => {
|
|
3711
|
+
const v = input.trim().toLowerCase();
|
|
3712
|
+
if (!/^[a-z0-9-]{1,63}$/.test(v)) throw new Error("Letters, digits and dashes");
|
|
3713
|
+
return v;
|
|
3714
|
+
}
|
|
3715
|
+
},
|
|
3716
|
+
validity: {
|
|
3717
|
+
defaultDays: 30,
|
|
3718
|
+
maxDays: 365
|
|
3719
|
+
},
|
|
3720
|
+
recheck: { afterSeconds: 3600 },
|
|
3721
|
+
privacy: "The verifier asks records.ghostly.test for the record: that server learns the name was checked.",
|
|
3722
|
+
signers: [{
|
|
3723
|
+
id: "fake-publish",
|
|
3724
|
+
kind: "publish",
|
|
3725
|
+
label: "Publish a record",
|
|
3726
|
+
instructions: (statement) => ({ steps: [{
|
|
3727
|
+
text: `Publish this record for ${statement.binding.subject}:`,
|
|
3728
|
+
copy: fakeRecordText(statement)
|
|
3729
|
+
}] }),
|
|
3730
|
+
evidence: () => ({})
|
|
3731
|
+
}],
|
|
3732
|
+
parseEvidence(raw) {
|
|
3733
|
+
if (!exactKeys(raw, "")) throw new Error("Invalid test evidence");
|
|
3734
|
+
return {};
|
|
3735
|
+
},
|
|
3736
|
+
async verify(statement, _evidence, ctx) {
|
|
3737
|
+
const response = await ctx.fetch(`${FAKE_RECORD_HOST}${statement.binding.subject}`, {
|
|
3738
|
+
maxBytes: 4096,
|
|
3739
|
+
signal: ctx.signal
|
|
3740
|
+
});
|
|
3741
|
+
if (response.status !== 200 || !response.text.split("\n").includes(fakeRecordText(statement))) throw new Error("The record is not published");
|
|
3742
|
+
return {
|
|
3743
|
+
subject: statement.binding.subject,
|
|
3744
|
+
source: "Record published at records.ghostly.test"
|
|
3745
|
+
};
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
];
|
|
3749
|
+
//#endregion
|
|
3750
|
+
//#region ../browser/src/proofs/registry.ts
|
|
3751
|
+
/**
|
|
3752
|
+
* Every identity-proof provider built into Ghostly. Adding one is adding its module under `providers/`
|
|
3753
|
+
* and ONE line here; see PROOFS.md. The order is the order of the picker on the Identities page. A
|
|
3754
|
+
* provider written outside the app registers through `plugins/registry.ts` (the SDK) and comes after.
|
|
3755
|
+
*/
|
|
3756
|
+
var IDENTITY_PROVIDERS = [
|
|
3757
|
+
nostr,
|
|
3758
|
+
pubky,
|
|
3759
|
+
domain,
|
|
3760
|
+
openpgp,
|
|
3761
|
+
bitcoin,
|
|
3762
|
+
ssh,
|
|
3763
|
+
sshGithub,
|
|
3764
|
+
sshGitlab,
|
|
3765
|
+
oidc,
|
|
3766
|
+
did,
|
|
3767
|
+
atproto
|
|
3768
|
+
];
|
|
3769
|
+
reserveAdapterIds("identity", IDENTITY_PROVIDERS.map((p) => p.id));
|
|
3770
|
+
var warned = /* @__PURE__ */ new Set();
|
|
3771
|
+
/** The built-in providers, then the ones plugins registered (a built-in's id wins), then the fakes when this browser asked for them (e2e). */
|
|
3772
|
+
function identityProviders() {
|
|
3773
|
+
const external = registeredIdentityProviders().filter((p) => {
|
|
3774
|
+
const taken = IDENTITY_PROVIDERS.some((b) => b.id === p.id);
|
|
3775
|
+
if (taken && !warned.has(p.id)) {
|
|
3776
|
+
warned.add(p.id);
|
|
3777
|
+
console.error(`[ghostly] identity provider ${p.id} from a plugin is ignored: a built-in has that id`);
|
|
3778
|
+
}
|
|
3779
|
+
return !taken;
|
|
3780
|
+
});
|
|
3781
|
+
return [
|
|
3782
|
+
...IDENTITY_PROVIDERS,
|
|
3783
|
+
...external,
|
|
3784
|
+
...testIdentitiesEnabled() ? FAKE_IDENTITY_PROVIDERS : []
|
|
3785
|
+
];
|
|
3786
|
+
}
|
|
3787
|
+
function identityProvider(id, providers = identityProviders()) {
|
|
3788
|
+
return providers.find((p) => p.id === id);
|
|
3789
|
+
}
|
|
3790
|
+
//#endregion
|
|
3791
|
+
export { identityProvider as n, identityProviders as r, IDENTITY_PROVIDERS as t };
|
|
3792
|
+
|
|
3793
|
+
//# sourceMappingURL=registry-DlAKO0Fy.js.map
|