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 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.signer = null;
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
- // Only persist the essential data, not the complex objects
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
- // Don't serialize signer/provider - they'll be recreated
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
- // Attempt to reconnect to Web3 provider
167
- await wallet.reconnectWeb3(ethers);
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
- if (await wallet.connectWeb3(ethers)) {
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 connectWeb3(ethers) {
181
- try {
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
- async reconnectWeb3(ethers) {
208
- try {
209
- if (typeof window !== "undefined" && (window.ethereum || window.web3)) {
210
- const provider = window.ethereum || window.web3.currentProvider;
211
- this.provider = new ethers.providers.Web3Provider(provider);
212
- this.signer = this.provider.getSigner();
213
-
214
- // Verify the address matches what we have stored
215
- const currentAddress = await this.signer.getAddress();
216
- if (currentAddress.toLowerCase() !== this.address.toLowerCase()) {
217
- this.address = currentAddress;
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 (!this.signer) {
229
- throw new Error("Web3 signer not available");
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
- const signature = await this.signer.signMessage(message);
233
-
234
- // Always update public key from signature for Web3 wallets
235
- if (ethers) {
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 signature;
339
+ const priv = await this._unlock(ethers);
340
+ return await new ethers.Wallet(priv).signTransaction(unsignedTx);
244
341
  }
245
342
 
246
- async derivePublicKeyPlaceholder() {
247
- // Placeholder until we get a real signature
248
- return `0x04${this.address.slice(2)}${"0".repeat(64)}`;
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 derivePublicKeyFromSignature(message, signature, ethers) {
252
- try {
253
- const messageHash = ethers.utils.hashMessage(message);
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
- if (this.contractAddress) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epistery",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "Epistery brings blockchain capabilities to mundane web tasks like engagement metrics, authentication and commerce of all sorts.",
5
5
  "author": "Rootz Corp.",
6
6
  "license": "MIT",
@@ -1,5 +1,4 @@
1
1
  domain=localhost
2
- pending=true
3
2
 
4
3
  [wallet]
5
4
  address=0x5277286b5821D1bF5c2395F38c3250A5B0F14da3