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