Files
silo-server/internal/webhooksync/repo.go
9e29e7b330 feat(security): encrypt server-owned credentials at rest (#45) (#95)
* feat(security): encrypt server-owned credentials at rest

Introduce AES-256-GCM at-rest encryption (HKDF-derived from a required
SECRET_KEY) for server-owned credentials, with row-bound AAD, a versioned
enc:v1: envelope, and an idempotent startup backfill.

- internal/secret: cipher + RowAAD/SettingsAAD + the startup backfill engine.
- SECRET_KEY required at bootstrap; cipher threaded as an explicit dependency.
- server_settings: EncryptedSettingsRepo decorator over the audited
  SensitiveSettingKeys (also drives admin redaction); the config watcher and
  watch-sync settings reads decrypt too.
- Arr keys inline-encrypted; the ambiguous SecretResolver indirection removed
  from requests/autoscan.
- Per-table columns encrypted: subtitles, watch-sync, webhook-sync (not
  webhook_secret), history-import, and the jellycompat session's bridged Silo
  access/refresh tokens.
- Startup backfill (resolve-then-encrypt for arr refs) is best-effort and
  primary-node gated.

Equality-looked-up secrets and plugin_runtime_configs.config_value are out of
scope (need hashing / cross-repo design) — see
docs/architecture/secret-encryption.md.

Refs #45

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(compose): require SECRET_KEY in docker-compose

The server now fatals without SECRET_KEY, so the integrated service (and the
commented distributed proxy/transcode examples) pass it through with a
fail-fast guard matching the existing MEDIA_ROOT pattern. Distributed worker
nodes must use the SAME key as the primary to decrypt shared data.
Generate with: openssl rand -base64 48.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): encrypt history import session credentials

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:25:48 -04:00

385 lines
15 KiB
Go

package webhooksync
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/secret"
)
var ErrConnectionNotFound = errors.New("webhook sync connection not found")
type Repository struct {
pool *pgxpool.Pool
cipher *secret.Cipher
}
func NewRepository(pool *pgxpool.Pool, cipher *secret.Cipher) *Repository {
return &Repository{pool: pool, cipher: cipher}
}
// webhookTokenAAD binds a webhook_sync_connections access_token ciphertext to
// its row. Defined at package scope so the secret package reference is isolated
// from GetConnectionBySecret, whose parameter shadows the import name.
// (webhook_secret is NOT encrypted — it is matched by exact value in
// GetConnectionBySecret and is carved out for a separate hashing follow-up.)
func webhookTokenAAD(id string) string {
return secret.RowAAD("webhook_sync_connections", "access_token", id)
}
func (r *Repository) ProfileExistsForUser(ctx context.Context, userID int, profileID string) (bool, error) {
var exists bool
if err := r.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM user_profiles WHERE user_id = $1 AND id = $2)`,
userID, profileID,
).Scan(&exists); err != nil {
return false, fmt.Errorf("checking profile ownership: %w", err)
}
return exists, nil
}
func (r *Repository) CreateConnection(ctx context.Context, conn Connection) (*Connection, error) {
accessToken, err := r.cipher.Encrypt(conn.AccessToken, webhookTokenAAD(conn.ID))
if err != nil {
return nil, fmt.Errorf("encrypt webhook access token: %w", err)
}
row := r.pool.QueryRow(ctx, `
INSERT INTO webhook_sync_connections (
id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id, webhook_secret
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id,
webhook_secret, account_discovery_available,
last_webhook_received_at, last_webhook_error_at, COALESCE(last_webhook_error_message, ''),
created_at, updated_at`,
conn.ID, conn.UserID, conn.Provider, conn.ServerID, conn.ServerName, conn.BaseURL, accessToken, conn.DefaultProfileID, conn.WebhookSecret,
)
return r.scanConnection(row)
}
func (r *Repository) ListConnections(ctx context.Context, userID int) ([]Connection, error) {
rows, err := r.pool.Query(ctx, `
SELECT c.id, c.user_id, c.provider, c.server_id, c.server_name, c.base_url, c.access_token, c.default_profile_id,
c.webhook_secret, c.account_discovery_available,
c.last_webhook_received_at, c.last_webhook_error_at, COALESCE(c.last_webhook_error_message, ''),
c.created_at, c.updated_at,
COUNT(m.id)::integer AS user_count
FROM webhook_sync_connections c
LEFT JOIN webhook_sync_profile_mappings m ON m.connection_id = c.id
WHERE c.user_id = $1
GROUP BY c.id
ORDER BY c.created_at DESC`, userID)
if err != nil {
return nil, fmt.Errorf("listing webhook sync connections: %w", err)
}
defer rows.Close()
var out []Connection
for rows.Next() {
var c Connection
if err := rows.Scan(
&c.ID, &c.UserID, &c.Provider, &c.ServerID, &c.ServerName, &c.BaseURL, &c.AccessToken, &c.DefaultProfileID,
&c.WebhookSecret, &c.AccountDiscoveryAvailable,
&c.LastWebhookReceivedAt, &c.LastWebhookErrorAt, &c.LastWebhookErrorMessage,
&c.CreatedAt, &c.UpdatedAt, &c.UserCount,
); err != nil {
return nil, fmt.Errorf("scanning webhook sync connection: %w", err)
}
if c.AccessToken, err = r.cipher.DecryptIfEncrypted(c.AccessToken, webhookTokenAAD(c.ID)); err != nil {
return nil, fmt.Errorf("decrypt webhook access token: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating webhook sync connections: %w", err)
}
return out, nil
}
func (r *Repository) GetConnection(ctx context.Context, userID int, id string) (*Connection, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id,
webhook_secret, account_discovery_available,
last_webhook_received_at, last_webhook_error_at, COALESCE(last_webhook_error_message, ''),
created_at, updated_at
FROM webhook_sync_connections
WHERE id = $1 AND user_id = $2`, id, userID)
return r.scanConnection(row)
}
func (r *Repository) GetConnectionBySecret(ctx context.Context, secret string) (*Connection, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id,
webhook_secret, account_discovery_available,
last_webhook_received_at, last_webhook_error_at, COALESCE(last_webhook_error_message, ''),
created_at, updated_at
FROM webhook_sync_connections
WHERE webhook_secret = $1`, secret)
return r.scanConnection(row)
}
func (r *Repository) scanConnection(row pgx.Row) (*Connection, error) {
var c Connection
if err := row.Scan(
&c.ID, &c.UserID, &c.Provider, &c.ServerID, &c.ServerName, &c.BaseURL, &c.AccessToken, &c.DefaultProfileID,
&c.WebhookSecret, &c.AccountDiscoveryAvailable,
&c.LastWebhookReceivedAt, &c.LastWebhookErrorAt, &c.LastWebhookErrorMessage,
&c.CreatedAt, &c.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrConnectionNotFound
}
return nil, fmt.Errorf("scanning webhook sync connection: %w", err)
}
// Decrypt the access token (read-path). webhook_secret is intentionally left
// as-is (equality-looked-up; carved out for the hashing follow-up).
token, err := r.cipher.DecryptIfEncrypted(c.AccessToken, webhookTokenAAD(c.ID))
if err != nil {
return nil, fmt.Errorf("decrypt webhook access token: %w", err)
}
c.AccessToken = token
return &c, nil
}
func (r *Repository) UpdateConnection(ctx context.Context, userID int, id string, input UpdateConnectionInput) (*Connection, error) {
row := r.pool.QueryRow(ctx, `
UPDATE webhook_sync_connections
SET server_name = COALESCE($3, server_name),
default_profile_id = COALESCE($4, default_profile_id),
updated_at = NOW()
WHERE id = $1 AND user_id = $2
RETURNING id, user_id, provider, server_id, server_name, base_url, access_token, default_profile_id,
webhook_secret, account_discovery_available,
last_webhook_received_at, last_webhook_error_at, COALESCE(last_webhook_error_message, ''),
created_at, updated_at`,
id, userID, input.ServerName, input.DefaultProfileID,
)
return r.scanConnection(row)
}
func (r *Repository) DeleteConnection(ctx context.Context, userID int, id string) error {
result, err := r.pool.Exec(ctx, `DELETE FROM webhook_sync_connections WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("deleting webhook sync connection: %w", err)
}
if result.RowsAffected() == 0 {
return ErrConnectionNotFound
}
return nil
}
func (r *Repository) UpdateWebhookSecret(ctx context.Context, userID int, id, secret string) error {
result, err := r.pool.Exec(ctx, `
UPDATE webhook_sync_connections
SET webhook_secret = $3, updated_at = NOW()
WHERE id = $1 AND user_id = $2`, id, userID, secret)
if err != nil {
return fmt.Errorf("updating webhook secret: %w", err)
}
if result.RowsAffected() == 0 {
return ErrConnectionNotFound
}
return nil
}
func (r *Repository) SetDiscoveryAvailable(ctx context.Context, connectionID string, available bool) error {
_, err := r.pool.Exec(ctx, `
UPDATE webhook_sync_connections
SET account_discovery_available = $2, updated_at = NOW()
WHERE id = $1`, connectionID, available)
if err != nil {
return fmt.Errorf("updating discovery availability: %w", err)
}
return nil
}
func (r *Repository) ListMappings(ctx context.Context, connectionID string) ([]ProfileMapping, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, connection_id, external_user_id, external_user_name, silo_profile_id,
last_seen_at, created_at, updated_at
FROM webhook_sync_profile_mappings
WHERE connection_id = $1
ORDER BY external_user_name ASC, id ASC`, connectionID)
if err != nil {
return nil, fmt.Errorf("listing webhook sync mappings: %w", err)
}
defer rows.Close()
var out []ProfileMapping
for rows.Next() {
var m ProfileMapping
if err := rows.Scan(
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scanning webhook sync mapping: %w", err)
}
out = append(out, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating webhook sync mappings: %w", err)
}
return out, nil
}
func (r *Repository) GetMappingByUser(ctx context.Context, connectionID, externalUserID string) (*ProfileMapping, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, connection_id, external_user_id, external_user_name, silo_profile_id,
last_seen_at, created_at, updated_at
FROM webhook_sync_profile_mappings
WHERE connection_id = $1 AND external_user_id = $2`, connectionID, externalUserID)
var m ProfileMapping
if err := row.Scan(
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("getting webhook sync mapping: %w", err)
}
return &m, nil
}
func (r *Repository) ReplaceMappings(ctx context.Context, connectionID string, mappings []UpdateProfileMapping) ([]ProfileMapping, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin replace webhook mappings: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
if _, err := tx.Exec(ctx, `DELETE FROM webhook_sync_profile_mappings WHERE connection_id = $1`, connectionID); err != nil {
return nil, fmt.Errorf("deleting webhook sync mappings: %w", err)
}
out := make([]ProfileMapping, 0, len(mappings))
for _, input := range mappings {
row := tx.QueryRow(ctx, `
INSERT INTO webhook_sync_profile_mappings (
connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at
) VALUES ($1, $2, $3, $4, NOW())
RETURNING id, connection_id, external_user_id, external_user_name, silo_profile_id,
last_seen_at, created_at, updated_at`,
connectionID, input.ExternalUserID, input.ExternalUserName, input.SiloProfileID,
)
var m ProfileMapping
if err := row.Scan(
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("creating webhook sync mapping: %w", err)
}
out = append(out, m)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit replace webhook mappings: %w", err)
}
return out, nil
}
func (r *Repository) CreateDefaultMapping(ctx context.Context, connectionID, externalUserID, externalUserName, profileID string) (*ProfileMapping, error) {
row := r.pool.QueryRow(ctx, `
INSERT INTO webhook_sync_profile_mappings (
connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at
) VALUES ($1, $2, $3, $4, NOW())
RETURNING id, connection_id, external_user_id, external_user_name, silo_profile_id,
last_seen_at, created_at, updated_at`,
connectionID, externalUserID, externalUserName, &profileID,
)
var m ProfileMapping
if err := row.Scan(
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("creating default webhook mapping: %w", err)
}
return &m, nil
}
func (r *Repository) UpsertSeenUser(ctx context.Context, connectionID, externalUserID, externalUserName string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO webhook_sync_profile_mappings (
connection_id, external_user_id, external_user_name, last_seen_at
) VALUES ($1, $2, $3, NOW())
ON CONFLICT (connection_id, external_user_id) DO UPDATE SET
external_user_name = EXCLUDED.external_user_name,
last_seen_at = NOW(),
updated_at = NOW()`,
connectionID, externalUserID, externalUserName,
)
if err != nil {
return fmt.Errorf("upserting seen external user: %w", err)
}
return nil
}
func (r *Repository) GetItemState(ctx context.Context, connectionID, externalUserID, externalItemID string) (*ItemState, error) {
row := r.pool.QueryRow(ctx, `
SELECT connection_id, external_user_id, external_item_id, COALESCE(media_item_id, ''),
last_event_at, last_completed, last_position_seconds, updated_at
FROM webhook_sync_item_state
WHERE connection_id = $1 AND external_user_id = $2 AND external_item_id = $3`,
connectionID, externalUserID, externalItemID,
)
var state ItemState
if err := row.Scan(
&state.ConnectionID, &state.ExternalUserID, &state.ExternalItemID, &state.MediaItemID,
&state.LastEventAt, &state.LastCompleted, &state.LastPositionSecond, &state.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("getting webhook item state: %w", err)
}
return &state, nil
}
func (r *Repository) UpsertItemState(ctx context.Context, state ItemState) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO webhook_sync_item_state (
connection_id, external_user_id, external_item_id, media_item_id,
last_event_at, last_completed, last_position_seconds, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (connection_id, external_user_id, external_item_id) DO UPDATE SET
media_item_id = EXCLUDED.media_item_id,
last_event_at = EXCLUDED.last_event_at,
last_completed = EXCLUDED.last_completed,
last_position_seconds = EXCLUDED.last_position_seconds,
updated_at = NOW()`,
state.ConnectionID, state.ExternalUserID, state.ExternalItemID, state.MediaItemID,
state.LastEventAt, state.LastCompleted, state.LastPositionSecond,
)
if err != nil {
return fmt.Errorf("upserting webhook item state: %w", err)
}
return nil
}
func (r *Repository) MarkWebhookReceived(ctx context.Context, connectionID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE webhook_sync_connections
SET last_webhook_received_at = NOW(),
last_webhook_error_at = NULL,
last_webhook_error_message = NULL,
updated_at = NOW()
WHERE id = $1`, connectionID)
if err != nil {
return fmt.Errorf("marking webhook receipt: %w", err)
}
return nil
}
func (r *Repository) MarkWebhookError(ctx context.Context, connectionID, message string) error {
_, err := r.pool.Exec(ctx, `
UPDATE webhook_sync_connections
SET last_webhook_error_at = NOW(), last_webhook_error_message = $2, updated_at = NOW()
WHERE id = $1`, connectionID, message)
if err != nil {
return fmt.Errorf("marking webhook error: %w", err)
}
return nil
}