initial commit
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.tsbuildinfo
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
14
README.md
Normal file
14
README.md
Normal file
@@ -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.
|
||||||
134
docs/filedrop-integration-contract.md
Normal file
134
docs/filedrop-integration-contract.md
Normal file
@@ -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=<hex>`.
|
||||||
|
|
||||||
|
The signature input is:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
<timestamp>.<raw request body>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
59
docs/return-file-key-model.md
Normal file
59
docs/return-file-key-model.md
Normal file
@@ -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.
|
||||||
62
package-lock.json
generated
Normal file
62
package-lock.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
package.json
Normal file
45
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
482
src/e2ee.ts
Normal file
482
src/e2ee.ts
Normal file
@@ -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<RecipientKeyPairJwks> {
|
||||||
|
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<string> {
|
||||||
|
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<string, unknown> = {}
|
||||||
|
): Promise<EncryptedPayload> {
|
||||||
|
return encryptFileForRecipients(file, [recipientPublicKeyJwk], metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptFileForRecipients(
|
||||||
|
file: File,
|
||||||
|
recipientPublicKeyJwks: RecipientPublicKeyJwk[],
|
||||||
|
metadata: Record<string, unknown> = {}
|
||||||
|
): Promise<EncryptedPayload> {
|
||||||
|
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<string, unknown> = {}
|
||||||
|
): Promise<EncryptedPayload> {
|
||||||
|
return encryptBytesForRecipients(plaintext, [recipientPublicKeyJwk], metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptBytesForRecipients(
|
||||||
|
plaintext: ArrayBuffer,
|
||||||
|
recipientPublicKeyJwks: RecipientPublicKeyJwk[],
|
||||||
|
metadata: Record<string, unknown> = {}
|
||||||
|
): Promise<EncryptedPayload> {
|
||||||
|
if (!recipientPublicKeyJwks.length) {
|
||||||
|
throw new Error("At least one recipient public key is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const crypto = getCrypto();
|
||||||
|
const recipientDescriptors = [];
|
||||||
|
const seenFingerprints = new Set<string>();
|
||||||
|
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<DecryptedPayload> {
|
||||||
|
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<E2eeEnvelopeV1> {
|
||||||
|
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<E2eeRecipientWrapV1> {
|
||||||
|
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<CryptoKey> {
|
||||||
|
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<string>();
|
||||||
|
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<string, E2eeRecipientWrapV1>();
|
||||||
|
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<string, unknown>;
|
||||||
|
return `{${Object.keys(record)
|
||||||
|
.sort()
|
||||||
|
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
||||||
|
.join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
543
src/filedrop.ts
Normal file
543
src/filedrop.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
storageDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileDropStoredObject {
|
||||||
|
driver: string;
|
||||||
|
manifestKey: string;
|
||||||
|
storageKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileDropStorage {
|
||||||
|
saveCiphertextUpload(input: SaveCiphertextInput): Promise<FileDropStoredObject>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RecipientPublicKeyJwk>(
|
||||||
|
(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<typeof fileDropUploadCommitSchema>;
|
||||||
|
export type FileDropEvent = z.infer<typeof fileDropEventSchema>;
|
||||||
|
export type FileDropRouting = z.infer<typeof fileDropRoutingSchema>;
|
||||||
|
export type FileDropRecipientKey = z.infer<typeof fileDropRecipientKeySchema>;
|
||||||
|
|
||||||
|
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<FileDropDiscovery> {
|
||||||
|
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<ReceivedCiphertextIntake> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
2
src/index.ts
Normal file
2
src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./e2ee.js";
|
||||||
|
export * from "./filedrop.js";
|
||||||
189
test/contract.test.ts
Normal file
189
test/contract.test.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown>; 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`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
89
test/e2ee.test.ts
Normal file
89
test/e2ee.test.ts
Normal file
@@ -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" });
|
||||||
|
});
|
||||||
41
test/fixtures/filedrop-v1.json
vendored
Normal file
41
test/fixtures/filedrop-v1.json
vendored
Normal file
@@ -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"
|
||||||
|
}
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user