diff --git a/bindings/csharp/local_api_methods.txt b/bindings/csharp/local_api_methods.txt index f82b2b21e..a49078e0e 100644 --- a/bindings/csharp/local_api_methods.txt +++ b/bindings/csharp/local_api_methods.txt @@ -1,5 +1,6 @@ accept_connection_request app_membership_status +ble_app_tag ble_clear_peer_mtu ble_fragment_fallback_count ble_fragment_received @@ -214,6 +215,7 @@ verify_identity_assertion verify_signature verify_signature_bytes wifi_direct_get_next_message +wifi_direct_identifiers wifi_direct_message_received wifi_direct_peer_connected wifi_direct_peer_disconnected diff --git a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/Enums.g.cs b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/Enums.g.cs index 68a28fff5..bb1fb4bdf 100644 --- a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/Enums.g.cs +++ b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/Enums.g.cs @@ -1,6 +1,6 @@ // // Generated by tools/gen_csharp_local_api.py from offline_protocol.udl -// UDL_SHA256 = e48caac6d20ce4f12b1da7266242a64ce6841e712bdcb9116970aa417b51df1a +// UDL_SHA256 = 566d18829172017f77b41f7141c0a886177cf118df8bb898a0d4148b89c5854d // Do not edit. #nullable enable diff --git a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/LocalApiClients.g.cs b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/LocalApiClients.g.cs index 69230df5e..46cea526f 100644 --- a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/LocalApiClients.g.cs +++ b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/LocalApiClients.g.cs @@ -1,6 +1,6 @@ // // Generated by tools/gen_csharp_local_api.py from offline_protocol.udl -// UDL_SHA256 = e48caac6d20ce4f12b1da7266242a64ce6841e712bdcb9116970aa417b51df1a +// UDL_SHA256 = 566d18829172017f77b41f7141c0a886177cf118df8bb898a0d4148b89c5854d // Do not edit. #nullable enable diff --git a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolConfig.g.cs b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolConfig.g.cs index e70b6c2b3..b8cb88cda 100644 --- a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolConfig.g.cs +++ b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolConfig.g.cs @@ -1,6 +1,6 @@ // // Generated by tools/gen_csharp_local_api.py from offline_protocol.udl -// UDL_SHA256 = e48caac6d20ce4f12b1da7266242a64ce6841e712bdcb9116970aa417b51df1a +// UDL_SHA256 = 566d18829172017f77b41f7141c0a886177cf118df8bb898a0d4148b89c5854d // Do not edit. #nullable enable diff --git a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.LocalApi.part01.g.cs b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.LocalApi.part01.g.cs index c3a2d5c91..4e0c04be9 100644 --- a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.LocalApi.part01.g.cs +++ b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.LocalApi.part01.g.cs @@ -1,6 +1,6 @@ // // Generated by tools/gen_csharp_local_api.py from offline_protocol.udl -// UDL_SHA256 = e48caac6d20ce4f12b1da7266242a64ce6841e712bdcb9116970aa417b51df1a +// UDL_SHA256 = 566d18829172017f77b41f7141c0a886177cf118df8bb898a0d4148b89c5854d // Do not edit. #nullable enable @@ -18,6 +18,9 @@ public Task AcceptConnectionRequestAsync(string? parametersJson = null, public Task AppMembershipStatusAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => CallWithResultAsync("app_membership_status", parametersJson, cancellationToken); + public Task BleAppTagAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => + CallWithResultAsync("ble_app_tag", parametersJson, cancellationToken); + public Task BleClearPeerMtuAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => CallAsync("ble_clear_peer_mtu", parametersJson, cancellationToken); @@ -248,7 +251,4 @@ public Task LeaveGroupAsync(string? parametersJson = null, CancellationToken can public Task ListGroupsAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => CallWithResultAsync("list_groups", parametersJson, cancellationToken); - - public Task MlsClearPendingWelcomeAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => - CallAsync("mls_clear_pending_welcome", parametersJson, cancellationToken); } diff --git a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.LocalApi.part02.g.cs b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.LocalApi.part02.g.cs index a17515ff7..df3ab3178 100644 --- a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.LocalApi.part02.g.cs +++ b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.LocalApi.part02.g.cs @@ -1,6 +1,6 @@ // // Generated by tools/gen_csharp_local_api.py from offline_protocol.udl -// UDL_SHA256 = e48caac6d20ce4f12b1da7266242a64ce6841e712bdcb9116970aa417b51df1a +// UDL_SHA256 = 566d18829172017f77b41f7141c0a886177cf118df8bb898a0d4148b89c5854d // Do not edit. #nullable enable @@ -12,6 +12,9 @@ namespace OfflineProtocol.Sdk; public sealed partial class ProtocolEngine { + public Task MlsClearPendingWelcomeAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => + CallAsync("mls_clear_pending_welcome", parametersJson, cancellationToken); + public Task MlsCreateSessionAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => CallWithResultAsync("mls_create_session", parametersJson, cancellationToken); @@ -248,7 +251,4 @@ public Task UpdateAckConfigAsync(string? parametersJson = null, CancellationToke public Task UpdateDedupConfigAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => CallAsync("update_dedup_config", parametersJson, cancellationToken); - - public Task UpdateDorsConfigAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => - CallAsync("update_dors_config", parametersJson, cancellationToken); } diff --git a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.LocalApi.part03.g.cs b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.LocalApi.part03.g.cs index 0b362cafb..15955970c 100644 --- a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.LocalApi.part03.g.cs +++ b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.LocalApi.part03.g.cs @@ -1,6 +1,6 @@ // // Generated by tools/gen_csharp_local_api.py from offline_protocol.udl -// UDL_SHA256 = e48caac6d20ce4f12b1da7266242a64ce6841e712bdcb9116970aa417b51df1a +// UDL_SHA256 = 566d18829172017f77b41f7141c0a886177cf118df8bb898a0d4148b89c5854d // Do not edit. #nullable enable @@ -12,6 +12,9 @@ namespace OfflineProtocol.Sdk; public sealed partial class ProtocolEngine { + public Task UpdateDorsConfigAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => + CallAsync("update_dors_config", parametersJson, cancellationToken); + public Task UpdateRelayConfigAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => CallAsync("update_relay_config", parametersJson, cancellationToken); @@ -30,6 +33,9 @@ public Task VerifySignatureBytesAsync(string? parametersJson = null, Can public Task WifiDirectGetNextMessageAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => CallWithResultAsync("wifi_direct_get_next_message", parametersJson, cancellationToken); + public Task WifiDirectIdentifiersAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => + CallWithResultAsync("wifi_direct_identifiers", parametersJson, cancellationToken); + public Task WifiDirectMessageReceivedAsync(string? parametersJson = null, CancellationToken cancellationToken = default) => CallAsync("wifi_direct_message_received", parametersJson, cancellationToken); diff --git a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.Static.g.cs b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.Static.g.cs index 10df7a777..e41396bf1 100644 --- a/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.Static.g.cs +++ b/bindings/csharp/src/OfflineProtocol.Sdk/Generated/ProtocolEngine.Static.g.cs @@ -1,6 +1,6 @@ // // Generated by tools/gen_csharp_local_api.py from offline_protocol.udl -// UDL_SHA256 = e48caac6d20ce4f12b1da7266242a64ce6841e712bdcb9116970aa417b51df1a +// UDL_SHA256 = 566d18829172017f77b41f7141c0a886177cf118df8bb898a0d4148b89c5854d // Do not edit. #nullable enable diff --git a/bindings/java/src/main/java/com/offlineprotocol/sdk/FfiConverterOptionalTypeWifiDirectIdentifiers.java b/bindings/java/src/main/java/com/offlineprotocol/sdk/FfiConverterOptionalTypeWifiDirectIdentifiers.java new file mode 100644 index 000000000..0cd936176 --- /dev/null +++ b/bindings/java/src/main/java/com/offlineprotocol/sdk/FfiConverterOptionalTypeWifiDirectIdentifiers.java @@ -0,0 +1,32 @@ +package com.offlineprotocol.sdk; + +public enum FfiConverterOptionalTypeWifiDirectIdentifiers implements FfiConverterRustBuffer { + INSTANCE; + + @Override + public WifiDirectIdentifiers read(java.nio.ByteBuffer buf) { + if (buf.get() == (byte)0) { + return null; + } + return FfiConverterTypeWifiDirectIdentifiers.INSTANCE.read(buf); + } + + @Override + public long allocationSize(WifiDirectIdentifiers value) { + if (value == null) { + return 1L; + } else { + return 1L + FfiConverterTypeWifiDirectIdentifiers.INSTANCE.allocationSize(value); + } + } + + @Override + public void write(WifiDirectIdentifiers value, java.nio.ByteBuffer buf) { + if (value == null) { + buf.put((byte)0); + } else { + buf.put((byte)1); + FfiConverterTypeWifiDirectIdentifiers.INSTANCE.write(value, buf); + } + } +} diff --git a/bindings/java/src/main/java/com/offlineprotocol/sdk/FfiConverterTypeWifiDirectIdentifiers.java b/bindings/java/src/main/java/com/offlineprotocol/sdk/FfiConverterTypeWifiDirectIdentifiers.java new file mode 100644 index 000000000..06ab614af --- /dev/null +++ b/bindings/java/src/main/java/com/offlineprotocol/sdk/FfiConverterTypeWifiDirectIdentifiers.java @@ -0,0 +1,31 @@ +package com.offlineprotocol.sdk; + + +public enum FfiConverterTypeWifiDirectIdentifiers implements FfiConverterRustBuffer { + INSTANCE; + + @Override + public WifiDirectIdentifiers read(java.nio.ByteBuffer buf) { + return new WifiDirectIdentifiers( + FfiConverterString.INSTANCE.read(buf), + FfiConverterString.INSTANCE.read(buf), + FfiConverterString.INSTANCE.read(buf) + ); + } + + @Override + public long allocationSize(WifiDirectIdentifiers value) { + return ( + FfiConverterString.INSTANCE.allocationSize(value.recordApp()) + + FfiConverterString.INSTANCE.allocationSize(value.networkName()) + + FfiConverterString.INSTANCE.allocationSize(value.passphrase()) + ); + } + + @Override + public void write(WifiDirectIdentifiers value, java.nio.ByteBuffer buf) { + FfiConverterString.INSTANCE.write(value.recordApp(), buf); + FfiConverterString.INSTANCE.write(value.networkName(), buf); + FfiConverterString.INSTANCE.write(value.passphrase(), buf); + } +} diff --git a/bindings/java/src/main/java/com/offlineprotocol/sdk/NamespaceLibrary.java b/bindings/java/src/main/java/com/offlineprotocol/sdk/NamespaceLibrary.java index 70bd5eec1..8a7d99a9d 100644 --- a/bindings/java/src/main/java/com/offlineprotocol/sdk/NamespaceLibrary.java +++ b/bindings/java/src/main/java/com/offlineprotocol/sdk/NamespaceLibrary.java @@ -190,6 +190,9 @@ static void uniffiCheckApiChecksums() { if (UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_app_membership_status() != ((short) 23851)) { throw new RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project"); } + if (UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag() != ((short) 50666)) { + throw new RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project"); + } if (UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_clear_peer_mtu() != ((short) 19884)) { throw new RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project"); } @@ -733,6 +736,9 @@ static void uniffiCheckApiChecksums() { if (UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_get_next_message() != ((short) 50868)) { throw new RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project"); } + if (UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers() != ((short) 20485)) { + throw new RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project"); + } if (UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_message_received() != ((short) 59107)) { throw new RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project"); } diff --git a/bindings/java/src/main/java/com/offlineprotocol/sdk/OfflineProtocol.java b/bindings/java/src/main/java/com/offlineprotocol/sdk/OfflineProtocol.java index 1fe24c861..9c3991eb1 100644 --- a/bindings/java/src/main/java/com/offlineprotocol/sdk/OfflineProtocol.java +++ b/bindings/java/src/main/java/com/offlineprotocol/sdk/OfflineProtocol.java @@ -177,6 +177,37 @@ public AppMembershipStatus appMembershipStatus() { + @Override + public java.util.List bleAppTag(java.lang.String deviceId) { + try { + return FfiConverterOptionalSequenceByte.INSTANCE.lift( + callWithHandle(_uniffiHandle -> { + try { + + return + UniffiHelpers.uniffiRustCall( (_allocator, _status) -> { + return UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag(_allocator, _uniffiHandle, + FfiConverterString.INSTANCE.lower(deviceId), _status); + }); + } catch (java.lang.RuntimeException _uniffi_ex) { + throw _uniffi_ex; + } catch (java.lang.Exception _uniffi_ex) { + throw new java.lang.RuntimeException(_uniffi_ex); + } + }) + ); + } catch (java.lang.RuntimeException _uniffi_ex) { + + + if (InternalException.class.isInstance(_uniffi_ex.getCause())) { + throw (InternalException)_uniffi_ex.getCause(); + } + throw _uniffi_ex; + } + } + + + @Override public void bleClearPeerMtu(java.lang.String peerId) throws ProtocolException { try { @@ -6007,6 +6038,37 @@ public WifiDirectMessage wifiDirectGetNextMessage() { + @Override + public WifiDirectIdentifiers wifiDirectIdentifiers() { + try { + return FfiConverterOptionalTypeWifiDirectIdentifiers.INSTANCE.lift( + callWithHandle(_uniffiHandle -> { + try { + + return + UniffiHelpers.uniffiRustCall( (_allocator, _status) -> { + return UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers(_allocator, _uniffiHandle, + _status); + }); + } catch (java.lang.RuntimeException _uniffi_ex) { + throw _uniffi_ex; + } catch (java.lang.Exception _uniffi_ex) { + throw new java.lang.RuntimeException(_uniffi_ex); + } + }) + ); + } catch (java.lang.RuntimeException _uniffi_ex) { + + + if (InternalException.class.isInstance(_uniffi_ex.getCause())) { + throw (InternalException)_uniffi_ex.getCause(); + } + throw _uniffi_ex; + } + } + + + @Override public void wifiDirectMessageReceived(java.lang.String senderId, java.util.List data) throws ProtocolException { try { diff --git a/bindings/java/src/main/java/com/offlineprotocol/sdk/OfflineProtocolInterface.java b/bindings/java/src/main/java/com/offlineprotocol/sdk/OfflineProtocolInterface.java index def1c0083..83c467917 100644 --- a/bindings/java/src/main/java/com/offlineprotocol/sdk/OfflineProtocolInterface.java +++ b/bindings/java/src/main/java/com/offlineprotocol/sdk/OfflineProtocolInterface.java @@ -3,6 +3,7 @@ public interface OfflineProtocolInterface { public java.lang.String acceptConnectionRequest(java.lang.String recipient, java.lang.String accepterName, java.util.List keyPackage) throws ProtocolException; public AppMembershipStatus appMembershipStatus(); + public java.util.List bleAppTag(java.lang.String deviceId); public void bleClearPeerMtu(java.lang.String peerId) throws ProtocolException; public long bleFragmentFallbackCount(); public void bleFragmentReceived(java.lang.String senderId, java.util.List fragment) throws ProtocolException; @@ -184,6 +185,7 @@ public interface OfflineProtocolInterface { public boolean verifySignature(java.util.List publicKey, java.util.List data, java.util.List signature) throws ProtocolException; public boolean verifySignatureBytes(byte[] publicKey, byte[] data, byte[] signature) throws ProtocolException; public WifiDirectMessage wifiDirectGetNextMessage(); + public WifiDirectIdentifiers wifiDirectIdentifiers(); public void wifiDirectMessageReceived(java.lang.String senderId, java.util.List data) throws ProtocolException; public void wifiDirectPeerConnected(java.lang.String peerId) throws ProtocolException; public void wifiDirectPeerDisconnected(java.lang.String peerId) throws ProtocolException; diff --git a/bindings/java/src/main/java/com/offlineprotocol/sdk/UniffiLib.java b/bindings/java/src/main/java/com/offlineprotocol/sdk/UniffiLib.java index 584cf5746..e8e686bf2 100644 --- a/bindings/java/src/main/java/com/offlineprotocol/sdk/UniffiLib.java +++ b/bindings/java/src/main/java/com/offlineprotocol/sdk/UniffiLib.java @@ -542,6 +542,15 @@ static java.lang.foreign.MemorySegment uniffi_offline_protocol_uniffi_fn_method_ } catch (Throwable _ex) { throw new AssertionError("invokeExact failed", _ex); } } + // uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag + private static final java.lang.invoke.MethodHandle MH_uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag = findDowncallHandle("uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag", java.lang.foreign.FunctionDescriptor.of(RustBuffer.LAYOUT, java.lang.foreign.ValueLayout.JAVA_LONG, RustBuffer.LAYOUT, java.lang.foreign.ValueLayout.ADDRESS)); + + static java.lang.foreign.MemorySegment uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag(java.lang.foreign.SegmentAllocator _allocator, long ptr, java.lang.foreign.MemorySegment deviceId, java.lang.foreign.MemorySegment _uniffiOutErr) { + try { + return (java.lang.foreign.MemorySegment) MH_uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag.invokeExact(_allocator, ptr, deviceId, _uniffiOutErr); + } catch (Throwable _ex) { throw new AssertionError("invokeExact failed", _ex); } + } + // uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_clear_peer_mtu private static final java.lang.invoke.MethodHandle MH_uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_clear_peer_mtu = findDowncallHandle("uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_clear_peer_mtu", java.lang.foreign.FunctionDescriptor.ofVoid(java.lang.foreign.ValueLayout.JAVA_LONG, RustBuffer.LAYOUT, java.lang.foreign.ValueLayout.ADDRESS)); @@ -2171,6 +2180,15 @@ static java.lang.foreign.MemorySegment uniffi_offline_protocol_uniffi_fn_method_ } catch (Throwable _ex) { throw new AssertionError("invokeExact failed", _ex); } } + // uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers + private static final java.lang.invoke.MethodHandle MH_uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers = findDowncallHandle("uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers", java.lang.foreign.FunctionDescriptor.of(RustBuffer.LAYOUT, java.lang.foreign.ValueLayout.JAVA_LONG, java.lang.foreign.ValueLayout.ADDRESS)); + + static java.lang.foreign.MemorySegment uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers(java.lang.foreign.SegmentAllocator _allocator, long ptr, java.lang.foreign.MemorySegment _uniffiOutErr) { + try { + return (java.lang.foreign.MemorySegment) MH_uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers.invokeExact(_allocator, ptr, _uniffiOutErr); + } catch (Throwable _ex) { throw new AssertionError("invokeExact failed", _ex); } + } + // uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_message_received private static final java.lang.invoke.MethodHandle MH_uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_message_received = findDowncallHandle("uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_message_received", java.lang.foreign.FunctionDescriptor.ofVoid(java.lang.foreign.ValueLayout.JAVA_LONG, RustBuffer.LAYOUT, RustBuffer.LAYOUT, java.lang.foreign.ValueLayout.ADDRESS)); @@ -3260,6 +3278,15 @@ static short uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_app_ } catch (Throwable _ex) { throw new AssertionError("invokeExact failed", _ex); } } + // uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag + private static final java.lang.invoke.MethodHandle MH_uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag = findDowncallHandle("uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag", java.lang.foreign.FunctionDescriptor.of(java.lang.foreign.ValueLayout.JAVA_SHORT)); + + static short uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag() { + try { + return (short) MH_uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag.invokeExact(); + } catch (Throwable _ex) { throw new AssertionError("invokeExact failed", _ex); } + } + // uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_clear_peer_mtu private static final java.lang.invoke.MethodHandle MH_uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_clear_peer_mtu = findDowncallHandle("uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_clear_peer_mtu", java.lang.foreign.FunctionDescriptor.of(java.lang.foreign.ValueLayout.JAVA_SHORT)); @@ -4889,6 +4916,15 @@ static short uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi } catch (Throwable _ex) { throw new AssertionError("invokeExact failed", _ex); } } + // uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers + private static final java.lang.invoke.MethodHandle MH_uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers = findDowncallHandle("uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers", java.lang.foreign.FunctionDescriptor.of(java.lang.foreign.ValueLayout.JAVA_SHORT)); + + static short uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers() { + try { + return (short) MH_uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers.invokeExact(); + } catch (Throwable _ex) { throw new AssertionError("invokeExact failed", _ex); } + } + // uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_message_received private static final java.lang.invoke.MethodHandle MH_uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_message_received = findDowncallHandle("uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_message_received", java.lang.foreign.FunctionDescriptor.of(java.lang.foreign.ValueLayout.JAVA_SHORT)); diff --git a/bindings/java/src/main/java/com/offlineprotocol/sdk/WifiDirectIdentifiers.java b/bindings/java/src/main/java/com/offlineprotocol/sdk/WifiDirectIdentifiers.java new file mode 100644 index 000000000..0aa1063bb --- /dev/null +++ b/bindings/java/src/main/java/com/offlineprotocol/sdk/WifiDirectIdentifiers.java @@ -0,0 +1,75 @@ +package com.offlineprotocol.sdk; + +public class WifiDirectIdentifiers { + private java.lang.String recordApp; + private java.lang.String networkName; + private java.lang.String passphrase; + + public WifiDirectIdentifiers( + java.lang.String recordApp, + java.lang.String networkName, + java.lang.String passphrase + ) { + + this.recordApp = recordApp; + + this.networkName = networkName; + + this.passphrase = passphrase; + } + + public java.lang.String recordApp() { + return this.recordApp; + } + + public java.lang.String networkName() { + return this.networkName; + } + + public java.lang.String passphrase() { + return this.passphrase; + } + public void setRecordApp(java.lang.String recordApp) { + this.recordApp = recordApp; + } + public void setNetworkName(java.lang.String networkName) { + this.networkName = networkName; + } + public void setPassphrase(java.lang.String passphrase) { + this.passphrase = passphrase; + } + + + + + @Override + public boolean equals(java.lang.Object _uniffiOther) { + if (_uniffiOther instanceof WifiDirectIdentifiers) { + WifiDirectIdentifiers _uniffiThat = (WifiDirectIdentifiers) _uniffiOther; + return ( + java.util.Objects.equals(recordApp, _uniffiThat.recordApp) && + + java.util.Objects.equals(networkName, _uniffiThat.networkName) && + + java.util.Objects.equals(passphrase, _uniffiThat.passphrase) + + ); + }; + return false; + } + @Override + public int hashCode() { + int _uniffiHash = 17; + _uniffiHash = 31 * _uniffiHash + java.util.Objects.hashCode(recordApp); + _uniffiHash = 31 * _uniffiHash + java.util.Objects.hashCode(networkName); + _uniffiHash = 31 * _uniffiHash + java.util.Objects.hashCode(passphrase); + return _uniffiHash; + } + + + + + +} + + diff --git a/bindings/python/offline_protocol_sdk/local_api/dispatch.py b/bindings/python/offline_protocol_sdk/local_api/dispatch.py index ff60090d7..e13cbe3cb 100644 --- a/bindings/python/offline_protocol_sdk/local_api/dispatch.py +++ b/bindings/python/offline_protocol_sdk/local_api/dispatch.py @@ -323,6 +323,11 @@ # and a certificate in the one identity's session: the same # operator-held configuration, acted on toward a device "provision_leaf_membership", + # what the application shows on the air, keyed by the application key + # (ADR 0037): read by the carrier drivers the server runs, and the + # Wi-Fi Direct passphrase among them is a secret no client needs. + "ble_app_tag", + "wifi_direct_identifiers", } ) diff --git a/bindings/python/offline_protocol_sdk/local_api/table.py b/bindings/python/offline_protocol_sdk/local_api/table.py index 28957a9a8..754cc683e 100644 --- a/bindings/python/offline_protocol_sdk/local_api/table.py +++ b/bindings/python/offline_protocol_sdk/local_api/table.py @@ -9,7 +9,7 @@ from __future__ import annotations -UDL_SHA256 = "e48caac6d20ce4f12b1da7266242a64ce6841e712bdcb9116970aa417b51df1a" +UDL_SHA256 = "566d18829172017f77b41f7141c0a886177cf118df8bb898a0d4148b89c5854d" TABLE = {'callbacks': ('MlsStorageProvider', 'ProtocolStateStorageProvider', @@ -224,6 +224,7 @@ ('key_package', 'sequence?')), 'string'), 'app_membership_status': ((), 'AppMembershipStatus'), + 'ble_app_tag': ((('device_id', 'string'),), 'sequence?'), 'ble_clear_peer_mtu': ((('peer_id', 'string'),), 'void'), 'ble_fragment_fallback_count': ((), 'u64'), 'ble_fragment_received': ((('sender_id', 'string'), @@ -565,6 +566,7 @@ ('signature', 'bytes')), 'boolean'), 'wifi_direct_get_next_message': ((), 'WifiDirectMessage?'), + 'wifi_direct_identifiers': ((), 'WifiDirectIdentifiers?'), 'wifi_direct_message_received': ((('sender_id', 'string'), ('data', 'sequence')), 'void'), @@ -938,5 +940,8 @@ ('drop_rate', 'f32?', False), ('average_hop_count', 'f32?', False), ('energy_cost', 'f32?', False)), + 'WifiDirectIdentifiers': (('record_app', 'string', False), + ('network_name', 'string', False), + ('passphrase', 'string', False)), 'WifiDirectMessage': (('recipient_id', 'string', False), ('data', 'sequence', False))}} diff --git a/bindings/python/offline_protocol_sdk/offline_protocol.py b/bindings/python/offline_protocol_sdk/offline_protocol.py index ea48ed3c4..afaadf008 100644 --- a/bindings/python/offline_protocol_sdk/offline_protocol.py +++ b/bindings/python/offline_protocol_sdk/offline_protocol.py @@ -667,6 +667,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_app_membership_status() != 23851: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag() != 50666: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_clear_peer_mtu() != 19884: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_fragment_fallback_count() != 60784: @@ -1029,6 +1031,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_wifi_direct_get_next_message() != 50868: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers() != 20485: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_message_received() != 59107: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_peer_connected() != 38596: @@ -1236,6 +1240,9 @@ def _uniffi_check_api_checksums(lib): _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_app_membership_status.argtypes = ( ) _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_app_membership_status.restype = ctypes.c_uint16 +_UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag.argtypes = ( +) +_UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag.restype = ctypes.c_uint16 _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_clear_peer_mtu.argtypes = ( ) _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_clear_peer_mtu.restype = ctypes.c_uint16 @@ -1779,6 +1786,9 @@ def _uniffi_check_api_checksums(lib): _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_get_next_message.argtypes = ( ) _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_get_next_message.restype = ctypes.c_uint16 +_UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers.argtypes = ( +) +_UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers.restype = ctypes.c_uint16 _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_message_received.argtypes = ( ) _UniffiLib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_message_received.restype = ctypes.c_uint16 @@ -2195,6 +2205,12 @@ def _uniffi_check_api_checksums(lib): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_app_membership_status.restype = _UniffiRustBuffer +_UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag.argtypes = ( + ctypes.c_uint64, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag.restype = _UniffiRustBuffer _UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_clear_peer_mtu.argtypes = ( ctypes.c_uint64, _UniffiRustBuffer, @@ -3295,6 +3311,11 @@ def _uniffi_check_api_checksums(lib): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_get_next_message.restype = _UniffiRustBuffer +_UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers.argtypes = ( + ctypes.c_uint64, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers.restype = _UniffiRustBuffer _UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_message_received.argtypes = ( ctypes.c_uint64, _UniffiRustBuffer, @@ -8236,6 +8257,48 @@ def write(value, buf): _UniffiFfiConverterOptionalFloat32.write(value.average_hop_count, buf) _UniffiFfiConverterOptionalFloat32.write(value.energy_cost, buf) +@dataclass +class WifiDirectIdentifiers: + def __init__(self, *, record_app:str, network_name:str, passphrase:str): + self.record_app = record_app + self.network_name = network_name + self.passphrase = passphrase + + + + + def __str__(self): + return "WifiDirectIdentifiers(record_app={}, network_name={}, passphrase={})".format(self.record_app, self.network_name, self.passphrase) + def __eq__(self, other): + if self.record_app != other.record_app: + return False + if self.network_name != other.network_name: + return False + if self.passphrase != other.passphrase: + return False + return True + +class _UniffiFfiConverterTypeWifiDirectIdentifiers(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return WifiDirectIdentifiers( + record_app=_UniffiFfiConverterString.read(buf), + network_name=_UniffiFfiConverterString.read(buf), + passphrase=_UniffiFfiConverterString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterString.check_lower(value.record_app) + _UniffiFfiConverterString.check_lower(value.network_name) + _UniffiFfiConverterString.check_lower(value.passphrase) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterString.write(value.record_app, buf) + _UniffiFfiConverterString.write(value.network_name, buf) + _UniffiFfiConverterString.write(value.passphrase, buf) + @dataclass class WifiDirectMessage: def __init__(self, *, recipient_id:str, data:typing.List[int]): @@ -11095,6 +11158,31 @@ def read(cls, buf): else: raise InternalError("Unexpected flag byte for optional type") +class _UniffiFfiConverterOptionalTypeWifiDirectIdentifiers(_UniffiConverterRustBuffer): + @classmethod + def check_lower(cls, value): + if value is not None: + _UniffiFfiConverterTypeWifiDirectIdentifiers.check_lower(value) + + @classmethod + def write(cls, value, buf): + if value is None: + buf.write_u8(0) + return + + buf.write_u8(1) + _UniffiFfiConverterTypeWifiDirectIdentifiers.write(value, buf) + + @classmethod + def read(cls, buf): + flag = buf.read_u8() + if flag == 0: + return None + elif flag == 1: + return _UniffiFfiConverterTypeWifiDirectIdentifiers.read(buf) + else: + raise InternalError("Unexpected flag byte for optional type") + class OfflineProtocolProtocol(typing.Protocol): @@ -11102,6 +11190,8 @@ def accept_connection_request(self, recipient: str,accepter_name: str,key_packag raise NotImplementedError def app_membership_status(self, ) -> AppMembershipStatus: raise NotImplementedError + def ble_app_tag(self, device_id: str) -> typing.Optional[typing.List[int]]: + raise NotImplementedError def ble_clear_peer_mtu(self, peer_id: str) -> None: raise NotImplementedError def ble_fragment_fallback_count(self, ) -> int: @@ -11464,6 +11554,8 @@ def verify_signature_bytes(self, public_key: bytes,data: bytes,signature: bytes) raise NotImplementedError def wifi_direct_get_next_message(self, ) -> typing.Optional[WifiDirectMessage]: raise NotImplementedError + def wifi_direct_identifiers(self, ) -> typing.Optional[WifiDirectIdentifiers]: + raise NotImplementedError def wifi_direct_message_received(self, sender_id: str,data: typing.List[int]) -> None: raise NotImplementedError def wifi_direct_peer_connected(self, peer_id: str) -> None: @@ -11541,6 +11633,21 @@ def app_membership_status(self, ) -> AppMembershipStatus: *_uniffi_lowered_args, ) return _uniffi_lift_return(_uniffi_ffi_result) + def ble_app_tag(self, device_id: str) -> typing.Optional[typing.List[int]]: + + _UniffiFfiConverterString.check_lower(device_id) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + _UniffiFfiConverterString.lower(device_id), + ) + _uniffi_lift_return = _UniffiFfiConverterOptionalSequenceUInt8.lift + _uniffi_error_converter = None + _uniffi_ffi_result = _uniffi_rust_call_with_error( + _uniffi_error_converter, + _UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag, + *_uniffi_lowered_args, + ) + return _uniffi_lift_return(_uniffi_ffi_result) def ble_clear_peer_mtu(self, peer_id: str) -> None: _UniffiFfiConverterString.check_lower(peer_id) @@ -14298,6 +14405,18 @@ def wifi_direct_get_next_message(self, ) -> typing.Optional[WifiDirectMessage]: *_uniffi_lowered_args, ) return _uniffi_lift_return(_uniffi_ffi_result) + def wifi_direct_identifiers(self, ) -> typing.Optional[WifiDirectIdentifiers]: + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + ) + _uniffi_lift_return = _UniffiFfiConverterOptionalTypeWifiDirectIdentifiers.lift + _uniffi_error_converter = None + _uniffi_ffi_result = _uniffi_rust_call_with_error( + _uniffi_error_converter, + _UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers, + *_uniffi_lowered_args, + ) + return _uniffi_lift_return(_uniffi_ffi_result) def wifi_direct_message_received(self, sender_id: str,data: typing.List[int]) -> None: _UniffiFfiConverterString.check_lower(sender_id) @@ -14536,6 +14655,7 @@ def verify_identity_assertion(assertion: typing.List[int]) -> str: "TelemetryStats", "TransportConfig", "TransportMetrics", + "WifiDirectIdentifiers", "WifiDirectMessage", "derive_address", "parse_invite", diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/BleAppTag.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/BleAppTag.kt index d7ecbd9c4..e86ce2642 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/BleAppTag.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/BleAppTag.kt @@ -33,6 +33,16 @@ import java.security.MessageDigest * app co-installed on the peer's phone that serves someone else's tag is the * malicious-application adversary (A5 in the threat model), which the SDK does * not defend an application against. + * + * ## With a publisher key + * + * This digest names the application to anyone who knows its `appId`. An + * application with a publisher key (ADR 0037) serves the engine's + * `OfflineProtocol.bleAppTag(address)` instead, `HMAC(K_app, address)[0..8]`: + * per instance, so it names and groups nothing for another application's + * developer. Its central reads Device id on each tagged instance and asks the + * engine for the tag over it ([BleServiceInstanceSelection.selectKeyed]). + * Nothing here computes that tag: the engine is its one implementation. */ object BleAppTag { diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/BleServiceInstanceSelection.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/BleServiceInstanceSelection.kt index b97fa8519..a5548325e 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/BleServiceInstanceSelection.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/BleServiceInstanceSelection.kt @@ -64,8 +64,11 @@ object BleServiceInstanceSelection { * build that predates the tag) or the read failed or timed out. * @property canHandshake whether the instance exposes both DEVICE_ID and * IDENTITY. One that lacks either can never complete the handshake. + * @property deviceId the DEVICE_ID value, read only by a central whose + * application has a publisher key ([selectKeyed]), and only on an + * instance that served a tag; null otherwise, or when the read failed. */ - class Candidate(val tag: ByteArray?, val canHandshake: Boolean) + class Candidate(val tag: ByteArray?, val canHandshake: Boolean, val deviceId: String? = null) /** * @property index index into the candidates passed to [select]. @@ -82,10 +85,32 @@ object BleServiceInstanceSelection { * the first handshake-capable instance; else index 0. */ @JvmStatic - fun select(candidates: List, ownTag: ByteArray): Selection { + fun select(candidates: List, ownTag: ByteArray): Selection = + select(candidates) { it.tag?.contentEquals(ownTag) == true } + + /** + * Chooses an instance for a central whose application has a publisher + * key (ADR 0037). Its tag is per instance, `HMAC(K_app, device_id)[0..8]`, + * so rule 1 takes the first handshake-capable instance whose tag equals + * [tagFor] over that instance's own [Candidate.deviceId]; rules 2 and 3 + * are [select]'s. The Device id is unproven here, so a match only chooses + * the instance: the handshake that follows still proves its address. + * + * @param tagFor the engine's `bleAppTag`, or null for a Device id it has + * no tag for. + */ + @JvmStatic + fun selectKeyed(candidates: List, tagFor: (String) -> ByteArray?): Selection = + select(candidates) { candidate -> + val tag = candidate.tag + val deviceId = candidate.deviceId + tag != null && deviceId != null && tagFor(deviceId)?.contentEquals(tag) == true + } + + private fun select(candidates: List, isOurs: (Candidate) -> Boolean): Selection { val usable = candidates.indices.filter { candidates[it].canHandshake } - usable.firstOrNull { candidates[it].tag?.contentEquals(ownTag) == true }?.let { + usable.firstOrNull { isOurs(candidates[it]) }?.let { return Selection(it, Reason.SAME_APP) } diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt index f0a14eebe..0fe790688 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt @@ -25,6 +25,14 @@ import java.security.MessageDigest * proves its peer with the identity preamble before it carries anything, and * every message is end to end encrypted above it. * + * An application with a publisher key (ADR 0037) does not use the three + * application-id derivations here ([appTag], [networkName], [passphrase]): the + * engine derives all three from the application key + * (`OfflineProtocol.wifiDirectIdentifiers`), which another application's + * developer cannot compute, so neither the record nor the group names the + * application. They are still one per application, so a device in range sees + * which devices share a record. The rules below are the same either way. + * * ## Why the name is the application's and not the owner's * * Discovery is asymmetric on real phones. A device that is a group owner, or diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt index a1dda0eac..1270a3506 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt @@ -17,6 +17,7 @@ import android.net.wifi.p2p.nsd.WifiP2pDnsSdServiceInfo import android.net.wifi.p2p.nsd.WifiP2pDnsSdServiceRequest import androidx.core.content.ContextCompat import uniffi.offline_protocol.OfflineProtocol +import uniffi.offline_protocol.WifiDirectIdentifiers import uniffi.offline_protocol.verifyIdentityAssertion import java.io.* import java.net.InetAddress @@ -268,8 +269,17 @@ class WifiDirectManager( // The device address of the owner of the application's group this device // is a client of, so a leave knows whom to avoid. private var appGroupOwnerDevice: String? = null - private val appTag: String by lazy { WifiDirectGroupFormation.appTag(appId) } - private val appNetwork: String by lazy { WifiDirectGroupFormation.networkName(appId) } + // With a publisher key the engine derives all three from the application + // key instead (ADR 0037), so neither the record nor the group names the + // application to another application's developer; without one they come + // from `appId` exactly as before. The engine answers without its lock. + private val keyedIdentifiers: WifiDirectIdentifiers? by lazy { protocol.wifiDirectIdentifiers() } + private val appTag: String by lazy { + keyedIdentifiers?.recordApp ?: WifiDirectGroupFormation.appTag(appId) + } + private val appNetwork: String by lazy { + keyedIdentifiers?.networkName ?: WifiDirectGroupFormation.networkName(appId) + } // State tracking. Volatile: written on the transport thread, read by the // socket thread that decides whether a client reconnects. @@ -1129,7 +1139,9 @@ class WifiDirectManager( if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null return WifiP2pConfig.Builder() .setNetworkName(network) - .setPassphrase(WifiDirectGroupFormation.passphrase(appId, network)) + // Keyed, the passphrase is the application key's and `network` is + // always [appNetwork], derived from the same key. + .setPassphrase(keyedIdentifiers?.passphrase ?: WifiDirectGroupFormation.passphrase(appId, network)) .enablePersistentMode(false) .build() } diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt index 66ccb4617..c04ec63ee 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/BleTransportFacade.kt @@ -199,6 +199,21 @@ class BleTransportFacade( * Fixed for the facade's lifetime, like the address. */ private val appTag: ByteArray = BleAppTag.compute(appId) + + /** + * The engine's App tag for a Device id when the application has a + * publisher key (ADR 0037): `HMAC(K_app, device_id)[0..8]`, per instance, + * so it names and groups nothing for another application. Null without a + * publisher key, and [appTag] is served and matched exactly as before. + * The engine answers it without its lock, so the binder thread that + * serves the App tag read may ask. + */ + private val keyedAppTag: ((String) -> ByteArray?)? = + if (keyedBleAppTag(protocol, deviceId) != null) { + { id -> keyedBleAppTag(protocol, id) } + } else { + null + } // MARK: - TransportManager Implementation @@ -521,6 +536,7 @@ class BleTransportFacade( appTagCharUuid = APP_TAG_CHAR_UUID, appTag = appTag, helloCharUuid = HELLO_CHAR_UUID, + keyedAppTag = keyedAppTag, host = object : CentralGattClient.Host { override val protocol: OfflineProtocol get() = this@BleTransportFacade.protocol override val connections: MeshConnectionRegistry get() = this@BleTransportFacade.connections @@ -4646,9 +4662,16 @@ class BleTransportFacade( override fun provideAppTagBytes(device: BluetoothDevice): ByteArray? { if (shuttingDown) return null - // Computed once from `appId` at construction; a copy so a caller - // that mutates the buffer cannot change what the next central reads. - return appTag.copyOf() + val keyed = keyedAppTag + // Computed once from `appId` at construction; a copy so a + // caller that mutates the buffer cannot change what the next + // central reads. + ?: return appTag.copyOf() + // Keyed: over the Device id this instance serves, so a central of + // this application can check it against what it read there. Null + // until the address exists, as the Device id read is. + val address = cachedLocalAddress ?: return null + return keyed(address) } } @@ -4947,3 +4970,7 @@ class BleTransportFacade( } } + +/** The engine's keyed App tag for [deviceId], or null without a publisher key. */ +private fun keyedBleAppTag(protocol: OfflineProtocol, deviceId: String): ByteArray? = + protocol.bleAppTag(deviceId)?.map { it.toByte() }?.toByteArray() diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt index 15c851a38..f29c916c6 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/ble/CentralGattClient.kt @@ -78,6 +78,14 @@ internal class CentralGattClient( private val host: Host, /** The peer's optional Hello characteristic, or null to write no hello. */ private val helloCharUuid: UUID? = null, + /** + * The engine's keyed App tag for a Device id (`OfflineProtocol.bleAppTag`), + * or null when the application has no publisher key. Set, a link with + * several instances also reads each tagged instance's DEVICE_ID while it + * chooses, because the tag is `HMAC(K_app, device_id)[0..8]` and cannot + * be checked without it (ADR 0037); [appTag] is then unused. + */ + private val keyedAppTag: ((String) -> ByteArray?)? = null, private val diagnosticEmitter: (level: String, message: String, ctx: Map) -> Unit = { _, _, _ -> }, ) { @@ -330,12 +338,23 @@ internal class CentralGattClient( * the binder thread in `onServicesDiscovered`; every later access is on * the BLE thread. */ - private class ServiceInstanceProbe(val instances: List) { + private class ServiceInstanceProbe( + val instances: List, + /** Whether each tagged instance's DEVICE_ID is read too (a keyed tag). */ + val readsDeviceIds: Boolean, + ) { val tags = arrayOfNulls(instances.size) - /** Index of the next instance whose tag has not been asked for. */ + val deviceIds = arrayOfNulls(instances.size) + /** + * Index of the next read: instance `next / reads`, its APP_TAG at + * offset 0 and, when [readsDeviceIds], its DEVICE_ID at offset 1. + */ var next = 0 + val reads: Int get() = if (readsDeviceIds) 2 else 1 /** Index whose read is outstanding, or -1. */ var readInFlight = -1 + /** The characteristic of the outstanding read. */ + var readInFlightUuid: UUID? = null } private val serviceInstanceProbes = ConcurrentHashMap() @@ -515,7 +534,7 @@ internal class CentralGattClient( clearServiceInstanceSelection(address) val instances = gatt.services.filter { it.uuid == serviceUuid } if (instances.size > 1) { - serviceInstanceProbes[address] = ServiceInstanceProbe(instances) + serviceInstanceProbes[address] = ServiceInstanceProbe(instances, keyedAppTag != null) Log.i(TAG, "$address serves ${instances.size} service instances; choosing one") diagnosticEmitter("info", "BLE peer serves several service instances; choosing one", mapOf( "address" to address, @@ -772,30 +791,45 @@ internal class CentralGattClient( } /** - * Issues the next APP_TAG read, one at a time. An instance without the - * characteristic, or whose read cannot be started, counts as having no - * tag. Chooses once every instance has been asked. + * Issues the next APP_TAG read, one at a time, and with a keyed tag the + * DEVICE_ID read of each instance that served one. An instance without + * the characteristic, or whose read cannot be started, counts as having + * no tag (or no Device id, which matches no keyed tag). Chooses once + * every instance has been asked. */ private fun readNextServiceInstanceTag(gatt: BluetoothGatt, address: String) { val probe = serviceInstanceProbes[address] ?: return - while (probe.next < probe.instances.size) { - val index = probe.next++ - val tagChar = probe.instances[index].getCharacteristic(appTagCharUuid) ?: continue + while (probe.next < probe.instances.size * probe.reads) { + val step = probe.next++ + val index = step / probe.reads + val readsDeviceId = step % probe.reads == 1 + // A Device id is read only to check a tag against it. + if (readsDeviceId && probe.tags[index] == null) continue + val charUuid = if (readsDeviceId) deviceIdCharUuid else appTagCharUuid + val char = probe.instances[index].getCharacteristic(charUuid) ?: continue val started = try { - gatt.readCharacteristic(tagChar) + gatt.readCharacteristic(char) } catch (e: SecurityException) { - Log.w(TAG, "Permission denied reading app tag on $address", e) + Log.w(TAG, "Permission denied reading $charUuid on $address", e) false } if (!started) continue probe.readInFlight = index + probe.readInFlightUuid = charUuid val watchdog = Runnable { if (host.isShuttingDown()) return@Runnable - if (serviceInstanceProbes[address] !== probe || probe.readInFlight != index) return@Runnable + if (serviceInstanceProbes[address] !== probe || probe.readInFlight != index || + probe.readInFlightUuid != charUuid) return@Runnable appTagReadWatchdogs.remove(address) probe.readInFlight = -1 - Log.w(TAG, "App tag read timed out on $address; treating the instance as untagged") - diagnosticEmitter("warning", "BLE app tag read timed out", mapOf("address" to address)) + probe.readInFlightUuid = null + if (readsDeviceId) { + Log.w(TAG, "Device id read timed out on $address while choosing; the instance is not ours") + diagnosticEmitter("warning", "BLE instance device id read timed out", mapOf("address" to address)) + } else { + Log.w(TAG, "App tag read timed out on $address; treating the instance as untagged") + diagnosticEmitter("warning", "BLE app tag read timed out", mapOf("address" to address)) + } readNextServiceInstanceTag(gatt, address) } appTagReadWatchdogs[address] = watchdog @@ -805,21 +839,34 @@ internal class CentralGattClient( finishServiceInstanceSelection(gatt, address) } - /** Records one APP_TAG read, then asks the next instance. An error or - * an empty value counts as no tag; any other value is kept as served, - * so a tag of the wrong length is simply one that is not ours. */ - private fun handleAppTagRead(gatt: BluetoothGatt, instanceId: Int?, value: ByteArray?, status: Int) { + /** Records one APP_TAG (or, with a keyed tag, DEVICE_ID) read while + * choosing, then asks the next. An error or an empty value counts as + * none; any other tag is kept as served, so a tag of the wrong length + * is simply one that is not ours. */ + private fun handleAppTagRead( + gatt: BluetoothGatt, + instanceId: Int?, + charUuid: UUID, + value: ByteArray?, + status: Int, + ) { val address = gatt.device.address val probe = serviceInstanceProbes[address] ?: return val index = probe.readInFlight - if (index < 0 || probe.instances[index].instanceId != instanceId) return + if (index < 0 || probe.instances[index].instanceId != instanceId || probe.readInFlightUuid != charUuid) return probe.readInFlight = -1 + probe.readInFlightUuid = null appTagReadWatchdogs.remove(address)?.let { bleHandler.removeCallbacks(it) } if (status == BluetoothGatt.GATT_SUCCESS && value != null && value.isNotEmpty()) { - probe.tags[index] = value + if (charUuid == deviceIdCharUuid) { + probe.deviceIds[index] = String(value, Charsets.UTF_8) + } else { + probe.tags[index] = value + } } else if (status != BluetoothGatt.GATT_SUCCESS) { - diagnosticEmitter("debug", "BLE app tag read failed; treating the instance as untagged", mapOf( + diagnosticEmitter("debug", "BLE instance read failed while choosing", mapOf( "address" to address, + "char" to charUuid.toString(), "status" to status, )) } @@ -840,9 +887,15 @@ internal class CentralGattClient( tag = probe.tags[index], canHandshake = instance.getCharacteristic(deviceIdCharUuid) != null && instance.getCharacteristic(identityCharUuid) != null, + deviceId = probe.deviceIds[index], ) } - val selection = BleServiceInstanceSelection.select(candidates, appTag) + val keyed = keyedAppTag + val selection = if (keyed != null) { + BleServiceInstanceSelection.selectKeyed(candidates, keyed) + } else { + BleServiceInstanceSelection.select(candidates, appTag) + } val chosen = probe.instances[selection.index] boundServiceInstanceIds[address] = chosen.instanceId @@ -874,18 +927,30 @@ internal class CentralGattClient( val charUuid = characteristic.uuid @Suppress("DEPRECATION") val snapshot = characteristic.value?.copyOf() - if (charUuid == appTagCharUuid) { + val instanceId = characteristic.service?.instanceId + val address = gatt.device.address + if (charUuid == appTagCharUuid || + (charUuid == deviceIdCharUuid && serviceInstanceProbes.containsKey(address)) + ) { // Read only while choosing among several instances, and never // part of the handshake: a failed read means "no tag", not a - // refusal. The instance id travels with it so a late answer to - // a read already given up on cannot be credited to the next. - val instanceId = characteristic.service?.instanceId + // refusal. A DEVICE_ID read lands here only while the link is + // choosing, which with a keyed tag reads each instance's. The + // instance id travels with it so a late answer to a read + // already given up on cannot be credited to the next. bleHandler.post { if (host.isShuttingDown()) return@post - handleAppTagRead(gatt, instanceId, snapshot, status) + handleAppTagRead(gatt, instanceId, charUuid, snapshot, status) } return } + if (charUuid == deviceIdCharUuid) { + // A DEVICE_ID answer from an instance other than the chosen + // one is a choosing read answered after the choice, never the + // handshake's own. + val bound = boundServiceInstanceIds[address] + if (bound != null && bound != instanceId) return + } bleHandler.post { if (host.isShuttingDown()) return@post handleCharacteristicReadOnBleThread(gatt, charUuid, snapshot, status) 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 1d74e670a..37d9883e4 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 @@ -971,6 +971,8 @@ internal object IntegrityCheckingUniffiLib { ): Int external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_app_membership_status( ): Int + external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag( + ): Int external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_clear_peer_mtu( ): Int external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_fragment_fallback_count( @@ -1333,6 +1335,8 @@ internal object IntegrityCheckingUniffiLib { ): Int external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_get_next_message( ): Int + external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers( + ): Int external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_message_received( ): Int external fun uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_peer_connected( @@ -1518,6 +1522,8 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_app_membership_status(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag(`ptr`: Long,`deviceId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_clear_peer_mtu(`ptr`: Long,`peerId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_fragment_fallback_count(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1880,6 +1886,8 @@ internal object UniffiLib { ): Byte external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_get_next_message(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_message_received(`ptr`: Long,`senderId`: RustBuffer.ByValue,`data`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit external fun uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_peer_connected(`ptr`: Long,`peerId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -2190,6 +2198,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_app_membership_status() and 0xFFFF) != 23851) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag() and 0xFFFF) != 50666) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_clear_peer_mtu() and 0xFFFF) != 19884) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -2733,6 +2744,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_get_next_message() and 0xFFFF) != 50868) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers() and 0xFFFF) != 20485) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_message_received() and 0xFFFF) != 59107) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4710,6 +4724,8 @@ public interface OfflineProtocolInterface { fun `appMembershipStatus`(): AppMembershipStatus + fun `bleAppTag`(`deviceId`: kotlin.String): List? + fun `bleClearPeerMtu`(`peerId`: kotlin.String) fun `bleFragmentFallbackCount`(): kotlin.ULong @@ -5072,6 +5088,8 @@ public interface OfflineProtocolInterface { fun `wifiDirectGetNextMessage`(): WifiDirectMessage? + fun `wifiDirectIdentifiers`(): WifiDirectIdentifiers? + fun `wifiDirectMessageReceived`(`senderId`: kotlin.String, `data`: List) fun `wifiDirectPeerConnected`(`peerId`: kotlin.String) @@ -5223,6 +5241,20 @@ open class OfflineProtocol: Disposable, AutoCloseable, OfflineProtocolInterface } + override fun `bleAppTag`(`deviceId`: kotlin.String): List? { + return FfiConverterOptionalSequenceUByte.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag( + it, + + FfiConverterString.lower(`deviceId`),_status) +} + } + ) + } + + @Throws(ProtocolException::class)override fun `bleClearPeerMtu`(`peerId`: kotlin.String) = @@ -7788,6 +7820,19 @@ open class OfflineProtocol: Disposable, AutoCloseable, OfflineProtocolInterface } + override fun `wifiDirectIdentifiers`(): WifiDirectIdentifiers? { + return FfiConverterOptionalTypeWifiDirectIdentifiers.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers( + it, + _status) +} + } + ) + } + + @Throws(ProtocolException::class)override fun `wifiDirectMessageReceived`(`senderId`: kotlin.String, `data`: List) = @@ -11037,6 +11082,49 @@ public object FfiConverterTypeTransportMetrics: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): WifiDirectIdentifiers { + return WifiDirectIdentifiers( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + } + + override fun allocationSize(value: WifiDirectIdentifiers) = ( + FfiConverterString.allocationSize(value.`recordApp`) + + FfiConverterString.allocationSize(value.`networkName`) + + FfiConverterString.allocationSize(value.`passphrase`) + ) + + override fun write(value: WifiDirectIdentifiers, buf: ByteBuffer) { + FfiConverterString.write(value.`recordApp`, buf) + FfiConverterString.write(value.`networkName`, buf) + FfiConverterString.write(value.`passphrase`, buf) + } +} + + + data class WifiDirectMessage ( var `recipientId`: kotlin.String , @@ -13249,6 +13337,38 @@ public object FfiConverterOptionalTypeTransportMetrics: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): WifiDirectIdentifiers? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterTypeWifiDirectIdentifiers.read(buf) + } + + override fun allocationSize(value: WifiDirectIdentifiers?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterTypeWifiDirectIdentifiers.allocationSize(value) + } + } + + override fun write(value: WifiDirectIdentifiers?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterTypeWifiDirectIdentifiers.write(value, buf) + } + } +} + + + + /** * @suppress */ diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/BleServiceInstanceSelectionTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/BleServiceInstanceSelectionTest.kt index 633180750..edf54739a 100644 --- a/bindings/react-native/android/src/test/java/com/offlineprotocol/BleServiceInstanceSelectionTest.kt +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/BleServiceInstanceSelectionTest.kt @@ -128,6 +128,60 @@ class BleServiceInstanceSelectionTest { assertEquals(Selection(0, Reason.NONE_CAN_HANDSHAKE), select()) } + // --- With a publisher key (ADR 0037) ---------------------------------- + + /** A stand-in for the engine's keyed tag: per Device id. */ + private val keyedTags = mapOf("off1ours" to ours, "off1chat" to chat) + + private fun keyed(tag: ByteArray?, deviceId: String?, canHandshake: Boolean = true) = + Candidate(tag, canHandshake, deviceId) + + private fun selectKeyed(vararg candidates: Candidate): Selection = + BleServiceInstanceSelection.selectKeyed(candidates.toList()) { keyedTags[it] } + + /** Ours is the instance whose tag is the keyed tag over its OWN Device id. */ + @Test + fun `keyed - picks the instance whose tag matches its own device id`() { + assertEquals( + Selection(1, Reason.SAME_APP), + selectKeyed(keyed(game, "off1game"), keyed(ours, "off1ours")), + ) + } + + /** + * A tag that is ours for another Device id does not match: the tag binds + * the instance that serves it, so a copied tag chooses nothing. + */ + @Test + fun `keyed - a tag served under another device id is not ours`() { + assertEquals( + Selection(0, Reason.FIRST_INSTANCE), + selectKeyed(keyed(chat, "off1game"), keyed(ours, "off1chat")), + ) + } + + /** No Device id read (it failed, or timed out): nothing to check, not ours. */ + @Test + fun `keyed - an instance with no device id is not ours and rules 2 and 3 still apply`() { + assertEquals( + Selection(1, Reason.SOLE_UNTAGGED), + selectKeyed(keyed(ours, null), keyed(null, null)), + ) + assertEquals( + Selection(0, Reason.FIRST_INSTANCE), + selectKeyed(keyed(ours, null), keyed(chat, "off1game")), + ) + } + + /** The unkeyed rule ignores Device ids entirely, as before. */ + @Test + fun `unkeyed - a device id changes nothing`() { + assertEquals( + Selection(1, Reason.SAME_APP), + select(keyed(chat, "off1ours"), keyed(ours, "off1game")), + ) + } + /** * The reason strings are shared with Swift; a rename on one side only would * split the diagnostics. diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/CentralGattClientInstanceSelectionTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/CentralGattClientInstanceSelectionTest.kt index d5f7cd08f..5760332ad 100644 --- a/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/CentralGattClientInstanceSelectionTest.kt +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/ble/CentralGattClientInstanceSelectionTest.kt @@ -95,7 +95,7 @@ class CentralGattClientInstanceSelectionTest { return Link(gatt, shadow) } - private fun client(): CentralGattClient = CentralGattClient( + private fun client(keyedAppTag: ((String) -> ByteArray?)? = null): CentralGattClient = CentralGattClient( bleHandler = Handler(Looper.getMainLooper()), serviceUuid = serviceUuid, messageCharUuid = messageUuid, @@ -105,8 +105,13 @@ class CentralGattClientInstanceSelectionTest { appTag = ourTag, host = FakeHost(), diagnosticEmitter = { _, message, ctx -> diagnostics += message to ctx }, + keyedAppTag = keyedAppTag, ) + /** A stand-in for the engine's keyed tag (ADR 0037): one per Device id. */ + private fun keyedTag(deviceId: String): ByteArray = + BleAppTag.compute("keyed:$deviceId") + private fun idle() = shadowOf(Looper.getMainLooper()).idle() private fun idleFor(ms: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms)) @@ -315,6 +320,71 @@ class CentralGattClientInstanceSelectionTest { 1, client.handshakeService(link.gatt)?.instanceId) } + // --- With a publisher key (ADR 0037) ------------------------------------- + + @Test + fun `keyed - each tagged instance's device id is read and the instance whose tag is ours over it is chosen`() { + val first = instance(instanceId = 1, deviceId = "peer-first", tag = keyedTag("peer-other")) + val untagged = instance(instanceId = 2, deviceId = "peer-untagged", tag = null) + val third = instance(instanceId = 3, deviceId = "peer-third", tag = keyedTag("peer-third")) + val link = link(first, untagged, third) + val client = client(keyedAppTag = ::keyedTag) + + client.callback.onServicesDiscovered(link.gatt, BluetoothGatt.GATT_SUCCESS) + client.callback.onMtuChanged(link.gatt, 247, BluetoothGatt.GATT_SUCCESS) + idle() + assertSame(first.appTag, link.shadow.reads.last()) + answerLastRead(client, link) + assertSame("a tagged instance's device id is read to check its tag", first.deviceId, link.shadow.reads.last()) + answerLastRead(client, link) + answerLastRead(client, link) // the third instance's tag + assertSame(third.deviceId, link.shadow.reads.last()) + answerLastRead(client, link) + + assertEquals(2, chosen()["index"]) + assertEquals("same_app", chosen()["reason"]) + assertEquals( + "an untagged instance has nothing to check, so its device id is never read while choosing", + listOf(first.appTag, first.deviceId, third.appTag, third.deviceId, third.deviceId), + link.shadow.reads, + ) + assertEquals(3, client.handshakeService(link.gatt)?.instanceId) + assertTrue(!link.shadow.closed) + } + + @Test + fun `keyed - a tag that is ours for another device id is not ours`() { + val first = instance(instanceId = 1, deviceId = "peer-first", tag = keyedTag("peer-second")) + val second = instance(instanceId = 2, deviceId = "peer-second", tag = keyedTag("peer-first")) + val link = link(first, second) + val client = client(keyedAppTag = ::keyedTag) + + client.callback.onServicesDiscovered(link.gatt, BluetoothGatt.GATT_SUCCESS) + client.callback.onMtuChanged(link.gatt, 247, BluetoothGatt.GATT_SUCCESS) + idle() + repeat(4) { answerLastRead(client, link) } + + assertEquals("first_instance", chosen()["reason"]) + assertEquals(0, chosen()["index"]) + assertSame(first.deviceId, link.shadow.reads.last()) + } + + @Test + fun `unkeyed - no device id is read while choosing`() { + val first = instance(instanceId = 1, deviceId = "peer-first", tag = otherTag) + val second = instance(instanceId = 2, deviceId = "peer-second", tag = ourTag) + val link = link(first, second) + val client = client() + + client.callback.onServicesDiscovered(link.gatt, BluetoothGatt.GATT_SUCCESS) + client.callback.onMtuChanged(link.gatt, 247, BluetoothGatt.GATT_SUCCESS) + idle() + answerLastRead(client, link) + answerLastRead(client, link) + + assertEquals(listOf(first.appTag, second.appTag, second.deviceId), link.shadow.reads) + } + // --- One instance ------------------------------------------------------- @Test diff --git a/bindings/react-native/ios/BleAppTag.swift b/bindings/react-native/ios/BleAppTag.swift index 9bcd90c2a..5171ec771 100644 --- a/bindings/react-native/ios/BleAppTag.swift +++ b/bindings/react-native/ios/BleAppTag.swift @@ -36,6 +36,16 @@ import Foundation /// An app co-installed on the peer's phone that serves someone else's tag is /// the malicious-application adversary (A5 in the threat model), which the SDK /// does not defend an application against. +/// +/// # With a publisher key +/// +/// This digest names the application to anyone who knows its `appId`. An +/// application with a publisher key (ADR 0037) serves the engine's +/// `OfflineProtocol.bleAppTag(deviceId:)` instead, `HMAC(K_app, address)[0..8]`: +/// per instance, so it names and groups nothing for another application's +/// developer. Its central reads Device id on each tagged instance and asks the +/// engine for the tag over it (`BleServiceInstanceSelection.selectKeyed`). +/// Nothing here computes that tag: the engine is its one implementation. enum BleAppTag { /// Bytes served in the characteristic. diff --git a/bindings/react-native/ios/BleManager.swift b/bindings/react-native/ios/BleManager.swift index fdfe7ed80..4fb5ff9bd 100644 --- a/bindings/react-native/ios/BleManager.swift +++ b/bindings/react-native/ios/BleManager.swift @@ -93,6 +93,12 @@ public class BleManager: NSObject, TransportManager { /// matched against a remote phone's instances when it runs several SDK /// apps. Fixed for the instance's lifetime, like the address. private let appTag: Data + /// Whether the application has a publisher key (ADR 0037). Then the App + /// tag is the engine's `bleAppTag(deviceId:)`, `HMAC(K_app, address)[0..8]`: + /// served over this instance's own address, and checked against each + /// remote instance's Device id, so `appTag` is neither served nor matched. + /// The engine answers it without its lock. + private let keyedAppTags: Bool private let meshController: MeshController // Central (scanner/client) components @@ -208,11 +214,22 @@ public class BleManager: NSObject, TransportManager { var canHandshake: [ObjectIdentifier: Bool] = [:] var tags: [ObjectIdentifier: Data] = [:] var pendingTagReads: Set = [] + /// With a keyed App tag only: each tagged instance's DEVICE_ID, the + /// value its tag is checked against. + var deviceIds: [ObjectIdentifier: String] = [:] + var pendingDeviceIdReads: Set = [] var isComplete: Bool { characteristicsKnown.count == instances.count && pendingTagReads.isEmpty + && pendingDeviceIdReads.isEmpty } } + + /// The engine's keyed App tag for a Device id, or nil without a publisher + /// key. + private func keyedAppTag(for deviceId: String) -> Data? { + protocolInstance.bleAppTag(deviceId: deviceId).map { Data($0) } + } private var serviceInstanceProbes: [UUID: ServiceInstanceProbe] = [:] private var serviceInstanceDeadlines: [UUID: DispatchWorkItem] = [:] @@ -670,6 +687,7 @@ public class BleManager: NSObject, TransportManager { self.protocolInstance = protocolInstance self.deviceId = deviceId self.appTag = BleAppTag.compute(appId: appId) + self.keyedAppTags = protocolInstance.bleAppTag(deviceId: deviceId) != nil self.meshController = MeshController(selfId: deviceId) super.init() meshController.markPeerActive(deviceId) @@ -1798,10 +1816,15 @@ public class BleManager: NSObject, TransportManager { // service behind one link, so a peer running one SDK app never costs // it a read. Static like DEVICE_ID: `appId` is fixed for the lifetime // of this manager. + // + // With a publisher key the tag is over the address DEVICE_ID serves + // (ADR 0037), so a central of this application checks it against + // what it read there, and an observer learns nothing that groups this + // application's phones. appTagCharacteristic = CBMutableCharacteristic( type: APP_TAG_CHAR_UUID, properties: [.read], - value: appTag, + value: keyedAppTags ? keyedAppTag(for: advertisedAddress) : appTag, permissions: [.readable] ) @@ -3968,6 +3991,19 @@ extension BleManager: CBPeripheralDelegate { recordServiceInstanceTag(from: characteristic, error: error, on: peripheral) return } + // With a keyed App tag a link that is choosing reads each tagged + // instance's DEVICE_ID too. That read is the choice's, never the + // handshake's, and neither is an answer from an instance other than + // the chosen one that lands after the choice. + if characteristic.uuid == DEVICE_ID_CHAR_UUID { + if recordServiceInstanceDeviceId(from: characteristic, error: error, on: peripheral) { + return + } + if case .bound(let bound)? = serviceInstanceBinding(for: peripheral.identifier), + characteristic.service !== bound { + return + } + } if let error = error { print("[BleManager] Error reading characteristic: \(error)") @@ -4362,6 +4398,12 @@ extension BleManager: CBPeripheralDelegate { ]) } else if let tag = characteristic.value, !tag.isEmpty { probe.tags[key] = tag + // A keyed tag is checked against the instance's own Device id. + if keyedAppTags, + let deviceIdCharacteristic = service.characteristics?.first(where: { $0.uuid == DEVICE_ID_CHAR_UUID }) { + probe.pendingDeviceIdReads.insert(key) + peripheral.readValue(for: deviceIdCharacteristic) + } } serviceInstanceProbes[identifier] = probe if probe.isComplete { @@ -4369,6 +4411,27 @@ extension BleManager: CBPeripheralDelegate { } } + /// Records one instance's DEVICE_ID read while its link is choosing with + /// a keyed App tag. True if the read was the choice's; false for any other + /// DEVICE_ID read, which goes on to the handshake. An error or a value + /// that is not UTF-8 leaves the instance with no Device id, which matches + /// no keyed tag. + private func recordServiceInstanceDeviceId(from characteristic: CBCharacteristic, error: Error?, on peripheral: CBPeripheral) -> Bool { + let identifier = peripheral.identifier + guard var probe = serviceInstanceProbes[identifier], + let service = characteristic.service else { return false } + let key = ObjectIdentifier(service) + guard probe.pendingDeviceIdReads.remove(key) != nil else { return false } + if error == nil, let value = characteristic.value, let deviceId = String(data: value, encoding: .utf8) { + probe.deviceIds[key] = deviceId + } + serviceInstanceProbes[identifier] = probe + if probe.isComplete { + finishServiceInstanceSelection(on: peripheral, timedOut: false) + } + return true + } + /// Chooses the instance, binds the link to it, and re-issues its /// characteristic discovery so the handshake runs on it exactly as it /// runs on a link with one instance. @@ -4381,10 +4444,13 @@ extension BleManager: CBPeripheralDelegate { let key = ObjectIdentifier(instance) return BleServiceInstanceSelection.Candidate( tag: probe.tags[key], - canHandshake: probe.canHandshake[key] ?? false + canHandshake: probe.canHandshake[key] ?? false, + deviceId: probe.deviceIds[key] ) } - let selection = BleServiceInstanceSelection.select(candidates, ownTag: appTag) + let selection = keyedAppTags + ? BleServiceInstanceSelection.selectKeyed(candidates) { [weak self] in self?.keyedAppTag(for: $0) } + : BleServiceInstanceSelection.select(candidates, ownTag: appTag) let chosen = probe.instances[selection.index] setServiceInstanceBinding(.bound(chosen), for: identifier) diff --git a/bindings/react-native/ios/BleServiceInstanceSelection.swift b/bindings/react-native/ios/BleServiceInstanceSelection.swift index e9c9df366..c33b702ed 100644 --- a/bindings/react-native/ios/BleServiceInstanceSelection.swift +++ b/bindings/react-native/ios/BleServiceInstanceSelection.swift @@ -60,6 +60,10 @@ enum BleServiceInstanceSelection { /// Whether the instance exposes both DEVICE_ID and IDENTITY. One that /// lacks either can never complete the handshake. let canHandshake: Bool + /// The DEVICE_ID value, read only by a central whose application has + /// a publisher key (`selectKeyed`), and only on an instance that + /// served a tag; nil otherwise, or when the read failed. + var deviceId: String? = nil } struct Selection: Equatable { @@ -76,9 +80,30 @@ enum BleServiceInstanceSelection { /// when exactly one handshake-capable instance serves no tag, that one; /// else the first handshake-capable instance; else index 0. static func select(_ candidates: [Candidate], ownTag: Data) -> Selection { + select(candidates) { $0.tag == ownTag } + } + + /// Chooses an instance for a central whose application has a publisher + /// key (ADR 0037). Its tag is per instance, `HMAC(K_app, device_id)[0..8]`, + /// so rule 1 takes the first handshake-capable instance whose tag equals + /// `tagFor` over that instance's own `deviceId`; rules 2 and 3 are + /// `select`'s. The Device id is unproven here, so a match only chooses the + /// instance: the handshake that follows still proves its address. + /// + /// - Parameter tagFor: the engine's `bleAppTag`, or nil for a Device id + /// it has no tag for. + static func selectKeyed(_ candidates: [Candidate], tagFor: (String) -> Data?) -> Selection { + select(candidates) { candidate in + guard let tag = candidate.tag, let deviceId = candidate.deviceId, + let expected = tagFor(deviceId) else { return false } + return tag == expected + } + } + + private static func select(_ candidates: [Candidate], isOurs: (Candidate) -> Bool) -> Selection { let usable = candidates.indices.filter { candidates[$0].canHandshake } - if let sameApp = usable.first(where: { candidates[$0].tag == ownTag }) { + if let sameApp = usable.first(where: { isOurs(candidates[$0]) }) { return Selection(index: sameApp, reason: Reason.sameApp) } diff --git a/bindings/react-native/ios/Generated/offline_protocol.swift b/bindings/react-native/ios/Generated/offline_protocol.swift index 69aa5fa64..a294256b4 100644 --- a/bindings/react-native/ios/Generated/offline_protocol.swift +++ b/bindings/react-native/ios/Generated/offline_protocol.swift @@ -1527,6 +1527,8 @@ public protocol OfflineProtocolProtocol: AnyObject, Sendable { func appMembershipStatus() -> AppMembershipStatus + func bleAppTag(deviceId: String) -> [UInt8]? + func bleClearPeerMtu(peerId: String) throws func bleFragmentFallbackCount() -> UInt64 @@ -1889,6 +1891,8 @@ public protocol OfflineProtocolProtocol: AnyObject, Sendable { func wifiDirectGetNextMessage() -> WifiDirectMessage? + func wifiDirectIdentifiers() -> WifiDirectIdentifiers? + func wifiDirectMessageReceived(senderId: String, data: [UInt8]) throws func wifiDirectPeerConnected(peerId: String) throws @@ -1981,6 +1985,16 @@ open func appMembershipStatus() -> AppMembershipStatus { }) } +open func bleAppTag(deviceId: String) -> [UInt8]? { + return try! FfiConverterOptionSequenceUInt8.lift(try! rustCall() { + uniffiCallStatus in + uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag( + self.uniffiCloneHandle(), + FfiConverterString.lower(deviceId),uniffiCallStatus + ) +}) +} + open func bleClearPeerMtu(peerId: String)throws {try rustCallWithError(FfiConverterTypeProtocolError_lift) { uniffiCallStatus in uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_clear_peer_mtu( @@ -3721,6 +3735,15 @@ open func wifiDirectGetNextMessage() -> WifiDirectMessage? { }) } +open func wifiDirectIdentifiers() -> WifiDirectIdentifiers? { + return try! FfiConverterOptionTypeWifiDirectIdentifiers.lift(try! rustCall() { + uniffiCallStatus in + uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + open func wifiDirectMessageReceived(senderId: String, data: [UInt8])throws {try rustCallWithError(FfiConverterTypeProtocolError_lift) { uniffiCallStatus in uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_message_received( @@ -7484,6 +7507,64 @@ public func FfiConverterTypeTransportMetrics_lower(_ value: TransportMetrics) -> } +public struct WifiDirectIdentifiers: Equatable, Hashable { + public var recordApp: String + public var networkName: String + public var passphrase: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(recordApp: String, networkName: String, passphrase: String) { + self.recordApp = recordApp + self.networkName = networkName + self.passphrase = passphrase + } + + + + +} + +#if compiler(>=6) +extension WifiDirectIdentifiers: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeWifiDirectIdentifiers: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WifiDirectIdentifiers { + return + try WifiDirectIdentifiers( + recordApp: FfiConverterString.read(from: &buf), + networkName: FfiConverterString.read(from: &buf), + passphrase: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: WifiDirectIdentifiers, into buf: inout [UInt8]) { + FfiConverterString.write(value.recordApp, into: &buf) + FfiConverterString.write(value.networkName, into: &buf) + FfiConverterString.write(value.passphrase, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWifiDirectIdentifiers_lift(_ buf: RustBuffer) throws -> WifiDirectIdentifiers { + return try FfiConverterTypeWifiDirectIdentifiers.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWifiDirectIdentifiers_lower(_ value: WifiDirectIdentifiers) -> RustBuffer { + return FfiConverterTypeWifiDirectIdentifiers.lower(value) +} + + public struct WifiDirectMessage: Equatable, Hashable { public var recipientId: String public var data: [UInt8] @@ -10849,6 +10930,30 @@ fileprivate struct FfiConverterOptionTypeTransportMetrics: FfiConverterRustBuffe } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeWifiDirectIdentifiers: FfiConverterRustBuffer { + typealias SwiftType = WifiDirectIdentifiers? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeWifiDirectIdentifiers.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeWifiDirectIdentifiers.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -11435,6 +11540,9 @@ private let initializationResult: InitializationResult = { if (uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_app_membership_status() != 23851) { return InitializationResult.apiChecksumMismatch } + if (uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag() != 50666) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_clear_peer_mtu() != 19884) { return InitializationResult.apiChecksumMismatch } @@ -11978,6 +12086,9 @@ private let initializationResult: InitializationResult = { if (uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_get_next_message() != 50868) { return InitializationResult.apiChecksumMismatch } + if (uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers() != 20485) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_message_received() != 59107) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/react-native/ios/Generated/offline_protocolFFI.h b/bindings/react-native/ios/Generated/offline_protocolFFI.h index 652286756..ac1d842b3 100644 --- a/bindings/react-native/ios/Generated/offline_protocolFFI.h +++ b/bindings/react-native/ios/Generated/offline_protocolFFI.h @@ -693,6 +693,11 @@ RustBuffer uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_accept_conne RustBuffer uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_app_membership_status(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_BLE_APP_TAG +#define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_BLE_APP_TAG +RustBuffer uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag(uint64_t ptr, RustBuffer device_id, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_BLE_CLEAR_PEER_MTU #define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_BLE_CLEAR_PEER_MTU void uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_clear_peer_mtu(uint64_t ptr, RustBuffer peer_id, RustCallStatus *_Nonnull out_status @@ -1598,6 +1603,11 @@ int8_t uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_verify_signature RustBuffer uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_get_next_message(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_WIFI_DIRECT_IDENTIFIERS +#define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_WIFI_DIRECT_IDENTIFIERS +RustBuffer uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_WIFI_DIRECT_MESSAGE_RECEIVED #define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_FN_METHOD_OFFLINEPROTOCOL_WIFI_DIRECT_MESSAGE_RECEIVED void uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_message_received(uint64_t ptr, RustBuffer sender_id, RustBuffer data, RustCallStatus *_Nonnull out_status @@ -2254,6 +2264,12 @@ uint16_t uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_accept_c #define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_APP_MEMBERSHIP_STATUS uint16_t uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_app_membership_status(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_BLE_APP_TAG +#define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_BLE_APP_TAG +uint16_t uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_BLE_CLEAR_PEER_MTU @@ -3340,6 +3356,12 @@ uint16_t uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_verify_s #define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_WIFI_DIRECT_GET_NEXT_MESSAGE uint16_t uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_get_next_message(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_WIFI_DIRECT_IDENTIFIERS +#define UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_WIFI_DIRECT_IDENTIFIERS +uint16_t uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_OFFLINE_PROTOCOL_UNIFFI_CHECKSUM_METHOD_OFFLINEPROTOCOL_WIFI_DIRECT_MESSAGE_RECEIVED diff --git a/bindings/react-native/ios/tests/BleServiceInstanceSelectionTests.swift b/bindings/react-native/ios/tests/BleServiceInstanceSelectionTests.swift index f0ef2bda0..be5f4f396 100644 --- a/bindings/react-native/ios/tests/BleServiceInstanceSelectionTests.swift +++ b/bindings/react-native/ios/tests/BleServiceInstanceSelectionTests.swift @@ -103,6 +103,57 @@ final class BleServiceInstanceSelectionTests: XCTestCase { ) } + // MARK: With a publisher key (ADR 0037) + + /// A stand-in for the engine's keyed tag: per Device id. + private lazy var keyedTags: [String: Data] = ["off1ours": ours, "off1chat": chat] + + private func keyed(_ tag: Data?, _ deviceId: String?, canHandshake: Bool = true) -> BleServiceInstanceSelection.Candidate { + BleServiceInstanceSelection.Candidate(tag: tag, canHandshake: canHandshake, deviceId: deviceId) + } + + private func selectKeyed(_ candidates: [BleServiceInstanceSelection.Candidate]) -> BleServiceInstanceSelection.Selection { + let tags = keyedTags + return BleServiceInstanceSelection.selectKeyed(candidates) { tags[$0] } + } + + /// Ours is the instance whose tag is the keyed tag over its OWN Device id. + func testKeyedPicksTheInstanceWhoseTagMatchesItsOwnDeviceId() { + XCTAssertEqual( + selectKeyed([keyed(game, "off1game"), keyed(ours, "off1ours")]), + .init(index: 1, reason: BleServiceInstanceSelection.Reason.sameApp) + ) + } + + /// A tag that is ours for another Device id does not match: the tag binds + /// the instance that serves it, so a copied tag chooses nothing. + func testKeyedTagServedUnderAnotherDeviceIdIsNotOurs() { + XCTAssertEqual( + selectKeyed([keyed(chat, "off1game"), keyed(ours, "off1chat")]), + .init(index: 0, reason: BleServiceInstanceSelection.Reason.firstInstance) + ) + } + + /// No Device id read (it failed, or timed out): nothing to check, not ours. + func testKeyedInstanceWithNoDeviceIdIsNotOurs() { + XCTAssertEqual( + selectKeyed([keyed(ours, nil), keyed(nil, nil)]), + .init(index: 1, reason: BleServiceInstanceSelection.Reason.soleUntagged) + ) + XCTAssertEqual( + selectKeyed([keyed(ours, nil), keyed(chat, "off1game")]), + .init(index: 0, reason: BleServiceInstanceSelection.Reason.firstInstance) + ) + } + + /// The unkeyed rule ignores Device ids entirely, as before. + func testUnkeyedIgnoresDeviceIds() { + XCTAssertEqual( + select([keyed(chat, "off1ours"), keyed(ours, "off1game")]), + .init(index: 1, reason: BleServiceInstanceSelection.Reason.sameApp) + ) + } + /// The reason strings are shared with Kotlin; a rename on one side only /// would split the diagnostics. func testReasonStringsAreStable() { diff --git a/bindings/ruby/lib/offline_protocol.rb b/bindings/ruby/lib/offline_protocol.rb index 63ba53de9..78f90ef5e 100644 --- a/bindings/ruby/lib/offline_protocol.rb +++ b/bindings/ruby/lib/offline_protocol.rb @@ -1469,6 +1469,27 @@ def consume_into_TypeTransportMetrics end end + # The Record type WifiDirectIdentifiers. + + def self.check_lower_TypeWifiDirectIdentifiers(v) + + + + end + + def self.alloc_from_TypeWifiDirectIdentifiers(v) + RustBuffer.allocWithBuilder do |builder| + builder.write_TypeWifiDirectIdentifiers(v) + return builder.finalize + end + end + + def consume_into_TypeWifiDirectIdentifiers + consumeWithStream do |stream| + return stream.read_TypeWifiDirectIdentifiers + end + end + # The Record type WifiDirectMessage. def self.check_lower_TypeWifiDirectMessage(v) @@ -2329,6 +2350,27 @@ def consume_into_OptionalTypeTransportMetrics end end + # The Optional type for TypeWifiDirectIdentifiers. + + def self.check_lower_OptionalTypeWifiDirectIdentifiers(v) + if !v.nil? + RustBuffer.check_lower_TypeWifiDirectIdentifiers(v) + end + end + + def self.alloc_from_OptionalTypeWifiDirectIdentifiers(v) + RustBuffer.allocWithBuilder do |builder| + builder.write_OptionalTypeWifiDirectIdentifiers(v) + return builder.finalize() + end + end + + def consume_into_OptionalTypeWifiDirectIdentifiers + consumeWithStream do |stream| + return stream.read_OptionalTypeWifiDirectIdentifiers + end + end + # The Optional type for TypeWifiDirectMessage. def self.check_lower_OptionalTypeWifiDirectMessage(v) @@ -3467,6 +3509,16 @@ def read_TypeTransportMetrics ) end + # The Record type WifiDirectIdentifiers. + + def read_TypeWifiDirectIdentifiers + WifiDirectIdentifiers.new( + record_app: read_string, + network_name: read_string, + passphrase: read_string + ) + end + # The Record type WifiDirectMessage. def read_TypeWifiDirectMessage @@ -4428,6 +4480,21 @@ def read_OptionalTypeTransportMetrics end + # The Optional type for TypeWifiDirectIdentifiers. + + def read_OptionalTypeWifiDirectIdentifiers + flag = unpack_from 1, 'c' + + if flag == 0 + return nil + elsif flag == 1 + return read_TypeWifiDirectIdentifiers + else + raise InternalError, 'Unexpected flag byte for OptionalTypeWifiDirectIdentifiers' + end + end + + # The Optional type for TypeWifiDirectMessage. def read_OptionalTypeWifiDirectMessage @@ -5382,6 +5449,14 @@ def write_TypeTransportMetrics(v) self.write_Optionalf32(v.energy_cost) end + # The Record type WifiDirectIdentifiers. + + def write_TypeWifiDirectIdentifiers(v) + self.write_string(v.record_app) + self.write_string(v.network_name) + self.write_string(v.passphrase) + end + # The Record type WifiDirectMessage. def write_TypeWifiDirectMessage(v) @@ -6086,6 +6161,17 @@ def write_OptionalTypeTransportMetrics(v) end end + # The Optional type for TypeWifiDirectIdentifiers. + + def write_OptionalTypeWifiDirectIdentifiers(v) + if v.nil? + pack_into(1, 'c', 0) + else + pack_into(1, 'c', 1) + self.write_TypeWifiDirectIdentifiers(v) + end + end + # The Optional type for TypeWifiDirectMessage. def write_OptionalTypeWifiDirectMessage(v) @@ -6923,6 +7009,10 @@ class VTableCallbackInterfaceWifiDirectTransportCallback < FFI::Struct [:uint64, RustCallStatus.by_ref], RustBuffer.by_value + attach_function :uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag, + [:uint64, RustBuffer.by_value, RustCallStatus.by_ref], + RustBuffer.by_value + attach_function :uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_clear_peer_mtu, [:uint64, RustBuffer.by_value, RustCallStatus.by_ref], :void @@ -7647,6 +7737,10 @@ class VTableCallbackInterfaceWifiDirectTransportCallback < FFI::Struct [:uint64, RustCallStatus.by_ref], RustBuffer.by_value + attach_function :uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers, + [:uint64, RustCallStatus.by_ref], + RustBuffer.by_value + attach_function :uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_message_received, [:uint64, RustBuffer.by_value, RustBuffer.by_value, RustCallStatus.by_ref], :void @@ -8131,6 +8225,10 @@ class VTableCallbackInterfaceWifiDirectTransportCallback < FFI::Struct [], :uint16 + attach_function :uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_app_tag, + [], + :uint16 + attach_function :uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_ble_clear_peer_mtu, [], :uint16 @@ -8855,6 +8953,10 @@ class VTableCallbackInterfaceWifiDirectTransportCallback < FFI::Struct [], :uint16 + attach_function :uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_identifiers, + [], + :uint16 + attach_function :uniffi_offline_protocol_uniffi_checksum_method_offlineprotocol_wifi_direct_message_received, [], :uint16 @@ -11283,6 +11385,32 @@ def ==(other) true end +end + + # Record type WifiDirectIdentifiers +class WifiDirectIdentifiers + attr_reader :record_app, :network_name, :passphrase + + def initialize(record_app:, network_name:, passphrase:) + @record_app = record_app + @network_name = network_name + @passphrase = passphrase + end + + def ==(other) + if @record_app != other.record_app + return false + end + if @network_name != other.network_name + return false + end + if @passphrase != other.passphrase + return false + end + + true + end + end # Record type WifiDirectMessage @@ -11987,6 +12115,12 @@ def app_membership_status() result = ::OfflineProtocol.rust_call(:uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_app_membership_status,uniffi_clone_handle(),) return result.consume_into_TypeAppMembershipStatus end + def ble_app_tag(device_id) + device_id = ::OfflineProtocol::uniffi_utf8(device_id) + + result = ::OfflineProtocol.rust_call(:uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_ble_app_tag,uniffi_clone_handle(),RustBuffer.alloc_from_string(device_id)) + return result.consume_into_OptionalSequenceu8 + end def ble_clear_peer_mtu(peer_id) peer_id = ::OfflineProtocol::uniffi_utf8(peer_id) @@ -13101,6 +13235,10 @@ def wifi_direct_get_next_message() result = ::OfflineProtocol.rust_call(:uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_get_next_message,uniffi_clone_handle(),) return result.consume_into_OptionalTypeWifiDirectMessage end + def wifi_direct_identifiers() + result = ::OfflineProtocol.rust_call(:uniffi_offline_protocol_uniffi_fn_method_offlineprotocol_wifi_direct_identifiers,uniffi_clone_handle(),) + return result.consume_into_OptionalTypeWifiDirectIdentifiers + end def wifi_direct_message_received(sender_id, data) sender_id = ::OfflineProtocol::uniffi_utf8(sender_id) diff --git a/bindings/web/src/bluetooth.js b/bindings/web/src/bluetooth.js index 89006ecaf..e8e61c756 100644 --- a/bindings/web/src/bluetooth.js +++ b/bindings/web/src/bluetooth.js @@ -65,6 +65,7 @@ export class BluetoothCarrier extends EventTarget { // peer -> link: { device, peer, message, queue, inbound, linked, announced, gone, onDisconnect, onNotify } #links = new Map(); #tag = null; + #keyed = null; #released = false; // device -> the handshake in flight for it, which a second connect joins. #connecting = new Map(); @@ -244,11 +245,14 @@ export class BluetoothCarrier extends EventTarget { // The chapter's instance choice: with one instance, that one; with several, // among those serving both Device id and Identity, the first whose App tag - // is this application's, else the only untagged one, else the first. + // is this application's, else the only untagged one, else the first. With + // a publisher key "this application's" is per instance: the engine's tag + // over that instance's Device id, which is read only to check it. async #choose(services) { if (services.length === 0) throw new Error("the device does not offer the SDK's service"); if (services.length === 1) return services[0]; - this.#tag ??= await this.#node.bleAppTag(); + this.#keyed ??= await this.#node.bleAppTagIsKeyed(); + if (!this.#keyed) this.#tag ??= await this.#node.bleAppTag(); const candidates = []; for (const service of services) { const chars = new Map((await characteristics(service)).map((c) => [c.uuid.toLowerCase(), c])); @@ -259,10 +263,23 @@ export class BluetoothCarrier extends EventTarget { } catch { tag = null; } - candidates.push({ service, tag: tag?.length ? tag : null }); + tag = tag?.length ? tag : null; + let ours = null; + if (tag && this.#keyed) { + try { + const deviceId = new TextDecoder("utf-8", { fatal: true }).decode(await chars.get(DEVICE_ID).readValue()); + ours = await this.#node.bleKeyedAppTag(deviceId); + } catch { + ours = null; + } + } else if (tag) { + ours = this.#tag; + } + candidates.push({ service, tag, ours }); } if (candidates.length === 0) return services[0]; - const ours = candidates.find(({ tag }) => tag && tag.length === this.#tag.length && tag.every((b, i) => b === this.#tag[i])); + const same = (a, b) => a && b && a.length === b.length && a.every((x, i) => x === b[i]); + const ours = candidates.find(({ tag, ours }) => same(tag, ours)); if (ours) return ours.service; const untagged = candidates.filter(({ tag }) => tag === null); if (untagged.length === 1) return untagged[0].service; diff --git a/bindings/web/src/index.d.ts b/bindings/web/src/index.d.ts index 9bb80286e..8c6ad1dae 100644 --- a/bindings/web/src/index.d.ts +++ b/bindings/web/src/index.d.ts @@ -228,6 +228,10 @@ export class OfflineNode extends EventTarget { /** BluetoothCarrier's half in the worker (W14). */ bleHello(): Promise; bleAppTag(): Promise; + /** Whether the App tag is keyed by the application's publisher key (ADR 0037). */ + bleAppTagIsKeyed(): Promise; + /** The keyed App tag an instance with this Device id serves; null without a publisher key. */ + bleKeyedAppTag(deviceId: string): Promise; bleVerify(deviceId: Uint8Array, identity: Uint8Array): Promise; bleLinkUp(peer: string, maxPayload: number): Promise; bleFragmentReceived(peer: string, bytes: Uint8Array): Promise; diff --git a/bindings/web/src/index.js b/bindings/web/src/index.js index 8ded84284..c9805172f 100644 --- a/bindings/web/src/index.js +++ b/bindings/web/src/index.js @@ -320,6 +320,14 @@ export class OfflineNode extends EventTarget { return this.#call("bleAppTag"); } + bleAppTagIsKeyed() { + return this.#call("bleAppTagIsKeyed"); + } + + bleKeyedAppTag(deviceId) { + return this.#call("bleKeyedAppTag", { deviceId }); + } + bleVerify(deviceId, identity) { return this.#call("bleVerify", { deviceId, identity }); } diff --git a/bindings/web/src/worker.js b/bindings/web/src/worker.js index 0f2f271ce..6c40e6568 100644 --- a/bindings/web/src/worker.js +++ b/bindings/web/src/worker.js @@ -33,7 +33,6 @@ import init, { signalEvent, signalOpen, parseInvite, - NearbyRoomKeys, bleAppTag, } from "../pkg/offline_protocol_web.js"; import { openDurableStore } from "./store.js"; @@ -305,7 +304,9 @@ const handlers = { // sockets and the STUN lookup; these derive, seal and open. nearbyOpen({ network, label = "" }) { if (nearby.size >= MAX_NEARBY_ROOMS) throw new Error(`${MAX_NEARBY_ROOMS} nearby rooms are already open`); - const room = new NearbyRoomKeys(startedAppId, network, label); + // From the engine, which derives the room from the application key when + // the application has a publisher key, and from its id otherwise. + const room = engine.nearbyRoom(network, label); const id = nextNearby++; nearby.set(id, room); return { id, key: room.key }; @@ -338,6 +339,14 @@ const handlers = { bleAppTag() { return bleAppTag(startedAppId); }, + // With a publisher key the tag is per instance, over its Device id + // (ADR 0037); the engine is its one implementation. + bleAppTagIsKeyed() { + return engine.bleAppTagIsKeyed(); + }, + bleKeyedAppTag({ deviceId }) { + return engine.bleKeyedAppTag(deviceId) ?? null; + }, bleVerify({ deviceId, identity }) { return engine.bleVerify(deviceId, identity); }, diff --git a/crates/offline-protocol-sealed/src/membership.rs b/crates/offline-protocol-sealed/src/membership.rs index 6cb5756af..db8bfbd41 100644 --- a/crates/offline-protocol-sealed/src/membership.rs +++ b/crates/offline-protocol-sealed/src/membership.rs @@ -83,6 +83,46 @@ pub const MEMBER_E2E_DOMAIN: &[u8] = b"offline-app-member-e2e-v1"; /// shape that lets an output of one stand in for an input of the other. pub const MEMBER_E2E_KEY_INFO: &[u8] = b"offline-app-member-e2e-key-v1"; +/// HMAC label of the Bluetooth LE App tag a configured application serves. +/// +/// `tag = HMAC-SHA256(K_app, ble_app_tag_input(device_id))[..8]`. The other +/// identifiers below are HKDF outputs; this one is a MAC because a central +/// recomputes it for each Device id it reads. +pub const BLE_APP_TAG_LABEL: &[u8] = b"offline-app-ble-tag-v1"; + +/// Length of the Bluetooth LE App tag, in bytes, keyed or not. +pub const BLE_APP_TAG_LEN: usize = 8; + +/// HKDF `info` of the value a configured application advertises under the +/// `app` key of its Android Wi-Fi Direct service record. +pub const WIFI_DIRECT_APP_INFO: &[u8] = b"offline-app-wifi-direct-record-v1"; + +/// HKDF `info` of a configured application's Android Wi-Fi Direct group name. +pub const WIFI_DIRECT_NETWORK_INFO: &[u8] = b"offline-app-wifi-direct-network-v1"; + +/// HKDF `info` of a configured application's Android Wi-Fi Direct group +/// passphrase. +pub const WIFI_DIRECT_PASSPHRASE_INFO: &[u8] = b"offline-app-wifi-direct-passphrase-v1"; + +/// HKDF `info` of the secret a configured application's browser nearby room +/// is derived from, in place of the application id. +pub const WEB_NEARBY_INFO: &[u8] = b"offline-app-web-nearby-v1"; + +/// The HMAC input of a configured application's Bluetooth LE App tag: +/// [`BLE_APP_TAG_LABEL`] followed by the instance's Device id, the `off1…` +/// address exactly as the Device id characteristic serves it (UTF-8). +/// +/// The rendering, not the 21-byte payload, because a central computes the tag +/// from the bytes it just read, before anything has proved or parsed them; a +/// Device id that is not an address simply matches no tag. The label is fixed +/// and the Device id last, so the concatenation has one parse. +pub fn ble_app_tag_input(device_id: &str) -> Vec { + let mut out = Vec::with_capacity(BLE_APP_TAG_LABEL.len() + device_id.len()); + out.extend_from_slice(BLE_APP_TAG_LABEL); + out.extend_from_slice(device_id.as_bytes()); + out +} + /// Length of an exchange nonce, in bytes. pub const MEMBER_NONCE_LEN: usize = 16; @@ -659,7 +699,7 @@ mod tests { /// signature or key from one step stand in for another. #[test] fn the_labels_are_pinned_and_distinct() { - let labels: [&[u8]; 7] = [ + let labels: [&[u8]; 12] = [ APP_KEY_INFO, MEMBER_CERT_DOMAIN, MEMBER_HELLO_LABEL, @@ -667,7 +707,23 @@ mod tests { MEMBER_TRANSCRIPT_DOMAIN, MEMBER_E2E_DOMAIN, MEMBER_E2E_KEY_INFO, + BLE_APP_TAG_LABEL, + WIFI_DIRECT_APP_INFO, + WIFI_DIRECT_NETWORK_INFO, + WIFI_DIRECT_PASSPHRASE_INFO, + WEB_NEARBY_INFO, ]; + assert_eq!(BLE_APP_TAG_LABEL, b"offline-app-ble-tag-v1"); + assert_eq!(WIFI_DIRECT_APP_INFO, b"offline-app-wifi-direct-record-v1"); + assert_eq!( + WIFI_DIRECT_NETWORK_INFO, + b"offline-app-wifi-direct-network-v1" + ); + assert_eq!( + WIFI_DIRECT_PASSPHRASE_INFO, + b"offline-app-wifi-direct-passphrase-v1" + ); + assert_eq!(WEB_NEARBY_INFO, b"offline-app-web-nearby-v1"); assert_eq!(APP_KEY_INFO, b"offline-protocol-app-v1"); assert_eq!(MEMBER_CERT_DOMAIN, b"offline-app-member-v1"); assert_eq!(MEMBER_HELLO_LABEL, b"offline-app-member-hello-v1"); @@ -685,6 +741,16 @@ mod tests { } } + #[test] + fn the_ble_app_tag_input_is_the_label_then_the_device_id_as_read() { + use alloc::string::ToString; + let device_id = addr(3).to_string(); + let input = ble_app_tag_input(&device_id); + assert_eq!(&input[..BLE_APP_TAG_LABEL.len()], BLE_APP_TAG_LABEL); + assert_eq!(&input[BLE_APP_TAG_LABEL.len()..], device_id.as_bytes()); + assert_eq!(ble_app_tag_input(""), BLE_APP_TAG_LABEL); + } + #[test] fn a_certificate_round_trips_and_refuses_other_lengths() { let cert = MemberCert { @@ -971,6 +1037,7 @@ mod vectors { "exchange", "proof", "provision", + "identifiers", ] { assert!(v[block].is_object(), "{block} is present"); } @@ -1131,6 +1198,51 @@ mod vectors { assert_eq!(proof.to_bytes().to_vec(), bytes); } + /// The labels and inputs of what a configured application shows on the + /// air. The engine and the browser crate run the primitives; this pins + /// what they run them over. + #[test] + fn the_identifiers_match() { + let v = vectors(); + let k_app = unhex(field(&v, &["app_key", "k_app_hex"])); + for key in ["a", "b"] { + let input = ble_app_tag_input(field(&v, &["addresses", key])); + assert_eq!( + hex(&input), + field( + &v, + &["identifiers", "ble_app_tag", &format!("{key}_input_hex")] + ) + ); + let mut mac = hmac::Hmac::::new_from_slice(&k_app).expect("any key length"); + mac.update(&input); + assert_eq!( + hex(&mac.finalize().into_bytes()[..BLE_APP_TAG_LEN]), + field( + &v, + &["identifiers", "ble_app_tag", &format!("{key}_tag_hex")] + ) + ); + } + let wd = |info: &[u8], n: usize| hex(&hkdf(&k_app, None, info)[..n]); + assert_eq!( + wd(WIFI_DIRECT_APP_INFO, 4), + field(&v, &["identifiers", "wifi_direct", "record_app"]) + ); + assert_eq!( + format!("DIRECT-op-{}", wd(WIFI_DIRECT_NETWORK_INFO, 4)), + field(&v, &["identifiers", "wifi_direct", "network_name"]) + ); + assert_eq!( + wd(WIFI_DIRECT_PASSPHRASE_INFO, 16), + field(&v, &["identifiers", "wifi_direct", "passphrase"]) + ); + assert_eq!( + hex(&hkdf(&k_app, None, WEB_NEARBY_INFO)), + field(&v, &["identifiers", "web_nearby", "secret_hex"]) + ); + } + #[test] fn the_provisioning_body_matches() { let v = vectors(); diff --git a/crates/offline-protocol-sealed/tests/data/app-membership-v1.vectors.json b/crates/offline-protocol-sealed/tests/data/app-membership-v1.vectors.json index a03d181f9..7e4273faf 100644 --- a/crates/offline-protocol-sealed/tests/data/app-membership-v1.vectors.json +++ b/crates/offline-protocol-sealed/tests/data/app-membership-v1.vectors.json @@ -71,6 +71,25 @@ "key_hex": "168ab14402b4425a73089cc0c1b0dbc90b689847926c210f1de4e4cbd6e268d2", "bytes_hex": "5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5aababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" }, + "identifiers": { + "note": "What a configured application shows on the air in place of anything derived from its application id. The Bluetooth LE App tag is per instance, over the Device id exactly as served; the others are one per application.", + "ble_app_tag": { + "a_input_hex": "6f66666c696e652d6170702d626c652d7461672d76316f6666317179756c7779377335657a7a323063793232327a727730347277647333397561707176396a387230", + "a_tag_hex": "dc5068c949f8ebd5", + "b_input_hex": "6f66666c696e652d6170702d626c652d7461672d76316f6666317138647671756c717a676161616676616d78656d6d32773076716d6c76776b327367787678306c6b", + "b_tag_hex": "d909530d6fca379f" + }, + "wifi_direct": { + "record_app": "cab8bc31", + "network_name": "DIRECT-op-c2509ed0", + "passphrase": "f2643519ea972fc2f6ec30d05ff24c45" + }, + "web_nearby": { + "secret_hex": "b0c14541d221131d3cba1c8ce23d3accb033aa5c13019f30e8e6b1d6b6324a66", + "scope": "v4/203.0.113.7", + "topic_hex": "80a19a5eafffc8b1d9150102afc1f0d5" + } + }, "rejects": [ { "name": "version_2", diff --git a/crates/offline-protocol-uniffi/src/capi/generated.rs b/crates/offline-protocol-uniffi/src/capi/generated.rs index 1fca4c390..1e1803f92 100644 --- a/crates/offline-protocol-uniffi/src/capi/generated.rs +++ b/crates/offline-protocol-uniffi/src/capi/generated.rs @@ -44,6 +44,35 @@ impl Codec for Vec { } } +impl Codec for crate::WifiDirectIdentifiers { + fn decode(value: &Value, at: &str) -> Decoded { + let mut f = Fields::new(value, at, "WifiDirectIdentifiers")?; + let out = Self { + record_app: f.take("record_app")?, + network_name: f.take("network_name")?, + passphrase: f.take("passphrase")?, + }; + f.finish()?; + Ok(out) + } + fn encode(&self) -> Value { + object(vec![ + ("record_app", self.record_app.encode()), + ("network_name", self.network_name.encode()), + ("passphrase", self.passphrase.encode()), + ]) + } +} + +impl Codec for Vec { + fn decode(value: &Value, at: &str) -> Decoded { + decode_array(value, at) + } + fn encode(&self) -> Value { + encode_array(self) + } +} + impl Codec for crate::InviteInfo { fn decode(value: &Value, at: &str) -> Decoded { let mut f = Fields::new(value, at, "InviteInfo")?; @@ -3127,6 +3156,17 @@ pub fn call_engine(target: &crate::OfflineProtocol, method: &str, mut args: Fiel let result = target.provision_leaf_membership(arg_peer_id, arg_cert)?; Ok(result.encode()) } + "ble_app_tag" => { + let arg_device_id = args.take("device_id")?; + args.finish()?; + let result = target.ble_app_tag(arg_device_id); + Ok(result.encode()) + } + "wifi_direct_identifiers" => { + args.finish()?; + let result = target.wifi_direct_identifiers(); + Ok(result.encode()) + } "initialize_mls_with_file_stores" => { let arg_mls_root = args.take("mls_root")?; let arg_state_root = args.take("state_root")?; diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 47f27e08b..ba466c8a9 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -177,6 +177,34 @@ pub fn sign_app_membership( .map_err(ProtocolError::from) } +/// See the UDL dictionary. `Debug` leaves the passphrase out. +#[derive(Clone, PartialEq, Eq)] +pub struct WifiDirectIdentifiers { + pub record_app: String, + pub network_name: String, + pub passphrase: String, +} + +impl std::fmt::Debug for WifiDirectIdentifiers { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WifiDirectIdentifiers") + .field("record_app", &self.record_app) + .field("network_name", &self.network_name) + .field("passphrase", &"") + .finish() + } +} + +impl From for WifiDirectIdentifiers { + fn from(ids: offline_protocol::WifiDirectIdentifiers) -> Self { + Self { + record_app: ids.record_app, + network_name: ids.network_name, + passphrase: ids.passphrase, + } + } +} + /// See the UDL dictionary. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AppMembershipStatus { @@ -2719,6 +2747,10 @@ pub struct OfflineProtocol { /// Which transports this instance was configured with, so they can be /// rebuilt against the derived address once `initialize_mls` knows it. enabled_transports: EnabledTransports, + /// What this application shows on the air (ADR 0037), copied out of the + /// engine at construction. The configuration fixes it, and the GATT and + /// Wi-Fi Direct paths that read it must not wait on [`Self::inner`]. + app_identifiers: offline_protocol::AppIdentifiers, /// Transport callbacks held here rather than only on the transport /// object, so `rebuild_transports_for_identity` can re-apply them. /// @@ -2895,8 +2927,10 @@ impl OfflineProtocol { } }); + let app_identifiers = protocol.app_identifiers(); Ok(Self { inner: Mutex::new(protocol), + app_identifiers, lock_telemetry: LockTelemetry::default(), state: RwLock::new(ProtocolState::Stopped), event_callback, @@ -6940,6 +6974,25 @@ impl OfflineProtocol { .map_err(ProtocolError::from) } + /// The Bluetooth LE App tag this application's instance at `device_id` + /// serves, keyed by `K_app`; `None` without a publisher key (ADR 0037). + /// + /// Read from the copy taken at construction, never through the engine + /// lock: the Android peripheral answers its App tag read on a binder + /// thread, where waiting on the engine stalls every GATT operation in the + /// process. + pub fn ble_app_tag(&self, device_id: String) -> Option> { + self.app_identifiers + .ble_app_tag(&device_id) + .map(|tag| tag.to_vec()) + } + + /// The Android Wi-Fi Direct identifiers keyed by `K_app`; `None` without + /// a publisher key (ADR 0037). Lock-free, as [`Self::ble_app_tag`]. + pub fn wifi_direct_identifiers(&self) -> Option { + self.app_identifiers.wifi_direct().map(Into::into) + } + /// Gets the number of pending ACKs. pub fn get_pending_ack_count(&self) -> u64 { let protocol = self.lock_inner_recovering(); @@ -9374,6 +9427,42 @@ mod tests { assert!(protocol.app_membership_status().configured); } + /// Without a publisher key nothing is derived and every bridge keeps the + /// application id's values; with one, the FFI hands out the engine's own + /// derivation, and answers while another thread holds the engine lock. + #[test] + fn the_app_identifiers_cross_the_ffi_only_when_configured() { + let protocol = OfflineProtocol::new(create_test_config()).expect("a protocol"); + assert_eq!(protocol.ble_app_tag("off1anything".to_string()), None); + assert_eq!(protocol.wifi_direct_identifiers(), None); + + let key = [7u8; 32]; + let mut config = create_test_config(); + let app_id = config.app_id.clone(); + config.app_publisher_key = Some(key.to_vec()); + let protocol = OfflineProtocol::new(config).expect("a protocol"); + let expected = offline_protocol::AppIdentifiers::new(Some(&key), &app_id); + let device_id = derive_address(vec![1u8; 32]).expect("an address"); + + let _held = protocol.inner.lock().expect("the engine lock"); + let tag = protocol + .ble_app_tag(device_id.clone()) + .expect("a keyed tag"); + assert_eq!(tag.len(), 8); + assert_eq!( + Some(tag.as_slice()), + expected.ble_app_tag(&device_id).as_ref().map(|t| &t[..]) + ); + let wd = protocol + .wifi_direct_identifiers() + .expect("keyed Wi-Fi Direct identifiers"); + assert_eq!( + Some(wd.clone()), + expected.wifi_direct().map(WifiDirectIdentifiers::from) + ); + assert!(!format!("{wd:?}").contains(&wd.passphrase)); + } + #[test] fn sign_app_membership_crosses_the_ffi() { let address = derive_address(vec![1u8; 32]).expect("an address"); @@ -16274,6 +16363,51 @@ mod tests { 1, "CentralGattClient.kt must choose an instance in exactly one place" ); + + // --- 4. A keyed tag is the engine's, served and checked per instance -- + // + // With a publisher key (ADR 0037) the tag is HMAC(K_app, device_id), + // and the engine is its one implementation: a hand mirror of an HMAC + // on a bridge is a third copy no vector would catch drifting before a + // member reads its own application's phone as a stranger's. + let tag_mirrors = [ + rn_source_code_only("ios/BleAppTag.swift"), + rn_source_code_only("android/src/main/java/com/offlineprotocol/BleAppTag.kt"), + ]; + for mirror in &tag_mirrors { + assert!( + !mirror.contains("HMAC") && !mirror.contains("Mac.getInstance"), + "BleAppTag must not compute the keyed tag: the bridges ask the engine \ + (`bleAppTag`), which derives it from K_app" + ); + } + assert!( + swift.contains("protocolInstance.bleAppTag(deviceId:") + && swift.contains("keyedAppTag(for: advertisedAddress)") + && swift.contains("BleServiceInstanceSelection.selectKeyed("), + "BleManager.swift must serve the engine's keyed tag over its own advertised address \ + and choose with selectKeyed when the application has a publisher key" + ); + assert!( + facade.contains("protocol.bleAppTag(") && facade.contains("keyedAppTag = keyedAppTag"), + "BleTransportFacade.kt must take the keyed tag from the engine and hand it to the \ + central client" + ); + assert!( + kotlin.contains("BleServiceInstanceSelection.selectKeyed(") + && kotlin.contains( + "charUuid == deviceIdCharUuid && serviceInstanceProbes.containsKey(address)" + ), + "CentralGattClient.kt must read each tagged instance's DEVICE_ID while choosing with \ + a keyed tag, route that read to the choice rather than the handshake, and choose \ + with selectKeyed" + ); + assert!( + rn_source_code_only("android/src/main/java/com/offlineprotocol/WifiDirectManager.kt") + .contains("protocol.wifiDirectIdentifiers()"), + "WifiDirectManager.kt must take a configured application's group name, passphrase \ + and record value from the engine (ADR 0037), not from the application id" + ); } /// State restoration must not issue CoreBluetooth commands where iOS hands diff --git a/crates/offline-protocol-uniffi/src/offline_protocol.udl b/crates/offline-protocol-uniffi/src/offline_protocol.udl index 0ea2dd9d8..86695dc6f 100644 --- a/crates/offline-protocol-uniffi/src/offline_protocol.udl +++ b/crates/offline-protocol-uniffi/src/offline_protocol.udl @@ -96,6 +96,18 @@ dictionary AppMembershipStatus { u32 visible_members; }; +// What a configured application advertises over Android Wi-Fi Direct in +// place of the values derived from its application id (ADR 0037). One per +// application: every member forms and joins the same group. +dictionary WifiDirectIdentifiers { + // The value under the service record's `app` key: eight hex digits. + string record_app; + // The group name: DIRECT-op- and eight hex digits. + string network_name; + // The group passphrase: 32 hex digits. A secret: never log it. + string passphrase; +}; + // A decoded and verified invite. dictionary InviteInfo { // The address this invite reaches, canonical "off1...". @@ -1795,6 +1807,20 @@ interface OfflineProtocol { // session: it never starts one. [Throws=ProtocolError] string provision_leaf_membership(string peer_id, sequence cert); + // The Bluetooth LE App tag an instance of this application serves when + // its Device id is device_id (8 bytes), keyed by the application key: + // a peripheral serves it over its own address, and a central that finds + // several instances on one link takes the first whose App tag equals it + // over that instance's Device id. Null without + // ProtocolConfig.app_publisher_key: serve and compare the application + // id's tag, as before. Fixed by the configuration and answered without + // the engine lock, so a GATT read callback may call it. + sequence? ble_app_tag(string device_id); + // The Android Wi-Fi Direct group name, passphrase and record value, + // keyed by the application key. Null without + // ProtocolConfig.app_publisher_key: derive them from the application id, + // as before. Answered without the engine lock. + WifiDirectIdentifiers? wifi_direct_identifiers(); // ======================================================================== // MLS (END-TO-END ENCRYPTION) OPERATIONS diff --git a/crates/offline-protocol-web/src/bindings.rs b/crates/offline-protocol-web/src/bindings.rs index f000054f7..26c6b67cf 100644 --- a/crates/offline-protocol-web/src/bindings.rs +++ b/crates/offline-protocol-web/src/bindings.rs @@ -240,6 +240,7 @@ impl WebEngine { username_discovery, }), ble, + ..NodeOptions::default() }; Ok(Self { node: Node::with_options(app_id, profile, options).map_err(js)?, @@ -501,6 +502,36 @@ impl WebEngine { self.node.ble_hello().map_err(js) } + /// Whether this application's App tag is keyed by its publisher key + /// (ADR 0037): then a central with several instances to choose from + /// reads each tagged instance's Device id and compares its tag with + /// [`WebEngine::ble_keyed_app_tag`] over it, in place of [`ble_app_tag`]. + #[wasm_bindgen(js_name = bleAppTagIsKeyed)] + pub fn ble_app_tag_is_keyed(&self) -> bool { + self.node.ble_app_tag_is_keyed() + } + + /// The App tag this application's instance with Device id `device_id` + /// serves, keyed by the application key; `undefined` without a publisher + /// key. + #[wasm_bindgen(js_name = bleKeyedAppTag)] + pub fn ble_keyed_app_tag(&self, device_id: &str) -> Option> { + self.node + .ble_keyed_app_tag(device_id) + .map(|tag| tag.to_vec()) + } + + /// Joins the nearby room behind `network` for this engine's application + /// (W13), under `label`: from the application key when the application + /// has a publisher key (ADR 0037), from the application id otherwise. + #[wasm_bindgen(js_name = nearbyRoom)] + pub fn nearby_room(&self, network: &str, label: &str) -> Result { + self.node + .nearby_room(network, label) + .map(|inner| WebNearby { inner }) + .map_err(|e| JsError::new(&e)) + } + /// Checks a peripheral's Device id and Identity values; returns the /// address the identity proves, which must equal the Device id. #[wasm_bindgen(js_name = bleVerify)] diff --git a/crates/offline-protocol-web/src/ble_host.rs b/crates/offline-protocol-web/src/ble_host.rs index a99282d54..8aaa6d1c3 100644 --- a/crates/offline-protocol-web/src/ble_host.rs +++ b/crates/offline-protocol-web/src/ble_host.rs @@ -80,6 +80,25 @@ pub(crate) struct BleHost { } impl Node { + /// The App tag an instance of this application serves when its Device id + /// is `device_id`, keyed by the application key ([ADR 0037]); `None` + /// without a publisher key, when the page compares [`app_tag`] instead. + /// + /// The page reads a peripheral's Device id on each tagged instance only + /// when the tag is keyed, because then the tag cannot be checked without + /// it. + /// + /// [ADR 0037]: https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0037-an-applications-users-are-visible-only-to-that-application.md + pub fn ble_keyed_app_tag(&self, device_id: &str) -> Option<[u8; APP_TAG_BYTES]> { + self.protocol.app_identifiers().ble_app_tag(device_id) + } + + /// Whether [`Self::ble_keyed_app_tag`] answers: the application has a + /// publisher key. + pub fn ble_app_tag_is_keyed(&self) -> bool { + self.protocol.app_identifiers().is_keyed() + } + /// This device's identity assertion, for the page to write to a /// peripheral's Hello characteristic. pub fn ble_hello(&self) -> NodeResult> { @@ -282,6 +301,49 @@ mod tests { bytes.iter().map(|b| format!("{b:02x}")).collect() } + /// Without a publisher key nothing is keyed and the page compares the + /// application id's tag; with one the node answers the engine's keyed + /// tag for any Device id, the one the membership vectors pin. + #[test] + fn the_keyed_app_tag_is_the_engines_and_only_with_a_publisher_key() { + let unkeyed = Node::new("web-ble-test", "unkeyed").expect("node"); + assert!(!unkeyed.ble_app_tag_is_keyed()); + assert_eq!(unkeyed.ble_keyed_app_tag("off1anything"), None); + + let v: serde_json::Value = serde_json::from_str(include_str!( + "../../offline-protocol-sealed/tests/data/app-membership-v1.vectors.json" + )) + .unwrap(); + let text = |path: &[&str]| -> String { + path.iter() + .fold(&v, |v, key| &v[*key]) + .as_str() + .unwrap() + .to_string() + }; + let pk = text(&["app_key", "publisher_public_key_hex"]); + let pk: [u8; 32] = (0..32) + .map(|i| u8::from_str_radix(&pk[2 * i..2 * i + 2], 16).unwrap()) + .collect::>() + .try_into() + .unwrap(); + let options = crate::node::NodeOptions { + ble: true, + app_publisher_key: Some(pk), + ..Default::default() + }; + let keyed = + Node::with_options(&text(&["app_key", "app_id"]), "keyed", options).expect("node"); + assert!(keyed.ble_app_tag_is_keyed()); + let tag = keyed + .ble_keyed_app_tag(&text(&["addresses", "a"])) + .expect("keyed"); + assert_eq!( + hex(&tag), + text(&["identifiers", "ble_app_tag", "a_tag_hex"]) + ); + } + /// The vectors iOS and Android pin (`BleAppTagTests.swift`, /// `BleAppTagTest.kt`): only shared vectors prove the copies agree. #[test] diff --git a/crates/offline-protocol-web/src/nearby.rs b/crates/offline-protocol-web/src/nearby.rs index 9a4185b82..2d23978d5 100644 --- a/crates/offline-protocol-web/src/nearby.rs +++ b/crates/offline-protocol-web/src/nearby.rs @@ -148,12 +148,31 @@ impl Nearby { /// `label`. Refuses an address that does not parse, a label over /// [`MAX_LABEL_CHARS`], and one with control characters. pub fn new(app_id: &str, network: &str, label: &str) -> Result { + Self::with_secret(None, app_id, network, label) + } + + /// As [`Self::new`], for an application with a publisher key when + /// `secret` is set: the room is derived from that secret in place of the + /// application id ([ADR 0037]), so its topic and key name nothing for + /// another application's developer. The secret is + /// `AppIdentifiers::web_nearby_secret`, from the application key. + /// + /// [ADR 0037]: https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0037-an-applications-users-are-visible-only-to-that-application.md + pub fn with_secret( + secret: Option<&[u8; 32]>, + app_id: &str, + network: &str, + label: &str, + ) -> Result { if app_id.is_empty() { return Err("a nearby room needs the application id".to_string()); } check_label(label)?; let scope = network_scope(network)?; - let mut ikm = app_id.as_bytes().to_vec(); + let mut ikm = Zeroizing::new(match secret { + Some(secret) => secret.to_vec(), + None => app_id.as_bytes().to_vec(), + }); ikm.push(0); ikm.extend_from_slice(scope.as_bytes()); let hkdf = Hkdf::::new(Some(HKDF_SALT), &ikm); @@ -433,6 +452,21 @@ pub fn now_unix_secs() -> i64 { signal::now_unix_secs() } +impl crate::node::Node { + /// This node's room behind `network`, under `label`: from the + /// application key when the application has a publisher key, from its + /// application id otherwise ([`Nearby::with_secret`]). + pub fn nearby_room(&self, network: &str, label: &str) -> Result { + let secret = self.protocol.app_identifiers().web_nearby_secret(); + Nearby::with_secret( + secret.as_deref(), + &self.protocol.config().app_id, + network, + label, + ) + } +} + #[cfg(test)] mod tests { use super::*; @@ -468,6 +502,75 @@ mod tests { ); } + /// An application with no publisher key keeps the room it had: the topic + /// below was computed outside this crate from the application id. + #[test] + fn an_unkeyed_room_is_the_application_ids() { + assert_eq!( + room("203.0.113.7", "").topic(), + "c2c8272b730b83de6fc04b6bd51b4989" + ); + assert_eq!( + Nearby::with_secret(None, APP, "203.0.113.7", "") + .unwrap() + .topic(), + room("203.0.113.7", "").topic() + ); + } + + /// With a publisher key the room comes from the application key, so its + /// topic is the one the membership vectors pin, and it is not the room + /// of the application id: another application's developer, who knows the + /// id, does not find it. + #[test] + fn a_keyed_room_is_the_application_keys() { + let v: serde_json::Value = serde_json::from_str(include_str!( + "../../offline-protocol-sealed/tests/data/app-membership-v1.vectors.json" + )) + .unwrap(); + let text = |path: &[&str]| -> String { + path.iter() + .fold(&v, |v, key| &v[*key]) + .as_str() + .unwrap() + .to_string() + }; + let unhex32 = |hex: String| -> [u8; 32] { + (0..32) + .map(|i| u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).unwrap()) + .collect::>() + .try_into() + .unwrap() + }; + let app_id = text(&["app_key", "app_id"]); + let secret = unhex32(text(&["identifiers", "web_nearby", "secret_hex"])); + let keyed = Nearby::with_secret(Some(&secret), &app_id, "203.0.113.7", "").unwrap(); + assert_eq!( + keyed.topic(), + text(&["identifiers", "web_nearby", "topic_hex"]) + ); + assert_ne!( + keyed.topic(), + Nearby::new(&app_id, "203.0.113.7", "").unwrap().topic() + ); + + // The engine derives the same secret from its publisher key. + let options = crate::node::NodeOptions { + app_publisher_key: Some(unhex32(text(&["app_key", "publisher_public_key_hex"]))), + ..Default::default() + }; + let node = crate::node::Node::with_options(&app_id, "keyed", options).unwrap(); + assert_eq!( + node.nearby_room("203.0.113.7", "").unwrap().topic(), + keyed.topic() + ); + let unkeyed = crate::node::Node::new(&app_id, "unkeyed").unwrap(); + assert_eq!( + unkeyed.nearby_room("203.0.113.7", "").unwrap().topic(), + Nearby::new(&app_id, "203.0.113.7", "").unwrap().topic() + ); + } + #[test] fn another_network_or_application_is_another_room() { let a = room("203.0.113.7", ""); diff --git a/crates/offline-protocol-web/src/node.rs b/crates/offline-protocol-web/src/node.rs index e12d37fa7..1606de405 100644 --- a/crates/offline-protocol-web/src/node.rs +++ b/crates/offline-protocol-web/src/node.rs @@ -122,6 +122,12 @@ pub struct NodeOptions { pub nostr: Option, /// The Bluetooth LE slot, driven from the page over Web Bluetooth. pub ble: bool, + /// The application's publisher key ([ADR 0037]): the Bluetooth LE App + /// tag and the nearby room are then derived from the application key. + /// Not yet reachable from the page, which configures no membership. + /// + /// [ADR 0037]: https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0037-an-applications-users-are-visible-only-to-that-application.md + pub app_publisher_key: Option<[u8; 32]>, } struct Link { @@ -177,6 +183,7 @@ impl Node { config.transport.nostr_username_discovery_enabled = nostr.username_discovery; } config.transport.wifi_direct_enabled = true; + config.app_publisher_key = options.app_publisher_key; let mut protocol = OfflineProtocol::new(config)?; // A placeholder id until `initialize` derives the address, exactly as diff --git a/crates/offline-protocol-web/src/nostr_tests.rs b/crates/offline-protocol-web/src/nostr_tests.rs index c17469e87..242f5cc6d 100644 --- a/crates/offline-protocol-web/src/nostr_tests.rs +++ b/crates/offline-protocol-web/src/nostr_tests.rs @@ -395,6 +395,7 @@ fn liveness_reported_before_an_address_is_not_kept() { let options = NodeOptions { ble: false, nostr: Some(NostrOptions::default()), + ..Default::default() }; let mut node = Node::with_options("web-nostr-test", "early", options).expect("node"); assert!(node.nostr_status_changed(true).is_err()); @@ -548,6 +549,7 @@ fn nostr_node(cold_contact: bool, username_discovery: bool, profile: &str) -> No cold_contact, username_discovery, }), + ..Default::default() }; let mut node = Node::with_options("web-nostr-test", profile, options).expect("node"); node.initialize().expect("initialize"); diff --git a/crates/offline-protocol-web/src/tests.rs b/crates/offline-protocol-web/src/tests.rs index 0c25ab44e..af4faf100 100644 --- a/crates/offline-protocol-web/src/tests.rs +++ b/crates/offline-protocol-web/src/tests.rs @@ -801,6 +801,35 @@ fn the_bluetooth_carrier_names_the_contracts_uuids() { ); } +/// What a configured application shows on the air comes from the engine +/// (ADR 0037): the carrier asks it for the keyed App tag over each tagged +/// instance's Device id, and the worker opens a nearby room through the +/// engine, never from the application id alone. A page that kept computing +/// the room from the id would put a configured application in the room +/// anyone who knows the id can find. +#[test] +fn the_page_takes_keyed_identifiers_from_the_engine() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let (Some(carrier), Some(worker)) = ( + read_repo_file(&root.join("bindings/web/src/bluetooth.js")), + read_repo_file(&root.join("bindings/web/src/worker.js")), + ) else { + // A published .crate archive has no workspace around it. + return; + }; + assert!( + carrier.contains("this.#node.bleAppTagIsKeyed()") + && carrier.contains("this.#node.bleKeyedAppTag(deviceId)"), + "bluetooth.js must ask the engine for the keyed App tag over each instance's Device id" + ); + assert!( + worker.contains("engine.nearbyRoom(network, label)") + && !worker.contains("new NearbyRoomKeys("), + "worker.js must open a nearby room through the engine, which keys it by the \ + application key when there is one" + ); +} + /// No shipped file names a relay. The relays a node uses are the /// application's choice, through its configuration, and a URL in a doc, a /// doc comment or an example is a default by another name: it becomes the diff --git a/crates/offline-protocol/src/lib.rs b/crates/offline-protocol/src/lib.rs index 4ffa71edf..026dfbd59 100644 --- a/crates/offline-protocol/src/lib.rs +++ b/crates/offline-protocol/src/lib.rs @@ -68,6 +68,7 @@ pub use offline_protocol_services::{ pub use protocol::custody::{CustodyRefusal, CustodyStats}; pub use protocol::mesh_relay::{MeshRelayConfig, MeshRelayStats}; pub use protocol::{sign_app_membership, AppMembershipStatus}; +pub use protocol::{AppIdentifiers, WifiDirectIdentifiers}; pub use protocol::{ GatewayCarrier, MediaSendOptions, OfflineProtocol, PublishReport, SendMessageOptions, }; diff --git a/crates/offline-protocol/src/protocol/app_identifiers.rs b/crates/offline-protocol/src/protocol/app_identifiers.rs new file mode 100644 index 000000000..c044a56f3 --- /dev/null +++ b/crates/offline-protocol/src/protocol/app_identifiers.rs @@ -0,0 +1,292 @@ +//! What an application shows on the air, beside its frames: the Bluetooth LE +//! App tag, the Android Wi-Fi Direct group and its service record, and the +//! browser's nearby room ([ADR 0037], "Nothing in the clear"). +//! +//! # Invariants +//! +//! - **A configured application names itself with nothing but `K_app`.** +//! With [`crate::ProtocolConfig::app_publisher_key`] set, every value here +//! is derived from the application key, which a party without the +//! publisher key cannot compute, so none of them names the application to +//! another application's developer. Without a publisher key nothing here is +//! derived and each carrier keeps the value it derived from the +//! application id before this existed, byte for byte: every accessor +//! returns `None` and the bridge falls back to its own derivation. +//! - **One derivation, one label.** Each value is an HKDF or HMAC output +//! under a label of its own from `offline_protocol_sealed::membership`, so +//! no identifier shown on the air is also a key or a tag of the membership +//! exchange. +//! - **The App tag is per instance.** It is a MAC over the Device id the +//! instance serves, so two phones of one application serve two unrelated +//! tags, and it groups nothing for an observer. A central of the same +//! application recomputes it from the Device id it read. +//! - **Derived once, read without the engine.** The value is fixed by the +//! configuration, so a binding holds a copy and answers a GATT read on a +//! binder thread without taking the engine's lock. +//! +//! [ADR 0037]: https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0037-an-applications-users-are-visible-only-to-that-application.md + +use core::fmt; + +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use offline_protocol_sealed::membership::{ + ble_app_tag_input, APP_KEY_LEN, BLE_APP_TAG_LEN, WEB_NEARBY_INFO, WIFI_DIRECT_APP_INFO, + WIFI_DIRECT_NETWORK_INFO, WIFI_DIRECT_PASSPHRASE_INFO, +}; +use sha2::Sha256; +use zeroize::Zeroizing; + +use super::membership::derive_app_key; + +/// Bytes of the Wi-Fi Direct record value, rendered as hex. +const WIFI_DIRECT_RECORD_BYTES: usize = 4; + +/// Bytes of the Wi-Fi Direct group name after its prefix, rendered as hex. +const WIFI_DIRECT_NETWORK_BYTES: usize = 4; + +/// Bytes of the Wi-Fi Direct passphrase, rendered as hex: 32 digits, inside +/// WPA2's 8 to 63. +const WIFI_DIRECT_PASSPHRASE_BYTES: usize = 16; + +/// The prefix Android requires of a group name (`DIRECT-` and two +/// characters), and the one the application-id derivation uses. +const WIFI_DIRECT_NETWORK_PREFIX: &str = "DIRECT-op-"; + +/// What a configured application advertises over Android Wi-Fi Direct, in +/// place of the values the bridge derives from the application id. +#[derive(Clone, PartialEq, Eq)] +pub struct WifiDirectIdentifiers { + /// The value under the `app` key of the service record: eight hex digits. + pub record_app: String, + /// The group name: `DIRECT-op-` and eight hex digits. + pub network_name: String, + /// The group passphrase: 32 hex digits. + pub passphrase: String, +} + +impl fmt::Debug for WifiDirectIdentifiers { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WifiDirectIdentifiers") + .field("record_app", &self.record_app) + .field("network_name", &self.network_name) + .field("passphrase", &"") + .finish() + } +} + +/// The identifiers an application shows on the air, from its application +/// key when it has one. See the module documentation. +#[derive(Clone, Default)] +pub struct AppIdentifiers { + app_key: Option>, +} + +impl fmt::Debug for AppIdentifiers { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AppIdentifiers") + .field("keyed", &self.is_keyed()) + .finish() + } +} + +impl AppIdentifiers { + /// The identifiers for a configuration: keyed by `K_app` when + /// `publisher_key` is set, derived exactly as the membership exchange + /// derives it. + pub fn new(publisher_key: Option<&[u8; 32]>, app_id: &str) -> Self { + Self::from_app_key(publisher_key.map(|pk| derive_app_key(pk, app_id))) + } + + pub(crate) fn from_app_key(app_key: Option<[u8; APP_KEY_LEN]>) -> Self { + Self { + app_key: app_key.map(Zeroizing::new), + } + } + + /// Whether the application has a publisher key, so every accessor below + /// returns a value. + pub fn is_keyed(&self) -> bool { + self.app_key.is_some() + } + + /// The App tag an instance of this application serves when its Device id + /// is `device_id`: `HMAC-SHA256(K_app, label || device_id)[..8]`. + /// + /// A peripheral serves it over its own address; a central that finds + /// several instances on one link compares each instance's App tag with + /// this over that instance's Device id. `None` without a publisher key: + /// the bridge serves and compares the tag of the application id, as it + /// did before. + pub fn ble_app_tag(&self, device_id: &str) -> Option<[u8; BLE_APP_TAG_LEN]> { + let key = self.app_key.as_ref()?; + // HMAC takes a key of any length, so this never refuses one. + let mut mac = as Mac>::new_from_slice(key.as_ref()).ok()?; + mac.update(&ble_app_tag_input(device_id)); + let digest = mac.finalize().into_bytes(); + let mut tag = [0u8; BLE_APP_TAG_LEN]; + tag.copy_from_slice(&digest[..BLE_APP_TAG_LEN]); + Some(tag) + } + + /// The Android Wi-Fi Direct group and record values, or `None` without a + /// publisher key. + /// + /// One per application, as the application-id values are: every member + /// must find and join the same group. They name nothing, but an observer + /// sees which devices advertise the same record, so they still group the + /// devices of one application near each other while a group forms. + pub fn wifi_direct(&self) -> Option { + let key = self.app_key.as_ref()?; + let record = expand(key.as_ref(), WIFI_DIRECT_APP_INFO); + let network = expand(key.as_ref(), WIFI_DIRECT_NETWORK_INFO); + let passphrase = expand(key.as_ref(), WIFI_DIRECT_PASSPHRASE_INFO); + Some(WifiDirectIdentifiers { + record_app: hex(&record[..WIFI_DIRECT_RECORD_BYTES]), + network_name: format!( + "{WIFI_DIRECT_NETWORK_PREFIX}{}", + hex(&network[..WIFI_DIRECT_NETWORK_BYTES]) + ), + passphrase: hex(&passphrase[..WIFI_DIRECT_PASSPHRASE_BYTES]), + }) + } + + /// The secret a browser's nearby room is derived from in place of the + /// application id, or `None` without a publisher key. + pub fn web_nearby_secret(&self) -> Option> { + let key = self.app_key.as_ref()?; + Some(expand(key.as_ref(), WEB_NEARBY_INFO)) + } +} + +/// `HKDF-SHA256(ikm = K_app, salt = none, info)`, 32 bytes. Shorter values +/// are its prefix, which is what HKDF-Expand of fewer bytes gives. +fn expand(app_key: &[u8], info: &[u8]) -> Zeroizing<[u8; 32]> { + let mut out = Zeroizing::new([0u8; 32]); + // 32 bytes is far below HKDF-SHA256's 8160-byte ceiling, so this cannot + // fail; the zeros are never reached. + let _ = Hkdf::::new(None, app_key).expand(info, out.as_mut()); + out +} + +fn hex(bytes: &[u8]) -> String { + use fmt::Write as _; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(out, "{byte:02x}"); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const VECTORS: &str = + include_str!("../../../offline-protocol-sealed/tests/data/app-membership-v1.vectors.json"); + + fn vectors() -> serde_json::Value { + serde_json::from_str(VECTORS).expect("the vector file is JSON") + } + + fn field<'a>(v: &'a serde_json::Value, path: &[&str]) -> &'a str { + path.iter() + .fold(v, |v, key| &v[*key]) + .as_str() + .unwrap_or_else(|| panic!("{path:?} is a string")) + } + + fn configured() -> AppIdentifiers { + let v = vectors(); + let pk: [u8; 32] = (0..32) + .map(|i| { + let text = field(&v, &["app_key", "publisher_public_key_hex"]); + u8::from_str_radix(&text[2 * i..2 * i + 2], 16).expect("hex") + }) + .collect::>() + .try_into() + .expect("32 bytes"); + AppIdentifiers::new(Some(&pk), field(&v, &["app_key", "app_id"])) + } + + #[test] + fn without_a_publisher_key_nothing_is_derived() { + let ids = AppIdentifiers::new(None, "com.example.chat"); + assert!(!ids.is_keyed()); + assert_eq!(ids.ble_app_tag("off1anything"), None); + assert_eq!(ids.wifi_direct(), None); + assert!(ids.web_nearby_secret().is_none()); + } + + #[test] + fn the_ble_app_tag_matches_the_vectors_and_differs_per_instance() { + let v = vectors(); + let ids = configured(); + assert!(ids.is_keyed()); + for key in ["a", "b"] { + let tag = ids + .ble_app_tag(field(&v, &["addresses", key])) + .expect("keyed"); + assert_eq!( + hex(&tag), + field( + &v, + &["identifiers", "ble_app_tag", &format!("{key}_tag_hex")] + ) + ); + } + assert_ne!( + field(&v, &["identifiers", "ble_app_tag", "a_tag_hex"]), + field(&v, &["identifiers", "ble_app_tag", "b_tag_hex"]) + ); + } + + #[test] + fn the_wifi_direct_values_match_the_vectors() { + let v = vectors(); + let wd = configured().wifi_direct().expect("keyed"); + assert_eq!( + wd.record_app, + field(&v, &["identifiers", "wifi_direct", "record_app"]) + ); + assert_eq!( + wd.network_name, + field(&v, &["identifiers", "wifi_direct", "network_name"]) + ); + assert_eq!( + wd.passphrase, + field(&v, &["identifiers", "wifi_direct", "passphrase"]) + ); + // What Android requires of a group name, and WPA2 of a passphrase. + assert!(wd.network_name.starts_with("DIRECT-") && wd.network_name.len() <= 32); + assert!((8..=63).contains(&wd.passphrase.len())); + assert!(!format!("{wd:?}").contains(&wd.passphrase)); + } + + #[test] + fn the_nearby_secret_matches_the_vectors() { + let v = vectors(); + let secret = configured().web_nearby_secret().expect("keyed"); + assert_eq!( + hex(secret.as_ref()), + field(&v, &["identifiers", "web_nearby", "secret_hex"]) + ); + } + + /// Two applications that share a publisher key still show different + /// values: the application id is in `K_app`. + #[test] + fn another_application_under_one_publisher_shows_other_values() { + let pk = [7u8; 32]; + let (a, b) = ( + AppIdentifiers::new(Some(&pk), "com.example.a"), + AppIdentifiers::new(Some(&pk), "com.example.b"), + ); + assert_ne!(a.ble_app_tag("off1x"), b.ble_app_tag("off1x")); + assert_ne!(a.wifi_direct(), b.wifi_direct()); + assert_ne!( + a.web_nearby_secret().map(|s| *s), + b.web_nearby_secret().map(|s| *s) + ); + } +} diff --git a/crates/offline-protocol/src/protocol/membership.rs b/crates/offline-protocol/src/protocol/membership.rs index 550a105a4..6f86ddde7 100644 --- a/crates/offline-protocol/src/protocol/membership.rs +++ b/crates/offline-protocol/src/protocol/membership.rs @@ -60,6 +60,7 @@ use rand_core::{OsRng, RngCore}; use sha2::Sha256; use tracing::{debug, error, info, warn}; +use super::app_identifiers::AppIdentifiers; use super::types::{storage_keys, CTRL_PK_META_KEY}; use super::{base64_decode, internal_prefixes, OfflineProtocol, OutboxReseal}; use crate::events::Event; @@ -203,13 +204,25 @@ impl Membership { /// The state for a configuration: `K_app` from the publisher key and the /// application id, or nothing to derive when there is no key. pub(crate) fn new(publisher_key: Option<[u8; 32]>, app_id: &str) -> Self { - let app_key = publisher_key.map(|pk| hkdf32(&pk, None, &app_key_info(app_id))); + let app_key = publisher_key.map(|pk| derive_app_key(&pk, app_id)); Self { app_key, publisher_key, ..Self::default() } } + + /// What this application shows on the air, keyed by the same `K_app`. + pub(crate) fn app_identifiers(&self) -> AppIdentifiers { + AppIdentifiers::from_app_key(self.app_key) + } +} + +/// `K_app = HKDF-SHA256(ikm = publisher_pk, salt = none, info = +/// app_key_info(app_id))`. The one derivation: the exchange and every +/// identifier in [`AppIdentifiers`] use it. +pub(crate) fn derive_app_key(publisher_key: &[u8; 32], app_id: &str) -> [u8; APP_KEY_LEN] { + hkdf32(publisher_key, None, &app_key_info(app_id)) } fn random_array() -> [u8; N] { @@ -453,6 +466,16 @@ impl OfflineProtocol { Ok(id) } + /// What this application shows on the air in place of its application + /// id: the Bluetooth LE App tag, the Wi-Fi Direct group and the browser's + /// nearby room, keyed by `K_app` when a publisher key is configured and + /// absent otherwise ([`AppIdentifiers`]). Fixed by the configuration, so + /// a binding takes one copy at construction and reads it without the + /// engine. + pub fn app_identifiers(&self) -> AppIdentifiers { + self.membership.app_identifiers() + } + /// Starts a fresh exchange with every current mesh neighbour. /// /// A certificate that arrives after this node met its neighbours (the diff --git a/crates/offline-protocol/src/protocol/mod.rs b/crates/offline-protocol/src/protocol/mod.rs index 74273e09c..357de5c34 100644 --- a/crates/offline-protocol/src/protocol/mod.rs +++ b/crates/offline-protocol/src/protocol/mod.rs @@ -1,5 +1,6 @@ //! Main protocol engine. +mod app_identifiers; mod blocking; mod config_accessors; mod custodian; @@ -29,6 +30,7 @@ pub(crate) mod state_crypto; mod storage; mod types; +pub use app_identifiers::{AppIdentifiers, WifiDirectIdentifiers}; pub use custody::{CustodyRefusal, CustodyStats}; pub(crate) use decryption_queue::PendingDecryptionQueue; pub use decryption_queue::PendingQueueMetrics; diff --git a/docs/bridges/web.md b/docs/bridges/web.md index befe80c63..6f3e0ec0f 100644 --- a/docs/bridges/web.md +++ b/docs/bridges/web.md @@ -455,6 +455,15 @@ lists the others it has heard within 15 s, and links over WebRTC to one its person picks. The relays carry only the setup; the link runs on the local network. +**The engine opens the room.** The worker asks the engine for it +(`nearbyRoom`), which derives it from the application key in place of the +application id when the application has a publisher key +([ADR 0037](../adr/0037-an-applications-users-are-visible-only-to-that-application.md)), +so the topic names nothing to someone who knows only the id. Without a +publisher key the room is the application id's, as before. The page does not +yet configure a publisher key, so a page's room is the id's today; the +derivation is in place for when it does. + **The room key is public.** Anyone can derive it from an address, so it seals only presences, and a presence holds a key used for this room and the application's label. Because the room key authenticates nothing, a presence @@ -541,6 +550,9 @@ attribute a write, and one it cannot attribute is dropped. The iOS, Android and Python peripherals serve Hello. With several instances of the service behind one device, the carrier binds the one carrying this application's App tag, else the only untagged one, else the first, as the chapter says. +With a publisher key the tag is per instance, so the carrier reads Device id +on each tagged instance and asks the engine for the tag over it +(`bleKeyedAppTag`); JavaScript computes no tag. **The hello is required, where the chapter makes it optional.** The chapter lets a central skip Hello, and keep a link whose hello was diff --git a/docs/spec/ble-framing.md b/docs/spec/ble-framing.md index da12d165b..b37fce7c0 100644 --- a/docs/spec/ble-framing.md +++ b/docs/spec/ble-framing.md @@ -105,10 +105,27 @@ the second has learned nothing. **App tag** names the application behind this instance of the service. It MAY be absent: a device running one application loses nothing by omitting it. When -present it MUST be readable and carries eight bytes, the first eight of -`SHA-256("offline-protocol-ble-app-tag-v1" || 0x00 || app_id)`, where `app_id` -is the application id of [the wire format](wire-format.md) as UTF-8. It proves -nothing, and is read only to choose between instances, below. +present it MUST be readable and carries eight bytes. It proves nothing, and is +read only to choose between instances, below. Which eight bytes depends on +whether the application has a publisher key +([ADR 0037](../adr/0037-an-applications-users-are-visible-only-to-that-application.md)): + +- **Without one**, the first eight of + `SHA-256("offline-protocol-ble-app-tag-v1" || 0x00 || app_id)`, where + `app_id` is the application id of [the wire format](wire-format.md) as + UTF-8. One value per application, so anyone who knows the id can read + which phones run it. +- **With one**, the first eight of + `HMAC-SHA256(K_app, "offline-app-ble-tag-v1" || device_id)`, where `K_app` + is the application key of + [the membership chapter](control-messages.md#application-membership) and + `device_id` is the instance's Device id value exactly as served (its + `off1…` address as UTF-8). Over the rendering rather than the address + payload because a central computes it from the bytes it just read, before + anything has proved or parsed them. One value per instance, so it names + and groups nothing for a party without the publisher key; a central of the + same application recomputes it from the Device id it reads. The vectors are + the `identifiers` block of `app-membership-v1.vectors.json`. **Hello** is written by a central to say who it is on the link it opened. It MAY be absent; when present it MUST be writable with response. The rest of @@ -202,14 +219,25 @@ per-send application id changes the `app_id` stamped on that one frame, never the tag this service advertises and never the storage the identity lives in. So a central that finds more than one instance MUST choose one before it reads -Device id or Identity, and MUST then read, subscribe and write on that instance -only. Among the instances that expose both Device id and Identity, it takes: - -1. the first whose App tag equals its own; +Identity, and MUST then read, subscribe and write on that instance only. Among +the instances that expose both Device id and Identity, it takes: + +1. the first whose App tag equals its own: for a central without a publisher + key, its application's tag; for a central with one, the keyed tag over + that instance's own Device id, which it reads on every instance that + served a tag (and on no other, since an untagged instance has nothing to + check); 2. otherwise, when exactly one has no App tag (absent, or its read failed), that one, since it is a build that predates the tag; 3. otherwise the first. +A central without a publisher key reads no Device id before it chooses. A +Device id read while choosing is unauthenticated: a match only chooses the +instance and grants nothing, and the central then reads Identity and +verifies it on the chosen instance only, as on a link with one instance. A +Device id that arrives from another instance after the choice is the +choice's read answered late, and is never taken as the handshake's. + When no instance exposes both, it takes the first, and the handshake refuses it for the characteristic it lacks. "First" is the order the platform reports the instances in. A central that finds one instance has nothing to choose, and the @@ -234,8 +262,10 @@ another application. App tag is not signed. Any identity key could sign any tag, so a signature would prove nothing the Identity characteristic does not. An application on the peer's phone that serves another's tag is chosen in its place, and is still bound -under its own proved address, never the displaced one's. That, and what the tag -reveals to an observer, is residual risk +under its own proved address, never the displaced one's. A keyed tag can be +served only by a holder of `K_app`, so this takes the publisher key (party 3 +of ADR 0037), not just the application id. That, and what the tag reveals to +an observer, is residual risk [R15](../security/threat-model.md#r15-the-bluetooth-le-app-tag-is-unsigned-and-readable). ### The identity assertion diff --git a/docs/spec/control-messages.md b/docs/spec/control-messages.md index 009a5503f..3ed325fa1 100644 --- a/docs/spec/control-messages.md +++ b/docs/spec/control-messages.md @@ -474,6 +474,31 @@ exchange like any node and stamps `app_id` `"offline"` on everything it sends. What a leaf owes is in [Leaf node provisioning](leaf-provisioning.md#application-membership). +**What the application shows on the air.** A configured node derives every +identifier a carrier shows outside a frame from `K_app`, each under its own +label, in place of the value an unconfigured node derives from the +application id (which is unchanged): + +``` +ble_app_tag = HMAC-SHA256(K_app, "offline-app-ble-tag-v1" || device_id_utf8)[..8] +wd_record_app = hex(HKDF(ikm = K_app, salt = none, info = "offline-app-wifi-direct-record-v1")[..4]) +wd_network = "DIRECT-op-" || hex(HKDF(ikm = K_app, salt = none, + info = "offline-app-wifi-direct-network-v1")[..4]) +wd_passphrase = hex(HKDF(ikm = K_app, salt = none, info = "offline-app-wifi-direct-passphrase-v1")[..16]) +nearby_secret = HKDF(ikm = K_app, salt = none, info = "offline-app-web-nearby-v1") 32 bytes +``` + +`hex` is lowercase. The App tag is the +[Bluetooth LE chapter](ble-framing.md#the-gatt-contract)'s, over the Device id +exactly as served, and differs per instance. The three Wi-Fi Direct values +replace the Android group's `app` record value, group name and passphrase; +they are one per application, since every member must find and join one +group. `nearby_secret` replaces the application id's bytes at the front of a +browser nearby room's input, which is otherwise unchanged. Each output is a +prefix of a 32-byte expand, which is what HKDF-Expand of fewer bytes gives. +The vectors are the `identifiers` block of `app-membership-v1.vectors.json`. + + **Admitted.** With a publisher key configured, one predicate decides what the application is told and what the node answers. A peer is admitted when it is visible; when it is in the **named set**, the addresses the application itself diff --git a/docs/spec/local-api.md b/docs/spec/local-api.md index 967e30cc1..b9dc88aa3 100644 --- a/docs/spec/local-api.md +++ b/docs/spec/local-api.md @@ -552,6 +552,7 @@ this chapter asserts it. | `constructor`, `start`, `stop`, `pause`, `resume`, `process`, `receive_message`, `set_event_callback`, `poll_event`, `emit_test_event` | The lifecycle, the run loop and the drain (first invariant) | | `initialize_mls`, `initialize_mls_with_file_stores`, `close_file_stores` | The identity's storage is attached once, by the process that holds the store key | | `set_app_membership`, `app_membership_status`, `sign_app_membership`, `provision_leaf_membership` | Application membership ([ADR 0037](../adr/0037-an-applications-users-are-visible-only-to-that-application.md)) binds the one identity every client shares, so it is the operator's; the signing helper takes the publisher's private key, which never crosses the socket; provisioning a paired leaf hands that same configuration to a device in the identity's own session | +| `ble_app_tag`, `wifi_direct_identifiers` | What the application shows on the air, from the application key ([ADR 0037](../adr/0037-an-applications-users-are-visible-only-to-that-application.md)): the carrier drivers read it, and the Wi-Fi Direct passphrase is a secret no client needs | | `enable_telemetry`, `disable_telemetry`, `set_telemetry_enabled`, `flush_telemetry`, `flush_telemetry_blocking`, `telemetry_stats`, `end_telemetry_session`, `notify_app_state`, `telemetry_install_id` | The binding fills the platform fields and owns the session boundary ([C12](../bridges/README.md#c12-telemetry-is-callback-free-and-the-binding-fills-the-platform)); the server is the binding | | `set_ble_transport_callback`, `set_wifi_direct_transport_callback`, `set_reticulum_transport_callback`, `set_nostr_transport_callback` | A callback interface has no JSON form, and each installs the driver of a carrier | | `ble_peer_discovered`, `ble_peer_lost`, `ble_peer_rssi`, `ble_node_hash`, `set_peer_rssi_events`, `ble_status_changed`, `ble_fragment_received`, `ble_get_next_fragment`, `ble_return_fragment`, `ble_get_peer_count`, `ble_set_peer_mtu`, `ble_clear_peer_mtu`, `ble_undersized_mtu_reports`, `ble_fragment_fallback_count`, `ble_recipient_not_among_peers_count`, `protocol_lock_diagnostics` | The Bluetooth LE driver's feed and drain, its signal readings and the switch the binding flips for them, and the host's lock diagnostics | diff --git a/tools/spec-vectors/generate.py b/tools/spec-vectors/generate.py index a10fd7da8..577e5fccb 100644 --- a/tools/spec-vectors/generate.py +++ b/tools/spec-vectors/generate.py @@ -1829,6 +1829,17 @@ def payload(addr: str) -> bytes: def provision_reject(raw: bytes) -> str: return base64.b64encode(raw).decode("ascii") + # The identifiers a configured application shows on the air (ADR 0037, + # "Nothing in the clear"), each from K_app under its own label. + tag_inputs = {k: b"offline-app-ble-tag-v1" + v.encode("utf-8") for k, v in (("a", addr_a), ("b", addr_b))} + ble_tags = {k: _hmac.new(k_app, v, hashlib.sha256).digest()[:8] for k, v in tag_inputs.items()} + wd_record = hkdf(k_app, b"", b"offline-app-wifi-direct-record-v1")[:4].hex() + wd_network = "DIRECT-op-" + hkdf(k_app, b"", b"offline-app-wifi-direct-network-v1")[:4].hex() + wd_passphrase = hkdf(k_app, b"", b"offline-app-wifi-direct-passphrase-v1")[:16].hex() + nearby_secret = hkdf(k_app, b"", b"offline-app-web-nearby-v1") + nearby_network = "v4/203.0.113.7" + nearby_ikm = nearby_secret + b"\x00" + nearby_network.encode("ascii") + nearby_topic = hkdf(nearby_ikm, b"offline-protocol web nearby v1", b"topic", 16) return { "_comment": [ @@ -1907,6 +1918,28 @@ def provision_reject(raw: bytes) -> str: "key_hex": hkdf(k_app, proof_salt, b"offline-app-member-e2e-key-v1").hex(), "bytes_hex": (proof_salt + sealed).hex(), }, + "identifiers": { + "note": "What a configured application shows on the air in place of " + "anything derived from its application id. The Bluetooth LE App tag " + "is per instance, over the Device id exactly as served; the others " + "are one per application.", + "ble_app_tag": { + "a_input_hex": tag_inputs["a"].hex(), + "a_tag_hex": ble_tags["a"].hex(), + "b_input_hex": tag_inputs["b"].hex(), + "b_tag_hex": ble_tags["b"].hex(), + }, + "wifi_direct": { + "record_app": wd_record, + "network_name": wd_network, + "passphrase": wd_passphrase, + }, + "web_nearby": { + "secret_hex": nearby_secret.hex(), + "scope": nearby_network, + "topic_hex": nearby_topic.hex(), + }, + }, "rejects": [ {"name": "version_2", "body": base64.b64encode(bytes([2, 1]) + bytes(32)).decode()}, {"name": "step_3", "body": base64.b64encode(bytes([1, 3]) + bytes(32)).decode()},