atproto utils for zig zat.dev
atproto sdk zig
at main 384 lines 13 kB view raw
1//! multibase codec 2//! 3//! encodes and decodes multibase-encoded strings (prefix + encoded data). 4//! supports base58btc (z prefix) and base32lower (b prefix). 5//! 6//! see: https://github.com/multiformats/multibase 7 8const std = @import("std"); 9 10/// multibase encoding types 11pub const Encoding = enum { 12 base58btc, // z prefix 13 base32lower, // b prefix 14 15 pub fn fromPrefix(prefix: u8) ?Encoding { 16 return switch (prefix) { 17 'z' => .base58btc, 18 'b' => .base32lower, 19 else => null, 20 }; 21 } 22}; 23 24/// decode a multibase string, returning the raw bytes 25/// the first character is the encoding prefix 26pub fn decode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { 27 if (input.len == 0) return error.EmptyInput; 28 29 const encoding = Encoding.fromPrefix(input[0]) orelse return error.UnsupportedEncoding; 30 31 return switch (encoding) { 32 .base58btc => try base58btc.decode(allocator, input[1..]), 33 .base32lower => try base32lower.decode(allocator, input[1..]), 34 }; 35} 36 37/// encode raw bytes to a multibase string with the given encoding 38pub fn encode(allocator: std.mem.Allocator, encoding: Encoding, data: []const u8) ![]u8 { 39 return switch (encoding) { 40 .base58btc => try base58btc.encode(allocator, data), 41 .base32lower => try base32lower.encode(allocator, data), 42 }; 43} 44 45/// base58btc codec (bitcoin alphabet) 46pub const base58btc = struct { 47 /// bitcoin base58 alphabet 48 const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; 49 50 /// reverse lookup table 51 const decode_table: [256]i8 = blk: { 52 var table: [256]i8 = .{-1} ** 256; 53 for (alphabet, 0..) |c, i| { 54 table[c] = @intCast(i); 55 } 56 break :blk table; 57 }; 58 59 /// encode bytes to base58btc string with 'z' multibase prefix 60 pub fn encode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { 61 // count leading zero bytes → leading '1's 62 var leading_zeros: usize = 0; 63 for (input) |b| { 64 if (b != 0) break; 65 leading_zeros += 1; 66 } 67 68 if (input.len == 0 or leading_zeros == input.len) { 69 // all zeros (or empty) 70 const result = try allocator.alloc(u8, 1 + leading_zeros); 71 result[0] = 'z'; // multibase prefix 72 @memset(result[1..], '1'); 73 return result; 74 } 75 76 // load bytes into big integer (big-endian) 77 var acc = try std.math.big.int.Managed.init(allocator); 78 defer acc.deinit(); 79 80 for (input) |b| { 81 try acc.shiftLeft(&acc, 8); 82 try acc.addScalar(&acc, b); 83 } 84 85 // repeatedly divide by 58 to extract base58 digits 86 var digits: std.ArrayList(u8) = .{}; 87 defer digits.deinit(allocator); 88 89 var divisor = try std.math.big.int.Managed.initSet(allocator, @as(u64, 58)); 90 defer divisor.deinit(); 91 92 var quotient = try std.math.big.int.Managed.init(allocator); 93 defer quotient.deinit(); 94 95 var remainder = try std.math.big.int.Managed.init(allocator); 96 defer remainder.deinit(); 97 98 while (!acc.toConst().eqlZero()) { 99 try quotient.divFloor(&remainder, &acc, &divisor); 100 const digit: usize = @intCast(remainder.toConst().toInt(u64) catch 0); 101 try digits.append(allocator, alphabet[digit]); 102 try acc.copy(quotient.toConst()); 103 } 104 105 // result: 'z' prefix + leading '1's + reversed digits 106 const total_len = 1 + leading_zeros + digits.items.len; 107 const result = try allocator.alloc(u8, total_len); 108 result[0] = 'z'; // multibase prefix 109 @memset(result[1 .. 1 + leading_zeros], '1'); 110 111 // digits were accumulated LSB-first, reverse into result 112 const digit_slice = result[1 + leading_zeros ..]; 113 for (digits.items, 0..) |d, i| { 114 digit_slice[digits.items.len - 1 - i] = d; 115 } 116 117 return result; 118 } 119 120 /// decode base58btc string to bytes 121 pub fn decode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { 122 if (input.len == 0) return allocator.alloc(u8, 0); 123 124 // count leading zeros (1s in base58) 125 var leading_zeros: usize = 0; 126 for (input) |c| { 127 if (c != '1') break; 128 leading_zeros += 1; 129 } 130 131 // decode using big integer arithmetic 132 var acc = try std.math.big.int.Managed.init(allocator); 133 defer acc.deinit(); 134 135 var multiplier = try std.math.big.int.Managed.initSet(allocator, @as(u64, 58)); 136 defer multiplier.deinit(); 137 138 var temp = try std.math.big.int.Managed.init(allocator); 139 defer temp.deinit(); 140 141 for (input) |c| { 142 const digit = decode_table[c]; 143 if (digit < 0) return error.InvalidCharacter; 144 145 try temp.mul(&acc, &multiplier); 146 try acc.copy(temp.toConst()); 147 try acc.addScalar(&acc, @as(u8, @intCast(digit))); 148 } 149 150 // convert big int to bytes (big-endian) 151 const limbs = acc.toConst().limbs; 152 const limb_count = acc.len(); 153 154 var byte_count: usize = 0; 155 if (limb_count > 0 and !acc.toConst().eqlZero()) { 156 byte_count = (acc.toConst().bitCountAbs() + 7) / 8; 157 } 158 159 const result = try allocator.alloc(u8, leading_zeros + byte_count); 160 @memset(result[0..leading_zeros], 0); 161 162 // convert limbs to big-endian bytes 163 if (byte_count > 0) { 164 const output_slice = result[leading_zeros..]; 165 var pos: usize = byte_count; 166 for (limbs[0..limb_count]) |limb| { 167 const limb_bytes = @sizeOf(@TypeOf(limb)); 168 var i: usize = 0; 169 while (i < limb_bytes and pos > 0) : (i += 1) { 170 pos -= 1; 171 output_slice[pos] = @truncate(limb >> @intCast(i * 8)); 172 } 173 } 174 } 175 176 return result; 177 } 178}; 179 180/// base32lower codec (RFC 4648, lowercase, no padding) 181pub const base32lower = struct { 182 const alphabet = "abcdefghijklmnopqrstuvwxyz234567"; 183 184 const decode_table: [256]i8 = blk: { 185 var table: [256]i8 = .{-1} ** 256; 186 for (alphabet, 0..) |c, i| { 187 table[c] = @intCast(i); 188 } 189 break :blk table; 190 }; 191 192 /// encode bytes to base32lower string with 'b' multibase prefix 193 pub fn encode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { 194 if (input.len == 0) { 195 const result = try allocator.alloc(u8, 1); 196 result[0] = 'b'; 197 return result; 198 } 199 200 // base32: 5 bytes → 8 chars 201 const out_len = (input.len * 8 + 4) / 5; // ceil(bits / 5) 202 const result = try allocator.alloc(u8, 1 + out_len); 203 result[0] = 'b'; // multibase prefix 204 205 var bit_buf: u32 = 0; 206 var bits: u5 = 0; 207 var pos: usize = 1; 208 209 for (input) |byte| { 210 bit_buf = (bit_buf << 8) | byte; 211 bits += 8; 212 while (bits >= 5) { 213 bits -= 5; 214 const idx: u5 = @truncate(bit_buf >> bits); 215 result[pos] = alphabet[idx]; 216 pos += 1; 217 } 218 } 219 220 // remaining bits (left-aligned) 221 if (bits > 0) { 222 const idx: u5 = @truncate(bit_buf << (@as(u5, 5) - bits)); 223 result[pos] = alphabet[idx]; 224 pos += 1; 225 } 226 227 return result[0..pos]; 228 } 229 230 /// decode base32lower string (no multibase prefix) to bytes 231 pub fn decode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { 232 if (input.len == 0) return allocator.alloc(u8, 0); 233 234 const out_len = input.len * 5 / 8; 235 const result = try allocator.alloc(u8, out_len); 236 errdefer allocator.free(result); 237 238 var bit_buf: u32 = 0; 239 var bits: u4 = 0; 240 var pos: usize = 0; 241 242 for (input) |c| { 243 if (c == '=') break; // stop at padding 244 const digit = decode_table[c]; 245 if (digit < 0) return error.InvalidCharacter; 246 247 bit_buf = (bit_buf << 5) | @as(u32, @intCast(digit)); 248 bits += 5; 249 if (bits >= 8) { 250 bits -= 8; 251 result[pos] = @truncate(bit_buf >> bits); 252 pos += 1; 253 } 254 } 255 256 return allocator.realloc(result, pos); 257 } 258}; 259 260// === tests === 261 262test "base58btc decode" { 263 const alloc = std.testing.allocator; 264 265 // "abc" in base58btc 266 // "abc" = 0x616263 = 6382179 267 // expected base58btc: "ZiCa" (verify with external tool) 268 { 269 const decoded = try base58btc.decode(alloc, "ZiCa"); 270 defer alloc.free(decoded); 271 try std.testing.expectEqualSlices(u8, "abc", decoded); 272 } 273} 274 275test "base58btc decode with leading zeros" { 276 const alloc = std.testing.allocator; 277 278 // leading 1s map to leading zero bytes 279 { 280 const decoded = try base58btc.decode(alloc, "111"); 281 defer alloc.free(decoded); 282 try std.testing.expectEqual(@as(usize, 3), decoded.len); 283 try std.testing.expectEqualSlices(u8, &[_]u8{ 0, 0, 0 }, decoded); 284 } 285} 286 287test "multibase decode base58btc" { 288 const alloc = std.testing.allocator; 289 290 // z prefix = base58btc 291 { 292 const decoded = try decode(alloc, "zZiCa"); 293 defer alloc.free(decoded); 294 try std.testing.expectEqualSlices(u8, "abc", decoded); 295 } 296} 297 298test "base58btc decode real multibase key - secp256k1" { 299 const alloc = std.testing.allocator; 300 const multicodec = @import("multicodec.zig"); 301 302 // from a real DID document: zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF 303 // this is a compressed secp256k1 public key with multicodec prefix 304 const key = "zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF"; 305 const decoded = try decode(alloc, key); 306 defer alloc.free(decoded); 307 308 // should decode to 35 bytes: 2-byte multicodec prefix (0xe7 0x01 varint) + 33-byte compressed key 309 try std.testing.expectEqual(@as(usize, 35), decoded.len); 310 311 // first two bytes should be secp256k1-pub multicodec prefix (0xe7 0x01 varint for 231) 312 try std.testing.expectEqual(@as(u8, 0xe7), decoded[0]); 313 try std.testing.expectEqual(@as(u8, 0x01), decoded[1]); 314 315 // parse with multicodec 316 const parsed = try multicodec.parsePublicKey(decoded); 317 try std.testing.expectEqual(multicodec.KeyType.secp256k1, parsed.key_type); 318 try std.testing.expectEqual(@as(usize, 33), parsed.raw.len); 319 320 // compressed point prefix should be 0x02 or 0x03 321 try std.testing.expect(parsed.raw[0] == 0x02 or parsed.raw[0] == 0x03); 322} 323 324test "base58btc encode-decode round-trip" { 325 const alloc = std.testing.allocator; 326 327 { 328 const original = "abc"; 329 const encoded = try base58btc.encode(alloc, original); 330 defer alloc.free(encoded); 331 // should have 'z' prefix 332 try std.testing.expectEqual(@as(u8, 'z'), encoded[0]); 333 334 const decoded = try decode(alloc, encoded); 335 defer alloc.free(decoded); 336 try std.testing.expectEqualSlices(u8, original, decoded); 337 } 338 339 // round-trip with leading zeros 340 { 341 const original = &[_]u8{ 0, 0, 0x01 }; 342 const encoded = try base58btc.encode(alloc, original); 343 defer alloc.free(encoded); 344 const decoded = try decode(alloc, encoded); 345 defer alloc.free(decoded); 346 try std.testing.expectEqualSlices(u8, original, decoded); 347 } 348} 349 350test "base32lower encode-decode round-trip" { 351 const alloc = std.testing.allocator; 352 353 { 354 const original = "hello"; 355 const encoded = try base32lower.encode(alloc, original); 356 defer alloc.free(encoded); 357 try std.testing.expectEqual(@as(u8, 'b'), encoded[0]); 358 359 const decoded = try decode(alloc, encoded); 360 defer alloc.free(decoded); 361 try std.testing.expectEqualSlices(u8, original, decoded); 362 } 363 364 // empty 365 { 366 const encoded = try base32lower.encode(alloc, ""); 367 defer alloc.free(encoded); 368 try std.testing.expectEqualStrings("b", encoded); 369 } 370} 371 372test "base32lower decode bafyrei prefix" { 373 const alloc = std.testing.allocator; 374 // CIDv1 dag-cbor sha2-256 always starts with "bafyrei" in base32lower 375 // "bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454" 376 const input = "afyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454"; 377 const decoded = try base32lower.decode(alloc, input); 378 defer alloc.free(decoded); 379 // CIDv1: version=1(0x01), codec=dag-cbor(0x71), hash=sha2-256(0x12), len=32(0x20) 380 try std.testing.expectEqual(@as(u8, 0x01), decoded[0]); 381 try std.testing.expectEqual(@as(u8, 0x71), decoded[1]); 382 try std.testing.expectEqual(@as(u8, 0x12), decoded[2]); 383 try std.testing.expectEqual(@as(u8, 0x20), decoded[3]); 384}