From 76bf226f9886f9480fde32a055d802e27240769c Mon Sep 17 00:00:00 2001 From: wollow Date: Tue, 11 Aug 2026 14:52:16 +0300 Subject: [PATCH 1/3] fix(nostr): cap DM envelope backdating at 24 hours --- .../com/bitchat/android/nostr/NostrCrypto.kt | 4 +- .../bitchat/android/nostr/NostrProtocol.kt | 2 +- .../android/nostr/NostrProtocolTest.kt | 46 +++++++++++++++++++ docs/client-rewrite-contracts.md | 7 ++- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt b/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt index b25499dd..972b90fa 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt @@ -317,9 +317,9 @@ object NostrCrypto { } /** - * Random timestamp up to maxPastSeconds in the past (default 2 days) + * Random timestamp up to maxPastSeconds in the past (default 24 hours for iOS compatibility) */ - fun randomizeTimestampUpToPast(maxPastSeconds: Int = 172800): Int { + fun randomizeTimestampUpToPast(maxPastSeconds: Int = 86400): Int { val now = (System.currentTimeMillis() / 1000).toInt() val offset = if (maxPastSeconds > 0) secureRandom.nextInt(maxPastSeconds + 1) else 0 return now - offset diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 4376d1e9..926ea493 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -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 (kind 13) signed by sender, timestamp randomized within iOS's 24h lookback val sealedEvent = createSeal( rumor = rumor, recipientPubkey = recipientPubkey, diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt index a5bd9561..3ef73733 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt @@ -3,6 +3,7 @@ package com.bitchat.android.nostr import com.google.gson.Gson import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class NostrProtocolTest { @@ -41,6 +42,47 @@ class NostrProtocolTest { assertNull(decrypted) } + @Test + fun createPrivateMessage_limitsEnvelopeTimestampsToIosLookback() { + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + + 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 be no more than 24 hours old", + createdAt >= beforeCreation - IOS_DM_LOOKBACK_SECONDS + ) + assertTrue( + "$envelope timestamp must not be in the future", + createdAt <= afterCreation + ) + } + private fun forgedGiftWrap( content: String, claimedSender: NostrIdentity, @@ -82,4 +124,8 @@ class NostrProtocolTest { content = giftWrapContent ).sign(wrapPrivateKey) } + + private companion object { + const val IOS_DM_LOOKBACK_SECONDS = 86400 + } } diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index 1abed243..1da0e33a 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -17,7 +17,7 @@ The remaining implementation work and milestone progress are tracked in | Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` | | 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, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `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, 24-hour 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 @@ -31,6 +31,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 must use a lookback at least as long as the maximum timestamp +randomization used by senders. Android caps outbound seal and gift-wrap +randomization at 24 hours for iOS interoperability while retaining its 48-hour +receive lookback. + ## Rewrite acceptance gate From a configured Android development environment, run: From bc03d789720707fd106cb0b4127dd8c2229e67d9 Mon Sep 17 00:00:00 2001 From: wollow Date: Tue, 11 Aug 2026 17:57:03 +0300 Subject: [PATCH 2/3] changed to 23-hour-45-minute maximum backdating window --- .../com/bitchat/android/nostr/NostrCrypto.kt | 8 +++++--- .../com/bitchat/android/nostr/NostrProtocol.kt | 2 +- .../bitchat/android/nostr/NostrProtocolTest.kt | 16 ++++++++++++---- docs/client-rewrite-contracts.md | 6 +++--- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt b/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt index 972b90fa..cab41ad0 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt @@ -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 = 85_500 + private val secureRandom = SecureRandom() // NIP-44 v2 only @@ -317,9 +319,9 @@ object NostrCrypto { } /** - * Random timestamp up to maxPastSeconds in the past (default 24 hours for iOS compatibility) + * Random timestamp in the past, defaulting to 23h45m to leave slack inside iOS's 24h lookback. */ - fun randomizeTimestampUpToPast(maxPastSeconds: Int = 86400): 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 diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 926ea493..42caefec 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -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 within iOS's 24h lookback + // 2. Seal the rumor with 15 minutes of slack inside iOS's 24-hour lookback. val sealedEvent = createSeal( rumor = rumor, recipientPubkey = recipientPubkey, diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt index 3ef73733..29a41f6e 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt @@ -43,10 +43,15 @@ class NostrProtocolTest { } @Test - fun createPrivateMessage_limitsEnvelopeTimestampsToIosLookback() { + 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( @@ -74,8 +79,8 @@ class NostrProtocolTest { afterCreation: Int ) { assertTrue( - "$envelope timestamp must be no more than 24 hours old", - createdAt >= beforeCreation - IOS_DM_LOOKBACK_SECONDS + "$envelope timestamp must leave 15 minutes inside the iOS lookback", + createdAt >= beforeCreation - MAX_OUTBOUND_BACKDATE_SECONDS ) assertTrue( "$envelope timestamp must not be in the future", @@ -126,6 +131,9 @@ class NostrProtocolTest { } private companion object { - const val IOS_DM_LOOKBACK_SECONDS = 86400 + const val IOS_DM_LOOKBACK_SECONDS = 86_400 + const val TIMESTAMP_SAFETY_SLACK_SECONDS = 900 + const val MAX_OUTBOUND_BACKDATE_SECONDS = + IOS_DM_LOOKBACK_SECONDS - TIMESTAMP_SAFETY_SLACK_SECONDS } } diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index 1da0e33a..ed92ab79 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -17,7 +17,7 @@ The remaining implementation work and milestone progress are tracked in | Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` | | 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, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` | -| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals, 24-hour outbound envelope randomization | `ClientRewriteNostrContractTest`, `NostrProtocolTest` | +| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals, 23h45m 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 @@ -33,8 +33,8 @@ at least one literal vector. NIP-17 receivers must use a lookback at least as long as the maximum timestamp randomization used by senders. Android caps outbound seal and gift-wrap -randomization at 24 hours for iOS interoperability while retaining its 48-hour -receive lookback. +randomization at 23h45m, leaving 15 minutes of slack inside iOS's 24-hour +subscription window, while retaining its 48-hour receive lookback. ## Rewrite acceptance gate From acfbacf78fefb6664a523111f2c8c73a2e85b67f Mon Sep 17 00:00:00 2001 From: wollow Date: Tue, 11 Aug 2026 22:37:44 +0300 Subject: [PATCH 3/3] fix(nostr): reserve two-hour DM timestamp margin --- app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt | 4 ++-- .../main/java/com/bitchat/android/nostr/NostrProtocol.kt | 2 +- .../kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt | 4 ++-- docs/client-rewrite-contracts.md | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt b/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt index cab41ad0..1bbdc972 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt @@ -22,7 +22,7 @@ import java.math.BigInteger */ object NostrCrypto { - internal const val NIP17_DEFAULT_MAX_PAST_SECONDS = 85_500 + internal const val NIP17_DEFAULT_MAX_PAST_SECONDS = 79_200 private val secureRandom = SecureRandom() // NIP-44 v2 only @@ -319,7 +319,7 @@ object NostrCrypto { } /** - * Random timestamp in the past, defaulting to 23h45m to leave slack inside iOS's 24h lookback. + * 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 = NIP17_DEFAULT_MAX_PAST_SECONDS): Int { val now = (System.currentTimeMillis() / 1000).toInt() diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 42caefec..7463a378 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -37,7 +37,7 @@ object NostrProtocol { val rumorId = rumorBase.computeEventIdHex() val rumor = rumorBase.copy(id = rumorId) - // 2. Seal the rumor with 15 minutes of slack inside iOS's 24-hour lookback. + // 2. Seal the rumor with 2 hours of slack inside iOS's 24-hour lookback. val sealedEvent = createSeal( rumor = rumor, recipientPubkey = recipientPubkey, diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt index 29a41f6e..5faae233 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt @@ -79,7 +79,7 @@ class NostrProtocolTest { afterCreation: Int ) { assertTrue( - "$envelope timestamp must leave 15 minutes inside the iOS lookback", + "$envelope timestamp must leave 2 hours inside the iOS lookback", createdAt >= beforeCreation - MAX_OUTBOUND_BACKDATE_SECONDS ) assertTrue( @@ -132,7 +132,7 @@ class NostrProtocolTest { private companion object { const val IOS_DM_LOOKBACK_SECONDS = 86_400 - const val TIMESTAMP_SAFETY_SLACK_SECONDS = 900 + const val TIMESTAMP_SAFETY_SLACK_SECONDS = 7_200 const val MAX_OUTBOUND_BACKDATE_SECONDS = IOS_DM_LOOKBACK_SECONDS - TIMESTAMP_SAFETY_SLACK_SECONDS } diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index ed92ab79..05d5e1ad 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -17,7 +17,7 @@ The remaining implementation work and milestone progress are tracked in | Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` | | 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, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` | -| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals, 23h45m outbound envelope randomization | `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 @@ -31,9 +31,9 @@ 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 must use a lookback at least as long as the maximum timestamp +NIP-17 receivers should reserve safety slack beyond the maximum timestamp randomization used by senders. Android caps outbound seal and gift-wrap -randomization at 23h45m, leaving 15 minutes of slack inside iOS's 24-hour +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