atproto utils for zig zat.dev
atproto sdk zig
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608//! JWT parsing and verification for AT Protocol//!//! parses and verifies JWTs used in AT Protocol service auth.//! supports ES256 (P-256) and ES256K (secp256k1) signing.//!//! see: https://atproto.com/specs/xrpc#service-auth
const std = @import("std");const crypto = std.crypto;const Io = std.Io;const json = @import("../xrpc/json.zig");const multibase = @import("multibase.zig");const multicodec = @import("multicodec.zig");
fn timestamp(io: Io) i64 { return @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_s));}
/// JWT signing algorithmpub const Algorithm = enum { ES256, // P-256 / secp256r1 ES256K, // secp256k1
pub fn fromString(s: []const u8) ?Algorithm { if (std.mem.eql(u8, s, "ES256")) return .ES256; if (std.mem.eql(u8, s, "ES256K")) return .ES256K; return null; }};
/// parsed JWT headerpub const Header = struct { alg: Algorithm, typ: []const u8,};
/// parsed JWT payload (AT Protocol service auth claims)pub const Payload = struct { /// issuer DID (account making the request) iss: []const u8, /// audience DID (service receiving the request) aud: []const u8, /// expiration timestamp (unix seconds) exp: i64, /// issued-at timestamp (unix seconds) iat: ?i64 = null, /// unique nonce for replay prevention jti: ?[]const u8 = null, /// lexicon method (optional, may become required) lxm: ?[]const u8 = null,};
/// parsed JWT with raw componentspub const Jwt = struct { allocator: std.mem.Allocator,
/// decoded header header: Header, /// decoded payload payload: Payload, /// raw signature bytes (r || s, 64 bytes) signature: []u8, /// the signed portion (header.payload) for verification signed_input: []const u8, /// original token for reference raw_token: []const u8,
/// parse a JWT token string pub fn parse(allocator: std.mem.Allocator, token: []const u8) !Jwt { // split on dots: header.payload.signature var parts: [3][]const u8 = undefined; var part_idx: usize = 0; var it = std.mem.splitScalar(u8, token, '.');
while (it.next()) |part| { if (part_idx >= 3) return error.InvalidJwt; parts[part_idx] = part; part_idx += 1; }
if (part_idx != 3) return error.InvalidJwt;
const header_b64 = parts[0]; const payload_b64 = parts[1]; const sig_b64 = parts[2];
// find signed input (everything before last dot) const last_dot = std.mem.lastIndexOfScalar(u8, token, '.') orelse return error.InvalidJwt; const signed_input = token[0..last_dot];
// decode header const header_json = try base64UrlDecode(allocator, header_b64); defer allocator.free(header_json);
const header = try parseHeader(allocator, header_json);
// decode payload const payload_json = try base64UrlDecode(allocator, payload_b64); defer allocator.free(payload_json);
const payload = try parsePayload(allocator, payload_json);
// decode signature const signature = try base64UrlDecode(allocator, sig_b64); errdefer allocator.free(signature);
// JWT signatures should be 64 bytes (r || s) if (signature.len != 64) { allocator.free(signature); return error.InvalidSignatureLength; }
return .{ .allocator = allocator, .header = header, .payload = payload, .signature = signature, .signed_input = signed_input, .raw_token = token, }; }
/// verify the JWT signature against a public key /// public_key should be multibase-encoded (from DID document) /// /// JWTs are JOSE tokens (RFC 7515): the low-S requirement does not apply, /// and signers such as WebCrypto-based OAuth clients routinely emit high-S /// signatures. verification is therefore lenient on S. atproto's strict /// low-S requirement is enforced where it belongs — on content-addressed /// signatures (repo commits via verifyCommitCar, did:key via /// verifyDidKeySignature), which use verifyP256/verifySecp256k1 directly. pub fn verify(self: *const Jwt, public_key_multibase: []const u8) !void { // decode multibase key const key_bytes = try multibase.decode(self.allocator, public_key_multibase); defer self.allocator.free(key_bytes);
// parse multicodec to get key type and raw bytes const parsed_key = try multicodec.parsePublicKey(key_bytes);
// verify key type matches algorithm switch (self.header.alg) { .ES256K => if (parsed_key.key_type != .secp256k1) return error.AlgorithmKeyMismatch, .ES256 => if (parsed_key.key_type != .p256) return error.AlgorithmKeyMismatch, }
try verifyJose(self.header.alg, self.signed_input, self.signature, parsed_key.raw); }
/// check if the token is expired pub fn isExpired(self: *const Jwt, io: Io) bool { const now = timestamp(io); return now > self.payload.exp; }
/// check if the token is expired with clock skew tolerance (in seconds) pub fn isExpiredWithSkew(self: *const Jwt, io: Io, skew_seconds: i64) bool { const now = timestamp(io); return now > (self.payload.exp + skew_seconds); }
pub fn deinit(self: *Jwt) void { self.allocator.free(self.signature); self.allocator.free(self.payload.iss); self.allocator.free(self.payload.aud); if (self.payload.jti) |s| self.allocator.free(s); if (self.payload.lxm) |s| self.allocator.free(s); }};
// === internal helpers ===
pub fn base64UrlDecode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { const decoder = &std.base64.url_safe_no_pad.Decoder; const size = try decoder.calcSizeForSlice(input); const output = try allocator.alloc(u8, size); errdefer allocator.free(output); try decoder.decode(output, input); return output;}
pub fn base64UrlEncode(allocator: std.mem.Allocator, data: []const u8) ![]u8 { const encoder = &std.base64.url_safe_no_pad.Encoder; const len = encoder.calcSize(data.len); const buf = try allocator.alloc(u8, len); _ = encoder.encode(buf, data); return buf;}
fn parseHeader(allocator: std.mem.Allocator, header_json: []const u8) !Header { const parsed = try std.json.parseFromSlice(std.json.Value, allocator, header_json, .{}); defer parsed.deinit();
const alg_str = json.getString(parsed.value, "alg") orelse return error.MissingAlgorithm; const alg = Algorithm.fromString(alg_str) orelse return error.UnsupportedAlgorithm;
return .{ .alg = alg, .typ = "JWT", // static string, no need to dupe };}
fn parsePayload(allocator: std.mem.Allocator, payload_json: []const u8) !Payload { const parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{}); defer parsed.deinit();
const iss_raw = json.getString(parsed.value, "iss") orelse return error.MissingIssuer; const aud_raw = json.getString(parsed.value, "aud") orelse return error.MissingAudience; const exp = json.getInt(parsed.value, "exp") orelse return error.MissingExpiration;
// dupe strings so they outlive parsed const iss = try allocator.dupe(u8, iss_raw); errdefer allocator.free(iss);
const aud = try allocator.dupe(u8, aud_raw); errdefer allocator.free(aud);
const jti: ?[]const u8 = if (json.getString(parsed.value, "jti")) |s| try allocator.dupe(u8, s) else null; errdefer if (jti) |s| allocator.free(s);
const lxm: ?[]const u8 = if (json.getString(parsed.value, "lxm")) |s| try allocator.dupe(u8, s) else null;
return .{ .iss = iss, .aud = aud, .exp = exp, .iat = json.getInt(parsed.value, "iat"), .jti = jti, .lxm = lxm, };}
/// compare two 32-byte big-endian values: true if a > bfn bigEndianGt(a: [32]u8, b: [32]u8) bool { for (a, b) |ab, bb| { if (ab > bb) return true; if (ab < bb) return false; } return false;}
/// reject high-S signatures (atproto requires low-S normalization)./// s is high-S if s > curve_order / 2.fn rejectHighS(comptime half_order: [32]u8, s_bytes: [32]u8) error{HighSSignature}!void { if (bigEndianGt(s_bytes, half_order)) return error.HighSSignature;}
// secp256k1 order/2 (big-endian)// order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141const secp256k1_half_order: [32]u8 = .{ 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x5D, 0x57, 0x6E, 0x73, 0x57, 0xA4, 0x50, 0x1D, 0xDF, 0xE9, 0x2F, 0x46, 0x68, 0x1B, 0x20, 0xA0,};
// P-256 order/2 (big-endian)// order = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551const p256_half_order: [32]u8 = .{ 0x7F, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xDE, 0x73, 0x7D, 0x56, 0xD3, 0x8B, 0xCF, 0x42, 0x79, 0xDC, 0xE5, 0x61, 0x7E, 0x31, 0x92, 0xA8,};
/// ECDSA signature (r || s, 64 bytes)pub const Signature = struct { bytes: [64]u8,};
/// sign a message with deterministic RFC 6979 ECDSA and low-S normalizationfn signEcdsa(comptime Scheme: type, comptime Curve: type, comptime half_order: [32]u8, message: []const u8, secret_key_bytes: []const u8) !Signature { if (secret_key_bytes.len != 32) return error.InvalidSecretKey; const sk = Scheme.SecretKey.fromBytes(secret_key_bytes[0..32].*) catch return error.InvalidSecretKey; const kp = Scheme.KeyPair.fromSecretKey(sk) catch return error.InvalidSecretKey;
var sig = kp.sign(message, null) catch return error.SigningFailed;
if (bigEndianGt(sig.s, half_order)) { sig.s = Curve.scalar.neg(sig.s, .big) catch return error.SigningFailed; }
return .{ .bytes = sig.toBytes() };}
/// verify an ECDSA signature, rejecting high-Sfn verifyEcdsa(comptime Scheme: type, comptime half_order: [32]u8, message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void { if (sig_bytes.len != 64) return error.InvalidSignature;
// reject high-S before constructing Signature — fromBytes does scalar // arithmetic that can overflow on out-of-range values rejectHighS(half_order, sig_bytes[32..64].*) catch return error.SignatureVerificationFailed;
const sig = Scheme.Signature.fromBytes(sig_bytes[0..64].*);
if (public_key_raw.len != 33) return error.InvalidPublicKey; const public_key = Scheme.PublicKey.fromSec1(public_key_raw) catch return error.InvalidPublicKey;
sig.verify(message, public_key) catch return error.SignatureVerificationFailed;}
pub fn signSecp256k1(message: []const u8, secret_key_bytes: []const u8) !Signature { return signEcdsa(crypto.sign.ecdsa.EcdsaSecp256k1Sha256, crypto.ecc.Secp256k1, secp256k1_half_order, message, secret_key_bytes);}
pub fn signP256(message: []const u8, secret_key_bytes: []const u8) !Signature { return signEcdsa(crypto.sign.ecdsa.EcdsaP256Sha256, crypto.ecc.P256, p256_half_order, message, secret_key_bytes);}
pub fn verifySecp256k1(message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void { return verifyEcdsa(crypto.sign.ecdsa.EcdsaSecp256k1Sha256, secp256k1_half_order, message, sig_bytes, public_key_raw);}
pub fn verifyP256(message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void { return verifyEcdsa(crypto.sign.ecdsa.EcdsaP256Sha256, p256_half_order, message, sig_bytes, public_key_raw);}
/// verify a JOSE ECDSA signature (RFC 7515) WITHOUT the atproto low-S/// requirement: both low-S and high-S signatures are accepted.////// use this for general JOSE JWTs — OAuth client assertions, DPoP proofs —/// where signing libraries are not required to normalize S, and high-S/// signatures are valid. repo commit and service-auth signatures must use the/// strict `Jwt.verify` / `verifyP256` / `verifySecp256k1` path, which rejects/// high-S to hold atproto's low-S requirement. do not reach for `verifyJose`/// where signature malleability matters (e.g. anything content-addressed).pub fn verifyJose(alg: Algorithm, message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void { return switch (alg) { .ES256 => verifyEcdsaJose(crypto.sign.ecdsa.EcdsaP256Sha256, message, sig_bytes, public_key_raw), .ES256K => verifyEcdsaJose(crypto.sign.ecdsa.EcdsaSecp256k1Sha256, message, sig_bytes, public_key_raw), };}
/// verify an ECDSA signature accepting both low-S and high-S (RFC 7515 / JOSE)./// unlike `verifyEcdsa`, there is no `rejectHighS` step. `Signature.fromBytes`/// only copies bytes; an out-of-range `s` (>= curve order) is rejected by/// `verify` as `NonCanonical`, surfaced here as `SignatureVerificationFailed`.fn verifyEcdsaJose(comptime Scheme: type, message: []const u8, sig_bytes: []const u8, public_key_raw: []const u8) !void { if (sig_bytes.len != 64) return error.InvalidSignature; if (public_key_raw.len != 33) return error.InvalidPublicKey;
const sig = Scheme.Signature.fromBytes(sig_bytes[0..64].*); const public_key = Scheme.PublicKey.fromSec1(public_key_raw) catch return error.InvalidPublicKey; sig.verify(message, public_key) catch return error.SignatureVerificationFailed;}
// === tests ===
test "parse jwt structure" { // a minimal valid JWT structure (signature won't verify, just testing parsing) // header: {"alg":"ES256K","typ":"JWT"} // payload: {"iss":"did:plc:test","aud":"did:plc:service","exp":9999999999} const token = "eyJhbGciOiJFUzI1NksiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJkaWQ6cGxjOnRlc3QiLCJhdWQiOiJkaWQ6cGxjOnNlcnZpY2UiLCJleHAiOjk5OTk5OTk5OTl9.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
var jwt = try Jwt.parse(std.testing.allocator, token); defer jwt.deinit();
try std.testing.expectEqual(Algorithm.ES256K, jwt.header.alg); try std.testing.expectEqualStrings("did:plc:test", jwt.payload.iss); try std.testing.expectEqualStrings("did:plc:service", jwt.payload.aud); try std.testing.expectEqual(@as(i64, 9999999999), jwt.payload.exp);}
test "reject invalid jwt format" { // missing parts try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "onlyonepart")); try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "two.parts")); try std.testing.expectError(error.InvalidJwt, Jwt.parse(std.testing.allocator, "too.many.parts.here"));}
test "verify ES256K signature - official fixture" { // test vector from bluesky-social/indigo atproto/auth/jwt_test.go // pubkey: did:key:zQ3shscXNYZQZSPwegiv7uQZZV5kzATLBRtgJhs7uRY7pfSk4 // iss: did:example:iss, aud: did:example:aud, exp: 1713571012 const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3MiLCJhdWQiOiJkaWQ6ZXhhbXBsZTphdWQiLCJleHAiOjE3MTM1NzEwMTJ9.J_In_PQCMjygeeoIKyjybORD89ZnEy1bZTd--sdq_78qv3KCO9181ZAh-2Pl0qlXZjfUlxgIa6wiak2NtsT98g";
// extract multibase key from did:key (strip "did:key:" prefix) const did_key = "did:key:zQ3shscXNYZQZSPwegiv7uQZZV5kzATLBRtgJhs7uRY7pfSk4"; const multibase_key = did_key["did:key:".len..];
var jwt = try Jwt.parse(std.testing.allocator, token); defer jwt.deinit();
// verify claims try std.testing.expectEqual(Algorithm.ES256K, jwt.header.alg); try std.testing.expectEqualStrings("did:example:iss", jwt.payload.iss); try std.testing.expectEqualStrings("did:example:aud", jwt.payload.aud);
// verify signature try jwt.verify(multibase_key);}
test "verify ES256 signature - official fixture" { // test vector from bluesky-social/indigo atproto/auth/jwt_test.go // pubkey: did:key:zDnaeXRDKRCEUoYxi8ZJS2pDsgfxUh3pZiu3SES9nbY4DoART // iss: did:example:iss, aud: did:example:aud, exp: 1713571554 const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3MiLCJhdWQiOiJkaWQ6ZXhhbXBsZTphdWQiLCJleHAiOjE3MTM1NzE1NTR9.FFRLm7SGbDUp6cL0WoCs0L5oqNkjCXB963TqbgI-KxIjbiqMQATVCalcMJx17JGTjMmfVHJP6Op_V4Z0TTjqog";
// extract multibase key from did:key const did_key = "did:key:zDnaeXRDKRCEUoYxi8ZJS2pDsgfxUh3pZiu3SES9nbY4DoART"; const multibase_key = did_key["did:key:".len..];
var jwt = try Jwt.parse(std.testing.allocator, token); defer jwt.deinit();
// verify claims try std.testing.expectEqual(Algorithm.ES256, jwt.header.alg); try std.testing.expectEqualStrings("did:example:iss", jwt.payload.iss); try std.testing.expectEqualStrings("did:example:aud", jwt.payload.aud);
// verify signature try jwt.verify(multibase_key);}
test "reject signature with wrong key" { // ES256K token const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3MiLCJhdWQiOiJkaWQ6ZXhhbXBsZTphdWQiLCJleHAiOjE3MTM1NzEwMTJ9.J_In_PQCMjygeeoIKyjybORD89ZnEy1bZTd--sdq_78qv3KCO9181ZAh-2Pl0qlXZjfUlxgIa6wiak2NtsT98g";
// different ES256K key (second fixture from indigo) const wrong_key = "zQ3shqKrpHzQ5HDfhgcYMWaFcpBK3SS39wZLdTjA5GeakX8G5";
var jwt = try Jwt.parse(std.testing.allocator, token); defer jwt.deinit();
// should fail verification with wrong key try std.testing.expectError(error.SignatureVerificationFailed, jwt.verify(wrong_key));}
test "sign and verify round-trip - secp256k1" { // generate a deterministic keypair using a fixed seed const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256; const sk_bytes = [_]u8{ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, };
const message = "hello atproto"; const sig = try signSecp256k1(message, &sk_bytes);
// verify low-S: s must be <= half_order const s = sig.bytes[32..64].*; try std.testing.expect(!bigEndianGt(s, secp256k1_half_order));
// verify with the corresponding public key const sk = try Scheme.SecretKey.fromBytes(sk_bytes); const kp = try Scheme.KeyPair.fromSecretKey(sk); const pk_bytes = kp.public_key.toCompressedSec1();
try verifySecp256k1(message, &sig.bytes, &pk_bytes);}
test "sign and verify round-trip - P-256" { const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256; const sk_bytes = [_]u8{ 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, };
const message = "hello atproto p256"; const sig = try signP256(message, &sk_bytes);
// verify low-S const s = sig.bytes[32..64].*; try std.testing.expect(!bigEndianGt(s, p256_half_order));
// verify with the corresponding public key const sk = try Scheme.SecretKey.fromBytes(sk_bytes); const kp = try Scheme.KeyPair.fromSecretKey(sk); const pk_bytes = kp.public_key.toCompressedSec1();
try verifyP256(message, &sig.bytes, &pk_bytes);}
test "sign produces deterministic signatures" { const sk_bytes = [_]u8{ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, }; const message = "deterministic test";
const sig1 = try signSecp256k1(message, &sk_bytes); const sig2 = try signSecp256k1(message, &sk_bytes); try std.testing.expectEqualSlices(u8, &sig1.bytes, &sig2.bytes);}
test "verifyJose accepts high-S P-256 while strict verifyP256 rejects it" { const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256; const sk_bytes = [_]u8{ 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, }; const message = "oauth client assertion";
// signP256 normalizes to low-S; flip S to its complement to get a valid // high-S signature, exactly as a non-normalizing JOSE signer would emit. const low = try signP256(message, &sk_bytes); var high = low.bytes; high[32..64].* = try crypto.ecc.P256.scalar.neg(low.bytes[32..64].*, .big); try std.testing.expect(bigEndianGt(high[32..64].*, p256_half_order));
const sk = try Scheme.SecretKey.fromBytes(sk_bytes); const kp = try Scheme.KeyPair.fromSecretKey(sk); const pk = kp.public_key.toCompressedSec1();
// the strict (repo / did:key) policy rejects high-S try std.testing.expectError(error.SignatureVerificationFailed, verifyP256(message, &high, &pk)); // the JOSE policy accepts it — and still accepts the low-S form try verifyJose(.ES256, message, &high, &pk); try verifyJose(.ES256, message, &low.bytes, &pk);}
test "verifyJose accepts high-S secp256k1 while strict verifySecp256k1 rejects it" { const Scheme = crypto.sign.ecdsa.EcdsaSecp256k1Sha256; const sk_bytes = [_]u8{ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, }; const message = "oauth client assertion";
const low = try signSecp256k1(message, &sk_bytes); var high = low.bytes; high[32..64].* = try crypto.ecc.Secp256k1.scalar.neg(low.bytes[32..64].*, .big); try std.testing.expect(bigEndianGt(high[32..64].*, secp256k1_half_order));
const sk = try Scheme.SecretKey.fromBytes(sk_bytes); const kp = try Scheme.KeyPair.fromSecretKey(sk); const pk = kp.public_key.toCompressedSec1();
try std.testing.expectError(error.SignatureVerificationFailed, verifySecp256k1(message, &high, &pk)); try verifyJose(.ES256K, message, &high, &pk); try verifyJose(.ES256K, message, &low.bytes, &pk);}
test "Jwt.verify accepts a high-S ES256 JWT (JOSE leniency)" { const allocator = std.testing.allocator; const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256; const sk_bytes = [_]u8{ 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, };
const header_b64 = try base64UrlEncode(allocator, "{\"alg\":\"ES256\",\"typ\":\"JWT\"}"); defer allocator.free(header_b64); const payload_b64 = try base64UrlEncode(allocator, "{\"iss\":\"did:example:iss\",\"aud\":\"did:example:aud\",\"exp\":9999999999}"); defer allocator.free(payload_b64); const signed_input = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ header_b64, payload_b64 }); defer allocator.free(signed_input);
const low = try signP256(signed_input, &sk_bytes); var high = low.bytes; high[32..64].* = try crypto.ecc.P256.scalar.neg(low.bytes[32..64].*, .big); const sig_b64 = try base64UrlEncode(allocator, &high); defer allocator.free(sig_b64); const token = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ signed_input, sig_b64 }); defer allocator.free(token);
// the public key as it would appear in a DID document (multibase multikey) const sk = try Scheme.SecretKey.fromBytes(sk_bytes); const kp = try Scheme.KeyPair.fromSecretKey(sk); const pk = kp.public_key.toCompressedSec1(); const mc = try multicodec.encodePublicKey(allocator, .p256, &pk); defer allocator.free(mc); const multibase_key = try multibase.encode(allocator, .base58btc, mc); defer allocator.free(multibase_key);
var jwt = try Jwt.parse(allocator, token); defer jwt.deinit(); try jwt.verify(multibase_key);}
test "verifyJose rejects out-of-range S without panicking" { const Scheme = crypto.sign.ecdsa.EcdsaP256Sha256; const sk_bytes = [_]u8{ 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, }; const message = "out of range";
const low = try signP256(message, &sk_bytes); var bad = low.bytes; bad[32..64].* = [_]u8{0xFF} ** 32; // s >= curve order: non-canonical
const sk = try Scheme.SecretKey.fromBytes(sk_bytes); const kp = try Scheme.KeyPair.fromSecretKey(sk); const pk = kp.public_key.toCompressedSec1();
try std.testing.expectError(error.SignatureVerificationFailed, verifyJose(.ES256, message, &bad, &pk));}