keychain vault + secrets cache + env separated keychains

This commit is contained in:
2026-08-02 11:38:19 +08:00
parent 23ef77ece6
commit 50a7771d97
6 changed files with 108 additions and 35 deletions
+1
View File
@@ -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."
+11 -20
View File
@@ -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>({
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)
+87
View File
@@ -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<string, string>()
const key = (service: string, name: string) => `${service}:${name}`
let loaded = false
let loadPromise: Promise<void> | null = null
let writeChain: Promise<void> = Promise.resolve()
async function ensureLoaded(): Promise<void> {
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<string, string>
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<void> {
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<string | null> {
const k = key(service, name)
await ensureLoaded()
return cache.get(k) ?? null
}
export async function setSecret(
service: string,
name: string,
value: string,
): Promise<void> {
await ensureLoaded()
cache.set(key(service, name), value)
await persist()
}
export async function deleteSecret(
service: string,
name: string,
): Promise<void> {
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
}
+3 -6
View File
@@ -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<XrpWalletInfo[]> {
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<XrpWalletInfo[]> {
export async function saveXrpWalletList(
wallets: XrpWalletInfo[],
): Promise<void> {
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<XrpBalance> {
Binary file not shown.
+6 -9
View File
@@ -10,6 +10,7 @@ import {
getFee,
} from '../../lib/xrp'
import type { XrpWalletInfo } from '../../../lib/rpc-schema'
import { getSecret, setSecret, deleteSecret } from '../../lib/secrets-cache'
const SERVICE = 'koins'
@@ -30,7 +31,7 @@ export function createXrpHandlers() {
const { address, seed } = generateWallet()
const id = crypto.randomUUID()
const vaultKey = `xrp_seed_${id}`
await Bun.secrets.set({ service: SERVICE, name: vaultKey, value: seed })
await setSecret(SERVICE, vaultKey, seed)
const wallets = await getXrpWalletList()
const createdAt = new Date().toISOString()
const info: XrpWalletInfo = {
@@ -58,11 +59,7 @@ export function createXrpHandlers() {
const { address } = walletFromSecret(secret)
const id = crypto.randomUUID()
const vaultKey = `xrp_seed_${id}`
await Bun.secrets.set({
service: SERVICE,
name: vaultKey,
value: secret.trim(),
})
await setSecret(SERVICE, vaultKey, secret.trim())
const wallets = await getXrpWalletList()
const createdAt = new Date().toISOString()
const info: XrpWalletInfo = {
@@ -83,15 +80,15 @@ export function createXrpHandlers() {
const wallet = wallets.find((w) => 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
},