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
@@ -69,9 +69,6 @@ export {
69
69
  type MintChunkUpload,
70
70
  type MountFileRoutesOptions,
71
71
  mountFileRoutes,
72
- type PrivateFileCatalogue,
73
- type PrivateFileItem,
74
- type PrivateKeyMode,
75
72
  type SqliteFileCatalogueOptions,
76
73
  type StorageAction,
77
74
  type StorageOpRow,
@@ -112,7 +109,6 @@ export {
112
109
  type MediaSource,
113
110
  type MediaStore,
114
111
  type PhotoSource,
115
- privateFsKey,
116
112
  } from './types';
117
113
  export {
118
114
  type ProcessOptions,
@@ -16,7 +16,7 @@ import {
16
16
 
17
17
  // Mounts the byte-serving media routes. These live OUTSIDE an app's guarded API (a <video>/
18
18
  // hls.js request can't send the platform Bearer) and authorize each request with a path-embedded
19
- // stream token instead. Optionally hooks decryption for private (pfs/…) bytes (private-media).
19
+ // stream token instead.
20
20
  //
21
21
  // `basePath` is the on-server MOUNT path (always path-only, e.g. '/_media'). It is separate from
22
22
  // the PUBLIC url base passed to `createMediaUrls` (which may be an absolute API-origin URL for a
@@ -48,12 +48,6 @@ export type MountMediaOptions = {
48
48
  /** Authorizes an upload request (it CAN send the Bearer, unlike a media element). Return a
49
49
  * MediaCtx to allow, or null to reject. Omit ⇒ uploads are disabled on this mount. */
50
50
  authorizeUpload?: (c: Context) => Promise<MediaCtx | null> | MediaCtx | null;
51
- /** Optional hook to decrypt bytes for the private (pfs/…) keyspace before serving. Wired by
52
- * the private-media layer; omitted ⇒ all bytes served as stored. */
53
- decryptStream?: (
54
- stream: ReadableStream<Uint8Array>,
55
- grant: StreamGrant,
56
- ) => ReadableStream<Uint8Array>;
57
51
  };
58
52
 
59
53
  const EXPOSE_HEADERS = 'Content-Range, Content-Length, Accept-Ranges';
@@ -217,14 +217,5 @@ export const fsPosterKey = (owner: string, id: string) => `fs/${owner}/${id}-pos
217
217
  export const fsDisplayKey = (owner: string, id: string) => `fs/${owner}/${id}-display.jpg`;
218
218
  export const fsSpriteKey = (owner: string, id: string) => `fs/${owner}/${id}-sprites.jpg`;
219
219
  export const fsVttKey = (owner: string, id: string) => `fs/${owner}/${id}-sprites.vtt`;
220
- /** Private (encrypted) namespace — the private-media layer stores ciphertext here. The HLS
221
- * segments under it are AES-128-encrypted at rest (the player fetches the content key via the
222
- * gated key route and decrypts); a distinct keyspace from public `hls/` so the two never collide. */
223
- export const privateFsKey = (owner: string, id: string) => `pfs/${owner}/${id}`;
224
- export const privateHlsPrefix = (owner: string, id: string) => `pfs/${owner}/${id}/hls`;
225
- export const privateHlsMasterKey = (owner: string, id: string) =>
226
- `pfs/${owner}/${id}/hls/master.m3u8`;
227
- export const privateHlsFileKey = (owner: string, id: string, rel: string) =>
228
- `pfs/${owner}/${id}/hls/${rel}`;
229
- export const privatePosterKey = (owner: string, id: string) => `pfs/${owner}/${id}/poster.enc`;
230
- export const privateSourceKey = (owner: string, id: string) => `pfs/${owner}/${id}/source.enc`;
220
+ // The `pfs/` (ciphertext) keyspace was deleted with private media on 2026-09-17 — nothing ever
221
+ // wrote a byte to it on this fleet, and binary-server stores objects unencrypted regardless.
@@ -60,9 +60,10 @@ describe('every file inside the published surface is tracked', () => {
60
60
  const untracked = out === '' ? [] : out.split('\n');
61
61
  expect(
62
62
  untracked,
63
- 'these would be PUBLISHED but exist in no commit — `bun publish` packs the working directory, not the index,\n' +
63
+ 'these would be PUBLISHED but git does not know them — `bun publish` packs the working directory,\n' +
64
64
  'so the registry would carry code nobody can review, diff or revert, and npm versions are immutable.\n' +
65
- 'Commit them (or add them to .gitignore if they genuinely must not ship):\n ' +
65
+ '`git add <path>` alone clears this (--others skips the INDEX, not just HEAD), which is what a\n' +
66
+ 'session forbidden from committing must do; otherwise commit them, or .gitignore them:\n ' +
66
67
  untracked.join('\n '),
67
68
  ).toEqual([]);
68
69
  });
@@ -1,37 +0,0 @@
1
- export type KeyMode = 'standard' | 'deniable';
2
- /** A random per-user salt (non-secret, stored plaintext, stable once created). Domain-separates
3
- * a user's password-derived keys so the same password yields different keys across users. */
4
- export declare const generateUserSalt: () => string;
5
- /** Zero-knowledge set key: scrypt(password, per-user salt). The server never stores this (or a
6
- * hash of it); it exists only transiently after an unlock. Different passwords → disjoint sets. */
7
- export declare const derivePasswordSetKey: (password: string, userSalt: string) => Buffer;
8
- /** Recoverable set key: scrypt(serverSecret, userId). The server can always derive it, so
9
- * `standard`-mode media is decryptable once private is unlocked (session-gated) — protects a
10
- * stolen disk / Finder / backups, but a compromised running server can decrypt. */
11
- export declare const deriveServerSetKey: (serverSecret: string, userId: string) => Buffer;
12
- export declare const sealMeta: (setKey: Buffer, meta: unknown) => string;
13
- /** Open a sealed envelope, or null if this key doesn't own it (wrong password / mode key).
14
- * The null return IS the membership signal — no stored set identifier is needed. */
15
- export declare function openMeta<T = unknown>(setKey: Buffer, sealed: string): T | null;
16
- export type ContentKey = {
17
- key: Buffer;
18
- iv: Buffer;
19
- };
20
- export declare const generateContentKey: () => ContentKey;
21
- export declare const wrapContentKey: (setKey: Buffer, content: ContentKey) => string;
22
- /** Unwrap a content key, or null if the set key can't open it (wrong password / mode key). */
23
- export declare function unwrapContentKey(setKey: Buffer, wrapped: string): ContentKey | null;
24
- /** Pipe a byte stream through the item's AES-128-CTR keystream. Symmetric: pass ciphertext to
25
- * decrypt, plaintext to encrypt. */
26
- /** Big-endian increment of a 16-byte CTR IV by `blocks` — the counter arithmetic that lets a
27
- * decryptor start mid-stream (genapp-100: Range/seek on private blobs). */
28
- export declare function incrementIv(iv: Buffer, blocks: number): Buffer;
29
- /**
30
- * Decrypt a CTR ciphertext stream that starts at the BLOCK-ALIGNED offset covering plaintext
31
- * byte `startOffset`, dropping the intra-block lead-in — the seek half of {@link cryptBlobStream}.
32
- * CTR is length-preserving and positionally keyed, so byte N of plaintext only needs ciphertext
33
- * from block ⌊N/16⌋ onward with the IV counter advanced by that many blocks (genapp-100: this is
34
- * what makes big private videos scrub on a remote-store host with no local derivatives).
35
- */
36
- export declare function cryptBlobRangeStream(content: ContentKey, stream: ReadableStream<Uint8Array>, startOffset: number): ReadableStream<Uint8Array>;
37
- export declare function cryptBlobStream(content: ContentKey, stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array>;
@@ -1,111 +0,0 @@
1
- import { createCipheriv, randomBytes } from 'node:crypto';
2
- import { decryptSecret, deriveKey, encryptSecret } from 'cwip/node';
3
- /** A random per-user salt (non-secret, stored plaintext, stable once created). Domain-separates
4
- * a user's password-derived keys so the same password yields different keys across users. */
5
- export const generateUserSalt = () => randomBytes(16).toString('base64url');
6
- /** Zero-knowledge set key: scrypt(password, per-user salt). The server never stores this (or a
7
- * hash of it); it exists only transiently after an unlock. Different passwords → disjoint sets. */
8
- export const derivePasswordSetKey = (password, userSalt) => deriveKey(password, `pmedia:${userSalt}`);
9
- /** Recoverable set key: scrypt(serverSecret, userId). The server can always derive it, so
10
- * `standard`-mode media is decryptable once private is unlocked (session-gated) — protects a
11
- * stolen disk / Finder / backups, but a compromised running server can decrypt. */
12
- export const deriveServerSetKey = (serverSecret, userId) => deriveKey(serverSecret, `pmedia-srv:${userId}`);
13
- // ── Sealed metadata envelope (title / thumbnail key / notes / tags) ──────────────
14
- // Encrypted under the set key; NEVER stored in the plaintext index. A successful decrypt is
15
- // also the deniable MEMBERSHIP TEST — trying a password reveals only the items it opens.
16
- export const sealMeta = (setKey, meta) => encryptSecret(JSON.stringify(meta), setKey);
17
- /** Open a sealed envelope, or null if this key doesn't own it (wrong password / mode key).
18
- * The null return IS the membership signal — no stored set identifier is needed. */
19
- export function openMeta(setKey, sealed) {
20
- try {
21
- return JSON.parse(decryptSecret(sealed, setKey));
22
- }
23
- catch {
24
- return null;
25
- }
26
- }
27
- export const generateContentKey = () => ({
28
- key: randomBytes(16), // AES-128
29
- iv: randomBytes(16),
30
- });
31
- export const wrapContentKey = (setKey, content) => encryptSecret(`${content.key.toString('base64')}.${content.iv.toString('base64')}`, setKey);
32
- /** Unwrap a content key, or null if the set key can't open it (wrong password / mode key). */
33
- export function unwrapContentKey(setKey, wrapped) {
34
- try {
35
- const [k, iv] = decryptSecret(wrapped, setKey).split('.');
36
- if (!k || !iv)
37
- return null;
38
- return { key: Buffer.from(k, 'base64'), iv: Buffer.from(iv, 'base64') };
39
- }
40
- catch {
41
- return null;
42
- }
43
- }
44
- // ── Whole-file blob cipher (non-video private files: photos, docs) ────────────────
45
- // AES-128-CTR under the item's content key — the same key/IV material the HLS path wraps, so
46
- // wrap/unwrap is shared. CTR is length-preserving and streaming (no padding, no buffering), and
47
- // encrypt/decrypt are the same operation. Tamper-evidence comes from the GCM-sealed descriptor +
48
- // the gated serve route (parity with the encrypted-HLS segments, which are CBC-without-MAC too).
49
- /** Pipe a byte stream through the item's AES-128-CTR keystream. Symmetric: pass ciphertext to
50
- * decrypt, plaintext to encrypt. */
51
- /** Big-endian increment of a 16-byte CTR IV by `blocks` — the counter arithmetic that lets a
52
- * decryptor start mid-stream (genapp-100: Range/seek on private blobs). */
53
- export function incrementIv(iv, blocks) {
54
- const out = Buffer.from(iv);
55
- let carry = BigInt(blocks);
56
- for (let i = out.length - 1; i >= 0 && carry > 0n; i--) {
57
- const sum = BigInt(out[i]) + (carry & 0xffn);
58
- out[i] = Number(sum & 0xffn);
59
- carry = (carry >> 8n) + (sum >> 8n);
60
- }
61
- return out;
62
- }
63
- /**
64
- * Decrypt a CTR ciphertext stream that starts at the BLOCK-ALIGNED offset covering plaintext
65
- * byte `startOffset`, dropping the intra-block lead-in — the seek half of {@link cryptBlobStream}.
66
- * CTR is length-preserving and positionally keyed, so byte N of plaintext only needs ciphertext
67
- * from block ⌊N/16⌋ onward with the IV counter advanced by that many blocks (genapp-100: this is
68
- * what makes big private videos scrub on a remote-store host with no local derivatives).
69
- */
70
- export function cryptBlobRangeStream(content, stream, startOffset) {
71
- const BLOCK = 16;
72
- const blocks = Math.floor(startOffset / BLOCK);
73
- let drop = startOffset % BLOCK;
74
- const cipher = createCipheriv('aes-128-ctr', content.key, incrementIv(content.iv, blocks));
75
- return stream.pipeThrough(new TransformStream({
76
- transform(chunk, controller) {
77
- let out = cipher.update(chunk);
78
- if (drop > 0) {
79
- const skip = Math.min(drop, out.byteLength);
80
- out = out.subarray(skip);
81
- drop -= skip;
82
- }
83
- if (out.byteLength > 0) {
84
- controller.enqueue(new Uint8Array(out.buffer, out.byteOffset, out.byteLength));
85
- }
86
- },
87
- flush(controller) {
88
- const out = cipher.final(); // empty for CTR — kept for cipher-contract completeness
89
- if (out.byteLength > 0) {
90
- controller.enqueue(new Uint8Array(out.buffer, out.byteOffset, out.byteLength));
91
- }
92
- },
93
- }));
94
- }
95
- export function cryptBlobStream(content, stream) {
96
- const cipher = createCipheriv('aes-128-ctr', content.key, content.iv);
97
- return stream.pipeThrough(new TransformStream({
98
- transform(chunk, controller) {
99
- const out = cipher.update(chunk);
100
- if (out.byteLength > 0) {
101
- controller.enqueue(new Uint8Array(out.buffer, out.byteOffset, out.byteLength));
102
- }
103
- },
104
- flush(controller) {
105
- const out = cipher.final(); // empty for CTR — kept for cipher-contract completeness
106
- if (out.byteLength > 0) {
107
- controller.enqueue(new Uint8Array(out.buffer, out.byteOffset, out.byteLength));
108
- }
109
- },
110
- }));
111
- }
@@ -1,31 +0,0 @@
1
- import type { ProcessingStatus } from 'cursedbelt-core/media';
2
- import type { KeyMode } from './crypto';
3
- export type PrivateItem = {
4
- id: string;
5
- owner: string;
6
- sizeBytes: number;
7
- createdAt: number;
8
- status: ProcessingStatus;
9
- keyMode: KeyMode;
10
- /** AES-128 content key + IV, wrapped under the item's set key. */
11
- wrappedContentKey: string;
12
- /** AES-GCM envelope of { title, notes, tags, durationSeconds, … } under the item's set key. */
13
- sealedMeta: string;
14
- };
15
- /** What the index endpoint exposes — clear, non-sensitive facts only. No title/thumb/notes/tags,
16
- * no key mode, no wrapped key. */
17
- export type PrivateIndexRow = {
18
- id: string;
19
- sizeBytes: number;
20
- createdAt: number;
21
- status: ProcessingStatus;
22
- };
23
- export declare const toPublic: (item: PrivateItem) => PrivateIndexRow;
24
- export interface PrivateIndex {
25
- add(item: PrivateItem): Promise<void>;
26
- get(owner: string, id: string): Promise<PrivateItem | null>;
27
- list(owner: string): Promise<PrivateItem[]>;
28
- update(owner: string, id: string, patch: Partial<Omit<PrivateItem, 'id' | 'owner'>>): Promise<void>;
29
- remove(owner: string, id: string): Promise<void>;
30
- }
31
- export declare function createMemoryPrivateIndex(): PrivateIndex;
@@ -1,36 +0,0 @@
1
- export const toPublic = (item) => ({
2
- id: item.id,
3
- sizeBytes: item.sizeBytes,
4
- createdAt: item.createdAt,
5
- status: item.status,
6
- });
7
- export function createMemoryPrivateIndex() {
8
- const byOwner = new Map();
9
- const bucket = (owner) => {
10
- let m = byOwner.get(owner);
11
- if (!m) {
12
- m = new Map();
13
- byOwner.set(owner, m);
14
- }
15
- return m;
16
- };
17
- return {
18
- async add(item) {
19
- bucket(item.owner).set(item.id, item);
20
- },
21
- async get(owner, id) {
22
- return bucket(owner).get(id) ?? null;
23
- },
24
- async list(owner) {
25
- return [...bucket(owner).values()].sort((a, b) => b.createdAt - a.createdAt);
26
- },
27
- async update(owner, id, patch) {
28
- const item = bucket(owner).get(id);
29
- if (item)
30
- bucket(owner).set(id, { ...item, ...patch });
31
- },
32
- async remove(owner, id) {
33
- bucket(owner).delete(id);
34
- },
35
- };
36
- }
@@ -1,26 +0,0 @@
1
- import type { Context, Hono } from 'hono';
2
- import { type MediaStore } from '../storage/types';
3
- import type { PrivateIndex } from './index-store';
4
- import type { UnlockPasswordPolicy, UnlockService } from './unlock';
5
- /** Access-Control-Allow-Origin resolver for split-origin private streaming (media on another
6
- * origin than the SPA). Same shape as the storage layer's. */
7
- export type CorsResolver = string | string[] | ((requestOrigin: string | undefined) => string | null | undefined);
8
- export type MountPrivateOptions = {
9
- store: MediaStore;
10
- index: PrivateIndex;
11
- unlock: UnlockService;
12
- /** Path prefix (default '/_private'). */
13
- basePath?: string;
14
- /** App session auth → the caller's id + their per-user salt (created lazily, stable). Return
15
- * null to reject. Gates unlock/index/reveal (the byte routes use the path token instead). */
16
- authorize: (c: Context) => Promise<{
17
- userId: string;
18
- userSalt: string;
19
- } | null>;
20
- /** What a supplied password means (default: derive — deniable sets). `gate` verifies it
21
- * against the app's accounts and mints the recoverable server-key grant instead. */
22
- passwordPolicy?: UnlockPasswordPolicy;
23
- /** Cross-origin access for split-origin deploys. */
24
- cors?: CorsResolver;
25
- };
26
- export declare function mountPrivateRoutes(app: Hono, opts: MountPrivateOptions): void;
@@ -1,219 +0,0 @@
1
- import { decodeRange, serveRange } from '../storage/streamRange';
2
- import { privateHlsFileKey, privateSourceKey } from '../storage/types';
3
- import { cryptBlobRangeStream, cryptBlobStream, openMeta, unwrapContentKey } from './crypto';
4
- import { toPublic } from './index-store';
5
- function resolveAcao(cors, reqOrigin) {
6
- if (!cors)
7
- return null;
8
- if (typeof cors === 'string')
9
- return cors;
10
- if (Array.isArray(cors))
11
- return reqOrigin && cors.includes(reqOrigin) ? reqOrigin : null;
12
- return cors(reqOrigin) ?? null;
13
- }
14
- export function mountPrivateRoutes(app, opts) {
15
- const base = opts.basePath ?? '/_private';
16
- const { store, index, unlock } = opts;
17
- const cors = (c, res) => {
18
- const acao = resolveAcao(opts.cors, c.req.header('origin'));
19
- if (acao) {
20
- res.headers.set('Access-Control-Allow-Origin', acao);
21
- res.headers.set('Access-Control-Expose-Headers', 'Content-Range, Content-Length, Accept-Ranges');
22
- res.headers.append('Vary', 'Origin');
23
- }
24
- return res;
25
- };
26
- const policy = opts.passwordPolicy ?? { mode: 'derive' };
27
- /** Resolve what the supplied password contributes under the active policy:
28
- * derive ⇒ it feeds key derivation (may be undefined ⇒ standard set);
29
- * gate ⇒ it must be present AND verify against the app's accounts, then the
30
- * set key is the recoverable server one (password never feeds derivation).
31
- * Returns an error Response to short-circuit with, or the derivation input. */
32
- const resolvePassword = async (c, who, password) => {
33
- if (policy.mode === 'derive')
34
- return { derivePassword: password };
35
- if (!password)
36
- return { error: c.json({ error: 'password required' }, 401) };
37
- if (!(await policy.verifyPassword(who, password))) {
38
- return { error: c.json({ error: 'invalid password' }, 401) };
39
- }
40
- return {}; // verified — standard (server-key) set
41
- };
42
- // POST /unlock { password? } → a token sealing the set key (deniable w/ password, else
43
- // standard; under the gate policy the password is verified and the set is always standard).
44
- app.post(`${base}/unlock`, async (c) => {
45
- const who = await opts.authorize(c);
46
- if (!who)
47
- return c.json({ error: 'unauthorized' }, 401);
48
- const body = (await c.req.json().catch(() => ({})));
49
- const resolved = await resolvePassword(c, who, body.password || undefined);
50
- if ('error' in resolved)
51
- return resolved.error;
52
- const { token, mode } = await unlock.unlock({
53
- userId: who.userId,
54
- userSalt: who.userSalt,
55
- password: resolved.derivePassword,
56
- });
57
- return c.json({ token, mode });
58
- });
59
- // GET /index → clear, non-sensitive rows only (size + createdAt + status). Never title/thumb/mode.
60
- app.get(`${base}/index`, async (c) => {
61
- const who = await opts.authorize(c);
62
- if (!who)
63
- return c.json({ error: 'unauthorized' }, 401);
64
- return c.json({ items: (await index.list(who.userId)).map(toPublic) });
65
- });
66
- // Decrypt every descriptor a set key opens, silently skipping the rest (decrypt-success IS the
67
- // membership test — deniability). Shared by the password and token reveal routes.
68
- const revealWithKey = async (userId, setKey) => {
69
- const revealed = [];
70
- for (const item of await index.list(userId)) {
71
- const meta = openMeta(setKey, item.sealedMeta);
72
- if (meta !== null)
73
- revealed.push({ id: item.id, meta });
74
- }
75
- return revealed;
76
- };
77
- // POST /reveal { password } → which items that password unlocks (GCM decrypt-success), with
78
- // their now-decrypted metadata. Others stay opaque. This is the "try a password" cleanup UX.
79
- // Under the gate policy the password must verify first and the standard set is revealed.
80
- app.post(`${base}/reveal`, async (c) => {
81
- const who = await opts.authorize(c);
82
- if (!who)
83
- return c.json({ error: 'unauthorized' }, 401);
84
- const body = (await c.req.json().catch(() => ({})));
85
- const resolved = await resolvePassword(c, who, body.password || undefined);
86
- if ('error' in resolved)
87
- return resolved.error;
88
- const setKey = unlock.deriveSetKey({
89
- userId: who.userId,
90
- userSalt: who.userSalt,
91
- password: resolved.derivePassword,
92
- });
93
- return c.json({ items: await revealWithKey(who.userId, setKey) });
94
- });
95
- // POST /reveal-unlocked { token } → the same reveal, keyed by an UNLOCK TOKEN instead of the
96
- // password (the token seals the set key — same mechanism the content-key route uses). Lets an
97
- // already-unlocked session list its items without re-typing the password. The token must belong
98
- // to the session caller.
99
- app.post(`${base}/reveal-unlocked`, async (c) => {
100
- const who = await opts.authorize(c);
101
- if (!who)
102
- return c.json({ error: 'unauthorized' }, 401);
103
- const body = (await c.req.json().catch(() => ({})));
104
- if (!body.token)
105
- return c.json({ error: 'token required' }, 400);
106
- const grant = await unlock.verify(body.token);
107
- if (!grant || grant.userId !== who.userId)
108
- return c.json({ error: 'invalid token' }, 401);
109
- return c.json({ items: await revealWithKey(who.userId, grant.setKey) });
110
- });
111
- // GET /u/:token/hls/:source/:video/* → the encrypted HLS tree. The `enc.key` sub-path returns
112
- // the unwrapped AES-128 content key (gated, no-store); every other path serves the encrypted
113
- // segment/playlist (edge-cacheable — hls.js decrypts with the key).
114
- app.get(`${base}/u/:token/hls/:source/:video/*`, async (c) => {
115
- const raw = c.req.param('token');
116
- if (!raw)
117
- return c.body(null, 401);
118
- const grant = await unlock.verify(raw);
119
- if (!grant)
120
- return c.body(null, 401);
121
- const video = c.req.param('video');
122
- const prefix = `${base}/u/${raw}/hls/${c.req.param('source')}/${video}/`;
123
- const rel = c.req.path.slice(prefix.length);
124
- if (!rel || rel.includes('..'))
125
- return c.body(null, 400);
126
- if (rel.endsWith('enc.key')) {
127
- const item = await index.get(grant.userId, video);
128
- if (!item)
129
- return c.body(null, 404);
130
- const content = unwrapContentKey(grant.setKey, item.wrappedContentKey);
131
- // null ⇒ this token's set key doesn't own the item (wrong password / wrong mode).
132
- if (!content)
133
- return c.body(null, 403);
134
- // A fresh, tightly-sized ArrayBuffer copy of the 16-byte key — an unambiguous BodyInit
135
- // (wrapping the Node Buffer directly can mis-size the response).
136
- const keyBytes = Uint8Array.from(content.key);
137
- return cors(c, new Response(keyBytes.buffer, {
138
- status: 200,
139
- headers: { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'no-store' },
140
- }));
141
- }
142
- const mime = rel.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t';
143
- return cors(c, await serveRange(store, privateHlsFileKey(grant.userId, video, rel), mime, c.req.header('range')));
144
- });
145
- // GET /u/:token/file/:id → a private file stored as one AES-128-CTR ciphertext blob at
146
- // privateSourceKey. Decrypted server-side per request under the token's set key; the mime
147
- // comes from the sealed descriptor, so a token whose set key can't open the item (wrong
148
- // password / wrong mode) gets a 403 and never any bytes.
149
- //
150
- // RANGE SUPPORT (genapp-100): CTR is positionally keyed, so a byte range decrypts from the
151
- // block-aligned ciphertext offset with the IV counter advanced (`cryptBlobRangeStream`). This
152
- // is what makes a big private VIDEO scrub on a remote-store host, where no local ffmpeg means
153
- // no HLS derivatives and the blob route is the only byte path. Plaintext is never stored.
154
- app.get(`${base}/u/:token/file/:id`, async (c) => {
155
- const raw = c.req.param('token');
156
- if (!raw)
157
- return c.body(null, 401);
158
- const grant = await unlock.verify(raw);
159
- if (!grant)
160
- return c.body(null, 401);
161
- const id = c.req.param('id');
162
- const item = await index.get(grant.userId, id);
163
- if (!item)
164
- return c.body(null, 404);
165
- const content = unwrapContentKey(grant.setKey, item.wrappedContentKey);
166
- if (!content)
167
- return c.body(null, 403);
168
- const meta = openMeta(grant.setKey, item.sealedMeta);
169
- const baseHeaders = {
170
- 'Content-Type': meta?.mime || 'application/octet-stream',
171
- 'Cache-Control': 'no-store',
172
- 'Accept-Ranges': 'bytes',
173
- };
174
- const key = privateSourceKey(grant.userId, id);
175
- const range = item.sizeBytes > 0 ? decodeRange(c.req.header('range'), item.sizeBytes) : { kind: 'full' };
176
- if (range.kind === 'unsatisfiable') {
177
- return cors(c, new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${item.sizeBytes}` } }));
178
- }
179
- if (range.kind === 'partial') {
180
- // Fetch ciphertext from the BLOCK-ALIGNED start (CTR block = 16 bytes); the range
181
- // stream drops the intra-block lead-in so the client sees exactly [start, end].
182
- const alignedStart = range.start - (range.start % 16);
183
- const stored = await store.getRange(key, { start: alignedStart, end: range.end });
184
- if (!stored)
185
- return c.body(null, 404);
186
- return cors(c, new Response(cryptBlobRangeStream(content, stored.stream, range.start), {
187
- status: 206,
188
- headers: {
189
- ...baseHeaders,
190
- 'Content-Range': `bytes ${range.start}-${range.end}/${item.sizeBytes}`,
191
- 'Content-Length': String(range.end - range.start + 1),
192
- },
193
- }));
194
- }
195
- const stored = await store.getRange(key);
196
- if (!stored)
197
- return c.body(null, 404);
198
- const headers = { ...baseHeaders };
199
- if (item.sizeBytes > 0)
200
- headers['Content-Length'] = String(item.sizeBytes);
201
- return cors(c, new Response(cryptBlobStream(content, stored.stream), { status: 200, headers }));
202
- });
203
- // Preflight for the cross-origin byte routes.
204
- if (opts.cors) {
205
- app.options(`${base}/u/*`, (c) => {
206
- const acao = resolveAcao(opts.cors, c.req.header('origin'));
207
- const headers = {
208
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
209
- 'Access-Control-Allow-Headers': 'Range',
210
- 'Access-Control-Max-Age': '86400',
211
- };
212
- if (acao) {
213
- headers['Access-Control-Allow-Origin'] = acao;
214
- headers.Vary = 'Origin';
215
- }
216
- return c.body(null, 204, headers);
217
- });
218
- }
219
- }
@@ -1,37 +0,0 @@
1
- import type { ProcessingStatus } from 'cursedbelt-core/media';
2
- import { type MediaStore } from '../storage/types';
3
- export type PrivateProcessResult = {
4
- status: ProcessingStatus;
5
- durationSeconds: number;
6
- hasHls: boolean;
7
- /** The AES-128 content key + IV, wrapped under the caller's set key. Null if processing failed. */
8
- wrappedContentKey: string | null;
9
- };
10
- export type PrivateProcessOptions = {
11
- store: MediaStore;
12
- /** Scratch dir for ffmpeg I/O + the transient key file (cleaned up afterwards). */
13
- workDir: string;
14
- owner: string;
15
- id: string;
16
- /** Local PLAINTEXT source, staged by the caller; never persisted (deleted with the job dir). */
17
- sourcePath: string;
18
- /** The mode key (server-held or password-derived) the content key is wrapped under. */
19
- setKey: Buffer;
20
- ladderHeights?: number[];
21
- onProgress?: (status: ProcessingStatus, pct: number) => void;
22
- };
23
- export declare function processPrivateVideo(opts: PrivateProcessOptions): Promise<PrivateProcessResult>;
24
- export type PrivateBlobResult = {
25
- status: ProcessingStatus;
26
- sizeBytes: number;
27
- /** The content key + IV, wrapped under the caller's set key. Null if processing failed. */
28
- wrappedContentKey: string | null;
29
- };
30
- export declare function processPrivateBlob(opts: {
31
- store: MediaStore;
32
- owner: string;
33
- id: string;
34
- /** Local PLAINTEXT source, staged by the caller; never persisted (caller deletes it). */
35
- sourcePath: string;
36
- setKey: Buffer;
37
- }): Promise<PrivateBlobResult>;