diff --git a/src/internal/crypto/keypair.zig b/src/internal/crypto/keypair.zig new file mode 100644 index 0000000..785c2e2 --- /dev/null +++ b/src/internal/crypto/keypair.zig @@ -0,0 +1,182 @@ +//! keypair abstraction for AT Protocol cryptography +//! +//! unified keypair type for secp256k1 (ES256K) and P-256 (ES256). +//! handles signing with low-S normalization, public key derivation, +//! and did:key formatting. +//! +//! see: https://atproto.com/specs/cryptography + +const std = @import("std"); +const crypto = std.crypto; +const multicodec = @import("multicodec.zig"); +const jwt = @import("jwt.zig"); + +pub const Keypair = struct { + key_type: multicodec.KeyType, + secret_key: [32]u8, + + /// create a keypair from raw secret key bytes (32 bytes). + /// validates the key is on the curve. + pub fn fromSecretKey(key_type: multicodec.KeyType, secret_key: [32]u8) !Keypair { + // validate by attempting to construct the stdlib key + switch (key_type) { + .secp256k1 => { + _ = crypto.sign.ecdsa.EcdsaSecp256k1Sha256.SecretKey.fromBytes(secret_key) catch + return error.InvalidSecretKey; + }, + .p256 => { + _ = crypto.sign.ecdsa.EcdsaP256Sha256.SecretKey.fromBytes(secret_key) catch + return error.InvalidSecretKey; + }, + } + return .{ .key_type = key_type, .secret_key = secret_key }; + } + + /// sign a message with deterministic ECDSA (RFC 6979) and low-S normalization + pub fn sign(self: *const Keypair, message: []const u8) !jwt.Signature { + return switch (self.key_type) { + .secp256k1 => jwt.signSecp256k1(message, &self.secret_key), + .p256 => jwt.signP256(message, &self.secret_key), + }; + } + + /// return the compressed SEC1 public key (33 bytes) + pub fn publicKey(self: *const Keypair) ![33]u8 { + switch (self.key_type) { + .secp256k1 => { + const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256; + const sk = Scheme.SecretKey.fromBytes(self.secret_key) catch return error.InvalidSecretKey; + const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey; + return kp.public_key.toCompressedSec1(); + }, + .p256 => { + const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256; + const sk = Scheme.SecretKey.fromBytes(self.secret_key) catch return error.InvalidSecretKey; + const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey; + return kp.public_key.toCompressedSec1(); + }, + } + } + + /// format the public key as a did:key string. + /// caller owns the returned slice. + pub fn did(self: *const Keypair, allocator: std.mem.Allocator) ![]u8 { + const pk = try self.publicKey(); + return multicodec.formatDidKey(allocator, self.key_type, &pk); + } + + /// return the JWT algorithm identifier + pub fn algorithm(self: *const Keypair) jwt.Algorithm { + return switch (self.key_type) { + .secp256k1 => .ES256K, + .p256 => .ES256, + }; + } +}; + +// === tests === + +test "keypair secp256k1 sign and verify round-trip" { + const alloc = std.testing.allocator; + + const kp = try Keypair.fromSecretKey(.secp256k1, .{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + }); + + const message = "keypair round-trip test"; + const sig = try kp.sign(message); + + // verify via did:key + const did_str = try kp.did(alloc); + defer alloc.free(did_str); + + try multicodec.verifyDidKeySignature(alloc, did_str, message, &sig.bytes); +} + +test "keypair p256 sign and verify round-trip" { + const alloc = std.testing.allocator; + + const kp = try Keypair.fromSecretKey(.p256, .{ + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, + }); + + const message = "keypair p256 round-trip"; + const sig = try kp.sign(message); + + const did_str = try kp.did(alloc); + defer alloc.free(did_str); + + try multicodec.verifyDidKeySignature(alloc, did_str, message, &sig.bytes); +} + +test "keypair did:key format is correct" { + const alloc = std.testing.allocator; + + const kp = try Keypair.fromSecretKey(.secp256k1, .{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + }); + + const did_str = try kp.did(alloc); + defer alloc.free(did_str); + + // must start with did:key:z (base58btc multibase prefix) + try std.testing.expect(std.mem.startsWith(u8, did_str, "did:key:z")); + + // must round-trip back to the same public key + const parsed = try multicodec.parseDidKey(alloc, did_str); + defer alloc.free(parsed.raw); + + const pk = try kp.publicKey(); + try std.testing.expectEqual(multicodec.KeyType.secp256k1, parsed.key_type); + try std.testing.expectEqualSlices(u8, &pk, parsed.raw); +} + +test "keypair algorithm matches key type" { + const secp = try Keypair.fromSecretKey(.secp256k1, .{0x01} ** 32); + try std.testing.expectEqual(jwt.Algorithm.ES256K, secp.algorithm()); + + const p256 = try Keypair.fromSecretKey(.p256, .{0x21} ** 32); + try std.testing.expectEqual(jwt.Algorithm.ES256, p256.algorithm()); +} + +test "keypair rejects invalid secret key" { + // all-zeros is not a valid scalar for either curve + try std.testing.expectError(error.InvalidSecretKey, Keypair.fromSecretKey(.secp256k1, .{0x00} ** 32)); + try std.testing.expectError(error.InvalidSecretKey, Keypair.fromSecretKey(.p256, .{0x00} ** 32)); +} + +test "keypair cross-verify: sign with keypair, verify with jwt.verify" { + // sign with Keypair, verify through the JWT multibase path (existing code) + const alloc = std.testing.allocator; + const multibase = @import("multibase.zig"); + + const sk_bytes = [_]u8{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + }; + + const kp = try Keypair.fromSecretKey(.secp256k1, sk_bytes); + const message = "cross-verify test"; + const sig = try kp.sign(message); + + // get the multibase-encoded key (as it would appear in a DID document) + const pk = try kp.publicKey(); + const mc_bytes = try multicodec.encodePublicKey(alloc, .secp256k1, &pk); + defer alloc.free(mc_bytes); + const multibase_key = try multibase.encode(alloc, .base58btc, mc_bytes); + defer alloc.free(multibase_key); + + // verify through the old path + try jwt.verifySecp256k1(message, &sig.bytes, &pk); +} diff --git a/src/internal/crypto/multicodec.zig b/src/internal/crypto/multicodec.zig index 647a456..f6204ee 100644 --- a/src/internal/crypto/multicodec.zig +++ b/src/internal/crypto/multicodec.zig @@ -81,13 +81,45 @@ pub fn formatDidKey(allocator: std.mem.Allocator, key_type: KeyType, raw: []cons defer allocator.free(multibase_str); // "did:key:" + multibase string (which already has 'z' prefix) - const prefix = "did:key:"; - const result = try allocator.alloc(u8, prefix.len + multibase_str.len); - @memcpy(result[0..prefix.len], prefix); - @memcpy(result[prefix.len..], multibase_str); + const result = try allocator.alloc(u8, did_key_prefix.len + multibase_str.len); + @memcpy(result[0..did_key_prefix.len], did_key_prefix); + @memcpy(result[did_key_prefix.len..], multibase_str); return result; } +const did_key_prefix = "did:key:"; + +/// parse a did:key string into key type and raw public key bytes. +/// caller owns the returned slice (raw field). +pub fn parseDidKey(allocator: std.mem.Allocator, did: []const u8) !struct { key_type: KeyType, raw: []u8 } { + const multibase = @import("multibase.zig"); + + if (!std.mem.startsWith(u8, did, did_key_prefix)) return error.InvalidDidKey; + const multibase_str = did[did_key_prefix.len..]; + if (multibase_str.len == 0) return error.InvalidDidKey; + + const mc_bytes = try multibase.decode(allocator, multibase_str); + defer allocator.free(mc_bytes); + + const parsed = try parsePublicKey(mc_bytes); + const raw = try allocator.dupe(u8, parsed.raw); + return .{ .key_type = parsed.key_type, .raw = raw }; +} + +/// verify an ECDSA signature given a did:key string. +/// dispatches to the correct curve based on the key type encoded in the did:key. +pub fn verifyDidKeySignature(allocator: std.mem.Allocator, did: []const u8, message: []const u8, sig_bytes: []const u8) !void { + const jwt = @import("jwt.zig"); + + const parsed = try parseDidKey(allocator, did); + defer allocator.free(parsed.raw); + + switch (parsed.key_type) { + .secp256k1 => try jwt.verifySecp256k1(message, sig_bytes, parsed.raw), + .p256 => try jwt.verifyP256(message, sig_bytes, parsed.raw), + } +} + // === tests === test "parse secp256k1 key" { @@ -183,3 +215,89 @@ test "did:key round-trip p256" { try std.testing.expectEqual(KeyType.p256, parsed.key_type); try std.testing.expectEqualSlices(u8, &raw, parsed.raw); } + +test "parseDidKey round-trip secp256k1" { + const alloc = std.testing.allocator; + + var raw: [33]u8 = undefined; + raw[0] = 0x02; + @memset(raw[1..], 0xcc); + + const did_str = try formatDidKey(alloc, .secp256k1, &raw); + defer alloc.free(did_str); + + const parsed = try parseDidKey(alloc, did_str); + defer alloc.free(parsed.raw); + + try std.testing.expectEqual(KeyType.secp256k1, parsed.key_type); + try std.testing.expectEqualSlices(u8, &raw, parsed.raw); +} + +test "parseDidKey round-trip p256" { + const alloc = std.testing.allocator; + + var raw: [33]u8 = undefined; + raw[0] = 0x03; + @memset(raw[1..], 0xdd); + + const did_str = try formatDidKey(alloc, .p256, &raw); + defer alloc.free(did_str); + + const parsed = try parseDidKey(alloc, did_str); + defer alloc.free(parsed.raw); + + try std.testing.expectEqual(KeyType.p256, parsed.key_type); + try std.testing.expectEqualSlices(u8, &raw, parsed.raw); +} + +test "parseDidKey with real indigo test vector" { + // from bluesky-social/indigo jwt test fixtures + const alloc = std.testing.allocator; + + const parsed = try parseDidKey(alloc, "did:key:zQ3shscXNYZQZSPwegiv7uQZZV5kzATLBRtgJhs7uRY7pfSk4"); + defer alloc.free(parsed.raw); + + try std.testing.expectEqual(KeyType.secp256k1, parsed.key_type); + try std.testing.expectEqual(@as(usize, 33), parsed.raw.len); + try std.testing.expect(parsed.raw[0] == 0x02 or parsed.raw[0] == 0x03); +} + +test "parseDidKey rejects invalid prefix" { + const alloc = std.testing.allocator; + try std.testing.expectError(error.InvalidDidKey, parseDidKey(alloc, "did:web:example.com")); + try std.testing.expectError(error.InvalidDidKey, parseDidKey(alloc, "did:key:")); + try std.testing.expectError(error.InvalidDidKey, parseDidKey(alloc, "")); +} + +test "verifyDidKeySignature secp256k1" { + const alloc = std.testing.allocator; + const jwt = @import("jwt.zig"); + const crypto = std.crypto; + const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256; + + const sk_bytes = [_]u8{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + }; + + const message = "verify via did:key"; + const sig = try jwt.signSecp256k1(message, &sk_bytes); + + // derive public key and format as did:key + const sk = try Scheme.SecretKey.fromBytes(sk_bytes); + const kp = try Scheme.KeyPair.fromSecretKey(sk); + const pk_bytes = kp.public_key.toCompressedSec1(); + const did = try formatDidKey(alloc, .secp256k1, &pk_bytes); + defer alloc.free(did); + + // should verify + try verifyDidKeySignature(alloc, did, message, &sig.bytes); + + // should reject wrong message + try std.testing.expectError( + error.SignatureVerificationFailed, + verifyDidKeySignature(alloc, did, "wrong message", &sig.bytes), + ); +} diff --git a/src/root.zig b/src/root.zig index 02459fc..01c9812 100644 --- a/src/root.zig +++ b/src/root.zig @@ -27,6 +27,7 @@ pub const jwt = @import("internal/crypto/jwt.zig"); pub const Jwt = jwt.Jwt; pub const multibase = @import("internal/crypto/multibase.zig"); pub const multicodec = @import("internal/crypto/multicodec.zig"); +pub const Keypair = @import("internal/crypto/keypair.zig").Keypair; // repo pub const mst = @import("internal/repo/mst.zig");