diff --git a/CHANGELOG.md b/CHANGELOG.md index fff58f15..dfcc3e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,71 @@ archived by series under [docs/changelog/](docs/changelog/); see the ### Added +- **Username discovery and a self-certifying invite payload.** Two ways to + reach a peer you have never spoken to, restoring reach-by-username, which the + addressing migration removed. + + `createInvite()` and `parseInvite()` produce and verify a compact base64url + blob carrying `{address, pubkey, petname?, sig?}`. It is verifiable offline + by anyone and, like `deriveAddress`, needs no protocol instance, so a scanner + can check a QR code before `create()`. The optional signature binds the + petname to the key; it does not defend against substitution (an attacker's + own correctly-signed invite is indistinguishable from a stranger's) but it + does stop a forwarded invite saving Alice's key under the name "Bob". Sign + when the invite may travel without its issuer. Invites deliberately carry no + key package (an MLS init key is single-use, a QR code is static, so pairing + them guarantees a collision the moment two people scan the same code) and no + expiry. + + `resolveUsername()` looks a name up in a directory published over Nostr + (addressable kind 30777, sealed, one record per device). Off by default via + `transports.nostr.usernameDiscoveryEnabled`, and it additionally requires + cold contact, since a claim points at an address whose key packages are what + a resolver fetches next. Default-off is deliberate: publishing binds a + human-readable name to an address in a public place, where the mapping *is* + the payload, which is materially more disclosure than a key-package record's + "an install with this tag exists". + + **The directory is not authoritative, and the API is shaped so you cannot + forget it.** Anyone may claim any name, so a resolution returns the whole set + of claimants as a single `username_resolved` event: no ranking, no "best" + claim, and no per-claim event to race. An app that auto-selects has converted + a non-authoritative directory into an authoritative-looking one, and its user + then believes the *name* was verified when only a *key* ever was. Present the + claims, let the user confirm out of band, and store the address rather than + the name. Note that even a single user resolves to a set: a phone and a + laptop are two genuine claims, and collapsing them hides the second device. + + `resolveUsername()` resolves `true` if it started the lookup and `false` if + it joined one already in flight; **both mean the event is coming.** Every + case where no event will ever arrive rejects instead, so awaiting the + resolution can never hang on a lookup that was never started. + + Each record binds the Nostr key it is published under, which the key-package + record does not. Without that binding a third party could unseal a claim, + re-seal the genuinely signed payload under their own key, and republish it, + and because addressable replacement is per-author the owner's retraction + would never displace the copy. Renaming or switching the feature off retracts + the standing claim. + + Discovery events are additionally checked against their own BIP-340 + signature, with the event id recomputed rather than trusted. This is the one + record kind that needs it: a retraction's body is a constant, so nothing + inside it is signed and its whole meaning is *who published it*, while the + seal key is public by construction. Without the check a single hostile relay + could forge a retraction for an honest claimant and erase them from the + resolved set even while every other relay served their genuine record — + inverting what querying many relays is for, since a claim needs only one + honest relay to survive. + + Invite petnames are screened for the control and format characters a + username already refuses. A petname is what an app renders in the + confirmation dialog after a scan, and on a signed invite a bidi override + would otherwise arrive bound to a valid signature. + + Wire format, verification order and threat model: + [docs/spec/username-discovery.md](docs/spec/username-discovery.md). + - **Capability bias in mesh forwarding.** Battery level and charging state now continuously scale how much of the mesh's traffic a device carries: the delay before it transmits a forward, the number of neighbors it fans out to, and diff --git a/Cargo.lock b/Cargo.lock index d44a8366..d7bb2b00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1398,6 +1398,7 @@ dependencies = [ "chacha20poly1305", "chrono", "ed25519-dalek 3.0.0", + "hex", "offline-protocol-core", "offline-protocol-mls", "offline-protocol-reliability", @@ -1441,6 +1442,7 @@ dependencies = [ "serde_json", "thiserror", "tracing", + "unicode-normalization", "uuid", ] @@ -2295,6 +2297,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tls_codec" version = "0.4.2" @@ -2420,6 +2437,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index 96222c7a..a1a1b5e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,11 @@ hex = "0.4" # Only `alloc` is needed: `std` adds `std::error::Error` impls on bech32's own # error types, which never cross this workspace's API surface. bech32 = { version = "0.11.1", default-features = false, features = ["alloc"] } +# NFC normalization for username claims. A discovery tag is a hash of the +# normalized name, so two implementations that normalize differently derive +# different tags and silently fail to find each other; the tables are what make +# "NFC" mean one thing everywhere. +unicode-normalization = "0.1" # Crypto # `ecdh` backs the NIP-44 v2 conversation key (secp256k1 ECDH, x-coordinate diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index fa4f15e2..a3264dc4 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -25,7 +25,7 @@ upstream source on each crate's page) — the exact versions are listed below. | License | Crates | |---------|--------| -| MIT License | 159 | +| MIT License | 162 | | Apache License 2.0 | 10 | | Mozilla Public License 2.0 | 9 | | GNU Affero General Public License v3.0 only | 8 | @@ -1065,6 +1065,7 @@ DEALINGS IN THE SOFTWARE. Used by: - [heck 0.5.0](https://github.com/withoutboats/heck) +- [unicode-normalization 0.1.25](https://github.com/unicode-rs/unicode-normalization) ``` Copyright (c) 2015 The Rust Project Developers @@ -3180,6 +3181,39 @@ SOFTWARE. Used by: +- [tinyvec_macros 0.1.1](https://github.com/Soveu/tinyvec_macros) + +``` +MIT License + +Copyright (c) 2020 Soveu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +--- + +### MIT License + +Used by: + - [chrono 0.4.42](https://github.com/chronotope/chrono) - [openmls 0.7.4](https://github.com/openmls/openmls/) - [openmls_basic_credential 0.4.1](https://github.com/openmls/openmls/tree/main/basic_credential) @@ -3351,6 +3385,23 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Used by: +- [tinyvec 1.12.0](https://github.com/Lokathor/tinyvec) + +``` +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +``` + +--- + +### MIT License + +Used by: + - [android_system_properties 0.1.5](https://github.com/nical/android_system_properties) ``` diff --git a/bindings/python/THIRD-PARTY-NOTICES.md b/bindings/python/THIRD-PARTY-NOTICES.md index fa4f15e2..a3264dc4 100644 --- a/bindings/python/THIRD-PARTY-NOTICES.md +++ b/bindings/python/THIRD-PARTY-NOTICES.md @@ -25,7 +25,7 @@ upstream source on each crate's page) — the exact versions are listed below. | License | Crates | |---------|--------| -| MIT License | 159 | +| MIT License | 162 | | Apache License 2.0 | 10 | | Mozilla Public License 2.0 | 9 | | GNU Affero General Public License v3.0 only | 8 | @@ -1065,6 +1065,7 @@ DEALINGS IN THE SOFTWARE. Used by: - [heck 0.5.0](https://github.com/withoutboats/heck) +- [unicode-normalization 0.1.25](https://github.com/unicode-rs/unicode-normalization) ``` Copyright (c) 2015 The Rust Project Developers @@ -3180,6 +3181,39 @@ SOFTWARE. Used by: +- [tinyvec_macros 0.1.1](https://github.com/Soveu/tinyvec_macros) + +``` +MIT License + +Copyright (c) 2020 Soveu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +--- + +### MIT License + +Used by: + - [chrono 0.4.42](https://github.com/chronotope/chrono) - [openmls 0.7.4](https://github.com/openmls/openmls/) - [openmls_basic_credential 0.4.1](https://github.com/openmls/openmls/tree/main/basic_credential) @@ -3351,6 +3385,23 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Used by: +- [tinyvec 1.12.0](https://github.com/Lokathor/tinyvec) + +``` +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +``` + +--- + +### MIT License + +Used by: + - [android_system_properties 0.1.5](https://github.com/nical/android_system_properties) ``` diff --git a/bindings/python/offline_protocol_sdk/offline_protocol.py b/bindings/python/offline_protocol_sdk/offline_protocol.py index b69dc5a6..cbfb4ce0 100644 --- a/bindings/python/offline_protocol_sdk/offline_protocol.py +++ b/bindings/python/offline_protocol_sdk/offline_protocol.py @@ -511,6 +511,8 @@ def _uniffi_check_contract_api_version(lib): def _uniffi_check_api_checksums(lib): if lib.uniffi_offline_protocol_uniffi_checksum_func_derive_address() != 55050: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_offline_protocol_uniffi_checksum_func_parse_invite() != 15865: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_offline_protocol_uniffi_checksum_constructor_meshservices_new() != 61363: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_offline_protocol_uniffi_checksum_method_meshservices_discover_services() != 65338: @@ -561,6 +563,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_group() != 15087: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_invite() != 60286: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_derive_user_id_from_public_key() != 2562: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_emit_test_event() != 6362: @@ -763,6 +767,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_request_group_relay_registration() != 6912: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_resolve_username() != 29454: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_resume() != 16439: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_reticulum_confirm_sent() != 25841: @@ -1327,6 +1333,11 @@ class _UniffiVTableCallbackInterfaceOfflineProtocolWifiDirectTransportCallback(c ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_offline_protocol_uniffi_fn_func_derive_address.restype = _UniffiRustBuffer +_UniffiLib.uniffi_offline_protocol_uniffi_fn_func_parse_invite.argtypes = ( + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_offline_protocol_uniffi_fn_func_parse_invite.restype = _UniffiRustBuffer _UniffiLib.uniffi_offline_protocol_uniffi_fn_constructor_meshservices_new.argtypes = ( ctypes.c_uint64, ctypes.POINTER(_UniffiRustCallStatus), @@ -1482,6 +1493,13 @@ class _UniffiVTableCallbackInterfaceOfflineProtocolWifiDirectTransportCallback(c ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_create_group.restype = _UniffiRustBuffer +_UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_create_invite.argtypes = ( + ctypes.c_uint64, + _UniffiRustBuffer, + ctypes.c_int8, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_create_invite.restype = _UniffiRustBuffer _UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_derive_user_id_from_public_key.argtypes = ( ctypes.c_uint64, _UniffiRustBuffer, @@ -2080,6 +2098,12 @@ class _UniffiVTableCallbackInterfaceOfflineProtocolWifiDirectTransportCallback(c ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_request_group_relay_registration.restype = ctypes.c_int8 +_UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_resolve_username.argtypes = ( + ctypes.c_uint64, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_resolve_username.restype = ctypes.c_int8 _UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_resume.argtypes = ( ctypes.c_uint64, ctypes.POINTER(_UniffiRustCallStatus), @@ -2388,6 +2412,9 @@ class _UniffiVTableCallbackInterfaceOfflineProtocolWifiDirectTransportCallback(c _UniffiLib.uniffi_offline_protocol_uniffi_checksum_func_derive_address.argtypes = ( ) _UniffiLib.uniffi_offline_protocol_uniffi_checksum_func_derive_address.restype = ctypes.c_uint16 +_UniffiLib.uniffi_offline_protocol_uniffi_checksum_func_parse_invite.argtypes = ( +) +_UniffiLib.uniffi_offline_protocol_uniffi_checksum_func_parse_invite.restype = ctypes.c_uint16 _UniffiLib.uniffi_offline_protocol_uniffi_checksum_constructor_meshservices_new.argtypes = ( ) _UniffiLib.uniffi_offline_protocol_uniffi_checksum_constructor_meshservices_new.restype = ctypes.c_uint16 @@ -2463,6 +2490,9 @@ class _UniffiVTableCallbackInterfaceOfflineProtocolWifiDirectTransportCallback(c _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_group.argtypes = ( ) _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_group.restype = ctypes.c_uint16 +_UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_invite.argtypes = ( +) +_UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_invite.restype = ctypes.c_uint16 _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_derive_user_id_from_public_key.argtypes = ( ) _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_derive_user_id_from_public_key.restype = ctypes.c_uint16 @@ -2766,6 +2796,9 @@ class _UniffiVTableCallbackInterfaceOfflineProtocolWifiDirectTransportCallback(c _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_request_group_relay_registration.argtypes = ( ) _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_request_group_relay_registration.restype = ctypes.c_uint16 +_UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_resolve_username.argtypes = ( +) +_UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_resolve_username.restype = ctypes.c_uint16 _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_resume.argtypes = ( ) _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_resume.restype = ctypes.c_uint16 @@ -3982,6 +4015,54 @@ def write(value, buf): _UniffiFfiConverterOptionalString.write(value.control_op, buf) _UniffiFfiConverterOptionalString.write(value.control_payload, buf) +@dataclass +class InviteInfo: + def __init__(self, *, address:str, public_key:typing.List[int], petname:typing.Optional[str], signed:bool): + self.address = address + self.public_key = public_key + self.petname = petname + self.signed = signed + + + + + def __str__(self): + return "InviteInfo(address={}, public_key={}, petname={}, signed={})".format(self.address, self.public_key, self.petname, self.signed) + def __eq__(self, other): + if self.address != other.address: + return False + if self.public_key != other.public_key: + return False + if self.petname != other.petname: + return False + if self.signed != other.signed: + return False + return True + +class _UniffiFfiConverterTypeInviteInfo(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return InviteInfo( + address=_UniffiFfiConverterString.read(buf), + public_key=_UniffiFfiConverterSequenceUInt8.read(buf), + petname=_UniffiFfiConverterOptionalString.read(buf), + signed=_UniffiFfiConverterBoolean.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterString.check_lower(value.address) + _UniffiFfiConverterSequenceUInt8.check_lower(value.public_key) + _UniffiFfiConverterOptionalString.check_lower(value.petname) + _UniffiFfiConverterBoolean.check_lower(value.signed) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterString.write(value.address, buf) + _UniffiFfiConverterSequenceUInt8.write(value.public_key, buf) + _UniffiFfiConverterOptionalString.write(value.petname, buf) + _UniffiFfiConverterBoolean.write(value.signed, buf) + class _UniffiFfiConverterOptionalUInt64(_UniffiConverterRustBuffer): @classmethod def check_lower(cls, value): @@ -5614,7 +5695,7 @@ def write(value, buf): @dataclass class ProtocolConfig: - def __init__(self, *, app_id:str, profile:str, ble_enabled:bool, wifi_direct_enabled:bool, internet_enabled:bool, reticulum_enabled:bool, nostr_enabled:bool, prefer_online:bool, initial_ttl:int, encryption_enabled:bool, auto_key_exchange:bool, store_pending:bool, require_encryption:bool = True, max_pending_per_peer:int, max_pending_global:int, pending_ttl_ms:int, overflow_policy:OverflowPolicy, max_group_members:int = 256, group_relay_enabled:bool = True, group_relay_broadcast_enabled:bool = True, group_enforce_admin_commits:bool = False, require_transport_identity:bool = False, binary_wire_enabled:bool = True, nostr_sealing_enabled:bool = True, nostr_cold_contact_enabled:bool = True, compact_envelope_enabled:bool = True, rich_payload_enabled:bool = True, crypto_recovery_enabled:bool = True): + def __init__(self, *, app_id:str, profile:str, ble_enabled:bool, wifi_direct_enabled:bool, internet_enabled:bool, reticulum_enabled:bool, nostr_enabled:bool, prefer_online:bool, initial_ttl:int, encryption_enabled:bool, auto_key_exchange:bool, store_pending:bool, require_encryption:bool = True, max_pending_per_peer:int, max_pending_global:int, pending_ttl_ms:int, overflow_policy:OverflowPolicy, max_group_members:int = 256, group_relay_enabled:bool = True, group_relay_broadcast_enabled:bool = True, group_enforce_admin_commits:bool = False, require_transport_identity:bool = False, binary_wire_enabled:bool = True, nostr_sealing_enabled:bool = True, nostr_cold_contact_enabled:bool = True, nostr_username_discovery_enabled:bool = False, compact_envelope_enabled:bool = True, rich_payload_enabled:bool = True, crypto_recovery_enabled:bool = True): self.app_id = app_id self.profile = profile self.ble_enabled = ble_enabled @@ -5640,6 +5721,7 @@ def __init__(self, *, app_id:str, profile:str, ble_enabled:bool, wifi_direct_ena self.binary_wire_enabled = binary_wire_enabled self.nostr_sealing_enabled = nostr_sealing_enabled self.nostr_cold_contact_enabled = nostr_cold_contact_enabled + self.nostr_username_discovery_enabled = nostr_username_discovery_enabled self.compact_envelope_enabled = compact_envelope_enabled self.rich_payload_enabled = rich_payload_enabled self.crypto_recovery_enabled = crypto_recovery_enabled @@ -5648,7 +5730,7 @@ def __init__(self, *, app_id:str, profile:str, ble_enabled:bool, wifi_direct_ena def __str__(self): - return "ProtocolConfig(app_id={}, profile={}, ble_enabled={}, wifi_direct_enabled={}, internet_enabled={}, reticulum_enabled={}, nostr_enabled={}, prefer_online={}, initial_ttl={}, encryption_enabled={}, auto_key_exchange={}, store_pending={}, require_encryption={}, max_pending_per_peer={}, max_pending_global={}, pending_ttl_ms={}, overflow_policy={}, max_group_members={}, group_relay_enabled={}, group_relay_broadcast_enabled={}, group_enforce_admin_commits={}, require_transport_identity={}, binary_wire_enabled={}, nostr_sealing_enabled={}, nostr_cold_contact_enabled={}, compact_envelope_enabled={}, rich_payload_enabled={}, crypto_recovery_enabled={})".format(self.app_id, self.profile, self.ble_enabled, self.wifi_direct_enabled, self.internet_enabled, self.reticulum_enabled, self.nostr_enabled, self.prefer_online, self.initial_ttl, self.encryption_enabled, self.auto_key_exchange, self.store_pending, self.require_encryption, self.max_pending_per_peer, self.max_pending_global, self.pending_ttl_ms, self.overflow_policy, self.max_group_members, self.group_relay_enabled, self.group_relay_broadcast_enabled, self.group_enforce_admin_commits, self.require_transport_identity, self.binary_wire_enabled, self.nostr_sealing_enabled, self.nostr_cold_contact_enabled, self.compact_envelope_enabled, self.rich_payload_enabled, self.crypto_recovery_enabled) + return "ProtocolConfig(app_id={}, profile={}, ble_enabled={}, wifi_direct_enabled={}, internet_enabled={}, reticulum_enabled={}, nostr_enabled={}, prefer_online={}, initial_ttl={}, encryption_enabled={}, auto_key_exchange={}, store_pending={}, require_encryption={}, max_pending_per_peer={}, max_pending_global={}, pending_ttl_ms={}, overflow_policy={}, max_group_members={}, group_relay_enabled={}, group_relay_broadcast_enabled={}, group_enforce_admin_commits={}, require_transport_identity={}, binary_wire_enabled={}, nostr_sealing_enabled={}, nostr_cold_contact_enabled={}, nostr_username_discovery_enabled={}, compact_envelope_enabled={}, rich_payload_enabled={}, crypto_recovery_enabled={})".format(self.app_id, self.profile, self.ble_enabled, self.wifi_direct_enabled, self.internet_enabled, self.reticulum_enabled, self.nostr_enabled, self.prefer_online, self.initial_ttl, self.encryption_enabled, self.auto_key_exchange, self.store_pending, self.require_encryption, self.max_pending_per_peer, self.max_pending_global, self.pending_ttl_ms, self.overflow_policy, self.max_group_members, self.group_relay_enabled, self.group_relay_broadcast_enabled, self.group_enforce_admin_commits, self.require_transport_identity, self.binary_wire_enabled, self.nostr_sealing_enabled, self.nostr_cold_contact_enabled, self.nostr_username_discovery_enabled, self.compact_envelope_enabled, self.rich_payload_enabled, self.crypto_recovery_enabled) def __eq__(self, other): if self.app_id != other.app_id: return False @@ -5700,6 +5782,8 @@ def __eq__(self, other): return False if self.nostr_cold_contact_enabled != other.nostr_cold_contact_enabled: return False + if self.nostr_username_discovery_enabled != other.nostr_username_discovery_enabled: + return False if self.compact_envelope_enabled != other.compact_envelope_enabled: return False if self.rich_payload_enabled != other.rich_payload_enabled: @@ -5737,6 +5821,7 @@ def read(buf): binary_wire_enabled=_UniffiFfiConverterBoolean.read(buf), nostr_sealing_enabled=_UniffiFfiConverterBoolean.read(buf), nostr_cold_contact_enabled=_UniffiFfiConverterBoolean.read(buf), + nostr_username_discovery_enabled=_UniffiFfiConverterBoolean.read(buf), compact_envelope_enabled=_UniffiFfiConverterBoolean.read(buf), rich_payload_enabled=_UniffiFfiConverterBoolean.read(buf), crypto_recovery_enabled=_UniffiFfiConverterBoolean.read(buf), @@ -5769,6 +5854,7 @@ def check_lower(value): _UniffiFfiConverterBoolean.check_lower(value.binary_wire_enabled) _UniffiFfiConverterBoolean.check_lower(value.nostr_sealing_enabled) _UniffiFfiConverterBoolean.check_lower(value.nostr_cold_contact_enabled) + _UniffiFfiConverterBoolean.check_lower(value.nostr_username_discovery_enabled) _UniffiFfiConverterBoolean.check_lower(value.compact_envelope_enabled) _UniffiFfiConverterBoolean.check_lower(value.rich_payload_enabled) _UniffiFfiConverterBoolean.check_lower(value.crypto_recovery_enabled) @@ -5800,6 +5886,7 @@ def write(value, buf): _UniffiFfiConverterBoolean.write(value.binary_wire_enabled, buf) _UniffiFfiConverterBoolean.write(value.nostr_sealing_enabled, buf) _UniffiFfiConverterBoolean.write(value.nostr_cold_contact_enabled, buf) + _UniffiFfiConverterBoolean.write(value.nostr_username_discovery_enabled, buf) _UniffiFfiConverterBoolean.write(value.compact_envelope_enabled, buf) _UniffiFfiConverterBoolean.write(value.rich_payload_enabled, buf) _UniffiFfiConverterBoolean.write(value.crypto_recovery_enabled, buf) @@ -6967,7 +7054,7 @@ def write(value, buf): @dataclass class TransportConfig: - def __init__(self, *, ble_enabled:bool, wifi_direct_enabled:bool, internet_enabled:bool, reticulum_enabled:bool, nostr_enabled:bool, nostr_sealing_enabled:bool = True, nostr_cold_contact_enabled:bool = True): + def __init__(self, *, ble_enabled:bool, wifi_direct_enabled:bool, internet_enabled:bool, reticulum_enabled:bool, nostr_enabled:bool, nostr_sealing_enabled:bool = True, nostr_cold_contact_enabled:bool = True, nostr_username_discovery_enabled:bool = False): self.ble_enabled = ble_enabled self.wifi_direct_enabled = wifi_direct_enabled self.internet_enabled = internet_enabled @@ -6975,12 +7062,13 @@ def __init__(self, *, ble_enabled:bool, wifi_direct_enabled:bool, internet_enabl self.nostr_enabled = nostr_enabled self.nostr_sealing_enabled = nostr_sealing_enabled self.nostr_cold_contact_enabled = nostr_cold_contact_enabled + self.nostr_username_discovery_enabled = nostr_username_discovery_enabled def __str__(self): - return "TransportConfig(ble_enabled={}, wifi_direct_enabled={}, internet_enabled={}, reticulum_enabled={}, nostr_enabled={}, nostr_sealing_enabled={}, nostr_cold_contact_enabled={})".format(self.ble_enabled, self.wifi_direct_enabled, self.internet_enabled, self.reticulum_enabled, self.nostr_enabled, self.nostr_sealing_enabled, self.nostr_cold_contact_enabled) + return "TransportConfig(ble_enabled={}, wifi_direct_enabled={}, internet_enabled={}, reticulum_enabled={}, nostr_enabled={}, nostr_sealing_enabled={}, nostr_cold_contact_enabled={}, nostr_username_discovery_enabled={})".format(self.ble_enabled, self.wifi_direct_enabled, self.internet_enabled, self.reticulum_enabled, self.nostr_enabled, self.nostr_sealing_enabled, self.nostr_cold_contact_enabled, self.nostr_username_discovery_enabled) def __eq__(self, other): if self.ble_enabled != other.ble_enabled: return False @@ -6996,6 +7084,8 @@ def __eq__(self, other): return False if self.nostr_cold_contact_enabled != other.nostr_cold_contact_enabled: return False + if self.nostr_username_discovery_enabled != other.nostr_username_discovery_enabled: + return False return True class _UniffiFfiConverterTypeTransportConfig(_UniffiConverterRustBuffer): @@ -7009,6 +7099,7 @@ def read(buf): nostr_enabled=_UniffiFfiConverterBoolean.read(buf), nostr_sealing_enabled=_UniffiFfiConverterBoolean.read(buf), nostr_cold_contact_enabled=_UniffiFfiConverterBoolean.read(buf), + nostr_username_discovery_enabled=_UniffiFfiConverterBoolean.read(buf), ) @staticmethod @@ -7020,6 +7111,7 @@ def check_lower(value): _UniffiFfiConverterBoolean.check_lower(value.nostr_enabled) _UniffiFfiConverterBoolean.check_lower(value.nostr_sealing_enabled) _UniffiFfiConverterBoolean.check_lower(value.nostr_cold_contact_enabled) + _UniffiFfiConverterBoolean.check_lower(value.nostr_username_discovery_enabled) @staticmethod def write(value, buf): @@ -7030,6 +7122,7 @@ def write(value, buf): _UniffiFfiConverterBoolean.write(value.nostr_enabled, buf) _UniffiFfiConverterBoolean.write(value.nostr_sealing_enabled, buf) _UniffiFfiConverterBoolean.write(value.nostr_cold_contact_enabled, buf) + _UniffiFfiConverterBoolean.write(value.nostr_username_discovery_enabled, buf) @@ -9082,6 +9175,8 @@ def cleanup_expired_routes(self, ) -> None: raise NotImplementedError def create_group(self, group_name: str) -> MlsGroupInfo: raise NotImplementedError + def create_invite(self, petname: typing.Optional[str],sign: bool) -> str: + raise NotImplementedError def derive_user_id_from_public_key(self, public_key: typing.List[int]) -> str: raise NotImplementedError def emit_test_event(self, ) -> None: @@ -9284,6 +9379,8 @@ def rename_group(self, group_id: str,new_name: str) -> None: raise NotImplementedError def request_group_relay_registration(self, group_id: str) -> bool: raise NotImplementedError + def resolve_username(self, username: str) -> bool: + raise NotImplementedError def resume(self, ) -> None: raise NotImplementedError def reticulum_confirm_sent(self, message_id: str) -> None: @@ -9676,6 +9773,24 @@ def create_group(self, group_name: str) -> MlsGroupInfo: *_uniffi_lowered_args, ) return _uniffi_lift_return(_uniffi_ffi_result) + def create_invite(self, petname: typing.Optional[str],sign: bool) -> str: + + _UniffiFfiConverterOptionalString.check_lower(petname) + + _UniffiFfiConverterBoolean.check_lower(sign) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + _UniffiFfiConverterOptionalString.lower(petname), + _UniffiFfiConverterBoolean.lower(sign), + ) + _uniffi_lift_return = _UniffiFfiConverterString.lift + _uniffi_error_converter = _UniffiFfiConverterTypeProtocolError + _uniffi_ffi_result = _uniffi_rust_call_with_error( + _uniffi_error_converter, + _UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_create_invite, + *_uniffi_lowered_args, + ) + return _uniffi_lift_return(_uniffi_ffi_result) def derive_user_id_from_public_key(self, public_key: typing.List[int]) -> str: _UniffiFfiConverterSequenceUInt8.check_lower(public_key) @@ -11167,6 +11282,21 @@ def request_group_relay_registration(self, group_id: str) -> bool: *_uniffi_lowered_args, ) return _uniffi_lift_return(_uniffi_ffi_result) + def resolve_username(self, username: str) -> bool: + + _UniffiFfiConverterString.check_lower(username) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + _UniffiFfiConverterString.lower(username), + ) + _uniffi_lift_return = _UniffiFfiConverterBoolean.lift + _uniffi_error_converter = _UniffiFfiConverterTypeProtocolError + _uniffi_ffi_result = _uniffi_rust_call_with_error( + _uniffi_error_converter, + _UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_resolve_username, + *_uniffi_lowered_args, + ) + return _uniffi_lift_return(_uniffi_ffi_result) def resume(self, ) -> None: _uniffi_lowered_args = ( self._uniffi_clone_handle(), @@ -11978,6 +12108,20 @@ def derive_address(public_key: typing.List[int]) -> str: *_uniffi_lowered_args, ) return _uniffi_lift_return(_uniffi_ffi_result) +def parse_invite(blob: str) -> InviteInfo: + + _UniffiFfiConverterString.check_lower(blob) + _uniffi_lowered_args = ( + _UniffiFfiConverterString.lower(blob), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeInviteInfo.lift + _uniffi_error_converter = _UniffiFfiConverterTypeProtocolError + _uniffi_ffi_result = _uniffi_rust_call_with_error( + _uniffi_error_converter, + _UniffiLib.uniffi_offline_protocol_uniffi_fn_func_parse_invite, + *_uniffi_lowered_args, + ) + return _uniffi_lift_return(_uniffi_ffi_result) __all__ = [ "InternalError", @@ -12011,6 +12155,7 @@ def derive_address(public_key: typing.List[int]) -> str: "GradientRoutingConfig", "GroupRichReadiness", "InternetMessage", + "InviteInfo", "MediaMetadata", "ReplyContext", "MediaSendOptions", @@ -12047,6 +12192,7 @@ def derive_address(public_key: typing.List[int]) -> str: "TransportStateEvent", "WifiDirectMessage", "derive_address", + "parse_invite", "MeshServices", "MeshServicesProtocol", "OfflineProtocol", diff --git a/bindings/react-native/THIRD-PARTY-NOTICES.md b/bindings/react-native/THIRD-PARTY-NOTICES.md index fa4f15e2..a3264dc4 100644 --- a/bindings/react-native/THIRD-PARTY-NOTICES.md +++ b/bindings/react-native/THIRD-PARTY-NOTICES.md @@ -25,7 +25,7 @@ upstream source on each crate's page) — the exact versions are listed below. | License | Crates | |---------|--------| -| MIT License | 159 | +| MIT License | 162 | | Apache License 2.0 | 10 | | Mozilla Public License 2.0 | 9 | | GNU Affero General Public License v3.0 only | 8 | @@ -1065,6 +1065,7 @@ DEALINGS IN THE SOFTWARE. Used by: - [heck 0.5.0](https://github.com/withoutboats/heck) +- [unicode-normalization 0.1.25](https://github.com/unicode-rs/unicode-normalization) ``` Copyright (c) 2015 The Rust Project Developers @@ -3180,6 +3181,39 @@ SOFTWARE. Used by: +- [tinyvec_macros 0.1.1](https://github.com/Soveu/tinyvec_macros) + +``` +MIT License + +Copyright (c) 2020 Soveu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +--- + +### MIT License + +Used by: + - [chrono 0.4.42](https://github.com/chronotope/chrono) - [openmls 0.7.4](https://github.com/openmls/openmls/) - [openmls_basic_credential 0.4.1](https://github.com/openmls/openmls/tree/main/basic_credential) @@ -3351,6 +3385,23 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Used by: +- [tinyvec 1.12.0](https://github.com/Lokathor/tinyvec) + +``` +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +``` + +--- + +### MIT License + +Used by: + - [android_system_properties 0.1.5](https://github.com/nical/android_system_properties) ``` diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt index 7429da27..0fe401d6 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt @@ -3531,6 +3531,69 @@ class OfflineProtocolModule(reactContext: ReactApplicationContext) : } } + /** + * Decode and verify an invite blob. Needs no protocol instance, so a + * scanner can check a QR code before create(). + * + * Verification is total: a rejected blob rejects the promise rather than + * resolving with a warning flag, because every failure mode here means the + * invite must not be acted on. + */ + @ReactMethod + fun parseInvite(blob: String, promise: Promise) { + try { + val invite = uniffi.offline_protocol.parseInvite(blob) + val publicKey = Arguments.createArray().apply { + invite.publicKey.forEach { pushInt(it.toInt()) } + } + promise.resolve(Arguments.createMap().apply { + putString("address", invite.address) + putArray("public_key", publicKey) + if (invite.petname != null) { + putString("petname", invite.petname) + } else { + putNull("petname") + } + putBoolean("signed", invite.signed) + }) + } catch (e: Exception) { + promise.reject("ERROR_INVALID_INVITE", "Invite did not verify: ${e.message}", e) + } + } + + /** + * Build an invite blob for this identity. + * + * Sign it when the invite may travel without its issuer; leave it unsigned + * for a QR shown phone to phone. See the JS createInvite doc. + */ + @ReactMethod + fun createInvite(petname: String?, signed: Boolean, promise: Promise) { + try { + val proto = protocol ?: throw IllegalStateException("Protocol not initialized") + promise.resolve(proto.createInvite(petname, signed)) + } catch (e: Exception) { + promise.reject("ERROR_CRYPTO", "Failed to create invite: ${e.message}", e) + } + } + + /** + * Resolve a username to the set of devices claiming it. + * + * The answer arrives as one username_resolved event carrying every + * verified claim. Never auto-select from it: see the JS resolveUsername + * doc for why picking the first entry defeats the design. + */ + @ReactMethod + fun resolveUsername(username: String, promise: Promise) { + try { + val proto = protocol ?: throw IllegalStateException("Protocol not initialized") + promise.resolve(proto.resolveUsername(username)) + } catch (e: Exception) { + promise.reject("ERROR_INVALID_ARGUMENT", "Failed to resolve username: ${e.message}", e) + } + } + /** * Derive a user ID from a public key. * diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt index b2172638..161b07b6 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt @@ -72,6 +72,17 @@ internal object ProtocolConfigParser { "coldContactEnabled", "cold_contact_enabled" ) ?: json.optBooleanCompat("nostrColdContactEnabled", "nostr_cold_contact_enabled") ?: true + // Nostr username discovery (claim publication + username resolution). + // Same nested-then-flat shape, but the default is FALSE: publishing + // binds a human-readable name to an address in a public place, which + // an app must opt into rather than inherit. + val nostrUsernameDiscoveryEnabled = nostrJson?.optBooleanCompat( + "usernameDiscoveryEnabled", + "username_discovery_enabled" + ) ?: json.optBooleanCompat( + "nostrUsernameDiscoveryEnabled", + "nostr_username_discovery_enabled" + ) ?: false val compactEnvelopeEnabled = encryptionJson?.optBooleanCompat( "compactEnvelopeEnabled", "compact_envelope_enabled" @@ -162,6 +173,7 @@ internal object ProtocolConfigParser { binaryWireEnabled = binaryWireEnabled, nostrSealingEnabled = nostrSealingEnabled, nostrColdContactEnabled = nostrColdContactEnabled, + nostrUsernameDiscoveryEnabled = nostrUsernameDiscoveryEnabled, compactEnvelopeEnabled = compactEnvelopeEnabled, richPayloadEnabled = richPayloadEnabled, cryptoRecoveryEnabled = cryptoRecoveryEnabled diff --git a/bindings/react-native/android/src/main/java/uniffi/offline_protocol/offline_protocol.kt b/bindings/react-native/android/src/main/java/uniffi/offline_protocol/offline_protocol.kt index 98982b03..8931047c 100644 --- a/bindings/react-native/android/src/main/java/uniffi/offline_protocol/offline_protocol.kt +++ b/bindings/react-native/android/src/main/java/uniffi/offline_protocol/offline_protocol.kt @@ -884,6 +884,8 @@ internal object IntegrityCheckingUniffiLib { } external fun uniffi_offline_protocol_uniffi_checksum_func_derive_address( ): Short +external fun uniffi_offline_protocol_uniffi_checksum_func_parse_invite( +): Short external fun uniffi_offline_protocol_uniffi_checksum_method_meshservices_discover_services( ): Short external fun uniffi_offline_protocol_uniffi_checksum_method_meshservices_register_service( @@ -930,6 +932,8 @@ external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_clea ): Short external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_group( ): Short +external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_invite( +): Short external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_derive_user_id_from_public_key( ): Short external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_emit_test_event( @@ -1132,6 +1136,8 @@ external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_rena ): Short external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_request_group_relay_registration( ): Short +external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_resolve_username( +): Short external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_resume( ): Short external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_reticulum_confirm_sent( @@ -1352,6 +1358,8 @@ external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_cleanup_ex ): Unit external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_create_group(`ptr`: Long,`groupName`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue +external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_create_invite(`ptr`: Long,`petname`: RustBuffer.ByValue,`sign`: Byte,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_derive_user_id_from_public_key(`ptr`: Long,`publicKey`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_emit_test_event(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1554,6 +1562,8 @@ external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_rename_gro ): Unit external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_request_group_relay_registration(`ptr`: Long,`groupId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Byte +external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_resolve_username(`ptr`: Long,`username`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Byte external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_resume(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_reticulum_confirm_sent(`ptr`: Long,`messageId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -1664,6 +1674,8 @@ external fun uniffi_offline_protocol_uniffi_fn_init_callback_vtable_wifidirecttr ): Unit external fun uniffi_offline_protocol_uniffi_fn_func_derive_address(`publicKey`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue +external fun uniffi_offline_protocol_uniffi_fn_func_parse_invite(`blob`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue external fun ffi_offline_protocol_uniffi_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun ffi_offline_protocol_uniffi_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -1786,6 +1798,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_offline_protocol_uniffi_checksum_func_derive_address() != 55050.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_offline_protocol_uniffi_checksum_func_parse_invite() != 15865.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_offline_protocol_uniffi_checksum_method_meshservices_discover_services() != 866.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1855,6 +1870,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_group() != 8723.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_invite() != 26172.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_derive_user_id_from_public_key() != 23152.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -2158,6 +2176,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_request_group_relay_registration() != 5596.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_resolve_username() != 5393.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_resume() != 39596.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -3268,6 +3289,8 @@ public interface OfflineProtocolInterface { fun `createGroup`(`groupName`: kotlin.String): MlsGroupInfo + fun `createInvite`(`petname`: kotlin.String?, `sign`: kotlin.Boolean): kotlin.String + fun `deriveUserIdFromPublicKey`(`publicKey`: List): kotlin.String fun `emitTestEvent`() @@ -3470,6 +3493,8 @@ public interface OfflineProtocolInterface { fun `requestGroupRelayRegistration`(`groupId`: kotlin.String): kotlin.Boolean + fun `resolveUsername`(`username`: kotlin.String): kotlin.Boolean + fun `resume`() fun `reticulumConfirmSent`(`messageId`: kotlin.String) @@ -3904,6 +3929,20 @@ open class OfflineProtocol: Disposable, AutoCloseable, OfflineProtocolInterface } + + @Throws(ProtocolException::class)override fun `createInvite`(`petname`: kotlin.String?, `sign`: kotlin.Boolean): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCallWithError(ProtocolException) { _status -> + UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_create_invite( + it, + FfiConverterOptionalString.lower(`petname`),FfiConverterBoolean.lower(`sign`),_status) +} + } + ) + } + + override fun `deriveUserIdFromPublicKey`(`publicKey`: List): kotlin.String { return FfiConverterString.lift( callWithHandle { @@ -5228,6 +5267,20 @@ open class OfflineProtocol: Disposable, AutoCloseable, OfflineProtocolInterface + @Throws(ProtocolException::class)override fun `resolveUsername`(`username`: kotlin.String): kotlin.Boolean { + return FfiConverterBoolean.lift( + callWithHandle { + uniffiRustCallWithError(ProtocolException) { _status -> + UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_resolve_username( + it, + FfiConverterString.lower(`username`),_status) +} + } + ) + } + + + @Throws(ProtocolException::class)override fun `resume`() = callWithHandle { @@ -6516,6 +6569,52 @@ public object FfiConverterTypeInternetMessage: FfiConverterRustBuffer + , + var `petname`: kotlin.String? + , + var `signed`: kotlin.Boolean + +){ + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeInviteInfo: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): InviteInfo { + return InviteInfo( + FfiConverterString.read(buf), + FfiConverterSequenceUByte.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterBoolean.read(buf), + ) + } + + override fun allocationSize(value: InviteInfo) = ( + FfiConverterString.allocationSize(value.`address`) + + FfiConverterSequenceUByte.allocationSize(value.`publicKey`) + + FfiConverterOptionalString.allocationSize(value.`petname`) + + FfiConverterBoolean.allocationSize(value.`signed`) + ) + + override fun write(value: InviteInfo, buf: ByteBuffer) { + FfiConverterString.write(value.`address`, buf) + FfiConverterSequenceUByte.write(value.`publicKey`, buf) + FfiConverterOptionalString.write(value.`petname`, buf) + FfiConverterBoolean.write(value.`signed`, buf) + } +} + + + data class MediaMetadata ( var `mimeType`: kotlin.String , @@ -7449,6 +7548,8 @@ data class ProtocolConfig ( , var `nostrColdContactEnabled`: kotlin.Boolean = true , + var `nostrUsernameDiscoveryEnabled`: kotlin.Boolean = false + , var `compactEnvelopeEnabled`: kotlin.Boolean = true , var `richPayloadEnabled`: kotlin.Boolean = true @@ -7496,6 +7597,7 @@ public object FfiConverterTypeProtocolConfig: FfiConverterRustBuffer + UniffiLib.uniffi_offline_protocol_uniffi_fn_func_parse_invite( + + FfiConverterString.lower(`blob`),_status) +} + ) + } + + diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/ProtocolConfigParserTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/ProtocolConfigParserTest.kt index 12732e5a..bd081f10 100644 --- a/bindings/react-native/android/src/test/java/com/offlineprotocol/ProtocolConfigParserTest.kt +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/ProtocolConfigParserTest.kt @@ -86,6 +86,47 @@ class ProtocolConfigParserTest { ) } + @Test + fun nostrUsernameDiscoveryDefaultsOffWhenOmitted() { + // OFF by default, unlike the two switches above. Publishing a claim + // binds a human-readable name to an address in a public place, so an + // app must opt in rather than inherit it. + assertFalse(parse("""{"appId":"app","userId":"alice"}""").nostrUsernameDiscoveryEnabled) + } + + @Test + fun nostrUsernameDiscoveryReadsBothShapes() { + // Same nested-home-plus-flat-fallback contract. A parser that missed a + // shape here would silently reset the flag to its default, which for + // this one means an app that asked to publish silently does not. + assertTrue( + parse( + """{"appId":"app","userId":"alice","transports":{"nostr":{"usernameDiscoveryEnabled":true}}}""" + ).nostrUsernameDiscoveryEnabled + ) + assertTrue( + parse( + """{"appId":"app","userId":"alice","transports":{"nostr":{"username_discovery_enabled":true}}}""" + ).nostrUsernameDiscoveryEnabled + ) + assertTrue( + parse("""{"appId":"app","userId":"alice","nostrUsernameDiscoveryEnabled":true}""") + .nostrUsernameDiscoveryEnabled + ) + assertTrue( + parse("""{"appId":"app","userId":"alice","nostr_username_discovery_enabled":true}""") + .nostrUsernameDiscoveryEnabled + ) + } + + @Test + fun nestedNostrUsernameDiscoveryWinsOverTopLevel() { + val config = parse( + """{"appId":"app","userId":"alice","nostrUsernameDiscoveryEnabled":true,"transports":{"nostr":{"usernameDiscoveryEnabled":false}}}""" + ) + assertFalse(config.nostrUsernameDiscoveryEnabled) + } + @Test fun nestedNostrColdContactWinsOverTopLevel() { val config = parse( diff --git a/bindings/react-native/ios/Generated/offline_protocol.swift b/bindings/react-native/ios/Generated/offline_protocol.swift index f3e54340..a8fc2ccc 100644 --- a/bindings/react-native/ios/Generated/offline_protocol.swift +++ b/bindings/react-native/ios/Generated/offline_protocol.swift @@ -830,6 +830,8 @@ public protocol OfflineProtocolProtocol: AnyObject, Sendable { func createGroup(groupName: String) throws -> MlsGroupInfo + func createInvite(petname: String?, sign: Bool) throws -> String + func deriveUserIdFromPublicKey(publicKey: [UInt8]) -> String func emitTestEvent() @@ -1032,6 +1034,8 @@ public protocol OfflineProtocolProtocol: AnyObject, Sendable { func requestGroupRelayRegistration(groupId: String) throws -> Bool + func resolveUsername(username: String) throws -> Bool + func resume() throws func reticulumConfirmSent(messageId: String) @@ -1331,6 +1335,16 @@ open func createGroup(groupName: String)throws -> MlsGroupInfo { }) } +open func createInvite(petname: String?, sign: Bool)throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeProtocolError_lift) { + uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_create_invite( + self.uniffiCloneHandle(), + FfiConverterOptionString.lower(petname), + FfiConverterBool.lower(sign),$0 + ) +}) +} + open func deriveUserIdFromPublicKey(publicKey: [UInt8]) -> String { return try! FfiConverterString.lift(try! rustCall() { uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_derive_user_id_from_public_key( @@ -2194,6 +2208,15 @@ open func requestGroupRelayRegistration(groupId: String)throws -> Bool { }) } +open func resolveUsername(username: String)throws -> Bool { + return try FfiConverterBool.lift(try rustCallWithError(FfiConverterTypeProtocolError_lift) { + uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_resolve_username( + self.uniffiCloneHandle(), + FfiConverterString.lower(username),$0 + ) +}) +} + open func resume()throws {try rustCallWithError(FfiConverterTypeProtocolError_lift) { uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_resume( self.uniffiCloneHandle(),$0 @@ -3472,6 +3495,66 @@ public func FfiConverterTypeInternetMessage_lower(_ value: InternetMessage) -> R } +public struct InviteInfo: Equatable, Hashable { + public var address: String + public var publicKey: [UInt8] + public var petname: String? + public var signed: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(address: String, publicKey: [UInt8], petname: String?, signed: Bool) { + self.address = address + self.publicKey = publicKey + self.petname = petname + self.signed = signed + } + + +} + +#if compiler(>=6) +extension InviteInfo: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeInviteInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> InviteInfo { + return + try InviteInfo( + address: FfiConverterString.read(from: &buf), + publicKey: FfiConverterSequenceUInt8.read(from: &buf), + petname: FfiConverterOptionString.read(from: &buf), + signed: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: InviteInfo, into buf: inout [UInt8]) { + FfiConverterString.write(value.address, into: &buf) + FfiConverterSequenceUInt8.write(value.publicKey, into: &buf) + FfiConverterOptionString.write(value.petname, into: &buf) + FfiConverterBool.write(value.signed, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeInviteInfo_lift(_ buf: RustBuffer) throws -> InviteInfo { + return try FfiConverterTypeInviteInfo.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeInviteInfo_lower(_ value: InviteInfo) -> RustBuffer { + return FfiConverterTypeInviteInfo.lower(value) +} + + public struct MediaMetadata: Equatable, Hashable { public var mimeType: String public var fileName: String @@ -4598,13 +4681,14 @@ public struct ProtocolConfig: Equatable, Hashable { public var binaryWireEnabled: Bool public var nostrSealingEnabled: Bool public var nostrColdContactEnabled: Bool + public var nostrUsernameDiscoveryEnabled: Bool public var compactEnvelopeEnabled: Bool public var richPayloadEnabled: Bool public var cryptoRecoveryEnabled: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(appId: String, profile: String, bleEnabled: Bool, wifiDirectEnabled: Bool, internetEnabled: Bool, reticulumEnabled: Bool, nostrEnabled: Bool, preferOnline: Bool, initialTtl: UInt8, encryptionEnabled: Bool, autoKeyExchange: Bool, storePending: Bool, requireEncryption: Bool = true, maxPendingPerPeer: UInt64, maxPendingGlobal: UInt64, pendingTtlMs: UInt64, overflowPolicy: OverflowPolicy, maxGroupMembers: UInt32 = UInt32(256), groupRelayEnabled: Bool = true, groupRelayBroadcastEnabled: Bool = true, groupEnforceAdminCommits: Bool = false, requireTransportIdentity: Bool = false, binaryWireEnabled: Bool = true, nostrSealingEnabled: Bool = true, nostrColdContactEnabled: Bool = true, compactEnvelopeEnabled: Bool = true, richPayloadEnabled: Bool = true, cryptoRecoveryEnabled: Bool = true) { + public init(appId: String, profile: String, bleEnabled: Bool, wifiDirectEnabled: Bool, internetEnabled: Bool, reticulumEnabled: Bool, nostrEnabled: Bool, preferOnline: Bool, initialTtl: UInt8, encryptionEnabled: Bool, autoKeyExchange: Bool, storePending: Bool, requireEncryption: Bool = true, maxPendingPerPeer: UInt64, maxPendingGlobal: UInt64, pendingTtlMs: UInt64, overflowPolicy: OverflowPolicy, maxGroupMembers: UInt32 = UInt32(256), groupRelayEnabled: Bool = true, groupRelayBroadcastEnabled: Bool = true, groupEnforceAdminCommits: Bool = false, requireTransportIdentity: Bool = false, binaryWireEnabled: Bool = true, nostrSealingEnabled: Bool = true, nostrColdContactEnabled: Bool = true, nostrUsernameDiscoveryEnabled: Bool = false, compactEnvelopeEnabled: Bool = true, richPayloadEnabled: Bool = true, cryptoRecoveryEnabled: Bool = true) { self.appId = appId self.profile = profile self.bleEnabled = bleEnabled @@ -4630,6 +4714,7 @@ public struct ProtocolConfig: Equatable, Hashable { self.binaryWireEnabled = binaryWireEnabled self.nostrSealingEnabled = nostrSealingEnabled self.nostrColdContactEnabled = nostrColdContactEnabled + self.nostrUsernameDiscoveryEnabled = nostrUsernameDiscoveryEnabled self.compactEnvelopeEnabled = compactEnvelopeEnabled self.richPayloadEnabled = richPayloadEnabled self.cryptoRecoveryEnabled = cryptoRecoveryEnabled @@ -4674,6 +4759,7 @@ public struct FfiConverterTypeProtocolConfig: FfiConverterRustBuffer { binaryWireEnabled: FfiConverterBool.read(from: &buf), nostrSealingEnabled: FfiConverterBool.read(from: &buf), nostrColdContactEnabled: FfiConverterBool.read(from: &buf), + nostrUsernameDiscoveryEnabled: FfiConverterBool.read(from: &buf), compactEnvelopeEnabled: FfiConverterBool.read(from: &buf), richPayloadEnabled: FfiConverterBool.read(from: &buf), cryptoRecoveryEnabled: FfiConverterBool.read(from: &buf) @@ -4706,6 +4792,7 @@ public struct FfiConverterTypeProtocolConfig: FfiConverterRustBuffer { FfiConverterBool.write(value.binaryWireEnabled, into: &buf) FfiConverterBool.write(value.nostrSealingEnabled, into: &buf) FfiConverterBool.write(value.nostrColdContactEnabled, into: &buf) + FfiConverterBool.write(value.nostrUsernameDiscoveryEnabled, into: &buf) FfiConverterBool.write(value.compactEnvelopeEnabled, into: &buf) FfiConverterBool.write(value.richPayloadEnabled, into: &buf) FfiConverterBool.write(value.cryptoRecoveryEnabled, into: &buf) @@ -5568,10 +5655,11 @@ public struct TransportConfig: Equatable, Hashable { public var nostrEnabled: Bool public var nostrSealingEnabled: Bool public var nostrColdContactEnabled: Bool + public var nostrUsernameDiscoveryEnabled: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(bleEnabled: Bool, wifiDirectEnabled: Bool, internetEnabled: Bool, reticulumEnabled: Bool, nostrEnabled: Bool, nostrSealingEnabled: Bool = true, nostrColdContactEnabled: Bool = true) { + public init(bleEnabled: Bool, wifiDirectEnabled: Bool, internetEnabled: Bool, reticulumEnabled: Bool, nostrEnabled: Bool, nostrSealingEnabled: Bool = true, nostrColdContactEnabled: Bool = true, nostrUsernameDiscoveryEnabled: Bool = false) { self.bleEnabled = bleEnabled self.wifiDirectEnabled = wifiDirectEnabled self.internetEnabled = internetEnabled @@ -5579,6 +5667,7 @@ public struct TransportConfig: Equatable, Hashable { self.nostrEnabled = nostrEnabled self.nostrSealingEnabled = nostrSealingEnabled self.nostrColdContactEnabled = nostrColdContactEnabled + self.nostrUsernameDiscoveryEnabled = nostrUsernameDiscoveryEnabled } @@ -5601,7 +5690,8 @@ public struct FfiConverterTypeTransportConfig: FfiConverterRustBuffer { reticulumEnabled: FfiConverterBool.read(from: &buf), nostrEnabled: FfiConverterBool.read(from: &buf), nostrSealingEnabled: FfiConverterBool.read(from: &buf), - nostrColdContactEnabled: FfiConverterBool.read(from: &buf) + nostrColdContactEnabled: FfiConverterBool.read(from: &buf), + nostrUsernameDiscoveryEnabled: FfiConverterBool.read(from: &buf) ) } @@ -5613,6 +5703,7 @@ public struct FfiConverterTypeTransportConfig: FfiConverterRustBuffer { FfiConverterBool.write(value.nostrEnabled, into: &buf) FfiConverterBool.write(value.nostrSealingEnabled, into: &buf) FfiConverterBool.write(value.nostrColdContactEnabled, into: &buf) + FfiConverterBool.write(value.nostrUsernameDiscoveryEnabled, into: &buf) } } @@ -9693,6 +9784,13 @@ public func deriveAddress(publicKey: [UInt8])throws -> String { ) }) } +public func parseInvite(blob: String)throws -> InviteInfo { + return try FfiConverterTypeInviteInfo_lift(try rustCallWithError(FfiConverterTypeProtocolError_lift) { + uniffi_offline_protocol_uniffi_fn_func_parse_invite( + FfiConverterString.lower(blob),$0 + ) +}) +} private enum InitializationResult { case ok @@ -9712,6 +9810,9 @@ private let initializationResult: InitializationResult = { if (uniffi_offline_protocol_uniffi_checksum_func_derive_address() != 55050) { return InitializationResult.apiChecksumMismatch } + if (uniffi_offline_protocol_uniffi_checksum_func_parse_invite() != 15865) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_offline_protocol_uniffi_checksum_method_meshservices_discover_services() != 866) { return InitializationResult.apiChecksumMismatch } @@ -9781,6 +9882,9 @@ private let initializationResult: InitializationResult = { if (uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_group() != 8723) { return InitializationResult.apiChecksumMismatch } + if (uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_invite() != 26172) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_derive_user_id_from_public_key() != 23152) { return InitializationResult.apiChecksumMismatch } @@ -10084,6 +10188,9 @@ private let initializationResult: InitializationResult = { if (uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_request_group_relay_registration() != 5596) { return InitializationResult.apiChecksumMismatch } + if (uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_resolve_username() != 5393) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_resume() != 39596) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/react-native/ios/Generated/offline_protocolFFI.h b/bindings/react-native/ios/Generated/offline_protocolFFI.h index 9655e821..7dfbb9fc 100644 --- a/bindings/react-native/ios/Generated/offline_protocolFFI.h +++ b/bindings/react-native/ios/Generated/offline_protocolFFI.h @@ -612,6 +612,11 @@ void uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_cleanup_expired_ro RustBuffer uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_create_group(uint64_t ptr, RustBuffer group_name, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_CREATE_INVITE +#define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_CREATE_INVITE +RustBuffer uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_create_invite(uint64_t ptr, RustBuffer petname, int8_t sign, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_DERIVE_USER_ID_FROM_PUBLIC_KEY #define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_DERIVE_USER_ID_FROM_PUBLIC_KEY RustBuffer uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_derive_user_id_from_public_key(uint64_t ptr, RustBuffer public_key, RustCallStatus *_Nonnull out_status @@ -1117,6 +1122,11 @@ void uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_rename_group(uint6 int8_t uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_request_group_relay_registration(uint64_t ptr, RustBuffer group_id, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_RESOLVE_USERNAME +#define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_RESOLVE_USERNAME +int8_t uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_resolve_username(uint64_t ptr, RustBuffer username, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_RESUME #define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_RESUME void uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_resume(uint64_t ptr, RustCallStatus *_Nonnull out_status @@ -1392,6 +1402,11 @@ void uniffi_offline_protocol_uniffi_fn_init_callback_vtable_wifidirecttransportc RustBuffer uniffi_offline_protocol_uniffi_fn_func_derive_address(RustBuffer public_key, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_FUNC_PARSE_INVITE +#define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_FUNC_PARSE_INVITE +RustBuffer uniffi_offline_protocol_uniffi_fn_func_parse_invite(RustBuffer blob, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_FFI_OFFLINE_PROTOCOL_UNIFFI_RUSTBUFFER_ALLOC #define UNIFFI_FFIDEF_FFI_OFFLINE_PROTOCOL_UNIFFI_RUSTBUFFER_ALLOC RustBuffer ffi_offline_protocol_uniffi_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status @@ -1656,6 +1671,12 @@ void ffi_offline_protocol_uniffi_rust_future_complete_void(uint64_t handle, Rust #define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_FUNC_DERIVE_ADDRESS uint16_t uniffi_offline_protocol_uniffi_checksum_func_derive_address(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_FUNC_PARSE_INVITE +#define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_FUNC_PARSE_INVITE +uint16_t uniffi_offline_protocol_uniffi_checksum_func_parse_invite(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_MESHSERVICES_DISCOVER_SERVICES @@ -1794,6 +1815,12 @@ uint16_t uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_cleanup_ #define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_CREATE_GROUP uint16_t uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_group(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_CREATE_INVITE +#define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_CREATE_INVITE +uint16_t uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_create_invite(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_DERIVE_USER_ID_FROM_PUBLIC_KEY @@ -2400,6 +2427,12 @@ uint16_t uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_rename_g #define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_REQUEST_GROUP_RELAY_REGISTRATION uint16_t uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_request_group_relay_registration(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_RESOLVE_USERNAME +#define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_RESOLVE_USERNAME +uint16_t uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_resolve_username(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_RESUME diff --git a/bindings/react-native/ios/OfflineProtocolModule.m b/bindings/react-native/ios/OfflineProtocolModule.m index 659da341..a0a4e851 100644 --- a/bindings/react-native/ios/OfflineProtocolModule.m +++ b/bindings/react-native/ios/OfflineProtocolModule.m @@ -587,6 +587,22 @@ @interface RCT_EXTERN_MODULE(OfflineProtocolModule, RCTEventEmitter) resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(parseInvite:(NSString *)blob + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +// `sign:`, never `signed:` — the macro expands the parameter name into a C +// declaration, and `signed` is a C type specifier, so `NSNumber *signed` is a +// syntax error rather than a variable. Must match the Swift @objc selector. +RCT_EXTERN_METHOD(createInvite:(NSString *)petname + sign:(nonnull NSNumber *)sign + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(resolveUsername:(NSString *)username + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + RCT_EXTERN_METHOD(deriveUserIdFromPublicKey:(NSArray *)publicKey resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) diff --git a/bindings/react-native/ios/OfflineProtocolModule.swift b/bindings/react-native/ios/OfflineProtocolModule.swift index e33c8d26..71d41f4d 100644 --- a/bindings/react-native/ios/OfflineProtocolModule.swift +++ b/bindings/react-native/ios/OfflineProtocolModule.swift @@ -354,6 +354,16 @@ class OfflineProtocolModule: RCTEventEmitter { ?? raw["nostr_cold_contact_enabled"] as? Bool ?? true + // Nostr username discovery (claim publication + username resolution). + // Same nested-then-flat shape, but the default is FALSE: publishing + // binds a human-readable name to an address in a public place, which + // an app must opt into rather than inherit. + let nostrUsernameDiscoveryEnabled = nostrRaw?["usernameDiscoveryEnabled"] as? Bool + ?? nostrRaw?["username_discovery_enabled"] as? Bool + ?? raw["nostrUsernameDiscoveryEnabled"] as? Bool + ?? raw["nostr_username_discovery_enabled"] as? Bool + ?? false + // Group section (nested home under `group`, then top level, both // cases — same shape rules as `encryption`; mirrors // ProtocolConfigParser.kt, keep in sync). These were UniFFI-only @@ -411,6 +421,7 @@ class OfflineProtocolModule: RCTEventEmitter { binaryWireEnabled: binaryWireEnabled, nostrSealingEnabled: nostrSealingEnabled, nostrColdContactEnabled: nostrColdContactEnabled, + nostrUsernameDiscoveryEnabled: nostrUsernameDiscoveryEnabled, compactEnvelopeEnabled: encryption.compactEnvelopeEnabled, richPayloadEnabled: encryption.richPayloadEnabled, cryptoRecoveryEnabled: encryption.cryptoRecoveryEnabled @@ -3380,6 +3391,83 @@ class OfflineProtocolModule: RCTEventEmitter { } } + /// Decode and verify an invite blob. Needs no protocol instance, so a + /// scanner can check a QR code before `create()`. + /// + /// Verification is total: a rejected blob throws rather than resolving + /// with a warning flag, because every failure mode here means the invite + /// must not be acted on. + /// + /// Same Swift-name-differs-from-JS-name rule as `deriveAddressBridge`: an + /// unqualified `parseInvite(blob:)` must resolve to the generated UniFFI + /// global rather than recurse into this method. + @objc(parseInvite:resolver:rejecter:) + func parseInviteBridge(_ blob: String, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + do { + let invite = try parseInvite(blob: blob) + resolver([ + "address": invite.address, + "public_key": invite.publicKey.map { NSNumber(value: $0) }, + "petname": invite.petname as Any, + "signed": invite.signed, + ]) + } catch { + rejecter("ERROR_INVALID_INVITE", + "Invite did not verify: \(error.localizedDescription)", + error) + } + } + + /// Build an invite blob for this identity. + /// + /// Sign it when the invite may travel without its issuer; leave it + /// unsigned for a QR shown phone to phone. See the JS `createInvite` doc. + /// + /// The argument label is `sign`, never `signed`: this method's selector + /// reaches Objective-C through `OfflineProtocolModule.m`, where a + /// parameter named `signed` is a C type specifier rather than an + /// identifier and does not compile. The JS-facing name is unaffected — + /// React Native bridges positionally. + @objc func createInvite(_ petname: String?, + sign: NSNumber, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + guard let proto = protocolInstance else { + rejecter("ERROR_NOT_INITIALIZED", "Protocol not initialized", nil) + return + } + do { + resolver(try proto.createInvite(petname: petname, sign: sign.boolValue)) + } catch { + rejecter("ERROR_CRYPTO", + "Failed to create invite: \(error.localizedDescription)", + error) + } + } + + /// Resolve a username to the set of devices claiming it. + /// + /// The answer arrives as one `username_resolved` event carrying every + /// verified claim. Never auto-select from it: see the JS `resolveUsername` + /// doc for why picking the first entry defeats the design. + @objc func resolveUsername(_ username: String, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + guard let proto = protocolInstance else { + rejecter("ERROR_NOT_INITIALIZED", "Protocol not initialized", nil) + return + } + do { + resolver(try proto.resolveUsername(username: username)) + } catch { + rejecter("ERROR_INVALID_ARGUMENT", + "Failed to resolve username: \(error.localizedDescription)", + error) + } + } + /// Derive a user ID from a public key. /// /// Deprecated: use `deriveAddress`, which needs no protocol instance and diff --git a/bindings/react-native/js-ci-harness/relay-config.test.js b/bindings/react-native/js-ci-harness/relay-config.test.js index 5bab858b..be3c7056 100644 --- a/bindings/react-native/js-ci-harness/relay-config.test.js +++ b/bindings/react-native/js-ci-harness/relay-config.test.js @@ -147,6 +147,53 @@ test('every relay field configured at create time reaches native', async () => { }); }); +// --------------------------------------------------------------------------- +// The Nostr username-discovery switch +// +// Same silent-failure class as the relay fields above, and worse in one +// direction: this flag governs whether a human-readable name is published to +// public relays. A drop between JS and native leaves the engine on its default +// with no error anywhere, so an app that asked to publish quietly does not, and +// an app that asked NOT to would quietly publish if the default were the other +// way. Assert both directions and the default. +// --------------------------------------------------------------------------- + +test('username discovery defaults to off in the create payload', async () => { + const sdk = newSdk(); + await sdk.start(); + + assert.equal( + payloadOf('create').nostrUsernameDiscoveryEnabled, + false, + 'publishing a name to a public directory must never be inherited' + ); +}); + +test('username discovery crosses the bridge when enabled', async () => { + const sdk = newSdk({ transports: { nostr: { usernameDiscoveryEnabled: true } } }); + await sdk.start(); + + assert.equal( + payloadOf('create').nostrUsernameDiscoveryEnabled, + true, + 'the nested key is the documented home; a drop here silently disables the feature' + ); +}); + +test('username discovery is explicitly present, never omitted', async () => { + const sdk = newSdk({ transports: { nostr: { usernameDiscoveryEnabled: false } } }); + await sdk.start(); + + // Present-and-false rather than absent: the native parsers fall back to + // their own literal default for a missing key, so an omitted field and an + // explicit false are indistinguishable on the far side. They agree today; + // asserting presence is what keeps a future default flip from being silent. + assert.ok( + 'nostrUsernameDiscoveryEnabled' in payloadOf('create'), + 'the flag must be sent explicitly, not left to the native default' + ); +}); + test('a relay section without a priority still crosses the bridge', async () => { // The old gate was `if (relay?.relayPriority)`, so a config that set only a // battery floor was dropped whole — the shape most apps actually write. diff --git a/bindings/react-native/src/index.ts b/bindings/react-native/src/index.ts index 8c7ed0f0..3ff3f0a4 100644 --- a/bindings/react-native/src/index.ts +++ b/bindings/react-native/src/index.ts @@ -58,6 +58,7 @@ import type { TransportMetrics, RelayConfig, RelayPriority, + InviteInfo, } from './types'; import { ContentType, MessagePriority } from './types'; import { @@ -133,6 +134,7 @@ interface NativeConfig { nostrEnabled: boolean; nostrSealingEnabled: boolean; nostrColdContactEnabled: boolean; + nostrUsernameDiscoveryEnabled: boolean; preferOnline: boolean; initialTtl: number; binaryWireEnabled: boolean; @@ -423,6 +425,10 @@ export class OfflineProtocol { // Same nested-is-the-documented-home shape as sealingEnabled above. nostrColdContactEnabled: this.config.transports?.nostr?.coldContactEnabled ?? true, + // Off by default, unlike the two above: publishing a username claim is + // materially more disclosure than publishing a key-package record. + nostrUsernameDiscoveryEnabled: + this.config.transports?.nostr?.usernameDiscoveryEnabled ?? false, preferOnline: dorsSource?.preferOnline ?? false, initialTtl: this.config.network?.initialTtl ?? 8, binaryWireEnabled: this.config.binaryWireEnabled ?? true, @@ -2612,6 +2618,91 @@ export class OfflineProtocol { return await OfflineProtocolNativeModule.deriveAddress(publicKey); } + /** + * Decodes and verifies an invite blob. + * + * Needs no protocol instance — safe to call before `create()`, which is the + * whole point: a scanner verifies a QR code before deciding to act on it. + * + * Verification is mandatory and total. The address must be the one its + * public key derives to, and any signature present must verify under that + * key, so a resolved `InviteInfo` is always self-certified. + * + * **What it does not prove:** that the invite came from who you think. An + * attacker's own correctly-signed invite is indistinguishable from a + * legitimate stranger's — only the out-of-band context (this QR was on + * *this* person's screen) carries that. + * + * @param blob - The base64url payload, e.g. the `c` query parameter + * @returns The verified invite + * @throws If the blob is malformed, the address is not the key's, or a + * signature does not verify. Every case means refuse, not warn. + */ + async parseInvite(blob: string): Promise { + return await OfflineProtocolNativeModule.parseInvite(blob); + } + + /** + * Builds an invite blob for this identity. + * + * The result is opaque base64url. Apps own the container; the recommended + * form is one parameter, `://connect?c=`, so it composes + * with an existing scheme and route. + * + * Sign it when the invite may travel **without its issuer** — a link + * forwarded through a third party — because the signature binds the petname + * to the key, so a forwarded invite cannot save Alice's key under the name + * "Bob". Leave it unsigned for a QR shown phone to phone: the physical + * channel already authenticates it, and an app that lets the user confirm + * the name has made the user the authority over it. Signing costs about 90 + * characters. + * + * Carries no key package by design (an MLS init key is single-use and a QR + * code is static, so pairing them guarantees a collision as soon as two + * people scan the same code) and no expiry (a printed QR that stops working + * is a bug). + * + * @param petname - Suggested display name, ≤ 64 bytes + * @param signed - Whether to bind the petname to the key + */ + async createInvite(petname?: string, signed = false): Promise { + return await OfflineProtocolNativeModule.createInvite( + petname ?? null, + signed + ); + } + + /** + * Resolves a username to the set of devices claiming it. + * + * Requires `transports.nostr.usernameDiscoveryEnabled`. Resolves `true` if + * this call started the lookup and `false` if it joined one already in + * flight. **Both mean an answer is coming**: exactly one + * `username_resolved` event follows either way, so awaiting that event after + * either result is safe. + * + * Every case where no event will ever arrive **rejects** instead — discovery + * disabled, or too many lookups in flight — so a `false` can never leave a + * spinner running forever. + * + * The answer carries **every** verified claim. There is deliberately no + * "best" claim and no ordering: anyone may publish any name, so what comes + * back is a set of assertions for a human to arbitrate, not a lookup result. + * + * **Do not auto-select.** Taking the first entry turns a non-authoritative + * directory into an authoritative-looking one — the user then believes the + * *name* was verified when only a key ever was. Present the claims, have the + * user confirm out of band, and store the address, never the name: a name + * can be re-claimed by anyone tomorrow, an address is self-certifying. + * + * @param username - The name to look up; normalized to NFC and lowercase + * @returns `true` if this call started the lookup, `false` if it joined one + * @throws If discovery is disabled, or too many lookups are in flight + */ + async resolveUsername(username: string): Promise { + return await OfflineProtocolNativeModule.resolveUsername(username); + } + /** * This device's own address (`off1…`), or `null` before startup completes. * diff --git a/bindings/react-native/src/types.ts b/bindings/react-native/src/types.ts index f2e64fa8..806945ac 100644 --- a/bindings/react-native/src/types.ts +++ b/bindings/react-native/src/types.ts @@ -245,6 +245,32 @@ export interface NostrTransportConfig { * Turn it off to keep the transport silent until it has traffic. */ coldContactEnabled?: boolean; + + /** + * Publish a username discovery record for this install's profile, and allow + * `resolveUsername()` to look names up. + * + * **Off by default**, unlike `coldContactEnabled`, and it additionally + * requires cold contact to be on: a claim points at an address whose key + * packages a resolver fetches next, so without them the name resolves and + * then dead-ends one hop later. + * + * Buys back reach-by-username: a stranger who knows only a name can find the + * addresses claiming it. The name published is the app's configured + * `profile`, normalized to NFC and lowercase. + * + * **Default-off is deliberate, and the reason is disclosure.** Publishing + * binds a human-readable name to an address in a public place — here the + * mapping *is* the payload, which is materially more than the key-package + * record's "an install with this tag exists". The record is sealed, so a + * relay scraping by kind reads nothing, but anyone who guesses the name can + * compute the tag and read the claim. + * + * **The directory is not authoritative.** Anyone may claim any name, so a + * resolution returns the whole set of claimants and a human must confirm out + * of band. See {@link UsernameResolvedEvent}. + */ + usernameDiscoveryEnabled?: boolean; } /** @@ -1495,6 +1521,103 @@ export interface GroupRelaySyncChangedEvent extends BaseEvent { reason: string; } +/** + * A decoded and verified invite. + * + * Every field has passed verification: the address is the one its public key + * derives to, and when `signed` is true the petname is bound to that key by + * the key's owner. + */ +export interface InviteInfo { + /** The address this invite reaches, canonical `off1…`. */ + address: string; + /** The Ed25519 identity key the address derives from. */ + public_key: number[]; + /** + * Suggested display name, if the invite carried one. + * + * Suggested, never authoritative: a petname is a *locally assigned* name and + * an app is right to let the user edit it before saving. + */ + petname: string | null; + /** + * Whether a valid signature accompanied the invite. + * + * `false` does **not** mean untrustworthy — an unsigned invite is the + * ordinary shape for a QR shown phone to phone, where the physical channel + * is the authentication. It means only that the petname is unbound to the + * key, so a forwarded copy could carry a different name. + */ + signed: boolean; +} + +/** + * One device's verified claim to a username. + * + * Every field here has already passed verification: the address derives from + * the public key, the record's signature verifies under that key, and the + * record was published under the Nostr key it names. What that proves is + * narrow: **this key asserts this name**. It does not prove the name belongs + * to the claimant, because nothing can. + */ +export interface UsernameClaim { + /** + * The claimed address, canonical `off1…`. + * + * This is the value to keep. An app that stores the *name* has stored + * something anyone can re-claim tomorrow; the address is self-certifying. + */ + address: string; + /** The Ed25519 identity key the address derives from, base64. */ + public_key: string; + /** + * When the claimant signed the record, in milliseconds since the epoch. + * + * **Advisory.** A record is not a liveness signal. An old claim from a peer + * who has been offline for a month is still valid, and filtering on age + * would make them unreachable by name while their key packages sit live on + * a relay. Sort by it if it helps a user choose; do not filter on it. + */ + issued_at_ms: number; +} + +/** + * A username resolution finished, carrying **every** claim found. + * + * ## The set is the whole point + * + * Anyone may publish any username claim, so a name resolves to a set of + * assertions, never to an answer. Even a single-device user is a set of one. + * This event fires exactly once per resolution and carries the complete set + * precisely so an app cannot accidentally treat the first arrival as the + * winner: there is no per-claim event to race and no ordering to mistake for + * a ranking. + * + * **Let the user choose.** Silently picking one claim converts a + * non-authoritative directory into an authoritative-looking one, which is + * worse than not shipping the feature: the user believes they are talking to + * the name, and the protocol only ever promised them a key. Present the + * claims, have a human confirm out of band (a QR code, a shared secret, a + * voice call), and store the address rather than the name. + * + * An empty `claims` list is an ordinary outcome, not an error. + */ +export interface UsernameResolvedEvent extends BaseEvent { + type: 'username_resolved'; + /** The normalized username that was resolved. */ + username: string; + /** Every claim that verified, in no meaningful order. */ + claims: UsernameClaim[]; + /** + * How many records were seen but rejected. + * + * Non-zero is normal, not an error: the tag is public, anyone may publish + * to it, and junk arrives. Surfaced so "not found, having seen nothing" can + * be told apart from "not found, everything was junk". + */ + rejected: number; +} + /** * Group message sent event — a group message was sent to all members via mesh * (MLS-encrypted fan-out). @@ -2217,6 +2340,7 @@ export type ProtocolEvent = | UserGroupsEvent | GroupErrorEvent | GroupRelaySyncChangedEvent + | UsernameResolvedEvent | GroupMessageSentEvent | GroupMessagePartialFailureEvent | GroupMessageDeliveryReportEvent diff --git a/crates/offline-protocol-core/Cargo.toml b/crates/offline-protocol-core/Cargo.toml index c388fbb5..ecc20767 100644 --- a/crates/offline-protocol-core/Cargo.toml +++ b/crates/offline-protocol-core/Cargo.toml @@ -24,6 +24,7 @@ serde_json = { workspace = true } postcard = { workspace = true } base64 = { workspace = true } bech32 = { workspace = true } +unicode-normalization = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } diff --git a/crates/offline-protocol-core/src/lib.rs b/crates/offline-protocol-core/src/lib.rs index 6ed353ff..0fd42e70 100644 --- a/crates/offline-protocol-core/src/lib.rs +++ b/crates/offline-protocol-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod message; pub mod service; pub mod sync; pub mod types; +pub mod username; pub mod wire; pub use address::{Address, AddressError}; @@ -30,4 +31,5 @@ pub use types::{ validate_id_chars, AppId, HopCount, IdValidationError, LamportClock, LocalInstant, Timestamp, UserId, WallClockTimestamp, MAX_ID_LEN, TTL, }; +pub use username::{contains_control_or_format, Username, UsernameError}; pub use wire::{WIRE_V1_MAGIC, WIRE_VERSION_V1}; diff --git a/crates/offline-protocol-core/src/username.rs b/crates/offline-protocol-core/src/username.rs new file mode 100644 index 00000000..22c8578c --- /dev/null +++ b/crates/offline-protocol-core/src/username.rs @@ -0,0 +1,595 @@ +//! Normalized username claims. +//! +//! A username is not an identity. It is a *label* a device claims in a +//! non-authoritative directory, and the identity it points at is the +//! [`Address`](crate::Address) inside the signed record. Nothing in the +//! protocol authenticates a username: anyone may claim any name, and the +//! resolver's job is to hand back every claimant rather than to pick one. See +//! `docs/spec/username-discovery.md`. +//! +//! # Why this is a type and not a `String` +//! +//! A discovery tag is a hash of the normalized name. Two implementations that +//! normalize differently derive different tags and **silently fail to find each +//! other** — there is no error, no mismatch to observe, just an empty result +//! for a name that exists. That is the whole failure mode this type exists to +//! remove: normalization happens once, at parse, and the derivations downstream +//! take [`Username`] rather than `&str`, so "derive a tag for whatever the app +//! typed" does not compile. +//! +//! It is the same move [`Address`](crate::Address) makes for routing tags, and +//! for the same reason. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use unicode_normalization::UnicodeNormalization; + +/// Why a username string was rejected by [`Username::from_str`]. +// Adding a variant to a public error enum is a breaking change without this +// attribute; downstream crates must carry a wildcard arm. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum UsernameError { + /// The username is empty, or is nothing but whitespace. + /// + /// Whitespace-only is refused here rather than treated as a name: a claim + /// nobody can type back, and that renders as nothing at all in every UI, + /// is not a name. Note that normalization does not remove it — NFC + /// discards nothing — so this is a screen, not a consequence of one. + #[error("username is empty or whitespace after normalization")] + Empty, + /// The normalized username exceeds [`Username::MAX_BYTES`]. + /// + /// Measured after normalization, since that is the form that gets + /// published, hashed and signed. + #[error("username is {len} bytes after normalization, maximum is {max}")] + TooLong { + /// Normalized length in bytes. + len: usize, + /// The ceiling. + max: usize, + }, + /// The username has the shape of an [`Address`](crate::Address). + /// + /// Refused at the type rather than at publish time so no caller can skip + /// the screen. Two namespaces that never share a spelling cannot be + /// confused by a UI that renders both, and a claim on an address-shaped + /// name has no legitimate use. + #[error("username has the shape of an address, which is not claimable")] + AddressShaped, + /// The username contains a control or format character. + /// + /// A name that can contain a newline or a bidi override is a name that + /// renders as something other than itself in every UI that displays it. + /// + /// Both Unicode `Cc` (control) and `Cf` (format) are refused, and the + /// second is the one that matters: `char::is_control` covers only `Cc`, so + /// a screen built on it alone lets through U+202E RIGHT-TO-LEFT OVERRIDE + /// and the zero-width joiners — exactly the characters that make a name + /// display as something other than its own bytes. Confusables between + /// *scripts* remain out of scope (see the module docs); characters whose + /// entire function is to alter rendering are not. + #[error("username contains a control or format character")] + ControlCharacter, +} + +/// Whether `c` is a Unicode format (`Cf`) character. +/// +/// [`char::is_control`] tests `Cc` only, which misses every character whose +/// entire function is to change how the text around it renders: the bidi +/// overrides, the zero-width joiners, the word joiner, the byte-order mark. A +/// name containing one displays as something other than its own bytes, which is +/// the failure [`UsernameError::ControlCharacter`] exists to prevent, so `Cf` +/// has to be screened alongside `Cc`. +/// +/// Hand-rolled rather than taken from a general-category crate on purpose. The +/// alternative is a second Unicode table in the dependency graph, and this +/// crate is already carrying `unicode-normalization` into a workspace with a +/// binary-size profile (`minisize`) that cares. `Cf` is 21 ranges and it grows +/// by a handful per Unicode release. +/// +/// Snapshot of `Cf` as of **Unicode 16.0**, generated from the character +/// database rather than transcribed. Regenerate it the same way; a range +/// missing here is a name that renders as something else, not a crash. +/// `username_rejects_every_format_character_range` pins one member of every +/// range so a bad edit fails rather than silently narrowing the screen. +fn is_format_character(c: char) -> bool { + matches!(c, + '\u{00AD}' // SOFT HYPHEN + | '\u{0600}'..='\u{0605}' + | '\u{061C}' // ARABIC LETTER MARK + | '\u{06DD}' // ARABIC END OF AYAH + | '\u{070F}' // SYRIAC ABBREVIATION MARK + | '\u{0890}'..='\u{0891}' + | '\u{08E2}' // ARABIC DISPUTED END OF AYAH + | '\u{180E}' // MONGOLIAN VOWEL SEPARATOR + | '\u{200B}'..='\u{200F}' // ZWSP, ZWNJ, ZWJ, LRM, RLM + | '\u{202A}'..='\u{202E}' // bidi embedding and overrides + | '\u{2060}'..='\u{2064}' + | '\u{2066}'..='\u{206F}' // bidi isolates and deprecated formats + | '\u{FEFF}' // ZERO WIDTH NO-BREAK SPACE + | '\u{FFF9}'..='\u{FFFB}' + | '\u{110BD}' + | '\u{110CD}' + | '\u{13430}'..='\u{1343F}' + | '\u{1BCA0}'..='\u{1BCA3}' + | '\u{1D173}'..='\u{1D17A}' + | '\u{E0001}' // LANGUAGE TAG + | '\u{E0020}'..='\u{E007F}' // tag characters + ) +} + +/// Whether `s` contains a character that renders as something other than +/// itself: Unicode `Cc` (control) or `Cf` (format). +/// +/// Public because a username is not the only display string this protocol +/// signs. An invite's petname is the other one, and it is the *more* exposed of +/// the two: it is what an app renders in the confirmation dialog after a scan, +/// and when the invite is signed the deceptive rendering arrives carrying a +/// valid signature. One screen serves both, so the two cannot drift into +/// disagreeing about what a displayable name is. +/// +/// `Cf` is the half that matters and the half [`char::is_control`] misses; see +/// the `is_format_character` table below it. +pub fn contains_control_or_format(s: &str) -> bool { + s.chars().any(|c| c.is_control() || is_format_character(c)) +} + +/// A normalized username claim: NFC, lowercase, bounded, never address-shaped. +/// +/// Construct with [`Username::from_str`]. The stored form **is** the normalized +/// form, so [`Username::as_str`] is what gets hashed into a discovery tag, +/// signed into a record, and compared against a queried name. +/// +/// # Ordering +/// +/// [`Ord`] compares the normalized bytes. Nothing in the protocol breaks a tie +/// on usernames — a resolution returns an unordered set of claims and the +/// *user* arbitrates — so this exists for deterministic test fixtures and +/// `BTreeMap` keys, not for consensus. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Username { + normalized: String, +} + +impl Username { + /// Maximum length of the normalized form, in bytes. + /// + /// Bytes rather than characters because the bound exists to cap what goes + /// on the wire and through a hash, and a character count says nothing + /// about either. Matches the design record's `≤ 64 bytes`, which is far + /// above every username policy this SDK's apps enforce (user-service caps + /// at 20 characters) — the ceiling is a wire bound, not a policy. + pub const MAX_BYTES: usize = 64; + + /// Length of the canonical string form of an address. + /// + /// Duplicated from `Address::ENCODED_LEN` rather than imported so this + /// module stays a leaf: the screen below is a *shape* test that must reject + /// address-looking strings whose checksum is wrong, so it deliberately does + /// not parse. `username_address_shape_matches_real_addresses` pins the two + /// against each other. + const ADDRESS_LEN: usize = 44; + + /// Human-readable prefix of an address, including the bech32 separator. + const ADDRESS_PREFIX: &'static str = "off1"; + + /// Normalizes and validates a username. + /// + /// Normalization is **lowercase then NFC**, in that order. The order is + /// load-bearing: Unicode lowercasing can emit a decomposed sequence, so + /// normalizing first would leave a form that is not NFC. Running NFC last + /// makes the operation idempotent, which + /// [`username_normalization_is_idempotent`] pins — a non-idempotent + /// normalizer would derive one tag on publish and another on resolve. + /// + /// The lowercase step is [`str::to_lowercase`], which is the **full**, + /// language-insensitive Unicode mapping. That choice is part of the wire + /// format, not an implementation detail: the full and simple mappings + /// disagree wherever one character lowercases to several (`İ` becomes + /// `i` + U+0307 under full, a bare `i` under simple), so a second + /// implementation that picks the other one derives a different tag and the + /// two silently never find each other. Language-insensitive matters for + /// the same reason — the Turkish tailoring maps `I` to `ı`, which would + /// make a name's tag depend on its publisher's locale. + /// [`username_lowercases_with_the_full_language_insensitive_mapping`] pins + /// both halves. + fn normalize(raw: &str) -> String { + raw.to_lowercase().nfc().collect() + } + + /// Whether `candidate` has the shape of an address. + /// + /// A shape test, not a parse: an address with a corrupted checksum is not + /// a valid address but is still an address-*shaped* string, and claiming it + /// as a username is exactly as confusing in a UI as claiming a valid one. + fn looks_like_address(candidate: &str) -> bool { + candidate.len() == Self::ADDRESS_LEN + && candidate.starts_with(Self::ADDRESS_PREFIX) + && candidate + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + } + + /// Returns the normalized username. + /// + /// This is the form that is hashed, signed and published. A caller that + /// wants the string the user typed must keep it themselves; the protocol + /// only ever sees this one. + pub fn as_str(&self) -> &str { + &self.normalized + } + + /// Consumes the username, returning the normalized string. + pub fn into_string(self) -> String { + self.normalized + } +} + +impl FromStr for Username { + type Err = UsernameError; + + fn from_str(s: &str) -> Result { + let normalized = Self::normalize(s); + + // Whitespace-only is refused with the empty case rather than accepted: + // NFC discards nothing, so a name of three spaces survives + // normalization intact and would otherwise become a claim that renders + // as nothing and cannot be typed back. + if normalized.is_empty() || normalized.chars().all(char::is_whitespace) { + return Err(UsernameError::Empty); + } + if normalized.len() > Self::MAX_BYTES { + return Err(UsernameError::TooLong { + len: normalized.len(), + max: Self::MAX_BYTES, + }); + } + // Checked on the normalized form: a name that only becomes a control + // character after normalization would otherwise slip the screen. + if contains_control_or_format(&normalized) { + return Err(UsernameError::ControlCharacter); + } + if Self::looks_like_address(&normalized) { + return Err(UsernameError::AddressShaped); + } + + Ok(Self { normalized }) + } +} + +impl fmt::Display for Username { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.normalized) + } +} + +impl Serialize for Username { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.normalized) + } +} + +impl<'de> Deserialize<'de> for Username { + /// Re-validates on the way in. + /// + /// A username arriving in a deserialized record is wire input: it has been + /// normalized by *somebody*, and that somebody may be an attacker who + /// normalized it differently on purpose. Parsing rather than accepting + /// means a record whose username is not in canonical form fails to + /// deserialize, instead of being compared against a queried name it can + /// never equal. + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + let parsed: Self = s.parse().map_err(serde::de::Error::custom)?; + // Reject a name that *normalizes* to something else rather than + // silently repairing it. Accepting `Alice` here would make the record + // verify against a tag it was never published at. + if parsed.as_str() != s { + return Err(serde::de::Error::custom( + "username is not in normalized form", + )); + } + Ok(parsed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(s: &str) -> Username { + s.parse().expect("username should parse") + } + + #[test] + fn username_lowercases_ascii() { + assert_eq!(parse("Alice").as_str(), "alice"); + assert_eq!(parse("ALICE").as_str(), "alice"); + } + + /// The case mapping is part of the wire format, so it is pinned here. + /// + /// `İ` is the character the full and simple mappings disagree on: full + /// gives `i` + U+0307, simple gives a bare `i`. An implementation that + /// chose simple would derive a different discovery tag for the same name + /// and silently never find the other's records — no error, just an empty + /// result. The ASCII golden vectors elsewhere cannot catch that, because + /// the two mappings agree on ASCII. + #[test] + fn username_lowercases_with_the_full_language_insensitive_mapping() { + let parsed = parse("\u{0130}"); + assert_eq!( + parsed.as_str(), + "i\u{0307}", + "the full mapping expands İ to i + COMBINING DOT ABOVE; a bare 'i' \ + means the simple mapping was used and every tag for such a name \ + will disagree with a conforming implementation" + ); + assert_eq!(parsed.as_str().len(), 3, "i + U+0307 is 3 UTF-8 bytes"); + + // Language-insensitive: the Turkish tailoring would lowercase 'I' to + // 'ı' (U+0131), which would make a tag depend on the publisher's + // locale. + assert_eq!(parse("I").as_str(), "i"); + } + + #[test] + fn username_applies_nfc() { + // "é" as e + U+0301 COMBINING ACUTE ACCENT normalizes to U+00E9. + let decomposed = "cafe\u{0301}"; + let composed = "caf\u{00e9}"; + assert_ne!(decomposed, composed, "fixture must start decomposed"); + assert_eq!(parse(decomposed).as_str(), composed); + assert_eq!(parse(decomposed), parse(composed)); + } + + /// The property the whole type exists for: publish and resolve must derive + /// the same tag. A normalizer that is not idempotent derives one on the way + /// out and another on the way back, and the failure is a silent empty + /// result rather than an error. + #[test] + fn username_normalization_is_idempotent() { + for raw in [ + "Alice", + "cafe\u{0301}", + "\u{1e9e}", // LATIN CAPITAL LETTER SHARP S, lowercases to "ß" + "İ", // LATIN CAPITAL LETTER I WITH DOT ABOVE, expands + "Džungla", // titlecase digraph + ] { + let once = parse(raw); + let twice = parse(once.as_str()); + assert_eq!( + once, twice, + "normalizing {:?} twice must equal normalizing it once", + raw + ); + } + } + + #[test] + fn username_rejects_empty_and_whitespace_only() { + assert_eq!("".parse::(), Err(UsernameError::Empty)); + // Not a consequence of normalization: NFC discards none of these, so + // without the explicit screen each one is a claimable name that renders + // as nothing. + for blank in [" ", " ", "\u{00A0}", "\u{3000}", " \u{2009}"] { + assert_eq!( + blank.parse::(), + Err(UsernameError::Empty), + "whitespace-only {:?} must not be a claimable name", + blank + ); + } + } + + #[test] + fn username_rejects_over_length() { + let long = "a".repeat(Username::MAX_BYTES + 1); + assert_eq!( + long.parse::(), + Err(UsernameError::TooLong { + len: Username::MAX_BYTES + 1, + max: Username::MAX_BYTES, + }) + ); + let at_limit = "a".repeat(Username::MAX_BYTES); + assert!(at_limit.parse::().is_ok()); + } + + /// The bound is on the *normalized* form, so a name that grows past the + /// ceiling only once normalized must still be refused. + #[test] + fn username_length_is_measured_after_normalization() { + // Each "ẞ" lowercases to "ß" (2 bytes in UTF-8). + let raw = "\u{1e9e}".repeat(Username::MAX_BYTES / 2 + 1); + assert!(matches!( + raw.parse::(), + Err(UsernameError::TooLong { .. }) + )); + } + + #[test] + fn username_rejects_control_characters() { + assert_eq!( + "ali\nce".parse::(), + Err(UsernameError::ControlCharacter) + ); + } + + /// The characters that make a name render as something other than itself. + /// + /// These are `Cf`, not `Cc`, so [`char::is_control`] does not see any of + /// them: a screen built on it alone accepts a claim carrying a + /// right-to-left override, which a UI renders as a different name than the + /// bytes that were signed. That is the exact failure + /// [`UsernameError::ControlCharacter`] documents, and it went unscreened + /// until this test existed. + #[test] + fn username_rejects_format_characters_that_control_check_alone_misses() { + for (label, raw) in [ + ("right-to-left override", "ali\u{202E}ce"), + ("zero-width joiner", "ali\u{200D}ce"), + ("zero-width non-joiner", "ali\u{200C}ce"), + ("zero-width space", "ali\u{200B}ce"), + ("left-to-right mark", "ali\u{200E}ce"), + ("soft hyphen", "ali\u{00AD}ce"), + ("word joiner", "ali\u{2060}ce"), + ("byte-order mark", "ali\u{FEFF}ce"), + ("tag character", "alice\u{E0041}"), + ] { + let parsed = raw.parse::(); + assert!( + !raw.chars().any(char::is_control), + "{label} must not be Cc, or this test proves nothing new" + ); + assert_eq!( + parsed, + Err(UsernameError::ControlCharacter), + "a username carrying a {label} must be refused" + ); + } + } + + /// Every range of the hand-rolled `Cf` table is live. + /// + /// The table is a snapshot maintained by hand, so the failure to guard + /// against is an edit that narrows a range and silently reopens the hole. + /// One member per range is enough to catch that; completeness against the + /// character database is a regeneration concern, not a runtime one. + #[test] + fn username_rejects_every_format_character_range() { + for c in [ + '\u{00AD}', + '\u{0600}', + '\u{061C}', + '\u{06DD}', + '\u{070F}', + '\u{0890}', + '\u{08E2}', + '\u{180E}', + '\u{200B}', + '\u{202A}', + '\u{2060}', + '\u{2066}', + '\u{FEFF}', + '\u{FFF9}', + '\u{110BD}', + '\u{110CD}', + '\u{13430}', + '\u{1BCA0}', + '\u{1D173}', + '\u{E0001}', + '\u{E0020}', + ] { + assert!( + is_format_character(c), + "U+{:04X} must be screened as a format character", + c as u32 + ); + assert_eq!( + format!("ali{c}ce").parse::(), + Err(UsernameError::ControlCharacter), + "U+{:04X} must be refused in a username", + c as u32 + ); + } + } + + /// The screen must not swallow ordinary international names. A rule that + /// rejects everything passes every negative test above and is useless. + #[test] + fn username_allows_ordinary_international_names() { + for raw in [ + "alice", + "josé", + "мария", + "上田", + "أحمد", + "ali ce", + "a_b-c.d", + ] { + assert!( + raw.parse::().is_ok(), + "{raw:?} is a legitimate name and must parse" + ); + } + } + + /// D3's publish-time refusal, enforced at the type so no call site can + /// forget it. + #[test] + fn username_rejects_address_shaped_names() { + let address = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn"; + assert_eq!(address.len(), Username::ADDRESS_LEN); + assert_eq!( + address.parse::(), + Err(UsernameError::AddressShaped) + ); + // Uppercase input normalizes into the refused shape rather than + // slipping past a case-sensitive screen. + assert_eq!( + address.to_uppercase().parse::(), + Err(UsernameError::AddressShaped) + ); + } + + /// The screen is a shape test, so a *broken* address is refused too — an + /// address with a mangled checksum reads exactly as confusingly in a UI. + #[test] + fn username_rejects_address_shaped_names_with_bad_checksums() { + let mangled = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgqq"; + assert_eq!(mangled.len(), Username::ADDRESS_LEN); + assert_eq!( + mangled.parse::(), + Err(UsernameError::AddressShaped) + ); + } + + /// Pins the duplicated constant against the real address format. If + /// `Address::ENCODED_LEN` ever changes, this fails rather than leaving the + /// screen quietly matching nothing. + #[test] + fn username_address_shape_matches_real_addresses() { + use crate::Address; + assert_eq!(Username::ADDRESS_LEN, Address::ENCODED_LEN); + let address = Address::from_hash_bytes([0u8; Address::HASH_LEN]).to_string(); + assert!( + Username::looks_like_address(&address), + "a real address must be refused as a username: {}", + address + ); + } + + /// A name that merely starts with `off1` is fine — only the full address + /// shape is refused, so this does not quietly ban a namespace. + #[test] + fn username_allows_names_that_merely_start_with_the_address_prefix() { + assert_eq!(parse("off1ce").as_str(), "off1ce"); + } + + #[test] + fn username_round_trips_through_serde() { + let username = parse("alice"); + let json = serde_json::to_string(&username).expect("serialize"); + assert_eq!(json, "\"alice\""); + let back: Username = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, username); + } + + /// Wire input is parsed, not accepted. A record naming `Alice` must fail to + /// deserialize rather than be repaired into `alice` — the repaired form + /// would verify against a tag the record was never published at. + #[test] + fn username_deserialization_refuses_unnormalized_input() { + for raw in ["\"Alice\"", "\"cafe\\u0301\""] { + assert!( + serde_json::from_str::(raw).is_err(), + "unnormalized {} must not deserialize", + raw + ); + } + } +} diff --git a/crates/offline-protocol-mls/src/canonical.rs b/crates/offline-protocol-mls/src/canonical.rs new file mode 100644 index 00000000..d3d18efc --- /dev/null +++ b/crates/offline-protocol-mls/src/canonical.rs @@ -0,0 +1,77 @@ +//! The house construction for domain-separated signing payloads. +//! +//! Every signature in this protocol is taken over +//! `domain ‖ Σ(u32be(len) ‖ field_bytes)`. The length prefix is what makes the +//! encoding unambiguous: without it, two different field splits can serialize +//! to the same bytes, and a signature over one is a valid signature over the +//! other. The domain is what stops a signature produced in one context from +//! being replayed in another that happens to reuse the same identity key. +//! +//! The construction is duplicated in three other places on purpose, because +//! they are different codebases and a shared crate would not reach them: +//! `OfflineProtocol::build_canonical_payload` (control frames), the relay +//! server's `address_proof_payload`, and the two bridge implementations of the +//! relay address proof. What keeps them honest is that the domains must be +//! mutually non-prefixing, which +//! `signing_domains_are_mutually_non_prefixing` pins over all four. +//! +//! # Why mutual non-prefixing matters +//! +//! If one domain were a prefix of another, the shorter domain's payload could +//! be made to collide with the longer one's by choosing a first field that +//! supplies the remaining domain bytes. The signature would then verify in a +//! context it was never issued for. Length-prefixing the fields does not +//! prevent this on its own, because the domain itself is not length-prefixed. + +use crate::error::{MlsError, Result}; + +/// Builds `domain ‖ Σ(u32be(len) ‖ field)`. +/// +/// # Errors +/// +/// Returns [`MlsError::Serialization`] if a field exceeds `u32::MAX` bytes, +/// which no caller in this crate can reach — every field is bounded far below +/// it — but which must not silently truncate a length prefix if one ever could. +pub(crate) fn canonical_payload(domain: &[u8], fields: &[&[u8]]) -> Result> { + let mut buf = + Vec::with_capacity(domain.len() + fields.iter().map(|f| 4 + f.len()).sum::()); + buf.extend_from_slice(domain); + for field in fields { + let len: u32 = field.len().try_into().map_err(|_| { + MlsError::Serialization(format!( + "Field too large for canonical payload length prefix: {} bytes", + field.len() + )) + })?; + buf.extend_from_slice(&len.to_be_bytes()); + buf.extend_from_slice(field); + } + Ok(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_payload_length_prefixes_every_field() { + let payload = canonical_payload(b"dom", &[b"ab", b"c"]).expect("payload"); + assert_eq!(payload, b"dom\x00\x00\x00\x02ab\x00\x00\x00\x01c".to_vec()); + } + + /// The property the length prefix buys: two different field splits that + /// concatenate to the same bytes must not produce the same payload. + #[test] + fn canonical_payload_distinguishes_field_splits() { + let a = canonical_payload(b"dom", &[b"ab", b"c"]).expect("payload"); + let b = canonical_payload(b"dom", &[b"a", b"bc"]).expect("payload"); + assert_ne!(a, b); + } + + #[test] + fn canonical_payload_distinguishes_domains() { + let a = canonical_payload(b"dom-a", &[b"x"]).expect("payload"); + let b = canonical_payload(b"dom-b", &[b"x"]).expect("payload"); + assert_ne!(a, b); + } +} diff --git a/crates/offline-protocol-mls/src/discovery.rs b/crates/offline-protocol-mls/src/discovery.rs new file mode 100644 index 00000000..cf06839b --- /dev/null +++ b/crates/offline-protocol-mls/src/discovery.rs @@ -0,0 +1,654 @@ +//! `DiscoveryRecordV1`: a signed, non-authoritative claim that a username +//! points at an address. +//! +//! # A claim, never a fact +//! +//! Anyone may publish any username claim at the right tag. There is no +//! first-publisher-wins on a Nostr relay and this layer does not invent one. +//! Every record says exactly one thing: *this key asserts this name*. What a +//! record cannot do is lie about a **key**, because +//! `derive_address(pubkey) == address` is checked on every one — so the worst a +//! squatter achieves is to offer you a name attached to their own real identity, +//! which is precisely NIP-05's threat model and precisely why its "identify, +//! never verify" semantics are the right ones to adopt. +//! +//! The consequence is normative and it belongs at the top of this file: a +//! resolver MUST surface the whole set of claimants and let a human arbitrate. +//! A UI that silently picks the first result has converted a deliberately +//! non-authoritative directory into an authoritative-looking one, which is worse +//! than not shipping it at all. See `docs/spec/username-discovery.md`. +//! +//! # Why a username resolves to a set, always +//! +//! One record per device. Each install signs its own record with its own +//! identity key and publishes it under its own Nostr key, so all of a user's +//! devices coexist at the same tag (addressable replacement is keyed on +//! `(kind, pubkey, d)`, and each device is a different `pubkey`). A resolver +//! queries once and receives the whole set. +//! +//! This is not a limitation to be designed around: no device knows the +//! addresses of its siblings — there is no identity export, import, backup or +//! sync API anywhere in this SDK, by construction — so a record shaped as +//! `{username, [devices]}` could not be produced at all. Aggregating at the tag +//! reaches the identical result with zero coordination. A username is therefore +//! 1:N even for a single-device user, who is simply a set of one. +//! +//! # The `nostr_author` binding +//! +//! Step 5 of verification is what this record has and the published key-package +//! record does not, and it closes a residual that is materially worse for a +//! directory than for a key package. +//! +//! Because the seal key is publicly derivable, a squatter can unseal a record, +//! re-seal the untouched (and genuinely signed) payload under their own author +//! key, and republish it. For a key package the cost is a dead session and one +//! exchange of delay. For a directory entry it defeats **retraction**: +//! addressable replacement is per-`pubkey`, so the owner's tombstone replaces +//! only the owner's own event and never a copy standing under someone else's +//! key. That would keep a rotated-away or compromised address in the directory +//! indefinitely. Binding the author key inside the signed payload makes a +//! re-authored copy fail outright. + +use offline_protocol_core::{Address, Username}; +use serde::{Deserialize, Serialize}; + +use crate::canonical::canonical_payload; +use crate::error::{MlsError, Result}; +use crate::manager::MlsManager; + +/// Signature domain for discovery records. +/// +/// Must not be a prefix of, or prefixed by, any other live signing domain. See +/// the `canonical` module. +pub const DISCOVERY_SIGN_DOMAIN: &[u8] = b"offline-disc-v1"; + +/// The only discovery record version this build produces or accepts. +pub const DISCOVERY_VERSION: u8 = 1; + +/// Length of an Ed25519 public key. +const PUBLIC_KEY_LEN: usize = 32; + +/// Length of an Ed25519 signature. +const SIGNATURE_LEN: usize = 64; + +/// Length of a Nostr x-only public key. +const NOSTR_KEY_LEN: usize = 32; + +/// A signed claim that a username points at an address. +/// +/// Serialized as JSON inside a sealed relay record. The byte-level contract is +/// [`Self::signing_payload`], not this struct's JSON: two implementations must +/// agree on the signed bytes, and may disagree on JSON key order. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiscoveryRecordV1 { + /// Format version. Always [`DISCOVERY_VERSION`] in this build. + pub v: u8, + /// The claimed name, in normalized form. + /// + /// Typed, so a record naming an unnormalized username fails to deserialize + /// rather than being compared against a queried name it can never equal. + pub username: Username, + /// The address the claim points at. + pub address: Address, + /// The Ed25519 identity key. `derive_address(pubkey)` must equal `address`. + #[serde(with = "base64_bytes")] + pub pubkey: Vec, + /// The x-only Nostr key this record is valid when published under. + /// + /// See the module docs: this is what stops a third party from keeping a + /// retracted claim alive. + #[serde(with = "hex_bytes")] + pub nostr_author: Vec, + /// Signing time, in milliseconds since the Unix epoch. + /// + /// **Advisory only.** See [`verify_discovery_record`]. + pub issued_at_ms: i64, + /// Ed25519 signature by `pubkey` over [`Self::signing_payload`]. + #[serde(with = "base64_bytes")] + pub sig: Vec, +} + +/// A tombstone: the body a retraction publishes in place of a claim. +/// +/// Retraction republishes the same `(kind, pubkey, d)` with this body and a +/// fresh `created_at`, plus a best-effort NIP-09 deletion. It is best-effort by +/// nature — a relay may honour neither — which is one more reason staleness is +/// advisory and the key-package fetch arbitrates. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiscoveryTombstoneV1 { + /// Format version. Always [`DISCOVERY_VERSION`]. + pub v: u8, + /// Always `true`. Present so a tombstone cannot be mistaken for a record + /// whose fields failed to parse. + pub retracted: bool, +} + +impl DiscoveryTombstoneV1 { + /// Builds a tombstone body. + pub fn new() -> Self { + Self { + v: DISCOVERY_VERSION, + retracted: true, + } + } +} + +impl Default for DiscoveryTombstoneV1 { + fn default() -> Self { + Self::new() + } +} + +/// What a resolved record body turned out to be. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiscoveryBody { + /// A claim, not yet verified. + Record(Box), + /// A retraction. The claimant is withdrawing the name. + Tombstone, +} + +/// Parses a record body, distinguishing a claim from a retraction. +/// +/// A body that is neither is an error, which the caller drops: a public tag +/// returns whatever the relay holds there, so junk is ordinary rather than +/// exceptional. +pub fn parse_discovery_body(bytes: &[u8]) -> Result { + // Tombstone first: it is the narrower shape, and a record can never satisfy + // it because `retracted` is required and has no counterpart in a claim. + if let Ok(tombstone) = serde_json::from_slice::(bytes) { + if tombstone.retracted { + return Ok(DiscoveryBody::Tombstone); + } + } + let record: DiscoveryRecordV1 = serde_json::from_slice(bytes) + .map_err(|e| MlsError::Deserialization(format!("Discovery record is malformed: {}", e)))?; + Ok(DiscoveryBody::Record(Box::new(record))) +} + +impl DiscoveryRecordV1 { + /// Builds the payload the signature is taken over. + /// + /// `domain ‖ u32be‖bytes` over + /// `[v, username, address, pubkey, nostr_author, issued_at_ms]`, in that + /// fixed order. `issued_at_ms` is encoded as its 8-byte big-endian form, + /// not as a decimal string, so two implementations cannot disagree about + /// leading zeroes or a sign. + pub fn signing_payload( + username: &Username, + address: &Address, + pubkey: &[u8], + nostr_author: &[u8], + issued_at_ms: i64, + ) -> Result> { + let address_string = address.to_string(); + canonical_payload( + DISCOVERY_SIGN_DOMAIN, + &[ + &[DISCOVERY_VERSION], + username.as_str().as_bytes(), + address_string.as_bytes(), + pubkey, + nostr_author, + &issued_at_ms.to_be_bytes(), + ], + ) + } + + /// Builds an unsigned record, ready for [`Self::sign_with`]. + pub fn unsigned( + username: Username, + address: Address, + pubkey: Vec, + nostr_author: Vec, + issued_at_ms: i64, + ) -> Self { + Self { + v: DISCOVERY_VERSION, + username, + address, + pubkey, + nostr_author, + issued_at_ms, + sig: Vec::new(), + } + } + + /// Signs the record with `signer`, which must be the private half of + /// `self.pubkey`. + /// + /// Taking a closure keeps the private key where it lives (the engine's MLS + /// manager) rather than passing key material into this module. + pub fn sign_with(mut self, signer: F) -> Result + where + F: FnOnce(&[u8]) -> Result>, + { + let payload = Self::signing_payload( + &self.username, + &self.address, + &self.pubkey, + &self.nostr_author, + self.issued_at_ms, + )?; + self.sig = signer(&payload)?; + Ok(self) + } +} + +/// Why a discovery record was rejected. +/// +/// Separate from [`MlsError`] because every variant here is an *ordinary* +/// outcome of querying a public tag, not a fault: a resolver drops the record +/// and keeps going. Carrying them as errors would make junk at a public tag +/// look like a failure of the system reading it. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DiscoveryRejection { + /// The version is not one this build understands. + #[error("discovery record version is {found}, expected {expected}")] + UnsupportedVersion { + /// The version the record carried. + found: u8, + /// The only version this build accepts. + expected: u8, + }, + /// A fixed-length field is the wrong size. + #[error("discovery record field '{field}' is {len} bytes, expected {expected}")] + FieldLength { + /// Which field. + field: &'static str, + /// Length found. + len: usize, + /// Length required. + expected: usize, + }, + /// The record claims a different name than the one queried. + /// + /// A record is only meaningful at its own tag, so this also catches a + /// record that was mis-tagged or copied to a foreign tag. + #[error("discovery record claims '{claimed}' but '{queried}' was queried")] + UsernameMismatch { + /// The name the record claims. + claimed: String, + /// The name that was resolved. + queried: String, + }, + /// `derive_address(pubkey)` is not the claimed address. + #[error("discovery record address is not the address its public key derives to")] + AddressNotDerived, + /// The signature does not verify under `pubkey`. + #[error("discovery record signature does not verify")] + BadSignature, + /// The record was published under a different Nostr key than it names. + /// + /// This is a re-authored copy: someone unsealed a genuine record and stood + /// it back up under their own key. See the module docs. + #[error("discovery record was published under a foreign Nostr key")] + ForeignAuthor, +} + +/// Verifies a discovery record against the username it was queried for and the +/// Nostr key it was published under. +/// +/// The checks run in the order the design fixes, cheap before expensive: +/// +/// 1. `v == 1` and every fixed-length field is the right size; +/// 2. the username matches the queried name exactly; +/// 3. `derive_address(pubkey) == address`; +/// 4. the Ed25519 signature verifies under `pubkey`; +/// 5. `nostr_author == event.pubkey`. +/// +/// # Staleness is advisory and is deliberately not checked here +/// +/// A record is not a liveness signal; the key-package fetch that follows it is. +/// A stale record whose key packages are gone fails at that fetch, which is the +/// honest place to fail. Rejecting on age would instead make a peer who has +/// been offline for a month unreachable *by name* while their key packages sit +/// valid on a relay for 30 days. Surface `issued_at_ms` to the app, let it sort, +/// and let the fetch arbitrate. +pub fn verify_discovery_record( + record: &DiscoveryRecordV1, + queried: &Username, + event_author: &[u8], +) -> std::result::Result<(), DiscoveryRejection> { + if record.v != DISCOVERY_VERSION { + return Err(DiscoveryRejection::UnsupportedVersion { + found: record.v, + expected: DISCOVERY_VERSION, + }); + } + if record.pubkey.len() != PUBLIC_KEY_LEN { + return Err(DiscoveryRejection::FieldLength { + field: "pubkey", + len: record.pubkey.len(), + expected: PUBLIC_KEY_LEN, + }); + } + if record.nostr_author.len() != NOSTR_KEY_LEN { + return Err(DiscoveryRejection::FieldLength { + field: "nostr_author", + len: record.nostr_author.len(), + expected: NOSTR_KEY_LEN, + }); + } + if record.sig.len() != SIGNATURE_LEN { + return Err(DiscoveryRejection::FieldLength { + field: "sig", + len: record.sig.len(), + expected: SIGNATURE_LEN, + }); + } + + if record.username != *queried { + return Err(DiscoveryRejection::UsernameMismatch { + claimed: record.username.as_str().to_string(), + queried: queried.as_str().to_string(), + }); + } + + let derived = MlsManager::derive_address(&record.pubkey) + .map_err(|_| DiscoveryRejection::AddressNotDerived)?; + if derived != record.address { + return Err(DiscoveryRejection::AddressNotDerived); + } + + let payload = DiscoveryRecordV1::signing_payload( + &record.username, + &record.address, + &record.pubkey, + &record.nostr_author, + record.issued_at_ms, + ) + .map_err(|_| DiscoveryRejection::BadSignature)?; + + match MlsManager::verify_signature(&record.pubkey, &payload, &record.sig) { + Ok(true) => {} + _ => return Err(DiscoveryRejection::BadSignature), + } + + // Last, because it is the check an honest record passes trivially and a + // re-authored one fails: everything above verifies against the *record*, + // and this is the only step that involves the event carrying it. + if record.nostr_author != event_author { + return Err(DiscoveryRejection::ForeignAuthor); + } + + Ok(()) +} + +/// Base64 for byte fields that are naturally binary. +mod base64_bytes { + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(bytes: &[u8], serializer: S) -> Result { + serializer.serialize_str(&STANDARD.encode(bytes)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let encoded = String::deserialize(deserializer)?; + STANDARD.decode(&encoded).map_err(serde::de::Error::custom) + } +} + +/// Hex for the Nostr author key, which is hex everywhere else in the Nostr +/// layer — matching it here means a comparison never crosses an encoding. +mod hex_bytes { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(bytes: &[u8], serializer: S) -> Result { + serializer.serialize_str(&hex::encode(bytes)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let encoded = String::deserialize(deserializer)?; + hex::decode(&encoded).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine as _; + use ed25519_dalek::{Signer, SigningKey}; + + fn identity(seed: u8) -> (SigningKey, Vec, Address) { + let signing = SigningKey::from_bytes(&[seed; 32]); + let public = signing.verifying_key().to_bytes().to_vec(); + let address = MlsManager::derive_address(&public).expect("derive"); + (signing, public, address) + } + + fn username(s: &str) -> Username { + s.parse().expect("username") + } + + fn signed_record(seed: u8, name: &str, author: &[u8]) -> DiscoveryRecordV1 { + let (signing, public, address) = identity(seed); + DiscoveryRecordV1::unsigned( + username(name), + address, + public, + author.to_vec(), + 1_700_000_000_000, + ) + .sign_with(|payload| Ok(signing.sign(payload).to_bytes().to_vec())) + .expect("sign") + } + + fn author(seed: u8) -> Vec { + vec![seed; NOSTR_KEY_LEN] + } + + #[test] + fn discovery_record_verifies() { + let author = author(9); + let record = signed_record(1, "alice", &author); + assert_eq!( + verify_discovery_record(&record, &username("alice"), &author), + Ok(()) + ); + } + + #[test] + fn discovery_record_round_trips_through_json() { + let author = author(9); + let record = signed_record(1, "alice", &author); + let json = serde_json::to_vec(&record).expect("serialize"); + match parse_discovery_body(&json).expect("parse") { + DiscoveryBody::Record(parsed) => assert_eq!(*parsed, record), + DiscoveryBody::Tombstone => panic!("a record must not parse as a tombstone"), + } + } + + #[test] + fn discovery_tombstone_parses_as_a_retraction() { + let json = serde_json::to_vec(&DiscoveryTombstoneV1::new()).expect("serialize"); + assert_eq!( + parse_discovery_body(&json).expect("parse"), + DiscoveryBody::Tombstone + ); + } + + /// The negative control for the `nostr_author` binding, and the single most + /// important test in this module: a squatter unseals a genuine record and + /// republishes it verbatim under their own key. Everything inside the + /// record still verifies — the signature is real — and the record must + /// still be refused. + #[test] + fn discovery_record_refuses_a_re_authored_copy() { + let genuine_author = author(9); + let record = signed_record(1, "alice", &genuine_author); + + // Untouched payload, republished under the squatter's key. + let squatter_author = author(200); + assert_eq!( + verify_discovery_record(&record, &username("alice"), &squatter_author), + Err(DiscoveryRejection::ForeignAuthor) + ); + } + + /// The squatter's other option — re-sign the record naming their own author + /// key — must fail too, because they do not hold the identity key. + #[test] + fn discovery_record_refuses_a_rebound_author_without_the_identity_key() { + let record = signed_record(1, "alice", &author(9)); + let mut rebound = record.clone(); + rebound.nostr_author = author(200); + assert_eq!( + verify_discovery_record(&rebound, &username("alice"), &author(200)), + Err(DiscoveryRejection::BadSignature) + ); + } + + #[test] + fn discovery_record_refuses_a_foreign_address() { + let author = author(9); + let mut record = signed_record(1, "alice", &author); + let (_, _, other) = identity(2); + record.address = other; + assert_eq!( + verify_discovery_record(&record, &username("alice"), &author), + Err(DiscoveryRejection::AddressNotDerived) + ); + } + + /// A record is only meaningful at its own tag. This is what catches a + /// genuine record for `bob` copied to `alice`'s tag. + #[test] + fn discovery_record_refuses_a_username_it_was_not_queried_for() { + let author = author(9); + let record = signed_record(1, "bob", &author); + assert_eq!( + verify_discovery_record(&record, &username("alice"), &author), + Err(DiscoveryRejection::UsernameMismatch { + claimed: "bob".to_string(), + queried: "alice".to_string(), + }) + ); + } + + #[test] + fn discovery_record_refuses_a_tampered_username() { + let author = author(9); + let mut record = signed_record(1, "alice", &author); + record.username = username("mallory"); + assert_eq!( + verify_discovery_record(&record, &username("mallory"), &author), + Err(DiscoveryRejection::BadSignature) + ); + } + + #[test] + fn discovery_record_refuses_a_tampered_issue_time() { + let author = author(9); + let mut record = signed_record(1, "alice", &author); + record.issued_at_ms += 1; + assert_eq!( + verify_discovery_record(&record, &username("alice"), &author), + Err(DiscoveryRejection::BadSignature) + ); + } + + #[test] + fn discovery_record_refuses_an_unsupported_version() { + let author = author(9); + let mut record = signed_record(1, "alice", &author); + record.v = 2; + assert!(matches!( + verify_discovery_record(&record, &username("alice"), &author), + Err(DiscoveryRejection::UnsupportedVersion { .. }) + )); + } + + #[test] + fn discovery_record_refuses_wrong_length_fields() { + let author = author(9); + for mutate in [ + (|r: &mut DiscoveryRecordV1| r.pubkey.truncate(31)) as fn(&mut DiscoveryRecordV1), + |r: &mut DiscoveryRecordV1| r.nostr_author.truncate(31), + |r: &mut DiscoveryRecordV1| r.sig.truncate(63), + ] { + let mut record = signed_record(1, "alice", &author); + mutate(&mut record); + assert!(matches!( + verify_discovery_record(&record, &username("alice"), &author), + Err(DiscoveryRejection::FieldLength { .. }) + )); + } + } + + /// Staleness must not reject. A record signed long ago still verifies; the + /// key-package fetch is what decides whether the peer is reachable. + #[test] + fn discovery_record_accepts_an_old_issue_time() { + let author = author(9); + let (signing, public, address) = identity(1); + let ancient = DiscoveryRecordV1::unsigned( + username("alice"), + address, + public, + author.clone(), + 1_000_000_000_000, + ) + .sign_with(|payload| Ok(signing.sign(payload).to_bytes().to_vec())) + .expect("sign"); + + assert_eq!( + verify_discovery_record(&ancient, &username("alice"), &author), + Ok(()) + ); + } + + /// A record naming an unnormalized username must not deserialize: the + /// repaired form would verify against a tag the record was never published + /// at, which is the silent-miss failure the `Username` type exists to stop. + #[test] + fn discovery_record_refuses_an_unnormalized_username_on_the_wire() { + let author = author(9); + let record = signed_record(1, "alice", &author); + let mut value: serde_json::Value = + serde_json::from_slice(&serde_json::to_vec(&record).expect("serialize")) + .expect("value"); + value["username"] = serde_json::Value::String("Alice".to_string()); + let json = serde_json::to_vec(&value).expect("reserialize"); + assert!(parse_discovery_body(&json).is_err()); + } + + /// Golden vector for the signed bytes. A second implementation must produce + /// this payload exactly, or the two sign different things and every + /// cross-implementation verification fails — silently, since a bad + /// signature is indistinguishable from a squatted record. + /// + /// **Computed independently of this code** by a Python script that builds + /// the payload from the written construction and derives the address with + /// the BIP-350 reference bech32m. Regenerate it the same way. + /// + /// The identity is the all-`0x01` Ed25519 seed and the author key is 32 + /// bytes of `0x09`, so a second implementation can reproduce it without + /// this repository. + #[test] + fn discovery_signing_payload_golden_vector() { + let (_, public, address) = identity(1); + assert_eq!( + address.to_string(), + "off1qy682ruch4vlely5dkj94247jva7z49yk5xpqee0", + "the golden identity's address changed" + ); + + let payload = DiscoveryRecordV1::signing_payload( + &username("alice"), + &address, + &public, + &author(9), + 1_700_000_000_000, + ) + .expect("payload"); + + assert_eq!( + base64::engine::general_purpose::STANDARD.encode(&payload), + "b2ZmbGluZS1kaXNjLXYxAAAAAQEAAAAFYWxpY2UAAAAsb2ZmMXF5NjgycnVjaDR2bGVseTVka2o5NDI0N2p2YTd6NDl5azV4cHFlZTAAAAAgiojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1wAAAAgCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkAAAAIAAABi8/laAA=" + ); + } +} diff --git a/crates/offline-protocol-mls/src/invite.rs b/crates/offline-protocol-mls/src/invite.rs new file mode 100644 index 00000000..faa9b6e7 --- /dev/null +++ b/crates/offline-protocol-mls/src/invite.rs @@ -0,0 +1,711 @@ +//! The invite payload: a self-verifying `{address, pubkey, petname?, sig?}` +//! blob for QR codes and links. +//! +//! This is the *permanent* path to first contact, and the primary one. Username +//! discovery (see [`crate::discovery`]) is additive: it is a non-authoritative +//! directory whose only trust anchor is a human confirming a name out of band, +//! which is exactly what scanning a QR code is. Deleting the invite path would +//! therefore delete the discovery layer's security model, not merely one of two +//! ways to reach someone. +//! +//! # What verification proves, and what it does not +//! +//! [`parse_invite`] enforces `derive_address(pubkey) == address` and, when a +//! signature is present, that the signature verifies under `pubkey`. That makes +//! the blob **self-certifying**: no directory, no server and no prior contact is +//! consulted, which is why the helpers are namespace-level and callable before +//! `create()`. +//! +//! It does **not** defend against substitution. An attacker who hands you their +//! own invite, correctly signed by their own key, is indistinguishable from a +//! legitimate stranger — no payload format can fix that, only the out-of-band +//! context in which the code was shown to you. +//! +//! What the optional signature defends is **relabeling**. Without it, anyone can +//! mint an invite pairing a victim's real, public `{address, pubkey}` with an +//! attacker-chosen petname, so an invite forwarded through a third party can +//! save Alice's key under the name "Bob". With it, the petname is bound to the +//! key by the key's owner. +//! +//! Include a signature when the invite may travel without its issuer. A QR code +//! shown phone-to-phone is already authenticated by the physical channel, and +//! an app that prompts the user to confirm or edit the name has made the user +//! the authority over it, which is what a petname properly is. +//! +//! # What an invite deliberately does not carry +//! +//! **No key package.** An MLS key package's init key is consumed by the first +//! peer who uses it, and a QR code is static, so pairing them guarantees a +//! collision as soon as two people scan the same code. Session establishment +//! runs over whatever transport connects, by the ordinary exchange. +//! +//! **No expiry.** A printed QR code that stops working is a bug. Apps that need +//! revocable invites have the server-mediated group-invite-link mechanism. +//! +//! # Container +//! +//! The SDK specifies the blob; apps own their URI scheme. The recommended form +//! is `://connect?c=` — one opaque parameter, so it composes +//! with any existing scheme and route. + +use std::str::FromStr; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use offline_protocol_core::{contains_control_or_format, Address}; + +use crate::canonical::canonical_payload; +use crate::error::{MlsError, Result}; +use crate::manager::MlsManager; + +/// Signature domain for invite payloads. +/// +/// Must not be a prefix of, or prefixed by, any other live signing domain. See +/// the `canonical` module. +pub const INVITE_SIGN_DOMAIN: &[u8] = b"offline-invite-v1"; + +/// The only invite format version this build produces or accepts. +pub const INVITE_VERSION: u8 = 1; + +/// Maximum petname length in bytes. +/// +/// A petname is a display string, so the bound is on bytes for the same reason +/// [`Username::MAX_BYTES`](offline_protocol_core::Username::MAX_BYTES) is: it +/// caps what goes on the wire and through a signature. +pub const MAX_PETNAME_BYTES: usize = 64; + +/// Length of an Ed25519 public key. +const PUBLIC_KEY_LEN: usize = 32; + +/// Length of an Ed25519 signature. +const SIGNATURE_LEN: usize = 64; + +/// Set when a petname follows the address. +const FLAG_PETNAME: u8 = 0b0000_0001; + +/// Set when a signature terminates the blob. +const FLAG_SIGNATURE: u8 = 0b0000_0010; + +/// Every flag bit this version defines. +/// +/// An unknown bit is refused rather than ignored: the bits select which +/// trailing sections are present, so misreading one desynchronizes the parse +/// and would surface as a corrupt petname rather than as a version error. +const KNOWN_FLAGS: u8 = FLAG_PETNAME | FLAG_SIGNATURE; + +/// A decoded and verified invite. +/// +/// Holding one of these means the address self-certified against the public key +/// and, if `signed` is true, that the petname is bound to that key by its owner. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Invite { + /// The address the invite reaches, already checked to be + /// `derive_address(public_key)`. + pub address: Address, + /// The Ed25519 identity key the address derives from. + pub public_key: Vec, + /// The suggested display name, if the invite carried one. + /// + /// Suggested, not authoritative: it is a *locally assigned* name and an app + /// is right to let the user edit it. + pub petname: Option, + /// Whether a valid signature accompanied the invite. + /// + /// False means the petname is unbound, not that the invite is invalid. An + /// unsigned invite is a legitimate and common shape. + pub signed: bool, +} + +/// Encodes an invite blob, optionally signed. +/// +/// `signature` must be a signature by the key `public_key` names, over +/// [`invite_signing_payload`]. Pass `None` for the unsigned form. +/// +/// # Errors +/// +/// Returns [`MlsError::InvalidPublicKey`] if `public_key` is not 32 bytes, +/// [`MlsError::Serialization`] if the petname is over-long or carries a control +/// or format character, or if the signature is the wrong length. +pub fn encode_invite( + address: &Address, + public_key: &[u8], + petname: Option<&str>, + signature: Option<&[u8]>, +) -> Result { + if public_key.len() != PUBLIC_KEY_LEN { + return Err(MlsError::InvalidPublicKey(format!( + "Ed25519 public key must be {} bytes, got {}", + PUBLIC_KEY_LEN, + public_key.len() + ))); + } + // Checked here as well as at the type, because a caller can hand us an + // address that is not this key's and produce a blob that fails to parse on + // every scanner. Failing at mint is the honest place. + let derived = MlsManager::derive_address(public_key)?; + if derived != *address { + return Err(MlsError::Serialization( + "Invite address is not the address this public key derives to".to_string(), + )); + } + + let petname_bytes = match petname { + // An empty petname and an absent one mean the same thing, so they get + // the same encoding rather than two spellings of one state. + Some("") => None, + Some(name) => { + if name.len() > MAX_PETNAME_BYTES { + return Err(MlsError::Serialization(format!( + "Invite petname is {} bytes, maximum is {}", + name.len(), + MAX_PETNAME_BYTES + ))); + } + // The same screen a username gets, for a stronger reason. See + // [`parse_invite`]: refusing at mint means an honest app cannot + // build one of these by accident from a pasted display name. + if contains_control_or_format(name) { + return Err(MlsError::Serialization( + "Invite petname contains a control or format character".to_string(), + )); + } + Some(name.as_bytes()) + } + None => None, + }; + + if let Some(sig) = signature { + if sig.len() != SIGNATURE_LEN { + return Err(MlsError::Serialization(format!( + "Invite signature must be {} bytes, got {}", + SIGNATURE_LEN, + sig.len() + ))); + } + } + + let address_string = address.to_string(); + let address_bytes = address_string.as_bytes(); + + let mut flags = 0u8; + if petname_bytes.is_some() { + flags |= FLAG_PETNAME; + } + if signature.is_some() { + flags |= FLAG_SIGNATURE; + } + + let mut blob = Vec::with_capacity( + 2 + PUBLIC_KEY_LEN + + 1 + + address_bytes.len() + + petname_bytes.map_or(0, |p| 1 + p.len()) + + signature.map_or(0, |s| s.len()), + ); + blob.push(INVITE_VERSION); + blob.push(flags); + blob.extend_from_slice(public_key); + // The address is ASCII bech32m of a fixed length, so a single-byte prefix + // cannot overflow. + blob.push(address_bytes.len() as u8); + blob.extend_from_slice(address_bytes); + if let Some(petname) = petname_bytes { + blob.push(petname.len() as u8); + blob.extend_from_slice(petname); + } + if let Some(sig) = signature { + blob.extend_from_slice(sig); + } + + Ok(URL_SAFE_NO_PAD.encode(&blob)) +} + +/// Builds the payload an invite signature is taken over. +/// +/// `domain ‖ u32be‖bytes` over `[v, address, public_key, petname]`, in that +/// fixed order, with an absent petname encoded as a zero-length field. +/// +/// Exposed because signing happens where the private key is (the engine) while +/// verification happens here, and both must build byte-identical input. +pub fn invite_signing_payload( + address: &Address, + public_key: &[u8], + petname: Option<&str>, +) -> Result> { + let address_string = address.to_string(); + let petname_bytes = petname.unwrap_or("").as_bytes(); + canonical_payload( + INVITE_SIGN_DOMAIN, + &[ + &[INVITE_VERSION], + address_string.as_bytes(), + public_key, + petname_bytes, + ], + ) +} + +/// Decodes and verifies an invite blob. +/// +/// Verification is mandatory and total: an invite that fails any check is an +/// error, never a partially-trusted value. In order, cheap before expensive: +/// +/// 1. base64url decodes, and the blob is structurally complete; +/// 2. `v == 1` and no unknown flag bits; +/// 3. the address parses in canonical form; +/// 4. the petname, when present, is displayable (no `Cc`, no `Cf`); +/// 5. `derive_address(public_key) == address`; +/// 6. the signature, when present, verifies under `public_key`. +/// +/// # Errors +/// +/// Returns [`MlsError::InvalidMessage`] for a malformed or truncated blob, +/// [`MlsError::VerificationFailed`] when the address does not derive from the +/// key or a present signature does not verify. +pub fn parse_invite(blob: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(blob.trim()) + .map_err(|e| MlsError::InvalidMessage(format!("Invite is not base64url: {}", e)))?; + + let mut cursor = Reader::new(&bytes); + + let version = cursor.take_u8("version")?; + if version != INVITE_VERSION { + return Err(MlsError::InvalidMessage(format!( + "Unsupported invite version {}, expected {}", + version, INVITE_VERSION + ))); + } + + let flags = cursor.take_u8("flags")?; + if flags & !KNOWN_FLAGS != 0 { + return Err(MlsError::InvalidMessage(format!( + "Invite carries unknown flag bits: {:#010b}", + flags + ))); + } + + let public_key = cursor.take("public key", PUBLIC_KEY_LEN)?.to_vec(); + + let address_len = cursor.take_u8("address length")? as usize; + let address_bytes = cursor.take("address", address_len)?; + let address_str = std::str::from_utf8(address_bytes) + .map_err(|_| MlsError::InvalidMessage("Invite address is not UTF-8".to_string()))?; + let address = Address::from_str(address_str) + .map_err(|e| MlsError::InvalidMessage(format!("Invite address is invalid: {}", e)))?; + + let petname = if flags & FLAG_PETNAME != 0 { + let len = cursor.take_u8("petname length")? as usize; + if len == 0 { + // The flag says a petname follows; a zero-length one is a second + // spelling of "absent", which `encode_invite` never emits. + return Err(MlsError::InvalidMessage( + "Invite sets the petname flag but carries an empty petname".to_string(), + )); + } + if len > MAX_PETNAME_BYTES { + return Err(MlsError::InvalidMessage(format!( + "Invite petname is {} bytes, maximum is {}", + len, MAX_PETNAME_BYTES + ))); + } + let bytes = cursor.take("petname", len)?; + let name = std::str::from_utf8(bytes) + .map_err(|_| MlsError::InvalidMessage("Invite petname is not UTF-8".to_string()))?; + // A petname carrying a bidi override or a zero-width joiner renders as + // something other than its own bytes, and this is the string an app + // shows in the confirmation dialog after a scan. Worse than for a + // username: when the invite is signed, the deceptive rendering arrives + // bound to a *valid* signature, so an app that trusts `signed` is + // trusting the wrong half. Refused here rather than left to every + // caller to sanitize. + if contains_control_or_format(name) { + return Err(MlsError::InvalidMessage( + "Invite petname contains a control or format character".to_string(), + )); + } + Some(name.to_string()) + } else { + None + }; + + let signature = if flags & FLAG_SIGNATURE != 0 { + Some(cursor.take("signature", SIGNATURE_LEN)?.to_vec()) + } else { + None + }; + + // Trailing bytes mean the blob is not what it claims to be. Ignoring them + // would let one address travel under several distinct blobs, and would hide + // a section this version does not know how to read. + cursor.finish()?; + + // The check the whole format exists for. Runs before the signature so a + // substituted key fails on the cheap comparison rather than on a verify. + let derived = MlsManager::derive_address(&public_key)?; + if derived != address { + return Err(MlsError::VerificationFailed( + "Invite address is not the address its public key derives to".to_string(), + )); + } + + let signed = match signature { + Some(sig) => { + let payload = invite_signing_payload(&address, &public_key, petname.as_deref())?; + if !MlsManager::verify_signature(&public_key, &payload, &sig)? { + return Err(MlsError::VerificationFailed( + "Invite signature does not verify under its public key".to_string(), + )); + } + true + } + None => false, + }; + + Ok(Invite { + address, + public_key, + petname, + signed, + }) +} + +/// A bounds-checked forward reader over the invite blob. +/// +/// Every field is taken through this so a truncated blob produces a named +/// error rather than a panic on a slice index. +struct Reader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Reader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn take(&mut self, field: &str, len: usize) -> Result<&'a [u8]> { + let end = self.offset.checked_add(len).ok_or_else(|| { + MlsError::InvalidMessage(format!("Invite {} length overflows", field)) + })?; + if end > self.bytes.len() { + return Err(MlsError::InvalidMessage(format!( + "Invite is truncated: {} needs {} bytes, {} remain", + field, + len, + self.bytes.len().saturating_sub(self.offset) + ))); + } + let slice = &self.bytes[self.offset..end]; + self.offset = end; + Ok(slice) + } + + fn take_u8(&mut self, field: &str) -> Result { + Ok(self.take(field, 1)?[0]) + } + + fn finish(&self) -> Result<()> { + if self.offset != self.bytes.len() { + return Err(MlsError::InvalidMessage(format!( + "Invite has {} trailing bytes", + self.bytes.len() - self.offset + ))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + /// A deterministic identity, so the golden vectors below are reproducible. + fn identity(seed: u8) -> (SigningKey, Vec, Address) { + let signing = SigningKey::from_bytes(&[seed; 32]); + let public = signing.verifying_key().to_bytes().to_vec(); + let address = MlsManager::derive_address(&public).expect("derive"); + (signing, public, address) + } + + fn sign(signing: &SigningKey, payload: &[u8]) -> Vec { + signing.sign(payload).to_bytes().to_vec() + } + + #[test] + fn invite_round_trips_unsigned_without_a_petname() { + let (_, public, address) = identity(1); + let blob = encode_invite(&address, &public, None, None).expect("encode"); + let invite = parse_invite(&blob).expect("parse"); + assert_eq!(invite.address, address); + assert_eq!(invite.public_key, public); + assert_eq!(invite.petname, None); + assert!(!invite.signed); + } + + #[test] + fn invite_round_trips_with_a_petname() { + let (_, public, address) = identity(2); + let blob = encode_invite(&address, &public, Some("Alice"), None).expect("encode"); + let invite = parse_invite(&blob).expect("parse"); + assert_eq!(invite.petname.as_deref(), Some("Alice")); + assert!(!invite.signed); + } + + #[test] + fn invite_round_trips_signed() { + let (signing, public, address) = identity(3); + let payload = invite_signing_payload(&address, &public, Some("Alice")).expect("payload"); + let signature = sign(&signing, &payload); + let blob = + encode_invite(&address, &public, Some("Alice"), Some(&signature)).expect("encode"); + let invite = parse_invite(&blob).expect("parse"); + assert_eq!(invite.petname.as_deref(), Some("Alice")); + assert!(invite.signed); + } + + /// An empty petname is the same state as no petname, and must not produce a + /// second encoding of it. + #[test] + fn invite_treats_an_empty_petname_as_absent() { + let (_, public, address) = identity(4); + let with_empty = encode_invite(&address, &public, Some(""), None).expect("encode"); + let without = encode_invite(&address, &public, None, None).expect("encode"); + assert_eq!(with_empty, without); + assert_eq!(parse_invite(&with_empty).expect("parse").petname, None); + } + + /// The check the format exists for: a blob pairing one key with another's + /// address must be refused. + #[test] + fn invite_refuses_an_address_that_is_not_the_keys() { + let (_, public_a, _) = identity(5); + let (_, _, address_b) = identity(6); + + // `encode_invite` refuses to mint it... + assert!(encode_invite(&address_b, &public_a, None, None).is_err()); + + // ...and a hand-built blob is refused at parse, which is the check that + // actually matters since an attacker does not use our encoder. + let address_bytes = address_b.to_string(); + let mut blob = vec![INVITE_VERSION, 0u8]; + blob.extend_from_slice(&public_a); + blob.push(address_bytes.len() as u8); + blob.extend_from_slice(address_bytes.as_bytes()); + let encoded = URL_SAFE_NO_PAD.encode(&blob); + + assert!(matches!( + parse_invite(&encoded), + Err(MlsError::VerificationFailed(_)) + )); + } + + /// The relabeling attack the optional signature exists to stop. + #[test] + fn invite_refuses_a_petname_swapped_under_a_signature() { + let (signing, public, address) = identity(7); + let payload = invite_signing_payload(&address, &public, Some("Alice")).expect("payload"); + let signature = sign(&signing, &payload); + + // The attacker keeps the victim's real, public {address, pubkey} and + // the genuine signature, and swaps only the name. + let relabeled = + encode_invite(&address, &public, Some("Bob"), Some(&signature)).expect("encode"); + + assert!(matches!( + parse_invite(&relabeled), + Err(MlsError::VerificationFailed(_)) + )); + } + + /// An invite signed by a *different* key than the one it names must fail: + /// the signature is verified under the invite's own public key, so this is + /// the case where an attacker signs someone else's identity. + #[test] + fn invite_refuses_a_signature_by_a_foreign_key() { + let (_, public, address) = identity(8); + let (foreign_signing, _, _) = identity(9); + let payload = invite_signing_payload(&address, &public, None).expect("payload"); + let signature = sign(&foreign_signing, &payload); + let blob = encode_invite(&address, &public, None, Some(&signature)).expect("encode"); + assert!(matches!( + parse_invite(&blob), + Err(MlsError::VerificationFailed(_)) + )); + } + + #[test] + fn invite_refuses_an_unknown_version() { + let (_, public, address) = identity(10); + let blob = encode_invite(&address, &public, None, None).expect("encode"); + let mut bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode"); + bytes[0] = 2; + assert!(matches!( + parse_invite(&URL_SAFE_NO_PAD.encode(&bytes)), + Err(MlsError::InvalidMessage(_)) + )); + } + + /// An unknown flag bit selects a section this build cannot read, so it is + /// refused rather than ignored — ignoring it desynchronizes the parse. + #[test] + fn invite_refuses_unknown_flag_bits() { + let (_, public, address) = identity(11); + let blob = encode_invite(&address, &public, None, None).expect("encode"); + let mut bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode"); + bytes[1] |= 0b1000_0000; + assert!(matches!( + parse_invite(&URL_SAFE_NO_PAD.encode(&bytes)), + Err(MlsError::InvalidMessage(_)) + )); + } + + #[test] + fn invite_refuses_truncation_at_every_length() { + let (signing, public, address) = identity(12); + let payload = invite_signing_payload(&address, &public, Some("Alice")).expect("payload"); + let signature = sign(&signing, &payload); + let blob = + encode_invite(&address, &public, Some("Alice"), Some(&signature)).expect("encode"); + let bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode"); + + for cut in 0..bytes.len() { + let truncated = URL_SAFE_NO_PAD.encode(&bytes[..cut]); + assert!( + parse_invite(&truncated).is_err(), + "a blob truncated to {} bytes must not parse", + cut + ); + } + } + + #[test] + fn invite_refuses_trailing_bytes() { + let (_, public, address) = identity(13); + let blob = encode_invite(&address, &public, None, None).expect("encode"); + let mut bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode"); + bytes.push(0); + assert!(matches!( + parse_invite(&URL_SAFE_NO_PAD.encode(&bytes)), + Err(MlsError::InvalidMessage(_)) + )); + } + + #[test] + fn invite_refuses_an_over_long_petname() { + let (_, public, address) = identity(14); + let long = "a".repeat(MAX_PETNAME_BYTES + 1); + assert!(encode_invite(&address, &public, Some(&long), None).is_err()); + } + + /// A petname that renders as something other than its own bytes is refused + /// at both ends, and the parse side is the one that matters: an attacker + /// does not use our encoder. + /// + /// The characters here are `Cf`, so [`char::is_control`] does not see any + /// of them — a screen built on it alone would pass every one. This is the + /// string an app shows in the dialog after a scan, so a right-to-left + /// override here reads as a different name than the bytes that were + /// signed. + #[test] + fn invite_refuses_a_petname_that_renders_as_another_name() { + let (signing, public, address) = identity(15); + + for (label, name) in [ + ("right-to-left override", "ali\u{202E}ce"), + ("zero-width joiner", "ali\u{200D}ce"), + ("soft hyphen", "ali\u{00AD}ce"), + ("byte-order mark", "ali\u{FEFF}ce"), + ("newline", "ali\nce"), + ] { + assert!( + encode_invite(&address, &public, Some(name), None).is_err(), + "a {label} petname must not be mintable" + ); + + // Hand-built, since the encoder now refuses to produce one — and + // signed, which is the case that matters: without this screen the + // deceptive rendering would arrive carrying a *valid* signature, + // so an app trusting `signed` would be trusting the wrong half. + let payload = invite_signing_payload(&address, &public, Some(name)).expect("payload"); + let signature = sign(&signing, &payload); + let address_bytes = address.to_string(); + let mut blob = vec![INVITE_VERSION, FLAG_PETNAME | FLAG_SIGNATURE]; + blob.extend_from_slice(&public); + blob.push(address_bytes.len() as u8); + blob.extend_from_slice(address_bytes.as_bytes()); + blob.push(name.len() as u8); + blob.extend_from_slice(name.as_bytes()); + blob.extend_from_slice(&signature); + + assert!( + matches!( + parse_invite(&URL_SAFE_NO_PAD.encode(&blob)), + Err(MlsError::InvalidMessage(_)) + ), + "a {label} petname must be refused at parse even when signed" + ); + } + } + + /// The screen must not swallow ordinary names, or it is useless. + #[test] + fn invite_allows_ordinary_international_petnames() { + let (_, public, address) = identity(16); + for name in ["Alice", "José", "上田", "أحمد", "Ann Lee"] { + let blob = encode_invite(&address, &public, Some(name), None).expect("encode"); + assert_eq!( + parse_invite(&blob).expect("parse").petname.as_deref(), + Some(name) + ); + } + } + + /// Golden vectors. These strings are the invite wire format: an + /// implementation in another language must produce them byte for byte. + /// + /// **Computed independently of this code**, by a Python script that builds + /// bech32m from the BIP-350 reference implementation, takes Ed25519 from + /// `cryptography`, and assembles the blob from the written format rather + /// than from this source. Agreement is therefore a two-implementation + /// cross-check and not a restatement of whatever the encoder happened to + /// emit. Regenerate them the same way, by changing the format on purpose — + /// never by pasting new code output. + /// + /// The identity is the all-`0x01` Ed25519 seed, so a second implementation + /// can reproduce every line without this repository. + #[test] + fn invite_golden_vectors() { + let (signing, public, address) = identity(1); + assert_eq!( + address.to_string(), + "off1qy682ruch4vlely5dkj94247jva7z49yk5xpqee0", + "the golden identity's address changed" + ); + + let unsigned = encode_invite(&address, &public, None, None).expect("encode"); + assert_eq!( + unsigned, + "AQCKiOPddAnxlf1S2y08ul1yymcJvx2UEhvzdIgBtA9vXCxvZmYxcXk2ODJydWNoNHZsZWx5NWRrajk0MjQ3anZhN3o0OXlrNXhwcWVlMA" + ); + + let named = encode_invite(&address, &public, Some("alice"), None).expect("encode"); + assert_eq!( + named, + "AQGKiOPddAnxlf1S2y08ul1yymcJvx2UEhvzdIgBtA9vXCxvZmYxcXk2ODJydWNoNHZsZWx5NWRrajk0MjQ3anZhN3o0OXlrNXhwcWVlMAVhbGljZQ" + ); + + let payload = invite_signing_payload(&address, &public, Some("alice")).expect("payload"); + assert_eq!( + base64::engine::general_purpose::STANDARD.encode(&payload), + "b2ZmbGluZS1pbnZpdGUtdjEAAAABAQAAACxvZmYxcXk2ODJydWNoNHZsZWx5NWRrajk0MjQ3anZhN3o0OXlrNXhwcWVlMAAAACCKiOPddAnxlf1S2y08ul1yymcJvx2UEhvzdIgBtA9vXAAAAAVhbGljZQ==" + ); + + let signature = sign(&signing, &payload); + let signed = + encode_invite(&address, &public, Some("alice"), Some(&signature)).expect("encode"); + assert!(parse_invite(&signed).expect("parse").signed); + assert_eq!(signed.len(), 199, "signed invite length changed"); + } +} diff --git a/crates/offline-protocol-mls/src/lib.rs b/crates/offline-protocol-mls/src/lib.rs index 70c3c701..1368dec2 100644 --- a/crates/offline-protocol-mls/src/lib.rs +++ b/crates/offline-protocol-mls/src/lib.rs @@ -32,8 +32,11 @@ #![deny(unsafe_code)] #![warn(missing_docs)] +mod canonical; +pub mod discovery; pub mod error; pub mod group; +pub mod invite; pub mod manager; pub mod provider; pub mod session; diff --git a/crates/offline-protocol-transport/src/constants.rs b/crates/offline-protocol-transport/src/constants.rs index 509eb5dd..d58128e8 100644 --- a/crates/offline-protocol-transport/src/constants.rs +++ b/crates/offline-protocol-transport/src/constants.rs @@ -224,6 +224,21 @@ pub const NOSTR_MAX_TRACKED_PEER_KEYS: usize = 1000; /// until the next tick refills rather than silently reusing a spent package. pub const NOSTR_KEY_PACKAGE_SLOTS: usize = 5; +/// Maximum discovery records one relay may return for a username query. +/// +/// A username resolves to the set of devices claiming it, so this bounds how +/// many claimants a single relay can put in front of a user. Sized well above +/// any real user's device count and well below +/// `nostr::MAX_QUERY_EVENTS`, which bounds the whole query across every +/// connected relay. +/// +/// The cost of this being too low is not a dropped record but a *displaced* +/// one: the tag is public, anyone may publish to it, and a squatter who floods +/// it pushes legitimate claimants out of the answer. That is crowding, which +/// the design accepts as the price of a non-authoritative directory — the user +/// still confirms out of band, and the invite path is unaffected. +pub const NOSTR_DISCOVERY_QUERY_LIMIT: usize = 16; + // Transport-wide Constants /// Default maximum message size in bytes (1 MB). /// Applied at the transport layer before JSON deserialization to prevent diff --git a/crates/offline-protocol-transport/src/nostr.rs b/crates/offline-protocol-transport/src/nostr.rs index 19fa9f99..f56ce94c 100644 --- a/crates/offline-protocol-transport/src/nostr.rs +++ b/crates/offline-protocol-transport/src/nostr.rs @@ -29,7 +29,7 @@ use crate::{ Error, Result, SharedCallback, Transport, TransportMetrics, TransportStatus, TransportType, }; use base64::Engine; -use offline_protocol_core::{Address, Message, MutexExt, RwLockExt}; +use offline_protocol_core::{Address, Message, MutexExt, RwLockExt, Username}; use std::borrow::Cow; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -53,24 +53,81 @@ const MAX_SIGN_RETRIES: u8 = 3; /// construction rather than by a check that could be forgotten. pub const NOSTR_PUBLICATION_ID_PREFIX: &str = "__nostr_kp__:"; -/// Recovers the slot id from a publication's synthetic message id, or `None` -/// for an ordinary message id. +/// Marks a synthetic message id belonging to a published username discovery +/// record. /// -/// Every message-path side effect must consult this, not just the failure -/// reporting that motivated it. In particular a publication's outcome is -/// deliberately kept out of [`TransportMetrics`]: DORS scores this transport's -/// reliability on `success_count / (success_count + failure_count)`, those +/// A second discriminator beside [`NOSTR_PUBLICATION_ID_PREFIX`], and the +/// reason the classifier returns an enum rather than an `Option`. +pub const NOSTR_DISCOVERY_ID_PREFIX: &str = "__nostr_disc__:"; + +/// Marks a synthetic message id belonging to a NIP-09 deletion request. +/// +/// Deletions are the best-effort half of a retraction and nothing recovers when +/// one fails, but they still ride the send queue and so still need to be kept +/// out of the delivery metrics. Giving them a discriminator is what makes that +/// automatic rather than a special case someone has to remember. +pub const NOSTR_DELETION_ID_PREFIX: &str = "__nostr_del__:"; + +/// What a synthetic message id denotes, when it is one. +/// +/// # Why this is an enum and not two `Option`-returning helpers +/// +/// This classifier is consulted by every message-path side effect — +/// [`NostrTransport::confirm_sent`], [`NostrTransport::report_send_failure`], +/// `drain_expired_pending` and `fail_all_pending` — and getting *one* of them +/// wrong is a shipped, previously-diagnosed bug: publication outcomes leaking +/// into [`TransportMetrics`] poison DORS scoring, because DORS scores this +/// transport on `success_count / (success_count + failure_count)`, those /// counters are lifetime totals with no decay, and an idle install publishes -/// far more than it sends — so counting publications would score the transport -/// on something other than its ability to carry messages. A relay that rejects -/// kind 30443 would drive the ratio toward zero and make DORS deprioritise -/// Nostr for traffic that delivers fine; publications that succeed would -/// equally mask real message failures. See -/// `test_publication_outcomes_stay_out_of_the_delivery_metrics`. -fn publication_slot_id(message_id: &str) -> Option { - message_id - .strip_prefix(NOSTR_PUBLICATION_ID_PREFIX) - .map(str::to_string) +/// far more than it sends. A relay that rejects the kind drives the ratio +/// toward zero and makes DORS deprioritise Nostr for traffic that delivers +/// fine; publications that succeed equally mask real message failures. +/// +/// A second record type is exactly the change that historically half-wires: +/// adding another prefix plus another `Option` helper leaves four call sites +/// that each *silently* keep compiling while consulting only the old one. +/// Returning a single enum means every site matches, so a third record type +/// added later fails to compile at each of them instead of quietly scoring +/// itself as delivery. That is the whole point of the shape. +/// +/// The metrics rule is kind-independent — any variant stays out of +/// [`TransportMetrics`] — while failure *routing* differs per variant, which is +/// why the payload rides along. See +/// `test_publication_outcomes_stay_out_of_the_delivery_metrics` and +/// `test_discovery_publication_outcomes_stay_out_of_the_delivery_metrics`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SyntheticPublication { + /// A key-package record. Carries the addressable slot id, which the engine + /// needs in order to mark the slot unpublished and refill it. + KeyPackage(String), + /// A username discovery record. Carries the discovery tag it was published + /// at, which is both its `#p` tag and its `d` tag. + Discovery(String), + /// A NIP-09 deletion request, the best-effort half of a retraction. + /// + /// Carries no payload because nothing acts on its outcome: the durable half + /// of a retraction is the tombstone, which replaces the claim through the + /// addressable rule rather than through a relay's cooperation. It is a + /// variant rather than an untagged id so the metrics exclusion covers it by + /// construction. + Deletion, +} + +/// Classifies a message id, returning `None` for an ordinary protocol message. +/// +/// See [`SyntheticPublication`] for why this exists and what every caller owes +/// it. +pub(crate) fn synthetic_publication(message_id: &str) -> Option { + if let Some(slot_id) = message_id.strip_prefix(NOSTR_PUBLICATION_ID_PREFIX) { + return Some(SyntheticPublication::KeyPackage(slot_id.to_string())); + } + if let Some(tag) = message_id.strip_prefix(NOSTR_DISCOVERY_ID_PREFIX) { + return Some(SyntheticPublication::Discovery(tag.to_string())); + } + if message_id.starts_with(NOSTR_DELETION_ID_PREFIX) { + return Some(SyntheticPublication::Deletion); + } + None } /// Maximum peers queued for key-package resolution at once. @@ -106,12 +163,8 @@ const MAX_QUERY_EVENTS: usize = 64; /// A resolution query the platform is currently running. #[derive(Debug)] struct ActiveQuery { - /// The peer being resolved. An inbound event is meaningless without it: - /// this is whose derivable key opens the record. - /// - /// Carried as a parsed [`Address`] so the record-seal derivation this - /// feeds cannot be handed a string that was never validated. - user_id: Address, + /// What this query is resolving, and the key that opens what comes back. + subject: QuerySubject, /// Event ids already taken for this query. The query is broadcast, so the /// same record arrives once per relay, and opening it more than once /// re-runs the key-package handler's durable writes for no gain. Bounded @@ -121,6 +174,74 @@ struct ActiveQuery { delivered: usize, } +/// What an active query is asking for. +/// +/// The variant decides three things at once — which kind is accepted, which +/// derivable key opens the payload, and which engine handler the plaintext goes +/// to — so carrying them together is what stops a discovery record from being +/// fed to the key-package handler, or vice versa. A record delivered under the +/// wrong subject is not merely useless: `handle_resolved_key_package` is a +/// deliberately narrow gate, and widening the set of things that can reach it +/// is exactly what that gate exists to prevent. +#[derive(Debug, Clone)] +enum QuerySubject { + /// A peer's published key packages. + /// + /// Carried as a parsed [`Address`] so the record-seal derivation this feeds + /// cannot be handed a string that was never validated. + KeyPackage(Address), + /// The devices claiming a username. + /// + /// Carried as a parsed [`Username`] for the same reason: the + /// discovery-seal derivation must not be handed an unnormalized name. + Discovery(Username), +} + +/// What a username-resolution request did. +/// +/// Exists because "no query was queued" is three different promises to the +/// caller, and only one of them means an answer is still coming. A bare `false` +/// makes an app that awaits the resolution event indistinguishable from an app +/// that hangs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolveRequest { + /// Newly queued. A resolution event will follow. + Queued, + /// A lookup for this name is already queued. Its event answers both + /// callers, so this is a success from the caller's point of view. + AlreadyQueued, + /// Username discovery is off. Nothing was sent to a relay, and no event + /// will ever arrive for this name. + Disabled, + /// The resolve queue is at its ceiling. Nothing will arrive for this name; + /// the caller may retry once earlier lookups drain. + QueueFull, +} + +/// What opening a query event produced. +#[derive(Debug, Clone)] +pub enum ResolvedRecord { + /// A key-package record: the author's Nostr pubkey and the decrypted + /// protocol message bytes. + KeyPackage { + /// The event's author key. + author: String, + /// The decrypted protocol message. + plaintext: Vec, + }, + /// A discovery record: the username queried, the author key it must be + /// bound to, and the decrypted record body. + Discovery { + /// The username this query resolved. + username: Username, + /// The event's author key, which the record's `nostr_author` field must + /// equal. + author: String, + /// The decrypted `DiscoveryRecordV1` or tombstone body. + plaintext: Vec, + }, +} + /// A key-package record waiting to be published to the relays. #[derive(Debug, Clone)] struct PendingPublication { @@ -131,6 +252,23 @@ struct PendingPublication { payload: Vec, } +/// A username discovery record waiting to be published. +#[derive(Debug, Clone)] +struct PendingDiscoveryPublication { + /// The username this record claims. Held parsed so the tag and seal + /// derivations at drain time cannot be handed an unnormalized string. + username: Username, + /// The serialized `DiscoveryRecordV1`, or a tombstone for a retraction. + payload: Vec, + /// Whether this publication is a retraction. + /// + /// A retraction publishes a tombstone into the addressable slot *and* + /// emits a best-effort NIP-09 deletion. Carried as a flag rather than a + /// separate queue because the two share the whole drain path and differ + /// only in that one extra event. + retraction: bool, +} + /// A relay query the platform should issue on the transport's behalf. #[derive(Debug, Clone)] pub struct NostrQuery { @@ -139,6 +277,14 @@ pub struct NostrQuery { pub query_id: String, /// Complete `["REQ", ...]` JSON string for the relay WebSocket. pub req_json: String, + /// The username being resolved, when this is a discovery query. + /// + /// Carried out so the engine can open an accumulator keyed by `query_id` + /// at the moment the query is minted. Doing it here rather than on the + /// first answer means a query that returns *nothing* still emits an empty + /// result rather than silently never completing — "no such name" is an + /// answer, and an app waiting on one deserves to receive it. + pub discovery_username: Option, } /// Mints a subscription id for a resolution query. @@ -287,6 +433,40 @@ pub struct NostrTransport { /// Bounded by construction: the keys are our own slot ids, of which the /// engine keeps at most `NOSTR_KEY_PACKAGE_SLOTS`. failed_publications: Mutex>, + /// Whether this install publishes and resolves username discovery records. + /// Mirrors `TransportConfig::nostr_username_discovery_enabled`. + /// + /// Off by default, unlike cold contact. Publishing a discovery record binds + /// a human-readable name to an address in a public place, which is + /// materially more disclosure than the key-package record's "an install + /// with this tag exists", and it is the kind of decision an app should make + /// deliberately rather than inherit. + discovery_enabled: Mutex, + /// Username discovery records waiting to go out. + /// + /// Separate from [`Self::publication_queue`] rather than a variant inside + /// it, because the two drain in a fixed order (key packages first) and + /// share no state: a discovery claim republished while a key-package slot + /// is queued must not displace it. + discovery_queue: Mutex>, + /// Discovery tags whose publication left the queue but never reached a + /// relay, waiting for the engine to drain them via + /// [`Self::take_failed_discovery_publications`]. + /// + /// The same channel [`Self::failed_publications`] provides for slots, and + /// for the same reason: the engine marks a claim published when it queues + /// it, so without this a failure after that point leaves the claim marked + /// healthy while the relays hold nothing. + /// + /// Bounded by construction: an install claims one username, so this holds + /// at most one entry in practice and is keyed by our own derived tag in + /// every case. + failed_discovery_publications: Mutex>, + /// Ready-built NIP-09 deletion events, as `(event_id, relay_json)`. + /// + /// Queued by a retraction after its tombstone is built, so the deletion + /// follows the tombstone rather than racing it. Nothing retries these. + pending_deletions: Mutex>, /// Peer addresses whose published key packages we want fetched. /// /// Holds parsed [`Address`]es rather than strings: an entry becomes the @@ -295,6 +475,12 @@ pub struct NostrTransport { /// writer and parses there, which is what lets `next_query` derive a tag /// without re-validating or re-deciding what to do when it fails. resolve_queue: Mutex>, + /// Usernames whose discovery records we want fetched. + /// + /// Holds parsed [`Username`]s for the same reason [`Self::resolve_queue`] + /// holds parsed [`Address`]es: an entry becomes the `#p` tag of a relay + /// query, and an unnormalized name derives a tag nobody publishes at. + discovery_resolve_queue: Mutex>, /// Query id → the query's state. An inbound event is meaningless without /// this: the id tells us whose derivable key opens it, and carries the /// per-query dedup and delivery ceiling with it. @@ -380,7 +566,12 @@ impl NostrTransport { cold_contact_enabled: Mutex::new(true), publication_queue: Mutex::new(VecDeque::new()), failed_publications: Mutex::new(HashSet::new()), + discovery_enabled: Mutex::new(false), + discovery_queue: Mutex::new(VecDeque::new()), + failed_discovery_publications: Mutex::new(HashSet::new()), + pending_deletions: Mutex::new(VecDeque::new()), resolve_queue: Mutex::new(VecDeque::new()), + discovery_resolve_queue: Mutex::new(VecDeque::new()), active_queries: Mutex::new(HashMap::new()), resolve_attempts: Mutex::new(HashMap::new()), config, @@ -598,6 +789,90 @@ impl NostrTransport { } } + /// Records discovery publications that never reached a relay. + fn mark_discovery_publications_failed(&self, tags: Vec) { + if tags.is_empty() { + return; + } + let mut failed = self.failed_discovery_publications.lock_or_recover(); + for tag in tags { + tracing::warn!( + "Nostr username discovery publication did not reach a relay; \ + the claim will be republished" + ); + failed.insert(tag); + } + } + + /// Drains the discovery tags whose publication never reached a relay. + pub fn take_failed_discovery_publications(&self) -> Vec { + self.failed_discovery_publications + .lock_or_recover() + .drain() + .collect() + } + + /// Enables or disables username discovery publication and resolution. + pub fn set_discovery_enabled(&self, enabled: bool) { + *self.discovery_enabled.lock_or_recover() = enabled; + } + + /// Whether username discovery is active. + /// + /// **Requires cold contact as well as its own switch.** A discovery record + /// points at an address whose key packages are what a resolver fetches + /// next; with cold contact off, nothing is published there and the claim + /// resolves to a dead end one hop later. Hard-coupled here rather than + /// documented, so the dead-end configuration is unreachable rather than + /// merely discouraged. + pub fn discovery_enabled(&self) -> bool { + *self.discovery_enabled.lock_or_recover() && self.cold_contact_enabled() + } + + /// Queues a username discovery record for publication. + /// + /// Replacing a claim is the caller's decision, not this queue's: the record + /// is addressable at a deterministic `d`, so publishing the same username + /// again overwrites it at the relay. A claim queued twice before it drains + /// is collapsed to the newer payload — the older one is by definition + /// superseded, and publishing both would briefly stand the stale one back + /// up. + pub fn publish_discovery_record(&self, username: Username, payload: Vec) { + self.enqueue_discovery_publication(username, payload, false); + } + + /// Queues a retraction: a tombstone into the claim's slot, plus a + /// best-effort NIP-09 deletion. + /// + /// Not gated on [`Self::discovery_enabled`], deliberately and unlike + /// [`Self::publish_discovery_record`]. Turning the feature *off* is the + /// single most likely reason to retract, and a gate here would make the + /// switch strand exactly the claim the user just asked to withdraw. + pub fn retract_discovery_record(&self, username: Username, payload: Vec) { + self.enqueue_discovery_publication(username, payload, true); + } + + fn enqueue_discovery_publication( + &self, + username: Username, + payload: Vec, + retraction: bool, + ) { + if !retraction && !self.discovery_enabled() { + return; + } + { + let mut queue = self.discovery_queue.lock_or_recover(); + queue.retain(|pending| pending.username != username); + queue.push_back(PendingDiscoveryPublication { + username, + payload, + retraction, + }); + } + self.notify_messages_available(); + } + /// Drains the slot ids whose publication never reached a relay, so the /// engine can clear them from its published set and republish. pub fn take_failed_publications(&self) -> Vec { @@ -677,17 +952,35 @@ impl NostrTransport { /// Pops the next queued resolution and returns the REQ for the platform to /// issue, registering the query so inbound events can be routed back. pub fn next_query(&self) -> Result> { - let user_id = { + // Key-package resolutions drain first: they are triggered by a send + // that is already waiting on the bootstrap leg, whereas a discovery + // query is a user-initiated lookup with no frame behind it. + let subject = { let mut queue = self.resolve_queue.lock_or_recover(); - match queue.pop_front() { - Some(u) => u, - None => return Ok(None), + queue.pop_front().map(QuerySubject::KeyPackage) + }; + let subject = match subject { + Some(subject) => subject, + None => { + let mut queue = self.discovery_resolve_queue.lock_or_recover(); + match queue.pop_front() { + Some(username) => QuerySubject::Discovery(username), + None => return Ok(None), + } } }; - let routing_tag = nostr_crypto::routing_tag_for_address(&user_id)?; let query_id = new_query_id(); - let req_json = nostr_crypto::create_key_package_query_message(&routing_tag, &query_id)?; + let req_json = match &subject { + QuerySubject::KeyPackage(address) => { + let routing_tag = nostr_crypto::routing_tag_for_address(address)?; + nostr_crypto::create_key_package_query_message(&routing_tag, &query_id)? + } + QuerySubject::Discovery(username) => { + let discovery_tag = nostr_crypto::discovery_tag_for_username(username)?; + nostr_crypto::create_discovery_query_message(&discovery_tag, &query_id)? + } + }; { let mut active = self.active_queries.lock_or_recover(); @@ -704,14 +997,23 @@ impl NostrTransport { active.insert( query_id.clone(), ActiveQuery { - user_id, + subject: subject.clone(), seen_events: HashSet::new(), delivered: 0, }, ); } - Ok(Some(NostrQuery { query_id, req_json })) + let discovery_username = match &subject { + QuerySubject::Discovery(username) => Some(username.clone()), + QuerySubject::KeyPackage(_) => None, + }; + + Ok(Some(NostrQuery { + query_id, + req_json, + discovery_username, + })) } /// Opens an event delivered for `query_id`, returning the author's Nostr @@ -729,6 +1031,16 @@ impl NostrTransport { /// placed at this peer's tag by somebody else therefore registers under /// *that* signer's identity, not under the peer we asked about. /// + /// # The one asymmetry: discovery events are authenticated here + /// + /// A key-package record needs no event-level check, for the reason above: + /// whatever it carries is authenticated downstream by its own signature. A + /// discovery *tombstone* has nothing downstream — its body is a constant + /// and its meaning is entirely "who published this" — so discovery events + /// are checked against their own BIP-340 signature before they are opened. + /// See `nostr_crypto::event_is_authentic` for what a forged tombstone would + /// otherwise buy. + /// /// # What squatting a tag does buy /// /// Two things, both bounded, neither a loss: @@ -761,16 +1073,16 @@ impl NostrTransport { &self, query_id: &str, event_json: &str, - ) -> Result)>> { + ) -> Result> { if event_json.len() > NOSTR_MAX_PAYLOAD_SIZE { tracing::warn!( len = event_json.len(), - "Oversized Nostr key-package record; ignoring" + "Oversized Nostr resolution record; ignoring" ); return Ok(None); } - let user_id = { + let subject = { let mut active = self.active_queries.lock_or_recover(); let Some(query) = active.get_mut(query_id) else { tracing::debug!(query_id = %query_id, "Nostr query event for an unknown query"); @@ -787,20 +1099,26 @@ impl NostrTransport { return Ok(None); } query.delivered += 1; - query.user_id + query.subject.clone() }; let event: serde_json::Value = match serde_json::from_str(event_json) { Ok(v) => v, Err(e) => { - tracing::debug!(error = %e, "Unparseable Nostr key-package record"); + tracing::debug!(error = %e, "Unparseable Nostr resolution record"); return Ok(None); } }; - if event.get("kind").and_then(|k| k.as_u64()) - != Some(nostr_crypto::NOSTR_KEY_PACKAGE_KIND as u64) - { + // The kind is checked against what *this query asked for*, not against + // the set of kinds this transport understands. A relay answering a + // discovery REQ with a key-package record — or the reverse — must be + // dropped rather than routed to the other handler. + let expected_kind = match &subject { + QuerySubject::KeyPackage(_) => nostr_crypto::NOSTR_KEY_PACKAGE_KIND, + QuerySubject::Discovery(_) => nostr_crypto::NOSTR_DISCOVERY_KIND, + }; + if event.get("kind").and_then(|k| k.as_u64()) != Some(expected_kind as u64) { return Ok(None); } @@ -811,6 +1129,22 @@ impl NostrTransport { return Ok(None); }; + // A discovery event must genuinely be from the key it names, and this + // is the only record kind for which that matters. A tombstone's whole + // meaning is *who published it* — there is nothing inside it to sign — + // and the discovery seal key is public, so without this one hostile + // relay could forge a retraction for an honest claimant and erase them + // from the resolved set. See `nostr_crypto::event_is_authentic`. + // + // Checked before the dedup mark below, so an unauthentic event never + // consumes a genuine record's slot. + if matches!(subject, QuerySubject::Discovery(_)) + && !nostr_crypto::event_is_authentic(&event) + { + tracing::debug!("Discovery event failed its own signature check; ignoring"); + return Ok(None); + } + // The query is broadcast, so every connected relay answers it and the // same record arrives once per relay. Take each event id once: behind // this call the key-package handler performs two durable @@ -834,28 +1168,119 @@ impl NostrTransport { let sealed = match base64::engine::general_purpose::STANDARD.decode(content) { Ok(bytes) => bytes, Err(e) => { - tracing::debug!(error = %e, "Nostr key-package record content is not base64"); + tracing::debug!(error = %e, "Nostr resolution record content is not base64"); return Ok(None); } }; - // The peer's derivable key is computable from their user id — that is - // the whole reason a record sealed to it stays fetchable by anyone - // entitled to fetch it, while remaining opaque to a relay scraping by - // kind alone. - let peer_key = nostr_crypto::record_seal_keypair_for_address(&user_id)?; - match nostr_crypto::open_key_package_publication(&peer_key, author, &sealed) { - Ok(plaintext) => Ok(Some((author.to_string(), plaintext))), - Err(e) => { - tracing::debug!(error = %e, "Nostr key-package record did not open"); - Ok(None) + match subject { + QuerySubject::KeyPackage(address) => { + // The peer's derivable key is computable from their address — + // that is the whole reason a record sealed to it stays + // fetchable by anyone entitled to fetch it, while remaining + // opaque to a relay scraping by kind alone. + let peer_key = nostr_crypto::record_seal_keypair_for_address(&address)?; + match nostr_crypto::open_key_package_publication(&peer_key, author, &sealed) { + Ok(plaintext) => Ok(Some(ResolvedRecord::KeyPackage { + author: author.to_string(), + plaintext, + })), + Err(e) => { + tracing::debug!(error = %e, "Nostr key-package record did not open"); + Ok(None) + } + } + } + QuerySubject::Discovery(username) => { + // Reconstructed from the queried name, exactly as any resolver + // would. See `discovery_seal_keypair_for_username` for why this + // key is public by construction and must never be load-bearing. + let seal_key = nostr_crypto::discovery_seal_keypair_for_username(&username)?; + match nostr_crypto::open_discovery_record(&seal_key, author, &sealed) { + Ok(plaintext) => Ok(Some(ResolvedRecord::Discovery { + username, + // Lower-cased because the resolver *keys* claims and + // retractions by this string while verification + // compares decoded bytes. Two spellings of one key + // would otherwise be two devices to the accumulator + // and one to the verifier, so a retraction in one + // spelling would not suppress a claim in the other. + author: author.to_ascii_lowercase(), + plaintext, + })), + Err(e) => { + tracing::debug!(error = %e, "Nostr discovery record did not open"); + Ok(None) + } + } } } } /// Releases a query once the platform has seen its end-of-stored-events. - pub fn complete_query(&self, query_id: &str) { - self.active_queries.lock_or_recover().remove(query_id); + /// + /// Returns the username when the query was a discovery lookup, so the + /// engine knows which accumulated claim set to flush. End-of-stored-events + /// is the *only* natural completion signal a Nostr query has, which is why + /// the engine also sweeps on a timer: a relay that never sends EOSE would + /// otherwise leave a resolution accumulating forever and never answering. + pub fn complete_query(&self, query_id: &str) -> Option { + let query = self.active_queries.lock_or_recover().remove(query_id)?; + match query.subject { + QuerySubject::Discovery(username) => Some(username), + QuerySubject::KeyPackage(_) => None, + } + } + + /// Requests resolution of the devices claiming `username`. + /// + /// Returns *why* it did what it did rather than a bare boolean. The three + /// refusals mean materially different things to a caller: after + /// [`ResolveRequest::AlreadyQueued`] an answer is still coming, while after + /// the other two nothing will ever arrive. Collapsing them into `false` + /// leaves an app that waits on the resolution event unable to tell "wait" + /// from "hang". See `OfflineProtocol::resolve_username`, which is what + /// turns that distinction into an error. + /// + /// Deliberately *not* rate-limited the way key-package resolution is. That + /// limiter exists because a send to an unpublished peer would otherwise + /// mint a round-trip per frame, automatically and invisibly. A discovery + /// lookup is user-initiated — someone typed a name and pressed search — so + /// throttling it would make the second attempt at a mistyped name silently + /// do nothing. The queue bound is what keeps it finite. + pub fn resolve_username(&self, username: Username) -> ResolveRequest { + if !self.discovery_enabled() { + return ResolveRequest::Disabled; + } + + { + let mut queue = self.discovery_resolve_queue.lock_or_recover(); + if queue.iter().any(|queued| *queued == username) { + return ResolveRequest::AlreadyQueued; + } + if queue.len() >= MAX_PENDING_RESOLUTIONS { + return ResolveRequest::QueueFull; + } + queue.push_back(username); + } + + self.notify_messages_available(); + ResolveRequest::Queued + } + + /// Removes a queued username lookup that has not been minted into a query. + /// + /// Returns whether one was removed. The engine calls this when it gives up + /// on a lookup that never reached a relay — queries are pumped only while + /// the socket is up, so a lookup made offline can sit here indefinitely. + /// Without the removal, [`Self::resolve_username`] would keep refusing that + /// name as "already queued" while nothing was ever going to answer it, so a + /// name that timed out once could not be looked up again. + pub fn cancel_username_resolution(&self, username: &Username) -> bool { + let mut queue = self.discovery_resolve_queue.lock_or_recover(); + let before = queue.len(); + queue.retain(|queued| queued != username); + before != queue.len() } /// Records a peer's real per-install Nostr public key, learned from the @@ -1115,6 +1540,132 @@ impl NostrTransport { } } + /// Builds the next queued discovery publication, if any. + /// + /// Mirrors [`Self::next_publication_event`], including its failure policy: + /// a build error drops the record rather than re-queueing it (retrying in + /// place would head-of-line block the message queue behind something that + /// keeps failing) and reports the tag through + /// [`Self::mark_discovery_publications_failed`], which is what makes the + /// engine republish it on a later tick. Reporting is not optional — the + /// engine marked the claim published when it queued the record, so a silent + /// drop strands it until the process restarts. + /// + /// A retraction additionally emits a NIP-09 deletion. That event is + /// best-effort: it is queued behind the tombstone, and a failure to build + /// it is logged rather than propagated, because the tombstone is the half + /// that actually works through addressable replacement. + fn next_discovery_event(&self) -> Result> { + let pending = { + let mut queue = self.discovery_queue.lock_or_recover(); + match queue.pop_front() { + Some(p) => p, + None => return Ok(None), + } + }; + + let tag = match nostr_crypto::discovery_tag_for_username(&pending.username) { + Ok(tag) => tag, + Err(e) => { + tracing::error!( + error = %e, + "Failed to derive a Nostr discovery tag; claim left unpublished" + ); + return Err(e); + } + }; + let message_id = format!("{}{}", NOSTR_DISCOVERY_ID_PREFIX, tag); + + let result = (|| { + let seal = nostr_crypto::discovery_seal_keypair_for_username(&pending.username)?; + let event = { + let keypair = self.keypair.read_or_recover(); + nostr_crypto::NostrEvent::create_discovery_publication( + &keypair, + &tag, + seal.public_key_hex(), + &pending.payload, + )? + }; + let event_id = event.id.clone(); + let event_json = event.to_relay_message()?; + if event_json.len() > NOSTR_MAX_PAYLOAD_SIZE { + return Err(crate::Error::MessageTooLarge( + event_json.len(), + NOSTR_MAX_PAYLOAD_SIZE, + )); + } + Ok((event_id, event_json)) + })(); + + match result { + Ok((event_id, event_json)) => { + if pending.retraction { + self.queue_discovery_deletion(&tag); + } + self.pending_confirmation + .lock_or_recover() + .insert(message_id.clone(), Instant::now()); + Ok(Some(SignedNostrEvent { + message_id, + event_id, + event_json, + })) + } + Err(e) => { + tracing::error!( + error = %e, + "Failed to build a Nostr discovery publication; claim left unpublished" + ); + self.mark_discovery_publications_failed(vec![tag]); + Err(e) + } + } + } + + /// Queues the best-effort NIP-09 deletion half of a retraction. + /// + /// Pushed to the *front* of the message queue's peer, the discovery queue, + /// so it follows the tombstone immediately. It carries no synthetic id and + /// no pending-confirmation entry: nothing republishes a deletion and + /// nothing recovers if a relay ignores it, which is what "best effort" + /// means here. + fn queue_discovery_deletion(&self, tag: &str) { + let event = { + let keypair = self.keypair.read_or_recover(); + nostr_crypto::NostrEvent::create_discovery_deletion(&keypair, tag) + }; + match event { + Ok(event) => { + let event_id = event.id.clone(); + match event.to_relay_message() { + Ok(json) => self + .pending_deletions + .lock_or_recover() + .push_back((event_id, json)), + Err(e) => tracing::warn!( + error = %e, + "Failed to serialize a Nostr discovery deletion; the tombstone still stands" + ), + } + } + Err(e) => tracing::warn!( + error = %e, + "Failed to build a Nostr discovery deletion; the tombstone still stands" + ), + } + } + + /// Pops a ready-built deletion event, if any. + fn next_deletion_event(&self) -> Option { + let (event_id, event_json) = self.pending_deletions.lock_or_recover().pop_front()?; + Some(SignedNostrEvent { + message_id: format!("{}{}", NOSTR_DELETION_ID_PREFIX, event_id), + event_id, + event_json, + }) + } + /// Pops the next outgoing message, creates a signed Nostr event, and returns /// `(message_id, recipient_device_id, relay_event_json)`. /// @@ -1140,6 +1691,24 @@ impl NostrTransport { return Ok(Some(publication)); } + // Discovery records drain after key packages and before messages. The + // order matters in one direction only: a claim points at an address + // whose key packages a resolver fetches next, so publishing the claim + // first would advertise a name that momentarily resolves to nothing. + if let Some(publication) = self.next_discovery_event()? { + return Ok(Some(publication)); + } + + // Deletions last among the synthetic events, so a retraction's + // tombstone is always on the wire before the deletion that accompanies + // it. If a relay honours the deletion and drops the tombstone, the + // claim is gone either way; the reverse order could delete the old + // claim and then have the tombstone fail, leaving nothing to mark the + // slot retracted. + if let Some(deletion) = self.next_deletion_event() { + return Ok(Some(deletion)); + } + let message = { let mut queue = self.send_queue.lock_or_recover(); match queue.pop_front() { @@ -1339,20 +1908,36 @@ impl NostrTransport { /// Fails all pending confirmations and records them as failures. fn fail_all_pending(&self) { - let (pending, publications) = { + let (pending, slots, tags) = { let mut map = self.pending_confirmation.lock_or_recover(); - let publications: Vec = map - .keys() - .filter_map(|id| publication_slot_id(id)) - .collect(); + let mut slots = Vec::new(); + let mut tags = Vec::new(); + let mut synthetic = 0usize; + for id in map.keys() { + match synthetic_publication(id) { + Some(SyntheticPublication::KeyPackage(slot)) => { + slots.push(slot); + synthetic += 1; + } + Some(SyntheticPublication::Discovery(tag)) => { + tags.push(tag); + synthetic += 1; + } + // Nothing republishes a deletion; it is counted only so it + // does not fall through into the delivery failures. + Some(SyntheticPublication::Deletion) => synthetic += 1, + None => {} + } + } // Only the messages count as failures — the publications among // them are reported to the engine instead. See - // [`publication_slot_id`]. - let count = map.len().saturating_sub(publications.len()); + // [`SyntheticPublication`]. + let count = map.len().saturating_sub(synthetic); map.clear(); - (count, publications) + (count, slots, tags) }; - self.mark_publications_failed(publications); + self.mark_publications_failed(slots); + self.mark_discovery_publications_failed(tags); if pending > 0 { let mut metrics = self.metrics.lock_or_recover(); metrics.failure_count = metrics.failure_count.saturating_add(pending as u32); @@ -1365,7 +1950,8 @@ impl NostrTransport { let timeout = Duration::from_secs(NOSTR_PENDING_CONFIRMATION_TIMEOUT_SECS); let now = Instant::now(); let mut expired_count = 0u32; - let mut expired_publications = Vec::new(); + let mut expired_slots = Vec::new(); + let mut expired_tags = Vec::new(); { let mut pending = self.pending_confirmation.lock_or_recover(); @@ -1373,9 +1959,16 @@ impl NostrTransport { if now.duration_since(*enqueued_at) > timeout { // A timed-out publication is reported to the engine, not // counted as a delivery failure. See - // [`publication_slot_id`]. - match publication_slot_id(message_id) { - Some(slot_id) => expired_publications.push(slot_id), + // [`SyntheticPublication`]. + match synthetic_publication(message_id) { + Some(SyntheticPublication::KeyPackage(slot_id)) => { + expired_slots.push(slot_id) + } + Some(SyntheticPublication::Discovery(tag)) => expired_tags.push(tag), + // Best effort: a deletion the relay never acknowledged + // is simply gone. The tombstone is what carries the + // retraction. + Some(SyntheticPublication::Deletion) => {} None => expired_count += 1, } false @@ -1385,7 +1978,8 @@ impl NostrTransport { }); } - self.mark_publications_failed(expired_publications); + self.mark_publications_failed(expired_slots); + self.mark_discovery_publications_failed(expired_tags); if expired_count > 0 { let mut metrics = self.metrics.lock_or_recover(); @@ -1626,9 +2220,10 @@ impl Transport for NostrTransport { .remove(message_id); // A publication is not a message and never moves the delivery metrics - // — see [`publication_slot_id`] for why counting it would misreport - // this transport's reliability to DORS. - if removed.is_some() && publication_slot_id(message_id).is_none() { + // — see [`SyntheticPublication`] for why counting it would misreport + // this transport's reliability to DORS. The rule is the same for every + // record kind, so this tests only that the id *is* synthetic. + if removed.is_some() && synthetic_publication(message_id).is_none() { let mut metrics = self.metrics.lock_or_recover(); metrics.success_count = metrics.success_count.saturating_add(1); recalculate_delivery_ratios(&mut metrics); @@ -1644,7 +2239,7 @@ impl Transport for NostrTransport { // Same rule as `confirm_sent`: a publication's outcome is reported to // the engine below, never to the delivery metrics DORS scores on. - if removed.is_some() && publication_slot_id(message_id).is_none() { + if removed.is_some() && synthetic_publication(message_id).is_none() { let mut metrics = self.metrics.lock_or_recover(); metrics.failure_count = metrics.failure_count.saturating_add(1); recalculate_delivery_ratios(&mut metrics); @@ -1652,10 +2247,17 @@ impl Transport for NostrTransport { // Unconditional, unlike the metrics above: a report that races the // confirmation timeout finds no pending entry, and missing a real - // failure strands the slot until restart while a redundant republish + // failure strands the record until restart while a redundant republish // costs one idempotent relay write. - if let Some(slot_id) = publication_slot_id(message_id) { - self.mark_publications_failed(vec![slot_id]); + match synthetic_publication(message_id) { + Some(SyntheticPublication::KeyPackage(slot_id)) => { + self.mark_publications_failed(vec![slot_id]) + } + Some(SyntheticPublication::Discovery(tag)) => { + self.mark_discovery_publications_failed(vec![tag]) + } + Some(SyntheticPublication::Deletion) => {} + None => {} } } } @@ -1739,6 +2341,11 @@ mod tests { addr_typed(label).to_string() } + /// A parsed [`Username`], for the derivations that take one. + fn user(label: &str) -> Username { + label.parse().expect("test username should parse") + } + /// `addr` as the parsed type, for the derivations that take an [`Address`]. fn addr_typed(label: &str) -> Address { use sha2::{Digest, Sha256}; @@ -3137,7 +3744,14 @@ mod tests { .open_query_event(&query.query_id, &event_json) .unwrap() .expect("bob's record opens with bob's derivable key"); - assert_eq!(opened.1, b"bob's key package"); + match opened { + ResolvedRecord::KeyPackage { plaintext, .. } => { + assert_eq!(plaintext, b"bob's key package"); + } + ResolvedRecord::Discovery { .. } => { + panic!("a key-package query must not yield a discovery record") + } + } // The same record delivered under a query for someone else does not // open: the query id is what says whose key to try. @@ -3244,6 +3858,393 @@ mod tests { ); } + /// The same rule, for the second record type. + /// + /// This is the test that exists because the rule was *nearly* half-wired + /// once: adding a record kind means adding a discriminator, and a + /// discriminator consulted at three of four sites produces exactly the DORS + /// poisoning the key-package version above prevents — silently, since + /// nothing observes a transport's score directly. + /// + /// It covers all three paths that move the counters: `confirm_sent`, + /// `report_send_failure`, and the confirmation timeout via + /// `fail_all_pending`. + #[test] + fn test_discovery_publication_outcomes_stay_out_of_the_delivery_metrics() { + let transport = NostrTransport::new(addr("alice")).unwrap(); + transport.set_discovery_enabled(true); + transport.start().unwrap(); + transport.on_status_changed(TransportStatus::Available); + + // One real message, delivered. + transport.send(&create_test_message()).unwrap(); + let msg = transport.get_next_signed_event().unwrap().unwrap(); + transport.confirm_sent(&msg.message_id); + + // A claim the relay rejects. + transport.publish_discovery_record(user("alice"), b"{}".to_vec()); + let published = transport.get_next_signed_event().unwrap().unwrap(); + assert!(published.message_id.starts_with(NOSTR_DISCOVERY_ID_PREFIX)); + transport.report_send_failure(&published.message_id); + + // A claim the relay accepts. + transport.publish_discovery_record(user("bob"), b"{}".to_vec()); + let accepted = transport.get_next_signed_event().unwrap().unwrap(); + transport.confirm_sent(&accepted.message_id); + + let metrics = transport.metrics(); + assert_eq!( + metrics.success_count, 1, + "an accepted discovery publication was counted as a delivered message" + ); + assert_eq!( + metrics.failure_count, 0, + "a rejected discovery publication was counted as a delivery failure" + ); + assert_eq!( + metrics.delivery_ratio, + Some(1.0), + "the only message sent was delivered; the ratio DORS reads must say so" + ); + + // ...and the failed claim is still reported back for republication. + assert_eq!( + transport.take_failed_discovery_publications(), + vec![nostr_crypto::discovery_tag_for_username(&user("alice")).unwrap()], + "keeping publications out of the metrics must not lose the reports" + ); + } + + /// A retraction's NIP-09 deletion rides the send queue like anything else, + /// so it must be kept out of the metrics too — and, unlike the tombstone, + /// it must *not* be reported for republication, because nothing retries a + /// deletion. + #[test] + fn test_discovery_deletion_is_metrics_neutral_and_never_republished() { + let transport = NostrTransport::new(addr("alice")).unwrap(); + transport.set_discovery_enabled(true); + transport.start().unwrap(); + transport.on_status_changed(TransportStatus::Available); + + transport.retract_discovery_record(user("alice"), b"{\"v\":1}".to_vec()); + + let tombstone = transport.get_next_signed_event().unwrap().unwrap(); + assert!(tombstone.message_id.starts_with(NOSTR_DISCOVERY_ID_PREFIX)); + transport.confirm_sent(&tombstone.message_id); + + let deletion = transport.get_next_signed_event().unwrap().unwrap(); + assert!( + deletion.message_id.starts_with(NOSTR_DELETION_ID_PREFIX), + "the deletion must follow the tombstone, not precede it" + ); + transport.report_send_failure(&deletion.message_id); + + let metrics = transport.metrics(); + assert_eq!(metrics.success_count, 0); + assert_eq!(metrics.failure_count, 0); + assert!( + transport.take_failed_discovery_publications().is_empty(), + "a failed deletion must not queue a republication: the tombstone \ + already carried the retraction" + ); + } + + /// A retraction must go out even when discovery has just been switched off, + /// which is the single most likely reason to retract. A gate here would + /// strand exactly the claim the user asked to withdraw. + #[test] + fn test_retraction_is_not_gated_on_discovery_being_enabled() { + let transport = NostrTransport::new(addr("alice")).unwrap(); + transport.start().unwrap(); + transport.on_status_changed(TransportStatus::Available); + + // Never enabled: a publication is refused... + transport.publish_discovery_record(user("alice"), b"{}".to_vec()); + assert!( + transport.get_next_signed_event().unwrap().is_none(), + "a claim must not publish while discovery is disabled" + ); + + // ...but a retraction still goes out. + transport.retract_discovery_record(user("alice"), b"{\"v\":1}".to_vec()); + let tombstone = transport + .get_next_signed_event() + .unwrap() + .expect("a retraction must publish even with discovery disabled"); + assert!(tombstone.message_id.starts_with(NOSTR_DISCOVERY_ID_PREFIX)); + } + + /// Discovery requires cold contact as well as its own switch: a claim + /// pointing at an address with no published key packages resolves and then + /// dead-ends one hop later. + #[test] + fn test_discovery_requires_cold_contact() { + let transport = NostrTransport::new(addr("alice")).unwrap(); + transport.set_discovery_enabled(true); + assert!(transport.discovery_enabled()); + + transport.set_cold_contact_enabled(false); + assert!( + !transport.discovery_enabled(), + "discovery must be off when cold contact is off, since the claim \ + would point at an address with no key packages to fetch" + ); + } + + /// Republishing a claim before it drains collapses to the newer payload: + /// the older one is by definition superseded, and publishing both would + /// briefly stand the stale one back up. + #[test] + fn test_requeued_discovery_claim_collapses_to_the_newest() { + let transport = NostrTransport::new(addr("alice")).unwrap(); + transport.set_discovery_enabled(true); + transport.start().unwrap(); + transport.on_status_changed(TransportStatus::Available); + + transport.publish_discovery_record(user("alice"), b"first".to_vec()); + transport.publish_discovery_record(user("alice"), b"second".to_vec()); + + assert!(transport.get_next_signed_event().unwrap().is_some()); + assert!( + transport.get_next_signed_event().unwrap().is_none(), + "a re-queued claim must replace the pending one, not queue twice" + ); + } + + /// A discovery query must not accept a key-package record, and vice versa. + /// The kind is checked against what *this query asked for*, so a relay + /// answering the wrong REQ cannot route a record into the other handler. + #[test] + fn test_query_refuses_a_record_of_the_wrong_kind() { + let alice = NostrTransport::new(addr("alice")).unwrap(); + alice.set_discovery_enabled(true); + + let bob = NostrTransport::new(addr("bob")).unwrap(); + bob.install_signing_secret(&[9u8; 32]).unwrap(); + bob.start().unwrap(); + bob.on_status_changed(TransportStatus::Available); + + // A genuine key-package record. + bob.publish_key_package("slot-a", b"bob's key package".to_vec()); + let published = bob.get_next_signed_event().unwrap().unwrap(); + let event_json = serde_json::to_string(&event_object(&published)).unwrap(); + + // Delivered under a *discovery* query. + alice.resolve_username(user("bob")); + let query = alice.next_query().unwrap().unwrap(); + assert!( + alice + .open_query_event(&query.query_id, &event_json) + .unwrap() + .is_none(), + "a key-package record must not open under a discovery query" + ); + } + + /// The query carries the username out so the engine can open an + /// accumulator at mint time — which is what lets a name nobody claims still + /// emit an empty answer rather than never completing. + #[test] + fn test_discovery_query_reports_its_username() { + let transport = NostrTransport::new(addr("alice")).unwrap(); + transport.set_discovery_enabled(true); + + assert_eq!( + transport.resolve_username(user("bob")), + ResolveRequest::Queued + ); + let query = transport.next_query().unwrap().unwrap(); + assert_eq!(query.discovery_username, Some(user("bob"))); + + // Completing it hands the username back, so the engine knows which + // accumulated set to flush. + assert_eq!(transport.complete_query(&query.query_id), Some(user("bob"))); + } + + /// A key-package query completing must not look like a discovery query + /// completing, or the engine would flush a resolution that never existed. + #[test] + fn test_key_package_query_completion_reports_no_username() { + let transport = NostrTransport::new(addr("alice")).unwrap(); + transport.request_peer_key_packages(&addr("bob")); + let query = transport.next_query().unwrap().unwrap(); + assert_eq!(query.discovery_username, None); + assert_eq!(transport.complete_query(&query.query_id), None); + } + + /// Resolution is refused outright while discovery is disabled: no query is + /// minted, so nothing is published to a relay about what this install is + /// looking up. + /// + /// Reported as `Disabled` rather than as a bare refusal, because the + /// engine turns that into an error: nothing will ever answer this lookup, + /// and a caller awaiting the resolution event must not be left waiting. + #[test] + fn test_resolution_is_refused_while_discovery_is_disabled() { + let transport = NostrTransport::new(addr("alice")).unwrap(); + assert_eq!( + transport.resolve_username(user("bob")), + ResolveRequest::Disabled + ); + assert!(transport.next_query().unwrap().is_none()); + } + + /// **The attack the event-signature check exists to stop.** + /// + /// A tombstone body is a constant, so nothing inside it is signed and its + /// entire meaning is "who published this". The discovery seal key is + /// public, so an attacker who knows the username can derive the + /// conversation key for the *victim's* author key and seal a retraction + /// attributed to them. Every layer above this one would then honour it: + /// the resolver keys retractions by author and keeps them sticky for the + /// life of the resolution, precisely so a stale copy cannot stand a + /// retracted claim back up. + /// + /// One hostile relay would therefore erase an honest claimant from the + /// answer even while every other relay served their genuine record. The + /// forgery is built here exactly as an attacker would build it — only the + /// event signature is beyond reach, because that needs the victim's key. + #[test] + fn test_a_forged_tombstone_for_another_author_is_refused() { + let alice = NostrTransport::new(addr("alice")).unwrap(); + alice.set_discovery_enabled(true); + + // The victim's install, whose author key the attacker will impersonate. + let victim = NostrTransport::new(addr("bob")).unwrap(); + victim.install_signing_secret(&[9u8; 32]).unwrap(); + let victim_author = victim.public_key_hex(); + + // The attacker seals a tombstone to the victim's author key, which the + // public discovery-seal key lets anyone do. + let username = user("bob"); + let seal = nostr_crypto::discovery_seal_keypair_for_username(&username).unwrap(); + let tombstone = br#"{"v":1,"retracted":true}"#; + let forged_content = { + let event = nostr_crypto::NostrEvent::create_discovery_publication( + &seal, + &nostr_crypto::discovery_tag_for_username(&username).unwrap(), + &victim_author, + tombstone, + ) + .unwrap(); + event.content + }; + + // Stood up as an event *claiming* the victim's author key. Every field + // an honest event has is present; only `sig` cannot be produced. + let forged = serde_json::json!({ + "id": "0".repeat(64), + "pubkey": victim_author, + "created_at": 1_700_000_000, + "kind": nostr_crypto::NOSTR_DISCOVERY_KIND, + "tags": [["d", "x"], ["p", "x"]], + "content": forged_content, + "sig": "0".repeat(128), + }); + + alice.resolve_username(username); + let query = alice.next_query().unwrap().unwrap(); + assert!( + alice + .open_query_event(&query.query_id, &forged.to_string()) + .unwrap() + .is_none(), + "a tombstone attributed to an author who did not sign the event \ + must never reach the resolver: one hostile relay would otherwise \ + erase an honest claimant from the answer" + ); + } + + /// The id is recomputed, not trusted. An event claiming a genuine record's + /// id would otherwise consume its per-query dedup slot and have the real + /// record dropped behind it as a duplicate. + #[test] + fn test_a_discovery_event_with_a_forged_id_is_refused() { + let alice = NostrTransport::new(addr("alice")).unwrap(); + alice.set_discovery_enabled(true); + + let publisher = NostrTransport::new(addr("bob")).unwrap(); + publisher.install_signing_secret(&[9u8; 32]).unwrap(); + publisher.set_discovery_enabled(true); + publisher.start().unwrap(); + publisher.on_status_changed(TransportStatus::Available); + publisher.publish_discovery_record(user("bob"), b"{\"v\":1}".to_vec()); + let published = publisher.get_next_signed_event().unwrap().unwrap(); + + let mut event = event_object(&published); + // Genuine event, genuine signature, one field edited. The signature is + // over the *recomputed* id, so any edit anywhere breaks it. + event["created_at"] = serde_json::json!(1_700_000_001); + + alice.resolve_username(user("bob")); + let query = alice.next_query().unwrap().unwrap(); + assert!( + alice + .open_query_event(&query.query_id, &event.to_string()) + .unwrap() + .is_none(), + "an event whose id does not hash its own fields must be refused" + ); + } + + /// The negative control for the two tests above: a genuine discovery event + /// must still open. A verifier that refused everything would pass both. + #[test] + fn test_a_genuine_discovery_event_still_opens() { + let alice = NostrTransport::new(addr("alice")).unwrap(); + alice.set_discovery_enabled(true); + + let publisher = NostrTransport::new(addr("bob")).unwrap(); + publisher.install_signing_secret(&[9u8; 32]).unwrap(); + publisher.set_discovery_enabled(true); + publisher.start().unwrap(); + publisher.on_status_changed(TransportStatus::Available); + publisher.publish_discovery_record(user("bob"), b"{\"v\":1}".to_vec()); + let published = publisher.get_next_signed_event().unwrap().unwrap(); + let event_json = serde_json::to_string(&event_object(&published)).unwrap(); + + alice.resolve_username(user("bob")); + let query = alice.next_query().unwrap().unwrap(); + match alice + .open_query_event(&query.query_id, &event_json) + .unwrap() + .expect("a genuine discovery event must open") + { + ResolvedRecord::Discovery { + author, plaintext, .. + } => { + assert_eq!(plaintext, b"{\"v\":1}"); + assert_eq!( + author, + publisher.public_key_hex(), + "the author is reported lower-cased, since the resolver keys on it" + ); + } + ResolvedRecord::KeyPackage { .. } => { + panic!("a discovery query must not yield a key-package record") + } + } + } + + /// A second request for a name already queued reports `AlreadyQueued`, + /// which is a *success* from the caller's side — the queued lookup answers + /// both — and must stay distinguishable from the two refusals that answer + /// nobody. + #[test] + fn test_a_duplicate_lookup_is_reported_as_already_queued() { + let transport = NostrTransport::new(addr("alice")).unwrap(); + transport.set_discovery_enabled(true); + + assert_eq!( + transport.resolve_username(user("bob")), + ResolveRequest::Queued + ); + assert_eq!( + transport.resolve_username(user("bob")), + ResolveRequest::AlreadyQueued + ); + } + /// The same rule in the other direction: a successful publication must not /// inflate the ratio and mask real message failures. #[test] diff --git a/crates/offline-protocol-transport/src/nostr_crypto.rs b/crates/offline-protocol-transport/src/nostr_crypto.rs index fc2a5d34..475608ed 100644 --- a/crates/offline-protocol-transport/src/nostr_crypto.rs +++ b/crates/offline-protocol-transport/src/nostr_crypto.rs @@ -54,7 +54,7 @@ use crate::nip44::{self, ConversationKey}; use crate::{Error, Result}; use hkdf::Hkdf; use k256::schnorr::SigningKey; -use offline_protocol_core::Address; +use offline_protocol_core::{Address, Username}; use rand_core::{OsRng, RngCore}; use sha2::{Digest, Sha256}; use zeroize::Zeroizing; @@ -73,6 +73,21 @@ const SIGNING_KEY_HKDF_INFO: &[u8] = b"offline-protocol/nostr/v1/signing-key/"; /// where they meant the other. const RECORD_SEAL_HKDF_INFO: &[u8] = b"offline-protocol/nostr/v1/record-seal-key/"; +/// Domain-separation prefix for the publicly computable discovery-seal key. +/// +/// Structurally identical to [`RECORD_SEAL_HKDF_INFO`], different info string, +/// and keyed on a *username* rather than an address — which is the property +/// that makes it a weaker key and the reason the invariant below is stated +/// rather than assumed. See [`discovery_seal_keypair_for_username`]. +const DISCOVERY_SEAL_HKDF_INFO: &[u8] = b"offline-protocol/nostr/v1/discovery-seal-key/"; + +/// Domain separator for the discovery tag's hash preimage. +/// +/// The trailing colon is part of the constant: without a separator, +/// `"offline-disc-v1" ‖ username` and a username that happened to begin with +/// the tail of the domain would share a preimage. +const DISCOVERY_TAG_DOMAIN: &[u8] = b"offline-disc-v1:"; + /// Upper bound on HKDF derivation attempts. Each attempt fails with /// probability ~2^-128 (scalar of zero or above the curve order), so more /// than one iteration is never expected in practice. @@ -300,6 +315,106 @@ pub fn routing_tag_for_address(address: &Address) -> Result { Ok(hex::encode(tag_key.verifying_key().to_bytes())) } +/// Computes the public discovery tag for a username. +/// +/// Derivation: `SHA-256("offline-disc-v1:" ‖ username)` → scalar → x-only +/// secp256k1 public key hex. The scalar→pubkey step mirrors +/// [`routing_tag_for_address`] so the published value is shaped like any other +/// `#p` pubkey and one relay-side code path serves both. +/// +/// # Why the preimage is domain-separated +/// +/// The naive framing overstates the direct damage, so be precise about what +/// this buys. If the preimage were the bare username, a user who registered +/// the literal string `off1qys…` would derive *that address's* key-package tag. +/// The immediate harm is small — the two record kinds are separately queryable +/// so results do not cross-contaminate, and the seal keys already differ by +/// their HKDF info string. +/// +/// What the separator actually buys is that the two namespaces stop sharing a +/// preimage space at all, so no *future* third derivation can be aimed across +/// them, and "refuse address-shaped usernames" +/// ([`UsernameError::AddressShaped`](offline_protocol_core::UsernameError)) +/// becomes belt-and-braces rather than the only thing standing between the two. +/// One constant, one class of bug removed. +/// +/// # Why the parameter is a [`Username`] and not a string +/// +/// The tag is a hash of the *normalized* name. Two callers that normalize +/// differently derive different tags and silently fail to find each other — +/// there is no error to observe, only an empty result for a name that exists. +/// Taking the parsed type moves normalization to the boundary and makes +/// "derive a tag for whatever the app typed" fail to compile, the same move +/// [`routing_tag_for_address`] makes for addresses. +pub fn discovery_tag_for_username(username: &Username) -> Result { + let mut preimage = Vec::with_capacity(DISCOVERY_TAG_DOMAIN.len() + username.as_str().len()); + preimage.extend_from_slice(DISCOVERY_TAG_DOMAIN); + preimage.extend_from_slice(username.as_str().as_bytes()); + + let tag_scalar = Sha256::digest(&preimage); + let tag_key = SigningKey::from_bytes(tag_scalar.as_slice()) + .map_err(|e| Error::CryptoError(format!("Invalid discovery tag for username: {}", e)))?; + Ok(hex::encode(tag_key.verifying_key().to_bytes())) +} + +/// Reconstructs the keypair that seals a username's discovery records. +/// +/// Derivation: `HKDF-SHA256(ikm = normalized username, salt = none, +/// info = "offline-protocol/nostr/v1/discovery-seal-key/" ‖ counter)`. +/// +/// # This key is public by construction, and that is deliberate +/// +/// **It reintroduces the exact class the address migration deleted**: a +/// keypair derived from a *guessable* string. That is not an oversight and it +/// must not be quietly "fixed" by a later change that does not know why it is +/// here, so the reasoning is recorded at the definition site. +/// +/// The invariant that makes it acceptable is the same one in both directions: +/// **this key must never be load-bearing.** It is an opaque discovery label. It +/// must never back encryption of anything secret, never back NIP-42 AUTH, and +/// never inform any authentication decision. Every authenticity property of a +/// discovery record comes from the Ed25519 signature inside it and from +/// `derive_address(pubkey) == address` — nothing rests on who could open the +/// seal. +/// +/// What sealing buys, given that anyone who knows the username can unseal, is +/// resistance to *bulk collection*. Publishing the records in the clear would +/// let a single `{"kinds":[30777]}` request return a directory of every +/// username on the relay paired with its address — strictly worse than the leak +/// the record-seal key was introduced to close, since there the pairing had to +/// be inferred and here the mapping *is* the payload. Sealing costs nothing in +/// reach: fetching requires the tag, the tag requires the username, and the +/// username is what reconstructs this key. +/// +/// Deliberately not numerically the discovery tag, for the same reason +/// [`record_seal_keypair_for_address`] is not the routing tag; pinned by +/// `test_discovery_seal_key_is_not_the_discovery_tag`. +pub fn discovery_seal_keypair_for_username(username: &Username) -> Result { + let hkdf = Hkdf::::new(None, username.as_str().as_bytes()); + let mut info = Vec::with_capacity(DISCOVERY_SEAL_HKDF_INFO.len() + 1); + for counter in 0..MAX_DERIVE_ATTEMPTS { + info.clear(); + info.extend_from_slice(DISCOVERY_SEAL_HKDF_INFO); + info.push(counter); + + let mut candidate = Zeroizing::new([0u8; 32]); + hkdf.expand(&info, &mut *candidate) + .map_err(|e| Error::CryptoError(format!("HKDF expand failed: {}", e)))?; + + if let Ok(signing_key) = SigningKey::from_bytes(&*candidate) { + let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes()); + return Ok(NostrKeypair { + signing_key, + public_key_hex, + }); + } + } + + Err(Error::CryptoError( + "Failed to derive a discovery-seal key for the username".to_string(), + )) +} + /// Event kind for a NIP-59 gift wrap. Taken in isolation one of our events is /// an ordinary wrapped DM — kind, unlinkable per-event pubkey, opaque `#p` /// tag, coarse timestamp, ciphertext — which is why we reuse the wrapper @@ -358,6 +473,33 @@ pub const NOSTR_LEGACY_DM_KIND: u32 = 4; /// on [`NOSTR_GIFT_WRAP_KIND`]. pub const NOSTR_KEY_PACKAGE_KIND: u32 = 30443; +/// Event kind for a published username discovery record: addressable, so a +/// republished claim *replaces* the previous one. +/// +/// # Why a kind of our own, when the key-package record shares Marmot's +/// +/// The key-package record shares 30443 to join an anonymity set: several +/// protocols publish MLS key packages to Nostr, so a scrape by kind alone +/// cannot enumerate this SDK's userbase. There is no equivalent set to join for +/// a username directory — no other protocol publishes one — so a distinct kind +/// buys clean filtering and costs nothing that was available to take. +/// +/// 30777 is **unregistered**. Nothing in the NIPs kind registry is assigned +/// anywhere in the 30700–30800 range (re-checked 2026-08-17), and nothing +/// prevents use of an unassigned kind. If this format is ever published as a +/// specification, whether to seek a registry entry or accept the collision risk +/// of an unregistered kind is an open question recorded in +/// `docs/spec/username-discovery.md`. +pub const NOSTR_DISCOVERY_KIND: u32 = 30777; + +/// NIP-09 deletion-request kind, used for the best-effort half of a retraction. +/// +/// Best-effort by nature: a relay may honour it, ignore it, or honour it only +/// for events it still holds. The durable half of a retraction is republishing +/// the addressable slot with a tombstone body, which uses the replacement rule +/// rather than the relay's goodwill. +pub const NOSTR_DELETION_KIND: u32 = 5; + /// Picks a `created_at` uniformly in `[now - NOSTR_CREATED_AT_JITTER_SECS, now]`. /// /// NIP-59 requires the wrapper's timestamp be randomized into the **past** — @@ -536,6 +678,64 @@ impl NostrEvent { ) } + /// Builds a sealed, addressable username discovery record. + /// + /// `discovery_tag` addresses the record and is **also its `d` tag**, which + /// is the one structural difference from + /// [`Self::create_key_package_publication`] and is deliberate. Key packages + /// need random slot ids because an init key is single-use and two fetchers + /// must not collide, so their records accumulate. A directory entry is the + /// opposite: it is a *statement* that should be replaced rather than + /// accumulated, and a deterministic `d` is exactly what makes NIP-01 + /// addressable replacement do that work. Setting `d` to the tag duplicates + /// a value that is already public, so it leaks nothing new, and it leaves a + /// device a natural way to claim more than one username — one record per + /// `d`. + /// + /// `created_at` is the true current time and is deliberately **not** + /// jittered, for the same reason the key-package record is not: relays keep + /// the newest event per `(kind, pubkey, d)`, so a backdated republication + /// is silently dropped and would strand a stale claim standing as live. + pub fn create_discovery_publication( + keypair: &NostrKeypair, + discovery_tag: &str, + seal_pubkey: &str, + plaintext: &[u8], + ) -> Result { + let seal_bytes = hex::decode(seal_pubkey) + .map_err(|e| Error::CryptoError(format!("Invalid discovery-seal pubkey: {}", e)))?; + let conversation_key = ConversationKey::derive(&keypair.signing_key, &seal_bytes)?; + let sealed = nip44::encrypt(plaintext, &conversation_key)?; + + let tags = vec![ + vec!["d".to_string(), discovery_tag.to_string()], + vec!["p".to_string(), discovery_tag.to_string()], + ]; + Self::sign_with_tags( + keypair, + NOSTR_DISCOVERY_KIND, + tags, + &sealed, + now_unix_secs(), + ) + } + + /// Builds a NIP-09 deletion request for an addressable discovery record. + /// + /// Names the record by its `a` coordinate (`kind:pubkey:d`) rather than by + /// event id, so it covers whichever event currently occupies the slot + /// without the caller having to have kept the id. + pub fn create_discovery_deletion(keypair: &NostrKeypair, discovery_tag: &str) -> Result { + let coordinate = format!( + "{}:{}:{}", + NOSTR_DISCOVERY_KIND, + keypair.public_key_hex(), + discovery_tag + ); + let tags = vec![vec!["a".to_string(), coordinate]]; + Self::sign_with_tags(keypair, NOSTR_DELETION_KIND, tags, "", now_unix_secs()) + } + /// Builds and signs a NIP-01 event with a single `p` tag. fn sign( keypair: &NostrKeypair, @@ -738,6 +938,156 @@ pub(crate) fn create_key_package_query_message( serde_json::to_string(&msg).map_err(|e| Error::SerializationError(e.to_string())) } +/// Opens a sealed discovery record. +/// +/// Like [`open_key_package_publication`], the *unsealing* does not authenticate +/// the publisher: it proves only that the content was sealed to the discovery +/// key, which is public by construction. Authenticity of a claim comes from the +/// Ed25519 signature inside it, and the binding of that claim to this publisher +/// from comparing the record's `nostr_author` against the event's author. +/// +/// A **tombstone has no inside**, which is why discovery events must +/// additionally pass [`event_is_authentic`] before reaching here. See that +/// function. +pub(crate) fn open_discovery_record( + discovery_seal_key: &NostrKeypair, + author_pubkey_hex: &str, + sealed: &[u8], +) -> Result> { + unwrap_gift_wrap(discovery_seal_key, author_pubkey_hex, sealed) +} + +/// Whether an inbound event is genuinely from the key it names: its `id` is the +/// NIP-01 hash of its own fields, and its `sig` is a BIP-340 signature over +/// that id under its `pubkey`. +/// +/// # Why a discovery record needs this and a key package does not +/// +/// Everywhere else in this transport the event signature is worthless: it +/// proves only that the publisher holds the key the event names, which is a +/// claim nothing rests on, because every record carries its own inner Ed25519 +/// signature and that is what is verified. +/// +/// A **tombstone breaks that symmetry**. Its whole body is +/// `{"v":1,"retracted":true}` — there is nothing inside to sign, and its entire +/// meaning comes from *who published it*. The discovery seal key is public by +/// construction ([`discovery_seal_keypair_for_username`]), so anyone who knows +/// the username can derive the conversation key for **any** author and seal a +/// tombstone attributed to them. Without this check, a single hostile relay +/// could serve a forged tombstone for an honest claimant and suppress their +/// claim from the resolved set — even while every other relay served the +/// genuine record. Resolution is order-independent and a tombstone is sticky +/// for the life of the resolution (deliberately, so a stale copy cannot stand a +/// retracted claim back up), which is exactly what makes the forgery total +/// rather than racy. +/// +/// That inverts what multiple relays are for. A claim needs *one* honest relay +/// to survive; without this check a retraction needed only *one* hostile relay +/// to succeed. A squatter running a popular relay could then make their own +/// claim the only one a user ever sees, which is the authoritative-looking +/// directory the whole set-shaped API exists to prevent. +/// +/// # The id is recomputed, never trusted +/// +/// The `id` field is re-derived from the event's own fields and compared before +/// the signature is checked. Trusting the claimed id would leave a second hole: +/// the resolver takes each event id once per query, so an event claiming a +/// genuine record's id could consume its dedup slot and have the real record +/// dropped as a duplicate. +/// +/// Returns `false` for anything malformed. Junk at a public tag is ordinary, +/// not exceptional. +pub(crate) fn event_is_authentic(event: &serde_json::Value) -> bool { + use k256::ecdsa::signature::hazmat::PrehashVerifier; + use k256::schnorr::VerifyingKey; + + let ( + Some(id), + Some(pubkey), + Some(created_at), + Some(kind), + Some(tags), + Some(content), + Some(sig), + ) = ( + event.get("id").and_then(|v| v.as_str()), + event.get("pubkey").and_then(|v| v.as_str()), + event.get("created_at").and_then(|v| v.as_i64()), + event.get("kind").and_then(|v| v.as_u64()), + event.get("tags"), + event.get("content").and_then(|v| v.as_str()), + event.get("sig").and_then(|v| v.as_str()), + ) + else { + return false; + }; + + // Decoded before it is interpolated below: this both rejects a key no + // curve can accept and guarantees the string is pure hex, so it cannot + // carry a quote into the canonical serialization. The length check is + // load-bearing for a second reason — `VerifyingKey::from_bytes` *panics* + // on any other length rather than erroring, and this value comes straight + // off a public tag. See `ConversationKey::derive`. + let Ok(pubkey_bytes) = hex::decode(pubkey) else { + return false; + }; + if pubkey_bytes.len() != 32 { + return false; + } + + let (Ok(tags_json), Ok(content_escaped)) = + (serde_json::to_string(tags), serde_json::to_string(content)) + else { + return false; + }; + // The same NIP-01 canonical serialization `sign_with_tags` produces, which + // is what keeps a record this build published verifiable by this build. + let serialized = format!( + "[0,\"{}\",{},{},{},{}]", + pubkey, created_at, kind, tags_json, content_escaped + ); + let recomputed = Sha256::digest(serialized.as_bytes()); + if !id.eq_ignore_ascii_case(&hex::encode(recomputed)) { + return false; + } + + let Ok(vk) = VerifyingKey::from_bytes(&pubkey_bytes) else { + return false; + }; + let Ok(sig_bytes) = hex::decode(sig) else { + return false; + }; + let Ok(signature) = k256::schnorr::Signature::try_from(sig_bytes.as_slice()) else { + return false; + }; + vk.verify_prehash(&recomputed, &signature).is_ok() +} + +/// Builds the REQ that fetches the records claiming a username. +/// +/// Carries no `since`, for the same reason the key-package query does not: a +/// claim is republished only when it changes, so a stable claimant's record may +/// be arbitrarily old while remaining entirely current. A `since` here would +/// hide exactly the long-settled claims a directory exists to return. +/// +/// `limit` bounds how many claimants one relay may offer. It is well above the +/// device count of any real user and far below +/// [`MAX_QUERY_EVENTS`](crate::nostr::MAX_QUERY_EVENTS): the tag is public, so +/// anything past a handful of records is another author crowding it, and +/// crowding costs the *legitimate* claimants their place in the answer. +pub(crate) fn create_discovery_query_message( + discovery_tag: &str, + subscription_id: &str, +) -> Result { + let filter = serde_json::json!({ + "#p": [discovery_tag], + "kinds": [NOSTR_DISCOVERY_KIND], + "limit": crate::constants::NOSTR_DISCOVERY_QUERY_LIMIT + }); + let msg = serde_json::json!(["REQ", subscription_id, filter]); + serde_json::to_string(&msg).map_err(|e| Error::SerializationError(e.to_string())) +} + /// Whether `data` has the shape of a sealed gift-wrap payload. pub(crate) fn is_sealed_payload(data: &[u8]) -> bool { nip44::looks_like_payload(data) @@ -769,6 +1119,346 @@ mod tests { Address::from_hash_bytes(hash) } + fn user(label: &str) -> Username { + label.parse().expect("username should parse") + } + + /// The discovery tag and the *address* routing tag for the same string must + /// differ. This is the whole point of domain-separating the discovery + /// preimage (see [`discovery_tag_for_username`]), and it is the case a + /// naive implementation collapses by hashing the bare name. + /// + /// The comparison is made over a string that is simultaneously a valid + /// username and, as a label, derivable to an address — so the two + /// derivations are being fed the same bytes, which is exactly the + /// collision this separation prevents. + #[test] + fn test_discovery_tag_is_not_the_address_routing_tag() { + for label in ["alice", "bob", "carol"] { + let username = user(label); + let discovery = discovery_tag_for_username(&username).expect("discovery tag"); + + // The address whose *canonical string* is what the routing tag + // hashes. Different preimage spaces entirely, which is the point. + let address = addr(label); + let routing = routing_tag_for_address(&address).expect("routing tag"); + + assert_ne!( + discovery, routing, + "the discovery tag must not equal the routing tag for {label}" + ); + } + } + + /// A username and an address that render to the same *string* must still + /// derive different tags. Address-shaped usernames are refused by the + /// `Username` type, so this pins the property one level down: even if that + /// screen were removed, the domain separator alone keeps the namespaces + /// apart. + #[test] + fn test_discovery_tag_domain_separates_from_the_bare_hash() { + let username = user("alice"); + let tag = discovery_tag_for_username(&username).expect("tag"); + + // What the tag would have been without the domain separator. + let bare_scalar = Sha256::digest(b"alice"); + let bare = hex::encode( + SigningKey::from_bytes(bare_scalar.as_slice()) + .expect("scalar") + .verifying_key() + .to_bytes(), + ); + + assert_ne!( + tag, bare, + "the discovery tag must not be the undomained hash of the username" + ); + } + + /// The same invariant [`test_record_seal_key_is_not_the_routing_tag`] pins + /// for addresses. A seal key that *is* the tag leaves every published label + /// standing as a key whose private half anyone can compute, which stops + /// being harmless the moment something reaches for "the key matching this + /// tag". + #[test] + fn test_discovery_seal_key_is_not_the_discovery_tag() { + for label in ["alice", "bob", "a-name-with-dashes"] { + let username = user(label); + assert_ne!( + discovery_seal_keypair_for_username(&username) + .unwrap() + .public_key_hex(), + discovery_tag_for_username(&username).unwrap(), + "the discovery-seal key must not be the discovery tag for {label}" + ); + } + } + + /// The two seal keys must not collide either: a username and an address are + /// different namespaces and their seals are derived under different HKDF + /// info strings. + #[test] + fn test_discovery_seal_key_is_not_the_record_seal_key() { + for label in ["alice", "bob"] { + let username = user(label); + let address = addr(label); + assert_ne!( + discovery_seal_keypair_for_username(&username) + .unwrap() + .public_key_hex(), + record_seal_keypair_for_address(&address) + .unwrap() + .public_key_hex(), + "the discovery-seal key must not be the record-seal key for {label}" + ); + } + } + + #[test] + fn test_discovery_tag_and_seal_key_are_deterministic() { + let username = user("alice"); + assert_eq!( + discovery_tag_for_username(&username).unwrap(), + discovery_tag_for_username(&username).unwrap() + ); + assert_eq!( + discovery_seal_keypair_for_username(&username) + .unwrap() + .public_key_hex(), + discovery_seal_keypair_for_username(&username) + .unwrap() + .public_key_hex() + ); + } + + #[test] + fn test_discovery_tag_is_username_specific() { + assert_ne!( + discovery_tag_for_username(&user("alice")).unwrap(), + discovery_tag_for_username(&user("bob")).unwrap() + ); + } + + /// Golden vectors for the discovery derivations, so a second + /// implementation can check its tag and seal derivations without this + /// repository. + /// + /// **Computed independently of this code**, by a Python script that + /// implements secp256k1 from the curve equation and HKDF-SHA256 from + /// RFC 5869 — not by calling `k256` or the `hkdf` crate. That script was + /// itself validated by reproducing all three shipped + /// [`test_routing_tag_golden_values`] literals, which were generated + /// independently of it, so the arithmetic behind these values is + /// cross-checked rather than self-consistent. + /// + /// Every intermediate is pinned as well as the result: a mismatch then + /// says *which* step diverged — the preimage, the HKDF, or the curve — + /// instead of only that the final hex differs. + #[test] + fn test_discovery_derivation_golden_values() { + let username = user("alice"); + + // The part another implementation is most likely to get wrong: the + // separator, and the fact that the name is the *normalized* one. + assert_eq!( + hex::encode(Sha256::digest(b"offline-disc-v1:alice")), + "f0fe1d050a281d611fdd951f13e6e6878407ec87a80cd7fa0b42442f44020466" + ); + + assert_eq!( + discovery_tag_for_username(&username).unwrap(), + "179183d1f394327bb8a244dbe9160dd77bf86f7125caa21c2d2eec6e50267703" + ); + assert_eq!( + discovery_seal_keypair_for_username(&username) + .unwrap() + .public_key_hex(), + "a008efac2c8ebe103dc2dfc78acd7890cff5cadb238159eaf615aa7ad9d8994e" + ); + } + + /// A discovery publication is addressable at a *deterministic* `d`, which + /// is what makes republishing replace the claim rather than accumulate + /// claims. Pinned because the key-package record does the opposite, and + /// copying its random slot id here would silently break retraction. + #[test] + fn test_discovery_publication_is_addressable_at_the_tag() { + let keypair = NostrKeypair::generate_ephemeral().expect("keypair"); + let username = user("alice"); + let tag = discovery_tag_for_username(&username).expect("tag"); + let seal = discovery_seal_keypair_for_username(&username).expect("seal"); + + let event = + NostrEvent::create_discovery_publication(&keypair, &tag, seal.public_key_hex(), b"{}") + .expect("publication"); + + assert_eq!(event.kind, NOSTR_DISCOVERY_KIND); + let d_tag = event + .tags + .iter() + .find(|t| t.first().map(String::as_str) == Some("d")) + .expect("d tag"); + assert_eq!(d_tag[1], tag, "the d tag must be the discovery tag"); + let p_tag = event + .tags + .iter() + .find(|t| t.first().map(String::as_str) == Some("p")) + .expect("p tag"); + assert_eq!(p_tag[1], tag); + } + + /// Republishing must land on the same addressable coordinate, or the relay + /// keeps both and a retraction never displaces the claim it retracts. + #[test] + fn test_discovery_republication_replaces_rather_than_accumulates() { + let keypair = NostrKeypair::generate_ephemeral().expect("keypair"); + let tag = discovery_tag_for_username(&user("alice")).expect("tag"); + let seal = discovery_seal_keypair_for_username(&user("alice")).expect("seal"); + + let first = + NostrEvent::create_discovery_publication(&keypair, &tag, seal.public_key_hex(), b"{}") + .expect("first"); + let second = NostrEvent::create_discovery_publication( + &keypair, + &tag, + seal.public_key_hex(), + b"{\"v\":1}", + ) + .expect("second"); + + assert_eq!(first.kind, second.kind); + assert_eq!(first.pubkey, second.pubkey); + let d_of = |e: &NostrEvent| { + e.tags + .iter() + .find(|t| t.first().map(String::as_str) == Some("d")) + .expect("d tag")[1] + .clone() + }; + assert_eq!(d_of(&first), d_of(&second)); + } + + /// `created_at` must be the true present, never jittered into the past: + /// relays keep the newest event per `(kind, pubkey, d)`, so a backdated + /// republication is dropped and strands the claim it meant to replace. + #[test] + fn test_discovery_publication_is_not_backdated() { + let keypair = NostrKeypair::generate_ephemeral().expect("keypair"); + let tag = discovery_tag_for_username(&user("alice")).expect("tag"); + let seal = discovery_seal_keypair_for_username(&user("alice")).expect("seal"); + + let before = now_unix_secs(); + let event = + NostrEvent::create_discovery_publication(&keypair, &tag, seal.public_key_hex(), b"{}") + .expect("publication"); + let after = now_unix_secs(); + + assert!( + event.created_at >= before && event.created_at <= after, + "created_at {} must sit in [{}, {}]", + event.created_at, + before, + after + ); + } + + #[test] + fn test_discovery_record_round_trips_through_its_seal() { + let keypair = NostrKeypair::generate_ephemeral().expect("keypair"); + let username = user("alice"); + let tag = discovery_tag_for_username(&username).expect("tag"); + let seal = discovery_seal_keypair_for_username(&username).expect("seal"); + let plaintext = b"{\"v\":1,\"username\":\"alice\"}"; + + let event = NostrEvent::create_discovery_publication( + &keypair, + &tag, + seal.public_key_hex(), + plaintext, + ) + .expect("publication"); + + let sealed = base64::engine::general_purpose::STANDARD + .decode(&event.content) + .expect("content is base64"); + let opened = open_discovery_record(&seal, &event.pubkey, &sealed).expect("open"); + assert_eq!(opened, plaintext); + } + + /// Anyone who knows the username can open the record. That is the design, + /// not a leak — the payload holds only what is public to someone who + /// already knows the name — and it is pinned so a later change that makes + /// this key load-bearing fails a test rather than a review. + #[test] + fn test_discovery_seal_is_openable_by_anyone_who_knows_the_username() { + let keypair = NostrKeypair::generate_ephemeral().expect("keypair"); + let username = user("alice"); + let tag = discovery_tag_for_username(&username).expect("tag"); + let publisher_seal = discovery_seal_keypair_for_username(&username).expect("seal"); + + let event = NostrEvent::create_discovery_publication( + &keypair, + &tag, + publisher_seal.public_key_hex(), + b"public", + ) + .expect("publication"); + + // A stranger reconstructs the same key from the name alone. + let stranger_seal = discovery_seal_keypair_for_username(&user("alice")).expect("seal"); + let sealed = base64::engine::general_purpose::STANDARD + .decode(&event.content) + .expect("base64"); + assert_eq!( + open_discovery_record(&stranger_seal, &event.pubkey, &sealed).expect("open"), + b"public" + ); + } + + #[test] + fn test_discovery_query_names_the_right_kind_and_tag() { + let tag = discovery_tag_for_username(&user("alice")).expect("tag"); + let req = create_discovery_query_message(&tag, "sub1").expect("req"); + let parsed: serde_json::Value = serde_json::from_str(&req).expect("json"); + + assert_eq!(parsed[0], "REQ"); + assert_eq!(parsed[1], "sub1"); + assert_eq!(parsed[2]["kinds"][0], NOSTR_DISCOVERY_KIND); + assert_eq!(parsed[2]["#p"][0], tag); + assert_eq!( + parsed[2]["limit"], + crate::constants::NOSTR_DISCOVERY_QUERY_LIMIT + ); + assert!( + parsed[2].get("since").is_none(), + "a discovery query must carry no `since`: a settled claim is old \ + and current at the same time" + ); + } + + #[test] + fn test_discovery_deletion_names_the_addressable_coordinate() { + let keypair = NostrKeypair::generate_ephemeral().expect("keypair"); + let tag = discovery_tag_for_username(&user("alice")).expect("tag"); + let event = NostrEvent::create_discovery_deletion(&keypair, &tag).expect("deletion"); + + assert_eq!(event.kind, NOSTR_DELETION_KIND); + let a_tag = event + .tags + .iter() + .find(|t| t.first().map(String::as_str) == Some("a")) + .expect("a tag"); + assert_eq!( + a_tag[1], + format!( + "{}:{}:{}", + NOSTR_DISCOVERY_KIND, + keypair.public_key_hex(), + tag + ) + ); + } + #[test] fn test_from_install_secret_deterministic() { let secret = [7u8; 32]; diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 92528b6d..27994bf2 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -39,7 +39,7 @@ use offline_protocol_router::{ RelayConfig as CoreRelayConfig, RelayPriority as CoreRelayPriority, }; use offline_protocol_transport::{ - ble::BleTransport, internet::InternetTransport, nostr::NostrTransport, + ble::BleTransport, internet::InternetTransport, nostr::NostrTransport, nostr::ResolvedRecord, reticulum::ReticulumTransport, Transport, TransportMetrics as CoreTransportMetrics, TransportStatus as CoreTransportStatus, TransportType as CoreTransportType, }; @@ -73,6 +73,61 @@ pub fn derive_address(public_key: Vec) -> Result { .map_err(|e| ProtocolError::MlsError(e.to_string())) } +/// A decoded and verified invite. +/// +/// Every field has passed verification: the address is the one its public key +/// derives to, and when `signed` is true the petname is bound to that key by +/// the key's owner. +pub struct InviteInfo { + /// The address this invite reaches, canonical `off1…`. + pub address: String, + /// The Ed25519 identity key the address derives from. + pub public_key: Vec, + /// The suggested display name, if the invite carried one. + /// + /// Suggested, never authoritative: a petname is a *locally assigned* name, + /// and an app is right to let the user edit it. + pub petname: Option, + /// Whether a valid signature accompanied the invite. + /// + /// `false` does not mean the invite is untrustworthy — an unsigned invite + /// is the ordinary shape for a QR shown phone to phone, where the physical + /// channel is the authentication. It means only that the *petname* is + /// unbound. + pub signed: bool, +} + +/// Decodes and verifies an invite blob. +/// +/// Instance-less on purpose, like [`derive_address`]: a scanner can verify a QR +/// code before `create()`, which is the whole reason the invite format is +/// self-certifying. +/// +/// # What this proves, and what it does not +/// +/// It proves the address belongs to the public key, and that any signature +/// present was made by that key. It does **not** prove the invite came from +/// who you think: an attacker handing you their own correctly-signed invite is +/// indistinguishable from a legitimate stranger, and no payload format can fix +/// that. Only the out-of-band context — that this QR code was on *this* +/// person's screen — carries that. +/// +/// # Errors +/// +/// Returns [`ProtocolError::MlsError`] for a malformed blob, an address that is +/// not the key's, or a signature that does not verify. Every one of these means +/// the invite must be refused, not shown with a warning. +pub fn parse_invite(blob: String) -> Result { + offline_protocol_mls::invite::parse_invite(&blob) + .map(|invite| InviteInfo { + address: invite.address.to_string(), + public_key: invite.public_key, + petname: invite.petname, + signed: invite.signed, + }) + .map_err(|e| ProtocolError::MlsError(e.to_string())) +} + // --------------------------------------------------------------------------- // Poison-recovery utilities for non-Result methods. // @@ -2128,6 +2183,8 @@ pub struct TransportConfig { pub nostr_sealing_enabled: bool, /// See [`ProtocolConfig::nostr_cold_contact_enabled`]. pub nostr_cold_contact_enabled: bool, + /// See [`ProtocolConfig::nostr_username_discovery_enabled`]. + pub nostr_username_discovery_enabled: bool, } /// Encryption configuration for automatic MLS handling @@ -2232,6 +2289,11 @@ pub struct ProtocolConfig { pub nostr_sealing_enabled: bool, /// Kill switch for Nostr key-package publication and peer resolution /// (default on). See the UDL dictionary and + /// `TransportConfig::nostr_username_discovery_enabled` — off by default. + /// Publishing binds a human-readable name to an address in a public place, + /// which is more disclosure than the key-package record's "an install with + /// this tag exists", and it needs cold contact on to be useful at all. + pub nostr_username_discovery_enabled: bool, /// `TransportConfig::nostr_cold_contact_enabled` for what it buys and what /// it costs. pub nostr_cold_contact_enabled: bool, @@ -2258,6 +2320,8 @@ impl From for CoreConfig { core_config.transport.nostr_enabled = config.nostr_enabled; core_config.transport.nostr_sealing_enabled = config.nostr_sealing_enabled; core_config.transport.nostr_cold_contact_enabled = config.nostr_cold_contact_enabled; + core_config.transport.nostr_username_discovery_enabled = + config.nostr_username_discovery_enabled; core_config.transport.binary_wire_enabled = config.binary_wire_enabled; core_config.encryption.compact_envelope_enabled = config.compact_envelope_enabled; core_config.encryption.rich_payload_enabled = config.rich_payload_enabled; @@ -4810,18 +4874,57 @@ impl OfflineProtocol { /// during a brief status flap, leaving the peer on the bootstrap leg until /// the rate limit lapses. pub fn nostr_get_next_query(&self) -> Option { - self.with_nostr_transport(|nostr| match nostr.next_query() { - Ok(Some(query)) => Some(NostrQuery { - query_id: query.query_id, - req_json: query.req_json, - }), - Ok(None) => None, - Err(e) => { - tracing::error!(error = %e, "Failed to build a Nostr resolution query"); - None - } + let query = self + .with_nostr_transport(|nostr| match nostr.next_query() { + Ok(Some(query)) => Some(query), + Ok(None) => None, + Err(e) => { + tracing::error!(error = %e, "Failed to build a Nostr resolution query"); + None + } + }) + .flatten()?; + + // A discovery query opens its accumulator here, at mint time rather + // than on the first answer, so a name nobody claims still emits an + // empty result. "No such name" is an answer. + // + // The inner lock is what serializes this against `resolve_username`, + // which registers its request while holding the same lock. Without + // that ordering a mint could land between the transport accepting a + // lookup and the engine recording it, and `begin_username_resolution` + // would discard the query as one the sweep had already answered — the + // lookup would then never accumulate anything. Keep the acquisition + // here, after the mint. + if let Some(username) = query.discovery_username.clone() { + let mut protocol = self.lock_inner_recovering(); + protocol.begin_username_resolution(query.query_id.clone(), username); + } + + Some(NostrQuery { + query_id: query.query_id, + req_json: query.req_json, }) - .flatten() + } + + /// Resolves a username to the set of devices claiming it. + /// + /// Returns `true` if this call started the lookup and `false` if it joined + /// one already in flight. **Both mean an answer is coming**: exactly one + /// `username_resolved` event follows either way. Every case where no event + /// will ever arrive throws instead — `InvalidConfiguration` when discovery + /// is off, `InvalidState` when too many lookups are in flight — so a caller + /// that awaits the event can never be left waiting on one that has no + /// trigger. + /// + /// The answer carries **every** verified claim. There is deliberately no + /// "best" claim and no ordering: anyone may publish any name, so what comes + /// back is a set of assertions for a human to arbitrate, not a lookup + /// result. An app that silently takes the first entry has turned a + /// non-authoritative directory into an authoritative-looking one. + pub fn resolve_username(&self, username: String) -> Result { + let mut protocol = self.lock_inner()?; + protocol.resolve_username(&username).map_err(Into::into) } /// Delivers one event received on a resolution query's subscription. @@ -4841,19 +4944,44 @@ impl OfflineProtocol { .map_err(|e| ProtocolError::TransportError(e.to_string()))? .flatten(); - let Some((_author_pubkey, plaintext)) = opened else { + let Some(record) = opened else { return Ok(()); }; let mut protocol = self.lock_inner()?; - protocol - .handle_resolved_key_package(&plaintext) - .map_err(ProtocolError::from) + match record { + ResolvedRecord::KeyPackage { plaintext, .. } => protocol + .handle_resolved_key_package(&plaintext) + .map_err(ProtocolError::from), + ResolvedRecord::Discovery { + username, + author, + plaintext, + } => { + // Accumulates rather than emits: the whole claim set leaves as + // one event when the query completes. See + // `PendingResolution` for why a per-claim event would be the + // wrong shape. + protocol + .handle_resolved_discovery_record(&query_id, &username, &author, &plaintext); + Ok(()) + } + } } /// Releases a resolution query after the relay's end-of-stored-events. + /// + /// For a username resolution this is also what *emits* the answer: the + /// accumulated claim set is flushed as a single `username_resolved` event. + /// The engine additionally sweeps on its tick, so a relay that never sends + /// end-of-stored-events yields a late answer rather than none. pub fn nostr_query_completed(&self, query_id: String) { - self.with_nostr_transport(|nostr| nostr.complete_query(&query_id)); + let resolved_username = self.with_nostr_transport(|nostr| nostr.complete_query(&query_id)); + + if matches!(resolved_username, Some(Some(_))) { + let mut protocol = self.lock_inner_recovering(); + protocol.flush_username_resolution(&query_id); + } } /// Called by the platform when publishing a Nostr event fails. @@ -6050,6 +6178,76 @@ impl OfflineProtocol { .map_err(|e| ProtocolError::MlsError(e.to_string())) } + /// Builds an invite blob for this identity. + /// + /// The result is an opaque base64url string. Apps own the container: the + /// recommended form is `://connect?c=`, one parameter, so + /// it composes with an existing scheme and route. + /// + /// # When to sign + /// + /// Pass `sign = true` when the invite may travel **without its issuer** — + /// a link forwarded through a third party. The signature binds the petname + /// to the key, so a forwarded invite cannot save Alice's key under the name + /// "Bob". + /// + /// Pass `false` for a QR code shown phone to phone: the physical channel + /// already authenticates it, and an app that lets the user confirm or edit + /// the name has made the user the authority over it, which is what a + /// petname properly is. Signing costs ~90 characters. + /// + /// A signature does **not** defend against substitution. See + /// [`parse_invite`]. + /// + /// # Why the parameter is `sign` and not `signed` + /// + /// A UDL parameter name is emitted verbatim as a C parameter name in the + /// generated FFI header, and `signed` is a C type specifier: `int8_t + /// signed` fails to compile and takes the whole `offline_protocolFFI` + /// module with it, breaking every iOS consumer of the generated bindings + /// rather than only this call. Rust, Kotlin, Swift and Python all accept + /// the name, so only the iOS bridge typecheck catches it. + pub fn create_invite( + &self, + petname: Option, + sign: bool, + ) -> Result { + let manager = self.get_mls_manager()?; + let guard = manager + .read() + .map_err(|e| ProtocolError::LockPoisoned(format!("mls_manager: {}", e)))?; + + let public_key = guard + .get_identity_public_key() + .map_err(|e| ProtocolError::MlsError(e.to_string()))?; + let address = CoreMlsManager::derive_address(&public_key) + .map_err(|e| ProtocolError::MlsError(e.to_string()))?; + + let signature = if sign { + let payload = offline_protocol_mls::invite::invite_signing_payload( + &address, + &public_key, + petname.as_deref(), + ) + .map_err(|e| ProtocolError::MlsError(e.to_string()))?; + Some( + guard + .sign_data(&payload) + .map_err(|e| ProtocolError::MlsError(e.to_string()))?, + ) + } else { + None + }; + + offline_protocol_mls::invite::encode_invite( + &address, + &public_key, + petname.as_deref(), + signature.as_deref(), + ) + .map_err(|e| ProtocolError::MlsError(e.to_string())) + } + /// Verify a signature against a public key. /// /// Returns true if the signature is valid, false otherwise. @@ -6673,6 +6871,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -6706,6 +6905,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -7137,6 +7337,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -7463,6 +7664,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -7493,6 +7695,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -7524,6 +7727,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -7554,6 +7758,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -7592,6 +7797,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -7646,6 +7852,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -7735,6 +7942,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -7766,6 +7974,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -7800,6 +8009,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -7838,6 +8048,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -7901,6 +8112,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -8011,6 +8223,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -8093,6 +8306,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -8121,6 +8335,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -8302,6 +8517,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -8337,6 +8553,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -8390,6 +8607,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -8411,6 +8629,7 @@ mod tests { binary_wire_enabled: true, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, @@ -13500,4 +13719,49 @@ mod tests { "dropping the guard must clear the holder record" ); } + + /// Pins the relay address-proof signing domain across the two bridges that + /// hand-mirror it. + /// + /// `offline-relay-addr-v1` is defined in the relay-server repository and + /// copied by hand into `AddressDeclarationPolicy.swift` and + /// `AddressDeclarationPolicy.kt`. No Rust constant holds it, so the + /// four-domain non-prefix test in `offline-protocol` carries it as a + /// literal — and a literal in one place plus copies in two others is + /// exactly the drift shape that ships silently. + /// + /// This reads both bridge sources and asserts the spelling. It cannot see + /// the relay's own copy (different repository), so a change there still + /// has to be coordinated by hand; what it does close is the case where one + /// bridge is edited and the other is not, which no test previously caught. + /// + /// The failure it prevents is not cosmetic. The domain separates an + /// address proof from a control frame, and if a device signed an address + /// proof under a domain that collided with `offline-ctrl-v1`, a hostile + /// relay would harvest a replayable control-frame signature from every + /// device that ever authenticated to it. + #[test] + fn relay_address_proof_domain_matches_across_both_bridges() { + const EXPECTED: &str = "offline-relay-addr-v1"; + + let rn_dir = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bindings/react-native"); + let read = |rel: &str| -> String { + let path = rn_dir.join(rel); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) + }; + + let swift = read("ios/AddressDeclarationPolicy.swift"); + assert!( + swift.contains(&format!("PROOF_DOMAIN = \"{EXPECTED}\"")), + "AddressDeclarationPolicy.swift must declare PROOF_DOMAIN = \"{EXPECTED}\"" + ); + + let kotlin = read("android/src/main/java/com/offlineprotocol/AddressDeclarationPolicy.kt"); + assert!( + kotlin.contains(&format!("PROOF_DOMAIN = \"{EXPECTED}\"")), + "AddressDeclarationPolicy.kt must declare PROOF_DOMAIN = \"{EXPECTED}\"" + ); + } } diff --git a/crates/offline-protocol-uniffi/src/offline_protocol.udl b/crates/offline-protocol-uniffi/src/offline_protocol.udl index 69928817..37541330 100644 --- a/crates/offline-protocol-uniffi/src/offline_protocol.udl +++ b/crates/offline-protocol-uniffi/src/offline_protocol.udl @@ -12,6 +12,43 @@ namespace offline_protocol { // Throws if publicKey is not exactly 32 bytes. [Throws=ProtocolError] string derive_address(sequence public_key); + + // Decode and verify an invite blob (the base64url payload produced by + // create_invite, typically carried as ://connect?c=). + // + // Instance-less for the same reason derive_address is: a scanner must be + // able to verify a QR code BEFORE create(). Verification is mandatory and + // total -- the address must be the one its public key derives to, and any + // signature present must verify under that key -- so a returned InviteInfo + // is always self-certified. + // + // What it does NOT prove: that the invite came from who you think. An + // attacker's own correctly-signed invite is indistinguishable from a + // stranger's. Only the out-of-band context (this QR was on THIS person's + // screen) carries that, which is why the invite path is the trust anchor + // for username discovery rather than the other way around. + // + // Throws if the blob is malformed, the address is not the key's, or a + // signature does not verify. Every case means refuse, not warn. + [Throws=ProtocolError] + InviteInfo parse_invite(string blob); +}; + +// A decoded and verified invite. +dictionary InviteInfo { + // The address this invite reaches, canonical "off1...". + string address; + // The Ed25519 identity key the address derives from. + sequence public_key; + // Suggested display name, if the invite carried one. Suggested, never + // authoritative: a petname is a locally assigned name and an app is right + // to let the user edit it. + string? petname; + // Whether a valid signature accompanied the invite. False does NOT mean + // untrustworthy -- an unsigned invite is the ordinary shape for a QR shown + // phone to phone, where the physical channel is the authentication. It + // means only that the petname is unbound to the key. + boolean signed; }; // ======================================================================== @@ -501,6 +538,8 @@ dictionary TransportConfig { boolean nostr_sealing_enabled = true; // See ProtocolConfig::nostr_cold_contact_enabled. boolean nostr_cold_contact_enabled = true; + // See ProtocolConfig::nostr_username_discovery_enabled. Off by default. + boolean nostr_username_discovery_enabled = false; }; enum OverflowPolicy { @@ -609,6 +648,22 @@ dictionary ProtocolConfig { // published to. Turn it off to keep the transport silent until it has // traffic. Defaulted so existing callers keep compiling. boolean nostr_cold_contact_enabled = true; + // Publishes a username discovery record for this install's profile, and + // allows resolve_username() to look names up. OFF by default, unlike cold + // contact, and it additionally requires cold contact to be on: a claim + // points at an address whose key packages a resolver fetches next, so + // without them it resolves and then dead-ends. + // + // Publishing binds a human-readable name to an address in a public place. + // That is materially more disclosure than the key-package record's "an + // install with this tag exists" -- here the mapping IS the payload. The + // record is sealed, so a relay scraping by kind reads nothing, but anyone + // who guesses the name computes the tag and reads the claim. + // + // The directory is NOT authoritative: anyone may claim any name, so + // resolution returns the whole set of claimants and a human must confirm + // out of band. Defaulted so existing callers keep compiling. + boolean nostr_username_discovery_enabled = false; // Kill switch for the compact MLS envelope on encrypted messages (default // on). The end-to-end sibling of binary_wire_enabled with the same shape: // negotiated per-recipient via the key package, parsing of inbound compact @@ -1491,7 +1546,55 @@ interface OfflineProtocol { // Returns true if the signature is valid, false otherwise. [Throws=ProtocolError] boolean verify_signature(sequence public_key, sequence data, sequence signature); - + + // Build an invite blob for this identity: an opaque base64url string. + // Apps own the container; the recommended form is one parameter, + // ://connect?c=, so it composes with an existing route. + // + // Sign it (sign = true) when the invite may travel WITHOUT its issuer -- + // a link forwarded through a third party -- because the signature binds + // the petname to the key, so a forwarded invite cannot save Alice's key + // under the name "Bob". Leave it unsigned for a QR shown phone to phone: + // the physical channel already authenticates it, and an app that lets the + // user confirm the name has made the user the authority over it. Signing + // costs about 90 characters. + // + // Carries no key package by design: an MLS init key is single-use and a QR + // code is static, so pairing them guarantees a collision the moment two + // people scan the same code. Carries no expiry either -- a printed QR that + // stops working is a bug. + // + // The parameter is `sign` and NOT `signed` because a UDL parameter name is + // emitted verbatim as a C parameter name in the generated FFI header, and + // `signed` is a C type specifier: `int8_t signed` fails to compile and + // takes the whole offline_protocolFFI module down with it, so every iOS + // consumer of the generated bindings breaks, not just this call. Rust, + // Kotlin, Swift and Python all accept the name, so only the iOS bridge + // typecheck catches it. Do not rename it back. + [Throws=ProtocolError] + string create_invite(string? petname, boolean sign); + + // Resolve a username to the set of devices claiming it. Returns true if + // this call started the lookup, false if it joined one already in flight. + // BOTH mean an answer is coming: exactly one username_resolved event + // follows either way. Every case where no event will ever arrive throws + // instead -- InvalidConfiguration when discovery is off, InvalidState when + // too many lookups are in flight -- so a caller awaiting the event is never + // left waiting on one that has no trigger. + // + // The answer arrives as ONE username_resolved event carrying EVERY + // verified claim. There is deliberately no "best" claim and no ordering: + // anyone may publish any name, so what comes back is a set of assertions + // for a human to arbitrate, not a lookup result. An app that silently + // takes the first entry has turned a non-authoritative directory into an + // authoritative-looking one -- worse than not shipping it, because the + // user then believes the name was verified when only a key ever was. + // + // Store the ADDRESS the user confirms, never the name: a name can be + // re-claimed by anyone tomorrow, an address is self-certifying. + [Throws=ProtocolError] + boolean resolve_username(string username); + // ======================================================================== // PRESENCE, TYPING INDICATORS, AND READ RECEIPTS // ======================================================================== diff --git a/crates/offline-protocol/Cargo.toml b/crates/offline-protocol/Cargo.toml index 7220a620..49061efe 100644 --- a/crates/offline-protocol/Cargo.toml +++ b/crates/offline-protocol/Cargo.toml @@ -32,6 +32,9 @@ uuid = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } base64 = { workspace = true } +# The Nostr author key is hex on the wire and hex in the discovery record, so +# the comparison that binds the two never crosses an encoding. +hex = { workspace = true } sha2 = "0.10" chacha20poly1305 = { workspace = true } rand_core = { workspace = true } diff --git a/crates/offline-protocol/src/config.rs b/crates/offline-protocol/src/config.rs index 78d70336..975fb28b 100644 --- a/crates/offline-protocol/src/config.rs +++ b/crates/offline-protocol/src/config.rs @@ -360,6 +360,39 @@ pub struct TransportConfig { /// until it has traffic, at the price of cold contact. pub nostr_cold_contact_enabled: bool, + /// Whether this install publishes a username discovery record and can + /// resolve usernames to the devices claiming them. + /// + /// Defaults to **`false`**, unlike `nostr_cold_contact_enabled`, and gates + /// publication and resolution together. It also *requires* cold contact: + /// a discovery record points at an address whose key packages are what a + /// resolver fetches next, so with cold contact off the claim resolves and + /// then dead-ends one hop later. The two are hard-coupled in the transport + /// rather than merely documented. + /// + /// Buys back **reach by username**, which the addressing migration removed: + /// a stranger who knows only a name can find the addresses claiming it and + /// open a session. The name is published as the app's configured `profile`, + /// normalized to NFC and lowercase. + /// + /// **Default-off is the deliberate choice, and the reason is disclosure.** + /// Publishing binds a human-readable name to an address in a public place. + /// That is materially more than the key-package record's "an install with + /// this tag exists": the mapping *is* the payload. The record is sealed to + /// a key derived from the username, so a relay scraping by kind alone reads + /// nothing, but anyone who guesses the name can compute the tag, confirm a + /// claim exists, and read it. That is the same class of exposure a public + /// user directory has, and it is a decision an app should make explicitly. + /// + /// **What the directory is not: authoritative.** Anyone may publish any + /// claim at any name. Every record is a claim that *some key* asserts a + /// name, and a resolution returns the whole set of claimants with their + /// verification status. An app that silently picks the first result has + /// converted a non-authoritative directory into an authoritative-looking + /// one; the user must confirm out of band. See + /// `docs/spec/username-discovery.md`. + pub nostr_username_discovery_enabled: bool, + /// Whether to negotiate and emit the compact binary wire codec. /// /// Defaults to `true`. It only takes effect between two peers that both @@ -379,6 +412,7 @@ impl Default for TransportConfig { nostr_enabled: false, nostr_sealing_enabled: true, nostr_cold_contact_enabled: true, + nostr_username_discovery_enabled: false, binary_wire_enabled: true, } } @@ -922,6 +956,14 @@ impl ProtocolConfigBuilder { self } + /// Enables username discovery publication and resolution + /// (default **off**; see + /// [`TransportConfig::nostr_username_discovery_enabled`]). + pub fn nostr_username_discovery_enabled(mut self, enabled: bool) -> Self { + self.config.transport.nostr_username_discovery_enabled = enabled; + self + } + /// Enables or disables Nostr key-package publication and peer resolution /// (default on; see [`TransportConfig::nostr_cold_contact_enabled`]). pub fn nostr_cold_contact_enabled(mut self, enabled: bool) -> Self { diff --git a/crates/offline-protocol/src/events.rs b/crates/offline-protocol/src/events.rs index f2a9e0e9..038b8f59 100644 --- a/crates/offline-protocol/src/events.rs +++ b/crates/offline-protocol/src/events.rs @@ -449,6 +449,33 @@ impl From<&offline_protocol_core::ReplyContext> for ReplyContextEvent { } } +/// One device's verified claim to a username. +/// +/// Every field here has already passed verification: the address derives from +/// the public key, the record's signature verifies under that key, and the +/// record was published under the Nostr key it names. What that proves is +/// narrow and worth stating exactly — **this key asserts this name**. It does +/// not prove the name belongs to the claimant, because nothing can: the +/// directory is non-authoritative by design. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct UsernameClaim { + /// The claimed address, in canonical `off1…` form. + /// + /// This is the value to keep. An app that stores the *name* has stored + /// something anyone can re-claim tomorrow; the address is self-certifying. + pub address: String, + /// The Ed25519 identity key the address derives from, base64. + pub public_key: String, + /// When the claimant signed the record, in milliseconds since the epoch. + /// + /// **Advisory.** A record is not a liveness signal — the key-package fetch + /// that follows it is. An old claim from a peer who has been offline for a + /// month is still perfectly valid, and rejecting it here would make them + /// unreachable by name while their key packages sit live on a relay. Sort + /// by it if that helps a user choose; do not filter on it. + pub issued_at_ms: i64, +} + /// Events that can occur in the protocol. /// /// Note: This type implements a custom Debug that redacts sensitive fields @@ -1206,6 +1233,42 @@ pub enum Event { reason: String, }, + /// A username resolution finished, carrying **every** claim found. + /// + /// # The set is the whole point, and there is deliberately no "best" claim + /// + /// Anyone may publish any username claim, so a name resolves to a set of + /// assertions, never to an answer. Even a single-device user is a set of + /// one. This event is emitted exactly once per resolution and carries the + /// complete set precisely so an app cannot accidentally treat the first + /// arrival as the winner: there is no per-claim event to race, and no + /// ordering field to mistake for a ranking. + /// + /// **An app MUST let the user choose.** Silently picking one claim converts + /// a non-authoritative directory into an authoritative-looking one, which + /// is worse than not shipping the feature: the user believes they are + /// talking to the name, and the protocol only ever promised them a key. + /// Present the claims, let a human confirm out of band (a QR code, a + /// shared secret, a voice), and remember the address rather than the name. + /// + /// An empty `claims` list is an ordinary outcome: the name may be + /// unclaimed, the claimant offline-published to relays we do not use, or + /// every claim we saw failed verification. + UsernameResolved { + /// The normalized username that was resolved. + username: String, + /// Every claim that verified, in no meaningful order. + claims: Vec, + /// How many records were seen but rejected. + /// + /// Non-zero is *normal*, not an error: the tag is public, anyone may + /// publish to it, and junk arrives. Surfaced because a resolution that + /// returns nothing while rejecting many records means something + /// different from one that returns nothing having seen nothing, and an + /// app showing "not found" may want to distinguish them. + rejected: u32, + }, + /// A group message was sent to all members via mesh (MLS-encrypted fan-out). GroupMessageSent { /// MLS group identifier. @@ -2541,6 +2604,7 @@ impl Event { Self::UserGroups { .. } => "protocol.group.user_groups", Self::GroupError { .. } => "protocol.group.error", Self::GroupRelaySyncChanged { .. } => "protocol.group.relay_sync_changed", + Self::UsernameResolved { .. } => "protocol.username.resolved", Self::GroupMessageSent { .. } => "protocol.group.message_sent", Self::GroupMessagePartialFailure { .. } => "protocol.group.message_partial_failure", Self::GroupMessageDeliveryReport { .. } => "protocol.group.delivery_report", @@ -3157,6 +3221,20 @@ impl fmt::Debug for Event { .field("synced", synced) .field("reason", reason) .finish(), + // The username is redacted: it is a human-readable name for a + // person, which is exactly the class of identifier this Debug + // exists to keep out of logs. The counts say everything a reader + // debugging a resolution needs. + Self::UsernameResolved { + username: _, + claims, + rejected, + } => f + .debug_struct("UsernameResolved") + .field("username", &"") + .field("claim_count", &claims.len()) + .field("rejected", rejected) + .finish(), Self::GroupMessageSent { group_id, message_ids, diff --git a/crates/offline-protocol/src/protocol/mod.rs b/crates/offline-protocol/src/protocol/mod.rs index 99874ca8..e5691a15 100644 --- a/crates/offline-protocol/src/protocol/mod.rs +++ b/crates/offline-protocol/src/protocol/mod.rs @@ -5,6 +5,7 @@ mod config_accessors; mod decryption_queue; pub(crate) mod mesh_relay; mod message_dispatch; +mod nostr_discovery; mod nostr_publication; mod observability; mod pending_queue; @@ -39,7 +40,7 @@ use crate::{ }; use chrono::{DateTime, Utc}; use mesh_relay::{MeshRelayGovernor, RelayActivity}; -use offline_protocol_core::{LamportClock, Message, MessageId, MutexExt}; +use offline_protocol_core::{LamportClock, Message, MessageId, MutexExt, Username}; use offline_protocol_mls::{EncryptedMessage, MlsManager, MlsStorage, WelcomeMessage}; use offline_protocol_reliability::{AckManager, Deduplicator, RetryQueue}; use offline_protocol_router::{ @@ -573,6 +574,51 @@ pub struct OfflineProtocol { /// repeats for a cause that by nature persists. last_nostr_slot_warning: Option, + /// The username this install has published a discovery claim for, as last + /// persisted. `None` means no claim stands. + /// + /// Persisted, because it is the only thing that knows *which* name to + /// retract when the profile changes or the feature is turned off. See + /// `storage_keys::NOSTR_DISCOVERY_CLAIM`. + nostr_discovery_claim: Option, + + /// Whether the standing claim has been published during *this* process. + /// + /// Not persisted, for the same reason `nostr_published_slots` is not: an + /// addressable record lives on the relays and a launch cannot know which + /// of them still hold it. Republishing once per process replaces rather + /// than accumulates. + nostr_discovery_published: bool, + + /// Republish backoff for the discovery claim, keyed by its tag. + /// + /// Memory-only and structurally identical to + /// `nostr_publication_backoff`: a relay that rejects kind 30777 outright + /// would otherwise be retried once per refresh forever. + nostr_discovery_backoff: HashMap, + + /// When the discovery claim was last re-examined, throttling the tick. + last_nostr_discovery_refresh: Option, + + /// In-flight username resolutions, keyed by query id. + /// + /// A resolution accumulates every verified claim for its username and + /// emits **one** event carrying the whole set. See + /// `nostr_discovery::PendingResolution` for why the set is the only thing + /// that is ever emitted. + nostr_resolutions: HashMap, + + /// Username lookups requested but not yet minted into a relay query, with + /// the time each was asked for. + /// + /// The transport holds the queue; this holds the *deadline*. They are + /// separate because minting is what may never happen: the platform pumps + /// queries only while the relay socket is up, so a lookup requested offline + /// would otherwise have no clock running behind it and no event to arrive. + /// Bounded by the transport's own resolve-queue ceiling, since an entry is + /// recorded only when the transport accepts the lookup. + nostr_resolution_requests: HashMap, + /// When the push key-package pool-exhaustion warning was last emitted. /// /// Suppressed for the same reason the slot warning is: the condition is a @@ -754,6 +800,12 @@ impl OfflineProtocol { last_nostr_slot_refresh: None, nostr_publication_backoff: HashMap::new(), last_nostr_slot_warning: None, + nostr_discovery_claim: None, + nostr_discovery_published: false, + nostr_discovery_backoff: HashMap::new(), + last_nostr_discovery_refresh: None, + nostr_resolutions: HashMap::new(), + nostr_resolution_requests: HashMap::new(), last_push_key_package_warning: None, config, }) @@ -927,6 +979,7 @@ impl OfflineProtocol { // a stale-but-installed mark does is narrow one replay window. self.restore_nostr_watermark(); self.restore_nostr_publication_slots(); + self.restore_nostr_discovery_claim(); // The launch's whole durable-delete allowance, in one place because the // bound is on the launch. A device-barrier storm kills *this call*, not @@ -1057,6 +1110,7 @@ impl OfflineProtocol { self.restore_or_init_nostr_signing_secret(); self.restore_nostr_watermark(); self.restore_nostr_publication_slots(); + self.restore_nostr_discovery_claim(); // Same three pools as `initialize_mls_inner`, for the same reason. let mut advisory_prunes = PruneAllowance::pool(); let mut pending_prunes = PruneAllowance::pool(); @@ -1337,6 +1391,9 @@ impl OfflineProtocol { .set_nostr_sealing_enabled(self.config.transport.nostr_sealing_enabled); self.transport_manager .set_nostr_cold_contact_enabled(self.config.transport.nostr_cold_contact_enabled); + self.transport_manager.set_nostr_username_discovery_enabled( + self.config.transport.nostr_username_discovery_enabled, + ); // Wire DORS event callback so app receives dors_score_updated, dors_transport_selected, let shared = self.shared_state.clone(); @@ -2459,6 +2516,11 @@ impl OfflineProtocol { let _ = self.prune_expired_pending_global_front(Instant::now(), 256); self.pump_media_transfers(); self.refresh_nostr_key_package_slots(); + self.refresh_nostr_discovery_claim(); + // A relay is free never to send end-of-stored-events, and that is the + // only natural completion signal a resolution has. Without this sweep + // a quiet relay turns a lookup into a hang rather than an empty answer. + self.sweep_username_resolutions(); self.cleanup_expired_entries(); self.evaluate_relay_role(); self.tick_telemetry_categories(); diff --git a/crates/offline-protocol/src/protocol/nostr_discovery.rs b/crates/offline-protocol/src/protocol/nostr_discovery.rs new file mode 100644 index 00000000..a84a6913 --- /dev/null +++ b/crates/offline-protocol/src/protocol/nostr_discovery.rs @@ -0,0 +1,696 @@ +//! Username discovery: publishing this install's claim, and resolving a name +//! to the set of devices claiming it. +//! +//! The publication half mirrors [`super::nostr_publication`] closely enough +//! that the differences are the interesting part: +//! +//! - **One claim, not a set of slots.** A key package is single-use, so several +//! must stand at once. A claim is a statement, so exactly one stands and +//! republishing *replaces* it. +//! - **A deterministic `d` tag.** That is what makes replacement work, and it +//! is what makes retraction possible at all. +//! - **Retraction exists.** A key-package slot is abandoned by letting it +//! expire; a claim must be actively withdrawn, because it names a human and +//! points at an address that stays live. +//! +//! The resolution half has one rule that outranks its mechanics: **a username +//! resolves to a set, and the set is the only thing ever emitted.** See +//! [`PendingResolution`]. + +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use offline_protocol_core::Username; +use offline_protocol_mls::discovery::{ + parse_discovery_body, verify_discovery_record, DiscoveryBody, DiscoveryRecordV1, + DiscoveryTombstoneV1, +}; +use offline_protocol_transport::nostr::ResolveRequest; +use tracing::{debug, warn}; + +use super::{storage_keys, OfflineProtocol}; +use crate::events::{Event, UsernameClaim}; +use crate::{Error, Result}; + +/// How often the discovery claim is re-examined, matching the slot refresh. +const DISCOVERY_REFRESH_INTERVAL: Duration = Duration::from_secs(60); + +/// Ceiling on the republish backoff, and the quiet period after which a +/// failure streak resets. Matches the key-package publication ladder. +const DISCOVERY_MAX_BACKOFF: Duration = Duration::from_secs(1800); + +/// How long a resolution may accumulate before it is flushed regardless. +/// +/// End-of-stored-events is the natural completion signal, and it is the one a +/// relay is free not to send. Without this sweep a relay that goes quiet after +/// answering would leave the resolution accumulating forever and the app +/// waiting on an event that never comes — a hang rather than an empty result. +/// Sized well above a relay round-trip and well below any human's patience. +const RESOLUTION_TIMEOUT: Duration = Duration::from_secs(30); + +/// Maximum concurrent username resolutions. +/// +/// Bounded like every other query-keyed map: a caller can start resolutions +/// faster than relays answer them, and an app looping over a contact list +/// would otherwise grow this without limit. At capacity the *oldest* is +/// flushed with whatever it has rather than dropped silently, so its caller +/// still receives an answer. +const MAX_PENDING_RESOLUTIONS: usize = 32; + +/// Maximum claims accumulated for one username. +/// +/// The tag is public, so a squatter can publish many records at it. This caps +/// what one resolution can cost in memory and what an app is asked to render. +/// Reaching it means crowding, which the design accepts: the honest claimants +/// may be among the ones displaced, which is why the invite path exists and +/// why a user confirms out of band. +const MAX_CLAIMS_PER_RESOLUTION: usize = 32; + +/// A username resolution in flight. +/// +/// # Why claims accumulate here instead of being emitted as they arrive +/// +/// Emitting one event per claim would make the *first* claim the easiest thing +/// for an app to consume, and the first claim is meaningless — it is whichever +/// relay answered fastest. An app written the obvious way against a stream of +/// per-claim events silently becomes an app that picks a winner, which is +/// precisely the failure this layer must not enable. +/// +/// So the set accumulates here and leaves as one event. The shape makes the +/// correct behaviour the path of least resistance rather than a documented +/// obligation, which is the only kind of API rule that survives contact with a +/// deadline. +pub(crate) struct PendingResolution { + /// The username being resolved. + username: Username, + /// Verified claims, keyed by the publishing Nostr author key. + /// + /// Keyed by author rather than by address because that is what a device + /// *is* here: one install publishes under one Nostr key. Two records from + /// the same author are the same device republishing, and the newer one + /// wins. Two records from different authors naming the same address would + /// be unusual (it needs the same identity key on two installs) and are + /// kept separate, since collapsing them would hide a real anomaly. + claims: HashMap, + /// Authors that have retracted, so a retraction survives whatever order the + /// relays answer in. + /// + /// A tombstone and the record it replaces both live on the relays for a + /// while: the tombstone occupies the addressable slot, and a relay that + /// missed the replacement still serves the old record. Removing the claim + /// on arrival is therefore not enough — a stale copy landing afterwards + /// would verify (it is genuinely signed) and stand the retracted claim back + /// up, which is precisely the outcome retraction exists to prevent. + /// + /// A suppressed record is **not** counted in [`Self::rejected`]. It is not + /// junk, and counting it would make the same two events report differently + /// depending on which arrived first, reintroducing the order-dependence in + /// the counter after removing it from the set. + /// + /// Bounded by the transport's per-query delivery ceiling, like `claims`. + tombstoned: HashSet, + /// Records seen and refused. Reported so an app can tell "nobody claims + /// this name" apart from "everything claiming it was junk". + rejected: u32, + /// When the resolution began, driving the timeout sweep. + started: Instant, +} + +impl OfflineProtocol { + /// Publishes, republishes or retracts this install's username claim. + /// + /// Runs on the process tick beside the key-package slot refresh. Four + /// states, and the transitions between them are the whole of this + /// function: + /// + /// 1. discovery is off and nothing stands: do nothing; + /// 2. discovery is off and a claim stands: retract it; + /// 3. discovery is on and the standing claim names a different username + /// than the current profile: retract the old one, then publish the new; + /// 4. discovery is on and the claim is current: republish once per process. + /// + /// State 3 is the one that is easy to omit and expensive to omit. A user + /// who renames leaves their old name standing in a public directory, + /// pointing at an address that is still live, with nothing to ever remove + /// it — and the *new* owner of that name looks like a squatter next to it. + pub(crate) fn refresh_nostr_discovery_claim(&mut self) { + if !self.nostr_discovery_refresh_due() { + return; + } + + let now = Instant::now(); + + // Drained after the throttle check, like the slot reports: draining is + // destructive and a throttled tick that dropped them would lose them. + for tag in self + .transport_manager + .take_failed_nostr_discovery_publications() + { + self.nostr_discovery_published = false; + self.note_discovery_failure(&tag, now); + } + + let enabled = self.transport_manager.nostr_discovery_active(); + let desired = if enabled { + match self.config.profile.parse::() { + Ok(username) => Some(username), + Err(e) => { + // Not a warning event: an app whose profile is not a + // claimable username has simply not opted into a feature + // that needs one, and saying so once per minute would be + // noise. The claim is not published, which is the correct + // and safe outcome. + debug!( + error = %e, + "Profile is not a claimable username; discovery claim not published" + ); + None + } + } + } else { + None + }; + + // Retract whatever stands that should not. + if let Some(standing) = self.nostr_discovery_claim.clone() { + if desired.as_ref() != Some(&standing) { + self.retract_discovery_claim(standing); + } + } + + let Some(username) = desired else { + return; + }; + + if self.nostr_discovery_claim.as_ref() == Some(&username) && self.nostr_discovery_published + { + return; + } + + if self.discovery_backoff_active(&username, now) { + return; + } + + // Identity readiness is a *timing* state, not a fault: this tick runs + // from process start, and MLS init plus the Nostr signing key land + // some ticks later. Warning about it would emit once a minute during + // every normal startup and train a reader to ignore the one message + // that means something. Checked before the attempt rather than + // classified after it, so the two cannot drift. + if self.mls_manager.is_none() + || self.local_address().is_none() + || self.transport_manager.nostr_public_key().is_none() + { + debug!("Identity not ready yet; discovery claim deferred to a later tick"); + return; + } + + if let Err(e) = self.publish_discovery_claim(username) { + warn!(error = %e, "Failed to publish the Nostr username discovery claim"); + } + } + + /// Builds, signs and queues the claim record. + fn publish_discovery_claim(&mut self, username: Username) -> Result<()> { + let address = self + .local_address() + .ok_or_else(|| Error::Other("No local address yet".to_string()))? + .parse() + .map_err(|e| Error::Other(format!("Local address is not canonical: {}", e)))?; + + let nostr_author = self + .transport_manager + .nostr_public_key() + .ok_or_else(|| Error::Other("Nostr transport has no signing key yet".to_string()))?; + let author_bytes = hex::decode(&nostr_author) + .map_err(|e| Error::Other(format!("Nostr public key is not hex: {}", e)))?; + + let mls = self.mls_manager.as_ref().ok_or(Error::MlsNotInitialized)?; + let manager = mls + .read() + .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?; + let public_key = manager + .get_identity_public_key() + .map_err(|e| Error::Other(format!("Failed to get identity public key: {}", e)))?; + + let record = DiscoveryRecordV1::unsigned( + username.clone(), + address, + public_key, + author_bytes, + chrono::Utc::now().timestamp_millis(), + ) + .sign_with(|payload| manager.sign_data(payload)) + .map_err(|e| Error::Other(format!("Failed to sign the discovery record: {}", e)))?; + drop(manager); + + let payload = + serde_json::to_vec(&record).map_err(|e| Error::Serialization(e.to_string()))?; + + self.transport_manager + .publish_nostr_discovery_record(username.clone(), payload); + + self.nostr_discovery_published = true; + if self.nostr_discovery_claim.as_ref() != Some(&username) { + self.nostr_discovery_claim = Some(username); + self.persist_nostr_discovery_claim(); + } + Ok(()) + } + + /// Queues a tombstone and a best-effort deletion for a standing claim. + /// + /// The persisted record is cleared *before* the queue call rather than + /// after: a retraction that fails to reach a relay leaves a claim we no + /// longer track, which is the recoverable direction (the claim expires when + /// its second hop fails). Keeping the record and failing to clear it would + /// instead have the next tick retract again forever. + fn retract_discovery_claim(&mut self, username: Username) { + match serde_json::to_vec(&DiscoveryTombstoneV1::new()) { + Ok(payload) => { + self.transport_manager + .retract_nostr_discovery_record(username, payload); + } + Err(e) => warn!(error = %e, "Failed to build a discovery tombstone"), + } + self.nostr_discovery_claim = None; + self.nostr_discovery_published = false; + self.persist_nostr_discovery_claim(); + } + + /// Whether enough time has passed to re-examine the claim. + fn nostr_discovery_refresh_due(&mut self) -> bool { + let now = Instant::now(); + if let Some(last) = self.last_nostr_discovery_refresh { + if now.duration_since(last) < DISCOVERY_REFRESH_INTERVAL { + return false; + } + } + self.last_nostr_discovery_refresh = Some(now); + true + } + + /// Records a failed publication and pushes the next attempt out. + /// + /// Identical ladder to the key-package slots: the first failure retries on + /// the next refresh, and only a claim that keeps failing climbs. + fn note_discovery_failure(&mut self, tag: &str, now: Instant) { + let entry = self + .nostr_discovery_backoff + .entry(tag.to_string()) + .or_insert(super::nostr_publication::PublicationBackoff { + failures: 0, + last_failure: now, + retry_at: now, + }); + + if now.duration_since(entry.last_failure) > DISCOVERY_MAX_BACKOFF { + entry.failures = 1; + } else { + entry.failures = entry.failures.saturating_add(1); + } + entry.last_failure = now; + + let delay = if entry.failures <= 1 { + Duration::ZERO + } else { + let shift = (entry.failures - 2).min(16); + DISCOVERY_REFRESH_INTERVAL + .saturating_mul(1u32 << shift) + .min(DISCOVERY_MAX_BACKOFF) + }; + entry.retry_at = now + delay; + } + + /// Whether the claim is waiting out a publication backoff. + fn discovery_backoff_active(&self, username: &Username, now: Instant) -> bool { + let Ok(tag) = + offline_protocol_transport::nostr_crypto::discovery_tag_for_username(username) + else { + return false; + }; + self.nostr_discovery_backoff + .get(&tag) + .is_some_and(|backoff| now < backoff.retry_at) + } + + /// Starts a username resolution. + /// + /// # The return value means exactly one thing: an answer is coming + /// + /// `Ok(true)` started a lookup and `Ok(false)` joined one already in + /// flight, and **both** are followed by exactly one + /// [`Event::UsernameResolved`] for the name. Every case where no event will + /// ever arrive is an error instead. + /// + /// That split is the whole point. A caller awaits the event, so folding + /// "discovery is off" into `false` alongside "already in flight" leaves an + /// app unable to tell waiting from hanging — and the failure mode is a + /// spinner that never stops, on the path a user reaches by typing a name + /// and pressing search. + /// + /// # Errors + /// + /// - [`Error::InvalidArgument`] if the string is not a claimable username. + /// - [`Error::InvalidConfiguration`] if username discovery is off. It also + /// requires cold contact, so this covers a claim that could only + /// dead-end one hop later. + /// - [`Error::InvalidState`] if too many lookups are already in flight. + /// Transient: retry once earlier ones drain. + pub fn resolve_username(&mut self, username: &str) -> Result { + let username: Username = username + .parse() + .map_err(|e| Error::InvalidArgument(format!("Not a resolvable username: {}", e)))?; + + if !self.transport_manager.nostr_discovery_active() { + return Err(Error::InvalidConfiguration( + "Username discovery is disabled; enable nostr_username_discovery_enabled \ + (which also requires nostr_cold_contact_enabled)" + .to_string(), + )); + } + + // Deduplicated against both halves of "in flight", because the transport + // can only see the first. A lookup lives in its queue until the platform + // mints a query, and from then on it exists solely as a resolution here. + // Checking only the transport would let the second request for a name + // mint a duplicate REQ and emit a second event for one lookup, breaking + // the exactly-one-event contract the whole API shape rests on. + if self.nostr_resolution_requests.contains_key(&username) + || self + .nostr_resolutions + .values() + .any(|resolution| resolution.username == username) + { + return Ok(false); + } + + match self + .transport_manager + .resolve_nostr_username(username.clone()) + { + ResolveRequest::Queued => {} + // Reachable despite the check above only if the two fell out of + // step; the in-flight lookup still answers this caller. + ResolveRequest::AlreadyQueued => return Ok(false), + ResolveRequest::Disabled => { + return Err(Error::InvalidConfiguration( + "Username discovery is disabled".to_string(), + )) + } + ResolveRequest::QueueFull => { + return Err(Error::InvalidState( + "Too many username lookups in flight; retry shortly".to_string(), + )) + } + } + + // The deadline starts here, not at query mint, because minting is what + // may never happen: the platform pumps queries only while the relay + // socket is up, so a lookup requested offline sits in the transport + // queue with no resolution behind it and no clock running. Timing from + // the request is what makes "a lookup was started" a promise this + // engine can keep. + self.nostr_resolution_requests + .entry(username) + .or_insert_with(Instant::now); + Ok(true) + } + + /// Registers a query the transport just minted, so its answers accumulate. + /// + /// A mint whose request has already been answered by the timeout sweep is + /// dropped rather than accumulated. That is what keeps the one-event + /// contract when a relay reconnects after the sweep gave up: the query + /// still goes out, its answers land on an unknown resolution and are + /// discarded, and no second, contradictory set is emitted for a lookup the + /// app has already been told the answer to. + pub fn begin_username_resolution(&mut self, query_id: String, username: Username) { + if self.nostr_resolution_requests.remove(&username).is_none() { + debug!("Discovery query minted for an already-answered request; not accumulating"); + return; + } + + if self.nostr_resolutions.len() >= MAX_PENDING_RESOLUTIONS { + // Flush the oldest rather than refusing the newest: its caller is + // still owed an answer, and an answer with fewer claims beats a + // silence that never resolves. + if let Some(stale) = self + .nostr_resolutions + .iter() + .min_by_key(|(_, resolution)| resolution.started) + .map(|(id, _)| id.clone()) + { + self.flush_username_resolution(&stale); + } + } + + self.nostr_resolutions.insert( + query_id, + PendingResolution { + username, + claims: HashMap::new(), + tombstoned: HashSet::new(), + rejected: 0, + started: Instant::now(), + }, + ); + } + + /// Accumulates one discovery record returned by a resolution query. + /// + /// Verification happens here and every failure is *ordinary*: the tag is + /// public, anyone may publish to it, and a query returns whatever the relay + /// holds. A refused record is counted and dropped, never surfaced as an + /// error — a resolver that reported junk as a failure would make an + /// unremarkable directory look broken. + pub fn handle_resolved_discovery_record( + &mut self, + query_id: &str, + username: &Username, + author: &str, + data: &[u8], + ) { + let Some(resolution) = self.nostr_resolutions.get_mut(query_id) else { + debug!(query_id = %query_id, "Discovery record for an unknown resolution"); + return; + }; + + let body = match parse_discovery_body(data) { + Ok(body) => body, + Err(e) => { + debug!(error = %e, "Undecodable discovery record"); + resolution.rejected = resolution.rejected.saturating_add(1); + return; + } + }; + + let record = match body { + DiscoveryBody::Record(record) => *record, + DiscoveryBody::Tombstone => { + // A retraction. Drop any claim this author had made and refuse + // any that arrives later: the tombstone replaced their record + // at the relay, so seeing both means we read a stale copy from + // one relay and the retraction from another. The retraction is + // the newer statement whichever order they land in. + resolution.claims.remove(author); + resolution.tombstoned.insert(author.to_string()); + return; + } + }; + + // Checked before the signature verify, both because it is cheaper and + // because a retracted author's record is refused on the strength of the + // retraction rather than on anything about the record. + if resolution.tombstoned.contains(author) { + debug!("Discovery record from an author that has retracted; ignoring"); + return; + } + + let author_bytes = match hex::decode(author) { + Ok(bytes) => bytes, + Err(_) => { + resolution.rejected = resolution.rejected.saturating_add(1); + return; + } + }; + + if let Err(rejection) = verify_discovery_record(&record, username, &author_bytes) { + debug!(reason = %rejection, "Discovery record refused"); + resolution.rejected = resolution.rejected.saturating_add(1); + return; + } + + if resolution.claims.len() >= MAX_CLAIMS_PER_RESOLUTION + && !resolution.claims.contains_key(author) + { + debug!( + cap = MAX_CLAIMS_PER_RESOLUTION, + "Username resolution at its claim ceiling; ignoring the rest" + ); + return; + } + + // One device, one claim: a repeat from the same author is that device + // republishing, so the newer statement wins. + match resolution.claims.get(author) { + Some(existing) if existing.issued_at_ms >= record.issued_at_ms => {} + _ => { + resolution.claims.insert(author.to_string(), record); + } + } + } + + /// Emits the accumulated set for a finished resolution. + /// + /// Idempotent: a resolution already flushed (by the timeout sweep, say) is + /// simply absent, so a late end-of-stored-events emits nothing rather than + /// a second, contradictory set. + pub fn flush_username_resolution(&mut self, query_id: &str) { + let Some(resolution) = self.nostr_resolutions.remove(query_id) else { + return; + }; + + let claims: Vec = resolution + .claims + .into_values() + .map(|record| UsernameClaim { + address: record.address.to_string(), + public_key: base64::engine::general_purpose::STANDARD.encode(&record.pubkey), + issued_at_ms: record.issued_at_ms, + }) + .collect(); + + self.emit_event(Event::UsernameResolved { + username: resolution.username.into_string(), + claims, + rejected: resolution.rejected, + }); + } + + /// Flushes resolutions whose relays never sent end-of-stored-events, and + /// answers lookups whose query was never minted at all. + /// + /// Runs on the process tick. Two distinct hangs, one deadline: + /// + /// - a relay that answers and then goes quiet leaves a resolution + /// accumulating with no completion signal, since end-of-stored-events is + /// the only one a Nostr query has; + /// - a lookup requested while the relay socket is down never reaches + /// [`Self::begin_username_resolution`] at all, because the platform pumps + /// queries only while connected. Nothing is accumulating, so there is + /// nothing for the first sweep to find, and the app waits forever on an + /// event with no trigger. + /// + /// The second case also **cancels** the queued lookup. Leaving it in the + /// transport queue would make every later `resolve_username` for that name + /// return `false` ("already queued") without ever emitting, so a name that + /// timed out once could never be looked up again for the life of the + /// process. + pub(crate) fn sweep_username_resolutions(&mut self) { + let now = Instant::now(); + + if !self.nostr_resolutions.is_empty() { + let expired: Vec = self + .nostr_resolutions + .iter() + .filter(|(_, resolution)| { + now.duration_since(resolution.started) > RESOLUTION_TIMEOUT + }) + .map(|(id, _)| id.clone()) + .collect(); + + for query_id in expired { + debug!(query_id = %query_id, "Username resolution timed out; emitting what it has"); + self.flush_username_resolution(&query_id); + } + } + + if self.nostr_resolution_requests.is_empty() { + return; + } + + let unminted: Vec = self + .nostr_resolution_requests + .iter() + .filter(|(_, requested_at)| now.duration_since(**requested_at) > RESOLUTION_TIMEOUT) + .map(|(username, _)| username.clone()) + .collect(); + + for username in unminted { + debug!("Username lookup never reached a relay; emitting an empty answer"); + self.nostr_resolution_requests.remove(&username); + self.transport_manager + .cancel_nostr_username_resolution(&username); + // An empty set, on the same terms as a query that reached a relay + // and found nothing. That is already what this engine emits when + // the platform releases a query with no relay connected, so the + // two unreachable paths report identically rather than one of them + // being silent. + self.emit_event(Event::UsernameResolved { + username: username.into_string(), + claims: Vec::new(), + rejected: 0, + }); + } + } + + /// Persists which username this install currently claims. + pub(crate) fn persist_nostr_discovery_claim(&mut self) { + let Some(storage) = self.protocol_state_storage.clone() else { + return; + }; + let bytes = match serde_json::to_vec(&self.nostr_discovery_claim) { + Ok(bytes) => bytes, + Err(e) => { + warn!(error = %e, "Failed to serialize the Nostr discovery claim"); + return; + } + }; + if let Err(e) = self.write_state_record( + storage.as_ref(), + storage_keys::NOSTR_DISCOVERY_CLAIM, + storage_keys::NOSTR_DISCOVERY_CLAIM_ID, + &bytes, + ) { + warn!(error = %e, "Failed to persist the Nostr discovery claim"); + } + } + + /// Restores the standing claim. + /// + /// Every failure lands on `None`, which means the next tick publishes the + /// current profile's claim and *does not* retract whatever was standing. + /// That is the benign direction: an unretracted claim expires when its + /// second hop fails, whereas guessing at a name to retract would publish a + /// tombstone at a tag this install may never have claimed. + pub(crate) fn restore_nostr_discovery_claim(&mut self) { + let Some(storage) = self.protocol_state_storage.clone() else { + return; + }; + + let data = match self.read_state_record( + storage.as_ref(), + storage_keys::NOSTR_DISCOVERY_CLAIM, + storage_keys::NOSTR_DISCOVERY_CLAIM_ID, + ) { + Ok(Some(data)) => data, + Ok(None) => return, + Err(e) => { + warn!(error = %e, "Failed to read the Nostr discovery claim; starting fresh"); + return; + } + }; + + match serde_json::from_slice::>(&data) { + Ok(claim) => { + self.nostr_discovery_claim = claim; + // A restored claim has not been published *this* process, and + // an addressable record lives on the relays rather than here. + self.nostr_discovery_published = false; + } + Err(e) => warn!(error = %e, "Corrupted Nostr discovery claim; starting fresh"), + } + } +} diff --git a/crates/offline-protocol/src/protocol/nostr_publication.rs b/crates/offline-protocol/src/protocol/nostr_publication.rs index 2345b47e..d4ea3fb7 100644 --- a/crates/offline-protocol/src/protocol/nostr_publication.rs +++ b/crates/offline-protocol/src/protocol/nostr_publication.rs @@ -76,11 +76,11 @@ pub(crate) struct NostrPublicationSlot { #[derive(Debug, Clone)] pub(crate) struct PublicationBackoff { /// Consecutive failures, which set the delay. - failures: u32, + pub(crate) failures: u32, /// When the most recent failure was reported. - last_failure: Instant, + pub(crate) last_failure: Instant, /// Earliest this slot may be republished. - retry_at: Instant, + pub(crate) retry_at: Instant, } impl OfflineProtocol { diff --git a/crates/offline-protocol/src/protocol/storage.rs b/crates/offline-protocol/src/protocol/storage.rs index 874f05a4..e576818e 100644 --- a/crates/offline-protocol/src/protocol/storage.rs +++ b/crates/offline-protocol/src/protocol/storage.rs @@ -62,6 +62,10 @@ pub(crate) enum StateCategory { /// [`Self::NostrWatermark`], and absent from /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`] for the same reason. NostrKeyPackageSlots, + /// This install's published username discovery claim. Post-split only, like + /// [`Self::NostrKeyPackageSlots`], and absent from + /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`] for the same reason. + NostrDiscoveryClaim, /// The value-less marker recording that the pre-split adoption sweep /// completed. Post-split only, so it is deliberately absent from /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`] — but it *is* written to @@ -88,6 +92,7 @@ impl StateCategory { storage_keys::LAMPORT_CLOCK => Self::LamportClock, storage_keys::NOSTR_WATERMARK => Self::NostrWatermark, storage_keys::NOSTR_KEY_PACKAGE_SLOTS => Self::NostrKeyPackageSlots, + storage_keys::NOSTR_DISCOVERY_CLAIM => Self::NostrDiscoveryClaim, storage_keys::STATE_ADOPTION => Self::StateAdoption, _ => return None, }) @@ -136,6 +141,16 @@ impl StateCategory { /// stranger who fetches it builds a Welcome that can never be processed. /// An AEAD makes that edit unopenable, and unopenable lands on the /// self-healing path. + /// - [`storage_keys::NOSTR_DISCOVERY_CLAIM`]: the third integrity case, and + /// again not for confidentiality — the value is a username this install + /// already published in a public directory. What sealing buys is that the + /// damaging edit is unreachable. This record is the *only* thing that + /// knows which name to retract, so an attacker who rewrites it to a name + /// this install never claimed makes the real claim unretractable: the + /// retraction is published at the wrong tag, and the live one stands + /// pointing at this address forever. Deleting the record instead is the + /// benign direction and is not prevented (nothing sealing does can), + /// which is why the claim is also republished on every launch. /// /// Everything else is advertised capability versions, a small state enum, a /// logical clock, a coarse wall-clock mark, or a value-less marker whose @@ -161,7 +176,8 @@ impl StateCategory { | Self::Outbox | Self::MediaDescriptors | Self::PeerKeyPackages - | Self::NostrKeyPackageSlots => true, + | Self::NostrKeyPackageSlots + | Self::NostrDiscoveryClaim => true, Self::PeerCapabilities | Self::SessionStates | Self::WelcomeLifecycles diff --git a/crates/offline-protocol/src/protocol/tests/mod.rs b/crates/offline-protocol/src/protocol/tests/mod.rs index f70ee647..10fa36bf 100644 --- a/crates/offline-protocol/src/protocol/tests/mod.rs +++ b/crates/offline-protocol/src/protocol/tests/mod.rs @@ -32607,10 +32607,14 @@ fn nostr_resolution_arms_the_reverse_exchange_that_heals_a_replayed_record() { bob_nostr.request_peer_key_packages(&id("alice")); let query = bob_nostr.next_query().unwrap().unwrap(); - let (_author, plaintext) = bob_nostr + use offline_protocol_transport::nostr::ResolvedRecord; + let ResolvedRecord::KeyPackage { plaintext, .. } = bob_nostr .open_query_event(&query.query_id, &event_json) .unwrap() - .expect("the record opens with alice's derivable key"); + .expect("the record opens with alice's derivable key") + else { + panic!("a key-package query must not yield a discovery record") + }; assert!( !bob.key_package_sent_to.contains(&id("alice")), @@ -32706,10 +32710,14 @@ fn nostr_resolution_registers_the_publishers_key_end_to_end() { bob_nostr.request_peer_key_packages(&id("alice")); let query = bob_nostr.next_query().unwrap().unwrap(); - let (_author, plaintext) = bob_nostr + use offline_protocol_transport::nostr::ResolvedRecord; + let ResolvedRecord::KeyPackage { plaintext, .. } = bob_nostr .open_query_event(&query.query_id, &event_json) .unwrap() - .expect("alice's published record opens with her derivable key"); + .expect("alice's published record opens with her derivable key") + else { + panic!("a key-package query must not yield a discovery record") + }; bob.handle_resolved_key_package(&plaintext).unwrap(); @@ -34519,3 +34527,648 @@ fn test_relay_prose_never_reaches_the_persisted_welcome_record() { .expect("the unreachable verdict must surface as a welcome send failure"); assert_eq!(transport_error.as_deref(), Some("recipient_unreachable")); } + +// ============================================================================ +// Username discovery: resolution accumulates a SET +// ============================================================================ + +/// Builds a signed discovery record for `username` from a deterministic +/// identity, published under `author`. +/// +/// Deliberately does not go through the engine: these tests are about what the +/// *resolver* does with records other devices published, so the records have to +/// come from somewhere the engine has no hand in. +fn discovery_record_for( + seed: u8, + username: &str, + author: &[u8], + issued_at_ms: i64, +) -> offline_protocol_mls::discovery::DiscoveryRecordV1 { + use ed25519_dalek::{Signer, SigningKey}; + use offline_protocol_mls::discovery::DiscoveryRecordV1; + + let signing = SigningKey::from_bytes(&[seed; 32]); + let public = signing.verifying_key().to_bytes().to_vec(); + let address = offline_protocol_mls::MlsManager::derive_address(&public).unwrap(); + + DiscoveryRecordV1::unsigned( + username.parse().unwrap(), + address, + public, + author.to_vec(), + issued_at_ms, + ) + .sign_with(|payload| Ok(signing.sign(payload).to_bytes().to_vec())) + .unwrap() +} + +/// Registers a lookup and then hands the engine the query the transport would +/// have minted for it. +/// +/// Both halves are needed because they are both real: `resolve_username` +/// records the request (which is what the timeout runs against), and the +/// platform's query pump is what later mints a query and calls +/// `begin_username_resolution`. A mint with no request behind it is +/// deliberately ignored as one the sweep already answered, so a test that +/// skipped the first half would be exercising that path rather than the +/// ordinary one, and would see no claims at all. +fn begin_resolution( + protocol: &mut OfflineProtocol, + query_id: &str, + username: &offline_protocol_core::Username, +) { + protocol + .nostr_resolution_requests + .insert(username.clone(), std::time::Instant::now()); + protocol.begin_username_resolution(query_id.to_string(), username.clone()); +} + +fn capture_events(protocol: &mut OfflineProtocol) -> Arc>> { + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let handle = Arc::clone(&events); + protocol.on_event(move |event| { + handle.lock().unwrap().push(event); + }); + events +} + +fn resolved_claims( + events: &Arc>>, +) -> Option<(String, Vec, u32)> { + events.lock().unwrap().iter().find_map(|event| match event { + Event::UsernameResolved { + username, + claims, + rejected, + } => Some((username.clone(), claims.clone(), *rejected)), + _ => None, + }) +} + +/// **The case a naive implementation collapses.** +/// +/// Two devices of the *same* user claim one name, and a third party claims it +/// too. All three must come back. An implementation keyed on the address, or +/// one that keeps "the" record for a username, silently returns one — and the +/// user never learns their own second device exists, let alone the squatter. +#[test] +fn test_username_resolution_returns_every_claimant_including_two_devices_of_one_user() { + let mut protocol = protocol_with_nostr("resolver"); + let events = capture_events(&mut protocol); + + let username: offline_protocol_core::Username = "alice".parse().unwrap(); + let query_id = "query-1".to_string(); + begin_resolution(&mut protocol, &query_id, &username); + + // Alice's phone and Alice's laptop: different identity keys, different + // Nostr author keys, same claimed name. Plus a squatter. + let alice_phone_author = [0x11u8; 32]; + let alice_laptop_author = [0x22u8; 32]; + let squatter_author = [0x33u8; 32]; + + for (seed, author) in [ + (1u8, alice_phone_author), + (2u8, alice_laptop_author), + (3u8, squatter_author), + ] { + let record = discovery_record_for(seed, "alice", &author, 1_700_000_000_000); + let body = serde_json::to_vec(&record).unwrap(); + protocol.handle_resolved_discovery_record( + &query_id, + &username, + &hex::encode(author), + &body, + ); + } + + protocol.flush_username_resolution(&query_id); + + let (name, claims, rejected) = + resolved_claims(&events).expect("a resolution must emit exactly one event"); + assert_eq!(name, "alice"); + assert_eq!(rejected, 0); + assert_eq!( + claims.len(), + 3, + "all three claimants must be returned; collapsing them hides both the \ + user's own second device and the squatter" + ); + + // Three distinct addresses, so nothing was merged. + let addresses: std::collections::HashSet<&str> = + claims.iter().map(|c| c.address.as_str()).collect(); + assert_eq!(addresses.len(), 3); +} + +/// Exactly one event per resolution. A per-claim stream would make "take the +/// first" the easiest thing an app could write, which is the failure mode the +/// whole set-shaped API exists to prevent. +#[test] +fn test_username_resolution_emits_exactly_one_event() { + let mut protocol = protocol_with_nostr("resolver"); + let events = capture_events(&mut protocol); + + let username: offline_protocol_core::Username = "alice".parse().unwrap(); + let query_id = "query-1".to_string(); + begin_resolution(&mut protocol, &query_id, &username); + + for (seed, author) in [(1u8, [0x11u8; 32]), (2u8, [0x22u8; 32])] { + let record = discovery_record_for(seed, "alice", &author, 1_700_000_000_000); + let body = serde_json::to_vec(&record).unwrap(); + protocol.handle_resolved_discovery_record( + &query_id, + &username, + &hex::encode(author), + &body, + ); + } + + assert!( + events.lock().unwrap().is_empty(), + "no event may be emitted before the resolution completes: a per-claim \ + event would make picking the first arrival the path of least resistance" + ); + + protocol.flush_username_resolution(&query_id); + let count = events + .lock() + .unwrap() + .iter() + .filter(|e| matches!(e, Event::UsernameResolved { .. })) + .count(); + assert_eq!(count, 1); +} + +/// A name nobody claims still answers. "No such name" is an answer, and an app +/// waiting on one must not hang. +#[test] +fn test_username_resolution_emits_an_empty_set_for_an_unclaimed_name() { + let mut protocol = protocol_with_nostr("resolver"); + let events = capture_events(&mut protocol); + + let username: offline_protocol_core::Username = "nobody".parse().unwrap(); + begin_resolution(&mut protocol, "query-1", &username); + protocol.flush_username_resolution("query-1"); + + let (name, claims, rejected) = resolved_claims(&events).expect("an empty answer is an answer"); + assert_eq!(name, "nobody"); + assert!(claims.is_empty()); + assert_eq!(rejected, 0); +} + +/// Flushing twice must not emit twice. The timeout sweep and a late +/// end-of-stored-events can both fire for one query, and a second, smaller set +/// would contradict the first. +#[test] +fn test_username_resolution_flush_is_idempotent() { + let mut protocol = protocol_with_nostr("resolver"); + let events = capture_events(&mut protocol); + + let username: offline_protocol_core::Username = "alice".parse().unwrap(); + begin_resolution(&mut protocol, "query-1", &username); + protocol.flush_username_resolution("query-1"); + protocol.flush_username_resolution("query-1"); + + let count = events + .lock() + .unwrap() + .iter() + .filter(|e| matches!(e, Event::UsernameResolved { .. })) + .count(); + assert_eq!(count, 1); +} + +/// A re-authored record — a squatter republishing someone else's genuinely +/// signed claim under their own Nostr key — must be counted as rejected, not +/// returned. This is the `nostr_author` binding doing its job at the engine +/// level, where the author actually comes off the wire. +#[test] +fn test_username_resolution_refuses_a_re_authored_record() { + let mut protocol = protocol_with_nostr("resolver"); + let events = capture_events(&mut protocol); + + let username: offline_protocol_core::Username = "alice".parse().unwrap(); + let query_id = "query-1".to_string(); + begin_resolution(&mut protocol, &query_id, &username); + + let genuine_author = [0x11u8; 32]; + let record = discovery_record_for(1, "alice", &genuine_author, 1_700_000_000_000); + let body = serde_json::to_vec(&record).unwrap(); + + // Untouched record, delivered under the squatter's author key. + let squatter_author = [0x99u8; 32]; + protocol.handle_resolved_discovery_record( + &query_id, + &username, + &hex::encode(squatter_author), + &body, + ); + + protocol.flush_username_resolution(&query_id); + + let (_, claims, rejected) = resolved_claims(&events).expect("event"); + assert!( + claims.is_empty(), + "a record republished under a foreign Nostr key must not be returned: \ + accepting it would let a squatter keep a retracted claim alive" + ); + assert_eq!(rejected, 1); +} + +/// A record claiming a different name than the one queried is refused. This is +/// what catches a genuine record for `bob` copied onto `alice`'s tag. +#[test] +fn test_username_resolution_refuses_a_record_for_another_name() { + let mut protocol = protocol_with_nostr("resolver"); + let events = capture_events(&mut protocol); + + let queried: offline_protocol_core::Username = "alice".parse().unwrap(); + let query_id = "query-1".to_string(); + begin_resolution(&mut protocol, &query_id, &queried); + + let author = [0x11u8; 32]; + let record = discovery_record_for(1, "bob", &author, 1_700_000_000_000); + let body = serde_json::to_vec(&record).unwrap(); + protocol.handle_resolved_discovery_record(&query_id, &queried, &hex::encode(author), &body); + + protocol.flush_username_resolution(&query_id); + + let (_, claims, rejected) = resolved_claims(&events).expect("event"); + assert!(claims.is_empty()); + assert_eq!(rejected, 1); +} + +/// One device republishing is one claim, and the newer statement wins. +#[test] +fn test_username_resolution_keeps_the_newest_record_per_device() { + let mut protocol = protocol_with_nostr("resolver"); + let events = capture_events(&mut protocol); + + let username: offline_protocol_core::Username = "alice".parse().unwrap(); + let query_id = "query-1".to_string(); + begin_resolution(&mut protocol, &query_id, &username); + + let author = [0x11u8; 32]; + for issued_at in [1_700_000_000_000i64, 1_700_000_500_000, 1_700_000_200_000] { + let record = discovery_record_for(1, "alice", &author, issued_at); + let body = serde_json::to_vec(&record).unwrap(); + protocol.handle_resolved_discovery_record( + &query_id, + &username, + &hex::encode(author), + &body, + ); + } + + protocol.flush_username_resolution(&query_id); + + let (_, claims, _) = resolved_claims(&events).expect("event"); + assert_eq!(claims.len(), 1, "one device is one claim"); + assert_eq!( + claims[0].issued_at_ms, 1_700_000_500_000, + "the newest record must win, whatever order the relays answered in" + ); +} + +/// A tombstone withdraws that device's claim **whichever order the two arrive +/// in**, which is the only version of this property worth having. +/// +/// A query is broadcast to every connected relay, and a retraction reaches them +/// at different times: the tombstone occupies the addressable slot on the +/// relays that took it, while any relay that missed the replacement keeps +/// serving the old record. So both bodies arrive, in an order nothing controls. +/// +/// Removing the claim when the tombstone lands is therefore only half the job. +/// A stale copy arriving afterwards is *genuinely signed* and passes every +/// check, so it would stand the retracted claim straight back up — leaving a +/// rotated-away or compromised address in front of the user, which is precisely +/// what retraction exists to prevent. The tombstone has to be sticky for the +/// life of the resolution. +#[test] +fn test_username_resolution_honours_a_tombstone_in_either_arrival_order() { + use offline_protocol_mls::discovery::DiscoveryTombstoneV1; + + // (label, tombstone arrives first) + for (label, tombstone_first) in [ + ("record then tombstone", false), + ("tombstone then record", true), + ] { + let mut protocol = protocol_with_nostr("resolver"); + let events = capture_events(&mut protocol); + + let username: offline_protocol_core::Username = "alice".parse().unwrap(); + let query_id = "query-1".to_string(); + begin_resolution(&mut protocol, &query_id, &username); + + let author = [0x11u8; 32]; + let record = serde_json::to_vec(&discovery_record_for( + 1, + "alice", + &author, + 1_700_000_000_000, + )) + .unwrap(); + let tombstone = serde_json::to_vec(&DiscoveryTombstoneV1::new()).unwrap(); + + let bodies: [&[u8]; 2] = if tombstone_first { + [&tombstone, &record] + } else { + [&record, &tombstone] + }; + for body in bodies { + protocol.handle_resolved_discovery_record( + &query_id, + &username, + &hex::encode(author), + body, + ); + } + + protocol.flush_username_resolution(&query_id); + + let (_, claims, rejected) = resolved_claims(&events).expect("event"); + assert!( + claims.is_empty(), + "{label}: a retraction must withdraw the claim, and a stale copy \ + arriving afterwards must not stand it back up" + ); + // The counter must be order-independent too. Counting the suppressed + // record as junk would move the order-dependence from the claim set + // into `rejected` rather than removing it: the same two events would + // report differently depending on which relay answered first. + assert_eq!( + rejected, 0, + "{label}: a record superseded by a retraction is not junk" + ); + } +} + +/// A tombstone from one device must not withdraw another device's claim. +/// +/// The suppression is keyed by publishing author, so the obvious way to get +/// this wrong — a per-resolution "retracted" flag — would let one device of a +/// multi-device user, or any passing squatter, retract everyone at the tag. +#[test] +fn test_username_resolution_tombstone_only_withdraws_its_own_author() { + use offline_protocol_mls::discovery::DiscoveryTombstoneV1; + + let mut protocol = protocol_with_nostr("resolver"); + let events = capture_events(&mut protocol); + + let username: offline_protocol_core::Username = "alice".parse().unwrap(); + let query_id = "query-1".to_string(); + begin_resolution(&mut protocol, &query_id, &username); + + let retracting_author = [0x11u8; 32]; + let standing_author = [0x22u8; 32]; + + // One device retracts... + protocol.handle_resolved_discovery_record( + &query_id, + &username, + &hex::encode(retracting_author), + &serde_json::to_vec(&DiscoveryTombstoneV1::new()).unwrap(), + ); + + // ...the other's claim, arriving after it, must survive. + let record = discovery_record_for(2, "alice", &standing_author, 1_700_000_000_000); + protocol.handle_resolved_discovery_record( + &query_id, + &username, + &hex::encode(standing_author), + &serde_json::to_vec(&record).unwrap(), + ); + + protocol.flush_username_resolution(&query_id); + + let (_, claims, _) = resolved_claims(&events).expect("event"); + assert_eq!( + claims.len(), + 1, + "a retraction is per-device: it must not withdraw a sibling's claim" + ); +} + +/// Claims are keyed by the publishing Nostr key, not by the address. +/// +/// The two agree in the ordinary case — one install holds one identity key and +/// one Nostr key — so this pins the case where they diverge: the *same* +/// identity key publishing from two Nostr keys. That is anomalous (it means an +/// identity key was copied between installs, which no shipped API allows) and +/// it is exactly why it must not be silently merged: collapsing the two would +/// hide the anomaly from the only layer positioned to show it. +#[test] +fn test_username_resolution_keys_claims_by_publisher_not_by_address() { + let mut protocol = protocol_with_nostr("resolver"); + let events = capture_events(&mut protocol); + + let username: offline_protocol_core::Username = "alice".parse().unwrap(); + let query_id = "query-1".to_string(); + begin_resolution(&mut protocol, &query_id, &username); + + // One identity (seed 1), two different Nostr publishers. + for author in [[0x11u8; 32], [0x22u8; 32]] { + let record = discovery_record_for(1, "alice", &author, 1_700_000_000_000); + let body = serde_json::to_vec(&record).unwrap(); + protocol.handle_resolved_discovery_record( + &query_id, + &username, + &hex::encode(author), + &body, + ); + } + + protocol.flush_username_resolution(&query_id); + + let (_, claims, _) = resolved_claims(&events).expect("event"); + assert_eq!( + claims.len(), + 2, + "two publishers of one identity must stay distinct: merging them would \ + hide that an identity key is in two places" + ); + assert_eq!( + claims[0].address, claims[1].address, + "precondition: both claims name the same address" + ); +} + +/// A lookup requested while the relay socket is down must still answer. +/// +/// The platform pumps queries only while connected, so such a lookup sits in +/// the transport's queue and never reaches `begin_username_resolution`: there +/// is no resolution accumulating, and therefore nothing for the ordinary +/// end-of-stored-events sweep to find. Without a deadline running from the +/// *request*, the app waits forever on an event with no trigger — the API +/// promised a lookup had started and then never finished one. +#[test] +fn test_username_lookup_that_never_reached_a_relay_still_answers() { + let mut protocol = protocol_with_nostr("resolver"); + let events = capture_events(&mut protocol); + + let username: offline_protocol_core::Username = "alice".parse().unwrap(); + let stale = std::time::Instant::now() + .checked_sub(std::time::Duration::from_secs(120)) + .expect("monotonic clock must be at least two minutes past boot"); + protocol + .nostr_resolution_requests + .insert(username.clone(), stale); + + protocol.sweep_username_resolutions(); + + let (name, claims, rejected) = + resolved_claims(&events).expect("a lookup that never reached a relay must still answer"); + assert_eq!(name, "alice"); + assert!( + claims.is_empty(), + "nothing was ever asked, so nothing verified" + ); + assert_eq!(rejected, 0); + assert!( + protocol.nostr_resolution_requests.is_empty(), + "the request must be cleared, or the sweep answers it again every tick" + ); +} + +/// A lookup that will never be answered is an **error**, not `false`. +/// +/// The distinction is the whole contract. A caller awaits the resolution +/// event, so folding "discovery is off" in with "already in flight" leaves an +/// app unable to tell waiting from hanging — and the symptom is a spinner that +/// never stops, on the path a user reaches by typing a name and pressing +/// search. `false` must mean an answer is still coming, always. +#[test] +fn test_resolving_with_discovery_disabled_is_an_error_not_a_false() { + let mut protocol = protocol_with_nostr("resolver"); + // Deliberately left off, which is the default. + assert!(matches!( + protocol.resolve_username("alice"), + Err(Error::InvalidConfiguration(_)) + )); + + // And nothing was recorded, so no sweep later invents an answer for a + // lookup that was never started. + assert!(protocol.nostr_resolution_requests.is_empty()); +} + +/// Deduplication must cover a lookup that has already been minted into a +/// query, not only one still sitting in the transport's queue. +/// +/// After the mint the transport has forgotten the name — the lookup exists +/// solely as a resolution here — so a check that consulted only the transport +/// would let the second request mint a *duplicate* relay query and emit a +/// second event for one lookup. Exactly one event per resolution is the +/// property the whole set-shaped API rests on. +#[test] +fn test_a_second_lookup_joins_an_already_minted_resolution() { + let mut protocol = protocol_with_nostr("resolver"); + protocol + .transport_manager_mut() + .set_nostr_username_discovery_enabled(true); + + let name = "alice"; + assert!( + protocol.resolve_username(name).expect("resolve"), + "precondition: the first lookup is accepted" + ); + + // The platform mints the query, which clears the request and opens the + // accumulator. The transport's queue no longer holds the name. + let username: offline_protocol_core::Username = name.parse().unwrap(); + protocol.begin_username_resolution("query-1".to_string(), username); + assert!(protocol.nostr_resolution_requests.is_empty()); + + assert!( + !protocol.resolve_username(name).expect("resolve"), + "a second lookup must join the in-flight resolution, not mint a \ + duplicate query that would emit a second event" + ); +} + +/// Giving up on a lookup must not make that name unlookupable for the rest of +/// the process. +/// +/// The transport refuses a name already in its resolve queue, so a request the +/// engine abandoned while leaving the queue entry behind would make every +/// later `resolve_username` for it return `false` without ever emitting — +/// turning a transient offline moment into a permanent one. +#[test] +fn test_a_swept_lookup_can_be_requested_again() { + let mut protocol = protocol_with_nostr("resolver"); + protocol + .transport_manager_mut() + .set_nostr_username_discovery_enabled(true); + + let name = "alice"; + assert!( + protocol.resolve_username(name).expect("resolve"), + "precondition: the first lookup is accepted" + ); + assert!( + !protocol.resolve_username(name).expect("resolve"), + "precondition: a duplicate is refused while the first is queued" + ); + + let username: offline_protocol_core::Username = name.parse().unwrap(); + let stale = std::time::Instant::now() + .checked_sub(std::time::Duration::from_secs(120)) + .expect("monotonic clock must be at least two minutes past boot"); + protocol + .nostr_resolution_requests + .insert(username.clone(), stale); + + protocol.sweep_username_resolutions(); + + assert!( + protocol.resolve_username(name).expect("resolve"), + "after giving up, the name must be lookupable again" + ); +} + +/// A query minted after the sweep already answered its request must not emit a +/// second, contradictory set. +/// +/// This is the race the request record closes: the transport can pop a name +/// into a query moments before the sweep gives up on it, so the mint and the +/// abandonment cross. One `resolveUsername` call owes exactly one event. +#[test] +fn test_a_query_minted_after_its_lookup_was_swept_emits_nothing_further() { + let mut protocol = protocol_with_nostr("resolver"); + let events = capture_events(&mut protocol); + + let username: offline_protocol_core::Username = "alice".parse().unwrap(); + let stale = std::time::Instant::now() + .checked_sub(std::time::Duration::from_secs(120)) + .expect("monotonic clock must be at least two minutes past boot"); + protocol + .nostr_resolution_requests + .insert(username.clone(), stale); + + protocol.sweep_username_resolutions(); + + // The query the transport had already popped arrives late, with a + // perfectly good record behind it. + let query_id = "query-1".to_string(); + protocol.begin_username_resolution(query_id.clone(), username.clone()); + let author = [0x11u8; 32]; + let record = discovery_record_for(1, "alice", &author, 1_700_000_000_000); + protocol.handle_resolved_discovery_record( + &query_id, + &username, + &hex::encode(author), + &serde_json::to_vec(&record).unwrap(), + ); + protocol.flush_username_resolution(&query_id); + + let count = events + .lock() + .unwrap() + .iter() + .filter(|e| matches!(e, Event::UsernameResolved { .. })) + .count(); + assert_eq!( + count, 1, + "one lookup owes one event: a late mint must not contradict the answer \ + the app was already given" + ); +} diff --git a/crates/offline-protocol/src/protocol/types.rs b/crates/offline-protocol/src/protocol/types.rs index b64161e6..d748d987 100644 --- a/crates/offline-protocol/src/protocol/types.rs +++ b/crates/offline-protocol/src/protocol/types.rs @@ -1613,6 +1613,22 @@ pub(crate) mod storage_keys { pub const NOSTR_KEY_PACKAGE_SLOTS: &str = "nostr_key_package_slots"; /// Key ID for the single Nostr publication-slot entry. pub const NOSTR_KEY_PACKAGE_SLOTS_ID: &str = "current"; + /// Key type for this install's published username discovery claim. + /// + /// Records *which* username was last published, so a profile change or a + /// switched-off feature can retract the previous claim. Without it a + /// renamed install leaves its old name standing in the directory + /// indefinitely, pointing at an address that is still live — the one + /// failure a directory must not have, since retraction is the only control + /// a claimant holds. + /// + /// Protocol state rather than secure storage, on the same reasoning as the + /// slot map: the value is a username that this install already published + /// in a public place. Losing it costs an un-retractable stale claim, which + /// expires only when a resolver's second hop fails. + pub const NOSTR_DISCOVERY_CLAIM: &str = "nostr_discovery_claim"; + /// Key ID for the single discovery-claim entry. + pub const NOSTR_DISCOVERY_CLAIM_ID: &str = "current"; /// Key type for the per-install key that seals sensitive protocol-state /// records at rest. /// @@ -1873,6 +1889,109 @@ pub(crate) enum OutboundSendPreparation { Queued(MessageId), } +/// Pins that every live signing domain is mutually non-prefixing. +/// +/// # Why this test exists here, of all places +/// +/// Four domains are in production and they live in three languages and two +/// repositories: `offline-ctrl-v1` (this crate), `offline-disc-v1` and +/// `offline-invite-v1` (the MLS crate), and `offline-relay-addr-v1` (the relay +/// server, plus hand-mirrored copies in the iOS and Android bridges). Nothing +/// can import all four, so this module is the one place they can be compared at +/// all: it is the highest crate that can see two of them, and the fourth is +/// pinned as a literal. +#[cfg(test)] +mod signing_domain_tests { + use offline_protocol_mls::discovery::DISCOVERY_SIGN_DOMAIN; + use offline_protocol_mls::invite::INVITE_SIGN_DOMAIN; + + use super::CTRL_SIGN_DOMAIN; + + /// The relay's address-proof domain. + /// + /// A literal because it is defined in the relay-server repository and + /// hand-mirrored in `AddressDeclarationPolicy.swift` and + /// `AddressDeclarationPolicy.kt` — there is no Rust constant in this + /// workspace to import. If it ever changes there, this test does not + /// notice, which is the accepted cost of a cross-repository constant and is + /// why it is spelled out with this comment attached. + const RELAY_ADDR_SIGN_DOMAIN: &[u8] = b"offline-relay-addr-v1"; + + /// No live domain may be a prefix of another. + /// + /// The canonical payload is `domain ‖ Σ(u32be(len) ‖ field)`, and the + /// **domain itself is not length-prefixed**. So if one domain were a prefix + /// of another, a signature made under the shorter domain could be replayed + /// as one made under the longer: an attacker chooses a first field whose + /// leading bytes supply the rest of the longer domain, and the two payloads + /// become byte-identical. Length-prefixing the fields does not prevent it, + /// because the collision happens before the first length prefix. + /// + /// The concrete damage this stops is recorded in the addressing work: a + /// hostile relay that could make an address-proof signature verify as a + /// control-frame signature would harvest a replayable control frame from + /// every device that ever authenticated to it. + #[test] + fn signing_domains_are_mutually_non_prefixing() { + let domains: [(&str, &[u8]); 4] = [ + ("offline-ctrl-v1", CTRL_SIGN_DOMAIN), + ("offline-disc-v1", DISCOVERY_SIGN_DOMAIN), + ("offline-invite-v1", INVITE_SIGN_DOMAIN), + ("offline-relay-addr-v1", RELAY_ADDR_SIGN_DOMAIN), + ]; + + for (a_name, a) in domains { + for (b_name, b) in domains { + if a_name == b_name { + continue; + } + // Both the constant's name and its *value* are reported: a + // failure is usually caused by an edited value, and a message + // naming only the constant sends the reader to the wrong place. + assert!( + !a.starts_with(b), + "signing domain {} ({:?}) is a prefix of {} ({:?}), which \ + lets a signature made under one verify under the other", + b_name, + String::from_utf8_lossy(b), + a_name, + String::from_utf8_lossy(a) + ); + } + } + } + + /// The literals are what the rest of the system, and every other + /// implementation, actually expects. A renamed constant that still passes + /// the non-prefix test above would silently invalidate every signature in + /// the field, so the spellings are pinned too. + #[test] + fn signing_domains_have_their_published_spellings() { + assert_eq!(CTRL_SIGN_DOMAIN, b"offline-ctrl-v1"); + assert_eq!(DISCOVERY_SIGN_DOMAIN, b"offline-disc-v1"); + assert_eq!(INVITE_SIGN_DOMAIN, b"offline-invite-v1"); + assert_eq!(RELAY_ADDR_SIGN_DOMAIN, b"offline-relay-addr-v1"); + } + + /// All four must be distinct, which non-prefixing already implies for + /// unequal strings but not for equal ones: two identical domains are + /// prefixes of each other, and the loop above skips same-name pairs. + #[test] + fn signing_domains_are_distinct() { + let domains: [&[u8]; 4] = [ + CTRL_SIGN_DOMAIN, + DISCOVERY_SIGN_DOMAIN, + INVITE_SIGN_DOMAIN, + RELAY_ADDR_SIGN_DOMAIN, + ]; + for (i, a) in domains.iter().enumerate() { + for b in domains.iter().skip(i + 1) { + assert_ne!(a, b, "two signing domains are the same string"); + } + } + } +} + #[cfg(test)] mod send_failure_classification_tests { use super::*; diff --git a/crates/offline-protocol/src/telemetry/record.rs b/crates/offline-protocol/src/telemetry/record.rs index b8cc8996..8e7644bb 100644 --- a/crates/offline-protocol/src/telemetry/record.rs +++ b/crates/offline-protocol/src/telemetry/record.rs @@ -151,6 +151,7 @@ mod tests { "protocol.group.user_groups", "protocol.group.error", "protocol.group.relay_sync_changed", + "protocol.username.resolved", "protocol.group.message_sent", "protocol.group.message_partial_failure", "protocol.group.delivery_report", @@ -329,6 +330,7 @@ mod tests { | Event::UserGroups { .. } | Event::GroupError { .. } | Event::GroupRelaySyncChanged { .. } + | Event::UsernameResolved { .. } | Event::GroupMessageSent { .. } | Event::GroupMessagePartialFailure { .. } | Event::GroupMessageDeliveryReport { .. } @@ -633,6 +635,11 @@ mod tests { synced: false, reason: String::new(), }, + Event::UsernameResolved { + username: String::new(), + claims: Vec::new(), + rejected: 0, + }, Event::GroupMessageSent { group_id: String::new(), message_ids: Vec::new(), diff --git a/crates/offline-protocol/src/telemetry/scrub_event.rs b/crates/offline-protocol/src/telemetry/scrub_event.rs index d5aa8d0b..1d2546fd 100644 --- a/crates/offline-protocol/src/telemetry/scrub_event.rs +++ b/crates/offline-protocol/src/telemetry/scrub_event.rs @@ -603,6 +603,23 @@ fn scrub_in_place(event: &mut Event, scrubber: &Scrubber) { } => { hash_string(group_id, scrubber); } + // A username is the most directly personal identifier this SDK emits: + // unlike an address it is chosen to be human-readable and is often the + // same handle the person uses elsewhere. Both it and every address it + // resolved to are hashed. The public keys inside the claims are hashed + // too — a raw identity key is an address in all but rendering, since + // anyone can derive one from the other. + Event::UsernameResolved { + username, + claims, + rejected: _, + } => { + hash_string(username, scrubber); + for claim in claims.iter_mut() { + hash_string(&mut claim.address, scrubber); + hash_string(&mut claim.public_key, scrubber); + } + } Event::GroupMessageSent { group_id, message_ids: _, @@ -830,6 +847,7 @@ fn event_variant_exhaustiveness_ward(e: &Event) { | Event::UserGroups { .. } | Event::GroupError { .. } | Event::GroupRelaySyncChanged { .. } + | Event::UsernameResolved { .. } | Event::GroupMessageSent { .. } | Event::GroupMessagePartialFailure { .. } | Event::GroupMessageDeliveryReport { .. } diff --git a/crates/offline-protocol/src/transport_manager.rs b/crates/offline-protocol/src/transport_manager.rs index 15084575..545d8041 100644 --- a/crates/offline-protocol/src/transport_manager.rs +++ b/crates/offline-protocol/src/transport_manager.rs @@ -9,12 +9,13 @@ use crate::events::{DorsEscalationPhase, DorsEscalationReasonCode, DorsReasonCod use crate::telemetry::routing::{RoutingDecision, RoutingPhase, RoutingReasonCode}; use crate::{Error, Result}; use chrono::Utc; -use offline_protocol_core::{Message, WireCodec}; +use offline_protocol_core::{Message, Username, WireCodec}; use offline_protocol_router::{ display_routing_score, DorsConfig, EscalationTriggerReason, TransportScore, TransportSelector, }; use offline_protocol_transport::{ - Error as TransportError, Transport, TransportMetrics, TransportStatus, TransportType, + nostr::ResolveRequest, Error as TransportError, Transport, TransportMetrics, TransportStatus, + TransportType, }; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -402,6 +403,65 @@ impl TransportManager { .unwrap_or(false) } + /// Enables or disables username discovery on the Nostr transport. + pub fn set_nostr_username_discovery_enabled(&mut self, enabled: bool) { + if let Some(nostr) = self.nostr_transport() { + nostr.set_discovery_enabled(enabled); + } + } + + /// Whether the Nostr transport is installed and username discovery is on. + /// + /// Already accounts for the cold-contact coupling: the transport reports + /// false when either switch is off. + pub fn nostr_discovery_active(&self) -> bool { + self.nostr_transport() + .map(|nostr| nostr.discovery_enabled()) + .unwrap_or(false) + } + + /// Queues this install's username claim for publication. + pub fn publish_nostr_discovery_record(&mut self, username: Username, payload: Vec) { + if let Some(nostr) = self.nostr_transport() { + nostr.publish_discovery_record(username, payload); + } + } + + /// Queues a retraction of this install's username claim. + pub fn retract_nostr_discovery_record(&mut self, username: Username, payload: Vec) { + if let Some(nostr) = self.nostr_transport() { + nostr.retract_discovery_record(username, payload); + } + } + + /// Drains the discovery tags whose publication never reached a relay. + pub fn take_failed_nostr_discovery_publications(&self) -> Vec { + self.nostr_transport() + .map(|nostr| nostr.take_failed_discovery_publications()) + .unwrap_or_default() + } + + /// Requests resolution of the devices claiming `username`. + /// + /// A missing Nostr transport reports `Disabled`, which is what it is from + /// the caller's side: no query was sent and no event will follow. + pub fn resolve_nostr_username(&self, username: Username) -> ResolveRequest { + self.nostr_transport() + .map(|nostr| nostr.resolve_username(username)) + .unwrap_or(ResolveRequest::Disabled) + } + + /// Withdraws a queued username lookup that was never issued to a relay. + /// + /// Returns whether one was removed. Called when the engine gives up on a + /// lookup, so the name does not sit in the queue making every later request + /// for it return "already queued" without ever answering. + pub fn cancel_nostr_username_resolution(&self, username: &Username) -> bool { + self.nostr_transport() + .map(|nostr| nostr.cancel_username_resolution(username)) + .unwrap_or(false) + } + fn nostr_transport(&self) -> Option<&offline_protocol_transport::nostr::NostrTransport> { self.transports.get(&TransportType::Nostr).and_then(|t| { t.as_any() diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 9cb48ea2..48bd32ed 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -1311,12 +1311,27 @@ The general rule: **if a string was doing double duty as "who I am" and "which storage is mine", the second job stays with `profile`.** Only the first job moves to the address. -### Cold contact by username is gone for now +### Cold contact by username Reaching someone by typing their username was only ever possible because -usernames were addresses. Until a signed username-discovery layer lands, first -contact is invite/QR only: exchange `{address, publicKey}` and verify it on the -spot with `deriveAddress(publicKey) === address`. +usernames were addresses. Two paths give it back, and they are not equivalent. + +**Invite/QR is the primary path, and permanent.** `createInvite()` produces a +compact blob carrying `{address, pubkey, petname?, sig?}`, and `parseInvite()` +verifies it offline: `derive_address(pubkey) == address` is checked at scan, +before `create()`. Nothing about this is transitional — the out-of-band +confirmation a scanned code represents is what makes the directory below safe. + +**A signed username directory is additive, and off by default.** Setting +`transports.nostr.usernameDiscoveryEnabled` publishes a record binding this +install's profile to its address and enables `resolveUsername()`. It is +deliberately opt-in: publishing binds a human-readable name to an address in a +public place, where the mapping *is* the payload. + +The directory is **not authoritative** — anyone may claim any name — so a +resolution returns the whole set of claimants in one `username_resolved` event +and a human must choose. Do not auto-select, and store the address rather than +the name. See [docs/spec/username-discovery.md](spec/username-discovery.md). **If you already run an account system, do not wait for that layer.** A serverless discovery record is the right design for peers with no infrastructure, and the @@ -1331,8 +1346,10 @@ four primitives already ship: 4. Server stores the address on the account row and serves it from its existing user lookup. -That gives reach-by-username back immediately, with your own uniqueness -guarantees, and it stays correct after the discovery layer ships. +That gives reach-by-username with your own uniqueness guarantees, and it stays +the better answer now that the discovery layer has shipped: yours is +authoritative and unsquattable, and the serverless directory by design is +neither. ### There is no in-place migration, and that is deliberate diff --git a/docs/nostr.md b/docs/nostr.md index e3bc5d34..f0e528d5 100644 --- a/docs/nostr.md +++ b/docs/nostr.md @@ -440,6 +440,105 @@ Sealing is therefore safe to enable or disable on one device without coordinatin Nothing in the SDK can detect this on your behalf: a relay that drops an event without an `["OK"]` is indistinguishable from a slow one, and the send fails through the ordinary pending-confirmation timeout. Before enabling Nostr in production, send real traffic through **your configured relays** and confirm delivery holds at your expected rate — a relay that works fine for one event per minute may not for a busy conversation. If it does not, the options are a relay you operate or one whose policy you know, rather than turning sealing off: unsealed frames publish the whole envelope in cleartext (see [What a relay can see](#what-a-relay-can-see)). +## Username Discovery + +Off by default. `transports.nostr.usernameDiscoveryEnabled` (RN) / +`nostr_username_discovery_enabled` (UniFFI, core `TransportConfig`) publishes a +signed record binding this install's `profile` to its address, and enables +`resolveUsername()`. + +The wire format, verification rules and threat model are specified in +[docs/spec/username-discovery.md](spec/username-discovery.md). What follows is +what an integrator needs to decide. + +### What it buys, and what it costs + +It restores reach-by-username, which the addressing migration removed: a +stranger who knows only a name can find the addresses claiming it and open a +session. Resolution is two hops, and only the first is new — a discovery record +hands back `{address, pubkey}`, and everything after that is the existing +published-key-package path. + +The cost is disclosure, and it is why the default is off rather than on like +cold contact. A key-package record says "an install with this tag exists". A +discovery record binds a human-readable name to an address, and here the +mapping *is* the payload. The record is sealed, so a relay scraping by kind +reads nothing, but anyone who guesses the name computes the tag and reads the +claim. Publishing is also unprompted traffic: the record's existence and +refresh timing are visible to every relay you publish to. + +Discovery additionally **requires cold contact**. A claim points at an address +whose key packages a resolver fetches next, so with cold contact off the name +resolves and then dead-ends. The two switches are coupled in the transport, not +merely documented. + +### The directory is not authoritative, and your UI must say so + +Anyone may publish any claim to any name. A resolution returns the **whole set** +of claimants as a single `username_resolved` event, and there is deliberately no +"best" claim, no ranking, and no per-claim event to race. + +**Do not auto-select.** Taking the first entry converts a non-authoritative +directory into an authoritative-looking one, and the user then believes the +*name* was verified when only a *key* ever was. Present the claims, have the +user confirm out of band, and store the **address**, never the name. + +Note that a username resolves to a set even for a single-device user: each +install publishes its own record under its own key, so a user with a phone and +a laptop is two claims, both genuine. An implementation that collapses them +hides the user's own second device. + +```ts +protocol.on('username_resolved', ({ username, claims, rejected }) => { + // claims: UsernameClaim[] — every claim that verified, unordered. + // rejected: how many records were seen and refused. Non-zero is normal; + // the tag is public and anyone may publish junk to it. + if (claims.length === 0) return showNotFound(username); + // Show them all. Let the user pick. Save claim.address, not username. + showClaimPicker(username, claims); +}); + +await protocol.resolveUsername('alice'); +``` + +`resolveUsername()` resolves `true` if it started the lookup and `false` if it +joined one already in flight. **Both mean the event above is coming**, so it is +safe to await it after either. Every case where no event will ever arrive +rejects instead (discovery disabled, or too many lookups in flight), so a +`false` can never leave a spinner running forever. + +### Renames and retraction + +The claim tracks `config.profile`. Changing the profile retracts the old claim +(a tombstone into the same addressable slot, plus a best-effort NIP-09 +deletion) and publishes the new one; turning the switch off retracts without +republishing. + +Retraction is best effort by nature: a relay may honour neither half. The +record that remembers *which* name to retract is persisted and sealed, because +it is the only thing that knows — losing it leaves an old name standing in a +public directory pointing at a live address. + +A retraction someone *else* published is a different matter, and it is refused. +Discovery events are checked against their own BIP-340 signature before they +are opened, with the event id recomputed rather than trusted. This is the only +record kind that needs it: a tombstone's body is a constant, so nothing inside +it is signed and its entire meaning is *who published it*, while the seal key +is public by construction. Without the check one hostile relay could serve a +forged retraction and erase an honest claimant from the resolved set even while +every other relay served their genuine record. + +### Invites are the primary path, and permanent + +`createInvite()` / `parseInvite()` produce and verify a self-certifying blob +carrying `{address, pubkey, petname?, sig?}`. Unlike a discovery record, an +invite is verifiable offline by anyone: `derive_address(pubkey) == address` is +checked at scan, before `create()`. + +Invites are not a stopgap for discovery. The out-of-band confirmation a QR code +represents is the directory's *only* trust anchor, so the invite path is what +makes discovery safe rather than the other way round. + ## Troubleshooting ### Nostr Not Connecting diff --git a/docs/spec/README.md b/docs/spec/README.md index 9a0a305f..006e1e0d 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -19,6 +19,7 @@ document says which reading is normative for the wire. | [Encryption envelopes](encryption-envelopes.md) | The `__MLS_ENC__` envelope forms, the media chunk envelope, and the sealed rich payload | | [Group protocol](group-protocol.md) | Group frames, membership commits, leaf identity binding, relay broadcast and the delivery report | | [Capability negotiation](capability-negotiation.md) | What peers advertise, what each capability gates, and what happens on absence | +| [Username discovery and invites](username-discovery.md) | The self-certifying invite payload, and the non-authoritative username directory | ## Conformance language diff --git a/docs/spec/username-discovery.md b/docs/spec/username-discovery.md new file mode 100644 index 00000000..36cd5d60 --- /dev/null +++ b/docs/spec/username-discovery.md @@ -0,0 +1,489 @@ +# Username discovery and the invite payload + +This chapter specifies two ways to learn a peer's address without having spoken +to them: a self-certifying **invite payload**, and a non-authoritative +**username directory** published over Nostr. + +They differ in what they promise, and the difference is the whole design: + +- An **invite** proves the address it carries belongs to the key it carries. + It is verifiable offline, by anyone, with no network. +- A **discovery record** proves nothing about the *name*. It says only that + some key asserts a name. The name is arbitrated by a human. + +## The invariant that outranks everything else in this chapter + +**A username is not an identity, and a resolver MUST NOT treat it as one.** + +Anyone may publish any claim to any name. A username therefore resolves to a +*set* of claims, never to an answer, and every conforming implementation MUST +surface the whole set. An implementation that silently selects one claim has +converted a non-authoritative directory into an authoritative-looking one, +which is worse than not implementing this chapter at all: the user believes the +*name* was verified, when only a *key* ever was. + +The identity that survives is the address. An implementation SHOULD store the +address a user confirms, not the name they searched for. A name can be +re-claimed by someone else tomorrow; an address is a function of a key. + +## Normalization + +A username is normalized before it is hashed, signed, or compared: + +1. lowercase, using the Unicode **full** lowercase mapping (Default Case + Conversion, `toLowercase`, the language-insensitive form); +2. then normalize to NFC. + +**Full, not simple, and the difference is a wire incompatibility rather than a +detail.** The two mappings disagree wherever one character lowercases to +several: U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE becomes `i` + U+0307 under +the full mapping and a bare `i` under the simple one. Two implementations that +choose differently derive different tags for the same name and silently never +find each other, which is the failure this whole section exists to prevent. The +mapping must also be language-insensitive: the Turkish tailoring maps `I` to +`ı`, and a directory whose tags depend on the publisher's locale is not a +directory. + +The order is normative. Unicode lowercasing can emit a decomposed sequence, so +normalizing first would leave a form that is not NFC, and the operation would +not be idempotent. A non-idempotent normalizer derives one tag when publishing +and a different one when resolving, and the failure is silent: an empty result +for a name that exists. + +A conforming implementation MUST refuse a username that, after normalization: + +- is empty, or consists only of whitespace, +- exceeds 64 bytes, +- contains a Unicode control (`Cc`) or format (`Cf`) character, or +- has the shape of an address (exactly 44 characters, `off1` prefix, all + lowercase ASCII alphanumerics). + +`Cf` is refused alongside `Cc` because it is the category that actually carries +the rendering attacks: the bidi overrides, the zero-width joiners and the +byte-order mark are all `Cf`, and a name containing one displays as something +other than the bytes that were signed. A screen written against a +"control character" predicate typically tests `Cc` only and lets every one of +them through. + +The address-shape refusal is a shape test, not a parse: an address-looking +string with a corrupted checksum is refused too, because it reads exactly as +confusingly in a user interface. + +Records arriving on the wire are **parsed, not repaired**. A record whose +username field is not already in normalized form MUST be rejected. Repairing it +would let a record verify against a tag it was never published at. + +> Confusable and homograph handling is deliberately out of scope for v1. It is +> the least-defended surface of this chapter. A display layer that warns about +> mixed-script names is a reasonable addition and needs no format change. + +## Signing domains + +Four domains are live across this protocol, and they MUST be mutually +non-prefixing: + +| Domain | Signs | +|--------|-------| +| `offline-ctrl-v1` | Control-plane frames | +| `offline-relay-addr-v1` | The relay address-declaration proof | +| `offline-disc-v1` | Discovery records | +| `offline-invite-v1` | Invite payloads | + +Every signature is taken over `domain ‖ Σ(u32be(len) ‖ field_bytes)`. The +**domain itself is not length-prefixed**, so if one domain were a prefix of +another, a signature made under the shorter domain could be replayed as one +made under the longer: an attacker picks a first field whose leading bytes +supply the rest of the longer domain, and the two payloads become identical. +Length-prefixing the fields does not prevent this, because the collision occurs +before the first length prefix. + +## The invite payload + +### Structure + +``` +InviteV1 { + v: u8 // 1 + address: string // bech32m off1… + pubkey: [u8; 32] // Ed25519; derive(pubkey) MUST == address + petname: string? // suggested display name, <= 64 bytes + sig: [u8; 64]? // optional; Ed25519 over the canonical payload +} +``` + +### Encoding + +A versioned compact binary struct, rendered **base64url without padding**: + +``` +[0] version = 1 +[1] flags: bit0 = petname present, bit1 = signature present +[2..34] pubkey (32 bytes) +[34] address length (u8) +[35..] address bytes (ASCII) + petname length (u8) + petname bytes -- if flags bit0 + signature (64 bytes) -- if flags bit1 +``` + +Bech32m is deliberately **not** used, despite the address being bech32m: +BIP-173 limits the checksum's error-detection guarantee to about 90 characters, +and this blob exceeds that. Using it here would be out of spec and +misleadingly reassuring. + +A decoder MUST reject an unknown version, an unknown flag bit, a truncated +blob, and trailing bytes. Unknown flag bits select trailing sections, so +ignoring one desynchronizes the parse and surfaces as a corrupt petname rather +than as a version error. + +An encoder MUST NOT emit a set petname flag with a zero-length petname: an +absent petname and an empty one are the same state and have one encoding. + +### Canonical signed payload + +``` +"offline-invite-v1" ‖ u32be(len)‖bytes over [v, address, pubkey, petname] +``` + +in that fixed order, with an absent petname encoded as a zero-length field. + +### Verification + +A verifier MUST, in order: + +1. decode the blob and confirm it is structurally complete; +2. confirm `v == 1` and that no unknown flag bits are set; +3. parse the address in canonical form; +4. confirm the petname, if present, contains no Unicode `Cc` or `Cf`; +5. confirm `derive_address(pubkey) == address`; +6. if a signature is present, confirm it verifies under `pubkey`. + +Any failure means **refuse**, not warn. + +Step 4 is the same screen [normalization](#normalization) applies to a +username, and it matters more here. A petname is what an application renders in +the confirmation dialog after a scan, so a bidi override or a zero-width joiner +makes the name display as something other than the bytes that were signed. When +the invite *is* signed, that deceptive rendering arrives carrying a valid +signature, so an application trusting the signature flag would be trusting the +wrong half. An encoder MUST NOT mint such a petname either. + +### What a signature does and does not defend + +It does **not** defend against substitution. An attacker who hands you their +own invite, correctly signed by their own key, is indistinguishable from a +legitimate stranger. No payload format can fix that; only out-of-band context +can. + +It defends **relabeling**. Without a signature, anyone can mint an invite +pairing a victim's real, public `{address, pubkey}` with an attacker-chosen +petname, so an invite forwarded through a third party can save Alice's key +under the name "Bob". With one, the petname is bound to the key by its owner. + +Sign when the invite may travel without its issuer. A QR code shown phone to +phone is already authenticated by the physical channel, and an application that +prompts the user to confirm or edit the name has made the user the authority +over it, which is what a petname properly is. + +### What an invite deliberately omits + +**No key package.** An MLS key package's init key is consumed by the first peer +who uses it, and a QR code is static, so pairing them guarantees a collision as +soon as two people scan the same code. Session establishment proceeds over +whatever transport connects, by the ordinary exchange. + +**No expiry.** A printed QR code that stops working is a bug. Applications +needing revocable invites have the server-mediated group-invite mechanism. + +### Container + +This specification defines the blob. Applications own the URI scheme. The +recommended form is `://connect?c=`: one opaque parameter, so +it composes with any existing scheme and route. + +## The username directory + +### Two hops + +``` +username --hop 1--> {address, pubkey} --hop 2--> key package --> MLS session + discovery record published KP record + at tag_disc(username) at tag_kp(address) +``` + +Hop 2 is the published key-package mechanism, unchanged. Only hop 1 is defined +here. Everything downstream of the address is already authenticated by key +derivation, which is why a discovery record cannot lie about a key and can lie +only about a name. + +### Cardinality: a set forms at the tag + +**One record per device.** Each install publishes its own record, signed by its +own identity key, authored by its own Nostr key, naming its own address. All of +a username's devices publish to the same tag, and because addressable +replacement is keyed on `(kind, pubkey, d)` and each device has a different +Nostr key, they coexist as separate events. A resolver queries once and +receives the whole set. + +This is not a limitation to design around. No device knows the addresses of its +siblings, so a record shaped as `{username, [devices]}` cannot be produced at +all. Aggregating at the tag reaches the same result with zero coordination. + +A username is therefore **1:N always**, even for a single-device user, who is a +set of one. + +> Consequence, not solved here: a sender addresses one device. An application +> whose user has three devices must decide whether to fan out or pick one. That +> is an application decision with real cost. The discovery layer's job is to +> stop pretending the mapping is 1:1. + +### Tag derivation + +``` +tag_disc(username) = x-only-secp256k1-pubkey(SHA-256("offline-disc-v1:" ‖ username)) +``` + +The scalar-to-pubkey step mirrors the address routing tag, so the published +value is shaped like any other `#p` pubkey. + +The domain separator is what stops the username and address namespaces sharing +a preimage space, so no future third derivation can be aimed across them. + +### Record structure + +``` +DiscoveryRecordV1 { + v: u8 // 1 + username: string // normalized + address: string // bech32m off1… + pubkey: [u8; 32] // Ed25519; derive(pubkey) MUST == address + nostr_author: [u8; 32] // x-only key this record is valid when published under + issued_at_ms: i64 // signing time + sig: [u8; 64] // Ed25519 by pubkey over the canonical payload +} +``` + +Canonical signed payload: + +``` +"offline-disc-v1" ‖ u32be(len)‖bytes over + [v, username, address, pubkey, nostr_author, issued_at_ms] +``` + +in that fixed order. `issued_at_ms` is encoded as its 8-byte big-endian two's +complement form, not as a decimal string, so two implementations cannot +disagree about leading zeroes or a sign. + +### Publication + +- Event kind **30777**, addressable. +- `d` tag = the discovery tag. Deterministic, **not** a random slot id: a + directory entry is a statement that should be *replaced*, and a deterministic + `d` is what makes NIP-01 addressable replacement do that work. It is also + what makes retraction possible at all. +- `p` tag = the discovery tag. +- Content is NIP-44-sealed to `discovery_seal_keypair_for_username(username)`: + HKDF-SHA256, salt none, IKM the normalized username bytes, info + `"offline-protocol/nostr/v1/discovery-seal-key/" ‖ counter`. +- `created_at` is the true current time and MUST NOT be jittered into the past. + Relays keep the newest event per `(kind, pubkey, d)`, so a backdated + republication is silently dropped and would strand a stale claim. + +> Kind 30777 is **unregistered**. Nothing in the NIPs kind registry is assigned +> anywhere in the 30700 to 30800 range as of 2026-08-17. An implementation +> publishing this format should be aware it may one day collide. + +### The seal key is public by construction + +Anyone who knows the username can derive the seal key and open the record. That +is the design. The record contains only what is public to someone who already +knows the name. + +**This key MUST NOT be load-bearing.** It must never back encryption of +anything secret, never back relay authentication, and never inform any +authentication decision. Every authenticity property of a record comes from its +Ed25519 signature and from `derive_address(pubkey) == address`. + +What sealing buys, given that anyone entitled to fetch can unseal, is +resistance to *bulk collection*. Publishing in the clear would let a single +`{"kinds":[30777]}` request return a directory of every username on the relay +paired with its address. Sealing costs nothing in reach: fetching requires the +tag, the tag requires the username, and the username reconstructs the key. + +### Verification + +A resolver MUST first confirm that the **carrying event** is authentic: that +its `id` is the NIP-01 hash of its own fields, and that its `sig` is a valid +BIP-340 signature over that id under its `pubkey`. This applies to every event +returned by a discovery query, claim and tombstone alike. See +[Event authenticity](#event-authenticity-is-required-here-and-nowhere-else) for +why this record kind needs it when no other does. + +It MUST then check the record body, in order: + +1. `v == 1`, and every fixed-length field is the right size; +2. the username matches the queried name exactly; +3. `derive_address(pubkey) == address`; +4. the Ed25519 signature verifies under `pubkey`; +5. `nostr_author` equals the publishing event's author key. + +Step 2 is what catches a genuine record for one name copied onto another's tag. + +Step 5 is what stops a **re-authored copy**. Because the seal key is publicly +derivable, a third party can unseal a record, re-seal the untouched and +genuinely signed payload under their own Nostr key, and republish it. For a key +package the cost is a dead session. For a directory entry it defeats +retraction: addressable replacement is per-author, so the owner's tombstone +replaces only the owner's own event and never a copy standing under someone +else's key. That would keep a rotated-away or compromised address in the +directory indefinitely. + +Every verification failure is **ordinary**, not exceptional. The tag is public, +anyone may publish to it, and a query returns whatever the relay holds. A +resolver drops the record and continues. + +### Event authenticity is required here, and nowhere else + +Everywhere else in this protocol the carrying Nostr event's signature is worth +nothing: it proves only that the publisher holds the key the event names, and +every record carries its own inner signature, which is what is checked. + +A **tombstone breaks that symmetry**. Its body is a constant, so there is +nothing inside it to sign, and its entire meaning is *who published it*. The +seal key is public by construction, so anyone who knows the username can derive +the conversation key for **any** author and seal a tombstone attributed to +them. A resolver that honoured it on decryption alone could be fed a forged +retraction by a single hostile relay, suppressing an honest claimant from the +resolved set even while every other relay served that claimant's genuine +record. Retraction is deliberately sticky for the life of a resolution (see +[Resolution](#resolution)), which is what would make such a forgery total +rather than racy. + +That inverts what querying many relays is for: a claim needs *one* honest relay +to survive, and without this check a retraction would need only *one* hostile +relay to succeed. A squatter operating a popular relay could then make their +own claim the only one a user ever sees, which is precisely the +authoritative-looking directory this chapter exists to prevent. + +A resolver MUST **recompute** the event id rather than trust the `id` field. +Resolvers take each event id once per query, so an event claiming a genuine +record's id could otherwise consume its slot and have the real record dropped +as a duplicate. + +### Staleness is advisory + +`issued_at_ms` MUST NOT be used to reject a record. A record is not a liveness +signal; the key-package fetch that follows it is. A stale record whose key +packages are gone fails at that fetch, which is the honest place to fail. +Rejecting on age would instead make a peer who has been offline for a month +unreachable *by name* while their key packages sit valid on a relay. + +Surface the age. Let the application sort. Let hop 2 arbitrate. + +### Retraction + +A retraction republishes the same `(kind, pubkey, d)` with a tombstone body and +a fresh `created_at`: + +```json +{"v": 1, "retracted": true} +``` + +and SHOULD additionally emit a NIP-09 deletion request naming the record's +`kind:pubkey:d` coordinate. + +Retraction is **best effort**: a relay may honour neither. The tombstone is the +half that works through the replacement rule rather than through a relay's +cooperation, so an implementation MUST publish the tombstone and MAY treat the +deletion as optional. + +A resolver MUST treat a tombstone, or an undecodable body, as "no claim from +this author". + +### Resolution + +Query: + +```json +{"#p": [""], "kinds": [30777], "limit": 16} +``` + +Carrying no `since`: a claim is republished only when it changes, so a settled +claimant's record may be arbitrarily old while remaining entirely current. + +A resolver: + +- accumulates verified claims keyed by the publishing Nostr key, since that is + what a device is here; +- keeps the newest `issued_at_ms` per author, because a repeat from one author + is that device republishing; +- MUST return the whole set, in no meaningful order; +- SHOULD report how many records were seen and refused, so "nobody claims this + name" can be distinguished from "everything claiming it was junk". + +An implementation MUST bound both the number of concurrent resolutions and the +number of claims accumulated per resolution. The tag is public and a squatter +can flood it. + +### Gating + +Publication and resolution are gated together by one switch, which SHOULD +default to **off**. Publishing binds a human-readable name to an address in a +public place, which is materially more disclosure than a key-package record's +"an install with this tag exists": here the mapping *is* the payload. + +Publication additionally REQUIRES published key packages. A discovery record +pointing at an address whose key packages are absent resolves and then dead-ends +one hop later, so the two are hard-coupled rather than merely documented. + +## Threat model and residuals + +**Squatting is possible by construction and is the design, not a defect.** +First-publisher-wins does not exist on a Nostr relay. Every claim is a claim. +The resolver surfaces the set and a human confirms out of band. This is NIP-05's +model verbatim: identify, never verify; follow keys, not names. + +This is also why the invite path is permanent rather than transitional: the +out-of-band confirmation is this layer's only trust anchor, so removing the +invite path would remove the directory's security model. + +Residuals, stated plainly: + +- **Enumeration by guessing.** Anyone who guesses a username can compute its + tag and learn whether a claim exists, plus its refresh timing. The mitigation + is that the preimage is a name the guesser already knew, and the payload is + sealed so a scrape by kind returns nothing. +- **Retraction is best effort.** A relay may ignore both halves. The + `nostr_author` binding stops a third party keeping a retracted claim alive, + and event-signature verification stops one forging a retraction for someone + else; what remains is the owner's own stale copies on relays they can no + longer reach. Hop 2 arbitrates. +- **Liveness signal.** Publishing is unprompted traffic. Record existence and + refresh timing are visible to every relay. Default-off is the answer. +- **The publishing key is a linkable identifier.** A device publishes under its + persistent Nostr key, which both the `nostr_author` binding and addressable + replacement require. That key sits in cleartext on a public tag, so anyone + who knows *one* username can read it without unsealing anything and then + recognise that device's other public activity — including a second username + claimed by the same device, since one device may hold one record per `d`. Two + names a person considers unrelated are therefore publicly linkable as one + device. Unsealing does not help, because the author key is event metadata, + not payload. An application whose users need unlinkable names must not claim + them from one install. +- **No revocation of a compromised device's claim by anyone but that device.** + A compromised key can re-sign its own claim indefinitely. This is inherent to + a non-authoritative directory. + +## The server-backed alternative + +An application that already operates unique usernames, a search index and +authentication should use *that* as its directory. It is authoritative and +unsquattable, which this chapter's directory by design is not. + +Binding needs no new protocol surface. The client signs a server-issued nonce +under a **domain-separated** payload, and the server verifies the signature and +**re-derives** `derive_address(pubkey) == address` rather than trusting the +presented address. The domain MUST NOT collide with `offline-ctrl-v1`, or a +hostile server harvests a replayable control-frame signature from every client +that ever authenticated. + +Cardinality must be 1:N there too.