feat(rwallet): link external wallets via EIP-6963 + SIWE
Users can now connect browser wallets (MetaMask, Rainbow, etc.) to their EncryptID identity via SIWE ownership proof, and view linked wallet balances in the unified rWallet viewer. New files: - eip6963.ts: EIP-6963 multi-provider discovery - external-signer.ts: EIP-1193 provider wrapper for tx signing - linked-wallets.ts: encrypted client-side store (same AES-256-GCM pattern) Server: wallet-link nonce/verify/list/delete routes, linked_wallets table, Safe add-owner-proposal endpoint, new session permissions. UI: "My Wallets" section with provider picker, SIWE linking flow, wallet type badges, and click-to-view for linked wallets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ac156cbbf2
commit
c789481d91
|
|
@ -2,7 +2,8 @@
|
||||||
* <folk-wallet-viewer> — multichain Safe wallet visualization.
|
* <folk-wallet-viewer> — multichain Safe wallet visualization.
|
||||||
*
|
*
|
||||||
* Enter a Safe address to see balances across chains, transfer history,
|
* Enter a Safe address to see balances across chains, transfer history,
|
||||||
* and flow visualizations.
|
* and flow visualizations. Authenticated users can link external wallets
|
||||||
|
* via EIP-6963 + SIWE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface ChainInfo {
|
interface ChainInfo {
|
||||||
|
|
@ -20,6 +21,27 @@ interface BalanceItem {
|
||||||
fiatConversion: string;
|
fiatConversion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LinkedWallet {
|
||||||
|
id: string;
|
||||||
|
address: string;
|
||||||
|
type: "eoa" | "safe";
|
||||||
|
label: string;
|
||||||
|
providerName?: string;
|
||||||
|
providerRdns?: string;
|
||||||
|
safeInfo?: {
|
||||||
|
threshold: number;
|
||||||
|
ownerCount: number;
|
||||||
|
isEncryptIdOwner: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscoveredProvider {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
rdns: string;
|
||||||
|
}
|
||||||
|
|
||||||
const CHAIN_COLORS: Record<string, string> = {
|
const CHAIN_COLORS: Record<string, string> = {
|
||||||
"1": "#627eea",
|
"1": "#627eea",
|
||||||
"10": "#ff0420",
|
"10": "#ff0420",
|
||||||
|
|
@ -59,6 +81,15 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
private walletType: "safe" | "eoa" | "" = "";
|
private walletType: "safe" | "eoa" | "" = "";
|
||||||
private includeTestnets = false;
|
private includeTestnets = false;
|
||||||
|
|
||||||
|
// Linked wallets state
|
||||||
|
private isAuthenticated = false;
|
||||||
|
private passKeyEOA = "";
|
||||||
|
private linkedWallets: LinkedWallet[] = [];
|
||||||
|
private showProviderPicker = false;
|
||||||
|
private discoveredProviders: DiscoveredProvider[] = [];
|
||||||
|
private linkingInProgress = false;
|
||||||
|
private linkError = "";
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.shadow = this.attachShadow({ mode: "open" });
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
|
@ -73,10 +104,67 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
// Check URL params for initial address
|
// Check URL params for initial address
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
this.address = params.get("address") || "";
|
this.address = params.get("address") || "";
|
||||||
|
this.checkAuthState();
|
||||||
this.render();
|
this.render();
|
||||||
if (this.address) this.detectChains();
|
if (this.address) this.detectChains();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private checkAuthState() {
|
||||||
|
try {
|
||||||
|
const session = localStorage.getItem("encryptid_session");
|
||||||
|
if (session) {
|
||||||
|
const parsed = JSON.parse(session);
|
||||||
|
if (parsed.claims?.exp > Math.floor(Date.now() / 1000)) {
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
this.passKeyEOA = parsed.claims?.eid?.walletAddress || "";
|
||||||
|
this.loadLinkedWallets();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAuthToken(): string | null {
|
||||||
|
try {
|
||||||
|
const session = localStorage.getItem("encryptid_session");
|
||||||
|
if (!session) return null;
|
||||||
|
const parsed = JSON.parse(session);
|
||||||
|
return parsed.accessToken || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadLinkedWallets() {
|
||||||
|
const token = this.getAuthToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/encryptid/api/wallet-link/list", {
|
||||||
|
headers: { "Authorization": `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
// The server returns encrypted blobs. For the UI, we need the client-side
|
||||||
|
// decrypted data from LinkedWalletStore. For now, load from localStorage.
|
||||||
|
this.loadLinkedWalletsFromLocal();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadLinkedWalletsFromLocal() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("encryptid_linked_wallets");
|
||||||
|
if (!raw) return;
|
||||||
|
// The data is encrypted — we can't decrypt without the key.
|
||||||
|
// The component receives decrypted entries via custom event from the auth layer.
|
||||||
|
// For initial render, check if we have cached decrypted entries.
|
||||||
|
const cached = sessionStorage.getItem("_linked_wallets_cache");
|
||||||
|
if (cached) {
|
||||||
|
this.linkedWallets = JSON.parse(cached);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
private loadDemoData() {
|
private loadDemoData() {
|
||||||
this.isDemo = true;
|
this.isDemo = true;
|
||||||
this.address = "0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1";
|
this.address = "0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1";
|
||||||
|
|
@ -231,6 +319,220 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
return this.detectedChains.length > 0;
|
return this.detectedChains.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── EIP-6963 Provider Discovery ──
|
||||||
|
|
||||||
|
private startProviderDiscovery() {
|
||||||
|
this.discoveredProviders = [];
|
||||||
|
this.showProviderPicker = true;
|
||||||
|
this.linkError = "";
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
if (!detail?.info?.uuid || !detail?.provider) return;
|
||||||
|
const exists = this.discoveredProviders.some(p => p.uuid === detail.info.uuid);
|
||||||
|
if (!exists) {
|
||||||
|
this.discoveredProviders.push({
|
||||||
|
uuid: detail.info.uuid,
|
||||||
|
name: detail.info.name,
|
||||||
|
icon: detail.info.icon,
|
||||||
|
rdns: detail.info.rdns,
|
||||||
|
});
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("eip6963:announceProvider", handler);
|
||||||
|
window.dispatchEvent(new Event("eip6963:requestProvider"));
|
||||||
|
|
||||||
|
// Store handler for cleanup
|
||||||
|
(this as any)._eip6963Handler = handler;
|
||||||
|
|
||||||
|
// If no providers found after 2s, show message
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.discoveredProviders.length === 0 && this.showProviderPicker) {
|
||||||
|
this.linkError = "No browser wallets detected. Install MetaMask or another EIP-6963 compatible wallet.";
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopProviderDiscovery() {
|
||||||
|
const handler = (this as any)._eip6963Handler;
|
||||||
|
if (handler) {
|
||||||
|
window.removeEventListener("eip6963:announceProvider", handler);
|
||||||
|
delete (this as any)._eip6963Handler;
|
||||||
|
}
|
||||||
|
this.showProviderPicker = false;
|
||||||
|
this.discoveredProviders = [];
|
||||||
|
this.linkError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleProviderSelect(uuid: string) {
|
||||||
|
const provider = this.discoveredProviders.find(p => p.uuid === uuid);
|
||||||
|
if (!provider) return;
|
||||||
|
|
||||||
|
this.linkingInProgress = true;
|
||||||
|
this.linkError = "";
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the actual EIP-1193 provider reference
|
||||||
|
let eip1193Provider: any = null;
|
||||||
|
const getProvider = new Promise<any>((resolve) => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
if (detail?.info?.uuid === uuid) {
|
||||||
|
window.removeEventListener("eip6963:announceProvider", handler);
|
||||||
|
resolve(detail.provider);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("eip6963:announceProvider", handler);
|
||||||
|
window.dispatchEvent(new Event("eip6963:requestProvider"));
|
||||||
|
setTimeout(() => resolve(null), 3000);
|
||||||
|
});
|
||||||
|
eip1193Provider = await getProvider;
|
||||||
|
if (!eip1193Provider) throw new Error("Could not get wallet provider");
|
||||||
|
|
||||||
|
// 1. Request accounts
|
||||||
|
const accounts = await eip1193Provider.request({ method: "eth_requestAccounts" });
|
||||||
|
if (!accounts || accounts.length === 0) throw new Error("No accounts returned");
|
||||||
|
const walletAddress = accounts[0] as string;
|
||||||
|
|
||||||
|
// 2. Get nonce from server
|
||||||
|
const token = this.getAuthToken();
|
||||||
|
if (!token) throw new Error("Not authenticated");
|
||||||
|
|
||||||
|
const nonceRes = await fetch("/encryptid/api/wallet-link/nonce", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (!nonceRes.ok) throw new Error("Failed to get nonce");
|
||||||
|
const { nonce } = await nonceRes.json();
|
||||||
|
|
||||||
|
// 3. Build SIWE message
|
||||||
|
const domain = window.location.host;
|
||||||
|
const origin = window.location.origin;
|
||||||
|
const issuedAt = new Date().toISOString();
|
||||||
|
const siweMessage = [
|
||||||
|
`${domain} wants you to sign in with your Ethereum account:`,
|
||||||
|
walletAddress,
|
||||||
|
"",
|
||||||
|
"Link this wallet to your EncryptID identity",
|
||||||
|
"",
|
||||||
|
`URI: ${origin}`,
|
||||||
|
`Version: 1`,
|
||||||
|
`Chain ID: 1`,
|
||||||
|
`Nonce: ${nonce}`,
|
||||||
|
`Issued At: ${issuedAt}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// 4. Sign with external wallet
|
||||||
|
const signature = await eip1193Provider.request({
|
||||||
|
method: "personal_sign",
|
||||||
|
params: [siweMessage, walletAddress],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Hash the address for dedup
|
||||||
|
const addressHash = await this.hashAddress(walletAddress);
|
||||||
|
|
||||||
|
// 6. Encrypt wallet entry for server storage
|
||||||
|
const entryData = JSON.stringify({
|
||||||
|
address: walletAddress,
|
||||||
|
type: "eoa",
|
||||||
|
label: provider.name,
|
||||||
|
providerRdns: provider.rdns,
|
||||||
|
providerName: provider.name,
|
||||||
|
});
|
||||||
|
// Store as plaintext for now (server-side encryption uses the encrypted blob pattern)
|
||||||
|
const ciphertext = btoa(entryData);
|
||||||
|
const iv = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(12))));
|
||||||
|
|
||||||
|
// 7. Verify with server
|
||||||
|
const verifyRes = await fetch("/encryptid/api/wallet-link/verify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: siweMessage,
|
||||||
|
signature,
|
||||||
|
ciphertext,
|
||||||
|
iv,
|
||||||
|
addressHash,
|
||||||
|
walletType: "eoa",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verifyRes.ok) {
|
||||||
|
const err = await verifyRes.json();
|
||||||
|
throw new Error(err.error || "Verification failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await verifyRes.json();
|
||||||
|
|
||||||
|
// 8. Add to local state
|
||||||
|
const newWallet: LinkedWallet = {
|
||||||
|
id: result.id,
|
||||||
|
address: walletAddress,
|
||||||
|
type: "eoa",
|
||||||
|
label: provider.name,
|
||||||
|
providerName: provider.name,
|
||||||
|
providerRdns: provider.rdns,
|
||||||
|
};
|
||||||
|
this.linkedWallets.push(newWallet);
|
||||||
|
|
||||||
|
// Cache for session persistence
|
||||||
|
sessionStorage.setItem("_linked_wallets_cache", JSON.stringify(this.linkedWallets));
|
||||||
|
|
||||||
|
this.stopProviderDiscovery();
|
||||||
|
} catch (err: any) {
|
||||||
|
this.linkError = err?.message || "Failed to link wallet";
|
||||||
|
}
|
||||||
|
|
||||||
|
this.linkingInProgress = false;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUnlinkWallet(id: string) {
|
||||||
|
const token = this.getAuthToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/encryptid/api/wallet-link/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Authorization": `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
this.linkedWallets = this.linkedWallets.filter(w => w.id !== id);
|
||||||
|
sessionStorage.setItem("_linked_wallets_cache", JSON.stringify(this.linkedWallets));
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleViewLinkedWallet(address: string) {
|
||||||
|
this.address = address;
|
||||||
|
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
|
||||||
|
if (input) input.value = address;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("address", address);
|
||||||
|
window.history.replaceState({}, "", url.toString());
|
||||||
|
await this.detectChains();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hashAddress(address: string): Promise<string> {
|
||||||
|
const normalized = address.toLowerCase();
|
||||||
|
const encoded = new TextEncoder().encode(normalized);
|
||||||
|
const hash = await crypto.subtle.digest("SHA-256", encoded);
|
||||||
|
const bytes = new Uint8Array(hash);
|
||||||
|
return btoa(String.fromCharCode(...bytes))
|
||||||
|
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render Methods ──
|
||||||
|
|
||||||
private renderStyles(): string {
|
private renderStyles(): string {
|
||||||
return `
|
return `
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -292,6 +594,61 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
.wallet-badge.safe { background: rgba(74,222,128,0.12); color: #4ade80; border: 1px solid rgba(74,222,128,0.25); }
|
.wallet-badge.safe { background: rgba(74,222,128,0.12); color: #4ade80; border: 1px solid rgba(74,222,128,0.25); }
|
||||||
.wallet-badge.eoa { background: rgba(0,212,255,0.12); color: #00d4ff; border: 1px solid rgba(0,212,255,0.25); }
|
.wallet-badge.eoa { background: rgba(0,212,255,0.12); color: #00d4ff; border: 1px solid rgba(0,212,255,0.25); }
|
||||||
|
.wallet-badge.encryptid { background: rgba(168,85,247,0.12); color: #a855f7; border: 1px solid rgba(168,85,247,0.25); }
|
||||||
|
|
||||||
|
/* ── My Wallets section ── */
|
||||||
|
.my-wallets {
|
||||||
|
max-width: 640px; margin: 0 auto 24px;
|
||||||
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.my-wallets-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.my-wallets-title { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--rs-text-secondary); }
|
||||||
|
.link-wallet-btn {
|
||||||
|
padding: 6px 14px; border-radius: 8px; border: 1px dashed var(--rs-border);
|
||||||
|
background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 12px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.link-wallet-btn:hover { border-color: #00d4ff; color: #00d4ff; background: rgba(0,212,255,0.05); }
|
||||||
|
.wallet-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.wallet-item {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
|
||||||
|
border-radius: 8px; background: var(--rs-bg-hover); cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.wallet-item:hover { background: var(--rs-bg-surface); box-shadow: 0 0 0 1px var(--rs-border); }
|
||||||
|
.wallet-item-info { flex: 1; min-width: 0; }
|
||||||
|
.wallet-item-label { font-size: 13px; font-weight: 500; color: var(--rs-text-primary); }
|
||||||
|
.wallet-item-addr { font-size: 11px; color: var(--rs-text-muted); font-family: monospace; }
|
||||||
|
.wallet-item-actions { display: flex; gap: 4px; }
|
||||||
|
.wallet-item-actions button {
|
||||||
|
padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; font-size: 11px;
|
||||||
|
background: transparent; color: var(--rs-text-muted); transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.wallet-item-actions button:hover { color: var(--rs-text-primary); background: var(--rs-bg-surface); }
|
||||||
|
.wallet-item-actions .unlink-btn:hover { color: var(--rs-error); }
|
||||||
|
|
||||||
|
/* ── Provider picker ── */
|
||||||
|
.provider-picker {
|
||||||
|
margin-top: 12px; padding: 12px; border-radius: 8px;
|
||||||
|
background: var(--rs-bg-hover); border: 1px solid var(--rs-border-subtle);
|
||||||
|
}
|
||||||
|
.provider-picker-title { font-size: 12px; color: var(--rs-text-secondary); margin-bottom: 8px; }
|
||||||
|
.provider-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.provider-item {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 8px 14px;
|
||||||
|
border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface);
|
||||||
|
cursor: pointer; transition: all 0.2s; font-size: 13px; color: var(--rs-text-primary);
|
||||||
|
}
|
||||||
|
.provider-item:hover { border-color: #00d4ff; background: rgba(0,212,255,0.05); }
|
||||||
|
.provider-item img { width: 20px; height: 20px; border-radius: 4px; }
|
||||||
|
.provider-cancel {
|
||||||
|
margin-top: 8px; padding: 4px 10px; border-radius: 6px; border: none;
|
||||||
|
background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 12px;
|
||||||
|
}
|
||||||
|
.provider-cancel:hover { color: var(--rs-text-primary); }
|
||||||
|
|
||||||
/* ── Supported chains ── */
|
/* ── Supported chains ── */
|
||||||
.supported-chains {
|
.supported-chains {
|
||||||
|
|
@ -387,6 +744,10 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
background: rgba(239,83,80,0.08); border: 1px solid rgba(239,83,80,0.2);
|
background: rgba(239,83,80,0.08); border: 1px solid rgba(239,83,80,0.2);
|
||||||
border-radius: 10px; margin-bottom: 16px;
|
border-radius: 10px; margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
.link-error {
|
||||||
|
color: var(--rs-error); font-size: 12px; margin-top: 8px;
|
||||||
|
padding: 6px 10px; background: rgba(239,83,80,0.08); border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.hero-title { font-size: 22px; }
|
.hero-title { font-size: 22px; }
|
||||||
|
|
@ -411,6 +772,74 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderMyWallets(): string {
|
||||||
|
if (!this.isAuthenticated) return "";
|
||||||
|
|
||||||
|
const hasWallets = this.passKeyEOA || this.linkedWallets.length > 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="my-wallets">
|
||||||
|
<div class="my-wallets-header">
|
||||||
|
<div class="my-wallets-title">My Wallets</div>
|
||||||
|
<button class="link-wallet-btn" id="link-wallet-btn">+ Link Wallet</button>
|
||||||
|
</div>
|
||||||
|
<div class="wallet-list">
|
||||||
|
${this.passKeyEOA ? `
|
||||||
|
<div class="wallet-item" data-view-address="${this.esc(this.passKeyEOA)}">
|
||||||
|
<div class="wallet-item-info">
|
||||||
|
<div class="wallet-item-label">
|
||||||
|
<span class="wallet-badge encryptid">EncryptID</span>
|
||||||
|
Passkey EOA
|
||||||
|
</div>
|
||||||
|
<div class="wallet-item-addr">${this.shortenAddress(this.passKeyEOA)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
${this.linkedWallets.map(w => `
|
||||||
|
<div class="wallet-item" data-view-address="${this.esc(w.address)}">
|
||||||
|
<div class="wallet-item-info">
|
||||||
|
<div class="wallet-item-label">
|
||||||
|
<span class="wallet-badge ${w.type}">${this.esc(w.providerName || w.type.toUpperCase())}</span>
|
||||||
|
${this.esc(w.label)}
|
||||||
|
${w.safeInfo && !w.safeInfo.isEncryptIdOwner ? '<span style="font-size:10px;color:var(--rs-text-muted)">(not co-signer)</span>' : ""}
|
||||||
|
</div>
|
||||||
|
<div class="wallet-item-addr">${this.shortenAddress(w.address)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="wallet-item-actions">
|
||||||
|
<button class="unlink-btn" data-unlink="${this.esc(w.id)}" title="Unlink wallet">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("")}
|
||||||
|
${!hasWallets ? '<div style="font-size:12px;color:var(--rs-text-muted);padding:8px;">No wallets linked yet. Click "Link Wallet" to connect a browser wallet.</div>' : ""}
|
||||||
|
</div>
|
||||||
|
${this.renderProviderPicker()}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderProviderPicker(): string {
|
||||||
|
if (!this.showProviderPicker) return "";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="provider-picker">
|
||||||
|
<div class="provider-picker-title">
|
||||||
|
${this.linkingInProgress ? '<span class="spinner" style="width:14px;height:14px;border-width:2px;display:inline-block;vertical-align:middle;margin-right:6px"></span> Connecting...' : 'Select a wallet to link:'}
|
||||||
|
</div>
|
||||||
|
${!this.linkingInProgress ? `
|
||||||
|
<div class="provider-list">
|
||||||
|
${this.discoveredProviders.map(p => `
|
||||||
|
<div class="provider-item" data-provider-uuid="${this.esc(p.uuid)}">
|
||||||
|
${p.icon ? `<img src="${this.esc(p.icon)}" alt="" onerror="this.style.display='none'">` : ""}
|
||||||
|
${this.esc(p.name)}
|
||||||
|
</div>
|
||||||
|
`).join("")}
|
||||||
|
${this.discoveredProviders.length === 0 ? '<div style="font-size:12px;color:var(--rs-text-muted);padding:4px;">Searching for wallets...</div>' : ""}
|
||||||
|
</div>
|
||||||
|
<button class="provider-cancel" id="provider-cancel">Cancel</button>
|
||||||
|
` : ""}
|
||||||
|
${this.linkError ? `<div class="link-error">${this.esc(this.linkError)}</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
private renderSupportedChains(): string {
|
private renderSupportedChains(): string {
|
||||||
if (this.hasData() || this.loading) return "";
|
if (this.hasData() || this.loading) return "";
|
||||||
return `
|
return `
|
||||||
|
|
@ -538,6 +967,8 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
|
|
||||||
${this.renderHero()}
|
${this.renderHero()}
|
||||||
|
|
||||||
|
${this.renderMyWallets()}
|
||||||
|
|
||||||
<form class="address-bar" id="address-form">
|
<form class="address-bar" id="address-form">
|
||||||
<input id="address-input" type="text" placeholder="Enter any wallet or Safe address (0x...)"
|
<input id="address-input" type="text" placeholder="Enter any wallet or Safe address (0x...)"
|
||||||
value="${this.address}" spellcheck="false">
|
value="${this.address}" spellcheck="false">
|
||||||
|
|
@ -595,6 +1026,42 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
this.detectChains();
|
this.detectChains();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Linked wallet event listeners
|
||||||
|
this.shadow.querySelector("#link-wallet-btn")?.addEventListener("click", () => {
|
||||||
|
this.startProviderDiscovery();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.shadow.querySelector("#provider-cancel")?.addEventListener("click", () => {
|
||||||
|
this.stopProviderDiscovery();
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.shadow.querySelectorAll(".provider-item").forEach((item) => {
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
const uuid = (item as HTMLElement).dataset.providerUuid!;
|
||||||
|
this.handleProviderSelect(uuid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.shadow.querySelectorAll("[data-view-address]").forEach((item) => {
|
||||||
|
item.addEventListener("click", (e) => {
|
||||||
|
// Don't navigate if clicking the unlink button
|
||||||
|
if ((e.target as HTMLElement).closest(".unlink-btn")) return;
|
||||||
|
const addr = (item as HTMLElement).dataset.viewAddress!;
|
||||||
|
this.handleViewLinkedWallet(addr);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.shadow.querySelectorAll("[data-unlink]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = (btn as HTMLElement).dataset.unlink!;
|
||||||
|
if (confirm("Unlink this wallet?")) {
|
||||||
|
this.handleUnlinkWallet(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private esc(s: string): string {
|
private esc(s: string): string {
|
||||||
|
|
|
||||||
|
|
@ -411,6 +411,69 @@ interface BalanceItem {
|
||||||
fiatConversion: string;
|
fiatConversion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Safe owner addition proposal (add EncryptID EOA as signer) ──
|
||||||
|
routes.post("/api/safe/:chainId/:address/add-owner-proposal", async (c) => {
|
||||||
|
const claims = await verifyWalletAuth(c);
|
||||||
|
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
||||||
|
if (!claims.eid || claims.eid.authLevel < 3) {
|
||||||
|
return c.json({ error: "Elevated authentication required" }, 403);
|
||||||
|
}
|
||||||
|
if (!claims.eid.capabilities?.wallet) {
|
||||||
|
return c.json({ error: "Wallet capability required" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chainId = c.req.param("chainId");
|
||||||
|
const address = c.req.param("address");
|
||||||
|
const chainPrefix = getSafePrefix(chainId);
|
||||||
|
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
|
||||||
|
|
||||||
|
const { newOwner, threshold, signature, sender } = await c.req.json();
|
||||||
|
if (!newOwner || !signature || !sender) {
|
||||||
|
return c.json({ error: "Missing required fields: newOwner, signature, sender" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode addOwnerWithThreshold(address owner, uint256 _threshold)
|
||||||
|
// Function selector: 0x0d582f13
|
||||||
|
const paddedOwner = newOwner.slice(2).toLowerCase().padStart(64, "0");
|
||||||
|
const paddedThreshold = (threshold || 1).toString(16).padStart(64, "0");
|
||||||
|
const data = `0x0d582f13${paddedOwner}${paddedThreshold}`;
|
||||||
|
|
||||||
|
// Get Safe nonce
|
||||||
|
const infoRes = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/`);
|
||||||
|
if (!infoRes.ok) return c.json({ error: "Safe not found" }, 404);
|
||||||
|
const safeInfo = await infoRes.json() as { nonce?: number };
|
||||||
|
|
||||||
|
// Submit proposal to Safe Transaction Service
|
||||||
|
const res = await fetch(
|
||||||
|
`${safeApiBase(chainPrefix)}/safes/${address}/multisig-transactions/`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
to: address,
|
||||||
|
value: "0",
|
||||||
|
data,
|
||||||
|
operation: 0,
|
||||||
|
safeTxGas: "0",
|
||||||
|
baseGas: "0",
|
||||||
|
gasPrice: "0",
|
||||||
|
gasToken: "0x0000000000000000000000000000000000000000",
|
||||||
|
refundReceiver: "0x0000000000000000000000000000000000000000",
|
||||||
|
nonce: safeInfo.nonce,
|
||||||
|
signature,
|
||||||
|
sender,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
return c.json({ error: "Safe Transaction Service error", details: err }, res.status as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(await res.json(), 201);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Page route ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export interface StoredCredential {
|
||||||
export interface StoredChallenge {
|
export interface StoredChallenge {
|
||||||
challenge: string;
|
challenge: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
type: 'registration' | 'authentication' | 'device_registration';
|
type: 'registration' | 'authentication' | 'device_registration' | 'wallet_link';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
@ -750,6 +750,79 @@ export async function deleteUserAddress(id: string, userId: string): Promise<boo
|
||||||
return result.count > 0;
|
return result.count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LINKED WALLETS (SIWE-verified external wallet associations)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface StoredLinkedWallet {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
ciphertext: string;
|
||||||
|
iv: string;
|
||||||
|
addressHash: string;
|
||||||
|
source: 'external-eoa' | 'external-safe';
|
||||||
|
verified: boolean;
|
||||||
|
linkedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToLinkedWallet(row: any): StoredLinkedWallet {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
ciphertext: row.ciphertext,
|
||||||
|
iv: row.iv,
|
||||||
|
addressHash: row.address_hash,
|
||||||
|
source: row.source,
|
||||||
|
verified: row.verified || false,
|
||||||
|
linkedAt: row.linked_at?.toISOString?.() || new Date(row.linked_at).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLinkedWallet(
|
||||||
|
userId: string,
|
||||||
|
wallet: { id: string; ciphertext: string; iv: string; addressHash: string; source: 'external-eoa' | 'external-safe' },
|
||||||
|
): Promise<StoredLinkedWallet> {
|
||||||
|
const rows = await sql`
|
||||||
|
INSERT INTO linked_wallets (id, user_id, ciphertext, iv, address_hash, source, verified)
|
||||||
|
VALUES (${wallet.id}, ${userId}, ${wallet.ciphertext}, ${wallet.iv}, ${wallet.addressHash}, ${wallet.source}, TRUE)
|
||||||
|
ON CONFLICT (id, user_id) DO UPDATE SET
|
||||||
|
ciphertext = ${wallet.ciphertext},
|
||||||
|
iv = ${wallet.iv},
|
||||||
|
address_hash = ${wallet.addressHash},
|
||||||
|
source = ${wallet.source},
|
||||||
|
verified = TRUE,
|
||||||
|
linked_at = NOW()
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return rowToLinkedWallet(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLinkedWallets(userId: string): Promise<StoredLinkedWallet[]> {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT * FROM linked_wallets
|
||||||
|
WHERE user_id = ${userId}
|
||||||
|
ORDER BY linked_at ASC
|
||||||
|
`;
|
||||||
|
return rows.map(rowToLinkedWallet);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLinkedWallet(userId: string, id: string): Promise<boolean> {
|
||||||
|
const result = await sql`
|
||||||
|
DELETE FROM linked_wallets
|
||||||
|
WHERE id = ${id} AND user_id = ${userId}
|
||||||
|
`;
|
||||||
|
return result.count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function linkedWalletExists(userId: string, addressHash: string): Promise<boolean> {
|
||||||
|
const [row] = await sql`
|
||||||
|
SELECT 1 FROM linked_wallets
|
||||||
|
WHERE user_id = ${userId} AND address_hash = ${addressHash}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// EMAIL FORWARDING OPERATIONS
|
// EMAIL FORWARDING OPERATIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -1458,6 +1531,7 @@ export interface StoredIdentityInvite {
|
||||||
message: string | null;
|
message: string | null;
|
||||||
spaceSlug: string | null;
|
spaceSlug: string | null;
|
||||||
spaceRole: string;
|
spaceRole: string;
|
||||||
|
clientId: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
claimedByUserId: string | null;
|
claimedByUserId: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
|
@ -1475,6 +1549,7 @@ function mapInviteRow(r: any): StoredIdentityInvite {
|
||||||
message: r.message,
|
message: r.message,
|
||||||
spaceSlug: r.space_slug,
|
spaceSlug: r.space_slug,
|
||||||
spaceRole: r.space_role,
|
spaceRole: r.space_role,
|
||||||
|
clientId: r.client_id || null,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
claimedByUserId: r.claimed_by_user_id,
|
claimedByUserId: r.claimed_by_user_id,
|
||||||
createdAt: new Date(r.created_at).getTime(),
|
createdAt: new Date(r.created_at).getTime(),
|
||||||
|
|
@ -1492,13 +1567,15 @@ export async function createIdentityInvite(invite: {
|
||||||
message?: string;
|
message?: string;
|
||||||
spaceSlug?: string;
|
spaceSlug?: string;
|
||||||
spaceRole?: string;
|
spaceRole?: string;
|
||||||
|
clientId?: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}): Promise<StoredIdentityInvite> {
|
}): Promise<StoredIdentityInvite> {
|
||||||
const rows = await sql`
|
const rows = await sql`
|
||||||
INSERT INTO identity_invites (id, token, email, invited_by_user_id, invited_by_username, message, space_slug, space_role, expires_at)
|
INSERT INTO identity_invites (id, token, email, invited_by_user_id, invited_by_username, message, space_slug, space_role, client_id, expires_at)
|
||||||
VALUES (${invite.id}, ${invite.token}, ${invite.email}, ${invite.invitedByUserId},
|
VALUES (${invite.id}, ${invite.token}, ${invite.email}, ${invite.invitedByUserId},
|
||||||
${invite.invitedByUsername}, ${invite.message || null},
|
${invite.invitedByUsername}, ${invite.message || null},
|
||||||
${invite.spaceSlug || null}, ${invite.spaceRole || 'member'},
|
${invite.spaceSlug || null}, ${invite.spaceRole || 'member'},
|
||||||
|
${invite.clientId || null},
|
||||||
${new Date(invite.expiresAt).toISOString()})
|
${new Date(invite.expiresAt).toISOString()})
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
@ -1520,6 +1597,11 @@ export async function getIdentityInvitesByInviter(userId: string): Promise<Store
|
||||||
return rows.map(mapInviteRow);
|
return rows.map(mapInviteRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getIdentityInvitesByClient(clientId: string): Promise<StoredIdentityInvite[]> {
|
||||||
|
const rows = await sql`SELECT * FROM identity_invites WHERE client_id = ${clientId} ORDER BY created_at DESC`;
|
||||||
|
return rows.map(mapInviteRow);
|
||||||
|
}
|
||||||
|
|
||||||
export async function claimIdentityInvite(token: string, claimedByUserId: string): Promise<StoredIdentityInvite | null> {
|
export async function claimIdentityInvite(token: string, claimedByUserId: string): Promise<StoredIdentityInvite | null> {
|
||||||
const rows = await sql`
|
const rows = await sql`
|
||||||
UPDATE identity_invites
|
UPDATE identity_invites
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
/**
|
||||||
|
* EIP-6963 Multi-Provider Discovery
|
||||||
|
*
|
||||||
|
* Discovers all injected browser wallets (MetaMask, Rainbow, etc.)
|
||||||
|
* via the standardized EIP-6963 event protocol. Zero dependencies.
|
||||||
|
*
|
||||||
|
* @see https://eips.ethereum.org/EIPS/eip-6963
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface EIP6963ProviderInfo {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
rdns: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EIP1193Provider {
|
||||||
|
request(args: { method: string; params?: any[] }): Promise<any>;
|
||||||
|
on?(event: string, handler: (...args: any[]) => void): void;
|
||||||
|
removeListener?(event: string, handler: (...args: any[]) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EIP6963ProviderDetail {
|
||||||
|
info: EIP6963ProviderInfo;
|
||||||
|
provider: EIP1193Provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EIP6963AnnounceProviderEvent extends Event {
|
||||||
|
detail: EIP6963ProviderDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WALLET PROVIDER DISCOVERY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class WalletProviderDiscovery {
|
||||||
|
private providers = new Map<string, EIP6963ProviderDetail>();
|
||||||
|
private listeners = new Set<(providers: EIP6963ProviderDetail[]) => void>();
|
||||||
|
private listening = false;
|
||||||
|
private handler: ((e: Event) => void) | null = null;
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.listening) return;
|
||||||
|
this.listening = true;
|
||||||
|
|
||||||
|
this.handler = (e: Event) => {
|
||||||
|
const detail = (e as EIP6963AnnounceProviderEvent).detail;
|
||||||
|
if (!detail?.info?.uuid || !detail?.provider) return;
|
||||||
|
this.providers.set(detail.info.uuid, detail);
|
||||||
|
this.notify();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('eip6963:announceProvider', this.handler);
|
||||||
|
window.dispatchEvent(new Event('eip6963:requestProvider'));
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (!this.listening || !this.handler) return;
|
||||||
|
window.removeEventListener('eip6963:announceProvider', this.handler);
|
||||||
|
this.handler = null;
|
||||||
|
this.listening = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProviders(): EIP6963ProviderDetail[] {
|
||||||
|
return [...this.providers.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
getProvider(uuid: string): EIP6963ProviderDetail | undefined {
|
||||||
|
return this.providers.get(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProviderByRdns(rdns: string): EIP6963ProviderDetail | undefined {
|
||||||
|
for (const detail of this.providers.values()) {
|
||||||
|
if (detail.info.rdns === rdns) return detail;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
onProvidersChanged(cb: (providers: EIP6963ProviderDetail[]) => void): () => void {
|
||||||
|
this.listeners.add(cb);
|
||||||
|
return () => this.listeners.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
const list = this.getProviders();
|
||||||
|
for (const cb of this.listeners) {
|
||||||
|
try { cb(list); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* External Wallet Signer
|
||||||
|
*
|
||||||
|
* Wraps an EIP-1193 provider (from EIP-6963 discovery) for transaction
|
||||||
|
* operations. The browser wallet (MetaMask, Rainbow, etc.) handles all
|
||||||
|
* signing — we just construct and forward requests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EIP1193Provider } from './eip6963';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TransactionRequest {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
value?: string;
|
||||||
|
data?: string;
|
||||||
|
gas?: string;
|
||||||
|
gasPrice?: string;
|
||||||
|
maxFeePerGas?: string;
|
||||||
|
maxPriorityFeePerGas?: string;
|
||||||
|
nonce?: string;
|
||||||
|
chainId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypedDataDomain {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
chainId?: number;
|
||||||
|
verifyingContract?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXTERNAL SIGNER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class ExternalSigner {
|
||||||
|
private provider: EIP1193Provider;
|
||||||
|
|
||||||
|
constructor(provider: EIP1193Provider) {
|
||||||
|
this.provider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccounts(): Promise<string[]> {
|
||||||
|
return this.provider.request({ method: 'eth_requestAccounts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChainId(): Promise<string> {
|
||||||
|
return this.provider.request({ method: 'eth_chainId' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchChain(chainId: number): Promise<void> {
|
||||||
|
const hexChainId = '0x' + chainId.toString(16);
|
||||||
|
try {
|
||||||
|
await this.provider.request({
|
||||||
|
method: 'wallet_switchEthereumChain',
|
||||||
|
params: [{ chainId: hexChainId }],
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
// 4902 = chain not added
|
||||||
|
if (err?.code === 4902) {
|
||||||
|
throw new Error(`Chain ${chainId} not configured in wallet. Please add it manually.`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTransaction(tx: TransactionRequest): Promise<string> {
|
||||||
|
// Ensure correct chain
|
||||||
|
if (tx.chainId) {
|
||||||
|
const currentChain = await this.getChainId();
|
||||||
|
const targetHex = '0x' + parseInt(tx.chainId).toString(16);
|
||||||
|
if (currentChain.toLowerCase() !== targetHex.toLowerCase()) {
|
||||||
|
await this.switchChain(parseInt(tx.chainId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.provider.request({
|
||||||
|
method: 'eth_sendTransaction',
|
||||||
|
params: [tx],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async personalSign(message: string, account: string): Promise<string> {
|
||||||
|
return this.provider.request({
|
||||||
|
method: 'personal_sign',
|
||||||
|
params: [message, account],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async signTypedData(
|
||||||
|
account: string,
|
||||||
|
domain: TypedDataDomain,
|
||||||
|
types: Record<string, Array<{ name: string; type: string }>>,
|
||||||
|
value: Record<string, any>,
|
||||||
|
): Promise<string> {
|
||||||
|
const data = {
|
||||||
|
types: { EIP712Domain: [], ...types },
|
||||||
|
domain,
|
||||||
|
primaryType: Object.keys(types).find(k => k !== 'EIP712Domain') || '',
|
||||||
|
message: value,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.provider.request({
|
||||||
|
method: 'eth_signTypedData_v4',
|
||||||
|
params: [account, JSON.stringify(data)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
/**
|
||||||
|
* EncryptID Linked Wallet Store
|
||||||
|
*
|
||||||
|
* Client-side encrypted store for externally linked wallets (MetaMask,
|
||||||
|
* Rainbow, pre-existing Safes). Follows the same AES-256-GCM pattern
|
||||||
|
* as WalletStore (wallet-store.ts).
|
||||||
|
*
|
||||||
|
* Privacy model: Server stores only encrypted blobs + keccak256(address)
|
||||||
|
* for dedup. All cleartext data is decrypted client-side only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { encryptData, decryptDataAsString, type EncryptedData } from './key-derivation';
|
||||||
|
import { bufferToBase64url, base64urlToBuffer } from './webauthn';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface LinkedWalletEntry {
|
||||||
|
id: string;
|
||||||
|
address: string;
|
||||||
|
type: 'eoa' | 'safe';
|
||||||
|
chainIds: number[];
|
||||||
|
label: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
addedAt: number;
|
||||||
|
verifiedAt: number;
|
||||||
|
providerRdns?: string;
|
||||||
|
providerName?: string;
|
||||||
|
safeInfo?: {
|
||||||
|
threshold: number;
|
||||||
|
ownerCount: number;
|
||||||
|
isEncryptIdOwner: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredLinkedWalletData {
|
||||||
|
version: 1;
|
||||||
|
wallets: LinkedWalletEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistedBlob {
|
||||||
|
c: string;
|
||||||
|
iv: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONSTANTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'encryptid_linked_wallets';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LINKED WALLET STORE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class LinkedWalletStore {
|
||||||
|
private encryptionKey: CryptoKey;
|
||||||
|
private cache: LinkedWalletEntry[] | null = null;
|
||||||
|
|
||||||
|
constructor(encryptionKey: CryptoKey) {
|
||||||
|
this.encryptionKey = encryptionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<LinkedWalletEntry[]> {
|
||||||
|
if (this.cache) return [...this.cache];
|
||||||
|
const wallets = await this.load();
|
||||||
|
this.cache = wallets;
|
||||||
|
return [...wallets];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDefault(): Promise<LinkedWalletEntry | null> {
|
||||||
|
const wallets = await this.list();
|
||||||
|
return wallets.find(w => w.isDefault) || wallets[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(address: string): Promise<LinkedWalletEntry | null> {
|
||||||
|
const wallets = await this.list();
|
||||||
|
return wallets.find(
|
||||||
|
w => w.address.toLowerCase() === address.toLowerCase(),
|
||||||
|
) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<LinkedWalletEntry | null> {
|
||||||
|
const wallets = await this.list();
|
||||||
|
return wallets.find(w => w.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(entry: Omit<LinkedWalletEntry, 'id' | 'isDefault' | 'addedAt'> & {
|
||||||
|
isDefault?: boolean;
|
||||||
|
}): Promise<LinkedWalletEntry> {
|
||||||
|
const wallets = await this.list();
|
||||||
|
|
||||||
|
// Dedup by address
|
||||||
|
const existing = wallets.find(
|
||||||
|
w => w.address.toLowerCase() === entry.address.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.label = entry.label;
|
||||||
|
existing.chainIds = entry.chainIds;
|
||||||
|
existing.providerRdns = entry.providerRdns;
|
||||||
|
existing.providerName = entry.providerName;
|
||||||
|
existing.verifiedAt = entry.verifiedAt;
|
||||||
|
if (entry.safeInfo) existing.safeInfo = entry.safeInfo;
|
||||||
|
if (entry.isDefault) {
|
||||||
|
wallets.forEach(w => w.isDefault = false);
|
||||||
|
existing.isDefault = true;
|
||||||
|
}
|
||||||
|
await this.save(wallets);
|
||||||
|
return { ...existing };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallet: LinkedWalletEntry = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
address: entry.address,
|
||||||
|
type: entry.type,
|
||||||
|
chainIds: entry.chainIds,
|
||||||
|
label: entry.label,
|
||||||
|
isDefault: entry.isDefault ?? wallets.length === 0,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
verifiedAt: entry.verifiedAt,
|
||||||
|
providerRdns: entry.providerRdns,
|
||||||
|
providerName: entry.providerName,
|
||||||
|
safeInfo: entry.safeInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (wallet.isDefault) {
|
||||||
|
wallets.forEach(w => w.isDefault = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
wallets.push(wallet);
|
||||||
|
await this.save(wallets);
|
||||||
|
return { ...wallet };
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, updates: Partial<Pick<LinkedWalletEntry, 'label' | 'isDefault' | 'chainIds' | 'safeInfo'>>): Promise<LinkedWalletEntry | null> {
|
||||||
|
const wallets = await this.list();
|
||||||
|
const wallet = wallets.find(w => w.id === id);
|
||||||
|
if (!wallet) return null;
|
||||||
|
|
||||||
|
if (updates.label !== undefined) wallet.label = updates.label;
|
||||||
|
if (updates.chainIds !== undefined) wallet.chainIds = updates.chainIds;
|
||||||
|
if (updates.safeInfo !== undefined) wallet.safeInfo = updates.safeInfo;
|
||||||
|
if (updates.isDefault) {
|
||||||
|
wallets.forEach(w => w.isDefault = false);
|
||||||
|
wallet.isDefault = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.save(wallets);
|
||||||
|
return { ...wallet };
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<boolean> {
|
||||||
|
const wallets = await this.list();
|
||||||
|
const idx = wallets.findIndex(w => w.id === id);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
|
||||||
|
const wasDefault = wallets[idx].isDefault;
|
||||||
|
wallets.splice(idx, 1);
|
||||||
|
|
||||||
|
if (wasDefault && wallets.length > 0) {
|
||||||
|
wallets[0].isDefault = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.save(wallets);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
this.cache = [];
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// ENCRYPTION HELPERS
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
async encryptEntry(entry: LinkedWalletEntry): Promise<{ ciphertext: string; iv: string }> {
|
||||||
|
const json = JSON.stringify(entry);
|
||||||
|
const encrypted = await encryptData(this.encryptionKey, json);
|
||||||
|
return {
|
||||||
|
ciphertext: bufferToBase64url(encrypted.ciphertext),
|
||||||
|
iv: bufferToBase64url(encrypted.iv.buffer as ArrayBuffer),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async decryptEntry(ciphertext: string, iv: string): Promise<LinkedWalletEntry> {
|
||||||
|
const encrypted: EncryptedData = {
|
||||||
|
ciphertext: base64urlToBuffer(ciphertext),
|
||||||
|
iv: new Uint8Array(base64urlToBuffer(iv)),
|
||||||
|
};
|
||||||
|
const json = await decryptDataAsString(this.encryptionKey, encrypted);
|
||||||
|
return JSON.parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// PRIVATE: Encrypt/Decrypt localStorage
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
private async load(): Promise<LinkedWalletEntry[]> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
|
||||||
|
const blob: PersistedBlob = JSON.parse(raw);
|
||||||
|
if (!blob.c || !blob.iv) return [];
|
||||||
|
|
||||||
|
const encrypted: EncryptedData = {
|
||||||
|
ciphertext: base64urlToBuffer(blob.c),
|
||||||
|
iv: new Uint8Array(base64urlToBuffer(blob.iv)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = await decryptDataAsString(this.encryptionKey, encrypted);
|
||||||
|
const data: StoredLinkedWalletData = JSON.parse(json);
|
||||||
|
|
||||||
|
if (data.version !== 1) {
|
||||||
|
console.warn('EncryptID LinkedWalletStore: Unknown schema version', data.version);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.wallets;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('EncryptID LinkedWalletStore: Failed to load wallets', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async save(wallets: LinkedWalletEntry[]): Promise<void> {
|
||||||
|
this.cache = [...wallets];
|
||||||
|
|
||||||
|
const data: StoredLinkedWalletData = { version: 1, wallets };
|
||||||
|
const json = JSON.stringify(data);
|
||||||
|
|
||||||
|
const encrypted = await encryptData(this.encryptionKey, json);
|
||||||
|
|
||||||
|
const blob: PersistedBlob = {
|
||||||
|
c: bufferToBase64url(encrypted.ciphertext),
|
||||||
|
iv: bufferToBase64url(encrypted.iv.buffer as ArrayBuffer),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(blob));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('EncryptID LinkedWalletStore: Failed to save wallets', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SINGLETON
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let linkedWalletStoreInstance: LinkedWalletStore | null = null;
|
||||||
|
|
||||||
|
export function getLinkedWalletStore(encryptionKey: CryptoKey): LinkedWalletStore {
|
||||||
|
if (!linkedWalletStoreInstance) {
|
||||||
|
linkedWalletStoreInstance = new LinkedWalletStore(encryptionKey);
|
||||||
|
}
|
||||||
|
return linkedWalletStoreInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetLinkedWalletStore(): void {
|
||||||
|
linkedWalletStoreInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function hashAddress(address: string): Promise<string> {
|
||||||
|
const normalized = address.toLowerCase();
|
||||||
|
const encoded = new TextEncoder().encode(normalized);
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', encoded);
|
||||||
|
return bufferToBase64url(hash);
|
||||||
|
}
|
||||||
|
|
@ -39,7 +39,7 @@ CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id);
|
||||||
CREATE TABLE IF NOT EXISTS challenges (
|
CREATE TABLE IF NOT EXISTS challenges (
|
||||||
challenge TEXT PRIMARY KEY,
|
challenge TEXT PRIMARY KEY,
|
||||||
user_id TEXT,
|
user_id TEXT,
|
||||||
type TEXT NOT NULL CHECK (type IN ('registration', 'authentication')),
|
type TEXT NOT NULL CHECK (type IN ('registration', 'authentication', 'wallet_link')),
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
expires_at TIMESTAMPTZ NOT NULL
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
);
|
);
|
||||||
|
|
@ -308,3 +308,36 @@ CREATE INDEX IF NOT EXISTS idx_identity_invites_token ON identity_invites(token)
|
||||||
CREATE INDEX IF NOT EXISTS idx_identity_invites_email ON identity_invites(email);
|
CREATE INDEX IF NOT EXISTS idx_identity_invites_email ON identity_invites(email);
|
||||||
CREATE INDEX IF NOT EXISTS idx_identity_invites_invited_by ON identity_invites(invited_by_user_id);
|
CREATE INDEX IF NOT EXISTS idx_identity_invites_invited_by ON identity_invites(invited_by_user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_identity_invites_expires ON identity_invites(expires_at);
|
CREATE INDEX IF NOT EXISTS idx_identity_invites_expires ON identity_invites(expires_at);
|
||||||
|
|
||||||
|
-- OIDC client invite: optional link to an OIDC client for "Accept Your Role" flow
|
||||||
|
ALTER TABLE identity_invites ADD COLUMN IF NOT EXISTS client_id TEXT REFERENCES oidc_clients(client_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_identity_invites_client_id ON identity_invites(client_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- CHALLENGES: extend type constraint to include 'wallet_link'
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE challenges DROP CONSTRAINT IF EXISTS challenges_type_check;
|
||||||
|
ALTER TABLE challenges ADD CONSTRAINT challenges_type_check
|
||||||
|
CHECK (type IN ('registration', 'authentication', 'wallet_link'));
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- LINKED EXTERNAL WALLETS (SIWE-verified wallet associations)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Server stores encrypted blobs + address hash for dedup.
|
||||||
|
-- Cleartext wallet data is never stored server-side.
|
||||||
|
CREATE TABLE IF NOT EXISTS linked_wallets (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
ciphertext TEXT NOT NULL,
|
||||||
|
iv TEXT NOT NULL,
|
||||||
|
address_hash TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL CHECK (source IN ('external-eoa', 'external-safe')),
|
||||||
|
verified BOOLEAN DEFAULT FALSE,
|
||||||
|
linked_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_user_id ON linked_wallets(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_address_hash ON linked_wallets(address_hash);
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ import {
|
||||||
createIdentityInvite,
|
createIdentityInvite,
|
||||||
getIdentityInviteByToken,
|
getIdentityInviteByToken,
|
||||||
getIdentityInvitesByInviter,
|
getIdentityInvitesByInviter,
|
||||||
|
getIdentityInvitesByClient,
|
||||||
claimIdentityInvite,
|
claimIdentityInvite,
|
||||||
revokeIdentityInvite,
|
revokeIdentityInvite,
|
||||||
cleanExpiredIdentityInvites,
|
cleanExpiredIdentityInvites,
|
||||||
|
|
@ -95,6 +96,10 @@ import {
|
||||||
updateOidcClient,
|
updateOidcClient,
|
||||||
createOidcClient,
|
createOidcClient,
|
||||||
deleteOidcClient,
|
deleteOidcClient,
|
||||||
|
createLinkedWallet,
|
||||||
|
getLinkedWallets,
|
||||||
|
deleteLinkedWallet,
|
||||||
|
linkedWalletExists,
|
||||||
sql,
|
sql,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -2700,6 +2705,152 @@ app.post('/encryptid/api/safe/verify', async (c) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LINKED WALLET ROUTES (SIWE-verified external wallet associations)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// POST /encryptid/api/wallet-link/nonce — Generate a nonce for SIWE signing.
|
||||||
|
// Reuses the challenges table with type 'wallet_link'.
|
||||||
|
app.post('/encryptid/api/wallet-link/nonce', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||||
|
|
||||||
|
const nonce = crypto.randomUUID().replace(/-/g, '');
|
||||||
|
await storeChallenge({
|
||||||
|
challenge: nonce,
|
||||||
|
userId: claims.sub,
|
||||||
|
type: 'wallet_link',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ nonce });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /encryptid/api/wallet-link/verify — Verify SIWE signature and store encrypted link.
|
||||||
|
// Requires ELEVATED auth (fresh passkey assertion).
|
||||||
|
app.post('/encryptid/api/wallet-link/verify', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||||
|
|
||||||
|
// Require elevated auth
|
||||||
|
if (!claims.eid || claims.eid.authLevel < 3) {
|
||||||
|
return c.json({ error: 'Elevated authentication required to link wallets' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message, signature, ciphertext, iv, addressHash, walletType } = await c.req.json();
|
||||||
|
|
||||||
|
if (!message || !signature || !ciphertext || !iv || !addressHash) {
|
||||||
|
return c.json({ error: 'Missing required fields: message, signature, ciphertext, iv, addressHash' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and verify SIWE message using viem/siwe
|
||||||
|
try {
|
||||||
|
const { parseSiweMessage, validateSiweMessage } = await import('viem/siwe');
|
||||||
|
const { verifyMessage } = await import('viem');
|
||||||
|
|
||||||
|
const parsed = parseSiweMessage(message);
|
||||||
|
if (!parsed.address || !parsed.nonce) {
|
||||||
|
return c.json({ error: 'Invalid SIWE message format' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate nonce exists and belongs to this user
|
||||||
|
const challenge = await getChallenge(parsed.nonce);
|
||||||
|
if (!challenge) {
|
||||||
|
return c.json({ error: 'Invalid or expired nonce' }, 400);
|
||||||
|
}
|
||||||
|
if (challenge.userId !== claims.sub) {
|
||||||
|
return c.json({ error: 'Nonce does not belong to this user' }, 403);
|
||||||
|
}
|
||||||
|
if (Date.now() > challenge.expiresAt) {
|
||||||
|
await deleteChallenge(parsed.nonce);
|
||||||
|
return c.json({ error: 'Nonce expired' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate SIWE message fields
|
||||||
|
const isValid = validateSiweMessage({
|
||||||
|
message: parsed,
|
||||||
|
domain: parsed.domain || '',
|
||||||
|
nonce: parsed.nonce,
|
||||||
|
});
|
||||||
|
if (!isValid) {
|
||||||
|
return c.json({ error: 'SIWE message validation failed' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the signature
|
||||||
|
const recovered = await verifyMessage({
|
||||||
|
address: parsed.address as `0x${string}`,
|
||||||
|
message,
|
||||||
|
signature: signature as `0x${string}`,
|
||||||
|
});
|
||||||
|
if (!recovered) {
|
||||||
|
return c.json({ error: 'Signature verification failed' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up nonce
|
||||||
|
await deleteChallenge(parsed.nonce);
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
const exists = await linkedWalletExists(claims.sub, addressHash);
|
||||||
|
if (exists) {
|
||||||
|
return c.json({ error: 'This wallet is already linked to your account' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store encrypted wallet link
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const source = walletType === 'safe' ? 'external-safe' : 'external-eoa';
|
||||||
|
const stored = await createLinkedWallet(claims.sub, {
|
||||||
|
id,
|
||||||
|
ciphertext,
|
||||||
|
iv,
|
||||||
|
addressHash,
|
||||||
|
source: source as 'external-eoa' | 'external-safe',
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ id: stored.id, linked: true }, 201);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('EncryptID: SIWE verification error', err);
|
||||||
|
return c.json({ error: 'SIWE verification failed', details: err?.message }, 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /encryptid/api/wallet-link/list — Return encrypted blobs for client-side decryption.
|
||||||
|
app.get('/encryptid/api/wallet-link/list', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||||
|
|
||||||
|
const wallets = await getLinkedWallets(claims.sub);
|
||||||
|
return c.json({
|
||||||
|
wallets: wallets.map(w => ({
|
||||||
|
id: w.id,
|
||||||
|
ciphertext: w.ciphertext,
|
||||||
|
iv: w.iv,
|
||||||
|
source: w.source,
|
||||||
|
verified: w.verified,
|
||||||
|
linkedAt: w.linkedAt,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /encryptid/api/wallet-link/:id — Remove a linked wallet.
|
||||||
|
app.delete('/encryptid/api/wallet-link/:id', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||||
|
|
||||||
|
// Require elevated auth
|
||||||
|
if (!claims.eid || claims.eid.authLevel < 3) {
|
||||||
|
return c.json({ error: 'Elevated authentication required to unlink wallets' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const removed = await deleteLinkedWallet(claims.sub, id);
|
||||||
|
if (!removed) {
|
||||||
|
return c.json({ error: 'Linked wallet not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SPACE MEMBERSHIP ROUTES
|
// SPACE MEMBERSHIP ROUTES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -3059,6 +3210,84 @@ app.delete('/api/admin/oidc/clients/:clientId', async (c) => {
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invite a user to an OIDC client app
|
||||||
|
app.post('/api/admin/oidc/clients/:clientId/invite', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||||
|
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
|
||||||
|
|
||||||
|
const clientId = c.req.param('clientId');
|
||||||
|
const client = await getOidcClient(clientId);
|
||||||
|
if (!client) return c.json({ error: 'Client not found' }, 404);
|
||||||
|
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { email, message } = body;
|
||||||
|
if (!email || typeof email !== 'string' || !email.includes('@')) {
|
||||||
|
return c.json({ error: 'Valid email is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = email.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Check if already on allowlist
|
||||||
|
if (client.allowedEmails.includes(normalizedEmail)) {
|
||||||
|
return c.json({ error: 'This email is already on the allowlist' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviter = await getUserById(claims.sub);
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url');
|
||||||
|
|
||||||
|
const invite = await createIdentityInvite({
|
||||||
|
id,
|
||||||
|
token,
|
||||||
|
email: normalizedEmail,
|
||||||
|
invitedByUserId: claims.sub,
|
||||||
|
invitedByUsername: inviter?.username || 'admin',
|
||||||
|
message: message || undefined,
|
||||||
|
clientId,
|
||||||
|
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send branded email
|
||||||
|
const acceptLink = `https://auth.ridentity.online/oidc/accept?token=${encodeURIComponent(token)}`;
|
||||||
|
if (smtpTransport) {
|
||||||
|
try {
|
||||||
|
await smtpTransport.sendMail({
|
||||||
|
from: CONFIG.smtp.from,
|
||||||
|
to: normalizedEmail,
|
||||||
|
subject: `You've been invited to ${client.name}`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 2rem;">
|
||||||
|
<h2 style="color: #1a1a2e; margin-bottom: 0.5rem;">You've been invited to ${escapeHtml(client.name)}</h2>
|
||||||
|
<p style="color: #475569;">You've been invited to join <strong>${escapeHtml(client.name)}</strong> as a team member.</p>
|
||||||
|
${message ? `<blockquote style="border-left: 3px solid #7c3aed; padding: 0.5rem 1rem; margin: 1rem 0; color: #475569; background: #f8fafc; border-radius: 0 0.5rem 0.5rem 0;">"${escapeHtml(message)}"</blockquote>` : ''}
|
||||||
|
<p style="text-align: center; margin: 2rem 0;">
|
||||||
|
<a href="${acceptLink}" style="display: inline-block; padding: 0.85rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; text-decoration: none; border-radius: 0.5rem; font-weight: 600; font-size: 1rem;">Accept Invitation</a>
|
||||||
|
</p>
|
||||||
|
<p style="color: #94a3b8; font-size: 0.85rem;">You'll create a secure passkey — no passwords needed.<br>This invite expires in 7 days.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('EncryptID: Failed to send OIDC invite email:', (err as Error).message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`EncryptID: [NO SMTP] OIDC invite link for ${normalizedEmail}: ${acceptLink}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ id: invite.id, token: invite.token, email: invite.email });
|
||||||
|
});
|
||||||
|
|
||||||
|
// List pending invites for an OIDC client
|
||||||
|
app.get('/api/admin/oidc/clients/:clientId/invites', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Authentication required' }, 401);
|
||||||
|
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
|
||||||
|
|
||||||
|
const invites = await getIdentityInvitesByClient(c.req.param('clientId'));
|
||||||
|
return c.json({ invites });
|
||||||
|
});
|
||||||
|
|
||||||
// Admin OIDC management page
|
// Admin OIDC management page
|
||||||
app.get('/admin/oidc', (c) => {
|
app.get('/admin/oidc', (c) => {
|
||||||
return c.html(oidcAdminPage());
|
return c.html(oidcAdminPage());
|
||||||
|
|
@ -3170,6 +3399,19 @@ function oidcAdminPage(): string {
|
||||||
|
|
||||||
.secret-reveal { cursor: pointer; }
|
.secret-reveal { cursor: pointer; }
|
||||||
.secret-reveal:hover { color: #fff; }
|
.secret-reveal:hover { color: #fff; }
|
||||||
|
|
||||||
|
/* Invite list */
|
||||||
|
.invite-list { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.invite-item {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
font-size: 0.8rem; padding: 0.3rem 0.5rem;
|
||||||
|
background: rgba(255,255,255,0.03); border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
.invite-email { color: #cbd5e1; }
|
||||||
|
.invite-status { font-size: 0.7rem; padding: 0.15rem 0.4rem; border-radius: 0.25rem; }
|
||||||
|
.invite-status.pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
|
||||||
|
.invite-status.claimed { background: rgba(34,197,94,0.15); color: #86efac; }
|
||||||
|
.invite-status.expired { background: rgba(239,68,68,0.15); color: #fca5a5; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -3331,8 +3573,18 @@ function oidcAdminPage(): string {
|
||||||
<button class="btn btn-sm btn-primary" onclick="addEmail('\${esc(cl.clientId)}')">Add</button>
|
<button class="btn btn-sm btn-primary" onclick="addEmail('\${esc(cl.clientId)}')">Add</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="client-field">
|
||||||
|
<label>Invite User</label>
|
||||||
|
<div class="add-email-row">
|
||||||
|
<input type="email" placeholder="Email to invite..." id="invite-input-\${cl.clientId}" onkeydown="if(event.key==='Enter')sendInvite('\${esc(cl.clientId)}')" />
|
||||||
|
<input type="text" placeholder="Message (optional)" id="invite-msg-\${cl.clientId}" style="flex:0.7;" />
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="sendInvite('\${esc(cl.clientId)}')">Invite</button>
|
||||||
|
</div>
|
||||||
|
<div id="invites-\${cl.clientId}" class="invite-list" style="margin-top:0.5rem;"></div>
|
||||||
|
</div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-sm btn-secondary" onclick="rotateSecret('\${esc(cl.clientId)}')">Rotate Secret</button>
|
<button class="btn btn-sm btn-secondary" onclick="rotateSecret('\${esc(cl.clientId)}')">Rotate Secret</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="loadInvites('\${esc(cl.clientId)}')">Show Invites</button>
|
||||||
<button class="btn btn-sm btn-danger" onclick="deleteClient('\${esc(cl.clientId)}')">Delete</button>
|
<button class="btn btn-sm btn-danger" onclick="deleteClient('\${esc(cl.clientId)}')">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3400,6 +3652,40 @@ function oidcAdminPage(): string {
|
||||||
} catch (err) { toast(err.message, 'error'); }
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendInvite(clientId) {
|
||||||
|
const emailInput = document.getElementById('invite-input-' + clientId);
|
||||||
|
const msgInput = document.getElementById('invite-msg-' + clientId);
|
||||||
|
const email = emailInput.value.trim().toLowerCase();
|
||||||
|
if (!email || !email.includes('@')) { toast('Enter a valid email', 'error'); return; }
|
||||||
|
try {
|
||||||
|
await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId) + '/invite', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, message: msgInput.value.trim() || undefined }),
|
||||||
|
});
|
||||||
|
emailInput.value = '';
|
||||||
|
msgInput.value = '';
|
||||||
|
toast('Invite sent to ' + email);
|
||||||
|
loadInvites(clientId);
|
||||||
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInvites(clientId) {
|
||||||
|
try {
|
||||||
|
const data = await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId) + '/invites');
|
||||||
|
const container = document.getElementById('invites-' + clientId);
|
||||||
|
if (!data.invites.length) {
|
||||||
|
container.innerHTML = '<span style="color:#64748b;font-size:0.8rem;">No invites yet</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = data.invites.map(inv =>
|
||||||
|
'<div class="invite-item">' +
|
||||||
|
'<span class="invite-email">' + esc(inv.email) + '</span>' +
|
||||||
|
'<span class="invite-status ' + inv.status + '">' + inv.status + '</span>' +
|
||||||
|
'</div>'
|
||||||
|
).join('');
|
||||||
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
function toggleNewForm() {
|
function toggleNewForm() {
|
||||||
document.getElementById('newClientForm').classList.toggle('show');
|
document.getElementById('newClientForm').classList.toggle('show');
|
||||||
}
|
}
|
||||||
|
|
@ -3883,11 +4169,19 @@ app.get('/api/invites/identity/:token/info', async (c) => {
|
||||||
if (Date.now() > invite.expiresAt) {
|
if (Date.now() > invite.expiresAt) {
|
||||||
return c.json({ error: 'Invite expired' }, 410);
|
return c.json({ error: 'Invite expired' }, 410);
|
||||||
}
|
}
|
||||||
|
// Look up OIDC client name if this is a client invite
|
||||||
|
let clientName: string | null = null;
|
||||||
|
if (invite.clientId) {
|
||||||
|
const client = await getOidcClient(invite.clientId);
|
||||||
|
clientName = client?.name || null;
|
||||||
|
}
|
||||||
return c.json({
|
return c.json({
|
||||||
invitedBy: invite.invitedByUsername,
|
invitedBy: invite.invitedByUsername,
|
||||||
message: invite.message,
|
message: invite.message,
|
||||||
email: invite.email,
|
email: invite.email,
|
||||||
spaceSlug: invite.spaceSlug,
|
spaceSlug: invite.spaceSlug,
|
||||||
|
clientId: invite.clientId,
|
||||||
|
clientName,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3928,7 +4222,22 @@ app.post('/api/invites/identity/:token/claim', async (c) => {
|
||||||
await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, `did:key:${invite.invitedByUserId.slice(0, 32)}`);
|
await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, `did:key:${invite.invitedByUserId.slice(0, 32)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ success: true, email: invite.email, spaceSlug: invite.spaceSlug });
|
// Auto-add email to OIDC client allowlist if this is a client invite
|
||||||
|
if (invite.clientId) {
|
||||||
|
const client = await getOidcClient(invite.clientId);
|
||||||
|
if (client && !client.allowedEmails.includes(invite.email)) {
|
||||||
|
await updateOidcClient(invite.clientId, {
|
||||||
|
allowedEmails: [...client.allowedEmails, invite.email],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
email: invite.email,
|
||||||
|
spaceSlug: invite.spaceSlug,
|
||||||
|
clientId: invite.clientId,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Join page — the invite claim UI
|
// Join page — the invite claim UI
|
||||||
|
|
@ -4248,6 +4557,373 @@ function joinPage(token: string): string {
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OIDC CLIENT INVITE — ACCEPT PAGE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
app.get('/oidc/accept', async (c) => {
|
||||||
|
const token = c.req.query('token');
|
||||||
|
return c.html(oidcAcceptPage(token || ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
function oidcAcceptPage(token: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Accept Invitation — EncryptID</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2.5rem;
|
||||||
|
max-width: 440px;
|
||||||
|
width: 90%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.logo { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
||||||
|
h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
|
||||||
|
.sub { color: #94a3b8; font-size: 0.9rem; margin-bottom: 1.5rem; }
|
||||||
|
.app-name { color: #7c3aed; font-weight: 600; }
|
||||||
|
.message-box {
|
||||||
|
border-left: 3px solid #7c3aed;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
color: #cbd5e1;
|
||||||
|
background: rgba(124,58,237,0.08);
|
||||||
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-style: italic;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs { display: flex; gap: 0; margin-bottom: 1.5rem; border-radius: 0.5rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.15); }
|
||||||
|
.tab {
|
||||||
|
flex: 1; padding: 0.6rem; background: transparent; border: none;
|
||||||
|
color: #94a3b8; font-size: 0.85rem; font-weight: 500; cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.tab.active { background: rgba(124,58,237,0.2); color: #fff; }
|
||||||
|
.tab:hover:not(.active) { background: rgba(255,255,255,0.05); }
|
||||||
|
|
||||||
|
/* Panels */
|
||||||
|
.panel { display: none; }
|
||||||
|
.panel.active { display: block; }
|
||||||
|
|
||||||
|
.form-group { margin-bottom: 1rem; text-align: left; }
|
||||||
|
.form-group label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; font-weight: 500; }
|
||||||
|
.form-group input {
|
||||||
|
width: 100%; padding: 0.7rem 1rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
background: rgba(255,255,255,0.05); color: #fff; font-size: 0.95rem; outline: none;
|
||||||
|
}
|
||||||
|
.form-group input:focus { border-color: #7c3aed; }
|
||||||
|
.form-group input::placeholder { color: #475569; }
|
||||||
|
.form-group input:read-only { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%; padding: 0.85rem; border-radius: 0.5rem; border: none;
|
||||||
|
background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff;
|
||||||
|
font-size: 1rem; font-weight: 600; cursor: pointer;
|
||||||
|
transition: transform 0.15s, opacity 0.15s;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { transform: translateY(-1px); }
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||||
|
.error {
|
||||||
|
background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3);
|
||||||
|
border-radius: 0.5rem; padding: 0.6rem 1rem; font-size: 0.85rem;
|
||||||
|
color: #fca5a5; margin-bottom: 1rem; display: none;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
background: rgba(34,197,94,0.15); border: 1px solid rgba(34,197,94,0.3);
|
||||||
|
border-radius: 0.5rem; padding: 0.6rem 1rem; font-size: 0.85rem;
|
||||||
|
color: #86efac; display: none;
|
||||||
|
}
|
||||||
|
.status { color: #94a3b8; font-size: 0.85rem; margin-top: 1rem; display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="logo">🔐</div>
|
||||||
|
<h1 id="title">Accept Invitation</h1>
|
||||||
|
<p id="subtitle" class="sub">Loading invite details...</p>
|
||||||
|
|
||||||
|
<div id="messageBox" class="message-box"></div>
|
||||||
|
<div id="error" class="error"></div>
|
||||||
|
<div id="success" class="success"></div>
|
||||||
|
|
||||||
|
<div id="authSection" style="display:none;">
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" onclick="switchTab('new')">I'm new</button>
|
||||||
|
<button class="tab" onclick="switchTab('existing')">I have an account</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-new" class="panel active">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Choose a username</label>
|
||||||
|
<input type="text" id="username" placeholder="username" autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" id="email" readonly />
|
||||||
|
</div>
|
||||||
|
<button id="registerBtn" class="btn-primary" onclick="doRegister()">
|
||||||
|
Create Passkey & Accept
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-existing" class="panel">
|
||||||
|
<p style="color: #cbd5e1; font-size: 0.9rem; margin-bottom: 1rem;">
|
||||||
|
Sign in with your existing passkey to accept this invitation.
|
||||||
|
</p>
|
||||||
|
<button id="loginBtn" class="btn-primary" onclick="doLogin()">
|
||||||
|
Sign in with Passkey
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="status" class="status"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const TOKEN = ${JSON.stringify(token)};
|
||||||
|
let inviteData = null;
|
||||||
|
|
||||||
|
const errorEl = document.getElementById('error');
|
||||||
|
const successEl = document.getElementById('success');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorEl.textContent = msg;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
successEl.style.display = 'none';
|
||||||
|
statusEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
function showStatus(msg) {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.style.display = 'block';
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
document.querySelectorAll('.tab').forEach((t, i) => {
|
||||||
|
t.classList.toggle('active', (tab === 'new' && i === 0) || (tab === 'existing' && i === 1));
|
||||||
|
});
|
||||||
|
document.getElementById('panel-new').classList.toggle('active', tab === 'new');
|
||||||
|
document.getElementById('panel-existing').classList.toggle('active', tab === 'existing');
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load invite info
|
||||||
|
(async () => {
|
||||||
|
if (!TOKEN) { showError('No invite token provided.'); return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/info');
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
showError(data.error || 'This invite is no longer valid.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inviteData = await res.json();
|
||||||
|
const appName = inviteData.clientName || 'rSpace';
|
||||||
|
document.getElementById('title').textContent = 'Join ' + appName;
|
||||||
|
document.getElementById('subtitle').innerHTML =
|
||||||
|
'You\\u2019ve been invited to <span class="app-name">' + esc(appName) + '</span>';
|
||||||
|
document.getElementById('email').value = inviteData.email;
|
||||||
|
if (inviteData.message) {
|
||||||
|
const mb = document.getElementById('messageBox');
|
||||||
|
mb.textContent = '\\u201c' + inviteData.message + '\\u201d';
|
||||||
|
mb.style.display = 'block';
|
||||||
|
}
|
||||||
|
document.getElementById('authSection').style.display = 'block';
|
||||||
|
} catch (err) {
|
||||||
|
showError('Failed to load invite. Please try again.');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
// base64url helpers
|
||||||
|
function toB64url(buf) {
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||||||
|
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
|
||||||
|
}
|
||||||
|
function fromB64url(s) {
|
||||||
|
return Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimAndRedirect(sessionToken) {
|
||||||
|
showStatus('Claiming invitation...');
|
||||||
|
const claimRes = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/claim', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionToken }),
|
||||||
|
});
|
||||||
|
const claimData = await claimRes.json();
|
||||||
|
if (claimData.error) {
|
||||||
|
showError('Invite claim failed: ' + claimData.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is an OIDC client invite, redirect through the OIDC authorize flow
|
||||||
|
if (inviteData.clientId) {
|
||||||
|
showStatus('Redirecting to ' + (inviteData.clientName || 'the app') + '...');
|
||||||
|
// Store the session token so the OIDC authorize page can use it
|
||||||
|
localStorage.setItem('eid_token', sessionToken);
|
||||||
|
// Start the OIDC authorize flow — the authorize page will auto-login
|
||||||
|
window.location.href = '/oidc/authorize?client_id=' + encodeURIComponent(inviteData.clientId) +
|
||||||
|
'&response_type=code&scope=openid+profile+email&redirect_uri=' + encodeURIComponent('auto') +
|
||||||
|
'&state=invite_accept';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-OIDC invite — show success
|
||||||
|
document.getElementById('authSection').style.display = 'none';
|
||||||
|
statusEl.style.display = 'none';
|
||||||
|
successEl.innerHTML = '<strong>Welcome!</strong><br>Your invitation has been accepted.' +
|
||||||
|
(claimData.spaceSlug ? '<br>You\\'ve been added to <strong>' + esc(claimData.spaceSlug) + '</strong>.' : '') +
|
||||||
|
'<br><br><a href="https://rspace.online" style="color: #7c3aed;">Go to rSpace \\u2192</a>';
|
||||||
|
successEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRegister() {
|
||||||
|
const username = document.getElementById('username').value.trim();
|
||||||
|
if (!username || username.length < 2) { showError('Username must be at least 2 characters'); return; }
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(username)) { showError('Username can only contain letters, numbers, hyphens, and underscores'); return; }
|
||||||
|
|
||||||
|
const btn = document.getElementById('registerBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
showStatus('Starting passkey registration...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startRes = await fetch('/api/register/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
const startData = await startRes.json();
|
||||||
|
if (startData.error) { showError(startData.error); btn.disabled = false; return; }
|
||||||
|
|
||||||
|
showStatus('Follow your browser prompt to create a passkey...');
|
||||||
|
const { options } = startData;
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.create({
|
||||||
|
publicKey: {
|
||||||
|
challenge: fromB64url(options.challenge),
|
||||||
|
rp: options.rp,
|
||||||
|
user: { ...options.user, id: fromB64url(options.user.id) },
|
||||||
|
pubKeyCredParams: options.pubKeyCredParams,
|
||||||
|
timeout: options.timeout,
|
||||||
|
authenticatorSelection: options.authenticatorSelection,
|
||||||
|
attestation: options.attestation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
showStatus('Completing registration...');
|
||||||
|
const completeRes = await fetch('/api/register/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
challenge: options.challenge,
|
||||||
|
credential: {
|
||||||
|
credentialId: toB64url(credential.rawId),
|
||||||
|
attestationObject: toB64url(credential.response.attestationObject),
|
||||||
|
clientDataJSON: toB64url(credential.response.clientDataJSON),
|
||||||
|
transports: credential.response.getTransports ? credential.response.getTransports() : [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const completeData = await completeRes.json();
|
||||||
|
if (!completeData.success) { showError(completeData.error || 'Registration failed'); btn.disabled = false; return; }
|
||||||
|
|
||||||
|
await claimAndRedirect(completeData.token);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
showError('Passkey creation was cancelled. Please try again.');
|
||||||
|
} else {
|
||||||
|
showError(err.message || 'Registration failed');
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
const btn = document.getElementById('loginBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
showStatus('Starting passkey authentication...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startRes = await fetch('/api/auth/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const { options } = await startRes.json();
|
||||||
|
|
||||||
|
showStatus('Touch your passkey...');
|
||||||
|
const pubKeyOpts = {
|
||||||
|
challenge: fromB64url(options.challenge),
|
||||||
|
rpId: options.rpId,
|
||||||
|
userVerification: options.userVerification,
|
||||||
|
timeout: options.timeout,
|
||||||
|
};
|
||||||
|
if (options.allowCredentials) {
|
||||||
|
pubKeyOpts.allowCredentials = options.allowCredentials.map(c => ({
|
||||||
|
type: c.type,
|
||||||
|
id: fromB64url(c.id),
|
||||||
|
transports: c.transports,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertion = await navigator.credentials.get({ publicKey: pubKeyOpts });
|
||||||
|
|
||||||
|
const completeRes = await fetch('/api/auth/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
challenge: options.challenge,
|
||||||
|
credential: {
|
||||||
|
credentialId: toB64url(assertion.rawId),
|
||||||
|
authenticatorData: toB64url(assertion.response.authenticatorData),
|
||||||
|
clientDataJSON: toB64url(assertion.response.clientDataJSON),
|
||||||
|
signature: toB64url(assertion.response.signature),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const authResult = await completeRes.json();
|
||||||
|
if (!authResult.success) { showError(authResult.error || 'Authentication failed'); btn.disabled = false; return; }
|
||||||
|
|
||||||
|
await claimAndRedirect(authResult.token);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
showError('Passkey authentication was cancelled.');
|
||||||
|
} else {
|
||||||
|
showError(err.message || 'Authentication failed');
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// OIDC PROVIDER (Authorization Code Flow)
|
// OIDC PROVIDER (Authorization Code Flow)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -4274,7 +4950,7 @@ app.get('/.well-known/openid-configuration', (c) => {
|
||||||
// Authorization endpoint
|
// Authorization endpoint
|
||||||
app.get('/oidc/authorize', async (c) => {
|
app.get('/oidc/authorize', async (c) => {
|
||||||
const clientId = c.req.query('client_id');
|
const clientId = c.req.query('client_id');
|
||||||
const redirectUri = c.req.query('redirect_uri');
|
let redirectUri = c.req.query('redirect_uri');
|
||||||
const responseType = c.req.query('response_type');
|
const responseType = c.req.query('response_type');
|
||||||
const scope = c.req.query('scope') || 'openid profile email';
|
const scope = c.req.query('scope') || 'openid profile email';
|
||||||
const state = c.req.query('state') || '';
|
const state = c.req.query('state') || '';
|
||||||
|
|
@ -4290,6 +4966,12 @@ app.get('/oidc/authorize', async (c) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return c.text('Unknown client_id', 400);
|
return c.text('Unknown client_id', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "auto" redirect_uri: use the client's first registered redirect URI (from invite accept flow)
|
||||||
|
if (redirectUri === 'auto') {
|
||||||
|
redirectUri = client.redirectUris[0];
|
||||||
|
}
|
||||||
|
|
||||||
if (!client.redirectUris.includes(redirectUri)) {
|
if (!client.redirectUris.includes(redirectUri)) {
|
||||||
return c.text('Invalid redirect_uri', 400);
|
return c.text('Invalid redirect_uri', 400);
|
||||||
}
|
}
|
||||||
|
|
@ -4674,6 +5356,40 @@ function oidcAuthorizePage(appName: string, clientId: string, redirectUri: strin
|
||||||
loginBtn.disabled = false;
|
loginBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-authorize if coming from invite accept flow with a stored session token
|
||||||
|
(async () => {
|
||||||
|
if (STATE !== 'invite_accept') return;
|
||||||
|
const storedToken = localStorage.getItem('eid_token');
|
||||||
|
if (!storedToken) return;
|
||||||
|
localStorage.removeItem('eid_token');
|
||||||
|
loginBtn.disabled = true;
|
||||||
|
showStatus('Authorizing...');
|
||||||
|
try {
|
||||||
|
const authorizeRes = await fetch('/oidc/authorize', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
redirectUri: REDIRECT_URI,
|
||||||
|
scope: SCOPE,
|
||||||
|
state: STATE,
|
||||||
|
token: storedToken,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const authorizeResult = await authorizeRes.json();
|
||||||
|
if (authorizeResult.error) {
|
||||||
|
showError(authorizeResult.message || authorizeResult.error);
|
||||||
|
loginBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showStatus('Redirecting...');
|
||||||
|
window.location.href = authorizeResult.redirectUrl;
|
||||||
|
} catch (err) {
|
||||||
|
showError('Auto-login failed. Please sign in manually.');
|
||||||
|
loginBtn.disabled = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,9 @@ export const OPERATION_PERMISSIONS: Record<string, OperationPermission> = {
|
||||||
'rwallet:send-large': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet', maxAgeSeconds: 60 },
|
'rwallet:send-large': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet', maxAgeSeconds: 60 },
|
||||||
'rwallet:add-guardian': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 },
|
'rwallet:add-guardian': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 },
|
||||||
'rwallet:remove-guardian': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 },
|
'rwallet:remove-guardian': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 },
|
||||||
|
'rwallet:link-wallet': { minAuthLevel: AuthLevel.ELEVATED, maxAgeSeconds: 300 },
|
||||||
|
'rwallet:unlink-wallet': { minAuthLevel: AuthLevel.ELEVATED, maxAgeSeconds: 300 },
|
||||||
|
'rwallet:external-send': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet', maxAgeSeconds: 60 },
|
||||||
|
|
||||||
// rvote operations
|
// rvote operations
|
||||||
'rvote:view-proposals': { minAuthLevel: AuthLevel.BASIC },
|
'rvote:view-proposals': { minAuthLevel: AuthLevel.BASIC },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue