Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/config/flags/binary.flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ export const binaryFlags = {
encrypt: {
type: 'boolean',
description:
'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). Can also be enabled with DCD_ENCRYPT=1.',
'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Can also be enabled with DCD_ENCRYPT=1.',
},
} as const satisfies ArgsDef;
35 changes: 31 additions & 4 deletions src/gateways/api-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,14 +257,32 @@ export const ApiGateway = {
return true;
},

/**
* Look for an already-uploaded binary to skip re-uploading.
*
* Two lookup keys, because encryption changes what is stable (dcd#1168).
* Unencrypted uploads pass `sha` (the hash of exactly what gets stored).
* Encrypted uploads pass `shaPlain` plus `encrypted: true`: their ciphertext is
* freshly keyed on every upload, so its hash never matches, and the plaintext
* hash is the only stable key. `sha` is deliberately not sent in that case —
* see the caller in methods.ts for why sending it would be unsafe.
*
* `encrypted` in the response reports whether the *matched* binary is stored
* encrypted, so the caller can verify the invariant it asked for instead of
* trusting the server to have applied the right predicate.
*/
async checkForExistingUpload(
baseUrl: string,
auth: AuthContext,
sha: string,
lookup: { encrypted?: boolean; sha?: string; shaPlain?: string } | string,
) {
// Historically this took a bare sha string; keep that shape working.
const body =
typeof lookup === 'string' ? { sha: lookup } : { ...lookup };

try {
const res = await fetch(`${baseUrl}/uploads/checkForExistingUpload`, {
body: JSON.stringify({ sha }),
body: JSON.stringify(body),
headers: {
'content-type': 'application/json',
...auth.headers,
Expand All @@ -277,7 +295,9 @@ export const ApiGateway = {
}

return await parseJsonResponse<
paths['/uploads/checkForExistingUpload']['post']['responses']['201']['content']['application/json']
paths['/uploads/checkForExistingUpload']['post']['responses']['201']['content']['application/json'] & {
encrypted?: boolean;
}
>(res, 'Failed to check for existing upload');
} catch (error) {
// Handle network-level errors (DNS, connection refused, timeout, etc.)
Expand Down Expand Up @@ -342,9 +362,15 @@ export const ApiGateway = {
metadata: TAppMetadata;
path: string;
sha?: string;
/**
* Hash of the PLAINTEXT, sent only for encrypted uploads (dcd#1168). Stored
* as `binaries.sha_plain` so later encrypted uploads of the same input can
* dedup; `sha` remains the ciphertext hash.
*/
shaPlain?: string;
supabaseSuccess: boolean;
}) {
const { baseUrl, auth, id, metadata, path, sha, supabaseSuccess, backblazeSuccess, bytes } = config;
const { baseUrl, auth, id, metadata, path, sha, shaPlain, supabaseSuccess, backblazeSuccess, bytes } = config;
try {
const res = await fetch(`${baseUrl}/uploads/finaliseUpload`, {
body: JSON.stringify({
Expand All @@ -354,6 +380,7 @@ export const ApiGateway = {
metadata,
path, // This is tempPath for TUS uploads
...(sha ? { sha } : {}),
...(shaPlain ? { shaPlain } : {}),
supabaseSuccess,
}),
headers: {
Expand Down
102 changes: 78 additions & 24 deletions src/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,23 +173,21 @@ export const uploadBinary = async (config: UploadBinaryConfig) => {
// Prepare file for upload
source = await prepareFileForUpload(filePath, debug, startTime);

// Encrypt before hashing/upload so the SHA, dedup check, and both uploaders
// all operate on ciphertext (binaries.sha = ciphertext hash, per #1138).
if (encrypt) {
const encrypted = await encryptUploadSource(source, apiUrl, debug);
enc = encrypted.enc;
encCleanupDir = encrypted.cleanupDir;
if (log) {
ux.info(colors.dim(`Encrypting binary before upload (KEK v${enc.kek})`));
}
}

// Calculate SHA hash
const sha = await calculateFileHash(source, debug, log);

// Check for existing upload with same SHA
if (!ignoreShaCheck && sha) {
const { exists, binaryId } = await checkExistingUpload(apiUrl, auth, sha, debug);
// Hash the PLAINTEXT first, before any encryption (dcd#1168). Encryption
// uses a fresh random DEK per upload, so the ciphertext hash differs every
// time and cannot dedup — the plaintext hash is the only stable key. Doing it
// in this order also means a dedup hit skips the encryption work entirely,
// not just the upload.
const shaPlain = await calculateFileHash(source, debug, log);

// Check for an existing upload before spending anything on encryption.
if (!ignoreShaCheck && shaPlain) {
const { exists, binaryId } = await checkExistingUpload(
apiUrl,
auth,
encrypt ? { encrypted: true, shaPlain } : { sha: shaPlain },
debug,
);

if (exists && binaryId) {
if (log) {
Expand All @@ -203,8 +201,35 @@ export const uploadBinary = async (config: UploadBinaryConfig) => {
}
}

// Encrypt after the dedup check, so the SHA sent at finalise, and both
// uploaders, operate on ciphertext (binaries.sha = ciphertext hash, #1138).
if (encrypt) {
const encrypted = await encryptUploadSource(source, apiUrl, debug);
enc = encrypted.enc;
encCleanupDir = encrypted.cleanupDir;
if (log) {
ux.info(colors.dim(`Encrypting binary before upload (KEK v${enc.kek})`));
}
}

// Re-hash once encrypted: what lands in storage is the ciphertext, and every
// downstream verifySha hashes the bytes it actually holds (no DEK required).
const sha = encrypt ? await calculateFileHash(source, debug, false) : shaPlain;

// Perform the upload
const uploadId = await performUpload({ auth, apiUrl, debug, enc, filePath, sha, source, startTime });
const uploadId = await performUpload({
auth,
apiUrl,
debug,
enc,
filePath,
sha,
// Only encrypted uploads record a plaintext hash; it is what makes the
// next encrypted run of this binary dedupable.
shaPlain: encrypt ? shaPlain : undefined,
source,
startTime,
});

if (log) {
ux.action.stop(colors.success('\n✓ Binary uploaded with ID: ') + formatId(uploadId));
Expand Down Expand Up @@ -425,30 +450,43 @@ async function calculateFileHash(
}

/**
* Checks if an upload with the same SHA already exists
* Checks whether a matching binary has already been uploaded.
*
* `lookup` is `{ sha }` for a plaintext upload, or `{ shaPlain, encrypted: true }`
* for an encrypted one (dcd#1168) — see {@link ApiGateway.checkForExistingUpload}.
*
* When asking as an encrypting client, a hit is only honoured if the server
* confirms the matched binary is itself encrypted. Any binary is a *plausible*
* match on plaintext hash, including a previously-uploaded plaintext copy of the
* same app, and reusing that would hand back an unencrypted binary while the user
* had asked for encryption. The server applies the same predicate; this is the
* client refusing to depend on that, so an older or misbehaving deployment
* degrades into a redundant upload rather than a silent loss of encryption.
*
* @param apiUrl API base URL
* @param auth AuthContext carrying request headers
* @param sha SHA-256 hash to check
* @param lookup Dedup key — plaintext hash for encrypted uploads, else the sha
* @param debug Whether debug logging is enabled
* @returns Promise resolving to object with exists flag and optional binaryId
*/
async function checkExistingUpload(
apiUrl: string,
auth: AuthContext,
sha: string,
lookup: { encrypted?: boolean; sha?: string; shaPlain?: string },
debug: boolean,
): Promise<{ binaryId?: string; exists: boolean }> {
try {
if (debug) {
console.log('[DEBUG] Checking for existing upload with matching SHA...');
console.log(`[DEBUG] Lookup: ${JSON.stringify(lookup)}`);
console.log(`[DEBUG] Target endpoint: ${apiUrl}/uploads/checkForExistingUpload`);
}

const shaCheckStartTime = Date.now();
const { appBinaryId, exists } = await ApiGateway.checkForExistingUpload(
const { appBinaryId, encrypted, exists } = await ApiGateway.checkForExistingUpload(
apiUrl,
auth,
sha as string,
lookup,
);

if (debug) {
Expand All @@ -459,6 +497,16 @@ async function checkExistingUpload(
}
}

if (exists && lookup.encrypted && encrypted !== true) {
if (debug) {
console.log(
'[DEBUG] Ignoring dedup hit: encryption was requested but the matched binary is not encrypted',
);
}

return { exists: false };
}

return { binaryId: appBinaryId, exists };
} catch (error) {
// Invalid credentials will fail every subsequent request — surface now
Expand Down Expand Up @@ -493,6 +541,11 @@ interface PerformUploadConfig {
enc?: BinaryEnvelope;
filePath: string;
sha: string | undefined;
/**
* Hash of the plaintext, set only for encrypted uploads (#1168). Persisted as
* `binaries.sha_plain` so the next encrypted upload of this binary can dedup.
*/
shaPlain?: string;
source: UploadSource;
startTime: number;
}
Expand Down Expand Up @@ -769,7 +822,7 @@ function validateUploadResults(
* @returns Promise resolving to upload ID
*/
async function performUpload(config: PerformUploadConfig): Promise<string> {
const { filePath, apiUrl, auth, enc, source, sha, debug, startTime } = config;
const { filePath, apiUrl, auth, enc, source, sha, shaPlain, debug, startTime } = config;

// Request upload URL and paths
const { id, tempPath, finalPath, b2 } = await requestUploadPaths(apiUrl, auth, filePath, source.size, debug);
Expand Down Expand Up @@ -832,6 +885,7 @@ async function performUpload(config: PerformUploadConfig): Promise<string> {
path: tempPath,
// sha is undefined when hash calculation failed — omit it explicitly
...(sha ? { sha } : {}),
...(shaPlain ? { shaPlain } : {}),
supabaseSuccess: supabaseResult.success,
});

Expand Down
Loading
Loading