Skip to content

SSH Key Generator — Zig source

Generate Ed25519 or RSA SSH key pairs in your browser. Copy or download the public and private keys. No server involved.

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

//! ssh-keygen — OpenSSH key pair generation (Ed25519).
//!
//! Language: Zig 0.14 (standard library only)
//! Ported from: src/lib/ssh-keygen.ts (the canonical TypeScript implementation).
//! display source — part of CosmoDev's polyglot tool pages.
//!
//! Formats produced:
//! - Ed25519 public key : `ssh-ed25519 <base64(blob)> <comment>` where blob is
//!   the OpenSSH wire format (string "ssh-ed25519" + string 32-byte key).
//! - Ed25519 private key: `-----BEGIN OPENSSH PRIVATE KEY-----` PEM containing
//!   the unencrypted "openssh-key-v1" structure (cipher "none", kdf "none").
//! - Fingerprint        : SHA-256 of the raw public key blob, base64-encoded
//!   without padding, prefixed "SHA256:" (matches OpenSSH).
//!
//! The TS reference also generates RSA-2048/4096 keys through the Web Crypto
//! API. Zig's standard library ships Ed25519 but no RSA key generator, so this
//! port implements the Ed25519 path natively and reports the RSA options as
//! unsupported (an RSA port would need a third-party crypto library).

const std = @import("std");

const Ed25519 = std.crypto.sign.Ed25519;
const Sha256 = std.crypto.hash.sha2.Sha256;
const B64Encoder = std.base64.standard.Encoder;

pub const SSHAlgorithm = enum {
    ed25519,
    rsa_2048,
    rsa_4096,
};

pub const SSHKeyOptions = struct {
    algorithm: SSHAlgorithm = .ed25519,
    /// Optional key comment, conventionally `user@host`. Omitted when empty.
    comment: []const u8 = "",
};

pub const SSHKeyPair = struct {
    public_key: []u8,
    private_key: []u8,
    fingerprint: []u8,
};

const magic = "openssh-key-v1\x00"; // "openssh-key-v1\0"

pub const Error = error{
    UnsupportedAlgorithm,
    UnexpectedKeyEncoding,
    OutOfMemory,
};

/// SSH "string": 4-byte big-endian length + raw bytes, appended to `list`.
fn sshString(list: *std.ArrayList(u8), data: []const u8) !void {
    var len_buf: [4]u8 = undefined;
    std.mem.writeInt(u32, &len_buf, @intCast(data.len), .big);
    try list.appendSlice(&len_buf);
    try list.appendSlice(data);
}

fn u32be(list: *std.ArrayList(u8), n: u32) !void {
    var buf: [4]u8 = undefined;
    std.mem.writeInt(u32, &buf, n, .big);
    try list.appendSlice(&buf);
}

/// Base64-encode into a fresh caller-owned buffer.
fn toBase64(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 {
    const out = try allocator.alloc(u8, B64Encoder.calcSize(bytes.len));
    _ = B64Encoder.encode(out, bytes);
    return out;
}

/// Wrap `b64` in a `-----BEGIN/END <label>-----` PEM with fixed line width.
fn pemWrap(allocator: std.mem.Allocator, b64: []const u8, label: []const u8, width: usize) ![]u8 {
    var out = std.ArrayList(u8).init(allocator);
    errdefer out.deinit();
    try out.writer().print("-----BEGIN {s}-----\n", .{label});
    var i: usize = 0;
    while (i < b64.len) : (i += width) {
        const end = @min(i + width, b64.len);
        try out.appendSlice(b64[i..end]);
        try out.append('\n');
    }
    try out.writer().print("-----END {s}-----\n", .{label});
    return out.toOwnedSlice();
}

/// "SHA256:<base64 digest of the public blob, unpadded>" - the OpenSSH
/// fingerprint format. Caller owns the returned string.
fn sshFingerprint(allocator: std.mem.Allocator, pub_blob: []const u8) ![]u8 {
    var digest: [Sha256.digest_length]u8 = undefined;
    Sha256.hash(pub_blob, &digest, .{});
    const b64 = try toBase64(allocator, &digest);
    defer allocator.free(b64);
    var unpadded_len: usize = b64.len;
    while (unpadded_len > 0 and b64[unpadded_len - 1] == '=') unpadded_len -= 1;
    return std.fmt.allocPrint(allocator, "SHA256:{s}", .{b64[0..unpadded_len]});
}

/// Build the unencrypted "openssh-key-v1" private key structure for Ed25519
/// (cipher "none", kdf "none"), wrapped in an OPENSSH PRIVATE KEY PEM.
fn openSshPrivatePem(
    allocator: std.mem.Allocator,
    pub_blob: []const u8,
    private_key_bytes: []const u8,
    comment: []const u8,
) ![]u8 {
    var outer = std.ArrayList(u8).init(allocator);
    defer outer.deinit();

    // Two random u32 check values, the public blob, then the private key and
    // comment as SSH strings; padded to a multiple of 8 with 1,2,3,...
    var check: u32 = undefined;
    std.crypto.random.bytes(std.mem.asBytes(&check));
    try u32be(&outer, check);
    try u32be(&outer, check);
    try sshString(&outer, pub_blob);
    try sshString(&outer, private_key_bytes);
    try sshString(&outer, comment);
    // Pad to a multiple of the block size (8 for cipher "none") with 1,2,3,...
    const pad_len = (8 - (outer.items.len % 8)) % 8;
    for (1..pad_len + 1) |i| try outer.append(@intCast(i));

    var inner = std.ArrayList(u8).init(allocator);
    defer inner.deinit();
    try inner.appendSlice(magic);
    try sshString(&inner, "none"); // ciphername
    try sshString(&inner, "none"); // kdfname
    try sshString(&inner, ""); // kdfoptions
    try u32be(&inner, 1); // number of keys
    try sshString(&inner, pub_blob);
    try sshString(&inner, outer.items);

    const b64 = try toBase64(allocator, inner.items);
    defer allocator.free(b64);
    return pemWrap(allocator, b64, "OPENSSH PRIVATE KEY", 70);
}

/// Generate an SSH key pair in OpenSSH format. Caller owns every field.
pub fn generateSSHKeyPair(
    allocator: std.mem.Allocator,
    options: SSHKeyOptions,
) (Error || std.mem.Allocator.Error)!SSHKeyPair {
    const comment = std.mem.trim(u8, options.comment, " \t\r\n");
    const suffix: []const u8 = if (comment.len > 0) " " else "";

    switch (options.algorithm) {
        .ed25519 => {
            const kp = Ed25519.KeyPair.generate();
            const pub = kp.public_key.toBytes(); // 32 raw bytes

            var blob = std.ArrayList(u8).init(allocator);
            defer blob.deinit();
            try sshString(&blob, "ssh-ed25519");
            try sshString(&blob, &pub);

            // Private half: the 64-byte expanded key = seed || public key
            // (Zig derives the full secret; the OpenSSH format stores
            // seed || public, which is the same 64 bytes).
            const expanded = kp.secret_key.toBytes();

            const pub_b64 = try toBase64(allocator, blob.items);
            const public_key = try std.fmt.allocPrint(
                allocator,
                "ssh-ed25519 {s}{s}{s}",
                .{ pub_b64, suffix, comment },
            );
            allocator.free(pub_b64);

            return .{
                .public_key = public_key,
                .private_key = try openSshPrivatePem(allocator, blob.items, &expanded, comment),
                .fingerprint = try sshFingerprint(allocator, blob.items),
            };
        },
        // Zig's std.crypto has Ed25519 but no RSA key generator; the RSA
        // variants would require a third-party crypto library.
        .rsa_2048, .rsa_4096 => return Error.UnsupportedAlgorithm,
    }
}

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 →