{"record":{"id":"cb5b1b0d4671342f","repo":"signalapp/Signal-Server","slug":"provided-challenge-did-not-match-stored-challenge","errorCode":null,"errorMessage":"Provided challenge did not match stored challenge","messagePattern":"Provided challenge did not match stored challenge","errorType":"exception","errorClass":"DeviceCheckVerificationFailedException","httpStatus":null,"severity":"error","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java","lineNumber":197,"sourceCode":"      final Account account,\n      final byte[] keyId,\n      final ChallengeType challengeType,\n      final String challenge,\n      final byte[] request,\n      final byte[] assertion)\n      throws ChallengeNotFoundException, DeviceCheckVerificationFailedException, DeviceCheckKeyIdNotFoundException, RequestReuseException {\n\n    final String redisChallengeKey = challengeKey(challengeType, account.getAccountIdentifier());\n    @Nullable final String storedChallenge = ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)\n            .executeSupplier(() -> redisClient.withCluster(cluster -> cluster.sync().get(redisChallengeKey)));\n\n    if (storedChallenge == null) {\n      throw new ChallengeNotFoundException();\n    }\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    }","sourceCodeStart":179,"sourceCodeEnd":215,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java#L179-L215","documentation":"AppleDeviceCheckManager.validateAssert throws DeviceCheckVerificationFailedException(\"Provided challenge did not match stored challenge\") when the challenge embedded in the client's DeviceCheck assertion does not equal (constant-time comparison) the challenge previously issued and stored in the challenge cache. The assertion is therefore stale, replayed, or fabricated.","triggerScenarios":"Calling validateAssert with a challenge value different from the one returned by the challenge-issuing endpoint for that account, including expired/evicted challenges (storedChallenge == null raises ChallengeNotFoundException instead), reused nonces, or replayed assertions.","commonSituations":"Client caches a challenge and reuses it for multiple assertions instead of fetching a fresh one per assertion; challenge TTL elapsed and it was evicted from the cache; clock/client/server mismatch causing the wrong challenge bytes (e.g. charset or hashing differences); replaying a captured assertion in an attack attempt.","solutions":["Fetch a fresh challenge from the challenge endpoint immediately before generating each DeviceCheck assertion and use exactly those bytes.","Never reuse or cache challenges client-side across requests; each assertion needs a new server-issued challenge.","Ensure the client encodes the challenge as UTF-8 without alteration (no re-basing, trimming, or charset conversion) when embedding it in the assertion payload.","If challenges expire quickly, reduce the delay between challenge issuance and assertion, and check cache TTL configuration server-side."],"exampleFix":"// before\nString challenge = cachedChallenge; // stale, fetched minutes ago\nbyte[] assertion = generateAssertion(account, keyId, challenge);\n// after\nString challenge = fetchNewChallenge(account); // fresh per assertion\nbyte[] assertion = generateAssertion(account, keyId, challenge.getBytes(StandardCharsets.UTF_8));","handlingStrategy":"retry","validationCode":"// client-side: verify the challenge used is the one most recently issued for this account\nif (!challenge.equals(lastIssuedChallenge) || challengeIssuedAt + TTL < now()) {\n  challenge = fetchNewChallenge(account);\n}","typeGuard":null,"tryCatchPattern":"try {\n  deviceCheckManager.validateAssert(accountnumber, request, keyId, assertion, challenge);\n} catch (DeviceCheckVerificationFailedException e) {\n  // challenge stale/mismatched: request a fresh challenge and retry once\n} catch (ChallengeNotFoundException e) {\n  // challenge expired or never issued\n}","preventionTips":["Fetch a new challenge immediately before every assertion — never cache or reuse","Encode challenges as raw UTF-8 bytes without transformation","Size the server challenge cache TTL to comfortably exceed worst-case client latency","Treat repeated mismatches from a client as a potential replay attack signal"],"tags":["devicecheck","challenge","replay-protection","apple"],"backgroundTag":"challenge-verification-failed","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"}