Skip to content

Commit 73d147d

Browse files
authored
Optimize DecodePubCurveKey and fix off-by-one (#23)
* Optimize DecodePubCurveKey * fix format * Refactor DecodePubCurveKey to use stack allocation and optimize key decoding logic. * Validate curve key during DecodePubCurveKey and add test to reject non-curve keys.
1 parent 0b11e47 commit 73d147d

4 files changed

Lines changed: 320 additions & 32 deletions

File tree

NATS.NKeys.Tests/NATS.NKeys.Tests.csproj

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,6 @@
2828
<Compile Include="..\NATS.NKeys.Benchmarks\NKeysReference1.cs" />
2929
<Compile Include="..\NATS.NKeys.Benchmarks\NKeysReference2.cs" />
3030
<Compile Include="..\NATS.NKeys.Benchmarks\FixedRng.cs" />
31-
<Compile Include="..\NATS.NKeys\Internal\Crc16.cs" />
32-
<Compile Include="..\NATS.NKeys\NaCl\*.cs">
33-
<Link>NaCl\%(Filename)%(Extension)</Link>
34-
</Compile>
35-
<Compile Include="..\NATS.NKeys\NaCl\Internal\Ed25519Ref10\*.cs">
36-
<Link>NaCl\Internal\Ed25519Ref10\%(Filename)%(Extension)</Link>
37-
</Compile>
3831
</ItemGroup>
3932

4033
<ItemGroup>

NATS.NKeys.Tests/NKeysTest.cs

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,287 @@ public void Api()
236236
Assert.False(kp5.Verify(corrupt, signature));
237237
}
238238

239+
[Fact]
240+
public void DecodePubCurveKey_returns_exactly_32_bytes()
241+
{
242+
var kp = KeyPair.CreatePair(PrefixByte.Curve);
243+
var decoded = KeyPair.DecodePubCurveKey(kp.GetPublicKey());
244+
Assert.Equal(32, decoded.Length);
245+
}
246+
247+
[Fact]
248+
public void DecodePubCurveKey_returns_32_bytes_for_multiple_keys()
249+
{
250+
// Verify across several independently generated keys
251+
for (var i = 0; i < 10; i++)
252+
{
253+
var kp = KeyPair.CreatePair(PrefixByte.Curve);
254+
var decoded = KeyPair.DecodePubCurveKey(kp.GetPublicKey());
255+
Assert.Equal(32, decoded.Length);
256+
}
257+
}
258+
259+
[Fact]
260+
public void DecodePubCurveKey_deterministic_for_known_seed()
261+
{
262+
var kp = KeyPair.FromSeed("SXAD4F52S2XAJTJ3TGDJ4VXQVW7TU35XJUSVKF25ZRXIWCIUK6NLANRHVY".ToCharArray());
263+
var decoded1 = KeyPair.DecodePubCurveKey(kp.GetPublicKey());
264+
var decoded2 = KeyPair.DecodePubCurveKey(kp.GetPublicKey());
265+
Assert.Equal(decoded1, decoded2);
266+
Assert.Equal(32, decoded1.Length);
267+
}
268+
269+
[Fact]
270+
public void DecodePubCurveKey_matches_known_public_key()
271+
{
272+
// Known seed/public key pair from existing tests
273+
var kp = KeyPair.FromSeed("SXANLPW5OPP62ISXMPTH26DBYM4BGT3U6P2FEALGW3BZAVXQRWCX3KH2ZM".ToCharArray());
274+
Assert.Equal("XBIGHMCJSHWYFW6ZY4WWONQ3FRIS5A3OQAUMPPKYBNKRYQTVX4PEAIZA", kp.GetPublicKey());
275+
var decoded = KeyPair.DecodePubCurveKey(kp.GetPublicKey());
276+
Assert.Equal(32, decoded.Length);
277+
}
278+
279+
[Fact]
280+
public void DecodePubCurveKey_does_not_include_prefix_byte()
281+
{
282+
var kp = KeyPair.CreatePair(PrefixByte.Curve);
283+
var publicKey = kp.GetPublicKey();
284+
285+
// Decode the full base32 to get raw bytes including prefix and CRC
286+
var rawLen = Base32.GetDataLength(publicKey.ToCharArray());
287+
var raw = new byte[rawLen];
288+
Base32.FromBase32(publicKey.ToCharArray(), raw);
289+
290+
// First byte is the prefix
291+
var prefixByte = raw[0];
292+
Assert.Equal((byte)PrefixByte.Curve, prefixByte);
293+
294+
// DecodePubCurveKey should return bytes [1..33), not include prefix or CRC
295+
var decoded = KeyPair.DecodePubCurveKey(publicKey);
296+
Assert.Equal(raw.AsSpan().Slice(1, 32).ToArray(), decoded);
297+
}
298+
299+
[Fact]
300+
public void DecodePubCurveKey_does_not_include_crc_bytes()
301+
{
302+
var kp = KeyPair.CreatePair(PrefixByte.Curve);
303+
var publicKey = kp.GetPublicKey();
304+
305+
// Get the raw decoded bytes
306+
var rawLen = Base32.GetDataLength(publicKey.ToCharArray());
307+
var raw = new byte[rawLen];
308+
Base32.FromBase32(publicKey.ToCharArray(), raw);
309+
310+
// Decoded key should be exactly raw[1..33) — the 32-byte key without prefix or CRC
311+
var decoded = KeyPair.DecodePubCurveKey(publicKey);
312+
Assert.Equal(32, decoded.Length);
313+
Assert.Equal(raw.AsSpan().Slice(1, 32).ToArray(), decoded);
314+
}
315+
316+
[Fact]
317+
public void DecodePubCurveKey_round_trips_with_seal_open()
318+
{
319+
var kp1 = KeyPair.FromSeed("SXAD4F52S2XAJTJ3TGDJ4VXQVW7TU35XJUSVKF25ZRXIWCIUK6NLANRHVY".ToCharArray());
320+
var kp2 = KeyPair.CreatePair(PrefixByte.Curve);
321+
322+
var message = new byte[] { 1, 2, 3, 4, 5 };
323+
var sealed1 = kp1.Seal(message, kp2.GetPublicKey());
324+
var opened = kp2.Open(sealed1, kp1.GetPublicKey());
325+
Assert.Equal(message, opened);
326+
}
327+
328+
[Fact]
329+
public void DecodePubCurveKey_rejects_non_curve_key()
330+
{
331+
// A valid User public key has the same decoded length but wrong prefix
332+
var kp = KeyPair.CreatePair(PrefixByte.User);
333+
var ex = Assert.Throws<NKeysException>(() => KeyPair.DecodePubCurveKey(kp.GetPublicKey()));
334+
Assert.Equal("Not a valid curve key", ex.Message);
335+
}
336+
337+
[Fact]
338+
public void DecodePubCurveKey_rejects_too_short()
339+
{
340+
var ex = Assert.Throws<NKeysException>(() => KeyPair.DecodePubCurveKey("XAAA"));
341+
Assert.Equal("Not a valid curve key", ex.Message);
342+
}
343+
344+
[Fact]
345+
public void DecodePubCurveKey_rejects_too_long()
346+
{
347+
// Take a valid key and append extra base32 characters
348+
var kp = KeyPair.CreatePair(PrefixByte.Curve);
349+
var tooLong = kp.GetPublicKey() + "AAAAAAAAAA";
350+
Assert.Throws<NKeysException>(() => KeyPair.DecodePubCurveKey(tooLong));
351+
}
352+
353+
[Fact]
354+
public void DecodePubCurveKey_rejects_empty_string()
355+
{
356+
Assert.ThrowsAny<Exception>(() => KeyPair.DecodePubCurveKey(string.Empty));
357+
}
358+
359+
[Fact]
360+
public void DecodePubCurveKey_rejects_corrupted_crc()
361+
{
362+
var kp = KeyPair.CreatePair(PrefixByte.Curve);
363+
var publicKey = kp.GetPublicKey();
364+
365+
// Corrupt a character in the middle of the key
366+
var chars = publicKey.ToCharArray();
367+
chars[10] = chars[10] == 'A' ? 'B' : 'A';
368+
var corrupted = new string(chars);
369+
370+
var ex = Assert.Throws<NKeysException>(() => KeyPair.DecodePubCurveKey(corrupted));
371+
Assert.Equal("Invalid CRC", ex.Message);
372+
}
373+
374+
[Fact]
375+
public void DecodePubCurveKey_rejects_invalid_base32_characters()
376+
{
377+
var kp = KeyPair.CreatePair(PrefixByte.Curve);
378+
var publicKey = kp.GetPublicKey();
379+
380+
// Replace with invalid base32 chars (lowercase, digits 0/1/8/9, symbols)
381+
var chars = publicKey.ToCharArray();
382+
chars[5] = '!';
383+
Assert.Throws<ArgumentException>(() => KeyPair.DecodePubCurveKey(new string(chars)));
384+
}
385+
386+
[Fact]
387+
public void DecodePubCurveKey_different_keys_produce_different_results()
388+
{
389+
var kp1 = KeyPair.CreatePair(PrefixByte.Curve);
390+
var kp2 = KeyPair.CreatePair(PrefixByte.Curve);
391+
392+
var decoded1 = KeyPair.DecodePubCurveKey(kp1.GetPublicKey());
393+
var decoded2 = KeyPair.DecodePubCurveKey(kp2.GetPublicKey());
394+
395+
Assert.NotEqual(decoded1, decoded2);
396+
}
397+
398+
[Fact]
399+
public void DecodePubCurveKey_cross_validates_with_go_test_data()
400+
{
401+
// Use the Go-generated test data to verify decode produces
402+
// keys that work correctly in seal/open operations
403+
var json = JsonNode.Parse(File.ReadAllText("test_data.json"));
404+
foreach (var data in json!["xkeys"]!.AsArray())
405+
{
406+
var pk1 = data!["pk1"]!.GetValue<string>();
407+
var pk2 = data!["pk2"]!.GetValue<string>();
408+
409+
var decoded1 = KeyPair.DecodePubCurveKey(pk1);
410+
var decoded2 = KeyPair.DecodePubCurveKey(pk2);
411+
412+
Assert.Equal(32, decoded1.Length);
413+
Assert.Equal(32, decoded2.Length);
414+
}
415+
}
416+
417+
[Fact]
418+
public void DecodePubCurveKey_blackbox_seal_open_with_decoded_key()
419+
{
420+
// Black box: if DecodePubCurveKey returns the correct raw key,
421+
// then encrypting for that key and decrypting must round-trip
422+
var sender = KeyPair.CreatePair(PrefixByte.Curve);
423+
var receiver = KeyPair.CreatePair(PrefixByte.Curve);
424+
425+
var payloads = new byte[][]
426+
{
427+
Array.Empty<byte>(),
428+
new byte[] { 0 },
429+
new byte[] { 0xFF },
430+
Encoding.UTF8.GetBytes("hello world"),
431+
new byte[1024],
432+
new byte[8192],
433+
};
434+
435+
foreach (var payload in payloads)
436+
{
437+
var sealed1 = sender.Seal(payload, receiver.GetPublicKey());
438+
var opened = receiver.Open(sealed1, sender.GetPublicKey());
439+
Assert.Equal(payload, opened);
440+
}
441+
}
442+
443+
[Fact]
444+
public void DecodePubCurveKey_blackbox_two_parties_cross_seal()
445+
{
446+
// Black box: both parties can seal for each other and open
447+
var alice = KeyPair.CreatePair(PrefixByte.Curve);
448+
var bob = KeyPair.CreatePair(PrefixByte.Curve);
449+
var message = Encoding.UTF8.GetBytes("secret message");
450+
451+
// Alice seals for Bob
452+
var sealedForBob = alice.Seal(message, bob.GetPublicKey());
453+
Assert.Equal(message, bob.Open(sealedForBob, alice.GetPublicKey()));
454+
455+
// Bob seals for Alice
456+
var sealedForAlice = bob.Seal(message, alice.GetPublicKey());
457+
Assert.Equal(message, alice.Open(sealedForAlice, bob.GetPublicKey()));
458+
}
459+
460+
[Fact]
461+
public void DecodePubCurveKey_blackbox_wrong_sender_fails_open()
462+
{
463+
// Black box: opening with wrong sender key must fail
464+
var alice = KeyPair.CreatePair(PrefixByte.Curve);
465+
var bob = KeyPair.CreatePair(PrefixByte.Curve);
466+
var eve = KeyPair.CreatePair(PrefixByte.Curve);
467+
468+
var sealed1 = alice.Seal(Encoding.UTF8.GetBytes("secret"), bob.GetPublicKey());
469+
470+
// Bob tries to open with Eve's public key as sender — must fail
471+
Assert.ThrowsAny<Exception>(() => bob.Open(sealed1, eve.GetPublicKey()));
472+
}
473+
474+
[Fact]
475+
public void DecodePubCurveKey_blackbox_wrong_receiver_fails_open()
476+
{
477+
// Black box: wrong receiver cannot open
478+
var alice = KeyPair.CreatePair(PrefixByte.Curve);
479+
var bob = KeyPair.CreatePair(PrefixByte.Curve);
480+
var eve = KeyPair.CreatePair(PrefixByte.Curve);
481+
482+
var sealed1 = alice.Seal(Encoding.UTF8.GetBytes("secret"), bob.GetPublicKey());
483+
484+
// Eve tries to open something meant for Bob
485+
Assert.ThrowsAny<Exception>(() => eve.Open(sealed1, alice.GetPublicKey()));
486+
}
487+
488+
[Fact]
489+
public void DecodePubCurveKey_blackbox_tampered_ciphertext_fails()
490+
{
491+
var alice = KeyPair.CreatePair(PrefixByte.Curve);
492+
var bob = KeyPair.CreatePair(PrefixByte.Curve);
493+
494+
var sealed1 = alice.Seal(Encoding.UTF8.GetBytes("secret"), bob.GetPublicKey());
495+
496+
// Flip a byte in the encrypted payload (after version + nonce header)
497+
sealed1[30] ^= 0xFF;
498+
499+
Assert.ThrowsAny<Exception>(() => bob.Open(sealed1, alice.GetPublicKey()));
500+
}
501+
502+
[Fact]
503+
public void DecodePubCurveKey_blackbox_each_seal_produces_different_ciphertext()
504+
{
505+
// Black box: nonce randomization means same plaintext encrypts differently
506+
var alice = KeyPair.CreatePair(PrefixByte.Curve);
507+
var bob = KeyPair.CreatePair(PrefixByte.Curve);
508+
var message = Encoding.UTF8.GetBytes("hello");
509+
510+
var sealed1 = alice.Seal(message, bob.GetPublicKey());
511+
var sealed2 = alice.Seal(message, bob.GetPublicKey());
512+
513+
Assert.NotEqual(sealed1, sealed2);
514+
515+
// But both decrypt to the same message
516+
Assert.Equal(message, bob.Open(sealed1, alice.GetPublicKey()));
517+
Assert.Equal(message, bob.Open(sealed2, alice.GetPublicKey()));
518+
}
519+
239520
[Fact]
240521
public void Public_key_does_not_have_seed_nor_secret_key()
241522
{

NATS.NKeys/KeyPair.cs

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,6 @@ public byte[] Seal(byte[] data, string receiver)
231231
throw new NKeysException("Curve key only operation");
232232
}
233233

234-
// TODO optimize
235234
var rpub = DecodePubCurveKey(receiver);
236235

237236
var nonce = new byte[CurveNonceLen];
@@ -268,7 +267,6 @@ public byte[] Open(byte[] input, string sender)
268267
throw new NKeysException("Curve key only operation");
269268
}
270269

271-
// TODO optimize
272270
if (input.Length <= Vlen + CurveNonceLen)
273271
{
274272
throw new NKeysException("Encrypted input is not valid");
@@ -302,6 +300,40 @@ public void Dispose()
302300
CryptoBytes.Wipe(_pk);
303301
}
304302

303+
internal static byte[] DecodePubCurveKey(string key)
304+
{
305+
var pub = new byte[CurveKeyLen];
306+
DecodePubCurveKey(key, pub);
307+
return pub;
308+
}
309+
310+
internal static void DecodePubCurveKey(string key, Span<byte> pub)
311+
{
312+
Span<byte> buf = stackalloc byte[CurveDecodeLen];
313+
var keySpan = key.AsSpan();
314+
var length = Base32.GetDataLength(keySpan);
315+
316+
if (length != CurveDecodeLen)
317+
{
318+
throw new NKeysException("Not a valid curve key");
319+
}
320+
321+
Base32.FromBase32(keySpan, buf);
322+
323+
if (buf[0] != (byte)PrefixByte.Curve)
324+
{
325+
throw new NKeysException("Not a valid curve key");
326+
}
327+
328+
var crc = (ushort)(buf[CurveDecodeLen - 2] | buf[CurveDecodeLen - 1] << 8);
329+
if (crc != Crc16.Checksum(buf.Slice(0, CurveDecodeLen - 2)))
330+
{
331+
ThrowInvalidCrcException();
332+
}
333+
334+
buf.Slice(1, CurveKeyLen).CopyTo(pub);
335+
}
336+
305337
private static string Encode(byte prefixByte, bool seed, byte[] src)
306338
{
307339
if (!IsValidPublicPrefixByte(prefixByte))
@@ -443,27 +475,4 @@ or NKeysConstants.PrefixByteUser
443475

444476
[MethodImpl(MethodImplOptions.NoInlining)]
445477
private static void ThrowInvalidCurveKeyOperationException() => throw new NKeysException("Invalid curve key operation");
446-
447-
private static byte[] DecodePubCurveKey(string key)
448-
{
449-
// TODO optimize
450-
var length = Base32.GetDataLength(key.ToCharArray());
451-
var buf = new Span<byte>(new byte[length]);
452-
Base32.FromBase32(key.ToCharArray(), buf);
453-
454-
if (buf.Length != CurveDecodeLen)
455-
{
456-
throw new NKeysException("Not a valid curve key");
457-
}
458-
459-
var crc = (ushort)(buf[length - 2] | buf[length - 1] << 8);
460-
if (crc != Crc16.Checksum(buf.Slice(0, length - 2)))
461-
{
462-
ThrowInvalidCrcException();
463-
}
464-
465-
var pub = buf.Slice(1, length - 2).ToArray();
466-
467-
return pub;
468-
}
469478
}

NATS.NKeys/NATS.NKeys.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,9 @@
2424
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.4" ExcludeAssets="Compile" PrivateAssets="All" />
2525
</ItemGroup>
2626

27+
<ItemGroup>
28+
<InternalsVisibleTo Include="NATS.NKeys.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100DB7DA1F2F89089327B47D26D69666FAD20861F24E9ACDB13965FB6C64DFEE8DA589B495DF37A75E934DDBACB0752A42C40F3DBC79614EEC9BB2A0B6741F9E2AD2876F95E74D54C23EEF0063EB4EFB1E7D824EE8A695B647C113C92834F04A3A83FB60F435814DDF5C4E5F66A168139C4C1B1A50A3E60C164D180E265B1F000CD" />
29+
<InternalsVisibleTo Include="NATS.NKeys.NaCl.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100DB7DA1F2F89089327B47D26D69666FAD20861F24E9ACDB13965FB6C64DFEE8DA589B495DF37A75E934DDBACB0752A42C40F3DBC79614EEC9BB2A0B6741F9E2AD2876F95E74D54C23EEF0063EB4EFB1E7D824EE8A695B647C113C92834F04A3A83FB60F435814DDF5C4E5F66A168139C4C1B1A50A3E60C164D180E265B1F000CD" />
30+
</ItemGroup>
31+
2732
</Project>

0 commit comments

Comments
 (0)