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:
Paul Hitt
2026-08-29 09:02:09 -04:00
co-authored by Claude Opus 4.8
parent b0c5a108b2
commit cbe2292d3c
2 changed files with 82 additions and 0 deletions
@@ -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))
}
}