Allow public IPv4 addresses for direct SRTP calls
Use shared unicast IPv4 validation for interface discovery, media endpoints and server signaling. Preserve disclosure consent and privacy constraints, and document direct connectivity requirements. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
7f0e868a32
commit
73411f2f7e
8 changed files with 65 additions and 22 deletions
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
### Added
|
||||
|
||||
* [PostIt/Calls] Allow directly assigned public IPv4 addresses for direct SRTP calls, alongside LAN/VPN and loopback addresses. Share unicast validation between interface discovery, media and server signaling; continue rejecting unspecified, link-local, multicast, reserved and IPv6 addresses. Keep explicit address-disclosure consent and no automatic NAT traversal.
|
||||
* [PostIt] Update bundled settings with the default podcast title, camera/microphone preferences and French language selection.
|
||||
|
||||
## [1.0.8-rc34] - unstable
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
Ce document décrit l'architecture cible et l'implémentation commencée.
|
||||
La préférence de confidentialité est persistée et éditable dans le profil.
|
||||
La signalisation est intégrée au chat, désactivée par défaut. Un premier
|
||||
transport natif **Android et Desktop Linux sur LAN/VPN** est raccordé :
|
||||
transport natif **Android et Desktop Linux en IPv4 directe (LAN/VPN ou IP publique)** est raccordé :
|
||||
captures Podcast existantes et SRTP direct, sans libwebrtc ni nouvelle
|
||||
couche JNI WebRTC. Il ne réalise pas encore la cible Internet, TURN ou groupe.
|
||||
Il complète la [topologie de PostIt](postit.md) et les
|
||||
|
|
@ -18,7 +18,17 @@ La cible couvre :
|
|||
- les appels de groupe ;
|
||||
- les clients Browser, Android et Desktop.
|
||||
|
||||
## Première implémentation Android et Desktop Linux : SRTP sur LAN/VPN
|
||||
## Première implémentation Android et Desktop Linux : SRTP direct IPv4
|
||||
|
||||
Les adresses IPv4 publiques directement assignees a une interface locale active
|
||||
sont egalement disponibles, avec la meme validation unicast cote client,
|
||||
media et signalisation serveur. Les adresses non specifiees, link-local,
|
||||
multicast, reservees (240/4), IPv6 et les formes non canoniques sont refusees.
|
||||
Il faut autoriser en entree les deux ports UDP consecutifs choisis et assurer
|
||||
la joignabilite entre les pairs. Ce support n'ajoute ni ICE/TURN ni traversee
|
||||
NAT automatique : l'adresse publique d'un routeur ne remplace pas une adresse
|
||||
assignee a la machine. Le consentement explicite au partage d'IP et le refus
|
||||
du mode direct pour les comptes masquant leur IP restent inchanges.
|
||||
|
||||
Cette tranche réutilise la capture caméra/microphone et les encodeurs des
|
||||
Podcasts, mais **pas leur segmentation HLS ni leurs envois de fichiers** :
|
||||
|
|
@ -67,7 +77,7 @@ de fichier média temporaire ou de moteur WebRTC Android ajouté.
|
|||
H.264/AAC. La version Debian 13 a été utilisée pour les essais Linux.
|
||||
Les deux comptes doivent être présents dans
|
||||
la même salle existante et ne pas se bloquer mutuellement.
|
||||
3. Dans « Appel direct (LAN/VPN) », actualiser les périphériques,
|
||||
3. Dans « Appel direct IPv4 (LAN/VPN ou IP publique) », actualiser les périphériques,
|
||||
sélectionner la caméra, le microphone et une IPv4 privée locale
|
||||
accessible depuis le pair. Les adresses RFC1918 et de bouclage sont
|
||||
acceptées ; pas de DNS, IPv6 ou adresse publique. Le bouclage sert aux
|
||||
|
|
@ -79,7 +89,7 @@ de fichier média temporaire ou de moteur WebRTC Android ajouté.
|
|||
adresse doivent utiliser des paires distinctes.
|
||||
Aucun pare-feu n'est modifié automatiquement.
|
||||
5. Saisir le pseudo exact du destinataire, confirmer la divulgation de
|
||||
l'adresse LAN/VPN, puis appeler. Le destinataire choisit ses propres
|
||||
l'adresse IPv4 locale (LAN/VPN ou publique), puis appeler. Le destinataire choisit ses propres
|
||||
périphériques et son adresse, confirme et accepte explicitement.
|
||||
Aucun accès à la caméra/microphone ne commence avant l'acceptation ;
|
||||
l'émission attend également la destination validée du pair.
|
||||
|
|
@ -140,7 +150,8 @@ l'ajout d'un moteur WebRTC ou d'une cryptographie propre au projet.
|
|||
Il n'y a aucun repli direct contournant cette préférence.
|
||||
- Pas d'ICE, de STUN, de TURN, de traversée NAT automatique, de groupe,
|
||||
d'interopérabilité Browser, de négociation de codecs ni
|
||||
d'adaptation de débit. Ce mode exige un LAN/VPN routable entre les pairs.
|
||||
d'adaptation de débit. Ce mode exige une connectivite IPv4 directe entre les pairs
|
||||
(LAN/VPN ou adresse publique assignee), avec les ports UDP ouverts.
|
||||
- H.264 à 30 images/s, cible 1 200 kbit/s ; AAC mono 48 kHz, 96 kbit/s.
|
||||
Ces réglages ne constituent pas une garantie de latence ni de débit.
|
||||
Le retour local n'émet pas le son du microphone.
|
||||
|
|
|
|||
|
|
@ -114,6 +114,18 @@ public sealed class LanCallViewModelTests
|
|||
return vm;
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Public_ipv4_can_be_selected_for_an_explicitly_consented_direct_call()
|
||||
{
|
||||
var signaling = new Signaling();
|
||||
await using var vm = Configure(signaling, (_, _) => throw new Exception("No media before acceptance"));
|
||||
vm.Addresses.Add("8.8.8.8");
|
||||
vm.Address = "8.8.8.8";
|
||||
await vm.InviteCommand.ExecuteAsync(null);
|
||||
Assert.Equal(1, signaling.Invites);
|
||||
Assert.True(vm.Busy);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task No_capture_starts_before_explicit_acceptance_and_endpoint_exchange()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ public sealed class LibVlcLanCallMedia : IAsyncDisposable
|
|||
Endpoint = new(localAddress, port, Convert.ToHexString(RandomNumberGenerator.GetBytes(16)),
|
||||
Convert.ToHexString(RandomNumberGenerator.GetBytes(14)));
|
||||
if (!Endpoint.IsValid())
|
||||
throw new ArgumentException("Adresse IPv4 privee et port UDP pair (1024-65534) requis.");
|
||||
throw new ArgumentException("Adresse IPv4 unicast et port UDP pair (1024-65534) requis.");
|
||||
using (var reservation = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp))
|
||||
using (var rtcp = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
|
|||
foreach (var address in NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(n => n.OperationalStatus == OperationalStatus.Up)
|
||||
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
|
||||
.Select(a => a.Address.ToString()).Where(LanCallNetwork.IsPrivateIPv4)
|
||||
.Select(a => a.Address.ToString()).Where(LanCallNetwork.IsUnicastIPv4)
|
||||
.Distinct().OrderBy(a => IPAddress.IsLoopback(IPAddress.Parse(a))))
|
||||
Addresses.Add(address);
|
||||
Address = preferred == null ? Addresses.FirstOrDefault() : Addresses.FirstOrDefault(a => a == preferred);
|
||||
|
|
@ -181,7 +181,7 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
|
|||
Status = Cameras.Count == 0 ? "Aucune camera detectee. Verifiez les peripheriques puis actualisez."
|
||||
: Incoming && _call != null
|
||||
? $"{_call.Caller} propose un appel dans {_call.RoomName}. Selectionnez vos peripheriques puis acceptez ou refusez."
|
||||
: "Selectionnez le pair, l'adresse LAN/VPN et les peripheriques. Utilisez un casque.";
|
||||
: "Selectionnez le pair, l'adresse IPv4 locale ou publique et les peripheriques. Utilisez un casque.";
|
||||
if (_settings != null)
|
||||
{
|
||||
_settings.PodcastCameras = Cameras.ToArray();
|
||||
|
|
@ -200,10 +200,10 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
|
|||
private void ValidateSelection()
|
||||
{
|
||||
if (!AllowAddressDisclosure)
|
||||
throw new InvalidOperationException("Confirmez le partage de votre adresse LAN/VPN avec le pair.");
|
||||
throw new InvalidOperationException("Confirmez le partage de votre adresse IPv4 avec le pair.");
|
||||
if (Camera == null || Microphone == null)
|
||||
throw new InvalidOperationException("Une camera et un microphone sont requis.");
|
||||
if (Address == null || !Addresses.Contains(Address) || !LanCallNetwork.IsPrivateIPv4(Address) ||
|
||||
if (Address == null || !Addresses.Contains(Address) || !LanCallNetwork.IsUnicastIPv4(Address) ||
|
||||
!int.TryParse(Port, out var port) || port is < 1024 or > 65534 || port % 2 != 0)
|
||||
throw new InvalidOperationException("Selectionnez une adresse locale et un port UDP pair (1024-65534).");
|
||||
_activeCamera = Camera.Value;
|
||||
|
|
|
|||
|
|
@ -103,16 +103,16 @@
|
|||
Content="Envoyer" />
|
||||
</Grid>
|
||||
|
||||
<Expander Grid.Row="3" Header="Appel direct (LAN/VPN)" IsVisible="{Binding SupportsLanCalls}" IsExpanded="True"
|
||||
<Expander Grid.Row="3" Header="Appel direct IPv4 (LAN/VPN ou IP publique)" IsVisible="{Binding SupportsLanCalls}" IsExpanded="True"
|
||||
IsEnabled="{Binding IsConnected}" Margin="0,0,0,8">
|
||||
<ScrollViewer MaxHeight="520">
|
||||
<StackPanel DataContext="{Binding LanCall}" x:DataType="vm:LanCallViewModel" Spacing="8">
|
||||
<TextBlock Text="Android et Desktop Linux sur LAN/VPN. SRTP chiffre les medias ; le pair connait votre IP. Ouvrir le port UDP choisi et le suivant. Sans TURN ni traversee NAT. Casque recommande (pas d'annulation d'echo)."
|
||||
<TextBlock Text="Android et Desktop Linux : IPv4 locale LAN/VPN ou publique directement assignee au PC. SRTP chiffre les medias ; le pair connait votre IP. Ouvrir le port UDP choisi et le suivant. Sans TURN ni traversee NAT. Casque recommande (pas d'annulation d'echo)."
|
||||
TextWrapping="Wrap" />
|
||||
<WrapPanel IsEnabled="{Binding CanConfigure}" IsVisible="{Binding !RemoteVideo}">
|
||||
<Button Content="Actualiser les peripheriques" Command="{Binding RefreshDevicesCommand}" Margin="0,0,8,4" />
|
||||
<ComboBox Width="160" ItemsSource="{Binding Addresses}" SelectedItem="{Binding Address}"
|
||||
PlaceholderText="Adresse locale LAN/VPN" Margin="0,0,8,4" />
|
||||
PlaceholderText="Adresse IPv4 de cette machine" Margin="0,0,8,4" />
|
||||
<TextBox Width="80" Text="{Binding Port}" PlaceholderText="Port pair" Margin="0,0,8,4" />
|
||||
<ComboBox Width="190" ItemsSource="{Binding Cameras}" SelectedItem="{Binding Camera}"
|
||||
PlaceholderText="Camera" Margin="0,0,8,4">
|
||||
|
|
@ -132,7 +132,7 @@
|
|||
</ComboBox>
|
||||
<TextBox Width="170" Text="{Binding Target}" PlaceholderText="Pseudo du pair" Margin="0,0,8,4" />
|
||||
</WrapPanel>
|
||||
<CheckBox Content="Je consens a partager mon adresse LAN/VPN avec le pair."
|
||||
<CheckBox Content="Je consens a partager mon adresse IPv4 avec le pair."
|
||||
IsChecked="{Binding AllowAddressDisclosure}" IsEnabled="{Binding CanConfigure}"
|
||||
IsVisible="{Binding !RemoteVideo}" />
|
||||
<WrapPanel>
|
||||
|
|
|
|||
|
|
@ -40,13 +40,23 @@ public sealed record LanCallEndpoint(string Address, int Port, string Key, strin
|
|||
public override string ToString() => "SRTP endpoint (credentials redacted)";
|
||||
|
||||
public bool IsValid() =>
|
||||
LanCallNetwork.IsPrivateIPv4(Address) && Port is >= 1024 and <= 65534 && Port % 2 == 0 &&
|
||||
LanCallNetwork.IsUnicastIPv4(Address) && Port is >= 1024 and <= 65534 && Port % 2 == 0 &&
|
||||
Key is { Length: 32 } && Key.All(Uri.IsHexDigit) &&
|
||||
Salt is { Length: 28 } && Salt.All(Uri.IsHexDigit);
|
||||
}
|
||||
|
||||
public static class LanCallNetwork
|
||||
{
|
||||
public static bool IsUnicastIPv4(string address)
|
||||
{
|
||||
if (!System.Net.IPAddress.TryParse(address, out var ip) ||
|
||||
ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork || ip.ToString() != address)
|
||||
return false;
|
||||
var bytes = ip.GetAddressBytes();
|
||||
return bytes[0] is > 0 and < 224
|
||||
&& !(bytes[0] == 169 && bytes[1] == 254);
|
||||
}
|
||||
|
||||
public static bool IsPrivateIPv4(string address)
|
||||
{
|
||||
if (!System.Net.IPAddress.TryParse(address, out var ip) ||
|
||||
|
|
|
|||
|
|
@ -38,14 +38,16 @@ public sealed class CallLanSignalingTests
|
|||
Assert.DoesNotContain(hub.Messages, m => m.Method == CallHubMethods.Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Endpoints_are_exchanged_only_once_between_consented_devices_without_ice_credentials()
|
||||
[Theory]
|
||||
[InlineData("192.168.1.2")]
|
||||
[InlineData("8.8.8.8")]
|
||||
public async Task Endpoints_are_exchanged_only_once_between_consented_devices_without_ice_credentials(string address)
|
||||
{
|
||||
var hub = new RecordingCallHub();
|
||||
using var service = Service(hub);
|
||||
var call = await service.InviteAsync("General", "alice", "bob", "a", ["b", "other"], false, CallTransports.LanSrtp);
|
||||
Assert.Equal(CallTransports.LanSrtp, call.Transport);
|
||||
var first = new CallSignal("rtp-endpoint", Endpoint: Endpoint());
|
||||
var first = new CallSignal("rtp-endpoint", Endpoint: Endpoint(address));
|
||||
var second = new CallSignal("rtp-endpoint", Endpoint: Endpoint("10.0.0.2"));
|
||||
await Assert.ThrowsAsync<HubException>(() => service.SignalAsync(call.Id, "alice", "a", first));
|
||||
await service.AcceptAsync(call.Id, "bob", "b", false);
|
||||
|
|
@ -76,7 +78,7 @@ public sealed class CallLanSignalingTests
|
|||
new("rtp-endpoint"), new("rtp-endpoint", Sdp: "injected", Endpoint: Endpoint()),
|
||||
new("rtp-endpoint", Endpoint: Endpoint() with { Key = new string('x', 32) }),
|
||||
new("rtp-endpoint", Endpoint: Endpoint() with { Salt = "short" }),
|
||||
new("rtp-endpoint", Endpoint: Endpoint("8.8.8.8")),
|
||||
new("rtp-endpoint", Endpoint: Endpoint("255.255.255.255")),
|
||||
new("rtp-endpoint", Endpoint: Endpoint(port: 5005))
|
||||
];
|
||||
foreach (var signal in invalid)
|
||||
|
|
@ -92,16 +94,23 @@ public sealed class CallLanSignalingTests
|
|||
[InlineData("172.31.255.254", true)]
|
||||
[InlineData("192.168.1.2", true)]
|
||||
[InlineData("127.0.0.1", true)]
|
||||
[InlineData("172.32.0.1", false)]
|
||||
[InlineData("172.32.0.1", true)]
|
||||
[InlineData("169.254.169.254", false)]
|
||||
[InlineData("8.8.8.8", false)]
|
||||
[InlineData("8.8.8.8", true)]
|
||||
[InlineData("100.64.0.1", true)]
|
||||
[InlineData("223.255.255.254", true)]
|
||||
[InlineData("224.0.0.1", false)]
|
||||
[InlineData("0.0.0.0", false)]
|
||||
[InlineData("0.1.2.3", false)]
|
||||
[InlineData("240.0.0.1", false)]
|
||||
[InlineData("255.255.255.255", false)]
|
||||
[InlineData("169.254.1.1", false)]
|
||||
[InlineData("::ffff:8.8.8.8", false)]
|
||||
[InlineData("::1", false)]
|
||||
[InlineData("localhost", false)]
|
||||
[InlineData("127.1", false)]
|
||||
[InlineData("192.168.1.2,port=80", false)]
|
||||
public void Only_canonical_private_or_loopback_ipv4_endpoints_are_allowed(string address, bool expected) =>
|
||||
public void Only_canonical_unicast_ipv4_endpoints_are_allowed(string address, bool expected) =>
|
||||
Assert.Equal(expected, Endpoint(address).IsValid());
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Reference in a new issue