initial commit

This commit is contained in:
2026-07-13 01:45:37 +02:00
commit ecbb8bba9b
14 changed files with 1685 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
node_modules
dist
*.tsbuildinfo
*.log
.DS_Store

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
*.tsbuildinfo
.DS_Store

14
README.md Normal file
View 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.

View 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.

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
export * from "./e2ee.js";
export * from "./filedrop.js";

189
test/contract.test.ts Normal file
View 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
View 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
View 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
View 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"]
}