Skip to content
Merged
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
17 changes: 2 additions & 15 deletions Age.Cli/AgeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,21 +132,8 @@ private static List<IIdentity> CollectDecryptIdentities(bool passphrase, string[
_ => null
};

private static IRecipient ParseRecipient(string s)
{
var callbacks = new CliPluginCallbacks();

if (s.StartsWith("age1pq"))
return MlKem768X25519Recipient.Parse(s);
if (s.StartsWith("age1") && s.IndexOf('1', 4) > 0)
return new PluginRecipient(s, callbacks);
if (s.StartsWith("age1"))
return X25519Recipient.Parse(s);
if (s.StartsWith("ssh-"))
return AgeKeygen.ParseSshRecipient(s);

throw new FormatException($"unknown recipient type: {s}");
}
private static IRecipient ParseRecipient(string s) =>
AgeKeygen.ParseRecipientLine(s, new CliPluginCallbacks());

private static List<IIdentity> LoadIdentities(string path, IPluginCallbacks callbacks)
{
Expand Down
45 changes: 44 additions & 1 deletion Age.Tests/RecipientsFileTests.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
using Age;
using Age.Crypto;
using Age.Recipients;
using Xunit;

namespace Age.Tests;

public class RecipientsFileTests
{
// Found by brute force: a real X25519 key whose recipient's bech32 data happens to start
// with "pq", so the string begins "age1pq" exactly like an ML-KEM-768 recipient does.
private const string PqLookalikeIdentity = "AGE-SECRET-KEY-1LRKLPKJT609NGXMQ8FJ2T985FU95PK29M9YZRPAW4WWZ5WWZWFRSNV7M2H";
private const string PqLookalikeRecipient = "age1pqw26wvuhkqsmmqh0flpkkt7hmn2mrwmr83v84fm8zjmalnqavuq2tj4sg";

[Fact]
public void ParseRecipientsFile_X25519()
{
Expand Down Expand Up @@ -75,7 +81,44 @@ public void ParseRecipientsFile_UnrecognizedLine_Throws()
{
var text = "NOT-A-VALID-RECIPIENT\n";
var ex = Assert.Throws<FormatException>(() => AgeKeygen.ParseRecipientsFile(text));
Assert.Contains("unrecognized line", ex.Message);
Assert.Contains("unrecognized recipient", ex.Message);
}

[Fact]
public void ParseRecipientsFile_X25519StartingWithAge1Pq_ParsesAsX25519()
{
using var identity = X25519Identity.Parse(PqLookalikeIdentity);
Assert.Equal(PqLookalikeRecipient, identity.Recipient.ToString());

var parsed = AgeKeygen.ParseRecipientsFile($"{PqLookalikeRecipient}\n");
Assert.Single(parsed);
Assert.IsType<X25519Recipient>(parsed[0]);
}

[Fact]
public void ParseRecipientsFile_X25519StartingWithAge1Pq_RoundTrips()
{
using var identity = X25519Identity.Parse(PqLookalikeIdentity);
var recipients = AgeKeygen.ParseRecipientsFile($"{PqLookalikeRecipient}\n");
var plaintext = "pq-lookalike recipient"u8.ToArray();

using var encInput = new MemoryStream(plaintext);
using var encOutput = new MemoryStream();
AgeEncrypt.Encrypt(encInput, encOutput, recipients.ToArray());

encOutput.Position = 0;
using var decOutput = new MemoryStream();
AgeEncrypt.Decrypt(encOutput, decOutput, identity);
Assert.Equal(plaintext, decOutput.ToArray());
}

[Fact]
public void ParseRecipientsFile_PluginNameStartingWithPq_ParsesAsPlugin()
{
var recipient = Bech32.Encode("age1pqtest", [0x01, 0x02, 0x03]);
var parsed = AgeKeygen.ParseRecipientsFile($"{recipient}\n");
Assert.Single(parsed);
Assert.IsType<PluginRecipient>(parsed[0]);
}

[Fact]
Expand Down
1 change: 1 addition & 0 deletions Age/Age.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

<ItemGroup>
<InternalsVisibleTo Include="Age.Tests" />
<InternalsVisibleTo Include="Age.Cli" />
</ItemGroup>

<ItemGroup>
Expand Down
26 changes: 19 additions & 7 deletions Age/AgeKeygen.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,26 @@ public static IReadOnlyList<IRecipient> ParseRecipientsFile(string text, IPlugin
.Select(line => ParseRecipientLine(line, callbacks))
.ToList();

private static IRecipient ParseRecipientLine(string line, IPluginCallbacks? callbacks) => line switch
/// <summary>
/// Parses a single recipient string: age X25519, ML-KEM-768, plugin, or SSH public key.
/// </summary>
internal static IRecipient ParseRecipientLine(string line, IPluginCallbacks? callbacks)
{
_ when line.StartsWith("age1pq") => MlKem768X25519Recipient.Parse(line),
_ when line.StartsWith("age1") && line.IndexOf('1', 4) > 0 => new PluginRecipient(line, callbacks),
_ when line.StartsWith("age1") => X25519Recipient.Parse(line),
_ when line.StartsWith("ssh-") => ParseSshRecipient(line),
_ => throw new FormatException($"unrecognized line in recipients file: {line}")
};
// Dispatch on the true bech32 HRP — everything before the LAST '1' (BIP-173), since
// data never contains '1'. String prefixes are ambiguous here: 'p' and 'q' are data
// characters, so an X25519 recipient's data can itself start with "pq" ("age1pq...").
var sep = line.LastIndexOf('1');
var hrp = sep > 0 ? line[..sep] : "";

return hrp switch
{
"age" => X25519Recipient.Parse(line),
"age1pq" => MlKem768X25519Recipient.Parse(line),
_ when hrp.StartsWith("age1") => new PluginRecipient(line, callbacks),
_ when line.StartsWith("ssh-") => ParseSshRecipient(line),
_ => throw new FormatException($"unrecognized recipient: {line}")
};
}

/// <summary>
/// Parses a plaintext identity file containing AGE-SECRET-KEY lines, plugin identities, comments, and blank lines.
Expand Down
Loading