mihomo-party-org/clash-party · error · Error

Failed to decrypt age ${label}: ${message}

Error message

Failed to decrypt age ${label}: ${message}

What it means

decryptAgeContent wraps any failure from the age decryption library (decrypter.decrypt on armored content) into a labeled error 'Failed to decrypt age <label>: <detail>'. It exists so callers (decryptedData, profile, decryptedContent) can tell which encrypted blob failed, since age itself only returns low-level crypto errors. The inner message from the age library (e.g. 'no identity matched any of the recipients') is preserved in the wrapped message.

Source

Thrown at src/main/utils/age.ts:89

  content: string,
  secretKey: string | undefined,
  label = 'config'
): Promise<string> {
  if (!isAgeArmored(content)) return content

  const identities = parseAgeSecretKeys(secretKey)
  if (identities.length === 0) {
    throw new Error(`Age encrypted ${label} requires an age secret key`)
  }

  try {
    const age = await loadAgeModule()
    const decrypter = new age.Decrypter()
    identities.forEach((identity) => decrypter.addIdentity(identity))
    return await decrypter.decrypt(age.armor.decode(content), 'text')
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error)
    throw new Error(`Failed to decrypt age ${label}: ${message}`)
  }
}

View on GitHub (pinned to 911e090537)

Solutions

  1. Verify the age identity/private key file on disk matches the one used when the content was encrypted (regenerating keys invalidates old ciphertext).
  2. Re-export or re-encrypt the data from its source of truth (e.g. re-save the profile so it is encrypted with the current identity).
  3. Inspect the inner message after the colon: 'no identity matched any of the recipients' means wrong key; malformed armor/EOF means the file itself is corrupt.
  4. Check file permissions and that the identity path passed into identities is readable by the app process.

Example fix

// before: app fails at startup with wrapped decrypt error
const data = await decryptedData()
// after: detect key mismatch and fall back to re-initialization
let data
try {
  data = await decryptedData()
} catch (e) {
  if (/no identity matched/.test(String(e))) {
    await resetEncryptedProfile() // re-encrypt with current identity
    data = await decryptedData()
  } else {
    throw e
  }
}
Defensive patterns

Strategy: try-catch

Validate before calling

import { existsSync } from 'fs'
// before decrypting, confirm an identity key exists and content looks like age armor
function canAttemptDecrypt(identityPath: string, content: string): boolean {
  return existsSync(identityPath) && content.startsWith('-----BEGIN AGE ENCRYPTED FILE-----')
}

Type guard

function isAgeArmored(content: string): boolean {
  return content.includes('-----BEGIN AGE ENCRYPTED FILE-----') && content.includes('-----END AGE ENCRYPTED FILE-----')
}

Try / catch

try {
  const plain = await decryptedContent(label)
} catch (e) {
  if (/Failed to decrypt age/.test(String(e))) {
    // key mismatch or corrupt payload: rebuild/re-encrypt from source of truth
    await resetAndReencrypt()
  } else {
    throw e
  }
}

Prevention

When it happens

Trigger: Calling decryptAgeContent (directly or via decryptedData/profile/decryptedContent) with content that was not produced by the matching age.encrypt/armor pipeline, an identity key that does not correspond to the file's recipient, or corrupted/truncated armored text.

Common situations: User restores an encrypted profile from a backup but the age identity/private key file was regenerated or is from another machine; app upgrade changes key storage location; file partially written during crash/disk-full; user hand-edits the armored payload.


AI-assisted analysis of mihomo-party-org/clash-party@911e090537 (2026-08-30). Data as JSON: /api/errors/f347469a02c7686e. Report an issue: GitHub.