From 50a7771d970a2a3d4f27fdcd6a2e8509b472b5ff Mon Sep 17 00:00:00 2001 From: TZGyn Date: Sun, 2 Aug 2026 11:38:19 +0800 Subject: [PATCH] keychain vault + secrets cache + env separated keychains --- AGENTS.md | 1 + apps/desktop/src/bun/index.ts | 31 +++---- apps/desktop/src/bun/lib/secrets-cache.ts | 87 +++++++++++++++++++ apps/desktop/src/bun/lib/xrp/index.ts | 9 +- apps/desktop/src/bun/native/biometric-helper | Bin 54880 -> 72832 bytes apps/desktop/src/bun/rpc/xrp/index.ts | 15 ++-- 6 files changed, 108 insertions(+), 35 deletions(-) create mode 100644 apps/desktop/src/bun/lib/secrets-cache.ts diff --git a/AGENTS.md b/AGENTS.md index 829a78d..1c395d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,4 +3,5 @@ - Never commit changes unless explicitly told to do so. Always ask first or wait for the user's command. - Never manually edit `.router/router.ts` — sv-router auto-generates it from the filesystem. Just create the route file in the correct filesystem location. - Only commit when explicitly told to do so. When asked to commit, always write a single simple line commit message. +- When asked to publish, always rebuild the DMG first (`bun run build:stable` from `apps/desktop`), wait for notarization to complete, verify with `xcrun stapler validate`, then publish the fresh DMG. Never publish a stale build. - At the end of every response, always surface a "Pending from you" section listing anything you need from the user (decisions, permissions, credentials, confirming a plan, etc.). If nothing is pending, say "Nothing pending." diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index 752e8be..5642121 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -13,7 +13,13 @@ import { createMoneroHandlers } from './rpc/monero' import { createXrpHandlers } from './rpc/xrp' import type { MoneroWalletState } from './lib/monero' import { canPromptTouchID, promptTouchID } from './lib/biometric' -import { getXrpWalletList } from './lib/xrp' +import { + getSecret, + setSecret, + deleteSecret, + clearSecretsCache, + VAULT_SERVICE, +} from './lib/secrets-cache' import type { RPC } from '../lib/rpc-schema' import { log } from './lib/logger' @@ -89,23 +95,8 @@ const rpc = BrowserView.defineRPC({ rpc.setRequestHandler({ resetApp: async () => { try { - const wallets = await getXrpWalletList() - await Promise.all([ - Bun.secrets.delete({ - service: 'koins', - name: 'xrp_wallets', - }), - ...wallets.flatMap((w) => [ - Bun.secrets.delete({ - service: 'koins', - name: w.vaultKey, - }), - Bun.secrets.delete({ - service: 'koins', - name: `xrp_auth_${w.id}`, - }), - ]), - ]) + await deleteSecret(VAULT_SERVICE, 'vault') + clearSecretsCache() console.log('[rpc] resetApp complete') return true } catch (e) { @@ -131,10 +122,10 @@ rpc.setRequestHandler({ } }, getSecret: async ({ name, service }) => { - return await Bun.secrets.get({ name, service }) + return await getSecret(service, name) }, setSecret: async ({ name, service, value }) => { - await Bun.secrets.set({ name, service, value }) + await setSecret(service, name, value) }, openExternal: async ({ url }) => { Utils.openExternal(url) diff --git a/apps/desktop/src/bun/lib/secrets-cache.ts b/apps/desktop/src/bun/lib/secrets-cache.ts new file mode 100644 index 0000000..604c716 --- /dev/null +++ b/apps/desktop/src/bun/lib/secrets-cache.ts @@ -0,0 +1,87 @@ +import { getENV } from './get-env' + +const env = getENV() +export const VAULT_SERVICE = env.appName +const VAULT_NAME = 'vault' + +const cache = new Map() +const key = (service: string, name: string) => `${service}:${name}` + +let loaded = false +let loadPromise: Promise | null = null +let writeChain: Promise = Promise.resolve() + +async function ensureLoaded(): Promise { + if (loaded) return + if (!loadPromise) { + loadPromise = (async () => { + const raw = await Bun.secrets.get({ + service: VAULT_SERVICE, + name: VAULT_NAME, + }) + if (raw) { + try { + const data = JSON.parse(raw) as Record + for (const [k, v] of Object.entries(data)) cache.set(k, v) + } catch (e) { + console.log('[secrets] vault parse error:', e) + } + } + loaded = true + })() + } + await loadPromise +} + +async function persist(): Promise { + const raw = JSON.stringify(Object.fromEntries(cache)) + writeChain = writeChain.then(() => + Bun.secrets.set({ + service: VAULT_SERVICE, + name: VAULT_NAME, + value: raw, + }), + ) + return writeChain +} + +export async function getSecret( + service: string, + name: string, +): Promise { + const k = key(service, name) + await ensureLoaded() + return cache.get(k) ?? null +} + +export async function setSecret( + service: string, + name: string, + value: string, +): Promise { + await ensureLoaded() + cache.set(key(service, name), value) + await persist() +} + +export async function deleteSecret( + service: string, + name: string, +): Promise { + if (service === VAULT_SERVICE && name === VAULT_NAME) { + cache.clear() + loaded = false + loadPromise = null + await Bun.secrets.delete({ service, name }) + return + } + await ensureLoaded() + cache.delete(key(service, name)) + await persist() +} + +export function clearSecretsCache(): void { + cache.clear() + loaded = false + loadPromise = null +} diff --git a/apps/desktop/src/bun/lib/xrp/index.ts b/apps/desktop/src/bun/lib/xrp/index.ts index 59e3ee1..d736d52 100644 --- a/apps/desktop/src/bun/lib/xrp/index.ts +++ b/apps/desktop/src/bun/lib/xrp/index.ts @@ -11,6 +11,7 @@ import type { XrpTxEntry, XrpWalletInfo, } from '../../../lib/rpc-schema' +import { getSecret, setSecret } from '../secrets-cache' const SERVER = 'wss://s1.ripple.com' const RIPPLE_EPOCH = 946684800 @@ -54,7 +55,7 @@ export function walletForSigning(secret: string): Wallet { } export async function getXrpWalletList(): Promise { - const raw = await Bun.secrets.get({ service: SERVICE, name: WALLET_LIST_KEY }) + const raw = await getSecret(SERVICE, WALLET_LIST_KEY) if (!raw) return [] try { return JSON.parse(raw) @@ -66,11 +67,7 @@ export async function getXrpWalletList(): Promise { export async function saveXrpWalletList( wallets: XrpWalletInfo[], ): Promise { - await Bun.secrets.set({ - service: SERVICE, - name: WALLET_LIST_KEY, - value: JSON.stringify(wallets), - }) + await setSecret(SERVICE, WALLET_LIST_KEY, JSON.stringify(wallets)) } export async function getBalance(address: string): Promise { diff --git a/apps/desktop/src/bun/native/biometric-helper b/apps/desktop/src/bun/native/biometric-helper index d6875f9ba759315c9c349342807009e480d9f300..bc745883c58e149ca9fbf4127ad34b9f09daaa42 100755 GIT binary patch delta 9640 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 w.id === id) if (!wallet) throw new Error('Wallet not found') await Promise.all([ - Bun.secrets.delete({ service: SERVICE, name: wallet.vaultKey }), - Bun.secrets.delete({ service: SERVICE, name: `xrp_auth_${id}` }), + deleteSecret(SERVICE, wallet.vaultKey), + deleteSecret(SERVICE, `xrp_auth_${id}`), ]) await saveXrpWalletList(wallets.filter((w) => w.id !== id)) console.log('[rpc] xrpDeleteWallet complete') }, xrpGetSeed: async ({ vaultKey }: { vaultKey: string }) => { console.log('[rpc] xrpGetSeed:', vaultKey) - const seed = await Bun.secrets.get({ service: SERVICE, name: vaultKey }) + const seed = await getSecret(SERVICE, vaultKey) if (!seed) throw new Error('Seed not found in keychain') return seed },