Audit H3: the safety number covers the post-quantum identity too
Mirror of iOS, byte-identical by construction: both keys folded under one label when both sides have a PQ identity, classical pairs unchanged, and the exact digits pinned against an independent implementation so the two platforms cannot drift into showing different numbers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b0c5a108b2
commit
cbe2292d3c
@@ -55,6 +55,35 @@ object SafetyNumber {
|
||||
else remote + local
|
||||
}
|
||||
|
||||
/**
|
||||
* The same number, covering the post-quantum identity as well (audit H3).
|
||||
*
|
||||
* 🔴 The number is the one check that does not trust the relay, and it
|
||||
* covered only the Ed25519 key. An adversary able to forge Ed25519 — the
|
||||
* exact adversary the ML-DSA identity exists to stop — could keep the real
|
||||
* signing key so the digits still matched, while substituting the PQ
|
||||
* identity at first contact. Folding it in makes that swap visible.
|
||||
*
|
||||
* ⚠️ Only when BOTH sides have one; otherwise the classical number stands,
|
||||
* because changing it for classical pairs would invalidate every number
|
||||
* already compared for no gain. Must stay byte-identical to iOS
|
||||
* `SafetyNumber.compute(localIK:localPQ:remoteIK:remotePQ:)`.
|
||||
*/
|
||||
fun compute(localIdentity: ByteArray, localPQ: ByteArray?,
|
||||
remoteIdentity: ByteArray, remotePQ: ByteArray?): String {
|
||||
if (localPQ == null || remotePQ == null || localPQ.isEmpty() || remotePQ.isEmpty()) {
|
||||
return compute(localIdentity, remoteIdentity)
|
||||
}
|
||||
val local = fingerprint(bind(localIdentity, localPQ))
|
||||
val remote = fingerprint(bind(remoteIdentity, remotePQ))
|
||||
return if (Bytes.compareUnsigned(localIdentity, remoteIdentity) < 0) local + remote
|
||||
else remote + local
|
||||
}
|
||||
|
||||
/** `SHA-256("fnsn-pq-v1" ‖ ik ‖ pq)` — one identity covering both keys. */
|
||||
private fun bind(ik: ByteArray, pq: ByteArray): ByteArray =
|
||||
Bytes.sha256(Bytes.concat("fnsn-pq-v1".toByteArray(Charsets.UTF_8), ik, pq))
|
||||
|
||||
/** Grouped into fives, which is how it is meant to be read aloud. */
|
||||
fun format(number: String): String =
|
||||
number.chunked(5).joinToString(" ")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
|
||||
/**
|
||||
* Audit H3: a substituted post-quantum identity must change the digits two
|
||||
* people read aloud. Cross-platform: these values are the same ones the iOS
|
||||
* SafetyNumberPQTests use, so a drift between the two engines fails here.
|
||||
*/
|
||||
class SafetyNumberPQTest {
|
||||
private val aliceIK = ByteArray(32) { 0x11 }
|
||||
private val bobIK = ByteArray(32) { 0x22 }
|
||||
private val alicePQ = ByteArray(32) { 0xA1.toByte() }
|
||||
private val bobPQ = ByteArray(32) { 0xB2.toByte() }
|
||||
|
||||
@Test fun `both sides see the same number`() {
|
||||
assertEquals(
|
||||
SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ),
|
||||
SafetyNumber.compute(bobIK, bobPQ, aliceIK, alicePQ))
|
||||
}
|
||||
|
||||
@Test fun `swapping the PQ identity changes the number`() {
|
||||
assertNotEquals(
|
||||
SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ),
|
||||
SafetyNumber.compute(aliceIK, alicePQ, bobIK, ByteArray(32) { 0xEE.toByte() }))
|
||||
}
|
||||
|
||||
@Test fun `a classical pair keeps the classical number`() {
|
||||
assertEquals(SafetyNumber.compute(aliceIK, bobIK),
|
||||
SafetyNumber.compute(aliceIK, null, bobIK, bobPQ))
|
||||
assertEquals(SafetyNumber.compute(aliceIK, bobIK),
|
||||
SafetyNumber.compute(aliceIK, alicePQ, bobIK, null))
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact digits, from an INDEPENDENT implementation of the rule — the
|
||||
* same constant the iOS suite pins. This is what makes both platforms
|
||||
* agree with the spec rather than merely with each other; a silent drift
|
||||
* would show two people different numbers and read as an attack.
|
||||
*/
|
||||
@Test fun `the number matches the independently computed value`() {
|
||||
assertEquals(
|
||||
"526042701003577849302816285461391061377406450730104222477184",
|
||||
SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ))
|
||||
}
|
||||
|
||||
@Test fun `the PQ number differs from the classical one`() {
|
||||
assertNotEquals(SafetyNumber.compute(aliceIK, bobIK),
|
||||
SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user