signalapp/Signal-Server · error · BadRequestException

Must specify a PNI-associated identity key when recovering…

Error message

Must specify a PNI-associated identity key when recovering an account by identifier

What it means

recoverAccount requires a PNI (phone-number identity) identity key when recovering an account by identifier, since recovery re-establishes the PNI identity. If registrationRequest.pniIdentityKey() is null the controller throws a BadRequestException (HTTP 400) before loading the existing account.

Solutions

  1. Generate and include the PNI identity key (IdentityKey for the PNI pair) in the RegistrationRequest.
  2. Upgrade the client to a PNI-aware version — pre-PNI clients cannot perform identifier recovery against this server API.
  3. Check request JSON deserialization logs if the key was sent but arrived null (field-name mismatch, wrong nesting).
  4. Update API test harnesses to always set pniIdentityKey alongside aciIdentityKey.

Example fix

// before
new RegistrationRequest(attrs, aciIdentityKey, null, recoveryPassword);
// after
new RegistrationRequest(attrs, aciIdentityKey, pniIdentityKey, recoveryPassword);
Defensive patterns

Strategy: validation

Validate before calling

if (request.pniIdentityKey() == null) {
  throw new IllegalArgumentException("pniIdentityKey is required for recovery by identifier");
}

Type guard

boolean hasPniIdentityKey(RegistrationRequest r) {
  return r.pniIdentityKey() != null;
}

Try / catch

try {
  recoverAccount(request);
} catch (BadRequestException e) {
  if (e.getMessage().contains("PNI-associated identity key")) {
    generatePniIdentityKeyAndRetry();
  } else throw e;
}

Prevention

When it happens

Trigger: Submitting a recovery-by-identifier registration request without the pniIdentityKey field populated (null after deserialization).

Common situations: Older clients predating PNI that only send an ACI identity key; hand-written API requests omitting pniIdentityKey; serialization bugs where the key object fails to deserialize into the request DTO.

Understand the failure class

Background: "missing required argument" and "the following required arguments were not provided": what required-argument errors mean and how to fix them — this error's family across 20 libraries.

Related errors


AI-assisted analysis of signalapp/Signal-Server@100ab61c82 (2026-09-09). Data as JSON: /api/errors/91ab92c08894aae0. Report an issue: GitHub.

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java:412

      final String password,
      final RegistrationRequest registrationRequest,
      final String userAgent,
      final String signalAgent) throws RegistrationLockFailureException, RateLimitExceededException {

    if (!dynamicConfigurationManager.getConfiguration().getLoginPurchaseConfiguration().enabled()) {
      throw new BadRequestException("login purchases are not enabled");
    }

    if (ArrayUtils.isEmpty(registrationRequest.recoveryPassword())) {
      throw new BadRequestException("Recovery password required for authentication when recovering an account by identifier");
    }

    if (registrationRequest.accountAttributes().recoveryPassword().isEmpty()) {
      throw new BadRequestException("Recovery password required for for storage when recovering an account by identifier");
    }

    if (registrationRequest.pniIdentityKey() == null) {
      throw new BadRequestException("Must specify a PNI-associated identity key when recovering an account by identifier");
    }

    final Account existingAccount = accounts.getByAccountIdentifier(accountIdentifier)
            .orElseThrow(ForbiddenException::new);

    final boolean passwordVerified = existingAccount.getAccountRecoveryPassword()
        .map(saltedRecoveryPasswordHash -> PhoneNumberRecoveryPasswordsManager.verify(saltedRecoveryPasswordHash, registrationRequest.recoveryPassword()))
        .orElse(false);

    if (!passwordVerified) {
      throw new ForbiddenException();
    }

    checkTotp(existingAccount, registrationRequest.totp());

    if (!registrationRequest.skipDeviceTransfer() && existingAccount.hasCapability(DeviceCapability.TRANSFER)) {
      // If a device transfer is possible, clients must explicitly opt out of a transfer (i.e. after prompting the user)
      // before we'll let them recover an account and start "from scratch"

View on GitHub (pinned to 100ab61c82)