Skip to content

Digital Signature — Zig source

Sign messages with Ed25519 or ECDSA and verify signatures. Prove authorship without revealing your private key.

This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! digital-signature — Ed25519 / ECDSA P-256 message signing and verification.
//!
//! Language: Zig 0.14 (standard library only)
//! Ported from: src/lib/digital-signature.ts (the canonical TypeScript implementation).
//! display source — part of CosmoDev's polyglot tool pages.
//!
//! The TS reference drives the Web Crypto API; Zig's standard library ships
//! both algorithms as `std.crypto.sign.Ed25519` and
//! `std.crypto.sign.ecdsa.EcdsaP256Sha256`, so this port runs the whole
//! pipeline — key generation, signing, verification — locally with zero
//! dependencies. Keys and messages never leave the process.
//!
//! Key encoding: keys are exchanged as hex of the standard DER structures -
//! SPKI (SubjectPublicKeyInfo) for public keys, PKCS8 for private keys - so
//! they interoperate with OpenSSL, SSH, JOSE tooling and any other Web Crypto
//! implementation. Signatures are hex of the raw signature bytes: 64 bytes for
//! both algorithms (Ed25519 R||S; ECDSA P-256 in IEEE P1363 r||s layout, NOT
//! ASN.1 DER). ECDSA signs the SHA-256 digest of the message internally.

const std = @import("std");

pub const SignAlgorithm = enum {
    ed25519,
    ecdsa_p256,

    /// Human label used in error messages ("Ed25519" / "ECDSA P-256").
    pub fn label(self: SignAlgorithm) []const u8 {
        return switch (self) {
            .ed25519 => "Ed25519",
            .ecdsa_p256 => "ECDSA P-256",
        };
    }
};

pub const KeyPair = struct {
    /// Hex-encoded SPKI (SubjectPublicKeyInfo) public key. Safe to share.
    public_key: []const u8,
    /// Hex-encoded PKCS8 private key. Keep it secret - it IS the identity.
    private_key: []const u8,
};

pub const Error = error{
    EmptyMessage,
    InvalidHex,
    InvalidPrivateKey,
    InvalidPublicKey,
    InvalidSignature,
    OutOfMemory,
};

const Ed25519 = std.crypto.sign.Ed25519;
const EcdsaP256Sha256 = std.crypto.sign.ecdsa.EcdsaP256Sha256;

// --- Hex codec (lowercase, matching the TS toHex) ---------------------------

fn toHex(allocator: std.mem.Allocator, bytes: []const u8) Error![]u8 {
    const out = try allocator.alloc(u8, bytes.len * 2);
    const charset = "0123456789abcdef";
    for (bytes, 0..) |b, i| {
        out[i * 2] = charset[b >> 4];
        out[i * 2 + 1] = charset[b & 0x0f];
    }
    return out;
}

fn nibble(c: u8) Error!u8 {
    return switch (c) {
        '0'...'9' => c - '0',
        'a'...'f' => c - 'a' + 10,
        'A'...'F' => c - 'A' + 10,
        else => Error.InvalidHex,
    };
}

/// Decode a hex string into `out`. Fails on odd length or non-hex characters.
fn hexToBytes(out: []u8, hex: []const u8) Error!usize {
    if (hex.len == 0 or hex.len % 2 != 0 or out.len * 2 < hex.len) return Error.InvalidHex;
    var i: usize = 0;
    while (i < hex.len) : (i += 2) {
        const hi = try nibble(hex[i]);
        const lo = try nibble(hex[i + 1]);
        out[i / 2] = (hi << 4) | lo;
    }
    return hex.len / 2;
}

// --- Minimal DER builder (fixed SPKI / PKCS8 shapes) --------------------------

/// Append `content` wrapped as one TLV: `tag`, DER length, content bytes.
fn appendTlv(list: *std.ArrayList(u8), tag: u8, content: []const u8) !void {
    try list.append(tag);
    if (content.len < 0x80) {
        try list.append(@intCast(content.len));
    } else {
        var len_bytes: [8]u8 = undefined;
        var len = content.len;
        var count: usize = 0;
        while (len > 0) : (len >>= 8) {
            len_bytes[count] = @intCast(len & 0xff);
            count += 1;
        }
        try list.append(0x80 | @as(u8, @intCast(count)));
        var j = count;
        while (j > 0) {
            j -= 1;
            try list.append(len_bytes[j]);
        }
    }
    try list.appendSlice(content);
}

/// DER OBJECT IDENTIFIER content bytes from a dotted string, e.g. "1.3.101.112".
fn appendOidContent(list: *std.ArrayList(u8), dotted: []const u8) !void {
    var parts = std.mem.splitScalar(u8, dotted, '.');
    const first = try std.fmt.parseInt(u32, parts.next().?, 10);
    const second = try std.fmt.parseInt(u32, parts.next().?, 10);
    try list.append(@intCast(40 * first + second));
    while (parts.next()) |p| {
        var v = try std.fmt.parseInt(u32, p, 10);
        var stack: [8]u8 = undefined;
        var depth: usize = 0;
        while (true) {
            stack[depth] = @intCast(v & 0x7f);
            depth += 1;
            v >>= 7;
            if (v == 0) break;
        }
        var j = depth;
        while (j > 1) {
            j -= 1;
            try list.append(stack[j] | 0x80);
        }
        try list.append(stack[0]);
    }
}

// OID 1.3.101.112 = Ed25519, 1.2.840.10045.2.1 = ecPublicKey, 1.2.840.10045.3.1.7 = P-256.
const OID_ED25519 = "1.3.101.112";
const OID_EC_PUBLIC_KEY = "1.2.840.10045.2.1";
const OID_P256 = "1.2.840.10045.3.1.7";

/// SEQUENCE { OID } — the AlgorithmIdentifier for Ed25519 (no parameters).
fn ed25519AlgId(list: *std.ArrayList(u8)) !void {
    var content = std.ArrayList(u8).init(list.allocator);
    defer content.deinit();
    try appendTlv(&content, 0x06, try oidSlice(list.allocator, OID_ED25519));
    try appendTlv(list, 0x30, content.items);
}

/// SEQUENCE { OID ecPublicKey, OID prime256v1 } — the ECDSA AlgorithmIdentifier.
fn ecdsaAlgId(list: *std.ArrayList(u8)) !void {
    var content = std.ArrayList(u8).init(list.allocator);
    defer content.deinit();
    try appendTlv(&content, 0x06, try oidSlice(list.allocator, OID_EC_PUBLIC_KEY));
    try appendTlv(&content, 0x06, try oidSlice(list.allocator, OID_P256));
    try appendTlv(list, 0x30, content.items);
}

/// The encoded content bytes of a dotted OID in a freshly allocated slice.
fn oidSlice(allocator: std.mem.Allocator, dotted: []const u8) ![]u8 {
    var tmp = std.ArrayList(u8).init(allocator);
    errdefer tmp.deinit();
    try appendOidContent(&tmp, dotted);
    return tmp.toOwnedSlice();
}

// --- SPKI / PKCS8 export (exactly the Web Crypto wire format) -----------------

/// Ed25519 SPKI: SEQUENCE { AlgorithmIdentifier, BIT STRING (0, A) } — 44 bytes.
fn ed25519Spki(allocator: std.mem.Allocator, public: *const [32]u8) ![]u8 {
    var body = std.ArrayList(u8).init(allocator);
    defer body.deinit();
    try ed25519AlgId(&body);
    var bits: [33]u8 = undefined;
    bits[0] = 0; // 0 unused bits in the last octet
    @memcpy(bits[1..], public);
    var bits_tlv = std.ArrayList(u8).init(allocator);
    defer bits_tlv.deinit();
    try appendTlv(&bits_tlv, 0x03, &bits);
    try body.appendSlice(bits_tlv.items);

    var out = std.ArrayList(u8).init(allocator);
    errdefer out.deinit();
    try appendTlv(&out, 0x30, body.items);
    return out.toOwnedSlice();
}

/// Ed25519 PKCS8: SEQUENCE { INTEGER 0, AlgorithmIdentifier, OCTET STRING seed }.
fn ed25519Pkcs8(allocator: std.mem.Allocator, seed: *const [32]u8) ![]u8 {
    var body = std.ArrayList(u8).init(allocator);
    defer body.deinit();
    try appendTlv(&body, 0x02, &[_]u8{0}); // version 0
    try ed25519AlgId(&body);
    var octet = std.ArrayList(u8).init(allocator);
    defer octet.deinit();
    try appendTlv(&octet, 0x04, seed);
    try body.appendSlice(octet.items);

    var out = std.ArrayList(u8).init(allocator);
    errdefer out.deinit();
    try appendTlv(&out, 0x30, body.items);
    return out.toOwnedSlice();
}

/// ECDSA P-256 SPKI: SEQUENCE { SEQUENCE { ecPublicKey, P-256 }, BIT STRING point }.
fn ecdsaSpki(allocator: std.mem.Allocator, sec1: *const [65]u8) ![]u8 {
    var body = std.ArrayList(u8).init(allocator);
    defer body.deinit();
    try ecdsaAlgId(&body);
    var bits: [66]u8 = undefined;
    bits[0] = 0;
    @memcpy(bits[1..], sec1);
    var bits_tlv = std.ArrayList(u8).init(allocator);
    defer bits_tlv.deinit();
    try appendTlv(&bits_tlv, 0x03, &bits);
    try body.appendSlice(bits_tlv.items);

    var out = std.ArrayList(u8).init(allocator);
    errdefer out.deinit();
    try appendTlv(&out, 0x30, body.items);
    return out.toOwnedSlice();
}

/// ECDSA P-256 PKCS8: SEQUENCE { INTEGER 0, SEQUENCE { ecPublicKey, P-256 }, OCTET STRING scalar }.
fn ecdsaPkcs8(allocator: std.mem.Allocator, scalar: *const [32]u8) ![]u8 {
    var body = std.ArrayList(u8).init(allocator);
    defer body.deinit();
    try appendTlv(&body, 0x02, &[_]u8{0});
    try ecdsaAlgId(&body);
    var octet = std.ArrayList(u8).init(allocator);
    defer octet.deinit();
    try appendTlv(&octet, 0x04, scalar);
    try body.appendSlice(octet.items);

    var out = std.ArrayList(u8).init(allocator);
    errdefer out.deinit();
    try appendTlv(&out, 0x30, body.items);
    return out.toOwnedSlice();
}

// --- Key generation -----------------------------------------------------------

/// Generate a fresh signing key pair, exported as hex SPKI / PKCS8 keys.
/// Caller owns both hex strings.
pub fn generateKeyPair(allocator: std.mem.Allocator, algorithm: SignAlgorithm) Error!KeyPair {
    switch (algorithm) {
        .ed25519 => {
            const kp = Ed25519.KeyPair.generate();
            const public = kp.public_key.toBytes();
            const seed = kp.secret_key.seed();
            const spki = try ed25519Spki(allocator, &public);
            defer allocator.free(spki);
            const pkcs8 = try ed25519Pkcs8(allocator, &seed);
            defer allocator.free(pkcs8);
            return .{
                .public_key = try toHex(allocator, spki),
                .private_key = try toHex(allocator, pkcs8),
            };
        },
        .ecdsa_p256 => {
            const kp = try EcdsaP256Sha256.KeyPair.create();
            const sec1 = kp.public_key.toSec1();
            const sk = kp.secret_key.toBytes();
            const spki = try ecdsaSpki(allocator, &sec1);
            defer allocator.free(spki);
            const pkcs8 = try ecdsaPkcs8(allocator, &sk);
            defer allocator.free(pkcs8);
            return .{
                .public_key = try toHex(allocator, spki),
                .private_key = try toHex(allocator, pkcs8),
            };
        },
    }
}

// --- Signing / verification ---------------------------------------------------

/// Sign `message` with a hex PKCS8 private key. Returns the hex signature
/// (128 hex chars = 64 raw bytes for both algorithms). Caller owns it.
pub fn signMessage(allocator: std.mem.Allocator, message: []const u8, private_key_hex: []const u8, algorithm: SignAlgorithm) Error![]u8 {
    if (message.len == 0) return Error.EmptyMessage;
    var der: [256]u8 = undefined;
    const der_len = try hexToBytes(&der, private_key_hex);

    switch (algorithm) {
        .ed25519 => {
            // PKCS8 tail is the 32-byte seed — walk past the fixed header.
            if (der_len < 48) return Error.InvalidPrivateKey;
            const seed = der[der_len - 32 ..][0..32];
            const kp = Ed25519.KeyPair.create(seed.*) catch return Error.InvalidPrivateKey;
            const sig = Ed25519.sign(message, kp.secret_key, null);
            return toHex(allocator, &sig.toBytes());
        },
        .ecdsa_p256 => {
            if (der_len < 64) return Error.InvalidPrivateKey;
            // PKCS8 tail is the 32-byte scalar (the private exponent).
            const scalar = der[der_len - 32 ..][0..32];
            const sk = EcdsaP256Sha256.SecretKey.fromBytes(scalar.*) catch return Error.InvalidPrivateKey;
            const sig = try EcdsaP256Sha256.sign(message, sk);
            return toHex(allocator, &sig.toRawBytes());
        },
    }
}

/// Verify `signature_hex` against `message` with a hex SPKI public key.
/// Returns false when the signature, message or key do not match; errors
/// only for malformed input (non-hex / wrong key type).
pub fn verifySignature(message: []const u8, signature_hex: []const u8, public_key_hex: []const u8, algorithm: SignAlgorithm) Error!bool {
    var sig_bytes: [64]u8 = undefined;
    const sig_len = try hexToBytes(&sig_bytes, signature_hex);
    if (sig_len != 64) return Error.InvalidSignature;

    var der: [512]u8 = undefined;
    const der_len = try hexToBytes(&der, public_key_hex);

    switch (algorithm) {
        .ed25519 => {
            // SPKI tail is the 32-byte compressed point.
            if (der_len < 44) return Error.InvalidPublicKey;
            const point = der[der_len - 32 ..][0..32];
            const pk = Ed25519.PublicKey.fromBytes(point.*) catch return Error.InvalidPublicKey;
            const sig = Ed25519.Signature.fromBytes(sig_bytes);
            sig.verify(message, pk) catch return false;
            return true;
        },
        .ecdsa_p256 => {
            // SPKI tail is the 65-byte uncompressed point (0x04 || X || Y).
            if (der_len < 91) return Error.InvalidPublicKey;
            const sec1 = der[der_len - 65 ..][0..65];
            const pk = EcdsaP256Sha256.PublicKey.fromSec1(sec1) catch return Error.InvalidPublicKey;
            const sig = EcdsaP256Sha256.Signature.fromBytes(sig_bytes);
            EcdsaP256Sha256.verify(message, sig, pk) catch return false;
            return true;
        },
    }
}

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →