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