epistery 2.2.0 → 2.2.2
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/wallet.js +178 -89
- package/client/witness.js +6 -0
- package/package.json +1 -1
- package/test/config/.epistery/localhost/config.ini +0 -1
package/client/wallet.js
CHANGED
|
@@ -28,6 +28,11 @@ export class Wallet {
|
|
|
28
28
|
// every wallet variant exposes the three-fact shape uniformly.
|
|
29
29
|
this.contractAddress = null;
|
|
30
30
|
this.rivetAddress = null;
|
|
31
|
+
// Capability declaration: true when this wallet holds a rivet key that
|
|
32
|
+
// implements encryptForPeer/decryptFromPeer. Consumers check THIS flag
|
|
33
|
+
// — never the source name, never typeof (the base stubs below always
|
|
34
|
+
// exist). Only a wallet whose key can actually run the ECDH sets it.
|
|
35
|
+
this.canPeerEncrypt = false;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
// The rivet. For wallets that have been upgraded to a contract,
|
|
@@ -49,9 +54,26 @@ export class Wallet {
|
|
|
49
54
|
address: this.address,
|
|
50
55
|
publicKey: this.publicKey,
|
|
51
56
|
source: this.source,
|
|
57
|
+
// Contract binding is a base-class fact — persist it for EVERY rivet
|
|
58
|
+
// kind, or adopting an identity silently unbinds on reload.
|
|
59
|
+
contractAddress: this.contractAddress,
|
|
60
|
+
rivetAddress: this.rivetAddress,
|
|
52
61
|
};
|
|
53
62
|
}
|
|
54
63
|
|
|
64
|
+
// Bind this rivet to an identity contract. A base-class capability: any
|
|
65
|
+
// rivet kind (browser, FIDO, web3) can be a signer on a contract. Does
|
|
66
|
+
// NOT flip `address` — the device key stays `address`/`rivetAddress`;
|
|
67
|
+
// the contract is a separate fact exposed via contractAddress /
|
|
68
|
+
// identityAddress.
|
|
69
|
+
upgradeToContract(contractAddress) {
|
|
70
|
+
if (this.contractAddress) {
|
|
71
|
+
throw new Error("Rivet is already using an identity contract");
|
|
72
|
+
}
|
|
73
|
+
this.rivetAddress = this.address;
|
|
74
|
+
this.contractAddress = contractAddress;
|
|
75
|
+
}
|
|
76
|
+
|
|
55
77
|
// Factory method to create appropriate wallet type from saved data.
|
|
56
78
|
// The three rivet types — web3, browser (rivet), and fido — are the only
|
|
57
79
|
// valid identities. Anything else is a legacy/unsupported entry (e.g. the
|
|
@@ -141,20 +163,77 @@ async function _aesGcmDecrypt(aesKey, ciphertextBytes, ivBytes, tagBytes) {
|
|
|
141
163
|
);
|
|
142
164
|
}
|
|
143
165
|
|
|
144
|
-
// Web3 Wallet (MetaMask, etc.)
|
|
166
|
+
// Web3 Wallet — a RIVET locked by a plugin wallet (MetaMask, etc.).
|
|
167
|
+
//
|
|
168
|
+
// The common model: every wallet IS a nimble rivet key; the sources differ
|
|
169
|
+
// only in what LOCKS that key. RivetWallet's key is locked by the
|
|
170
|
+
// non-extractable WebCrypto master key; FidoWallet's by the authenticator's
|
|
171
|
+
// PRF secret; here the plugin wallet is the lock. The rivet private key is
|
|
172
|
+
// DERIVED from one deterministic personal_sign over a fixed message that
|
|
173
|
+
// binds BOTH the site (hostname) and the plugin account:
|
|
174
|
+
//
|
|
175
|
+
// priv = keccak256( sign("epistery rivet key v1\n<hostname>\n<web3Address>…") )
|
|
176
|
+
//
|
|
177
|
+
// ECDSA in every major plugin wallet is RFC-6979 deterministic, so the same
|
|
178
|
+
// account on the same site always re-derives the same rivet — nothing
|
|
179
|
+
// secret is stored, and the rivet is recoverable on any device that holds
|
|
180
|
+
// the plugin wallet. Binding the hostname makes web3 rivets ORIGIN-SCOPED,
|
|
181
|
+
// exactly like browser rivets (whose keys live in origin-scoped
|
|
182
|
+
// localStorage) and consistent with the one-rivet-one-contract policy: a
|
|
183
|
+
// different site derives a different rivet, so identities don't link
|
|
184
|
+
// across sites — and a signature phished on another site derives that
|
|
185
|
+
// site's rivet, useless here. The wallet's ADDRESS and publicKey are the
|
|
186
|
+
// RIVET's; the plugin account is kept as `web3Address` (the lock, shown in
|
|
187
|
+
// UI, usable as a value/recovery signer on-chain). Everything a rivet does
|
|
188
|
+
// — sign, transact, peer encryption — runs on the derived key, unlocked
|
|
189
|
+
// once per session.
|
|
190
|
+
//
|
|
191
|
+
// Phishing note: only a page the user believes is <hostname> can obtain a
|
|
192
|
+
// useful signature. The message names the site and the account; plugin
|
|
193
|
+
// wallets display it verbatim. Same posture as the widely used
|
|
194
|
+
// signature-derived-key pattern (dYdX/StarkEx et al.), tightened by the
|
|
195
|
+
// hostname binding.
|
|
196
|
+
//
|
|
197
|
+
// Legacy records (pre-lock, address == plugin account, placeholder pubkey)
|
|
198
|
+
// load in a signing-only compatibility mode (`canPeerEncrypt` stays false);
|
|
199
|
+
// re-adding the plugin wallet mints the locked rivet.
|
|
200
|
+
const WEB3_LOCK_MESSAGE_V1 = (hostname, addr) =>
|
|
201
|
+
`epistery rivet key v1\n${hostname}\n${addr.toLowerCase()}\n\nSigning this unlocks your epistery device key on ${hostname}. Only sign it on that site.`;
|
|
202
|
+
|
|
203
|
+
// The site component of the lock message. hostname (not origin/host): stable
|
|
204
|
+
// across ports and schemes so dev restarts don't orphan rivets, while still
|
|
205
|
+
// separating every domain.
|
|
206
|
+
function _lockHostname() {
|
|
207
|
+
if (typeof window === "undefined" || !window.location || !window.location.hostname) {
|
|
208
|
+
throw new Error("web3 rivets exist only in a browser context (no hostname to bind)");
|
|
209
|
+
}
|
|
210
|
+
return window.location.hostname.toLowerCase();
|
|
211
|
+
}
|
|
212
|
+
|
|
145
213
|
export class Web3Wallet extends Wallet {
|
|
146
214
|
constructor() {
|
|
147
215
|
super();
|
|
148
216
|
this.source = "web3";
|
|
149
|
-
this.
|
|
217
|
+
this.web3Address = null; // the plugin account that locks the rivet
|
|
218
|
+
this.label = null;
|
|
219
|
+
this.createdAt = null;
|
|
220
|
+
this.signer = null; // plugin signer (the lock) — session only
|
|
150
221
|
this.provider = null;
|
|
222
|
+
this._priv = null; // unlocked rivet key — closure/session only
|
|
223
|
+
this._legacy = false; // pre-lock record: address IS the plugin account
|
|
224
|
+
this.canPeerEncrypt = false; // true once the rivet exists (locked mode)
|
|
151
225
|
}
|
|
152
226
|
|
|
153
227
|
toJSON() {
|
|
154
|
-
//
|
|
228
|
+
// Nothing secret persists: the rivet re-derives from the plugin
|
|
229
|
+
// signature on unlock. signer/provider/_priv are session-only.
|
|
155
230
|
return {
|
|
156
231
|
...super.toJSON(),
|
|
157
|
-
//
|
|
232
|
+
// A legacy record must round-trip as legacy: writing web3Address
|
|
233
|
+
// would dress it up as a locked rivet it never minted.
|
|
234
|
+
web3Address: this._legacy ? null : this.web3Address,
|
|
235
|
+
label: this.label,
|
|
236
|
+
createdAt: this.createdAt,
|
|
158
237
|
};
|
|
159
238
|
}
|
|
160
239
|
|
|
@@ -162,100 +241,121 @@ export class Web3Wallet extends Wallet {
|
|
|
162
241
|
const wallet = new Web3Wallet();
|
|
163
242
|
wallet.address = data.address;
|
|
164
243
|
wallet.publicKey = data.publicKey;
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
244
|
+
// Locked-rivet records carry web3Address. Legacy pre-lock records don't:
|
|
245
|
+
// their address IS the plugin account and no rivet was ever minted —
|
|
246
|
+
// they stay signing-only (the plugin signs directly) until re-added.
|
|
247
|
+
wallet._legacy = !data.web3Address;
|
|
248
|
+
wallet.web3Address = data.web3Address || data.address;
|
|
249
|
+
wallet.label = data.label || null;
|
|
250
|
+
wallet.createdAt = data.createdAt || null;
|
|
251
|
+
wallet.contractAddress = data.contractAddress || null;
|
|
252
|
+
wallet.rivetAddress = data.rivetAddress || null;
|
|
253
|
+
wallet.canPeerEncrypt = !wallet._legacy;
|
|
254
|
+
// Lazy: the plugin reconnect + unlock signature happen on first use,
|
|
255
|
+
// not at restore — a locked wallet still reports address/publicKey.
|
|
168
256
|
return wallet;
|
|
169
257
|
}
|
|
170
258
|
|
|
171
259
|
static async create(ethers) {
|
|
172
260
|
const wallet = new Web3Wallet();
|
|
173
|
-
|
|
174
|
-
|
|
261
|
+
try {
|
|
262
|
+
if (!(await wallet._connectPlugin(ethers))) return null;
|
|
263
|
+
await wallet._unlock(ethers); // derives + installs the rivet
|
|
264
|
+
wallet.createdAt = Date.now();
|
|
175
265
|
return wallet;
|
|
266
|
+
} catch (error) {
|
|
267
|
+
console.warn("Web3Wallet.create failed:", error.message);
|
|
268
|
+
return null; // user declined, or no plugin wallet available
|
|
176
269
|
}
|
|
177
|
-
return null; // Connection failed
|
|
178
270
|
}
|
|
179
271
|
|
|
180
|
-
async
|
|
181
|
-
|
|
182
|
-
if (typeof window !== "undefined" && (window.ethereum || window.web3)) {
|
|
183
|
-
const provider = window.ethereum || window.web3.currentProvider;
|
|
184
|
-
|
|
185
|
-
// Request account access
|
|
186
|
-
const accounts = await provider.request({
|
|
187
|
-
method: "eth_requestAccounts",
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
if (accounts && accounts.length > 0) {
|
|
191
|
-
this.address = accounts[0];
|
|
192
|
-
this.provider = new ethers.providers.Web3Provider(provider);
|
|
193
|
-
this.signer = this.provider.getSigner();
|
|
194
|
-
|
|
195
|
-
// Get public key from first signature
|
|
196
|
-
this.publicKey = await this.derivePublicKeyPlaceholder();
|
|
197
|
-
|
|
198
|
-
return true;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return false;
|
|
202
|
-
} catch (error) {
|
|
272
|
+
async _connectPlugin(ethers) {
|
|
273
|
+
if (typeof window === "undefined" || !(window.ethereum || window.web3)) {
|
|
203
274
|
return false;
|
|
204
275
|
}
|
|
276
|
+
const provider = window.ethereum || window.web3.currentProvider;
|
|
277
|
+
const accounts = await provider.request({ method: "eth_requestAccounts" });
|
|
278
|
+
if (!accounts || !accounts.length) return false;
|
|
279
|
+
this.provider = new ethers.providers.Web3Provider(provider);
|
|
280
|
+
this.signer = this.provider.getSigner();
|
|
281
|
+
if (!this.web3Address) this.web3Address = accounts[0];
|
|
282
|
+
if (accounts[0].toLowerCase() !== this.web3Address.toLowerCase()) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`This wallet is locked by ${this.web3Address} — switch the plugin wallet to that account to unlock it`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
205
288
|
}
|
|
206
289
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
return true;
|
|
220
|
-
}
|
|
221
|
-
return false;
|
|
222
|
-
} catch (error) {
|
|
223
|
-
return false;
|
|
290
|
+
// Unlock: one deterministic plugin signature → the rivet key, cached for
|
|
291
|
+
// the session. Verifies against the stored rivet address so a different
|
|
292
|
+
// plugin account (different derivation) fails closed instead of silently
|
|
293
|
+
// becoming a different rivet.
|
|
294
|
+
async _unlock(ethers) {
|
|
295
|
+
if (this._legacy) {
|
|
296
|
+
throw new Error("legacy web3 wallet has no rivet — remove and re-add it to mint one");
|
|
297
|
+
}
|
|
298
|
+
if (this._priv) return this._priv;
|
|
299
|
+
if (!this.signer && !(await this._connectPlugin(ethers))) {
|
|
300
|
+
throw new Error("Plugin wallet not available — connect it to unlock this rivet");
|
|
224
301
|
}
|
|
302
|
+
const signature = await this.signer.signMessage(
|
|
303
|
+
WEB3_LOCK_MESSAGE_V1(_lockHostname(), this.web3Address),
|
|
304
|
+
);
|
|
305
|
+
const priv = ethers.utils.keccak256(ethers.utils.arrayify(signature));
|
|
306
|
+
const signingKey = new ethers.utils.SigningKey(priv);
|
|
307
|
+
const address = ethers.utils.computeAddress(signingKey.publicKey);
|
|
308
|
+
if (this.address && address.toLowerCase() !== this.address.toLowerCase()) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
"Unlock produced a different rivet — sign with the plugin account that created this wallet",
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
this._priv = priv;
|
|
314
|
+
this.address = address;
|
|
315
|
+
this.publicKey = signingKey.publicKey; // real uncompressed secp256k1
|
|
316
|
+
this.canPeerEncrypt = true;
|
|
317
|
+
return priv;
|
|
225
318
|
}
|
|
226
319
|
|
|
227
320
|
async sign(message, ethers) {
|
|
228
|
-
if (
|
|
229
|
-
|
|
321
|
+
if (this._legacy) {
|
|
322
|
+
// Pre-lock record: its address IS the plugin account, which may
|
|
323
|
+
// already be a rivet on an IdentityContract — it must keep signing
|
|
324
|
+
// (that's how the owner authorizes adding a locked rivet later).
|
|
325
|
+
if (!this.signer && !(await this._connectPlugin(ethers))) {
|
|
326
|
+
throw new Error("Plugin wallet not available");
|
|
327
|
+
}
|
|
328
|
+
return await this.signer.signMessage(message);
|
|
230
329
|
}
|
|
330
|
+
const priv = await this._unlock(ethers);
|
|
331
|
+
return await new ethers.Wallet(priv).signMessage(message);
|
|
332
|
+
}
|
|
231
333
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
this.publicKey = await this.derivePublicKeyFromSignature(
|
|
237
|
-
message,
|
|
238
|
-
signature,
|
|
239
|
-
ethers,
|
|
240
|
-
);
|
|
334
|
+
async signTransaction(unsignedTx, ethers) {
|
|
335
|
+
if (this._legacy) {
|
|
336
|
+
// Plugin signers can't produce raw signed txs (and never could here).
|
|
337
|
+
throw new Error("legacy web3 wallet cannot sign transactions — remove and re-add it to mint its locked rivet");
|
|
241
338
|
}
|
|
242
|
-
|
|
243
|
-
return
|
|
339
|
+
const priv = await this._unlock(ethers);
|
|
340
|
+
return await new ethers.Wallet(priv).signTransaction(unsignedTx);
|
|
244
341
|
}
|
|
245
342
|
|
|
246
|
-
async
|
|
247
|
-
|
|
248
|
-
|
|
343
|
+
async encryptForPeer(peerPublicKey, plaintextBytes, ethers) {
|
|
344
|
+
if (!this.canPeerEncrypt) {
|
|
345
|
+
throw new Error("legacy web3 wallet — remove and re-add it to mint its locked rivet");
|
|
346
|
+
}
|
|
347
|
+
const priv = await this._unlock(ethers);
|
|
348
|
+
const aesKey = await _deriveAesKeyFromPriv(priv, peerPublicKey, ethers);
|
|
349
|
+
return await _aesGcmEncrypt(aesKey, plaintextBytes);
|
|
249
350
|
}
|
|
250
351
|
|
|
251
|
-
async
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
return ethers.utils.recoverPublicKey(messageHash, signature);
|
|
255
|
-
} catch (error) {
|
|
256
|
-
console.error("Failed to derive public key from signature:", error);
|
|
257
|
-
return this.derivePublicKeyPlaceholder();
|
|
352
|
+
async decryptFromPeer(peerPublicKey, ciphertextBytes, ivBytes, tagBytes, ethers) {
|
|
353
|
+
if (!this.canPeerEncrypt) {
|
|
354
|
+
throw new Error("legacy web3 wallet — remove and re-add it to mint its locked rivet");
|
|
258
355
|
}
|
|
356
|
+
const priv = await this._unlock(ethers);
|
|
357
|
+
const aesKey = await _deriveAesKeyFromPriv(priv, peerPublicKey, ethers);
|
|
358
|
+
return await _aesGcmDecrypt(aesKey, ciphertextBytes, ivBytes, tagBytes);
|
|
259
359
|
}
|
|
260
360
|
}
|
|
261
361
|
|
|
@@ -265,6 +365,7 @@ export class RivetWallet extends Wallet {
|
|
|
265
365
|
constructor() {
|
|
266
366
|
super();
|
|
267
367
|
this.source = "rivet";
|
|
368
|
+
this.canPeerEncrypt = true;
|
|
268
369
|
this.keyId = null;
|
|
269
370
|
this.type = "Browser"; // Browser, Contract, or Web3
|
|
270
371
|
this.label = null;
|
|
@@ -287,8 +388,6 @@ export class RivetWallet extends Wallet {
|
|
|
287
388
|
createdAt: this.createdAt,
|
|
288
389
|
lastUpdated: this.lastUpdated,
|
|
289
390
|
encryptedPrivateKey: this.encryptedPrivateKey,
|
|
290
|
-
contractAddress: this.contractAddress,
|
|
291
|
-
rivetAddress: this.rivetAddress,
|
|
292
391
|
associations: this.associations,
|
|
293
392
|
};
|
|
294
393
|
}
|
|
@@ -1011,23 +1110,10 @@ export class RivetWallet extends Wallet {
|
|
|
1011
1110
|
|
|
1012
1111
|
/**
|
|
1013
1112
|
* Upgrades this rivet to use an identity contract
|
|
1014
|
-
* Updates the wallet to present the contract address instead of rivet address
|
|
1015
1113
|
* @param {string} contractAddress - The deployed identity contract address
|
|
1016
1114
|
*/
|
|
1017
1115
|
upgradeToContract(contractAddress) {
|
|
1018
|
-
|
|
1019
|
-
throw new Error("Rivet is already using an identity contract");
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
// Do NOT flip `address` to the contract. `address` stays the rivet (device
|
|
1023
|
-
// key) for the life of the wallet; the contract is a separate fact exposed
|
|
1024
|
-
// via contractAddress / identityAddress. Flipping used to make the device
|
|
1025
|
-
// wallet masquerade as its contract — the device list then showed the
|
|
1026
|
-
// contract address, and every consumer that read `address` expecting the
|
|
1027
|
-
// signer was wrong. rivetAddress is kept set (== address) for the
|
|
1028
|
-
// signerAddress getter and a stable persisted shape.
|
|
1029
|
-
this.rivetAddress = this.address;
|
|
1030
|
-
this.contractAddress = contractAddress;
|
|
1116
|
+
super.upgradeToContract(contractAddress);
|
|
1031
1117
|
this.type = "Contract";
|
|
1032
1118
|
this.lastUpdated = Date.now();
|
|
1033
1119
|
}
|
|
@@ -1052,6 +1138,7 @@ export class FidoWallet extends Wallet {
|
|
|
1052
1138
|
constructor() {
|
|
1053
1139
|
super();
|
|
1054
1140
|
this.source = "fido";
|
|
1141
|
+
this.canPeerEncrypt = true;
|
|
1055
1142
|
this.credentialId = null; // base64url string
|
|
1056
1143
|
this.encryptedPrivateKey = null; // JSON: { ciphertext, iv } as hex
|
|
1057
1144
|
this.label = null;
|
|
@@ -1079,6 +1166,8 @@ export class FidoWallet extends Wallet {
|
|
|
1079
1166
|
wallet.label = data.label;
|
|
1080
1167
|
wallet.createdAt = data.createdAt;
|
|
1081
1168
|
wallet.lastUpdated = data.lastUpdated;
|
|
1169
|
+
wallet.contractAddress = data.contractAddress || null;
|
|
1170
|
+
wallet.rivetAddress = data.rivetAddress || null;
|
|
1082
1171
|
return wallet;
|
|
1083
1172
|
}
|
|
1084
1173
|
|
package/client/witness.js
CHANGED
|
@@ -393,6 +393,12 @@ export default class Witness {
|
|
|
393
393
|
if (!this.wallet || this.wallet.source !== "web3" || !this.serverInfo) {
|
|
394
394
|
return;
|
|
395
395
|
}
|
|
396
|
+
// Locked-rivet web3 wallets restore lazily — no provider until first
|
|
397
|
+
// plugin use. Chain compatibility only matters when the plugin itself
|
|
398
|
+
// sends a tx (legacy path), and that path switches chains at tx time.
|
|
399
|
+
if (!this.wallet.provider || typeof this.wallet.provider.getNetwork !== "function") {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
396
402
|
|
|
397
403
|
try {
|
|
398
404
|
const targetChainId = this.serverInfo.chainId;
|
package/package.json
CHANGED