+1
-1
build.zig.zon
+1
-1
build.zig.zon
+48
-285
src/stream/atproto.zig
+48
-285
src/stream/atproto.zig
···
1
1
const std = @import("std");
2
2
const mem = std.mem;
3
-
const base64 = std.base64;
4
3
const Allocator = mem.Allocator;
5
4
const zat = @import("zat");
6
5
7
6
// =============================================================================
8
7
// atproto utilities
9
8
//
10
-
// JWT verification and API helpers.
11
-
// DID resolution and XRPC now handled by zat.
9
+
// JWT verification and API helpers using zat SDK.
12
10
// =============================================================================
13
11
14
-
pub const ServiceJwtPayload = struct {
15
-
iss: []const u8, // issuer - the requester's DID
16
-
aud: []const u8, // audience - the service DID
17
-
exp: i64, // expiration timestamp
18
-
iat: ?i64 = null, // issued at
19
-
lxm: ?[]const u8 = null, // lexicon method
20
-
jti: ?[]const u8 = null, // JWT ID
21
-
};
22
-
23
-
pub const JwtError = error{
24
-
MalformedJwt,
25
-
InvalidBase64,
26
-
InvalidJson,
27
-
MissingField,
28
-
Expired,
29
-
InvalidAudience,
30
-
InvalidSignature,
31
-
DidResolutionFailed,
32
-
};
12
+
const log = std.log.scoped(.atproto);
33
13
34
-
/// parse a JWT without verifying the signature.
35
-
pub fn parseJwtUnsafe(allocator: Allocator, jwt: []const u8) !ServiceJwtPayload {
36
-
var parts = mem.splitScalar(u8, jwt, '.');
14
+
/// extract requester DID from an HTTP Authorization header (claims-only verification).
15
+
pub fn getRequesterDid(allocator: Allocator, auth_header: ?[]const u8, service_did: []const u8) ?[]const u8 {
16
+
const auth = auth_header orelse return null;
17
+
if (!mem.startsWith(u8, auth, "Bearer ")) return null;
37
18
38
-
// skip header
39
-
_ = parts.next() orelse return JwtError.MalformedJwt;
40
-
41
-
// decode payload
42
-
const payload_b64 = parts.next() orelse return JwtError.MalformedJwt;
43
-
const payload_json = try decodeBase64Url(allocator, payload_b64);
44
-
defer allocator.free(payload_json);
45
-
46
-
// parse JSON
47
-
const parsed = std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{}) catch {
48
-
return JwtError.InvalidJson;
49
-
};
50
-
defer parsed.deinit();
19
+
const token = auth["Bearer ".len..];
51
20
52
-
const obj = parsed.value.object;
21
+
var jwt = zat.Jwt.parse(allocator, token) catch return null;
22
+
defer jwt.deinit();
53
23
54
-
// extract required fields
55
-
const iss = obj.get("iss") orelse return JwtError.MissingField;
56
-
if (iss != .string) return JwtError.MissingField;
24
+
// check claims
25
+
if (jwt.isExpired()) return null;
26
+
if (!mem.eql(u8, jwt.payload.aud, service_did)) return null;
57
27
58
-
const aud = obj.get("aud") orelse return JwtError.MissingField;
59
-
if (aud != .string) return JwtError.MissingField;
28
+
return allocator.dupe(u8, jwt.payload.iss) catch null;
29
+
}
60
30
61
-
const exp = obj.get("exp") orelse return JwtError.MissingField;
62
-
if (exp != .integer) return JwtError.MissingField;
31
+
/// extract requester DID with full signature verification.
32
+
pub fn getRequesterDidVerified(allocator: Allocator, auth_header: ?[]const u8, service_did: []const u8) ?[]const u8 {
33
+
const auth = auth_header orelse return null;
34
+
if (!mem.startsWith(u8, auth, "Bearer ")) return null;
63
35
64
-
// optional fields
65
-
const iat: ?i64 = if (obj.get("iat")) |v| if (v == .integer) v.integer else null else null;
66
-
const lxm: ?[]const u8 = if (obj.get("lxm")) |v| if (v == .string) v.string else null else null;
67
-
const jti: ?[]const u8 = if (obj.get("jti")) |v| if (v == .string) v.string else null else null;
36
+
const token = auth["Bearer ".len..];
68
37
69
-
return .{
70
-
.iss = try allocator.dupe(u8, iss.string),
71
-
.aud = try allocator.dupe(u8, aud.string),
72
-
.exp = exp.integer,
73
-
.iat = iat,
74
-
.lxm = if (lxm) |s| try allocator.dupe(u8, s) else null,
75
-
.jti = if (jti) |s| try allocator.dupe(u8, s) else null,
38
+
var jwt = zat.Jwt.parse(allocator, token) catch |err| {
39
+
log.debug("jwt parse failed: {}", .{err});
40
+
return null;
76
41
};
77
-
}
42
+
defer jwt.deinit();
78
43
79
-
/// verify a JWT's claims (expiration, audience) without signature verification.
80
-
pub fn verifyJwtClaims(
81
-
allocator: Allocator,
82
-
jwt: []const u8,
83
-
expected_audience: []const u8,
84
-
) !ServiceJwtPayload {
85
-
const payload = try parseJwtUnsafe(allocator, jwt);
86
-
87
-
if (payload.exp < std.time.timestamp()) {
88
-
return JwtError.Expired;
44
+
// check claims
45
+
if (jwt.isExpired()) {
46
+
log.debug("jwt expired", .{});
47
+
return null;
89
48
}
90
-
91
-
if (!mem.eql(u8, payload.aud, expected_audience)) {
92
-
return JwtError.InvalidAudience;
49
+
if (!mem.eql(u8, jwt.payload.aud, service_did)) {
50
+
log.debug("jwt audience mismatch", .{});
51
+
return null;
93
52
}
94
53
95
-
return payload;
96
-
}
97
-
98
-
/// verify a JWT fully, including cryptographic signature.
99
-
pub fn verifyJwt(
100
-
allocator: Allocator,
101
-
jwt: []const u8,
102
-
expected_audience: []const u8,
103
-
) !ServiceJwtPayload {
104
-
const payload = try verifyJwtClaims(allocator, jwt, expected_audience);
54
+
// resolve issuer's DID document to get signing key
55
+
const did = zat.Did.parse(jwt.payload.iss) orelse {
56
+
log.debug("invalid issuer DID: {s}", .{jwt.payload.iss});
57
+
return null;
58
+
};
105
59
106
-
// resolve DID using zat
107
-
const did = zat.Did.parse(payload.iss) orelse return JwtError.DidResolutionFailed;
108
60
var resolver = zat.DidResolver.init(allocator);
109
61
defer resolver.deinit();
110
62
111
-
var doc = resolver.resolve(did) catch return JwtError.DidResolutionFailed;
63
+
var doc = resolver.resolve(did) catch |err| {
64
+
log.debug("DID resolution failed: {}", .{err});
65
+
return null;
66
+
};
112
67
defer doc.deinit();
113
68
114
-
const signing_key = doc.signingKey() orelse return JwtError.DidResolutionFailed;
115
-
116
-
verifyJwtSignature(allocator, jwt, signing_key.public_key_multibase) catch {
117
-
return JwtError.InvalidSignature;
69
+
const signing_key = doc.signingKey() orelse {
70
+
log.debug("no signing key in DID document", .{});
71
+
return null;
118
72
};
119
73
120
-
return payload;
121
-
}
122
-
123
-
/// extract requester DID from an HTTP Authorization header.
124
-
pub fn getRequesterDid(allocator: Allocator, auth_header: ?[]const u8, service_did: []const u8) ?[]const u8 {
125
-
const auth = auth_header orelse return null;
126
-
if (!mem.startsWith(u8, auth, "Bearer ")) return null;
127
-
128
-
const jwt = auth[7..];
129
-
const payload = verifyJwtClaims(allocator, jwt, service_did) catch return null;
130
-
131
-
return payload.iss;
132
-
}
133
-
134
-
/// extract requester DID with full signature verification.
135
-
pub fn getRequesterDidVerified(allocator: Allocator, auth_header: ?[]const u8, service_did: []const u8) ?[]const u8 {
136
-
const auth = auth_header orelse return null;
137
-
if (!mem.startsWith(u8, auth, "Bearer ")) return null;
138
-
139
-
const jwt = auth[7..];
140
-
const payload = verifyJwt(allocator, jwt, service_did) catch |err| {
141
-
std.debug.print("jwt verification failed: {}\n", .{err});
74
+
// verify signature
75
+
jwt.verify(signing_key.public_key_multibase) catch |err| {
76
+
log.debug("jwt signature verification failed: {}", .{err});
142
77
return null;
143
78
};
144
79
145
-
return payload.iss;
146
-
}
147
-
148
-
// -----------------------------------------------------------------------------
149
-
// JWT signature verification (crypto)
150
-
// -----------------------------------------------------------------------------
151
-
152
-
const ecdsa = std.crypto.sign.ecdsa;
153
-
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
154
-
155
-
fn decodeBase58(allocator: Allocator, input: []const u8) ![]u8 {
156
-
if (input.len == 0) return allocator.alloc(u8, 0);
157
-
158
-
var leading_zeros: usize = 0;
159
-
for (input) |c| {
160
-
if (c == '1') {
161
-
leading_zeros += 1;
162
-
} else {
163
-
break;
164
-
}
165
-
}
166
-
167
-
const output_size = input.len;
168
-
var output = try allocator.alloc(u8, output_size);
169
-
@memset(output, 0);
170
-
171
-
var output_len: usize = 0;
172
-
173
-
for (input) |c| {
174
-
const val: u8 = for (BASE58_ALPHABET, 0..) |a, i| {
175
-
if (a == c) break @intCast(i);
176
-
} else return error.InvalidBase58;
177
-
178
-
var carry: u32 = val;
179
-
var i: usize = 0;
180
-
while (i < output_len or carry != 0) : (i += 1) {
181
-
if (i >= output.len) return error.InvalidBase58;
182
-
const idx = output.len - 1 - i;
183
-
carry += @as(u32, output[idx]) * 58;
184
-
output[idx] = @truncate(carry & 0xff);
185
-
carry >>= 8;
186
-
}
187
-
output_len = @max(output_len, i);
188
-
}
189
-
190
-
const start = output.len - output_len;
191
-
const result_len = leading_zeros + output_len;
192
-
const result = try allocator.alloc(u8, result_len);
193
-
@memset(result[0..leading_zeros], 0);
194
-
@memcpy(result[leading_zeros..], output[start..]);
195
-
allocator.free(output);
196
-
197
-
return result;
198
-
}
199
-
200
-
const KeyType = enum { p256, secp256k1 };
201
-
202
-
fn decodeMultibaseKey(allocator: Allocator, multibase: []const u8) !struct { key: []u8, key_type: KeyType } {
203
-
if (multibase.len == 0) return error.InvalidMultibase;
204
-
if (multibase[0] != 'z') return error.UnsupportedMultibase;
205
-
206
-
const decoded = try decodeBase58(allocator, multibase[1..]);
207
-
errdefer allocator.free(decoded);
208
-
209
-
if (decoded.len < 2) return error.InvalidMulticodec;
210
-
211
-
if (decoded.len >= 2 and decoded[0] == 0xe7 and decoded[1] == 0x01) {
212
-
const key = try allocator.dupe(u8, decoded[2..]);
213
-
allocator.free(decoded);
214
-
return .{ .key = key, .key_type = .secp256k1 };
215
-
} else if (decoded.len >= 2 and decoded[0] == 0x80 and decoded[1] == 0x24) {
216
-
const key = try allocator.dupe(u8, decoded[2..]);
217
-
allocator.free(decoded);
218
-
return .{ .key = key, .key_type = .p256 };
219
-
} else {
220
-
return error.UnsupportedKeyType;
221
-
}
222
-
}
223
-
224
-
pub fn verifyJwtSignature(allocator: Allocator, jwt: []const u8, public_key_multibase: []const u8) !void {
225
-
var parts = mem.splitScalar(u8, jwt, '.');
226
-
const header_b64 = parts.next() orelse return error.MalformedJwt;
227
-
const payload_b64 = parts.next() orelse return error.MalformedJwt;
228
-
const sig_b64 = parts.next() orelse return error.MalformedJwt;
229
-
230
-
const header_end = @intFromPtr(header_b64.ptr) - @intFromPtr(jwt.ptr) + header_b64.len;
231
-
const payload_end = header_end + 1 + payload_b64.len;
232
-
const message = jwt[0..payload_end];
233
-
234
-
const sig_bytes = try decodeBase64Url(allocator, sig_b64);
235
-
defer allocator.free(sig_bytes);
236
-
237
-
const key_info = try decodeMultibaseKey(allocator, public_key_multibase);
238
-
defer allocator.free(key_info.key);
239
-
240
-
switch (key_info.key_type) {
241
-
.p256 => {
242
-
const pubkey = ecdsa.EcdsaP256Sha256.PublicKey.fromSec1(key_info.key) catch {
243
-
return error.InvalidSignature;
244
-
};
245
-
if (sig_bytes.len != ecdsa.EcdsaP256Sha256.Signature.encoded_length) {
246
-
return error.InvalidSignature;
247
-
}
248
-
const sig = ecdsa.EcdsaP256Sha256.Signature.fromBytes(sig_bytes[0..64].*);
249
-
sig.verify(message, pubkey) catch return error.InvalidSignature;
250
-
},
251
-
.secp256k1 => {
252
-
const pubkey = ecdsa.EcdsaSecp256k1Sha256.PublicKey.fromSec1(key_info.key) catch {
253
-
return error.InvalidSignature;
254
-
};
255
-
if (sig_bytes.len != ecdsa.EcdsaSecp256k1Sha256.Signature.encoded_length) {
256
-
return error.InvalidSignature;
257
-
}
258
-
const sig = ecdsa.EcdsaSecp256k1Sha256.Signature.fromBytes(sig_bytes[0..64].*);
259
-
sig.verify(message, pubkey) catch return error.InvalidSignature;
260
-
},
261
-
}
262
-
}
263
-
264
-
fn decodeBase64Url(allocator: Allocator, input: []const u8) ![]u8 {
265
-
var buf = try allocator.alloc(u8, input.len + 4);
266
-
defer allocator.free(buf);
267
-
268
-
var i: usize = 0;
269
-
for (input) |c| {
270
-
buf[i] = switch (c) {
271
-
'-' => '+',
272
-
'_' => '/',
273
-
else => c,
274
-
};
275
-
i += 1;
276
-
}
277
-
278
-
const padding = (4 - (i % 4)) % 4;
279
-
for (0..padding) |_| {
280
-
buf[i] = '=';
281
-
i += 1;
282
-
}
283
-
284
-
const decoder = base64.standard.Decoder;
285
-
const decoded_len = decoder.calcSizeForSlice(buf[0..i]) catch return JwtError.InvalidBase64;
286
-
const result = try allocator.alloc(u8, decoded_len);
287
-
decoder.decode(result, buf[0..i]) catch return JwtError.InvalidBase64;
288
-
289
-
return result;
80
+
return allocator.dupe(u8, jwt.payload.iss) catch null;
290
81
}
291
82
292
83
// -----------------------------------------------------------------------------
···
320
111
}
321
112
322
113
var response = client.query(nsid, params) catch |err| {
323
-
std.debug.print("getFollows API error: {}\n", .{err});
114
+
log.err("getFollows API error: {}", .{err});
324
115
return error.ApiFailed;
325
116
};
326
117
defer response.deinit();
···
406
197
try params.put("filter", "posts_no_replies");
407
198
408
199
var response = client.query(nsid, params) catch |err| {
409
-
std.debug.print("getAuthorFeed API error for {s}: {}\n", .{ actor_did, err });
200
+
log.err("getAuthorFeed API error for {s}: {}", .{ actor_did, err });
410
201
return error.ApiFailed;
411
202
};
412
203
defer response.deinit();
···
435
226
436
227
return try posts.toOwnedSlice(allocator);
437
228
}
438
-
439
-
// -----------------------------------------------------------------------------
440
-
// tests
441
-
// -----------------------------------------------------------------------------
442
-
443
-
test "parseJwtUnsafe" {
444
-
const jwt = "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6cGxjOnRlc3QiLCJhdWQiOiJkaWQ6d2ViOmZlZWQuZXhhbXBsZSIsImV4cCI6OTk5OTk5OTk5OX0.fake_signature";
445
-
446
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
447
-
defer arena.deinit();
448
-
449
-
const payload = try parseJwtUnsafe(arena.allocator(), jwt);
450
-
try std.testing.expectEqualStrings("did:plc:test", payload.iss);
451
-
try std.testing.expectEqualStrings("did:web:feed.example", payload.aud);
452
-
}
453
-
454
-
test "decodeBase58" {
455
-
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
456
-
defer arena.deinit();
457
-
458
-
const result1 = try decodeBase58(arena.allocator(), "1");
459
-
try std.testing.expectEqual(@as(usize, 1), result1.len);
460
-
try std.testing.expectEqual(@as(u8, 0), result1[0]);
461
-
462
-
const result2 = try decodeBase58(arena.allocator(), "2");
463
-
try std.testing.expectEqual(@as(usize, 1), result2.len);
464
-
try std.testing.expectEqual(@as(u8, 1), result2[0]);
465
-
}