techne-sdk 0.3.3

TechneSDK's logic written once: version ranking (eventState) and offline record verification over the published check, shipped to TypeScript as WebAssembly; with opt-in atproto plumbing (service auth, DID resolution, signed labels, proven record fetches, PDS password sessions) behind the `crypto` and `net` features.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
//! atproto inter-service-auth JWTs (ES256K): verify, pre-check, mint.
//!
//! Ported from regenOS `regenos_appview::auth::service_jwt` (P20.7 G4); the platform-only pieces
//! (`principal_for_jwt` and its Postgres upserts) stay there. [`crate::service_auth::ServiceAuth`]
//! composes these with a DID resolver and a `jti` cache.
//!
//! Cross-service calls carry a short-lived JWT minted by `com.atproto.server.getServiceAuth`, signed
//! by the account's `#atproto` signing key, with claims `iss` (account DID), `aud` (recipient service
//! DID), `lxm` (the lexicon method this token authorizes), `exp`, optional `iat`, and `jti`/`nonce`.
//!
//! - `iat` is OPTIONAL; `lxm` is REQUIRED; `jti` OR `nonce` is required (surfaced for the replay cache).
//! - `aud` accepts the bare service DID or that DID with the `#atproto_appview` fragment, nothing else.
//! - low-S is required (atproto malleability guard).
//! - a MAX lifetime ceiling ([`MAX_SERVICE_JWT_LIFETIME_SECS`]) bounds the fail-open `jti` cache's
//!   replay window.
//!
//! [`multibase_to_verifying_key`] decodes a `z…` secp256k1 multikey (rejecting P-256) into a key.

use base64::Engine;
use k256::ecdsa::signature::Verifier;
use k256::ecdsa::{Signature, VerifyingKey};

const B64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;

/// The atproto multicodec prefix for a secp256k1 public key (`0xe7` varint-encoded as `0xe7 0x01`). The
/// `did:key` / DID-doc `#atproto` multibase encodes `prefix || compressed-sec1-pubkey`.
const MULTICODEC_SECP256K1: [u8; 2] = [0xe7, 0x01];
/// The multicodec prefix for a p256 (secp256r1) public key — REJECTED (we verify ES256K only).
const MULTICODEC_P256: [u8; 2] = [0x80, 0x24];

/// The maximum accepted lifetime (`exp - now`) of a Lane-B service-auth JWT. atproto `getServiceAuth`
/// tokens are short-lived (~60s); we reject anything claiming materially more. This makes REAL the §3.4
/// safety argument that the fail-OPEN `jti` replay cache's exposure is bounded by a short `exp`: without a
/// ceiling a signer could mint a years-long token that, during a cache outage, is replayable for its full
/// life. 300s is a generous bound over the ~60s convention (clock skew + minting latency).
pub const MAX_SERVICE_JWT_LIFETIME_SECS: u64 = 300;

/// Why a service-auth JWT was rejected. Every non-happy path is fail-closed to one of these.
#[derive(Debug, PartialEq, Eq)]
pub enum ServiceJwtError {
    /// Not three base64url segments / undecodable / non-JSON.
    Malformed,
    /// Header `alg` is not `ES256K` (we only accept the secp256k1 method).
    BadAlg,
    /// A required claim (`iss`/`aud`/`lxm`/`exp`, or a `jti`/`nonce`) is missing or the wrong type.
    MissingClaim,
    /// ECDSA verification against the resolved `#atproto` key failed (tamper / wrong signer).
    BadSignature,
    /// Signature was high-S — atproto requires low-S (malleability guard).
    HighS,
    /// `aud` is not this AppView's service DID — token minted for some other service.
    AudMismatch,
    /// `lxm` is not the method being called — token authorizes a different endpoint.
    LxmMismatch,
    /// `exp` is in the past.
    Expired,
    /// `exp` is too far in the future — the token's claimed lifetime exceeds
    /// [`MAX_SERVICE_JWT_LIFETIME_SECS`]. Bounds the fail-OPEN `jti` cache's replay window (§3.4).
    ExcessiveLifetime,
    /// The multibase `#atproto` key was undecodable, the wrong multicodec (e.g. p256), or not a valid
    /// secp256k1 point. Surfaced by [`multibase_to_verifying_key`].
    BadKey,
}

/// The claims we trust *after* signature + audience + method + expiry all pass.
#[derive(Debug, PartialEq, Eq)]
pub struct VerifiedClaims {
    /// `iss` — the account DID the request is on behalf of. Becomes `Principal::User(iss)`.
    pub iss: String,
    /// `lxm` — the method this token authorizes (== the called NSID). The Lane-B scope set is `[lxm]`.
    pub lxm: String,
    /// The replay nonce — `jti` if present, else `nonce`. The caller feeds this to the `JtiCache`.
    pub jti: String,
    /// `exp` (unix seconds) — surfaced so the caller can record `(jti, exp)` in the replay cache.
    pub exp: u64,
}

/// Sign `msg` with the k256 private key given as hex (`hex_priv`), returning the base64url-nopad
/// (`r‖s`, 64 bytes) signature. k256's `Signer` always produces a LOW-S signature. `None` on
/// malformed hex / an invalid scalar (bad key material is a caller config error, never a panic).
/// [`multibase_to_verifying_key`] over the published `#atproto` key is its verification inverse.
pub fn sign_bytes(hex_priv: &str, msg: &[u8]) -> Option<String> {
    use k256::ecdsa::signature::Signer;
    use k256::ecdsa::{Signature, SigningKey};

    let priv_bytes = hex::decode(hex_priv.trim()).ok()?;
    let sk = SigningKey::from_slice(&priv_bytes).ok()?;
    let sig: Signature = sk.sign(msg);
    Some(B64.encode(sig.to_bytes()))
}

/// Mint an atproto inter-service-auth JWT (ES256K) signed with the caller's own service key: the
/// outbound twin of [`verify_service_jwt`]. `iss` is the caller's service DID (whose DID document
/// publishes the key at `#atproto`), `aud` the callee's DID, `lxm` the one method it authorizes;
/// `exp` is `now_unix + ttl_secs`, and a fresh random `jti` makes each token single-use. `None` on
/// malformed key material.
pub fn mint_service_jwt(hex_priv: &str, iss: &str, aud: &str, lxm: &str, ttl_secs: u64, now_unix: u64) -> Option<String> {
    let mut nonce = [0u8; 16];
    getrandom::getrandom(&mut nonce).ok()?;
    let header = serde_json::json!({ "typ": "JWT", "alg": "ES256K" });
    let payload = serde_json::json!({
        "iss": iss, "aud": aud, "lxm": lxm,
        "iat": now_unix, "exp": now_unix + ttl_secs, "jti": hex::encode(nonce),
    });
    let signing_input = format!(
        "{}.{}",
        B64.encode(serde_json::to_vec(&header).ok()?),
        B64.encode(serde_json::to_vec(&payload).ok()?)
    );
    let sig = sign_bytes(hex_priv, signing_input.as_bytes())?;
    Some(format!("{signing_input}.{sig}"))
}

/// Verify an atproto inter-service-auth JWT, GIVEN the signer's already-resolved `#atproto` key.
///
/// Ordering is fail-closed: structure → alg → signature → low-S → aud → lxm → expiry → required claims.
/// The signature is checked BEFORE any claim is trusted. `now_unix` is injected so the check is
/// deterministic in tests. Replay (`jti`) dedup is the CALLER's responsibility (it has the cache); this
/// function only surfaces the nonce in [`VerifiedClaims::jti`].
pub fn verify_service_jwt(
    token: &str,
    signer_key: &VerifyingKey,
    expected_aud: &str,
    expected_lxm: &str,
    now_unix: u64,
) -> Result<VerifiedClaims, ServiceJwtError> {
    // 1. Split `header.payload.signature`.
    let parts: Vec<&str> = token.split('.').collect();
    if parts.len() != 3 {
        return Err(ServiceJwtError::Malformed);
    }
    let (b64_header, b64_payload, b64_sig) = (parts[0], parts[1], parts[2]);

    // 2. Header: must be ES256K (the secp256k1 JWS alg).
    let header: serde_json::Value = decode_json(b64_header)?;
    if header.get("alg").and_then(|v| v.as_str()) != Some("ES256K") {
        return Err(ServiceJwtError::BadAlg);
    }

    // 3. Signature, over the ASCII `header.payload` signing input. k256 hashes SHA-256 internally,
    //    matching JWS ES256K. We verify the signature BEFORE trusting any claim.
    let sig_bytes = B64.decode(b64_sig).map_err(|_| ServiceJwtError::Malformed)?;
    let sig = Signature::from_slice(&sig_bytes).map_err(|_| ServiceJwtError::Malformed)?;
    if sig.normalize_s().is_some() {
        // `normalize_s` returns Some only when the input was high-S — atproto requires low-S.
        return Err(ServiceJwtError::HighS);
    }
    let signing_input = format!("{b64_header}.{b64_payload}");
    signer_key
        .verify(signing_input.as_bytes(), &sig)
        .map_err(|_| ServiceJwtError::BadSignature)?;

    // 4. Claims — only now that the signature holds.
    let payload: serde_json::Value = decode_json(b64_payload)?;
    let iss = claim_str(&payload, "iss")?;
    let aud = claim_str(&payload, "aud")?;
    let lxm = claim_str(&payload, "lxm")?;
    let exp = claim_u64(&payload, "exp")?;
    // §3.4: `iat` is OPTIONAL — read it if present, never error on its absence.
    let _iat = payload.get("iat").and_then(|v| v.as_u64());
    // §3.4: accept `jti` OR `nonce` (a PDS emits `jti`; a lex-server reads `nonce`). At least one is
    // required so the replay cache has a key.
    let jti = payload
        .get("jti")
        .and_then(|v| v.as_str())
        .or_else(|| payload.get("nonce").and_then(|v| v.as_str()))
        .map(str::to_owned)
        .ok_or(ServiceJwtError::MissingClaim)?;

    // §3.4: `aud` accepts BOTH the bare service DID and the `#atproto_appview` fragment form (forward-safe).
    if !aud_matches(&aud, expected_aud) {
        return Err(ServiceJwtError::AudMismatch);
    }
    if !lxm_matches(&lxm, expected_lxm, now_unix) {
        return Err(ServiceJwtError::LxmMismatch);
    }
    if now_unix >= exp {
        return Err(ServiceJwtError::Expired);
    }
    // Lifetime ceiling: `exp > now` here (checked above), so `exp - now` is the remaining life. A token
    // claiming materially more than the ~60s service-auth convention is rejected, keeping the fail-OPEN
    // `jti` cache's replay window bounded (§3.4).
    if exp - now_unix > MAX_SERVICE_JWT_LIFETIME_SECS {
        return Err(ServiceJwtError::ExcessiveLifetime);
    }

    Ok(VerifiedClaims { iss, lxm, jti, exp })
}

/// The one service fragment an `aud` may carry.
const AUD_FRAGMENT: &str = "#atproto_appview";

/// `aud` matches iff it is the expected service DID, bare or with the `#atproto_appview` fragment.
/// The configured DID may carry that fragment too. Any other fragment (`#bsky_appview`,
/// `#atproto_labeler`, ...) names a different service and is refused, as is a configured DID with one.
fn aud_matches(aud: &str, expected: &str) -> bool {
    let bare = |s: &str| -> Option<String> {
        let did = s.strip_suffix(AUD_FRAGMENT).unwrap_or(s);
        (!did.is_empty() && !did.contains('#')).then(|| did.to_owned())
    };
    matches!((bare(aud), bare(expected)), (Some(a), Some(e)) if a == e)
}

/// The decode inverse of the `did:key` multikey encoder ([`crate::labels::multibase`]): a multibase
/// (`z`-prefixed base58btc) secp256k1 public key → a [`VerifyingKey`].
///
/// Steps: strip the leading `z` → `bs58::decode` → assert the multicodec prefix is `[0xe7,0x01]` (REJECT
/// p256 `[0x80,0x24]` and anything else) → `VerifyingKey::from_sec1_bytes(&bytes[2..])` over the
/// remaining compressed SEC1 point. Any failure → [`ServiceJwtError::BadKey`].
pub fn multibase_to_verifying_key(s: &str) -> Result<VerifyingKey, ServiceJwtError> {
    // Multibase base58btc is `z`-prefixed (the only base atproto did:key uses).
    let b58 = s.strip_prefix('z').ok_or(ServiceJwtError::BadKey)?;
    let bytes = bs58::decode(b58).into_vec().map_err(|_| ServiceJwtError::BadKey)?;
    if bytes.len() < 2 {
        return Err(ServiceJwtError::BadKey);
    }
    let prefix = [bytes[0], bytes[1]];
    if prefix == MULTICODEC_P256 {
        // Explicitly reject p256 — we verify ES256K only.
        return Err(ServiceJwtError::BadKey);
    }
    if prefix != MULTICODEC_SECP256K1 {
        return Err(ServiceJwtError::BadKey);
    }
    VerifyingKey::from_sec1_bytes(&bytes[2..]).map_err(|_| ServiceJwtError::BadKey)
}

/// `lxm` binds the called method `expected`: the same name, or an old name of it that the platform's
/// rename map still honours on `now_unix`'s day (C6.3, N1, as the platform's `lxm_matches`: the alias
/// layer renames an old method path before auth, while a draft-era client's token still names the method
/// it called). Never the other way round.
fn lxm_matches(lxm: &str, expected: &str, now_unix: u64) -> bool {
    lxm == expected || crate::renames::current_name_at(lxm, now_unix) == Some(expected)
}

/// The cheap, crypto-free, I/O-free Lane-B claims pre-check (§3.4 DoS bound). Decode the UNVERIFIED JWT
/// payload (the middle base64url-no-pad segment) ONCE and reject early on the four pure-local conditions —
/// in the cheapest-first order — returning the `iss` string ONLY for a token that survives all of them:
///   1. `aud == our service DID` (the strongest, cheapest filter: a pure string compare, no crypto, no I/O —
///      a flood of Bearers minted for some OTHER service is dropped here, before any PLC lookup);
///   2. `lxm` is present AND binds the called `nsid` ([`lxm_matches`]: the same name, or an old name of it);
///   3. `exp` is present AND `> now` (not expired);
///   4. `iss` is present AND a SYNTACTIC DID ([`crate::did::is_did`] — a pure local validation).
///
/// Returns `None` (⇒ no resolution, held to Anonymous) on ANY structural/decoding failure or any of the
/// four rejections. This reads the UNVERIFIED payload, so it is a fast REJECT-ONLY filter: the
/// signature/crypto in [`verify_service_jwt`] RE-checks every one of these against the signed payload before
/// any authority is granted, so a forged claim that slips the pre-check is still caught post-resolution.
///
/// Public so every service that verifies these tokens ([`crate::service_auth::ServiceAuth`]) runs the
/// identical pre-check → resolve → verify → replay pipeline.
pub fn precheck_claims(jwt: &str, expected_aud: &str, expected_lxm: &str, now_unix: u64) -> Option<String> {
    let payload_b64 = jwt.split('.').nth(1)?;
    let bytes = B64.decode(payload_b64).ok()?;
    let payload: serde_json::Value = serde_json::from_slice(&bytes).ok()?;

    // 1. `aud` — the cheapest, strongest filter (pure string compare). A token for another service stops here.
    let aud = payload.get("aud").and_then(|v| v.as_str())?;
    if !aud_matches(aud, expected_aud) {
        return None;
    }
    // 2. `lxm` — must be present and bind to the called method.
    let lxm = payload.get("lxm").and_then(|v| v.as_str())?;
    if !lxm_matches(lxm, expected_lxm, now_unix) {
        return None;
    }
    // 3. `exp` — must be present and in the future.
    let exp = payload.get("exp").and_then(|v| v.as_u64())?;
    if now_unix >= exp {
        return None;
    }
    // 3b. Lifetime ceiling (§3.4 — DoS + fail-open-cache bound): reject a token claiming a lifetime far
    // beyond the ~60s service-auth convention BEFORE resolving its DID. `verify_service_jwt` re-checks this.
    if exp - now_unix > MAX_SERVICE_JWT_LIFETIME_SECS {
        return None;
    }
    // 4. `iss` — must be present and a syntactic DID (so we never resolve a non-DID string).
    let iss = payload.get("iss").and_then(|v| v.as_str())?;
    if !crate::did::is_did(iss) {
        return None;
    }
    Some(iss.to_owned())
}

fn decode_json(seg: &str) -> Result<serde_json::Value, ServiceJwtError> {
    let bytes = B64.decode(seg).map_err(|_| ServiceJwtError::Malformed)?;
    serde_json::from_slice(&bytes).map_err(|_| ServiceJwtError::Malformed)
}

fn claim_str(payload: &serde_json::Value, key: &str) -> Result<String, ServiceJwtError> {
    payload
        .get(key)
        .and_then(|v| v.as_str())
        .map(str::to_owned)
        .ok_or(ServiceJwtError::MissingClaim)
}

fn claim_u64(payload: &serde_json::Value, key: &str) -> Result<u64, ServiceJwtError> {
    payload
        .get(key)
        .and_then(|v| v.as_u64())
        .ok_or(ServiceJwtError::MissingClaim)
}

#[cfg(test)]
mod tests {
    use super::*;
    use k256::ecdsa::signature::Signer;
    use k256::ecdsa::SigningKey;

    const ISS: &str = "did:plc:alice0000000000000000000";
    const AUD: &str = "did:web:scenius.makewith.you"; // the AppView service DID
    const LXM: &str = "pub.techne.draft.getMyAttention";
    const NOW: u64 = 1_750_000_000;

    fn keypair() -> (SigningKey, VerifyingKey) {
        // Deterministic secp256k1 test scalar (mirrors identity::keys::FileEnvKeyStore::for_test).
        let sk = SigningKey::from_slice(&[0x11u8; 32]).expect("valid scalar");
        let vk = *sk.verifying_key();
        (sk, vk)
    }

    /// Test-only minter: stands in for `com.atproto.server.getServiceAuth` so the round-trip is
    /// self-contained. `iat`/`jti`/`nonce` are passed through so the gap-fix tests can vary them.
    /// Borrowed params for the test-only [`mint`] minter (the arg list was 8).
    struct MintParams<'a> {
        signing_key: &'a SigningKey,
        iss: &'a str,
        aud: &'a str,
        lxm: &'a str,
        exp: u64,
        iat: Option<u64>,
        jti: Option<&'a str>,
        nonce: Option<&'a str>,
    }

    fn mint(p: MintParams<'_>) -> String {
        let MintParams { signing_key, iss, aud, lxm, exp, iat, jti, nonce } = p;
        let header = serde_json::json!({ "typ": "JWT", "alg": "ES256K" });
        let mut payload = serde_json::json!({ "iss": iss, "aud": aud, "lxm": lxm, "exp": exp });
        if let Some(iat) = iat {
            payload["iat"] = serde_json::json!(iat);
        }
        if let Some(jti) = jti {
            payload["jti"] = serde_json::json!(jti);
        }
        if let Some(nonce) = nonce {
            payload["nonce"] = serde_json::json!(nonce);
        }
        let b64_header = B64.encode(serde_json::to_vec(&header).unwrap());
        let b64_payload = B64.encode(serde_json::to_vec(&payload).unwrap());
        let signing_input = format!("{b64_header}.{b64_payload}");
        let sig: Signature = signing_key.sign(signing_input.as_bytes());
        let b64_sig = B64.encode(sig.to_bytes());
        format!("{signing_input}.{b64_sig}")
    }

    fn good_token(sk: &SigningKey) -> String {
        mint(MintParams { signing_key: sk, iss: ISS, aud: AUD, lxm: LXM, exp: NOW + 60, iat: Some(NOW), jti: Some("nonce-0001"), nonce: None })
    }

    // ── the 8 promoted spike tests ──────────────────────────────────────────────────────────────

    #[test]
    fn valid_token_verifies_and_yields_the_issuer_did() {
        let (sk, vk) = keypair();
        let claims = verify_service_jwt(&good_token(&sk), &vk, AUD, LXM, NOW).expect("should verify");
        assert_eq!(claims.iss, ISS, "iss becomes Principal::User(iss)");
        assert_eq!(claims.lxm, LXM);
        assert_eq!(claims.jti, "nonce-0001");
        assert_eq!(claims.exp, NOW + 60);
    }

    #[test]
    fn tampered_payload_fails_signature() {
        let (sk, vk) = keypair();
        let token = good_token(&sk);
        let mut parts: Vec<String> = token.split('.').map(str::to_owned).collect();
        let forged = serde_json::json!({
            "iss": "did:plc:mallory00000000000000000", "aud": AUD, "lxm": LXM, "exp": NOW + 60, "iat": NOW, "jti": "x",
        });
        parts[1] = B64.encode(serde_json::to_vec(&forged).unwrap());
        let token = parts.join(".");
        assert_eq!(verify_service_jwt(&token, &vk, AUD, LXM, NOW), Err(ServiceJwtError::BadSignature));
    }

    #[test]
    fn wrong_signer_key_fails_signature() {
        let (sk, _) = keypair();
        let other = SigningKey::from_slice(&[0x22u8; 32]).unwrap();
        let wrong_vk = *other.verifying_key();
        assert_eq!(
            verify_service_jwt(&good_token(&sk), &wrong_vk, AUD, LXM, NOW),
            Err(ServiceJwtError::BadSignature)
        );
    }

    #[test]
    fn token_for_another_service_is_rejected() {
        let (sk, vk) = keypair();
        let token = mint(MintParams { signing_key: &sk, iss: ISS, aud: "did:web:someone-else.example", lxm: LXM, exp: NOW + 60, iat: Some(NOW), jti: Some("x"), nonce: None });
        assert_eq!(verify_service_jwt(&token, &vk, AUD, LXM, NOW), Err(ServiceJwtError::AudMismatch));
    }

    #[test]
    fn token_for_another_method_is_rejected() {
        let (sk, vk) = keypair();
        let token = mint(MintParams { signing_key: &sk, iss: ISS, aud: AUD, lxm: "pub.techne.draft.deleteEverything", exp: NOW + 60, iat: Some(NOW), jti: Some("x"), nonce: None });
        assert_eq!(verify_service_jwt(&token, &vk, AUD, LXM, NOW), Err(ServiceJwtError::LxmMismatch));
    }

    #[test]
    fn expired_token_is_rejected() {
        let (sk, vk) = keypair();
        let token = mint(MintParams { signing_key: &sk, iss: ISS, aud: AUD, lxm: LXM, exp: NOW - 1, iat: Some(NOW - 61), jti: Some("x"), nonce: None });
        assert_eq!(verify_service_jwt(&token, &vk, AUD, LXM, NOW), Err(ServiceJwtError::Expired));
    }

    #[test]
    fn excessive_lifetime_is_rejected() {
        // A token whose exp is far beyond the ~60s service-auth convention is rejected, so the fail-OPEN
        // jti cache's replay window stays bounded (§3.4). A signer controls its own exp, so the ceiling
        // must be enforced by the VERIFIER, not assumed.
        let (sk, vk) = keypair();
        let token = mint(MintParams { signing_key: &sk, iss: ISS, aud: AUD, lxm: LXM, exp: NOW + 86_400, iat: Some(NOW), jti: Some("x"), nonce: None });
        assert_eq!(
            verify_service_jwt(&token, &vk, AUD, LXM, NOW),
            Err(ServiceJwtError::ExcessiveLifetime)
        );
        // Boundary is inclusive: exactly at the ceiling still verifies.
        let at_ceiling = mint(MintParams { signing_key: &sk, iss: ISS, aud: AUD, lxm: LXM, exp: NOW + MAX_SERVICE_JWT_LIFETIME_SECS, iat: Some(NOW), jti: Some("x"), nonce: None });
        assert!(verify_service_jwt(&at_ceiling, &vk, AUD, LXM, NOW).is_ok());
    }

    #[test]
    fn non_es256k_alg_is_rejected() {
        let (_, vk) = keypair();
        let header = B64.encode(br#"{"typ":"JWT","alg":"HS256"}"#);
        let payload = B64.encode(br#"{"iss":"x","aud":"x","lxm":"x","exp":9999999999,"iat":1,"jti":"x"}"#);
        let token = format!("{header}.{payload}.{}", B64.encode([0u8; 64]));
        assert_eq!(verify_service_jwt(&token, &vk, AUD, LXM, NOW), Err(ServiceJwtError::BadAlg));
    }

    #[test]
    fn garbage_is_rejected_not_panicked() {
        let (_, vk) = keypair();
        for junk in ["", "a.b", "...", "not-a-jwt", "a.b.c.d"] {
            assert!(verify_service_jwt(junk, &vk, AUD, LXM, NOW).is_err(), "junk {junk:?} must fail closed");
        }
    }

    // ── §3.4 verify-gap tests ───────────────────────────────────────────────────────────────────

    #[test]
    fn iat_is_optional() {
        // No `iat` at all — must still verify (the spike used to require it).
        let (sk, vk) = keypair();
        let token = mint(MintParams { signing_key: &sk, iss: ISS, aud: AUD, lxm: LXM, exp: NOW + 60, iat: None, jti: Some("x"), nonce: None });
        assert!(verify_service_jwt(&token, &vk, AUD, LXM, NOW).is_ok(), "iat must be optional");
    }

    #[test]
    fn lxm_is_required() {
        let (sk, vk) = keypair();
        // Mint without `lxm` — verify must reject as a missing claim.
        let header = B64.encode(br#"{"typ":"JWT","alg":"ES256K"}"#);
        let payload = serde_json::json!({ "iss": ISS, "aud": AUD, "exp": NOW + 60, "jti": "x" });
        let b64_payload = B64.encode(serde_json::to_vec(&payload).unwrap());
        let signing_input = format!("{header}.{b64_payload}");
        let sig: Signature = sk.sign(signing_input.as_bytes());
        let token = format!("{signing_input}.{}", B64.encode(sig.to_bytes()));
        assert_eq!(verify_service_jwt(&token, &vk, AUD, LXM, NOW), Err(ServiceJwtError::MissingClaim));
    }

    #[test]
    fn nonce_is_accepted_when_jti_absent() {
        let (sk, vk) = keypair();
        let token = mint(MintParams { signing_key: &sk, iss: ISS, aud: AUD, lxm: LXM, exp: NOW + 60, iat: Some(NOW), jti: None, nonce: Some("nonce-from-lexserver") });
        let claims = verify_service_jwt(&token, &vk, AUD, LXM, NOW).expect("nonce should be accepted");
        assert_eq!(claims.jti, "nonce-from-lexserver");
    }

    #[test]
    fn missing_jti_and_nonce_is_rejected() {
        let (sk, vk) = keypair();
        let token = mint(MintParams { signing_key: &sk, iss: ISS, aud: AUD, lxm: LXM, exp: NOW + 60, iat: Some(NOW), jti: None, nonce: None });
        assert_eq!(verify_service_jwt(&token, &vk, AUD, LXM, NOW), Err(ServiceJwtError::MissingClaim));
    }

    #[test]
    fn aud_accepts_the_atproto_appview_fragment_form() {
        let (sk, vk) = keypair();
        // Token carries the fragment; our configured aud is bare — must still match.
        let token = mint(MintParams { signing_key: &sk, iss: ISS, aud: &format!("{AUD}#atproto_appview"), lxm: LXM, exp: NOW + 60, iat: Some(NOW), jti: Some("x"), nonce: None });
        assert!(verify_service_jwt(&token, &vk, AUD, LXM, NOW).is_ok());
        // And the symmetric case: configured aud carries the fragment, token is bare.
        let token2 = good_token(&sk);
        assert!(verify_service_jwt(&token2, &vk, &format!("{AUD}#atproto_appview"), LXM, NOW).is_ok());
    }

    #[test]
    fn aud_refuses_any_other_fragment() {
        let (sk, vk) = keypair();
        for aud in [format!("{AUD}#bsky_appview"), format!("{AUD}#atproto_labeler"), format!("{AUD}#"), format!("{AUD}#atproto_appview#atproto_appview")] {
            let token = mint(MintParams { signing_key: &sk, iss: ISS, aud: &aud, lxm: LXM, exp: NOW + 60, iat: Some(NOW), jti: Some("x"), nonce: None });
            assert_eq!(verify_service_jwt(&token, &vk, AUD, LXM, NOW).err(), Some(ServiceJwtError::AudMismatch), "{aud}");
            assert_eq!(precheck_claims(&token, AUD, LXM, NOW), None, "{aud}");
        }
        // A configured DID with another fragment matches nothing, not even itself.
        let token = mint(MintParams { signing_key: &sk, iss: ISS, aud: &format!("{AUD}#other"), lxm: LXM, exp: NOW + 60, iat: Some(NOW), jti: Some("x"), nonce: None });
        assert_eq!(verify_service_jwt(&token, &vk, &format!("{AUD}#other"), LXM, NOW).err(), Some(ServiceJwtError::AudMismatch));
        assert!(!aud_matches(AUD, &format!("{AUD}#other")));
        assert!(!aud_matches("#atproto_appview", "#atproto_appview"));
    }

    #[test]
    fn high_s_signature_is_rejected() {
        // Construct a high-S signature by negating S of a valid low-S signature. atproto requires low-S.
        let (sk, vk) = keypair();
        let header = B64.encode(br#"{"typ":"JWT","alg":"ES256K"}"#);
        let payload = serde_json::json!({ "iss": ISS, "aud": AUD, "lxm": LXM, "exp": NOW + 60, "jti": "x" });
        let b64_payload = B64.encode(serde_json::to_vec(&payload).unwrap());
        let signing_input = format!("{header}.{b64_payload}");
        let sig: Signature = sk.sign(signing_input.as_bytes());
        // k256 always produces low-S; flip to high-S so the verifier's low-S guard fires.
        let high = sig.normalize_s().unwrap_or(sig);
        // `normalize_s` returns the low-S form; to get a high-S we need the complement. Compute it.
        let high_s_sig = {
            // S' = n - S yields the high-S counterpart. Use the scalar arithmetic via the signature bytes.
            use k256::elliptic_curve::scalar::IsHigh;
            let s = high.s();
            let neg_s = -*s;
            // If S was already low, -S is high.
            let candidate = Signature::from_scalars(*high.r(), neg_s).unwrap();
            assert!(bool::from(candidate.s().is_high()), "candidate must be high-S");
            candidate
        };
        let token = format!("{signing_input}.{}", B64.encode(high_s_sig.to_bytes()));
        assert_eq!(verify_service_jwt(&token, &vk, AUD, LXM, NOW), Err(ServiceJwtError::HighS));
    }

    // ── multibase_to_verifying_key: the decode inverse of keys.rs ───────────────────────────────

    /// Encode a VerifyingKey the way `keys.rs:121-131` does, so the decode round-trips.
    fn encode_did_key_multibase(vk: &VerifyingKey) -> String {
        let compressed = vk.to_encoded_point(true);
        let mut prefixed = Vec::with_capacity(2 + compressed.as_bytes().len());
        prefixed.extend_from_slice(&MULTICODEC_SECP256K1);
        prefixed.extend_from_slice(compressed.as_bytes());
        format!("z{}", bs58::encode(prefixed).into_string())
    }

    #[test]
    fn multibase_round_trips_the_keys_rs_encoder() {
        let (_, vk) = keypair();
        let mb = encode_did_key_multibase(&vk);
        let decoded = multibase_to_verifying_key(&mb).expect("should decode our own encoding");
        assert_eq!(decoded, vk, "decode must invert the keys.rs encoder");
    }

    #[test]
    fn multibase_rejects_p256_multicodec() {
        // A `z`-prefixed base58 of the p256 multicodec + 33 junk bytes — must be rejected (BadKey),
        // never silently treated as secp256k1.
        let mut bytes = MULTICODEC_P256.to_vec();
        bytes.extend_from_slice(&[0x02; 33]);
        let mb = format!("z{}", bs58::encode(&bytes).into_string());
        assert_eq!(multibase_to_verifying_key(&mb), Err(ServiceJwtError::BadKey));
    }

    #[test]
    fn multibase_rejects_garbage() {
        assert_eq!(multibase_to_verifying_key(""), Err(ServiceJwtError::BadKey)); // no `z`
        assert_eq!(multibase_to_verifying_key("zzz!!!"), Err(ServiceJwtError::BadKey)); // bad base58
        assert_eq!(multibase_to_verifying_key("z"), Err(ServiceJwtError::BadKey)); // empty body
    }

    #[test]
    fn verify_chains_through_a_resolved_multibase_key() {
        // The end-to-end Lane-B shape: a DID-doc multibase string → key → verify a real token.
        let (sk, vk) = keypair();
        let mb = encode_did_key_multibase(&vk);
        let resolved = multibase_to_verifying_key(&mb).unwrap();
        let claims = verify_service_jwt(&good_token(&sk), &resolved, AUD, LXM, NOW).expect("verify");
        assert_eq!(claims.iss, ISS);
    }

    // ── sign_bytes: the channelVerification writer's signing primitive ────────────────────────────

    #[test]
    fn sign_bytes_round_trips_through_verifying_key() {
        use k256::ecdsa::signature::Verifier;

        let (sk, vk) = keypair();
        let hex_priv = hex::encode(sk.to_bytes());
        let msg = b"the quick brown fox";
        let sig_b64 = sign_bytes(&hex_priv, msg).expect("valid scalar signs");
        let sig_bytes = B64.decode(&sig_b64).expect("base64url-nopad");
        let sig = Signature::from_slice(&sig_bytes).expect("64-byte r||s");
        assert!(vk.verify(msg, &sig).is_ok(), "the resolved verifying key must accept the signature");
    }

    #[test]
    fn sign_bytes_is_deterministic_and_message_bound() {
        let (sk, _) = keypair();
        let hex_priv = hex::encode(sk.to_bytes());
        let a = sign_bytes(&hex_priv, b"payload a").unwrap();
        let b = sign_bytes(&hex_priv, b"payload a").unwrap();
        assert_eq!(a, b, "RFC 6979 deterministic ECDSA: same key + same message ⇒ same signature");
        let c = sign_bytes(&hex_priv, b"payload b").unwrap();
        assert_ne!(a, c, "a different message must yield a different signature");
    }

    #[test]
    fn sign_bytes_is_always_low_s() {
        let (sk, _) = keypair();
        let hex_priv = hex::encode(sk.to_bytes());
        for msg in [&b"one"[..], b"two", b"three-longer-message"] {
            let sig_b64 = sign_bytes(&hex_priv, msg).unwrap();
            let sig_bytes = B64.decode(&sig_b64).unwrap();
            let sig = Signature::from_slice(&sig_bytes).unwrap();
            assert!(sig.normalize_s().is_none(), "k256 must produce a low-S signature");
        }
    }

    #[test]
    fn sign_bytes_rejects_malformed_key_material() {
        assert!(sign_bytes("not-hex", b"msg").is_none());
        assert!(sign_bytes("00", b"msg").is_none(), "wrong-length scalar");
    }

    /// The last day the platform's rename map honours `old` (its `closeEarlier` date, else `deleteAfter`),
    /// as a unix time at noon UTC that day; read straight off the crate's copy of the map.
    fn last_day_noon(old: &str) -> u64 {
        let doc: serde_json::Value = serde_json::from_str(include_str!("nsid_renames.json")).unwrap();
        let early = doc["closeEarlier"].as_object().unwrap().iter().find(|(_, olds)| olds.as_array().unwrap().iter().any(|o| o == old));
        let day = early.map(|(d, _)| d.as_str()).unwrap_or_else(|| doc["deleteAfter"].as_str().unwrap());
        let date = chrono::NaiveDate::parse_from_str(day, "%Y-%m-%d").unwrap();
        date.and_hms_opt(12, 0, 0).unwrap().and_utc().timestamp() as u64
    }

    /// C6.3 (N1, the platform's 2ebc41bf6): a token minted for an old name of the called method (a
    /// draft-era client through the PDS proxy asks for `lxm` = the name it calls; the alias layer renamed
    /// the path before auth) binds the method while the rename map honours that name, and is refused from
    /// the day after its window closes. Never the other way round, nor another method. Both kinds of
    /// pair: a pre-N1 method rename (social.scenius.*Scene, closes early) and an N1 one (coop.lexicon.draft.*).
    #[test]
    fn an_old_name_of_the_called_method_binds_inside_its_window_only() {
        let (sk, vk) = keypair();
        let doc: serde_json::Value = serde_json::from_str(include_str!("nsid_renames.json")).unwrap();
        let renames = doc["renames"].as_object().unwrap();
        let pair = |pick: &dyn Fn(&str) -> bool| {
            let (o, n) = renames.iter().find(|(o, _)| pick(o)).expect("a renamed method");
            (o.clone(), n.as_str().unwrap().to_owned())
        };
        let pre_n1 = pair(&|o| o.starts_with("social.scenius."));
        let n1 = pair(&|o| o.ends_with(".commons.runChain"));
        for (old_m, new_m) in [pre_n1, n1] {
            let (old_m, new_m) = (old_m.as_str(), new_m.as_str());
            let token = mint(MintParams { signing_key: &sk, iss: ISS, aud: AUD, lxm: old_m, exp: NOW + 60, iat: Some(NOW), jti: Some("x"), nonce: None });
            let claims = verify_service_jwt(&token, &vk, AUD, new_m, NOW).unwrap_or_else(|e| panic!("{old_m} binds {new_m}: {e:?}"));
            assert_eq!(claims.lxm, old_m);
            assert_eq!(precheck_claims(&token, AUD, new_m, NOW).as_deref(), Some(ISS), "{old_m} prechecks for {new_m}");
            // The last day itself is inside the window; the day after is not.
            let last = last_day_noon(old_m);
            let on_last = mint(MintParams { signing_key: &sk, iss: ISS, aud: AUD, lxm: old_m, exp: last + 60, iat: Some(last), jti: Some("w"), nonce: None });
            assert!(verify_service_jwt(&on_last, &vk, AUD, new_m, last).is_ok(), "{old_m}: honoured through its last day");
            let after = last + 86_400;
            let late = mint(MintParams { signing_key: &sk, iss: ISS, aud: AUD, lxm: old_m, exp: after + 60, iat: Some(after), jti: Some("y"), nonce: None });
            assert_eq!(verify_service_jwt(&late, &vk, AUD, new_m, after), Err(ServiceJwtError::LxmMismatch), "{old_m} after its window");
            assert!(precheck_claims(&late, AUD, new_m, after).is_none());
            // Never the other way: a current-name token does not bind an old name, nor another method.
            let current = mint(MintParams { signing_key: &sk, iss: ISS, aud: AUD, lxm: new_m, exp: NOW + 60, iat: Some(NOW), jti: Some("z"), nonce: None });
            assert_eq!(verify_service_jwt(&current, &vk, AUD, old_m, NOW), Err(ServiceJwtError::LxmMismatch));
            assert!(precheck_claims(&current, AUD, old_m, NOW).is_none(), "{new_m} does not precheck for {old_m}");
            assert_eq!(verify_service_jwt(&token, &vk, AUD, LXM, NOW), Err(ServiceJwtError::LxmMismatch));
            assert!(precheck_claims(&token, AUD, LXM, NOW).is_none());
        }
    }
}

#[cfg(test)]
mod mint_tests {
    use super::*;

    /// The outbound token verifies under the key the signer publishes, for exactly its aud and lxm.
    #[test]
    fn a_minted_token_verifies_for_its_aud_and_lxm_only() {
        let hex_priv = hex::encode([0x22u8; 32]);
        let sk = k256::ecdsa::SigningKey::from_slice(&[0x22u8; 32]).unwrap();
        let vk = *sk.verifying_key();
        let lxm = "pub.techne.draft.commons.runChain";
        let now = 1_750_000_000;
        let jwt = mint_service_jwt(&hex_priv, "did:web:appview.example", "did:web:rules.example", lxm, 60, now).unwrap();
        let claims = verify_service_jwt(&jwt, &vk, "did:web:rules.example", lxm, now + 1).unwrap();
        assert_eq!(claims.iss, "did:web:appview.example");
        assert!(verify_service_jwt(&jwt, &vk, "did:web:other.example", lxm, now + 1).is_err());
        assert!(verify_service_jwt(&jwt, &vk, "did:web:rules.example", "pub.techne.draft.getEvent", now + 1).is_err());
        assert!(verify_service_jwt(&jwt, &vk, "did:web:rules.example", lxm, now + 120).is_err(), "expired");
        assert!(mint_service_jwt("not hex", "a", "b", lxm, 60, now).is_none());
    }

}