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.