diff --git a/infrastructure/eid-wallet/package.json b/infrastructure/eid-wallet/package.json index aaf622eb1..c9c7e5ca8 100644 --- a/infrastructure/eid-wallet/package.json +++ b/infrastructure/eid-wallet/package.json @@ -1,6 +1,6 @@ { "name": "eid-wallet", - "version": "0.7.0", + "version": "0.7.1", "description": "", "type": "module", "scripts": { diff --git a/infrastructure/eid-wallet/src-tauri/tauri.conf.json b/infrastructure/eid-wallet/src-tauri/tauri.conf.json index 50cf462f7..ab31af488 100644 --- a/infrastructure/eid-wallet/src-tauri/tauri.conf.json +++ b/infrastructure/eid-wallet/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "eID for W3DS", - "version": "0.7.0", + "version": "0.7.1", "identifier": "foundation.metastate.eid-wallet", "build": { "beforeDevCommand": "pnpm dev", @@ -28,7 +28,7 @@ "active": true, "targets": "all", "android": { - "versionCode": 24 + "versionCode": 25 }, "icon": [ "icons/32x32.png", diff --git a/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts b/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts index cc3445e52..763ccaafa 100644 --- a/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts +++ b/infrastructure/eid-wallet/src/lib/global/controllers/evault.ts @@ -158,33 +158,39 @@ export class VaultController { /** * Sync public key to eVault core via wallet-sdk. * SDK checks /whois and skips PATCH if current key already in certs; otherwise PATCHes /public-key. + * Returns true if the sync succeeded, false otherwise. */ - async syncPublicKey(eName: string): Promise { + async syncPublicKey( + eName: string, + keyId = "default", + context = "onboarding", + ): Promise { if (!this.#walletSdkAdapter) { console.warn( "Wallet SDK adapter not available, cannot sync public key", ); - return; + return false; } const vault = await this.vault; if (!vault?.uri) { console.warn("No vault URI available, cannot sync public key"); - return; + return false; } try { await syncPublicKeyToEvault(this.#walletSdkAdapter, { evaultUri: vault.uri, eName, - keyId: "default", - context: "onboarding", + keyId, + context, authToken: PUBLIC_EID_WALLET_TOKEN || null, registryUrl: PUBLIC_REGISTRY_URL, }); localStorage.setItem(`publicKeySaved_${eName}`, "true"); console.log(`Public key synced successfully for ${eName}`); + return true; } catch (error) { console.error("Failed to sync public key:", error); - // Don't throw - this is a non-critical operation + return false; } } @@ -681,6 +687,64 @@ export class VaultController { } } + /** + * Fetch the public key(s) currently registered in the eVault via /whois. + * Decodes JWT payloads from keyBindingCertificates without full verification + * — we only need the public key strings for local comparison. + * Returns empty array on any failure (network, parse, etc). + */ + async fetchRegisteredPublicKeys(eName: string): Promise { + const vault = await this.vault; + if (!vault?.uri) { + console.warn( + "No vault URI available, cannot fetch registered keys", + ); + return []; + } + + try { + const base = vault.uri.replace(/\/$/, ""); + const whoisUrl = new URL("/whois", base).toString(); + const headers: Record = { "X-ENAME": eName }; + if (PUBLIC_EID_WALLET_TOKEN) { + headers.Authorization = `Bearer ${PUBLIC_EID_WALLET_TOKEN}`; + } + + const response = await axios.get(whoisUrl, { + headers, + timeout: 5000, + }); + const certs = response.data?.keyBindingCertificates; + if (!Array.isArray(certs) || certs.length === 0) { + return []; + } + + const publicKeys: string[] = []; + for (const jwt of certs) { + try { + const parts = (jwt as string).split("."); + if (parts.length !== 3) continue; + // Decode base64url payload (add padding for atob) + let b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + while (b64.length % 4 !== 0) b64 += "="; + const payload = JSON.parse(atob(b64)) as { + ename?: string; + publicKey?: string; + }; + if (payload.ename === eName && payload.publicKey) { + publicKeys.push(payload.publicKey); + } + } catch { + // skip malformed certs + } + } + return publicKeys; + } catch (error) { + console.error("Failed to fetch registered public keys:", error); + return []; + } + } + async clear() { await this.#store.delete("vault"); } diff --git a/infrastructure/eid-wallet/src/lib/global/controllers/key.ts b/infrastructure/eid-wallet/src/lib/global/controllers/key.ts index 277da40a8..89a1ddcc0 100644 --- a/infrastructure/eid-wallet/src/lib/global/controllers/key.ts +++ b/infrastructure/eid-wallet/src/lib/global/controllers/key.ts @@ -22,6 +22,9 @@ type HardwareFallbackEvent = { originalError: unknown; }; +type EvaultKeyResolver = (keyId: string, context: string) => Promise; +type EvaultSyncHandler = (keyId: string, context: string) => Promise; + const CONTEXTS_KEY = "keyService.contexts"; const READY_KEY = "keyService.ready"; @@ -33,6 +36,9 @@ export class KeyService { #onHardwareFallback: | ((event: HardwareFallbackEvent) => Promise | void) | null = null; + #evaultKeyResolver: EvaultKeyResolver | null = null; + #evaultSyncHandler: EvaultSyncHandler | null = null; + #syncedKeyIds = new Set(); constructor(store: Store) { this.#store = store; @@ -63,6 +69,7 @@ export class KeyService { async reset(): Promise { this.#managerCache.clear(); this.#contexts.clear(); + this.#syncedKeyIds.clear(); await this.#store.delete(CONTEXTS_KEY); await this.#store.delete(READY_KEY); this.#ready = false; @@ -89,8 +96,49 @@ export class KeyService { this.#managerCache.delete(cacheKey); } + // Check persisted context — exact match first, then cross-context by keyId + const exactPersisted = this.#contexts.get(cacheKey); + const crossContext = exactPersisted + ? undefined + : this.#findPersistedByKeyId(keyId); + const persistedEntry = exactPersisted ?? crossContext?.entry; + const persistedMapKey = exactPersisted + ? cacheKey + : crossContext?.mapKey; + + if (persistedEntry && persistedMapKey) { + const restoredManager = await KeyManagerFactory.getKeyManager({ + keyId, + useHardware: persistedEntry.managerType === "hardware", + preVerificationMode: false, + }); + const keyExists = await restoredManager.exists(keyId); + if (keyExists) { + this.#managerCache.set(cacheKey, restoredManager); + await this.#touchContext(cacheKey, restoredManager); + return restoredManager; + } + // Key missing from storage — clear the actual stale persisted entry + this.#contexts.delete(persistedMapKey); + await this.#store.set( + CONTEXTS_KEY, + Object.fromEntries(this.#contexts), + ); + } + + // Check eVault for which local key is actually registered (source of truth) + const evaultMatch = await this.#resolveManagerByEvaultKey( + keyId, + context, + ); + if (evaultMatch) { + this.#managerCache.set(cacheKey, evaultMatch); + await this.#persistContext(cacheKey, evaultMatch, keyId, context); + return evaultMatch; + } + + // Last resort: factory logic (for fresh users with no persisted context and no eVault key yet) const isFake = await this.#isPreVerificationUser(); - // Force pre-verification mode if user is fake/pre-verification const effectiveContext = isFake ? "pre-verification" : context; const manager = await KeyManagerFactory.getKeyManagerForContext( keyId, @@ -185,6 +233,10 @@ export class KeyService { ); } + // Ensure the key we're about to use is synced to the eVault before signing. + // Only done once per session per keyId to avoid repeated network calls. + await this.#ensureKeySyncedToEvault(keyId, context, manager); + const cacheKey = this.#getCacheKey(keyId, context); try { console.log("=".repeat(70)); @@ -198,12 +250,8 @@ export class KeyService { await this.#touchContext(cacheKey, manager); return signature; } catch (signError) { - if (managerType !== "hardware") { - throw signError; - } - console.warn( - "[KeyService] Hardware signing failed; falling back to software key", + `[KeyService] Signing failed (${managerType}); attempting eVault key resolution`, { keyId, context, @@ -214,6 +262,46 @@ export class KeyService { }, ); + // Try eVault resolution: find whichever local key matches the registered one + const syncCacheKey = `${keyId}:${context}`; + this.#syncedKeyIds.delete(syncCacheKey); + const evaultMatch = await this.#resolveManagerByEvaultKey( + keyId, + context, + ); + if (evaultMatch && evaultMatch.getType() !== managerType) { + console.log( + `[KeyService] eVault match found (${evaultMatch.getType()}), retrying sign`, + ); + const retrySignature = await evaultMatch.signPayload( + keyId, + payload, + ); + this.#managerCache.set(cacheKey, evaultMatch); + await this.#persistContext( + cacheKey, + evaultMatch, + keyId, + context, + ); + this.#syncedKeyIds.add(syncCacheKey); + console.log( + `✅ [KeyService] eVault-resolved signature: ${retrySignature.substring(0, 50)}...`, + ); + console.log("=".repeat(70)); + return retrySignature; + } + + // If not hardware, no further fallback possible + if (managerType !== "hardware") { + throw signError; + } + + // Hardware-specific fallback: try software key, generate if needed, then sync + console.warn( + "[KeyService] No eVault match; falling back to software key generation", + ); + const softwareManager = await KeyManagerFactory.getKeyManager({ keyId, useHardware: false, @@ -325,6 +413,7 @@ export class KeyService { async clear() { this.#managerCache.clear(); this.#contexts.clear(); + this.#syncedKeyIds.clear(); await this.#store.delete(CONTEXTS_KEY); await this.#store.delete(READY_KEY); this.#ready = false; @@ -338,6 +427,14 @@ export class KeyService { this.#onHardwareFallback = handler; } + setEvaultKeyResolver(resolver: EvaultKeyResolver | null): void { + this.#evaultKeyResolver = resolver; + } + + setEvaultSyncHandler(handler: EvaultSyncHandler | null): void { + this.#evaultSyncHandler = handler; + } + async #runHardwareFallbackCallback( event: HardwareFallbackEvent, ): Promise { @@ -351,4 +448,133 @@ export class KeyService { ); } } + + /** + * Ensure the key we're about to use is synced to the eVault. + * Checks the eVault once per session per (keyId, context); if our public key + * is not in the registered keys, triggers a sync before signing. + * Only caches as synced when the handler confirms success. + */ + async #ensureKeySyncedToEvault( + keyId: string, + context: KeyServiceContext, + manager: KeyManager, + ): Promise { + const syncCacheKey = `${keyId}:${context}`; + if (this.#syncedKeyIds.has(syncCacheKey)) return; + if (!this.#evaultKeyResolver || !this.#evaultSyncHandler) return; + + try { + const publicKey = await manager.getPublicKey(keyId); + if (!publicKey) return; + + const registeredKeys = await this.#evaultKeyResolver( + keyId, + context, + ); + if (registeredKeys.includes(publicKey)) { + // Already synced — mark and skip future checks + this.#syncedKeyIds.add(syncCacheKey); + return; + } + + // Our key is not registered — sync it before signing + console.warn( + `[KeyService] Key ${keyId} (${manager.getType()}) not found in eVault; syncing before sign`, + ); + const synced = await this.#evaultSyncHandler(keyId, context); + if (synced) { + this.#syncedKeyIds.add(syncCacheKey); + } + } catch (error) { + // Non-fatal: if sync check fails (offline, etc), proceed with signing + console.warn( + "[KeyService] Pre-sign eVault sync check failed:", + error instanceof Error ? error.message : String(error), + ); + } + } + + /** + * Find any persisted context entry for the given keyId, regardless of context. + * This handles the case where a key was created with "onboarding" context + * but is now being used with "signing" context. + * Returns both the entry and its map key so the caller can delete the correct one. + */ + #findPersistedByKeyId( + keyId: string, + ): { mapKey: string; entry: PersistedContext } | undefined { + for (const [mapKey, entry] of this.#contexts.entries()) { + if (entry.keyId === keyId) { + return { mapKey, entry }; + } + } + return undefined; + } + + /** + * Query the eVault for registered public keys and find a local key manager + * whose public key matches. The eVault is the source of truth — only a key + * that is synced there can produce valid signatures. + */ + async #resolveManagerByEvaultKey( + keyId: string, + context: KeyServiceContext = "signing", + ): Promise { + if (!this.#evaultKeyResolver) return null; + + let registeredKeys: string[]; + try { + registeredKeys = await this.#evaultKeyResolver(keyId, context); + } catch { + return null; + } + if (registeredKeys.length === 0) return null; + + // Check software key first (more likely to be the mismatched one) + try { + const softwareManager = await KeyManagerFactory.getKeyManager({ + keyId, + useHardware: false, + preVerificationMode: false, + }); + if (await softwareManager.exists(keyId)) { + const pubKey = await softwareManager.getPublicKey(keyId); + if (pubKey && registeredKeys.includes(pubKey)) { + console.log( + "[KeyService] eVault key matches local software key", + ); + return softwareManager; + } + } + } catch { + /* ignore */ + } + + // Check hardware key + try { + const hardwareAvailable = + await KeyManagerFactory.isHardwareAvailable(); + if (hardwareAvailable) { + const hardwareManager = await KeyManagerFactory.getKeyManager({ + keyId, + useHardware: true, + preVerificationMode: false, + }); + if (await hardwareManager.exists(keyId)) { + const pubKey = await hardwareManager.getPublicKey(keyId); + if (pubKey && registeredKeys.includes(pubKey)) { + console.log( + "[KeyService] eVault key matches local hardware key", + ); + return hardwareManager; + } + } + } + } catch { + /* ignore */ + } + + return null; + } } diff --git a/infrastructure/eid-wallet/src/lib/global/state.ts b/infrastructure/eid-wallet/src/lib/global/state.ts index aa9d92074..3dc2587f3 100644 --- a/infrastructure/eid-wallet/src/lib/global/state.ts +++ b/infrastructure/eid-wallet/src/lib/global/state.ts @@ -77,6 +77,22 @@ export class GlobalState { } }, ); + + this.keyService.setEvaultKeyResolver(async (keyId, context) => { + const vault = await this.vaultController.vault; + if (!vault?.ename) return []; + return this.vaultController.fetchRegisteredPublicKeys(vault.ename); + }); + + this.keyService.setEvaultSyncHandler(async (keyId, context) => { + const vault = await this.vaultController.vault; + if (!vault?.ename) return false; + return this.vaultController.syncPublicKey( + vault.ename, + keyId, + context, + ); + }); } /** diff --git a/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte index eebf26086..556e28eef 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte @@ -537,7 +537,7 @@ async function confirmSocialBinding() { }); const sig = await globalState.walletSdkAdapter.signPayload( "default", - "default", + "signing", payload, ); await addCounterpartySignature( diff --git a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts index 16873b94e..428ff2038 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts @@ -221,7 +221,15 @@ export function createScanLogic({ scannedData.set(res); const content = res.content; if (content.startsWith("w3ds://social_binding")) { - void handleSocialBindingRequest(content); + handleSocialBindingRequest(content).catch((err) => { + console.error( + "[SocialBinding] unhandled error:", + err, + ); + socialBindingError.set( + "Failed to process social binding request.", + ); + }); } else if (content.startsWith("w3ds://sign")) { handleSigningRequest(content); } else if (content.startsWith("w3ds://reveal")) { @@ -666,9 +674,12 @@ export function createScanLogic({ "[SocialBinding] failed to fetch requester name:", err, ); + // Show eName as fallback instead of "Unknown" + socialBindingRequesterName.set(normalized); } } catch (err) { console.error("[SocialBinding] failed to parse QR:", err); + socialBindingError.set("Failed to process social binding request."); } } @@ -734,7 +745,7 @@ export function createScanLogic({ const payload = getCanonicalBindingDocString(doc); const sig = await globalState.walletSdkAdapter.signPayload( "default", - "default", + "signing", payload, ); diff --git a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte index 667638837..215a6df87 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte @@ -161,7 +161,7 @@ $effect(() => { onclick={handleVersionTap} disabled={isRetrying} > - Version v0.7.0 + Version v0.7.1 {#if retryMessage} diff --git a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte index 28e4ad9c3..27e33f92c 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte @@ -426,7 +426,7 @@ const handleAnonymousSubmit = async () => { }); const signature = await globalState.walletSdkAdapter.signPayload( KEY_ID, - "default", + "signing", payload, ); diff --git a/platforms/blabsy/client/src/components/chat/chat-window.tsx b/platforms/blabsy/client/src/components/chat/chat-window.tsx index 235d4f063..d01e1332d 100644 --- a/platforms/blabsy/client/src/components/chat/chat-window.tsx +++ b/platforms/blabsy/client/src/components/chat/chat-window.tsx @@ -158,7 +158,10 @@ export function ChatWindow(): JSX.Element { const [messageText, setMessageText] = useState(''); const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); - const savedScrollInfo = useRef<{ scrollHeight: number; scrollTop: number } | null>(null); + const savedScrollInfo = useRef<{ + scrollHeight: number; + scrollTop: number; + } | null>(null); const prevNewestMessageId = useRef(null); const [otherUser, setOtherUser] = useState(null); const [participantsData, setParticipantsData] = useState< @@ -256,7 +259,8 @@ export function ChatWindow(): JSX.Element { const el = scrollContainerRef.current; const saved = savedScrollInfo.current; if (el && saved) { - el.scrollTop = el.scrollHeight - saved.scrollHeight + saved.scrollTop; + el.scrollTop = + el.scrollHeight - saved.scrollHeight + saved.scrollTop; savedScrollInfo.current = null; } }, [messages]); diff --git a/platforms/blabsy/client/src/lib/context/chat-context.tsx b/platforms/blabsy/client/src/lib/context/chat-context.tsx index 626bdcfa7..4775ce339 100644 --- a/platforms/blabsy/client/src/lib/context/chat-context.tsx +++ b/platforms/blabsy/client/src/lib/context/chat-context.tsx @@ -1,4 +1,11 @@ -import { useState, useEffect, useContext, createContext, useMemo, useCallback } from 'react'; +import { + useState, + useEffect, + useContext, + createContext, + useMemo, + useCallback +} from 'react'; import { collection, query, @@ -39,7 +46,11 @@ type ChatContext = { hasMoreMessages: boolean; loadingOlderMessages: boolean; setCurrentChat: (chat: Chat | null) => void; - createNewChat: (participants: string[], name?: string, description?: string) => Promise; + createNewChat: ( + participants: string[], + name?: string, + description?: string + ) => Promise; sendNewMessage: (text: string) => Promise; markAsRead: (messageId: string) => Promise; addParticipant: (userId: string) => Promise; @@ -59,13 +70,16 @@ export function ChatContextProvider({ const { user } = useAuth(); const [chats, setChats] = useState(null); const [currentChat, setCurrentChat] = useState(null); - const [realtimeMessages, setRealtimeMessages] = useState(null); + const [realtimeMessages, setRealtimeMessages] = useState( + null + ); const [olderMessages, setOlderMessages] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [hasMoreMessages, setHasMoreMessages] = useState(true); const [loadingOlderMessages, setLoadingOlderMessages] = useState(false); - const [oldestDocSnapshot, setOldestDocSnapshot] = useState(null); + const [oldestDocSnapshot, setOldestDocSnapshot] = + useState(null); // Merge realtime + older messages, deduplicating by id const messages = useMemo(() => { @@ -222,7 +236,13 @@ export function ChatContextProvider({ }, [currentChat]); const loadOlderMessages = useCallback(async (): Promise => { - if (!currentChat || !hasMoreMessages || loadingOlderMessages || !oldestDocSnapshot) return; + if ( + !currentChat || + !hasMoreMessages || + loadingOlderMessages || + !oldestDocSnapshot + ) + return; setLoadingOlderMessages(true); diff --git a/platforms/esigner/client/src/lib/stores/files.ts b/platforms/esigner/client/src/lib/stores/files.ts index 777817502..4b770553a 100644 --- a/platforms/esigner/client/src/lib/stores/files.ts +++ b/platforms/esigner/client/src/lib/stores/files.ts @@ -118,7 +118,9 @@ export const uploadFile = async ( }, }); if (!uploadResponse.ok) { - throw new Error(`S3 upload failed with status ${uploadResponse.status}`); + throw new Error( + `S3 upload failed with status ${uploadResponse.status}`, + ); } // Step 3: Confirm upload diff --git a/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte b/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte index 1c426f17c..7cc9c5024 100644 --- a/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte +++ b/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte @@ -99,22 +99,17 @@ ): Record[] { return messagesArray.map((msg, index) => { const prevMessage = index > 0 ? messagesArray[index - 1] : null; - const nextMessage = - index < messagesArray.length - 1 ? messagesArray[index + 1] : null; + const nextMessage = index < messagesArray.length - 1 ? messagesArray[index + 1] : null; const isHeadNeeded = !prevMessage || prevMessage.isOwn !== msg.isOwn || - (prevMessage.senderId && - msg.senderId && - prevMessage.senderId !== msg.senderId); + (prevMessage.senderId && msg.senderId && prevMessage.senderId !== msg.senderId); const isTimestampNeeded = !nextMessage || nextMessage.isOwn !== msg.isOwn || - (nextMessage.senderId && - msg.senderId && - nextMessage.senderId !== msg.senderId); + (nextMessage.senderId && msg.senderId && nextMessage.senderId !== msg.senderId); return { ...msg, @@ -278,7 +273,9 @@

No more messages

{/if} {#if messages.length === 0} -

No messages yet. Start the conversation!

+

+ No messages yet. Start the conversation! +

{/if} {/if} {#each messages as msg (msg.id)}