Skip to content

Commit 10f53d4

Browse files
committed
Adds error vector validation and EC key support
- Implements comprehensive error test vector suite validating 4,240 negative test cases (bit flip, truncation, API mismatch) - Adds EC point decompression for compressed P-256 and P-384 public keys using simplified modular square root - Updates signature verification to support both P-256 (SHA-256/secp256r1) and P-384 (SHA-384/secp384r1) curves - Enables API mismatch test using fail_on_signed streaming option - All error vectors correctly return {:error, _} as expected - Maintains 100% success vector compatibility (2,861 tests pass) Closes #77
1 parent 25f1173 commit 10f53d4

7 files changed

Lines changed: 2393 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- Error test vector validation suite with 4,240 negative test cases (#77)
12+
- Compressed EC public key decompression for P-256 and P-384 curves (#77)
13+
- Multi-curve ECDSA signature verification supporting SHA-256/secp256r1 and SHA-384/secp384r1 (#77)
14+
- API mismatch test validating unsigned-only streaming decryption mode (#77)
15+
- Comprehensive error categorization (bit flip, truncation, API mismatch, other) (#77)
1116
- Full test vector runner executing 2,861 success test vectors via complete decrypt flow (#76)
1217
- Comprehensive test coverage for all 11 ESDK algorithm suites including committed suites (0x0478, 0x0578)
1318
- Test vector filtering helpers (success/error tests, raw key tests, encryption algorithm filters)
@@ -36,6 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3641
- Examples README updated with category-based navigation and quick start commands
3742

3843
### Fixed
44+
- ECDSA signature verification now handles compressed EC public keys (0x02/0x03 prefix) (#77)
45+
- Signature verification uses correct hash algorithm and curve based on algorithm suite (#77)
3946
- Header body serialization to include version/type bytes in AAD computation per spec (#76)
4047
- Required encryption context filtering in header authentication tag computation (#76)
4148
- CMM test vector helpers to extract key names from EDK provider_info (#76)

lib/aws_encryption_sdk/crypto/ecdsa.ex

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ defmodule AwsEncryptionSdk.Crypto.ECDSA do
1111
"""
1212

1313
@type key_pair :: {private_key :: binary(), public_key :: binary()}
14-
@type curve :: :secp384r1
14+
@type curve :: :secp384r1 | :secp256r1
1515

1616
@doc """
1717
Generates an ECDSA key pair for the P-384 curve.
@@ -183,6 +183,129 @@ defmodule AwsEncryptionSdk.Crypto.ECDSA do
183183
@spec verify(binary(), binary(), binary(), curve()) :: boolean()
184184
def verify(message, signature, public_key, :secp384r1)
185185
when is_binary(message) and is_binary(signature) and is_binary(public_key) do
186-
:crypto.verify(:ecdsa, :sha384, message, signature, [public_key, :secp384r1])
186+
# Normalize public key to uncompressed format if needed
187+
normalized_key = normalize_public_key(public_key, :secp384r1)
188+
:crypto.verify(:ecdsa, :sha384, message, signature, [normalized_key, :secp384r1])
189+
end
190+
191+
@doc """
192+
Normalizes a public key to uncompressed format.
193+
194+
Handles both compressed (0x02/0x03 prefix, 49 bytes for P-384 or 33 bytes for P-256)
195+
and uncompressed (0x04 prefix, 97 bytes for P-384 or 65 bytes for P-256) formats.
196+
197+
The curve is auto-detected from the key size when possible:
198+
- 33 bytes compressed or 65 bytes uncompressed → secp256r1
199+
- 49 bytes compressed or 97 bytes uncompressed → secp384r1
200+
"""
201+
@spec normalize_public_key(binary(), curve()) :: binary()
202+
def normalize_public_key(<<0x04, _rest::binary>> = uncompressed_key, _curve) do
203+
# Already uncompressed
204+
uncompressed_key
205+
end
206+
207+
# 33-byte key = P-256 compressed (1 prefix + 32 bytes x)
208+
def normalize_public_key(<<prefix, _x_coord::binary-size(32)>> = compressed_key, _curve)
209+
when prefix in [0x02, 0x03] do
210+
decompress_ec_point(compressed_key, :secp256r1)
211+
end
212+
213+
# 49-byte key = P-384 compressed (1 prefix + 48 bytes x)
214+
def normalize_public_key(<<prefix, _x_coord::binary-size(48)>> = compressed_key, _curve)
215+
when prefix in [0x02, 0x03] do
216+
decompress_ec_point(compressed_key, :secp384r1)
217+
end
218+
219+
def normalize_public_key(key, _curve), do: key
220+
221+
# Manual EC point decompression for NIST curves
222+
# Uses simplified modular square root since p ≡ 3 (mod 4) for both P-256 and P-384
223+
# See SEC 1 v2.0 Section 2.3.4: https://www.secg.org/sec1-v2.pdf
224+
225+
# secp256r1 (P-256): 33-byte compressed key (1 prefix + 32 bytes x)
226+
defp decompress_ec_point(<<prefix, x_bytes::binary-size(32)>>, :secp256r1)
227+
when prefix in [0x02, 0x03] do
228+
# secp256r1 curve parameters
229+
# p = 2^256 - 2^224 + 2^192 + 2^96 - 1
230+
p = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
231+
232+
# b coefficient from secp256r1 specification
233+
b = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
234+
235+
decompress_with_params(prefix, x_bytes, p, b, 32)
236+
end
237+
238+
# secp384r1 (P-384): 49-byte compressed key (1 prefix + 48 bytes x)
239+
defp decompress_ec_point(<<prefix, x_bytes::binary-size(48)>>, :secp384r1)
240+
when prefix in [0x02, 0x03] do
241+
# secp384r1 curve parameters
242+
# p = 2^384 - 2^128 - 2^96 + 2^32 - 1
243+
p =
244+
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFFFF0000000000000000FFFFFFFF
245+
246+
# b coefficient from secp384r1 specification
247+
b =
248+
0xB3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013875AC656398D8A2ED19D2A85C8EDD3EC2AEF
249+
250+
decompress_with_params(prefix, x_bytes, p, b, 48)
251+
end
252+
253+
defp decompress_ec_point(key, _curve), do: key
254+
255+
# Generic decompression using curve parameters
256+
defp decompress_with_params(prefix, x_bytes, p, b, coord_size) do
257+
# a = -3 (mod p), which is p - 3 for both curves
258+
a = p - 3
259+
260+
# Convert x bytes to integer
261+
x = :binary.decode_unsigned(x_bytes, :big)
262+
263+
# Calculate y² = x³ + ax + b (mod p)
264+
# Note: The curve equation is y² = x³ - 3x + b, and a = -3
265+
x_cubed = mod_pow(x, 3, p)
266+
ax = mod(a * x, p)
267+
y_squared = mod(x_cubed + ax + b, p)
268+
269+
# Since p ≡ 3 (mod 4), we can compute sqrt using: y = y²^((p+1)/4) mod p
270+
exponent = div(p + 1, 4)
271+
y = mod_pow(y_squared, exponent, p)
272+
273+
# Determine if we need to negate y based on the prefix
274+
# prefix 0x02 = even y, prefix 0x03 = odd y
275+
y_is_odd = Integer.mod(y, 2) == 1
276+
prefix_wants_odd = prefix == 0x03
277+
278+
final_y =
279+
if y_is_odd == prefix_wants_odd do
280+
y
281+
else
282+
# Use the other root: -y mod p = p - y
283+
p - y
284+
end
285+
286+
# Convert y back to binary (big-endian, zero-padded)
287+
y_bytes = :binary.encode_unsigned(final_y, :big)
288+
y_bytes_padded = pad_to_length(y_bytes, coord_size)
289+
290+
# Build uncompressed point: 0x04 || x || y
291+
<<0x04, x_bytes::binary, y_bytes_padded::binary>>
292+
end
293+
294+
# Modular exponentiation using Erlang's built-in
295+
defp mod_pow(base, exp, mod) do
296+
:crypto.mod_pow(base, exp, mod)
297+
|> :binary.decode_unsigned(:big)
298+
end
299+
300+
# Modular reduction for potentially negative numbers
301+
defp mod(n, p) when n >= 0, do: Integer.mod(n, p)
302+
defp mod(n, p), do: Integer.mod(n + p, p)
303+
304+
# Pad binary to specified length with leading zeros
305+
defp pad_to_length(binary, length) when byte_size(binary) >= length, do: binary
306+
307+
defp pad_to_length(binary, length) do
308+
padding_size = length - byte_size(binary)
309+
<<0::size(padding_size * 8), binary::binary>>
187310
end
188311
end

lib/aws_encryption_sdk/decrypt.ex

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ defmodule AwsEncryptionSdk.Decrypt do
1515

1616
alias AwsEncryptionSdk.Crypto.AesGcm
1717
alias AwsEncryptionSdk.Crypto.Commitment
18+
alias AwsEncryptionSdk.Crypto.ECDSA
1819
alias AwsEncryptionSdk.Crypto.HeaderAuth
1920
alias AwsEncryptionSdk.Crypto.HKDF
2021
alias AwsEncryptionSdk.Format.BodyAad
@@ -64,7 +65,7 @@ defmodule AwsEncryptionSdk.Decrypt do
6465
materials.required_encryption_context_keys
6566
),
6667
{:ok, plaintext} <- decrypt_body(message.body, message.header, derived_key),
67-
:ok <- verify_signature(message, materials) do
68+
:ok <- verify_signature(message, materials, ciphertext) do
6869
{:ok,
6970
%{
7071
plaintext: plaintext,
@@ -197,17 +198,63 @@ defmodule AwsEncryptionSdk.Decrypt do
197198
end
198199

199200
# Verify signature (for signed suites)
200-
defp verify_signature(%{footer: nil}, _materials), do: :ok
201+
defp verify_signature(%{footer: nil}, _materials, _ciphertext), do: :ok
201202

202-
defp verify_signature(%{footer: %{signature: _signature}}, %{verification_key: nil}) do
203+
defp verify_signature(
204+
%{footer: %{signature: _signature}},
205+
%{verification_key: nil},
206+
_ciphertext
207+
) do
203208
# Signed suite but no verification key provided
204209
{:error, :missing_verification_key}
205210
end
206211

207-
defp verify_signature(_message, _materials) do
208-
# TO DO: Implement ECDSA signature verification
209-
# For now, skip signature verification for signed suites
210-
# This will be implemented when we add ECDSA support
211-
:ok
212+
defp verify_signature(
213+
%{footer: %{signature: signature}},
214+
%{algorithm_suite: suite, verification_key: verification_key},
215+
ciphertext
216+
)
217+
when is_binary(verification_key) do
218+
# Calculate header + body bytes from ciphertext
219+
# Footer format: signature_length (2 bytes) + signature
220+
# Signature is computed over header + body (everything before the footer)
221+
footer_len = 2 + byte_size(signature)
222+
message_len = byte_size(ciphertext) - footer_len
223+
<<message_bytes::binary-size(message_len), _footer::binary>> = ciphertext
224+
225+
# Get the correct hash and curve from the algorithm suite
226+
{hash_algo, curve} = signature_params_from_suite(suite)
227+
228+
# Normalize the public key (decompress if needed)
229+
normalized_key = ECDSA.normalize_public_key(verification_key, curve)
230+
231+
# Compute hash and verify signature
232+
try do
233+
digest = :crypto.hash(hash_algo, message_bytes)
234+
235+
if :crypto.verify(:ecdsa, hash_algo, {:digest, digest}, signature, [normalized_key, curve]) do
236+
:ok
237+
else
238+
{:error, :signature_verification_failed}
239+
end
240+
rescue
241+
_e ->
242+
# :crypto.verify raised an error
243+
{:error, :signature_verification_failed}
244+
end
245+
end
246+
247+
# Get signature hash algorithm and curve from algorithm suite
248+
defp signature_params_from_suite(%{signature_algorithm: :ecdsa_p256}) do
249+
{:sha256, :secp256r1}
250+
end
251+
252+
defp signature_params_from_suite(%{signature_algorithm: :ecdsa_p384}) do
253+
{:sha384, :secp384r1}
254+
end
255+
256+
defp signature_params_from_suite(_suite) do
257+
# Default for backwards compatibility (shouldn't be reached for signed suites)
258+
{:sha384, :secp384r1}
212259
end
213260
end

0 commit comments

Comments
 (0)