commit ecbb8bba9b7e3be77201312efb70a7b453d4a888 Author: Albrecht Degering Date: Mon Jul 13 01:45:37 2026 +0200 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a1a6a6c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +node_modules +dist +*.tsbuildinfo +*.log +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62ccde4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..3873f1c --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# @addideas/sefidrop + +Sefidrop provides browser-side E2EE file encryption helpers and backend File Drop contract primitives. + +It includes: + +- recipient key generation, fingerprinting, encryption and decryption helpers; +- `filedrop.v1` discovery, upload commit and event schemas; +- local ciphertext storage adapter plus a storage interface for object stores; +- signed webhook envelope helpers for standalone service integrations. + +The package is intended to be consumed by host applications such as Interling, and can be served as its own API product. + +See `docs/return-file-key-model.md` for the multi-recipient direction needed for encrypted deliverables returned to clients and contractor assignment workflows. diff --git a/docs/filedrop-integration-contract.md b/docs/filedrop-integration-contract.md new file mode 100644 index 0000000..d282a4a --- /dev/null +++ b/docs/filedrop-integration-contract.md @@ -0,0 +1,134 @@ +# File Drop Integration Contract + +Version: `filedrop.v1` + +The File Drop service is backend/API-first. UI code is only an integration example. Host systems discover an active dropbox, encrypt locally to the advertised recipient key, then commit ciphertext and the E2EE envelope to the intake endpoint. + +## Discovery + +`GET /api/dropbox/current` + +Response: + +```json +{ + "contractVersion": "filedrop.v1", + "dropboxId": "default", + "fingerprint": "recipient-key-fingerprint", + "key": { + "fingerprint": "recipient-key-fingerprint", + "publicKey": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }, + "source": "configured", + "status": "active" + }, + "label": "Interling secure intake", + "publicKey": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }, + "source": "configured", + "status": "active", + "recipientKeys": [ + { + "fingerprint": "recipient-key-fingerprint", + "publicKey": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }, + "source": "configured", + "status": "active" + }, + { + "fingerprint": "master-recovery-fingerprint", + "publicKey": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }, + "source": "configured", + "status": "active" + } + ] +} +``` + +Integrations must show or otherwise pin the recipient `fingerprint` where key authenticity matters. + +The `key.status` value is one of `active`, `rotating`, or `revoked`. Upload UIs must not accept new files for revoked keys. + +## Upload Commit + +`POST /api/uploads/intake` + +Content type: `multipart/form-data` + +Fields: + +- `ciphertext`: encrypted file bytes. +- `envelope`: JSON E2EE envelope from `@addideas/sefidrop/e2ee`. +- `contactName`: sender name. +- `contactEmail`: sender email. +- `dropboxId`: discovery response id. +- `routingLabel`: operational routing label. +- `serviceType`: optional operational service type. +- `languagePair`: optional operational language pair. +- `requestedDeadline`: optional operational date. +- `projectPublicId`: optional existing order id for project-specific drop links. + +The original filename, note, MIME type, and other sensitive sender metadata stay in the encrypted E2EE metadata envelope. +New integrations should encrypt to every active `recipientKeys[]` entry and submit an envelope with `recipients[]`. The legacy `recipient` field remains required and mirrors the first recipient wrap for older consumers. + +Response: + +```json +{ + "id": "upload-id", + "orderId": "INTAKE-20260710-XXXXXXXX", + "storageKey": "upload-id.enc", + "status": "ciphertext_received" +} +``` + +## Event + +Each accepted upload emits a `filedrop.upload.received` event. Interling records that event in `project_events`, links the ciphertext row to an order, and exposes it in the portal timeline. + +The event payload is intentionally host-neutral: + +```json +{ + "contractVersion": "filedrop.v1", + "eventType": "filedrop.upload.received", + "occurredAt": "2026-07-10T17:32:07.000Z", + "sender": { "contactEmail": "client@example.com" }, + "routing": { + "dropboxId": "default", + "serviceType": "translation", + "languagePair": "DE -> EN" + }, + "storage": { + "driver": "local", + "key": "upload-id.enc", + "manifestKey": "upload-id.json" + }, + "upload": { + "id": "upload-id", + "ciphertextByteLength": 12345, + "ciphertextSha256": "base64url-sha256", + "recipientKeyFingerprint": "recipient-key-fingerprint", + "recipientKeyFingerprints": ["recipient-key-fingerprint", "master-recovery-fingerprint"] + } +} +``` + +## Webhook Signature + +Future standalone deployments can deliver the same event via webhook instead of direct DB integration. + +Signed webhook deliveries use these headers: + +- `x-filedrop-webhook-id`: stable id for idempotency. +- `x-filedrop-timestamp`: Unix timestamp in seconds. +- `x-filedrop-signature`: HMAC-SHA256 signature, formatted as `v1=`. + +The signature input is: + +```txt +. +``` + +Receivers should reject old timestamps and verify the signature with a shared webhook secret. + +## Storage Boundary + +The package exposes a `FileDropStorage` interface. The current Interling host uses the local filesystem adapter; a standalone service can provide S3-compatible storage without changing the upload commit contract. diff --git a/docs/return-file-key-model.md b/docs/return-file-key-model.md new file mode 100644 index 0000000..90d460c --- /dev/null +++ b/docs/return-file-key-model.md @@ -0,0 +1,59 @@ +# Return File Key Model + +File drops and returned deliverables use the same browser-only cryptographic boundary, but they need different recipient discovery. + +## Principle + +The server stores ciphertext, public keys, key fingerprints, wrapped content keys, routing metadata, and audit events. It must not receive private keys or plaintext file bytes. + +Each encrypted file should have one content key and one or more recipient key wraps: + +- office intake key for incoming client material; +- assigned contractor key for source files that need contractor access; +- client account key for deliverables returned through the portal; +- master recovery key for operational recovery. + +The existing single-recipient envelope remains valid for simple file drops. New envelopes include `recipients[]` while keeping the legacy `recipient` field readable. + +## Client Delivery + +Client accounts need a recipient key pair before they can receive encrypted deliverables. + +Recommended flow: + +1. The browser creates or imports a P-256 recipient key pair. +2. The private key stays browser-side and can be downloaded as a JSON key file or pasted/imported later. +3. The public key and fingerprint are registered with the host system. +4. Deliverables are encrypted in the browser to the client public key and the master recovery public key. +5. The client decrypts in the browser by presenting the private key file or pasted private key. + +For non-technical clients, the private-key file is the least ambiguous first UX. Passphrase-protected key files can be added later. + +## Contractor Access + +Contractors should have their own registered public key. When a source file needs contractor access, an authorized office browser decrypts or unwraps the content key and re-encrypts/rewraps it to the contractor key. The server only receives the updated recipient wrap. + +Contractor deliverables are uploaded as encrypted ciphertext and recorded as a source-deliverable pair: + +- source file id; +- deliverable file id; +- contractor user id; +- delivery status and review timestamps. + +## Master Recovery Key + +The master recovery key is a public key configured in the host system. Its private key should be kept offline or in a controlled operator keystore, never in the application database. + +Every real encrypted file should include a master recipient wrap. Recovery is then possible by importing the master private key in a browser-only operator tool and re-encrypting the content key to a new client or contractor key. + +## Host API Shape + +A host system should provide: + +- recipient key registration for account keys; +- active public-key discovery for client, contractor, office, and master recipients; +- encrypted file upload for source and deliverable purposes; +- recipient wrap update for assignment/recovery workflows; +- encrypted file download with access checks only. + +The host remains responsible for orders, users, payment state, permissions, and audit trails. Sefidrop remains responsible for the ciphertext and envelope contract. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..12bf3f4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,62 @@ +{ + "name": "@addideas/sefidrop", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@addideas/sefidrop", + "version": "0.1.0", + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..58d1a00 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "@addideas/sefidrop", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./e2ee": { + "types": "./dist/e2ee.d.ts", + "import": "./dist/e2ee.js", + "default": "./dist/e2ee.js" + }, + "./filedrop": { + "types": "./dist/filedrop.d.ts", + "import": "./dist/filedrop.js", + "default": "./dist/filedrop.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "npm run build && node --test test/*.test.ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/src/e2ee.ts b/src/e2ee.ts new file mode 100644 index 0000000..6dabf93 --- /dev/null +++ b/src/e2ee.ts @@ -0,0 +1,482 @@ +export type RecipientPublicKeyJwk = JsonWebKey & { + crv: "P-256"; + kty: "EC"; + x: string; + y: string; +}; + +export type RecipientPrivateKeyJwk = JsonWebKey & { + crv: "P-256"; + d: string; + kty: "EC"; + x: string; + y: string; +}; + +export interface RecipientKeyPairJwks { + publicKey: RecipientPublicKeyJwk; + privateKey: RecipientPrivateKeyJwk; + fingerprint: string; +} + +export interface E2eeRecipientWrapV1 { + keyFingerprint: string; + ephemeralPublicKey: RecipientPublicKeyJwk; + wrappedContentKey: string; +} + +export interface E2eeEnvelopeV1 { + version: 1; + suite: "ECDH-P256+A256KW+A256GCM"; + createdAt: string; + recipient: E2eeRecipientWrapV1; + recipients?: E2eeRecipientWrapV1[]; + content: { + algorithm: "AES-GCM-256"; + iv: string; + byteLength: number; + sha256: string; + }; + metadata: { + algorithm: "AES-GCM-256"; + iv: string; + ciphertext: string; + }; +} + +export interface EncryptedPayload { + ciphertext: Blob; + envelope: E2eeEnvelopeV1; +} + +export interface DecryptedPayload { + plaintext: ArrayBuffer; + metadata: unknown; +} + +export interface DecryptEnvelopeOptions { + keyFingerprint?: string; +} + +export interface RewrapEnvelopeOptions extends DecryptEnvelopeOptions { + replaceExisting?: boolean; +} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export async function createRecipientKeyPair(): Promise { + const crypto = getCrypto(); + const pair = await crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveKey", "deriveBits"] + ); + + const publicKey = (await crypto.subtle.exportKey("jwk", pair.publicKey)) as RecipientPublicKeyJwk; + const privateKey = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as RecipientPrivateKeyJwk; + return { + publicKey, + privateKey, + fingerprint: await fingerprintRecipientPublicKey(publicKey) + }; +} + +export async function fingerprintRecipientPublicKey(publicKey: RecipientPublicKeyJwk): Promise { + const crypto = getCrypto(); + const canonical = canonicalJson({ + crv: publicKey.crv, + kty: publicKey.kty, + x: publicKey.x, + y: publicKey.y + }); + const digest = await crypto.subtle.digest("SHA-256", textEncoder.encode(canonical)); + return bytesToBase64Url(new Uint8Array(digest)).slice(0, 22); +} + +export async function encryptFileForRecipient( + file: File, + recipientPublicKeyJwk: RecipientPublicKeyJwk, + metadata: Record = {} +): Promise { + return encryptFileForRecipients(file, [recipientPublicKeyJwk], metadata); +} + +export async function encryptFileForRecipients( + file: File, + recipientPublicKeyJwks: RecipientPublicKeyJwk[], + metadata: Record = {} +): Promise { + const plaintext = await file.arrayBuffer(); + return encryptBytesForRecipients(plaintext, recipientPublicKeyJwks, { + originalName: file.name, + mimeType: file.type || "application/octet-stream", + size: file.size, + lastModified: file.lastModified, + ...metadata + }); +} + +export async function encryptBytesForRecipient( + plaintext: ArrayBuffer, + recipientPublicKeyJwk: RecipientPublicKeyJwk, + metadata: Record = {} +): Promise { + return encryptBytesForRecipients(plaintext, [recipientPublicKeyJwk], metadata); +} + +export async function encryptBytesForRecipients( + plaintext: ArrayBuffer, + recipientPublicKeyJwks: RecipientPublicKeyJwk[], + metadata: Record = {} +): Promise { + if (!recipientPublicKeyJwks.length) { + throw new Error("At least one recipient public key is required."); + } + + const crypto = getCrypto(); + const recipientDescriptors = []; + const seenFingerprints = new Set(); + for (const recipientPublicKeyJwk of recipientPublicKeyJwks) { + const keyFingerprint = await fingerprintRecipientPublicKey(recipientPublicKeyJwk); + if (seenFingerprints.has(keyFingerprint)) { + continue; + } + seenFingerprints.add(keyFingerprint); + recipientDescriptors.push({ + keyFingerprint, + publicKey: await crypto.subtle.importKey( + "jwk", + recipientPublicKeyJwk, + { name: "ECDH", namedCurve: "P-256" }, + false, + [] + ) + }); + } + if (!recipientDescriptors.length) { + throw new Error("At least one unique recipient public key is required."); + } + + const ephemeralPair = await crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveKey", "deriveBits"] + ); + + const contentKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + const contentIv = randomBytes(12); + const metadataIv = randomBytes(12); + const metadataBytes = textEncoder.encode(JSON.stringify(metadata)); + + const [ciphertext, encryptedMetadata] = await Promise.all([ + crypto.subtle.encrypt({ name: "AES-GCM", iv: asBufferSource(contentIv), tagLength: 128 }, contentKey, plaintext), + crypto.subtle.encrypt({ name: "AES-GCM", iv: asBufferSource(metadataIv), tagLength: 128 }, contentKey, metadataBytes) + ]); + + const ciphertextHash = await crypto.subtle.digest("SHA-256", ciphertext); + const ephemeralPublicKey = (await crypto.subtle.exportKey( + "jwk", + ephemeralPair.publicKey + )) as RecipientPublicKeyJwk; + const recipients = await Promise.all( + recipientDescriptors.map(async (recipient) => { + const wrappingKey = await crypto.subtle.deriveKey( + { name: "ECDH", public: recipient.publicKey }, + ephemeralPair.privateKey, + { name: "AES-KW", length: 256 }, + false, + ["wrapKey"] + ); + const wrappedContentKey = await crypto.subtle.wrapKey("raw", contentKey, wrappingKey, "AES-KW"); + return { + keyFingerprint: recipient.keyFingerprint, + ephemeralPublicKey, + wrappedContentKey: arrayBufferToBase64Url(wrappedContentKey) + }; + }) + ); + const primaryRecipient = recipients[0]; + + return { + ciphertext: new Blob([ciphertext], { type: "application/octet-stream" }), + envelope: { + version: 1, + suite: "ECDH-P256+A256KW+A256GCM", + createdAt: new Date().toISOString(), + recipient: primaryRecipient, + recipients, + content: { + algorithm: "AES-GCM-256", + iv: bytesToBase64Url(contentIv), + byteLength: ciphertext.byteLength, + sha256: arrayBufferToBase64Url(ciphertextHash) + }, + metadata: { + algorithm: "AES-GCM-256", + iv: bytesToBase64Url(metadataIv), + ciphertext: arrayBufferToBase64Url(encryptedMetadata) + } + } + }; +} + +export async function decryptPayloadFromEnvelope( + ciphertext: ArrayBuffer, + envelope: E2eeEnvelopeV1, + recipientPrivateKeyJwk: RecipientPrivateKeyJwk, + options: DecryptEnvelopeOptions = {} +): Promise { + const crypto = getCrypto(); + const contentKey = await unwrapContentKeyFromEnvelope(envelope, recipientPrivateKeyJwk, options, { + extractable: false, + keyUsages: ["decrypt"] + }); + const plaintext = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: asBufferSource(base64UrlToBytes(envelope.content.iv)), + tagLength: 128 + }, + contentKey, + ciphertext + ); + const metadataBytes = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: asBufferSource(base64UrlToBytes(envelope.metadata.iv)), + tagLength: 128 + }, + contentKey, + asBufferSource(base64UrlToBytes(envelope.metadata.ciphertext)) + ); + + return { + plaintext, + metadata: JSON.parse(textDecoder.decode(metadataBytes)) + }; +} + +export async function rewrapEnvelopeForRecipients( + envelope: E2eeEnvelopeV1, + recipientPrivateKeyJwk: RecipientPrivateKeyJwk, + recipientPublicKeyJwks: RecipientPublicKeyJwk[], + options: RewrapEnvelopeOptions = {} +): Promise { + if (!recipientPublicKeyJwks.length) { + return envelope; + } + + const contentKey = await unwrapContentKeyFromEnvelope(envelope, recipientPrivateKeyJwk, options, { + extractable: true, + keyUsages: ["decrypt"] + }); + const additions = await Promise.all( + recipientPublicKeyJwks.map((recipientPublicKeyJwk) => createRecipientWrapForContentKey(contentKey, recipientPublicKeyJwk)) + ); + const recipients = mergeRecipientWraps(getEnvelopeRecipientWraps(envelope), additions, Boolean(options.replaceExisting)); + return { + ...envelope, + recipient: recipients[0], + recipients + }; +} + +export async function createRecipientWrapForContentKey( + contentKey: CryptoKey, + recipientPublicKeyJwk: RecipientPublicKeyJwk +): Promise { + const crypto = getCrypto(); + const recipientPublicKey = await crypto.subtle.importKey( + "jwk", + recipientPublicKeyJwk, + { name: "ECDH", namedCurve: "P-256" }, + false, + [] + ); + const ephemeralPair = await crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveKey", "deriveBits"] + ); + const wrappingKey = await crypto.subtle.deriveKey( + { name: "ECDH", public: recipientPublicKey }, + ephemeralPair.privateKey, + { name: "AES-KW", length: 256 }, + false, + ["wrapKey"] + ); + const ephemeralPublicKey = (await crypto.subtle.exportKey( + "jwk", + ephemeralPair.publicKey + )) as RecipientPublicKeyJwk; + const wrappedContentKey = await crypto.subtle.wrapKey("raw", contentKey, wrappingKey, "AES-KW"); + return { + ephemeralPublicKey, + keyFingerprint: await fingerprintRecipientPublicKey(recipientPublicKeyJwk), + wrappedContentKey: arrayBufferToBase64Url(wrappedContentKey) + }; +} + +export async function unwrapContentKeyFromEnvelope( + envelope: E2eeEnvelopeV1, + recipientPrivateKeyJwk: RecipientPrivateKeyJwk, + options: DecryptEnvelopeOptions = {}, + unwrapOptions: { extractable?: boolean; keyUsages?: KeyUsage[] } = {} +): Promise { + const crypto = getCrypto(); + const recipient = await selectRecipientWrap(envelope, recipientPrivateKeyJwk, options); + const privateKey = await crypto.subtle.importKey( + "jwk", + recipientPrivateKeyJwk, + { name: "ECDH", namedCurve: "P-256" }, + false, + ["deriveKey"] + ); + const ephemeralPublicKey = await crypto.subtle.importKey( + "jwk", + recipient.ephemeralPublicKey, + { name: "ECDH", namedCurve: "P-256" }, + false, + [] + ); + const wrappingKey = await crypto.subtle.deriveKey( + { name: "ECDH", public: ephemeralPublicKey }, + privateKey, + { name: "AES-KW", length: 256 }, + false, + ["unwrapKey"] + ); + return crypto.subtle.unwrapKey( + "raw", + asBufferSource(base64UrlToBytes(recipient.wrappedContentKey)), + wrappingKey, + "AES-KW", + { name: "AES-GCM", length: 256 }, + unwrapOptions.extractable ?? true, + unwrapOptions.keyUsages ?? ["decrypt"] + ); +} + +export function getEnvelopeRecipientWraps(envelope: E2eeEnvelopeV1): E2eeRecipientWrapV1[] { + const recipients = envelope.recipients?.length ? envelope.recipients : [envelope.recipient]; + const seen = new Set(); + return recipients.filter((recipient) => { + const key = `${recipient.keyFingerprint}:${recipient.wrappedContentKey}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +async function selectRecipientWrap( + envelope: E2eeEnvelopeV1, + recipientPrivateKeyJwk: RecipientPrivateKeyJwk, + options: DecryptEnvelopeOptions +) { + const recipients = getEnvelopeRecipientWraps(envelope); + if (options.keyFingerprint) { + const recipient = recipients.find((candidate) => candidate.keyFingerprint === options.keyFingerprint); + if (!recipient) { + throw new Error("No matching recipient wrap found for the requested key fingerprint."); + } + return recipient; + } + + const privateKeyFingerprint = await fingerprintRecipientPublicKey(recipientPrivateKeyJwk); + const recipient = recipients.find((candidate) => candidate.keyFingerprint === privateKeyFingerprint); + if (recipient) { + return recipient; + } + if (recipients.length === 1) { + return recipients[0]; + } + throw new Error("No matching recipient wrap found for the private key."); +} + +function mergeRecipientWraps( + existingRecipients: E2eeRecipientWrapV1[], + additions: E2eeRecipientWrapV1[], + replaceExisting: boolean +) { + const recipientsByFingerprint = new Map(); + for (const recipient of existingRecipients) { + recipientsByFingerprint.set(recipient.keyFingerprint, recipient); + } + for (const recipient of additions) { + if (replaceExisting || !recipientsByFingerprint.has(recipient.keyFingerprint)) { + recipientsByFingerprint.set(recipient.keyFingerprint, recipient); + } + } + return Array.from(recipientsByFingerprint.values()); +} + +function randomBytes(length: number): Uint8Array { + const bytes = new Uint8Array(length); + getCrypto().getRandomValues(bytes); + return bytes; +} + +function getCrypto(): Crypto { + const crypto = globalThis.crypto; + if (!crypto?.subtle) { + throw new Error("Web Crypto is not available in this runtime."); + } + return crypto; +} + +function arrayBufferToBase64Url(buffer: ArrayBuffer): string { + return bytesToBase64Url(new Uint8Array(buffer)); +} + +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + const base64 = + typeof btoa === "function" + ? btoa(binary) + : (globalThis as unknown as { Buffer: { from(input: Uint8Array): { toString(format: string): string } } }).Buffer + .from(bytes) + .toString("base64"); + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/u, ""); +} + +function base64UrlToBytes(value: string): Uint8Array { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + if (typeof atob === "function") { + const binary = atob(base64); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); + } + const buffer = (globalThis as unknown as { Buffer: { from(input: string, format: string): Uint8Array } }).Buffer.from( + base64, + "base64" + ); + return new Uint8Array(buffer); +} + +function asBufferSource(bytes: Uint8Array): BufferSource { + return bytes as unknown as BufferSource; +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + if (value && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} diff --git a/src/filedrop.ts b/src/filedrop.ts new file mode 100644 index 0000000..c59b20a --- /dev/null +++ b/src/filedrop.ts @@ -0,0 +1,543 @@ +import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + fingerprintRecipientPublicKey, + getEnvelopeRecipientWraps, + type E2eeEnvelopeV1, + type RecipientPublicKeyJwk +} from "./e2ee.js"; +import { z } from "zod"; + +export const FILEDROP_CONTRACT_VERSION = "filedrop.v1"; +export const FILEDROP_WEBHOOK_SIGNATURE_HEADER = "x-filedrop-signature"; +export const FILEDROP_WEBHOOK_TIMESTAMP_HEADER = "x-filedrop-timestamp"; +export const FILEDROP_WEBHOOK_ID_HEADER = "x-filedrop-webhook-id"; + +export const developmentRecipientPublicKey: RecipientPublicKeyJwk = { + key_ops: [], + ext: true, + kty: "EC", + x: "5SL1U-NK-YxVJ3VogCvUj5NXye4egWvFzZUOS9mp1jM", + y: "dibuzB2lXxOlgyJ_relW52HKP16f_8d8yjhdGh5ILE8", + crv: "P-256" +}; + +export interface FileDropEnvironment { + FILEDROP_DROPBOX_ID?: string; + FILEDROP_DROPBOX_LABEL?: string; + FILEDROP_RECIPIENT_KEY_CREATED_AT?: string; + FILEDROP_RECIPIENT_KEY_REVOKED_AT?: string; + FILEDROP_RECIPIENT_KEY_ROTATED_AT?: string; + FILEDROP_RECIPIENT_KEY_STATUS?: string; + FILEDROP_RECIPIENT_PUBLIC_KEY_JWK?: string; + FILEDROP_WEBHOOK_SECRET?: string; + /** @deprecated Use FILEDROP_DROPBOX_LABEL. */ + DROPBOX_RECIPIENT_LABEL?: string; + /** @deprecated Use FILEDROP_RECIPIENT_PUBLIC_KEY_JWK. */ + DROPBOX_RECIPIENT_PUBLIC_KEY_JWK?: string; + LOCAL_STORAGE_DIR?: string; +} + +export type FileDropKeyStatus = "active" | "rotating" | "revoked"; + +export interface SaveCiphertextInput { + ciphertext: Blob; + id: string; + manifest: Record; + storageDir?: string; +} + +export interface FileDropStoredObject { + driver: string; + manifestKey: string; + storageKey: string; +} + +export interface FileDropStorage { + saveCiphertextUpload(input: SaveCiphertextInput): Promise; +} + +export interface ReceivedCiphertextIntake { + commit: FileDropUploadCommit; + event: FileDropEvent; + id: string; + manifestKey: string; + status: "ciphertext_received"; + storageDriver: string; + storageKey: string; +} + +export interface FileDropRecipientKeyDescriptor { + createdAt?: string; + fingerprint: string; + publicKey: RecipientPublicKeyJwk; + revokedAt?: string; + rotatedAt?: string; + source: "configured" | "development"; + status: FileDropKeyStatus; +} + +export interface FileDropDiscovery { + contractVersion: typeof FILEDROP_CONTRACT_VERSION; + dropboxId: string; + fingerprint: string; + key: FileDropRecipientKeyDescriptor; + label: string; + publicKey: RecipientPublicKeyJwk; + source: "configured" | "development"; + status: FileDropKeyStatus; +} + +export interface FileDropWebhookEnvelope { + contractVersion: typeof FILEDROP_CONTRACT_VERSION; + createdAt: string; + event: FileDropEvent; + webhookId: string; +} + +export class FileDropInputError extends Error { + readonly details?: unknown; + + constructor(message: string, details?: unknown) { + super(message); + this.details = details; + this.name = "FileDropInputError"; + } +} + +const optionalText = (maxLength: number) => + z.preprocess( + (value) => (typeof value === "string" && value.trim() === "" ? undefined : value), + z.string().trim().max(maxLength).optional() + ); + +export const e2eeRecipientWrapV1Schema = z + .object({ + keyFingerprint: z.string().min(1), + wrappedContentKey: z.string().min(1) + }) + .passthrough(); + +export const e2eeEnvelopeV1Schema = z + .object({ + content: z + .object({ + byteLength: z.number().int().nonnegative(), + sha256: z.string().min(1) + }) + .passthrough(), + metadata: z.object({}).passthrough(), + recipient: e2eeRecipientWrapV1Schema, + recipients: z.array(e2eeRecipientWrapV1Schema).min(1).optional(), + suite: z.string().min(1), + version: z.literal(1) + }) + .passthrough(); + +export const fileDropSenderSchema = z.object({ + contactEmail: z.string().trim().email().max(240), + contactName: z.string().trim().min(1).max(160) +}); + +export const fileDropRoutingSchema = z.object({ + dropboxId: optionalText(80), + languagePair: optionalText(80), + projectPublicId: optionalText(80), + requestedDeadline: optionalText(32), + routingLabel: optionalText(160), + serviceType: optionalText(80) +}); + +export const fileDropKeyStatusSchema = z.enum(["active", "rotating", "revoked"]); + +export const fileDropRecipientKeySchema = z.object({ + createdAt: z.string().datetime().optional(), + fingerprint: z.string().min(1), + publicKey: z.custom( + (value) => + Boolean( + value && + typeof value === "object" && + (value as RecipientPublicKeyJwk).kty === "EC" && + (value as RecipientPublicKeyJwk).crv === "P-256" && + typeof (value as RecipientPublicKeyJwk).x === "string" && + typeof (value as RecipientPublicKeyJwk).y === "string" + ) + ), + revokedAt: z.string().datetime().optional(), + rotatedAt: z.string().datetime().optional(), + source: z.enum(["configured", "development"]), + status: fileDropKeyStatusSchema +}); + +export const fileDropDiscoverySchema = z.object({ + contractVersion: z.literal(FILEDROP_CONTRACT_VERSION), + dropboxId: z.string().min(1).max(80), + fingerprint: z.string().min(1), + key: fileDropRecipientKeySchema, + label: z.string().min(1).max(160), + publicKey: fileDropRecipientKeySchema.shape.publicKey, + source: z.enum(["configured", "development"]), + status: fileDropKeyStatusSchema +}); + +export const fileDropUploadCommitSchema = z.object({ + ciphertext: z.object({ + byteLength: z.number().int().nonnegative(), + sha256: z.string().min(1).optional() + }), + contractVersion: z.literal(FILEDROP_CONTRACT_VERSION), + envelope: e2eeEnvelopeV1Schema, + receivedAt: z.string().datetime(), + routing: fileDropRoutingSchema, + sender: fileDropSenderSchema +}); + +export const fileDropEventSchema = z.object({ + contractVersion: z.literal(FILEDROP_CONTRACT_VERSION), + eventType: z.literal("filedrop.upload.received"), + occurredAt: z.string().datetime(), + routing: fileDropRoutingSchema, + sender: fileDropSenderSchema.pick({ contactEmail: true }), + storage: z.object({ + driver: z.string().min(1), + key: z.string().min(1), + manifestKey: z.string().min(1) + }), + upload: z.object({ + ciphertextByteLength: z.number().int().nonnegative(), + ciphertextSha256: z.string().min(1).optional(), + id: z.string().min(1), + recipientKeyFingerprint: z.string().min(1), + recipientKeyFingerprints: z.array(z.string().min(1)).min(1).optional() + }) +}); + +export type FileDropUploadCommit = z.infer; +export type FileDropEvent = z.infer; +export type FileDropRouting = z.infer; +export type FileDropRecipientKey = z.infer; + +export const intakeSchema = z.object({ + contactName: z.string().trim().min(1).max(160), + contactEmail: z.string().trim().email().max(240), + envelope: z.string().min(1), + dropboxId: optionalText(80), + languagePair: optionalText(80), + projectPublicId: optionalText(80), + requestedDeadline: optionalText(32), + routingLabel: optionalText(160), + serviceType: optionalText(80) +}); + +export async function getActiveDropboxFromEnvironment(env: FileDropEnvironment): Promise { + const configuredPublicKey = env.FILEDROP_RECIPIENT_PUBLIC_KEY_JWK ?? env.DROPBOX_RECIPIENT_PUBLIC_KEY_JWK; + const publicKey = configuredPublicKey + ? (JSON.parse(configuredPublicKey) as RecipientPublicKeyJwk) + : developmentRecipientPublicKey; + const source = configuredPublicKey ? ("configured" as const) : ("development" as const); + const fingerprint = await fingerprintRecipientPublicKey(publicKey); + const key = fileDropRecipientKeySchema.parse({ + createdAt: emptyToUndefined(env.FILEDROP_RECIPIENT_KEY_CREATED_AT), + fingerprint, + publicKey, + revokedAt: emptyToUndefined(env.FILEDROP_RECIPIENT_KEY_REVOKED_AT), + rotatedAt: emptyToUndefined(env.FILEDROP_RECIPIENT_KEY_ROTATED_AT), + source, + status: parseKeyStatus(env.FILEDROP_RECIPIENT_KEY_STATUS) + }); + + return fileDropDiscoverySchema.parse({ + contractVersion: FILEDROP_CONTRACT_VERSION, + dropboxId: env.FILEDROP_DROPBOX_ID?.trim() || "default", + fingerprint, + key, + label: env.FILEDROP_DROPBOX_LABEL ?? env.DROPBOX_RECIPIENT_LABEL ?? "Secure file drop", + publicKey, + source, + status: key.status + }); +} + +export async function receiveCiphertextIntakeFromFormData( + formData: FormData, + env: FileDropEnvironment, + options: { id?: string; now?: Date; storage?: FileDropStorage } = {} +): Promise { + const ciphertext = formData.get("ciphertext"); + if (!(ciphertext instanceof Blob)) { + throw new FileDropInputError("Missing encrypted payload."); + } + + const parsed = intakeSchema.safeParse({ + contactName: formData.get("contactName"), + contactEmail: formData.get("contactEmail"), + dropboxId: formData.get("dropboxId") ?? undefined, + envelope: formData.get("envelope"), + languagePair: formData.get("languagePair") ?? undefined, + projectPublicId: formData.get("projectPublicId") ?? undefined, + requestedDeadline: formData.get("requestedDeadline") ?? undefined, + routingLabel: formData.get("routingLabel") ?? undefined, + serviceType: formData.get("serviceType") ?? undefined + }); + + if (!parsed.success) { + throw new FileDropInputError("Invalid intake payload.", parsed.error.flatten()); + } + + let envelope: unknown; + try { + envelope = JSON.parse(parsed.data.envelope); + } catch { + throw new FileDropInputError("Envelope must be JSON."); + } + const parsedEnvelope = e2eeEnvelopeV1Schema.safeParse(envelope); + if (!parsedEnvelope.success) { + throw new FileDropInputError("Envelope does not match the filedrop.v1 encryption contract.", parsedEnvelope.error.flatten()); + } + const recipientKeyFingerprints = getEnvelopeRecipientWraps(parsedEnvelope.data as unknown as E2eeEnvelopeV1).map( + (recipient) => recipient.keyFingerprint + ); + + const uploadId = options.id ?? randomUUID(); + const receivedAt = (options.now ?? new Date()).toISOString(); + const commit = fileDropUploadCommitSchema.parse({ + ciphertext: { + byteLength: ciphertext.size, + sha256: parsedEnvelope.data.content.sha256 + }, + contractVersion: FILEDROP_CONTRACT_VERSION, + envelope: parsedEnvelope.data, + receivedAt, + routing: { + dropboxId: parsed.data.dropboxId, + languagePair: parsed.data.languagePair, + projectPublicId: parsed.data.projectPublicId, + requestedDeadline: parsed.data.requestedDeadline, + routingLabel: parsed.data.routingLabel ?? "secure-intake", + serviceType: parsed.data.serviceType + }, + sender: { + contactEmail: parsed.data.contactEmail, + contactName: parsed.data.contactName + } + }); + const storage = options.storage ?? createLocalFileDropStorage({ storageDir: env.LOCAL_STORAGE_DIR }); + const stored = await storage.saveCiphertextUpload({ + ciphertext, + id: uploadId, + manifest: { + commit, + contractVersion: FILEDROP_CONTRACT_VERSION, + id: uploadId, + receivedAt + } + }); + const event = fileDropEventSchema.parse({ + contractVersion: FILEDROP_CONTRACT_VERSION, + eventType: "filedrop.upload.received", + occurredAt: receivedAt, + routing: commit.routing, + sender: { contactEmail: commit.sender.contactEmail }, + storage: { + driver: stored.driver, + key: stored.storageKey, + manifestKey: stored.manifestKey + }, + upload: { + ciphertextByteLength: commit.ciphertext.byteLength, + ciphertextSha256: commit.ciphertext.sha256, + id: uploadId, + recipientKeyFingerprint: commit.envelope.recipient.keyFingerprint, + recipientKeyFingerprints + } + }); + + return { + commit, + event, + id: uploadId, + manifestKey: stored.manifestKey, + status: "ciphertext_received", + storageDriver: stored.driver, + storageKey: stored.storageKey + }; +} + +export function createLocalFileDropStorage(options: { storageDir?: string } = {}): FileDropStorage { + return { + async saveCiphertextUpload(input) { + const baseDir = options.storageDir ?? input.storageDir ?? path.join(process.cwd(), "storage", "uploads"); + await mkdir(baseDir, { recursive: true }); + + const storageKey = `${input.id}.enc`; + const manifestKey = `${input.id}.json`; + + await Promise.all([ + writeFile(path.join(baseDir, storageKey), Buffer.from(await input.ciphertext.arrayBuffer())), + writeFile(path.join(baseDir, manifestKey), JSON.stringify(input.manifest, null, 2)) + ]); + + return { + driver: "local", + manifestKey, + storageKey + }; + } + }; +} + +export async function saveCiphertextUpload(input: SaveCiphertextInput) { + return createLocalFileDropStorage({ storageDir: input.storageDir }).saveCiphertextUpload(input); +} + +export function createFileDropWebhookEnvelope( + event: FileDropEvent, + options: { now?: Date; webhookId?: string } = {} +): FileDropWebhookEnvelope { + return { + contractVersion: FILEDROP_CONTRACT_VERSION, + createdAt: (options.now ?? new Date()).toISOString(), + event, + webhookId: options.webhookId ?? randomUUID() + }; +} + +export function serializeFileDropWebhookEnvelope(envelope: FileDropWebhookEnvelope) { + return stableJson(envelope); +} + +export function signFileDropWebhookBody(input: { body: string; secret: string; timestamp: string }) { + const digest = createHmac("sha256", input.secret).update(`${input.timestamp}.${input.body}`).digest("hex"); + return `v1=${digest}`; +} + +export function createSignedFileDropWebhook( + event: FileDropEvent, + secret: string, + options: { now?: Date; webhookId?: string } = {} +) { + const envelope = createFileDropWebhookEnvelope(event, options); + const body = serializeFileDropWebhookEnvelope(envelope); + const timestamp = Math.floor((options.now ?? new Date()).getTime() / 1000).toString(); + const signature = signFileDropWebhookBody({ body, secret, timestamp }); + + return { + body, + envelope, + headers: { + "content-type": "application/json", + [FILEDROP_WEBHOOK_ID_HEADER]: envelope.webhookId, + [FILEDROP_WEBHOOK_SIGNATURE_HEADER]: signature, + [FILEDROP_WEBHOOK_TIMESTAMP_HEADER]: timestamp + } + }; +} + +export function verifyFileDropWebhookSignature(input: { + body: string; + now?: Date; + secret: string; + signatureHeader?: string | null; + timestampHeader?: string | null; + toleranceSeconds?: number; +}) { + const timestamp = input.timestampHeader ? Number(input.timestampHeader) : NaN; + if (!Number.isFinite(timestamp)) { + return false; + } + + const toleranceSeconds = input.toleranceSeconds ?? 5 * 60; + const nowSeconds = Math.floor((input.now ?? new Date()).getTime() / 1000); + if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) { + return false; + } + + const signature = parseSignature(input.signatureHeader); + if (!signature) { + return false; + } + + const expected = signFileDropWebhookBody({ + body: input.body, + secret: input.secret, + timestamp: input.timestampHeader ?? "" + }); + return safeCompare(signature, expected); +} + +export function parseFileDropWebhook(input: { + body: string; + now?: Date; + secret: string; + signatureHeader?: string | null; + timestampHeader?: string | null; + toleranceSeconds?: number; +}) { + if (!verifyFileDropWebhookSignature(input)) { + throw new FileDropInputError("Invalid file drop webhook signature."); + } + + let payload: unknown; + try { + payload = JSON.parse(input.body); + } catch { + throw new FileDropInputError("Invalid file drop webhook JSON."); + } + + const parsed = z + .object({ + contractVersion: z.literal(FILEDROP_CONTRACT_VERSION), + createdAt: z.string().datetime(), + event: fileDropEventSchema, + webhookId: z.string().min(1) + }) + .safeParse(payload); + if (!parsed.success) { + throw new FileDropInputError("Invalid file drop webhook payload.", parsed.error.flatten()); + } + + return parsed.data; +} + +function parseKeyStatus(value: string | undefined): FileDropKeyStatus { + const parsed = fileDropKeyStatusSchema.safeParse(value); + return parsed.success ? parsed.data : "active"; +} + +function emptyToUndefined(value: string | undefined) { + return value?.trim() ? value.trim() : undefined; +} + +function parseSignature(value: string | null | undefined) { + return value + ?.split(",") + .map((part) => part.trim()) + .find((part) => part.startsWith("v1=")); +} + +function safeCompare(value: string, expected: string) { + const valueBuffer = Buffer.from(value); + const expectedBuffer = Buffer.from(expected); + return valueBuffer.length === expectedBuffer.length && timingSafeEqual(valueBuffer, expectedBuffer); +} + +function stableJson(value: unknown): string { + return JSON.stringify(sortJsonValue(value)); +} + +function sortJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJsonValue); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => [key, sortJsonValue(entryValue)]) + ); + } + return value; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..590fc33 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from "./e2ee.js"; +export * from "./filedrop.js"; diff --git a/test/contract.test.ts b/test/contract.test.ts new file mode 100644 index 0000000..ed32548 --- /dev/null +++ b/test/contract.test.ts @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { + FILEDROP_CONTRACT_VERSION, + FILEDROP_WEBHOOK_SIGNATURE_HEADER, + FILEDROP_WEBHOOK_TIMESTAMP_HEADER, + createLocalFileDropStorage, + createSignedFileDropWebhook, + developmentRecipientPublicKey, + fileDropEventSchema, + fileDropUploadCommitSchema, + getActiveDropboxFromEnvironment, + parseFileDropWebhook, + receiveCiphertextIntakeFromFormData, + verifyFileDropWebhookSignature, + type FileDropStorage +} from "../dist/index.js"; + +const fixture = JSON.parse(await readFile(new URL("./fixtures/filedrop-v1.json", import.meta.url), "utf8")) as { + ciphertext: string; + contactEmail: string; + contactName: string; + dropboxId: string; + envelope: Record; + languagePair: string; + now: string; + requestedDeadline: string; + routingLabel: string; + serviceType: string; + uploadId: string; + webhookId: string; +}; + +test("discovery is generic and exposes key lifecycle state", async () => { + const discovery = await getActiveDropboxFromEnvironment({ + FILEDROP_DROPBOX_ID: fixture.dropboxId, + FILEDROP_DROPBOX_LABEL: "Fixture secure drop", + FILEDROP_RECIPIENT_KEY_ROTATED_AT: fixture.now, + FILEDROP_RECIPIENT_KEY_STATUS: "rotating", + FILEDROP_RECIPIENT_PUBLIC_KEY_JWK: JSON.stringify(developmentRecipientPublicKey) + }); + + assert.equal(discovery.contractVersion, FILEDROP_CONTRACT_VERSION); + assert.equal(discovery.dropboxId, fixture.dropboxId); + assert.equal(discovery.label, "Fixture secure drop"); + assert.equal(discovery.status, "rotating"); + assert.equal(discovery.key.status, "rotating"); + assert.equal(discovery.key.rotatedAt, fixture.now); + assert.equal(discovery.source, "configured"); +}); + +test("discovery defaults do not contain host-specific branding", async () => { + const discovery = await getActiveDropboxFromEnvironment({}); + assert.equal(discovery.label, "Secure file drop"); + assert.equal(discovery.dropboxId, "default"); + assert.equal(discovery.source, "development"); +}); + +test("receiveCiphertextIntakeFromFormData validates filedrop.v1 and supports custom storage", async () => { + const saved: Array<{ id: string; manifest: Record; text: string }> = []; + const storage: FileDropStorage = { + async saveCiphertextUpload(input) { + saved.push({ + id: input.id, + manifest: input.manifest, + text: Buffer.from(await input.ciphertext.arrayBuffer()).toString("utf8") + }); + return { + driver: "memory", + manifestKey: `memory/${input.id}.json`, + storageKey: `memory/${input.id}.enc` + }; + } + }; + + const received = await receiveCiphertextIntakeFromFormData(createFixtureFormData(), {}, { + id: fixture.uploadId, + now: new Date(fixture.now), + storage + }); + + assert.equal(received.id, fixture.uploadId); + assert.equal(received.storageDriver, "memory"); + assert.equal(received.storageKey, `memory/${fixture.uploadId}.enc`); + assert.equal(received.commit.contractVersion, FILEDROP_CONTRACT_VERSION); + assert.equal(received.commit.sender.contactEmail, fixture.contactEmail); + assert.equal(received.commit.routing.dropboxId, fixture.dropboxId); + assert.equal(received.event.eventType, "filedrop.upload.received"); + assert.equal(received.event.storage.driver, "memory"); + assert.equal(saved[0]?.text, fixture.ciphertext); + + assert.doesNotThrow(() => fileDropUploadCommitSchema.parse(received.commit)); + assert.doesNotThrow(() => fileDropEventSchema.parse(received.event)); +}); + +test("local storage adapter writes ciphertext and manifest", async () => { + const storageDir = await mkdtemp(path.join(tmpdir(), "filedrop-core-")); + try { + const received = await receiveCiphertextIntakeFromFormData(createFixtureFormData(), {}, { + id: fixture.uploadId, + now: new Date(fixture.now), + storage: createLocalFileDropStorage({ storageDir }) + }); + + const ciphertext = await readFile(path.join(storageDir, received.storageKey), "utf8"); + const manifest = JSON.parse(await readFile(path.join(storageDir, received.manifestKey), "utf8")) as { + contractVersion: string; + commit: { sender: { contactEmail: string } }; + }; + + assert.equal(ciphertext, fixture.ciphertext); + assert.equal(manifest.contractVersion, FILEDROP_CONTRACT_VERSION); + assert.equal(manifest.commit.sender.contactEmail, fixture.contactEmail); + } finally { + await rm(storageDir, { force: true, recursive: true }); + } +}); + +test("webhook signatures verify and reject tampered payloads", async () => { + const received = await receiveCiphertextIntakeFromFormData(createFixtureFormData(), {}, { + id: fixture.uploadId, + now: new Date(fixture.now), + storage: memoryStorage() + }); + const signed = createSignedFileDropWebhook(received.event, "test-secret", { + now: new Date(fixture.now), + webhookId: fixture.webhookId + }); + + assert.equal( + verifyFileDropWebhookSignature({ + body: signed.body, + now: new Date(fixture.now), + secret: "test-secret", + signatureHeader: signed.headers[FILEDROP_WEBHOOK_SIGNATURE_HEADER], + timestampHeader: signed.headers[FILEDROP_WEBHOOK_TIMESTAMP_HEADER] + }), + true + ); + assert.equal( + verifyFileDropWebhookSignature({ + body: `${signed.body} `, + now: new Date(fixture.now), + secret: "test-secret", + signatureHeader: signed.headers[FILEDROP_WEBHOOK_SIGNATURE_HEADER], + timestampHeader: signed.headers[FILEDROP_WEBHOOK_TIMESTAMP_HEADER] + }), + false + ); + + const parsed = parseFileDropWebhook({ + body: signed.body, + now: new Date(fixture.now), + secret: "test-secret", + signatureHeader: signed.headers[FILEDROP_WEBHOOK_SIGNATURE_HEADER], + timestampHeader: signed.headers[FILEDROP_WEBHOOK_TIMESTAMP_HEADER] + }); + assert.equal(parsed.webhookId, fixture.webhookId); + assert.equal(parsed.event.upload.id, fixture.uploadId); +}); + +function createFixtureFormData() { + const formData = new FormData(); + formData.append("ciphertext", new Blob([fixture.ciphertext], { type: "application/octet-stream" }), "payload.enc"); + formData.append("contactEmail", fixture.contactEmail); + formData.append("contactName", fixture.contactName); + formData.append("dropboxId", fixture.dropboxId); + formData.append("envelope", JSON.stringify(fixture.envelope)); + formData.append("languagePair", fixture.languagePair); + formData.append("requestedDeadline", fixture.requestedDeadline); + formData.append("routingLabel", fixture.routingLabel); + formData.append("serviceType", fixture.serviceType); + return formData; +} + +function memoryStorage(): FileDropStorage { + return { + async saveCiphertextUpload(input) { + return { + driver: "memory", + manifestKey: `memory/${input.id}.json`, + storageKey: `memory/${input.id}.enc` + }; + } + }; +} diff --git a/test/e2ee.test.ts b/test/e2ee.test.ts new file mode 100644 index 0000000..05be470 --- /dev/null +++ b/test/e2ee.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + createRecipientKeyPair, + decryptPayloadFromEnvelope, + encryptBytesForRecipient, + encryptBytesForRecipients, + getEnvelopeRecipientWraps, + rewrapEnvelopeForRecipients +} from "../dist/index.js"; + +test("multi-recipient envelope decrypts with each recipient private key", async () => { + const first = await createRecipientKeyPair(); + const second = await createRecipientKeyPair(); + const plaintext = new TextEncoder().encode("shared encrypted payload"); + + const encrypted = await encryptBytesForRecipients(plaintext.buffer as ArrayBuffer, [first.publicKey, second.publicKey], { + purpose: "deliverable" + }); + + const wraps = getEnvelopeRecipientWraps(encrypted.envelope); + assert.equal(wraps.length, 2); + assert.deepEqual( + wraps.map((wrap) => wrap.keyFingerprint).sort(), + [first.fingerprint, second.fingerprint].sort() + ); + + const ciphertext = await encrypted.ciphertext.arrayBuffer(); + const firstDecrypted = await decryptPayloadFromEnvelope(ciphertext, encrypted.envelope, first.privateKey); + const secondDecrypted = await decryptPayloadFromEnvelope(ciphertext, encrypted.envelope, second.privateKey); + + assert.equal(new TextDecoder().decode(firstDecrypted.plaintext), "shared encrypted payload"); + assert.equal(new TextDecoder().decode(secondDecrypted.plaintext), "shared encrypted payload"); + assert.deepEqual(firstDecrypted.metadata, { purpose: "deliverable" }); + assert.deepEqual(secondDecrypted.metadata, { purpose: "deliverable" }); +}); + +test("single-recipient helper keeps the legacy recipient field", async () => { + const recipient = await createRecipientKeyPair(); + const plaintext = new TextEncoder().encode("legacy payload"); + + const encrypted = await encryptBytesForRecipient(plaintext.buffer as ArrayBuffer, recipient.publicKey, { + purpose: "source" + }); + + assert.equal(encrypted.envelope.recipient.keyFingerprint, recipient.fingerprint); + assert.equal(getEnvelopeRecipientWraps(encrypted.envelope).length, 1); + + const decrypted = await decryptPayloadFromEnvelope(await encrypted.ciphertext.arrayBuffer(), encrypted.envelope, recipient.privateKey); + assert.equal(new TextDecoder().decode(decrypted.plaintext), "legacy payload"); + assert.deepEqual(decrypted.metadata, { purpose: "source" }); +}); + +test("envelope can be rewrapped to a new recipient without changing ciphertext metadata", async () => { + const admin = await createRecipientKeyPair(); + const client = await createRecipientKeyPair(); + const replacementClient = await createRecipientKeyPair(); + const plaintext = new TextEncoder().encode("deliverable payload"); + + const encrypted = await encryptBytesForRecipients(plaintext.buffer as ArrayBuffer, [admin.publicKey, client.publicKey], { + purpose: "deliverable" + }); + const originalContent = encrypted.envelope.content; + const originalMetadata = encrypted.envelope.metadata; + + const rewrappedEnvelope = await rewrapEnvelopeForRecipients( + encrypted.envelope, + admin.privateKey, + [replacementClient.publicKey], + { keyFingerprint: admin.fingerprint } + ); + + assert.deepEqual(rewrappedEnvelope.content, originalContent); + assert.deepEqual(rewrappedEnvelope.metadata, originalMetadata); + assert.deepEqual( + getEnvelopeRecipientWraps(rewrappedEnvelope) + .map((wrap) => wrap.keyFingerprint) + .sort(), + [admin.fingerprint, client.fingerprint, replacementClient.fingerprint].sort() + ); + + const decrypted = await decryptPayloadFromEnvelope( + await encrypted.ciphertext.arrayBuffer(), + rewrappedEnvelope, + replacementClient.privateKey + ); + assert.equal(new TextDecoder().decode(decrypted.plaintext), "deliverable payload"); + assert.deepEqual(decrypted.metadata, { purpose: "deliverable" }); +}); diff --git a/test/fixtures/filedrop-v1.json b/test/fixtures/filedrop-v1.json new file mode 100644 index 0000000..50eaff4 --- /dev/null +++ b/test/fixtures/filedrop-v1.json @@ -0,0 +1,41 @@ +{ + "ciphertext": "encrypted fixture payload", + "contactEmail": "client@example.test", + "contactName": "Fixture Client", + "dropboxId": "fixture-dropbox", + "envelope": { + "version": 1, + "suite": "ECDH-P256+A256KW+A256GCM", + "createdAt": "2026-07-10T12:00:00.000Z", + "recipient": { + "keyFingerprint": "fixture-key-fingerprint", + "ephemeralPublicKey": { + "key_ops": [], + "ext": true, + "kty": "EC", + "x": "5SL1U-NK-YxVJ3VogCvUj5NXye4egWvFzZUOS9mp1jM", + "y": "dibuzB2lXxOlgyJ_relW52HKP16f_8d8yjhdGh5ILE8", + "crv": "P-256" + }, + "wrappedContentKey": "fixture-wrapped-content-key" + }, + "content": { + "algorithm": "AES-GCM-256", + "iv": "fixture-content-iv", + "byteLength": 25, + "sha256": "fixture-ciphertext-sha256" + }, + "metadata": { + "algorithm": "AES-GCM-256", + "iv": "fixture-metadata-iv", + "ciphertext": "fixture-encrypted-metadata" + } + }, + "languagePair": "DE -> EN", + "now": "2026-07-10T12:00:00.000Z", + "requestedDeadline": "2026-08-01", + "routingLabel": "translation", + "serviceType": "translation", + "uploadId": "upload-fixture-1", + "webhookId": "webhook-fixture-1" +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..43bf09d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}