{"record":{"id":"0d07dcecd785718f","repo":"signalapp/Signal-Server","slug":"sign-count-from-request-less-than-stored-sign-coun","errorCode":null,"errorMessage":"Sign count from request less than stored sign count","messagePattern":"Sign count from request less than stored sign count","errorType":"exception","errorClass":"RequestReuseException","httpStatus":null,"severity":"error","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java","lineNumber":211,"sourceCode":"    }\n    if (!MessageDigest.isEqual(\n        storedChallenge.getBytes(StandardCharsets.UTF_8),\n        challenge.getBytes(StandardCharsets.UTF_8))) {\n      throw new DeviceCheckVerificationFailedException(\"Provided challenge did not match stored challenge\");\n    }\n\n    final DCAppleDevice appleDevice = appleDeviceChecks.lookup(account, keyId)\n        .orElseThrow(DeviceCheckKeyIdNotFoundException::new);\n    final DCAssertionRequest dcAssertionRequest = new DCAssertionRequest(keyId, assertion, sha256(request));\n    final DCAssertionParameters dcAssertionParameters =\n        new DCAssertionParameters(new DCServerProperty(teamId, bundleId, new DefaultChallenge(request)), appleDevice);\n\n    try {\n      deviceCheckManager.validate(dcAssertionRequest, dcAssertionParameters);\n    } catch (MaliciousCounterValueException e) {\n      // We will only accept assertions that have a sign count greater than the last assertion we saw. Step 5 here:\n      // https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server#Verify-the-assertion\n      throw new RequestReuseException(\"Sign count from request less than stored sign count\");\n    } catch (VerificationException e) {\n      logger.info(\"Failed to validate DeviceCheck assert\", e);\n      throw new DeviceCheckVerificationFailedException(e);\n    }\n\n    // Store the updated sign count, so we can check the next assertion (step 6)\n    if (!appleDeviceChecks.updateCounter(account, keyId, appleDevice.getCounter())) {\n      throw new RequestReuseException(\"Sign count from request less than stored sign count\");\n    }\n    removeChallenge(redisChallengeKey);\n  }\n\n  /**\n   * Create a challenge that can be used in an attestation or assertion\n   *\n   * @param challengeType The type of the challenge\n   * @param account       The account that will use the challenge\n   * @return The challenge to be included as part of an attestation or assertion","sourceCodeStart":193,"sourceCodeEnd":229,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java#L193-L229","documentation":"Signal throws RequestReuseException during DeviceCheck assertion validation when the sign counter embedded in the client's assertion is not greater than the sign counter already stored server-side. Per Apple's web-credential assertion spec (step 5), the authenticator's counter must monotonically increase; a lower or equal counter indicates a replayed or cloned assertion. The server rejects it as an anti-replay measure.","triggerScenarios":"Client submits a DeviceCheck assertion whose signCount <= the stored counter in apple_device_checks for the (account, keyId). Happens when the same assertion payload is replayed, when the client restored an older backup with a stale counter, or when multiple devices share the same assertion key.","commonSituations":"Replay attacks or automated request-forgery attempts; a user restoring a device from backup so the device's counter regresses; a bug in client-side counter persistence causing stale counters to be sent.","solutions":["Ensure the client persists and increments its DeviceCheck sign counter locally after every successful assertion and never restores an old counter value.","Regenerate the DeviceCheck/assertion key on the client (fresh key starts a new counter lineage) and re-enroll it with the server.","If the client genuinely lost its counter (backup restore), have the user delete and recreate the linked device/credential so the server resets the stored counter.","Check that no proxy or client middleware is caching and resending the assertion request."],"exampleFix":"// client-side (before: reusing cached assertion)\nfinal DeviceCheckRequest cached = loadCachedAssertion();\nsendAssertion(cached);\n// after: always build fresh assertion; WebAuthn authenticators increment counter internally\nfinal AssertionResult result = platformProvider.getAssertion(challenge);\nsendAssertion(result);\n// server treats counter regression as replay\nif (assertion.getSignCount() <= storedCounter) {\n  throw new RequestReuseException(\"Sign count from request less than stored sign count\");\n}","handlingStrategy":"try-catch","validationCode":"// client-side: never resend a previously used assertion\nif (sentAssertions.contains(assertionHash)) { throw new IllegalStateException(\"assertion already used\"); }","typeGuard":"function hasFreshCounter(newCount, storedCount) { return typeof newCount === 'number' && typeof storedCount === 'number' && newCount > storedCount; }","tryCatchPattern":"try {\n  api.validateAssertion(assertion);\n} catch (RequestReuseException e) {\n  regenerateKeyAndReEnroll(); // counter lineage is broken; start fresh\n}","preventionTips":["Persist the sign counter durably on the client and never restore stale values from backups","Never reuse or cache assertion payloads across requests","Regenerate assertion keys after backup restore"],"tags":["devicecheck","replay-protection","sign-counter","anti-reuse"],"backgroundTag":"invalid-state-transition","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"}