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..1bbdc972 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 = 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 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..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 (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, 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..5faae233 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,52 @@ class NostrProtocolTest { assertNull(decrypted) } + @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, @@ -82,4 +129,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 + } } diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index 1abed243..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 | `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,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 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: