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

  1. Load the correct current encryption key on this client so 'missing-key' is resolved
  2. If the key was rotated, re-fetch/enter the new key matching the server's encryptKeyId
  3. Check sync-server logs for storage corruption; restore budget data from backup if payloads are corrupted
  4. 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

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


AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29). Data as JSON: /api/errors/09947381ebe2158c. Report an issue: GitHub.