mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge main and retain iOS parity regression coverage
This commit is contained in:
commit
6a14ff7cfc
@ -3,6 +3,7 @@ package com.bitchat.android.mesh
|
||||
import android.util.Log
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.sync.PacketIdUtil
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||
@ -240,21 +241,9 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
return encryptionService.getCombinedPublicKeyData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate message ID for duplicate detection
|
||||
*/
|
||||
/** Deduplicates by peer and a hash of packet type, sender, timestamp, and full payload. */
|
||||
private fun generateMessageID(packet: BitchatPacket, peerID: String): String {
|
||||
return when (MessageType.fromValue(packet.type)) {
|
||||
MessageType.FRAGMENT -> {
|
||||
// For fragments, include the payload hash to distinguish different fragments
|
||||
"${packet.timestamp}-$peerID-${packet.type}-${packet.payload.contentHashCode()}"
|
||||
}
|
||||
else -> {
|
||||
// For other messages, use a truncated payload hash
|
||||
val payloadHash = packet.payload.sliceArray(0 until minOf(64, packet.payload.size)).contentHashCode()
|
||||
"${packet.timestamp}-$peerID-$payloadHash"
|
||||
}
|
||||
}
|
||||
return "$peerID-${PacketIdUtil.computeIdHex(packet)}"
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -21,7 +21,9 @@ import java.math.BigInteger
|
||||
* Includes secp256k1 operations, ECDH, and NIP-44 encryption
|
||||
*/
|
||||
object NostrCrypto {
|
||||
|
||||
|
||||
internal const val NIP17_DEFAULT_MAX_PAST_SECONDS = 79_200
|
||||
|
||||
private val secureRandom = SecureRandom()
|
||||
// NIP-44 v2 only
|
||||
|
||||
@ -317,9 +319,9 @@ object NostrCrypto {
|
||||
}
|
||||
|
||||
/**
|
||||
* Random timestamp up to maxPastSeconds in the past (default 2 days)
|
||||
* Random timestamp in the past, defaulting to 22 hours to leave 2 hours of slack inside iOS's 24-hour lookback.
|
||||
*/
|
||||
fun randomizeTimestampUpToPast(maxPastSeconds: Int = 172800): Int {
|
||||
fun randomizeTimestampUpToPast(maxPastSeconds: Int = NIP17_DEFAULT_MAX_PAST_SECONDS): Int {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val offset = if (maxPastSeconds > 0) secureRandom.nextInt(maxPastSeconds + 1) else 0
|
||||
return now - offset
|
||||
|
||||
@ -79,9 +79,9 @@ class NostrEventDeduplicator(
|
||||
* @return true if the event is a duplicate (already seen), false if it's new
|
||||
*/
|
||||
fun isDuplicate(eventId: String): Boolean {
|
||||
totalChecks++
|
||||
|
||||
synchronized(lruLock) {
|
||||
totalChecks++
|
||||
|
||||
val existingNode = nodeMap[eventId]
|
||||
|
||||
if (existingNode != null) {
|
||||
|
||||
@ -37,7 +37,7 @@ object NostrProtocol {
|
||||
val rumorId = rumorBase.computeEventIdHex()
|
||||
val rumor = rumorBase.copy(id = rumorId)
|
||||
|
||||
// 2. Seal the rumor (kind 13) signed by sender, timestamp randomized up to 2 days
|
||||
// 2. Seal the rumor with 2 hours of slack inside iOS's 24-hour lookback.
|
||||
val sealedEvent = createSeal(
|
||||
rumor = rumor,
|
||||
recipientPubkey = recipientPubkey,
|
||||
|
||||
@ -14,8 +14,6 @@ import okhttp3.*
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Manages WebSocket connections to Nostr relays
|
||||
@ -49,12 +47,8 @@ class NostrRelayManager private constructor() {
|
||||
"wss://nostr21.com"
|
||||
)
|
||||
|
||||
// Exponential backoff configuration (same as iOS)
|
||||
private const val INITIAL_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS // 1 second
|
||||
private const val MAX_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS // 5 minutes
|
||||
private const val BACKOFF_MULTIPLIER = com.bitchat.android.util.AppConstants.Nostr.BACKOFF_MULTIPLIER
|
||||
private const val MAX_RECONNECT_ATTEMPTS = com.bitchat.android.util.AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS
|
||||
|
||||
// Reconnect backoff lives in RelayReconnectPolicy.
|
||||
|
||||
// Track gift-wraps we initiated for logging
|
||||
private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
@ -1116,36 +1110,15 @@ class NostrRelayManager private constructor() {
|
||||
if (!desiredConnected.get() ||
|
||||
!isNetworkActionAllowed(connectionToken)
|
||||
) return
|
||||
|
||||
// Check if this is a DNS error
|
||||
val errorMessage = error.message?.lowercase() ?: ""
|
||||
if (errorMessage.contains("hostname could not be found") ||
|
||||
errorMessage.contains("dns") ||
|
||||
errorMessage.contains("unable to resolve host")) {
|
||||
|
||||
val relay = relaysList.find { it.url == relayUrl }
|
||||
if (relay?.lastError == null) {
|
||||
Log.w(TAG, "Nostr relay DNS failure; not retrying")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Implement exponential backoff for non-DNS errors
|
||||
|
||||
// Every failure backs off and retries, including a name-resolution
|
||||
// failure: "unable to resolve host" is what this device reports when it
|
||||
// simply has no network, so treating it as permanent turns a walk
|
||||
// through a tunnel into a dead relay layer for the rest of the process.
|
||||
val relay = relaysList.find { it.url == relayUrl } ?: return
|
||||
relay.reconnectAttempts++
|
||||
|
||||
// Stop attempting after max attempts
|
||||
if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
Log.w(TAG, "Max Nostr relay reconnection attempts reached")
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate backoff interval
|
||||
val backoffInterval = min(
|
||||
INITIAL_BACKOFF_INTERVAL * BACKOFF_MULTIPLIER.pow(relay.reconnectAttempts - 1.0),
|
||||
MAX_BACKOFF_INTERVAL.toDouble()
|
||||
).toLong()
|
||||
|
||||
relay.reconnectAttempts = RelayReconnectPolicy.nextAttempt(relay.reconnectAttempts)
|
||||
val backoffInterval = RelayReconnectPolicy.backoffMs(relay.reconnectAttempts)
|
||||
|
||||
relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval
|
||||
|
||||
Log.d(TAG, "Scheduling Nostr relay reconnection")
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Reconnect schedule for a Nostr relay socket.
|
||||
*
|
||||
* A phone loses its data connection constantly — airplane mode, a tunnel, a
|
||||
* Wi-Fi to cellular handover, a dead zone — and every relay fails at once when
|
||||
* it does. The schedule therefore has to survive an outage of arbitrary length
|
||||
* and heal on its own, because nothing else will: the relay layer registers no
|
||||
* connectivity callback, the periodic subscription validator only repairs
|
||||
* subscriptions on sockets that are already open, and `connect()` runs once at
|
||||
* startup.
|
||||
*
|
||||
* So the backoff grows exponentially and then *saturates* rather than
|
||||
* terminating. Retrying forever at the ceiling costs one connection attempt per
|
||||
* relay per [AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS]; giving up costs the
|
||||
* user every internet DM, delivery receipt and geohash channel until they
|
||||
* notice and restart the app.
|
||||
*/
|
||||
internal object RelayReconnectPolicy {
|
||||
|
||||
/**
|
||||
* Attempt count past which the interval stops growing. Beyond this the
|
||||
* exponential term is already above the ceiling, so pinning it keeps the
|
||||
* schedule at a steady [AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS] and
|
||||
* keeps the exponent from running away over a long outage.
|
||||
*/
|
||||
const val SATURATION_ATTEMPTS: Int = AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS
|
||||
|
||||
/** Attempt number to record after a failure at [previousAttempts]. */
|
||||
fun nextAttempt(previousAttempts: Int): Int =
|
||||
(previousAttempts.coerceAtLeast(0) + 1).coerceAtMost(SATURATION_ATTEMPTS)
|
||||
|
||||
/** Delay before the reconnect for a given attempt number (1-based). */
|
||||
fun backoffMs(attempt: Int): Long {
|
||||
val step = attempt.coerceAtLeast(1)
|
||||
val exponential = AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS *
|
||||
AppConstants.Nostr.BACKOFF_MULTIPLIER.pow(step - 1.0)
|
||||
return min(exponential, AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS.toDouble()).toLong()
|
||||
}
|
||||
}
|
||||
@ -598,4 +598,65 @@ class SecurityManagerTest {
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
// Duplicate-detection identity.
|
||||
|
||||
/**
|
||||
* Two distinct packets that agree on their first 64 bytes and share a
|
||||
* timestamp. The old key hashed only that prefix, with a 32-bit
|
||||
* `contentHashCode`, so these were "the same packet" and the second was
|
||||
* silently dropped.
|
||||
*/
|
||||
private fun prefixSharingPair(): Pair<BitchatPacket, BitchatPacket> {
|
||||
val shared = ByteArray(64) { 0x7 }
|
||||
val first = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = otherPeerID.hexToByteArrayForTest(),
|
||||
recipientID = myPeerID.hexToByteArrayForTest(),
|
||||
timestamp = 1_700_000_000_000uL,
|
||||
payload = shared + byteArrayOf(0x01, 0x02, 0x03),
|
||||
ttl = 7u
|
||||
)
|
||||
val second = first.copy(payload = shared + byteArrayOf(0x0A, 0x0B, 0x0C))
|
||||
return first to second
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packets differing only past the first 64 bytes are not treated as duplicates`() {
|
||||
val (first, second) = prefixSharingPair()
|
||||
|
||||
assertTrue(securityManager.validatePacket(first, otherPeerID))
|
||||
assertTrue(
|
||||
"A distinct packet must not be dropped as a duplicate",
|
||||
securityManager.validatePacket(second, otherPeerID)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a genuine replay of the same packet is still rejected`() {
|
||||
// The other half: strengthening the identity must not weaken replay
|
||||
// protection, which is the reason this cache exists.
|
||||
val (first, _) = prefixSharingPair()
|
||||
|
||||
assertTrue(securityManager.validatePacket(first, otherPeerID))
|
||||
assertFalse(
|
||||
"The identical packet must still be caught",
|
||||
securityManager.validatePacket(first, otherPeerID)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same packet from two different peers is tracked separately`() {
|
||||
// Peer scoping is deliberately kept: PacketIdUtil covers the packet's
|
||||
// own senderID, which is not the peer it was received from once a
|
||||
// packet has been relayed.
|
||||
val (first, _) = prefixSharingPair()
|
||||
|
||||
assertTrue(securityManager.validatePacket(first, otherPeerID))
|
||||
assertTrue(securityManager.validatePacket(first, unknownPeerID))
|
||||
}
|
||||
|
||||
private fun String.hexToByteArrayForTest(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class NostrEventDeduplicatorTest {
|
||||
@Test
|
||||
fun `isDuplicate flags a repeated event id`() {
|
||||
val deduplicator = NostrEventDeduplicator(maxCapacity = 10)
|
||||
|
||||
assertEquals(false, deduplicator.isDuplicate("event-1"))
|
||||
assertEquals(true, deduplicator.isDuplicate("event-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capacity evicts the least recently used event id`() {
|
||||
val deduplicator = NostrEventDeduplicator(maxCapacity = 2)
|
||||
|
||||
deduplicator.isDuplicate("a")
|
||||
deduplicator.isDuplicate("b")
|
||||
deduplicator.isDuplicate("c") // evicts "a"
|
||||
|
||||
assertTrue(deduplicator.contains("b"))
|
||||
assertTrue(deduplicator.contains("c"))
|
||||
assertEquals(false, deduplicator.contains("a"))
|
||||
}
|
||||
|
||||
/**
|
||||
* totalChecks used to be incremented outside the lruLock that guards every other
|
||||
* mutation in this class, so concurrent callers could race on the read-modify-write
|
||||
* and lose increments. Every other counter (duplicateCount, evictionCount) was
|
||||
* already incremented under the lock, which is why only this one drifted.
|
||||
*/
|
||||
@Test
|
||||
fun `totalChecks counts every call exactly once under concurrent access`() {
|
||||
val deduplicator = NostrEventDeduplicator(maxCapacity = 10_000)
|
||||
val threadCount = 8
|
||||
val checksPerThread = 2_000
|
||||
val executor = Executors.newFixedThreadPool(threadCount)
|
||||
val start = CountDownLatch(1)
|
||||
val done = CountDownLatch(threadCount)
|
||||
|
||||
repeat(threadCount) { threadIndex ->
|
||||
executor.submit {
|
||||
start.await()
|
||||
repeat(checksPerThread) { callIndex ->
|
||||
deduplicator.isDuplicate("thread-$threadIndex-event-$callIndex")
|
||||
}
|
||||
done.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
start.countDown()
|
||||
assertTrue(done.await(10, TimeUnit.SECONDS))
|
||||
executor.shutdown()
|
||||
|
||||
assertEquals(
|
||||
(threadCount * checksPerThread).toLong(),
|
||||
deduplicator.getStats().totalChecks
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -91,6 +91,52 @@ class NostrProtocolTest {
|
||||
assertEquals("hello", NostrProtocol.decryptPrivateMessage(ios, recipient)?.first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createPrivateMessage_reservesSlackInsideIosLookback() {
|
||||
val sender = NostrIdentity.generate()
|
||||
val recipient = NostrIdentity.generate()
|
||||
|
||||
assertEquals(
|
||||
IOS_DM_LOOKBACK_SECONDS - TIMESTAMP_SAFETY_SLACK_SECONDS,
|
||||
NostrCrypto.NIP17_DEFAULT_MAX_PAST_SECONDS
|
||||
)
|
||||
|
||||
repeat(20) {
|
||||
val beforeCreation = (System.currentTimeMillis() / 1000).toInt()
|
||||
val giftWrap = NostrProtocol.createPrivateMessage(
|
||||
content = "bitchat1:test",
|
||||
recipientPubkey = recipient.publicKeyHex,
|
||||
senderIdentity = sender
|
||||
).single()
|
||||
val afterCreation = (System.currentTimeMillis() / 1000).toInt()
|
||||
val sealJson = NostrCrypto.decryptNIP44(
|
||||
ciphertext = giftWrap.content,
|
||||
senderPublicKeyHex = giftWrap.pubkey,
|
||||
recipientPrivateKeyHex = recipient.privateKeyHex
|
||||
)
|
||||
val seal = gson.fromJson(sealJson, NostrEvent::class.java)
|
||||
|
||||
assertTimestampWithinIosLookback("gift wrap", giftWrap.createdAt, beforeCreation, afterCreation)
|
||||
assertTimestampWithinIosLookback("seal", seal.createdAt, beforeCreation, afterCreation)
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertTimestampWithinIosLookback(
|
||||
envelope: String,
|
||||
createdAt: Int,
|
||||
beforeCreation: Int,
|
||||
afterCreation: Int
|
||||
) {
|
||||
assertTrue(
|
||||
"$envelope timestamp must leave 2 hours inside the iOS lookback",
|
||||
createdAt >= beforeCreation - MAX_OUTBOUND_BACKDATE_SECONDS
|
||||
)
|
||||
assertTrue(
|
||||
"$envelope timestamp must not be in the future",
|
||||
createdAt <= afterCreation
|
||||
)
|
||||
}
|
||||
|
||||
private fun forgedGiftWrap(
|
||||
content: String,
|
||||
claimedSender: NostrIdentity,
|
||||
@ -137,4 +183,11 @@ class NostrProtocolTest {
|
||||
content = giftWrapContent
|
||||
).sign(wrapPrivateKey)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val IOS_DM_LOOKBACK_SECONDS = 86_400
|
||||
const val TIMESTAMP_SAFETY_SLACK_SECONDS = 7_200
|
||||
const val MAX_OUTBOUND_BACKDATE_SECONDS =
|
||||
IOS_DM_LOOKBACK_SECONDS - TIMESTAMP_SAFETY_SLACK_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The relay layer has no connectivity callback, its periodic validator only
|
||||
* repairs subscriptions on sockets that are already open, and `connect()` runs
|
||||
* once at startup. The backoff schedule is therefore the only thing that can
|
||||
* bring relays back after the phone loses its data connection, so it has to
|
||||
* saturate rather than terminate.
|
||||
*/
|
||||
class RelayReconnectPolicyTest {
|
||||
|
||||
private val initial = AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS
|
||||
private val ceiling = AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS
|
||||
|
||||
@Test
|
||||
fun `the first retry waits the initial interval`() {
|
||||
assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the interval doubles per attempt until it reaches the ceiling`() {
|
||||
var attempt = 0
|
||||
var previous = 0L
|
||||
var sawCeiling = false
|
||||
|
||||
repeat(RelayReconnectPolicy.SATURATION_ATTEMPTS) {
|
||||
attempt = RelayReconnectPolicy.nextAttempt(attempt)
|
||||
val delay = RelayReconnectPolicy.backoffMs(attempt)
|
||||
|
||||
assertTrue("delay must never exceed the ceiling", delay <= ceiling)
|
||||
if (delay == ceiling) {
|
||||
sawCeiling = true
|
||||
} else {
|
||||
assertEquals("expected doubling below the ceiling", maxOf(initial, previous * 2), delay)
|
||||
}
|
||||
previous = delay
|
||||
}
|
||||
|
||||
assertTrue("the schedule must actually reach the ceiling", sawCeiling)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an outage longer than the schedule keeps retrying at the ceiling`() {
|
||||
var attempt = 0
|
||||
// Far past the old give-up point; a real outage can last hours.
|
||||
repeat(500) { attempt = RelayReconnectPolicy.nextAttempt(attempt) }
|
||||
|
||||
assertEquals(RelayReconnectPolicy.SATURATION_ATTEMPTS, attempt)
|
||||
assertEquals(ceiling, RelayReconnectPolicy.backoffMs(attempt))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long outage cannot run the attempt counter or the exponent away`() {
|
||||
var attempt = 0
|
||||
repeat(10_000) { attempt = RelayReconnectPolicy.nextAttempt(attempt) }
|
||||
|
||||
val delay = RelayReconnectPolicy.backoffMs(attempt)
|
||||
assertTrue("delay must stay finite and bounded", delay in 1..ceiling)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a successful connection resets the schedule to the initial interval`() {
|
||||
var attempt = 0
|
||||
repeat(6) { attempt = RelayReconnectPolicy.nextAttempt(attempt) }
|
||||
assertTrue(RelayReconnectPolicy.backoffMs(attempt) > initial)
|
||||
|
||||
// updateRelayStatus zeroes reconnectAttempts on a successful open.
|
||||
attempt = 0
|
||||
|
||||
assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(attempt)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a nonsensical stored attempt count still yields a usable delay`() {
|
||||
assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(-5)))
|
||||
assertTrue(RelayReconnectPolicy.backoffMs(0) in 1..ceiling)
|
||||
assertTrue(RelayReconnectPolicy.backoffMs(Int.MAX_VALUE) in 1..ceiling)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the whole schedule stays under an hour of total wait before the ceiling`() {
|
||||
var attempt = 0
|
||||
var total = 0L
|
||||
repeat(RelayReconnectPolicy.SATURATION_ATTEMPTS) {
|
||||
attempt = RelayReconnectPolicy.nextAttempt(attempt)
|
||||
total += RelayReconnectPolicy.backoffMs(attempt)
|
||||
}
|
||||
|
||||
assertTrue("reaching the steady state must not take an hour", total < 60 * 60 * 1000L)
|
||||
}
|
||||
}
|
||||
@ -18,7 +18,7 @@ The remaining implementation work and milestone progress are tracked in
|
||||
| Store and forward | Courier type `0x04`, rotating HMAC recipient tags, Noise X seals, copy-budget and prekey-ID TLVs, 24-hour expiry, bounded tiered custody | `CourierEnvelopeTest`, `NoiseCourierTest`, `MessageRouterTest` |
|
||||
| Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` |
|
||||
| Sync/routing | Stable packet IDs, GCS bitstream, type-scoped filters, bounded-history cursors, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewriteWireContractTest`, `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `GossipSyncManagerTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` |
|
||||
| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals | `ClientRewriteNostrContractTest`, `NostrProtocolTest` |
|
||||
| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals, 22h outbound envelope randomization | `ClientRewriteNostrContractTest`, `NostrProtocolTest` |
|
||||
| Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` |
|
||||
|
||||
## Golden-vector policy
|
||||
@ -32,6 +32,11 @@ Round-trip tests remain useful but are not sufficient on their own: an encoder
|
||||
and decoder can share the same defect. Each critical wire format therefore has
|
||||
at least one literal vector.
|
||||
|
||||
NIP-17 receivers should reserve safety slack beyond the maximum timestamp
|
||||
randomization used by senders. Android caps outbound seal and gift-wrap
|
||||
randomization at 22h, leaving 2 hours of slack inside iOS's 24-hour
|
||||
subscription window, while retaining its 48-hour receive lookback.
|
||||
|
||||
## Rewrite acceptance gate
|
||||
|
||||
From a configured Android development environment, run:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user