cursedbelt-server 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/activity/index.d.ts +1 -1
- package/dist/server/activity/index.js +1 -1
- package/dist/server/auth/jwt.d.ts +1 -1
- package/dist/server/auth/jwt.js +2 -2
- package/dist/server/master-lock/accountsPage.d.ts +39 -0
- package/dist/server/master-lock/accountsPage.js +455 -0
- package/dist/server/master-lock/guard.js +57 -1
- package/dist/server/master-lock/index.d.ts +10 -2
- package/dist/server/master-lock/index.js +10 -2
- package/dist/server/master-lock/lockPage.d.ts +9 -0
- package/dist/server/master-lock/lockPage.js +44 -36
- package/dist/server/media-bun/streaming.d.ts +0 -1
- package/dist/server/media-bun/streaming.js +1 -1
- package/dist/server/storage/files/catalogue.d.ts +2 -2
- package/dist/server/storage/files/catalogue.js +18 -208
- package/dist/server/storage/files/fileRoutes.d.ts +2 -20
- package/dist/server/storage/files/fileRoutes.js +1 -74
- package/dist/server/storage/files/index.d.ts +1 -1
- package/dist/server/storage/files/index.js +1 -1
- package/dist/server/storage/files/migrations.js +9 -6
- package/dist/server/storage/files/stack.d.ts +6 -31
- package/dist/server/storage/files/stack.js +0 -27
- package/dist/server/storage/files/types.d.ts +10 -60
- package/dist/server/storage/files/types.js +9 -0
- package/dist/server/storage/index.d.ts +2 -2
- package/dist/server/storage/index.js +1 -1
- package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
- package/dist/server/storage/types.d.ts +0 -9
- package/dist/server/storage/types.js +2 -9
- package/package.json +2 -2
- package/src/leafSubpathsImportNothing.spec.ts +6 -4
- package/src/server/activity/index.ts +1 -1
- package/src/server/auth/jwt.ts +2 -2
- package/src/server/master-lock/accountsPage.spec.ts +231 -0
- package/src/server/master-lock/accountsPage.ts +465 -0
- package/src/server/master-lock/guard.spec.ts +25 -0
- package/src/server/master-lock/guard.ts +65 -1
- package/src/server/master-lock/index.ts +22 -2
- package/src/server/master-lock/lockPage.spec.ts +20 -16
- package/src/server/master-lock/lockPage.ts +45 -36
- package/src/server/media-bun/streaming.ts +0 -6
- package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
- package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
- package/src/server/storage/files/adapter.photo.spec.ts +2 -2
- package/src/server/storage/files/adapter.spec.ts +0 -1
- package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
- package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
- package/src/server/storage/files/catalogue.ts +19 -240
- package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
- package/src/server/storage/files/fileRoutes.spec.ts +3 -97
- package/src/server/storage/files/fileRoutes.ts +2 -96
- package/src/server/storage/files/index.ts +1 -4
- package/src/server/storage/files/migrations.ts +9 -7
- package/src/server/storage/files/stack.spec.ts +11 -111
- package/src/server/storage/files/stack.ts +9 -61
- package/src/server/storage/files/types.ts +20 -68
- package/src/server/storage/index.ts +0 -4
- package/src/server/storage/mountMediaRoutes.ts +1 -7
- package/src/server/storage/types.ts +2 -11
- package/src/shippedFilesAreTracked.spec.ts +3 -2
- package/dist/server/private-media/crypto.d.ts +0 -37
- package/dist/server/private-media/crypto.js +0 -111
- package/dist/server/private-media/index-store.d.ts +0 -31
- package/dist/server/private-media/index-store.js +0 -36
- package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
- package/dist/server/private-media/mountPrivateRoutes.js +0 -219
- package/dist/server/private-media/pipeline.d.ts +0 -37
- package/dist/server/private-media/pipeline.js +0 -97
- package/dist/server/private-media/unlock.d.ts +0 -52
- package/dist/server/private-media/unlock.js +0 -55
- package/src/server/private-media/blobRoute.spec.ts +0 -165
- package/src/server/private-media/crypto.spec.ts +0 -81
- package/src/server/private-media/crypto.ts +0 -155
- package/src/server/private-media/gatePolicy.spec.ts +0 -98
- package/src/server/private-media/index-store.ts +0 -83
- package/src/server/private-media/mountPrivateRoutes.ts +0 -265
- package/src/server/private-media/pipeline.spec.ts +0 -111
- package/src/server/private-media/pipeline.ts +0 -170
- package/src/server/private-media/routes.spec.ts +0 -265
- package/src/server/private-media/unlock.ts +0 -112
- package/src/server/storage/files/catalogue.private.spec.ts +0 -106
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { Hono } from 'hono';
|
|
3
|
-
import { createDiskStore } from '../storage/diskStore';
|
|
4
|
-
import { generateUserSalt, sealMeta } from './crypto';
|
|
5
|
-
import { createMemoryPrivateIndex } from './index-store';
|
|
6
|
-
import { mountPrivateRoutes } from './mountPrivateRoutes';
|
|
7
|
-
import { createUnlockService } from './unlock';
|
|
8
|
-
|
|
9
|
-
// The gate password policy: the password is an ACCESS CHECK against the app's own accounts
|
|
10
|
-
// (e.g. the login store), not a key-derivation input. Correct password ⇒ a STANDARD
|
|
11
|
-
// (recoverable, server-key) grant; wrong/absent password ⇒ 401; password changes never
|
|
12
|
-
// orphan content because the set key never depended on the password.
|
|
13
|
-
|
|
14
|
-
const unlock = createUnlockService({ serverSecret: 'srv', unlockSecret: 'unlock' });
|
|
15
|
-
const salt = generateUserSalt();
|
|
16
|
-
|
|
17
|
-
function gateApp(verify: (password: string) => boolean) {
|
|
18
|
-
const app = new Hono();
|
|
19
|
-
const index = createMemoryPrivateIndex();
|
|
20
|
-
mountPrivateRoutes(app, {
|
|
21
|
-
store: createDiskStore('/tmp/unused-pm-gate'),
|
|
22
|
-
index,
|
|
23
|
-
unlock,
|
|
24
|
-
authorize: async () => ({ userId: 'u1', userSalt: salt }),
|
|
25
|
-
passwordPolicy: {
|
|
26
|
-
mode: 'gate',
|
|
27
|
-
verifyPassword: async (_who, password) => verify(password),
|
|
28
|
-
},
|
|
29
|
-
});
|
|
30
|
-
return { app, index };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const post = (app: Hono, path: string, body: unknown) =>
|
|
34
|
-
app.request(path, {
|
|
35
|
-
method: 'POST',
|
|
36
|
-
headers: { 'content-type': 'application/json' },
|
|
37
|
-
body: JSON.stringify(body),
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe('unlock gate policy', () => {
|
|
41
|
-
test('correct password ⇒ standard grant (key never derived from the password)', async () => {
|
|
42
|
-
const { app } = gateApp((pw) => pw === 'login-pw');
|
|
43
|
-
const res = await post(app, '/_private/unlock', { password: 'login-pw' });
|
|
44
|
-
expect(res.status).toBe(200);
|
|
45
|
-
const { token, mode } = (await res.json()) as { token: string; mode: string };
|
|
46
|
-
expect(mode).toBe('standard');
|
|
47
|
-
const grant = await unlock.verify(token);
|
|
48
|
-
expect(grant?.mode).toBe('standard');
|
|
49
|
-
// The grant's key equals the server-derived one — NOT a password derivation.
|
|
50
|
-
expect(grant?.setKey.equals(unlock.deriveSetKey({ userId: 'u1', userSalt: salt }))).toBe(true);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test('wrong password ⇒ 401, no token', async () => {
|
|
54
|
-
const { app } = gateApp((pw) => pw === 'login-pw');
|
|
55
|
-
const res = await post(app, '/_private/unlock', { password: 'wrong' });
|
|
56
|
-
expect(res.status).toBe(401);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test('missing password ⇒ 401 (no silent session unlock under gate)', async () => {
|
|
60
|
-
const { app } = gateApp(() => true);
|
|
61
|
-
const res = await post(app, '/_private/unlock', {});
|
|
62
|
-
expect(res.status).toBe(401);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test('reveal under gate verifies then reveals the STANDARD set', async () => {
|
|
66
|
-
const { app, index } = gateApp((pw) => pw === 'login-pw');
|
|
67
|
-
const standardKey = unlock.deriveSetKey({ userId: 'u1', userSalt: salt });
|
|
68
|
-
await index.add({
|
|
69
|
-
id: 'item1',
|
|
70
|
-
owner: 'u1',
|
|
71
|
-
sealedMeta: sealMeta(standardKey, { name: 'secret.jpg', mime: 'image/jpeg', tags: [] }),
|
|
72
|
-
wrappedContentKey: 'unused',
|
|
73
|
-
sizeBytes: 3,
|
|
74
|
-
createdAt: Date.now(),
|
|
75
|
-
status: 'ready',
|
|
76
|
-
keyMode: 'standard',
|
|
77
|
-
});
|
|
78
|
-
const bad = await post(app, '/_private/reveal', { password: 'nope' });
|
|
79
|
-
expect(bad.status).toBe(401);
|
|
80
|
-
const good = await post(app, '/_private/reveal', { password: 'login-pw' });
|
|
81
|
-
expect(good.status).toBe(200);
|
|
82
|
-
const { items } = (await good.json()) as { items: Array<{ id: string }> };
|
|
83
|
-
expect(items.map((i) => i.id)).toEqual(['item1']);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('derive policy (default) still mints deniable sets from passwords', async () => {
|
|
87
|
-
const app = new Hono();
|
|
88
|
-
mountPrivateRoutes(app, {
|
|
89
|
-
store: createDiskStore('/tmp/unused-pm-gate'),
|
|
90
|
-
index: createMemoryPrivateIndex(),
|
|
91
|
-
unlock,
|
|
92
|
-
authorize: async () => ({ userId: 'u1', userSalt: salt }),
|
|
93
|
-
});
|
|
94
|
-
const res = await post(app, '/_private/unlock', { password: 'anything' });
|
|
95
|
-
expect(res.status).toBe(200);
|
|
96
|
-
expect(((await res.json()) as { mode: string }).mode).toBe('deniable');
|
|
97
|
-
});
|
|
98
|
-
});
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import type { ProcessingStatus } from 'cursedbelt-core/media';
|
|
2
|
-
import type { KeyMode } from './crypto';
|
|
3
|
-
|
|
4
|
-
// ── Private index ───────────────────────────────────────────────────────────────
|
|
5
|
-
// Per-user rows for encrypted media. The row holds ONLY non-sensitive facts in the clear
|
|
6
|
-
// (size, created-at, status) so a user can see "N locked files, X GB" and prune ones whose
|
|
7
|
-
// password they've lost. The sensitive parts — title / thumbnail / notes / tags — live in the
|
|
8
|
-
// AES-GCM `sealedMeta` envelope, and the AES-128 content key lives in `wrappedContentKey`; both
|
|
9
|
-
// are opaque without the set key. The public projection (`toPublic`) is what the index endpoint
|
|
10
|
-
// returns — it strips every secret, including the key MODE (so the clear index can't even hint
|
|
11
|
-
// which rows are deniable). An app swaps the in-memory store for a SQLite-backed one (see
|
|
12
|
-
// cursedbelt-server/sqlite) without touching the routes.
|
|
13
|
-
|
|
14
|
-
export type PrivateItem = {
|
|
15
|
-
id: string;
|
|
16
|
-
owner: string;
|
|
17
|
-
sizeBytes: number;
|
|
18
|
-
createdAt: number;
|
|
19
|
-
status: ProcessingStatus;
|
|
20
|
-
keyMode: KeyMode;
|
|
21
|
-
/** AES-128 content key + IV, wrapped under the item's set key. */
|
|
22
|
-
wrappedContentKey: string;
|
|
23
|
-
/** AES-GCM envelope of { title, notes, tags, durationSeconds, … } under the item's set key. */
|
|
24
|
-
sealedMeta: string;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/** What the index endpoint exposes — clear, non-sensitive facts only. No title/thumb/notes/tags,
|
|
28
|
-
* no key mode, no wrapped key. */
|
|
29
|
-
export type PrivateIndexRow = {
|
|
30
|
-
id: string;
|
|
31
|
-
sizeBytes: number;
|
|
32
|
-
createdAt: number;
|
|
33
|
-
status: ProcessingStatus;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export const toPublic = (item: PrivateItem): PrivateIndexRow => ({
|
|
37
|
-
id: item.id,
|
|
38
|
-
sizeBytes: item.sizeBytes,
|
|
39
|
-
createdAt: item.createdAt,
|
|
40
|
-
status: item.status,
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
export interface PrivateIndex {
|
|
44
|
-
add(item: PrivateItem): Promise<void>;
|
|
45
|
-
get(owner: string, id: string): Promise<PrivateItem | null>;
|
|
46
|
-
list(owner: string): Promise<PrivateItem[]>;
|
|
47
|
-
update(
|
|
48
|
-
owner: string,
|
|
49
|
-
id: string,
|
|
50
|
-
patch: Partial<Omit<PrivateItem, 'id' | 'owner'>>,
|
|
51
|
-
): Promise<void>;
|
|
52
|
-
remove(owner: string, id: string): Promise<void>;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function createMemoryPrivateIndex(): PrivateIndex {
|
|
56
|
-
const byOwner = new Map<string, Map<string, PrivateItem>>();
|
|
57
|
-
const bucket = (owner: string) => {
|
|
58
|
-
let m = byOwner.get(owner);
|
|
59
|
-
if (!m) {
|
|
60
|
-
m = new Map();
|
|
61
|
-
byOwner.set(owner, m);
|
|
62
|
-
}
|
|
63
|
-
return m;
|
|
64
|
-
};
|
|
65
|
-
return {
|
|
66
|
-
async add(item) {
|
|
67
|
-
bucket(item.owner).set(item.id, item);
|
|
68
|
-
},
|
|
69
|
-
async get(owner, id) {
|
|
70
|
-
return bucket(owner).get(id) ?? null;
|
|
71
|
-
},
|
|
72
|
-
async list(owner) {
|
|
73
|
-
return [...bucket(owner).values()].sort((a, b) => b.createdAt - a.createdAt);
|
|
74
|
-
},
|
|
75
|
-
async update(owner, id, patch) {
|
|
76
|
-
const item = bucket(owner).get(id);
|
|
77
|
-
if (item) bucket(owner).set(id, { ...item, ...patch });
|
|
78
|
-
},
|
|
79
|
-
async remove(owner, id) {
|
|
80
|
-
bucket(owner).delete(id);
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
}
|
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
import type { Context, Hono } from 'hono';
|
|
2
|
-
import { decodeRange, serveRange } from '../storage/streamRange';
|
|
3
|
-
import { type MediaStore, privateHlsFileKey, privateSourceKey } from '../storage/types';
|
|
4
|
-
import { cryptBlobRangeStream, cryptBlobStream, openMeta, unwrapContentKey } from './crypto';
|
|
5
|
-
import type { PrivateIndex } from './index-store';
|
|
6
|
-
import { toPublic } from './index-store';
|
|
7
|
-
import type { UnlockPasswordPolicy, UnlockService } from './unlock';
|
|
8
|
-
|
|
9
|
-
// Mounts the private-media routes. The unlock / index / reveal endpoints are session-guarded (a
|
|
10
|
-
// fetch CAN send the app's Bearer). The byte routes under /u/<token>/ are UN-guarded (hls.js
|
|
11
|
-
// can't send a Bearer) and authorized by the path-embedded unlock token instead — the same token
|
|
12
|
-
// carries the sealed set key, so the content-key route can unwrap the item's AES-128 key. The
|
|
13
|
-
// encrypted segments themselves are edge-cacheable ciphertext; only the tiny key is gated + no-store.
|
|
14
|
-
|
|
15
|
-
/** Access-Control-Allow-Origin resolver for split-origin private streaming (media on another
|
|
16
|
-
* origin than the SPA). Same shape as the storage layer's. */
|
|
17
|
-
export type CorsResolver =
|
|
18
|
-
| string
|
|
19
|
-
| string[]
|
|
20
|
-
| ((requestOrigin: string | undefined) => string | null | undefined);
|
|
21
|
-
|
|
22
|
-
function resolveAcao(cors: CorsResolver | undefined, reqOrigin: string | undefined): string | null {
|
|
23
|
-
if (!cors) return null;
|
|
24
|
-
if (typeof cors === 'string') return cors;
|
|
25
|
-
if (Array.isArray(cors)) return reqOrigin && cors.includes(reqOrigin) ? reqOrigin : null;
|
|
26
|
-
return cors(reqOrigin) ?? null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export type MountPrivateOptions = {
|
|
30
|
-
store: MediaStore;
|
|
31
|
-
index: PrivateIndex;
|
|
32
|
-
unlock: UnlockService;
|
|
33
|
-
/** Path prefix (default '/_private'). */
|
|
34
|
-
basePath?: string;
|
|
35
|
-
/** App session auth → the caller's id + their per-user salt (created lazily, stable). Return
|
|
36
|
-
* null to reject. Gates unlock/index/reveal (the byte routes use the path token instead). */
|
|
37
|
-
authorize: (c: Context) => Promise<{ userId: string; userSalt: string } | null>;
|
|
38
|
-
/** What a supplied password means (default: derive — deniable sets). `gate` verifies it
|
|
39
|
-
* against the app's accounts and mints the recoverable server-key grant instead. */
|
|
40
|
-
passwordPolicy?: UnlockPasswordPolicy;
|
|
41
|
-
/** Cross-origin access for split-origin deploys. */
|
|
42
|
-
cors?: CorsResolver;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export function mountPrivateRoutes(app: Hono, opts: MountPrivateOptions): void {
|
|
46
|
-
const base = opts.basePath ?? '/_private';
|
|
47
|
-
const { store, index, unlock } = opts;
|
|
48
|
-
const cors = (c: Context, res: Response): Response => {
|
|
49
|
-
const acao = resolveAcao(opts.cors, c.req.header('origin'));
|
|
50
|
-
if (acao) {
|
|
51
|
-
res.headers.set('Access-Control-Allow-Origin', acao);
|
|
52
|
-
res.headers.set(
|
|
53
|
-
'Access-Control-Expose-Headers',
|
|
54
|
-
'Content-Range, Content-Length, Accept-Ranges',
|
|
55
|
-
);
|
|
56
|
-
res.headers.append('Vary', 'Origin');
|
|
57
|
-
}
|
|
58
|
-
return res;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const policy = opts.passwordPolicy ?? { mode: 'derive' as const };
|
|
62
|
-
|
|
63
|
-
/** Resolve what the supplied password contributes under the active policy:
|
|
64
|
-
* derive ⇒ it feeds key derivation (may be undefined ⇒ standard set);
|
|
65
|
-
* gate ⇒ it must be present AND verify against the app's accounts, then the
|
|
66
|
-
* set key is the recoverable server one (password never feeds derivation).
|
|
67
|
-
* Returns an error Response to short-circuit with, or the derivation input. */
|
|
68
|
-
const resolvePassword = async (
|
|
69
|
-
c: Context,
|
|
70
|
-
who: { userId: string; userSalt: string },
|
|
71
|
-
password: string | undefined,
|
|
72
|
-
): Promise<{ derivePassword?: string } | { error: Response }> => {
|
|
73
|
-
if (policy.mode === 'derive') return { derivePassword: password };
|
|
74
|
-
if (!password) return { error: c.json({ error: 'password required' }, 401) };
|
|
75
|
-
if (!(await policy.verifyPassword(who, password))) {
|
|
76
|
-
return { error: c.json({ error: 'invalid password' }, 401) };
|
|
77
|
-
}
|
|
78
|
-
return {}; // verified — standard (server-key) set
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
// POST /unlock { password? } → a token sealing the set key (deniable w/ password, else
|
|
82
|
-
// standard; under the gate policy the password is verified and the set is always standard).
|
|
83
|
-
app.post(`${base}/unlock`, async (c) => {
|
|
84
|
-
const who = await opts.authorize(c);
|
|
85
|
-
if (!who) return c.json({ error: 'unauthorized' }, 401);
|
|
86
|
-
const body = (await c.req.json().catch(() => ({}))) as { password?: string };
|
|
87
|
-
const resolved = await resolvePassword(c, who, body.password || undefined);
|
|
88
|
-
if ('error' in resolved) return resolved.error;
|
|
89
|
-
const { token, mode } = await unlock.unlock({
|
|
90
|
-
userId: who.userId,
|
|
91
|
-
userSalt: who.userSalt,
|
|
92
|
-
password: resolved.derivePassword,
|
|
93
|
-
});
|
|
94
|
-
return c.json({ token, mode });
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
// GET /index → clear, non-sensitive rows only (size + createdAt + status). Never title/thumb/mode.
|
|
98
|
-
app.get(`${base}/index`, async (c) => {
|
|
99
|
-
const who = await opts.authorize(c);
|
|
100
|
-
if (!who) return c.json({ error: 'unauthorized' }, 401);
|
|
101
|
-
return c.json({ items: (await index.list(who.userId)).map(toPublic) });
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
// Decrypt every descriptor a set key opens, silently skipping the rest (decrypt-success IS the
|
|
105
|
-
// membership test — deniability). Shared by the password and token reveal routes.
|
|
106
|
-
const revealWithKey = async (userId: string, setKey: Buffer) => {
|
|
107
|
-
const revealed: Array<{ id: string; meta: unknown }> = [];
|
|
108
|
-
for (const item of await index.list(userId)) {
|
|
109
|
-
const meta = openMeta(setKey, item.sealedMeta);
|
|
110
|
-
if (meta !== null) revealed.push({ id: item.id, meta });
|
|
111
|
-
}
|
|
112
|
-
return revealed;
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
// POST /reveal { password } → which items that password unlocks (GCM decrypt-success), with
|
|
116
|
-
// their now-decrypted metadata. Others stay opaque. This is the "try a password" cleanup UX.
|
|
117
|
-
// Under the gate policy the password must verify first and the standard set is revealed.
|
|
118
|
-
app.post(`${base}/reveal`, async (c) => {
|
|
119
|
-
const who = await opts.authorize(c);
|
|
120
|
-
if (!who) return c.json({ error: 'unauthorized' }, 401);
|
|
121
|
-
const body = (await c.req.json().catch(() => ({}))) as { password?: string };
|
|
122
|
-
const resolved = await resolvePassword(c, who, body.password || undefined);
|
|
123
|
-
if ('error' in resolved) return resolved.error;
|
|
124
|
-
const setKey = unlock.deriveSetKey({
|
|
125
|
-
userId: who.userId,
|
|
126
|
-
userSalt: who.userSalt,
|
|
127
|
-
password: resolved.derivePassword,
|
|
128
|
-
});
|
|
129
|
-
return c.json({ items: await revealWithKey(who.userId, setKey) });
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// POST /reveal-unlocked { token } → the same reveal, keyed by an UNLOCK TOKEN instead of the
|
|
133
|
-
// password (the token seals the set key — same mechanism the content-key route uses). Lets an
|
|
134
|
-
// already-unlocked session list its items without re-typing the password. The token must belong
|
|
135
|
-
// to the session caller.
|
|
136
|
-
app.post(`${base}/reveal-unlocked`, async (c) => {
|
|
137
|
-
const who = await opts.authorize(c);
|
|
138
|
-
if (!who) return c.json({ error: 'unauthorized' }, 401);
|
|
139
|
-
const body = (await c.req.json().catch(() => ({}))) as { token?: string };
|
|
140
|
-
if (!body.token) return c.json({ error: 'token required' }, 400);
|
|
141
|
-
const grant = await unlock.verify(body.token);
|
|
142
|
-
if (!grant || grant.userId !== who.userId) return c.json({ error: 'invalid token' }, 401);
|
|
143
|
-
return c.json({ items: await revealWithKey(who.userId, grant.setKey) });
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
// GET /u/:token/hls/:source/:video/* → the encrypted HLS tree. The `enc.key` sub-path returns
|
|
147
|
-
// the unwrapped AES-128 content key (gated, no-store); every other path serves the encrypted
|
|
148
|
-
// segment/playlist (edge-cacheable — hls.js decrypts with the key).
|
|
149
|
-
app.get(`${base}/u/:token/hls/:source/:video/*`, async (c) => {
|
|
150
|
-
const raw = c.req.param('token');
|
|
151
|
-
if (!raw) return c.body(null, 401);
|
|
152
|
-
const grant = await unlock.verify(raw);
|
|
153
|
-
if (!grant) return c.body(null, 401);
|
|
154
|
-
const video = c.req.param('video');
|
|
155
|
-
const prefix = `${base}/u/${raw}/hls/${c.req.param('source')}/${video}/`;
|
|
156
|
-
const rel = c.req.path.slice(prefix.length);
|
|
157
|
-
if (!rel || rel.includes('..')) return c.body(null, 400);
|
|
158
|
-
|
|
159
|
-
if (rel.endsWith('enc.key')) {
|
|
160
|
-
const item = await index.get(grant.userId, video);
|
|
161
|
-
if (!item) return c.body(null, 404);
|
|
162
|
-
const content = unwrapContentKey(grant.setKey, item.wrappedContentKey);
|
|
163
|
-
// null ⇒ this token's set key doesn't own the item (wrong password / wrong mode).
|
|
164
|
-
if (!content) return c.body(null, 403);
|
|
165
|
-
// A fresh, tightly-sized ArrayBuffer copy of the 16-byte key — an unambiguous BodyInit
|
|
166
|
-
// (wrapping the Node Buffer directly can mis-size the response).
|
|
167
|
-
const keyBytes = Uint8Array.from(content.key);
|
|
168
|
-
return cors(
|
|
169
|
-
c,
|
|
170
|
-
new Response(keyBytes.buffer, {
|
|
171
|
-
status: 200,
|
|
172
|
-
headers: { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'no-store' },
|
|
173
|
-
}),
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const mime = rel.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t';
|
|
178
|
-
return cors(
|
|
179
|
-
c,
|
|
180
|
-
await serveRange(
|
|
181
|
-
store,
|
|
182
|
-
privateHlsFileKey(grant.userId, video, rel),
|
|
183
|
-
mime,
|
|
184
|
-
c.req.header('range'),
|
|
185
|
-
),
|
|
186
|
-
);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
// GET /u/:token/file/:id → a private file stored as one AES-128-CTR ciphertext blob at
|
|
190
|
-
// privateSourceKey. Decrypted server-side per request under the token's set key; the mime
|
|
191
|
-
// comes from the sealed descriptor, so a token whose set key can't open the item (wrong
|
|
192
|
-
// password / wrong mode) gets a 403 and never any bytes.
|
|
193
|
-
//
|
|
194
|
-
// RANGE SUPPORT (genapp-100): CTR is positionally keyed, so a byte range decrypts from the
|
|
195
|
-
// block-aligned ciphertext offset with the IV counter advanced (`cryptBlobRangeStream`). This
|
|
196
|
-
// is what makes a big private VIDEO scrub on a remote-store host, where no local ffmpeg means
|
|
197
|
-
// no HLS derivatives and the blob route is the only byte path. Plaintext is never stored.
|
|
198
|
-
app.get(`${base}/u/:token/file/:id`, async (c) => {
|
|
199
|
-
const raw = c.req.param('token');
|
|
200
|
-
if (!raw) return c.body(null, 401);
|
|
201
|
-
const grant = await unlock.verify(raw);
|
|
202
|
-
if (!grant) return c.body(null, 401);
|
|
203
|
-
const id = c.req.param('id');
|
|
204
|
-
const item = await index.get(grant.userId, id);
|
|
205
|
-
if (!item) return c.body(null, 404);
|
|
206
|
-
const content = unwrapContentKey(grant.setKey, item.wrappedContentKey);
|
|
207
|
-
if (!content) return c.body(null, 403);
|
|
208
|
-
const meta = openMeta<{ mime?: string }>(grant.setKey, item.sealedMeta);
|
|
209
|
-
const baseHeaders: Record<string, string> = {
|
|
210
|
-
'Content-Type': meta?.mime || 'application/octet-stream',
|
|
211
|
-
'Cache-Control': 'no-store',
|
|
212
|
-
'Accept-Ranges': 'bytes',
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
const key = privateSourceKey(grant.userId, id);
|
|
216
|
-
const range = item.sizeBytes > 0 ? decodeRange(c.req.header('range'), item.sizeBytes) : { kind: 'full' as const };
|
|
217
|
-
if (range.kind === 'unsatisfiable') {
|
|
218
|
-
return cors(
|
|
219
|
-
c,
|
|
220
|
-
new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${item.sizeBytes}` } }),
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
if (range.kind === 'partial') {
|
|
224
|
-
// Fetch ciphertext from the BLOCK-ALIGNED start (CTR block = 16 bytes); the range
|
|
225
|
-
// stream drops the intra-block lead-in so the client sees exactly [start, end].
|
|
226
|
-
const alignedStart = range.start - (range.start % 16);
|
|
227
|
-
const stored = await store.getRange(key, { start: alignedStart, end: range.end });
|
|
228
|
-
if (!stored) return c.body(null, 404);
|
|
229
|
-
return cors(
|
|
230
|
-
c,
|
|
231
|
-
new Response(cryptBlobRangeStream(content, stored.stream, range.start), {
|
|
232
|
-
status: 206,
|
|
233
|
-
headers: {
|
|
234
|
-
...baseHeaders,
|
|
235
|
-
'Content-Range': `bytes ${range.start}-${range.end}/${item.sizeBytes}`,
|
|
236
|
-
'Content-Length': String(range.end - range.start + 1),
|
|
237
|
-
},
|
|
238
|
-
}),
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const stored = await store.getRange(key);
|
|
243
|
-
if (!stored) return c.body(null, 404);
|
|
244
|
-
const headers = { ...baseHeaders };
|
|
245
|
-
if (item.sizeBytes > 0) headers['Content-Length'] = String(item.sizeBytes);
|
|
246
|
-
return cors(c, new Response(cryptBlobStream(content, stored.stream), { status: 200, headers }));
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
// Preflight for the cross-origin byte routes.
|
|
250
|
-
if (opts.cors) {
|
|
251
|
-
app.options(`${base}/u/*`, (c) => {
|
|
252
|
-
const acao = resolveAcao(opts.cors, c.req.header('origin'));
|
|
253
|
-
const headers: Record<string, string> = {
|
|
254
|
-
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
255
|
-
'Access-Control-Allow-Headers': 'Range',
|
|
256
|
-
'Access-Control-Max-Age': '86400',
|
|
257
|
-
};
|
|
258
|
-
if (acao) {
|
|
259
|
-
headers['Access-Control-Allow-Origin'] = acao;
|
|
260
|
-
headers.Vary = 'Origin';
|
|
261
|
-
}
|
|
262
|
-
return c.body(null, 204, headers);
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
}
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
2
|
-
import { createDecipheriv } from 'node:crypto';
|
|
3
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { isFfmpegAvailable } from '../media-bun';
|
|
7
|
-
import { createDiskStore } from '../storage/diskStore';
|
|
8
|
-
import { privateHlsFileKey, privateHlsMasterKey } from '../storage/types';
|
|
9
|
-
import { deriveServerSetKey, unwrapContentKey } from './crypto';
|
|
10
|
-
import { processPrivateVideo } from './pipeline';
|
|
11
|
-
|
|
12
|
-
// Integration test — needs ffmpeg on PATH. When absent (CI image without it) the tests no-op so
|
|
13
|
-
// the suite still passes; the pipeline itself returns 'failed' when ffmpeg is missing (private
|
|
14
|
-
// media can't degrade to plaintext), which is the intended behavior.
|
|
15
|
-
describe('processPrivateVideo (encrypted HLS)', () => {
|
|
16
|
-
let root: string;
|
|
17
|
-
let work: string;
|
|
18
|
-
let clip: string;
|
|
19
|
-
let hasFfmpeg = false;
|
|
20
|
-
|
|
21
|
-
beforeAll(async () => {
|
|
22
|
-
hasFfmpeg = await isFfmpegAvailable();
|
|
23
|
-
root = await mkdtemp(path.join(tmpdir(), 'pm-store-'));
|
|
24
|
-
work = await mkdtemp(path.join(tmpdir(), 'pm-work-'));
|
|
25
|
-
if (!hasFfmpeg) return;
|
|
26
|
-
clip = path.join(work, 'src.mp4');
|
|
27
|
-
const proc = Bun.spawn(
|
|
28
|
-
[
|
|
29
|
-
'ffmpeg',
|
|
30
|
-
'-y',
|
|
31
|
-
'-f',
|
|
32
|
-
'lavfi',
|
|
33
|
-
'-i',
|
|
34
|
-
'testsrc=size=854x480:rate=15:duration=3',
|
|
35
|
-
'-pix_fmt',
|
|
36
|
-
'yuv420p',
|
|
37
|
-
'-c:v',
|
|
38
|
-
'libx264',
|
|
39
|
-
'-preset',
|
|
40
|
-
'ultrafast',
|
|
41
|
-
'-t',
|
|
42
|
-
'3',
|
|
43
|
-
clip,
|
|
44
|
-
],
|
|
45
|
-
{ stdout: 'ignore', stderr: 'ignore' },
|
|
46
|
-
);
|
|
47
|
-
await proc.exited;
|
|
48
|
-
});
|
|
49
|
-
afterAll(async () => {
|
|
50
|
-
await rm(root, { recursive: true, force: true });
|
|
51
|
-
await rm(work, { recursive: true, force: true });
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('encrypts HLS segments; the wrapped key unwraps and decrypts a segment to valid MPEG-TS', async () => {
|
|
55
|
-
if (!hasFfmpeg) {
|
|
56
|
-
console.warn('[pipeline.spec] ffmpeg absent — skipping encrypted-HLS integration test');
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
const store = createDiskStore(root);
|
|
60
|
-
const setKey = deriveServerSetKey('server-secret', 'user-1');
|
|
61
|
-
|
|
62
|
-
const res = await processPrivateVideo({
|
|
63
|
-
store,
|
|
64
|
-
workDir: work,
|
|
65
|
-
owner: 't1',
|
|
66
|
-
id: 'v1',
|
|
67
|
-
sourcePath: clip,
|
|
68
|
-
setKey,
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
expect(res.status).toBe('ready');
|
|
72
|
-
expect(res.hasHls).toBe(true);
|
|
73
|
-
expect(res.wrappedContentKey).not.toBeNull();
|
|
74
|
-
|
|
75
|
-
// The master + a 480p variant playlist exist, and the playlist declares AES-128 encryption.
|
|
76
|
-
expect(await store.stat(privateHlsMasterKey('t1', 'v1'))).not.toBeNull();
|
|
77
|
-
const playlistBytes = await store.get(privateHlsFileKey('t1', 'v1', '480/playlist.m3u8'));
|
|
78
|
-
expect(playlistBytes).not.toBeNull();
|
|
79
|
-
const playlist = new TextDecoder().decode(playlistBytes as Uint8Array);
|
|
80
|
-
expect(playlist).toContain('#EXT-X-KEY:METHOD=AES-128');
|
|
81
|
-
expect(playlist).toContain('URI="enc.key"');
|
|
82
|
-
|
|
83
|
-
// The raw content key never leaves wrapped — but unwrapping with the set key recovers it,
|
|
84
|
-
// and it correctly AES-128-CBC-decrypts a real segment back to an MPEG-TS stream (0x47 sync).
|
|
85
|
-
const content = unwrapContentKey(setKey, res.wrappedContentKey as string);
|
|
86
|
-
expect(content).not.toBeNull();
|
|
87
|
-
expect(content?.key.length).toBe(16);
|
|
88
|
-
|
|
89
|
-
const seg = await store.get(privateHlsFileKey('t1', 'v1', '480/seg_00000.ts'));
|
|
90
|
-
expect(seg).not.toBeNull();
|
|
91
|
-
const d = createDecipheriv('aes-128-cbc', content?.key as Buffer, content?.iv as Buffer);
|
|
92
|
-
const plain = Buffer.concat([d.update(Buffer.from(seg as Uint8Array)), d.final()]);
|
|
93
|
-
expect(plain[0]).toBe(0x47); // MPEG-TS sync byte ⇒ decryption + key are correct
|
|
94
|
-
}, 30_000);
|
|
95
|
-
|
|
96
|
-
test('returns failed when ffmpeg is unavailable (private media cannot go plaintext)', async () => {
|
|
97
|
-
// Documents the contract; only meaningfully exercised on a machine without ffmpeg.
|
|
98
|
-
if (hasFfmpeg) return;
|
|
99
|
-
const store = createDiskStore(root);
|
|
100
|
-
const res = await processPrivateVideo({
|
|
101
|
-
store,
|
|
102
|
-
workDir: work,
|
|
103
|
-
owner: 't',
|
|
104
|
-
id: 'v',
|
|
105
|
-
sourcePath: '/nonexistent',
|
|
106
|
-
setKey: deriveServerSetKey('s', 'u'),
|
|
107
|
-
});
|
|
108
|
-
expect(res.status).toBe('failed');
|
|
109
|
-
expect(res.wrappedContentKey).toBeNull();
|
|
110
|
-
});
|
|
111
|
-
});
|