Files
WebDrop/web/src/core-bridge/worker/account.ts
T

286 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>; error?: string;
timer?: ReturnType<typeof setTimeout>; polling?: ReturnType<typeof setInterval>;
}
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<void> | 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<AccountSession, 'token'>): Record<string, string> { 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<void> {
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<void> {
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<void> {
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<VaultBody>);
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<VaultBody>);
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<AccountSession> {
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<void> {
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<VaultBody>);
} 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<Awaited<ReturnType<typeof accountStatus>>> {
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<Awaited<ReturnType<typeof accountStatus>>> {
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<AccountSaltBody>);
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<AccountSessionBody>);
} 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<Awaited<ReturnType<typeof accountStatus>>> {
if (account) throw new Error('请先退出当前账号');
try {
const remote = await apiFetch('/accounts/vault', { headers: headers(identity) }).then(r => r.json() as Promise<VaultBody>);
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<PublicKeyBody>);
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<void> {
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<void> { 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<void> {
if (account) {
account.remember = false;
await account.syncing?.catch(() => {});
}
await forgetAccount(); await forgetLoginFields();
}
export async function updatePersonal(displayName: string, avatar?: string): Promise<void> {
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<AccountProfile>);
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<AccountDeviceBody[]> { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json() as Promise<AccountDeviceBody[]>); }
export async function renameAccountDevice(id: string, name: string): Promise<void> {
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<void> {
requireAccount(); await apiFetch(`/accounts/devices/${id}`, { method: 'DELETE' });
if ((await localAccountDevice()).id === id) await logoutAccount();
}
export async function listAccountInvitations(): Promise<Invitation[]> {
requireAccount();
const rows = await apiFetch('/accounts/invitations').then((r) => r.json() as Promise<InvitationBody[]>);
const session = account;
return Promise.all(rows.map(async (row): Promise<Invitation> => {
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<void> {
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<void> {
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<InvitationCryptoPlan>);
const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise<PublicKeyBody>);
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<Invitation[]> {
const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders('owner') }).then((r) => r.json() as Promise<Invitation[]>);
}
export async function revokeChannelInvitation(channelId: string, input: string): Promise<void> {
const { session } = await getSession(channelId); await apiFetch(`/channels/${channelId}/invitations/${usernameOf(input)}`, { method: 'DELETE', headers: session.authHeaders('owner') });
}