feat(encryptid): sync wallet associations across devices via encrypted vault
Wallets stored in local WalletStore are now bidirectionally synced with the encrypted AccountVault on the server. On login, vault wallets are restored to the local store; on wallet changes, local state is pushed back to the vault. The server user profile wallet_address is also set on login so mobile devices (without PRF) get the address via JWT. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
564c16e431
commit
fc1776aedf
|
|
@ -78,6 +78,15 @@ export {
|
|||
type AccountVault,
|
||||
} from './vault';
|
||||
|
||||
// ============================================================================
|
||||
// WALLET SYNC
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
syncWalletsOnLogin,
|
||||
syncWalletsToVault,
|
||||
} from './wallet-sync';
|
||||
|
||||
// ============================================================================
|
||||
// UI COMPONENTS
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { getKeyManager } from '../key-derivation';
|
|||
import { getSessionManager, AuthLevel } from '../session';
|
||||
import { getDocBridge, resetDocBridge } from '../../../shared/local-first/encryptid-bridge';
|
||||
import { getVaultManager, resetVaultManager } from '../vault';
|
||||
import { syncWalletsOnLogin } from '../wallet-sync';
|
||||
|
||||
// ============================================================================
|
||||
// STYLES
|
||||
|
|
@ -415,9 +416,9 @@ export class EncryptIDLoginButton extends HTMLElement {
|
|||
// Load encrypted account vault in background
|
||||
const docCrypto = getDocBridge().getDocCrypto();
|
||||
if (docCrypto) {
|
||||
getVaultManager(docCrypto).load().catch(err =>
|
||||
console.warn('Vault load failed:', err)
|
||||
);
|
||||
getVaultManager(docCrypto).load()
|
||||
.then(() => syncWalletsOnLogin(docCrypto))
|
||||
.catch(err => console.warn('Vault load failed:', err));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -429,7 +430,7 @@ export class EncryptIDLoginButton extends HTMLElement {
|
|||
await sessionManager.createSession(result, keys.did, {
|
||||
encrypt: true,
|
||||
sign: true,
|
||||
wallet: false, // Will be true after wallet setup
|
||||
wallet: !!keys.eoaAddress,
|
||||
});
|
||||
|
||||
// Dispatch success event
|
||||
|
|
@ -534,9 +535,9 @@ export class EncryptIDLoginButton extends HTMLElement {
|
|||
// Load encrypted account vault in background
|
||||
const docCrypto = getDocBridge().getDocCrypto();
|
||||
if (docCrypto) {
|
||||
getVaultManager(docCrypto).load().catch(err =>
|
||||
console.warn('Vault load failed:', err)
|
||||
);
|
||||
getVaultManager(docCrypto).load()
|
||||
.then(() => syncWalletsOnLogin(docCrypto))
|
||||
.catch(err => console.warn('Vault load failed:', err));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -546,7 +547,7 @@ export class EncryptIDLoginButton extends HTMLElement {
|
|||
await sessionManager.createSession(result, keys.did, {
|
||||
encrypt: true,
|
||||
sign: true,
|
||||
wallet: false,
|
||||
wallet: !!keys.eoaAddress,
|
||||
});
|
||||
|
||||
this.dispatchEvent(new CustomEvent('login-success', {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* EncryptID Wallet Sync
|
||||
*
|
||||
* Bridges WalletStore (encrypted localStorage) ↔ VaultManager (encrypted server blob)
|
||||
* so wallet associations survive across devices.
|
||||
*
|
||||
* On login:
|
||||
* 1. Load vault from server
|
||||
* 2. Merge vault wallets into local WalletStore (new entries only)
|
||||
* 3. Merge local wallets into vault (in case this device has wallets the vault doesn't)
|
||||
* 4. Save vault if anything changed
|
||||
*
|
||||
* On wallet add/remove:
|
||||
* syncWalletsToVault() pushes current WalletStore state into the vault.
|
||||
*/
|
||||
|
||||
import { getWalletStore, type SafeWalletEntry } from './wallet-store';
|
||||
import { getVaultManager, type AccountVault } from './vault';
|
||||
import { getKeyManager } from './key-derivation';
|
||||
import type { DocCrypto } from '../../shared/local-first/crypto';
|
||||
|
||||
/**
|
||||
* After login + vault load, merge wallets bidirectionally between
|
||||
* the local WalletStore and the remote AccountVault.
|
||||
*
|
||||
* Requires PRF-derived encryption key (for WalletStore) and DocCrypto (for VaultManager).
|
||||
*/
|
||||
export async function syncWalletsOnLogin(docCrypto: DocCrypto): Promise<void> {
|
||||
try {
|
||||
const km = getKeyManager();
|
||||
if (!km.isInitialized()) return;
|
||||
|
||||
const keys = await km.getKeys();
|
||||
if (!keys.encryptionKey) return;
|
||||
|
||||
const walletStore = getWalletStore(keys.encryptionKey);
|
||||
const vaultManager = getVaultManager(docCrypto);
|
||||
|
||||
const vault = await vaultManager.get();
|
||||
const localWallets = await walletStore.list();
|
||||
const vaultWallets = vault.wallets || [];
|
||||
|
||||
let changed = false;
|
||||
|
||||
// 1. Restore vault wallets → local WalletStore (if not already present)
|
||||
for (const vw of vaultWallets) {
|
||||
const exists = localWallets.find(
|
||||
lw => lw.safeAddress.toLowerCase() === vw.safeAddress.toLowerCase()
|
||||
&& lw.chainId === vw.chainId
|
||||
);
|
||||
if (!exists) {
|
||||
await walletStore.add({
|
||||
safeAddress: vw.safeAddress,
|
||||
chainId: vw.chainId,
|
||||
eoaAddress: vw.eoaAddress,
|
||||
label: vw.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Push local wallets → vault (if not already present)
|
||||
const updatedLocal = await walletStore.list();
|
||||
const updatedVaultWallets = [...vaultWallets];
|
||||
|
||||
for (const lw of updatedLocal) {
|
||||
const exists = updatedVaultWallets.find(
|
||||
vw => vw.safeAddress.toLowerCase() === lw.safeAddress.toLowerCase()
|
||||
&& vw.chainId === lw.chainId
|
||||
);
|
||||
if (!exists) {
|
||||
updatedVaultWallets.push({
|
||||
id: lw.id,
|
||||
safeAddress: lw.safeAddress,
|
||||
chainId: lw.chainId,
|
||||
eoaAddress: lw.eoaAddress,
|
||||
label: lw.label,
|
||||
addedAt: lw.addedAt,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Also ensure the server-side wallet_address profile field is set
|
||||
// This is the fallback for mobile (no PRF) — the JWT carries it.
|
||||
if (keys.eoaAddress) {
|
||||
await setServerWalletAddress(keys.eoaAddress).catch(() => {});
|
||||
}
|
||||
|
||||
// 4. Save vault if anything changed
|
||||
if (changed) {
|
||||
await vaultManager.update({ wallets: updatedVaultWallets });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Wallet sync failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push current WalletStore state into the vault.
|
||||
* Call this after any wallet add/update/remove operation.
|
||||
*/
|
||||
export async function syncWalletsToVault(docCrypto: DocCrypto): Promise<void> {
|
||||
try {
|
||||
const km = getKeyManager();
|
||||
if (!km.isInitialized()) return;
|
||||
|
||||
const keys = await km.getKeys();
|
||||
if (!keys.encryptionKey) return;
|
||||
|
||||
const walletStore = getWalletStore(keys.encryptionKey);
|
||||
const vaultManager = getVaultManager(docCrypto);
|
||||
|
||||
const localWallets = await walletStore.list();
|
||||
const vaultWallets = localWallets.map(lw => ({
|
||||
id: lw.id,
|
||||
safeAddress: lw.safeAddress,
|
||||
chainId: lw.chainId,
|
||||
eoaAddress: lw.eoaAddress,
|
||||
label: lw.label,
|
||||
addedAt: lw.addedAt,
|
||||
}));
|
||||
|
||||
await vaultManager.update({ wallets: vaultWallets });
|
||||
} catch (err) {
|
||||
console.warn('Wallet vault sync failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the wallet address on the server user profile.
|
||||
* This ensures the JWT carries the wallet address for non-PRF devices (mobile).
|
||||
*/
|
||||
async function setServerWalletAddress(walletAddress: string): Promise<void> {
|
||||
const token = localStorage.getItem('encryptid_token');
|
||||
if (!token) return;
|
||||
|
||||
await fetch('/encryptid/api/wallet-capability', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ walletAddress }),
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue