atproto utils for zig zat.dev
atproto sdk zig
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

zzstoatzz 50dfad14 2c583f85

+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");