Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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,
Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down