feat(web): add account workspaces and signed content flows
This commit is contained in:
1 parent
7b8d61d1ba
commit
b5cf3d5958
117 files changed
+2789
-1674
No files matched your search
@@ -2,24 +2,38 @@ import { importResources, importResourceDeletes, listAllResources, listResourceD
|
||||
import { apiFetch } from './http';
|
||||
import { clearResourceSessions, getSession } from './sessions';
|
||||
import { setAccountIdentity, requireAccount } from './accountSession';
|
||||
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys } from './accountCrypto';
|
||||
import { mergeVault, validateVault, validatePersonal, type VaultProfile, type PersonalProfile } from './accountVault';
|
||||
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto';
|
||||
import { mergeVault, validateVault, type VaultProfile } from './accountVault';
|
||||
import { joinChannel } from './channelResources';
|
||||
import { openChannelGrant, 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';
|
||||
|
||||
interface VaultBody { revision: number; vault: string }
|
||||
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;
|
||||
remember: boolean;
|
||||
deviceSigning?: CryptoKeyPair;
|
||||
remember: boolean; personal?: PersonalProfile;
|
||||
controller: AbortController; dirty: boolean; syncing?: Promise<void>; error?: string;
|
||||
timer?: ReturnType<typeof setTimeout>; polling?: ReturnType<typeof setInterval>;
|
||||
}
|
||||
export interface Invitation { channel_id: string; username: string; payload: string; created_at: number }
|
||||
export type Invitation = InvitationBody;
|
||||
let account: AccountSession | undefined;
|
||||
export function unlockedAccount() {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
return account;
|
||||
}
|
||||
let restoration: Promise<void> | undefined;
|
||||
function usernameOf(input: string): string {
|
||||
export function usernameOf(input: string): string {
|
||||
const username = input.trim().toLowerCase();
|
||||
if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new Error('用户名需为 3–32 位字母、数字、下划线或短横线');
|
||||
return username;
|
||||
@@ -33,7 +47,7 @@ async function applyProfile(profile: VaultProfile): Promise<void> {
|
||||
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, ...value });
|
||||
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> {
|
||||
@@ -41,6 +55,7 @@ async function syncVault(): Promise<void> {
|
||||
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;
|
||||
@@ -48,11 +63,14 @@ async function syncVault(): Promise<void> {
|
||||
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 merged = mergeVault(profile, await listAllResources(), await listResourceDeletes(), session.profile.personal);
|
||||
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, merged.personal);
|
||||
setAccountIdentity(session, merged.keys.publicKey, session.personal);
|
||||
await persistSession(session, remote);
|
||||
break;
|
||||
}
|
||||
@@ -64,7 +82,7 @@ async function syncVault(): Promise<void> {
|
||||
stillActive(session);
|
||||
await applyProfile(merged);
|
||||
session.profile = merged; session.revision = result.revision; session.error = undefined;
|
||||
setAccountIdentity(session, merged.keys.publicKey, merged.personal);
|
||||
setAccountIdentity(session, merged.keys.publicKey, session.personal);
|
||||
await persistSession(session, { vault, revision: result.revision });
|
||||
break;
|
||||
} catch (error) {
|
||||
@@ -75,6 +93,7 @@ async function syncVault(): Promise<void> {
|
||||
})().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();
|
||||
@@ -85,11 +104,17 @@ async function syncVault(): Promise<void> {
|
||||
return session.syncing;
|
||||
}
|
||||
|
||||
async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true): Promise<AccountSession> {
|
||||
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 = await ensureDeviceSigning(identity.username, identity.token, profile.keys.signing);
|
||||
clearResourceSessions(); setResourceScope(identity.username);
|
||||
const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false };
|
||||
account = session; setAccountIdentity(identity, profile.keys.publicKey, profile.personal);
|
||||
const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false, deviceSigning };
|
||||
try { session.personal = await loadPersonal(identity.token); }
|
||||
catch (error) {
|
||||
if (!(error instanceof Error && error.message === '网络连接失败')) throw error;
|
||||
session.personal = cachedPersonal;
|
||||
}
|
||||
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);
|
||||
@@ -108,10 +133,8 @@ export function restoreAccount(): Promise<void> {
|
||||
remote = saved;
|
||||
}
|
||||
try {
|
||||
const session = await activate(saved, saved.key, remote);
|
||||
const local = validateVault(await openJson(saved.vault, saved.key));
|
||||
if (local.personal && local.personal.modifiedAt > (session.profile.personal?.modifiedAt ?? -1)) session.profile = { ...session.profile, personal: local.personal };
|
||||
setAccountIdentity(session, session.profile.keys.publicKey, session.profile.personal);
|
||||
const session = await activate(saved, saved.key, remote, true, saved.personal);
|
||||
setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
|
||||
await syncVault().catch(() => {});
|
||||
} catch (error) { await logoutAccount(); throw error; }
|
||||
})().catch((error: unknown) => { restoration = undefined; throw error; });
|
||||
@@ -129,10 +152,12 @@ export async function registerAccount(input: string, password: string, remember
|
||||
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 profile: VaultProfile = { v: 2, keys: await generateInvitationKeys(), resources: [], deletes: [] };
|
||||
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), vault: await sealJson(profile, key) }) });
|
||||
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);
|
||||
}
|
||||
@@ -140,22 +165,31 @@ export async function loginAccount(input: string, password: string, remember = t
|
||||
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<{ salt: string }>);
|
||||
.then((r) => r.json() as Promise<AccountSaltBody>);
|
||||
const { verifier, key } = await deriveAccountKeys(password, unb64(challenge.salt));
|
||||
let identity: { username: string; token: string };
|
||||
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 }) })
|
||||
.then((r) => r.json() as Promise<{ username: string; token: string }>);
|
||||
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 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/${username}/key`, { headers: headers(identity) }).then((r) => r.json() as Promise<{ public_key: string }>);
|
||||
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);
|
||||
if (remember) await saveLoginFields(username, password); else await forgetLoginFields();
|
||||
await persistSession(session, remote);
|
||||
await syncVault().catch(() => {});
|
||||
return accountStatus();
|
||||
@@ -165,13 +199,15 @@ export async function loginAccount(input: string, password: string, remember = t
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string }> {
|
||||
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,
|
||||
...(account?.profile.personal ? { personal: account.profile.personal } : {}),
|
||||
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);
|
||||
@@ -192,13 +228,15 @@ export async function clearSavedLogin(): Promise<void> {
|
||||
}
|
||||
export async function updatePersonal(displayName: string, avatar?: string): Promise<void> {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
account.profile = { ...account.profile, personal: validatePersonal({ displayName, ...(avatar ? { avatar } : {}), modifiedAt: Date.now() }) };
|
||||
setAccountIdentity(account, account.profile.keys.publicKey, account.profile.personal);
|
||||
// Retain an offline profile edit for the next session restoration.
|
||||
await persistSession(account, { vault: await sealJson(account.profile, account.key), revision: account.revision });
|
||||
await syncVault();
|
||||
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<unknown> { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json()); }
|
||||
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());
|
||||
@@ -221,9 +259,12 @@ export async function acceptInvitation(channelId: string): Promise<void> {
|
||||
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 body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise<{ public_key: string }>);
|
||||
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);
|
||||
await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload }) });
|
||||
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[]>);
|
||||
|
||||
Reference in new issue
Block a user