epistery 1.5.9 → 1.5.10
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/client/witness.js +268 -0
- package/index.mjs +17 -2
- package/package.json +1 -1
- package/routes/connect.mjs +64 -2
package/client/witness.js
CHANGED
|
@@ -44,6 +44,118 @@ async function ensureEthers() {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// --- Orphaned-rivet recovery -------------------------------------------------
|
|
48
|
+
// A RivetWallet is split across two per-origin stores: the rivet record
|
|
49
|
+
// (keyId + AES-encrypted private key) in localStorage["epistery"], and the
|
|
50
|
+
// non-extractable AES master key that decrypts it in IndexedDB
|
|
51
|
+
// (EpisteryRivets/masterKeys, keyed by keyId). Browsers evict IndexedDB far
|
|
52
|
+
// more aggressively than localStorage, so the master key can vanish while the
|
|
53
|
+
// rivet record survives. sign() then throws "Master key not found" on every
|
|
54
|
+
// connect(), and connect() never self-heals because it only mints a fresh
|
|
55
|
+
// rivet when there is NO wallet at all (see `if (!witness.wallet)` below).
|
|
56
|
+
//
|
|
57
|
+
// reset_master_key() is the manual recovery path: it finds rivet records whose
|
|
58
|
+
// master key is missing and removes those records so the next page load mints
|
|
59
|
+
// a fresh device key for this origin. It is intentionally NOT called from
|
|
60
|
+
// connect() — advise affected users to run reset_master_key() in the console
|
|
61
|
+
// until the impact of auto-healing is understood.
|
|
62
|
+
//
|
|
63
|
+
// Scope note: IndexedDB and localStorage are siloed per ORIGIN (scheme + host
|
|
64
|
+
// + port), stricter than cookies. This only ever touches the current origin;
|
|
65
|
+
// it cannot affect any other site. The cost is a NEW device address for THIS
|
|
66
|
+
// origin — anything bound to the old address here (follows, previously-signed
|
|
67
|
+
// messages) will not carry over.
|
|
68
|
+
async function reset_master_key({ confirm = true } = {}) {
|
|
69
|
+
const raw = localStorage.getItem("epistery");
|
|
70
|
+
if (!raw) {
|
|
71
|
+
console.log(
|
|
72
|
+
"[reset_master_key] No epistery storage on this origin — nothing to reset. Reload to mint a fresh device key.",
|
|
73
|
+
);
|
|
74
|
+
return { removed: 0, healthy: 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let data;
|
|
78
|
+
try {
|
|
79
|
+
data = JSON.parse(raw);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
console.warn(
|
|
82
|
+
"[reset_master_key] epistery storage is corrupt JSON; clearing it.",
|
|
83
|
+
e,
|
|
84
|
+
);
|
|
85
|
+
localStorage.removeItem("epistery");
|
|
86
|
+
setTimeout(() => location.reload(), 250);
|
|
87
|
+
return { removed: -1, healthy: 0 };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Support both the legacy single-wallet shape and the multi-wallet shape.
|
|
91
|
+
const isMulti = Array.isArray(data.wallets);
|
|
92
|
+
const entries = isMulti
|
|
93
|
+
? data.wallets
|
|
94
|
+
: data.wallet
|
|
95
|
+
? [{ id: "legacy", wallet: data.wallet }]
|
|
96
|
+
: [];
|
|
97
|
+
|
|
98
|
+
const orphaned = [];
|
|
99
|
+
let healthy = 0;
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
const wal = entry.wallet || entry;
|
|
102
|
+
if (!wal || wal.source !== "rivet") continue;
|
|
103
|
+
const masterKey = wal.keyId
|
|
104
|
+
? await RivetWallet.getMasterKey(wal.keyId)
|
|
105
|
+
: null;
|
|
106
|
+
if (masterKey) {
|
|
107
|
+
healthy++;
|
|
108
|
+
} else {
|
|
109
|
+
orphaned.push({ id: entry.id, keyId: wal.keyId, address: wal.address });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (orphaned.length === 0) {
|
|
114
|
+
console.log(
|
|
115
|
+
`[reset_master_key] No orphaned rivets found (${healthy} healthy). Nothing to do.`,
|
|
116
|
+
);
|
|
117
|
+
return { removed: 0, healthy };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(
|
|
121
|
+
`[reset_master_key] Found ${orphaned.length} orphaned rivet(s) — master key missing from IndexedDB:`,
|
|
122
|
+
orphaned,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (confirm && typeof window?.confirm === "function") {
|
|
126
|
+
const ok = window.confirm(
|
|
127
|
+
`Epistery: ${orphaned.length} device key(s) on ${location.host} can't be unlocked — ` +
|
|
128
|
+
`the browser evicted their master key. Reset will mint a NEW device address for this site only. Continue?`,
|
|
129
|
+
);
|
|
130
|
+
if (!ok) {
|
|
131
|
+
console.log("[reset_master_key] Cancelled — no changes made.");
|
|
132
|
+
return { removed: 0, healthy, cancelled: true };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (isMulti) {
|
|
137
|
+
const orphanIds = new Set(orphaned.map((o) => o.id));
|
|
138
|
+
data.wallets = data.wallets.filter((w) => !orphanIds.has(w.id));
|
|
139
|
+
if (orphanIds.has(data.defaultWalletId)) {
|
|
140
|
+
data.defaultWalletId = data.wallets[0]?.id || null;
|
|
141
|
+
}
|
|
142
|
+
localStorage.setItem("epistery", JSON.stringify(data));
|
|
143
|
+
} else {
|
|
144
|
+
// Legacy shape: the single wallet is the orphan.
|
|
145
|
+
localStorage.removeItem("epistery");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.log(
|
|
149
|
+
`[reset_master_key] Removed ${orphaned.length} orphaned rivet(s) (${healthy} healthy kept). Reloading to mint a fresh device key…`,
|
|
150
|
+
);
|
|
151
|
+
setTimeout(() => location.reload(), 250);
|
|
152
|
+
return { removed: orphaned.length, healthy };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (typeof window !== "undefined") {
|
|
156
|
+
window.reset_master_key = reset_master_key;
|
|
157
|
+
}
|
|
158
|
+
|
|
47
159
|
export default class Witness {
|
|
48
160
|
constructor(rootPath) {
|
|
49
161
|
if (Witness.instance) return Witness.instance;
|
|
@@ -1162,6 +1274,162 @@ export default class Witness {
|
|
|
1162
1274
|
};
|
|
1163
1275
|
}
|
|
1164
1276
|
|
|
1277
|
+
// Bind this origin's local rivet to an existing IdentityContract owned by
|
|
1278
|
+
// the user at another epistery host (defaults to epistery.io). This is the
|
|
1279
|
+
// cross-host counterpart of the in-browser `acceptJoinToken` flow — the
|
|
1280
|
+
// ferry that lets a user pick an authorized rivet on epistery.io to sign a
|
|
1281
|
+
// join token for a fresh rivet on `acme-host.example`.
|
|
1282
|
+
//
|
|
1283
|
+
// Flow:
|
|
1284
|
+
// 1. Ensure we have a local RivetWallet to register as the new rivet.
|
|
1285
|
+
// If the current default isn't a Browser-type rivet, mint a fresh one.
|
|
1286
|
+
// 2. Open <issuerUrl>/auth in a popup, passing audience + nonce + the
|
|
1287
|
+
// local rivet address as `targetRivetAddress`. The issuer's auth page
|
|
1288
|
+
// drives `prepareAddRivetToContract` + `addRivet` on chain AND has
|
|
1289
|
+
// the user's authorized rivet sign a join token bound to this rivet.
|
|
1290
|
+
// 3. Receive the base64 join token via postMessage. Call
|
|
1291
|
+
// `localRivet.acceptJoinToken(joinToken)` — that verifies the
|
|
1292
|
+
// signature, calls `upgradeToContract(contractAddress)`, and now the
|
|
1293
|
+
// local rivet presents the contract address as its identity.
|
|
1294
|
+
// 4. Re-run key exchange so the host's server sees the new identity.
|
|
1295
|
+
async bindToEpisteryIdentity({
|
|
1296
|
+
issuerUrl = "https://epistery.io",
|
|
1297
|
+
} = {}) {
|
|
1298
|
+
await ensureEthers();
|
|
1299
|
+
|
|
1300
|
+
// Step 1: ensure a local rivet that isn't already bound to a contract.
|
|
1301
|
+
let localRivet = this.wallet;
|
|
1302
|
+
const haveUsableRivet =
|
|
1303
|
+
localRivet &&
|
|
1304
|
+
localRivet.source === "rivet" &&
|
|
1305
|
+
!localRivet.contractAddress;
|
|
1306
|
+
if (!haveUsableRivet) {
|
|
1307
|
+
localRivet = await RivetWallet.create(ethers);
|
|
1308
|
+
localRivet.label = "Browser Wallet";
|
|
1309
|
+
this.wallet = localRivet;
|
|
1310
|
+
this.save();
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Step 2: open the issuer's auth popup and await the join token.
|
|
1314
|
+
const nonce = ethers.utils.hexlify(ethers.utils.randomBytes(16));
|
|
1315
|
+
const audience = location.host;
|
|
1316
|
+
const { joinToken, identityName, identityDomain, contractAddress, chainId } =
|
|
1317
|
+
await this._runEpisteryAuth(issuerUrl, {
|
|
1318
|
+
audience,
|
|
1319
|
+
nonce,
|
|
1320
|
+
target_rivet: localRivet.address,
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
if (!joinToken) {
|
|
1324
|
+
throw new Error("Issuer did not return a join token");
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// Step 3: accept the token. acceptJoinToken verifies the signature
|
|
1328
|
+
// against the inviter's claim, then upgrades this rivet to present the
|
|
1329
|
+
// contract address (see RivetWallet.acceptJoinToken + upgradeToContract).
|
|
1330
|
+
await localRivet.acceptJoinToken(joinToken, ethers);
|
|
1331
|
+
|
|
1332
|
+
// Best-effort metadata from the issuer — handy for the UI but the
|
|
1333
|
+
// authoritative identity is the contract on-chain.
|
|
1334
|
+
if (identityName) localRivet.label = identityDomain
|
|
1335
|
+
? `${identityName}@${identityDomain}`
|
|
1336
|
+
: identityName;
|
|
1337
|
+
this.save();
|
|
1338
|
+
|
|
1339
|
+
// Step 4: re-run key exchange so the host learns the new identity.
|
|
1340
|
+
// performKeyExchange now sees wallet.address == contractAddress and
|
|
1341
|
+
// wallet.rivetAddress == the original rivet, and posts both.
|
|
1342
|
+
//
|
|
1343
|
+
// The issuer's addRivet tx may still be confirming when we land here —
|
|
1344
|
+
// the host's /connect verifies on-chain isAuthorized, which won't pass
|
|
1345
|
+
// until the tx mines (~30s on Polygon). Retry with backoff so the
|
|
1346
|
+
// binding is robust without forcing the issuer to block on confirmation.
|
|
1347
|
+
let lastErr = null;
|
|
1348
|
+
const delays = [0, 5000, 10000, 15000, 20000, 30000]; // ~80s total
|
|
1349
|
+
for (const delay of delays) {
|
|
1350
|
+
if (delay) await new Promise((r) => setTimeout(r, delay));
|
|
1351
|
+
try {
|
|
1352
|
+
await this.performKeyExchange();
|
|
1353
|
+
lastErr = null;
|
|
1354
|
+
break;
|
|
1355
|
+
} catch (e) {
|
|
1356
|
+
lastErr = e;
|
|
1357
|
+
// Only retry on 401-ish (server rejected the contract claim).
|
|
1358
|
+
// Other errors (network, etc.) also retry — cheap and bounded.
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
if (lastErr) throw lastErr;
|
|
1362
|
+
|
|
1363
|
+
return {
|
|
1364
|
+
id: localRivet.id,
|
|
1365
|
+
address: localRivet.address, // = contract
|
|
1366
|
+
rivetAddress: localRivet.rivetAddress,
|
|
1367
|
+
source: localRivet.source,
|
|
1368
|
+
label: localRivet.label,
|
|
1369
|
+
identityName: identityName || null,
|
|
1370
|
+
identityDomain: identityDomain || null,
|
|
1371
|
+
contractAddress: contractAddress || localRivet.contractAddress,
|
|
1372
|
+
chainId: chainId || null,
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Open <issuerUrl>/auth in a popup and await a postMessage result.
|
|
1377
|
+
// The issuer's auth page posts `{type:"epistery-auth", joinToken, ...}`
|
|
1378
|
+
// back to this window when the user has approved and the inviter rivet
|
|
1379
|
+
// has signed a join token. Rejects on issuer error or popup close.
|
|
1380
|
+
async _runEpisteryAuth(issuerUrl, params) {
|
|
1381
|
+
const url = new URL("/auth", issuerUrl);
|
|
1382
|
+
for (const [k, v] of Object.entries(params)) {
|
|
1383
|
+
url.searchParams.set(k, v);
|
|
1384
|
+
}
|
|
1385
|
+
const expectedOrigin = new URL(issuerUrl).origin;
|
|
1386
|
+
const popup = window.open(
|
|
1387
|
+
url.toString(),
|
|
1388
|
+
"epistery-auth",
|
|
1389
|
+
"width=480,height=720,resizable=yes,scrollbars=yes",
|
|
1390
|
+
);
|
|
1391
|
+
if (!popup) {
|
|
1392
|
+
throw new Error(
|
|
1393
|
+
`Popup blocked. Allow popups for ${location.host} to add an Epistery Identity.`,
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
return new Promise((resolve, reject) => {
|
|
1398
|
+
let settled = false;
|
|
1399
|
+
const cleanup = () => {
|
|
1400
|
+
settled = true;
|
|
1401
|
+
window.removeEventListener("message", onMessage);
|
|
1402
|
+
clearInterval(closeWatcher);
|
|
1403
|
+
};
|
|
1404
|
+
const onMessage = (event) => {
|
|
1405
|
+
if (event.origin !== expectedOrigin) return;
|
|
1406
|
+
const msg = event.data;
|
|
1407
|
+
if (!msg || msg.type !== "epistery-auth") return;
|
|
1408
|
+
if (msg.error) {
|
|
1409
|
+
cleanup();
|
|
1410
|
+
try { popup.close(); } catch (e) {}
|
|
1411
|
+
reject(new Error(msg.error));
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
// The issuer posts whatever fields it has; callers care about
|
|
1415
|
+
// joinToken at minimum. Pass the whole payload through.
|
|
1416
|
+
cleanup();
|
|
1417
|
+
try { popup.close(); } catch (e) {}
|
|
1418
|
+
resolve(msg);
|
|
1419
|
+
};
|
|
1420
|
+
window.addEventListener("message", onMessage);
|
|
1421
|
+
|
|
1422
|
+
// If the user closes the window before completing, surface that.
|
|
1423
|
+
const closeWatcher = setInterval(() => {
|
|
1424
|
+
if (settled) return;
|
|
1425
|
+
if (popup.closed) {
|
|
1426
|
+
cleanup();
|
|
1427
|
+
reject(new Error("Epistery auth window closed before completing"));
|
|
1428
|
+
}
|
|
1429
|
+
}, 500);
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1165
1433
|
async setDefaultWallet(walletId) {
|
|
1166
1434
|
const storageData = this.loadStorageData();
|
|
1167
1435
|
const walletData = storageData.wallets.find((w) => w.id === walletId);
|
package/index.mjs
CHANGED
|
@@ -113,8 +113,13 @@ class EpisteryAttach {
|
|
|
113
113
|
Buffer.from(cookieValue, "base64").toString("utf8"),
|
|
114
114
|
);
|
|
115
115
|
if (sessionData?.rivetAddress) {
|
|
116
|
+
const hasContract = !!sessionData.contractAddress;
|
|
116
117
|
return {
|
|
117
|
-
address:
|
|
118
|
+
address: hasContract
|
|
119
|
+
? sessionData.contractAddress
|
|
120
|
+
: sessionData.rivetAddress,
|
|
121
|
+
signerAddress: sessionData.rivetAddress,
|
|
122
|
+
contractAddress: sessionData.contractAddress || null,
|
|
118
123
|
publicKey: sessionData.publicKey,
|
|
119
124
|
authenticated: sessionData.authenticated || false,
|
|
120
125
|
};
|
|
@@ -179,8 +184,18 @@ class EpisteryAttach {
|
|
|
179
184
|
Buffer.from(req.cookies._epistery, "base64").toString("utf8"),
|
|
180
185
|
);
|
|
181
186
|
if (sessionData && sessionData.rivetAddress) {
|
|
187
|
+
// If the session was established with a contract-backed rivet
|
|
188
|
+
// (i.e. /connect verified IdentityContract.isAuthorized), surface
|
|
189
|
+
// the contract as the canonical identity and keep the rivet as
|
|
190
|
+
// signerAddress. Plain Tier 1 sessions (no contract) keep
|
|
191
|
+
// address == rivet — back-compat.
|
|
192
|
+
const hasContract = !!sessionData.contractAddress;
|
|
182
193
|
req.episteryClient = {
|
|
183
|
-
address:
|
|
194
|
+
address: hasContract
|
|
195
|
+
? sessionData.contractAddress
|
|
196
|
+
: sessionData.rivetAddress,
|
|
197
|
+
signerAddress: sessionData.rivetAddress,
|
|
198
|
+
contractAddress: sessionData.contractAddress || null,
|
|
184
199
|
publicKey: sessionData.publicKey,
|
|
185
200
|
authenticated: sessionData.authenticated || false,
|
|
186
201
|
};
|
package/package.json
CHANGED
package/routes/connect.mjs
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
+
import { createRequire } from "module";
|
|
2
3
|
import { Epistery } from "../dist/epistery.js";
|
|
3
4
|
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const ethers = require("ethers");
|
|
7
|
+
|
|
8
|
+
// Subset of IdentityContract used to verify a rivet's membership claim.
|
|
9
|
+
// Both V2 and V3 IdentityContract expose isAuthorized(address).
|
|
10
|
+
const IDENTITY_AUTHORIZED_ABI = [
|
|
11
|
+
"function isAuthorized(address) view returns (bool)",
|
|
12
|
+
];
|
|
13
|
+
|
|
4
14
|
/**
|
|
5
15
|
* Connect routes - key exchange and wallet creation
|
|
6
16
|
* @param {Object} epistery - The EpisteryAttach instance
|
|
@@ -41,8 +51,56 @@ export default function connectRoutes(epistery) {
|
|
|
41
51
|
error: "Key exchange failed - invalid client credentials",
|
|
42
52
|
});
|
|
43
53
|
}
|
|
54
|
+
|
|
55
|
+
// If the client presents a contract-backed identity (Tier 2), verify
|
|
56
|
+
// on-chain that the signing rivet is actually one of the contract's
|
|
57
|
+
// authorized rivets. This is what closes the cross-host trust loop —
|
|
58
|
+
// the witness can claim any contract address, but the chain is truth.
|
|
59
|
+
//
|
|
60
|
+
// Provider: use the host's domain RPC. v0 assumes the IdentityContract
|
|
61
|
+
// lives on the same chain as the host. Cross-chain identity is a
|
|
62
|
+
// future concern.
|
|
63
|
+
let verifiedContractAddress = null;
|
|
64
|
+
const claimedContract = data.contractAddress || data.identityAddress;
|
|
65
|
+
if (claimedContract) {
|
|
66
|
+
try {
|
|
67
|
+
const rpcUrl =
|
|
68
|
+
serverWallet?.provider?.privateRpc ||
|
|
69
|
+
serverWallet?.provider?.rpc ||
|
|
70
|
+
process.env.CHAIN_RPC_URL;
|
|
71
|
+
if (!rpcUrl) {
|
|
72
|
+
return res.status(500).json({
|
|
73
|
+
error: "No chain RPC configured to verify identity contract",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const provider = new ethers.providers.JsonRpcProvider(rpcUrl);
|
|
77
|
+
const identity = new ethers.Contract(
|
|
78
|
+
claimedContract,
|
|
79
|
+
IDENTITY_AUTHORIZED_ABI,
|
|
80
|
+
provider,
|
|
81
|
+
);
|
|
82
|
+
const isAuth = await identity.isAuthorized(data.clientAddress);
|
|
83
|
+
if (!isAuth) {
|
|
84
|
+
return res.status(401).json({
|
|
85
|
+
error:
|
|
86
|
+
"Identity contract does not authorize this rivet (isAuthorized returned false)",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
verifiedContractAddress = claimedContract;
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.error("[connect] Identity contract verification failed:", e.message);
|
|
92
|
+
return res.status(401).json({
|
|
93
|
+
error: `Identity contract verification failed: ${e.message}`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
44
98
|
const clientInfo = {
|
|
45
|
-
|
|
99
|
+
// For verified contract sessions, present the contract as the
|
|
100
|
+
// canonical identity. The rivet remains accessible as signerAddress.
|
|
101
|
+
address: verifiedContractAddress || data.clientAddress,
|
|
102
|
+
signerAddress: data.clientAddress,
|
|
103
|
+
contractAddress: verifiedContractAddress,
|
|
46
104
|
publicKey: data.clientPublicKey,
|
|
47
105
|
};
|
|
48
106
|
try {
|
|
@@ -60,9 +118,13 @@ export default function connectRoutes(epistery) {
|
|
|
60
118
|
}
|
|
61
119
|
req.episteryClient = clientInfo;
|
|
62
120
|
|
|
63
|
-
// Create session cookie
|
|
121
|
+
// Create session cookie. Rivet address always recorded; contract
|
|
122
|
+
// address only when we just verified it on-chain. Downstream middleware
|
|
123
|
+
// (index.mjs) surfaces contractAddress as req.episteryClient.address
|
|
124
|
+
// when present.
|
|
64
125
|
const sessionData = {
|
|
65
126
|
rivetAddress: data.clientAddress,
|
|
127
|
+
contractAddress: verifiedContractAddress || null,
|
|
66
128
|
publicKey: data.clientPublicKey,
|
|
67
129
|
authenticated: clientInfo.authenticated || false,
|
|
68
130
|
timestamp: new Date().toISOString(),
|