actualbudget/actual · critical · SyncError
decrypt-failure
decrypt-failure
Error message
decrypt-failure
What it means
A SyncError thrown by the sync encoder (decode) when encryption.decrypt() fails to decrypt an incoming sync message envelope marked isEncrypted. Like encrypt-failure it sets isMissingKey=true when the cause is a missing key. The received message is dropped and the whole sync fails, since the payload cannot be read.
Source
Thrown at packages/loot-core/src/server/sync/encoder.ts:120
const messages = [];
for (const envelopePb of responsePb.messages) {
let msg;
if (envelopePb.isEncrypted) {
const binary = fromBinary(EncryptedDataSchema, envelopePb.content);
let decrypted;
try {
decrypted = await encryption.decrypt(coerceBuffer(binary.data), {
keyId: encryptKeyId,
algorithm: 'aes-256-gcm',
iv: coerceBuffer(binary.iv),
authTag: coerceBuffer(binary.authTag),
});
} catch (e) {
logger.log(e);
throw new SyncError('decrypt-failure', {
isMissingKey: e.message === 'missing-key',
});
}
msg = fromBinary(MessageSchema, decrypted);
} else {
msg = fromBinary(MessageSchema, envelopePb.content);
}
messages.push({
timestamp: Timestamp.parse(envelopePb.timestamp),
dataset: msg.dataset,
row: msg.row,
column: msg.column,
value: msg.value,
});
}
View on GitHub (pinned to d4334cb6e6)
Solutions
- Load the correct current encryption key on this client so 'missing-key' is resolved
- If the key was rotated, re-fetch/enter the new key matching the server's encryptKeyId
- Check sync-server logs for storage corruption; restore budget data from backup if payloads are corrupted
- As a last resort, reset the budget's sync data (recreate the group) and re-sync from one authoritative device
Example fix
// before const res = await syncApi.sync(...); // decrypt-failure // after: ensure key exists before syncing const key = await encryption.getKey(encryptKeyId); if (!key) await promptUserForEncryptionKey(encryptKeyId); const res = await syncApi.sync(...);
Defensive patterns
Strategy: try-catch
Validate before calling
const { encryptKeyId } = prefs.getPrefs();
if (envelope.isEncrypted && !encryptKeyId) {
throw new Error('Cannot decrypt: no encryption key configured');
} Type guard
function isDecryptFailure(e: unknown): e is SyncError & { reason: { isMissingKey: boolean } } {
return e instanceof SyncError && e.reason?.code === 'decrypt-failure';
} Try / catch
try {
const res = await fullSync();
} catch (e) {
if (isDecryptFailure(e)) {
await promptForCorrectKey(); // then retry sync once
} else throw e;
} Prevention
- Rotate keys on all devices at once, never partially
- Verify AES-256-GCM authTag integrity — repeated auth failures indicate corruption
- Keep the key store backed up alongside server data
- Sync all devices after any key change
When it happens
Trigger: decode() processes a sync response whose envelope isEncrypted=true and encryption.decrypt(binary.data, { keyId: encryptKeyId, algorithm: 'aes-256-gcm', iv, authTag }) throws — key for encryptKeyId missing locally, wrong key, or corrupted/truncated ciphertext with bad authTag (GCM auth failure).
Common situations: Downloading messages from a budget whose key was rotated while this client still holds an old key; shared budget where another device encrypted with a key this device never received; tampered or partially truncated sync payloads failing AES-256-GCM authentication.
Related errors
- encrypt-failure
- missing-key
- decrypt-failure
- Sync ID is required for sync ${flag}. Set --sync-id or ACTUA
- Could not resolve on-disk budget id for syncId ${syncId} aft
AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29).
Data as JSON: /api/errors/09947381ebe2158c.
Report an issue: GitHub.