cursedbelt-server 3.0.1 → 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/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 +39 -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/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.ts +44 -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
|
@@ -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.
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
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
|
-
'
|
|
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>;
|