Something went wrong. Try again.
Read-only ActivityPub → atproto bridge for the threadiverse using Coves lexicons
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309package ap
import ( "context" "crypto/rsa" "net/http" "net/http/httptest" "strings" "testing" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/require"
"tidepool/internal/errors")
// testKey generates a throwaway RSA key once per test binary — key// generation is the slow part of these tests.var testKeyOnce = struct { key *rsa.PrivateKey err error ok bool}{}
func testRSAKey(t *testing.T) *rsa.PrivateKey { t.Helper() if !testKeyOnce.ok { testKeyOnce.key, testKeyOnce.err = GenerateRSAKey() testKeyOnce.ok = true } require.NoError(t, testKeyOnce.err) return testKeyOnce.key}
const testActorID = "https://bridge.example/actor"const testKeyID = testActorID + "#main-key"
func staticResolver(key *rsa.PublicKey) KeyResolver { return KeyResolverFunc(func(_ context.Context, keyID string) (*rsa.PublicKey, string, error) { return key, ActorIDFromKeyID(keyID), nil })}
func TestSignRequest_GETHeaderSet(t *testing.T) { key := testRSAKey(t) signer := NewSigner(testKeyID, key)
req := httptest.NewRequest(http.MethodGet, "https://lemmy.world/c/technology?page=2", nil) require.NoError(t, signer.SignRequest(req, nil))
// The exact header set Lemmy's verifier accepts for GETs. Digest MUST be // present and signed even on GET: Lemmy's verify_signature_inner applies // require_digest() to every request (activitypub-federation-rust // src/http_signatures.rs), and bridgy-fed ships the same set. assert.NotEmpty(t, req.Header.Get("Date")) _, err := http.ParseTime(req.Header.Get("Date")) assert.NoError(t, err, "Date must be a valid HTTP date") // SHA-256 of the empty body. assert.Equal(t, "SHA-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", req.Header.Get("Digest"))
fields := parseSignatureHeader(req.Header.Get("Signature")) assert.Equal(t, testKeyID, fields["keyid"]) assert.Equal(t, "rsa-sha256", fields["algorithm"]) assert.Equal(t, "(request-target) host date digest", fields["headers"]) assert.NotEmpty(t, fields["signature"])
// The signing string must include the query in (request-target). assert.Equal(t, "https://lemmy.world/c/technology?page=2", req.URL.String())}
func TestSignRequest_POSTHeaderSet(t *testing.T) { key := testRSAKey(t) signer := NewSigner(testKeyID, key) body := []byte(`{"type":"Follow"}`)
req := httptest.NewRequest(http.MethodPost, "https://lemmy.world/c/technology/inbox", nil) req.Header.Set("Content-Type", ContentTypeActivityJSON) require.NoError(t, signer.SignRequest(req, body))
fields := parseSignatureHeader(req.Header.Get("Signature")) assert.Equal(t, "(request-target) host date digest content-type", fields["headers"], "POSTs sign content-type too, matching Lemmy's own outbound set") assert.NotEmpty(t, req.Header.Get("Digest")) assert.NotEqual(t, "SHA-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", req.Header.Get("Digest"), "POST digest covers the body, not the empty string")}
func TestSignVerifyRoundTrip_GET(t *testing.T) { key := testRSAKey(t) signer := NewSigner(testKeyID, key) verifier := NewVerifier(staticResolver(&key.PublicKey))
req := httptest.NewRequest(http.MethodGet, "https://lemmy.world/post/49131386", nil) require.NoError(t, signer.SignRequest(req, nil))
actorID, err := verifier.Verify(context.Background(), req, nil) require.NoError(t, err) assert.Equal(t, testActorID, actorID)}
func TestSignVerifyRoundTrip_POST(t *testing.T) { key := testRSAKey(t) signer := NewSigner(testKeyID, key) verifier := NewVerifier(staticResolver(&key.PublicKey)) body := []byte(`{"type":"Announce","id":"https://lemmy.world/activities/announce/x"}`)
req := httptest.NewRequest(http.MethodPost, "https://bridge.example/inbox", nil) require.NoError(t, signer.SignRequest(req, body))
actorID, err := verifier.Verify(context.Background(), req, body) require.NoError(t, err) assert.Equal(t, testActorID, actorID)}
func TestVerify_RejectsTamperedBody(t *testing.T) { key := testRSAKey(t) signer := NewSigner(testKeyID, key) verifier := NewVerifier(staticResolver(&key.PublicKey)) body := []byte(`{"type":"Announce"}`)
req := httptest.NewRequest(http.MethodPost, "https://bridge.example/inbox", nil) require.NoError(t, signer.SignRequest(req, body))
_, err := verifier.Verify(context.Background(), req, []byte(`{"type":"Delete"}`)) require.Error(t, err) assert.True(t, errors.IsValidation(err), "signature failures unwrap to ErrInvalidInput, got %v", err) assert.Contains(t, err.Error(), "digest")}
func TestVerify_RejectsTamperedTarget(t *testing.T) { key := testRSAKey(t) signer := NewSigner(testKeyID, key) verifier := NewVerifier(staticResolver(&key.PublicKey))
req := httptest.NewRequest(http.MethodGet, "https://bridge.example/actor", nil) require.NoError(t, signer.SignRequest(req, nil)) // Replay the signed headers against a different path. replayed := httptest.NewRequest(http.MethodGet, "https://bridge.example/inbox", nil) replayed.Header = req.Header.Clone() replayed.Host = req.Host
_, err := verifier.Verify(context.Background(), replayed, nil) require.Error(t, err) assert.Contains(t, err.Error(), "does not verify")}
func TestVerify_RejectsWrongKey(t *testing.T) { key := testRSAKey(t) otherKey, err := GenerateRSAKey() require.NoError(t, err)
signer := NewSigner(testKeyID, key) verifier := NewVerifier(staticResolver(&otherKey.PublicKey))
req := httptest.NewRequest(http.MethodGet, "https://bridge.example/actor", nil) require.NoError(t, signer.SignRequest(req, nil))
_, err = verifier.Verify(context.Background(), req, nil) require.Error(t, err) assert.True(t, errors.IsValidation(err))}
func TestVerify_RejectsDateSkew(t *testing.T) { key := testRSAKey(t) signer := NewSigner(testKeyID, key) verifier := NewVerifier(staticResolver(&key.PublicKey)) // Pretend the request arrives 90 minutes later — past Lemmy's one-hour // window. verifier.now = func() time.Time { return time.Now().Add(90 * time.Minute) }
req := httptest.NewRequest(http.MethodGet, "https://bridge.example/actor", nil) require.NoError(t, signer.SignRequest(req, nil))
_, err := verifier.Verify(context.Background(), req, nil) require.Error(t, err) assert.Contains(t, err.Error(), "skew")}
func TestVerify_RequiresSignature(t *testing.T) { key := testRSAKey(t) verifier := NewVerifier(staticResolver(&key.PublicKey))
req := httptest.NewRequest(http.MethodPost, "https://bridge.example/inbox", nil) _, err := verifier.Verify(context.Background(), req, []byte(`{}`)) require.Error(t, err) assert.True(t, errors.IsValidation(err))}
func TestVerify_RequiresDigestOnBody(t *testing.T) { key := testRSAKey(t) signer := NewSigner(testKeyID, key) verifier := NewVerifier(staticResolver(&key.PublicKey)) body := []byte(`{"type":"Like"}`)
// Sign as GET (no digest in signed set)… req := httptest.NewRequest(http.MethodPost, "https://bridge.example/inbox", nil) require.NoError(t, signer.SignRequest(req, nil)) // …then claim a body: must be rejected because digest isn't signed over // the actual payload. _, err := verifier.Verify(context.Background(), req, body) require.Error(t, err) assert.True(t, errors.IsValidation(err))}
func TestVerify_RequiresHostSigned(t *testing.T) { key := testRSAKey(t) signer := NewSigner(testKeyID, key) verifier := NewVerifier(staticResolver(&key.PublicKey))
req := httptest.NewRequest(http.MethodGet, "https://bridge.example/actor", nil) require.NoError(t, signer.SignRequest(req, nil)) // Strip host from the signed header set (still present in the header map). sig := req.Header.Get("Signature") sig = strings.Replace(sig, "(request-target) host date digest", "(request-target) date digest", 1) req.Header.Set("Signature", sig)
_, err := verifier.Verify(context.Background(), req, nil) require.Error(t, err) assert.True(t, errors.IsValidation(err)) assert.Contains(t, err.Error(), "host")}
func TestVerify_AcceptsHS2019Algorithm(t *testing.T) { key := testRSAKey(t) signer := NewSigner(testKeyID, key) verifier := NewVerifier(staticResolver(&key.PublicKey))
req := httptest.NewRequest(http.MethodGet, "https://bridge.example/actor", nil) require.NoError(t, signer.SignRequest(req, nil)) // Mastodon labels RSA-SHA256 signatures "hs2019"; the bytes are the same. req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), `algorithm="rsa-sha256"`, `algorithm="hs2019"`, 1))
_, err := verifier.Verify(context.Background(), req, nil) require.NoError(t, err)}
func TestVerify_RejectsUnknownAlgorithm(t *testing.T) { key := testRSAKey(t) signer := NewSigner(testKeyID, key) verifier := NewVerifier(staticResolver(&key.PublicKey))
req := httptest.NewRequest(http.MethodGet, "https://bridge.example/actor", nil) require.NoError(t, signer.SignRequest(req, nil)) req.Header.Set("Signature", strings.Replace(req.Header.Get("Signature"), `algorithm="rsa-sha256"`, `algorithm="ed25519"`, 1))
_, err := verifier.Verify(context.Background(), req, nil) require.Error(t, err)}
func TestParseSignatureHeader(t *testing.T) { // Real-world shape (Lemmy emits exactly this ordering). fields := parseSignatureHeader( `keyId="https://lemmy.world/c/technology#main-key",algorithm="rsa-sha256",` + `headers="(request-target) content-type date digest host",signature="c2ln"`) assert.Equal(t, "https://lemmy.world/c/technology#main-key", fields["keyid"]) assert.Equal(t, "rsa-sha256", fields["algorithm"]) assert.Equal(t, "(request-target) content-type date digest host", fields["headers"]) assert.Equal(t, "c2ln", fields["signature"])
// Spacing and unquoted values tolerated. fields = parseSignatureHeader(`keyId="k" , algorithm=hs2019, signature="s"`) assert.Equal(t, "k", fields["keyid"]) assert.Equal(t, "hs2019", fields["algorithm"]) assert.Equal(t, "s", fields["signature"])}
func TestDigestMatches(t *testing.T) { assert.True(t, digestMatches("SHA-256=abc", "abc")) assert.True(t, digestMatches("sha-256=abc", "abc"), "algorithm name is case-insensitive") assert.True(t, digestMatches("SHA-512=zzz, SHA-256=abc", "abc"), "multi-valued digest headers") assert.False(t, digestMatches("SHA-256=xyz", "abc")) assert.False(t, digestMatches("", "abc"))}
func TestKeyPEMRoundTrip(t *testing.T) { key := testRSAKey(t)
privatePEM, err := EncodePrivateKeyPEM(key) require.NoError(t, err) assert.Contains(t, string(privatePEM), "BEGIN PRIVATE KEY") parsedPrivate, err := ParsePrivateKeyPEM(privatePEM) require.NoError(t, err) assert.True(t, key.Equal(parsedPrivate))
publicPEM, err := EncodePublicKeyPEM(&key.PublicKey) require.NoError(t, err) assert.Contains(t, string(publicPEM), "BEGIN PUBLIC KEY") parsedPublic, err := ParsePublicKeyPEM(publicPEM) require.NoError(t, err) assert.True(t, key.PublicKey.Equal(parsedPublic))}
func TestParsePublicKeyPEM_LemmyFixture(t *testing.T) { // The publicKeyPem published by a real Lemmy actor must parse. person := parseFixture(t, "person_lemmy_world.json") require.NotNil(t, person.PublicKey) key, err := ParsePublicKeyPEM([]byte(person.PublicKey.PublicKeyPem)) require.NoError(t, err) assert.Equal(t, ServiceKeyBits, key.Size()*8, "Lemmy actor keys are RSA-2048")}
func TestActorIDFromKeyID(t *testing.T) { assert.Equal(t, "https://lemmy.world/u/alice", ActorIDFromKeyID("https://lemmy.world/u/alice#main-key")) assert.Equal(t, "https://lemmy.world/u/alice", ActorIDFromKeyID("https://lemmy.world/u/alice"))}