passbolt/passbolt_api · error · App\Error\Exception\InvalidUserSignatureException

The user signature could not be verified.

Error message

The user signature could not be verified.

What it means

During GPG-backed JWT login the client signs and encrypts a challenge; the server decrypts it with signature verification enabled ($this->gpg->decrypt($armoredChallenge, true)). This error is thrown when decryption succeeds contextually but the signature check fails (InvalidSignatureException), meaning the challenge was not signed by the user's registered OpenPGP private key.

Solutions

  1. Verify the challenge is signed with the private key matching the fingerprint stored in the user's gpgkeys record
  2. Re-import or update the user's public key in passbolt if it was rotated (passbolt pro user settings or CLI)
  3. Check client SDK login code signs the challenge before encrypting (sign-then-encrypt) with the correct key
  4. Inspect the logged InvalidSignatureException message in error logs for the offending key fingerprint
  5. Clear stale local keyring entries (gpg --delete-secret-key) and re-run login

Example fix

// before: challenge only encrypted, not signed
$challenge = $gpg->encrypt($serverKeyFingerprint, $json);
// after: sign with user private key before encrypting
$challenge = $gpg->sign($userPrivateKey, $passphrase, $json);
$challenge = $gpg->encrypt($serverKeyFingerprint, $challenge);
Defensive patterns

Strategy: try-catch

Validate before calling

const fp = user.gpgkey.fingerprint;
const sigOk = gpg.verify(challenge) && gpg.signingKeyFingerprint === fp;

Type guard

function isValidSignedChallenge(c) { return typeof c === 'string' && c.startsWith('-----BEGIN PGP MESSAGE-----'); }

Try / catch

try { await login(challenge); } catch (e) { if (e.message.includes('signature could not be verified')) { reSignChallengeWithCorrectKey(); } }

Prevention

When it happens

Trigger: POST to /auth/jwt/login (verifyChallenge called from authenticate) where the armored challenge payload decrypts but its signature does not match the user's public key: challenge signed with a different/wrong key, key rotated on server while client kept old private key, or tampered/replayed payload.

Common situations: Developer regenerated their GPG key locally without updating the key in passbolt; multiple keyrings on the machine and the client signed with the wrong key; a proxy or middleware mangled the armored challenge body; replaying an old challenge after the user's key was revoked.

Related errors


AI-assisted analysis of passbolt/passbolt_api@31c1bbc10f (2026-09-17). Data as JSON: /api/errors/4ff3673886d11aca. Report an issue: GitHub.

Appendix: source

Thrown at plugins/PassboltCe/JwtAuthentication/src/Authenticator/GpgJwtAuthenticator.php:314

    }

    /**
     * @throws \InvalidArgumentException if the challenge is missing
     * @throws \Cake\Http\Exception\BadRequestException if the challenge is invalid
     * @return string
     */
    public function verifyChallenge(): string
    {
        // Sanity check
        $armoredChallenge = $this->request->getData('challenge');
        $this->assertArmoredChallenge($armoredChallenge);

        // Decrypt and verify signature
        try {
            $clearTextChallenge = $this->gpg->decrypt($armoredChallenge, true);
        } catch (InvalidSignatureException $exception) {
            Log::error($exception->getMessage());
            throw new InvalidUserSignatureException(__('The user signature could not be verified.'));
        } catch (Exception $exception) {
            Log::error($exception->getMessage());
            throw new BadRequestException(__('The challenge cannot be decrypted.'));
        }

        // Deserialize JSON
        try {
            $jsonChallenge = json_decode($clearTextChallenge, true, 2, JSON_THROW_ON_ERROR);
            [
                'version' => $version,
                'domain' => $domain,
                'verify_token' => $verifyToken,
                'verify_token_expiry' => $verifyTokenExpiry,
            ] = $jsonChallenge;
        } catch (Exception $exception) {
            Log::error($exception->getMessage() . "\n" . $clearTextChallenge);
            throw new BadRequestException(__('The challenge is invalid. Deserialization failed.'));
        }

View on GitHub (pinned to 31c1bbc10f)