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.
Files changed (81) hide show
  1. package/dist/server/activity/index.d.ts +1 -1
  2. package/dist/server/activity/index.js +1 -1
  3. package/dist/server/auth/jwt.d.ts +1 -1
  4. package/dist/server/auth/jwt.js +2 -2
  5. package/dist/server/master-lock/accountsPage.d.ts +39 -0
  6. package/dist/server/master-lock/accountsPage.js +455 -0
  7. package/dist/server/master-lock/guard.js +57 -1
  8. package/dist/server/master-lock/index.d.ts +10 -2
  9. package/dist/server/master-lock/index.js +10 -2
  10. package/dist/server/master-lock/lockPage.d.ts +9 -0
  11. package/dist/server/master-lock/lockPage.js +44 -36
  12. package/dist/server/media-bun/streaming.d.ts +0 -1
  13. package/dist/server/media-bun/streaming.js +1 -1
  14. package/dist/server/storage/files/catalogue.d.ts +2 -2
  15. package/dist/server/storage/files/catalogue.js +18 -208
  16. package/dist/server/storage/files/fileRoutes.d.ts +2 -20
  17. package/dist/server/storage/files/fileRoutes.js +1 -74
  18. package/dist/server/storage/files/index.d.ts +1 -1
  19. package/dist/server/storage/files/index.js +1 -1
  20. package/dist/server/storage/files/migrations.js +9 -6
  21. package/dist/server/storage/files/stack.d.ts +6 -31
  22. package/dist/server/storage/files/stack.js +0 -27
  23. package/dist/server/storage/files/types.d.ts +10 -60
  24. package/dist/server/storage/files/types.js +9 -0
  25. package/dist/server/storage/index.d.ts +2 -2
  26. package/dist/server/storage/index.js +1 -1
  27. package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
  28. package/dist/server/storage/types.d.ts +0 -9
  29. package/dist/server/storage/types.js +2 -9
  30. package/package.json +2 -2
  31. package/src/leafSubpathsImportNothing.spec.ts +6 -4
  32. package/src/server/activity/index.ts +1 -1
  33. package/src/server/auth/jwt.ts +2 -2
  34. package/src/server/master-lock/accountsPage.spec.ts +231 -0
  35. package/src/server/master-lock/accountsPage.ts +465 -0
  36. package/src/server/master-lock/guard.spec.ts +25 -0
  37. package/src/server/master-lock/guard.ts +65 -1
  38. package/src/server/master-lock/index.ts +22 -2
  39. package/src/server/master-lock/lockPage.spec.ts +20 -16
  40. package/src/server/master-lock/lockPage.ts +45 -36
  41. package/src/server/media-bun/streaming.ts +0 -6
  42. package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
  43. package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
  44. package/src/server/storage/files/adapter.photo.spec.ts +2 -2
  45. package/src/server/storage/files/adapter.spec.ts +0 -1
  46. package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
  47. package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
  48. package/src/server/storage/files/catalogue.ts +19 -240
  49. package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
  50. package/src/server/storage/files/fileRoutes.spec.ts +3 -97
  51. package/src/server/storage/files/fileRoutes.ts +2 -96
  52. package/src/server/storage/files/index.ts +1 -4
  53. package/src/server/storage/files/migrations.ts +9 -7
  54. package/src/server/storage/files/stack.spec.ts +11 -111
  55. package/src/server/storage/files/stack.ts +9 -61
  56. package/src/server/storage/files/types.ts +20 -68
  57. package/src/server/storage/index.ts +0 -4
  58. package/src/server/storage/mountMediaRoutes.ts +1 -7
  59. package/src/server/storage/types.ts +2 -11
  60. package/src/shippedFilesAreTracked.spec.ts +3 -2
  61. package/dist/server/private-media/crypto.d.ts +0 -37
  62. package/dist/server/private-media/crypto.js +0 -111
  63. package/dist/server/private-media/index-store.d.ts +0 -31
  64. package/dist/server/private-media/index-store.js +0 -36
  65. package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
  66. package/dist/server/private-media/mountPrivateRoutes.js +0 -219
  67. package/dist/server/private-media/pipeline.d.ts +0 -37
  68. package/dist/server/private-media/pipeline.js +0 -97
  69. package/dist/server/private-media/unlock.d.ts +0 -52
  70. package/dist/server/private-media/unlock.js +0 -55
  71. package/src/server/private-media/blobRoute.spec.ts +0 -165
  72. package/src/server/private-media/crypto.spec.ts +0 -81
  73. package/src/server/private-media/crypto.ts +0 -155
  74. package/src/server/private-media/gatePolicy.spec.ts +0 -98
  75. package/src/server/private-media/index-store.ts +0 -83
  76. package/src/server/private-media/mountPrivateRoutes.ts +0 -265
  77. package/src/server/private-media/pipeline.spec.ts +0 -111
  78. package/src/server/private-media/pipeline.ts +0 -170
  79. package/src/server/private-media/routes.spec.ts +0 -265
  80. package/src/server/private-media/unlock.ts +0 -112
  81. package/src/server/storage/files/catalogue.private.spec.ts +0 -106
@@ -1,97 +0,0 @@
1
- import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { hlsLadderArgs, isFfmpegAvailable, LIBRARY_CRF, probeVideoStream, resolveLadder, runFfmpegProgress, } from '../media-bun';
4
- import { privateHlsFileKey, privateHlsMasterKey, privateSourceKey, } from '../storage/types';
5
- import { cryptBlobStream, generateContentKey, wrapContentKey } from './crypto';
6
- // ── Encrypted-HLS pipeline (private media) ──────────────────────────────────────
7
- // Transcodes a private upload into an AES-128-encrypted HLS ladder: ffmpeg encrypts every
8
- // segment with a per-item content key (via a key-info file), so the segments are ciphertext at
9
- // rest (Spotlight-safe, opaque names) AND safe to edge-cache — the player fetches the content
10
- // key from the gated key route and decrypts client-side (fast). The plaintext source is staged
11
- // only in the work dir and never persisted; only the encrypted segments + playlists are stored.
12
- // The content key is returned WRAPPED under the caller's set key (server-held or password-derived).
13
- const KEY_URI = 'enc.key'; // relative in the playlist → the gated key route serves it per-video
14
- async function uploadTree(store, dir, owner, id) {
15
- const walk = async (rel) => {
16
- const entries = await readdir(path.join(dir, rel), { withFileTypes: true });
17
- for (const e of entries) {
18
- const childRel = rel ? path.join(rel, e.name) : e.name;
19
- if (e.isDirectory()) {
20
- await walk(childRel);
21
- }
22
- else {
23
- const mime = e.name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t';
24
- const file = Bun.file(path.join(dir, childRel));
25
- await store.putStream(privateHlsFileKey(owner, id, childRel), file.stream(), mime, {
26
- contentLength: file.size,
27
- });
28
- }
29
- }
30
- };
31
- await walk('');
32
- }
33
- export async function processPrivateVideo(opts) {
34
- const { store, workDir, owner, id, sourcePath, setKey, onProgress } = opts;
35
- const report = (s, p) => onProgress?.(s, p);
36
- const failed = {
37
- status: 'failed',
38
- durationSeconds: 0,
39
- hasHls: false,
40
- wrappedContentKey: null,
41
- };
42
- // Private media MUST be encrypted — unlike public it can't degrade to a plaintext fallback.
43
- if (!(await isFfmpegAvailable()))
44
- return failed;
45
- const jobDir = path.join(workDir, owner, id);
46
- await mkdir(jobDir, { recursive: true });
47
- try {
48
- report('processing', 1);
49
- const probe = await probeVideoStream(sourcePath);
50
- // Mint the AES-128 content key + write the ffmpeg key-info file (key URI / key file / IV).
51
- const content = generateContentKey();
52
- const keyFile = path.join(jobDir, 'enc.key.bin');
53
- await writeFile(keyFile, content.key);
54
- const keyInfoFile = path.join(jobDir, 'enc.keyinfo');
55
- await writeFile(keyInfoFile, `${KEY_URI}\n${keyFile}\n${content.iv.toString('hex')}\n`);
56
- const heights = resolveLadder(opts.ladderHeights ?? [480, 720, 1080], probe.height);
57
- const hlsDir = path.join(jobDir, 'hls');
58
- await mkdir(hlsDir, { recursive: true });
59
- const ok = await runFfmpegProgress(hlsLadderArgs(sourcePath, hlsDir, {
60
- heights,
61
- crf: Number(LIBRARY_CRF),
62
- hasAudio: probe.hasAudio,
63
- keyInfoFile,
64
- }), probe.duration, (pct) => report('processing', Math.max(1, Math.min(98, pct))));
65
- if (!ok)
66
- return failed;
67
- // Upload ONLY the encrypted segments + playlists — never the key file (served via the route).
68
- await uploadTree(store, hlsDir, owner, id);
69
- report('ready', 100);
70
- return {
71
- status: 'ready',
72
- durationSeconds: probe.duration,
73
- hasHls: (await store.stat(privateHlsMasterKey(owner, id))) !== null,
74
- wrappedContentKey: wrapContentKey(setKey, content),
75
- };
76
- }
77
- catch {
78
- report('failed', 0);
79
- return failed;
80
- }
81
- finally {
82
- // Wipe the job dir — the plaintext source, the raw key file, and the outputs all vanish.
83
- await rm(jobDir, { recursive: true, force: true }).catch(() => { });
84
- }
85
- }
86
- export async function processPrivateBlob(opts) {
87
- try {
88
- const file = Bun.file(opts.sourcePath);
89
- const sizeBytes = file.size; // CTR is length-preserving, so plaintext size == stored size
90
- const content = generateContentKey();
91
- await opts.store.putStream(privateSourceKey(opts.owner, opts.id), cryptBlobStream(content, file.stream()), 'application/octet-stream', { contentLength: sizeBytes });
92
- return { status: 'ready', sizeBytes, wrappedContentKey: wrapContentKey(opts.setKey, content) };
93
- }
94
- catch {
95
- return { status: 'failed', sizeBytes: 0, wrappedContentKey: null };
96
- }
97
- }
@@ -1,52 +0,0 @@
1
- import { type KeyMode } from './crypto';
2
- export type UnlockGrant = {
3
- userId: string;
4
- setKey: Buffer;
5
- mode: KeyMode;
6
- };
7
- /**
8
- * What a supplied password MEANS at the unlock/reveal/private-upload surfaces:
9
- * • derive (default) — the password derives the set key (deniable sets; the
10
- * historical behavior).
11
- * • gate — the password is an ACCESS CHECK against the app's own accounts
12
- * (verifyPassword, e.g. the login store's argon2id verify) and the set key
13
- * stays the recoverable server-derived one. Password changes never orphan
14
- * content, and a password is REQUIRED to unlock (no silent session unlock).
15
- */
16
- export type UnlockPasswordPolicy = {
17
- mode: 'derive';
18
- } | {
19
- mode: 'gate';
20
- verifyPassword: (who: {
21
- userId: string;
22
- userSalt: string;
23
- }, password: string) => Promise<boolean>;
24
- };
25
- export interface UnlockService {
26
- /** Mint an unlock token. Pass `password` for a deniable set; omit for the standard server-key
27
- * set (the caller must already have authenticated the user). */
28
- unlock(args: {
29
- userId: string;
30
- userSalt: string;
31
- password?: string;
32
- }): Promise<{
33
- token: string;
34
- mode: KeyMode;
35
- }>;
36
- /** Verify a token and recover the sealed set key, or null if invalid/expired/tampered. */
37
- verify(token: string): Promise<UnlockGrant | null>;
38
- /** Derive a set key without minting a token (for the reveal endpoint's per-item trials). */
39
- deriveSetKey(args: {
40
- userId: string;
41
- userSalt: string;
42
- password?: string;
43
- }): Buffer;
44
- }
45
- export declare function createUnlockService(opts: {
46
- /** Derives the recoverable (standard) set key. */
47
- serverSecret: string;
48
- /** Signs the unlock JWT AND (domain-separated) seals the set key inside it. */
49
- unlockSecret: string;
50
- /** Token lifetime — long enough to survive a paused-and-resumed private video. Default 6h. */
51
- ttlSeconds?: number;
52
- }): UnlockService;
@@ -1,55 +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 } from './crypto';
9
- // ── Unlock service ──────────────────────────────────────────────────────────────
10
- // Turns a private-mode unlock into a short-lived token that CARRIES the set key so later
11
- // requests (the HLS content-key route, reveal) can unwrap without re-deriving. Two modes:
12
- // • deniable — password given → setKey = scrypt(password, userSalt); the server holds no
13
- // copy, so the token is the ONLY way back to that set key until it expires.
14
- // • standard — no password → setKey = scrypt(serverSecret, userId); recoverable, session-gated.
15
- // The set key is SEALED inside the token (AES-GCM under a server seal key), so the client can't
16
- // read it out; only this server can. The JWT is signed via cursedbelt's one jose integration
17
- // under the existing 'pm-unlock' purpose, so it can't be replayed as an access token.
18
- const PURPOSE = 'pm-unlock';
19
- export function createUnlockService(opts) {
20
- const ttl = opts.ttlSeconds ?? 6 * 60 * 60;
21
- const sealKey = deriveKey(opts.unlockSecret, 'pm-unlock-seal');
22
- const deriveSetKey = ({ userId, userSalt, password, }) => password
23
- ? derivePasswordSetKey(password, userSalt)
24
- : deriveServerSetKey(opts.serverSecret, userId);
25
- return {
26
- deriveSetKey,
27
- async unlock(args) {
28
- const mode = args.password ? 'deniable' : 'standard';
29
- const setKey = deriveSetKey(args);
30
- const token = await signToken({
31
- userId: args.userId,
32
- sessionId: 'pm',
33
- roles: [],
34
- purpose: PURPOSE,
35
- sk: encryptSecret(setKey.toString('base64'), sealKey),
36
- m: mode,
37
- }, opts.unlockSecret, ttl);
38
- return { token, mode };
39
- },
40
- async verify(token) {
41
- try {
42
- const p = await verifyPurposeToken(token, opts.unlockSecret, PURPOSE);
43
- const sealed = p.sk;
44
- const mode = p.m;
45
- if (typeof sealed !== 'string' || (mode !== 'standard' && mode !== 'deniable'))
46
- return null;
47
- const setKey = Buffer.from(decryptSecret(sealed, sealKey), 'base64');
48
- return { userId: p.userId, setKey, mode };
49
- }
50
- catch {
51
- return null;
52
- }
53
- },
54
- };
55
- }
@@ -1,165 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
4
- import path from 'node:path';
5
- import { Hono } from 'hono';
6
- import { createDiskStore } from '../storage/diskStore';
7
- import type { MediaStore } from '../storage/types';
8
- import { generateUserSalt, sealMeta } from './crypto';
9
- import { createMemoryPrivateIndex } from './index-store';
10
- import { mountPrivateRoutes } from './mountPrivateRoutes';
11
- import { processPrivateBlob } from './pipeline';
12
- import { createUnlockService } from './unlock';
13
-
14
- // The gated blob route: a private NON-video file (photo/doc) is one AES-128-CTR ciphertext at
15
- // privateSourceKey; the route decrypts under the unlock token's set key and serves plaintext
16
- // with the sealed descriptor's mime. A token whose set key doesn't own the item gets 403.
17
-
18
- const unlock = createUnlockService({ serverSecret: 'srv', unlockSecret: 'unlock' });
19
- const salt = generateUserSalt();
20
- const PLAINTEXT = 'plain photo bytes ✻ not for strangers';
21
-
22
- describe('private blob route', () => {
23
- let root: string;
24
- let store: MediaStore;
25
- const index = createMemoryPrivateIndex();
26
- const app = new Hono();
27
-
28
- beforeAll(async () => {
29
- root = await mkdtemp(path.join(tmpdir(), 'pm-blob-'));
30
- store = createDiskStore(path.join(root, 'store'));
31
- mountPrivateRoutes(app, {
32
- store,
33
- index,
34
- unlock,
35
- authorize: async () => ({ userId: 'u1', userSalt: salt }),
36
- });
37
-
38
- // Encrypt a staged plaintext under the 'alpha' set and index it, mirroring ingestPrivate.
39
- const src = path.join(root, 'src.bin');
40
- await writeFile(src, PLAINTEXT);
41
- const setKey = unlock.deriveSetKey({ userId: 'u1', userSalt: salt, password: 'alpha' });
42
- const res = await processPrivateBlob({ store, owner: 'u1', id: 'b1', sourcePath: src, setKey });
43
- expect(res.status).toBe('ready');
44
- await index.add({
45
- id: 'b1',
46
- owner: 'u1',
47
- sizeBytes: res.sizeBytes,
48
- createdAt: 1,
49
- status: 'ready',
50
- keyMode: 'deniable',
51
- wrappedContentKey: res.wrappedContentKey ?? '',
52
- sealedMeta: sealMeta(setKey, { name: 'secret.jpg', mime: 'image/jpeg', tags: [] }),
53
- });
54
- });
55
- afterAll(async () => {
56
- await rm(root, { recursive: true, force: true });
57
- });
58
-
59
- const tokenFor = async (password: string) =>
60
- (await unlock.unlock({ userId: 'u1', userSalt: salt, password })).token;
61
-
62
- test('serves decrypted plaintext with the sealed mime to the owning set', async () => {
63
- const res = await app.request(`/_private/u/${await tokenFor('alpha')}/file/b1`);
64
- expect(res.status).toBe(200);
65
- expect(res.headers.get('content-type')).toBe('image/jpeg');
66
- expect(res.headers.get('cache-control')).toBe('no-store');
67
- expect(await res.text()).toBe(PLAINTEXT);
68
- });
69
-
70
- test("a valid token from the WRONG set can't open it (403, zero bytes)", async () => {
71
- const res = await app.request(`/_private/u/${await tokenFor('bravo')}/file/b1`);
72
- expect(res.status).toBe(403);
73
- });
74
-
75
- test('garbage token → 401; unknown id → 404', async () => {
76
- expect((await app.request('/_private/u/not-a-token/file/b1')).status).toBe(401);
77
- const res = await app.request(`/_private/u/${await tokenFor('alpha')}/file/nope`);
78
- expect(res.status).toBe(404);
79
- });
80
- });
81
-
82
- // ── Range/CTR-seek (genapp-100) ──────────────────────────────────────────────
83
- // A big private VIDEO on a remote-store host has no HLS derivatives — this
84
- // route is its only byte path, so Range is what makes it scrub. CTR decrypts
85
- // from the block-aligned offset with the IV counter advanced; these pin the
86
- // exact-slice contract at aligned, unaligned, suffix, and unsatisfiable ranges.
87
- describe('private blob route — Range (CTR seek)', () => {
88
- let root: string;
89
- let store: MediaStore;
90
- const index = createMemoryPrivateIndex();
91
- const app = new Hono();
92
- // >3 CTR blocks with an unaligned tail, so mid-block seeks are meaningful.
93
- const BYTES = Buffer.from(Array.from({ length: 1000 }, (_, i) => i % 251));
94
-
95
- beforeAll(async () => {
96
- root = await mkdtemp(path.join(tmpdir(), 'pm-blob-range-'));
97
- store = createDiskStore(path.join(root, 'store'));
98
- mountPrivateRoutes(app, {
99
- store,
100
- index,
101
- unlock,
102
- authorize: async () => ({ userId: 'u1', userSalt: salt }),
103
- });
104
- const src = path.join(root, 'clip.bin');
105
- await writeFile(src, BYTES);
106
- const setKey = unlock.deriveSetKey({ userId: 'u1', userSalt: salt, password: 'alpha' });
107
- const res = await processPrivateBlob({ store, owner: 'u1', id: 'v1', sourcePath: src, setKey });
108
- expect(res.status).toBe('ready');
109
- await index.add({
110
- id: 'v1',
111
- owner: 'u1',
112
- sizeBytes: res.sizeBytes,
113
- createdAt: 1,
114
- status: 'ready',
115
- keyMode: 'deniable',
116
- wrappedContentKey: res.wrappedContentKey ?? '',
117
- sealedMeta: sealMeta(setKey, { name: 'clip.mp4', mime: 'video/mp4', tags: [] }),
118
- });
119
- });
120
- afterAll(async () => {
121
- await rm(root, { recursive: true, force: true });
122
- });
123
-
124
- const token = async () => (await unlock.unlock({ userId: 'u1', userSalt: salt, password: 'alpha' })).token;
125
- const get = async (range?: string) =>
126
- app.request(`/_private/u/${await token()}/file/v1`, range ? { headers: { range } } : undefined);
127
-
128
- test('a full read advertises Accept-Ranges and still decrypts whole', async () => {
129
- const res = await get();
130
- expect(res.status).toBe(200);
131
- expect(res.headers.get('accept-ranges')).toBe('bytes');
132
- expect(Buffer.from(await res.arrayBuffer()).equals(BYTES)).toBe(true);
133
- });
134
-
135
- test('a block-aligned range decrypts the exact slice as 206', async () => {
136
- const res = await get('bytes=160-479');
137
- expect(res.status).toBe(206);
138
- expect(res.headers.get('content-range')).toBe('bytes 160-479/1000');
139
- expect(res.headers.get('content-length')).toBe('320');
140
- expect(Buffer.from(await res.arrayBuffer()).equals(BYTES.subarray(160, 480))).toBe(true);
141
- });
142
-
143
- test('an UNALIGNED mid-block range decrypts the exact slice (the CTR counter + lead-in drop)', async () => {
144
- const res = await get('bytes=7-300');
145
- expect(res.status).toBe(206);
146
- expect(res.headers.get('content-range')).toBe('bytes 7-300/1000');
147
- expect(Buffer.from(await res.arrayBuffer()).equals(BYTES.subarray(7, 301))).toBe(true);
148
- });
149
-
150
- test('an open-ended range runs to the last byte; a suffix range serves the tail', async () => {
151
- const open = await get('bytes=990-');
152
- expect(open.status).toBe(206);
153
- expect(Buffer.from(await open.arrayBuffer()).equals(BYTES.subarray(990))).toBe(true);
154
- const suffix = await get('bytes=-25');
155
- expect(suffix.status).toBe(206);
156
- expect(suffix.headers.get('content-range')).toBe('bytes 975-999/1000');
157
- expect(Buffer.from(await suffix.arrayBuffer()).equals(BYTES.subarray(975))).toBe(true);
158
- });
159
-
160
- test('an out-of-bounds range answers 416 with the total', async () => {
161
- const res = await get('bytes=1000-1200');
162
- expect(res.status).toBe(416);
163
- expect(res.headers.get('content-range')).toBe('bytes */1000');
164
- });
165
- });
@@ -1,81 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import {
3
- derivePasswordSetKey,
4
- deriveServerSetKey,
5
- generateContentKey,
6
- generateUserSalt,
7
- openMeta,
8
- sealMeta,
9
- unwrapContentKey,
10
- wrapContentKey,
11
- } from './crypto';
12
-
13
- describe('private-media crypto core', () => {
14
- const salt = generateUserSalt();
15
-
16
- test('same password + salt → same key; different password → different key', () => {
17
- const a = derivePasswordSetKey('hunter2', salt);
18
- const a2 = derivePasswordSetKey('hunter2', salt);
19
- const b = derivePasswordSetKey('other-pass', salt);
20
- expect(a.equals(a2)).toBe(true);
21
- expect(a.equals(b)).toBe(false);
22
- expect(a.length).toBe(32);
23
- });
24
-
25
- test('the same password under different user salts yields different keys', () => {
26
- const s2 = generateUserSalt();
27
- expect(derivePasswordSetKey('pw', salt).equals(derivePasswordSetKey('pw', s2))).toBe(false);
28
- });
29
-
30
- test('server key is recoverable and distinct from any password key', () => {
31
- const srv = deriveServerSetKey('server-secret', 'user-1');
32
- expect(srv.equals(deriveServerSetKey('server-secret', 'user-1'))).toBe(true);
33
- expect(srv.equals(deriveServerSetKey('server-secret', 'user-2'))).toBe(false);
34
- expect(srv.equals(derivePasswordSetKey('server-secret', salt))).toBe(false);
35
- });
36
-
37
- test('sealMeta/openMeta round-trips; a wrong key returns null (the membership test)', () => {
38
- const key = derivePasswordSetKey('pw-A', salt);
39
- const wrong = derivePasswordSetKey('pw-B', salt);
40
- const sealed = sealMeta(key, { title: 'Secret Clip', notes: 'private', tags: ['x'] });
41
- expect(openMeta<{ title: string }>(key, sealed)?.title).toBe('Secret Clip');
42
- expect(openMeta(wrong, sealed)).toBeNull(); // wrong password ⇒ not a member, no info leak
43
- });
44
-
45
- test('content key wrap/unwrap round-trips; wrong key ⇒ null', () => {
46
- const key = derivePasswordSetKey('pw', salt);
47
- const wrong = deriveServerSetKey('srv', 'u');
48
- const content = generateContentKey();
49
- expect(content.key.length).toBe(16); // AES-128
50
- expect(content.iv.length).toBe(16);
51
- const wrapped = wrapContentKey(key, content);
52
- const unwrapped = unwrapContentKey(key, wrapped);
53
- expect(unwrapped?.key.equals(content.key)).toBe(true);
54
- expect(unwrapped?.iv.equals(content.iv)).toBe(true);
55
- expect(unwrapContentKey(wrong, wrapped)).toBeNull();
56
- });
57
-
58
- test('a tampered envelope decrypts to null, not garbage', () => {
59
- const key = derivePasswordSetKey('pw', salt);
60
- const sealed = sealMeta(key, { title: 't' });
61
- expect(openMeta(key, `${sealed}x`)).toBeNull();
62
- });
63
- });
64
-
65
- describe('incrementIv (genapp-100 CTR seek)', () => {
66
- test('adds blocks big-endian with byte carry', async () => {
67
- const { incrementIv } = await import('./crypto');
68
- const iv = Buffer.alloc(16);
69
- iv[15] = 0xff;
70
- const plus1 = incrementIv(iv, 1);
71
- expect(plus1[15]).toBe(0x00);
72
- expect(plus1[14]).toBe(0x01);
73
- // A large jump carries across multiple bytes: 0x01_0000 blocks.
74
- const jump = incrementIv(Buffer.alloc(16), 0x010000);
75
- expect(jump[13]).toBe(0x01);
76
- expect(jump[14]).toBe(0x00);
77
- expect(jump[15]).toBe(0x00);
78
- // The input IV is never mutated.
79
- expect(iv[15]).toBe(0xff);
80
- });
81
- });
@@ -1,155 +0,0 @@
1
- import { createCipheriv, randomBytes } from 'node:crypto';
2
- import { decryptSecret, deriveKey, encryptSecret } from 'cwip/node';
3
-
4
- // ── private-media crypto core ───────────────────────────────────────────────────
5
- // The reusable heart of encrypted private media. Pure composition of cwip's `secretBox`
6
- // (scrypt `deriveKey` + AES-256-GCM `encryptSecret`/`decryptSecret`) — whose own docs describe
7
- // exactly our two key modes: derive from a SERVER SECRET (recoverable "standard") vs from the
8
- // USER'S PASSWORD (zero-knowledge "deniable"). We use scrypt (slow, brute-force-resistant) for
9
- // the password KDF — NOT cursedbelt's vault HKDF, which is fast and password-unsafe.
10
- //
11
- // Streaming stays fast because the bytes are AES-128 HLS segments the player decrypts (edge-
12
- // cacheable); this module only mints/wraps the small 16-byte content key + seals metadata.
13
-
14
- export type KeyMode = 'standard' | 'deniable';
15
-
16
- /** A random per-user salt (non-secret, stored plaintext, stable once created). Domain-separates
17
- * a user's password-derived keys so the same password yields different keys across users. */
18
- export const generateUserSalt = (): string => randomBytes(16).toString('base64url');
19
-
20
- /** Zero-knowledge set key: scrypt(password, per-user salt). The server never stores this (or a
21
- * hash of it); it exists only transiently after an unlock. Different passwords → disjoint sets. */
22
- export const derivePasswordSetKey = (password: string, userSalt: string): Buffer =>
23
- deriveKey(password, `pmedia:${userSalt}`);
24
-
25
- /** Recoverable set key: scrypt(serverSecret, userId). The server can always derive it, so
26
- * `standard`-mode media is decryptable once private is unlocked (session-gated) — protects a
27
- * stolen disk / Finder / backups, but a compromised running server can decrypt. */
28
- export const deriveServerSetKey = (serverSecret: string, userId: string): Buffer =>
29
- deriveKey(serverSecret, `pmedia-srv:${userId}`);
30
-
31
- // ── Sealed metadata envelope (title / thumbnail key / notes / tags) ──────────────
32
- // Encrypted under the set key; NEVER stored in the plaintext index. A successful decrypt is
33
- // also the deniable MEMBERSHIP TEST — trying a password reveals only the items it opens.
34
-
35
- export const sealMeta = (setKey: Buffer, meta: unknown): string =>
36
- encryptSecret(JSON.stringify(meta), setKey);
37
-
38
- /** Open a sealed envelope, or null if this key doesn't own it (wrong password / mode key).
39
- * The null return IS the membership signal — no stored set identifier is needed. */
40
- export function openMeta<T = unknown>(setKey: Buffer, sealed: string): T | null {
41
- try {
42
- return JSON.parse(decryptSecret(sealed, setKey)) as T;
43
- } catch {
44
- return null;
45
- }
46
- }
47
-
48
- // ── AES-128 HLS content key: minted per item, WRAPPED under the set key ───────────
49
- // The raw 16-byte key + 16-byte IV drive ffmpeg's `-hls_key_info_file` (AES-128 segments). Only
50
- // the wrapped form is stored; the key route unwraps it (with the unlock-provided set key) and
51
- // returns the raw key to hls.js, which decrypts segments client-side.
52
-
53
- export type ContentKey = { key: Buffer; iv: Buffer };
54
-
55
- export const generateContentKey = (): ContentKey => ({
56
- key: randomBytes(16), // AES-128
57
- iv: randomBytes(16),
58
- });
59
-
60
- export const wrapContentKey = (setKey: Buffer, content: ContentKey): string =>
61
- encryptSecret(`${content.key.toString('base64')}.${content.iv.toString('base64')}`, setKey);
62
-
63
- /** Unwrap a content key, or null if the set key can't open it (wrong password / mode key). */
64
- export function unwrapContentKey(setKey: Buffer, wrapped: string): ContentKey | null {
65
- try {
66
- const [k, iv] = decryptSecret(wrapped, setKey).split('.');
67
- if (!k || !iv) return null;
68
- return { key: Buffer.from(k, 'base64'), iv: Buffer.from(iv, 'base64') };
69
- } catch {
70
- return null;
71
- }
72
- }
73
-
74
- // ── Whole-file blob cipher (non-video private files: photos, docs) ────────────────
75
- // AES-128-CTR under the item's content key — the same key/IV material the HLS path wraps, so
76
- // wrap/unwrap is shared. CTR is length-preserving and streaming (no padding, no buffering), and
77
- // encrypt/decrypt are the same operation. Tamper-evidence comes from the GCM-sealed descriptor +
78
- // the gated serve route (parity with the encrypted-HLS segments, which are CBC-without-MAC too).
79
-
80
- /** Pipe a byte stream through the item's AES-128-CTR keystream. Symmetric: pass ciphertext to
81
- * decrypt, plaintext to encrypt. */
82
- /** Big-endian increment of a 16-byte CTR IV by `blocks` — the counter arithmetic that lets a
83
- * decryptor start mid-stream (genapp-100: Range/seek on private blobs). */
84
- export function incrementIv(iv: Buffer, blocks: number): Buffer {
85
- const out = Buffer.from(iv);
86
- let carry = BigInt(blocks);
87
- for (let i = out.length - 1; i >= 0 && carry > 0n; i--) {
88
- const sum = BigInt(out[i]) + (carry & 0xffn);
89
- out[i] = Number(sum & 0xffn);
90
- carry = (carry >> 8n) + (sum >> 8n);
91
- }
92
- return out;
93
- }
94
-
95
- /**
96
- * Decrypt a CTR ciphertext stream that starts at the BLOCK-ALIGNED offset covering plaintext
97
- * byte `startOffset`, dropping the intra-block lead-in — the seek half of {@link cryptBlobStream}.
98
- * CTR is length-preserving and positionally keyed, so byte N of plaintext only needs ciphertext
99
- * from block ⌊N/16⌋ onward with the IV counter advanced by that many blocks (genapp-100: this is
100
- * what makes big private videos scrub on a remote-store host with no local derivatives).
101
- */
102
- export function cryptBlobRangeStream(
103
- content: ContentKey,
104
- stream: ReadableStream<Uint8Array>,
105
- startOffset: number,
106
- ): ReadableStream<Uint8Array> {
107
- const BLOCK = 16;
108
- const blocks = Math.floor(startOffset / BLOCK);
109
- let drop = startOffset % BLOCK;
110
- const cipher = createCipheriv('aes-128-ctr', content.key, incrementIv(content.iv, blocks));
111
- return stream.pipeThrough(
112
- new TransformStream<Uint8Array, Uint8Array>({
113
- transform(chunk, controller) {
114
- let out = cipher.update(chunk);
115
- if (drop > 0) {
116
- const skip = Math.min(drop, out.byteLength);
117
- out = out.subarray(skip);
118
- drop -= skip;
119
- }
120
- if (out.byteLength > 0) {
121
- controller.enqueue(new Uint8Array(out.buffer, out.byteOffset, out.byteLength));
122
- }
123
- },
124
- flush(controller) {
125
- const out = cipher.final(); // empty for CTR — kept for cipher-contract completeness
126
- if (out.byteLength > 0) {
127
- controller.enqueue(new Uint8Array(out.buffer, out.byteOffset, out.byteLength));
128
- }
129
- },
130
- }),
131
- );
132
- }
133
-
134
- export function cryptBlobStream(
135
- content: ContentKey,
136
- stream: ReadableStream<Uint8Array>,
137
- ): ReadableStream<Uint8Array> {
138
- const cipher = createCipheriv('aes-128-ctr', content.key, content.iv);
139
- return stream.pipeThrough(
140
- new TransformStream<Uint8Array, Uint8Array>({
141
- transform(chunk, controller) {
142
- const out = cipher.update(chunk);
143
- if (out.byteLength > 0) {
144
- controller.enqueue(new Uint8Array(out.buffer, out.byteOffset, out.byteLength));
145
- }
146
- },
147
- flush(controller) {
148
- const out = cipher.final(); // empty for CTR — kept for cipher-contract completeness
149
- if (out.byteLength > 0) {
150
- controller.enqueue(new Uint8Array(out.buffer, out.byteOffset, out.byteLength));
151
- }
152
- },
153
- }),
154
- );
155
- }