Something went wrong. Try again.
Read-only ActivityPub → atproto bridge for the threadiverse using Coves lexicons
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482// This file implements draft-cavage HTTP signatures (the flavor the// fediverse actually speaks), exactly as Lemmy validates them.//// What Lemmy requires, from LemmyNet/activitypub-federation-rust// src/http_signatures.rs (the library Lemmy federates with)://// - Verification uses http_signature_normalization Config::new()// .set_expiration(EXPIRES_AFTER).require_digest(), where EXPIRES_AFTER is// one hour. require_digest() marks the Digest header REQUIRED among the// signed headers for EVERY verified request — including signed GETs (the// crate comment says digest "doesn't make sense for GET", but Lemmy// applies one shared config to inbox POSTs and authorized fetches alike).// We therefore always send a Digest header, computed over the empty body// for GETs, and always include it in the signature. bridgy-fed does the// same (HTTP_SIG_HEADERS = ('Date', 'Host', 'Digest', '(request-target)')// for both GET and POST) and interoperates with Lemmy in production.// - Signatures are RSA PKCS#1 v1.5 over SHA-256 (Pkcs1v15Sign::new::<Sha256>).// Lemmy requires RSA actor keys; this is the bridge's AP-side key,// distinct from the atproto secp256k1 repo keys.// - keyId must be "{actorID}#main-key" — Lemmy's signing_actor() extracts// the actor id with the regex keyId="([^"]+)#([^"]+)".// - Lemmy's own outbound signatures cover// "(request-target) content-type date digest host" (see the crate's// test_sign fixture); our Verify accepts any signed-header set that// includes (request-target) and date, and requires digest on requests// with a body.//// We sign "(request-target) host date digest" plus, on requests with a body,// "content-type". The signature algorithm parameter is emitted as// "rsa-sha256"; on verification "hs2019" is treated as rsa-sha256, matching// bridgy-fed's compat note (Mastodon emits hs2019 but still means RSA-SHA256).
package ap
import ( "context" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/pem" "fmt" "net/http" "strings" "time"
"tidepool/internal/errors")
// ServiceKeyBits is the RSA modulus size for generated AP keys, matching// Lemmy's generate_actor_keypair (2048).const ServiceKeyBits = 2048
// maxDateSkew is how far a request's Date header may deviate from now before// verification fails. Lemmy tolerates one hour (EXPIRES_AFTER); we match it.const maxDateSkew = time.Hour
// GenerateRSAKey generates a new 2048-bit RSA keypair for AP signing.func GenerateRSAKey() (*rsa.PrivateKey, error) { key, err := rsa.GenerateKey(rand.Reader, ServiceKeyBits) if err != nil { return nil, fmt.Errorf("ap: generate RSA key: %w", err) } return key, nil}
// EncodePrivateKeyPEM encodes an RSA private key as PKCS#8 PEM, the format// Lemmy uses for its own keys.func EncodePrivateKeyPEM(key *rsa.PrivateKey) ([]byte, error) { der, err := x509.MarshalPKCS8PrivateKey(key) if err != nil { return nil, fmt.Errorf("ap: marshal private key: %w", err) } return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil}
// ParsePrivateKeyPEM decodes a PKCS#8 or PKCS#1 PEM RSA private key.func ParsePrivateKeyPEM(pemBytes []byte) (*rsa.PrivateKey, error) { block, _ := pem.Decode(pemBytes) if block == nil { return nil, fmt.Errorf("ap: private key PEM: no PEM block found") } if parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { rsaKey, ok := parsed.(*rsa.PrivateKey) if !ok { return nil, fmt.Errorf("ap: private key PEM: not an RSA key (%T)", parsed) } return rsaKey, nil } rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) if err != nil { return nil, fmt.Errorf("ap: parse private key PEM: %w", err) } return rsaKey, nil}
// EncodePublicKeyPEM encodes an RSA public key as SPKI PEM ("PUBLIC KEY"),// the format every fediverse implementation publishes in publicKeyPem.func EncodePublicKeyPEM(key *rsa.PublicKey) ([]byte, error) { der, err := x509.MarshalPKIXPublicKey(key) if err != nil { return nil, fmt.Errorf("ap: marshal public key: %w", err) } return pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}), nil}
// ParsePublicKeyPEM decodes an SPKI ("PUBLIC KEY") or PKCS#1 ("RSA PUBLIC// KEY") PEM RSA public key, as found in actors' publicKeyPem fields.func ParsePublicKeyPEM(pemBytes []byte) (*rsa.PublicKey, error) { block, _ := pem.Decode(pemBytes) if block == nil { return nil, fmt.Errorf("ap: public key PEM: no PEM block found") } if parsed, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil { rsaKey, ok := parsed.(*rsa.PublicKey) if !ok { return nil, fmt.Errorf("ap: public key PEM: not an RSA key (%T)", parsed) } return rsaKey, nil } rsaKey, err := x509.ParsePKCS1PublicKey(block.Bytes) if err != nil { return nil, fmt.Errorf("ap: parse public key PEM: %w", err) } return rsaKey, nil}
// Signer signs outbound HTTP requests with the bridge's AP-side RSA key.type Signer struct { keyID string privateKey *rsa.PrivateKey}
// NewSigner creates a Signer. keyID is the full key id published in the// actor document, e.g. "https://bridge.example/actor#main-key".func NewSigner(keyID string, privateKey *rsa.PrivateKey) *Signer { return &Signer{keyID: keyID, privateKey: privateKey}}
// KeyID returns the signer's key id.func (s *Signer) KeyID() string { return s.keyID }
// SignRequest adds Date, Host, Digest, and Signature headers to req, signing// over "(request-target) host date digest" (plus content-type when the// request carries a body). body must be the exact request payload (nil for// GET). The caller sets req.Body itself; SignRequest only reads body to// compute the digest.func (s *Signer) SignRequest(req *http.Request, body []byte) error { now := time.Now().UTC().Format(http.TimeFormat) req.Header.Set("Date", now) // Go sends req.Host (or req.URL.Host) as the Host header; mirror it in // the header map so the signing string and the wire agree. host := req.Host if host == "" { host = req.URL.Host } req.Host = host
digest := "SHA-256=" + base64.StdEncoding.EncodeToString(sha256Sum(body)) req.Header.Set("Digest", digest)
signedHeaders := []string{"(request-target)", "host", "date", "digest"} if len(body) > 0 { // Lemmy signs content-type on its own POSTs; include it for parity. if req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", ContentTypeActivityJSON) } signedHeaders = append(signedHeaders, "content-type") }
signingString := buildSigningString(req, host, signedHeaders) hashed := sha256.Sum256([]byte(signingString)) signature, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA256, hashed[:]) if err != nil { return fmt.Errorf("ap: sign request: %w", err) }
req.Header.Set("Signature", fmt.Sprintf( `keyId="%s",algorithm="rsa-sha256",headers="%s",signature="%s"`, s.keyID, strings.Join(signedHeaders, " "), base64.StdEncoding.EncodeToString(signature), )) return nil}
func sha256Sum(data []byte) []byte { sum := sha256.Sum256(data) return sum[:]}
// buildSigningString assembles the draft-cavage signing string for the given// header list, using req's method, path, and headers.func buildSigningString(req *http.Request, host string, headers []string) string { lines := make([]string, 0, len(headers)) for _, header := range headers { switch header { case "(request-target)": target := req.URL.RequestURI() lines = append(lines, fmt.Sprintf("(request-target): %s %s", strings.ToLower(req.Method), target)) case "host": lines = append(lines, "host: "+host) default: lines = append(lines, header+": "+req.Header.Get(header)) } } return strings.Join(lines, "\n")}
// KeyResolver resolves a signature keyId to the RSA public key the signing// actor publishes. The AP client implements it (fetch actor, read// publicKey.publicKeyPem) with caching; tests inject stubs.type KeyResolver interface { // ResolveKey returns the public key for keyID and the id of the actor // that owns it. ResolveKey(ctx context.Context, keyID string) (key *rsa.PublicKey, ownerID string, err error)}
// freshKeyResolver is an optional extension of KeyResolver: it re-resolves a// key bypassing any positive cache. The Verifier uses it for a single// retry-with-fresh-key when a cached key fails to verify a signature — the// remote may have rotated its key while we still held the old one in cache,// which is otherwise indistinguishable from a forgery and would blackhole all// of that actor's deliveries for the full cache TTL. The Client implements it// via ResolveKeyFresh (task 06 relies on this path).type freshKeyResolver interface { ResolveKeyFresh(ctx context.Context, keyID string) (*rsa.PublicKey, string, error)}
// cacheAwareKeyResolver is an optional extension of KeyResolver that reports// whether the resolved key was served from a cache. The Verifier uses it to// GATE the fresh-key retry: a key that was just fetched from the network// cannot be stale, so a signature failing against it is a forgery (or// garbage) and a re-fetch gains nothing — it only lets an attacker turn one// bogus delivery into two outbound fetches (amplification). The Client// implements it via ResolveKeyDetailed.type cacheAwareKeyResolver interface { ResolveKeyDetailed(ctx context.Context, keyID string) (key *rsa.PublicKey, ownerID string, fromCache bool, err error)}
// KeyResolverFunc adapts a function to the KeyResolver interface.type KeyResolverFunc func(ctx context.Context, keyID string) (*rsa.PublicKey, string, error)
// ResolveKey calls f.func (f KeyResolverFunc) ResolveKey(ctx context.Context, keyID string) (*rsa.PublicKey, string, error) { return f(ctx, keyID)}
// SignatureError reports a failed HTTP signature verification. It unwraps to// ErrInvalidInput so task 06 can reject unauthenticated deliveries uniformly.type SignatureError struct { Reason string}
func (e SignatureError) Error() string { return "ap: http signature verification failed: " + e.Reason}
// Unwrap makes errors.Is(err, errors.ErrInvalidInput) true.func (e SignatureError) Unwrap() error { return errors.ErrInvalidInput }
// Verifier checks inbound draft-cavage HTTP signatures (task 06 wires it to// the inbox). Keys are resolved through a KeyResolver.type Verifier struct { resolver KeyResolver // now is stubbed in tests. now func() time.Time}
// NewVerifier creates a Verifier that resolves signing keys with resolver.func NewVerifier(resolver KeyResolver) *Verifier { return &Verifier{resolver: resolver, now: time.Now}}
// Verify checks the request's HTTP signature and returns the AP actor id// that owns the signing key. body must be the request payload already read// by the caller (the inbox buffers it anyway to parse the activity).//// Validation rules (matching Lemmy's verifier plus the digest rules// bridgy-fed applies):// - a Signature header must be present, with keyId, headers, signature;// - "(request-target)" and "date" must be among the signed headers;// - the Date header must be within maxDateSkew of now (Lemmy: one hour);// - requests with a body must have a Digest header, it must be signed,// and it must match SHA-256(body);// - algorithm, when present, must be rsa-sha256 or hs2019 (treated as// rsa-sha256, the bridgy-fed compat rule).//// All verification failures unwrap to errors.ErrInvalidInput via// SignatureError; key-resolution failures propagate as-is.func (v *Verifier) Verify(ctx context.Context, req *http.Request, body []byte) (actorID string, err error) { header := req.Header.Get("Signature") if header == "" { return "", SignatureError{Reason: "missing Signature header"} } fields := parseSignatureHeader(header) keyID := fields["keyid"] signatureB64 := fields["signature"] if keyID == "" || signatureB64 == "" { return "", SignatureError{Reason: "Signature header missing keyId or signature"} } switch algorithm := fields["algorithm"]; algorithm { case "", "rsa-sha256", "hs2019": // hs2019 in the wild means "figure it out"; every fediverse // implementation that sends it uses RSA-SHA256 (bridgy-fed applies // the same mapping). default: return "", SignatureError{Reason: fmt.Sprintf("unsupported algorithm %q", algorithm)} }
signedHeaders := strings.Fields(strings.ToLower(fields["headers"])) if len(signedHeaders) == 0 { // Per draft-cavage the default is "date" alone. signedHeaders = []string{"date"} } if !containsString(signedHeaders, "(request-target)") { return "", SignatureError{Reason: "(request-target) not signed"} } if !containsString(signedHeaders, "date") { return "", SignatureError{Reason: "date not signed"} } // Host must be signed: it binds the signature to the request's authority. // Every fediverse signer (Lemmy, Mastodon, bridgy-fed) signs host, and // without it a signature captured for one host could be replayed against // another. Signer always includes it. if !containsString(signedHeaders, "host") { return "", SignatureError{Reason: "host not signed"} }
// Date skew. date, err := http.ParseTime(req.Header.Get("Date")) if err != nil { return "", SignatureError{Reason: "missing or malformed Date header"} } if skew := v.now().Sub(date); skew > maxDateSkew || skew < -maxDateSkew { return "", SignatureError{Reason: fmt.Sprintf("date skew %s exceeds %s", skew.Round(time.Second), maxDateSkew)} }
// Digest: required and checked whenever the request carries a body // (POST inbox deliveries). GETs without a body may omit it. if len(body) > 0 { if !containsString(signedHeaders, "digest") { return "", SignatureError{Reason: "digest not signed on request with body"} } digest := req.Header.Get("Digest") expected := base64.StdEncoding.EncodeToString(sha256Sum(body)) if !digestMatches(digest, expected) { return "", SignatureError{Reason: "digest mismatch"} } }
// Resolve the signing key, learning whether it came from a cache when // the resolver can tell us (the Client can): only a cached key may be // stale, so only a cached key earns the rotation retry below. var ( key *rsa.PublicKey ownerID string fromCache bool ) if aware, ok := v.resolver.(cacheAwareKeyResolver); ok { key, ownerID, fromCache, err = aware.ResolveKeyDetailed(ctx, keyID) } else { // Resolvers that cannot report cache provenance keep the old // behavior (retry allowed) — better a rare extra fetch than // blackholing a rotated key for the cache TTL. fromCache = true key, ownerID, err = v.resolver.ResolveKey(ctx, keyID) } if err != nil { return "", fmt.Errorf("ap: resolve signing key %q: %w", keyID, err) }
host := req.Host if host == "" { host = req.URL.Host } signingString := buildSigningString(req, host, signedHeaders) signature, err := base64.StdEncoding.DecodeString(signatureB64) if err != nil { return "", SignatureError{Reason: "signature is not valid base64"} } hashed := sha256.Sum256([]byte(signingString)) if rsa.VerifyPKCS1v15(key, crypto.SHA256, hashed[:], signature) == nil { return ownerID, nil } // The (possibly cached) key did not verify. If the key came from a cache // and the resolver can bypass it, try exactly once with a freshly-fetched // key: a remote key rotation leaves a stale key in cache that fails every // signature until it expires. The retry is gated on fromCache — a key // fetched fresh from the network moments ago cannot be stale, and // re-fetching it would just let forged deliveries amplify outbound // traffic. We also only re-verify when the fresh key actually differs, so // a forged signature does not gain extra verification attempts. if fresh, ok := v.resolver.(freshKeyResolver); ok && fromCache { freshKey, freshOwner, ferr := fresh.ResolveKeyFresh(ctx, keyID) if ferr == nil && !freshKey.Equal(key) && rsa.VerifyPKCS1v15(freshKey, crypto.SHA256, hashed[:], signature) == nil { return freshOwner, nil } } return "", SignatureError{Reason: "signature does not verify"}}
// digestMatches compares a Digest header (possibly multi-valued, e.g.// "SHA-256=xxx,sha-512=yyy") against the expected SHA-256 base64 value.func digestMatches(header, expectedB64 string) bool { for _, part := range strings.Split(header, ",") { part = strings.TrimSpace(part) algorithm, value, found := strings.Cut(part, "=") if !found { continue } if strings.EqualFold(algorithm, "SHA-256") && value == expectedB64 { return true } } return false}
// parseSignatureHeader parses the comma-separated key="value" pairs of a// draft-cavage Signature header. Keys are lower-cased. Values may contain// commas (base64 never does, but headers lists don't either — split on// `",` boundaries would be fragile, so scan properly).func parseSignatureHeader(header string) map[string]string { fields := make(map[string]string) rest := header for rest != "" { rest = strings.TrimLeft(rest, " \t,") equals := strings.IndexByte(rest, '=') if equals < 0 { break } key := strings.ToLower(strings.TrimSpace(rest[:equals])) rest = rest[equals+1:] var value string if strings.HasPrefix(rest, `"`) { closing := strings.IndexByte(rest[1:], '"') if closing < 0 { value = rest[1:] rest = "" } else { value = rest[1 : 1+closing] rest = rest[closing+2:] } } else { comma := strings.IndexByte(rest, ',') if comma < 0 { value = rest rest = "" } else { value = rest[:comma] rest = rest[comma+1:] } value = strings.TrimSpace(value) } fields[key] = value } return fields}
func containsString(list []string, want string) bool { for _, v := range list { if v == want { return true } } return false}
// ActorIDFromKeyID strips the fragment from a keyId, yielding the actor id// ("https://host/u/alice#main-key" → "https://host/u/alice"). Lemmy resolves// keys the same way.func ActorIDFromKeyID(keyID string) string { if hash := strings.IndexByte(keyID, '#'); hash >= 0 { return keyID[:hash] } return keyID}