Files
sencho/backend/src/helpers/snapshotFileDecrypt.ts
T
Anso 3b027957c4 fix(fleet): isolate corrupt snapshot file decrypt failures (#1650)
* fix(fleet): isolate corrupt snapshot file decrypt failures

A single damaged encrypted fleet-snapshot row no longer fails detail, restore, or off-site upload for the whole snapshot. Unavailable members are marked, restore is blocked before mutation, and cloud upload fails closed with no PutObject.

* fix(fleet): fail closed on damaged enc snapshot envelopes

Unrecognized enc: payloads no longer fall through as usable plaintext. Only clear legacy prose stays readable; delimiter-byte and similar envelope damage stays unavailable through restore and cloud upload.

* fix(fleet): subordinate legacy enc prose to envelope shape

Legacy exceptions no longer trigger from = or whitespace alone. Encryption-shaped payloads (length and hex density) stay unavailable through restore and cloud upload, while short genuine prose such as enc:hello remains usable.

* fix(fleet): preserve non-envelope enc legacy plaintext

Any non-empty enc: payload that is not encryption-shaped is kept verbatim, including punctuation forms such as enc:hello-world, while envelope-shaped damage remains unavailable.
2026-07-19 20:08:31 -04:00

149 lines
5.3 KiB
TypeScript

import { CryptoService } from '../services/CryptoService';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
const ENCRYPTED_PREFIX = 'enc:';
const HEX_RE = /^[0-9a-fA-F]+$/;
/**
* Producer envelopes are at least an IV worth of hex (24) plus delimiters /
* ciphertext. Genuine legacy prose such as enc:hello is much shorter.
*/
const MIN_ENVELOPE_LIKE_LENGTH = 24;
/** Damaged encrypt() payloads remain mostly hex even after a one-byte mutation. */
const ENVELOPE_HEX_DENSITY = 0.75;
/** Database row shape for fleet_snapshot_files (content still ciphertext or legacy plaintext). */
export interface SnapshotFileRow {
node_id: number;
node_name: string;
stack_name: string;
filename: string;
content: string;
}
type SnapshotFileMeta = Omit<SnapshotFileRow, 'content'>;
/**
* Decrypted snapshot file read result. Unavailable rows carry no content so
* callers cannot accidentally forward a placeholder into restore or archives.
*/
export type SnapshotFileReadResult =
| (SnapshotFileMeta & { available: true; content: string })
| (SnapshotFileMeta & { available: false });
export type AvailableSnapshotFile = Extract<SnapshotFileReadResult, { available: true }>;
export function isAvailableSnapshotFile(file: SnapshotFileReadResult): file is AvailableSnapshotFile {
return file.available;
}
export function isStructurallyValidSnapshotEnvelope(value: string): boolean {
if (!value.startsWith(ENCRYPTED_PREFIX)) return false;
const parts = value.slice(ENCRYPTED_PREFIX.length).split(':');
if (parts.length !== 3) return false;
const [ivHex, authTagHex, encryptedHex] = parts;
return (
HEX_RE.test(ivHex) && ivHex.length === 24 &&
HEX_RE.test(authTagHex) && authTagHex.length === 32 &&
!!encryptedHex && encryptedHex.length % 2 === 0 && HEX_RE.test(encryptedHex)
);
}
function hexDensity(payload: string): number {
if (payload.length === 0) return 0;
const hexChars = payload.match(/[0-9a-fA-F]/g)?.length ?? 0;
return hexChars / payload.length;
}
/**
* True when the payload still looks like CryptoService.encrypt output after
* truncation or one-byte field/delimiter corruption (high length + hex density),
* or is pure hex of any length.
*/
export function isEnvelopeShapedPayload(payload: string): boolean {
if (payload === '') return true;
if (HEX_RE.test(payload)) return true;
return payload.length >= MIN_ENVELOPE_LIKE_LENGTH && hexDensity(payload) >= ENVELOPE_HEX_DENSITY;
}
/**
* Non-envelope legacy plaintext that happens to start with enc:.
* Any non-empty payload that is not encryption-shaped is preserved verbatim
* (SEN-213). Envelope-shaped damage never qualifies, even with = or whitespace.
*/
export function isClearlyLegacyEncProse(value: string): boolean {
if (!value.startsWith(ENCRYPTED_PREFIX)) return false;
const payload = value.slice(ENCRYPTED_PREFIX.length);
if (payload === '') return false;
return !isEnvelopeShapedPayload(payload);
}
/**
* Producer-envelope damage (no DB provenance). Any enc: payload that is not
* a structurally valid envelope and not clearly legacy prose is treated as
* damage so delimiter substitution and similar corruption cannot fall through
* as writable plaintext.
*/
export function isEnvelopeLikeDamage(value: string): boolean {
if (!value.startsWith(ENCRYPTED_PREFIX)) return false;
if (isStructurallyValidSnapshotEnvelope(value)) return false;
if (isClearlyLegacyEncProse(value)) return false;
return true;
}
export type SnapshotContentClass =
| { kind: 'usable'; content: string }
| { kind: 'unavailable'; reason: 'decrypt_failed' | 'envelope_damage'; detail?: string };
/**
* Classify a stored snapshot file body. Without a provenance marker, enc:
* values that are not valid envelopes and not clearly legacy prose fail closed.
*/
export function classifySnapshotFileContent(raw: string): SnapshotContentClass {
if (!raw.startsWith(ENCRYPTED_PREFIX)) {
return { kind: 'usable', content: raw };
}
if (isStructurallyValidSnapshotEnvelope(raw)) {
try {
return { kind: 'usable', content: CryptoService.getInstance().decrypt(raw) };
} catch (err) {
return {
kind: 'unavailable',
reason: 'decrypt_failed',
detail: getErrorMessage(err, 'decrypt failed'),
};
}
}
if (isClearlyLegacyEncProse(raw)) {
return { kind: 'usable', content: raw };
}
return { kind: 'unavailable', reason: 'envelope_damage' };
}
export function readSnapshotFileRow(
row: SnapshotFileRow,
snapshotId: number,
): SnapshotFileReadResult {
const { content: raw, ...meta } = row;
const classified = classifySnapshotFileContent(raw);
if (classified.kind === 'usable') {
return { ...meta, available: true, content: classified.content };
}
const reasonText = classified.detail
? `${classified.reason}: ${classified.detail}`
: classified.reason;
console.error(
`[snapshotFileDecrypt] Failed to decrypt snapshot file ` +
`snapshot=${sanitizeForLog(snapshotId)} ` +
`node=${sanitizeForLog(meta.node_id)} ` +
`stack=${sanitizeForLog(meta.stack_name)} ` +
`file=${sanitizeForLog(meta.filename)}: ${reasonText}`,
);
return { ...meta, available: false };
}