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 interop tests for did:key derivation and data model fixtures

wire up 3 new fixture sets from atproto-interop-tests:
- did:key derivation K256 (5 vectors) and P256 (1 vector)
- data model JSON↔DAG-CBOR round-trip (3 vectors)

also fix keypair zero-scalar validation that surfaced when
the new import path pulled keypair tests into a second compilation unit.

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

+193
+4
build.zig
··· 38 38 .{ "aturi_syntax_invalid", "syntax/aturi_syntax_invalid.txt" }, 39 39 // crypto fixtures 40 40 .{ "signature_fixtures", "crypto/signature-fixtures.json" }, 41 + .{ "w3c_didkey_K256", "crypto/w3c_didkey_K256.json" }, 42 + .{ "w3c_didkey_P256", "crypto/w3c_didkey_P256.json" }, 43 + // data model fixtures 44 + .{ "data_model_fixtures", "data-model/data-model-fixtures.json" }, 41 45 // mst fixtures 42 46 .{ "mst_key_heights", "mst/key_heights.json" }, 43 47 .{ "common_prefix", "mst/common_prefix.json" },
+2
src/internal/crypto/keypair.zig
··· 18 18 /// create a keypair from raw secret key bytes (32 bytes). 19 19 /// validates the key is on the curve. 20 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; 21 23 // validate by attempting to construct the stdlib key 22 24 switch (key_type) { 23 25 .secp256k1 => {
+187
src/internal/testing/interop_tests.zig
··· 14 14 15 15 // crypto 16 16 const jwt = @import("../crypto/jwt.zig"); 17 + const Keypair = @import("../crypto/keypair.zig").Keypair; 17 18 const multibase = @import("../crypto/multibase.zig"); 18 19 const multicodec = @import("../crypto/multicodec.zig"); 19 20 ··· 231 232 232 233 // should have tested all 6 fixtures 233 234 try std.testing.expect(tested == fixtures.len); 235 + } 236 + 237 + // === tier 2b: did:key derivation === 238 + 239 + test "interop: did:key derivation K256" { 240 + const allocator = std.testing.allocator; 241 + 242 + const fixture_json = @embedFile("w3c_didkey_K256"); 243 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{}); 244 + defer parsed.deinit(); 245 + 246 + const fixtures = parsed.value.array.items; 247 + var tested: usize = 0; 248 + 249 + for (fixtures) |fixture| { 250 + const obj = fixture.object; 251 + const hex_str = obj.get("privateKeyBytesHex").?.string; 252 + const expected_did = obj.get("publicDidKey").?.string; 253 + 254 + var sk_bytes: [32]u8 = undefined; 255 + _ = std.fmt.hexToBytes(&sk_bytes, hex_str) catch return error.InvalidHex; 256 + 257 + const kp = try Keypair.fromSecretKey(.secp256k1, sk_bytes); 258 + const actual_did = try kp.did(allocator); 259 + defer allocator.free(actual_did); 260 + 261 + if (!std.mem.eql(u8, actual_did, expected_did)) { 262 + std.debug.print("FAIL K256: expected {s}, got {s}\n", .{ expected_did, actual_did }); 263 + return error.DidKeyMismatch; 264 + } 265 + tested += 1; 266 + } 267 + 268 + try std.testing.expectEqual(@as(usize, 5), tested); 269 + } 270 + 271 + test "interop: did:key derivation P256" { 272 + const allocator = std.testing.allocator; 273 + 274 + const fixture_json = @embedFile("w3c_didkey_P256"); 275 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{}); 276 + defer parsed.deinit(); 277 + 278 + const fixtures = parsed.value.array.items; 279 + var tested: usize = 0; 280 + 281 + for (fixtures) |fixture| { 282 + const obj = fixture.object; 283 + const b58_str = obj.get("privateKeyBytesBase58").?.string; 284 + const expected_did = obj.get("publicDidKey").?.string; 285 + 286 + // raw base58 (no multibase 'z' prefix) 287 + const decoded = try multibase.base58btc.decode(allocator, b58_str); 288 + defer allocator.free(decoded); 289 + if (decoded.len < 32) return error.KeyTooShort; 290 + 291 + const kp = try Keypair.fromSecretKey(.p256, decoded[0..32].*); 292 + const actual_did = try kp.did(allocator); 293 + defer allocator.free(actual_did); 294 + 295 + if (!std.mem.eql(u8, actual_did, expected_did)) { 296 + std.debug.print("FAIL P256: expected {s}, got {s}\n", .{ expected_did, actual_did }); 297 + return error.DidKeyMismatch; 298 + } 299 + tested += 1; 300 + } 301 + 302 + try std.testing.expectEqual(@as(usize, 1), tested); 303 + } 304 + 305 + // === tier 2c: data model round-trip === 306 + 307 + /// convert AT Protocol JSON to CBOR value 308 + /// handles $link (CID) and $bytes (byte string) special types 309 + fn jsonToCbor(allocator: std.mem.Allocator, json: std.json.Value) !cbor.Value { 310 + switch (json) { 311 + .object => |obj| { 312 + // check for $link → CID 313 + if (obj.get("$link")) |link_val| { 314 + const link_str = switch (link_val) { 315 + .string => |s| s, 316 + else => return error.InvalidLink, 317 + }; 318 + // bafyrei... is base32lower multibase (without 'b' prefix in the $link value, 319 + // but CID strings in AT Protocol use the full multibase-prefixed form) 320 + // actually the fixture CIDs start with "bafyrei" which is base32lower with 'b' prefix 321 + const raw = try multibase.base32lower.decode(allocator, link_str[1..]); 322 + return .{ .cid = .{ .raw = raw } }; 323 + } 324 + // check for $bytes → byte string 325 + if (obj.get("$bytes")) |bytes_val| { 326 + const b64_str = switch (bytes_val) { 327 + .string => |s| s, 328 + else => return error.InvalidBytes, 329 + }; 330 + const decoded = try base64StdDecode(allocator, b64_str); 331 + return .{ .bytes = decoded }; 332 + } 333 + // regular object → map 334 + const entries = try allocator.alloc(cbor.Value.MapEntry, obj.count()); 335 + var i: usize = 0; 336 + var it = obj.iterator(); 337 + while (it.next()) |kv| { 338 + entries[i] = .{ 339 + .key = kv.key_ptr.*, 340 + .value = try jsonToCbor(allocator, kv.value_ptr.*), 341 + }; 342 + i += 1; 343 + } 344 + return .{ .map = entries }; 345 + }, 346 + .array => |arr| { 347 + const items = try allocator.alloc(cbor.Value, arr.items.len); 348 + for (arr.items, 0..) |item, i| { 349 + items[i] = try jsonToCbor(allocator, item); 350 + } 351 + return .{ .array = items }; 352 + }, 353 + .string => |s| return .{ .text = s }, 354 + .integer => |n| { 355 + if (n >= 0) return .{ .unsigned = @intCast(n) }; 356 + return .{ .negative = n }; 357 + }, 358 + .float => |f| { 359 + // DAG-CBOR has no floats; coerce integer-valued floats 360 + const int_val: i64 = @intFromFloat(f); 361 + if (@as(f64, @floatFromInt(int_val)) != f) return error.UnsupportedFloat; 362 + if (int_val >= 0) return .{ .unsigned = @intCast(int_val) }; 363 + return .{ .negative = int_val }; 364 + }, 365 + .null => return .null, 366 + .bool => |b| return .{ .boolean = b }, 367 + .number_string => return error.UnsupportedNumberString, 368 + } 369 + } 370 + 371 + test "interop: data model fixtures" { 372 + const allocator = std.testing.allocator; 373 + 374 + const fixture_json = @embedFile("data_model_fixtures"); 375 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, fixture_json, .{}); 376 + defer parsed.deinit(); 377 + 378 + const fixtures = parsed.value.array.items; 379 + var tested: usize = 0; 380 + 381 + for (fixtures) |fixture| { 382 + var arena = std.heap.ArenaAllocator.init(allocator); 383 + defer arena.deinit(); 384 + const a = arena.allocator(); 385 + 386 + const obj = fixture.object; 387 + const json_val = obj.get("json").?; 388 + const expected_cbor_b64 = obj.get("cbor_base64").?.string; 389 + const expected_cid_str = obj.get("cid").?.string; 390 + 391 + // convert JSON → CBOR value → encoded bytes 392 + const cbor_val = try jsonToCbor(a, json_val); 393 + const encoded = try cbor.encodeAlloc(a, cbor_val); 394 + 395 + // compare encoded bytes with expected 396 + const expected_bytes = try base64StdDecode(a, expected_cbor_b64); 397 + if (!std.mem.eql(u8, encoded, expected_bytes)) { 398 + std.debug.print("FAIL data model: CBOR encoding mismatch for fixture {d}\n", .{tested}); 399 + std.debug.print(" expected ({d} bytes): ", .{expected_bytes.len}); 400 + for (expected_bytes) |b| std.debug.print("{x:0>2}", .{b}); 401 + std.debug.print("\n actual ({d} bytes): ", .{encoded.len}); 402 + for (encoded) |b| std.debug.print("{x:0>2}", .{b}); 403 + std.debug.print("\n", .{}); 404 + return error.CborEncodingMismatch; 405 + } 406 + 407 + // compute CID and compare 408 + const cid = try cbor.Cid.forDagCbor(a, encoded); 409 + // format as base32lower multibase string: "b" + base32lower(raw) 410 + const cid_str = try multibase.base32lower.encode(a, cid.raw); 411 + if (!std.mem.eql(u8, cid_str, expected_cid_str)) { 412 + std.debug.print("FAIL data model: CID mismatch for fixture {d}\n", .{tested}); 413 + std.debug.print(" expected: {s}\n actual: {s}\n", .{ expected_cid_str, cid_str }); 414 + return error.CidMismatch; 415 + } 416 + 417 + tested += 1; 418 + } 419 + 420 + try std.testing.expectEqual(@as(usize, 3), tested); 234 421 } 235 422 236 423 // === tier 3: MST ===