From bb51c89798ff6702f4ea0268e796c07fa990e9ba Mon Sep 17 00:00:00 2001 From: Patrick Scheid Date: Thu, 23 Jul 2026 14:21:42 +0200 Subject: [PATCH] Dispatch recipients on bech32 HRP instead of string prefixes ParseRecipientLine matched "age1pq" as a string prefix, but 'p' and 'q' are bech32 data characters: roughly 1 in 1024 X25519 recipients legitimately starts with "age1pq" and was misrouted to the ML-KEM parser, surfacing as random test failures (most recently ParseRecipientsFile_MixedWithPlugin on CI). Plugins whose name starts with "pq" hit the same misroute. Split at the last '1' (BIP-173: data never contains '1') and dispatch on the true HRP, mirroring what Bech32.Decode does. The CLI carried its own copy of the prefix dispatch; it now delegates to AgeKeygen.ParseRecipientLine so both paths share the fix. --- Age.Cli/AgeCommand.cs | 17 ++---------- Age.Tests/RecipientsFileTests.cs | 45 +++++++++++++++++++++++++++++++- Age/Age.csproj | 1 + Age/AgeKeygen.cs | 26 +++++++++++++----- 4 files changed, 66 insertions(+), 23 deletions(-) diff --git a/Age.Cli/AgeCommand.cs b/Age.Cli/AgeCommand.cs index 80ae01d..9fb0ba6 100644 --- a/Age.Cli/AgeCommand.cs +++ b/Age.Cli/AgeCommand.cs @@ -132,21 +132,8 @@ private static List 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 LoadIdentities(string path, IPluginCallbacks callbacks) { diff --git a/Age.Tests/RecipientsFileTests.cs b/Age.Tests/RecipientsFileTests.cs index e8dfd67..238f5be 100644 --- a/Age.Tests/RecipientsFileTests.cs +++ b/Age.Tests/RecipientsFileTests.cs @@ -1,4 +1,5 @@ using Age; +using Age.Crypto; using Age.Recipients; using Xunit; @@ -6,6 +7,11 @@ 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() { @@ -75,7 +81,44 @@ public void ParseRecipientsFile_UnrecognizedLine_Throws() { var text = "NOT-A-VALID-RECIPIENT\n"; var ex = Assert.Throws(() => 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(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(parsed[0]); } [Fact] diff --git a/Age/Age.csproj b/Age/Age.csproj index 6d94443..0b00403 100644 --- a/Age/Age.csproj +++ b/Age/Age.csproj @@ -29,6 +29,7 @@ + diff --git a/Age/AgeKeygen.cs b/Age/AgeKeygen.cs index ee594ac..2815b22 100644 --- a/Age/AgeKeygen.cs +++ b/Age/AgeKeygen.cs @@ -68,14 +68,26 @@ public static IReadOnlyList ParseRecipientsFile(string text, IPlugin .Select(line => ParseRecipientLine(line, callbacks)) .ToList(); - private static IRecipient ParseRecipientLine(string line, IPluginCallbacks? callbacks) => line switch + /// + /// Parses a single recipient string: age X25519, ML-KEM-768, plugin, or SSH public key. + /// + 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}") + }; + } /// /// Parses a plaintext identity file containing AGE-SECRET-KEY lines, plugin identities, comments, and blank lines.