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 jwt verification for service auth

- jwt.zig: parse and verify ES256/ES256K tokens
- multibase.zig: base58btc decoding for DID document keys
- multicodec.zig: parse secp256k1/p256 key types from prefix

follows atproto.com/specs/xrpc service auth spec

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

zzstoatzz b22c0686 db759a26

+572
+31
README.md
··· 94 94 const post = try zat.json.extractAt(FeedPost, allocator, value, .{"post"}); 95 95 ``` 96 96 97 + ### jwt verification 98 + 99 + verify service auth tokens: 100 + 101 + ```zig 102 + var jwt = try zat.Jwt.parse(allocator, token_string); 103 + defer jwt.deinit(); 104 + 105 + // check claims 106 + if (jwt.isExpired()) return error.TokenExpired; 107 + if (!std.mem.eql(u8, jwt.payload.aud, expected_audience)) return error.InvalidAudience; 108 + 109 + // verify signature against issuer's public key (from DID document) 110 + try jwt.verify(public_key_multibase); 111 + ``` 112 + 113 + supports ES256 (P-256) and ES256K (secp256k1) signing algorithms. 114 + 115 + ### multibase decoding 116 + 117 + decode public keys from DID documents: 118 + 119 + ```zig 120 + const key_bytes = try zat.multibase.decode(allocator, "zQ3sh..."); 121 + defer allocator.free(key_bytes); 122 + 123 + const parsed = try zat.multicodec.parsePublicKey(key_bytes); 124 + // parsed.key_type: .secp256k1 or .p256 125 + // parsed.raw: 33-byte compressed public key 126 + ``` 127 + 97 128 ## specs 98 129 99 130 validation follows [atproto.com/specs](https://atproto.com/specs/atp).
+255
src/internal/jwt.zig
··· 1 + //! JWT parsing and verification for AT Protocol 2 + //! 3 + //! parses and verifies JWTs used in AT Protocol service auth. 4 + //! supports ES256 (P-256) and ES256K (secp256k1) signing. 5 + //! 6 + //! see: https://atproto.com/specs/xrpc#service-auth 7 + 8 + const std = @import("std"); 9 + const crypto = std.crypto; 10 + const json = @import("json.zig"); 11 + const multibase = @import("multibase.zig"); 12 + const multicodec = @import("multicodec.zig"); 13 + 14 + /// JWT signing algorithm 15 + pub const Algorithm = enum { 16 + ES256, // P-256 / secp256r1 17 + ES256K, // secp256k1 18 + 19 + pub fn fromString(s: []const u8) ?Algorithm { 20 + if (std.mem.eql(u8, s, "ES256")) return .ES256; 21 + if (std.mem.eql(u8, s, "ES256K")) return .ES256K; 22 + return null; 23 + } 24 + }; 25 + 26 + /// parsed JWT header 27 + pub const Header = struct { 28 + alg: Algorithm, 29 + typ: []const u8, 30 + }; 31 + 32 + /// parsed JWT payload (AT Protocol service auth claims) 33 + pub const Payload = struct { 34 + /// issuer DID (account making the request) 35 + iss: []const u8, 36 + /// audience DID (service receiving the request) 37 + aud: []const u8, 38 + /// expiration timestamp (unix seconds) 39 + exp: i64, 40 + /// issued-at timestamp (unix seconds) 41 + iat: ?i64 = null, 42 + /// unique nonce for replay prevention 43 + jti: ?[]const u8 = null, 44 + /// lexicon method (optional, may become required) 45 + lxm: ?[]const u8 = null, 46 + }; 47 + 48 + /// parsed JWT with raw components 49 + pub const Jwt = struct { 50 + allocator: std.mem.Allocator, 51 + 52 + /// decoded header 53 + header: Header, 54 + /// decoded payload 55 + payload: Payload, 56 + /// raw signature bytes (r || s, 64 bytes) 57 + signature: []u8, 58 + /// the signed portion (header.payload) for verification 59 + signed_input: []const u8, 60 + /// original token for reference 61 + raw_token: []const u8, 62 + 63 + /// parse a JWT token string 64 + pub fn parse(allocator: std.mem.Allocator, token: []const u8) !Jwt { 65 + // split on dots: header.payload.signature 66 + var parts: [3][]const u8 = undefined; 67 + var part_idx: usize = 0; 68 + var it = std.mem.splitScalar(u8, token, '.'); 69 + 70 + while (it.next()) |part| { 71 + if (part_idx >= 3) return error.InvalidJwt; 72 + parts[part_idx] = part; 73 + part_idx += 1; 74 + } 75 + 76 + if (part_idx != 3) return error.InvalidJwt; 77 + 78 + const header_b64 = parts[0]; 79 + const payload_b64 = parts[1]; 80 + const sig_b64 = parts[2]; 81 + 82 + // find signed input (everything before last dot) 83 + const last_dot = std.mem.lastIndexOfScalar(u8, token, '.') orelse return error.InvalidJwt; 84 + const signed_input = token[0..last_dot]; 85 + 86 + // decode header 87 + const header_json = try base64UrlDecode(allocator, header_b64); 88 + defer allocator.free(header_json); 89 + 90 + const header = try parseHeader(allocator, header_json); 91 + 92 + // decode payload 93 + const payload_json = try base64UrlDecode(allocator, payload_b64); 94 + defer allocator.free(payload_json); 95 + 96 + const payload = try parsePayload(allocator, payload_json); 97 + 98 + // decode signature 99 + const signature = try base64UrlDecode(allocator, sig_b64); 100 + errdefer allocator.free(signature); 101 + 102 + // JWT signatures should be 64 bytes (r || s) 103 + if (signature.len != 64) { 104 + allocator.free(signature); 105 + return error.InvalidSignatureLength; 106 + } 107 + 108 + return .{ 109 + .allocator = allocator, 110 + .header = header, 111 + .payload = payload, 112 + .signature = signature, 113 + .signed_input = signed_input, 114 + .raw_token = token, 115 + }; 116 + } 117 + 118 + /// verify the JWT signature against a public key 119 + /// public_key should be multibase-encoded (from DID document) 120 + pub fn verify(self: *const Jwt, public_key_multibase: []const u8) !void { 121 + // decode multibase key 122 + const key_bytes = try multibase.decode(self.allocator, public_key_multibase); 123 + defer self.allocator.free(key_bytes); 124 + 125 + // parse multicodec to get key type and raw bytes 126 + const parsed_key = try multicodec.parsePublicKey(key_bytes); 127 + 128 + // verify key type matches algorithm 129 + switch (self.header.alg) { 130 + .ES256K => { 131 + if (parsed_key.key_type != .secp256k1) return error.AlgorithmKeyMismatch; 132 + try verifySecp256k1(self.signed_input, self.signature, parsed_key.raw); 133 + }, 134 + .ES256 => { 135 + if (parsed_key.key_type != .p256) return error.AlgorithmKeyMismatch; 136 + try verifyP256(self.signed_input, self.signature, parsed_key.raw); 137 + }, 138 + } 139 + } 140 + 141 + /// check if the token is expired 142 + pub fn isExpired(self: *const Jwt) bool { 143 + const now = std.time.timestamp(); 144 + return now > self.payload.exp; 145 + } 146 + 147 + /// check if the token is expired with clock skew tolerance (in seconds) 148 + pub fn isExpiredWithSkew(self: *const Jwt, skew_seconds: i64) bool { 149 + const now = std.time.timestamp(); 150 + return now > (self.payload.exp + skew_seconds); 151 + } 152 + 153 + pub fn deinit(self: *Jwt) void { 154 + self.allocator.free(self.signature); 155 + } 156 + }; 157 + 158 + // === internal helpers === 159 + 160 + fn base64UrlDecode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { 161 + const decoder = &std.base64.url_safe_no_pad.Decoder; 162 + const size = try decoder.calcSizeForSlice(input); 163 + const output = try allocator.alloc(u8, size); 164 + errdefer allocator.free(output); 165 + try decoder.decode(output, input); 166 + return output; 167 + } 168 + 169 + fn parseHeader(allocator: std.mem.Allocator, header_json: []const u8) !Header { 170 + _ = allocator; 171 + const parsed = try std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, header_json, .{}); 172 + defer parsed.deinit(); 173 + 174 + const alg_str = json.getString(parsed.value, "alg") orelse return error.MissingAlgorithm; 175 + const alg = Algorithm.fromString(alg_str) orelse return error.UnsupportedAlgorithm; 176 + const typ = json.getString(parsed.value, "typ") orelse "JWT"; 177 + 178 + return .{ 179 + .alg = alg, 180 + .typ = typ, 181 + }; 182 + } 183 + 184 + fn parsePayload(allocator: std.mem.Allocator, payload_json: []const u8) !Payload { 185 + _ = allocator; 186 + const parsed = try std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, payload_json, .{}); 187 + defer parsed.deinit(); 188 + 189 + const iss = json.getString(parsed.value, "iss") orelse return error.MissingIssuer; 190 + const aud = json.getString(parsed.value, "aud") orelse return error.MissingAudience; 191 + const exp = json.getInt(parsed.value, "exp") orelse return error.MissingExpiration; 192 + 193 + return .{ 194 + .iss = iss, 195 + .aud = aud, 196 + .exp = exp, 197 + .iat = json.getInt(parsed.value, "iat"), 198 + .jti = json.getString(parsed.value, "jti"), 199 + .lxm = json.getString(parsed.value, "lxm"), 200 + }; 201 + } 202 + 203 + fn verifySecp256k1(message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void { 204 + const Scheme = crypto.ecdsa.EcdsaSecp256k1Sha256; 205 + 206 + // parse signature (r || s, 64 bytes) 207 + if (sig_bytes.len != 64) return error.InvalidSignature; 208 + const sig = Scheme.Signature.fromBytes(sig_bytes[0..64].*); 209 + 210 + // parse public key from SEC1 compressed format 211 + if (public_key_raw.len != 33) return error.InvalidPublicKey; 212 + const public_key = Scheme.PublicKey.fromSec1(public_key_raw) catch return error.InvalidPublicKey; 213 + 214 + // verify 215 + sig.verify(message, public_key) catch return error.SignatureVerificationFailed; 216 + } 217 + 218 + fn verifyP256(message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void { 219 + const Scheme = crypto.ecdsa.EcdsaP256Sha256; 220 + 221 + // parse signature (r || s, 64 bytes) 222 + if (sig_bytes.len != 64) return error.InvalidSignature; 223 + const sig = Scheme.Signature.fromBytes(sig_bytes[0..64].*); 224 + 225 + // parse public key from SEC1 compressed format 226 + if (public_key_raw.len != 33) return error.InvalidPublicKey; 227 + const public_key = Scheme.PublicKey.fromSec1(public_key_raw) catch return error.InvalidPublicKey; 228 + 229 + // verify 230 + sig.verify(message, public_key) catch return error.SignatureVerificationFailed; 231 + } 232 + 233 + // === tests === 234 + 235 + test "parse jwt structure" { 236 + // a minimal valid JWT structure (signature won't verify, just testing parsing) 237 + // header: {"alg":"ES256K","typ":"JWT"} 238 + // payload: {"iss":"did:plc:test","aud":"did:plc:service","exp":9999999999} 239 + const token = "eyJhbGciOiJFUzI1NksiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJkaWQ6cGxjOnRlc3QiLCJhdWQiOiJkaWQ6cGxjOnNlcnZpY2UiLCJleHAiOjk5OTk5OTk5OTl9.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; 240 + 241 + var jwt = try Jwt.parse(std.testing.allocator, token); 242 + defer jwt.deinit(); 243 + 244 + try std.testing.expectEqual(Algorithm.ES256K, jwt.header.alg); 245 + try std.testing.expectEqualStrings("did:plc:test", jwt.payload.iss); 246 + try std.testing.expectEqualStrings("did:plc:service", jwt.payload.aud); 247 + try std.testing.expectEqual(@as(i64, 9999999999), jwt.payload.exp); 248 + } 249 + 250 + test "reject invalid jwt format" { 251 + // missing parts 252 + try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "onlyonepart")); 253 + try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "two.parts")); 254 + try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "too.many.parts.here")); 255 + }
+190
src/internal/multibase.zig
··· 1 + //! multibase decoder 2 + //! 3 + //! decodes multibase-encoded strings (prefix + encoded data). 4 + //! currently supports base58btc (z prefix) for DID document public keys. 5 + //! 6 + //! see: https://github.com/multiformats/multibase 7 + 8 + const std = @import("std"); 9 + 10 + /// multibase encoding types 11 + pub const Encoding = enum { 12 + base58btc, // z prefix 13 + 14 + pub fn fromPrefix(prefix: u8) ?Encoding { 15 + return switch (prefix) { 16 + 'z' => .base58btc, 17 + else => null, 18 + }; 19 + } 20 + }; 21 + 22 + /// decode a multibase string, returning the raw bytes 23 + /// the first character is the encoding prefix 24 + pub fn decode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { 25 + if (input.len == 0) return error.EmptyInput; 26 + 27 + const encoding = Encoding.fromPrefix(input[0]) orelse return error.UnsupportedEncoding; 28 + 29 + return switch (encoding) { 30 + .base58btc => try base58btc.decode(allocator, input[1..]), 31 + }; 32 + } 33 + 34 + /// base58btc decoder (bitcoin alphabet) 35 + pub const base58btc = struct { 36 + /// bitcoin base58 alphabet 37 + const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; 38 + 39 + /// reverse lookup table 40 + const decode_table: [256]i8 = blk: { 41 + var table: [256]i8 = .{-1} ** 256; 42 + for (alphabet, 0..) |c, i| { 43 + table[c] = @intCast(i); 44 + } 45 + break :blk table; 46 + }; 47 + 48 + /// decode base58btc string to bytes 49 + pub fn decode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { 50 + if (input.len == 0) return allocator.alloc(u8, 0); 51 + 52 + // count leading zeros (1s in base58) 53 + var leading_zeros: usize = 0; 54 + for (input) |c| { 55 + if (c != '1') break; 56 + leading_zeros += 1; 57 + } 58 + 59 + // estimate output size: each base58 char represents ~5.86 bits 60 + // use a simple overestimate: input.len bytes is more than enough 61 + const max_output = input.len; 62 + const result = try allocator.alloc(u8, max_output); 63 + errdefer allocator.free(result); 64 + 65 + // decode using big integer arithmetic 66 + // accumulator = accumulator * 58 + digit 67 + var acc = try std.math.big.int.Managed.init(allocator); 68 + defer acc.deinit(); 69 + 70 + var multiplier = try std.math.big.int.Managed.initSet(allocator, @as(u64, 58)); 71 + defer multiplier.deinit(); 72 + 73 + var temp = try std.math.big.int.Managed.init(allocator); 74 + defer temp.deinit(); 75 + 76 + for (input) |c| { 77 + const digit = decode_table[c]; 78 + if (digit < 0) { 79 + allocator.free(result); 80 + return error.InvalidCharacter; 81 + } 82 + 83 + // acc = acc * 58 + digit 84 + try temp.mul(&acc, &multiplier); 85 + try acc.copy(temp.toConst()); 86 + try acc.addScalar(&acc, @as(u8, @intCast(digit))); 87 + } 88 + 89 + // convert big int to bytes (big-endian for base58) 90 + const limbs = acc.toConst().limbs; 91 + const limb_count = acc.len(); 92 + 93 + // calculate byte size from limbs 94 + var byte_count: usize = 0; 95 + if (limb_count > 0 and !acc.toConst().eqlZero()) { 96 + const bit_count = acc.toConst().bitCountAbs(); 97 + byte_count = (bit_count + 7) / 8; 98 + } 99 + 100 + // write bytes in big-endian order 101 + var output_bytes = try allocator.alloc(u8, leading_zeros + byte_count); 102 + errdefer allocator.free(output_bytes); 103 + 104 + // leading zeros 105 + @memset(output_bytes[0..leading_zeros], 0); 106 + 107 + // convert limbs to big-endian bytes 108 + if (byte_count > 0) { 109 + const output_slice = output_bytes[leading_zeros..]; 110 + 111 + // limbs are in little-endian order, we need big-endian output 112 + var pos: usize = byte_count; 113 + for (limbs[0..limb_count]) |limb| { 114 + const limb_bytes = @sizeOf(@TypeOf(limb)); 115 + var i: usize = 0; 116 + while (i < limb_bytes and pos > 0) : (i += 1) { 117 + pos -= 1; 118 + output_slice[pos] = @truncate(limb >> @intCast(i * 8)); 119 + } 120 + } 121 + } 122 + 123 + allocator.free(result); 124 + return output_bytes; 125 + } 126 + }; 127 + 128 + // === tests === 129 + 130 + test "base58btc decode" { 131 + const alloc = std.testing.allocator; 132 + 133 + // "abc" in base58btc 134 + // "abc" = 0x616263 = 6382179 135 + // expected base58btc: "ZiCa" (verify with external tool) 136 + { 137 + const decoded = try base58btc.decode(alloc, "ZiCa"); 138 + defer alloc.free(decoded); 139 + try std.testing.expectEqualSlices(u8, "abc", decoded); 140 + } 141 + } 142 + 143 + test "base58btc decode with leading zeros" { 144 + const alloc = std.testing.allocator; 145 + 146 + // leading 1s map to leading zero bytes 147 + { 148 + const decoded = try base58btc.decode(alloc, "111"); 149 + defer alloc.free(decoded); 150 + try std.testing.expectEqual(@as(usize, 3), decoded.len); 151 + try std.testing.expectEqualSlices(u8, &[_]u8{ 0, 0, 0 }, decoded); 152 + } 153 + } 154 + 155 + test "multibase decode base58btc" { 156 + const alloc = std.testing.allocator; 157 + 158 + // z prefix = base58btc 159 + { 160 + const decoded = try decode(alloc, "zZiCa"); 161 + defer alloc.free(decoded); 162 + try std.testing.expectEqualSlices(u8, "abc", decoded); 163 + } 164 + } 165 + 166 + test "base58btc decode real multibase key - secp256k1" { 167 + const alloc = std.testing.allocator; 168 + const multicodec = @import("multicodec.zig"); 169 + 170 + // from a real DID document: zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF 171 + // this is a compressed secp256k1 public key with multicodec prefix 172 + const key = "zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF"; 173 + const decoded = try decode(alloc, key); 174 + defer alloc.free(decoded); 175 + 176 + // should decode to 35 bytes: 2-byte multicodec prefix (0xe7 0x01 varint) + 33-byte compressed key 177 + try std.testing.expectEqual(@as(usize, 35), decoded.len); 178 + 179 + // first two bytes should be secp256k1-pub multicodec prefix (0xe7 0x01 varint for 231) 180 + try std.testing.expectEqual(@as(u8, 0xe7), decoded[0]); 181 + try std.testing.expectEqual(@as(u8, 0x01), decoded[1]); 182 + 183 + // parse with multicodec 184 + const parsed = try multicodec.parsePublicKey(decoded); 185 + try std.testing.expectEqual(multicodec.KeyType.secp256k1, parsed.key_type); 186 + try std.testing.expectEqual(@as(usize, 33), parsed.raw.len); 187 + 188 + // compressed point prefix should be 0x02 or 0x03 189 + try std.testing.expect(parsed.raw[0] == 0x02 or parsed.raw[0] == 0x03); 190 + }
+91
src/internal/multicodec.zig
··· 1 + //! multicodec key parsing 2 + //! 3 + //! parses multicodec-prefixed public keys from DID documents. 4 + //! extracts key type and raw key bytes. 5 + //! 6 + //! see: https://github.com/multiformats/multicodec 7 + 8 + const std = @import("std"); 9 + 10 + /// supported key types for AT Protocol 11 + pub const KeyType = enum { 12 + secp256k1, // ES256K - used by most AT Protocol accounts 13 + p256, // ES256 - also supported 14 + }; 15 + 16 + /// parsed public key with type and raw bytes 17 + pub const PublicKey = struct { 18 + key_type: KeyType, 19 + /// raw compressed public key (33 bytes for secp256k1/p256) 20 + raw: []const u8, 21 + }; 22 + 23 + /// multicodec prefixes (unsigned varint encoding) 24 + /// secp256k1-pub: 0xe7 = 231, varint encoded as 0xe7 0x01 (2 bytes) 25 + /// p256-pub: 0x1200 = 4608, varint encoded as 0x80 0x24 (2 bytes) 26 + 27 + /// parse a multicodec-prefixed public key 28 + /// returns the key type and a slice pointing to the raw key bytes 29 + pub fn parsePublicKey(data: []const u8) !PublicKey { 30 + if (data.len < 2) return error.TooShort; 31 + 32 + // check for secp256k1-pub (varint 0xe7 = 231 encoded as 0xe7 0x01) 33 + if (data.len >= 2 and data[0] == 0xe7 and data[1] == 0x01) { 34 + const raw = data[2..]; 35 + if (raw.len != 33) return error.InvalidKeyLength; 36 + return .{ 37 + .key_type = .secp256k1, 38 + .raw = raw, 39 + }; 40 + } 41 + 42 + // check for p256-pub (varint 0x1200 = 4608 encoded as 0x80 0x24) 43 + if (data.len >= 2 and data[0] == 0x80 and data[1] == 0x24) { 44 + const raw = data[2..]; 45 + if (raw.len != 33) return error.InvalidKeyLength; 46 + return .{ 47 + .key_type = .p256, 48 + .raw = raw, 49 + }; 50 + } 51 + 52 + return error.UnsupportedKeyType; 53 + } 54 + 55 + // === tests === 56 + 57 + test "parse secp256k1 key" { 58 + // 0xe7 0x01 prefix (varint) + 33-byte compressed key 59 + var data: [35]u8 = undefined; 60 + data[0] = 0xe7; 61 + data[1] = 0x01; 62 + data[2] = 0x02; // compressed point prefix 63 + @memset(data[3..], 0xaa); 64 + 65 + const key = try parsePublicKey(&data); 66 + try std.testing.expectEqual(KeyType.secp256k1, key.key_type); 67 + try std.testing.expectEqual(@as(usize, 33), key.raw.len); 68 + } 69 + 70 + test "parse p256 key" { 71 + // 0x80 0x24 prefix + 33-byte compressed key 72 + var data: [35]u8 = undefined; 73 + data[0] = 0x80; 74 + data[1] = 0x24; 75 + data[2] = 0x03; // compressed point prefix 76 + @memset(data[3..], 0xbb); 77 + 78 + const key = try parsePublicKey(&data); 79 + try std.testing.expectEqual(KeyType.p256, key.key_type); 80 + try std.testing.expectEqual(@as(usize, 33), key.raw.len); 81 + } 82 + 83 + test "reject unsupported key type" { 84 + const data = [_]u8{ 0xff, 0x02, 0x00 }; 85 + try std.testing.expectError(error.UnsupportedKeyType, parsePublicKey(&data)); 86 + } 87 + 88 + test "reject too short" { 89 + const data = [_]u8{0xe7}; 90 + try std.testing.expectError(error.TooShort, parsePublicKey(&data)); 91 + }
+5
src/root.zig
··· 20 20 21 21 // json helpers 22 22 pub const json = @import("internal/json.zig"); 23 + 24 + // service auth 25 + pub const Jwt = @import("internal/jwt.zig").Jwt; 26 + pub const multibase = @import("internal/multibase.zig"); 27 + pub const multicodec = @import("internal/multicodec.zig");