agalwood/Motrix · error · SecretStoreError

plugin.lifecycle.secrets_invalid_token

plugin.lifecycle.secrets_invalid_token

Error message

secret token has invalid format: expected prefix "${PREFIX}"

What it means

Thrown by LibsodiumSecretStore.decrypt when the supplied token does not start with the expected PREFIX (`"box:"`). Every token this store produces is shaped `box:<base64(nonce)>:<base64(ciphertext)>`, so a missing prefix means the value was not produced by this store — it is plaintext, a legacy `safe:` token from the deprecated Electron SafeStorage backend, or corrupted.

Source

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

    }
  }

  available(): boolean {
    return true
  }

  async encrypt(plaintext: string): Promise<string> {
    await sodium.ready
    const nonce = sodium.randombytes_buf(NONCE_BYTES)
    const ct = sodium.crypto_secretbox_easy(plaintext, nonce, this.key)
    const nonceb64 = Buffer.from(nonce).toString('base64')
    const ctb64 = Buffer.from(ct).toString('base64')
    return `${PREFIX}${nonceb64}:${ctb64}`
  }

  async decrypt(token: string): Promise<string> {
    if (!token.startsWith(PREFIX)) {
      throw new SecretStoreError(
        'plugin.lifecycle.secrets_invalid_token',
        `secret token has invalid format: expected prefix "${PREFIX}"`
      )
    }

    const rest = token.slice(PREFIX.length)
    const colonIdx = rest.indexOf(':')
    if (colonIdx === -1) {
      throw new SecretStoreError(
        'plugin.lifecycle.secrets_invalid_token',
        'secret token has invalid format: missing nonce/ciphertext separator'
      )
    }

    const nonceb64 = rest.slice(0, colonIdx)
    const ctb64 = rest.slice(colonIdx + 1)

    if (!nonceb64 || !ctb64) {

View on GitHub (pinned to 1a708ee577)

Solutions

  1. If migrating from a legacy store, re-encrypt the plaintext through LibsodiumSecretStore.encrypt once and persist the new `box:` token.
  2. If the field should be plaintext, do not route it through decrypt().
  3. Confirm only LibsodiumSecretStore (or FailingSecretStore) is used to write/read the field across all runtimes.

Example fix

// before — legacy/plaintext value fed to decrypt
await store.decrypt('my-api-key')

// after — re-encrypt once, then store the resulting token
const token = await store.encrypt('my-api-key')
await saveSettings({ apiKey: token })
// later: await store.decrypt(settings.apiKey)
Defensive patterns

Strategy: validation

Validate before calling

const BOX_PREFIX = 'box:'
function isLikelyBoxToken(s: unknown): boolean {
  return typeof s === 'string' && s.startsWith(BOX_PREFIX)
}
if (!isLikelyBoxToken(settings.apiKey)) {
  // value is plaintext or legacy; re-encrypt before calling decrypt
  settings.apiKey = await store.encrypt(settings.apiKey)
}

Type guard

function isBoxToken(s: unknown): s is string {
  return typeof s === 'string' && s.startsWith('box:')
}

Try / catch

try {
  await store.decrypt(token)
} catch (e) {
  if (e instanceof SecretStoreError && e.code === 'plugin.lifecycle.secrets_invalid_token') {
    // token was not produced by this store — migrate/re-encrypt, then retry
  } else throw e
}

Prevention

When it happens

Trigger: decrypt(token) where `!token.startsWith('box:')`. Fires when reading an old config secret that predates the libsodium store, when the secret field holds raw plaintext, or when a different SecretStore implementation wrote the value.

Common situations: Upgrading from the deprecated Electron keychain-backed SafeStorageSecretStore whose tokens used a `safe:` prefix. Manual edit of appSettings JSON replaced an encrypted value with plaintext. Two stores (env-seed vs lockbox) — value encrypted under one key source, decrypted under another runtime with no matching key.

Related errors


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