Chapter 9: Elliptic Curve Cryptography

PRE-ALPHA WARNING: This is a pre-alpha version of The Sigma Book. Content may be incomplete, inaccurate, or subject to change. Do not use as a source of truth. For authoritative information, consult the official repositories:

Prerequisites

  • Basic finite field arithmetic: operations modulo a prime p, multiplicative inverses
  • Public key cryptography concepts: key pairs, discrete logarithm problem
  • Understanding of elliptic curves as sets of points satisfying y² = x³ + ax + b over a finite field
  • Prior chapters: Chapter 2 for the GroupElement type

Learning Objectives

By the end of this chapter, you will be able to:

  • Explain why secp256k1 was chosen for Sigma protocols and describe its key parameters
  • Implement the discrete logarithm group interface: exponentiate, multiply, inverse
  • Encode and decode group elements using compressed SEC1 format (33 bytes)
  • Translate between multiplicative group notation (used in Sigma protocols) and additive notation (used in libraries)

The Secp256k1 Curve

Sigma protocols use secp256k1—the same elliptic curve as Bitcoin and Ethereum12. This choice provides several benefits: widespread library support, extensive security analysis, and compatibility with existing blockchain infrastructure. The curve offers 128-bit security (meaning the best known attack requires approximately 2^128 operations) while using 256-bit keys.

Curve Definition

The curve is defined by:

y² = x³ + 7  (mod p)

where:
  p = 2²⁵⁶ - 2³² - 977  (field characteristic)
  n = group order        (number of points)
  G = generator point    (base point)

Cryptographic Constants

const CryptoConstants = struct {
    /// Encoded group element size in bytes (compressed)
    pub const ENCODED_GROUP_ELEMENT_LENGTH: usize = 33;

    /// Group size in bits
    pub const GROUP_SIZE_BITS: u32 = 256;

    /// Challenge size for Sigma protocols
    /// Must be < GROUP_SIZE_BITS for security
    pub const SOUNDNESS_BITS: u32 = 192;

    /// Group order (number of curve points)
    /// n = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
    pub const GROUP_ORDER: [32]u8 = .{
        0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
        0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE,
        0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B,
        0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41,
    };

    /// Field characteristic
    /// p = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
    pub const FIELD_PRIME: [32]u8 = .{
        0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
        0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
        0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
        0xFF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFC, 0x2F,
    };

    comptime {
        // Security constraint: 2^soundnessBits < groupOrder
        std.debug.assert(SOUNDNESS_BITS < GROUP_SIZE_BITS);
    }
};

Group Element Representation

EcPoint Structure

const EcPoint = struct {
    /// Compressed encoding size
    pub const GROUP_SIZE: usize = 33;

    /// Internal representation (projective coordinates)
    x: FieldElement,
    y: FieldElement,
    z: FieldElement,

    /// Identity element (point at infinity)
    pub const IDENTITY = EcPoint{
        .x = FieldElement.zero(),
        .y = FieldElement.one(),
        .z = FieldElement.zero(),
    };

    /// Generator point G
    pub const GENERATOR = init: {
        // secp256k1 generator coordinates
        const gx = FieldElement.fromHex(
            "79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798"
        );
        const gy = FieldElement.fromHex(
            "483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8"
        );
        break :init EcPoint{ .x = gx, .y = gy, .z = FieldElement.one() };
    };

    /// Check if this is the identity (infinity) point
    pub fn isIdentity(self: *const EcPoint) bool {
        return self.z.isZero();
    }

    /// Convert to affine coordinates
    pub fn toAffine(self: *const EcPoint) struct { x: FieldElement, y: FieldElement } {
        if (self.isIdentity()) return .{ .x = .zero(), .y = .zero() };
        const z_inv = self.z.inverse();
        return .{
            .x = self.x.mul(z_inv),
            .y = self.y.mul(z_inv),
        };
    }
};

Group Operations

The discrete logarithm group interface provides standard operations34:

Operation           Notation      Description
─────────────────────────────────────────────────────
Exponentiate        g^x           Scalar multiplication
Multiply            g * h         Point addition
Inverse             g^(-1)        Point negation
Identity            1             Point at infinity
Generator           g             Base point G

Group Interface

SECURITY: The exponentiate (scalar multiplication) operation must be implemented in constant-time when the scalar is secret (e.g., private keys, nonces). Variable-time implementations leak secret bits through timing side-channels. Use audited libraries like libsecp256k1 or Zig's std.crypto.ecc.

const DlogGroup = struct {
    /// The generator point
    pub fn generator() EcPoint {
        return EcPoint.GENERATOR;
    }

    /// The identity element (point at infinity)
    pub fn identity() EcPoint {
        return EcPoint.IDENTITY;
    }

    /// Check if point is identity
    pub fn isIdentity(point: *const EcPoint) bool {
        return point.isIdentity();
    }

    /// Exponentiate: base^exponent (scalar multiplication)
    pub fn exponentiate(base: *const EcPoint, exponent: *const Scalar) EcPoint {
        if (base.isIdentity()) return base.*;

        // Handle negative exponents
        var exp = exponent.*;
        if (exp.isNegative()) {
            exp = exp.mod(CryptoConstants.GROUP_ORDER);
        }

        return scalarMul(base, &exp);
    }

    /// Multiply two group elements: g1 * g2 (point addition)
    pub fn multiply(g1: *const EcPoint, g2: *const EcPoint) EcPoint {
        return pointAdd(g1, g2);
    }

    /// Compute inverse: g^(-1) (point negation)
    pub fn inverse(point: *const EcPoint) EcPoint {
        return EcPoint{
            .x = point.x,
            .y = point.y.negate(),
            .z = point.z,
        };
    }

    /// Create random group element
    pub fn randomElement(rng: std.rand.Random) EcPoint {
        const scalar = Scalar.random(rng);
        return exponentiate(&EcPoint.GENERATOR, &scalar);
    }
};

Notation Translation

Sigma protocols use multiplicative notation while underlying libraries often use additive5:

Sigma (multiplicative)    Library (additive)     Operation
──────────────────────────────────────────────────────────
g * h                     g + h                  Point addition
g^n                       n * g                  Scalar multiplication
g^(-1)                    -g                     Point negation
1 (identity)              O (origin)             Point at infinity
/// Wrapper translating multiplicative to additive notation
const MultiplicativeGroup = struct {
    /// Multiply in multiplicative notation = Add in additive
    pub fn mul(a: *const EcPoint, b: *const EcPoint) EcPoint {
        return pointAdd(a, b);
    }

    /// Exponentiate in multiplicative = Scalar multiply in additive
    pub fn exp(base: *const EcPoint, scalar: *const Scalar) EcPoint {
        return scalarMul(base, scalar);
    }

    /// Inverse in multiplicative = Negate in additive
    pub fn inv(p: *const EcPoint) EcPoint {
        return pointNegate(p);
    }
};

Point Encoding

Group elements use compressed SEC1 encoding (33 bytes)67:

Compressed Point Format (33 bytes)
────────────────────────────────────────────────────

┌──────────┬────────────────────────────────────────┐
│ Byte 0   │           Bytes 1-32                   │
├──────────┼────────────────────────────────────────┤
│ 0x02     │    X coordinate (32 bytes, big-end)    │  Y is even
│ 0x03     │    X coordinate (32 bytes, big-end)    │  Y is odd
│ 0x00     │    31 zero bytes                       │  Identity
└──────────┴────────────────────────────────────────┘

Serialization Implementation

const GroupElementSerializer = struct {
    const ENCODING_SIZE: usize = 33;

    /// Identity encoding (33 zero bytes)
    const IDENTITY_ENCODING = [_]u8{0} ** ENCODING_SIZE;

    pub fn serialize(point: *const EcPoint, writer: anytype) !void {
        if (point.isIdentity()) {
            try writer.writeAll(&IDENTITY_ENCODING);
            return;
        }

        const affine = point.toAffine();

        // Determine sign byte from Y coordinate parity
        const y_bytes = affine.y.toBytes();
        const sign_byte: u8 = if (y_bytes[31] & 1 == 0) 0x02 else 0x03;

        // Write sign byte + X coordinate
        try writer.writeByte(sign_byte);
        try writer.writeAll(&affine.x.toBytes());
    }

    pub fn deserialize(reader: anytype) !EcPoint {
        var buf: [ENCODING_SIZE]u8 = undefined;
        try reader.readNoEof(&buf);

        if (buf[0] == 0) {
            // Check all zeros for identity
            for (buf[1..]) |b| {
                if (b != 0) return error.InvalidEncoding;
            }
            return EcPoint.IDENTITY;
        }

        if (buf[0] != 0x02 and buf[0] != 0x03) {
            return error.InvalidPrefix;
        }

        // Recover Y from X using curve equation: y² = x³ + 7
        const x = FieldElement.fromBytes(buf[1..33]);
        const y_squared = x.cube().add(FieldElement.fromInt(7));
        var y = y_squared.sqrt() orelse return error.NotOnCurve;

        // Choose correct Y based on sign byte
        const y_is_odd = y.toBytes()[31] & 1 == 1;
        if ((buf[0] == 0x02) == y_is_odd) {
            y = y.negate();
        }

        const point = EcPoint{ .x = x, .y = y, .z = FieldElement.one() };

        // CRITICAL: Validate point is on curve and in correct subgroup
        // This prevents invalid curve attacks. See ZIGMA_STYLE.md.
        // if (!point.isOnCurve()) return error.NotOnCurve;
        // if (!point.isInSubgroup()) return error.InvalidSubgroup;

        return point;
    }
};

Why Compressed Encoding?

Format          Size     Content
────────────────────────────────────────────────────
Compressed      33 B     Sign (1) + X (32)
Uncompressed    65 B     0x04 (1) + X (32) + Y (32)
Savings         49%      Y recovered from curve equation

Coordinate Systems

Affine vs Projective

Libraries use projective coordinates internally for efficiency:

Coordinate System    Representation    Division Required
──────────────────────────────────────────────────────────
Affine               (x, y)            Per operation
Projective           (X, Y, Z)         Only at end
                     x = X/Z
                     y = Y/Z

Normalization

/// Normalize point to affine coordinates
/// Required before: encoding, comparison, coordinate access
pub fn normalize(point: *const EcPoint) EcPoint {
    if (point.isIdentity()) return point.*;

    const z_inv = point.z.inverse();
    const z_inv_sq = z_inv.square();
    const z_inv_cu = z_inv_sq.mul(z_inv);

    return EcPoint{
        .x = point.x.mul(z_inv_sq),
        .y = point.y.mul(z_inv_cu),
        .z = FieldElement.one(),
    };
}

Random Scalar Generation

Secure random scalars for key generation8:

const Scalar = struct {
    bytes: [32]u8,

    /// Generate random scalar in [1, n-1] where n is group order
    pub fn random(rng: std.rand.Random) Scalar {
        while (true) {
            var bytes: [32]u8 = undefined;
            rng.bytes(&bytes);

            // Ensure scalar < group order
            if (lessThan(&bytes, &CryptoConstants.GROUP_ORDER)) {
                // Ensure non-zero
                var is_zero = true;
                for (bytes) |b| {
                    if (b != 0) { is_zero = false; break; }
                }
                if (!is_zero) {
                    return Scalar{ .bytes = bytes };
                }
            }
        }
    }

    /// Constant-time comparison to prevent timing attacks
    fn lessThan(a: *const [32]u8, b: *const [32]u8) bool {
        // NOTE: This simplified version is NOT constant-time.
        // In production, use constant-time comparison like:
        //   var borrow: u1 = 0;
        //   for (a.*, b.*) |ai, bi| {
        //       borrow = @intFromBool(ai < bi) | (borrow & @intFromBool(ai == bi));
        //   }
        //   return borrow == 1;
        // See ZIGMA_STYLE.md for constant-time crypto requirements.
        for (a.*, b.*) |ai, bi| {
            if (ai < bi) return true;
            if (ai > bi) return false;
        }
        return false;
    }
};

Security Properties

Discrete Logarithm Assumption

The security relies on the hardness of the DLP9:

Given:  g (generator), h = g^x (public key)
Find:   x (secret key)

Best known attack: ~2^128 operations for secp256k1

Soundness Parameter

The SOUNDNESS_BITS = 192 determines:

  • Challenge size in Sigma protocols
  • Security level against malicious provers
  • Constraint: 2^192 < n (group order)
comptime {
    // Verify soundness constraint
    // 2^soundnessBits must be less than group order
    // Group order ≈ 2^256, so 192 < 256 satisfies this
    std.debug.assert(CryptoConstants.SOUNDNESS_BITS < 256);
}

Summary

This chapter covered the elliptic curve cryptography foundation that underlies all Sigma protocol operations:

  • secp256k1 (y² = x³ + 7) provides the mathematical foundation for Sigma protocols, chosen for its security properties and widespread support in Bitcoin and Ethereum tooling
  • Group elements are encoded as 33 bytes using compressed SEC1 format—a sign byte (0x02 or 0x03 based on Y coordinate parity) followed by the 32-byte X coordinate
  • Multiplicative notation used in Sigma protocol literature (g^x, g·h) maps to additive operations in typical EC libraries (scalar multiplication, point addition)
  • SOUNDNESS_BITS = 192 determines the challenge size in Sigma protocols and must be less than the group order's bit length for security
  • The DlogGroup interface provides exponentiate (scalar multiplication), multiply (point addition), inverse (point negation), and identity (point at infinity)
  • Projective coordinates (X, Y, Z) avoid expensive field inversions during computation; conversion to affine coordinates is required only for encoding and comparison

Next: Chapter 10: Hash Functions

3

Scala: DlogGroup.scala