atproto utils for zig zat.dev
atproto sdk zig
at main 6.3 kB view raw
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 8const std = @import("std"); 9 10/// multibase encoding types 11pub 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 24pub 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) 35pub 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 130test "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 143test "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 155test "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 166test "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}