agalwood/Motrix · critical · SecretStoreError

plugin.lifecycle.secrets_decrypt_failed

plugin.lifecycle.secrets_decrypt_failed

Error message

libsodium decryption failed: ciphertext may be tampered or key mismatch

What it means

Thrown by LibsodiumSecretStore.decrypt: the token was structurally valid and the nonce was 24 bytes, but `sodium.crypto_secretbox_open_easy(ct, nonce, key)` threw. crypto_secretbox is authenticated — failure means the ciphertext/Auth tag did not validate under the current key. The two real causes are a tampered/corrupted ciphertext, or a key mismatch (the key used to decrypt differs from the one used to encrypt).

Source

Thrown at src/core/plugin/secret-store-libsodium.ts:150

      )
    }

    await sodium.ready
    const nonce = new Uint8Array(Buffer.from(nonceb64, 'base64'))
    const ct = new Uint8Array(Buffer.from(ctb64, 'base64'))

    if (nonce.byteLength !== NONCE_BYTES) {
      throw new SecretStoreError(
        'plugin.lifecycle.secrets_invalid_token',
        `secret token nonce must be ${NONCE_BYTES} bytes`
      )
    }

    let plainBytes: Uint8Array
    try {
      plainBytes = sodium.crypto_secretbox_open_easy(ct, nonce, this.key)
    } catch {
      throw new SecretStoreError(
        'plugin.lifecycle.secrets_decrypt_failed',
        'libsodium decryption failed: ciphertext may be tampered or key mismatch'
      )
    }

    return sodium.to_string(plainBytes)
  }
}

View on GitHub (pinned to 1a708ee577)

Solutions

  1. Restore the matching key: re-supply the same MOTRIX_SECRETS_SEED env var, or restore the original <userDataDir>/secrets.lockbox file.
  2. If the plaintext is recoverable elsewhere, re-encrypt it under the current key and overwrite the stored token.
  3. If the key is permanently lost, accept the secrets are unrecoverable, re-enter them through the normal config UI so they are re-encrypted.
  4. Back up secrets.lockbox alongside the secrets data so key and ciphertexts always travel together.

Example fix

// before — lockbox regenerated, old tokens undecryptable
// userDataDir/secrets.lockbox was deleted -> new random key
await store.decrypt(oldToken)  // throws secrets_decrypt_failed

// after — restore the original lockbox (or re-enter secrets)
// 1) stop the app, restore the backed-up secrets.lockbox to userDataDir
// 2) restart; decrypt now uses the matching key
await store.decrypt(oldToken)  // succeeds
Defensive patterns

Strategy: try-catch

Try / catch

try {
  return await store.decrypt(token)
} catch (e) {
  if (e instanceof SecretStoreError && e.code === 'plugin.lifecycle.secrets_decrypt_failed') {
    // Key mismatch or tampering. Try to restore the original key source
    // (MOTRIX_SECRETS_SEED or <userDataDir>/secrets.lockbox); if unrecoverable,
    // prompt the user to re-enter the secret and re-encrypt.
    return await recoverSecretAndReencrypt(token)
  }
  throw e
}

Prevention

When it happens

Trigger: decrypt(token) where the format/nonce checks pass but the AEAD verification fails. Most common when the secrets.lockbox file was regenerated (new random key) or MOTRIX_SECRETS_SEED changed between encrypt and decrypt runs.

Common situations: userDataDir was reset/moved so a fresh secrets.lockbox was generated (Priority 3 in create()). envSeed changed because the deployment env var was rotated. Same plaintext encrypted under one runtime's key then decrypted under another (Electron vs server) where key sources differ. Backup restored secrets without restoring the lockbox.

Related errors


AI-assisted analysis of agalwood/Motrix@1a708ee577 (2026-08-12). Data as JSON: /api/errors/0c4442a25848574b. Report an issue: GitHub.