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,170 +0,0 @@
|
|
|
1
|
-
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import type { ProcessingStatus } from 'cursedbelt-core/media';
|
|
4
|
-
import {
|
|
5
|
-
hlsLadderArgs,
|
|
6
|
-
isFfmpegAvailable,
|
|
7
|
-
LIBRARY_CRF,
|
|
8
|
-
probeVideoStream,
|
|
9
|
-
resolveLadder,
|
|
10
|
-
runFfmpegProgress,
|
|
11
|
-
} from '../media-bun';
|
|
12
|
-
import {
|
|
13
|
-
type MediaStore,
|
|
14
|
-
privateHlsFileKey,
|
|
15
|
-
privateHlsMasterKey,
|
|
16
|
-
privateSourceKey,
|
|
17
|
-
} from '../storage/types';
|
|
18
|
-
import { cryptBlobStream, generateContentKey, wrapContentKey } from './crypto';
|
|
19
|
-
|
|
20
|
-
// ── Encrypted-HLS pipeline (private media) ──────────────────────────────────────
|
|
21
|
-
// Transcodes a private upload into an AES-128-encrypted HLS ladder: ffmpeg encrypts every
|
|
22
|
-
// segment with a per-item content key (via a key-info file), so the segments are ciphertext at
|
|
23
|
-
// rest (Spotlight-safe, opaque names) AND safe to edge-cache — the player fetches the content
|
|
24
|
-
// key from the gated key route and decrypts client-side (fast). The plaintext source is staged
|
|
25
|
-
// only in the work dir and never persisted; only the encrypted segments + playlists are stored.
|
|
26
|
-
// The content key is returned WRAPPED under the caller's set key (server-held or password-derived).
|
|
27
|
-
|
|
28
|
-
const KEY_URI = 'enc.key'; // relative in the playlist → the gated key route serves it per-video
|
|
29
|
-
|
|
30
|
-
export type PrivateProcessResult = {
|
|
31
|
-
status: ProcessingStatus;
|
|
32
|
-
durationSeconds: number;
|
|
33
|
-
hasHls: boolean;
|
|
34
|
-
/** The AES-128 content key + IV, wrapped under the caller's set key. Null if processing failed. */
|
|
35
|
-
wrappedContentKey: string | null;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export type PrivateProcessOptions = {
|
|
39
|
-
store: MediaStore;
|
|
40
|
-
/** Scratch dir for ffmpeg I/O + the transient key file (cleaned up afterwards). */
|
|
41
|
-
workDir: string;
|
|
42
|
-
owner: string;
|
|
43
|
-
id: string;
|
|
44
|
-
/** Local PLAINTEXT source, staged by the caller; never persisted (deleted with the job dir). */
|
|
45
|
-
sourcePath: string;
|
|
46
|
-
/** The mode key (server-held or password-derived) the content key is wrapped under. */
|
|
47
|
-
setKey: Buffer;
|
|
48
|
-
ladderHeights?: number[];
|
|
49
|
-
onProgress?: (status: ProcessingStatus, pct: number) => void;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
async function uploadTree(store: MediaStore, dir: string, owner: string, id: string) {
|
|
53
|
-
const walk = async (rel: string): Promise<void> => {
|
|
54
|
-
const entries = await readdir(path.join(dir, rel), { withFileTypes: true });
|
|
55
|
-
for (const e of entries) {
|
|
56
|
-
const childRel = rel ? path.join(rel, e.name) : e.name;
|
|
57
|
-
if (e.isDirectory()) {
|
|
58
|
-
await walk(childRel);
|
|
59
|
-
} else {
|
|
60
|
-
const mime = e.name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t';
|
|
61
|
-
const file = Bun.file(path.join(dir, childRel));
|
|
62
|
-
await store.putStream(privateHlsFileKey(owner, id, childRel), file.stream(), mime, {
|
|
63
|
-
contentLength: file.size,
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
};
|
|
68
|
-
await walk('');
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export async function processPrivateVideo(
|
|
72
|
-
opts: PrivateProcessOptions,
|
|
73
|
-
): Promise<PrivateProcessResult> {
|
|
74
|
-
const { store, workDir, owner, id, sourcePath, setKey, onProgress } = opts;
|
|
75
|
-
const report = (s: ProcessingStatus, p: number) => onProgress?.(s, p);
|
|
76
|
-
const failed: PrivateProcessResult = {
|
|
77
|
-
status: 'failed',
|
|
78
|
-
durationSeconds: 0,
|
|
79
|
-
hasHls: false,
|
|
80
|
-
wrappedContentKey: null,
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
// Private media MUST be encrypted — unlike public it can't degrade to a plaintext fallback.
|
|
84
|
-
if (!(await isFfmpegAvailable())) return failed;
|
|
85
|
-
|
|
86
|
-
const jobDir = path.join(workDir, owner, id);
|
|
87
|
-
await mkdir(jobDir, { recursive: true });
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
report('processing', 1);
|
|
91
|
-
const probe = await probeVideoStream(sourcePath);
|
|
92
|
-
|
|
93
|
-
// Mint the AES-128 content key + write the ffmpeg key-info file (key URI / key file / IV).
|
|
94
|
-
const content = generateContentKey();
|
|
95
|
-
const keyFile = path.join(jobDir, 'enc.key.bin');
|
|
96
|
-
await writeFile(keyFile, content.key);
|
|
97
|
-
const keyInfoFile = path.join(jobDir, 'enc.keyinfo');
|
|
98
|
-
await writeFile(keyInfoFile, `${KEY_URI}\n${keyFile}\n${content.iv.toString('hex')}\n`);
|
|
99
|
-
|
|
100
|
-
const heights = resolveLadder(opts.ladderHeights ?? [480, 720, 1080], probe.height);
|
|
101
|
-
const hlsDir = path.join(jobDir, 'hls');
|
|
102
|
-
await mkdir(hlsDir, { recursive: true });
|
|
103
|
-
|
|
104
|
-
const ok = await runFfmpegProgress(
|
|
105
|
-
hlsLadderArgs(sourcePath, hlsDir, {
|
|
106
|
-
heights,
|
|
107
|
-
crf: Number(LIBRARY_CRF),
|
|
108
|
-
hasAudio: probe.hasAudio,
|
|
109
|
-
keyInfoFile,
|
|
110
|
-
}),
|
|
111
|
-
probe.duration,
|
|
112
|
-
(pct) => report('processing', Math.max(1, Math.min(98, pct))),
|
|
113
|
-
);
|
|
114
|
-
if (!ok) return failed;
|
|
115
|
-
|
|
116
|
-
// Upload ONLY the encrypted segments + playlists — never the key file (served via the route).
|
|
117
|
-
await uploadTree(store, hlsDir, owner, id);
|
|
118
|
-
|
|
119
|
-
report('ready', 100);
|
|
120
|
-
return {
|
|
121
|
-
status: 'ready',
|
|
122
|
-
durationSeconds: probe.duration,
|
|
123
|
-
hasHls: (await store.stat(privateHlsMasterKey(owner, id))) !== null,
|
|
124
|
-
wrappedContentKey: wrapContentKey(setKey, content),
|
|
125
|
-
};
|
|
126
|
-
} catch {
|
|
127
|
-
report('failed', 0);
|
|
128
|
-
return failed;
|
|
129
|
-
} finally {
|
|
130
|
-
// Wipe the job dir — the plaintext source, the raw key file, and the outputs all vanish.
|
|
131
|
-
await rm(jobDir, { recursive: true, force: true }).catch(() => {});
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ── Encrypted-blob pipeline (private NON-video files: photos, docs, audio) ────────
|
|
136
|
-
// A non-video private upload has no HLS ladder to build — it becomes ONE AES-128-CTR ciphertext
|
|
137
|
-
// blob at privateSourceKey, under the same per-item content-key material the HLS path wraps.
|
|
138
|
-
// Serving is the gated blob route in mountPrivateRoutes (decrypts server-side per request); the
|
|
139
|
-
// plaintext staging file is the caller's to wipe, mirroring processPrivateVideo.
|
|
140
|
-
|
|
141
|
-
export type PrivateBlobResult = {
|
|
142
|
-
status: ProcessingStatus;
|
|
143
|
-
sizeBytes: number;
|
|
144
|
-
/** The content key + IV, wrapped under the caller's set key. Null if processing failed. */
|
|
145
|
-
wrappedContentKey: string | null;
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
export async function processPrivateBlob(opts: {
|
|
149
|
-
store: MediaStore;
|
|
150
|
-
owner: string;
|
|
151
|
-
id: string;
|
|
152
|
-
/** Local PLAINTEXT source, staged by the caller; never persisted (caller deletes it). */
|
|
153
|
-
sourcePath: string;
|
|
154
|
-
setKey: Buffer;
|
|
155
|
-
}): Promise<PrivateBlobResult> {
|
|
156
|
-
try {
|
|
157
|
-
const file = Bun.file(opts.sourcePath);
|
|
158
|
-
const sizeBytes = file.size; // CTR is length-preserving, so plaintext size == stored size
|
|
159
|
-
const content = generateContentKey();
|
|
160
|
-
await opts.store.putStream(
|
|
161
|
-
privateSourceKey(opts.owner, opts.id),
|
|
162
|
-
cryptBlobStream(content, file.stream()),
|
|
163
|
-
'application/octet-stream',
|
|
164
|
-
{ contentLength: sizeBytes },
|
|
165
|
-
);
|
|
166
|
-
return { status: 'ready', sizeBytes, wrappedContentKey: wrapContentKey(opts.setKey, content) };
|
|
167
|
-
} catch {
|
|
168
|
-
return { status: 'failed', sizeBytes: 0, wrappedContentKey: null };
|
|
169
|
-
}
|
|
170
|
-
}
|
|
@@ -1,265 +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 { Hono } from 'hono';
|
|
7
|
-
import { isFfmpegAvailable } from '../media-bun';
|
|
8
|
-
import { createDiskStore } from '../storage/diskStore';
|
|
9
|
-
import { generateUserSalt, sealMeta } from './crypto';
|
|
10
|
-
import { createMemoryPrivateIndex, type PrivateItem } from './index-store';
|
|
11
|
-
import { mountPrivateRoutes } from './mountPrivateRoutes';
|
|
12
|
-
import { processPrivateVideo } from './pipeline';
|
|
13
|
-
import { createUnlockService } from './unlock';
|
|
14
|
-
|
|
15
|
-
const unlock = createUnlockService({ serverSecret: 'srv', unlockSecret: 'unlock' });
|
|
16
|
-
|
|
17
|
-
describe('unlock service', () => {
|
|
18
|
-
test('round-trips a deniable set key through a token', async () => {
|
|
19
|
-
const { token, mode } = await unlock.unlock({ userId: 'u1', userSalt: 's', password: 'pw' });
|
|
20
|
-
expect(mode).toBe('deniable');
|
|
21
|
-
const grant = await unlock.verify(token);
|
|
22
|
-
expect(grant?.userId).toBe('u1');
|
|
23
|
-
expect(grant?.mode).toBe('deniable');
|
|
24
|
-
// The recovered key matches a fresh derivation from the same password.
|
|
25
|
-
expect(
|
|
26
|
-
grant?.setKey.equals(unlock.deriveSetKey({ userId: 'u1', userSalt: 's', password: 'pw' })),
|
|
27
|
-
).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
test('no password ⇒ standard (recoverable) mode', async () => {
|
|
30
|
-
const { mode } = await unlock.unlock({ userId: 'u1', userSalt: 's' });
|
|
31
|
-
expect(mode).toBe('standard');
|
|
32
|
-
});
|
|
33
|
-
test('a token from a different secret does not verify', async () => {
|
|
34
|
-
const other = createUnlockService({ serverSecret: 'srv', unlockSecret: 'DIFFERENT' });
|
|
35
|
-
const { token } = await other.unlock({ userId: 'u1', userSalt: 's', password: 'pw' });
|
|
36
|
-
expect(await unlock.verify(token)).toBeNull();
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
// Index + reveal need no ffmpeg — sealed metadata under two passwords, then the "try a password"
|
|
41
|
-
// endpoint reveals only the matching set; the index endpoint leaks only size + createdAt.
|
|
42
|
-
describe('index + reveal routes', () => {
|
|
43
|
-
const index = createMemoryPrivateIndex();
|
|
44
|
-
const salt = generateUserSalt();
|
|
45
|
-
const app = new Hono();
|
|
46
|
-
mountPrivateRoutes(app, {
|
|
47
|
-
store: createDiskStore('/tmp/unused-pm'),
|
|
48
|
-
index,
|
|
49
|
-
unlock,
|
|
50
|
-
authorize: async () => ({ userId: 'u1', userSalt: salt }),
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
beforeAll(async () => {
|
|
54
|
-
const keyA = unlock.deriveSetKey({ userId: 'u1', userSalt: salt, password: 'alpha' });
|
|
55
|
-
const keyB = unlock.deriveSetKey({ userId: 'u1', userSalt: salt, password: 'bravo' });
|
|
56
|
-
const mk = (id: string, key: Buffer, title: string): PrivateItem => ({
|
|
57
|
-
id,
|
|
58
|
-
owner: 'u1',
|
|
59
|
-
sizeBytes: 1234,
|
|
60
|
-
createdAt: Date.now(),
|
|
61
|
-
status: 'ready',
|
|
62
|
-
keyMode: 'deniable',
|
|
63
|
-
wrappedContentKey: 'x',
|
|
64
|
-
sealedMeta: sealMeta(key, { title, notes: 'secret' }),
|
|
65
|
-
});
|
|
66
|
-
await index.add(mk('a1', keyA, 'Alpha One'));
|
|
67
|
-
await index.add(mk('a2', keyA, 'Alpha Two'));
|
|
68
|
-
await index.add(mk('b1', keyB, 'Bravo One'));
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('index exposes only size/createdAt/status — no title/notes/mode', async () => {
|
|
72
|
-
const res = await app.request('/_private/index');
|
|
73
|
-
const body = (await res.json()) as { items: Array<Record<string, unknown>> };
|
|
74
|
-
expect(body.items.length).toBe(3);
|
|
75
|
-
for (const row of body.items) {
|
|
76
|
-
expect(row.sizeBytes).toBe(1234);
|
|
77
|
-
expect(row).not.toHaveProperty('title');
|
|
78
|
-
expect(row).not.toHaveProperty('sealedMeta');
|
|
79
|
-
expect(row).not.toHaveProperty('keyMode');
|
|
80
|
-
expect(row).not.toHaveProperty('wrappedContentKey');
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test('reveal unlocks only the matching password set', async () => {
|
|
85
|
-
const res = await app.request('/_private/reveal', {
|
|
86
|
-
method: 'POST',
|
|
87
|
-
headers: { 'content-type': 'application/json' },
|
|
88
|
-
body: JSON.stringify({ password: 'alpha' }),
|
|
89
|
-
});
|
|
90
|
-
const body = (await res.json()) as { items: Array<{ id: string; meta: { title: string } }> };
|
|
91
|
-
expect(body.items.map((i) => i.id).sort()).toEqual(['a1', 'a2']);
|
|
92
|
-
expect(body.items.find((i) => i.id === 'a1')?.meta.title).toBe('Alpha One');
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test('a wrong password reveals nothing (no info leak)', async () => {
|
|
96
|
-
const res = await app.request('/_private/reveal', {
|
|
97
|
-
method: 'POST',
|
|
98
|
-
headers: { 'content-type': 'application/json' },
|
|
99
|
-
body: JSON.stringify({ password: 'zzz' }),
|
|
100
|
-
});
|
|
101
|
-
expect(((await res.json()) as { items: unknown[] }).items).toEqual([]);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test('reveal-unlocked: an unlock token reveals the same set as its password', async () => {
|
|
105
|
-
const { token } = await unlock.unlock({ userId: 'u1', userSalt: salt, password: 'alpha' });
|
|
106
|
-
const res = await app.request('/_private/reveal-unlocked', {
|
|
107
|
-
method: 'POST',
|
|
108
|
-
headers: { 'content-type': 'application/json' },
|
|
109
|
-
body: JSON.stringify({ token }),
|
|
110
|
-
});
|
|
111
|
-
expect(res.status).toBe(200);
|
|
112
|
-
const body = (await res.json()) as { items: Array<{ id: string; meta: { title: string } }> };
|
|
113
|
-
expect(body.items.map((i) => i.id).sort()).toEqual(['a1', 'a2']);
|
|
114
|
-
expect(body.items.find((i) => i.id === 'a1')?.meta.title).toBe('Alpha One');
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test('reveal-unlocked: a token for a DIFFERENT set reveals only that set', async () => {
|
|
118
|
-
const { token } = await unlock.unlock({ userId: 'u1', userSalt: salt, password: 'bravo' });
|
|
119
|
-
const res = await app.request('/_private/reveal-unlocked', {
|
|
120
|
-
method: 'POST',
|
|
121
|
-
headers: { 'content-type': 'application/json' },
|
|
122
|
-
body: JSON.stringify({ token }),
|
|
123
|
-
});
|
|
124
|
-
const body = (await res.json()) as { items: Array<{ id: string }> };
|
|
125
|
-
expect(body.items.map((i) => i.id)).toEqual(['b1']);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test('reveal-unlocked: missing / invalid / other-user tokens are rejected', async () => {
|
|
129
|
-
const missing = await app.request('/_private/reveal-unlocked', {
|
|
130
|
-
method: 'POST',
|
|
131
|
-
headers: { 'content-type': 'application/json' },
|
|
132
|
-
body: JSON.stringify({}),
|
|
133
|
-
});
|
|
134
|
-
expect(missing.status).toBe(400);
|
|
135
|
-
|
|
136
|
-
const bogus = await app.request('/_private/reveal-unlocked', {
|
|
137
|
-
method: 'POST',
|
|
138
|
-
headers: { 'content-type': 'application/json' },
|
|
139
|
-
body: JSON.stringify({ token: 'not-a-token' }),
|
|
140
|
-
});
|
|
141
|
-
expect(bogus.status).toBe(401);
|
|
142
|
-
|
|
143
|
-
// A valid token minted for ANOTHER user must not open this session's items.
|
|
144
|
-
const { token: foreign } = await unlock.unlock({
|
|
145
|
-
userId: 'u2',
|
|
146
|
-
userSalt: salt,
|
|
147
|
-
password: 'alpha',
|
|
148
|
-
});
|
|
149
|
-
const cross = await app.request('/_private/reveal-unlocked', {
|
|
150
|
-
method: 'POST',
|
|
151
|
-
headers: { 'content-type': 'application/json' },
|
|
152
|
-
body: JSON.stringify({ token: foreign }),
|
|
153
|
-
});
|
|
154
|
-
expect(cross.status).toBe(401);
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
// Full encrypted playback path — needs ffmpeg (no-ops without it).
|
|
159
|
-
describe('encrypted playback routes', () => {
|
|
160
|
-
let root: string;
|
|
161
|
-
let work: string;
|
|
162
|
-
let hasFfmpeg = false;
|
|
163
|
-
|
|
164
|
-
beforeAll(async () => {
|
|
165
|
-
hasFfmpeg = await isFfmpegAvailable();
|
|
166
|
-
root = await mkdtemp(path.join(tmpdir(), 'pm-r-store-'));
|
|
167
|
-
work = await mkdtemp(path.join(tmpdir(), 'pm-r-work-'));
|
|
168
|
-
});
|
|
169
|
-
afterAll(async () => {
|
|
170
|
-
await rm(root, { recursive: true, force: true });
|
|
171
|
-
await rm(work, { recursive: true, force: true });
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test('unlock → content key (gated) decrypts a served encrypted segment', async () => {
|
|
175
|
-
if (!hasFfmpeg) {
|
|
176
|
-
console.warn('[routes.spec] ffmpeg absent — skipping encrypted playback route test');
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
const store = createDiskStore(root);
|
|
180
|
-
const index = createMemoryPrivateIndex();
|
|
181
|
-
const salt = generateUserSalt();
|
|
182
|
-
const setKey = unlock.deriveSetKey({ userId: 'u1', userSalt: salt, password: 'pw' });
|
|
183
|
-
|
|
184
|
-
// Produce an encrypted item + register it in the index.
|
|
185
|
-
const clip = path.join(work, 'src.mp4');
|
|
186
|
-
const gen = Bun.spawn(
|
|
187
|
-
[
|
|
188
|
-
'ffmpeg',
|
|
189
|
-
'-y',
|
|
190
|
-
'-f',
|
|
191
|
-
'lavfi',
|
|
192
|
-
'-i',
|
|
193
|
-
'testsrc=size=854x480:rate=15:duration=3',
|
|
194
|
-
'-pix_fmt',
|
|
195
|
-
'yuv420p',
|
|
196
|
-
'-c:v',
|
|
197
|
-
'libx264',
|
|
198
|
-
'-preset',
|
|
199
|
-
'ultrafast',
|
|
200
|
-
'-t',
|
|
201
|
-
'3',
|
|
202
|
-
clip,
|
|
203
|
-
],
|
|
204
|
-
{ stdout: 'ignore', stderr: 'ignore' },
|
|
205
|
-
);
|
|
206
|
-
await gen.exited;
|
|
207
|
-
const res = await processPrivateVideo({
|
|
208
|
-
store,
|
|
209
|
-
workDir: work,
|
|
210
|
-
owner: 'u1',
|
|
211
|
-
id: 'v1',
|
|
212
|
-
sourcePath: clip,
|
|
213
|
-
setKey,
|
|
214
|
-
});
|
|
215
|
-
expect(res.status).toBe('ready');
|
|
216
|
-
await index.add({
|
|
217
|
-
id: 'v1',
|
|
218
|
-
owner: 'u1',
|
|
219
|
-
sizeBytes: 1000,
|
|
220
|
-
createdAt: Date.now(),
|
|
221
|
-
status: 'ready',
|
|
222
|
-
keyMode: 'deniable',
|
|
223
|
-
wrappedContentKey: res.wrappedContentKey as string,
|
|
224
|
-
sealedMeta: sealMeta(setKey, { title: 'Private Clip' }),
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
const app = new Hono();
|
|
228
|
-
mountPrivateRoutes(app, {
|
|
229
|
-
store,
|
|
230
|
-
index,
|
|
231
|
-
unlock,
|
|
232
|
-
authorize: async () => ({ userId: 'u1', userSalt: salt }),
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
// Unlock with the password → token.
|
|
236
|
-
const unlockRes = await app.request('/_private/unlock', {
|
|
237
|
-
method: 'POST',
|
|
238
|
-
headers: { 'content-type': 'application/json' },
|
|
239
|
-
body: JSON.stringify({ password: 'pw' }),
|
|
240
|
-
});
|
|
241
|
-
const token = ((await unlockRes.json()) as { token: string }).token;
|
|
242
|
-
|
|
243
|
-
// The gated key route returns the 16-byte content key.
|
|
244
|
-
const keyRes = await app.request(`/_private/u/${token}/hls/gallery/v1/480/enc.key`);
|
|
245
|
-
expect(keyRes.status).toBe(200);
|
|
246
|
-
expect(keyRes.headers.get('cache-control')).toBe('no-store');
|
|
247
|
-
const contentKey = new Uint8Array(await keyRes.arrayBuffer());
|
|
248
|
-
expect(contentKey.length).toBe(16);
|
|
249
|
-
|
|
250
|
-
// The segment route serves the ENCRYPTED bytes; decrypting with the key → valid MPEG-TS.
|
|
251
|
-
const segRes = await app.request(`/_private/u/${token}/hls/gallery/v1/480/seg_00000.ts`);
|
|
252
|
-
expect([200, 206]).toContain(segRes.status);
|
|
253
|
-
const segBytes = new Uint8Array(await segRes.arrayBuffer());
|
|
254
|
-
// Recover the IV from the variant playlist (#EXT-X-KEY … IV=0x…).
|
|
255
|
-
const plRes = await app.request(`/_private/u/${token}/hls/gallery/v1/480/playlist.m3u8`);
|
|
256
|
-
const playlist = await plRes.text();
|
|
257
|
-
const iv = Buffer.from(/IV=0x([0-9a-fA-F]+)/.exec(playlist)?.[1] ?? '', 'hex');
|
|
258
|
-
const d = createDecipheriv('aes-128-cbc', Buffer.from(contentKey), iv);
|
|
259
|
-
const plain = Buffer.concat([d.update(Buffer.from(segBytes)), d.final()]);
|
|
260
|
-
expect(plain[0]).toBe(0x47);
|
|
261
|
-
|
|
262
|
-
// A bogus token is rejected.
|
|
263
|
-
expect((await app.request('/_private/u/bogus/hls/gallery/v1/480/enc.key')).status).toBe(401);
|
|
264
|
-
}, 30_000);
|
|
265
|
-
});
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { decryptSecret, deriveKey, encryptSecret } from 'cwip/node';
|
|
2
|
-
// 🔴 `../auth/jwt`, NOT `../auth` — same reason as `storage/streamToken.ts`, and this is the
|
|
3
|
-
// SECOND edge of the pair: `cursedbelt-server/storage` reaches private-media, so the barrel
|
|
4
|
-
// import here put `otplib` (via `auth/index` → `totp.ts`) back into the storage graph after
|
|
5
|
-
// streamToken.ts had been fixed. Found by bundling the graph and reading the externals, not by
|
|
6
|
-
// grep — a two-hop edge is invisible to the obvious search.
|
|
7
|
-
import { signToken, verifyPurposeToken } from '../auth/jwt';
|
|
8
|
-
import { derivePasswordSetKey, deriveServerSetKey, type KeyMode } from './crypto';
|
|
9
|
-
|
|
10
|
-
// ── Unlock service ──────────────────────────────────────────────────────────────
|
|
11
|
-
// Turns a private-mode unlock into a short-lived token that CARRIES the set key so later
|
|
12
|
-
// requests (the HLS content-key route, reveal) can unwrap without re-deriving. Two modes:
|
|
13
|
-
// • deniable — password given → setKey = scrypt(password, userSalt); the server holds no
|
|
14
|
-
// copy, so the token is the ONLY way back to that set key until it expires.
|
|
15
|
-
// • standard — no password → setKey = scrypt(serverSecret, userId); recoverable, session-gated.
|
|
16
|
-
// The set key is SEALED inside the token (AES-GCM under a server seal key), so the client can't
|
|
17
|
-
// read it out; only this server can. The JWT is signed via cursedbelt's one jose integration
|
|
18
|
-
// under the existing 'pm-unlock' purpose, so it can't be replayed as an access token.
|
|
19
|
-
|
|
20
|
-
const PURPOSE = 'pm-unlock';
|
|
21
|
-
|
|
22
|
-
export type UnlockGrant = { userId: string; setKey: Buffer; mode: KeyMode };
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* What a supplied password MEANS at the unlock/reveal/private-upload surfaces:
|
|
26
|
-
* • derive (default) — the password derives the set key (deniable sets; the
|
|
27
|
-
* historical behavior).
|
|
28
|
-
* • gate — the password is an ACCESS CHECK against the app's own accounts
|
|
29
|
-
* (verifyPassword, e.g. the login store's argon2id verify) and the set key
|
|
30
|
-
* stays the recoverable server-derived one. Password changes never orphan
|
|
31
|
-
* content, and a password is REQUIRED to unlock (no silent session unlock).
|
|
32
|
-
*/
|
|
33
|
-
export type UnlockPasswordPolicy =
|
|
34
|
-
| { mode: 'derive' }
|
|
35
|
-
| {
|
|
36
|
-
mode: 'gate';
|
|
37
|
-
verifyPassword: (
|
|
38
|
-
who: { userId: string; userSalt: string },
|
|
39
|
-
password: string,
|
|
40
|
-
) => Promise<boolean>;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export interface UnlockService {
|
|
44
|
-
/** Mint an unlock token. Pass `password` for a deniable set; omit for the standard server-key
|
|
45
|
-
* set (the caller must already have authenticated the user). */
|
|
46
|
-
unlock(args: { userId: string; userSalt: string; password?: string }): Promise<{
|
|
47
|
-
token: string;
|
|
48
|
-
mode: KeyMode;
|
|
49
|
-
}>;
|
|
50
|
-
/** Verify a token and recover the sealed set key, or null if invalid/expired/tampered. */
|
|
51
|
-
verify(token: string): Promise<UnlockGrant | null>;
|
|
52
|
-
/** Derive a set key without minting a token (for the reveal endpoint's per-item trials). */
|
|
53
|
-
deriveSetKey(args: { userId: string; userSalt: string; password?: string }): Buffer;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function createUnlockService(opts: {
|
|
57
|
-
/** Derives the recoverable (standard) set key. */
|
|
58
|
-
serverSecret: string;
|
|
59
|
-
/** Signs the unlock JWT AND (domain-separated) seals the set key inside it. */
|
|
60
|
-
unlockSecret: string;
|
|
61
|
-
/** Token lifetime — long enough to survive a paused-and-resumed private video. Default 6h. */
|
|
62
|
-
ttlSeconds?: number;
|
|
63
|
-
}): UnlockService {
|
|
64
|
-
const ttl = opts.ttlSeconds ?? 6 * 60 * 60;
|
|
65
|
-
const sealKey = deriveKey(opts.unlockSecret, 'pm-unlock-seal');
|
|
66
|
-
|
|
67
|
-
const deriveSetKey = ({
|
|
68
|
-
userId,
|
|
69
|
-
userSalt,
|
|
70
|
-
password,
|
|
71
|
-
}: {
|
|
72
|
-
userId: string;
|
|
73
|
-
userSalt: string;
|
|
74
|
-
password?: string;
|
|
75
|
-
}): Buffer =>
|
|
76
|
-
password
|
|
77
|
-
? derivePasswordSetKey(password, userSalt)
|
|
78
|
-
: deriveServerSetKey(opts.serverSecret, userId);
|
|
79
|
-
|
|
80
|
-
return {
|
|
81
|
-
deriveSetKey,
|
|
82
|
-
async unlock(args) {
|
|
83
|
-
const mode: KeyMode = args.password ? 'deniable' : 'standard';
|
|
84
|
-
const setKey = deriveSetKey(args);
|
|
85
|
-
const token = await signToken(
|
|
86
|
-
{
|
|
87
|
-
userId: args.userId,
|
|
88
|
-
sessionId: 'pm',
|
|
89
|
-
roles: [],
|
|
90
|
-
purpose: PURPOSE,
|
|
91
|
-
sk: encryptSecret(setKey.toString('base64'), sealKey),
|
|
92
|
-
m: mode,
|
|
93
|
-
},
|
|
94
|
-
opts.unlockSecret,
|
|
95
|
-
ttl,
|
|
96
|
-
);
|
|
97
|
-
return { token, mode };
|
|
98
|
-
},
|
|
99
|
-
async verify(token) {
|
|
100
|
-
try {
|
|
101
|
-
const p = await verifyPurposeToken(token, opts.unlockSecret, PURPOSE);
|
|
102
|
-
const sealed = (p as { sk?: unknown }).sk;
|
|
103
|
-
const mode = (p as { m?: unknown }).m;
|
|
104
|
-
if (typeof sealed !== 'string' || (mode !== 'standard' && mode !== 'deniable')) return null;
|
|
105
|
-
const setKey = Buffer.from(decryptSecret(sealed, sealKey), 'base64');
|
|
106
|
-
return { userId: p.userId, setKey, mode };
|
|
107
|
-
} catch {
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
}
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { Database } from 'bun:sqlite';
|
|
2
|
-
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
3
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { runMigrations } from '../../migration/runner';
|
|
7
|
-
import { derivePasswordSetKey, generateUserSalt, openMeta } from '../../private-media/crypto';
|
|
8
|
-
import type { PrivateIndex } from '../../private-media/index-store';
|
|
9
|
-
import { createDiskStore } from '../diskStore';
|
|
10
|
-
import type { MediaCtx } from '../types';
|
|
11
|
-
import { createSqliteFileCatalogue } from './catalogue';
|
|
12
|
-
import { FILE_CATALOGUE_MIGRATIONS } from './migrations';
|
|
13
|
-
import type { PrivateFileCatalogue } from './types';
|
|
14
|
-
|
|
15
|
-
const CTX: MediaCtx = { userId: 'u1', tenantId: 't1' };
|
|
16
|
-
const stream = (s: string) =>
|
|
17
|
-
new Response(new TextEncoder().encode(s)).body as ReadableStream<Uint8Array>;
|
|
18
|
-
const OWNER = CTX.userId; // private owner axis = userId (matches mountPrivateRoutes)
|
|
19
|
-
|
|
20
|
-
// Seal/reveal/deniability is ffmpeg-independent: the descriptor is sealed regardless of transcode
|
|
21
|
-
// success, so these run everywhere. (Content-key unwrap needs a real transcode → ffmpeg-gated e2e.)
|
|
22
|
-
describe('FileCatalogue private media — ingest + PrivateIndex view + deniability', () => {
|
|
23
|
-
let db: Database;
|
|
24
|
-
let root: string;
|
|
25
|
-
let cat: PrivateFileCatalogue;
|
|
26
|
-
let index: PrivateIndex;
|
|
27
|
-
const salt = generateUserSalt();
|
|
28
|
-
const keyA = derivePasswordSetKey('alpha-pass', salt);
|
|
29
|
-
const keyB = derivePasswordSetKey('bravo-pass', salt);
|
|
30
|
-
|
|
31
|
-
beforeAll(async () => {
|
|
32
|
-
db = new Database(':memory:');
|
|
33
|
-
runMigrations(db, FILE_CATALOGUE_MIGRATIONS);
|
|
34
|
-
root = await mkdtemp(path.join(tmpdir(), 'fc-priv-'));
|
|
35
|
-
let clock = 1;
|
|
36
|
-
// owner = userId so private rows + mountPrivateRoutes agree on the scope.
|
|
37
|
-
cat = createSqliteFileCatalogue({
|
|
38
|
-
db,
|
|
39
|
-
store: createDiskStore(root),
|
|
40
|
-
workDir: path.join(root, 'w'),
|
|
41
|
-
owner: (c) => c.userId,
|
|
42
|
-
now: () => ++clock,
|
|
43
|
-
});
|
|
44
|
-
index = cat.asPrivateIndex();
|
|
45
|
-
});
|
|
46
|
-
afterAll(async () => {
|
|
47
|
-
db.close();
|
|
48
|
-
await rm(root, { recursive: true, force: true });
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test('ingests private rows under two disjoint passwords', async () => {
|
|
52
|
-
await cat.ingestPrivate(
|
|
53
|
-
CTX,
|
|
54
|
-
{ name: 'secret-a.mp4', mime: 'video/mp4', body: stream('a-bytes'), tags: ['setA'] },
|
|
55
|
-
{ setKey: keyA, keyMode: 'deniable' },
|
|
56
|
-
);
|
|
57
|
-
await cat.ingestPrivate(
|
|
58
|
-
CTX,
|
|
59
|
-
{ name: 'secret-b.mp4', mime: 'video/mp4', body: stream('b-bytes'), tags: ['setB'] },
|
|
60
|
-
{ setKey: keyB, keyMode: 'deniable' },
|
|
61
|
-
);
|
|
62
|
-
expect((await index.list(OWNER)).length).toBe(2);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test('a set key reveals only its own items (deniability) via the index envelopes', () => {
|
|
66
|
-
const reveal = async (key: Buffer) => {
|
|
67
|
-
const names: string[] = [];
|
|
68
|
-
for (const item of await index.list(OWNER)) {
|
|
69
|
-
const meta = openMeta<{ name: string }>(key, item.sealedMeta);
|
|
70
|
-
if (meta) names.push(meta.name);
|
|
71
|
-
}
|
|
72
|
-
return names.sort();
|
|
73
|
-
};
|
|
74
|
-
return Promise.all([
|
|
75
|
-
reveal(keyA),
|
|
76
|
-
reveal(keyB),
|
|
77
|
-
reveal(derivePasswordSetKey('nope', salt)),
|
|
78
|
-
]).then(([a, b, wrong]) => {
|
|
79
|
-
expect(a).toEqual(['secret-a.mp4']);
|
|
80
|
-
expect(b).toEqual(['secret-b.mp4']);
|
|
81
|
-
expect(wrong).toEqual([]); // an unused password shows nothing — no hint the sets exist
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test('the clear index exposes no set identifier or key mode difference', async () => {
|
|
86
|
-
// Both rows are keyMode 'deniable' here; the public projection (toPublic) that the /index route
|
|
87
|
-
// returns strips keyMode entirely — asserted by mountPrivateRoutes' own tests. Here we just
|
|
88
|
-
// confirm the raw items carry the wrapped key + sealed meta (opaque without a key).
|
|
89
|
-
for (const item of await index.list(OWNER)) {
|
|
90
|
-
expect(item.sealedMeta.length).toBeGreaterThan(0);
|
|
91
|
-
expect(openMeta(Buffer.alloc(32), item.sealedMeta)).toBeNull(); // a random key opens nothing
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test('index.remove drops the row', async () => {
|
|
96
|
-
const items = await index.list(OWNER);
|
|
97
|
-
await index.remove(OWNER, items[0].id);
|
|
98
|
-
expect((await index.list(OWNER)).length).toBe(1);
|
|
99
|
-
expect(await index.get(OWNER, items[0].id)).toBeNull();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('private rows never appear in the public feed', async () => {
|
|
103
|
-
const publicCat = cat as unknown as { list: (c: MediaCtx) => Promise<{ items: unknown[] }> };
|
|
104
|
-
expect((await publicCat.list(CTX)).items).toEqual([]);
|
|
105
|
-
});
|
|
106
|
-
});
|