diff --git a/cloudflare/src/bindings.ts b/cloudflare/src/bindings.ts index 31ab4a5..815346a 100644 --- a/cloudflare/src/bindings.ts +++ b/cloudflare/src/bindings.ts @@ -2,6 +2,21 @@ export interface ElyKvNamespace { get(key: string): Promise; } +export interface ElyR2Object { + arrayBuffer(): Promise; +} + +export interface ElyR2PutOptions { + httpMetadata?: { contentType?: string }; + customMetadata?: Record; + sha256?: ArrayBuffer; +} + +export interface ElyR2Bucket { + get(key: string): Promise; + put(key: string, value: ArrayBuffer, options?: ElyR2PutOptions): Promise; +} + export interface ElyD1PreparedStatement { bind(...values: unknown[]): ElyD1PreparedStatement; first(): Promise; @@ -32,6 +47,7 @@ export interface ElyAnalyticsDataset { export interface Env { ELY_DB: ElyD1Database; ELY_KV: ElyKvNamespace; + ELY_STORAGE: ElyR2Bucket; ELY_RATE_LIMITER: ElyRateLimit; ELY_API_AUDIT: ElyAnalyticsDataset; ELY_ENVIRONMENT: string; diff --git a/cloudflare/src/storage.ts b/cloudflare/src/storage.ts new file mode 100644 index 0000000..63ee986 --- /dev/null +++ b/cloudflare/src/storage.ts @@ -0,0 +1,221 @@ +import type { ElyR2Bucket } from "./bindings.js"; + +const SHA256_HEX = /^[a-f0-9]{64}$/; +const REGION = /^[a-z0-9][a-z0-9-]{1,31}$/; +const SEGMENT = /^[a-z0-9][a-z0-9._-]{0,127}$/; +const SYNC_OBJECT_TYPES = new Set([ + "spaces", + "tabs", + "bookmarks", + "notes", + "reading-list", + "profiles", + "site-permissions", + "history", + "plugin-settings", +]); + +export class StorageObjectError extends Error { + constructor(message: string) { + super(message); + this.name = "StorageObjectError"; + } +} + +export interface StoredObject { + key: string; + sha256: string; + sizeBytes: number; +} + +export function syncPayloadKey(params: { + region: string; + userHash: string; + objectType: string; + objectId: string; + payloadHash: string; +}): string { + assertRegion(params.region); + assertSha256Hex(params.userHash, "user_hash"); + assertSyncObjectType(params.objectType); + assertSegment(params.objectId, "object_id"); + assertSha256Hex(params.payloadHash, "payload_hash"); + return [ + "sync-payloads", + params.region, + params.userHash, + params.objectType, + params.objectId, + `${params.payloadHash}.bin`, + ].join("/"); +} + +export function syncSnapshotKey(params: { + region: string; + userHash: string; + snapshotId: string; +}): string { + assertRegion(params.region); + assertSha256Hex(params.userHash, "user_hash"); + assertSegment(params.snapshotId, "snapshot_id"); + return ["sync-snapshots", params.region, params.userHash, `${params.snapshotId}.bin`].join("/"); +} + +export function pluginPackageKey(params: { pluginId: string; packageHash: string }): string { + assertSegment(params.pluginId, "plugin_id"); + assertSha256Hex(params.packageHash, "package_hash"); + return ["plugin-packages", params.pluginId, `${params.packageHash}.rplug`].join("/"); +} + +export function pluginAssetKey(params: { pluginId: string; assetHash: string }): string { + assertSegment(params.pluginId, "plugin_id"); + assertSha256Hex(params.assetHash, "asset_hash"); + return ["plugin-assets", params.pluginId, params.assetHash].join("/"); +} + +export function userAvatarKey(params: { userHash: string; avatarHash: string }): string { + assertSha256Hex(params.userHash, "user_hash"); + assertSha256Hex(params.avatarHash, "avatar_hash"); + return ["user-avatars", params.userHash, params.avatarHash].join("/"); +} + +export function crashAttachmentKey(params: { + reportId: string; + attachmentHash: string; +}): string { + assertSegment(params.reportId, "report_id"); + assertSha256Hex(params.attachmentHash, "attachment_hash"); + return ["crash-attachments", params.reportId, params.attachmentHash].join("/"); +} + +export function exportObjectKey(params: { userHash: string; exportId: string }): string { + assertSha256Hex(params.userHash, "user_hash"); + assertSegment(params.exportId, "export_id"); + return ["exports", params.userHash, `${params.exportId}.bin`].join("/"); +} + +export async function putVerifiedObject( + bucket: ElyR2Bucket, + key: string, + payload: ArrayBuffer, + expectedSha256: string, + contentType: string, +): Promise { + assertKnownObjectKey(key); + assertSha256Hex(expectedSha256, "sha256"); + assertKeyChecksum(key, expectedSha256); + const actualSha256 = await sha256Hex(payload); + if (actualSha256 !== expectedSha256) { + throw new StorageObjectError("r2_checksum_mismatch"); + } + + await bucket.put(key, payload, { + httpMetadata: { contentType }, + customMetadata: { sha256: expectedSha256 }, + sha256: hexToArrayBuffer(expectedSha256), + }); + return { key, sha256: expectedSha256, sizeBytes: payload.byteLength }; +} + +export async function getVerifiedObject( + bucket: ElyR2Bucket, + key: string, + expectedSha256: string, +): Promise { + assertKnownObjectKey(key); + assertSha256Hex(expectedSha256, "sha256"); + assertKeyChecksum(key, expectedSha256); + const object = await bucket.get(key); + if (object === null) { + return null; + } + + const payload = await object.arrayBuffer(); + const actualSha256 = await sha256Hex(payload); + if (actualSha256 !== expectedSha256) { + throw new StorageObjectError("r2_checksum_mismatch"); + } + return payload; +} + +function assertKnownObjectKey(key: string): void { + const matches = [ + /^sync-payloads\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.bin$/, + /^sync-snapshots\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\.bin$/, + /^plugin-packages\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.rplug$/, + /^plugin-assets\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}$/, + /^user-avatars\/[a-f0-9]{64}\/[a-f0-9]{64}$/, + /^crash-attachments\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}$/, + /^exports\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\.bin$/, + ]; + if (!matches.some((pattern) => pattern.test(key))) { + throw new StorageObjectError("r2_key_invalid"); + } + if (key.startsWith("sync-payloads/")) { + assertSyncObjectType(key.split("/")[3] ?? ""); + } +} + +function assertKeyChecksum(key: string, expectedSha256: string): void { + const keyChecksum = checksumFromKey(key); + if (keyChecksum !== null && keyChecksum !== expectedSha256) { + throw new StorageObjectError("r2_key_checksum_mismatch"); + } +} + +function checksumFromKey(key: string): string | null { + const segments = key.split("/"); + const prefix = segments[0]; + const lastSegment = segments[segments.length - 1]; + if (lastSegment === undefined) { + return null; + } + + if (prefix === "sync-payloads") { + return lastSegment.slice(0, -".bin".length); + } + if (prefix === "plugin-packages") { + return lastSegment.slice(0, -".rplug".length); + } + if (prefix === "plugin-assets" || prefix === "user-avatars" || prefix === "crash-attachments") { + return lastSegment; + } + return null; +} + +function assertRegion(value: string): void { + if (!REGION.test(value)) { + throw new StorageObjectError("region_invalid"); + } +} + +function assertSegment(value: string, name: string): void { + if (!SEGMENT.test(value)) { + throw new StorageObjectError(`${name}_invalid`); + } +} + +function assertSyncObjectType(value: string): void { + if (!SYNC_OBJECT_TYPES.has(value)) { + throw new StorageObjectError("object_type_invalid"); + } +} + +function assertSha256Hex(value: string, name: string): void { + if (!SHA256_HEX.test(value)) { + throw new StorageObjectError(`${name}_invalid`); + } +} + +async function sha256Hex(payload: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest("SHA-256", payload); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function hexToArrayBuffer(hex: string): ArrayBuffer { + const bytes = new Uint8Array(hex.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return bytes.buffer; +} diff --git a/cloudflare/tests/api_controls.test.ts b/cloudflare/tests/api_controls.test.ts index c86c282..7ae40ff 100644 --- a/cloudflare/tests/api_controls.test.ts +++ b/cloudflare/tests/api_controls.test.ts @@ -103,6 +103,7 @@ function testEnv(options: TestEnvOptions = {}): Env { return Promise.resolve(values.get(key) ?? null); }, }, + ELY_STORAGE: testR2Bucket(), ELY_RATE_LIMITER: { limit(input: { key: string }): Promise<{ success: boolean }> { options.rateLimitKeys?.push(input.key); @@ -119,6 +120,21 @@ function testEnv(options: TestEnvOptions = {}): Env { }; } +function testR2Bucket(): Env["ELY_STORAGE"] { + return { + get() { + return Promise.resolve(null); + }, + put() { + return Promise.resolve({ + arrayBuffer() { + return Promise.resolve(new ArrayBuffer(0)); + }, + }); + }, + }; +} + function testD1Database(): Env["ELY_DB"] { return { prepare() { diff --git a/cloudflare/tests/index.test.ts b/cloudflare/tests/index.test.ts index e814927..b790e72 100644 --- a/cloudflare/tests/index.test.ts +++ b/cloudflare/tests/index.test.ts @@ -347,6 +347,7 @@ function testEnv( return Promise.resolve(values.get(key) ?? null); }, }, + ELY_STORAGE: testR2Bucket(), ELY_RATE_LIMITER: { limit(): Promise<{ success: boolean }> { return Promise.resolve({ success: true }); @@ -358,6 +359,21 @@ function testEnv( }; } +function testR2Bucket(): Env["ELY_STORAGE"] { + return { + get() { + return Promise.resolve(null); + }, + put() { + return Promise.resolve({ + arrayBuffer() { + return Promise.resolve(new ArrayBuffer(0)); + }, + }); + }, + }; +} + function releaseManifestDocument(): string { return JSON.stringify({ version: 1, diff --git a/cloudflare/tests/storage.test.ts b/cloudflare/tests/storage.test.ts new file mode 100644 index 0000000..88cd587 --- /dev/null +++ b/cloudflare/tests/storage.test.ts @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { describe, it } from "node:test"; + +import type { ElyR2Bucket, ElyR2PutOptions } from "../src/bindings.js"; +import { + StorageObjectError, + crashAttachmentKey, + exportObjectKey, + getVerifiedObject, + pluginAssetKey, + pluginPackageKey, + putVerifiedObject, + syncPayloadKey, + syncSnapshotKey, + userAvatarKey, +} from "../src/storage.js"; + +const USER_HASH = "a".repeat(64); +const PAYLOAD_HASH = "b".repeat(64); +const PACKAGE_HASH = "c".repeat(64); +const ASSET_HASH = "d".repeat(64); +const AVATAR_HASH = "e".repeat(64); +const ATTACHMENT_HASH = "f".repeat(64); + +describe("R2 storage contracts", () => { + it("builds PRD object keys without sensitive path segments", () => { + assert.equal( + syncPayloadKey({ + region: "us-east", + userHash: USER_HASH, + objectType: "tabs", + objectId: "tab-01", + payloadHash: PAYLOAD_HASH, + }), + `sync-payloads/us-east/${USER_HASH}/tabs/tab-01/${PAYLOAD_HASH}.bin`, + ); + assert.equal( + syncSnapshotKey({ region: "us-east", userHash: USER_HASH, snapshotId: "snapshot-01" }), + `sync-snapshots/us-east/${USER_HASH}/snapshot-01.bin`, + ); + assert.equal( + pluginPackageKey({ pluginId: "elydora.reader", packageHash: PACKAGE_HASH }), + `plugin-packages/elydora.reader/${PACKAGE_HASH}.rplug`, + ); + assert.equal( + pluginAssetKey({ pluginId: "elydora.reader", assetHash: ASSET_HASH }), + `plugin-assets/elydora.reader/${ASSET_HASH}`, + ); + assert.equal( + userAvatarKey({ userHash: USER_HASH, avatarHash: AVATAR_HASH }), + `user-avatars/${USER_HASH}/${AVATAR_HASH}`, + ); + assert.equal( + crashAttachmentKey({ reportId: "report-01", attachmentHash: ATTACHMENT_HASH }), + `crash-attachments/report-01/${ATTACHMENT_HASH}`, + ); + assert.equal( + exportObjectKey({ userHash: USER_HASH, exportId: "export-01" }), + `exports/${USER_HASH}/export-01.bin`, + ); + }); + + it("rejects R2 keys with path traversal or unsupported sync object types", () => { + assert.throws( + () => + syncPayloadKey({ + region: "us-east", + userHash: USER_HASH, + objectType: "passwords", + objectId: "tab-01", + payloadHash: PAYLOAD_HASH, + }), + StorageObjectError, + ); + assert.throws( + () => crashAttachmentKey({ reportId: "../report-01", attachmentHash: ATTACHMENT_HASH }), + StorageObjectError, + ); + }); + + it("rejects raw sync payload keys with unsupported object types before R2 writes", async () => { + const bucket = recordedR2Bucket(); + const payload = bytes("encrypted sync payload"); + const checksum = sha256(payload); + const key = `sync-payloads/us-east/${USER_HASH}/passwords/item-01/${checksum}.bin`; + + await assert.rejects( + () => putVerifiedObject(bucket, key, payload, checksum, "application/octet-stream"), + StorageObjectError, + ); + assert.equal(bucket.puts.length, 0); + }); + + it("writes R2 objects only after checksum verification", async () => { + const bucket = recordedR2Bucket(); + const payload = bytes("encrypted sync payload"); + const checksum = sha256(payload); + const key = syncPayloadKey({ + region: "us-east", + userHash: USER_HASH, + objectType: "tabs", + objectId: "tab-01", + payloadHash: checksum, + }); + + const stored = await putVerifiedObject(bucket, key, payload, checksum, "application/octet-stream"); + + assert.deepEqual(stored, { key, sha256: checksum, sizeBytes: payload.byteLength }); + assert.equal(bucket.puts.length, 1); + assert.equal(bucket.puts[0]?.key, key); + assert.equal(bucket.puts[0]?.options.httpMetadata?.contentType, "application/octet-stream"); + assert.equal(bucket.puts[0]?.options.customMetadata?.sha256, checksum); + assert.deepEqual( + new Uint8Array(bucket.puts[0]?.options.sha256 ?? new ArrayBuffer(0)), + new Uint8Array(Buffer.from(checksum, "hex")), + ); + }); + + it("rejects payload checksums that disagree with hashed R2 key segments", async () => { + const bucket = recordedR2Bucket(); + const payload = bytes("encrypted sync payload"); + const checksum = sha256(payload); + const key = syncPayloadKey({ + region: "us-east", + userHash: USER_HASH, + objectType: "tabs", + objectId: "tab-01", + payloadHash: PAYLOAD_HASH, + }); + + await assert.rejects( + () => putVerifiedObject(bucket, key, payload, checksum, "application/octet-stream"), + StorageObjectError, + ); + assert.equal(bucket.puts.length, 0); + }); + + it("rejects payload checksum mismatches before R2 writes", async () => { + const bucket = recordedR2Bucket(); + const payload = bytes("encrypted sync payload"); + const key = syncPayloadKey({ + region: "us-east", + userHash: USER_HASH, + objectType: "tabs", + objectId: "tab-01", + payloadHash: PAYLOAD_HASH, + }); + + await assert.rejects( + () => putVerifiedObject(bucket, key, payload, PAYLOAD_HASH, "application/octet-stream"), + StorageObjectError, + ); + assert.equal(bucket.puts.length, 0); + }); + + it("verifies R2 downloads against the expected checksum", async () => { + const payload = bytes("encrypted snapshot"); + const checksum = sha256(payload); + const bucket = recordedR2Bucket(payload); + const key = syncSnapshotKey({ + region: "us-east", + userHash: USER_HASH, + snapshotId: "snapshot-01", + }); + + const downloaded = await getVerifiedObject(bucket, key, checksum); + + assert.deepEqual(new Uint8Array(downloaded ?? new ArrayBuffer(0)), new Uint8Array(payload)); + }); +}); + +interface RecordedPut { + key: string; + payload: ArrayBuffer; + options: ElyR2PutOptions; +} + +interface RecordedR2Bucket extends ElyR2Bucket { + puts: RecordedPut[]; +} + +function recordedR2Bucket(payload?: ArrayBuffer): RecordedR2Bucket { + const puts: RecordedPut[] = []; + return { + puts, + get() { + if (payload === undefined) { + return Promise.resolve(null); + } + return Promise.resolve({ + arrayBuffer() { + return Promise.resolve(payload); + }, + }); + }, + put(key: string, value: ArrayBuffer, options: ElyR2PutOptions = {}) { + puts.push({ key, payload: value, options }); + return Promise.resolve({ + arrayBuffer() { + return Promise.resolve(value); + }, + }); + }, + }; +} + +function bytes(value: string): ArrayBuffer { + return new TextEncoder().encode(value).buffer; +} + +function sha256(payload: ArrayBuffer): string { + return createHash("sha256").update(new Uint8Array(payload)).digest("hex"); +} diff --git a/cloudflare/wrangler.toml b/cloudflare/wrangler.toml index ba03f8c..33ab615 100644 --- a/cloudflare/wrangler.toml +++ b/cloudflare/wrangler.toml @@ -8,6 +8,10 @@ database_name = "elydora-db" database_id = "6cf37748-b5ff-4765-ba91-69f3780dcbd7" migrations_dir = "migrations" +[[r2_buckets]] +binding = "ELY_STORAGE" +bucket_name = "ely-browser-storage" + [[kv_namespaces]] binding = "ELY_KV" id = "5eff92ba31c94fcf83e1b6d5e79ce070"