Skip to main content

src/aws/internal/ecdsa_deterministic.gleam

//// RFC 6979 deterministic ECDSA over NIST P-256 with SHA-256.
////
//// The standard ECDSA algorithm picks a fresh random nonce `k` per
//// signature; the signature varies between two signings of the
//// same `(key, message)`. Erlang's `crypto:sign/4` follows that
//// model — signatures are valid (server-side verify works) but
//// not byte-reproducible. RFC 6979 derives `k` deterministically
//// from `(d, h1)` via HMAC-DRBG, making signatures byte-for-byte
//// reproducible against the reference vectors in §A.2.5 and
//// against the aws-c-auth v4a corpus.
////
//// **Architecture.** The HMAC-DRBG nonce derivation, modular
//// arithmetic, and DER encoding are pure Gleam. The one expensive
//// step — the elliptic-curve point multiplication `k·G` — is
//// outsourced to Erlang's `crypto:generate_key(ecdh, secp256r1, k)`
//// via the existing `aws_ffi:ecdsa_p256_public_key/1` FFI. That
//// gives us the X coordinate of `k·G` from which the `r` half of
//// the signature is `X mod q`. Computing `s` and DER-encoding the
//// `(r, s)` pair is then pure integer arithmetic, which BEAM
//// bignums handle natively.

import aws/internal/crypto
import gleam/bit_array

/// NIST P-256 (secp256r1) curve order. Defined in NIST SP 800-186
/// §3.2.1.3. Every ECDSA-over-P256 scalar is reduced mod this.
const p256_order: Int = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551

/// Sign a 32-byte SHA-256 hash with a 32-byte P-256 private scalar.
/// Returns the DER-encoded `(r, s)` blob — the same shape Erlang's
/// `crypto:sign/4` returns for `ecdsa`, so callers can swap the two
/// without touching downstream code.
pub fn sign_p256(private_key: BitArray, message_hash: BitArray) -> BitArray {
  let #(r, s) = sign_p256_rs(private_key, message_hash)
  der_encode_sig(r, s)
}

/// Same signature as `sign_p256` but returns `(r_hex, s_hex)` as
/// 64-character lowercase strings. Test-side helper for matching
/// against the RFC 6979 reference vectors which list `r` / `s` as
/// hex.
pub fn sign_p256_rs_hex(
  private_key: BitArray,
  message_hash: BitArray,
) -> #(String, String) {
  let #(r, s) = sign_p256_rs(private_key, message_hash)
  #(int_to_hex_256(r), int_to_hex_256(s))
}

fn sign_p256_rs(private_key: BitArray, message_hash: BitArray) -> #(Int, Int) {
  let assert <<d:size(256)-big-unsigned>> = private_key
  let assert <<e:size(256)-big-unsigned>> = message_hash
  let e_mod_q = e % p256_order
  sign_retry_loop(private_key, message_hash, d, e_mod_q, 0)
}

/// RFC 6979's outer retry loop: if `r == 0` or `s == 0` (both
/// statistically impossible — ~2^-256 each — but the spec mandates
/// the retry), pull a fresh `k` from the HMAC-DRBG and try again.
/// The `attempt` arg threads into the nonce derivation so successive
/// tries don't keep producing the same `k`.
fn sign_retry_loop(
  private_key: BitArray,
  message_hash: BitArray,
  d: Int,
  e: Int,
  attempt: Int,
) -> #(Int, Int) {
  case attempt > 100 {
    True ->
      // Unreachable in practice; guard against pathological inputs.
      panic as "RFC 6979 sign: exceeded 100 nonce-retry attempts"
    False -> {
      let #(_, k) = derive_nonce(private_key, message_hash, attempt)
      // k·G via the existing scalar-multiplication FFI. The returned
      // uncompressed SEC1 form is `04 || X(32) || Y(32)`; we want
      // the X coordinate.
      let k_padded = pad_to_32(<<k:size(256)-big>>)
      let pubkey = ecdsa_p256_public_key(k_padded)
      let assert <<_:8, x_r:size(256)-big-unsigned, _:256>> = pubkey
      let r = x_r % p256_order
      case r == 0 {
        True -> sign_retry_loop(private_key, message_hash, d, e, attempt + 1)
        False -> {
          let k_inv = mod_inverse(k, p256_order)
          let s =
            mod_mul(
              k_inv,
              mod_add(e, mod_mul(r, d, p256_order), p256_order),
              p256_order,
            )
          case s == 0 {
            True ->
              sign_retry_loop(private_key, message_hash, d, e, attempt + 1)
            False -> #(r, s)
          }
        }
      }
    }
  }
}

/// HMAC-DRBG nonce derivation per RFC 6979 §3.2. Returns the
/// nonce both as a 32-byte big-endian bit array (for the
/// EC point-multiplication FFI) and as an integer (for the
/// `(r, s)` computation). `attempt` only matters when the outer
/// retry loop fires; for the typical `attempt = 0` path it returns
/// the canonical nonce.
fn derive_nonce(
  d_bytes: BitArray,
  h1: BitArray,
  attempt: Int,
) -> #(BitArray, Int) {
  let assert <<h1_int:size(256)-big-unsigned>> = h1
  let h1_mod_q = h1_int % p256_order
  let bits2octets_h1 = <<h1_mod_q:size(256)-big>>

  let v0 = repeat_byte(0x01, 32)
  let k0 = repeat_byte(0x00, 32)

  // Steps d-g of RFC 6979 §3.2.
  let k1 =
    crypto.hmac_sha256(k0, <<
      v0:bits, 0:size(8), d_bytes:bits, bits2octets_h1:bits,
    >>)
  let v1 = crypto.hmac_sha256(k1, v0)
  let k2 =
    crypto.hmac_sha256(k1, <<
      v1:bits, 1:size(8), d_bytes:bits, bits2octets_h1:bits,
    >>)
  let v2 = crypto.hmac_sha256(k2, v1)

  // Step h-k: each HMAC of V produces exactly 32 bytes = qlen
  // bits, so T = V after one round. Inner concatenation loop is
  // not needed for P-256 / SHA-256.
  nonce_loop(k2, v2, attempt)
}

fn nonce_loop(k: BitArray, v: BitArray, skip: Int) -> #(BitArray, Int) {
  let v_new = crypto.hmac_sha256(k, v)
  let assert <<candidate:size(256)-big-unsigned>> = v_new
  case candidate >= 1 && candidate < p256_order && skip == 0 {
    True -> #(v_new, candidate)
    False -> {
      // Either the candidate is out of range, OR we're being asked
      // to skip `skip` valid candidates (the outer retry path).
      let k_next = crypto.hmac_sha256(k, <<v_new:bits, 0:size(8)>>)
      let v_next = crypto.hmac_sha256(k_next, v_new)
      let next_skip = case candidate >= 1 && candidate < p256_order {
        True -> skip - 1
        False -> skip
      }
      nonce_loop(k_next, v_next, next_skip)
    }
  }
}

// ---------- modular arithmetic ----------

fn mod_add(a: Int, b: Int, m: Int) -> Int {
  { a + b } % m
}

fn mod_mul(a: Int, b: Int, m: Int) -> Int {
  a * b % m
}

/// Modular inverse via the extended Euclidean algorithm. Caller
/// guarantees `gcd(a, m) == 1` — P-256's order is prime so any
/// `a ∈ [1, q-1]` is coprime to `q`.
fn mod_inverse(a: Int, m: Int) -> Int {
  let #(_g, x, _y) = ext_gcd(a, m)
  let result = x % m
  case result < 0 {
    True -> result + m
    False -> result
  }
}

fn ext_gcd(a: Int, b: Int) -> #(Int, Int, Int) {
  case b {
    0 -> #(a, 1, 0)
    _ -> {
      let #(g, x1, y1) = ext_gcd(b, a % b)
      #(g, y1, x1 - { a / b } * y1)
    }
  }
}

// ---------- DER encoding ----------

fn der_encode_sig(r: Int, s: Int) -> BitArray {
  let r_int = der_int_bytes(r)
  let s_int = der_int_bytes(s)
  let inner_len = bit_array.byte_size(r_int) + bit_array.byte_size(s_int)
  <<0x30, inner_len:size(8), r_int:bits, s_int:bits>>
}

/// DER `INTEGER`: `0x02 LL <bytes>`. Bytes are the minimum-length
/// big-endian magnitude of `n` with a 0x00 prefix when the high bit
/// of the magnitude is set (so it reads as unsigned).
fn der_int_bytes(n: Int) -> BitArray {
  let raw = strip_leading_zeros(<<n:size(256)-big>>)
  let prefixed = case high_bit_set(raw) {
    True -> <<0:size(8), raw:bits>>
    False -> raw
  }
  <<0x02, { bit_array.byte_size(prefixed) }:size(8), prefixed:bits>>
}

fn strip_leading_zeros(b: BitArray) -> BitArray {
  case b {
    <<0:size(8), rest:bits>> ->
      case bit_array.byte_size(rest) > 0 {
        True -> strip_leading_zeros(rest)
        False -> b
      }
    _ -> b
  }
}

fn high_bit_set(b: BitArray) -> Bool {
  case b {
    <<first:size(8), _:bits>> -> first >= 0x80
    _ -> False
  }
}

// ---------- bit / hex helpers ----------

fn repeat_byte(byte: Int, count: Int) -> BitArray {
  do_repeat(byte, count, <<>>)
}

fn do_repeat(byte: Int, remaining: Int, acc: BitArray) -> BitArray {
  case remaining {
    0 -> acc
    _ -> do_repeat(byte, remaining - 1, <<acc:bits, byte:size(8)>>)
  }
}

/// Bit-pattern `<<k:size(256)-big-unsigned>>` always produces 32
/// bytes regardless of `k`'s magnitude. Provided as a function for
/// signature clarity / future-proofing if we need to accept
/// shorter inputs.
fn pad_to_32(b: BitArray) -> BitArray {
  b
}

fn int_to_hex_256(n: Int) -> String {
  crypto.hex_encode(<<n:size(256)-big>>)
}

@external(erlang, "aws_ffi", "ecdsa_p256_public_key")
fn ecdsa_p256_public_key(scalar: BitArray) -> BitArray