atproto utils for zig zat.dev
atproto sdk zig

add parseDidKey, verifyDidKeySignature, and Keypair abstraction

completes the crypto module's did:key lifecycle — formatDidKey already
existed but the inverse (parsing a did:key string back to key type +
raw bytes) was missing. adds a unified Keypair struct for sign/verify
workflows and a convenience verifyDidKeySignature that dispatches by
curve type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+305 -4
+182
src/internal/crypto/keypair.zig
··· 1 + //! keypair abstraction for AT Protocol cryptography 2 + //! 3 + //! unified keypair type for secp256k1 (ES256K) and P-256 (ES256). 4 + //! handles signing with low-S normalization, public key derivation, 5 + //! and did:key formatting. 6 + //! 7 + //! see: https://atproto.com/specs/cryptography 8 + 9 + const std = @import("std"); 10 + const crypto = std.crypto; 11 + const multicodec = @import("multicodec.zig"); 12 + const jwt = @import("jwt.zig"); 13 + 14 + pub const Keypair = struct { 15 + key_type: multicodec.KeyType, 16 + secret_key: [32]u8, 17 + 18 + /// create a keypair from raw secret key bytes (32 bytes). 19 + /// validates the key is on the curve. 20 + pub fn fromSecretKey(key_type: multicodec.KeyType, secret_key: [32]u8) !Keypair { 21 + // validate by attempting to construct the stdlib key 22 + switch (key_type) { 23 + .secp256k1 => { 24 + _ = crypto.sign.ecdsa.EcdsaSecp256k1Sha256.SecretKey.fromBytes(secret_key) catch 25 + return error.InvalidSecretKey; 26 + }, 27 + .p256 => { 28 + _ = crypto.sign.ecdsa.EcdsaP256Sha256.SecretKey.fromBytes(secret_key) catch 29 + return error.InvalidSecretKey; 30 + }, 31 + } 32 + return .{ .key_type = key_type, .secret_key = secret_key }; 33 + } 34 + 35 + /// sign a message with deterministic ECDSA (RFC 6979) and low-S normalization 36 + pub fn sign(self: *const Keypair, message: []const u8) !jwt.Signature { 37 + return switch (self.key_type) { 38 + .secp256k1 => jwt.signSecp256k1(message, &self.secret_key), 39 + .p256 => jwt.signP256(message, &self.secret_key), 40 + }; 41 + } 42 + 43 + /// return the compressed SEC1 public key (33 bytes) 44 + pub fn publicKey(self: *const Keypair) ![33]u8 { 45 + switch (self.key_type) { 46 + .secp256k1 => { 47 + const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256; 48 + const sk = Scheme.SecretKey.fromBytes(self.secret_key) catch return error.InvalidSecretKey; 49 + const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey; 50 + return kp.public_key.toCompressedSec1(); 51 + }, 52 + .p256 => { 53 + const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256; 54 + const sk = Scheme.SecretKey.fromBytes(self.secret_key) catch return error.InvalidSecretKey; 55 + const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey; 56 + return kp.public_key.toCompressedSec1(); 57 + }, 58 + } 59 + } 60 + 61 + /// format the public key as a did:key string. 62 + /// caller owns the returned slice. 63 + pub fn did(self: *const Keypair, allocator: std.mem.Allocator) ![]u8 { 64 + const pk = try self.publicKey(); 65 + return multicodec.formatDidKey(allocator, self.key_type, &pk); 66 + } 67 + 68 + /// return the JWT algorithm identifier 69 + pub fn algorithm(self: *const Keypair) jwt.Algorithm { 70 + return switch (self.key_type) { 71 + .secp256k1 => .ES256K, 72 + .p256 => .ES256, 73 + }; 74 + } 75 + }; 76 + 77 + // === tests === 78 + 79 + test "keypair secp256k1 sign and verify round-trip" { 80 + const alloc = std.testing.allocator; 81 + 82 + const kp = try Keypair.fromSecretKey(.secp256k1, .{ 83 + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 84 + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 85 + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 86 + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 87 + }); 88 + 89 + const message = "keypair round-trip test"; 90 + const sig = try kp.sign(message); 91 + 92 + // verify via did:key 93 + const did_str = try kp.did(alloc); 94 + defer alloc.free(did_str); 95 + 96 + try multicodec.verifyDidKeySignature(alloc, did_str, message, &sig.bytes); 97 + } 98 + 99 + test "keypair p256 sign and verify round-trip" { 100 + const alloc = std.testing.allocator; 101 + 102 + const kp = try Keypair.fromSecretKey(.p256, .{ 103 + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 104 + 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 105 + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 106 + 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 107 + }); 108 + 109 + const message = "keypair p256 round-trip"; 110 + const sig = try kp.sign(message); 111 + 112 + const did_str = try kp.did(alloc); 113 + defer alloc.free(did_str); 114 + 115 + try multicodec.verifyDidKeySignature(alloc, did_str, message, &sig.bytes); 116 + } 117 + 118 + test "keypair did:key format is correct" { 119 + const alloc = std.testing.allocator; 120 + 121 + const kp = try Keypair.fromSecretKey(.secp256k1, .{ 122 + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 123 + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 124 + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 125 + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 126 + }); 127 + 128 + const did_str = try kp.did(alloc); 129 + defer alloc.free(did_str); 130 + 131 + // must start with did:key:z (base58btc multibase prefix) 132 + try std.testing.expect(std.mem.startsWith(u8, did_str, "did:key:z")); 133 + 134 + // must round-trip back to the same public key 135 + const parsed = try multicodec.parseDidKey(alloc, did_str); 136 + defer alloc.free(parsed.raw); 137 + 138 + const pk = try kp.publicKey(); 139 + try std.testing.expectEqual(multicodec.KeyType.secp256k1, parsed.key_type); 140 + try std.testing.expectEqualSlices(u8, &pk, parsed.raw); 141 + } 142 + 143 + test "keypair algorithm matches key type" { 144 + const secp = try Keypair.fromSecretKey(.secp256k1, .{0x01} ** 32); 145 + try std.testing.expectEqual(jwt.Algorithm.ES256K, secp.algorithm()); 146 + 147 + const p256 = try Keypair.fromSecretKey(.p256, .{0x21} ** 32); 148 + try std.testing.expectEqual(jwt.Algorithm.ES256, p256.algorithm()); 149 + } 150 + 151 + test "keypair rejects invalid secret key" { 152 + // all-zeros is not a valid scalar for either curve 153 + try std.testing.expectError(error.InvalidSecretKey, Keypair.fromSecretKey(.secp256k1, .{0x00} ** 32)); 154 + try std.testing.expectError(error.InvalidSecretKey, Keypair.fromSecretKey(.p256, .{0x00} ** 32)); 155 + } 156 + 157 + test "keypair cross-verify: sign with keypair, verify with jwt.verify" { 158 + // sign with Keypair, verify through the JWT multibase path (existing code) 159 + const alloc = std.testing.allocator; 160 + const multibase = @import("multibase.zig"); 161 + 162 + const sk_bytes = [_]u8{ 163 + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 164 + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 165 + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 166 + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 167 + }; 168 + 169 + const kp = try Keypair.fromSecretKey(.secp256k1, sk_bytes); 170 + const message = "cross-verify test"; 171 + const sig = try kp.sign(message); 172 + 173 + // get the multibase-encoded key (as it would appear in a DID document) 174 + const pk = try kp.publicKey(); 175 + const mc_bytes = try multicodec.encodePublicKey(alloc, .secp256k1, &pk); 176 + defer alloc.free(mc_bytes); 177 + const multibase_key = try multibase.encode(alloc, .base58btc, mc_bytes); 178 + defer alloc.free(multibase_key); 179 + 180 + // verify through the old path 181 + try jwt.verifySecp256k1(message, &sig.bytes, &pk); 182 + }
+122 -4
src/internal/crypto/multicodec.zig
··· 81 81 defer allocator.free(multibase_str); 82 82 83 83 // "did:key:" + multibase string (which already has 'z' prefix) 84 - const prefix = "did:key:"; 85 - const result = try allocator.alloc(u8, prefix.len + multibase_str.len); 86 - @memcpy(result[0..prefix.len], prefix); 87 - @memcpy(result[prefix.len..], multibase_str); 84 + const result = try allocator.alloc(u8, did_key_prefix.len + multibase_str.len); 85 + @memcpy(result[0..did_key_prefix.len], did_key_prefix); 86 + @memcpy(result[did_key_prefix.len..], multibase_str); 88 87 return result; 88 + } 89 + 90 + const did_key_prefix = "did:key:"; 91 + 92 + /// parse a did:key string into key type and raw public key bytes. 93 + /// caller owns the returned slice (raw field). 94 + pub fn parseDidKey(allocator: std.mem.Allocator, did: []const u8) !struct { key_type: KeyType, raw: []u8 } { 95 + const multibase = @import("multibase.zig"); 96 + 97 + if (!std.mem.startsWith(u8, did, did_key_prefix)) return error.InvalidDidKey; 98 + const multibase_str = did[did_key_prefix.len..]; 99 + if (multibase_str.len == 0) return error.InvalidDidKey; 100 + 101 + const mc_bytes = try multibase.decode(allocator, multibase_str); 102 + defer allocator.free(mc_bytes); 103 + 104 + const parsed = try parsePublicKey(mc_bytes); 105 + const raw = try allocator.dupe(u8, parsed.raw); 106 + return .{ .key_type = parsed.key_type, .raw = raw }; 107 + } 108 + 109 + /// verify an ECDSA signature given a did:key string. 110 + /// dispatches to the correct curve based on the key type encoded in the did:key. 111 + pub fn verifyDidKeySignature(allocator: std.mem.Allocator, did: []const u8, message: []const u8, sig_bytes: []const u8) !void { 112 + const jwt = @import("jwt.zig"); 113 + 114 + const parsed = try parseDidKey(allocator, did); 115 + defer allocator.free(parsed.raw); 116 + 117 + switch (parsed.key_type) { 118 + .secp256k1 => try jwt.verifySecp256k1(message, sig_bytes, parsed.raw), 119 + .p256 => try jwt.verifyP256(message, sig_bytes, parsed.raw), 120 + } 89 121 } 90 122 91 123 // === tests === ··· 183 215 try std.testing.expectEqual(KeyType.p256, parsed.key_type); 184 216 try std.testing.expectEqualSlices(u8, &raw, parsed.raw); 185 217 } 218 + 219 + test "parseDidKey round-trip secp256k1" { 220 + const alloc = std.testing.allocator; 221 + 222 + var raw: [33]u8 = undefined; 223 + raw[0] = 0x02; 224 + @memset(raw[1..], 0xcc); 225 + 226 + const did_str = try formatDidKey(alloc, .secp256k1, &raw); 227 + defer alloc.free(did_str); 228 + 229 + const parsed = try parseDidKey(alloc, did_str); 230 + defer alloc.free(parsed.raw); 231 + 232 + try std.testing.expectEqual(KeyType.secp256k1, parsed.key_type); 233 + try std.testing.expectEqualSlices(u8, &raw, parsed.raw); 234 + } 235 + 236 + test "parseDidKey round-trip p256" { 237 + const alloc = std.testing.allocator; 238 + 239 + var raw: [33]u8 = undefined; 240 + raw[0] = 0x03; 241 + @memset(raw[1..], 0xdd); 242 + 243 + const did_str = try formatDidKey(alloc, .p256, &raw); 244 + defer alloc.free(did_str); 245 + 246 + const parsed = try parseDidKey(alloc, did_str); 247 + defer alloc.free(parsed.raw); 248 + 249 + try std.testing.expectEqual(KeyType.p256, parsed.key_type); 250 + try std.testing.expectEqualSlices(u8, &raw, parsed.raw); 251 + } 252 + 253 + test "parseDidKey with real indigo test vector" { 254 + // from bluesky-social/indigo jwt test fixtures 255 + const alloc = std.testing.allocator; 256 + 257 + const parsed = try parseDidKey(alloc, "did:key:zQ3shscXNYZQZSPwegiv7uQZZV5kzATLBRtgJhs7uRY7pfSk4"); 258 + defer alloc.free(parsed.raw); 259 + 260 + try std.testing.expectEqual(KeyType.secp256k1, parsed.key_type); 261 + try std.testing.expectEqual(@as(usize, 33), parsed.raw.len); 262 + try std.testing.expect(parsed.raw[0] == 0x02 or parsed.raw[0] == 0x03); 263 + } 264 + 265 + test "parseDidKey rejects invalid prefix" { 266 + const alloc = std.testing.allocator; 267 + try std.testing.expectError(error.InvalidDidKey, parseDidKey(alloc, "did:web:example.com")); 268 + try std.testing.expectError(error.InvalidDidKey, parseDidKey(alloc, "did:key:")); 269 + try std.testing.expectError(error.InvalidDidKey, parseDidKey(alloc, "")); 270 + } 271 + 272 + test "verifyDidKeySignature secp256k1" { 273 + const alloc = std.testing.allocator; 274 + const jwt = @import("jwt.zig"); 275 + const crypto = std.crypto; 276 + const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256; 277 + 278 + const sk_bytes = [_]u8{ 279 + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 280 + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 281 + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 282 + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 283 + }; 284 + 285 + const message = "verify via did:key"; 286 + const sig = try jwt.signSecp256k1(message, &sk_bytes); 287 + 288 + // derive public key and format as did:key 289 + const sk = try Scheme.SecretKey.fromBytes(sk_bytes); 290 + const kp = try Scheme.KeyPair.fromSecretKey(sk); 291 + const pk_bytes = kp.public_key.toCompressedSec1(); 292 + const did = try formatDidKey(alloc, .secp256k1, &pk_bytes); 293 + defer alloc.free(did); 294 + 295 + // should verify 296 + try verifyDidKeySignature(alloc, did, message, &sig.bytes); 297 + 298 + // should reject wrong message 299 + try std.testing.expectError( 300 + error.SignatureVerificationFailed, 301 + verifyDidKeySignature(alloc, did, "wrong message", &sig.bytes), 302 + ); 303 + }
+1
src/root.zig
··· 27 27 pub const Jwt = jwt.Jwt; 28 28 pub const multibase = @import("internal/crypto/multibase.zig"); 29 29 pub const multicodec = @import("internal/crypto/multicodec.zig"); 30 + pub const Keypair = @import("internal/crypto/keypair.zig").Keypair; 30 31 31 32 // repo 32 33 pub const mst = @import("internal/repo/mst.zig");