From 2659e8433630d84944060d4b88cf543d83eb8d21 Mon Sep 17 00:00:00 2001 From: Alex Arguello Date: Fri, 14 Aug 2026 12:39:24 -0700 Subject: [PATCH] fix(taptopay): sign the assertion timestamp once, not twice `/activate` refused every Android device with `Assertion verification failed: `, on both bench handsets. The signer hashed the timestamp and handed the digest to `SHA256withECDSA`, which hashes again, so the signature was taken over the hash of a hash. The service verifies a signature over `SHA256(UTF8(timestamp))`, one hash. Hand the timestamp bytes to the key unhashed and let the algorithm apply the single hash. `clientDataHash` goes with it, along with its claim that the double hash mirrored the sibling platform: the sibling's second hash is imposed by a platform attestation API on a different layer, over an object this key does not produce. This was unreachable until now. Attestation does not verify an assertion this way, and the unattested case returns at the attestation lookup before verification is reached, so the confirm path is the only one that exercises it. Three unit assertions pinned the old shape and now pin the new one. --- .../attestation/device/DeviceAssertion.kt | 6 +++--- .../device/DeviceAssertionSigner.kt | 21 +++++-------------- .../device/DeviceAssertionSignerTest.kt | 18 ++++++++-------- 3 files changed, 17 insertions(+), 28 deletions(-) diff --git a/taptopay/src/main/java/com/payabli/sdk/taptopay/attestation/device/DeviceAssertion.kt b/taptopay/src/main/java/com/payabli/sdk/taptopay/attestation/device/DeviceAssertion.kt index 20628060..ba103428 100644 --- a/taptopay/src/main/java/com/payabli/sdk/taptopay/attestation/device/DeviceAssertion.kt +++ b/taptopay/src/main/java/com/payabli/sdk/taptopay/attestation/device/DeviceAssertion.kt @@ -32,8 +32,8 @@ internal class DeviceIdentity( /** * The proof-of-possession headers `/activate` requires, and `/config` after it. * - * The server re-derives what was signed from [timestamp] alone: `clientDataHash = SHA256(UTF8(timestamp))`, - * and the signature is checked over that against the public key stored at attestation. **So [timestamp] must + * The server re-derives what was signed from [timestamp] alone, as `SHA256(UTF8(timestamp))`, and checks the + * signature over that digest against the public key stored at attestation. **So [timestamp] must * be the exact string the signer signed, byte for byte.** That is why it is carried as a string rather than * an instant formatted here: a second formatting of the same moment can differ in fractional digits or offset * spelling, and the failure would surface as a signature mismatch with nothing pointing at the cause. The @@ -49,7 +49,7 @@ internal class DeviceIdentity( * helper is `internal` to that module, so this is the same rule stated again rather than a second rule. */ internal class DeviceAssertion( - /** Base64 of the DER ECDSA signature over `SHA256(UTF8(timestamp))`. */ + /** Base64 of the DER ECDSA signature over `UTF8(timestamp)`, which the algorithm hashes once. */ val assertion: String, /** * The signing key's identifier, matched against the attestation row. Derived from the key, not its alias. diff --git a/taptopay/src/main/java/com/payabli/sdk/taptopay/attestation/device/DeviceAssertionSigner.kt b/taptopay/src/main/java/com/payabli/sdk/taptopay/attestation/device/DeviceAssertionSigner.kt index c94b86de..88451faf 100644 --- a/taptopay/src/main/java/com/payabli/sdk/taptopay/attestation/device/DeviceAssertionSigner.kt +++ b/taptopay/src/main/java/com/payabli/sdk/taptopay/attestation/device/DeviceAssertionSigner.kt @@ -1,7 +1,6 @@ package com.payabli.sdk.taptopay.attestation.device import com.payabli.sdk.core.devicekey.DeviceKey -import java.security.MessageDigest import java.time.Clock import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatterBuilder @@ -35,6 +34,10 @@ internal class DeviceAssertionSigner( /** * An assertion for one call, over a timestamp minted now. * + * **The timestamp bytes go to the signer unhashed.** `SHA256withECDSA` applies the one hash the server + * verifies against; pre-hashing here would sign the hash of a hash, which verifies against nothing the + * server computes and is refused as an assertion failure. + * * Never cached: the server's window is two minutes, and a reused assertion is a replay of a proof that was * only ever good for the request it was made for. */ @@ -43,7 +46,7 @@ internal class DeviceAssertionSigner( // One call, so the signature and the identity that labels it describe the same key. Taken separately // a replacement between them would send a signature the service cannot verify against the row that // identity selects. - val signed = deviceKey.sign(clientDataHash(timestamp)) + val signed = deviceKey.sign(timestamp.toByteArray(Charsets.UTF_8)) return DeviceAssertion( assertion = Base64.getEncoder().encodeToString(signed.signature), keyId = signed.identity, @@ -52,21 +55,7 @@ internal class DeviceAssertionSigner( ) } - /** - * What the signature is taken over: `SHA256(UTF8(timestamp))`. - * - * The digest is the input to the signing algorithm, which hashes it again, so the value the server checks - * is a signature over the hash of this digest. That is what the sibling platform does, where the same - * digest is handed to a platform attestation API that hashes it in turn, and this platform matches it for - * Phase 1 rather than reading the wording the other way. The target design signs a server-issued value - * instead and is a separate change. - */ - private fun clientDataHash(timestamp: String): ByteArray = - MessageDigest.getInstance(DIGEST).digest(timestamp.toByteArray(Charsets.UTF_8)) - private companion object { - const val DIGEST = "SHA-256" - /** * ISO-8601 in UTC with exactly three fractional digits. * diff --git a/taptopay/src/test/java/com/payabli/sdk/taptopay/attestation/device/DeviceAssertionSignerTest.kt b/taptopay/src/test/java/com/payabli/sdk/taptopay/attestation/device/DeviceAssertionSignerTest.kt index 5798c0a3..d926a7b9 100644 --- a/taptopay/src/test/java/com/payabli/sdk/taptopay/attestation/device/DeviceAssertionSignerTest.kt +++ b/taptopay/src/test/java/com/payabli/sdk/taptopay/attestation/device/DeviceAssertionSignerTest.kt @@ -84,19 +84,19 @@ class DeviceAssertionSignerTest { // an assertion failure with nothing naming the timestamp. assertTrue( "the signature does not verify over the timestamp that was sent", - verifies(assertion, sha256(assertion.timestamp)), + verifies(assertion, assertion.timestamp.toByteArray()), ) } @Test - fun `the signed bytes are the digest of the timestamp, not the timestamp`() { + fun `the signed bytes are the timestamp, not its digest`() { val assertion = signerAt(1785931200).sign(DEVICE_ID) - // The digest goes to the signer, which hashes it again. Matching the sibling platform, which hands the - // same digest to an attestation API that hashes it in turn. Signing the timestamp directly verifies - // just as cleanly on this side and is refused by the server. - assertEquals(sha256(assertion.timestamp).toList(), key.signed.single().toList()) - assertNotEquals(assertion.timestamp.toByteArray().toList(), key.signed.single().toList()) + // `SHA256withECDSA` applies the hash the server verifies against. Handing it a digest instead would + // sign the hash of a hash, which verifies on this side against the same double hash and is refused by + // the server as an assertion failure. + assertEquals(assertion.timestamp.toByteArray().toList(), key.signed.single().toList()) + assertNotEquals(sha256(assertion.timestamp).toList(), key.signed.single().toList()) } @Test @@ -129,8 +129,8 @@ class DeviceAssertionSignerTest { // returns two different signatures over identical input is the provider's nonce strategy, and a // deterministic ECDSA provider would fail this while the signer stayed correct. assertEquals(2, key.signed.size) - assertTrue(verifies(first, sha256(first.timestamp))) - assertTrue(verifies(second, sha256(second.timestamp))) + assertTrue(verifies(first, first.timestamp.toByteArray())) + assertTrue(verifies(second, second.timestamp.toByteArray())) } @Test