diff --git a/.env.ci b/.env.ci index 6982e16..a16d384 100644 --- a/.env.ci +++ b/.env.ci @@ -130,6 +130,12 @@ HANDLE_WELL_KNOWN_HOSTS=.local.coves.dev=localhost:3001,.coves.social=localhost: OAUTH_PRIVATE_JWK={"alg":"ES256","crv":"P-256","d":"9tCMceYSgyZfO5KYOCm3rWEhXLqq2l4LjP7-PJtJKyk","kid":"oauth-client-key","kty":"EC","use":"sig","x":"EOYWEgZ2d-smTO6jh0f-9B7YSFYdlrvlryjuXTCrOjE","y":"_FR2jBcWNxoJl5cd1eq9sYtAs33No9AVtd42UyyWYi4"} OAUTH_COOKIE_SECRET=f1132c01b1a625a865c6c455a75ee793572cedb059cebe0c4c1ae4c446598f7d OAUTH_SEAL_SECRET=AW0U2uzDzrS0zI3OeZXvsWXbmw5UwebmDtXtQwDYKMs= + +# ENCRYPTION_KEY seals stored credentials (community PDS passwords/tokens, +# aggregator OAuth sessions) with AES-256-GCM in the AppView process. Dev/CI +# only: unset in dev generates a random key per boot, which strands every +# stored credential at the next restart, so pin one here. Never reuse in prod. +ENCRYPTION_KEY=s+mOXSpNxojVEVj1IgVYrWBYxqbifw8WTBMn7BSsSdc= OAUTH_CLIENT_PRIVATE_KEY=z42ti3ZG2JDj4RvFKdovxbgHB9Q4uvwsPvew8NQaxKxdfTLY OAUTH_CLIENT_KEY_ID=coves-key-1770360581 diff --git a/.env.dev b/.env.dev index 92e9731..718ea79 100644 --- a/.env.dev +++ b/.env.dev @@ -167,6 +167,12 @@ OAUTH_COOKIE_SECRET=f1132c01b1a625a865c6c455a75ee793572cedb059cebe0c4c1ae4c44659 # mobile session). Localhost-only value, fine to commit (see header note). OAUTH_SEAL_SECRET=AW0U2uzDzrS0zI3OeZXvsWXbmw5UwebmDtXtQwDYKMs= +# ENCRYPTION_KEY seals stored credentials (community PDS passwords/tokens, +# aggregator OAuth sessions) with AES-256-GCM in the AppView process. Dev/CI +# only: unset in dev generates a random key per boot, which strands every +# stored credential at the next restart, so pin one here. Never reuse in prod. +ENCRYPTION_KEY=s+mOXSpNxojVEVj1IgVYrWBYxqbifw8WTBMn7BSsSdc= + # OAuth Confidential Client Configuration (optional, for testing) # If both are set, Coves becomes a confidential OAuth client with 90-day session lifetime # (Public clients are limited to 14 days by the auth server) diff --git a/.env.dev.example b/.env.dev.example index ae5ab84..cc0865a 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -107,7 +107,7 @@ LOG_ENABLED=true # Security settings (ONLY for local dev - set to false in production!) # IS_DEV_ENV=true is what makes SKIP_DID_WEB_VERIFICATION acceptable: with it # false, the server refuses to start while did:web verification is disabled, and -# it additionally requires OAUTH_SEAL_SECRET, CURSOR_SECRET, JETSTREAM_FEEDS, +# it additionally requires OAUTH_SEAL_SECRET, ENCRYPTION_KEY, CURSOR_SECRET, JETSTREAM_FEEDS, # APPVIEW_PUBLIC_URL, and a non-localhost PDS_URL. # AUTH_SKIP_VERIFY and HS256_ISSUERS are read by no Go code in this repository; # they are inert and nothing gates them. @@ -243,3 +243,10 @@ OTEL_ENABLED=false # non-positive value and clamps an over-large one — so it is not validated at # startup. # ACCEPTANCE_QUEUE_BATCH_SIZE=50 + +# ENCRYPTION_KEY seals stored credentials (community PDS passwords/tokens, +# aggregator OAuth sessions) with AES-256-GCM in the AppView process. Dev/CI +# only: unset in dev generates a random key per boot, which strands every +# stored credential at the next restart, so pin one here. Never reuse in prod. +# Generate a production key with: openssl rand -base64 32 +ENCRYPTION_KEY=s+mOXSpNxojVEVj1IgVYrWBYxqbifw8WTBMn7BSsSdc= diff --git a/.env.prod.example b/.env.prod.example index 05cf26f..849599e 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -121,8 +121,12 @@ ANDROID_SHA256_FINGERPRINT=AA:BB:CC:DD:EE:FF:11:22:33:44:55:66:77:88:99:00:AA:BB # ============================================================================= # Security & Encryption # ============================================================================= -# For encrypting community credentials in database -# Generate with: openssl rand -base64 32 +# Seals stored credentials (community PDS passwords and tokens, aggregator +# OAuth sessions and DPoP keys) with AES-256-GCM inside the AppView process. +# The key never enters the database, so a database read or backup yields only +# ciphertext. Required in production; losing it makes every stored credential +# unrecoverable, so keep it with the other production secrets. +# See docs/CREDENTIAL_ENCRYPTION.md. Generate with: openssl rand -base64 32 ENCRYPTION_KEY=CHANGE_ME_BASE64_ENCODED_KEY # Secret for HMAC signing of pagination cursors diff --git a/cmd/rematerialize-posts/main.go b/cmd/rematerialize-posts/main.go index c7589e9..bc12f6d 100644 --- a/cmd/rematerialize-posts/main.go +++ b/cmd/rematerialize-posts/main.go @@ -50,6 +50,7 @@ package main import ( + "Coves/internal/crypto/credentialcipher" "context" "database/sql" "encoding/json" @@ -123,6 +124,16 @@ func main() { if err != nil { log.Fatalf("rematerialize-posts: loading config: %v", err) } + if cfg.EncryptionKeyGenerated { + // A maintenance tool has no use for a per-process key: every community + // credential it reads was sealed under the server's persistent key, so + // a generated one turns every row into an authentication failure. + log.Fatal("rematerialize-posts: ENCRYPTION_KEY is unset; set it to the AppView's key before running") + } + credentialCipher, err := credentialcipher.NewFromBase64(cfg.EncryptionKey) + if err != nil { + log.Fatalf("rematerialize-posts: initializing credential cipher: %v", err) + } db, err := openDatabase(cfg) if err != nil { @@ -183,7 +194,7 @@ func main() { provisioner := communities.NewPDSAccountProvisioner( cfg.Instance.Domain, cfg.PDS.URL, communityPDSOptions...) communityService := communities.NewCommunityService( - postgresRepo.NewCommunityRepository(db), + postgresRepo.NewCommunityRepository(db, credentialCipher), cfg.PDS.URL, cfg.Instance.DID, cfg.Instance.Domain, diff --git a/cmd/server/database.go b/cmd/server/database.go index b99edc3..7de492a 100644 --- a/cmd/server/database.go +++ b/cmd/server/database.go @@ -2,7 +2,9 @@ package main import ( "Coves/internal/config" + "Coves/internal/crypto/credentialcipher" "Coves/internal/db/migrations" + postgresRepo "Coves/internal/db/postgres" "context" "database/sql" "fmt" @@ -22,8 +24,13 @@ import ( // a slow one. // // The caller owns the returned pool and must Close it. -func openDatabase(ctx context.Context, cfg config.DatabaseConfig) (*sql.DB, error) { - if err := runMigrations(ctx, cfg); err != nil { +func openDatabase( + ctx context.Context, + cfg config.DatabaseConfig, + cipher *credentialcipher.Cipher, + keyIsEphemeral bool, +) (*sql.DB, error) { + if err := runMigrations(ctx, cfg, cipher, keyIsEphemeral); err != nil { return nil, err } @@ -67,9 +74,17 @@ func openDatabase(ctx context.Context, cfg config.DatabaseConfig) (*sql.DB, erro } // runMigrations applies pending migrations on a short-lived connection that -// carries no statement timeout. Migrations are embedded in the binary, so this -// does not depend on the process's working directory. -func runMigrations(ctx context.Context, cfg config.DatabaseConfig) error { +// carries no statement timeout. Before goose runs, legacy credentials are +// resealed with the application cipher because migration 046 refuses to drop +// their database-held key while any remain. On a fresh database the legacy key +// table does not exist yet, so the pass is a no-op. Migrations are embedded in the +// binary, so this does not depend on the process's working directory. +func runMigrations( + ctx context.Context, + cfg config.DatabaseConfig, + cipher *credentialcipher.Cipher, + keyIsEphemeral bool, +) error { dsn, err := cfg.MigrationDSN() if err != nil { return fmt.Errorf("building migration DSN: %w", err) @@ -93,6 +108,17 @@ func runMigrations(ctx context.Context, cfg config.DatabaseConfig) error { return fmt.Errorf("pinging database for migrations: %w", err) } + report, err := postgresRepo.ReencryptLegacyCredentials(ctx, db, cipher, keyIsEphemeral) + if err != nil { + return fmt.Errorf("re-encrypting legacy database credentials: %w", err) + } + if report.CommunitiesRewritten > 0 || report.AggregatorsRewritten > 0 { + slog.Info("legacy database credentials re-encrypted with the application cipher", + "communities_rewritten", report.CommunitiesRewritten, + "aggregators_rewritten", report.AggregatorsRewritten, + ) + } + if err := goose.SetDialect("postgres"); err != nil { return fmt.Errorf("setting goose dialect: %w", err) } diff --git a/cmd/server/main.go b/cmd/server/main.go index fa59389..ac7d2cb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -7,6 +7,7 @@ import ( "Coves/internal/atproto/oauth" "Coves/internal/config" "Coves/internal/core/users" + "Coves/internal/crypto/credentialcipher" "Coves/internal/observability" "context" "errors" @@ -44,6 +45,10 @@ func run() error { if err != nil { return err } + credentialCipher, err := credentialcipher.NewFromBase64(cfg.EncryptionKey) + if err != nil { + return fmt.Errorf("initializing credential cipher: %w", err) + } logStartupWarnings(cfg) // Signal handling is installed first so a Ctrl-C during the slower parts @@ -51,7 +56,7 @@ func run() error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - db, err := openDatabase(ctx, cfg.Database) + db, err := openDatabase(ctx, cfg.Database, credentialCipher, cfg.EncryptionKeyGenerated) if err != nil { return err } @@ -74,7 +79,7 @@ func run() error { } }() - app, err := buildApplication(ctx, cfg, db) + app, err := buildApplication(ctx, cfg, db, credentialCipher) if err != nil { return err } @@ -274,6 +279,10 @@ func logStartupWarnings(cfg *config.Config) { slog.Warn("OAUTH_SEAL_SECRET is unset: generated a random seal secret, " + "so every restart signs out all users") } + if cfg.EncryptionKeyGenerated { + slog.Warn("ENCRYPTION_KEY is unset: generated a random credential encryption key, " + + "so stored credentials will not survive a restart") + } // Signup stays gated by the PDS's own PDS_INVITE_REQUIRED, so missing // config here closes signup rather than leaving it unprotected. diff --git a/cmd/server/wiring.go b/cmd/server/wiring.go index bc0d3a5..185c74b 100644 --- a/cmd/server/wiring.go +++ b/cmd/server/wiring.go @@ -24,6 +24,7 @@ import ( "Coves/internal/core/userblocks" "Coves/internal/core/users" "Coves/internal/core/votes" + "Coves/internal/crypto/credentialcipher" "Coves/internal/notify/telegram" "context" "database/sql" @@ -80,8 +81,9 @@ const ( // then read-only. It exists so the wiring can be split across focused // functions without threading a dozen parameters through each one. type application struct { - cfg *config.Config - db *sql.DB + cfg *config.Config + db *sql.DB + credentialCipher *credentialcipher.Cipher // Identity and authentication identityResolver identity.Resolver @@ -164,10 +166,16 @@ type application struct { // and asking the caller to Close it would invert that convention, and would // become a nil dereference during an already-failing boot the first time // anyone added a plain `return nil, err` below. -func buildApplication(ctx context.Context, cfg *config.Config, db *sql.DB) (app *application, err error) { +func buildApplication( + ctx context.Context, + cfg *config.Config, + db *sql.DB, + credentialCipher *credentialcipher.Cipher, +) (app *application, err error) { app = &application{ cfg: cfg, db: db, + credentialCipher: credentialCipher, stopImageProxyCleanup: func() {}, } @@ -324,12 +332,12 @@ func oauthScopes() []string { func (a *application) buildRepositories() { a.userRepo = postgresRepo.NewUserRepository(a.db) - a.communityRepo = postgresRepo.NewCommunityRepository(a.db) + a.communityRepo = postgresRepo.NewCommunityRepository(a.db, a.credentialCipher) a.postRepo = postgresRepo.NewPostRepository(a.db) a.voteRepo = postgresRepo.NewVoteRepository(a.db) a.commentRepo = postgresRepo.NewCommentRepository(a.db) a.userBlockRepo = postgresRepo.NewUserBlockRepository(a.db) - a.aggregatorRepo = postgresRepo.NewAggregatorRepository(a.db) + a.aggregatorRepo = postgresRepo.NewAggregatorRepository(a.db, a.credentialCipher) a.admissionRepo = postgresRepo.NewAdmissionRepository(a.db) } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1fdbfb2..bb92d5e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -109,7 +109,7 @@ services: ENV: production LOG_LEVEL: info - # Encryption key for community credentials + # AES-256-GCM key sealing stored credentials in the AppView process; never stored in the database ENCRYPTION_KEY: ${ENCRYPTION_KEY} # Cursor encryption for pagination diff --git a/docs/CREDENTIAL_ENCRYPTION.md b/docs/CREDENTIAL_ENCRYPTION.md new file mode 100644 index 0000000..647c198 --- /dev/null +++ b/docs/CREDENTIAL_ENCRYPTION.md @@ -0,0 +1,152 @@ +# Credential encryption at rest + +Stored credentials — community PDS passwords and session tokens, aggregator +OAuth access/refresh tokens and DPoP private keys — are sealed by the AppView +process with AES-256-GCM before they reach PostgreSQL. The key comes from the +`ENCRYPTION_KEY` environment variable and is never stored in the database, so +a database read or a `pg_dump` backup yields ciphertext the reader cannot open. + +This replaced the scheme from migrations 006, 007 and 025, where pgcrypto's +`pgp_sym_encrypt` used a key stored in the `encryption_keys` table of the same +database. Anyone with a database read or a backup file had the key beside the +ciphertext. Migration 046 dropped that table. + +## Mechanism + +- Package `internal/crypto/credentialcipher`. Wire format is one version byte + (`credentialcipher.Version`, `0x01`), a 12-byte random nonce, the + ciphertext, and the 16-byte GCM tag. pgcrypto output starts with `0xC3`, so + the two formats never collide on the first byte. +- Every value is bound to an authenticated context string of the form + `.:` (see `internal/db/postgres/credential_context.go`), + so a ciphertext copied to another row or column fails to decrypt. The version + byte is part of the authenticated data as well. The context strings are + persisted as authenticated data, so renaming a table or column requires a + re-encryption pass. +- Repositories take the cipher as a constructor argument and encrypt before the + parameterized write and decrypt after the scan. A value that fails to open + makes the read return an error wrapping `credentialcipher.ErrInvalidCiphertext` + and naming the DID; nothing is skipped or returned empty. An unknown version + byte additionally wraps `ErrUnsupportedVersion`, and a `0xC3` first byte is + reported as pgcrypto legacy ciphertext so the operator knows the startup + conversion has not run on that database. +- Empty-credential semantics are unchanged: `Create` and `SetAPIKey` store NULL + for an empty value; `UpdateCredentials` and `UpdateOAuthTokens` always write + ciphertext. A zero-length bytea is read as an absent credential, the same as + NULL. + +## Configuration + +`ENCRYPTION_KEY` is standard base64 of exactly 32 random bytes: + +```bash +openssl rand -base64 32 +``` + +- Production (`IS_DEV_ENV=false`): required. Unset, the documented + `CHANGE_ME_...` placeholder, non-base64, or a wrong decoded length all fail + startup with a message naming the variable. +- Dev and CI: `.env.dev`, `.env.dev.example` and `.env.ci` pin a key. If it is + unset in dev the server generates a random key per boot and logs a warning; + every credential stored under a generated key is unreadable after the next + restart, so keep it pinned. `rematerialize-posts` refuses to run under a + generated key for the same reason. +- A set but malformed key fails in dev as well; it is never silently replaced. +- There is no key rotation. Rotating means decrypting every row under the old + key and re-sealing under the new one, which nothing implements today. + +## Legacy conversion and migration 046 + +The server converts rows still sealed by pgcrypto at startup, before goose runs: + +1. `postgres.ReencryptLegacyCredentials` runs on the migration connection. It + handles each table only once all three of that table's credential columns + exist (communities from 007, aggregators from 025), so a database older than + 025 gets a second boot instead of a crash: migration 046 refuses the first + boot, the operator restarts, and the pass converts what 025 sealed. +2. When the `encryption_keys` table is absent (fresh database, or already + converted) the pass is a no-op, unless a legacy value still exists in a + credential column. That value can no longer be decrypted by anyone, so the + pass fails startup and names the table and count; the recovery is a pre-046 + backup, or NULLing the column and re-provisioning. +3. Otherwise, in one transaction with the affected rows locked, it decrypts + each legacy value with `pgp_sym_decrypt` (the legacy key is read by the + database inside the same statement and never enters the Go process), seals + it with the application cipher, and rewrites the row. Values already in the + new format and NULLs are left untouched, so the pass is idempotent. A + zero-length value is rewritten to NULL. Any value that fails to decrypt + aborts the whole pass with an error naming the table and DID; nothing is + committed. +4. It refuses to convert under a dev-generated key while legacy rows remain, + because those rows would be stranded at the next restart. +5. Migration 046 then raises unless every non-NULL credential column starts + with the version byte, and only then drops `encryption_keys`. The pgcrypto + extension stays installed because the conversion pass needs it. + +The conversion commits before goose runs. If 046 then fails for an unrelated +reason, the rows are already in the new format and a pre-cutover binary cannot +read them; roll forward by fixing the migration and booting again, which is +self-healing because the pass is idempotent. + +Migration 046's Down is schema-only. It recreates `encryption_keys` with a +fresh random key while the credential columns keep their AES-256-GCM values, +so a pre-046 binary still cannot read any stored credential. There is no +reverse conversion. To actually roll back, restore a pre-cutover backup or +NULL the credential columns and re-provision the affected communities and +aggregators. + +## Threat model and accepted tradeoffs + +- Protected: read access to the database, and backup files, no longer yield + credentials or the key that opens them. +- Not protected: a compromise of the AppView host or its environment, which + holds the key. +- Accepted: the authenticated context has no freshness component. Someone who + can write to the database, or who restores a stale backup over live data, can + put back an older ciphertext for the same row and column and it will open. + Binding a timestamp into the context would catch this at the cost of a + re-encryption on every row update; the threat model above does not include a + database-write attacker, so the simpler scheme was kept. +- Assumed: a single AppView instance replaced on deploy. Migration 046's guard + and the conversion pass are not safe against an older binary writing pgcrypto + ciphertext concurrently. + +## Production cutover + +1. Generate a key and add `ENCRYPTION_KEY=` to the production env file. + Check the file for an existing pin first; pins override compose defaults. + `docker-compose.prod.yml` already passes the variable to the AppView. +2. Deploy the new image. On boot the server converts the existing rows, logs + `legacy database credentials re-encrypted with the application cipher` with + the counts, and migration 046 drops the key table. Startup fails loudly + instead of dropping the key if anything is left unconverted. +3. Take a fresh backup and confirm it no longer contains the table. Backups are + plain SQL, gzipped: + + ```bash + zcat .sql.gz | grep -c encryption_keys # expect 0 + ``` + +4. Older backups still hold the old key beside the old ciphertext. Treat them + as containing plaintext credentials: purge them once a post-cutover backup is + verified (the backup script prunes after 30 days on its own), and rotate the + community PDS passwords and aggregator OAuth sessions if any old dump may + have left the host. + +Losing `ENCRYPTION_KEY` after the cutover makes every stored credential +unrecoverable. Keep it with the other production secrets. + +## Tests + +- T0: `internal/crypto/credentialcipher/cipher_test.go` (key validation, + framing, tamper, wrong-key/context and version rejection), + `internal/config/encryption_key_config_test.go`. +- T1: `internal/db/postgres/credential_cipher_acceptance_test.go` (the + end-to-end contract), `credential_cipher_repo_test.go` (per-method + behavior, tamper handling, legacy-value classification), + `credential_reencrypt_migration_test.go` (migration 046 guard and rollback, + the conversion pass including the pre-025 schema, zero-length values and the + dropped-table case, and the handoff between them), plus the pre-existing + `community_repo_credentials_test.go`, `aggregator_repo_credentials_test.go` + and `internal/core/communities/service_credentials_test.go`, which now run + against the application cipher. diff --git a/docs/PRD_BACKLOG.md b/docs/PRD_BACKLOG.md index da75be2..7de4dc3 100644 --- a/docs/PRD_BACKLOG.md +++ b/docs/PRD_BACKLOG.md @@ -662,7 +662,7 @@ Create shared `http.Client` with connection pooling instead of new client per re ### Architecture Decision Records (ADRs) **Added:** 2025-10-11 | **Effort:** Ongoing -Document: did:plc choice, pgcrypto encryption, Jetstream vs firehose, write-forward pattern, single handle field. +Document: did:plc choice, application-side credential encryption (done: docs/CREDENTIAL_ENCRYPTION.md), Jetstream vs firehose, write-forward pattern, single handle field. --- diff --git a/docs/PRD_COMMUNITIES.md b/docs/PRD_COMMUNITIES.md index d323bff..f926e54 100644 --- a/docs/PRD_COMMUNITIES.md +++ b/docs/PRD_COMMUNITIES.md @@ -40,7 +40,7 @@ Hosted By: did:web:coves.social (instance manages credentials) - [x] **PDS Account Provisioning:** Automatic account creation for each community - [x] **Credential Management:** Secure storage of community PDS credentials - [x] **Token Refresh:** Automatic refresh of expired access tokens (completed 2025-10-17) -- [x] **Encryption at Rest:** PostgreSQL pgcrypto for sensitive credentials +- [x] **Encryption at Rest:** application-side AES-256-GCM for sensitive credentials, key from `ENCRYPTION_KEY` (see docs/CREDENTIAL_ENCRYPTION.md) - [x] **Write-Forward Pattern:** Service → PDS → Firehose → AppView - [x] **Jetstream Consumer:** Real-time indexing from firehose - [x] **V2 Validation:** Strict rkey="self" enforcement (no V1 compatibility) @@ -60,7 +60,7 @@ Hosted By: did:web:coves.social (instance manages credentials) - [x] **Subscriptions Table:** Lightweight feed following - [x] **Memberships Table:** Active participation tracking - [x] **Moderation Table:** Local moderation actions -- [x] **Encryption Keys Table:** Secure key management for pgcrypto +- [x] **Key outside the database:** the credential key lives in the AppView environment, not in a table beside the ciphertext (migration 046 dropped `encryption_keys`) - [x] **Indexes:** Optimized for search, visibility filtering, and lookups ### Service Layer @@ -349,7 +349,7 @@ Communities can define content posting restrictions via the `contentRules` objec **Impact:** Communities can now be updated after creation (credentials survive restarts) **Issue:** Credentials stored in plaintext in PostgreSQL -**Fix:** Added pgcrypto encryption for access/refresh tokens +**Fix:** Added pgcrypto encryption for access/refresh tokens (superseded 2026-09 by application-side AES-256-GCM with the key held outside the database; see docs/CREDENTIAL_ENCRYPTION.md) **Impact:** Database compromise no longer exposes active tokens **Issue:** UpdateCommunity used instance credentials instead of community credentials @@ -494,7 +494,7 @@ profile record cannot supply it. ### 2025-10-10: V2 Architecture Completed - Migrated from instance-owned to community-owned repositories - Each community now has own PDS account -- Credentials encrypted at rest using pgcrypto +- Credentials encrypted at rest with application-side AES-256-GCM; the key is never stored in the database - Strict V2 enforcement (no V1 compatibility) ### 2025-10-08: DID Architecture & atProto Compliance diff --git a/internal/api/handlers/community/viewer_state_test.go b/internal/api/handlers/community/viewer_state_test.go index 6b53d41..c4ca386 100644 --- a/internal/api/handlers/community/viewer_state_test.go +++ b/internal/api/handlers/community/viewer_state_test.go @@ -21,6 +21,7 @@ package community_test import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "encoding/json" "fmt" @@ -205,7 +206,7 @@ func TestCommunityGet_ViewerState(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := postgres.NewCommunityRepository(db) + repo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) // Two communities so that "subscribed" and "not subscribed" are answered // from the same database state: a handler that hardcoded either answer // would fail one of the two subtests. @@ -278,7 +279,7 @@ func TestCommunityList_ViewerState(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := postgres.NewCommunityRepository(db) + repo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) communityDIDs := seedCommunities(t, repo, "listviewer", 3) viewerDID := fixtures.DID("listviewer" + testkit.UniqueID(t)) diff --git a/internal/api/handlers/imageproxy/avatar_serving_test.go b/internal/api/handlers/imageproxy/avatar_serving_test.go index de9996a..b5e2725 100644 --- a/internal/api/handlers/imageproxy/avatar_serving_test.go +++ b/internal/api/handlers/imageproxy/avatar_serving_test.go @@ -26,6 +26,7 @@ package imageproxy_test import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "fmt" "image/color" @@ -85,7 +86,7 @@ func provisionCommunityAvatar(t *testing.T, width, height int, fill color.Color) // provisioner builds the community's handle as c-{name}.{domain}. handleDomain := endpoints.PDS.HandleDomain communityService := communities.NewCommunityServiceWithPDSFactory( - postgres.NewCommunityRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), endpoints.PDS.BaseURL, fixtures.InstanceDID(), handleDomain, diff --git a/internal/api/handlers/imageproxy/roundtrip_serving_test.go b/internal/api/handlers/imageproxy/roundtrip_serving_test.go index 5b29841..321f8b8 100644 --- a/internal/api/handlers/imageproxy/roundtrip_serving_test.go +++ b/internal/api/handlers/imageproxy/roundtrip_serving_test.go @@ -25,6 +25,7 @@ package imageproxy_test import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "image/color" "net/http" @@ -71,7 +72,7 @@ func TestImageProxy_EmittedURLsAreFetchable(t *testing.T) { // rather than a row insert. handleDomain := endpoints.PDS.HandleDomain communityService := communities.NewCommunityServiceWithPDSFactory( - postgres.NewCommunityRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), endpoints.PDS.BaseURL, fixtures.InstanceDID(), handleDomain, diff --git a/internal/api/handlers/post/harness_test.go b/internal/api/handlers/post/harness_test.go index 02940f0..3009bc7 100644 --- a/internal/api/handlers/post/harness_test.go +++ b/internal/api/handlers/post/harness_test.go @@ -3,6 +3,7 @@ package post_test import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "database/sql" "os" "testing" @@ -66,7 +67,7 @@ func newCreateStack(t *testing.T, db *sql.DB) createStack { t.Helper() pdsURL := testkit.Endpoints().PDS.BaseURL - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) communityService := communities.NewCommunityServiceWithPDSFactory( communityRepo, pdsURL, diff --git a/internal/atproto/jetstream/acceptance_consumer_test.go b/internal/atproto/jetstream/acceptance_consumer_test.go index d57fd07..9b08858 100644 --- a/internal/atproto/jetstream/acceptance_consumer_test.go +++ b/internal/atproto/jetstream/acceptance_consumer_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "encoding/json" @@ -81,7 +82,7 @@ func newAccFixture(t *testing.T, db *sql.DB, opts ...PostEventConsumerOption) ac return accFixture{ consumer: NewPostEventConsumer( postgres.NewPostRepository(db), - postgres.NewCommunityRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), us, db, wired..., @@ -370,7 +371,7 @@ func newRealRepoFixture(t *testing.T, db *sql.DB) *realRepoFixture { admissions := postgres.NewAdmissionRepository(db) consumer := NewPostEventConsumer( - postgres.NewPostRepository(db), postgres.NewCommunityRepository(db), + postgres.NewPostRepository(db), postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), newMockUserService(), db, WithAdmissions(admissions), WithDeletedAccounts(postgres.NewDeletedAccountRepository(db)), diff --git a/internal/atproto/jetstream/admission_durability_test.go b/internal/atproto/jetstream/admission_durability_test.go index 0421506..e2662a8 100644 --- a/internal/atproto/jetstream/admission_durability_test.go +++ b/internal/atproto/jetstream/admission_durability_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "errors" @@ -161,7 +162,7 @@ func TestAdmission_ConvergeMustNotRegressTheEvaluatedCID(t *testing.T) { // live in different repos, Jetstream parallelises across repos, and the // fetch exists precisely because the post's own event had not arrived yet. f.consumer = NewPostEventConsumer( - postgres.NewPostRepository(db), postgres.NewCommunityRepository(db), + postgres.NewPostRepository(db), postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), newMockUserService(), db, WithAdmissions(f.admissions), WithDeletedAccounts(postgres.NewDeletedAccountRepository(db)), @@ -210,7 +211,7 @@ func TestAdmission_SurvivesAFailedUpsertAcrossRedelivery(t *testing.T) { } f := newAccFixture(t, db) f.consumer = NewPostEventConsumer( - postgres.NewPostRepository(db), postgres.NewCommunityRepository(db), + postgres.NewPostRepository(db), postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), newMockUserService(), db, WithAdmissions(flaky), WithDeletedAccounts(postgres.NewDeletedAccountRepository(db)), @@ -342,7 +343,7 @@ func TestAdmission_FailedWithdrawalIsRetriedOnRedelivery(t *testing.T) { sweep := &flakyDeleter{failures: 1, err: errors.New("the community's PDS is briefly unreachable")} f := newAccFixture(t, db) f.consumer = NewPostEventConsumer( - postgres.NewPostRepository(db), postgres.NewCommunityRepository(db), + postgres.NewPostRepository(db), postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), newMockUserService(), db, WithAdmissions(f.admissions), WithDeletedAccounts(postgres.NewDeletedAccountRepository(db)), diff --git a/internal/atproto/jetstream/bridged_stats_test.go b/internal/atproto/jetstream/bridged_stats_test.go index f391a92..13a2be8 100644 --- a/internal/atproto/jetstream/bridged_stats_test.go +++ b/internal/atproto/jetstream/bridged_stats_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "testing" @@ -95,7 +96,7 @@ func setupCommentThread(t *testing.T, db *sql.DB) (postURI, postCID string) { insertBridgedCommunity(t, db, bridgedTestCommunity, "brcommunity.test", bridgedTestAuthor) pc := NewPostEventConsumer( - postgres.NewPostRepository(db), postgres.NewCommunityRepository(db), newMockUserService(), db, + postgres.NewPostRepository(db), postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), newMockUserService(), db, WithAdmissions(postgres.NewAdmissionRepository(db)), ) const rkey = "cthread" diff --git a/internal/atproto/jetstream/community_consumer_block_test.go b/internal/atproto/jetstream/community_consumer_block_test.go index 703b638..1055981 100644 --- a/internal/atproto/jetstream/community_consumer_block_test.go +++ b/internal/atproto/jetstream/community_consumer_block_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "testing" "time" @@ -66,7 +67,7 @@ func blockEvent(userDID, rkey, operation, subject string) *JetstreamEvent { func newCommunityBlockConsumer(t *testing.T) (*CommunityEventConsumer, communities.Repository) { t.Helper() - repo := postgres.NewCommunityRepository(testkit.DB(t)) + repo := postgres.NewCommunityRepository(testkit.DB(t), credentialciphertest.Fixed()) return NewCommunityEventConsumer(repo, "did:web:coves.social", true, nil), repo } diff --git a/internal/atproto/jetstream/community_handle_conflict_test.go b/internal/atproto/jetstream/community_handle_conflict_test.go index 8fe9471..8564df6 100644 --- a/internal/atproto/jetstream/community_handle_conflict_test.go +++ b/internal/atproto/jetstream/community_handle_conflict_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "fmt" "testing" @@ -87,7 +88,7 @@ func TestCommunityConsumer_HandleTakenByAnotherDID_IsAPermanentRefusal(t *testin // skipVerification: the hostedBy/handle-domain check is a different security // property with its own tests, and leaving it on would reject these events // before the conflict path is ever reached. - consumer := NewCommunityEventConsumer(postgres.NewCommunityRepository(db), "did:web:test.local", true, nil) + consumer := NewCommunityEventConsumer(postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), "did:web:test.local", true, nil) suffix := testkit.UniqueID(t) contested := fmt.Sprintf("c-first%s.test.local", suffix) @@ -130,7 +131,7 @@ func TestCommunityConsumer_SameDIDReplay_StaysSilent(t *testing.T) { db := testkit.DB(t) ctx := context.Background() - consumer := NewCommunityEventConsumer(postgres.NewCommunityRepository(db), "did:web:test.local", true, nil) + consumer := NewCommunityEventConsumer(postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), "did:web:test.local", true, nil) suffix := testkit.UniqueID(t) handle := fmt.Sprintf("c-replay%s.test.local", suffix) @@ -169,7 +170,7 @@ func TestCommunityConsumer_UnverifiableHandle_IsNotStored(t *testing.T) { resolver := &mockIdentityResolverForUser{identities: map[string]*identity.Identity{ did: {DID: did, Handle: invalidHandle, PDSURL: "https://pds.example.invalid"}, }} - consumer := NewCommunityEventConsumer(postgres.NewCommunityRepository(db), "did:web:test.local", true, resolver) + consumer := NewCommunityEventConsumer(postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), "did:web:test.local", true, resolver) event := communityProfileEvent(did, "", "unverified", "3lunverified") delete(event.Commit.Record, "handle") diff --git a/internal/atproto/jetstream/community_hostedby_verification_test.go b/internal/atproto/jetstream/community_hostedby_verification_test.go index f0714d7..4f4257d 100644 --- a/internal/atproto/jetstream/community_hostedby_verification_test.go +++ b/internal/atproto/jetstream/community_hostedby_verification_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "fmt" "net/http" @@ -57,7 +58,7 @@ func TestHostedByVerification_DomainMatching(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := postgres.NewCommunityRepository(db) + repo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) ctx := context.Background() t.Run("rejects community with mismatched hostedBy domain", func(t *testing.T) { @@ -289,7 +290,7 @@ func TestBidirectionalDIDVerification(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := postgres.NewCommunityRepository(db) + repo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) ctx := context.Background() t.Run("indexes a community whose domain serves a DID document with alsoKnownAs", func(t *testing.T) { @@ -437,7 +438,7 @@ func TestExtractDomainFromHandle(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := postgres.NewCommunityRepository(db) + repo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) ctx := context.Background() testCases := []struct { diff --git a/internal/atproto/jetstream/direct_fetch_verification_test.go b/internal/atproto/jetstream/direct_fetch_verification_test.go index d70dc32..d1ce7fa 100644 --- a/internal/atproto/jetstream/direct_fetch_verification_test.go +++ b/internal/atproto/jetstream/direct_fetch_verification_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "net/http" "net/http/httptest" @@ -81,7 +82,7 @@ func TestDirectFetch_RecomputesTheCIDFromARealRepo(t *testing.T) { fetcher := NewDirectPostFetcher(pinnedResolver(author.DID, pdsServer.URL()), PrivatePostFetcherOptions(true)...) consumer := NewPostEventConsumer( - postgres.NewPostRepository(db), postgres.NewCommunityRepository(db), + postgres.NewPostRepository(db), postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), newMockUserService(), db, WithAdmissions(postgres.NewAdmissionRepository(db)), WithDeletedAccounts(postgres.NewDeletedAccountRepository(db)), diff --git a/internal/atproto/jetstream/duplicate_delivery_test.go b/internal/atproto/jetstream/duplicate_delivery_test.go index 005db35..a273bec 100644 --- a/internal/atproto/jetstream/duplicate_delivery_test.go +++ b/internal/atproto/jetstream/duplicate_delivery_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "testing" @@ -43,7 +44,7 @@ func setupDupFixtures(t *testing.T, db *sql.DB) (postURI, postCID string) { us := newMockUserService() us.users[dupTestAuthor] = &users.User{DID: dupTestAuthor, Handle: "dupauthor.test"} pc := NewPostEventConsumer( - postgres.NewPostRepository(db), postgres.NewCommunityRepository(db), us, db, + postgres.NewPostRepository(db), postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), us, db, WithAdmissions(postgres.NewAdmissionRepository(db)), ) @@ -205,7 +206,7 @@ func TestAggregatorConsumer_DuplicateDelivery_LeavesTheTriggerStatsAlone(t *test insertBridgedUser(t, db, dupTestAuthor, "dupauthor.test") insertBridgedCommunity(t, db, dupTestCommunity, "dupcommunity.test", dupTestAuthor) - ac := NewAggregatorEventConsumer(postgres.NewAggregatorRepository(db)) + ac := NewAggregatorEventConsumer(postgres.NewAggregatorRepository(db, credentialciphertest.Fixed())) ctx := context.Background() declaration := &JetstreamEvent{ diff --git a/internal/atproto/jetstream/erasure_integrity_test.go b/internal/atproto/jetstream/erasure_integrity_test.go index d748f09..fc9d4a7 100644 --- a/internal/atproto/jetstream/erasure_integrity_test.go +++ b/internal/atproto/jetstream/erasure_integrity_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "errors" @@ -63,7 +64,7 @@ func newErasedFixture(t *testing.T, db *sql.DB, opts ...PostEventConsumerOption) return erasedFixture{ consumer: NewPostEventConsumer( - postgres.NewPostRepository(db), postgres.NewCommunityRepository(db), + postgres.NewPostRepository(db), postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), newMockUserService(), db, wired...), userService: users.NewUserService(postgres.NewUserRepository(db), nil, bskySocialPDS, nil, ""), admissions: admissions, diff --git a/internal/atproto/jetstream/error_taxonomy_transient_test.go b/internal/atproto/jetstream/error_taxonomy_transient_test.go index ea729a1..7936915 100644 --- a/internal/atproto/jetstream/error_taxonomy_transient_test.go +++ b/internal/atproto/jetstream/error_taxonomy_transient_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "testing" @@ -29,7 +30,7 @@ func TestCommunityConsumer_SubscriptionCommunityNotFound_IsUnresolved(t *testing subscriber = "did:plc:jstaxsubscriber" ) - c := NewCommunityEventConsumer(postgres.NewCommunityRepository(db), "did:web:test.local", true, nil) + c := NewCommunityEventConsumer(postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), "did:web:test.local", true, nil) err := c.HandleEvent(context.Background(), taxonomyEvent( subscriber, "social.coves.community.subscription", "create", "s1", map[string]interface{}{ @@ -50,7 +51,7 @@ func TestPostV2Consumer_NonDIDCommunity_IsPermanent(t *testing.T) { // fires before the first repository access. t.Parallel() db := testkit.DB(t) - c := NewPostEventConsumer(postgres.NewPostRepository(db), postgres.NewCommunityRepository(db), newMockUserService(), db, + c := NewPostEventConsumer(postgres.NewPostRepository(db), postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), newMockUserService(), db, WithAdmissions(postgres.NewAdmissionRepository(db))) err := c.HandleEvent(context.Background(), taxonomyEvent( "did:plc:someauthor", PostV2Collection, "create", "p1", diff --git a/internal/atproto/jetstream/postv2_aggregation_test.go b/internal/atproto/jetstream/postv2_aggregation_test.go index 49fce72..35c71a2 100644 --- a/internal/atproto/jetstream/postv2_aggregation_test.go +++ b/internal/atproto/jetstream/postv2_aggregation_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "testing" @@ -59,7 +60,7 @@ func indexAuthorOwnedPost(t *testing.T, db *sql.DB) (postURI, postCID string) { consumer := NewPostEventConsumer( postgres.NewPostRepository(db), - postgres.NewCommunityRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), userService, db, WithAdmissions(postgres.NewAdmissionRepository(db)), diff --git a/internal/atproto/jetstream/postv2_consumer_test.go b/internal/atproto/jetstream/postv2_consumer_test.go index 194d442..8fff10e 100644 --- a/internal/atproto/jetstream/postv2_consumer_test.go +++ b/internal/atproto/jetstream/postv2_consumer_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "errors" @@ -76,7 +77,7 @@ func newPV2Fixture(t *testing.T, db *sql.DB) pv2Fixture { admissions := postgres.NewAdmissionRepository(db) consumer := NewPostEventConsumer( postgres.NewPostRepository(db), - postgres.NewCommunityRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), us, db, WithAdmissions(admissions), @@ -502,7 +503,7 @@ func TestPostV2Consumer_Delete_WithdrawsTheHostedCommunitysAcceptance(t *testing f := newPV2Fixture(t, db) f.consumer = NewPostEventConsumer( postgres.NewPostRepository(db), - postgres.NewCommunityRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), f.users, db, WithAdmissions(f.admissions), @@ -558,7 +559,7 @@ func TestPostV2Consumer_Delete_DoesNotSweepWhenNoAcceptanceStands(t *testing.T) f := newPV2Fixture(t, db) f.consumer = NewPostEventConsumer( postgres.NewPostRepository(db), - postgres.NewCommunityRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), f.users, db, WithAdmissions(f.admissions), @@ -598,7 +599,7 @@ func TestPostV2Consumer_Delete_SurvivesAFailedSweep(t *testing.T) { f := newPV2Fixture(t, db) f.consumer = NewPostEventConsumer( postgres.NewPostRepository(db), - postgres.NewCommunityRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), f.users, db, WithAdmissions(f.admissions), diff --git a/internal/atproto/jetstream/postv2_hydration_test.go b/internal/atproto/jetstream/postv2_hydration_test.go index 49464e2..ba89285 100644 --- a/internal/atproto/jetstream/postv2_hydration_test.go +++ b/internal/atproto/jetstream/postv2_hydration_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "testing" @@ -32,7 +33,7 @@ func newPostV2HydrationFixture( ) f.consumer = NewPostEventConsumer( postgres.NewPostRepository(db), - postgres.NewCommunityRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), userService, db, WithAdmissions(f.admissions), diff --git a/internal/atproto/jetstream/postv2_mechanisms_test.go b/internal/atproto/jetstream/postv2_mechanisms_test.go index 998bc5a..ed1cfd8 100644 --- a/internal/atproto/jetstream/postv2_mechanisms_test.go +++ b/internal/atproto/jetstream/postv2_mechanisms_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "testing" @@ -65,7 +66,7 @@ func newPostV2BridgedFixture(t *testing.T, db *sql.DB, authorPDS, communityPDS s f.users.users[pv2Author].PDSURL = authorPDS f.consumer = NewPostEventConsumer( postgres.NewPostRepository(db), - postgres.NewCommunityRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), f.users, db, WithAdmissions(f.admissions), diff --git a/internal/atproto/jetstream/redrive_recency_test.go b/internal/atproto/jetstream/redrive_recency_test.go index 7676d5a..2ef423e 100644 --- a/internal/atproto/jetstream/redrive_recency_test.go +++ b/internal/atproto/jetstream/redrive_recency_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "testing" @@ -70,7 +71,7 @@ func setupRecencyFixtures(t *testing.T, db *sql.DB) *PostEventConsumer { us := newMockUserService() us.users[recencyTestAuthor] = &users.User{DID: recencyTestAuthor, Handle: "rcyauthor.test"} return NewPostEventConsumer( - postgres.NewPostRepository(db), postgres.NewCommunityRepository(db), us, db, + postgres.NewPostRepository(db), postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), us, db, WithAdmissions(postgres.NewAdmissionRepository(db)), ) } diff --git a/internal/atproto/jetstream/rev_gate_test.go b/internal/atproto/jetstream/rev_gate_test.go index 65432b8..4fa6eb8 100644 --- a/internal/atproto/jetstream/rev_gate_test.go +++ b/internal/atproto/jetstream/rev_gate_test.go @@ -3,6 +3,7 @@ package jetstream import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "testing" @@ -71,7 +72,7 @@ func setupRevFixtures(t *testing.T, db *sql.DB) (pc *PostEventConsumer, postURI, us := newMockUserService() us.users[revTestAuthor] = &users.User{DID: revTestAuthor, Handle: "revauthor.test"} pc = NewPostEventConsumer( - postgres.NewPostRepository(db), postgres.NewCommunityRepository(db), us, db, + postgres.NewPostRepository(db), postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), us, db, WithAdmissions(postgres.NewAdmissionRepository(db)), ) @@ -374,7 +375,7 @@ func TestCommunityConsumer_StaleSubscribeReplayAfterUnsubscribe_DoesNotResubscri insertBridgedUser(t, db, revTestAuthor, "revauthor.test") insertBridgedCommunity(t, db, revTestCommunity, "revcommunity.test", revTestAuthor) - cec := NewCommunityEventConsumer(postgres.NewCommunityRepository(db), "did:web:test.local", true, nil, + cec := NewCommunityEventConsumer(postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), "did:web:test.local", true, nil, WithCommunityRevGate(NewRevGate(db))) ctx := context.Background() base := time.Now().UnixMicro() diff --git a/internal/config/config.go b/internal/config/config.go index f5405d0..db7e6c4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,9 @@ const ( // sealSecretBytes is the decoded length oauth.NewOAuthClient requires of // OAUTH_SEAL_SECRET. sealSecretBytes = 32 + // encryptionKeyBytes is the decoded length AES-256 requires of + // ENCRYPTION_KEY. + encryptionKeyBytes = 32 // minSecretLength is the shortest value accepted for a production secret // that is used directly as key material rather than decoded. @@ -122,6 +125,16 @@ type Config struct { // CursorSecret is the HMAC key that signs pagination cursors, preventing // clients from forging or tampering with them. CursorSecret string + + // EncryptionKey is the base64-encoded 32-byte AES-256 key that seals + // stored credentials (community PDS credentials, aggregator OAuth + // sessions). It lives in the environment, never in the database. + EncryptionKey string + + // EncryptionKeyGenerated reports that EncryptionKey was randomly generated + // because ENCRYPTION_KEY was unset in dev. Credentials sealed under a + // generated key do not survive a restart. + EncryptionKeyGenerated bool } // DatabaseConfig holds the AppView PostgreSQL connection and pool settings. @@ -462,6 +475,17 @@ func Load() (*Config, error) { if err := cfg.loadOAuth(); err != nil { return nil, err } + cfg.EncryptionKey = lookup("ENCRYPTION_KEY") + if cfg.EncryptionKey == "" && cfg.IsDevEnv { + // Dev convenience only. A generated key cannot decrypt credentials after + // a restart, so production requires an operator-provided key. + randomBytes := make([]byte, encryptionKeyBytes) + if _, err := rand.Read(randomBytes); err != nil { + return nil, fmt.Errorf("generating dev encryption key: %w", err) + } + cfg.EncryptionKey = base64.StdEncoding.EncodeToString(randomBytes) + cfg.EncryptionKeyGenerated = true + } if err := cfg.loadInstance(); err != nil { return nil, err } @@ -1097,6 +1121,27 @@ func (c *Config) Validate() error { // over-large one, so the bound exists whatever this value is, and the worst // an omission costs is a different page size. + switch { + case c.EncryptionKey == "" && !c.IsDevEnv: + problems = append(problems, "ENCRYPTION_KEY is required in production") + case c.EncryptionKey == "": + // An unset key is allowed only in a hand-assembled dev Config. Load + // generates one before validation. + case !c.IsDevEnv && isPlaceholder(c.EncryptionKey): + problems = append(problems, "ENCRYPTION_KEY is still set to a documented "+ + "placeholder value; generate one with: openssl rand -base64 32") + default: + decoded, err := base64.StdEncoding.DecodeString(c.EncryptionKey) + if err != nil { + problems = append(problems, "ENCRYPTION_KEY must be base64: "+err.Error()) + } else if len(decoded) != encryptionKeyBytes { + problems = append(problems, fmt.Sprintf( + "ENCRYPTION_KEY must decode to %d bytes, got %d; "+ + "generate one with: openssl rand -base64 %d", + encryptionKeyBytes, len(decoded), encryptionKeyBytes)) + } + } + if !c.IsDevEnv { switch { case c.OAuth.SealSecret == "": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2198079..46f7e09 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -20,6 +20,10 @@ import ( // bytes, which is what oauth.NewOAuthClient requires. var validSealSecret = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0xA5}, 32)) +// validEncryptionKey is a well-formed ENCRYPTION_KEY: standard base64 of the +// 32 raw bytes required by AES-256. +var validEncryptionKey = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x5A}, 32)) + // prodEnv is the minimum set of variables a production configuration must // provide. Tests start from this and mutate one thing at a time so each case // asserts about exactly one rule. @@ -28,6 +32,7 @@ func prodEnv(t *testing.T) { t.Setenv("IS_DEV_ENV", "false") t.Setenv("DATABASE_URL", "postgres://u:p@db:5432/coves?sslmode=disable") t.Setenv("OAUTH_SEAL_SECRET", validSealSecret) + t.Setenv("ENCRYPTION_KEY", validEncryptionKey) t.Setenv("CURSOR_SECRET", "a-real-cursor-secret-long-enough") t.Setenv("JETSTREAM_FEEDS", "bsky=wss://jetstream2.us-east.bsky.network") t.Setenv("INSTANCE_DID", "did:web:coves.social") @@ -160,7 +165,7 @@ func TestLoad_UnsetIsDevEnvMeansProduction(t *testing.T) { if err == nil { t.Fatal("Load() succeeded with IS_DEV_ENV unset; an unset value must mean production") } - for _, want := range []string{"OAUTH_SEAL_SECRET", "CURSOR_SECRET", "JETSTREAM_FEEDS"} { + for _, want := range []string{"OAUTH_SEAL_SECRET", "ENCRYPTION_KEY", "CURSOR_SECRET", "JETSTREAM_FEEDS"} { if !strings.Contains(err.Error(), want) { t.Errorf("error should mention %s; got:\n%s", want, err.Error()) } @@ -249,6 +254,7 @@ func TestLoad_RejectsDocumentedPlaceholderSecrets(t *testing.T) { }{ {"CURSOR_SECRET", "CHANGE_ME_CURSOR_SECRET"}, {"OAUTH_SEAL_SECRET", "CHANGE_ME_BASE64_32_BYTES"}, + {"ENCRYPTION_KEY", "CHANGE_ME_BASE64_ENCODED_KEY"}, } for _, tc := range tests { t.Run(tc.key, func(t *testing.T) { @@ -357,6 +363,7 @@ func TestLoad_ProductionRequiresSecrets(t *testing.T) { wantText string }{ {"missing seal secret", "OAUTH_SEAL_SECRET", "OAUTH_SEAL_SECRET is required"}, + {"missing encryption key", "ENCRYPTION_KEY", "ENCRYPTION_KEY is required"}, {"missing cursor secret", "CURSOR_SECRET", "CURSOR_SECRET is required"}, {"missing jetstream feeds", "JETSTREAM_FEEDS", "JETSTREAM_FEEDS is required"}, } @@ -638,7 +645,7 @@ func TestValidate_ReportsAllProblems(t *testing.T) { t.Fatal("Validate() returned nil for an invalid production config") } for _, want := range []string{ - "INSTANCE_DOMAIN", "OAUTH_SEAL_SECRET", "CURSOR_SECRET", "JETSTREAM_FEEDS", + "INSTANCE_DOMAIN", "OAUTH_SEAL_SECRET", "ENCRYPTION_KEY", "CURSOR_SECRET", "JETSTREAM_FEEDS", } { if !strings.Contains(err.Error(), want) { t.Errorf("error should mention %s; got:\n%s", want, err.Error()) diff --git a/internal/config/encryption_key_config_test.go b/internal/config/encryption_key_config_test.go new file mode 100644 index 0000000..d38e202 --- /dev/null +++ b/internal/config/encryption_key_config_test.go @@ -0,0 +1,120 @@ +package config + +import ( + "bytes" + "encoding/base64" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadReadsEncryptionKeyFromEnvironment(t *testing.T) { + clearEnv(t) + t.Setenv("IS_DEV_ENV", "true") + t.Setenv("ENCRYPTION_KEY", " \t"+validEncryptionKey+"\n ") + + cfg, err := Load() + require.NoError(t, err) + assert.Equal(t, validEncryptionKey, cfg.EncryptionKey) + assert.False(t, cfg.EncryptionKeyGenerated) +} + +func TestLoadGeneratesUniqueEncryptionKeysInDev(t *testing.T) { + clearEnv(t) + t.Setenv("IS_DEV_ENV", "true") + + first, err := Load() + require.NoError(t, err) + second, err := Load() + require.NoError(t, err) + + for name, cfg := range map[string]*Config{"first": first, "second": second} { + t.Run(name, func(t *testing.T) { + decoded, decodeErr := base64.StdEncoding.DecodeString(cfg.EncryptionKey) + require.NoError(t, decodeErr, "generated ENCRYPTION_KEY must use standard base64") + assert.Len(t, decoded, 32) + assert.True(t, cfg.EncryptionKeyGenerated) + }) + } + assert.NotEqual(t, first.EncryptionKey, second.EncryptionKey, + "two process starts must not receive the same randomly generated dev key") +} + +func TestLoadRejectsMalformedEncryptionKeyInDev(t *testing.T) { + tests := []struct { + name string + value string + }{ + {name: "non-base64", value: "not valid base64!"}, + {name: "sixteen bytes", value: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x16}, 16))}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + clearEnv(t) + t.Setenv("IS_DEV_ENV", "true") + t.Setenv("ENCRYPTION_KEY", test.value) + + _, err := Load() + require.Error(t, err, "a set but invalid key must not be replaced by a generated dev key") + assert.Contains(t, err.Error(), "ENCRYPTION_KEY") + }) + } +} + +func TestLoadValidatesEncryptionKeyInProduction(t *testing.T) { + tests := []struct { + name string + value string + wantText string + }{ + { + name: "placeholder", + value: "CHANGE_ME_BASE64_ENCODED_KEY", + wantText: "openssl rand -base64 32", + }, + { + name: "non-base64", + value: "not valid base64!", + wantText: "base64", + }, + { + name: "wrong decoded length", + value: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x16}, 16)), + wantText: "32 bytes", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv("ENCRYPTION_KEY", test.value) + + _, err := Load() + require.Error(t, err) + assert.Contains(t, err.Error(), "ENCRYPTION_KEY") + assert.Contains(t, err.Error(), test.wantText) + }) + } +} + +func TestLoadAcceptsValidEncryptionKeyInProduction(t *testing.T) { + clearEnv(t) + prodEnv(t) + + cfg, err := Load() + require.NoError(t, err) + assert.Equal(t, validEncryptionKey, cfg.EncryptionKey) + assert.False(t, cfg.EncryptionKeyGenerated) +} + +func TestClearEnvForTestClearsEncryptionKey(t *testing.T) { + t.Setenv("ENCRYPTION_KEY", validEncryptionKey) + + ClearEnvForTest(t) + + assert.Empty(t, os.Getenv("ENCRYPTION_KEY")) +} diff --git a/internal/config/testing.go b/internal/config/testing.go index 79a34df..289a6ed 100644 --- a/internal/config/testing.go +++ b/internal/config/testing.go @@ -14,7 +14,7 @@ var loadedEnvVars = []string{ "HTTP_READ_HEADER_TIMEOUT", "HTTP_READ_TIMEOUT", "HTTP_WRITE_TIMEOUT", "HTTP_IDLE_TIMEOUT", "HTTP_SHUTDOWN_TIMEOUT", "PLC_DIRECTORY_URL", "IDENTITY_PLC_URL", "IDENTITY_CACHE_TTL", "HANDLE_WELL_KNOWN_HOSTS", - "OAUTH_SEAL_SECRET", "APPVIEW_PUBLIC_URL", + "OAUTH_SEAL_SECRET", "ENCRYPTION_KEY", "APPVIEW_PUBLIC_URL", "OAUTH_CLIENT_PRIVATE_KEY", "OAUTH_CLIENT_KEY_ID", "INSTANCE_DID", "INSTANCE_DOMAIN", "COMMUNITY_CREATORS", "TRUSTED_BRIDGE_PDS_HOSTS", "SKIP_DID_WEB_VERIFICATION", diff --git a/internal/core/aggregators/service_validate_post_test.go b/internal/core/aggregators/service_validate_post_test.go index dcecf2f..fb03b2e 100644 --- a/internal/core/aggregators/service_validate_post_test.go +++ b/internal/core/aggregators/service_validate_post_test.go @@ -3,6 +3,7 @@ package aggregators_test import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "testing" "time" @@ -50,7 +51,7 @@ func newPostValidation(t *testing.T) *postValidation { db := testkit.DB(t) ctx := context.Background() - repo := postgres.NewAggregatorRepository(db) + repo := postgres.NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := "did:plc:" + testkit.UniqueID(t) require.NoError(t, repo.CreateAggregator(ctx, &aggregators.Aggregator{ @@ -64,7 +65,7 @@ func newPostValidation(t *testing.T) *postValidation { name := testkit.UniqueID(t) communityDID := "did:plc:" + name - _, err := postgres.NewCommunityRepository(db).Create(ctx, &communities.Community{ + _, err := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()).Create(ctx, &communities.Community{ DID: communityDID, Handle: "c-" + name + ".coves.social", Name: name, diff --git a/internal/core/blobs/blob_upload_integration_test.go b/internal/core/blobs/blob_upload_integration_test.go index 075a19f..41ce44f 100644 --- a/internal/core/blobs/blob_upload_integration_test.go +++ b/internal/core/blobs/blob_upload_integration_test.go @@ -9,6 +9,7 @@ import ( "Coves/internal/core/communities" "Coves/internal/core/posts" "Coves/internal/core/users" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/internal/db/postgres" "Coves/tests/fixtures" "Coves/tests/testkit" @@ -55,7 +56,7 @@ func TestBlobUpload_E2E_PostWithImages(t *testing.T) { ctx := context.Background() // Setup repositories - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) postRepo := postgres.NewPostRepository(db) userRepo := postgres.NewUserRepository(db) @@ -419,7 +420,7 @@ func TestBlobUpload_E2E_CommentWithImage(t *testing.T) { ctx := context.Background() // Setup repositories - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) commentRepo := postgres.NewCommentRepository(db) // Setup services (pdsURL already declared in health check above) @@ -574,7 +575,7 @@ func TestBlobUpload_Validation(t *testing.T) { t.Parallel() db := testkit.DB(t) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) blobService := blobs.NewBlobService(testkit.Endpoints().PDS.BaseURL, blobs.PrivateHostOptions(true)...) community := createTestCommunityWithBlobCredentials(t, communityRepo, "validation") ctx := context.Background() diff --git a/internal/core/comments/comment_query_test.go b/internal/core/comments/comment_query_test.go index 160aced..dc48340 100644 --- a/internal/core/comments/comment_query_test.go +++ b/internal/core/comments/comment_query_test.go @@ -6,6 +6,7 @@ import ( commentsAPI "Coves/internal/api/handlers/comments" "Coves/internal/atproto/jetstream" "Coves/internal/core/comments" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/internal/db/postgres" "Coves/tests/fixtures" "Coves/tests/testkit" @@ -1242,7 +1243,7 @@ func setupCommentService(db *sql.DB) comments.Service { commentRepo := postgres.NewCommentRepository(db) postRepo := postgres.NewPostRepository(db) userRepo := postgres.NewUserRepository(db) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) // Use factory constructor with nil factory - these tests only use the read path (GetComments) return comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil) } @@ -1329,7 +1330,7 @@ func setupCommentServiceAdapter(db *sql.DB) *testCommentServiceAdapter { commentRepo := postgres.NewCommentRepository(db) postRepo := postgres.NewPostRepository(db) userRepo := postgres.NewUserRepository(db) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) // Use factory constructor with nil factory - these tests only use the read path (GetComments) service := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil) return &testCommentServiceAdapter{service: service} diff --git a/internal/core/comments/comment_vote_test.go b/internal/core/comments/comment_vote_test.go index 5b8aee7..4334336 100644 --- a/internal/core/comments/comment_vote_test.go +++ b/internal/core/comments/comment_vote_test.go @@ -25,6 +25,7 @@ import ( "Coves/internal/atproto/jetstream" "Coves/internal/core/comments" "Coves/internal/core/users" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/internal/db/postgres" "Coves/tests/fixtures" "Coves/tests/testkit" @@ -352,7 +353,7 @@ func TestCommentVote_ViewerState(t *testing.T) { voteRepo := postgres.NewVoteRepository(db) postRepo := postgres.NewPostRepository(db) userRepo := postgres.NewUserRepository(db) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) userService := users.NewUserService(userRepo, nil, testkit.Endpoints().PDS.BaseURL, nil, "") voteConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) diff --git a/internal/core/communities/consumer_profile_indexing_test.go b/internal/core/communities/consumer_profile_indexing_test.go index a5c0ac2..898828f 100644 --- a/internal/core/communities/consumer_profile_indexing_test.go +++ b/internal/core/communities/consumer_profile_indexing_test.go @@ -6,6 +6,7 @@ import ( "Coves/internal/atproto/identity" "Coves/internal/atproto/jetstream" "Coves/internal/core/communities" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/internal/db/postgres" "Coves/tests/fixtures" "Coves/tests/testkit" @@ -102,7 +103,7 @@ func newCommunityConsumer(t *testing.T, resolver *stubIdentityResolver) ( ) { t.Helper() - repo := postgres.NewCommunityRepository(testkit.DB(t)) + repo := postgres.NewCommunityRepository(testkit.DB(t), credentialciphertest.Fixed()) if resolver == nil { // A typed nil in an interface parameter is not nil, and the consumer // branches on the interface being nil to decide whether it is in diff --git a/internal/core/communities/pds_provisioning.go b/internal/core/communities/pds_provisioning.go index 6561db6..eefea48 100644 --- a/internal/core/communities/pds_provisioning.go +++ b/internal/core/communities/pds_provisioning.go @@ -145,7 +145,7 @@ func (p *PDSAccountProvisioner) ProvisionCommunityAccount( // CRITICAL: The password MUST be encrypted (not hashed) before database storage // We need to recover the plaintext password to call com.atproto.server.createSession // when access/refresh tokens expire (90-day window on refresh tokens) - // The repository layer handles encryption using pgp_sym_encrypt() + // The repository layer seals it with the application credential cipher. return &CommunityPDSAccount{ DID: output.Did, // The community's DID (PDS-generated) Handle: output.Handle, // e.g., gaming.community.coves.social diff --git a/internal/core/communities/service_credentials_test.go b/internal/core/communities/service_credentials_test.go index 6d5e3b1..a112c8f 100644 --- a/internal/core/communities/service_credentials_test.go +++ b/internal/core/communities/service_credentials_test.go @@ -26,12 +26,13 @@ import ( // a database dump would otherwise be a set of live logins to every community // this instance hosts. // -// pgcrypto's pgp_sym_encrypt is what reconciles the two, applied in the -// repository's SQL rather than in Go. Nothing in the type system says so: the -// Community struct carries a plain string on both sides of the write, and a -// repository that quietly stopped encrypting would still round-trip perfectly. -// The only way to see the difference is to read the column directly, which is -// why these tests hold the database handle as well as the service. +// The app-side AES-256-GCM credential cipher reconciles the two, applied by the +// repository before values enter SQL and after they leave it. Nothing in the +// type system says so: the Community struct carries a plain string on both sides +// of the write, and a repository that quietly stopped encrypting would still +// round-trip perfectly. The only way to see the difference is to read the column +// directly, which is why these tests hold the database handle as well as the +// service. // // # WHAT IS ASSERTED ELSEWHERE // diff --git a/internal/core/communities/service_provisioning_test.go b/internal/core/communities/service_provisioning_test.go index df92deb..51a7f0f 100644 --- a/internal/core/communities/service_provisioning_test.go +++ b/internal/core/communities/service_provisioning_test.go @@ -6,6 +6,7 @@ import ( "Coves/internal/atproto/pds" "Coves/internal/core/blobs" "Coves/internal/core/communities" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/internal/db/postgres" "Coves/tests/testkit" "context" @@ -82,7 +83,7 @@ func newCommunityServiceWithDatabase(t *testing.T) ( t.Helper() db := testkit.DB(t) - repo := postgres.NewCommunityRepository(db) + repo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) pdsServer := testkit.NewPDS(t) service := communities.NewCommunityServiceWithPDSFactory( repo, diff --git a/internal/core/posts/consumer_comment_count_test.go b/internal/core/posts/consumer_comment_count_test.go index 474576c..9e8f903 100644 --- a/internal/core/posts/consumer_comment_count_test.go +++ b/internal/core/posts/consumer_comment_count_test.go @@ -3,6 +3,7 @@ package posts_test import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "fmt" "testing" @@ -54,7 +55,7 @@ func TestPostConsumer_ReconcilesCommentCountWhenCommentsArriveFirst(t *testing.T postRepo := postgres.NewPostRepository(db) commentRepo := postgres.NewCommentRepository(db) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) userRepo := postgres.NewUserRepository(db) // A nil identity resolver is deliberate: the post consumer only asks the user // service about authors it has not seen, and every author here is seeded diff --git a/internal/core/posts/repository_create_test.go b/internal/core/posts/repository_create_test.go index 105b2f4..6f1a6d1 100644 --- a/internal/core/posts/repository_create_test.go +++ b/internal/core/posts/repository_create_test.go @@ -3,6 +3,7 @@ package posts_test import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "fmt" "testing" @@ -40,7 +41,7 @@ func TestPostRepository_CreateIndexesEachPostOnce(t *testing.T) { pdsURL := testkit.Endpoints().PDS.BaseURL userRepo := postgres.NewUserRepository(db) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) postRepo := postgres.NewPostRepository(db) // A post row references both, so both have to exist before one can be diff --git a/internal/core/posts/service_admission_test.go b/internal/core/posts/service_admission_test.go index 19a0d6c..54c855d 100644 --- a/internal/core/posts/service_admission_test.go +++ b/internal/core/posts/service_admission_test.go @@ -3,6 +3,7 @@ package posts_test import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "fmt" @@ -127,7 +128,7 @@ func newAdmissionFixture(t *testing.T) *admissionFixture { Limits: limits, Now: clock.Now, }))...), - repo: postgres.NewCommunityRepository(base.db), + repo: postgres.NewCommunityRepository(base.db, credentialciphertest.Fixed()), clock: clock, limits: limits, } diff --git a/internal/core/posts/service_aggregator_test.go b/internal/core/posts/service_aggregator_test.go index 129479a..ec2a6b5 100644 --- a/internal/core/posts/service_aggregator_test.go +++ b/internal/core/posts/service_aggregator_test.go @@ -3,6 +3,7 @@ package posts_test import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "fmt" "testing" @@ -74,7 +75,7 @@ func newAggregatorFixture(t *testing.T) *aggregatorFixture { base := newPostFixture(t) ctx := context.Background() - index := postgres.NewAggregatorRepository(base.db) + index := postgres.NewAggregatorRepository(base.db, credentialciphertest.Fixed()) aggregatorAccount := base.authorRepos.register( base.pds.CreateAccount(t, testkit.WithHandlePrefix("ag"))) aggregatorDID := aggregatorAccount.DID diff --git a/internal/core/posts/service_author_posts_query_test.go b/internal/core/posts/service_author_posts_query_test.go index 7bcac5f..4278aaf 100644 --- a/internal/core/posts/service_author_posts_query_test.go +++ b/internal/core/posts/service_author_posts_query_test.go @@ -3,6 +3,7 @@ package posts_test import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "encoding/json" @@ -84,7 +85,7 @@ func newAuthorPostsFixture(t *testing.T) *authorPostsFixture { postRepo := postgres.NewPostRepository(db) userRepo := postgres.NewUserRepository(db) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) voteRepo := postgres.NewVoteRepository(db) resolver := identity.NewResolver(db, identity.DefaultConfig()) diff --git a/internal/core/posts/service_create_validation_test.go b/internal/core/posts/service_create_validation_test.go index baceee8..fc5883b 100644 --- a/internal/core/posts/service_create_validation_test.go +++ b/internal/core/posts/service_create_validation_test.go @@ -3,6 +3,7 @@ package posts_test import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "fmt" "strings" @@ -54,7 +55,7 @@ func TestService_CreateResolvesTheCommunityAndValidatesTheRequest(t *testing.T) pdsURL := testkit.Endpoints().PDS.BaseURL userRepo := postgres.NewUserRepository(db) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) postRepo := postgres.NewPostRepository(db) resolver := identity.NewResolver(db, identity.DefaultConfig()) diff --git a/internal/core/posts/service_writeforward_test.go b/internal/core/posts/service_writeforward_test.go index 0b88562..dc79388 100644 --- a/internal/core/posts/service_writeforward_test.go +++ b/internal/core/posts/service_writeforward_test.go @@ -3,6 +3,7 @@ package posts_test import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "fmt" @@ -312,7 +313,7 @@ func newPostFixture(t *testing.T) *postFixture { db := testkit.DB(t) pdsServer := testkit.NewPDS(t) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) communityService := communities.NewCommunityServiceWithPDSFactory( communityRepo, diff --git a/internal/core/unfurl/post_unfurl_integration_test.go b/internal/core/unfurl/post_unfurl_integration_test.go index d5ce688..c702b8b 100644 --- a/internal/core/unfurl/post_unfurl_integration_test.go +++ b/internal/core/unfurl/post_unfurl_integration_test.go @@ -10,6 +10,7 @@ import ( "Coves/internal/core/posts" "Coves/internal/core/unfurl" "Coves/internal/core/users" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/internal/db/postgres" "Coves/tests/fixtures" "Coves/tests/testkit" @@ -35,7 +36,7 @@ func TestPostUnfurl_UnsupportedURL(t *testing.T) { // Setup services userRepo := postgres.NewUserRepository(db) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) postRepo := postgres.NewPostRepository(db) identityConfig := identity.DefaultConfig() @@ -131,7 +132,7 @@ func TestPostUnfurl_MissingEmbedType(t *testing.T) { // Setup userRepo := postgres.NewUserRepository(db) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) postRepo := postgres.NewPostRepository(db) unfurlRepo := unfurl.NewRepository(db) @@ -296,7 +297,7 @@ func TestPostUnfurl_E2E_WithJetstream(t *testing.T) { // Setup repositories userRepo := postgres.NewUserRepository(db) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) postRepo := postgres.NewPostRepository(db) unfurlRepo := unfurl.NewRepository(db) diff --git a/internal/crypto/credentialcipher/cipher.go b/internal/crypto/credentialcipher/cipher.go new file mode 100644 index 0000000..cccbbbd --- /dev/null +++ b/internal/crypto/credentialcipher/cipher.go @@ -0,0 +1,102 @@ +// Package credentialcipher encrypts stored credentials (community PDS +// passwords and tokens, aggregator OAuth sessions and DPoP keys) with a key +// held by the process rather than by the database, so a database read or a +// backup does not yield the plaintext. +package credentialcipher + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" +) + +// KeySize is the number of raw key bytes AES-256 requires. +const KeySize = 32 + +var ( + // ErrInvalidKey reports a key that is not exactly KeySize bytes. + ErrInvalidKey = errors.New("credentialcipher: key must be 32 bytes") + // ErrInvalidCiphertext reports a value that is too short, tampered with, + // or sealed under a different key or context. + ErrInvalidCiphertext = errors.New("credentialcipher: invalid ciphertext") + // ErrUnsupportedVersion reports invalid ciphertext whose leading version + // byte this build does not understand. + ErrUnsupportedVersion = fmt.Errorf("%w: unsupported ciphertext version", ErrInvalidCiphertext) +) + +// Version is the leading byte of every value this package writes. Migration +// 046 and the startup conversion pass compare against it; pgcrypto output +// starts with 0xC3, so the two never collide. +const Version = byte(0x01) + +// Cipher seals and opens credential values with AES-256-GCM. +type Cipher struct { + aead cipher.AEAD +} + +// New builds a Cipher from exactly KeySize raw key bytes. +func New(key []byte) (*Cipher, error) { + if len(key) != KeySize { + return nil, fmt.Errorf("%w: got %d bytes", ErrInvalidKey, len(key)) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("%w: initialize AES", ErrInvalidKey) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("credentialcipher: initialize GCM: %w", err) + } + + return &Cipher{aead: aead}, nil +} + +// NewFromBase64 builds a Cipher from a standard-base64 encoding of KeySize bytes. +func NewFromBase64(encoded string) (*Cipher, error) { + key, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("%w: invalid base64", ErrInvalidKey) + } + return New(key) +} + +// Encrypt seals plaintext bound to context. The same context must be passed +// to Decrypt; a value moved to a different row or column will not open. +func (c *Cipher) Encrypt(plaintext, context string) ([]byte, error) { + nonce := make([]byte, c.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("credentialcipher: generate nonce: %w", err) + } + + sealed := make([]byte, 1+len(nonce)) + sealed[0] = Version + copy(sealed[1:], nonce) + authenticatedData := append([]byte{Version}, context...) + return c.aead.Seal(sealed, nonce, []byte(plaintext), authenticatedData), nil +} + +// Decrypt opens a value produced by Encrypt under the same key and context. +func (c *Cipher) Decrypt(ciphertext []byte, context string) (string, error) { + minimumLength := 1 + c.aead.NonceSize() + c.aead.Overhead() + if len(ciphertext) < minimumLength { + return "", fmt.Errorf("%w: too short", ErrInvalidCiphertext) + } + if ciphertext[0] != Version { + if ciphertext[0] == 0xC3 { + return "", fmt.Errorf("%w: pgcrypto legacy ciphertext (version 195); the AppView startup conversion has not run on this database", ErrUnsupportedVersion) + } + return "", fmt.Errorf("%w: version %d", ErrUnsupportedVersion, ciphertext[0]) + } + + nonceEnd := 1 + c.aead.NonceSize() + authenticatedData := append([]byte{Version}, context...) + plaintext, err := c.aead.Open(nil, ciphertext[1:nonceEnd], ciphertext[nonceEnd:], authenticatedData) + if err != nil { + return "", fmt.Errorf("%w: authentication failed", ErrInvalidCiphertext) + } + return string(plaintext), nil +} diff --git a/internal/crypto/credentialcipher/cipher_test.go b/internal/crypto/credentialcipher/cipher_test.go new file mode 100644 index 0000000..13b6991 --- /dev/null +++ b/internal/crypto/credentialcipher/cipher_test.go @@ -0,0 +1,243 @@ +package credentialcipher_test + +import ( + "bytes" + "encoding/base64" + "strconv" + "strings" + "testing" + + "Coves/internal/crypto/credentialcipher" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testKeyString = "0123456789abcdef0123456789abcdef" + wrongKeyString = "fedcba9876543210fedcba9876543210" + wireVersion = byte(0x01) + nonceSize = 12 + tagSize = 16 +) + +func TestNewValidatesKeyLength(t *testing.T) { + cipher, err := credentialcipher.New([]byte(testKeyString)) + require.NoError(t, err) + assert.NotNil(t, cipher) + + for _, length := range []int{0, 16, 31, 33, 64} { + t.Run(stringLengthName(length), func(t *testing.T) { + key := bytes.Repeat([]byte{'k'}, length) + invalidCipher, err := credentialcipher.New(key) + + require.ErrorIs(t, err, credentialcipher.ErrInvalidKey) + assert.Nil(t, invalidCipher) + if len(key) > 0 { + assert.NotContains(t, err.Error(), string(key), "error disclosed the rejected key") + } + }) + } +} + +func TestNewFromBase64ValidatesEncodingAndKeyLength(t *testing.T) { + encodedKey := base64.StdEncoding.EncodeToString([]byte(testKeyString)) + cipher, err := credentialcipher.NewFromBase64(encodedKey) + require.NoError(t, err) + assert.NotNil(t, cipher) + + tests := []struct { + name string + encoded string + key string + }{ + { + name: "non-base64 text", + encoded: "this is not standard base64!", + }, + { + name: "sixteen decoded bytes", + encoded: base64.StdEncoding.EncodeToString([]byte("0123456789abcdef")), + key: "0123456789abcdef", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + invalidCipher, err := credentialcipher.NewFromBase64(test.encoded) + + require.ErrorIs(t, err, credentialcipher.ErrInvalidKey) + assert.Nil(t, invalidCipher) + if test.key != "" { + assert.NotContains(t, err.Error(), test.key, "error disclosed the decoded key") + } + }) + } +} + +func TestEncryptUsesVersionedAESGCMFramingAndRandomNonces(t *testing.T) { + cipher, err := credentialcipher.New([]byte(testKeyString)) + require.NoError(t, err) + + const plaintext = "same credential" + const context = "communities.pds_password_encrypted:did:plc:framing" + + first, err := cipher.Encrypt(plaintext, context) + require.NoError(t, err) + second, err := cipher.Encrypt(plaintext, context) + require.NoError(t, err) + + expectedLength := 1 + nonceSize + len(plaintext) + tagSize + if assert.Len(t, first, expectedLength) { + assert.Equal(t, wireVersion, first[0]) + } + if assert.Len(t, second, expectedLength) { + assert.Equal(t, wireVersion, second[0]) + } + assert.NotEqual(t, first, second, "reusing a nonce makes repeated credential encryption deterministic") +} + +func TestCipherRoundTripsCredentialPayloads(t *testing.T) { + cipher, err := credentialcipher.New([]byte(testKeyString)) + require.NoError(t, err) + + tests := []struct { + name string + plaintext string + }{ + {name: "empty", plaintext: ""}, + {name: "JWT punctuation", plaintext: "eyJhbGciOiJIUzI1NiJ9.payload+with/slash.signature=="}, + {name: "multibase key", plaintext: "zQ3shK7ExampleMultibasePrivateKey"}, + {name: "unicode", plaintext: "\u5bc6\u78bc-\u30c8\u30fc\u30af\u30f3-\U0001f510"}, + {name: "four KiB", plaintext: strings.Repeat("x", 4*1024)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + context := "aggregators.oauth_access_token_encrypted:did:plc:" + strings.ReplaceAll(test.name, " ", "-") + sealed, err := cipher.Encrypt(test.plaintext, context) + require.NoError(t, err) + assert.Len(t, sealed, 1+nonceSize+len(test.plaintext)+tagSize) + + opened, err := cipher.Decrypt(sealed, context) + require.NoError(t, err) + assert.Equal(t, test.plaintext, opened) + }) + } +} + +func TestDecryptRejectsWrongKeyAndContext(t *testing.T) { + cipher, err := credentialcipher.New([]byte(testKeyString)) + require.NoError(t, err) + wrongCipher, err := credentialcipher.New([]byte(wrongKeyString)) + require.NoError(t, err) + + const plaintext = "credential-plaintext-do-not-leak" + const context = "aggregators.oauth_refresh_token_encrypted:did:plc:binding" + sealed, err := cipher.Encrypt(plaintext, context) + require.NoError(t, err) + + t.Run("wrong key", func(t *testing.T) { + _, err := wrongCipher.Decrypt(sealed, context) + requireInvalidCiphertextWithoutSecrets(t, err, plaintext, testKeyString, wrongKeyString) + }) + + t.Run("wrong context", func(t *testing.T) { + _, err := cipher.Decrypt(sealed, context+"-different") + requireInvalidCiphertextWithoutSecrets(t, err, plaintext, testKeyString) + }) +} + +func TestDecryptRejectsTamperingInEveryAuthenticatedRegion(t *testing.T) { + cipher, err := credentialcipher.New([]byte(testKeyString)) + require.NoError(t, err) + + const plaintext = "credential-plaintext-do-not-leak" + const context = "communities.pds_access_token_encrypted:did:plc:tamper" + sealed, err := cipher.Encrypt(plaintext, context) + require.NoError(t, err) + require.Greater(t, len(sealed), 1+nonceSize+tagSize) + + tests := []struct { + name string + index int + }{ + {name: "nonce", index: 1}, + {name: "body", index: 13}, + {name: "tag", index: len(sealed) - 1}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tampered := append([]byte(nil), sealed...) + tampered[test.index] ^= 0x01 + + _, err := cipher.Decrypt(tampered, context) + requireInvalidCiphertextWithoutSecrets(t, err, plaintext, testKeyString) + }) + } +} + +func TestDecryptRejectsMalformedCiphertext(t *testing.T) { + cipher, err := credentialcipher.New([]byte(testKeyString)) + require.NoError(t, err) + + tests := []struct { + name string + ciphertext []byte + }{ + {name: "nil", ciphertext: nil}, + {name: "empty", ciphertext: []byte{}}, + {name: "truncated", ciphertext: append([]byte{wireVersion}, make([]byte, nonceSize+tagSize-1)...)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := cipher.Decrypt(test.ciphertext, "communities.pds_refresh_token_encrypted:did:plc:malformed") + requireInvalidCiphertextWithoutSecrets(t, err, testKeyString) + }) + } +} + +func TestDecryptRejectsUnsupportedVersion(t *testing.T) { + cipher, err := credentialcipher.New([]byte(testKeyString)) + require.NoError(t, err) + + const credentialContext = "aggregators.oauth_dpop_private_key_encrypted:did:plc:version" + ciphertext, err := cipher.Encrypt("versioned credential", credentialContext) + require.NoError(t, err) + + tests := []struct { + name string + version byte + messagePart string + }{ + {name: "pgcrypto legacy ciphertext", version: 0xc3, messagePart: "pgcrypto"}, + {name: "unknown application version", version: 0x02, messagePart: "version 2"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + unsupported := append([]byte(nil), ciphertext...) + unsupported[0] = test.version + + _, err := cipher.Decrypt(unsupported, credentialContext) + assert.ErrorIs(t, err, credentialcipher.ErrInvalidCiphertext) + assert.ErrorIs(t, err, credentialcipher.ErrUnsupportedVersion) + assert.Contains(t, strings.ToLower(err.Error()), test.messagePart) + assert.NotContains(t, err.Error(), testKeyString, "error disclosed the encryption key") + }) + } +} + +func requireInvalidCiphertextWithoutSecrets(t *testing.T, err error, secrets ...string) { + t.Helper() + require.ErrorIs(t, err, credentialcipher.ErrInvalidCiphertext) + for _, secret := range secrets { + assert.NotContains(t, err.Error(), secret, "error disclosed credential material") + } +} + +func stringLengthName(length int) string { + return "length " + strconv.Itoa(length) +} diff --git a/internal/crypto/credentialcipher/credentialciphertest/fixed.go b/internal/crypto/credentialcipher/credentialciphertest/fixed.go new file mode 100644 index 0000000..ef4e0aa --- /dev/null +++ b/internal/crypto/credentialcipher/credentialciphertest/fixed.go @@ -0,0 +1,15 @@ +// Package credentialciphertest provides a deterministic Cipher for tests that +// need to construct repositories but do not care about key management. +package credentialciphertest + +import "Coves/internal/crypto/credentialcipher" + +// Fixed returns a Cipher built from a constant, publicly known key. Never use +// it outside tests. +func Fixed() *credentialcipher.Cipher { + cipher, err := credentialcipher.New([]byte("coves-test-credential-key-000000")) + if err != nil { + panic("credentialciphertest: fixed key rejected: " + err.Error()) + } + return cipher +} diff --git a/internal/db/migrations/046_drop_encryption_keys.sql b/internal/db/migrations/046_drop_encryption_keys.sql new file mode 100644 index 0000000..883d129 --- /dev/null +++ b/internal/db/migrations/046_drop_encryption_keys.sql @@ -0,0 +1,70 @@ +-- +goose Up +-- The literal 1 below is credentialcipher.Version, the leading byte of every +-- value the application cipher writes; pgcrypto output starts with 0xC3, so a +-- first byte other than 1 (or an empty value) is legacy data. The AppView's +-- startup pass (postgres.ReencryptLegacyCredentials) runs before goose and +-- converts such rows; this guard is the independent backstop so the key is +-- never dropped while something still needs it. It assumes the single-writer +-- deployment Coves runs: one AppView, replaced on deploy, so no older binary +-- can write pgcrypto ciphertext between this check and the DROP. +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM communities + WHERE (pds_password_encrypted IS NOT NULL AND + (octet_length(pds_password_encrypted) = 0 OR get_byte(pds_password_encrypted, 0) <> 1)) + OR (pds_access_token_encrypted IS NOT NULL AND + (octet_length(pds_access_token_encrypted) = 0 OR get_byte(pds_access_token_encrypted, 0) <> 1)) + OR (pds_refresh_token_encrypted IS NOT NULL AND + (octet_length(pds_refresh_token_encrypted) = 0 OR get_byte(pds_refresh_token_encrypted, 0) <> 1)) + UNION ALL + SELECT 1 + FROM aggregators + WHERE (oauth_access_token_encrypted IS NOT NULL AND + (octet_length(oauth_access_token_encrypted) = 0 OR get_byte(oauth_access_token_encrypted, 0) <> 1)) + OR (oauth_refresh_token_encrypted IS NOT NULL AND + (octet_length(oauth_refresh_token_encrypted) = 0 OR get_byte(oauth_refresh_token_encrypted, 0) <> 1)) + OR (oauth_dpop_private_key_encrypted IS NOT NULL AND + (octet_length(oauth_dpop_private_key_encrypted) = 0 OR get_byte(oauth_dpop_private_key_encrypted, 0) <> 1)) + ) THEN + RAISE EXCEPTION 'legacy pgcrypto ciphertext remains; the AppView must re-encrypt credentials with ENCRYPTION_KEY before migration 046 can drop encryption_keys'; + END IF; +END +$$; +-- +goose StatementEnd + +COMMENT ON COLUMN communities.pds_password_encrypted IS 'SENSITIVE: AES-256-GCM ciphertext sealed by the application with the key from ENCRYPTION_KEY; required for session recovery when tokens expire'; +COMMENT ON COLUMN communities.pds_access_token_encrypted IS 'SENSITIVE: AES-256-GCM ciphertext sealed by the application with the key from ENCRYPTION_KEY for community PDS access'; +COMMENT ON COLUMN communities.pds_refresh_token_encrypted IS 'SENSITIVE: AES-256-GCM ciphertext sealed by the application with the key from ENCRYPTION_KEY for community PDS session renewal'; +COMMENT ON COLUMN aggregators.oauth_access_token_encrypted IS 'SENSITIVE: AES-256-GCM ciphertext sealed by the application with the key from ENCRYPTION_KEY for PDS operations'; +COMMENT ON COLUMN aggregators.oauth_refresh_token_encrypted IS 'SENSITIVE: AES-256-GCM ciphertext sealed by the application with the key from ENCRYPTION_KEY for session renewal'; +COMMENT ON COLUMN aggregators.oauth_dpop_private_key_encrypted IS 'SENSITIVE: AES-256-GCM ciphertext sealed by the application with the key from ENCRYPTION_KEY for token refresh'; + +DROP TABLE encryption_keys; + +-- +goose Down +-- Schema-only rollback. The table comes back with a FRESH random key and the +-- credential columns keep their AES-256-GCM values, so a pre-046 binary cannot +-- read any stored credential after this runs. There is no reverse conversion: +-- to actually roll back, restore a pre-cutover backup or NULL the credential +-- columns and re-provision the affected communities and aggregators. +CREATE TABLE encryption_keys ( + id INTEGER PRIMARY KEY CHECK (id = 1), + key_data BYTEA NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + rotated_at TIMESTAMP WITH TIME ZONE +); + +INSERT INTO encryption_keys (id, key_data) +VALUES (1, gen_random_bytes(32)) +ON CONFLICT (id) DO NOTHING; + +COMMENT ON TABLE encryption_keys IS 'Encryption keys for sensitive data - RESTRICT ACCESS'; +COMMENT ON COLUMN communities.pds_password_encrypted IS 'Encrypted community PDS password (pgp_sym_encrypt) - required for session recovery when tokens expire'; +COMMENT ON COLUMN communities.pds_access_token_encrypted IS 'Encrypted JWT - decrypt with pgp_sym_decrypt'; +COMMENT ON COLUMN communities.pds_refresh_token_encrypted IS 'Encrypted refresh token - decrypt with pgp_sym_decrypt'; +COMMENT ON COLUMN aggregators.oauth_access_token_encrypted IS 'SENSITIVE: Encrypted OAuth access token (pgp_sym_encrypt) for PDS operations'; +COMMENT ON COLUMN aggregators.oauth_refresh_token_encrypted IS 'SENSITIVE: Encrypted OAuth refresh token (pgp_sym_encrypt) for session renewal'; +COMMENT ON COLUMN aggregators.oauth_dpop_private_key_encrypted IS 'SENSITIVE: Encrypted DPoP private key (pgp_sym_encrypt) for token refresh'; diff --git a/internal/db/postgres/admission_queue_repo_test.go b/internal/db/postgres/admission_queue_repo_test.go index 2043b19..3943789 100644 --- a/internal/db/postgres/admission_queue_repo_test.go +++ b/internal/db/postgres/admission_queue_repo_test.go @@ -42,8 +42,9 @@ import ( // The credential column is written directly rather than through // UpdateCredentials, and the value is not a real token, because the query's // question is PRESENCE and nothing decrypts it here. Going through -// UpdateCredentials would drag in the encryption_keys row and pgp_sym_encrypt to -// prove something about a predicate that only reads IS NOT NULL. +// UpdateCredentials would drag in the app-side credential cipher and generate a +// real encrypted value to prove something about a predicate that only reads IS +// NOT NULL. func hostedCommunity(t *testing.T, db *sql.DB, name string) string { t.Helper() diff --git a/internal/db/postgres/admission_repo_schema_test.go b/internal/db/postgres/admission_repo_schema_test.go index 269ff1c..d16b410 100644 --- a/internal/db/postgres/admission_repo_schema_test.go +++ b/internal/db/postgres/admission_repo_schema_test.go @@ -328,6 +328,8 @@ func TestMigration034_DownRestoresTheAuthorForeignKeyUnvalidated(t *testing.T) { // come off first. Rolling back explicitly, // one asserted step at a time, is what keeps the assertions below pointed at // 034's Down rather than at whatever happens to be newest. + require.EqualValues(t, 46, testkit.MigrateDownOne(t, db, 46), + "046 (drop encryption_keys) sits on top of 045 and must be rolled back first") require.EqualValues(t, 45, testkit.MigrateDownOne(t, db, 45), "045 (the community subscriber recount) sits on top of 044 and must be rolled back first") require.EqualValues(t, 44, testkit.MigrateDownOne(t, db, 44), diff --git a/internal/db/postgres/aggregator_repo.go b/internal/db/postgres/aggregator_repo.go index fdad522..ce9d82b 100644 --- a/internal/db/postgres/aggregator_repo.go +++ b/internal/db/postgres/aggregator_repo.go @@ -2,6 +2,7 @@ package postgres import ( "Coves/internal/core/aggregators" + "Coves/internal/crypto/credentialcipher" "context" "database/sql" "errors" @@ -12,12 +13,16 @@ import ( ) type postgresAggregatorRepo struct { - db *sql.DB + db *sql.DB + cipher *credentialcipher.Cipher } // NewAggregatorRepository creates a new PostgreSQL aggregator repository -func NewAggregatorRepository(db *sql.DB) aggregators.Repository { - return &postgresAggregatorRepo{db: db} +func NewAggregatorRepository(db *sql.DB, cipher *credentialcipher.Cipher) aggregators.Repository { + if cipher == nil { + panic("NewAggregatorRepository: credential cipher is required") + } + return &postgresAggregatorRepo{db: db, cipher: cipher} } // ===== Aggregator CRUD Operations ===== @@ -879,21 +884,37 @@ func (r *postgresAggregatorRepo) GetByAPIKeyHash(ctx context.Context, keyHash st // SetAPIKey stores API key credentials and OAuth session for an aggregator // This is called after successful OAuth flow to generate the API key -// SECURITY: OAuth tokens and DPoP private key are encrypted at rest using pgp_sym_encrypt +// SECURITY: OAuth tokens and the DPoP private key are encrypted before storage. func (r *postgresAggregatorRepo) SetAPIKey(ctx context.Context, did, keyPrefix, keyHash string, oauthCreds *aggregators.OAuthCredentials) error { + accessTokenCiphertext, err := encryptOptionalCredential( + r.cipher, oauthCreds.AccessToken, aggregatorOAuthAccessTokenCredentialContext(did)) + if err != nil { + return fmt.Errorf("failed to encrypt aggregator OAuth access token: %w", err) + } + refreshTokenCiphertext, err := encryptOptionalCredential( + r.cipher, oauthCreds.RefreshToken, aggregatorOAuthRefreshTokenCredentialContext(did)) + if err != nil { + return fmt.Errorf("failed to encrypt aggregator OAuth refresh token: %w", err) + } + dpopPrivateKeyCiphertext, err := encryptOptionalCredential( + r.cipher, oauthCreds.DPoPPrivateKeyMultibase, aggregatorOAuthDPoPPrivateKeyCredentialContext(did)) + if err != nil { + return fmt.Errorf("failed to encrypt aggregator OAuth DPoP private key: %w", err) + } + query := ` UPDATE aggregators SET api_key_prefix = $2, api_key_hash = $3, api_key_created_at = NOW(), api_key_revoked_at = NULL, - oauth_access_token_encrypted = CASE WHEN $4 != '' THEN pgp_sym_encrypt($4, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) ELSE NULL END, - oauth_refresh_token_encrypted = CASE WHEN $5 != '' THEN pgp_sym_encrypt($5, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) ELSE NULL END, + oauth_access_token_encrypted = $4, + oauth_refresh_token_encrypted = $5, oauth_token_expires_at = $6, oauth_pds_url = $7, oauth_auth_server_iss = $8, oauth_auth_server_token_endpoint = $9, - oauth_dpop_private_key_encrypted = CASE WHEN $10 != '' THEN pgp_sym_encrypt($10, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) ELSE NULL END, + oauth_dpop_private_key_encrypted = $10, oauth_dpop_authserver_nonce = $11, oauth_dpop_pds_nonce = $12 WHERE did = $1` @@ -902,13 +923,13 @@ func (r *postgresAggregatorRepo) SetAPIKey(ctx context.Context, did, keyPrefix, did, keyPrefix, keyHash, - oauthCreds.AccessToken, - oauthCreds.RefreshToken, + accessTokenCiphertext, + refreshTokenCiphertext, oauthCreds.TokenExpiresAt, oauthCreds.PDSURL, oauthCreds.AuthServerIss, oauthCreds.AuthServerTokenEndpoint, - oauthCreds.DPoPPrivateKeyMultibase, + dpopPrivateKeyCiphertext, oauthCreds.DPoPAuthServerNonce, oauthCreds.DPoPPDSNonce, ) @@ -929,16 +950,25 @@ func (r *postgresAggregatorRepo) SetAPIKey(ctx context.Context, did, keyPrefix, // UpdateOAuthTokens updates OAuth tokens after a refresh operation // Called after successfully refreshing an expired access token -// SECURITY: OAuth tokens are encrypted at rest using pgp_sym_encrypt +// SECURITY: OAuth tokens are encrypted before storage. func (r *postgresAggregatorRepo) UpdateOAuthTokens(ctx context.Context, did, accessToken, refreshToken string, expiresAt time.Time) error { + accessTokenCiphertext, err := r.cipher.Encrypt(accessToken, aggregatorOAuthAccessTokenCredentialContext(did)) + if err != nil { + return fmt.Errorf("failed to encrypt aggregator OAuth access token: %w", err) + } + refreshTokenCiphertext, err := r.cipher.Encrypt(refreshToken, aggregatorOAuthRefreshTokenCredentialContext(did)) + if err != nil { + return fmt.Errorf("failed to encrypt aggregator OAuth refresh token: %w", err) + } + query := ` UPDATE aggregators SET - oauth_access_token_encrypted = pgp_sym_encrypt($2, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)), - oauth_refresh_token_encrypted = pgp_sym_encrypt($3, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)), + oauth_access_token_encrypted = $2, + oauth_refresh_token_encrypted = $3, oauth_token_expires_at = $4 WHERE did = $1` - result, err := r.db.ExecContext(ctx, query, did, accessToken, refreshToken, expiresAt) + result, err := r.db.ExecContext(ctx, query, did, accessTokenCiphertext, refreshTokenCiphertext, expiresAt) if err != nil { return fmt.Errorf("failed to update OAuth tokens: %w", err) } @@ -1039,32 +1069,20 @@ func (r *postgresAggregatorRepo) GetAggregatorCredentials(ctx context.Context, d SELECT did, api_key_prefix, api_key_hash, api_key_created_at, api_key_revoked_at, api_key_last_used_at, - CASE - WHEN oauth_access_token_encrypted IS NOT NULL - THEN pgp_sym_decrypt(oauth_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) - ELSE NULL - END as oauth_access_token, - CASE - WHEN oauth_refresh_token_encrypted IS NOT NULL - THEN pgp_sym_decrypt(oauth_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) - ELSE NULL - END as oauth_refresh_token, + oauth_access_token_encrypted, oauth_refresh_token_encrypted, oauth_token_expires_at, oauth_pds_url, oauth_auth_server_iss, oauth_auth_server_token_endpoint, - CASE - WHEN oauth_dpop_private_key_encrypted IS NOT NULL - THEN pgp_sym_decrypt(oauth_dpop_private_key_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) - ELSE NULL - END as oauth_dpop_private_key_multibase, + oauth_dpop_private_key_encrypted, oauth_dpop_authserver_nonce, oauth_dpop_pds_nonce FROM aggregators WHERE did = $1` creds := &aggregators.AggregatorCredentials{} var apiKeyPrefix, apiKeyHash sql.NullString - var oauthAccessToken, oauthRefreshToken sql.NullString + var oauthAccessTokenCiphertext, oauthRefreshTokenCiphertext []byte var oauthPDSURL, oauthAuthServerIss, oauthAuthServerTokenEndpoint sql.NullString - var oauthDPoPPrivateKey, oauthDPoPAuthServerNonce, oauthDPoPPDSNonce sql.NullString + var oauthDPoPPrivateKeyCiphertext []byte + var oauthDPoPAuthServerNonce, oauthDPoPPDSNonce sql.NullString var apiKeyCreatedAt, apiKeyRevokedAt, apiKeyLastUsed, oauthTokenExpiresAt sql.NullTime err := r.db.QueryRowContext(ctx, query, did).Scan( @@ -1074,13 +1092,13 @@ func (r *postgresAggregatorRepo) GetAggregatorCredentials(ctx context.Context, d &apiKeyCreatedAt, &apiKeyRevokedAt, &apiKeyLastUsed, - &oauthAccessToken, - &oauthRefreshToken, + &oauthAccessTokenCiphertext, + &oauthRefreshTokenCiphertext, &oauthTokenExpiresAt, &oauthPDSURL, &oauthAuthServerIss, &oauthAuthServerTokenEndpoint, - &oauthDPoPPrivateKey, + &oauthDPoPPrivateKeyCiphertext, &oauthDPoPAuthServerNonce, &oauthDPoPPDSNonce, ) @@ -1091,16 +1109,17 @@ func (r *postgresAggregatorRepo) GetAggregatorCredentials(ctx context.Context, d if err != nil { return nil, fmt.Errorf("failed to get aggregator credentials: %w", err) } + if err := r.decryptOAuthCredentials( + creds, oauthAccessTokenCiphertext, oauthRefreshTokenCiphertext, oauthDPoPPrivateKeyCiphertext); err != nil { + return nil, fmt.Errorf("failed to get aggregator credentials: %w", err) + } // Map nullable string fields creds.APIKeyPrefix = apiKeyPrefix.String creds.APIKeyHash = apiKeyHash.String - creds.OAuthAccessToken = oauthAccessToken.String - creds.OAuthRefreshToken = oauthRefreshToken.String creds.OAuthPDSURL = oauthPDSURL.String creds.OAuthAuthServerIss = oauthAuthServerIss.String creds.OAuthAuthServerTokenEndpoint = oauthAuthServerTokenEndpoint.String - creds.OAuthDPoPPrivateKeyMultibase = oauthDPoPPrivateKey.String creds.OAuthDPoPAuthServerNonce = oauthDPoPAuthServerNonce.String creds.OAuthDPoPPDSNonce = oauthDPoPPDSNonce.String @@ -1133,32 +1152,20 @@ func (r *postgresAggregatorRepo) GetCredentialsByAPIKeyHash(ctx context.Context, SELECT did, api_key_prefix, api_key_hash, api_key_created_at, api_key_revoked_at, api_key_last_used_at, - CASE - WHEN oauth_access_token_encrypted IS NOT NULL - THEN pgp_sym_decrypt(oauth_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) - ELSE NULL - END as oauth_access_token, - CASE - WHEN oauth_refresh_token_encrypted IS NOT NULL - THEN pgp_sym_decrypt(oauth_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) - ELSE NULL - END as oauth_refresh_token, + oauth_access_token_encrypted, oauth_refresh_token_encrypted, oauth_token_expires_at, oauth_pds_url, oauth_auth_server_iss, oauth_auth_server_token_endpoint, - CASE - WHEN oauth_dpop_private_key_encrypted IS NOT NULL - THEN pgp_sym_decrypt(oauth_dpop_private_key_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) - ELSE NULL - END as oauth_dpop_private_key_multibase, + oauth_dpop_private_key_encrypted, oauth_dpop_authserver_nonce, oauth_dpop_pds_nonce FROM aggregators WHERE api_key_hash = $1` creds := &aggregators.AggregatorCredentials{} var apiKeyPrefix, apiKeyHash sql.NullString - var oauthAccessToken, oauthRefreshToken sql.NullString + var oauthAccessTokenCiphertext, oauthRefreshTokenCiphertext []byte var oauthPDSURL, oauthAuthServerIss, oauthAuthServerTokenEndpoint sql.NullString - var oauthDPoPPrivateKey, oauthDPoPAuthServerNonce, oauthDPoPPDSNonce sql.NullString + var oauthDPoPPrivateKeyCiphertext []byte + var oauthDPoPAuthServerNonce, oauthDPoPPDSNonce sql.NullString var apiKeyCreatedAt, apiKeyRevokedAt, apiKeyLastUsed, oauthTokenExpiresAt sql.NullTime err := r.db.QueryRowContext(ctx, query, keyHash).Scan( @@ -1168,13 +1175,13 @@ func (r *postgresAggregatorRepo) GetCredentialsByAPIKeyHash(ctx context.Context, &apiKeyCreatedAt, &apiKeyRevokedAt, &apiKeyLastUsed, - &oauthAccessToken, - &oauthRefreshToken, + &oauthAccessTokenCiphertext, + &oauthRefreshTokenCiphertext, &oauthTokenExpiresAt, &oauthPDSURL, &oauthAuthServerIss, &oauthAuthServerTokenEndpoint, - &oauthDPoPPrivateKey, + &oauthDPoPPrivateKeyCiphertext, &oauthDPoPAuthServerNonce, &oauthDPoPPDSNonce, ) @@ -1185,16 +1192,17 @@ func (r *postgresAggregatorRepo) GetCredentialsByAPIKeyHash(ctx context.Context, if err != nil { return nil, fmt.Errorf("failed to get credentials by API key hash: %w", err) } + if err := r.decryptOAuthCredentials( + creds, oauthAccessTokenCiphertext, oauthRefreshTokenCiphertext, oauthDPoPPrivateKeyCiphertext); err != nil { + return nil, fmt.Errorf("failed to get credentials by API key hash: %w", err) + } // Map nullable string fields creds.APIKeyPrefix = apiKeyPrefix.String creds.APIKeyHash = apiKeyHash.String - creds.OAuthAccessToken = oauthAccessToken.String - creds.OAuthRefreshToken = oauthRefreshToken.String creds.OAuthPDSURL = oauthPDSURL.String creds.OAuthAuthServerIss = oauthAuthServerIss.String creds.OAuthAuthServerTokenEndpoint = oauthAuthServerTokenEndpoint.String - creds.OAuthDPoPPrivateKeyMultibase = oauthDPoPPrivateKey.String creds.OAuthDPoPAuthServerNonce = oauthDPoPAuthServerNonce.String creds.OAuthDPoPPDSNonce = oauthDPoPPDSNonce.String @@ -1243,23 +1251,10 @@ func (r *postgresAggregatorRepo) ListAggregatorsNeedingTokenRefresh(ctx context. SELECT did, api_key_prefix, api_key_hash, api_key_created_at, api_key_revoked_at, api_key_last_used_at, - CASE - WHEN oauth_access_token_encrypted IS NOT NULL - THEN pgp_sym_decrypt(oauth_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) - ELSE NULL - END as oauth_access_token, - CASE - WHEN oauth_refresh_token_encrypted IS NOT NULL - THEN pgp_sym_decrypt(oauth_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) - ELSE NULL - END as oauth_refresh_token, + oauth_access_token_encrypted, oauth_refresh_token_encrypted, oauth_token_expires_at, oauth_pds_url, oauth_auth_server_iss, oauth_auth_server_token_endpoint, - CASE - WHEN oauth_dpop_private_key_encrypted IS NOT NULL - THEN pgp_sym_decrypt(oauth_dpop_private_key_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) - ELSE NULL - END as oauth_dpop_private_key_multibase, + oauth_dpop_private_key_encrypted, oauth_dpop_authserver_nonce, oauth_dpop_pds_nonce FROM aggregators WHERE api_key_hash IS NOT NULL @@ -1277,9 +1272,10 @@ func (r *postgresAggregatorRepo) ListAggregatorsNeedingTokenRefresh(ctx context. for rows.Next() { creds := &aggregators.AggregatorCredentials{} var apiKeyPrefix, apiKeyHash sql.NullString - var oauthAccessToken, oauthRefreshToken sql.NullString + var oauthAccessTokenCiphertext, oauthRefreshTokenCiphertext []byte var oauthPDSURL, oauthAuthServerIss, oauthAuthServerTokenEndpoint sql.NullString - var oauthDPoPPrivateKey, oauthDPoPAuthServerNonce, oauthDPoPPDSNonce sql.NullString + var oauthDPoPPrivateKeyCiphertext []byte + var oauthDPoPAuthServerNonce, oauthDPoPPDSNonce sql.NullString var apiKeyCreatedAt, apiKeyRevokedAt, apiKeyLastUsed, oauthTokenExpiresAt sql.NullTime err := rows.Scan( @@ -1289,29 +1285,30 @@ func (r *postgresAggregatorRepo) ListAggregatorsNeedingTokenRefresh(ctx context. &apiKeyCreatedAt, &apiKeyRevokedAt, &apiKeyLastUsed, - &oauthAccessToken, - &oauthRefreshToken, + &oauthAccessTokenCiphertext, + &oauthRefreshTokenCiphertext, &oauthTokenExpiresAt, &oauthPDSURL, &oauthAuthServerIss, &oauthAuthServerTokenEndpoint, - &oauthDPoPPrivateKey, + &oauthDPoPPrivateKeyCiphertext, &oauthDPoPAuthServerNonce, &oauthDPoPPDSNonce, ) if err != nil { return nil, fmt.Errorf("failed to scan aggregator credentials: %w", err) } + if err := r.decryptOAuthCredentials( + creds, oauthAccessTokenCiphertext, oauthRefreshTokenCiphertext, oauthDPoPPrivateKeyCiphertext); err != nil { + return nil, fmt.Errorf("failed to decrypt aggregator credentials needing token refresh: %w", err) + } // Map nullable string fields creds.APIKeyPrefix = apiKeyPrefix.String creds.APIKeyHash = apiKeyHash.String - creds.OAuthAccessToken = oauthAccessToken.String - creds.OAuthRefreshToken = oauthRefreshToken.String creds.OAuthPDSURL = oauthPDSURL.String creds.OAuthAuthServerIss = oauthAuthServerIss.String creds.OAuthAuthServerTokenEndpoint = oauthAuthServerTokenEndpoint.String - creds.OAuthDPoPPrivateKeyMultibase = oauthDPoPPrivateKey.String creds.OAuthDPoPAuthServerNonce = oauthDPoPAuthServerNonce.String creds.OAuthDPoPPDSNonce = oauthDPoPPDSNonce.String @@ -1345,6 +1342,29 @@ func (r *postgresAggregatorRepo) ListAggregatorsNeedingTokenRefresh(ctx context. // ===== Helper Functions ===== +func (r *postgresAggregatorRepo) decryptOAuthCredentials( + credentials *aggregators.AggregatorCredentials, + accessTokenCiphertext, refreshTokenCiphertext, dpopPrivateKeyCiphertext []byte, +) error { + var err error + credentials.OAuthAccessToken, err = decryptOptionalCredential( + r.cipher, accessTokenCiphertext, aggregatorOAuthAccessTokenCredentialContext(credentials.DID)) + if err != nil { + return fmt.Errorf("decrypt OAuth access token for DID %s: %w", credentials.DID, err) + } + credentials.OAuthRefreshToken, err = decryptOptionalCredential( + r.cipher, refreshTokenCiphertext, aggregatorOAuthRefreshTokenCredentialContext(credentials.DID)) + if err != nil { + return fmt.Errorf("decrypt OAuth refresh token for DID %s: %w", credentials.DID, err) + } + credentials.OAuthDPoPPrivateKeyMultibase, err = decryptOptionalCredential( + r.cipher, dpopPrivateKeyCiphertext, aggregatorOAuthDPoPPrivateKeyCredentialContext(credentials.DID)) + if err != nil { + return fmt.Errorf("decrypt OAuth DPoP private key for DID %s: %w", credentials.DID, err) + } + return nil +} + // scanAuthorizations is a helper to scan multiple authorization rows func scanAuthorizations(rows *sql.Rows) ([]*aggregators.Authorization, error) { var auths []*aggregators.Authorization diff --git a/internal/db/postgres/aggregator_repo_credentials_test.go b/internal/db/postgres/aggregator_repo_credentials_test.go index cf27a79..3d8fac3 100644 --- a/internal/db/postgres/aggregator_repo_credentials_test.go +++ b/internal/db/postgres/aggregator_repo_credentials_test.go @@ -3,6 +3,7 @@ package postgres import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "bytes" "context" "crypto/sha256" @@ -42,13 +43,12 @@ import ( // UPDATE … WHERE did = $1, and the cost of a missing predicate is one bot // holding another bot's PDS session. // -// The OAuth tokens and the DPoP private key are encrypted at rest by Postgres -// (pgp_sym_encrypt against the key seeded by migration 006, moved to these -// columns by 025). That makes the encryption part of the SQL rather than part -// of any Go code, which in turn makes a repository test the only place it is -// exercised at all — a mocked repository stores plaintext strings and proves -// nothing. Where it matters, the ciphertext column is read directly to confirm -// the plaintext is not sitting in it. +// The repository encrypts the OAuth tokens and DPoP private key with the +// app-side AES-256-GCM credential cipher before writing their BYTEA columns. +// The key stays in the process, and each value is bound to its table, column, +// and aggregator DID. A mocked repository stores plaintext strings and proves +// nothing about that boundary, so the ciphertext is read directly where it +// matters to confirm the plaintext is not sitting in the database. // aggregatorAPIKeyHash returns a value shaped like what the middleware actually // stores: the hex SHA-256 of the presented key, which is exactly the 64 @@ -117,7 +117,7 @@ func TestAggregatorRepo_SetAPIKey(t *testing.T) { t.Run("stores the whole OAuth session behind the key", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := indexAggregator(t, repo, "Fresh Aggregator") expires := time.Now().UTC().Add(90 * time.Minute).Truncate(time.Microsecond) @@ -133,7 +133,7 @@ func TestAggregatorRepo_SetAPIKey(t *testing.T) { assert.Equal(t, keyHash, creds.APIKeyHash) assert.Equal(t, session.AccessToken, creds.OAuthAccessToken, "the access token is what signs the write to the aggregator's PDS; a token that does not "+ - "survive the round trip through pgp_sym_encrypt leaves the bot authenticated to Coves "+ + "survive the round trip through the credential cipher leaves the bot authenticated to Coves "+ "and unable to write anywhere") assert.Equal(t, session.RefreshToken, creds.OAuthRefreshToken) assert.Equal(t, session.PDSURL, creds.OAuthPDSURL) @@ -156,7 +156,7 @@ func TestAggregatorRepo_SetAPIKey(t *testing.T) { assert.True(t, creds.HasActiveAPIKey()) }) - // The three sensitive columns are BYTEA holding pgp_sym_encrypt output. If a + // The three sensitive columns are BYTEA holding versioned AES-GCM output. If a // future migration or a refactor made them plain text, every assertion above // would still pass — the round trip would simply be an identity function — // and a database backup would carry every aggregator's PDS credentials in @@ -164,7 +164,7 @@ func TestAggregatorRepo_SetAPIKey(t *testing.T) { t.Run("keeps the secrets out of the clear in the row itself", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := indexAggregator(t, repo, "Encrypted Aggregator") session := aggregatorOAuthSession("cipher", time.Now().Add(time.Hour)) @@ -187,7 +187,7 @@ func TestAggregatorRepo_SetAPIKey(t *testing.T) { t.Run("an absent token is NULL, not encrypted emptiness", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := indexAggregator(t, repo, "Tokenless Aggregator") session := aggregatorOAuthSession("sparse", time.Now().Add(time.Hour)) @@ -215,7 +215,7 @@ func TestAggregatorRepo_SetAPIKey(t *testing.T) { t.Run("a new key supersedes the old one", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := indexAggregator(t, repo, "Rekeyed Aggregator") leaked := aggregatorAPIKeyHash(t, "leaked-key") @@ -246,7 +246,7 @@ func TestAggregatorRepo_SetAPIKey(t *testing.T) { t.Run("re-keying clears an earlier revocation", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, _ := aggregatorWithAPIKey(t, repo, "revived", time.Now().Add(time.Hour)) require.NoError(t, repo.RevokeAPIKey(ctx, did)) @@ -267,7 +267,7 @@ func TestAggregatorRepo_SetAPIKey(t *testing.T) { t.Run("writes to one aggregator only", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) subject := indexAggregator(t, repo, "Subject") bystander, bystanderHash := aggregatorWithAPIKey(t, repo, "bystander", time.Now().Add(time.Hour)) @@ -285,7 +285,7 @@ func TestAggregatorRepo_SetAPIKey(t *testing.T) { t.Run("reports an aggregator the AppView never indexed", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) err := repo.SetAPIKey(ctx, "did:plc:"+testkit.UniqueID(t), "cvs_ghost", aggregatorAPIKeyHash(t, "ghost-key"), aggregatorOAuthSession("ghost", time.Now().Add(time.Hour))) @@ -302,7 +302,7 @@ func TestAggregatorRepo_GetByAPIKeyHash(t *testing.T) { t.Run("answers with the aggregator that owns the hash and no other", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) firstDID, firstHash := aggregatorWithAPIKey(t, repo, "first", time.Now().Add(time.Hour)) secondDID, secondHash := aggregatorWithAPIKey(t, repo, "second", time.Now().Add(time.Hour)) @@ -326,7 +326,7 @@ func TestAggregatorRepo_GetByAPIKeyHash(t *testing.T) { t.Run("a hash nobody holds is an error, not an empty aggregator", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorWithAPIKey(t, repo, "legit", time.Now().Add(time.Hour)) guessed, err := repo.GetByAPIKeyHash(ctx, aggregatorAPIKeyHash(t, "a-key-nobody-issued")) @@ -342,7 +342,7 @@ func TestAggregatorRepo_GetByAPIKeyHash(t *testing.T) { t.Run("an empty hash matches nobody, including aggregators with no key", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) indexAggregator(t, repo, "Never Completed OAuth") _, err := repo.GetByAPIKeyHash(ctx, "") @@ -352,7 +352,7 @@ func TestAggregatorRepo_GetByAPIKeyHash(t *testing.T) { t.Run("a revoked key stops authenticating", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, keyHash := aggregatorWithAPIKey(t, repo, "doomed", time.Now().Add(time.Hour)) before, err := repo.GetByAPIKeyHash(ctx, keyHash) @@ -375,7 +375,7 @@ func TestAggregatorRepo_GetByAPIKeyHash(t *testing.T) { t.Run("distinguishes a revoked key from an unknown one", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, keyHash := aggregatorWithAPIKey(t, repo, "classify", time.Now().Add(time.Hour)) require.NoError(t, repo.RevokeAPIKey(ctx, did)) @@ -396,7 +396,7 @@ func TestAggregatorRepo_GetCredentialsByAPIKeyHash(t *testing.T) { t.Run("hands the owner's session to the authenticated caller", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) mineDID, mineHash := aggregatorWithAPIKey(t, repo, "mine", time.Now().Add(time.Hour)) aggregatorWithAPIKey(t, repo, "theirs", time.Now().Add(time.Hour)) @@ -420,7 +420,7 @@ func TestAggregatorRepo_GetCredentialsByAPIKeyHash(t *testing.T) { t.Run("an unknown hash is an invalid key rather than a missing aggregator", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorWithAPIKey(t, repo, "present", time.Now().Add(time.Hour)) creds, err := repo.GetCredentialsByAPIKeyHash(ctx, aggregatorAPIKeyHash(t, "forged")) @@ -431,7 +431,7 @@ func TestAggregatorRepo_GetCredentialsByAPIKeyHash(t *testing.T) { t.Run("withholds the session when the key is revoked", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, keyHash := aggregatorWithAPIKey(t, repo, "cancelled", time.Now().Add(time.Hour)) require.NoError(t, repo.RevokeAPIKey(ctx, did)) @@ -451,7 +451,7 @@ func TestAggregatorRepo_RevokeAPIKey(t *testing.T) { t.Run("revokes only the aggregator named", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) targetDID, _ := aggregatorWithAPIKey(t, repo, "target", time.Now().Add(time.Hour)) _, bystanderHash := aggregatorWithAPIKey(t, repo, "spared", time.Now().Add(time.Hour)) @@ -471,7 +471,7 @@ func TestAggregatorRepo_RevokeAPIKey(t *testing.T) { t.Run("reports an aggregator that has no key to revoke", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) keyless := indexAggregator(t, repo, "Keyless") assert.ErrorIs(t, repo.RevokeAPIKey(ctx, keyless), aggregators.ErrAggregatorNotFound) @@ -485,7 +485,7 @@ func TestAggregatorRepo_RevokeAPIKey(t *testing.T) { t.Run("a repeated revocation rewrites when the key was withdrawn", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, _ := aggregatorWithAPIKey(t, repo, "twice", time.Now().Add(time.Hour)) require.NoError(t, repo.RevokeAPIKey(ctx, did)) @@ -525,7 +525,7 @@ func TestAggregatorRepo_UpdateAPIKeyLastUsed(t *testing.T) { t.Run("moves the timestamp forward", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, _ := aggregatorWithAPIKey(t, repo, "active", time.Now().Add(time.Hour)) @@ -558,7 +558,7 @@ func TestAggregatorRepo_UpdateAPIKeyLastUsed(t *testing.T) { t.Run("leaves the credential itself alone", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, keyHash := aggregatorWithAPIKey(t, repo, "audited", time.Now().Add(time.Hour)) require.NoError(t, repo.UpdateAPIKeyLastUsed(ctx, did)) @@ -577,7 +577,7 @@ func TestAggregatorRepo_UpdateAPIKeyLastUsed(t *testing.T) { t.Run("touches one aggregator", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, _ := aggregatorWithAPIKey(t, repo, "user", time.Now().Add(time.Hour)) idle, _ := aggregatorWithAPIKey(t, repo, "idle", time.Now().Add(time.Hour)) @@ -593,7 +593,7 @@ func TestAggregatorRepo_UpdateAPIKeyLastUsed(t *testing.T) { t.Run("reports a DID nothing indexed", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) assert.ErrorIs(t, repo.UpdateAPIKeyLastUsed(ctx, "did:plc:"+testkit.UniqueID(t)), aggregators.ErrAggregatorNotFound) @@ -611,7 +611,7 @@ func TestAggregatorRepo_UpdateOAuthTokens(t *testing.T) { t.Run("replaces both tokens and the expiry", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, _ := aggregatorWithAPIKey(t, repo, "refreshed", time.Now().Add(time.Minute)) renewedUntil := time.Now().UTC().Add(6 * time.Hour).Truncate(time.Microsecond) @@ -633,7 +633,7 @@ func TestAggregatorRepo_UpdateOAuthTokens(t *testing.T) { t.Run("re-encrypts rather than storing the new token in the clear", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, _ := aggregatorWithAPIKey(t, repo, "reciphered", time.Now().Add(time.Minute)) require.NoError(t, repo.UpdateOAuthTokens(ctx, did, "post-refresh-access", "post-refresh-refresh", @@ -650,7 +650,7 @@ func TestAggregatorRepo_UpdateOAuthTokens(t *testing.T) { t.Run("leaves the rest of the session intact", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, keyHash := aggregatorWithAPIKey(t, repo, "partial", time.Now().Add(time.Minute)) require.NoError(t, repo.UpdateOAuthTokens(ctx, did, "a", "r", time.Now().Add(time.Hour))) @@ -671,7 +671,7 @@ func TestAggregatorRepo_UpdateOAuthTokens(t *testing.T) { t.Run("refreshes one aggregator's session", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, _ := aggregatorWithAPIKey(t, repo, "renewer", time.Now().Add(time.Minute)) other, _ := aggregatorWithAPIKey(t, repo, "sleeper", time.Now().Add(time.Minute)) @@ -688,7 +688,7 @@ func TestAggregatorRepo_UpdateOAuthTokens(t *testing.T) { t.Run("reports a DID nothing indexed", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) err := repo.UpdateOAuthTokens(ctx, "did:plc:"+testkit.UniqueID(t), "a", "r", time.Now().Add(time.Hour)) assert.ErrorIs(t, err, aggregators.ErrAggregatorNotFound, @@ -704,7 +704,7 @@ func TestAggregatorRepo_UpdateOAuthNonces(t *testing.T) { t.Run("records the nonce each server last issued", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, _ := aggregatorWithAPIKey(t, repo, "nonced", time.Now().Add(time.Hour)) require.NoError(t, repo.UpdateOAuthNonces(ctx, did, "fresh-authserver", "fresh-pds")) @@ -722,7 +722,7 @@ func TestAggregatorRepo_UpdateOAuthNonces(t *testing.T) { t.Run("an empty nonce keeps the stored one", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, _ := aggregatorWithAPIKey(t, repo, "halfnonce", time.Now().Add(time.Hour)) @@ -744,7 +744,7 @@ func TestAggregatorRepo_UpdateOAuthNonces(t *testing.T) { t.Run("does not disturb the tokens or the key", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, keyHash := aggregatorWithAPIKey(t, repo, "intact", time.Now().Add(time.Hour)) require.NoError(t, repo.UpdateOAuthNonces(ctx, did, "n1", "n2")) @@ -762,7 +762,7 @@ func TestAggregatorRepo_UpdateOAuthNonces(t *testing.T) { t.Run("touches one aggregator", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, _ := aggregatorWithAPIKey(t, repo, "talker", time.Now().Add(time.Hour)) other, _ := aggregatorWithAPIKey(t, repo, "quiet", time.Now().Add(time.Hour)) @@ -780,7 +780,7 @@ func TestAggregatorRepo_UpdateOAuthNonces(t *testing.T) { t.Run("reports a DID nothing indexed", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) assert.ErrorIs(t, repo.UpdateOAuthNonces(ctx, "did:plc:"+testkit.UniqueID(t), "a", "p"), aggregators.ErrAggregatorNotFound) @@ -798,7 +798,7 @@ func TestAggregatorRepo_GetAggregatorCredentials(t *testing.T) { t.Run("an aggregator with no key yields empty credentials, not an error", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := indexAggregator(t, repo, "Unenrolled") creds, err := repo.GetAggregatorCredentials(ctx, did) @@ -822,7 +822,7 @@ func TestAggregatorRepo_GetAggregatorCredentials(t *testing.T) { t.Run("returns revoked credentials rather than refusing them", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did, keyHash := aggregatorWithAPIKey(t, repo, "shownrevoked", time.Now().Add(time.Hour)) require.NoError(t, repo.RevokeAPIKey(ctx, did)) @@ -838,7 +838,7 @@ func TestAggregatorRepo_GetAggregatorCredentials(t *testing.T) { t.Run("reports a DID nothing indexed", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) creds, err := repo.GetAggregatorCredentials(ctx, "did:plc:"+testkit.UniqueID(t)) require.ErrorIs(t, err, aggregators.ErrAggregatorNotFound) @@ -872,7 +872,7 @@ func TestAggregatorRepo_ListAggregatorsNeedingTokenRefresh(t *testing.T) { seed := func(t *testing.T) aggregatorRefreshCohort { t.Helper() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) now := time.Now() cohort := aggregatorRefreshCohort{repo: repo} @@ -1002,7 +1002,7 @@ func TestAggregatorRepo_ListAggregatorsNeedingTokenRefresh(t *testing.T) { t.Run("an installation with nothing due lists nothing", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) indexAggregator(t, repo, "Idle") due, err := repo.ListAggregatorsNeedingTokenRefresh(ctx, 0) diff --git a/internal/db/postgres/aggregator_repo_lifecycle_test.go b/internal/db/postgres/aggregator_repo_lifecycle_test.go index 648bad0..33212d8 100644 --- a/internal/db/postgres/aggregator_repo_lifecycle_test.go +++ b/internal/db/postgres/aggregator_repo_lifecycle_test.go @@ -3,6 +3,7 @@ package postgres import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "testing" @@ -93,7 +94,7 @@ func TestAggregatorRepo_UpdateAggregator(t *testing.T) { t.Run("replaces every declared field", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := indexAggregator(t, repo, "Original Name") declaredAt := time.Now().UTC().Add(-48 * time.Hour).Truncate(time.Microsecond) @@ -134,7 +135,7 @@ func TestAggregatorRepo_UpdateAggregator(t *testing.T) { t.Run("does not touch the trigger-maintained stats", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := indexAggregator(t, repo, "Established") communityDID := indexAuthorizingCommunity(t, db) @@ -162,7 +163,7 @@ func TestAggregatorRepo_UpdateAggregator(t *testing.T) { t.Run("an omitted optional field is cleared, not preserved", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := "did:plc:" + testkit.UniqueID(t) require.NoError(t, repo.CreateAggregator(ctx, &aggregators.Aggregator{ @@ -192,7 +193,7 @@ func TestAggregatorRepo_UpdateAggregator(t *testing.T) { t.Run("updates one aggregator", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) subject := indexAggregator(t, repo, "Subject") bystander := indexAggregator(t, repo, "Bystander") @@ -212,7 +213,7 @@ func TestAggregatorRepo_UpdateAggregator(t *testing.T) { t.Run("reports an aggregator the AppView never indexed", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := "did:plc:" + testkit.UniqueID(t) err := repo.UpdateAggregator(ctx, &aggregators.Aggregator{ @@ -232,7 +233,7 @@ func TestAggregatorRepo_UpdateAggregator(t *testing.T) { t.Run("refuses to claim another aggregator's record URI", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) first := indexAggregator(t, repo, "First") second := indexAggregator(t, repo, "Second") @@ -261,7 +262,7 @@ func TestAggregatorRepo_DeleteAggregator(t *testing.T) { t.Run("takes the authorizations and the post ledger with it", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := indexAggregator(t, repo, "Withdrawn") communityDID := indexAuthorizingCommunity(t, db) @@ -294,7 +295,7 @@ func TestAggregatorRepo_DeleteAggregator(t *testing.T) { t.Run("deletes one aggregator", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) doomed := indexAggregator(t, repo, "Doomed") spared := indexAggregator(t, repo, "Spared") @@ -312,7 +313,7 @@ func TestAggregatorRepo_DeleteAggregator(t *testing.T) { t.Run("reports a delete that matched nothing", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := indexAggregator(t, repo, "Once") require.NoError(t, repo.DeleteAggregator(ctx, did)) @@ -336,7 +337,7 @@ func TestAggregatorRepo_ListAggregators(t *testing.T) { seed := func(t *testing.T) (aggregators.Repository, map[string]string) { t.Helper() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) byName := map[string]string{} for _, spec := range []struct { @@ -415,7 +416,7 @@ func TestAggregatorRepo_ListAggregators(t *testing.T) { t.Run("hydrates the fields the directory renders", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := "did:plc:" + testkit.UniqueID(t) schema := []byte(`{"type":"object","properties":{"feedUrl":{"type":"string"}}}`) @@ -445,7 +446,7 @@ func TestAggregatorRepo_ListAggregators(t *testing.T) { t.Run("an installation with no aggregators returns nil rather than an empty slice", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) listed, err := repo.ListAggregators(ctx, 10, 0) require.NoError(t, err) @@ -468,7 +469,7 @@ func TestAggregatorRepo_GetAggregatorsByDIDs(t *testing.T) { t.Run("returns exactly the aggregators asked for", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) wanted := indexAggregator(t, repo, "Wanted") alsoWanted := indexAggregator(t, repo, "Also Wanted") @@ -488,7 +489,7 @@ func TestAggregatorRepo_GetAggregatorsByDIDs(t *testing.T) { t.Run("silently omits DIDs it has never seen", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) known := indexAggregator(t, repo, "Known") fetched, err := repo.GetAggregatorsByDIDs(ctx, []string{known, "did:plc:" + testkit.UniqueID(t)}) @@ -501,7 +502,7 @@ func TestAggregatorRepo_GetAggregatorsByDIDs(t *testing.T) { t.Run("a DID asked for twice comes back once", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := indexAggregator(t, repo, "Repeated") fetched, err := repo.GetAggregatorsByDIDs(ctx, []string{did, did, did}) @@ -512,7 +513,7 @@ func TestAggregatorRepo_GetAggregatorsByDIDs(t *testing.T) { t.Run("an empty request is answered without a query", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) fetched, err := repo.GetAggregatorsByDIDs(ctx, nil) require.NoError(t, err) @@ -527,7 +528,7 @@ func TestAggregatorRepo_GetAggregatorsByDIDs(t *testing.T) { t.Run("a request that matches nothing returns nil rather than an empty slice", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) fetched, err := repo.GetAggregatorsByDIDs(ctx, []string{"did:plc:" + testkit.UniqueID(t)}) require.NoError(t, err) @@ -541,7 +542,7 @@ func TestAggregatorRepo_GetAggregatorsByDIDs(t *testing.T) { t.Run("hydrates nullable fields and the config schema", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) schema := []byte(`{"type":"object","required":["feedUrl"]}`) did := "did:plc:" + testkit.UniqueID(t) @@ -586,7 +587,7 @@ func TestAggregatorRepo_GetAuthorizationByURI(t *testing.T) { t.Run("resolves the record a delete event names", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Authorized") communityDID := indexAuthorizingCommunity(t, db) @@ -609,7 +610,7 @@ func TestAggregatorRepo_GetAuthorizationByURI(t *testing.T) { t.Run("carries the revocation audit trail", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Revoked") communityDID := indexAuthorizingCommunity(t, db) @@ -631,7 +632,7 @@ func TestAggregatorRepo_GetAuthorizationByURI(t *testing.T) { t.Run("finds the one record with that URI and no other", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Multi") first := indexAuthorizingCommunity(t, db) @@ -650,7 +651,7 @@ func TestAggregatorRepo_GetAuthorizationByURI(t *testing.T) { t.Run("reports a URI nothing indexed", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) found, err := repo.GetAuthorizationByURI(ctx, "at://did:plc:"+testkit.UniqueID(t)+"/"+aggregatorAuthorizationCollection+"/missing") @@ -663,7 +664,7 @@ func TestAggregatorRepo_GetAuthorizationByURI(t *testing.T) { t.Run("an empty URI matches nothing", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Present") communityDID := indexAuthorizingCommunity(t, db) @@ -684,7 +685,7 @@ func TestAggregatorRepo_UpdateAuthorization(t *testing.T) { t.Run("disabling stops the aggregator being authorized", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Disabled Soon") communityDID := indexAuthorizingCommunity(t, db) @@ -718,7 +719,7 @@ func TestAggregatorRepo_UpdateAuthorization(t *testing.T) { t.Run("re-enabling restores access", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Reinstated") communityDID := indexAuthorizingCommunity(t, db) @@ -743,7 +744,7 @@ func TestAggregatorRepo_UpdateAuthorization(t *testing.T) { t.Run("rewrites the community's configuration", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Reconfigured") communityDID := indexAuthorizingCommunity(t, db) @@ -769,7 +770,7 @@ func TestAggregatorRepo_UpdateAuthorization(t *testing.T) { t.Run("updates the named pair only", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Shared") subject := indexAuthorizingCommunity(t, db) @@ -791,7 +792,7 @@ func TestAggregatorRepo_UpdateAuthorization(t *testing.T) { t.Run("reports a pair that was never authorized", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Unauthorized") communityDID := indexAuthorizingCommunity(t, db) @@ -810,7 +811,7 @@ func TestAggregatorRepo_UpdateAuthorization(t *testing.T) { t.Run("an update with no author fails on the constraint rather than on validation", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Authorless") communityDID := indexAuthorizingCommunity(t, db) @@ -844,7 +845,7 @@ func TestAggregatorRepo_DeleteAuthorizationByURI(t *testing.T) { t.Run("withdraws the permission the record granted", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Deauthorized") communityDID := indexAuthorizingCommunity(t, db) @@ -875,7 +876,7 @@ func TestAggregatorRepo_DeleteAuthorizationByURI(t *testing.T) { t.Run("deletes the one record named", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Partially Deauthorized") doomedCommunity := indexAuthorizingCommunity(t, db) @@ -895,7 +896,7 @@ func TestAggregatorRepo_DeleteAuthorizationByURI(t *testing.T) { t.Run("reports a delete that matched nothing", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Replayed") communityDID := indexAuthorizingCommunity(t, db) @@ -930,7 +931,7 @@ func TestAggregatorRepo_ListAuthorizationsForAggregator(t *testing.T) { seed := func(t *testing.T) aggregatorGrantFixture { t.Helper() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) fixture := aggregatorGrantFixture{repo: repo} fixture.subject = indexAggregator(t, repo, "Subject") @@ -1013,7 +1014,7 @@ func TestAggregatorRepo_ListAuthorizationsForAggregator(t *testing.T) { t.Run("an aggregator nobody has authorized lists nothing", func(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) did := indexAggregator(t, repo, "Unwanted") listed, err := repo.ListAuthorizationsForAggregator(ctx, did, false, 10, 0) @@ -1048,7 +1049,7 @@ func TestAggregatorRepo_GetRecentPosts(t *testing.T) { seed := func(t *testing.T) aggregatorLedgerFixture { t.Helper() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) now := time.Now().UTC() fixture := aggregatorLedgerFixture{repo: repo} diff --git a/internal/db/postgres/aggregator_repo_test.go b/internal/db/postgres/aggregator_repo_test.go index d379341..4ea377d 100644 --- a/internal/db/postgres/aggregator_repo_test.go +++ b/internal/db/postgres/aggregator_repo_test.go @@ -4,6 +4,7 @@ package postgres import ( "Coves/internal/core/aggregators" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/tests/testkit" "context" "database/sql" @@ -87,7 +88,7 @@ func TestAggregatorRepo_CreateRoundTripsTheServiceDeclaration(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) ctx := context.Background() did := "did:plc:" + testkit.UniqueID(t) @@ -144,7 +145,7 @@ func TestAggregatorRepo_CreateUpsertsTheDeclarationWithoutLosingStats(t *testing t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) ctx := context.Background() aggregatorDID := indexAggregator(t, repo, "Original Name") @@ -179,7 +180,7 @@ func TestAggregatorRepo_IsAggregator(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) ctx := context.Background() aggregatorDID := indexAggregator(t, repo, "Declared") @@ -199,7 +200,7 @@ func TestAggregatorRepo_CreateAuthorizationRoundTrips(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) ctx := context.Background() aggregatorDID := indexAggregator(t, repo, "RSS Feed Aggregator") @@ -234,7 +235,7 @@ func TestAggregatorRepo_CreateAuthorizationUpsertsPerCommunity(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) ctx := context.Background() aggregatorDID := indexAggregator(t, repo, "RSS Feed Aggregator") @@ -265,7 +266,7 @@ func TestAggregatorRepo_IsAuthorized(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) ctx := context.Background() aggregatorDID := indexAggregator(t, repo, "RSS Feed Aggregator") @@ -305,7 +306,7 @@ func TestAggregatorRepo_CountsRecentPostsForRateLimiting(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) ctx := context.Background() aggregatorDID := indexAggregator(t, repo, "RSS Feed Aggregator") @@ -339,7 +340,7 @@ func TestAggregatorRepo_CommunitiesUsingTracksEnabledAuthorizations(t *testing.T t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) ctx := context.Background() aggregatorDID := indexAggregator(t, repo, "Widely Used Aggregator") @@ -379,7 +380,7 @@ func TestAggregatorRepo_PostsCreatedCountsEveryTrackedPostOnce(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) ctx := context.Background() aggregatorDID := indexAggregator(t, repo, "Prolific Aggregator") @@ -410,7 +411,7 @@ func TestAggregatorRepo_AuthorizationRoundTripsTheDisableAuditTrail(t *testing.T t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) ctx := context.Background() aggregatorDID := indexAggregator(t, repo, "RSS Feed Aggregator") @@ -453,7 +454,7 @@ func TestAggregatorRepo_CreateAuthorizationRetargetsTheRecordURI(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) ctx := context.Background() firstAggregator := indexAggregator(t, repo, "First Aggregator") @@ -492,7 +493,7 @@ func TestAggregatorRepo_CreateAuthorizationForUnknownCommunityIsNotFound(t *test t.Parallel() db := testkit.DB(t) - repo := NewAggregatorRepository(db) + repo := NewAggregatorRepository(db, credentialciphertest.Fixed()) aggregatorDID := indexAggregator(t, repo, "Orphan Aggregator") err := repo.CreateAuthorization(context.Background(), diff --git a/internal/db/postgres/community_feed_test.go b/internal/db/postgres/community_feed_test.go index 3b24f1d..d48d9d9 100644 --- a/internal/db/postgres/community_feed_test.go +++ b/internal/db/postgres/community_feed_test.go @@ -8,6 +8,7 @@ import ( "Coves/internal/core/communities" "Coves/internal/core/communityFeeds" "Coves/internal/core/posts" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/internal/db/postgres" "Coves/tests/fixtures" "Coves/tests/testkit" @@ -62,7 +63,7 @@ const feedCursorSecret = "test-cursor-secret" // reasons that have nothing to do with ordering. func newCommunityFeedHandler(db *sql.DB) *communityFeed.GetCommunityHandler { communityService := communities.NewCommunityServiceWithPDSFactory( - postgres.NewCommunityRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), testkit.Endpoints().PDS.BaseURL, fixtures.InstanceDID(), testkit.Endpoints().PDS.HandleDomain, diff --git a/internal/db/postgres/community_hosted_test.go b/internal/db/postgres/community_hosted_test.go index 6f6c3a5..9258531 100644 --- a/internal/db/postgres/community_hosted_test.go +++ b/internal/db/postgres/community_hosted_test.go @@ -3,6 +3,7 @@ package postgres import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "fmt" "testing" @@ -35,7 +36,7 @@ func TestHostedCommunityDIDs_SelectsOnlyCommunitiesWithStoredCredentials(t *test db := testkit.DB(t) ctx := context.Background() - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) id := testkit.UniqueID(t) hostedDID := "did:plc:hosted" + id @@ -94,7 +95,7 @@ func TestHostedCommunityDIDs_ReturnsIdentifiersOnlyNeverSecrets(t *testing.T) { db := testkit.DB(t) ctx := context.Background() - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) id := testkit.UniqueID(t) did := "did:plc:secret" + id secret := "refresh-token-that-must-not-escape-" + id diff --git a/internal/db/postgres/community_origin_repo_test.go b/internal/db/postgres/community_origin_repo_test.go index 3eaa463..2038d39 100644 --- a/internal/db/postgres/community_origin_repo_test.go +++ b/internal/db/postgres/community_origin_repo_test.go @@ -3,6 +3,7 @@ package postgres_test import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "fmt" "testing" @@ -25,7 +26,7 @@ func TestCommunityRepo_OriginRoundTrip(t *testing.T) { t.Parallel() db := testkit.DB(t) ctx := context.Background() - repo := postgres.NewCommunityRepository(db) + repo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) suffix := testkit.UniqueID(t) name := fmt.Sprintf("originrt%s", suffix) @@ -90,7 +91,7 @@ func TestCommunityRepo_GetByNameAndOrigin(t *testing.T) { t.Parallel() db := testkit.DB(t) ctx := context.Background() - repo := postgres.NewCommunityRepository(db) + repo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) suffix := testkit.UniqueID(t) name := "pair" + suffix @@ -152,7 +153,7 @@ func TestCommunityRepo_GetByNameAndOriginIgnoresNameCase(t *testing.T) { t.Parallel() db := testkit.DB(t) ctx := context.Background() - repo := postgres.NewCommunityRepository(db) + repo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) suffix := testkit.UniqueID(t) did := "did:plc:mixedcase" + suffix diff --git a/internal/db/postgres/community_repo.go b/internal/db/postgres/community_repo.go index 9cca2fd..6f4e60c 100644 --- a/internal/db/postgres/community_repo.go +++ b/internal/db/postgres/community_repo.go @@ -2,6 +2,7 @@ package postgres import ( "Coves/internal/core/communities" + "Coves/internal/crypto/credentialcipher" "context" "database/sql" "errors" @@ -32,12 +33,16 @@ var ( ) type postgresCommunityRepo struct { - db *sql.DB + db *sql.DB + cipher *credentialcipher.Cipher } // NewCommunityRepository creates a new PostgreSQL community repository -func NewCommunityRepository(db *sql.DB) communities.Repository { - return &postgresCommunityRepo{db: db} +func NewCommunityRepository(db *sql.DB, cipher *credentialcipher.Cipher) communities.Repository { + if cipher == nil { + panic("NewCommunityRepository: credential cipher is required") + } + return &postgresCommunityRepo{db: db, cipher: cipher} } // Create inserts a new community into the communities table @@ -50,6 +55,22 @@ func (r *postgresCommunityRepo) Create(ctx context.Context, community *communiti // subscriber_count is deliberately absent: it is derived from indexed // subscription relationships and every newly materialized community starts // at the database default of zero. + passwordCiphertext, err := encryptOptionalCredential( + r.cipher, community.PDSPassword, communityPDSPasswordCredentialContext(community.DID)) + if err != nil { + return nil, fmt.Errorf("failed to encrypt community PDS password: %w", err) + } + accessTokenCiphertext, err := encryptOptionalCredential( + r.cipher, community.PDSAccessToken, communityPDSAccessTokenCredentialContext(community.DID)) + if err != nil { + return nil, fmt.Errorf("failed to encrypt community PDS access token: %w", err) + } + refreshTokenCiphertext, err := encryptOptionalCredential( + r.cipher, community.PDSRefreshToken, communityPDSRefreshTokenCredentialContext(community.DID)) + if err != nil { + return nil, fmt.Errorf("failed to encrypt community PDS refresh token: %w", err) + } + query := ` INSERT INTO communities ( did, handle, name, display_name, description, description_facets, @@ -62,11 +83,7 @@ func (r *postgresCommunityRepo) Create(ctx context.Context, community *communiti record_uri, record_cid, origin ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, - $12, - CASE WHEN $13 != '' THEN pgp_sym_encrypt($13, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) ELSE NULL END, - CASE WHEN $14 != '' THEN pgp_sym_encrypt($14, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) ELSE NULL END, - CASE WHEN $15 != '' THEN pgp_sym_encrypt($15, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) ELSE NULL END, - $16, + $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29 ) @@ -80,7 +97,7 @@ func (r *postgresCommunityRepo) Create(ctx context.Context, community *communiti descFacets = nil } - err := r.db.QueryRowContext(ctx, query, + err = r.db.QueryRowContext(ctx, query, community.DID, community.Handle, // Always non-empty - constructed by AppView consumer community.Name, @@ -94,9 +111,9 @@ func (r *postgresCommunityRepo) Create(ctx context.Context, community *communiti community.HostedByDID, // V2.0: PDS credentials for community account (encrypted at rest) nullString(community.PDSEmail), - nullString(community.PDSPassword), // Encrypted by pgp_sym_encrypt - nullString(community.PDSAccessToken), // Encrypted by pgp_sym_encrypt - nullString(community.PDSRefreshToken), // Encrypted by pgp_sym_encrypt + passwordCiphertext, + accessTokenCiphertext, + refreshTokenCiphertext, nullString(community.PDSURL), // V2.0: No key columns - PDS manages all keys community.Visibility, @@ -139,22 +156,8 @@ func (r *postgresCommunityRepo) GetByDID(ctx context.Context, did string) (*comm query := ` SELECT id, did, handle, name, display_name, description, description_facets, avatar_cid, banner_cid, owner_did, created_by_did, hosted_by_did, - pds_email, - CASE - WHEN pds_password_encrypted IS NOT NULL - THEN pgp_sym_decrypt(pds_password_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) - ELSE NULL - END as pds_password, - CASE - WHEN pds_access_token_encrypted IS NOT NULL - THEN pgp_sym_decrypt(pds_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) - ELSE NULL - END as pds_access_token, - CASE - WHEN pds_refresh_token_encrypted IS NOT NULL - THEN pgp_sym_decrypt(pds_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) - ELSE NULL - END as pds_refresh_token, + pds_email, pds_password_encrypted, + pds_access_token_encrypted, pds_refresh_token_encrypted, pds_url, visibility, allow_external_discovery, moderation_type, content_warnings, member_count, subscriber_count, ` + communityPostCountUnqualified + ` AS post_count, @@ -165,7 +168,8 @@ func (r *postgresCommunityRepo) GetByDID(ctx context.Context, did string) (*comm var displayName, description, avatarCID, bannerCID, moderationType sql.NullString var federatedFrom, federatedID, recordURI, recordCID sql.NullString - var pdsEmail, pdsPassword, pdsAccessToken, pdsRefreshToken, pdsURL, origin sql.NullString + var pdsEmail, pdsURL, origin sql.NullString + var pdsPasswordCiphertext, pdsAccessTokenCiphertext, pdsRefreshTokenCiphertext []byte var descFacets []byte var contentWarnings []string @@ -174,8 +178,7 @@ func (r *postgresCommunityRepo) GetByDID(ctx context.Context, did string) (*comm &displayName, &description, &descFacets, &avatarCID, &bannerCID, &community.OwnerDID, &community.CreatedByDID, &community.HostedByDID, - // V2.0: PDS credentials (decrypted from pgp_sym_encrypt) - &pdsEmail, &pdsPassword, &pdsAccessToken, &pdsRefreshToken, &pdsURL, + &pdsEmail, &pdsPasswordCiphertext, &pdsAccessTokenCiphertext, &pdsRefreshTokenCiphertext, &pdsURL, &community.Visibility, &community.AllowExternalDiscovery, &moderationType, pq.Array(&contentWarnings), &community.MemberCount, &community.SubscriberCount, &community.PostCount, @@ -191,15 +194,28 @@ func (r *postgresCommunityRepo) GetByDID(ctx context.Context, did string) (*comm return nil, fmt.Errorf("failed to get community by DID: %w", err) } + community.PDSPassword, err = decryptOptionalCredential( + r.cipher, pdsPasswordCiphertext, communityPDSPasswordCredentialContext(community.DID)) + if err != nil { + return nil, fmt.Errorf("failed to decrypt community PDS password for DID %s: %w", community.DID, err) + } + community.PDSAccessToken, err = decryptOptionalCredential( + r.cipher, pdsAccessTokenCiphertext, communityPDSAccessTokenCredentialContext(community.DID)) + if err != nil { + return nil, fmt.Errorf("failed to decrypt community PDS access token for DID %s: %w", community.DID, err) + } + community.PDSRefreshToken, err = decryptOptionalCredential( + r.cipher, pdsRefreshTokenCiphertext, communityPDSRefreshTokenCredentialContext(community.DID)) + if err != nil { + return nil, fmt.Errorf("failed to decrypt community PDS refresh token for DID %s: %w", community.DID, err) + } + // Map nullable fields community.DisplayName = displayName.String community.Description = description.String community.AvatarCID = avatarCID.String community.BannerCID = bannerCID.String community.PDSEmail = pdsEmail.String - community.PDSPassword = pdsPassword.String - community.PDSAccessToken = pdsAccessToken.String - community.PDSRefreshToken = pdsRefreshToken.String community.PDSURL = pdsURL.String // V2.0: No key fields - PDS manages all keys community.RotationKeyPEM = "" // Empty - PDS-managed @@ -414,17 +430,26 @@ func (r *postgresCommunityRepo) Update(ctx context.Context, community *communiti // CRITICAL: Both tokens must be updated together because refresh tokens are single-use // After a successful token refresh, the old refresh token is immediately revoked by the PDS func (r *postgresCommunityRepo) UpdateCredentials(ctx context.Context, did, accessToken, refreshToken string) error { + accessTokenCiphertext, err := r.cipher.Encrypt(accessToken, communityPDSAccessTokenCredentialContext(did)) + if err != nil { + return fmt.Errorf("failed to encrypt community PDS access token: %w", err) + } + refreshTokenCiphertext, err := r.cipher.Encrypt(refreshToken, communityPDSRefreshTokenCredentialContext(did)) + if err != nil { + return fmt.Errorf("failed to encrypt community PDS refresh token: %w", err) + } + query := ` UPDATE communities SET - pds_access_token_encrypted = pgp_sym_encrypt($2, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)), - pds_refresh_token_encrypted = pgp_sym_encrypt($3, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)), + pds_access_token_encrypted = $2, + pds_refresh_token_encrypted = $3, updated_at = NOW() WHERE did = $1 RETURNING did` var returnedDID string - err := r.db.QueryRowContext(ctx, query, did, accessToken, refreshToken).Scan(&returnedDID) + err = r.db.QueryRowContext(ctx, query, did, accessTokenCiphertext, refreshTokenCiphertext).Scan(&returnedDID) if errors.Is(err, sql.ErrNoRows) { return communities.ErrCommunityNotFound diff --git a/internal/db/postgres/community_repo_blocks_test.go b/internal/db/postgres/community_repo_blocks_test.go index 948c258..60db6f8 100644 --- a/internal/db/postgres/community_repo_blocks_test.go +++ b/internal/db/postgres/community_repo_blocks_test.go @@ -4,6 +4,7 @@ package postgres import ( "Coves/internal/core/communities" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/tests/testkit" "context" "testing" @@ -67,7 +68,7 @@ func blockCommunity(t *testing.T, repo communities.Repository, userDID string, c func TestCommunityRepo_ListBlockedCommunities(t *testing.T) { t.Parallel() - repo := NewCommunityRepository(testkit.DB(t)) + repo := NewCommunityRepository(testkit.DB(t), credentialciphertest.Fixed()) ctx := context.Background() userDID := "did:plc:blocklister" @@ -116,7 +117,7 @@ func TestCommunityRepo_ListBlockedCommunities_StablePagesOnEqualTimestamps(t *te t.Parallel() db := testkit.DB(t) - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) ctx := context.Background() const userDID = "did:plc:blockstablepages" @@ -158,7 +159,7 @@ func TestCommunityRepo_ListBlockedCommunities_StablePagesOnEqualTimestamps(t *te func TestCommunityRepo_IsBlocked(t *testing.T) { t.Parallel() - repo := NewCommunityRepository(testkit.DB(t)) + repo := NewCommunityRepository(testkit.DB(t), credentialciphertest.Fixed()) ctx := context.Background() t.Run("false when no block exists", func(t *testing.T) { @@ -197,7 +198,7 @@ func TestCommunityRepo_IsBlocked(t *testing.T) { func TestCommunityRepo_GetBlock(t *testing.T) { t.Parallel() - repo := NewCommunityRepository(testkit.DB(t)) + repo := NewCommunityRepository(testkit.DB(t), credentialciphertest.Fixed()) ctx := context.Background() userDID := "did:plc:getblockuser" community := blockableCommunity(t, repo, "getblock") diff --git a/internal/db/postgres/community_repo_credentials_test.go b/internal/db/postgres/community_repo_credentials_test.go index 7826866..ccac1b4 100644 --- a/internal/db/postgres/community_repo_credentials_test.go +++ b/internal/db/postgres/community_repo_credentials_test.go @@ -4,6 +4,7 @@ package postgres import ( "Coves/internal/core/communities" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/tests/testkit" "context" "fmt" @@ -15,11 +16,11 @@ import ( // // A community in the V2 model owns a PDS account, and the repository is the only // place its password and its access and refresh tokens are encrypted and -// decrypted: Create wraps them in pgp_sym_encrypt() with the row from -// encryption_keys, and the read paths unwrap them with pgp_sym_decrypt(). Both -// halves are SQL, so the round trip cannot be proven anywhere but against a real -// database with pgcrypto and a seeded key — which is why this suite lives beside -// community_repo.go rather than in internal/core/communities. +// decrypted: Create seals them with the app-side AES-256-GCM credential cipher +// before the INSERT, and the read paths open them after the SELECT. The key stays +// in the process, and each value is bound to its table, column, and community DID, +// so the storage boundary is why this suite lives beside community_repo.go rather +// than in internal/core/communities. // // The load-bearing assertion is the one that reads the ciphertext columns // directly. A repository that stored the tokens in plaintext would pass every @@ -34,7 +35,7 @@ func TestCommunityRepository_CredentialPersistence(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) ctx := context.Background() t.Run("persists PDS credentials on create", func(t *testing.T) { @@ -132,8 +133,8 @@ func TestCommunityRepository_CredentialPersistence(t *testing.T) { } // The password is the column this file's siblings care most about, and it // was the one missing from this list. It goes through the same - // pgp_sym_encrypt/pgp_sym_decrypt pair as the tokens above but is read on - // a different path — EnsureFreshToken falls back to it to open a new + // app-side credential cipher as the tokens above but is read on a different + // path — EnsureFreshToken falls back to it to open a new // session when the refresh token has expired — so a firehose-indexed // community giving back ciphertext, or an error, instead of "" would // surface there rather than here. @@ -154,7 +155,7 @@ func TestCommunityRepository_EncryptedCredentials(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) ctx := context.Background() t.Run("credentials are encrypted in database", func(t *testing.T) { @@ -275,7 +276,7 @@ func TestCommunityRepository_V2OwnershipModel(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) ctx := context.Background() t.Run("V2 communities are self-owned", func(t *testing.T) { diff --git a/internal/db/postgres/community_repo_list_test.go b/internal/db/postgres/community_repo_list_test.go index de41429..1c5387c 100644 --- a/internal/db/postgres/community_repo_list_test.go +++ b/internal/db/postgres/community_repo_list_test.go @@ -5,6 +5,7 @@ package postgres import ( "Coves/internal/core/communities" "Coves/internal/core/posts" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/tests/testkit" "context" "database/sql" @@ -139,7 +140,7 @@ func namesOf(listed []*communities.Community) []string { func seedSortFixture(t *testing.T, db *sql.DB) communities.Repository { t.Helper() - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) seedListableCommunity(t, db, repo, "alpha", "public", 1, 30, base) seedListableCommunity(t, db, repo, "bravo", "public", 30, 1, base.Add(time.Hour)) @@ -206,7 +207,7 @@ func TestCommunityRepo_ListVisibilityFilter(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) public := seedListableCommunity(t, db, repo, "openone", "public", 5, 5, base) diff --git a/internal/db/postgres/community_repo_memberships_test.go b/internal/db/postgres/community_repo_memberships_test.go index 349435e..88dd956 100644 --- a/internal/db/postgres/community_repo_memberships_test.go +++ b/internal/db/postgres/community_repo_memberships_test.go @@ -3,6 +3,7 @@ package postgres import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "testing" @@ -55,7 +56,7 @@ func memberOf(t *testing.T) (communities.Repository, *communities.Community) { func memberOfWithDB(t *testing.T) (communities.Repository, *sql.DB, *communities.Community) { t.Helper() db := testkit.DB(t) - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) id := testkit.UniqueID(t) community, err := repo.Create(context.Background(), &communities.Community{ DID: "did:plc:mem" + id, @@ -621,7 +622,7 @@ func TestCommunityRepo_Counters(t *testing.T) { func TestCommunityRepo_CountersOnAnAbsentCommunityAreSilent(t *testing.T) { t.Parallel() ctx := context.Background() - repo := NewCommunityRepository(testkit.DB(t)) + repo := NewCommunityRepository(testkit.DB(t), credentialciphertest.Fixed()) absent := "did:plc:neverindexed0000000" for name, increment := range map[string]func(context.Context, string) error{ diff --git a/internal/db/postgres/community_repo_mutation_test.go b/internal/db/postgres/community_repo_mutation_test.go index e82cd25..fb0b236 100644 --- a/internal/db/postgres/community_repo_mutation_test.go +++ b/internal/db/postgres/community_repo_mutation_test.go @@ -3,6 +3,7 @@ package postgres import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "strings" "testing" @@ -44,7 +45,7 @@ var seededUpdatedAt = time.Date(2024, 3, 4, 5, 6, 7, 0, time.UTC) // an update can be shown to change what it means to and nothing else. func updatableCommunity(t *testing.T) (communities.Repository, *communities.Community) { t.Helper() - repo := NewCommunityRepository(testkit.DB(t)) + repo := NewCommunityRepository(testkit.DB(t), credentialciphertest.Fixed()) id := testkit.UniqueID(t) community, err := repo.Create(context.Background(), &communities.Community{ @@ -243,7 +244,7 @@ func TestCommunityRepo_UpdateCredentialsReportsAnAbsentCommunity(t *testing.T) { // that matters to the token-refresh loop: a refresh for a community that is // no longer indexed must fail loudly, because the refresh token it just // spent is single-use and the old one is already revoked. - repo := NewCommunityRepository(testkit.DB(t)) + repo := NewCommunityRepository(testkit.DB(t), credentialciphertest.Fixed()) err := repo.UpdateCredentials(context.Background(), "did:plc:nosuchcommunity0000", "access", "refresh") require.ErrorIs(t, err, communities.ErrCommunityNotFound, @@ -314,7 +315,7 @@ func TestCommunityRepo_Search(t *testing.T) { // which is why these are seeded into a per-test clone and named plainly. seed := func(t *testing.T) communities.Repository { t.Helper() - repo := NewCommunityRepository(testkit.DB(t)) + repo := NewCommunityRepository(testkit.DB(t), credentialciphertest.Fixed()) for _, fixture := range []struct { name, description, visibility string members int @@ -463,7 +464,7 @@ func TestCommunityRepo_Search(t *testing.T) { func TestCommunityRepo_SearchTotalCountsRowsTheResultsExclude(t *testing.T) { t.Parallel() ctx := context.Background() - repo := NewCommunityRepository(testkit.DB(t)) + repo := NewCommunityRepository(testkit.DB(t), credentialciphertest.Fixed()) // "art" appears inside this description, so ILIKE matches. Against a // description this long the trigram similarity of a three-character query @@ -519,7 +520,7 @@ func communityNames(list []*communities.Community) []string { func TestCommunityRepo_SearchIsNotSQLInjectable(t *testing.T) { t.Parallel() ctx := context.Background() - repo := NewCommunityRepository(testkit.DB(t)) + repo := NewCommunityRepository(testkit.DB(t), credentialciphertest.Fixed()) id := testkit.UniqueID(t) _, err := repo.Create(ctx, &communities.Community{ diff --git a/internal/db/postgres/community_repo_subscription_lists_test.go b/internal/db/postgres/community_repo_subscription_lists_test.go index d79df2e..178b906 100644 --- a/internal/db/postgres/community_repo_subscription_lists_test.go +++ b/internal/db/postgres/community_repo_subscription_lists_test.go @@ -3,6 +3,7 @@ package postgres import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "testing" "time" @@ -45,7 +46,7 @@ type subscriptionFixture struct { func newSubscriptionFixture(t *testing.T) subscriptionFixture { t.Helper() ctx := context.Background() - repo := NewCommunityRepository(testkit.DB(t)) + repo := NewCommunityRepository(testkit.DB(t), credentialciphertest.Fixed()) id := testkit.UniqueID(t) seedCommunity := func(name string) *communities.Community { diff --git a/internal/db/postgres/community_repo_test.go b/internal/db/postgres/community_repo_test.go index ba20eda..4e92cde 100644 --- a/internal/db/postgres/community_repo_test.go +++ b/internal/db/postgres/community_repo_test.go @@ -4,6 +4,7 @@ package postgres import ( "Coves/internal/core/communities" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/tests/testkit" "context" "fmt" @@ -34,7 +35,7 @@ func TestCommunityRepository_Create(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) ctx := context.Background() t.Run("creates community successfully", func(t *testing.T) { @@ -171,7 +172,7 @@ func TestCommunityRepository_GetByDID(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) ctx := context.Background() t.Run("retrieves existing community", func(t *testing.T) { @@ -224,7 +225,7 @@ func TestCommunityRepository_GetByHandle(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) ctx := context.Background() t.Run("retrieves community by handle", func(t *testing.T) { @@ -266,7 +267,7 @@ func TestCommunityRepository_Subscriptions(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) ctx := context.Background() // One community for every subscription case below. @@ -423,7 +424,7 @@ func TestCommunityRepository_List(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) ctx := context.Background() t.Run("lists communities with pagination", func(t *testing.T) { @@ -513,7 +514,7 @@ func TestCommunityRepository_GetSubscribedCommunityDIDs(t *testing.T) { t.Parallel() db := testkit.DB(t) - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) ctx := context.Background() // Three communities so the assertions can distinguish "subscribed", diff --git a/internal/db/postgres/community_subscriber_recount_migration_test.go b/internal/db/postgres/community_subscriber_recount_migration_test.go index f1cee9d..c8708b9 100644 --- a/internal/db/postgres/community_subscriber_recount_migration_test.go +++ b/internal/db/postgres/community_subscriber_recount_migration_test.go @@ -3,6 +3,7 @@ package postgres import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "testing" @@ -21,11 +22,13 @@ func TestMigration045RecountsAndMaintainsCommunitySubscribers(t *testing.T) { t.Parallel() db := testkit.DB(t) + require.EqualValues(t, 46, testkit.MigrateDownOne(t, db, 46), + "046 (drop encryption_keys) sits on top of 045 and must be rolled back first") require.EqualValues(t, 45, testkit.MigrateDownOne(t, db, 45), "this test seeds the record-asserted state migration 045 repairs") ctx := context.Background() - repo := NewCommunityRepository(db) + repo := NewCommunityRepository(db, credentialciphertest.Fixed()) // Down uses IF EXISTS throughout, so a misnamed object would roll back // "successfully" and leave the trigger live. Prove it is actually gone. diff --git a/internal/db/postgres/concurrent_writes_test.go b/internal/db/postgres/concurrent_writes_test.go index ee01971..2f7dbd5 100644 --- a/internal/db/postgres/concurrent_writes_test.go +++ b/internal/db/postgres/concurrent_writes_test.go @@ -7,6 +7,7 @@ import ( "Coves/internal/core/comments" "Coves/internal/core/communities" "Coves/internal/core/users" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/internal/db/postgres" "Coves/tests/fixtures" "Coves/tests/testkit" @@ -303,7 +304,7 @@ func TestConcurrentCommenting_MultipleUsersOnSamePost(t *testing.T) { commentRepo := postgres.NewCommentRepository(db) postRepo := postgres.NewPostRepository(db) userRepo := postgres.NewUserRepository(db) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db) fixedTime := time.Date(2025, 11, 16, 12, 0, 0, 0, time.UTC) @@ -544,7 +545,7 @@ func TestConcurrentCommunityCreation_DuplicateHandle(t *testing.T) { db := testkit.DB(t) ctx := context.Background() - repo := postgres.NewCommunityRepository(db) + repo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) t.Run("Concurrent creation with same handle should fail", func(t *testing.T) { const numAttempts = 10 @@ -678,7 +679,7 @@ func TestConcurrentSubscription_RaceConditions(t *testing.T) { db := testkit.DB(t) ctx := context.Background() - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) // did:web verification is skipped: these events are synthesised locally and // there is no PLC directory in this package's infrastructure floor. consumer := jetstream.NewCommunityEventConsumer(communityRepo, "did:web:coves.local", true, nil) diff --git a/internal/db/postgres/credential_cipher_acceptance_test.go b/internal/db/postgres/credential_cipher_acceptance_test.go new file mode 100644 index 0000000..5c03cc3 --- /dev/null +++ b/internal/db/postgres/credential_cipher_acceptance_test.go @@ -0,0 +1,159 @@ +//go:build integration + +package postgres + +import ( + "bytes" + "context" + "database/sql" + "fmt" + "testing" + "time" + + "Coves/internal/core/communities" + "Coves/internal/crypto/credentialcipher" + "Coves/tests/testkit" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCredentialCipherAcceptance(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + + cipherA, err := credentialcipher.New(bytes.Repeat([]byte{0xa5}, credentialcipher.KeySize)) + require.NoError(t, err) + cipherB, err := credentialcipher.New(bytes.Repeat([]byte{0x5a}, credentialcipher.KeySize)) + require.NoError(t, err) + + communityRepo := NewCommunityRepository(db, cipherA) + aggregatorRepo := NewAggregatorRepository(db, cipherA) + + id := testkit.UniqueID(t) + communityDID := "did:plc:" + id + community := &communities.Community{ + DID: communityDID, + Handle: fmt.Sprintf("!credential-cipher-%s@coves.local", id), + Name: "credential-cipher-acceptance", + OwnerDID: communityDID, + CreatedByDID: "did:plc:credential-cipher-creator", + HostedByDID: "did:web:coves.local", + Visibility: "public", + PDSEmail: "credential-cipher@communities.coves.local", + PDSPassword: "community-password-acceptance", + PDSAccessToken: "community-access-token-acceptance", + PDSRefreshToken: "community-refresh-token-acceptance", + PDSURL: testkit.Endpoints().PDS.BaseURL, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _, err = communityRepo.Create(ctx, community) + require.NoError(t, err) + + aggregatorDID := indexAggregator(t, aggregatorRepo, "Credential Cipher Acceptance") + oauthSession := aggregatorOAuthSession("acceptance", time.Now().Add(time.Hour)) + require.NoError(t, aggregatorRepo.SetAPIKey( + ctx, + aggregatorDID, + aggregatorKeyPrefix("acceptance"), + aggregatorAPIKeyHash(t, "credential-cipher-acceptance"), + oauthSession, + )) + + var communityPassword, communityAccess, communityRefresh []byte + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT pds_password_encrypted, pds_access_token_encrypted, pds_refresh_token_encrypted + FROM communities + WHERE did = $1`, communityDID).Scan(&communityPassword, &communityAccess, &communityRefresh)) + aggregatorAccess, aggregatorRefresh, aggregatorDPoP := aggregatorCiphertext(t, db, aggregatorDID) + + credentials := []struct { + name string + ciphertext []byte + plaintext string + context string + }{ + { + name: "community password", + ciphertext: communityPassword, + plaintext: community.PDSPassword, + context: "communities.pds_password_encrypted:" + communityDID, + }, + { + name: "community access token", + ciphertext: communityAccess, + plaintext: community.PDSAccessToken, + context: "communities.pds_access_token_encrypted:" + communityDID, + }, + { + name: "community refresh token", + ciphertext: communityRefresh, + plaintext: community.PDSRefreshToken, + context: "communities.pds_refresh_token_encrypted:" + communityDID, + }, + { + name: "aggregator access token", + ciphertext: aggregatorAccess, + plaintext: oauthSession.AccessToken, + context: "aggregators.oauth_access_token_encrypted:" + aggregatorDID, + }, + { + name: "aggregator refresh token", + ciphertext: aggregatorRefresh, + plaintext: oauthSession.RefreshToken, + context: "aggregators.oauth_refresh_token_encrypted:" + aggregatorDID, + }, + { + name: "aggregator DPoP private key", + ciphertext: aggregatorDPoP, + plaintext: oauthSession.DPoPPrivateKeyMultibase, + context: "aggregators.oauth_dpop_private_key_encrypted:" + aggregatorDID, + }, + } + + t.Run("stores opaque non-NULL ciphertext", func(t *testing.T) { + for _, credential := range credentials { + assert.NotNil(t, credential.ciphertext, "%s ciphertext is NULL", credential.name) + assert.False(t, bytes.Contains(credential.ciphertext, []byte(credential.plaintext)), + "%s ciphertext contains its plaintext", credential.name) + } + }) + + t.Run("cipher A decrypts every credential with its row and column context", func(t *testing.T) { + for _, credential := range credentials { + plaintext, decryptErr := cipherA.Decrypt(credential.ciphertext, credential.context) + if assert.NoError(t, decryptErr, "%s did not decrypt with cipher A", credential.name) { + assert.Equal(t, credential.plaintext, plaintext, "%s plaintext mismatch", credential.name) + } + } + }) + + t.Run("cipher B cannot decrypt cipher A credentials", func(t *testing.T) { + for _, credential := range credentials { + _, decryptErr := cipherB.Decrypt(credential.ciphertext, credential.context) + assert.ErrorIs(t, decryptErr, credentialcipher.ErrInvalidCiphertext, + "%s decrypted with the wrong key", credential.name) + } + }) + + t.Run("repositories return plaintext credentials", func(t *testing.T) { + retrievedCommunity, getErr := communityRepo.GetByDID(ctx, communityDID) + require.NoError(t, getErr) + assert.Equal(t, community.PDSPassword, retrievedCommunity.PDSPassword) + assert.Equal(t, community.PDSAccessToken, retrievedCommunity.PDSAccessToken) + assert.Equal(t, community.PDSRefreshToken, retrievedCommunity.PDSRefreshToken) + + retrievedAggregator, getErr := aggregatorRepo.GetAggregatorCredentials(ctx, aggregatorDID) + require.NoError(t, getErr) + assert.Equal(t, oauthSession.AccessToken, retrievedAggregator.OAuthAccessToken) + assert.Equal(t, oauthSession.RefreshToken, retrievedAggregator.OAuthRefreshToken) + assert.Equal(t, oauthSession.DPoPPrivateKeyMultibase, retrievedAggregator.OAuthDPoPPrivateKeyMultibase) + }) + + t.Run("database stores no encryption key", func(t *testing.T) { + var encryptionKeysTable sql.NullString + require.NoError(t, db.QueryRowContext(ctx, `SELECT to_regclass('encryption_keys')`).Scan(&encryptionKeysTable)) + assert.False(t, encryptionKeysTable.Valid, "encryption_keys still exists as %q", encryptionKeysTable.String) + }) +} diff --git a/internal/db/postgres/credential_cipher_repo_test.go b/internal/db/postgres/credential_cipher_repo_test.go new file mode 100644 index 0000000..1165dce --- /dev/null +++ b/internal/db/postgres/credential_cipher_repo_test.go @@ -0,0 +1,356 @@ +//go:build integration + +package postgres + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "strings" + "testing" + "time" + + "Coves/internal/core/aggregators" + "Coves/internal/core/communities" + "Coves/internal/crypto/credentialcipher" + "Coves/tests/testkit" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCredentialCipherRepoCommunityUpdateCredentials(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + cipher := credentialCipherRepoTestCipher(t) + repo := NewCommunityRepository(db, cipher) + community := credentialCipherRepoCommunity(t, "update") + + _, err := repo.Create(ctx, community) + require.NoError(t, err) + passwordBefore, _, _ := communityCredentialCiphertext(t, db, community.DID) + + const newAccessToken = "community-access-token-after-refresh" + const newRefreshToken = "community-refresh-token-after-refresh" + require.NoError(t, repo.UpdateCredentials(ctx, community.DID, newAccessToken, newRefreshToken)) + + passwordAfter, accessAfter, refreshAfter := communityCredentialCiphertext(t, db, community.DID) + t.Run("rewrites tokens with the application cipher", func(t *testing.T) { + assertCredentialCiphertext(t, cipher, accessAfter, + "communities.pds_access_token_encrypted:"+community.DID, newAccessToken) + assertCredentialCiphertext(t, cipher, refreshAfter, + "communities.pds_refresh_token_encrypted:"+community.DID, newRefreshToken) + }) + + t.Run("leaves the password ciphertext untouched", func(t *testing.T) { + assert.Equal(t, passwordBefore, passwordAfter) + }) + + t.Run("returns the refreshed tokens", func(t *testing.T) { + retrieved, err := repo.GetByDID(ctx, community.DID) + require.NoError(t, err) + assert.Equal(t, newAccessToken, retrieved.PDSAccessToken) + assert.Equal(t, newRefreshToken, retrieved.PDSRefreshToken) + assert.Equal(t, community.PDSPassword, retrieved.PDSPassword) + }) +} + +func TestCredentialCipherRepoCommunityEmptyCredentialsStayNull(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + repo := NewCommunityRepository(db, credentialCipherRepoTestCipher(t)) + community := credentialCipherRepoCommunity(t, "empty") + community.PDSPassword = "" + community.PDSAccessToken = "" + community.PDSRefreshToken = "" + + _, err := repo.Create(ctx, community) + require.NoError(t, err) + + password, access, refresh := communityCredentialCiphertext(t, db, community.DID) + assert.Nil(t, password, "empty password must be SQL NULL") + assert.Nil(t, access, "empty access token must be SQL NULL") + assert.Nil(t, refresh, "empty refresh token must be SQL NULL") + + retrieved, err := repo.GetByDID(ctx, community.DID) + require.NoError(t, err) + assert.Empty(t, retrieved.PDSPassword) + assert.Empty(t, retrieved.PDSAccessToken) + assert.Empty(t, retrieved.PDSRefreshToken) +} + +func TestCredentialCipherRepoCommunityRejectsTampering(t *testing.T) { + t.Run("flipped authentication tag", func(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + repo := NewCommunityRepository(db, credentialCipherRepoTestCipher(t)) + community := credentialCipherRepoCommunity(t, "flipped") + _, err := repo.Create(ctx, community) + require.NoError(t, err) + + password, _, _ := communityCredentialCiphertext(t, db, community.DID) + require.NotEmpty(t, password) + tampered := append([]byte(nil), password...) + tampered[len(tampered)-1] ^= 0x01 + _, err = db.ExecContext(ctx, + `UPDATE communities SET pds_password_encrypted = $2 WHERE did = $1`, + community.DID, tampered) + require.NoError(t, err) + + retrieved, err := repo.GetByDID(ctx, community.DID) + assert.ErrorIs(t, err, credentialcipher.ErrInvalidCiphertext) + assert.Nil(t, retrieved, "a decryption failure must not become a community with an empty password") + }) + + t.Run("ciphertext copied from another community", func(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + repo := NewCommunityRepository(db, credentialCipherRepoTestCipher(t)) + target := credentialCipherRepoCommunity(t, "copy-target") + source := credentialCipherRepoCommunity(t, "copy-source") + source.PDSPassword = "password-bound-to-the-source-community" + _, err := repo.Create(ctx, target) + require.NoError(t, err) + _, err = repo.Create(ctx, source) + require.NoError(t, err) + + sourcePassword, _, _ := communityCredentialCiphertext(t, db, source.DID) + require.NotEmpty(t, sourcePassword) + _, err = db.ExecContext(ctx, + `UPDATE communities SET pds_password_encrypted = $2 WHERE did = $1`, + target.DID, sourcePassword) + require.NoError(t, err) + + retrieved, err := repo.GetByDID(ctx, target.DID) + assert.ErrorIs(t, err, credentialcipher.ErrInvalidCiphertext) + assert.Nil(t, retrieved, "row-bound ciphertext copied across communities must not yield a password") + }) +} + +func TestCredentialCipherRepoAggregatorUpdateOAuthTokens(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + cipher := credentialCipherRepoTestCipher(t) + repo := NewAggregatorRepository(db, cipher) + aggregatorDID := indexAggregator(t, repo, "Credential Update Aggregator") + keyHash := aggregatorAPIKeyHash(t, "credential-update-api-key") + session := aggregatorOAuthSession("before-update", time.Now().Add(-time.Hour)) + require.NoError(t, repo.SetAPIKey(ctx, aggregatorDID, aggregatorKeyPrefix("update"), keyHash, session)) + _, _, dpopBefore := aggregatorCiphertext(t, db, aggregatorDID) + + const newAccessToken = "aggregator-access-token-after-refresh" + const newRefreshToken = "aggregator-refresh-token-after-refresh" + require.NoError(t, repo.UpdateOAuthTokens( + ctx, aggregatorDID, newAccessToken, newRefreshToken, time.Now().Add(-time.Minute))) + + accessAfter, refreshAfter, dpopAfter := aggregatorCiphertext(t, db, aggregatorDID) + t.Run("rewrites tokens with the application cipher", func(t *testing.T) { + assertCredentialCiphertext(t, cipher, accessAfter, + "aggregators.oauth_access_token_encrypted:"+aggregatorDID, newAccessToken) + assertCredentialCiphertext(t, cipher, refreshAfter, + "aggregators.oauth_refresh_token_encrypted:"+aggregatorDID, newRefreshToken) + }) + + t.Run("leaves the DPoP ciphertext untouched", func(t *testing.T) { + assert.Equal(t, dpopBefore, dpopAfter) + }) + + t.Run("all credential readers return the refreshed tokens", func(t *testing.T) { + byDID, err := repo.GetAggregatorCredentials(ctx, aggregatorDID) + require.NoError(t, err) + assert.Equal(t, newAccessToken, byDID.OAuthAccessToken) + assert.Equal(t, newRefreshToken, byDID.OAuthRefreshToken) + + byHash, err := repo.GetCredentialsByAPIKeyHash(ctx, keyHash) + require.NoError(t, err) + assert.Equal(t, newAccessToken, byHash.OAuthAccessToken) + assert.Equal(t, newRefreshToken, byHash.OAuthRefreshToken) + + due, err := repo.ListAggregatorsNeedingTokenRefresh(ctx, 0) + require.NoError(t, err) + listed := credentialCipherRepoFindAggregator(due, aggregatorDID) + require.NotNil(t, listed) + assert.Equal(t, newAccessToken, listed.OAuthAccessToken) + assert.Equal(t, newRefreshToken, listed.OAuthRefreshToken) + }) +} + +func TestCredentialCipherRepoAggregatorEmptyCredentialsStayNull(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + repo := NewAggregatorRepository(db, credentialCipherRepoTestCipher(t)) + aggregatorDID := indexAggregator(t, repo, "Empty Credential Aggregator") + session := aggregatorOAuthSession("empty", time.Now().Add(time.Hour)) + session.AccessToken = "" + session.RefreshToken = "" + session.DPoPPrivateKeyMultibase = "" + + require.NoError(t, repo.SetAPIKey( + ctx, + aggregatorDID, + aggregatorKeyPrefix("empty"), + aggregatorAPIKeyHash(t, "empty-credential-api-key"), + session, + )) + + access, refresh, dpop := aggregatorCiphertext(t, db, aggregatorDID) + assert.Nil(t, access, "empty access token must be SQL NULL") + assert.Nil(t, refresh, "empty refresh token must be SQL NULL") + assert.Nil(t, dpop, "empty DPoP private key must be SQL NULL") + + retrieved, err := repo.GetAggregatorCredentials(ctx, aggregatorDID) + require.NoError(t, err) + assert.Empty(t, retrieved.OAuthAccessToken) + assert.Empty(t, retrieved.OAuthRefreshToken) + assert.Empty(t, retrieved.OAuthDPoPPrivateKeyMultibase) +} + +func TestCredentialCipherRepoAggregatorRejectsTamperedDPoPKey(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + repo := NewAggregatorRepository(db, credentialCipherRepoTestCipher(t)) + aggregatorDID := indexAggregator(t, repo, "Tampered Credential Aggregator") + keyHash := aggregatorAPIKeyHash(t, "tampered-credential-api-key") + session := aggregatorOAuthSession("tampered", time.Now().Add(-time.Minute)) + require.NoError(t, repo.SetAPIKey( + ctx, aggregatorDID, aggregatorKeyPrefix("tampered"), keyHash, session)) + + _, _, dpop := aggregatorCiphertext(t, db, aggregatorDID) + require.NotEmpty(t, dpop) + tampered := append([]byte(nil), dpop...) + tampered[len(tampered)-1] ^= 0x01 + _, err := db.ExecContext(ctx, + `UPDATE aggregators SET oauth_dpop_private_key_encrypted = $2 WHERE did = $1`, + aggregatorDID, tampered) + require.NoError(t, err) + + byDID, err := repo.GetAggregatorCredentials(ctx, aggregatorDID) + assert.ErrorIs(t, err, credentialcipher.ErrInvalidCiphertext) + assert.Nil(t, byDID) + + byHash, err := repo.GetCredentialsByAPIKeyHash(ctx, keyHash) + assert.ErrorIs(t, err, credentialcipher.ErrInvalidCiphertext) + assert.Nil(t, byHash) + + due, err := repo.ListAggregatorsNeedingTokenRefresh(ctx, 0) + assert.ErrorIs(t, err, credentialcipher.ErrInvalidCiphertext, + "the refresh list must fail rather than silently skip a row with corrupted credentials") + assert.Nil(t, due) +} + +func TestCredentialCipherRepoClassifiesLegacyPgcryptoValues(t *testing.T) { + t.Run("community password", func(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + repo := NewCommunityRepository(db, credentialCipherRepoTestCipher(t)) + community := credentialCipherRepoCommunity(t, "legacy-pgcrypto") + _, err := repo.Create(ctx, community) + require.NoError(t, err) + + _, err = db.ExecContext(ctx, ` + UPDATE communities + SET pds_password_encrypted = pgp_sym_encrypt('x', 'k') + WHERE did = $1 + `, community.DID) + require.NoError(t, err) + + retrieved, err := repo.GetByDID(ctx, community.DID) + require.Error(t, err) + assert.True(t, errors.Is(err, credentialcipher.ErrInvalidCiphertext)) + assert.True(t, errors.Is(err, credentialcipher.ErrUnsupportedVersion)) + assert.Contains(t, err.Error(), community.DID) + assert.Contains(t, strings.ToLower(err.Error()), "pgcrypto") + assert.Nil(t, retrieved) + }) + + t.Run("aggregator DPoP private key", func(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + repo := NewAggregatorRepository(db, credentialCipherRepoTestCipher(t)) + aggregatorDID := indexAggregator(t, repo, "Legacy Pgcrypto Aggregator") + + _, err := db.ExecContext(ctx, ` + UPDATE aggregators + SET oauth_dpop_private_key_encrypted = pgp_sym_encrypt('x', 'k') + WHERE did = $1 + `, aggregatorDID) + require.NoError(t, err) + + retrieved, err := repo.GetAggregatorCredentials(ctx, aggregatorDID) + require.Error(t, err) + assert.True(t, errors.Is(err, credentialcipher.ErrInvalidCiphertext)) + assert.True(t, errors.Is(err, credentialcipher.ErrUnsupportedVersion)) + assert.Contains(t, err.Error(), aggregatorDID) + assert.Contains(t, strings.ToLower(err.Error()), "pgcrypto") + assert.Nil(t, retrieved) + }) +} + +func credentialCipherRepoTestCipher(t *testing.T) *credentialcipher.Cipher { + t.Helper() + cipher, err := credentialcipher.New(bytes.Repeat([]byte{0x42}, credentialcipher.KeySize)) + require.NoError(t, err) + return cipher +} + +func credentialCipherRepoCommunity(t *testing.T, label string) *communities.Community { + t.Helper() + id := testkit.UniqueID(t) + did := "did:plc:" + id + return &communities.Community{ + DID: did, + Handle: fmt.Sprintf("!cipher-%s-%s@coves.local", label, id), + Name: "credential-cipher-" + label, + OwnerDID: did, + CreatedByDID: "did:plc:credential-cipher-creator", + HostedByDID: "did:web:coves.local", + Visibility: "public", + PDSEmail: label + "@communities.coves.local", + PDSPassword: "community-password-" + label, + PDSAccessToken: "community-access-token-" + label, + PDSRefreshToken: "community-refresh-token-" + label, + PDSURL: testkit.Endpoints().PDS.BaseURL, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +func communityCredentialCiphertext(t *testing.T, db *sql.DB, did string) (password, access, refresh []byte) { + t.Helper() + require.NoError(t, db.QueryRowContext(context.Background(), ` + SELECT pds_password_encrypted, pds_access_token_encrypted, pds_refresh_token_encrypted + FROM communities + WHERE did = $1`, did).Scan(&password, &access, &refresh)) + return password, access, refresh +} + +func assertCredentialCiphertext( + t *testing.T, + cipher *credentialcipher.Cipher, + ciphertext []byte, + credentialContext string, + wantPlaintext string, +) { + t.Helper() + if assert.NotEmpty(t, ciphertext) { + assert.Equal(t, byte(0x01), ciphertext[0], "credential is not in the versioned AES-GCM format") + } + plaintext, err := cipher.Decrypt(ciphertext, credentialContext) + if assert.NoError(t, err) { + assert.Equal(t, wantPlaintext, plaintext) + } +} + +func credentialCipherRepoFindAggregator( + credentials []*aggregators.AggregatorCredentials, + did string, +) *aggregators.AggregatorCredentials { + for _, credential := range credentials { + if credential.DID == did { + return credential + } + } + return nil +} diff --git a/internal/db/postgres/credential_context.go b/internal/db/postgres/credential_context.go new file mode 100644 index 0000000..b071d47 --- /dev/null +++ b/internal/db/postgres/credential_context.go @@ -0,0 +1,49 @@ +package postgres + +import "Coves/internal/crypto/credentialcipher" + +// Credential contexts bind each ciphertext to its table, column, and row DID. +// They are persisted as GCM additional authenticated data, so renaming a table +// or column or changing this format requires a re-encryption pass. +func communityPDSPasswordCredentialContext(did string) string { + return "communities.pds_password_encrypted:" + did +} + +func communityPDSAccessTokenCredentialContext(did string) string { + return "communities.pds_access_token_encrypted:" + did +} + +func communityPDSRefreshTokenCredentialContext(did string) string { + return "communities.pds_refresh_token_encrypted:" + did +} + +func aggregatorOAuthAccessTokenCredentialContext(did string) string { + return "aggregators.oauth_access_token_encrypted:" + did +} + +func aggregatorOAuthRefreshTokenCredentialContext(did string) string { + return "aggregators.oauth_refresh_token_encrypted:" + did +} + +func aggregatorOAuthDPoPPrivateKeyCredentialContext(did string) string { + return "aggregators.oauth_dpop_private_key_encrypted:" + did +} + +// encryptOptionalCredential returns an untyped nil for an empty credential so +// the driver binds SQL NULL. The return type is any on purpose: lib/pq encodes +// a nil []byte as an empty bytea, not NULL, and absent credentials must be NULL. +func encryptOptionalCredential(cipher *credentialcipher.Cipher, plaintext, context string) (any, error) { + if plaintext == "" { + return nil, nil + } + return cipher.Encrypt(plaintext, context) +} + +// decryptOptionalCredential treats SQL NULL and zero-length bytea values as an +// absent optional credential. +func decryptOptionalCredential(cipher *credentialcipher.Cipher, ciphertext []byte, context string) (string, error) { + if len(ciphertext) == 0 { + return "", nil + } + return cipher.Decrypt(ciphertext, context) +} diff --git a/internal/db/postgres/credential_reencrypt.go b/internal/db/postgres/credential_reencrypt.go new file mode 100644 index 0000000..7f299ae --- /dev/null +++ b/internal/db/postgres/credential_reencrypt.go @@ -0,0 +1,364 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "Coves/internal/crypto/credentialcipher" +) + +// ReencryptReport counts what a ReencryptLegacyCredentials pass rewrote. +type ReencryptReport struct { + // CommunitiesRewritten is the number of community rows whose pgcrypto + // credentials were resealed with the application cipher. + CommunitiesRewritten int + // AggregatorsRewritten is the same count for aggregator rows. + AggregatorsRewritten int +} + +// ReencryptLegacyCredentials moves credentials off the database-held pgcrypto +// key before migration 046 removes that key. One transaction makes the pass +// atomic with migration 046's legacy-data guard and prevents a failure from +// leaving a half-converted database. Each table is handled only after all three +// of its credential columns exist. A database older than migration 025 may +// therefore need a second boot: migration 046 refuses the first boot, and after +// the operator restarts, this pass converts credentials sealed by migration 025. +// A generated dev key is refused because credentials resealed with it would be +// stranded at restart. +func ReencryptLegacyCredentials( + ctx context.Context, + db *sql.DB, + cipher *credentialcipher.Cipher, + keyIsEphemeral bool, +) (report ReencryptReport, returnErr error) { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return ReencryptReport{}, fmt.Errorf("begin credential re-encryption transaction: %w", err) + } + defer func() { + if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { + returnErr = errors.Join(returnErr, fmt.Errorf("roll back credential re-encryption transaction: %w", rollbackErr)) + } + }() + + communitiesAvailable, err := credentialColumnsAvailable(ctx, tx, "communities", + "pds_password_encrypted", "pds_access_token_encrypted", "pds_refresh_token_encrypted") + if err != nil { + return ReencryptReport{}, err + } + aggregatorsAvailable, err := credentialColumnsAvailable(ctx, tx, "aggregators", + "oauth_access_token_encrypted", "oauth_refresh_token_encrypted", "oauth_dpop_private_key_encrypted") + if err != nil { + return ReencryptReport{}, err + } + + var encryptionKeysTable sql.NullString + if err := tx.QueryRowContext(ctx, `SELECT to_regclass('encryption_keys')`).Scan(&encryptionKeysTable); err != nil { + return ReencryptReport{}, fmt.Errorf("check for legacy encryption key table: %w", err) + } + if !encryptionKeysTable.Valid { + if err := rejectUnrecoverableLegacyCredentials(ctx, tx, communitiesAvailable, aggregatorsAvailable); err != nil { + return ReencryptReport{}, err + } + if err := tx.Commit(); err != nil { + return ReencryptReport{}, fmt.Errorf("commit credential re-encryption transaction: %w", err) + } + return ReencryptReport{}, nil + } + + var communities []legacyCommunityCredentials + if communitiesAvailable { + communities, err = loadLegacyCommunityCredentials(ctx, tx) + if err != nil { + return ReencryptReport{}, err + } + } + var aggregators []legacyAggregatorCredentials + if aggregatorsAvailable { + aggregators, err = loadLegacyAggregatorCredentials(ctx, tx) + if err != nil { + return ReencryptReport{}, err + } + } + + legacyRowCount := len(communities) + len(aggregators) + if keyIsEphemeral && legacyRowCount > 0 { + return ReencryptReport{}, fmt.Errorf( + "cannot re-encrypt %d legacy credential rows with a generated ENCRYPTION_KEY; configure a persistent ENCRYPTION_KEY first", + legacyRowCount) + } + if legacyRowCount == 0 { + if err := tx.Commit(); err != nil { + return ReencryptReport{}, fmt.Errorf("commit credential re-encryption transaction: %w", err) + } + return ReencryptReport{}, nil + } + + for _, community := range communities { + password, passwordLegacy, err := reencryptLegacyCredential( + ctx, tx, cipher, legacyCredential{ + table: "communities", column: "pds_password_encrypted", did: community.did, + context: communityPDSPasswordCredentialContext(community.did), ciphertext: community.password, + }) + if err != nil { + return ReencryptReport{}, err + } + accessToken, accessTokenLegacy, err := reencryptLegacyCredential( + ctx, tx, cipher, legacyCredential{ + table: "communities", column: "pds_access_token_encrypted", did: community.did, + context: communityPDSAccessTokenCredentialContext(community.did), ciphertext: community.accessToken, + }) + if err != nil { + return ReencryptReport{}, err + } + refreshToken, refreshTokenLegacy, err := reencryptLegacyCredential( + ctx, tx, cipher, legacyCredential{ + table: "communities", column: "pds_refresh_token_encrypted", did: community.did, + context: communityPDSRefreshTokenCredentialContext(community.did), ciphertext: community.refreshToken, + }) + if err != nil { + return ReencryptReport{}, err + } + + if _, err := tx.ExecContext(ctx, ` + UPDATE communities SET + pds_password_encrypted = CASE WHEN $2 THEN $3 ELSE pds_password_encrypted END, + pds_access_token_encrypted = CASE WHEN $4 THEN $5 ELSE pds_access_token_encrypted END, + pds_refresh_token_encrypted = CASE WHEN $6 THEN $7 ELSE pds_refresh_token_encrypted END + WHERE did = $1`, + community.did, + passwordLegacy, password, + accessTokenLegacy, accessToken, + refreshTokenLegacy, refreshToken, + ); err != nil { + return ReencryptReport{}, fmt.Errorf("update communities credentials for DID %s: %w", community.did, err) + } + report.CommunitiesRewritten++ + } + + for _, aggregator := range aggregators { + accessToken, accessTokenLegacy, err := reencryptLegacyCredential( + ctx, tx, cipher, legacyCredential{ + table: "aggregators", column: "oauth_access_token_encrypted", did: aggregator.did, + context: aggregatorOAuthAccessTokenCredentialContext(aggregator.did), ciphertext: aggregator.accessToken, + }) + if err != nil { + return ReencryptReport{}, err + } + refreshToken, refreshTokenLegacy, err := reencryptLegacyCredential( + ctx, tx, cipher, legacyCredential{ + table: "aggregators", column: "oauth_refresh_token_encrypted", did: aggregator.did, + context: aggregatorOAuthRefreshTokenCredentialContext(aggregator.did), ciphertext: aggregator.refreshToken, + }) + if err != nil { + return ReencryptReport{}, err + } + dpopPrivateKey, dpopPrivateKeyLegacy, err := reencryptLegacyCredential( + ctx, tx, cipher, legacyCredential{ + table: "aggregators", column: "oauth_dpop_private_key_encrypted", did: aggregator.did, + context: aggregatorOAuthDPoPPrivateKeyCredentialContext(aggregator.did), ciphertext: aggregator.dpopPrivateKey, + }) + if err != nil { + return ReencryptReport{}, err + } + + if _, err := tx.ExecContext(ctx, ` + UPDATE aggregators SET + oauth_access_token_encrypted = CASE WHEN $2 THEN $3 ELSE oauth_access_token_encrypted END, + oauth_refresh_token_encrypted = CASE WHEN $4 THEN $5 ELSE oauth_refresh_token_encrypted END, + oauth_dpop_private_key_encrypted = CASE WHEN $6 THEN $7 ELSE oauth_dpop_private_key_encrypted END + WHERE did = $1`, + aggregator.did, + accessTokenLegacy, accessToken, + refreshTokenLegacy, refreshToken, + dpopPrivateKeyLegacy, dpopPrivateKey, + ); err != nil { + return ReencryptReport{}, fmt.Errorf("update aggregators credentials for DID %s: %w", aggregator.did, err) + } + report.AggregatorsRewritten++ + } + + if err := tx.Commit(); err != nil { + return ReencryptReport{}, fmt.Errorf("commit credential re-encryption transaction: %w", err) + } + return report, nil +} + +func credentialColumnsAvailable( + ctx context.Context, + tx *sql.Tx, + table, firstColumn, secondColumn, thirdColumn string, +) (bool, error) { + var count int + if err := tx.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + AND column_name IN ($2, $3, $4)`, + table, firstColumn, secondColumn, thirdColumn).Scan(&count); err != nil { + return false, fmt.Errorf("check %s credential columns: %w", table, err) + } + return count == 3, nil +} + +func rejectUnrecoverableLegacyCredentials( + ctx context.Context, + tx *sql.Tx, + communitiesAvailable, aggregatorsAvailable bool, +) error { + var affected []string + if communitiesAvailable { + var count int + if err := tx.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM communities + WHERE (octet_length(pds_password_encrypted) > 0 AND get_byte(pds_password_encrypted, 0) <> $1) + OR (octet_length(pds_access_token_encrypted) > 0 AND get_byte(pds_access_token_encrypted, 0) <> $1) + OR (octet_length(pds_refresh_token_encrypted) > 0 AND get_byte(pds_refresh_token_encrypted, 0) <> $1)`, + credentialcipher.Version).Scan(&count); err != nil { + return fmt.Errorf("count unrecoverable communities credentials: %w", err) + } + if count > 0 { + affected = append(affected, fmt.Sprintf("communities: %d", count)) + } + } + if aggregatorsAvailable { + var count int + if err := tx.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM aggregators + WHERE (octet_length(oauth_access_token_encrypted) > 0 AND get_byte(oauth_access_token_encrypted, 0) <> $1) + OR (octet_length(oauth_refresh_token_encrypted) > 0 AND get_byte(oauth_refresh_token_encrypted, 0) <> $1) + OR (octet_length(oauth_dpop_private_key_encrypted) > 0 AND get_byte(oauth_dpop_private_key_encrypted, 0) <> $1)`, + credentialcipher.Version).Scan(&count); err != nil { + return fmt.Errorf("count unrecoverable aggregators credentials: %w", err) + } + if count > 0 { + affected = append(affected, fmt.Sprintf("aggregators: %d", count)) + } + } + if len(affected) > 0 { + return fmt.Errorf("legacy credential rows (%s) cannot be recovered because encryption_keys was already dropped; restore a pre-046 backup or NULL the columns and re-provision credentials", strings.Join(affected, ", ")) + } + return nil +} + +type legacyCommunityCredentials struct { + did string + password []byte + accessToken []byte + refreshToken []byte +} + +func loadLegacyCommunityCredentials(ctx context.Context, tx *sql.Tx) ([]legacyCommunityCredentials, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT did, pds_password_encrypted, pds_access_token_encrypted, pds_refresh_token_encrypted + FROM communities + WHERE (pds_password_encrypted IS NOT NULL AND + (octet_length(pds_password_encrypted) = 0 OR get_byte(pds_password_encrypted, 0) <> $1)) + OR (pds_access_token_encrypted IS NOT NULL AND + (octet_length(pds_access_token_encrypted) = 0 OR get_byte(pds_access_token_encrypted, 0) <> $1)) + OR (pds_refresh_token_encrypted IS NOT NULL AND + (octet_length(pds_refresh_token_encrypted) = 0 OR get_byte(pds_refresh_token_encrypted, 0) <> $1)) + ORDER BY did + FOR UPDATE`, credentialcipher.Version) + if err != nil { + return nil, fmt.Errorf("lock communities with legacy credentials: %w", err) + } + defer func() { _ = rows.Close() }() + + var credentials []legacyCommunityCredentials + for rows.Next() { + var credential legacyCommunityCredentials + if err := rows.Scan( + &credential.did, &credential.password, &credential.accessToken, &credential.refreshToken, + ); err != nil { + return nil, fmt.Errorf("scan communities legacy credentials: %w", err) + } + credentials = append(credentials, credential) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate communities legacy credentials: %w", err) + } + return credentials, nil +} + +type legacyAggregatorCredentials struct { + did string + accessToken []byte + refreshToken []byte + dpopPrivateKey []byte +} + +func loadLegacyAggregatorCredentials(ctx context.Context, tx *sql.Tx) ([]legacyAggregatorCredentials, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT did, oauth_access_token_encrypted, oauth_refresh_token_encrypted, + oauth_dpop_private_key_encrypted + FROM aggregators + WHERE (oauth_access_token_encrypted IS NOT NULL AND + (octet_length(oauth_access_token_encrypted) = 0 OR get_byte(oauth_access_token_encrypted, 0) <> $1)) + OR (oauth_refresh_token_encrypted IS NOT NULL AND + (octet_length(oauth_refresh_token_encrypted) = 0 OR get_byte(oauth_refresh_token_encrypted, 0) <> $1)) + OR (oauth_dpop_private_key_encrypted IS NOT NULL AND + (octet_length(oauth_dpop_private_key_encrypted) = 0 OR get_byte(oauth_dpop_private_key_encrypted, 0) <> $1)) + ORDER BY did + FOR UPDATE`, credentialcipher.Version) + if err != nil { + return nil, fmt.Errorf("lock aggregators with legacy credentials: %w", err) + } + defer func() { _ = rows.Close() }() + + var credentials []legacyAggregatorCredentials + for rows.Next() { + var credential legacyAggregatorCredentials + if err := rows.Scan( + &credential.did, &credential.accessToken, &credential.refreshToken, &credential.dpopPrivateKey, + ); err != nil { + return nil, fmt.Errorf("scan aggregators legacy credentials: %w", err) + } + credentials = append(credentials, credential) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate aggregators legacy credentials: %w", err) + } + return credentials, nil +} + +type legacyCredential struct { + table string + column string + did string + context string + ciphertext []byte +} + +// reencryptLegacyCredential returns the replacement value as any so that an +// untyped nil binds as SQL NULL; lib/pq would send a nil []byte as empty bytea. +func reencryptLegacyCredential( + ctx context.Context, + tx *sql.Tx, + cipher *credentialcipher.Cipher, + credential legacyCredential, +) (any, bool, error) { + if credential.ciphertext == nil || (len(credential.ciphertext) > 0 && credential.ciphertext[0] == credentialcipher.Version) { + return nil, false, nil + } + if len(credential.ciphertext) == 0 { + return nil, true, nil + } + + var plaintext string + if err := tx.QueryRowContext(ctx, + `SELECT pgp_sym_decrypt($1::bytea, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))`, + credential.ciphertext).Scan(&plaintext); err != nil { + return nil, false, fmt.Errorf("decrypt %s.%s for DID %s: %w", credential.table, credential.column, credential.did, err) + } + + sealed, err := cipher.Encrypt(plaintext, credential.context) + if err != nil { + return nil, false, fmt.Errorf("encrypt %s.%s for DID %s: %w", credential.table, credential.column, credential.did, err) + } + return sealed, true, nil +} diff --git a/internal/db/postgres/credential_reencrypt_migration_test.go b/internal/db/postgres/credential_reencrypt_migration_test.go new file mode 100644 index 0000000..24f3fc1 --- /dev/null +++ b/internal/db/postgres/credential_reencrypt_migration_test.go @@ -0,0 +1,566 @@ +//go:build integration + +package postgres + +import ( + "bytes" + "context" + "database/sql" + "strings" + "testing" + + "Coves/internal/crypto/credentialcipher" + "Coves/internal/db/migrations" + "Coves/tests/testkit" + + "github.com/pressly/goose/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + legacyCredentialVersion = byte(0xc3) + appCredentialVersion = byte(0x01) +) + +func TestMigration046DownRestoresUsableEncryptionKey(t *testing.T) { + db := testkit.DB(t) + assert.False(t, credentialReencryptKeyTable(t, db).Valid, + "migration 046 Up must drop encryption_keys before its Down behavior can be tested") + require.EqualValues(t, 46, testkit.MigrateDownOne(t, db, 46)) + + table := credentialReencryptKeyTable(t, db) + require.True(t, table.Valid, "migration 046 Down must recreate encryption_keys") + + var keyCount, keyLength int + require.NoError(t, db.QueryRowContext(context.Background(), ` + SELECT COUNT(*), COALESCE(MAX(octet_length(key_data)), 0) + FROM encryption_keys + `).Scan(&keyCount, &keyLength)) + assert.Equal(t, 1, keyCount) + assert.Equal(t, credentialcipher.KeySize, keyLength) +} + +func TestMigration046RejectsLegacyCiphertext(t *testing.T) { + db := credentialReencryptVersion45Database(t) + ctx := context.Background() + did := credentialReencryptInsertCommunity(t, db, "migration-legacy") + credentialReencryptSeedLegacyCommunity(t, db, did, + "migration legacy password", nil, nil) + + provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.FS) + require.NoError(t, err) + _, err = provider.Up(ctx) + if assert.Error(t, err, "migration 046 must refuse to drop the only key for legacy ciphertext") { + assert.Contains(t, strings.ToLower(err.Error()), "re-encrypt") + } + assert.True(t, credentialReencryptKeyTable(t, db).Valid, + "a refused migration must leave encryption_keys available for recovery") +} + +func TestMigration046DropsEncryptionKeysAfterLegacyDataRemoved(t *testing.T) { + db := credentialReencryptVersion45Database(t) + ctx := context.Background() + did := credentialReencryptInsertCommunity(t, db, "migration-app-cipher") + cipher := credentialReencryptCipher(t) + ciphertext, err := cipher.Encrypt( + "already encrypted by the application", + "communities.pds_password_encrypted:"+did, + ) + require.NoError(t, err) + require.Equal(t, appCredentialVersion, ciphertext[0]) + _, err = db.ExecContext(ctx, + `UPDATE communities SET pds_password_encrypted = $2 WHERE did = $1`, did, ciphertext) + require.NoError(t, err) + + testkit.MigrateUp(t, db) + assert.False(t, credentialReencryptKeyTable(t, db).Valid) +} + +func TestCredentialReencryptRewritesLegacyRowsAndIsIdempotent(t *testing.T) { + db := credentialReencryptVersion45Database(t) + ctx := context.Background() + cipher := credentialReencryptCipher(t) + + communityAllDID := credentialReencryptInsertCommunity(t, db, "community-all") + communityPasswordDID := credentialReencryptInsertCommunity(t, db, "community-password") + aggregatorAllDID := credentialReencryptInsertAggregator(t, db, "aggregator-all") + aggregatorEmptyDID := credentialReencryptInsertAggregator(t, db, "aggregator-empty") + + communityPassword := "community all password" + communityAccess := "community all access" + communityRefresh := "community all refresh" + passwordOnly := "community password only" + aggregatorAccess := "aggregator all access" + aggregatorRefresh := "aggregator all refresh" + aggregatorDPoP := "aggregator all dpop" + credentialReencryptSeedLegacyCommunity(t, db, communityAllDID, + communityPassword, &communityAccess, &communityRefresh) + credentialReencryptSeedLegacyCommunity(t, db, communityPasswordDID, + passwordOnly, nil, nil) + credentialReencryptSeedLegacyAggregator(t, db, aggregatorAllDID, + &aggregatorAccess, &aggregatorRefresh, &aggregatorDPoP) + + // Counts are rows with at least one value rewritten, not credential columns: + // three values in one row still contribute one to its table's count. + report, err := ReencryptLegacyCredentials(ctx, db, cipher, false) + require.NoError(t, err) + assert.Equal(t, ReencryptReport{CommunitiesRewritten: 2, AggregatorsRewritten: 1}, report) + + communityAll := credentialReencryptCommunityBytes(t, db, communityAllDID) + credentialReencryptAssertAppCiphertext(t, cipher, communityAll.password, + "communities.pds_password_encrypted:"+communityAllDID, communityPassword) + credentialReencryptAssertAppCiphertext(t, cipher, communityAll.access, + "communities.pds_access_token_encrypted:"+communityAllDID, communityAccess) + credentialReencryptAssertAppCiphertext(t, cipher, communityAll.refresh, + "communities.pds_refresh_token_encrypted:"+communityAllDID, communityRefresh) + + communityPasswordOnly := credentialReencryptCommunityBytes(t, db, communityPasswordDID) + credentialReencryptAssertAppCiphertext(t, cipher, communityPasswordOnly.password, + "communities.pds_password_encrypted:"+communityPasswordDID, passwordOnly) + assert.Nil(t, communityPasswordOnly.access) + assert.Nil(t, communityPasswordOnly.refresh) + + aggregatorAll := credentialReencryptAggregatorBytes(t, db, aggregatorAllDID) + credentialReencryptAssertAppCiphertext(t, cipher, aggregatorAll.access, + "aggregators.oauth_access_token_encrypted:"+aggregatorAllDID, aggregatorAccess) + credentialReencryptAssertAppCiphertext(t, cipher, aggregatorAll.refresh, + "aggregators.oauth_refresh_token_encrypted:"+aggregatorAllDID, aggregatorRefresh) + credentialReencryptAssertAppCiphertext(t, cipher, aggregatorAll.dpop, + "aggregators.oauth_dpop_private_key_encrypted:"+aggregatorAllDID, aggregatorDPoP) + + aggregatorEmpty := credentialReencryptAggregatorBytes(t, db, aggregatorEmptyDID) + assert.Nil(t, aggregatorEmpty.access) + assert.Nil(t, aggregatorEmpty.refresh) + assert.Nil(t, aggregatorEmpty.dpop) + + secondReport, err := ReencryptLegacyCredentials(ctx, db, cipher, false) + require.NoError(t, err) + assert.Equal(t, ReencryptReport{}, secondReport) + assert.Equal(t, communityAll, credentialReencryptCommunityBytes(t, db, communityAllDID)) + assert.Equal(t, communityPasswordOnly, credentialReencryptCommunityBytes(t, db, communityPasswordDID)) + assert.Equal(t, aggregatorAll, credentialReencryptAggregatorBytes(t, db, aggregatorAllDID)) + assert.Equal(t, aggregatorEmpty, credentialReencryptAggregatorBytes(t, db, aggregatorEmptyDID)) +} + +func TestCredentialReencryptLeavesAppCipherRowsAndRewritesLegacySiblings(t *testing.T) { + db := credentialReencryptVersion45Database(t) + ctx := context.Background() + cipher := credentialReencryptCipher(t) + appDID := credentialReencryptInsertCommunity(t, db, "mixed-app") + legacyDID := credentialReencryptInsertCommunity(t, db, "mixed-legacy") + + const appPassword = "password already using application encryption" + appBytes, err := cipher.Encrypt(appPassword, + "communities.pds_password_encrypted:"+appDID) + require.NoError(t, err) + _, err = db.ExecContext(ctx, + `UPDATE communities SET pds_password_encrypted = $2 WHERE did = $1`, appDID, appBytes) + require.NoError(t, err) + const legacyPassword = "password still using pgcrypto" + credentialReencryptSeedLegacyCommunity(t, db, legacyDID, legacyPassword, nil, nil) + + report, err := ReencryptLegacyCredentials(ctx, db, cipher, false) + require.NoError(t, err) + assert.Equal(t, ReencryptReport{CommunitiesRewritten: 1}, report) + assert.Equal(t, appBytes, credentialReencryptCommunityBytes(t, db, appDID).password, + "already migrated ciphertext must not be resealed with a new nonce") + credentialReencryptAssertAppCiphertext(t, cipher, + credentialReencryptCommunityBytes(t, db, legacyDID).password, + "communities.pds_password_encrypted:"+legacyDID, legacyPassword) +} + +func TestCredentialReencryptRejectsEphemeralKeyWhenLegacyRowsExist(t *testing.T) { + db := credentialReencryptVersion45Database(t) + ctx := context.Background() + cipher := credentialReencryptCipher(t) + did := credentialReencryptInsertCommunity(t, db, "ephemeral-legacy") + credentialReencryptSeedLegacyCommunity(t, db, did, "legacy password", nil, nil) + before := credentialReencryptCommunityBytes(t, db, did) + + report, err := ReencryptLegacyCredentials(ctx, db, cipher, true) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "ENCRYPTION_KEY") + } + assert.Equal(t, ReencryptReport{}, report) + assert.Equal(t, before, credentialReencryptCommunityBytes(t, db, did)) + assert.Equal(t, legacyCredentialVersion, + credentialReencryptCommunityBytes(t, db, did).password[0]) + assert.True(t, credentialReencryptKeyTable(t, db).Valid) +} + +func TestCredentialReencryptAllowsEphemeralKeyWithoutLegacyRows(t *testing.T) { + db := credentialReencryptVersion45Database(t) + cipher := credentialReencryptCipher(t) + did := credentialReencryptInsertCommunity(t, db, "ephemeral-app") + ciphertext, err := cipher.Encrypt("already migrated", + "communities.pds_password_encrypted:"+did) + require.NoError(t, err) + _, err = db.ExecContext(context.Background(), + `UPDATE communities SET pds_password_encrypted = $2 WHERE did = $1`, did, ciphertext) + require.NoError(t, err) + + report, err := ReencryptLegacyCredentials(context.Background(), db, cipher, true) + require.NoError(t, err) + assert.Equal(t, ReencryptReport{}, report) + assert.Equal(t, ciphertext, credentialReencryptCommunityBytes(t, db, did).password) +} + +func TestCredentialReencryptRollsBackEveryRowOnCorruptLegacyCiphertext(t *testing.T) { + db := credentialReencryptVersion45Database(t) + ctx := context.Background() + cipher := credentialReencryptCipher(t) + validDID := credentialReencryptInsertCommunity(t, db, "transaction-valid") + corruptDID := credentialReencryptInsertAggregator(t, db, "transaction-corrupt") + credentialReencryptSeedLegacyCommunity(t, db, validDID, "valid sibling password", nil, nil) + + const corruptPlaintext = "plaintext-that-must-not-leak" + _, err := db.ExecContext(ctx, ` + UPDATE aggregators + SET oauth_dpop_private_key_encrypted = pgp_sym_encrypt($2, 'some-other-key') + WHERE did = $1 + `, corruptDID, corruptPlaintext) + require.NoError(t, err) + corruptBefore := credentialReencryptAggregatorBytes(t, db, corruptDID) + credentialReencryptRequireLegacy(t, corruptBefore.dpop) + validBefore := credentialReencryptCommunityBytes(t, db, validDID) + + report, err := ReencryptLegacyCredentials(ctx, db, cipher, false) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "aggregators") + assert.Contains(t, err.Error(), corruptDID) + assert.NotContains(t, err.Error(), corruptPlaintext) + } + assert.Equal(t, ReencryptReport{}, report) + assert.Equal(t, validBefore, credentialReencryptCommunityBytes(t, db, validDID), + "a later corrupt row must roll back an earlier valid conversion") + assert.Equal(t, corruptBefore, credentialReencryptAggregatorBytes(t, db, corruptDID)) + assert.Equal(t, legacyCredentialVersion, + credentialReencryptCommunityBytes(t, db, validDID).password[0]) +} + +func TestCredentialReencryptIsNoOpWithoutEncryptionKeysTable(t *testing.T) { + db := testkit.DB(t) + assert.False(t, credentialReencryptKeyTable(t, db).Valid, + "the version-46 template must not contain encryption_keys") + + report, err := ReencryptLegacyCredentials( + context.Background(), db, credentialReencryptCipher(t), false) + require.NoError(t, err) + assert.Equal(t, ReencryptReport{}, report) +} + +func TestCredentialReencryptHandsOffToMigration046(t *testing.T) { + db := credentialReencryptVersion45Database(t) + ctx := context.Background() + cipher := credentialReencryptCipher(t) + did := credentialReencryptInsertCommunity(t, db, "handoff") + const password = "handoff legacy password" + credentialReencryptSeedLegacyCommunity(t, db, did, password, nil, nil) + + report, err := ReencryptLegacyCredentials(ctx, db, cipher, false) + require.NoError(t, err) + require.Equal(t, ReencryptReport{CommunitiesRewritten: 1}, report) + credentialReencryptAssertAppCiphertext(t, cipher, + credentialReencryptCommunityBytes(t, db, did).password, + "communities.pds_password_encrypted:"+did, password) + + testkit.MigrateUp(t, db) + assert.False(t, credentialReencryptKeyTable(t, db).Valid) +} + +func TestCredentialReencryptConvertsCommunityBeforeAggregatorCredentialColumnsExist(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.FS) + require.NoError(t, err) + _, err = provider.DownTo(ctx, 24) + require.NoError(t, err) + require.True(t, credentialReencryptKeyTable(t, db).Valid) + + var aggregatorCredentialColumnCount int + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'aggregators' + AND column_name IN ( + 'oauth_access_token_encrypted', + 'oauth_refresh_token_encrypted', + 'oauth_dpop_private_key_encrypted' + ) + `).Scan(&aggregatorCredentialColumnCount)) + require.Zero(t, aggregatorCredentialColumnCount) + + did := credentialReencryptInsertCommunity(t, db, "pre-025") + const password = "legacy password before aggregator encryption" + credentialReencryptSeedLegacyCommunity(t, db, did, password, nil, nil) + cipher := credentialReencryptCipher(t) + + report, err := ReencryptLegacyCredentials(ctx, db, cipher, false) + require.NoError(t, err) + assert.Equal(t, ReencryptReport{CommunitiesRewritten: 1}, report) + credentialReencryptAssertAppCiphertext(t, cipher, + credentialReencryptCommunityBytes(t, db, did).password, + "communities.pds_password_encrypted:"+did, password) + + testkit.MigrateUp(t, db) + assert.False(t, credentialReencryptKeyTable(t, db).Valid) +} + +func TestCredentialReencryptSkipsUnavailableColumnsAtLowestReachableSchema(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.FS) + require.NoError(t, err) + _, err = provider.DownTo(ctx, 15) + require.NoError(t, err) + require.True(t, credentialReencryptKeyTable(t, db).Valid) + + var aggregatorCredentialColumnCount int + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'aggregators' + AND column_name IN ( + 'oauth_access_token_encrypted', + 'oauth_refresh_token_encrypted', + 'oauth_dpop_private_key_encrypted' + ) + `).Scan(&aggregatorCredentialColumnCount)) + require.Zero(t, aggregatorCredentialColumnCount) + + report, err := ReencryptLegacyCredentials(ctx, db, credentialReencryptCipher(t), false) + require.NoError(t, err) + assert.Equal(t, ReencryptReport{}, report) +} + +func TestCredentialReencryptNormalizesZeroLengthCredentialsToNull(t *testing.T) { + db := credentialReencryptVersion45Database(t) + ctx := context.Background() + communityDID := credentialReencryptInsertCommunity(t, db, "zero-length") + aggregatorDID := credentialReencryptInsertAggregator(t, db, "zero-length") + _, err := db.ExecContext(ctx, + `UPDATE communities SET pds_password_encrypted = ''::bytea WHERE did = $1`, communityDID) + require.NoError(t, err) + _, err = db.ExecContext(ctx, + `UPDATE aggregators SET oauth_refresh_token_encrypted = ''::bytea WHERE did = $1`, aggregatorDID) + require.NoError(t, err) + communityBefore := credentialReencryptCommunityBytes(t, db, communityDID) + aggregatorBefore := credentialReencryptAggregatorBytes(t, db, aggregatorDID) + require.NotNil(t, communityBefore.password) + require.Empty(t, communityBefore.password) + require.NotNil(t, aggregatorBefore.refresh) + require.Empty(t, aggregatorBefore.refresh) + + report, err := ReencryptLegacyCredentials(ctx, db, credentialReencryptCipher(t), false) + require.NoError(t, err) + assert.Equal(t, ReencryptReport{CommunitiesRewritten: 1, AggregatorsRewritten: 1}, report) + assert.Nil(t, credentialReencryptCommunityBytes(t, db, communityDID).password) + assert.Nil(t, credentialReencryptAggregatorBytes(t, db, aggregatorDID).refresh) + + testkit.MigrateUp(t, db) + assert.False(t, credentialReencryptKeyTable(t, db).Valid) +} + +func TestCredentialReencryptRejectsLegacyRowsAfterEncryptionKeysDropped(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + require.False(t, credentialReencryptKeyTable(t, db).Valid) + did := credentialReencryptInsertCommunity(t, db, "orphaned-legacy") + _, err := db.ExecContext(ctx, ` + UPDATE communities + SET pds_access_token_encrypted = pgp_sym_encrypt('stale', 'unrelated-key') + WHERE did = $1 + `, did) + require.NoError(t, err) + before := credentialReencryptCommunityBytes(t, db, did) + credentialReencryptRequireLegacy(t, before.access) + + report, err := ReencryptLegacyCredentials(ctx, db, credentialReencryptCipher(t), false) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "encryption_keys") + assert.Contains(t, strings.ToLower(err.Error()), "communities") + assert.Equal(t, ReencryptReport{}, report) + assert.Equal(t, before, credentialReencryptCommunityBytes(t, db, did)) +} + +func credentialReencryptVersion45Database(t *testing.T) *sql.DB { + t.Helper() + db := testkit.DB(t) + require.EqualValues(t, 46, testkit.MigrateDownOne(t, db, 46)) + return db +} + +func credentialReencryptCipher(t *testing.T) *credentialcipher.Cipher { + t.Helper() + cipher, err := credentialcipher.New(bytes.Repeat([]byte{0x71}, credentialcipher.KeySize)) + require.NoError(t, err) + return cipher +} + +func credentialReencryptKeyTable(t *testing.T, db *sql.DB) sql.NullString { + t.Helper() + var table sql.NullString + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT to_regclass('encryption_keys')`).Scan(&table)) + return table +} + +func credentialReencryptInsertCommunity(t *testing.T, db *sql.DB, label string) string { + t.Helper() + id := testkit.UniqueID(t) + did := "did:plc:" + id + _, err := db.ExecContext(context.Background(), ` + INSERT INTO communities ( + did, handle, name, owner_did, created_by_did, hosted_by_did, created_at + ) VALUES ($1, $2, $3, $1, $1, $1, NOW()) + `, did, "c-"+label+"-"+id+".coves.social", "credential-"+label) + require.NoError(t, err) + return did +} + +func credentialReencryptInsertAggregator(t *testing.T, db *sql.DB, label string) string { + t.Helper() + id := testkit.UniqueID(t) + did := "did:plc:" + id + _, err := db.ExecContext(context.Background(), ` + INSERT INTO aggregators (did, display_name, record_uri, record_cid) + VALUES ($1, $2, $3, $4) + `, did, "Credential "+label, "at://"+did+"/social.coves.aggregator.service/self", "bafy"+id) + require.NoError(t, err) + return did +} + +type credentialReencryptCommunityRaw struct { + password []byte + access []byte + refresh []byte +} + +func credentialReencryptCommunityBytes(t *testing.T, db *sql.DB, did string) credentialReencryptCommunityRaw { + t.Helper() + var raw credentialReencryptCommunityRaw + require.NoError(t, db.QueryRowContext(context.Background(), ` + SELECT pds_password_encrypted, pds_access_token_encrypted, pds_refresh_token_encrypted + FROM communities + WHERE did = $1 + `, did).Scan(&raw.password, &raw.access, &raw.refresh)) + return raw +} + +type credentialReencryptAggregatorRaw struct { + access []byte + refresh []byte + dpop []byte +} + +func credentialReencryptAggregatorBytes(t *testing.T, db *sql.DB, did string) credentialReencryptAggregatorRaw { + t.Helper() + var raw credentialReencryptAggregatorRaw + require.NoError(t, db.QueryRowContext(context.Background(), ` + SELECT oauth_access_token_encrypted, oauth_refresh_token_encrypted, + oauth_dpop_private_key_encrypted + FROM aggregators + WHERE did = $1 + `, did).Scan(&raw.access, &raw.refresh, &raw.dpop)) + return raw +} + +func credentialReencryptSeedLegacyCommunity( + t *testing.T, + db *sql.DB, + did string, + password string, + accessToken *string, + refreshToken *string, +) { + t.Helper() + _, err := db.ExecContext(context.Background(), ` + UPDATE communities SET + pds_password_encrypted = pgp_sym_encrypt($2, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)), + pds_access_token_encrypted = CASE WHEN $3::text IS NULL THEN NULL ELSE pgp_sym_encrypt($3::text, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) END, + pds_refresh_token_encrypted = CASE WHEN $4::text IS NULL THEN NULL ELSE pgp_sym_encrypt($4::text, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) END + WHERE did = $1 + `, did, password, credentialReencryptNullableString(accessToken), credentialReencryptNullableString(refreshToken)) + require.NoError(t, err) + raw := credentialReencryptCommunityBytes(t, db, did) + credentialReencryptRequireLegacy(t, raw.password) + if accessToken == nil { + require.Nil(t, raw.access) + } else { + credentialReencryptRequireLegacy(t, raw.access) + } + if refreshToken == nil { + require.Nil(t, raw.refresh) + } else { + credentialReencryptRequireLegacy(t, raw.refresh) + } +} + +func credentialReencryptSeedLegacyAggregator( + t *testing.T, + db *sql.DB, + did string, + accessToken *string, + refreshToken *string, + dpopPrivateKey *string, +) { + t.Helper() + _, err := db.ExecContext(context.Background(), ` + UPDATE aggregators SET + oauth_access_token_encrypted = CASE WHEN $2::text IS NULL THEN NULL ELSE pgp_sym_encrypt($2::text, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) END, + oauth_refresh_token_encrypted = CASE WHEN $3::text IS NULL THEN NULL ELSE pgp_sym_encrypt($3::text, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) END, + oauth_dpop_private_key_encrypted = CASE WHEN $4::text IS NULL THEN NULL ELSE pgp_sym_encrypt($4::text, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) END + WHERE did = $1 + `, did, credentialReencryptNullableString(accessToken), credentialReencryptNullableString(refreshToken), + credentialReencryptNullableString(dpopPrivateKey)) + require.NoError(t, err) + raw := credentialReencryptAggregatorBytes(t, db, did) + for _, credential := range []struct { + value *string + ciphertext []byte + }{ + {value: accessToken, ciphertext: raw.access}, + {value: refreshToken, ciphertext: raw.refresh}, + {value: dpopPrivateKey, ciphertext: raw.dpop}, + } { + if credential.value == nil { + require.Nil(t, credential.ciphertext) + } else { + credentialReencryptRequireLegacy(t, credential.ciphertext) + } + } +} + +func credentialReencryptNullableString(value *string) any { + if value == nil { + return nil + } + return *value +} + +func credentialReencryptRequireLegacy(t *testing.T, ciphertext []byte) { + t.Helper() + require.NotEmpty(t, ciphertext, "legacy seed unexpectedly stored NULL") + require.Equal(t, legacyCredentialVersion, ciphertext[0], + "fixture must prove it seeded pgcrypto data rather than creating a false-green NULL") +} + +func credentialReencryptAssertAppCiphertext( + t *testing.T, + cipher *credentialcipher.Cipher, + ciphertext []byte, + credentialContext string, + wantPlaintext string, +) { + t.Helper() + if assert.NotEmpty(t, ciphertext) { + assert.Equal(t, appCredentialVersion, ciphertext[0]) + } + plaintext, err := cipher.Decrypt(ciphertext, credentialContext) + if assert.NoError(t, err) { + assert.Equal(t, wantPlaintext, plaintext) + } +} diff --git a/internal/db/postgres/future_comment_created_at_migration_test.go b/internal/db/postgres/future_comment_created_at_migration_test.go index 2d51421..37e1b57 100644 --- a/internal/db/postgres/future_comment_created_at_migration_test.go +++ b/internal/db/postgres/future_comment_created_at_migration_test.go @@ -18,6 +18,8 @@ func TestMigration041_ClampsFutureCommentCreatedAt(t *testing.T) { t.Parallel() db := testkit.DB(t) + require.EqualValues(t, 46, testkit.MigrateDownOne(t, db, 46), + "046 (drop encryption_keys) sits on top of 045 and must be rolled back first") require.EqualValues(t, 45, testkit.MigrateDownOne(t, db, 45), "045 (the community subscriber recount) sits on top of 044 and must be rolled back first") require.EqualValues(t, 44, testkit.MigrateDownOne(t, db, 44), diff --git a/internal/db/postgres/post_visibility_test.go b/internal/db/postgres/post_visibility_test.go index 2d36271..de30025 100644 --- a/internal/db/postgres/post_visibility_test.go +++ b/internal/db/postgres/post_visibility_test.go @@ -3,6 +3,7 @@ package postgres import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "database/sql" "testing" @@ -479,9 +480,9 @@ func TestProfileStatsVisibility_PostCountExcludesNonAccepted(t *testing.T) { // that a reviewer showed could each be deleted from that copy with no test // noticing: // -// 1. the collection check — drop it and a failed-seed postv2 counts -// 2. the pinned-CID equality — drop it and a §5.5 drifted post counts -// 3. the community join half — drop it and another community's acceptance counts +// 1. the collection check — drop it and a failed-seed postv2 counts +// 2. the pinned-CID equality — drop it and a §5.5 drifted post counts +// 3. the community join half — drop it and another community's acceptance counts // // The count now calls visiblePostsJoin, so a copy cannot drift; this is the // assertion that says so out loud, and it is the one that fails if anyone @@ -691,7 +692,7 @@ func TestGetCommentsVisibility_HeaderIsAdmissionAndDeleteAware(t *testing.T) { NewCommentRepository(db), NewUserRepository(db), NewPostRepository(db), - NewCommunityRepository(db), + NewCommunityRepository(db, credentialciphertest.Fixed()), nil, nil, ) @@ -827,7 +828,7 @@ func TestCommunityPostCountVisibility_MatchesWhatTheFeedRenders(t *testing.T) { "the fixture no longer means what this test says it means: expected exactly the accepted postv2 and the "+ "legacy post to be publicly visible, got %v", visible) - got, err := NewCommunityRepository(db).GetByDID(ctx, community) + got, err := NewCommunityRepository(db, credentialciphertest.Fixed()).GetByDID(ctx, community) require.NoError(t, err) assert.Equalf(t, len(visible), got.PostCount, "community.postCount (%d) disagrees with what the community feed serves (%d posts: %v). It must be the SAME "+ @@ -849,7 +850,7 @@ func TestCommunityPostCountVisibility_MatchesWhatTheFeedRenders(t *testing.T) { // wire value rather than about a query the test wrote itself. func communityPostCount(t *testing.T, ctx context.Context, db *sql.DB, communityDID string) int { t.Helper() - community, err := NewCommunityRepository(db).GetByDID(ctx, communityDID) + community, err := NewCommunityRepository(db, credentialciphertest.Fixed()).GetByDID(ctx, communityDID) require.NoError(t, err) return community.PostCount } @@ -1087,7 +1088,7 @@ func TestCommunityListVisibility_ActiveSortOrdersByVisiblePosts(t *testing.T) { seedVisibilityAdmission(t, db, quiet, uri, posts.AdmissionStatusPending, "", "") } - listed, err := NewCommunityRepository(db).List(ctx, communities.ListCommunitiesRequest{ + listed, err := NewCommunityRepository(db, credentialciphertest.Fixed()).List(ctx, communities.ListCommunitiesRequest{ Sort: "active", Limit: 100, }) require.NoError(t, err) @@ -1203,7 +1204,7 @@ func TestActorCommentsVisibility_RootIsReferenceOnly(t *testing.T) { require.NoError(t, err) service := comments.NewCommentServiceWithPDSFactory( - NewCommentRepository(db), NewUserRepository(db), NewPostRepository(db), NewCommunityRepository(db), nil, nil, + NewCommentRepository(db), NewUserRepository(db), NewPostRepository(db), NewCommunityRepository(db, credentialciphertest.Fixed()), nil, nil, ) resp, err := service.GetActorComments(ctx, &comments.GetActorCommentsRequest{ActorDID: actor, Limit: 50}) diff --git a/internal/db/postgres/rematerialize_ledger_schema_test.go b/internal/db/postgres/rematerialize_ledger_schema_test.go index cfa1c60..89378ce 100644 --- a/internal/db/postgres/rematerialize_ledger_schema_test.go +++ b/internal/db/postgres/rematerialize_ledger_schema_test.go @@ -200,6 +200,8 @@ func TestRematerializeLedgerMigration_RollsBack(t *testing.T) { // top of 037 and come off first, one asserted step at a time. Asserting which // migration rolled back is what keeps this pointed at 037's Down rather than // drifting onto a newer one later. + require.EqualValues(t, 46, testkit.MigrateDownOne(t, db, 46), + "046 (drop encryption_keys) sits on top of 045 and must be rolled back first") require.EqualValues(t, 45, testkit.MigrateDownOne(t, db, 45), "045 (the community subscriber recount) sits on top of 044 and must be rolled back first") require.EqualValues(t, 44, testkit.MigrateDownOne(t, db, 44), diff --git a/internal/db/postgres/user_repo_delete_subscriptions_test.go b/internal/db/postgres/user_repo_delete_subscriptions_test.go index 1d9fff7..18acb83 100644 --- a/internal/db/postgres/user_repo_delete_subscriptions_test.go +++ b/internal/db/postgres/user_repo_delete_subscriptions_test.go @@ -3,6 +3,7 @@ package postgres import ( + "Coves/internal/crypto/credentialcipher/credentialciphertest" "context" "sync" "testing" @@ -29,7 +30,7 @@ func TestUserRepo_Delete_ConcurrentDeletionsSharingCommunitiesDoNotDeadlock(t *t db := testkit.DB(t) ctx := context.Background() - communityRepo := NewCommunityRepository(db) + communityRepo := NewCommunityRepository(db, credentialciphertest.Fixed()) userRepo := NewUserRepository(db) const communityCount = 6 diff --git a/internal/db/postgres/vote_drift_recount_migration_test.go b/internal/db/postgres/vote_drift_recount_migration_test.go index 5e05a74..1c79568 100644 --- a/internal/db/postgres/vote_drift_recount_migration_test.go +++ b/internal/db/postgres/vote_drift_recount_migration_test.go @@ -80,6 +80,8 @@ func TestMigration040_RecountsVoteDriftAndSweepsLegacyOrphans(t *testing.T) { // point of a repair migration and cannot be observed by seeding after it has // run. Asserting the version that came off is the tripwire that keeps this // pointed at 040 when later migrations land. + require.EqualValues(t, 46, testkit.MigrateDownOne(t, db, 46), + "046 (drop encryption_keys) sits on top of 045 and must be rolled back first") require.EqualValues(t, 45, testkit.MigrateDownOne(t, db, 45), "045 (the community subscriber recount) sits on top of 044 and must be rolled back first") require.EqualValues(t, 44, testkit.MigrateDownOne(t, db, 44), diff --git a/tests/live/post_unfurl_test.go b/tests/live/post_unfurl_test.go index 3df7d07..cdd5cab 100644 --- a/tests/live/post_unfurl_test.go +++ b/tests/live/post_unfurl_test.go @@ -9,6 +9,7 @@ import ( "Coves/internal/core/posts" "Coves/internal/core/unfurl" "Coves/internal/core/users" + "Coves/internal/crypto/credentialcipher/credentialciphertest" "Coves/internal/db/postgres" "Coves/tests/testkit" "context" @@ -195,7 +196,7 @@ func TestPostUnfurl_UserProvidedMetadata(t *testing.T) { // Setup userRepo := postgres.NewUserRepository(db) - communityRepo := postgres.NewCommunityRepository(db) + communityRepo := postgres.NewCommunityRepository(db, credentialciphertest.Fixed()) postRepo := postgres.NewPostRepository(db) unfurlRepo := unfurl.NewRepository(db) diff --git a/tests/testkit/db.go b/tests/testkit/db.go index 456195a..2aad01e 100644 --- a/tests/testkit/db.go +++ b/tests/testkit/db.go @@ -883,13 +883,13 @@ func newCloneName() string { return newSweepableName(ClonePrefix) } // DB returns a private, fully migrated Postgres database for this test. // // The database is a clone of the migrated template, so it starts with the -// production schema and exactly the rows the migrations seed — which is not -// none: 006 and 025 insert encryption keys, and code that decrypts community -// or aggregator credentials depends on them being there. "Empty" means no rows -// from any other test. It is dropped when the test finishes; nothing -// the test writes is visible to any other test, and no cleanup code is needed -// (or wanted — an explicit DELETE in a test body is a sign the test is still -// assuming a shared database). +// production schema at its latest migration. Credential keys are not seeded: +// the AES-256-GCM key belongs to the application process, and migration 046 +// removes the legacy encryption_keys table. "Empty" means no application rows +// from any other test. It is dropped when the test finishes; nothing the test +// writes is visible to any other test, and no cleanup code is needed (or wanted +// — an explicit DELETE in a test body is a sign the test is still assuming a +// shared database). // // A missing or unreachable Postgres fails the test. Tests do not skip on absent // infrastructure: if the suite was invoked, the infrastructure was requested.