Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/Aetherphone.Tests/ConversationKeyStoreSecurityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using System.Security.Cryptography;
using Aetherphone.Core.Aethernet.Clients;
using Aetherphone.Core.Aethernet.Contracts;
using Aetherphone.Core.Crypto;
using Xunit;

namespace Aetherphone.Tests;

public sealed class ConversationKeyStoreSecurityTests
{
[Fact]
public async Task DmMemberRestrictionDropsInjectedThirdParty()
{
using var victim = CryptoBox.TryGenerateIdentity()!;
using var bob = CryptoBox.TryGenerateIdentity()!;
using var attacker = CryptoBox.TryGenerateIdentity()!;
var cek = CryptoBox.GenerateCek();

var response = new ConversationKeysDto(
"velvet-thread",
1,
new[] { new KeyWrapDto(1, CryptoBox.WrapCek(cek, CryptoBox.ExportPublicKey(victim))!, "victim", 1, 0) },
new[]
{
new UserPublicKeyDto("victim", CryptoBox.ExportPublicKey(victim), 1),
new UserPublicKeyDto("bob", CryptoBox.ExportPublicKey(bob), 1),
new UserPublicKeyDto("evil", CryptoBox.ExportPublicKey(attacker), 1),
},
Array.Empty<string>(),
Array.Empty<string>(),
new[] { "evil" },
false);

var client = new FakeKeysClient { VelvetResponse = response };
var store = new ConversationKeyStore(client, new FakeVault(victim));
await store.EnsureVelvetKeysAsync("bob", "victim", CancellationToken.None);

Assert.DoesNotContain(client.VelvetWraps, wrap => wrap.RecipientUserId == "evil");

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: no positive control. Nothing asserts a legitimate stale peer still gets re-wrapped after the filter, which is the regression this change could plausibly cause.

I traced it and the test does fail if the filter is removed, so it's valid, just weak. Assert.DoesNotContain against an empty list passes for "blocked it" and "nothing happened" alike.

}

[Fact]
public async Task GuardRosterRejectsInjectedRecipientInGroupChat()
{
using var victim = CryptoBox.TryGenerateIdentity()!;
using var attacker = CryptoBox.TryGenerateIdentity()!;
var cek = CryptoBox.GenerateCek();

var response = new ConversationKeysDto(
"42",
1,
new[] { new KeyWrapDto(1, CryptoBox.WrapCek(cek, CryptoBox.ExportPublicKey(victim))!, "victim", 1, 0) },
new[] { new UserPublicKeyDto("evil", CryptoBox.ExportPublicKey(attacker), 1) },
Array.Empty<string>(),
Array.Empty<string>(),
new[] { "evil" },
false);

var guard = new PinnedRecipientGuard();
guard.SetAuthorizedMembers("victim");
var client = new FakeKeysClient { ChatResponse = response };
var store = new ConversationKeyStore(client, new FakeVault(victim), guard);
await store.EnsureChatKeysAsync("42", CancellationToken.None);

Assert.Empty(client.ChatWraps);
}
}

internal sealed class FakeVault : IKeyVault
{
private readonly ECDiffieHellman privateKey;

public FakeVault(ECDiffieHellman privateKey) => this.privateKey = privateKey;

public event Action? Changed
{
add { }
remove { }
}

public KeyVaultState State => KeyVaultState.Unlocked;

public byte[]? UnwrapCek(string wrappedKey) => CryptoBox.UnwrapCek(wrappedKey, privateKey);
}

internal sealed class FakeKeysClient : IKeysClient
{
public ConversationKeysDto? ChatResponse { get; set; }

public ConversationKeysDto? VelvetResponse { get; set; }

public List<NewWrapDto> ChatWraps { get; } = new();

public List<NewWrapDto> VelvetWraps { get; } = new();

public Task<ConversationKeysDto?> ConversationKeysAsync(string conversationId, CancellationToken token)
=> Task.FromResult(ChatResponse);

public Task<bool> AddConversationWrapsAsync(string conversationId, AddWrapsRequest request, CancellationToken token)
{
ChatWraps.AddRange(request.Wraps);
return Task.FromResult(true);
}

public Task<ConversationKeysDto?> VelvetThreadKeysAsync(string otherId, CancellationToken token)
=> Task.FromResult(VelvetResponse);

public Task<bool> AddVelvetWrapsAsync(string otherId, AddWrapsRequest request, CancellationToken token)
{
VelvetWraps.AddRange(request.Wraps);
return Task.FromResult(true);
}

public Task<MyKeysDto?> PutMyKeysAsync(PutMyKeysRequest request, CancellationToken token) => Task.FromResult<MyKeysDto?>(null);

public Task<(MyKeysDto? Keys, int Status)> MyKeysAsync(CancellationToken token) => Task.FromResult<(MyKeysDto?, int)>((null, 0));

public Task<PublicKeysDto?> PublicKeysAsync(string[] userIds, CancellationToken token) => Task.FromResult<PublicKeysDto?>(null);

public Task<MyConversationKeysDto?> MyConversationKeysAsync(CancellationToken token) => Task.FromResult<MyConversationKeysDto?>(null);

public Task<(bool Ok, int Status)> CreateConversationGenerationAsync(string conversationId, CreateGenerationRequest request, CancellationToken token) => Task.FromResult((false, 0));

public Task<MyConversationKeysDto?> VelvetKeysAsync(CancellationToken token) => Task.FromResult<MyConversationKeysDto?>(null);

public Task<(bool Ok, int Status)> CreateVelvetGenerationAsync(string otherId, CreateGenerationRequest request, CancellationToken token) => Task.FromResult((false, 0));

public Task<MyConversationKeysDto?> GramKeysAsync(CancellationToken token) => Task.FromResult<MyConversationKeysDto?>(null);

public Task<ConversationKeysDto?> GramThreadKeysAsync(string otherId, CancellationToken token) => Task.FromResult<ConversationKeysDto?>(null);

public Task<(bool Ok, int Status)> CreateGramGenerationAsync(string otherId, CreateGenerationRequest request, CancellationToken token) => Task.FromResult((false, 0));

public Task<bool> AddGramWrapsAsync(string otherId, AddWrapsRequest request, CancellationToken token) => Task.FromResult(true);
}
48 changes: 48 additions & 0 deletions src/Aetherphone.Tests/PinnedRecipientGuardTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Aetherphone.Core.Aethernet.Contracts;
using Aetherphone.Core.Crypto;
using Xunit;

namespace Aetherphone.Tests;

public sealed class PinnedRecipientGuardTests
{
[Fact]
public void AllowsUnknownRecipientWhenNoRosterConfigured()
{
var guard = new PinnedRecipientGuard();
Assert.True(guard.IsAuthorized(new UserPublicKeyDto("bob", "key-a", 1)));
}

[Fact]
public void RejectsRecipientNotOnConfiguredRoster()
{
var guard = new PinnedRecipientGuard();
guard.SetAuthorizedMembers("alice");
Assert.False(guard.IsAuthorized(new UserPublicKeyDto("bob", "key-a", 1)));
}

[Fact]
public void RejectsSameVersionKeySubstitution()
{
var guard = new PinnedRecipientGuard();
guard.Pin("bob", 1, "honest-key");
Assert.False(guard.IsAuthorized(new UserPublicKeyDto("bob", "attacker-key", 1)));
}

[Fact]
public void AllowsHigherVersionKeyRotation()
{
var guard = new PinnedRecipientGuard();
guard.Pin("bob", 1, "honest-key");
Assert.True(guard.IsAuthorized(new UserPublicKeyDto("bob", "rotated-key", 2)));
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This asserts the bypass as intended behaviour: any key at a higher version is accepted.


[Fact]
public void AcceptsMatchingPinnedKey()
{
var guard = new PinnedRecipientGuard();
var recipient = new UserPublicKeyDto("bob", "key-a", 1);
Assert.True(guard.IsAuthorized(recipient));
Assert.True(guard.IsAuthorized(recipient));
}
}
36 changes: 36 additions & 0 deletions src/Aetherphone/Core/Aethernet/Clients/IKeysClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Aetherphone.Core.Aethernet.Contracts;

namespace Aetherphone.Core.Aethernet.Clients;

internal interface IKeysClient
{
Task<MyKeysDto?> PutMyKeysAsync(PutMyKeysRequest request, CancellationToken token);

Task<(MyKeysDto? Keys, int Status)> MyKeysAsync(CancellationToken token);

Task<PublicKeysDto?> PublicKeysAsync(string[] userIds, CancellationToken token);

Task<MyConversationKeysDto?> MyConversationKeysAsync(CancellationToken token);

Task<ConversationKeysDto?> ConversationKeysAsync(string conversationId, CancellationToken token);

Task<(bool Ok, int Status)> CreateConversationGenerationAsync(string conversationId, CreateGenerationRequest request, CancellationToken token);

Task<bool> AddConversationWrapsAsync(string conversationId, AddWrapsRequest request, CancellationToken token);

Task<MyConversationKeysDto?> VelvetKeysAsync(CancellationToken token);

Task<ConversationKeysDto?> VelvetThreadKeysAsync(string otherId, CancellationToken token);

Task<(bool Ok, int Status)> CreateVelvetGenerationAsync(string otherId, CreateGenerationRequest request, CancellationToken token);

Task<bool> AddVelvetWrapsAsync(string otherId, AddWrapsRequest request, CancellationToken token);

Task<MyConversationKeysDto?> GramKeysAsync(CancellationToken token);

Task<ConversationKeysDto?> GramThreadKeysAsync(string otherId, CancellationToken token);

Task<(bool Ok, int Status)> CreateGramGenerationAsync(string otherId, CreateGenerationRequest request, CancellationToken token);

Task<bool> AddGramWrapsAsync(string otherId, AddWrapsRequest request, CancellationToken token);
}
2 changes: 1 addition & 1 deletion src/Aetherphone/Core/Aethernet/Clients/KeysClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Aetherphone.Core.Aethernet.Clients;

internal sealed class KeysClient
internal sealed class KeysClient : IKeysClient
{
private readonly AethernetTransport net;

Expand Down
68 changes: 63 additions & 5 deletions src/Aetherphone/Core/Crypto/ConversationKeyStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@ internal sealed record ChatKeyStatus(

internal sealed class ConversationKeyStore
{
private readonly KeysClient client;
private readonly KeyVault vault;
private readonly IKeysClient client;
private readonly IKeyVault vault;
private readonly IWrapRecipientGuard? guard;
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, byte[]>> keysByScope = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, int> currentGenerations = new(StringComparer.Ordinal);

public ConversationKeyStore(KeysClient client, KeyVault vault)
public ConversationKeyStore(IKeysClient client, IKeyVault vault, IWrapRecipientGuard? guard = null)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: an optional guard means the check is off by default, and BuildWraps treats null as authorize-everything. Require the dependency and pass a no-op implementation so a new call site can't silently lose it.

{
this.client = client;
this.vault = vault;
this.guard = guard;
vault.Changed += OnVaultChanged;
}

Expand Down Expand Up @@ -129,6 +131,7 @@ public async Task<ChatKeyStatus> EnsureVelvetKeysAsync(string otherId, string my
break;
}

keys = RestrictToMembers(keys, myUserId, otherId);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: myUserId is session.CurrentUser?.Id ?? string.Empty (ChatThreadStoreBase.cs:219), and nothing checks it's actually present in MemberKeys.

If it's ever empty or from a different id space, I get filtered out of my own member list, generation 1 is created wrapped for the peer only, and the in-memory CEK keeps CanEncrypt returning true. Verified: with an empty id the created generation contains exactly one wrap, for the peer, none for me. Messages send fine until the next launch, then that thread is permanently undecryptable.

Worth noting Pair(myUserId, otherId) previously used these ids only as a cache key, where a mismatch cost a cache miss. This makes the same assumption load-bearing for who gets keys.

Suggest: if myUserId isn't in MemberKeys, skip the filter and return the response unchanged.

CacheWraps(scope, keys.CurrentGeneration, keys.MyWraps);

if (keys.CurrentGeneration == 0)
Expand Down Expand Up @@ -273,6 +276,7 @@ public async Task<ChatKeyStatus> EnsureGramKeysAsync(string otherId, string myUs
break;
}

keys = RestrictToMembers(keys, myUserId, otherId);
CacheWraps(scope, keys.CurrentGeneration, keys.MyWraps);

if (keys.CurrentGeneration == 0)
Expand Down Expand Up @@ -534,12 +538,66 @@ private async Task FixWrapsAsync(string conversationId, string scope, Conversati
}
}

private static NewWrapDto[]? BuildWraps(byte[] cek, IReadOnlyList<UserPublicKeyDto> recipients)
private static ConversationKeysDto RestrictToMembers(ConversationKeysDto keys, string first, string second)
{
var wraps = new NewWrapDto[recipients.Count];
return keys with
{
MemberKeys = FilterKeys(keys.MemberKeys, first, second),
MembersWithoutKeys = FilterIds(keys.MembersWithoutKeys, first, second),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: filtering MembersWithoutKeys deletes the signal the gen-0 path aborts on (MembersWithoutKeys.Length > 0 at line 139).

If the server reports a third participant with no keys, the client now mints generation 1 instead of refusing. A security change shouldn't remove an abort condition.

StaleWrapUserIds = FilterIds(keys.StaleWrapUserIds, first, second),
MissingWrapUserIds = FilterIds(keys.MissingWrapUserIds, first, second),
};
}

private static UserPublicKeyDto[] FilterKeys(UserPublicKeyDto[] items, string first, string second)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: FilterKeys and FilterIds are the same function twice, and each allocates a List plus a ToArray on a path that runs on every thread open and every key-status refresh. In the honest case nothing is filtered, so return the input array unchanged when the count is unchanged.

{
var result = new List<UserPublicKeyDto>(items.Length);
for (var index = 0; index < items.Length; index++)
{
if (items[index].UserId == first || items[index].UserId == second)
{
result.Add(items[index]);
}
}

return result.ToArray();
}

private static string[] FilterIds(string[] items, string first, string second)
{
var result = new List<string>(items.Length);
for (var index = 0; index < items.Length; index++)
{
if (items[index] == first || items[index] == second)
{
result.Add(items[index]);
}
}

return result.ToArray();
}

private NewWrapDto[]? BuildWraps(byte[] cek, IReadOnlyList<UserPublicKeyDto> recipients)
{
var authorized = new List<UserPublicKeyDto>(recipients.Count);
for (var index = 0; index < recipients.Count; index++)
{
var recipient = recipients[index];
if (guard is null || guard.IsAuthorized(recipient))
{
authorized.Add(recipient);
}
}

if (authorized.Count == 0)
{
return null;
}

var wraps = new NewWrapDto[authorized.Count];
for (var index = 0; index < authorized.Count; index++)
{
var recipient = authorized[index];
var wrapped = CryptoBox.WrapCek(cek, recipient.PublicKey);
if (wrapped is null)
{
Expand Down
10 changes: 10 additions & 0 deletions src/Aetherphone/Core/Crypto/IKeyVault.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Aetherphone.Core.Crypto;

internal interface IKeyVault
{
event Action? Changed;

KeyVaultState State { get; }

byte[]? UnwrapCek(string wrappedKey);
}
8 changes: 8 additions & 0 deletions src/Aetherphone/Core/Crypto/IWrapRecipientGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Aetherphone.Core.Aethernet.Contracts;

namespace Aetherphone.Core.Crypto;

internal interface IWrapRecipientGuard
{
bool IsAuthorized(UserPublicKeyDto recipient);
}
11 changes: 1 addition & 10 deletions src/Aetherphone/Core/Crypto/KeyVault.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,7 @@

namespace Aetherphone.Core.Crypto;

internal enum KeyVaultState
{
Unavailable = 0,
Provisioning = 1,
Unlocked = 2,
Unsupported = 3,
Locked = 4,
}

internal sealed class KeyVault : IDisposable
internal sealed class KeyVault : IDisposable, IKeyVault
{
private readonly Configuration configuration;
private readonly AethernetSession session;
Expand Down
10 changes: 10 additions & 0 deletions src/Aetherphone/Core/Crypto/KeyVaultState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Aetherphone.Core.Crypto;

internal enum KeyVaultState
{
Unavailable = 0,
Provisioning = 1,
Unlocked = 2,
Unsupported = 3,
Locked = 4,
}
Loading