diff --git a/src/internal/crypto/multibase.zig b/src/internal/crypto/multibase.zig index 5174cd1..c85e1ee 100644 --- a/src/internal/crypto/multibase.zig +++ b/src/internal/crypto/multibase.zig @@ -201,30 +201,41 @@ pub const base32lower = struct { const out_len = (input.len * 8 + 4) / 5; // ceil(bits / 5) const result = try allocator.alloc(u8, 1 + out_len); result[0] = 'b'; // multibase prefix + const pos = 1 + encodeInto(result[1..], input); + return result[0..pos]; + } + + /// encode input into out (no multibase prefix), 5-byte chunks at a time. + /// out must have room for ceil(input.len * 8 / 5) bytes; returns bytes written. + pub fn encodeInto(out: []u8, input: []const u8) usize { + var pos: usize = 0; + var i: usize = 0; + while (i + 5 <= input.len) : (i += 5) { + const chunk = input[i..][0..5]; + const v = (@as(u64, chunk[0]) << 32) | (@as(u64, chunk[1]) << 24) | + (@as(u64, chunk[2]) << 16) | (@as(u64, chunk[3]) << 8) | chunk[4]; + inline for (0..8) |j| { + out[pos + j] = alphabet[@as(u5, @truncate(v >> (35 - 5 * j)))]; + } + pos += 8; + } var bit_buf: u32 = 0; var bits: u5 = 0; - var pos: usize = 1; - - for (input) |byte| { + for (input[i..]) |byte| { bit_buf = (bit_buf << 8) | byte; bits += 8; while (bits >= 5) { bits -= 5; - const idx: u5 = @truncate(bit_buf >> bits); - result[pos] = alphabet[idx]; + out[pos] = alphabet[@as(u5, @truncate(bit_buf >> bits))]; pos += 1; } } - - // remaining bits (left-aligned) if (bits > 0) { - const idx: u5 = @truncate(bit_buf << (@as(u5, 5) - bits)); - result[pos] = alphabet[idx]; + out[pos] = alphabet[@as(u5, @truncate(bit_buf << (@as(u5, 5) - bits)))]; pos += 1; } - - return result[0..pos]; + return pos; } /// decode base32lower string (no multibase prefix) to bytes diff --git a/src/internal/repo/cbor.zig b/src/internal/repo/cbor.zig index c255799..36bee6a 100644 --- a/src/internal/repo/cbor.zig +++ b/src/internal/repo/cbor.zig @@ -13,6 +13,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const multibase = @import("../crypto/multibase.zig"); /// CBOR major types (high 3 bits of initial byte) const MajorType = enum(u3) { @@ -192,14 +193,67 @@ pub const Cid = struct { return self.raw[pos..][0..digest_len]; } + pub const ParseError = error{ + InvalidCid, + UnsupportedCidVersion, + UnsupportedCodec, + UnsupportedHash, + }; + + /// strictly validate raw CID bytes against the atproto-blessed profile: + /// CIDv1, dag-cbor or raw codec, SHA-256 with a 32-byte digest, canonical + /// (minimal) varints, and no trailing bytes. borrows `bytes` — no allocation. + pub fn fromBytes(bytes: []const u8) ParseError!Cid { + if (bytes.len == 0) return error.InvalidCid; + // CIDv0 (bare sha2-256 multihash) is not part of the atproto data model + if (bytes.len == 34 and bytes[0] == 0x12 and bytes[1] == 0x20) return error.UnsupportedCidVersion; + + var pos: usize = 0; + const ver = readUvarintMinimal(bytes, &pos) orelse return error.InvalidCid; + if (ver != 1) return error.UnsupportedCidVersion; + const cod = readUvarintMinimal(bytes, &pos) orelse return error.InvalidCid; + if (cod != Codec.dag_cbor and cod != Codec.raw) return error.UnsupportedCodec; + const hash_code = readUvarintMinimal(bytes, &pos) orelse return error.InvalidCid; + if (hash_code != HashFn.sha2_256) return error.UnsupportedHash; + const digest_len = readUvarintMinimal(bytes, &pos) orelse return error.InvalidCid; + if (digest_len != 32) return error.InvalidCid; + if (bytes.len != pos + 32) return error.InvalidCid; + + return .{ .raw = bytes }; + } + + /// parse a multibase CID string (base32lower, 'b' prefix) with strict + /// validation. the returned Cid's raw slice is owned by the allocator. + pub fn fromString(allocator: Allocator, s: []const u8) !Cid { + if (s.len == 0) return error.InvalidCid; + if (s[0] != 'b') return error.UnsupportedEncoding; + const raw = try multibase.base32lower.decode(allocator, s[1..]); + errdefer allocator.free(raw); + _ = try fromBytes(raw); + return .{ .raw = raw }; + } + + /// format as a multibase string ('b' + base32lower). single allocation. + pub fn toString(self: Cid, allocator: Allocator) ![]u8 { + return multibase.base32lower.encode(allocator, self.raw); + } + /// create a CIDv1 by hashing DAG-CBOR encoded data with SHA-256. /// the returned Cid's raw slice is owned by the allocator. - pub fn forDagCbor(allocator: Allocator, data: []const u8) !Cid { - return create(allocator, 1, Codec.dag_cbor, HashFn.sha2_256, data); + pub fn forDagCbor(allocator: Allocator, data: []const u8) Allocator.Error!Cid { + return createSha256(allocator, 1, Codec.dag_cbor, data); } /// create a CIDv1 with the given codec by hashing data with SHA-256. + /// hash_fn_code must be HashFn.sha2_256 — this function only computes + /// SHA-256 digests, and writing any other code would produce a CID whose + /// header lies about its digest. pub fn create(allocator: Allocator, ver: u64, cod: u64, hash_fn_code: u64, data: []const u8) !Cid { + if (hash_fn_code != HashFn.sha2_256) return error.UnsupportedHash; + return createSha256(allocator, ver, cod, data); + } + + fn createSha256(allocator: Allocator, ver: u64, cod: u64, data: []const u8) Allocator.Error!Cid { const Sha256 = std.crypto.hash.sha2.Sha256; var hash: [Sha256.digest_length]u8 = undefined; Sha256.hash(data, &hash, .{}); @@ -210,7 +264,7 @@ pub const Cid = struct { var w: std.Io.Writer = .fixed(&buf); writeUvarint(&w, ver) catch unreachable; writeUvarint(&w, cod) catch unreachable; - writeUvarint(&w, hash_fn_code) catch unreachable; + writeUvarint(&w, HashFn.sha2_256) catch unreachable; writeUvarint(&w, Sha256.digest_length) catch unreachable; w.writeAll(&hash) catch unreachable; @@ -407,6 +461,29 @@ pub fn parseCid(raw: []const u8) Cid { /// read an unsigned varint (LEB128). rejects varints longer than 10 bytes /// and rejects overflow (10th byte must have value <= 1). +/// read an unsigned varint that must be canonically (minimally) encoded, as +/// required for the components of a CID. a multi-byte encoding whose final +/// group is zero — i.e. the value could have fit in fewer bytes — is rejected. +/// length is capped at 9 bytes (63 bits) per the multiformats unsigned-varint spec. +pub fn readUvarintMinimal(data: []const u8, pos: *usize) ?u64 { + var result: u64 = 0; + var shift: u6 = 0; + for (0..9) |i| { + if (pos.* >= data.len) return null; + const byte = data[pos.*]; + pos.* += 1; + if (byte & 0x80 == 0) { + if (i > 0 and byte == 0) return null; // non-minimal encoding + result |= @as(u64, byte) << shift; + return result; + } + if (i == 8) return null; // too long + result |= @as(u64, byte & 0x7f) << shift; + shift += 7; + } + return null; +} + pub fn readUvarint(data: []const u8, pos: *usize) ?u64 { var result: u64 = 0; var shift: u7 = 0; diff --git a/src/internal/repo/cbor_test.zig b/src/internal/repo/cbor_test.zig index a311ab7..1d4b5c6 100644 --- a/src/internal/repo/cbor_test.zig +++ b/src/internal/repo/cbor_test.zig @@ -1283,3 +1283,103 @@ test "round-trip encode min i64" { const decoded = try cbor.decodeAll(alloc, encoded); try std.testing.expectEqual(min_i64, decoded.negative); } + +// === strict CID parsing (atproto-blessed profile) === + +test "Cid.fromString round-trips a known CID" { + const a = std.testing.allocator; + const s = "bafyreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm"; + const cid = try Cid.fromString(a, s); + defer a.free(cid.raw); + const out = try cid.toString(a); + defer a.free(out); + try std.testing.expectEqualStrings(s, out); + try std.testing.expectEqual(@as(?u64, 1), cid.version()); + try std.testing.expectEqual(@as(?u64, cbor.Codec.dag_cbor), cid.codec()); +} + +test "Cid.fromString rejects empty, bad prefix, bad base32" { + const a = std.testing.allocator; + try std.testing.expectError(error.InvalidCid, Cid.fromString(a, "")); + try std.testing.expectError(error.UnsupportedEncoding, Cid.fromString(a, "zafyrei")); + try std.testing.expectError(error.InvalidCharacter, Cid.fromString(a, "b!!!!")); +} + +test "Cid.fromBytes rejects CIDv0" { + var v0: [34]u8 = undefined; + v0[0] = 0x12; + v0[1] = 0x20; + @memset(v0[2..], 0xaa); + try std.testing.expectError(error.UnsupportedCidVersion, Cid.fromBytes(&v0)); +} + +test "Cid.fromBytes rejects wrong version, codec, hash, length, trailing bytes" { + const a = std.testing.allocator; + const valid = try Cid.forDagCbor(a, "hello"); + defer a.free(valid.raw); + _ = try Cid.fromBytes(valid.raw); + + var buf: [40]u8 = undefined; + + // version 2 + @memcpy(buf[0..valid.raw.len], valid.raw); + buf[0] = 0x02; + try std.testing.expectError(error.UnsupportedCidVersion, Cid.fromBytes(buf[0..valid.raw.len])); + + // dag-pb codec + @memcpy(buf[0..valid.raw.len], valid.raw); + buf[1] = 0x70; + try std.testing.expectError(error.UnsupportedCodec, Cid.fromBytes(buf[0..valid.raw.len])); + + // blake3 hash code + @memcpy(buf[0..valid.raw.len], valid.raw); + buf[2] = 0x1e; + try std.testing.expectError(error.UnsupportedHash, Cid.fromBytes(buf[0..valid.raw.len])); + + // digest length 31 + @memcpy(buf[0..valid.raw.len], valid.raw); + buf[3] = 31; + try std.testing.expectError(error.InvalidCid, Cid.fromBytes(buf[0..valid.raw.len])); + + // trailing byte + @memcpy(buf[0..valid.raw.len], valid.raw); + buf[valid.raw.len] = 0x00; + try std.testing.expectError(error.InvalidCid, Cid.fromBytes(buf[0 .. valid.raw.len + 1])); + + // truncated digest + try std.testing.expectError(error.InvalidCid, Cid.fromBytes(valid.raw[0 .. valid.raw.len - 1])); +} + +test "Cid.fromBytes rejects non-minimal varint encoding" { + const a = std.testing.allocator; + const valid = try Cid.forDagCbor(a, "hello"); + defer a.free(valid.raw); + + // re-encode the version varint (1) as overlong 0x81 0x00 + var buf: [64]u8 = undefined; + buf[0] = 0x81; + buf[1] = 0x00; + @memcpy(buf[2 .. 1 + valid.raw.len], valid.raw[1..]); + try std.testing.expectError(error.InvalidCid, Cid.fromBytes(buf[0 .. 1 + valid.raw.len])); +} + +test "Cid.create rejects a hash code it does not compute" { + const a = std.testing.allocator; + try std.testing.expectError( + error.UnsupportedHash, + Cid.create(a, 1, cbor.Codec.dag_cbor, 0x1e, "hello"), + ); + const cid = try Cid.create(a, 1, cbor.Codec.raw, cbor.HashFn.sha2_256, "hello"); + defer a.free(cid.raw); + _ = try Cid.fromBytes(cid.raw); +} + +test "readUvarintMinimal rejects overlong and oversized encodings" { + var pos: usize = 0; + try std.testing.expectEqual(@as(?u64, 1), cbor.readUvarintMinimal(&.{0x01}, &pos)); + pos = 0; + try std.testing.expectEqual(@as(?u64, null), cbor.readUvarintMinimal(&.{ 0x81, 0x00 }, &pos)); + pos = 0; + const too_long = [_]u8{0x80} ** 9 ++ [_]u8{0x01}; + try std.testing.expectEqual(@as(?u64, null), cbor.readUvarintMinimal(&too_long, &pos)); +} diff --git a/src/internal/repo/mst.zig b/src/internal/repo/mst.zig index a40faec..5a60887 100644 --- a/src/internal/repo/mst.zig +++ b/src/internal/repo/mst.zig @@ -14,7 +14,6 @@ const std = @import("std"); const cbor = @import("cbor.zig"); const car = @import("car.zig"); -const multibase = @import("../crypto/multibase.zig"); const Allocator = std.mem.Allocator; /// compute MST tree layer for a key. @@ -82,11 +81,7 @@ fn keyEql(a: []const u8, b: []const u8) bool { /// parse a CID string (base32lower multibase, e.g. "bafyrei...") pub fn parseCidString(allocator: Allocator, s: []const u8) !cbor.Cid { - if (s.len == 0) return error.InvalidCid; - // strip 'b' multibase prefix and decode base32lower - if (s[0] != 'b') return error.UnsupportedEncoding; - const raw = try multibase.base32lower.decode(allocator, s[1..]); - return .{ .raw = raw }; + return cbor.Cid.fromString(allocator, s); } /// content-addressed block reader used by lazy MST loading. diff --git a/src/root.zig b/src/root.zig index a62b3b2..017920d 100644 --- a/src/root.zig +++ b/src/root.zig @@ -36,6 +36,7 @@ pub const oauth = @import("internal/oauth.zig"); // repo pub const mst = @import("internal/repo/mst.zig"); pub const cbor = @import("internal/repo/cbor.zig"); +pub const Cid = cbor.Cid; pub const car = @import("internal/repo/car.zig"); // repo commit layer: build/sign, load, and verify