mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
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.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user