bsky feeds about music music-atmosphere-feed.plyr.fm/
bsky feed zig
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix multicodec varint parsing for JWT verification

the multicodec prefix for secp256k1 (0xe7 = 231) is actually 2 bytes
(0xe7 0x01) because values >= 128 require LEB128 continuation bytes.
we were only skipping 1 byte, passing a 34-byte "key" instead of the
correct 33-byte compressed public key.

also adds docs/atproto-crypto.md with notes on AT Protocol JWT auth.

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

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

zzstoatzz c9628e86 172d4bf7

+89 -5
+83
docs/atproto-crypto.md
··· 1 + # AT Protocol Cryptography Notes 2 + 3 + ## JWT Service Authentication 4 + 5 + AT Protocol uses signed JWTs for inter-service authentication. When a service (like a feed generator) receives a request from Bluesky, the request includes a JWT in the `Authorization: Bearer <token>` header. 6 + 7 + ### JWT Structure 8 + 9 + Required fields: 10 + - `iss`: issuer - the requester's DID (e.g., `did:plc:abc123`) 11 + - `aud`: audience - the service DID (e.g., `did:web:zig-bsky-feed.fly.dev`) 12 + - `exp`: expiration - UNIX timestamp (~60 seconds recommended) 13 + 14 + Optional fields: 15 + - `iat`: issued at timestamp 16 + - `lxm`: lexicon method (NSID of the endpoint being called) 17 + - `jti`: unique nonce to prevent replay attacks 18 + 19 + ### Signing Algorithms 20 + 21 + The JWT `alg` header indicates the key type: 22 + - `ES256K` for secp256k1 (k256) keys 23 + - `ES256` for P-256 (p256) keys 24 + 25 + The signature is computed over `base64url(header).base64url(payload)` using the account's signing key (same key used for repo commits). 26 + 27 + ## DID Document and Public Keys 28 + 29 + To verify a JWT signature, the receiving service must: 30 + 1. Parse the JWT to extract the issuer DID 31 + 2. Resolve the DID document from plc.directory (for did:plc) or .well-known/did.json (for did:web) 32 + 3. Extract the public key from the `#atproto` verification method 33 + 4. Verify the signature 34 + 35 + Example DID document: 36 + ```json 37 + { 38 + "id": "did:plc:abc123", 39 + "verificationMethod": [{ 40 + "id": "did:plc:abc123#atproto", 41 + "type": "Multikey", 42 + "publicKeyMultibase": "zQ3sh..." 43 + }] 44 + } 45 + ``` 46 + 47 + ## Multibase and Multicodec Encoding 48 + 49 + Public keys are encoded using: 50 + 1. **Multibase**: prefix indicating encoding (e.g., `z` = base58btc) 51 + 2. **Multicodec**: prefix indicating key type (varint encoded) 52 + 53 + ### Multicodec Key Type Prefixes 54 + 55 + Multicodec uses unsigned LEB128 (varint) encoding: 56 + 57 + | Key Type | Decimal | Varint Bytes | 58 + |----------|---------|--------------| 59 + | secp256k1-pub | 231 (0xe7) | `0xe7 0x01` | 60 + | p256-pub | 4608 (0x1200) | `0x80 0x24` | 61 + 62 + **Important**: Values >= 128 require 2+ bytes in varint encoding. The high bit indicates continuation. 63 + 64 + ### Decoding a Multibase Key 65 + 66 + Example: `zQ3shpqvKe7jKxYdPBoJHq9VVNLoqaDkL7GTtsX16eqFKXFjS` 67 + 68 + 1. Strip `z` prefix (base58btc) 69 + 2. Base58 decode the rest 70 + 3. First 2 bytes: `0xe7 0x01` = secp256k1-pub 71 + 4. Remaining 33 bytes: compressed SEC1 public key (starts with 0x02 or 0x03) 72 + 73 + ## Bug We Fixed 74 + 75 + The original code assumed secp256k1's multicodec prefix was 1 byte (`0xe7`), but it's actually 2 bytes (`0xe7 0x01`) because 231 >= 128 requires varint continuation. 76 + 77 + This caused signature verification to fail because we passed a 34-byte "key" (with the extra `0x01` byte prepended) instead of the correct 33-byte compressed public key. 78 + 79 + ## References 80 + 81 + - [AT Protocol XRPC Spec](https://atproto.com/specs/xrpc) 82 + - [Multicodec Table](https://github.com/multiformats/multicodec/blob/master/table.csv) 83 + - [Multibase Spec](https://github.com/multiformats/multibase)
+6 -5
src/atproto.zig
··· 463 463 if (decoded.len < 2) return error.InvalidMulticodec; 464 464 465 465 // parse multicodec varint prefix 466 - if (decoded[0] == 0xe7) { 467 - // secp256k1: single byte prefix 468 - const key = try allocator.dupe(u8, decoded[1..]); 466 + // multicodec uses unsigned LEB128 (varint) encoding 467 + // secp256k1-pub (0xe7 = 231) encodes as: 0xe7 0x01 (2 bytes) 468 + // p256-pub (0x1200 = 4608) encodes as: 0x80 0x24 (2 bytes) 469 + if (decoded.len >= 2 and decoded[0] == 0xe7 and decoded[1] == 0x01) { 470 + const key = try allocator.dupe(u8, decoded[2..]); 469 471 allocator.free(decoded); 470 472 return .{ .key = key, .key_type = .secp256k1 }; 471 - } else if (decoded[0] == 0x80 and decoded.len > 2 and decoded[1] == 0x24) { 472 - // P-256: two byte prefix (0x80 0x24 = varint 0x1200) 473 + } else if (decoded.len >= 2 and decoded[0] == 0x80 and decoded[1] == 0x24) { 473 474 const key = try allocator.dupe(u8, decoded[2..]); 474 475 allocator.free(decoded); 475 476 return .{ .key = key, .key_type = .p256 };