import { importResources, importResourceDeletes, listAllResources, listResourceDeletes, setResourceChangeListener, setResourceScope, type SavedResource } from '../database'; import { apiFetch } from './http'; import { clearResourceSessions, getSession } from './sessions'; import { setAccountIdentity, requireAccount } from './accountSession'; import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto'; import { mergeVault, validateVault, type VaultProfile } from './accountVault'; import { joinChannel } from './channelResources'; import { openChannelGrant, peekChannelGrant, sealChannelGrant } from './channelGrant'; import { recoverAccountChannels } from './accountChannels'; import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName, saveLoginFields, forgetLoginFields, savedLoginFields } from './accountPersistence'; import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '../generated/api'; import { loadPersonal, personalProfile } from './accountProfile'; import type { AccountProfile, AccountProfileRequest } from '../generated/api'; import type { PersonalProfile } from '../protocol/account'; import { passkeyUnlockUsername, clearPasskeyState } from './passkeys'; import { packageInvitationDomains } from './channelDataKeys'; import { clearDeviceSigner, ensureDeviceSigning } from './accountSigning'; type VaultBody = AccountVaultBody; interface AccountSession { username: string; token: string; key: CryptoKey; profile: VaultProfile; revision: number; deviceSigning?: CryptoKeyPair; remember: boolean; personal?: PersonalProfile; controller: AbortController; dirty: boolean; syncing?: Promise; error?: string; timer?: ReturnType; polling?: ReturnType; } export type Invitation = InvitationBody & { channelName?: string; invitedBy?: string }; let account: AccountSession | undefined; export function unlockedAccount() { if (!account) throw new Error('请先登录账号'); return account; } let restoration: Promise | undefined; export function usernameOf(input: string): string { const username = input.trim().toLowerCase(); if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new Error('账号 ID 需为 3–32 位字母、数字、下划线或短横线'); return username; } function headers(session: Pick): Record { return { 'X-WD-Account': session.token }; } function stillActive(session: AccountSession): void { if (account !== session || session.controller.signal.aborted) throw new Error('账号已切换,操作已停止'); } async function applyProfile(profile: VaultProfile): Promise { await importResources(profile.resources.map((row) => ({ ...row, endpointSecret: new Uint8Array(32) } as SavedResource))); await importResourceDeletes(profile.deletes); } async function persistSession(session: AccountSession, value: VaultBody): Promise { if (session.remember) await rememberAccount({ username: session.username, token: session.token, key: session.key, personal: session.personal, ...value }); else await forgetAccount(); } async function syncVault(): Promise { if (!account) throw new Error('请先登录账号'); const session = account; if (session.syncing) { session.dirty = true; return session.syncing; } session.syncing = (async () => { session.personal = await loadPersonal(session.token); await recoverAccountChannels(session.username, session.profile.keys, session.controller.signal); do { session.dirty = false; for (let attempt = 0; attempt < 3; attempt++) { const remote = await apiFetch('/accounts/vault', { headers: headers(session), signal: session.controller.signal }).then((r) => r.json() as Promise); const profile = validateVault(await openJson(remote.vault, session.key)); if (profile.keys.publicKey.x !== session.profile.keys.publicKey.x || profile.keys.publicKey.y !== session.profile.keys.publicKey.y) throw new Error('账号邀请密钥发生冲突,已停止同步'); const remoteSigning = profile.keys.signing.publicKey; const localSigning = session.profile.keys.signing.publicKey; if (remoteSigning.x !== localSigning.x || remoteSigning.y !== localSigning.y) throw new Error('账号签名密钥发生冲突,已停止同步'); const merged = mergeVault(profile, await listAllResources(), await listResourceDeletes()); if (JSON.stringify(merged) === JSON.stringify(profile)) { stillActive(session); await applyProfile(merged); session.profile = merged; session.revision = remote.revision; session.error = undefined; setAccountIdentity(session, merged.keys.publicKey, session.personal); await persistSession(session, remote); break; } const vault = await sealJson(merged, session.key); stillActive(session); try { const result = await apiFetch('/accounts/vault', { method: 'PUT', headers: { ...headers(session), 'Content-Type': 'application/json' }, signal: session.controller.signal, body: JSON.stringify({ revision: remote.revision, vault }) }).then((r) => r.json() as Promise); stillActive(session); await applyProfile(merged); session.profile = merged; session.revision = result.revision; session.error = undefined; setAccountIdentity(session, merged.keys.publicKey, session.personal); await persistSession(session, { vault, revision: result.revision }); break; } catch (error) { if (attempt === 2 || !(error instanceof Error && 'status' in error && error.status === 409)) throw error; } } } while (session.dirty && account === session); })().catch((error: unknown) => { if (account === session) session.error = error instanceof Error ? error.message : '同步失败'; if (account === session && error instanceof Error && 'status' in error && error.status === 401) { clearDeviceSigner(); account = undefined; clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort(); setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null); void forgetAccount(); void forgetLoginFields(); } throw error; }).finally(() => { session.syncing = undefined; }); return session.syncing; } async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true, cachedPersonal?: PersonalProfile): Promise { const profile = validateVault(await openJson(remote.vault, key)); // 设备签名密钥与个人资料互不依赖,同时请求,冷启动少等一个往返 const [deviceSigning, personal] = await Promise.all([ ensureDeviceSigning(identity.username, identity.token, profile.keys.signing), loadPersonal(identity.token).catch((error: unknown) => { if (!(error instanceof Error && error.message === '网络连接失败')) throw error; return cachedPersonal; }), ]); clearResourceSessions(); setResourceScope(identity.username); const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false, deviceSigning }; session.personal = personal; account = session; setAccountIdentity(identity, profile.keys.publicKey, session.personal); await applyProfile(mergeVault(profile, await listAllResources(), await listResourceDeletes())); setResourceChangeListener(scheduleSync); session.polling = setInterval(scheduleSync, 20_000); return session; } export function restoreAccount(): Promise { restoration ??= (async () => { const saved = await rememberedAccount(); if (!saved || account) return; let remote: VaultBody; try { remote = await apiFetch('/accounts/vault', { headers: headers(saved) }).then((r) => r.json() as Promise); } catch (error) { if (error instanceof Error && 'status' in error && error.status === 401) { await forgetAccount(); await forgetLoginFields(); return; } remote = saved; } try { const session = await activate(saved, saved.key, remote, true, saved.personal); setAccountIdentity(session, session.profile.keys.publicKey, session.personal); // 保险库已在 activate 里并入本机资源,完整同步(约 4 次往返)放到后台,不挡住打开页面 void syncVault().catch(() => {}); } catch (error) { await logoutAccount(); throw error; } })().catch((error: unknown) => { restoration = undefined; throw error; }); return restoration; } function scheduleSync(): void { if (!account) return; account.dirty = true; clearTimeout(account.timer); account.timer = setTimeout(() => { void syncVault().catch(() => {}); }, 700); } export async function registerAccount(input: string, password: string, remember = true): Promise>> { if (account) throw new Error('请先退出当前账号'); const username = usernameOf(input); if (password.length < 12 || password.length > 1024) throw new Error('密码长度需为 12–1024 个字符'); const salt = crypto.getRandomValues(new Uint8Array(16)); const { verifier, key } = await deriveAccountKeys(password, salt); const invitationKeys = await generateInvitationKeys(); const signing = await generateAccountSigningKeys(); const profile: VaultProfile = { v: 3, keys: { ...invitationKeys, signing }, resources: [], deletes: [] }; try { await apiFetch('/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, salt: b64(salt), verifier: b64(verifier), public_key: JSON.stringify(profile.keys.publicKey), signing_public_key: JSON.stringify(signing.publicKey), vault: await sealJson(profile, key) } satisfies AccountRegisterRequest) }); } finally { verifier.fill(0); } return loginAccount(username, password, remember); } export async function loginAccount(input: string, password: string, remember = true): Promise>> { if (account) throw new Error('请先退出当前账号'); const username = usernameOf(input); const challenge = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }) .then((r) => r.json() as Promise); const { verifier, key } = await deriveAccountKeys(password, unb64(challenge.salt)); let identity: AccountSessionBody; try { const device = await localAccountDevice(); identity = await apiFetch('/accounts/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, verifier: b64(verifier), device_id: device.id, device_name: device.name } satisfies AccountSessionRequest) }) .then((r) => r.json() as Promise); } finally { verifier.fill(0); } const status = await unlockAuthenticatedAccount(identity, key, remember); if (remember) await saveLoginFields(username, password); else await forgetLoginFields(); return status; } export async function unlockAuthenticatedAccount(identity: AccountSessionBody, key: CryptoKey, remember = true): Promise>> { if (account) throw new Error('请先退出当前账号'); try { const remote = await apiFetch('/accounts/vault', { headers: headers(identity) }).then(r => r.json() as Promise); const profile = validateVault(await openJson(remote.vault, key)); const publicKey = await apiFetch('/accounts/users/' + identity.username + '/key', { headers: headers(identity) }).then(r => r.json() as Promise); const published = JSON.parse(publicKey.public_key) as JsonWebKey; if (published.x !== profile.keys.publicKey.x || published.y !== profile.keys.publicKey.y) throw new Error('账号邀请公钥与保险库不匹配'); if (!publicKey.signing_public_key) throw new Error('账号签名公钥不存在。'); const publishedSigning = JSON.parse(publicKey.signing_public_key) as JsonWebKey; if (publishedSigning.x !== profile.keys.signing.publicKey.x || publishedSigning.y !== profile.keys.signing.publicKey.y) throw new Error('账号签名公钥与保险库不匹配'); const session = await activate(identity, key, remote, remember); await persistSession(session, remote); await syncVault().catch(() => {}); return accountStatus(); } catch (error) { await logoutAccount(); void apiFetch('/accounts/session', { method: 'DELETE', headers: headers(identity) }).catch(() => {}); throw error; } } export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string }> { return { username: account?.username ?? null, revision: account?.revision ?? 0, remembered: account?.remember ?? false, unlockUsername: passkeyUnlockUsername(), ...(account?.personal ? { personal: account.personal } : {}), ...(account?.error ? { error: '账号资源同步失败,请重试。' } : {}) }; } export async function logoutAccount(): Promise { clearDeviceSigner(); clearPasskeyState(); const session = account; account = undefined; if (session) { clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort(); await session.syncing?.catch(() => {}); } setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null); await forgetAccount(); if (session) void apiFetch('/accounts/session', { method: 'DELETE', headers: headers(session) }).catch(() => {}); } export async function syncAccount(): Promise { await syncVault(); } export async function getSavedLogin(): Promise<{ username: string; password: string; saved: boolean }> { if (account) return { username: '', password: '', saved: false }; const fields = await savedLoginFields(); return fields ? { ...fields, saved: true } : { username: '', password: '', saved: false }; } export async function clearSavedLogin(): Promise { if (account) { account.remember = false; await account.syncing?.catch(() => {}); } await forgetAccount(); await forgetLoginFields(); } export async function updatePersonal(displayName: string, avatar?: string): Promise { if (!account) throw new Error('请先登录账号'); const session = account; const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), 'Content-Type': 'application/json' }, body: JSON.stringify({ displayName, avatar } satisfies AccountProfileRequest) }).then(r => r.json() as Promise); stillActive(session); session.personal = personalProfile(value); setAccountIdentity(session, session.profile.keys.publicKey, session.personal); await persistSession(session, { vault: await sealJson(session.profile, session.key), revision: session.revision }); } export async function listAccountDevices(): Promise { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json() as Promise); } export async function renameAccountDevice(id: string, name: string): Promise { requireAccount(); await apiFetch(`/accounts/devices/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); if ((await localAccountDevice()).id === id) await saveDeviceName(name.trim()); } export async function revokeAccountDevice(id: string): Promise { requireAccount(); await apiFetch(`/accounts/devices/${id}`, { method: 'DELETE' }); if ((await localAccountDevice()).id === id) await logoutAccount(); } export async function listAccountInvitations(): Promise { requireAccount(); const rows = await apiFetch('/accounts/invitations').then((r) => r.json() as Promise); const session = account; return Promise.all(rows.map(async (row): Promise => { const meta = session ? await peekChannelGrant(row.payload, session.username, row.channel_id, session.profile.keys.privateKey) : {}; return { ...row, ...(meta.name ? { channelName: meta.name } : {}), ...(meta.invitedBy ? { invitedBy: meta.invitedBy } : {}) }; })); } export async function acceptInvitation(channelId: string): Promise { if (!account) throw new Error('请先登录账号'); const session = account; const invitation = (await listAccountInvitations()).find((row) => row.channel_id === channelId); if (!invitation) throw new Error('邀请已失效'); const link = await openChannelGrant(invitation.payload, session.username, channelId, session.profile.keys.privateKey, 'member', self.location.origin); // 保险库同步(约 5 次往返)放到后台,不让用户等着:频道资源已在本机保存,可以直接打开 stillActive(session); await joinChannel({ link }); scheduleSync(); } export async function inviteChannelUser(channelId: string, input: string): Promise { requireAccount(); const username = usernameOf(input); const { session, resource } = await getSession(channelId); if (!session.can('owner')) throw new Error('只有空间主可以邀请用户'); const plan = await apiFetch(`/channels/${channelId}/invitations/${username}/crypto-plan`, { headers: session.authHeaders('owner') }) .then(r => r.json() as Promise); const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise); const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey, { name: resource.name, invitedBy: account?.username }); const domainEnvelopes = await packageInvitationDomains(resource, session, plan); await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload, domain_envelopes: domainEnvelopes }) }); } export async function listChannelInvitations(channelId: string): Promise { const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders('owner') }).then((r) => r.json() as Promise); } export async function revokeChannelInvitation(channelId: string, input: string): Promise { const { session } = await getSession(channelId); await apiFetch(`/channels/${channelId}/invitations/${usernameOf(input)}`, { method: 'DELETE', headers: session.authHeaders('owner') }); }