mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 11:05:19 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41486fd1c9 | ||
|
|
d8527b98fd | ||
|
|
5be8b5b37f | ||
|
|
7200b3de2a | ||
|
|
3f6dc7dd36 |
@@ -6,7 +6,6 @@
|
|||||||
plans/
|
plans/
|
||||||
|
|
||||||
## AI
|
## AI
|
||||||
CLAUDE.md
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# SwiftLint configuration for BitChat.
|
||||||
|
#
|
||||||
|
# Intentionally pragmatic: rules that would flood the existing codebase are
|
||||||
|
# disabled so the lint signal stays actionable; re-enable them individually as
|
||||||
|
# files get cleaned up. Not wired into CI yet — run locally with `swiftlint`
|
||||||
|
# from the repo root.
|
||||||
|
|
||||||
|
included:
|
||||||
|
- bitchat
|
||||||
|
- bitchatTests
|
||||||
|
- localPackages/BitFoundation/Sources
|
||||||
|
- localPackages/BitFoundation/Tests
|
||||||
|
- localPackages/BitLogger/Sources
|
||||||
|
|
||||||
|
excluded:
|
||||||
|
- .build
|
||||||
|
- localPackages/Arti
|
||||||
|
- localPackages/BitFoundation/.build
|
||||||
|
- localPackages/BitLogger/.build
|
||||||
|
|
||||||
|
disabled_rules:
|
||||||
|
# Style/volume rules that currently produce noise across the codebase.
|
||||||
|
- line_length
|
||||||
|
- identifier_name
|
||||||
|
- type_name
|
||||||
|
- type_body_length
|
||||||
|
- cyclomatic_complexity
|
||||||
|
- function_parameter_count
|
||||||
|
- large_tuple
|
||||||
|
- nesting
|
||||||
|
- todo
|
||||||
|
- trailing_comma
|
||||||
|
- opening_brace
|
||||||
|
- statement_position
|
||||||
|
- for_where
|
||||||
|
- redundant_string_enum_value
|
||||||
|
- non_optional_string_data_conversion
|
||||||
|
# Common in tests (force casts/tries on fixtures).
|
||||||
|
- force_cast
|
||||||
|
- force_try
|
||||||
|
|
||||||
|
file_length:
|
||||||
|
warning: 600
|
||||||
|
# BLEService.swift is currently ~3,500 lines; the error threshold sits above
|
||||||
|
# today's maximum so the codebase passes as-is and only future growth of the
|
||||||
|
# largest files trips it.
|
||||||
|
error: 4000
|
||||||
|
|
||||||
|
function_body_length:
|
||||||
|
warning: 100
|
||||||
|
error: 200
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build macOS (no signing)
|
||||||
|
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build
|
||||||
|
|
||||||
|
# Build iOS for simulator
|
||||||
|
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' build
|
||||||
|
|
||||||
|
# Run all tests (iOS simulator)
|
||||||
|
xcodebuild -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' test
|
||||||
|
|
||||||
|
# Run tests via Swift Package Manager (used in CI)
|
||||||
|
swift build && swift test --parallel
|
||||||
|
|
||||||
|
# Clean build
|
||||||
|
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" clean
|
||||||
|
|
||||||
|
# Quick dev build and run (macOS only, requires `just`)
|
||||||
|
just run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Specific Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run a single test class
|
||||||
|
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/IntegrationTests
|
||||||
|
|
||||||
|
# Run a single test method
|
||||||
|
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/NoiseProtocolTests/testHandshake
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
BitChat is a **dual-transport P2P messaging app**: Bluetooth mesh for offline local communication, Nostr protocol for internet-based global messaging.
|
||||||
|
|
||||||
|
### Local Packages (`localPackages/`)
|
||||||
|
|
||||||
|
- **BitFoundation**: Shared foundation types — `BinaryProtocol` (compact binary packet format for BLE), `BitchatPacket`, `BitchatMessage`, `PeerID`, hex/SHA256/compression utilities. Has its own test suite under `localPackages/BitFoundation/Tests` (run `swift test` inside the package).
|
||||||
|
- **BitLogger**: `SecureLogger` logging.
|
||||||
|
- **Arti**: Tor integration (exposes the `Tor` product).
|
||||||
|
|
||||||
|
### Transport Layer
|
||||||
|
|
||||||
|
- **BLEService** (`bitchat/Services/BLE/BLEService.swift`): Core Bluetooth LE mesh networking - peer discovery, connection management, multi-hop relay (max 7 hops), packet fragmentation. The BLE directory contains ~40 focused collaborators (handlers, policies, buffers): `BLEReceivePipeline` dispatches inbound packets to `BLEAnnounceHandler` / `BLENoisePacketHandler` / `BLEPublicMessageHandler` / `BLEFragmentHandler` etc.; outbound planning lives in the `BLEOutbound*` types; peer state in `BLEPeerRegistry`.
|
||||||
|
- **NostrTransport** (`bitchat/Services/NostrTransport.swift`): Internet transport via Nostr relays with NIP-17 encryption
|
||||||
|
- **Transport protocol** (`bitchat/Services/Transport.swift`): Common interface both transports implement
|
||||||
|
|
||||||
|
### Encryption Layer
|
||||||
|
|
||||||
|
- **NoiseProtocol** (`bitchat/Noise/`): Noise XX pattern for end-to-end encryption with forward secrecy
|
||||||
|
- `NoiseEncryptionService`: Main encryption/decryption API
|
||||||
|
- `NoiseSessionManager`: Thread-safe per-peer session management
|
||||||
|
- `NoiseSession`: Individual peer session state (handshake, send/receive ciphers)
|
||||||
|
- **NostrProtocol** (`bitchat/Nostr/NostrProtocol.swift`): NIP-17 gift-wrapped encryption for Nostr messages
|
||||||
|
|
||||||
|
### Protocol Layer
|
||||||
|
|
||||||
|
- **BinaryProtocol** (`localPackages/BitFoundation/Sources/BitFoundation/BinaryProtocol.swift`): Compact binary packet format for BLE (lives in BitFoundation, not `bitchat/Protocols/`)
|
||||||
|
- **BitchatProtocol** (`bitchat/Protocols/BitchatProtocol.swift`): Message types and packet structures
|
||||||
|
- **LocationChannel/Geohash** (`bitchat/Protocols/`): Geographic channel routing
|
||||||
|
|
||||||
|
### Application Layer
|
||||||
|
|
||||||
|
- **AppRuntime** (`bitchat/App/AppRuntime.swift`): Composition root. Owns `ChatViewModel`, `ConversationStore`, the feature models (`PublicChatModel`, `PeerListModel`, `LocationPresenceStore`, `PeerIdentityStore`, ...), and the app event stream.
|
||||||
|
- **ConversationStore** (`bitchat/App/ConversationStore.swift`): Single-writer, single source of truth for conversation message state and selection (see `docs/CONVERSATION-STORE-DESIGN.md`). Feature models and `ChatViewModel` observe it and mutate it through its intent API.
|
||||||
|
- **ChatViewModel** (`bitchat/ViewModels/ChatViewModel.swift`, ~1,600 lines): Central coordinator that wires and delegates to ~25 focused coordinator/pipeline types in `bitchat/ViewModels/`, e.g.:
|
||||||
|
- `ChatPrivateConversationCoordinator` / `ChatPublicConversationCoordinator`: DM and public chat flows
|
||||||
|
- `ChatNostrCoordinator` → `GeohashSubscriptionManager`, `NostrInboundPipeline`, `GeoPresenceTracker`, `GeoChannelCoordinator`: Nostr/geohash channel logic
|
||||||
|
- `ChatOutgoingCoordinator`, `ChatDeliveryCoordinator`, `PublicMessagePipeline`: send paths and delivery tracking
|
||||||
|
- `ChatMediaTransferCoordinator`, `ChatMediaPreparation`: media transfers
|
||||||
|
- `ChatLifecycleCoordinator`, `ChatTransportEventCoordinator`, `ChatPeerListCoordinator`, `ChatPeerIdentityCoordinator`, `ChatVerificationCoordinator`: lifecycle, transport events, peer state
|
||||||
|
- `bitchat/ViewModels/Extensions/` (`ChatViewModel+Nostr/+PrivateChat/+Tor`): thin delegation shims kept for call-site stability; the real logic lives in the coordinators
|
||||||
|
- **MessageRouter** (`bitchat/Services/MessageRouter.swift`): Intelligent transport selection (BLE → Nostr fallback)
|
||||||
|
- **PrivateChatManager** (`bitchat/Services/PrivateChatManager.swift`): DM session management
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
Tests use an **in-memory networking harness** for deterministic, race-free testing:
|
||||||
|
|
||||||
|
- **MockBLEService** (`bitchatTests/Mocks/MockBLEService.swift`): Simulated BLE mesh with configurable topology
|
||||||
|
- `MockBLEService.resetTestBus()` - Clear state in setUp()
|
||||||
|
- `simulateConnectedPeer(_:)` / `simulateDisconnectedPeer(_:)` - Configure topology
|
||||||
|
- `autoFloodEnabled` - Enable broadcast flooding for Integration tests only
|
||||||
|
|
||||||
|
- **Test categories**:
|
||||||
|
- `bitchatTests/EndToEnd/`: Full message flow tests with explicit routing
|
||||||
|
- `bitchatTests/Integration/`: Multi-node topology tests with auto-flooding
|
||||||
|
- Unit tests: Individual component tests
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
- **Threading**: Use `@MainActor` for UI, `Task { @MainActor in ... }` for main thread dispatch
|
||||||
|
- **Delegation**: `BitchatDelegate`, `TransportPeerEventsDelegate` for event propagation
|
||||||
|
- **Session recovery**: On decrypt failure, clear local session and re-initiate Noise handshake (no NACK)
|
||||||
|
|
||||||
|
## Device Setup
|
||||||
|
|
||||||
|
To run on physical devices:
|
||||||
|
1. Copy `Configs/Local.xcconfig.example` to `Configs/Local.xcconfig`
|
||||||
|
2. Add your Developer Team ID to `Local.xcconfig`
|
||||||
|
3. Replace `group.chat.bitchat` with `group.<your_bundle_id>` in entitlements
|
||||||
@@ -328,52 +328,85 @@ struct NostrProtocol {
|
|||||||
return try NostrEvent(from: rumorDict)
|
return try NostrEvent(from: rumorDict)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption (NIP-44 v2)
|
// MARK: - Encryption (NIP-44 v2/v3)
|
||||||
|
|
||||||
private static func encrypt(
|
/// Whether outgoing DMs use the padded "v3:" envelope.
|
||||||
|
///
|
||||||
|
/// TWO-PHASE ROLLOUT — DO NOT FLIP YET. Deployed clients hard-reject any
|
||||||
|
/// ciphertext that does not start with "v2:" (`decrypt` below, as shipped,
|
||||||
|
/// throws `invalidCiphertext` on unknown version prefixes), and the "v2:"
|
||||||
|
/// payload is the raw UTF-8 rumor JSON, so a padded payload cannot be
|
||||||
|
/// smuggled inside "v2:" without breaking old receivers either. Enabling
|
||||||
|
/// this today would strand every client in the field.
|
||||||
|
///
|
||||||
|
/// Phase 1 (this change): ship decrypt-side support for "v3:" everywhere.
|
||||||
|
/// Phase 2 (future release, once phase-1 clients are widely deployed):
|
||||||
|
/// set this to true so outgoing DMs stop leaking plaintext length to
|
||||||
|
/// relays.
|
||||||
|
static let sendPaddedEnvelope = false
|
||||||
|
|
||||||
|
/// Internal (rather than private) so tests can exercise both envelope
|
||||||
|
/// versions directly.
|
||||||
|
static func encrypt(
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderKey: P256K.Schnorr.PrivateKey
|
senderKey: P256K.Schnorr.PrivateKey,
|
||||||
|
padded: Bool = NostrProtocol.sendPaddedEnvelope
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
|
|
||||||
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
||||||
throw NostrError.invalidPublicKey
|
throw NostrError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
// Encrypting message (XChaCha20-Poly1305, versioned envelope)
|
||||||
|
|
||||||
// Derive shared secret
|
// Derive shared secret
|
||||||
let sharedSecret = try deriveSharedSecret(
|
let sharedSecret = try deriveSharedSecret(
|
||||||
privateKey: senderKey,
|
privateKey: senderKey,
|
||||||
publicKey: recipientPubkeyData
|
publicKey: recipientPubkeyData
|
||||||
)
|
)
|
||||||
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info).
|
||||||
|
// The v3 envelope deliberately reuses the same key derivation; it only
|
||||||
|
// changes the payload framing (length prefix + padding).
|
||||||
let key = try deriveNIP44V2Key(from: sharedSecret)
|
let key = try deriveNIP44V2Key(from: sharedSecret)
|
||||||
|
|
||||||
// 24-byte random nonce for XChaCha20-Poly1305
|
// 24-byte random nonce for XChaCha20-Poly1305
|
||||||
var nonce24 = Data(count: 24)
|
var nonce24 = Data(count: 24)
|
||||||
_ = nonce24.withUnsafeMutableBytes { ptr in
|
_ = nonce24.withUnsafeMutableBytes { ptr in
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2 payload: raw UTF-8 plaintext (length leaks to relays)
|
||||||
|
// v3 payload: NIP-44 style [2-byte BE length][plaintext][zero padding]
|
||||||
let pt = Data(plaintext.utf8)
|
let pt = Data(plaintext.utf8)
|
||||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
let payload = padded ? try NIP44Padding.pad(pt) : pt
|
||||||
|
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: payload, key: key, nonce24: nonce24)
|
||||||
// v2: base64url(nonce24 || ciphertext || tag)
|
|
||||||
|
// version prefix + base64url(nonce24 || ciphertext || tag)
|
||||||
var combined = Data()
|
var combined = Data()
|
||||||
combined.append(nonce24)
|
combined.append(nonce24)
|
||||||
combined.append(sealed.ciphertext)
|
combined.append(sealed.ciphertext)
|
||||||
combined.append(sealed.tag)
|
combined.append(sealed.tag)
|
||||||
return "v2:" + Base64URLCoding.encode(combined)
|
return (padded ? "v3:" : "v2:") + Base64URLCoding.encode(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decrypt(
|
/// Internal (rather than private) so tests can exercise both envelope
|
||||||
|
/// versions directly.
|
||||||
|
static func decrypt(
|
||||||
ciphertext: String,
|
ciphertext: String,
|
||||||
senderPubkey: String,
|
senderPubkey: String,
|
||||||
recipientKey: P256K.Schnorr.PrivateKey
|
recipientKey: P256K.Schnorr.PrivateKey
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
// Expect NIP-44 v2 format
|
// Accept both the legacy unpadded "v2:" envelope and the padded "v3:"
|
||||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
// envelope (see `sendPaddedEnvelope` for the rollout plan).
|
||||||
|
let isPadded: Bool
|
||||||
|
if ciphertext.hasPrefix("v2:") {
|
||||||
|
isPadded = false
|
||||||
|
} else if ciphertext.hasPrefix("v3:") {
|
||||||
|
isPadded = true
|
||||||
|
} else {
|
||||||
|
throw NostrError.invalidCiphertext
|
||||||
|
}
|
||||||
let encoded = String(ciphertext.dropFirst(3))
|
let encoded = String(ciphertext.dropFirst(3))
|
||||||
guard let data = Base64URLCoding.decode(encoded),
|
guard let data = Base64URLCoding.decode(encoded),
|
||||||
data.count > (24 + 16),
|
data.count > (24 + 16),
|
||||||
@@ -399,18 +432,23 @@ struct NostrProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If 32 bytes (x-only) try both parities, otherwise single try
|
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||||
|
let payload: Data
|
||||||
if senderPubkeyData.count == 32 {
|
if senderPubkeyData.count == 32 {
|
||||||
let even = Data([0x02]) + senderPubkeyData
|
let even = Data([0x02]) + senderPubkeyData
|
||||||
if let pt = try? attemptDecrypt(using: even) {
|
if let pt = try? attemptDecrypt(using: even) {
|
||||||
return String(data: pt, encoding: .utf8) ?? ""
|
payload = pt
|
||||||
|
} else {
|
||||||
|
let odd = Data([0x03]) + senderPubkeyData
|
||||||
|
payload = try attemptDecrypt(using: odd)
|
||||||
}
|
}
|
||||||
let odd = Data([0x03]) + senderPubkeyData
|
|
||||||
let pt = try attemptDecrypt(using: odd)
|
|
||||||
return String(data: pt, encoding: .utf8) ?? ""
|
|
||||||
} else {
|
} else {
|
||||||
let pt = try attemptDecrypt(using: senderPubkeyData)
|
payload = try attemptDecrypt(using: senderPubkeyData)
|
||||||
return String(data: pt, encoding: .utf8) ?? ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The AEAD tag has already authenticated the payload; unpadding
|
||||||
|
// failures here mean a malformed sender, not a wrong key.
|
||||||
|
let plaintextData = isPadded ? try NIP44Padding.unpad(payload) : payload
|
||||||
|
return String(data: plaintextData, encoding: .utf8) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func deriveSharedSecret(
|
private static func deriveSharedSecret(
|
||||||
@@ -641,6 +679,60 @@ enum NostrError: Error {
|
|||||||
case encryptionFailed
|
case encryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - NIP-44 style padding (v3 envelope payload framing)
|
||||||
|
|
||||||
|
/// Payload framing for the padded "v3:" envelope, modeled on NIP-44 v2:
|
||||||
|
/// `[2-byte big-endian plaintext length][plaintext][zero padding]`, where the
|
||||||
|
/// total is padded to `paddedLength(for:)` — power-of-two-derived buckets with
|
||||||
|
/// a 32-byte minimum — so ciphertext length no longer reveals exact plaintext
|
||||||
|
/// length to relays.
|
||||||
|
enum NIP44Padding {
|
||||||
|
static let minPaddedLength = 32
|
||||||
|
static let maxPlaintextLength = 65535
|
||||||
|
|
||||||
|
/// NIP-44's calc_padded_len: pad to 32 bytes minimum, then to a chunk
|
||||||
|
/// granularity of max(32, nextPowerOfTwo/8).
|
||||||
|
static func paddedLength(for unpaddedLength: Int) -> Int {
|
||||||
|
guard unpaddedLength > minPaddedLength else { return minPaddedLength }
|
||||||
|
// Smallest power of two strictly greater than (unpaddedLength - 1).
|
||||||
|
let nextPower = 1 << (Int.bitWidth - (unpaddedLength - 1).leadingZeroBitCount)
|
||||||
|
let chunk = nextPower <= 256 ? 32 : nextPower / 8
|
||||||
|
return chunk * ((unpaddedLength - 1) / chunk + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefix plaintext with its 2-byte big-endian length and zero-pad to the
|
||||||
|
/// bucketed length. Rejects empty plaintexts and plaintexts that do not
|
||||||
|
/// fit the 16-bit length prefix.
|
||||||
|
static func pad(_ plaintext: Data) throws -> Data {
|
||||||
|
let length = plaintext.count
|
||||||
|
guard length >= 1, length <= maxPlaintextLength else {
|
||||||
|
throw NostrError.encryptionFailed
|
||||||
|
}
|
||||||
|
let padded = paddedLength(for: length)
|
||||||
|
var result = Data(capacity: 2 + padded)
|
||||||
|
result.append(UInt8(length >> 8))
|
||||||
|
result.append(UInt8(length & 0xFF))
|
||||||
|
result.append(plaintext)
|
||||||
|
result.append(Data(count: padded - length))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the 2-byte length prefix, validate the total padded size matches
|
||||||
|
/// it exactly, and return the plaintext. Throws on any inconsistency so a
|
||||||
|
/// malformed (already-authenticated) payload can never over- or
|
||||||
|
/// under-read.
|
||||||
|
static func unpad(_ padded: Data) throws -> Data {
|
||||||
|
guard padded.count >= 2 else { throw NostrError.invalidCiphertext }
|
||||||
|
let start = padded.startIndex
|
||||||
|
let length = Int(padded[start]) << 8 | Int(padded[start + 1])
|
||||||
|
guard length >= 1,
|
||||||
|
padded.count == 2 + paddedLength(for: length) else {
|
||||||
|
throw NostrError.invalidCiphertext
|
||||||
|
}
|
||||||
|
return padded.subdata(in: (start + 2)..<(start + 2 + length))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||||
|
|
||||||
private extension NostrProtocol {
|
private extension NostrProtocol {
|
||||||
|
|||||||
@@ -289,6 +289,159 @@ struct NostrProtocolTests {
|
|||||||
#expect(object["limit"] as? Int == 42)
|
#expect(object["limit"] as? Int == 42)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Padding (v3 envelope)
|
||||||
|
|
||||||
|
@Test func paddedLengthMatchesNIP44Buckets() {
|
||||||
|
// Vectors from the NIP-44 reference test suite (calc_padded_len).
|
||||||
|
let vectors: [(Int, Int)] = [
|
||||||
|
(1, 32), (16, 32), (32, 32), (33, 64), (37, 64), (45, 64), (49, 64),
|
||||||
|
(64, 64), (65, 96), (100, 128), (111, 128), (200, 224), (250, 256),
|
||||||
|
(320, 320), (383, 384), (384, 384), (400, 448), (500, 512),
|
||||||
|
(512, 512), (515, 640), (700, 768), (800, 896), (900, 1024),
|
||||||
|
(1020, 1024), (65535, 65536)
|
||||||
|
]
|
||||||
|
for (unpadded, expected) in vectors {
|
||||||
|
#expect(
|
||||||
|
NIP44Padding.paddedLength(for: unpadded) == expected,
|
||||||
|
"paddedLength(for: \(unpadded)) should be \(expected)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func padUnpadRoundTrip() throws {
|
||||||
|
for length in [1, 2, 31, 32, 33, 100, 320, 1020, 4096, 65535] {
|
||||||
|
let plaintext = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
|
||||||
|
let padded = try NIP44Padding.pad(plaintext)
|
||||||
|
#expect(padded.count == 2 + NIP44Padding.paddedLength(for: length))
|
||||||
|
let unpadded = try NIP44Padding.unpad(padded)
|
||||||
|
#expect(unpadded == plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func padHidesExactLengthWithinBucket() throws {
|
||||||
|
// Two plaintexts of different length in the same bucket must produce
|
||||||
|
// identically sized padded payloads (and thus ciphertexts).
|
||||||
|
let short = try NIP44Padding.pad(Data(repeating: 0x41, count: 65))
|
||||||
|
let long = try NIP44Padding.pad(Data(repeating: 0x42, count: 96))
|
||||||
|
#expect(short.count == long.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func padRejectsOutOfRangePlaintexts() {
|
||||||
|
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data()) }
|
||||||
|
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data(count: 65536)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unpadRejectsTamperedLengthPrefix() throws {
|
||||||
|
var padded = try NIP44Padding.pad(Data(repeating: 0x41, count: 40))
|
||||||
|
|
||||||
|
// Claimed length larger than the actual payload
|
||||||
|
var tooLong = padded
|
||||||
|
tooLong[tooLong.startIndex] = 0xFF
|
||||||
|
tooLong[tooLong.startIndex + 1] = 0xFF
|
||||||
|
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(tooLong) }
|
||||||
|
|
||||||
|
// Claimed length of zero
|
||||||
|
var zero = padded
|
||||||
|
zero[zero.startIndex] = 0x00
|
||||||
|
zero[zero.startIndex + 1] = 0x00
|
||||||
|
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(zero) }
|
||||||
|
|
||||||
|
// Claimed length whose bucket does not match the payload size
|
||||||
|
// (payload is bucket 64; a claimed length of 20 expects bucket 32)
|
||||||
|
var wrongBucket = padded
|
||||||
|
wrongBucket[wrongBucket.startIndex] = 0x00
|
||||||
|
wrongBucket[wrongBucket.startIndex + 1] = 0x14
|
||||||
|
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(wrongBucket) }
|
||||||
|
|
||||||
|
// Truncated payloads
|
||||||
|
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data()) }
|
||||||
|
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data([0x00])) }
|
||||||
|
padded.removeLast()
|
||||||
|
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(padded) }
|
||||||
|
|
||||||
|
// Works on Data slices with non-zero startIndex
|
||||||
|
let sliced = try (Data([0xAB]) + NIP44Padding.pad(Data(repeating: 0x41, count: 40))).dropFirst()
|
||||||
|
#expect(try NIP44Padding.unpad(sliced) == Data(repeating: 0x41, count: 40))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func paddedEnvelopeRoundTrip_v3() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let plaintext = "padded envelope test"
|
||||||
|
|
||||||
|
let ciphertext = try NostrProtocol.encrypt(
|
||||||
|
plaintext: plaintext,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderKey: sender.schnorrSigningKey(),
|
||||||
|
padded: true
|
||||||
|
)
|
||||||
|
#expect(ciphertext.hasPrefix("v3:"))
|
||||||
|
|
||||||
|
let decrypted = try NostrProtocol.decrypt(
|
||||||
|
ciphertext: ciphertext,
|
||||||
|
senderPubkey: sender.publicKeyHex,
|
||||||
|
recipientKey: recipient.schnorrSigningKey()
|
||||||
|
)
|
||||||
|
#expect(decrypted == plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func legacyUnpaddedEnvelopeStillDecrypts_v2() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let plaintext = "legacy v2 envelope"
|
||||||
|
|
||||||
|
// What deployed clients send today.
|
||||||
|
let ciphertext = try NostrProtocol.encrypt(
|
||||||
|
plaintext: plaintext,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderKey: sender.schnorrSigningKey(),
|
||||||
|
padded: false
|
||||||
|
)
|
||||||
|
#expect(ciphertext.hasPrefix("v2:"))
|
||||||
|
|
||||||
|
let decrypted = try NostrProtocol.decrypt(
|
||||||
|
ciphertext: ciphertext,
|
||||||
|
senderPubkey: sender.publicKeyHex,
|
||||||
|
recipientKey: recipient.schnorrSigningKey()
|
||||||
|
)
|
||||||
|
#expect(decrypted == plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func outgoingMessagesStillUseV2UntilRolloutFlagFlips() throws {
|
||||||
|
// Deployed clients reject anything that is not "v2:", so the padded
|
||||||
|
// envelope must stay off by default until decrypt-side support is
|
||||||
|
// widely shipped (see NostrProtocol.sendPaddedEnvelope).
|
||||||
|
#expect(NostrProtocol.sendPaddedEnvelope == false)
|
||||||
|
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
|
content: "default envelope",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decryptRejectsUnknownEnvelopeVersion() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let ciphertext = try NostrProtocol.encrypt(
|
||||||
|
plaintext: "test",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderKey: sender.schnorrSigningKey(),
|
||||||
|
padded: false
|
||||||
|
)
|
||||||
|
let mutated = "v9:" + ciphertext.dropFirst(3)
|
||||||
|
#expect(throws: NostrError.invalidCiphertext) {
|
||||||
|
_ = try NostrProtocol.decrypt(
|
||||||
|
ciphertext: mutated,
|
||||||
|
senderPubkey: sender.publicKeyHex,
|
||||||
|
recipientKey: recipient.schnorrSigningKey()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
private static func base64URLDecode(_ s: String) -> Data? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
|
|||||||
@@ -8,12 +8,44 @@
|
|||||||
|
|
||||||
import struct Foundation.Data
|
import struct Foundation.Data
|
||||||
|
|
||||||
|
/// Lowercase hex digits used by `hexEncodedString()`.
|
||||||
|
private let hexDigits: [UInt8] = Array("0123456789abcdef".utf8)
|
||||||
|
|
||||||
|
/// Maps an ASCII byte to its hex nibble value, or nil for non-hex characters.
|
||||||
|
/// Accepts both lowercase and uppercase hex digits.
|
||||||
|
@inline(__always)
|
||||||
|
private func hexNibble(_ ascii: UInt8) -> UInt8? {
|
||||||
|
switch ascii {
|
||||||
|
case UInt8(ascii: "0")...UInt8(ascii: "9"):
|
||||||
|
return ascii - UInt8(ascii: "0")
|
||||||
|
case UInt8(ascii: "a")...UInt8(ascii: "f"):
|
||||||
|
return ascii - UInt8(ascii: "a") + 10
|
||||||
|
case UInt8(ascii: "A")...UInt8(ascii: "F"):
|
||||||
|
return ascii - UInt8(ascii: "A") + 10
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public extension Data {
|
public extension Data {
|
||||||
|
/// Lowercase hex representation of the bytes.
|
||||||
|
///
|
||||||
|
/// Lookup-table based: this sits on the hot BLE receive path (it is called
|
||||||
|
/// several times per received packet via `PeerID(hexData:)`), where the
|
||||||
|
/// previous per-byte `String(format: "%02x", _)` implementation spent most
|
||||||
|
/// of its time re-parsing the format string through Foundation.
|
||||||
func hexEncodedString() -> String {
|
func hexEncodedString() -> String {
|
||||||
if self.isEmpty {
|
if isEmpty {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return self.map { String(format: "%02x", $0) }.joined()
|
var output = [UInt8](repeating: 0, count: count * 2)
|
||||||
|
var i = 0
|
||||||
|
for byte in self {
|
||||||
|
output[i] = hexDigits[Int(byte >> 4)]
|
||||||
|
output[i + 1] = hexDigits[Int(byte & 0x0F)]
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
return String(decoding: output, as: UTF8.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize Data from a hex string.
|
/// Initialize Data from a hex string.
|
||||||
@@ -28,28 +60,28 @@ public extension Data {
|
|||||||
hex = String(hex.dropFirst(2))
|
hex = String(hex.dropFirst(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ascii = Array(hex.utf8)
|
||||||
|
|
||||||
// Reject odd-length strings
|
// Reject odd-length strings
|
||||||
guard hex.count % 2 == 0 else {
|
guard ascii.count % 2 == 0 else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject empty strings
|
// Accept empty strings
|
||||||
guard !hex.isEmpty else {
|
guard !ascii.isEmpty else {
|
||||||
self = Data()
|
self = Data()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let len = hex.count / 2
|
var data = Data(capacity: ascii.count / 2)
|
||||||
var data = Data(capacity: len)
|
var index = 0
|
||||||
var index = hex.startIndex
|
while index < ascii.count {
|
||||||
|
guard let high = hexNibble(ascii[index]),
|
||||||
for _ in 0..<len {
|
let low = hexNibble(ascii[index + 1]) else {
|
||||||
let nextIndex = hex.index(index, offsetBy: 2)
|
|
||||||
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
data.append(byte)
|
data.append((high << 4) | low)
|
||||||
index = nextIndex
|
index += 2
|
||||||
}
|
}
|
||||||
|
|
||||||
self = data
|
self = data
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//
|
||||||
|
// DataHexTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import BitFoundation
|
||||||
|
|
||||||
|
struct DataHexTests {
|
||||||
|
|
||||||
|
// MARK: - Encoding
|
||||||
|
|
||||||
|
@Test func encode_knownVectors() {
|
||||||
|
#expect(Data().hexEncodedString() == "")
|
||||||
|
#expect(Data([0x00]).hexEncodedString() == "00")
|
||||||
|
#expect(Data([0x0f]).hexEncodedString() == "0f")
|
||||||
|
#expect(Data([0xf0]).hexEncodedString() == "f0")
|
||||||
|
#expect(Data([0xff]).hexEncodedString() == "ff")
|
||||||
|
#expect(Data([0xde, 0xad, 0xbe, 0xef]).hexEncodedString() == "deadbeef")
|
||||||
|
#expect(Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]).hexEncodedString() == "0123456789abcdef")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func encode_allByteValues_matchesFormatReference() {
|
||||||
|
let all = Data((0...255).map { UInt8($0) })
|
||||||
|
let reference = (0...255).map { String(format: "%02x", $0) }.joined()
|
||||||
|
#expect(all.hexEncodedString() == reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func encode_worksOnDataSlices() {
|
||||||
|
let data = Data([0xaa, 0xde, 0xad, 0xbe, 0xef, 0xbb])
|
||||||
|
let slice = data.dropFirst().dropLast()
|
||||||
|
#expect(slice.hexEncodedString() == "deadbeef")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Decoding
|
||||||
|
|
||||||
|
@Test func decode_knownVectors() {
|
||||||
|
#expect(Data(hexString: "deadbeef") == Data([0xde, 0xad, 0xbe, 0xef]))
|
||||||
|
#expect(Data(hexString: "DEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef]))
|
||||||
|
#expect(Data(hexString: "DeAdBeEf") == Data([0xde, 0xad, 0xbe, 0xef]))
|
||||||
|
#expect(Data(hexString: "00") == Data([0x00]))
|
||||||
|
#expect(Data(hexString: "0123456789abcdef") == Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decode_handlesPrefixAndWhitespace() {
|
||||||
|
#expect(Data(hexString: "0xdeadbeef") == Data([0xde, 0xad, 0xbe, 0xef]))
|
||||||
|
#expect(Data(hexString: "0XDEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef]))
|
||||||
|
#expect(Data(hexString: " deadbeef\n") == Data([0xde, 0xad, 0xbe, 0xef]))
|
||||||
|
#expect(Data(hexString: "") == Data())
|
||||||
|
#expect(Data(hexString: "0x") == Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decode_rejectsInvalidInput() {
|
||||||
|
#expect(Data(hexString: "abc") == nil) // odd length
|
||||||
|
#expect(Data(hexString: "zz") == nil) // non-hex characters
|
||||||
|
#expect(Data(hexString: "0xg1") == nil) // non-hex after prefix
|
||||||
|
#expect(Data(hexString: "+f") == nil) // sign characters are not hex
|
||||||
|
#expect(Data(hexString: "-0") == nil)
|
||||||
|
#expect(Data(hexString: "a\u{00e9}") == nil) // non-ASCII
|
||||||
|
#expect(Data(hexString: "de ad") == nil) // interior whitespace
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Round trip
|
||||||
|
|
||||||
|
@Test func roundTrip_randomLengths() {
|
||||||
|
for length in [0, 1, 2, 3, 8, 16, 31, 32, 33, 64, 255, 1024] {
|
||||||
|
let data = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
|
||||||
|
let hex = data.hexEncodedString()
|
||||||
|
#expect(hex.count == length * 2)
|
||||||
|
#expect(Data(hexString: hex) == data)
|
||||||
|
#expect(Data(hexString: hex.uppercased()) == data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user