{"record":{"id":"11ca5cc6da435711","repo":"signalapp/Signal-Server","slug":"pni-identity-key-must-not-be-provided-if-existing","errorCode":null,"errorMessage":"PNI identity key must not be provided if existing account does not have a phone number","messagePattern":"PNI identity key must not be provided if existing account does not have a phone number","errorType":"validation","errorClass":"IllegalArgumentException","httpStatus":400,"severity":"error","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java","lineNumber":593,"sourceCode":"      @Nullable final String userAgent) {\n\n    final Account recoveredAccount = accountLockManager.withSingleAccountLock(existingAccount, () -> {\n      final Account account = new Account();\n      account.setAccountIdentifier(existingAccount.getAccountIdentifier());\n\n      if (existingAccount.getNumber().isPresent()) {\n        account.setNumber(existingAccount.getNumber().get(),\n            existingAccount.getPhoneNumberIdentifier()\n                .orElseThrow(() -> new AssertionError(\"Accounts that have a phone number must also have a PNI\")));\n\n        account.setPhoneNumberIdentityKey(maybePniIdentityKey\n            .orElseThrow(() -> new IllegalArgumentException(\"PNI identity key must be provided if existing account has a phone number\")));\n\n        account.setRegistrationLockFromAttributes(accountAttributes);\n        account.setDiscoverableByPhoneNumber(accountAttributes.isDiscoverableByPhoneNumber());\n      } else {\n        if (maybePniIdentityKey.isPresent()) {\n          throw new IllegalArgumentException(\"PNI identity key must not be provided if existing account does not have a phone number\");\n        }\n\n        final byte[] authCredentialSalt = new byte[AUTH_CREDENTIAL_SALT_SIZE];\n        SECURE_RANDOM.nextBytes(authCredentialSalt);\n\n        account.setAuthCredentialSalt(authCredentialSalt);\n      }\n\n      account.setIdentityKey(aciIdentityKey);\n      account.addDevice(primaryDeviceSpec.toDevice(Device.PRIMARY_ID, clock, aciIdentityKey));\n      account.setUnidentifiedAccessKey(accountAttributes.getUnidentifiedAccessKey());\n      account.setUnrestrictedUnidentifiedAccess(accountAttributes.isUnrestrictedUnidentifiedAccess());\n      account.setAccountRecoveryPassword(accountAttributes.recoveryPassword().orElseThrow(() ->\n          new IllegalArgumentException(\"Must specify a recovery password when reclaiming an existing account\")));\n\n      reclaimAccount(account, existingAccount, primaryDeviceSpec, accountAttributes);\n\n      return account;","sourceCodeStart":575,"sourceCodeEnd":611,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java#L575-L611","documentation":"During account creation/update, AccountsManager throws IllegalArgumentException(\"PNI identity key must not be provided if existing account does not have a phone number\") when a caller supplies a PNI identity key for an account that has no phone number (and thus no PNI). The PNI identity key only makes sense for accounts with a phone-number identity.","triggerScenarios":"Calling changeNumber or account creation flows passing a pniIdentityKey Optional.of(...) while the existing account lacks a phone number.","commonSituations":"Client migration code written before PNI existed being applied to legacy/no-PNI accounts; copy-paste in account-update endpoints passing all keys unconditionally; tests using fixtures mixing old and new account shapes.","solutions":["Only include pniIdentityKey in the request when the target account has a phone number (account.getPhoneNumberIdentifier().isPresent())","Check existingAccount.getPhoneNumberIdentifier().isPresent() before passing the key","Update older clients/tools to omit the PNI key for non-PNI accounts","Fix test fixtures to match the account shape"],"exampleFix":"// before\nmanager.changeNumber(account, number, pniIdentityKey, pniSigningKey, attributes); // always passes key\n// after\nfinal Optional<IdentityKey> effectivePniKey = account.getPhoneNumberIdentifier().isPresent()\n    ? Optional.of(pniIdentityKey) : Optional.empty();\nmanager.changeNumber(account, number, effectivePniKey, pniSigningKey, attributes);","handlingStrategy":"validation","validationCode":"// Only pass PNI identity key when the account has a PNI\nfinal boolean hasPni = existingAccount.getPhoneNumberIdentifier().isPresent();\nif (!hasPni && pniIdentityKey.isPresent()) {\n  throw new WebApplicationException(Response.status(400).build());\n}","typeGuard":"static boolean canAcceptPniIdentityKey(Account account, Optional<IdentityKey> pniKey) {\n  return pniKey.isEmpty() || account.getPhoneNumberIdentifier().isPresent();\n}","tryCatchPattern":"try {\n  accounts.changeNumber(...);\n} catch (IllegalArgumentException e) {\n  if (e.getMessage().contains(\"PNI identity key must not be provided\")) {\n    return Response.status(400).entity(e.getMessage()).build();\n  }\n  throw e;\n}","preventionTips":["Always check getPhoneNumberIdentifier().isPresent() before supplying a PNI key","Keep legacy account flows free of PNI parameters","Mirror this validation in the API layer for early 400s","Review fixtures/tests for mixed account shapes"],"tags":["illegal-argument","accounts","pni","validation"],"backgroundTag":"invalid-argument-value","analyzedSha":"100ab61c82627582c867d19e1c0561ba2781e927","analyzedAt":"2026-09-09T13:29:47.883Z","contentChangedAt":"2026-09-09T13:29:47.883Z","schemaVersion":2},"datasetVersion":"2026-09-16T09:17:16.951Z"}