atproto utils for zig zat.dev
atproto sdk zig
at main 331 lines 13 kB view raw
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 9const std = @import("std"); 10const crypto = std.crypto; 11const multicodec = @import("multicodec.zig"); 12const jwt = @import("jwt.zig"); 13 14pub 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 // zero is not a valid scalar for any curve 22 if (std.mem.allEqual(u8, &secret_key, 0)) return error.InvalidSecretKey; 23 // validate by attempting to construct the stdlib key 24 switch (key_type) { 25 .secp256k1 => { 26 _ = crypto.sign.ecdsa.EcdsaSecp256k1Sha256.SecretKey.fromBytes(secret_key) catch 27 return error.InvalidSecretKey; 28 }, 29 .p256 => { 30 _ = crypto.sign.ecdsa.EcdsaP256Sha256.SecretKey.fromBytes(secret_key) catch 31 return error.InvalidSecretKey; 32 }, 33 } 34 return .{ .key_type = key_type, .secret_key = secret_key }; 35 } 36 37 /// sign a message with deterministic ECDSA (RFC 6979) and low-S normalization 38 pub fn sign(self: *const Keypair, message: []const u8) !jwt.Signature { 39 return switch (self.key_type) { 40 .secp256k1 => jwt.signSecp256k1(message, &self.secret_key), 41 .p256 => jwt.signP256(message, &self.secret_key), 42 }; 43 } 44 45 /// return the compressed SEC1 public key (33 bytes) 46 pub fn publicKey(self: *const Keypair) ![33]u8 { 47 switch (self.key_type) { 48 .secp256k1 => { 49 const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256; 50 const sk = Scheme.SecretKey.fromBytes(self.secret_key) catch return error.InvalidSecretKey; 51 const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey; 52 return kp.public_key.toCompressedSec1(); 53 }, 54 .p256 => { 55 const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256; 56 const sk = Scheme.SecretKey.fromBytes(self.secret_key) catch return error.InvalidSecretKey; 57 const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey; 58 return kp.public_key.toCompressedSec1(); 59 }, 60 } 61 } 62 63 /// format the public key as a did:key string. 64 /// caller owns the returned slice. 65 pub fn did(self: *const Keypair, allocator: std.mem.Allocator) ![]u8 { 66 const pk = try self.publicKey(); 67 return multicodec.formatDidKey(allocator, self.key_type, &pk); 68 } 69 70 /// return the JWT algorithm identifier 71 pub fn algorithm(self: *const Keypair) jwt.Algorithm { 72 return switch (self.key_type) { 73 .secp256k1 => .ES256K, 74 .p256 => .ES256, 75 }; 76 } 77 78 /// return the uncompressed SEC1 public key (65 bytes: 0x04 || x[32] || y[32]). 79 pub fn uncompressedPublicKey(self: *const Keypair) ![65]u8 { 80 switch (self.key_type) { 81 .secp256k1 => { 82 const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256; 83 const sk = Scheme.SecretKey.fromBytes(self.secret_key) catch return error.InvalidSecretKey; 84 const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey; 85 return kp.public_key.toUncompressedSec1(); 86 }, 87 .p256 => { 88 const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256; 89 const sk = Scheme.SecretKey.fromBytes(self.secret_key) catch return error.InvalidSecretKey; 90 const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey; 91 return kp.public_key.toUncompressedSec1(); 92 }, 93 } 94 } 95 96 /// format the public key as a JWK JSON string. 97 /// includes kty, crv, x, y, kid (thumbprint), use, alg. 98 /// caller owns the returned slice. 99 pub fn jwk(self: *const Keypair, allocator: std.mem.Allocator) ![]u8 { 100 const uncompressed = try self.uncompressedPublicKey(); 101 const x_b64 = try jwt.base64UrlEncode(allocator, uncompressed[1..33]); 102 defer allocator.free(x_b64); 103 const y_b64 = try jwt.base64UrlEncode(allocator, uncompressed[33..65]); 104 defer allocator.free(y_b64); 105 106 const crv = switch (self.key_type) { 107 .p256 => "P-256", 108 .secp256k1 => "secp256k1", 109 }; 110 const alg = @tagName(self.algorithm()); 111 112 // RFC 7638 thumbprint inline — avoids re-deriving the public key 113 const canonical = try std.fmt.allocPrint(allocator, 114 \\{{"crv":"{s}","kty":"EC","x":"{s}","y":"{s}"}} 115 , .{ crv, x_b64, y_b64 }); 116 defer allocator.free(canonical); 117 118 var hash: [32]u8 = undefined; 119 crypto.hash.sha2.Sha256.hash(canonical, &hash, .{}); 120 const kid = try jwt.base64UrlEncode(allocator, &hash); 121 defer allocator.free(kid); 122 123 return std.fmt.allocPrint(allocator, 124 \\{{"kty":"EC","crv":"{s}","x":"{s}","y":"{s}","kid":"{s}","use":"sig","alg":"{s}"}} 125 , .{ crv, x_b64, y_b64, kid, alg }); 126 } 127 128 /// compute the JWK thumbprint (RFC 7638) as a base64url string. 129 /// canonical form: {"crv":"...","kty":"EC","x":"...","y":"..."} 130 /// caller owns the returned slice. 131 pub fn jwkThumbprint(self: *const Keypair, allocator: std.mem.Allocator) ![]u8 { 132 const uncompressed = try self.uncompressedPublicKey(); 133 const x_b64 = try jwt.base64UrlEncode(allocator, uncompressed[1..33]); 134 defer allocator.free(x_b64); 135 const y_b64 = try jwt.base64UrlEncode(allocator, uncompressed[33..65]); 136 defer allocator.free(y_b64); 137 138 const crv = switch (self.key_type) { 139 .p256 => "P-256", 140 .secp256k1 => "secp256k1", 141 }; 142 143 // RFC 7638: required members in lexicographic order 144 const canonical = try std.fmt.allocPrint(allocator, 145 \\{{"crv":"{s}","kty":"EC","x":"{s}","y":"{s}"}} 146 , .{ crv, x_b64, y_b64 }); 147 defer allocator.free(canonical); 148 149 var hash: [32]u8 = undefined; 150 crypto.hash.sha2.Sha256.hash(canonical, &hash, .{}); 151 return jwt.base64UrlEncode(allocator, &hash); 152 } 153}; 154 155// === tests === 156 157test "keypair secp256k1 sign and verify round-trip" { 158 const alloc = std.testing.allocator; 159 160 const kp = try Keypair.fromSecretKey(.secp256k1, .{ 161 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 162 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 163 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 164 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 165 }); 166 167 const message = "keypair round-trip test"; 168 const sig = try kp.sign(message); 169 170 // verify via did:key 171 const did_str = try kp.did(alloc); 172 defer alloc.free(did_str); 173 174 try multicodec.verifyDidKeySignature(alloc, did_str, message, &sig.bytes); 175} 176 177test "keypair p256 sign and verify round-trip" { 178 const alloc = std.testing.allocator; 179 180 const kp = try Keypair.fromSecretKey(.p256, .{ 181 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 182 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 183 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 184 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 185 }); 186 187 const message = "keypair p256 round-trip"; 188 const sig = try kp.sign(message); 189 190 const did_str = try kp.did(alloc); 191 defer alloc.free(did_str); 192 193 try multicodec.verifyDidKeySignature(alloc, did_str, message, &sig.bytes); 194} 195 196test "keypair did:key format is correct" { 197 const alloc = std.testing.allocator; 198 199 const kp = try Keypair.fromSecretKey(.secp256k1, .{ 200 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 201 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 202 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 203 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 204 }); 205 206 const did_str = try kp.did(alloc); 207 defer alloc.free(did_str); 208 209 // must start with did:key:z (base58btc multibase prefix) 210 try std.testing.expect(std.mem.startsWith(u8, did_str, "did:key:z")); 211 212 // must round-trip back to the same public key 213 const parsed = try multicodec.parseDidKey(alloc, did_str); 214 defer alloc.free(parsed.raw); 215 216 const pk = try kp.publicKey(); 217 try std.testing.expectEqual(multicodec.KeyType.secp256k1, parsed.key_type); 218 try std.testing.expectEqualSlices(u8, &pk, parsed.raw); 219} 220 221test "keypair algorithm matches key type" { 222 const secp = try Keypair.fromSecretKey(.secp256k1, .{0x01} ** 32); 223 try std.testing.expectEqual(jwt.Algorithm.ES256K, secp.algorithm()); 224 225 const p256 = try Keypair.fromSecretKey(.p256, .{0x21} ** 32); 226 try std.testing.expectEqual(jwt.Algorithm.ES256, p256.algorithm()); 227} 228 229test "keypair rejects invalid secret key" { 230 // all-zeros is not a valid scalar for either curve 231 try std.testing.expectError(error.InvalidSecretKey, Keypair.fromSecretKey(.secp256k1, .{0x00} ** 32)); 232 try std.testing.expectError(error.InvalidSecretKey, Keypair.fromSecretKey(.p256, .{0x00} ** 32)); 233} 234 235test "keypair jwk p256 round-trip" { 236 const alloc = std.testing.allocator; 237 const kp = try Keypair.fromSecretKey(.p256, .{ 238 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 239 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 240 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 241 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 242 }); 243 244 const jwk_json = try kp.jwk(alloc); 245 defer alloc.free(jwk_json); 246 247 const parsed = try std.json.parseFromSlice(std.json.Value, alloc, jwk_json, .{}); 248 defer parsed.deinit(); 249 250 const obj = parsed.value.object; 251 try std.testing.expectEqualStrings("EC", obj.get("kty").?.string); 252 try std.testing.expectEqualStrings("P-256", obj.get("crv").?.string); 253 try std.testing.expectEqualStrings("ES256", obj.get("alg").?.string); 254 try std.testing.expectEqualStrings("sig", obj.get("use").?.string); 255 try std.testing.expect(obj.get("x") != null); 256 try std.testing.expect(obj.get("y") != null); 257 try std.testing.expect(obj.get("kid") != null); 258} 259 260test "keypair jwk secp256k1 round-trip" { 261 const alloc = std.testing.allocator; 262 const kp = try Keypair.fromSecretKey(.secp256k1, .{ 263 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 264 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 265 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 266 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 267 }); 268 269 const jwk_json = try kp.jwk(alloc); 270 defer alloc.free(jwk_json); 271 272 const parsed = try std.json.parseFromSlice(std.json.Value, alloc, jwk_json, .{}); 273 defer parsed.deinit(); 274 275 const obj = parsed.value.object; 276 try std.testing.expectEqualStrings("EC", obj.get("kty").?.string); 277 try std.testing.expectEqualStrings("secp256k1", obj.get("crv").?.string); 278 try std.testing.expectEqualStrings("ES256K", obj.get("alg").?.string); 279 try std.testing.expectEqualStrings("sig", obj.get("use").?.string); 280} 281 282test "keypair jwk thumbprint matches kid" { 283 const alloc = std.testing.allocator; 284 const kp = try Keypair.fromSecretKey(.p256, .{ 285 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 286 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 287 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 288 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 289 }); 290 291 // get thumbprint directly 292 const thumbprint = try kp.jwkThumbprint(alloc); 293 defer alloc.free(thumbprint); 294 295 // get kid from JWK 296 const jwk_json = try kp.jwk(alloc); 297 defer alloc.free(jwk_json); 298 299 const parsed = try std.json.parseFromSlice(std.json.Value, alloc, jwk_json, .{}); 300 defer parsed.deinit(); 301 302 const kid = parsed.value.object.get("kid").?.string; 303 try std.testing.expectEqualStrings(thumbprint, kid); 304} 305 306test "keypair cross-verify: sign with keypair, verify with jwt.verify" { 307 // sign with Keypair, verify through the JWT multibase path (existing code) 308 const alloc = std.testing.allocator; 309 const multibase = @import("multibase.zig"); 310 311 const sk_bytes = [_]u8{ 312 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 313 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 314 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 315 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 316 }; 317 318 const kp = try Keypair.fromSecretKey(.secp256k1, sk_bytes); 319 const message = "cross-verify test"; 320 const sig = try kp.sign(message); 321 322 // get the multibase-encoded key (as it would appear in a DID document) 323 const pk = try kp.publicKey(); 324 const mc_bytes = try multicodec.encodePublicKey(alloc, .secp256k1, &pk); 325 defer alloc.free(mc_bytes); 326 const multibase_key = try multibase.encode(alloc, .base58btc, mc_bytes); 327 defer alloc.free(multibase_key); 328 329 // verify through the old path 330 try jwt.verifySecp256k1(message, &sig.bytes, &pk); 331}