{"record":{"id":"6a2745ff6d17453c","repo":"signalapp/Signal-Server","slug":"only-primary-devices-can-link-devices","errorCode":null,"errorMessage":"Only primary devices can link devices","messagePattern":"Only primary devices can link devices","errorType":"validation","errorClass":"IllegalArgumentException","httpStatus":400,"severity":"error","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java","lineNumber":1708,"sourceCode":"    final List<String> keysToDelete = new ArrayList<>(2);\n    account.getPhoneNumberIdentifier()\n        .map(pni -> getAccountMapKey(pni.toString()))\n        .ifPresent(keysToDelete::add);\n    keysToDelete.add(getAccountEntityKey(account.getAccountIdentifier()));\n\n    ResilienceUtil.getGeneralRedisRetry(RETRY_NAME).executeRunnable(() ->\n        redisDeleteTimer.record(() ->\n            cacheCluster.useCluster(connection ->\n                connection.sync().del(keysToDelete.toArray(String[]::new)))));\n  }\n\n  public CompletableFuture<Optional<DeviceInfo>> waitForNewLinkedDevice(\n      final UUID accountIdentifier,\n      final Device linkingDevice,\n      final String linkDeviceTokenIdentifier,\n      final Duration timeout) {\n    if (!linkingDevice.isPrimary()) {\n      throw new IllegalArgumentException(\"Only primary devices can link devices\");\n    }\n\n    // Unbeknownst to callers but beknownst to us, the \"link device token identifier\" is the base64/url-encoded SHA256\n    // hash of a device-linking token. Before we use the string anywhere, make sure it's the right \"shape\" for a hash.\n    if (Base64.getUrlDecoder().decode(linkDeviceTokenIdentifier).length != SHA256_HASH_LENGTH) {\n      return CompletableFuture.failedFuture(new IllegalArgumentException(\"Invalid token identifier\"));\n    }\n\n    final Instant deadline = clock.instant().plus(timeout);\n    final CompletableFuture<Optional<DeviceInfo>> deviceAdded = waitForPubSubKey(waitForDeviceFuturesByTokenIdentifier,\n        linkDeviceTokenIdentifier, getLinkedDeviceKey(linkDeviceTokenIdentifier), timeout, this::handleDeviceAdded);\n\n    return deviceAdded.thenCompose(maybeDeviceInfo -> maybeDeviceInfo.map(deviceInfo -> {\n          // The device finished linking, we now want to make sure the primary client has fetched messages that could\n          // have come in before the linked device's mailbox was set up. This avoids a race where the linked device\n          // misses out on messages that were sent before its mailbox was set up but received by the primary *after*\n          // creating its backup for the linked device.\n","sourceCodeStart":1690,"sourceCodeEnd":1726,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java#L1690-L1726","documentation":"AccountsManager.waitForNewLinkedDevice throws IllegalArgumentException(\"Only primary devices can link devices\") when the device performing the link is not the primary device. Linked (secondary) devices are not permitted to authorize additional device links; only the primary can approve new linkings.","triggerScenarios":"Calling waitForNewLinkedDevice() with a linkingDevice whose isPrimary() is false — e.g. a secondary device initiating the link-device handshake.","commonSituations":"Client bugs where the link request is routed from the wrong device; duplicated provisioning code running on a linked device; reversed arguments when constructing the linking Device object in tests.","solutions":["Ensure the link-device request is handled by the primary device's session/auth context","Verify device.isPrimary() before invoking waitForNewLinkedDevice and return a client error otherwise","Fix clients so secondary devices redirect linking to the primary device","Correct test harnesses to use a primary Device instance"],"exampleFix":"// before\naccounts.waitForNewLinkedDevice(accountUuid, authenticatedDevice, token, timeout); // may be secondary\n// after\nif (!authenticatedDevice.isPrimary()) {\n  throw new WebApplicationException(Response.status(403).build());\n}\naccounts.waitForNewLinkedDevice(accountUuid, authenticatedDevice, token, timeout);","handlingStrategy":"validation","validationCode":"// Ensure the authenticated device is primary before initiating a link\nif (!authenticatedDevice.isPrimary()) {\n  throw new WebApplicationException(Response.status(403).build());\n}","typeGuard":"static boolean canInitiateDeviceLink(Device d) {\n  return d.isPrimary();\n}","tryCatchPattern":"try {\n  return accounts.waitForNewLinkedDevice(accountUuid, device, token, timeout);\n} catch (IllegalArgumentException e) {\n  if (e.getMessage().equals(\"Only primary devices can link devices\")) {\n    return CompletableFuture.failedFuture(new WebApplicationException(Response.status(403).build()));\n  }\n  throw e;\n}","preventionTips":["Route link-device requests exclusively through primary-device sessions","Check isPrimary() in the API auth layer","Fix clients that let secondary devices start provisioning","Test link flows with both primary and secondary auth contexts"],"tags":["illegal-argument","device-linking","unsupported-operation","accounts"],"backgroundTag":"unsupported-operation","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"}