Files
maarcadetweet/crates/pds-server/src/jwt_issuer.rs
T
tomdeboneandClaude Opus 5 786a892658 feat(pds): DID-Dokument unter /.well-known/did.json ausliefern
Die AppView soll die Access-Tokens der PDS prüfen können, ohne dass
PDS_JWT_SECRET den PDS-Prozess verlässt. Verifiziert wird ES256 mit dem
*öffentlichen* Teil des P-256-Schlüssels — den veröffentlicht die PDS
jetzt als verificationMethod (Multikey) in ihrem DID-Dokument.

Damit fällt auch die hartkodierte Service-DID: describeServer gab stur
did:web:pds.maarcadetweet.local zurück, unabhängig von PDS_PUBLIC_URL.
Beide Endpoints leiten sie jetzt aus einer Quelle ab
(AppConfig::pds_did(), did:web-Regel mit %3A-kodiertem Port). Der `iss`
des Access-Tokens baute die DID zuvor ohne Port-Kodierung zusammen —
also in einer Form, der kein did:web-Resolver folgen könnte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:01:16 +02:00

79 lines
2.7 KiB
Rust

use anyhow::Result;
use at_crypto::jwt::JwtClaims;
use at_crypto::ecdsa::P256Keypair;
use at_shared::config::AppConfig;
pub fn server_p256_keypair(cfg: &AppConfig) -> Result<P256Keypair> {
use p256::elliptic_curve::sec1::ToEncodedPoint;
let raw = hex::decode(cfg.pds_jwt_secret.trim_start_matches("0x"))?;
if raw.len() < 32 {
anyhow::bail!("PDS_JWT_SECRET must be ≥ 32 bytes for P-256 key");
}
let mut bytes = [0u8; 32];
bytes.copy_from_slice(&raw[..32]);
let sk = p256::SecretKey::from_bytes((&bytes).into())
.map_err(|e| anyhow::anyhow!("p256 sk: {e}"))?;
let vk = sk.public_key();
let pt = vk.to_encoded_point(false);
let mut mb_raw = vec![0x80u8, 0x12u8];
mb_raw.extend_from_slice(pt.x().unwrap());
mb_raw.extend_from_slice(pt.y().unwrap());
let secret_hex = hex::encode(sk.to_bytes());
let public_multibase = at_crypto::multibase_util::encode_b58btc(&mb_raw);
Ok(P256Keypair {
secret_hex,
public_multibase,
})
}
pub fn server_p256_public_multibase(cfg: &AppConfig) -> Result<String> {
Ok(server_p256_keypair(cfg)?.public_multibase)
}
pub fn issue_access_jwt(
cfg: &AppConfig,
did: &str,
_handle: &str,
) -> Result<(String, i64)> {
let kp = server_p256_keypair(cfg)?;
let now = chrono::Utc::now().timestamp();
let exp = now + 3600;
let claims = JwtClaims {
// Same derivation as `describeServer` and `/.well-known/did.json`
// (`AppConfig::pds_did`), so a verifier can take `iss`, resolve
// the did:web document and arrive at the key this token is
// signed with. The previous inline version dropped the
// percent-encoding of the port, producing an `iss` that no
// did:web resolver could follow.
iss: cfg.pds_did(),
sub: did.to_string(),
aud: "did:web:appview.maarcadetweet.local".into(),
iat: now,
exp,
jti: Some(uuid::Uuid::new_v4().to_string()),
scope: Some("com.atproto.access".into()),
};
let token = at_crypto::jwt::issue_jwt(&kp, &claims)?;
Ok((token, exp))
}
pub fn issue_refresh_jwt(
cfg: &AppConfig,
did: &str,
) -> Result<(String, i64)> {
let kp = server_p256_keypair(cfg)?;
let now = chrono::Utc::now().timestamp();
let exp = now + 90 * 24 * 3600;
let claims = JwtClaims {
iss: "did:web:refresh.maarcadetweet.local".into(),
sub: did.to_string(),
aud: "did:web:refresh.maarcadetweet.local".into(),
iat: now,
exp,
jti: Some(uuid::Uuid::new_v4().to_string()),
scope: Some("com.atproto.refresh".into()),
};
let token = at_crypto::jwt::issue_jwt(&kp, &claims)?;
Ok((token, exp))
}