feat(api): bind encrypted workspaces to accounts and devices
This commit is contained in:
1 parent
019b15041a
commit
c7d8ef15bc
49 files changed
+1904
-278
No files matched your search
@@ -30,7 +30,9 @@ const exe = process.env.WEBDROP_SERVER ?? join(root, 'out', 'win-x64', 'webdrop.
|
||||
const linuxExe = join(root, 'out', 'linux-x64', 'webdrop');
|
||||
const dll = process.env.WEBDROP_SERVER_DLL ?? join(root, 'src', 'WebDrop.Api', 'bin', 'Debug', 'net10.0', 'webdrop.dll');
|
||||
|
||||
const command: [string, string[]] | undefined = existsSync(exe)
|
||||
const command: [string, string[]] | undefined = !process.env.WEBDROP_SERVER && existsSync(dll)
|
||||
? ['dotnet', [dll]]
|
||||
: existsSync(exe)
|
||||
? [exe, []]
|
||||
: existsSync(linuxExe)
|
||||
? [linuxExe, []]
|
||||
@@ -41,6 +43,10 @@ const command: [string, string[]] | undefined = existsSync(exe)
|
||||
let proc: ChildProcess | undefined;
|
||||
let dataDir = '';
|
||||
let base = '';
|
||||
let accountToken = '';
|
||||
function headersFor(session: ChannelSession, role: AuthRole): Record<string, string> {
|
||||
return { ...session.authHeaders(role), ...(session.role === 'owner' ? { 'X-WD-Account': accountToken } : {}) };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!command) return;
|
||||
@@ -69,6 +75,16 @@ beforeAll(async () => {
|
||||
});
|
||||
proc!.on('exit', (code) => reject(new Error(`服务端提前退出,代码 ${code}`)));
|
||||
});
|
||||
const verifier = crypto.getRandomValues(new Uint8Array(32));
|
||||
const keys = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
||||
const pubkey = await crypto.subtle.exportKey('jwk', keys.publicKey);
|
||||
const encoded = (value: Uint8Array) => Buffer.from(value).toString('base64');
|
||||
const registered = await fetch(`${base}/api/v1/accounts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
|
||||
username: 'core_e2e', salt: encoded(crypto.getRandomValues(new Uint8Array(16))), verifier: encoded(verifier), vault: encoded(new Uint8Array(28)), public_key: JSON.stringify(pubkey),
|
||||
}) });
|
||||
expect(registered.status).toBe(204);
|
||||
const session = await fetch(`${base}/api/v1/accounts/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'core_e2e', verifier: encoded(verifier) }) });
|
||||
expect(session.status).toBe(200); accountToken = ((await session.json()) as { token: string }).token;
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -85,7 +101,7 @@ async function createChannel(opts: Parameters<typeof ChannelSession.create>[0] =
|
||||
const created = await ChannelSession.create(opts);
|
||||
const res = await fetch(api('/channels'), {
|
||||
method: 'POST',
|
||||
headers: { ...created.request.headers, 'Content-Type': 'application/json' },
|
||||
headers: { ...created.request.headers, 'X-WD-Account': accountToken, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(created.request.body),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
@@ -95,7 +111,7 @@ async function createChannel(opts: Parameters<typeof ChannelSession.create>[0] =
|
||||
}
|
||||
|
||||
async function joinChannel(session: ChannelSession, role: AuthRole = 'read') {
|
||||
const res = await fetch(api(`/channels/${session.id}/seats`), { method: 'POST', headers: session.authHeaders(role) });
|
||||
const res = await fetch(api(`/channels/${session.id}/seats`), { method: 'POST', headers: headersFor(session, role) });
|
||||
expect(res.status, await res.clone().text()).toBe(200);
|
||||
}
|
||||
|
||||
@@ -105,7 +121,7 @@ async function send(session: ChannelSession, plan: ItemPlan, role: AuthRole) {
|
||||
for await (const frame of plan.frames) {
|
||||
const res = await fetch(api(`/channels/${session.id}/frames`), {
|
||||
method: 'POST',
|
||||
headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...(first ? plan.frame0Headers : {}) },
|
||||
headers: { ...headersFor(session, role), 'Content-Type': 'application/octet-stream', ...(first ? plan.frame0Headers : {}) },
|
||||
body: frame.bytes,
|
||||
});
|
||||
expect(res.status, await res.clone().text()).toBeLessThan(300);
|
||||
@@ -116,16 +132,17 @@ async function send(session: ChannelSession, plan: ItemPlan, role: AuthRole) {
|
||||
}
|
||||
|
||||
async function readLog(session: ChannelSession, role: AuthRole, from = 0) {
|
||||
const res = await fetch(api(`/channels/${session.id}/log?from=${from}`), { headers: session.authHeaders(role) });
|
||||
const res = await fetch(api(`/channels/${session.id}/log?from=${from}`), { headers: headersFor(session, role) });
|
||||
expect(res.status).toBe(200);
|
||||
return parseLogRecords(new Uint8Array(await res.arrayBuffer()));
|
||||
}
|
||||
|
||||
/** 连接 WebSocket(凭据放在子协议里),收集事件直到满足 until。 */
|
||||
function subscribe(session: ChannelSession, role: AuthRole, until: (events: ItemEvent[]) => boolean, from = 0) {
|
||||
const h = session.authHeaders(role);
|
||||
const h = headersFor(session, role);
|
||||
const protocols = ['wd.v1', `wd.role.${role}`, `wd.ep.${h['X-WD-Endpoint']!}`];
|
||||
if (h.Authorization) protocols.push(`wd.auth.${h.Authorization.replace('Bearer ', '')}`);
|
||||
if (session.role === 'owner') protocols.push(`wd.account.${accountToken.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`);
|
||||
|
||||
const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/channels/${session.id}/ws?from=${from}`, protocols);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
@@ -227,7 +244,7 @@ suite('端到端(真实服务端)', () => {
|
||||
const frame = (await collectFrames(again))[0]!;
|
||||
const res = await fetch(api(`/channels/${owner.id}/frames`), {
|
||||
method: 'POST',
|
||||
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' },
|
||||
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
|
||||
body: frame.bytes,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
@@ -239,7 +256,7 @@ suite('端到端(真实服务端)', () => {
|
||||
const marker = 'UNIQUE-PLAINTEXT-MARKER-7f3a9c';
|
||||
await send(owner, owner.encryptText(marker), 'owner');
|
||||
|
||||
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: owner.authHeaders('owner') });
|
||||
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: headersFor(owner, 'owner') });
|
||||
const wire = new Uint8Array(await res.arrayBuffer());
|
||||
expect(new TextDecoder('latin1').decode(wire)).not.toContain(marker);
|
||||
expect(new TextDecoder('latin1').decode(wire)).not.toContain('"kind"');
|
||||
@@ -260,7 +277,7 @@ suite('端到端(真实服务端)', () => {
|
||||
expect(log.map((r) => r.kind)).toEqual(['announce']);
|
||||
|
||||
// 流式下载 + 流式解密
|
||||
const res = await fetch(api(`/channels/${owner.id}/items/${plan.itemId}/frames`), { headers: member.authHeaders('read') });
|
||||
const res = await fetch(api(`/channels/${owner.id}/items/${plan.itemId}/frames`), { headers: headersFor(member, 'read') });
|
||||
expect(res.status).toBe(200);
|
||||
const { content: stream, result } = member.decryptItemStream(res.body!, { itemId: plan.idBytes });
|
||||
const out = await readAll(stream);
|
||||
@@ -278,15 +295,15 @@ suite('端到端(真实服务端)', () => {
|
||||
await send(owner, plan, 'owner');
|
||||
|
||||
const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`);
|
||||
const first = await fetch(url, { headers: owner.authHeaders('owner') });
|
||||
const first = await fetch(url, { headers: headersFor(owner, 'owner') });
|
||||
const decoded = owner.decryptItemStream(first.body!);
|
||||
expect(Buffer.compare(await readAll(decoded.content), content)).toBe(0);
|
||||
|
||||
const second = await fetch(url, { headers: owner.authHeaders('owner') });
|
||||
const second = await fetch(url, { headers: headersFor(owner, 'owner') });
|
||||
expect(second.status).toBe(410);
|
||||
expect(((await second.json()) as { error: string }).error).toBe('gone');
|
||||
|
||||
const resumed = await fetch(`${url}?from=1`, { headers: owner.authHeaders('owner') });
|
||||
const resumed = await fetch(`${url}?from=1`, { headers: headersFor(owner, 'owner') });
|
||||
expect(resumed.status).toBe(200);
|
||||
await resumed.arrayBuffer();
|
||||
}, 60_000);
|
||||
@@ -308,7 +325,7 @@ suite('端到端(真实服务端)', () => {
|
||||
if (sent === 2) break;
|
||||
const res = await fetch(api(`/channels/${owner.id}/frames`), {
|
||||
method: 'POST',
|
||||
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream', ...(isFirst ? first.frame0Headers : {}) },
|
||||
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream', ...(isFirst ? first.frame0Headers : {}) },
|
||||
body: f.bytes,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
@@ -322,14 +339,14 @@ suite('端到端(真实服务端)', () => {
|
||||
for await (const f of rest.frames) {
|
||||
const res = await fetch(api(`/channels/${owner.id}/frames`), {
|
||||
method: 'POST',
|
||||
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' },
|
||||
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
|
||||
body: f.bytes,
|
||||
});
|
||||
statuses.push(res.status);
|
||||
}
|
||||
expect(statuses).toEqual([200, 201, 201]); // 第 1 帧已存在(幂等),第 2、3 帧是新的
|
||||
|
||||
const res = await fetch(api(`/channels/${owner.id}/items/${toBase64Url(itemId)}/frames`), { headers: owner.authHeaders('owner') });
|
||||
const res = await fetch(api(`/channels/${owner.id}/items/${toBase64Url(itemId)}/frames`), { headers: headersFor(owner, 'owner') });
|
||||
const { content: stream, result } = owner.decryptItemStream(res.body!);
|
||||
expect(Buffer.compare(await readAll(stream), content)).toBe(0);
|
||||
expect((await result).complete).toBe(true);
|
||||
@@ -343,7 +360,7 @@ suite('端到端(真实服务端)', () => {
|
||||
const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`);
|
||||
|
||||
// 第一次:只读前约 1.5 帧就断开
|
||||
const first = await fetch(url, { headers: owner.authHeaders('owner') });
|
||||
const first = await fetch(url, { headers: headersFor(owner, 'owner') });
|
||||
const reader = first.body!.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let got = 0;
|
||||
@@ -361,7 +378,7 @@ suite('端到端(真实服务端)', () => {
|
||||
})();
|
||||
expect(part1.result.complete).toBe(false);
|
||||
|
||||
const second = await fetch(`${url}?from=${part1.result.nextIndex}`, { headers: owner.authHeaders('owner') });
|
||||
const second = await fetch(`${url}?from=${part1.result.nextIndex}`, { headers: headersFor(owner, 'owner') });
|
||||
expect(second.status).toBe(200);
|
||||
const dec2 = owner.decryptItemStream(second.body!, { fromIndex: part1.result.nextIndex });
|
||||
const part2 = await readAll(dec2.content);
|
||||
@@ -378,12 +395,12 @@ suite('端到端(真实服务端)', () => {
|
||||
const blob = await member.sealLabel('我的 iPhone');
|
||||
const put = await fetch(api(`/channels/${owner.id}/seats/me`), {
|
||||
method: 'PUT',
|
||||
headers: { ...member.authHeaders('read'), 'Content-Type': 'application/octet-stream' },
|
||||
headers: { ...headersFor(member, 'read'), 'Content-Type': 'application/octet-stream' },
|
||||
body: blob,
|
||||
});
|
||||
expect(put.status).toBe(204);
|
||||
|
||||
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: owner.authHeaders('owner') });
|
||||
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: headersFor(owner, 'owner') });
|
||||
const seats = (await res.json()) as Array<{ endpoint_hash: string; label?: string }>;
|
||||
const mine = seats.find((s) => s.endpoint_hash === toBase64Url(member.endpointHash))!;
|
||||
expect(await owner.openLabel(mine.label!, mine.endpoint_hash)).toBe('我的 iPhone');
|
||||
@@ -396,7 +413,7 @@ suite('端到端(真实服务端)', () => {
|
||||
const blob = await owner.sealLabel('x'.repeat(228));
|
||||
const put = await fetch(api(`/channels/${owner.id}/seats/me`), {
|
||||
method: 'PUT',
|
||||
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' },
|
||||
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
|
||||
body: blob,
|
||||
});
|
||||
expect(put.status).toBe(204);
|
||||
@@ -415,11 +432,11 @@ suite('端到端(真实服务端)', () => {
|
||||
for (const r of records) out.push(...(await reader.push(r)));
|
||||
return out;
|
||||
})());
|
||||
expect(item!.kind).toBe('raw');
|
||||
expect(item!.kind).toBe('text');
|
||||
expect(itemText(item!)).toBe('raw 明文 visible');
|
||||
|
||||
// 对比 enc:raw 的明文确实出现在服务端返回的字节里
|
||||
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: member.authHeaders('read') });
|
||||
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: headersFor(member, 'read') });
|
||||
expect(new TextDecoder().decode(await res.arrayBuffer())).toContain('raw 明文 visible');
|
||||
});
|
||||
|
||||
@@ -428,8 +445,8 @@ suite('端到端(真实服务端)', () => {
|
||||
const member = await ChannelSession.open(owner.link(ORIGIN));
|
||||
await joinChannel(member, 'read');
|
||||
|
||||
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: { ...member.authHeaders('read') } });
|
||||
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: { ...headersFor(member, 'read') } });
|
||||
expect(res.status).toBe(403);
|
||||
expect(() => member.authHeaders('owner')).toThrow();
|
||||
expect(() => headersFor(member, 'owner')).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user