Skip to content

Commit 5b53fbf

Browse files
authored
Merge pull request #6 from dexcompiler/copilot/post-phase-security-hardening-v2
Security hardening + commit hook (replacement for #5)
2 parents bf9a27f + 5664bd4 commit 5b53fbf

5 files changed

Lines changed: 240 additions & 18 deletions

File tree

.githooks/commit-msg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/sh
2+
powershell -NoProfile -ExecutionPolicy Bypass -File ".githooks/commit-msg.ps1" "$1"

.githooks/commit-msg.ps1

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
param(
2+
[Parameter(Mandatory = $true)]
3+
[string]$MessageFile
4+
)
5+
6+
$lines = Get-Content -LiteralPath $MessageFile
7+
$filtered = foreach ($line in $lines) {
8+
if ($line -notmatch '^(?i)co-authored-by:\s') { $line }
9+
}
10+
11+
while ($filtered.Count -gt 0 -and $filtered[-1] -eq '') {
12+
$filtered = $filtered[0..($filtered.Count - 2)]
13+
}
14+
15+
if ($filtered.Count -eq 0) {
16+
Set-Content -LiteralPath $MessageFile -NoNewline -Value ''
17+
}
18+
else {
19+
Set-Content -LiteralPath $MessageFile -Value $filtered
20+
}

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ Ed25519.CreateKeypair(publicKey, privateKey, seed);
4949
- **Constant-time vs variable-time**:
5050
- Signing uses constant-time building blocks (`CMov`, etc.).
5151
- Verification uses variable-time operations (inputs are public).
52+
- **Strict verification**:
53+
- Verification rejects signatures with non-canonical `S` (must satisfy `S < L`).
54+
- Verification rejects low-order public keys (small torsion subgroup).
55+
- **Exact-size contracts**:
56+
- `CreateKeypair`: public key = 32 bytes, private key = 64 bytes, seed = 32 bytes.
57+
- `Sign`/`Verify`: fixed-size key/signature inputs must be exact size (not oversized).
5258

5359
### Sign a Message
5460

@@ -115,6 +121,8 @@ If your goal is *certificate issuance*, this repo includes **CSR (PKCS#10) helpe
115121

116122
It also supports exporting **encrypted** PKCS#8 PEM (`"ENCRYPTED PRIVATE KEY"`) via `Pkcs.ExportEncryptedPrivateKeyPem(seed, password, iterations)`.
117123

124+
The old encrypted export overload `Pkcs.ExportEncryptedPrivateKeyPem(seed, publicKey, password, iterations)` is kept for compatibility but marked obsolete; the `publicKey` argument is ignored.
125+
118126
## Implementation Notes
119127

120128
This implementation follows the ref10 naming conventions from the original C code. The short names preserve traceability to the reference implementation for auditing purposes.

src/Ed25519.cs

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ namespace Ed25519;
1515
/// </summary>
1616
public static class Ed25519
1717
{
18+
// Group order L in little-endian form.
19+
private static readonly byte[] ScalarOrderL =
20+
[
21+
0xED, 0xD3, 0xF5, 0x5C, 0x1A, 0x63, 0x12, 0x58,
22+
0xD6, 0x9C, 0xF7, 0xA2, 0xDE, 0xF9, 0xDE, 0x14,
23+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
24+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10
25+
];
26+
1827
/// <summary>Size of a public key in bytes.</summary>
1928
public const int PublicKeySize = 32;
2029

@@ -35,18 +44,18 @@ public static class Ed25519
3544
/// <param name="seed">Input: 32-byte random seed.</param>
3645
public static void CreateKeypair(Span<byte> publicKey, Span<byte> privateKey, ReadOnlySpan<byte> seed)
3746
{
38-
if (publicKey.Length < PublicKeySize)
39-
throw new ArgumentException($"Public key buffer must be at least {PublicKeySize} bytes", nameof(publicKey));
40-
if (privateKey.Length < PrivateKeySize)
41-
throw new ArgumentException($"Private key buffer must be at least {PrivateKeySize} bytes", nameof(privateKey));
42-
if (seed.Length < SeedSize)
43-
throw new ArgumentException($"Seed must be at least {SeedSize} bytes", nameof(seed));
47+
if (publicKey.Length != PublicKeySize)
48+
throw new ArgumentException($"Public key buffer must be exactly {PublicKeySize} bytes", nameof(publicKey));
49+
if (privateKey.Length != PrivateKeySize)
50+
throw new ArgumentException($"Private key buffer must be exactly {PrivateKeySize} bytes", nameof(privateKey));
51+
if (seed.Length != SeedSize)
52+
throw new ArgumentException($"Seed must be exactly {SeedSize} bytes", nameof(seed));
4453

4554
// Hash the seed to produce the private scalar and prefix
4655
Span<byte> hash = stackalloc byte[64];
4756
try
4857
{
49-
SHA512.HashData(seed[..SeedSize], hash);
58+
SHA512.HashData(seed, hash);
5059

5160
// Clamp the scalar (first 32 bytes)
5261
hash[0] &= 248;
@@ -91,12 +100,12 @@ public static (byte[] PublicKey, byte[] PrivateKey, byte[] Seed) GenerateKeypair
91100
/// <param name="privateKey">The 64-byte private key.</param>
92101
public static void Sign(Span<byte> signature, ReadOnlySpan<byte> message, ReadOnlySpan<byte> publicKey, ReadOnlySpan<byte> privateKey)
93102
{
94-
if (signature.Length < SignatureSize)
95-
throw new ArgumentException($"Signature buffer must be at least {SignatureSize} bytes", nameof(signature));
96-
if (publicKey.Length < PublicKeySize)
97-
throw new ArgumentException($"Public key must be at least {PublicKeySize} bytes", nameof(publicKey));
98-
if (privateKey.Length < PrivateKeySize)
99-
throw new ArgumentException($"Private key must be at least {PrivateKeySize} bytes", nameof(privateKey));
103+
if (signature.Length != SignatureSize)
104+
throw new ArgumentException($"Signature buffer must be exactly {SignatureSize} bytes", nameof(signature));
105+
if (publicKey.Length != PublicKeySize)
106+
throw new ArgumentException($"Public key must be exactly {PublicKeySize} bytes", nameof(publicKey));
107+
if (privateKey.Length != PrivateKeySize)
108+
throw new ArgumentException($"Private key must be exactly {PrivateKeySize} bytes", nameof(privateKey));
100109

101110
// The private key contains: [0..31] = clamped hash (scalar), [32..63] = prefix
102111
ReadOnlySpan<byte> prefix = privateKey[32..64];
@@ -123,7 +132,7 @@ public static void Sign(Span<byte> signature, ReadOnlySpan<byte> message, ReadOn
123132
using (var sha = IncrementalHash.CreateHash(HashAlgorithmName.SHA512))
124133
{
125134
sha.AppendData(signature[..32]);
126-
sha.AppendData(publicKey[..32]);
135+
sha.AppendData(publicKey);
127136
sha.AppendData(message);
128137
sha.GetHashAndReset(h);
129138
}
@@ -148,8 +157,8 @@ public static void Sign(Span<byte> signature, ReadOnlySpan<byte> message, ReadOn
148157
/// <param name="privateKey">The 64-byte private key (expanded key: scalar||prefix).</param>
149158
public static void Sign(Span<byte> signature, ReadOnlySpan<byte> message, ReadOnlySpan<byte> privateKey)
150159
{
151-
if (privateKey.Length < PrivateKeySize)
152-
throw new ArgumentException($"Private key buffer must be at least {PrivateKeySize} bytes", nameof(privateKey));
160+
if (privateKey.Length != PrivateKeySize)
161+
throw new ArgumentException($"Private key buffer must be exactly {PrivateKeySize} bytes", nameof(privateKey));
153162

154163
// Derive public key: A = [scalar] * B
155164
Span<byte> publicKey = stackalloc byte[PublicKeySize];
@@ -196,14 +205,16 @@ public static byte[] Sign(ReadOnlySpan<byte> message, ReadOnlySpan<byte> private
196205
/// <returns>True if the signature is valid, false otherwise.</returns>
197206
public static bool Verify(ReadOnlySpan<byte> signature, ReadOnlySpan<byte> message, ReadOnlySpan<byte> publicKey)
198207
{
199-
if (signature.Length < SignatureSize)
208+
if (signature.Length != SignatureSize)
200209
return false;
201-
if (publicKey.Length < PublicKeySize)
210+
if (publicKey.Length != PublicKeySize)
202211
return false;
203212

204213
// Check that s is in range (top 3 bits of s must be 0)
205214
if ((signature[63] & 224) != 0)
206215
return false;
216+
if (!IsCanonicalScalar(signature[32..64]))
217+
return false;
207218

208219
// Decode public key as point A (negated for subtraction in verification)
209220
if (Ge.FromBytesNegateVartime(out GeP3 A, publicKey) != 0)
@@ -245,4 +256,24 @@ private static bool ConstantTimeEquals(ReadOnlySpan<byte> x, ReadOnlySpan<byte>
245256
}
246257
return r == 0;
247258
}
259+
260+
// Returns true iff s is a canonical scalar in [0, L).
261+
private static bool IsCanonicalScalar(ReadOnlySpan<byte> s)
262+
{
263+
if (s.Length != 32)
264+
return false;
265+
266+
// Inputs in Verify are public, so this lexicographic comparison does not
267+
// introduce a secret-dependent side-channel.
268+
for (int i = 31; i >= 0; i--)
269+
{
270+
if (s[i] < ScalarOrderL[i])
271+
return true;
272+
if (s[i] > ScalarOrderL[i])
273+
return false;
274+
}
275+
276+
// Equal to L is non-canonical and must be rejected.
277+
return false;
278+
}
248279
}

tests/Ed25519Tests.cs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ namespace Ed25519.Tests;
1414
/// </summary>
1515
public class Ed25519Tests
1616
{
17+
private static readonly byte[] ScalarOrderL =
18+
[
19+
0xED, 0xD3, 0xF5, 0x5C, 0x1A, 0x63, 0x12, 0x58,
20+
0xD6, 0x9C, 0xF7, 0xA2, 0xDE, 0xF9, 0xDE, 0x14,
21+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
22+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10
23+
];
24+
1725
// RFC 8032 Section 7.1 - TEST 1
1826
// SECRET KEY: 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60
1927
// PUBLIC KEY: d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a
@@ -214,6 +222,49 @@ public void SignatureWithNonCanonicalS_ShouldNotVerify()
214222
Assert.False(Ed25519.Verify(signature, message, publicKey));
215223
}
216224

225+
[Fact]
226+
public void Verify_WithSExactlyL_ShouldReturnFalse()
227+
{
228+
byte[] lowOrderPublicKey = new byte[32];
229+
lowOrderPublicKey[0] = 0x01; // Edwards identity
230+
231+
byte[] signature = new byte[64];
232+
signature[0] = 0x01; // Encoded identity for R
233+
ScalarOrderL.CopyTo(signature.AsSpan(32, 32));
234+
235+
Assert.False(Ed25519.Verify(signature, "m"u8, lowOrderPublicKey));
236+
}
237+
238+
[Fact]
239+
public void Verify_WithSGreaterThanL_ShouldReturnFalse()
240+
{
241+
byte[] lowOrderPublicKey = new byte[32];
242+
lowOrderPublicKey[0] = 0x01; // Edwards identity
243+
244+
byte[] signature = new byte[64];
245+
signature[0] = 0x01; // Encoded identity for R
246+
ScalarOrderL.CopyTo(signature.AsSpan(32, 32));
247+
signature[32]++; // S = L + 1
248+
249+
Assert.False(Ed25519.Verify(signature, "m"u8, lowOrderPublicKey));
250+
}
251+
252+
[Fact]
253+
public void Verify_WithValidSignatureMutatedByAddingLToS_ShouldReturnFalse()
254+
{
255+
var (publicKey, privateKey, _) = Ed25519.GenerateKeypair();
256+
byte[] message = "malleability-check"u8.ToArray();
257+
byte[] signature = Ed25519.Sign(message, publicKey, privateKey);
258+
259+
byte[] mutated = signature.ToArray();
260+
AddLittleEndian(mutated.AsSpan(32, 32), ScalarOrderL);
261+
262+
if ((mutated[63] & 0b1110_0000) == 0)
263+
{
264+
Assert.False(Ed25519.Verify(mutated, message, publicKey));
265+
}
266+
}
267+
217268
[Fact]
218269
public void Verify_WithInvalidPublicKeyEncoding_ShouldReturnFalse()
219270
{
@@ -238,6 +289,23 @@ public void Verify_WithLowOrderPublicKey_ShouldReturnFalse()
238289
Assert.False(Ed25519.Verify(signature, message, lowOrderPublicKey));
239290
}
240291

292+
[Fact]
293+
public void Verify_WithOversizedInputs_ShouldReturnFalse()
294+
{
295+
var (publicKey, privateKey, _) = Ed25519.GenerateKeypair();
296+
byte[] message = "oversize"u8.ToArray();
297+
byte[] signature = Ed25519.Sign(message, publicKey, privateKey);
298+
299+
byte[] oversizedSignature = new byte[65];
300+
signature.CopyTo(oversizedSignature, 0);
301+
302+
byte[] oversizedPublicKey = new byte[33];
303+
publicKey.CopyTo(oversizedPublicKey, 0);
304+
305+
Assert.False(Ed25519.Verify(oversizedSignature, message, publicKey));
306+
Assert.False(Ed25519.Verify(signature, message, oversizedPublicKey));
307+
}
308+
241309
[Fact]
242310
public void SignOverload_WithAndWithoutPublicKey_ShouldMatch()
243311
{
@@ -255,6 +323,38 @@ public void SignOverload_WithAndWithoutPublicKey_ShouldMatch()
255323
Assert.True(Ed25519.Verify(sig1, message, publicKey));
256324
}
257325

326+
[Fact]
327+
public void CreateKeypair_WithUndersizedOrOversizedInputs_ShouldThrow()
328+
{
329+
byte[] seed = new byte[Ed25519.SeedSize];
330+
byte[] publicKey = new byte[Ed25519.PublicKeySize];
331+
byte[] privateKey = new byte[Ed25519.PrivateKeySize];
332+
333+
Assert.Throws<ArgumentException>(() => Ed25519.CreateKeypair(publicKey.AsSpan(0, 31), privateKey, seed));
334+
Assert.Throws<ArgumentException>(() => Ed25519.CreateKeypair(new byte[33], privateKey, seed));
335+
Assert.Throws<ArgumentException>(() => Ed25519.CreateKeypair(publicKey, privateKey.AsSpan(0, 63), seed));
336+
Assert.Throws<ArgumentException>(() => Ed25519.CreateKeypair(publicKey, new byte[65], seed));
337+
Assert.Throws<ArgumentException>(() => Ed25519.CreateKeypair(publicKey, privateKey, seed.AsSpan(0, 31)));
338+
Assert.Throws<ArgumentException>(() => Ed25519.CreateKeypair(publicKey, privateKey, new byte[33]));
339+
}
340+
341+
[Fact]
342+
public void Sign_WithUndersizedOrOversizedInputs_ShouldThrow()
343+
{
344+
var (publicKey, privateKey, _) = Ed25519.GenerateKeypair();
345+
byte[] message = "size-check-sign"u8.ToArray();
346+
347+
Assert.Throws<ArgumentException>(() => Ed25519.Sign(new byte[63], message, publicKey, privateKey));
348+
Assert.Throws<ArgumentException>(() => Ed25519.Sign(new byte[65], message, publicKey, privateKey));
349+
Assert.Throws<ArgumentException>(() => Ed25519.Sign(new byte[64], message, publicKey.AsSpan(0, 31), privateKey));
350+
Assert.Throws<ArgumentException>(() => Ed25519.Sign(new byte[64], message, new byte[33], privateKey));
351+
Assert.Throws<ArgumentException>(() => Ed25519.Sign(new byte[64], message, publicKey, privateKey.AsSpan(0, 63)));
352+
Assert.Throws<ArgumentException>(() => Ed25519.Sign(new byte[64], message, publicKey, new byte[65]));
353+
354+
Assert.Throws<ArgumentException>(() => Ed25519.Sign(new byte[64], message, privateKey.AsSpan(0, 63)));
355+
Assert.Throws<ArgumentException>(() => Ed25519.Sign(new byte[64], message, new byte[65]));
356+
}
357+
258358
[Fact]
259359
public void DeterministicSignatures_SameInputsSameSignature()
260360
{
@@ -484,6 +584,54 @@ public void EncryptedPkcs8Pem_RoundTrip_DecryptAndRecoverSeed()
484584
Assert.Equal(seed, recoveredSeed);
485585
}
486586

587+
[Fact]
588+
public void ExportEncryptedPrivateKeyPem_ObsoleteOverload_ShouldMatchNewOverload()
589+
{
590+
var (publicKey, _, seed) = Ed25519.GenerateKeypair();
591+
const string password = "migration-check-password";
592+
593+
#pragma warning disable CS0618
594+
string oldPem = Pkcs.ExportEncryptedPrivateKeyPem(seed, publicKey, password, iterations: 1_000);
595+
#pragma warning restore CS0618
596+
string newPem = Pkcs.ExportEncryptedPrivateKeyPem(seed, password, iterations: 1_000);
597+
598+
byte[] oldDer = Pkcs.DecodePem(oldPem, "ENCRYPTED PRIVATE KEY");
599+
byte[] newDer = Pkcs.DecodePem(newPem, "ENCRYPTED PRIVATE KEY");
600+
601+
var oldInfo = Pkcs8PrivateKeyInfo.DecryptAndDecode(password, oldDer, out _);
602+
var newInfo = Pkcs8PrivateKeyInfo.DecryptAndDecode(password, newDer, out _);
603+
Assert.Equal(Pkcs.DecodePkcs8PrivateKey(oldInfo.Encode()), Pkcs.DecodePkcs8PrivateKey(newInfo.Encode()));
604+
}
605+
606+
[Fact]
607+
public void VerifyPkcs10CertificationRequest_WithTamperedSignature_ShouldReturnFalse()
608+
{
609+
var (publicKey, privateKey, _) = Ed25519.GenerateKeypair();
610+
byte[] subjectNameDer = new X500DistinguishedName("CN=tamper-check").RawData;
611+
byte[] csrDer = Pkcs.EncodePkcs10CertificationRequest(subjectNameDer, publicKey, privateKey);
612+
613+
byte[] tampered = csrDer.ToArray();
614+
tampered[^1] ^= 0x01;
615+
616+
Assert.False(Pkcs.VerifyPkcs10CertificationRequest(tampered, out _, out _));
617+
}
618+
619+
[Fact]
620+
public void VerifyPkcs10CertificationRequest_WithWrongAlgorithmOid_ShouldReturnFalse()
621+
{
622+
var (publicKey, privateKey, _) = Ed25519.GenerateKeypair();
623+
byte[] subjectNameDer = new X500DistinguishedName("CN=oid-check").RawData;
624+
byte[] csrDer = Pkcs.EncodePkcs10CertificationRequest(subjectNameDer, publicKey, privateKey);
625+
626+
byte[] tampered = csrDer.ToArray();
627+
// OID encoding in CSR: 06 03 2B 65 70. Corrupt payload byte.
628+
int oidIndex = Array.IndexOf(tampered, (byte)0x06);
629+
Assert.True(oidIndex >= 0);
630+
tampered[oidIndex + 2] ^= 0x01;
631+
632+
Assert.False(Pkcs.VerifyPkcs10CertificationRequest(tampered, out _, out _));
633+
}
634+
487635
[Fact]
488636
public void Pkcs10Csr_RoundTrip_VerifyAndExtract()
489637
{
@@ -551,4 +699,17 @@ private static byte[] EncodeWithNonMinimalOuterLength(ReadOnlySpan<byte> der)
551699
der[2..].CopyTo(nonMinimal.AsSpan(3));
552700
return nonMinimal;
553701
}
702+
703+
private static void AddLittleEndian(Span<byte> destination, ReadOnlySpan<byte> addend)
704+
{
705+
Assert.Equal(destination.Length, addend.Length);
706+
707+
int carry = 0;
708+
for (int i = 0; i < destination.Length; i++)
709+
{
710+
int sum = destination[i] + addend[i] + carry;
711+
destination[i] = (byte)sum;
712+
carry = sum >> 8;
713+
}
714+
}
554715
}

0 commit comments

Comments
 (0)