package secret import ( "context" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Column is one database column that holds a sealed credential. // // Its Purpose ("table.column") is the key-derivation label every Seal and // Open of that column passes. The owner a value is bound to is the row's key // as text (KeyColumn::text), or "" for a single-row settings table. type Column struct { Table string Column string KeyColumn string Singleton bool } // Purpose is the label Seal and Open take for this column. func (c Column) Purpose() string { return c.Table + "." + c.Column } // The sealed columns, and the purpose strings their callers pass. Every // credential Minstrel stores for an outside system, or that must be read back // in the clear, is listed here. var ( AcquisitionAPIKey = Column{"acquisition_services", "api_key", "code", false} AcquisitionPassword = Column{"acquisition_services", "password", "code", false} AcoustIDAPIKey = Column{"acoustid_settings", "api_key", "id", true} SMTPPassword = Column{"smtp_config", "password", "id", true} ListenBrainzToken = Column{"users", "listenbrainz_token", "id", false} SubsonicPassword = Column{"users", "subsonic_password", "id", false} CoverArtAPIKey = Column{"cover_art_provider_settings", "api_key", "provider_id", false} TagProviderAPIKey = Column{"tag_provider_settings", "api_key", "provider_id", false} ) // Columns is every sealed column, for SealStored and for anything that has to // know which columns hold credentials (a backup leaves them out). var Columns = []Column{ AcquisitionAPIKey, AcquisitionPassword, AcoustIDAPIKey, SMTPPassword, ListenBrainzToken, SubsonicPassword, CoverArtAPIKey, TagProviderAPIKey, } // Owner is the owner string for a row whose key is key. A single-row table's // rows are owned by "". func (c Column) Owner(key string) string { if c.Singleton { return "" } return key } // SealStored seals every value in Columns that is still stored in the clear, // and returns how many it sealed. The server runs it at startup, after // migrations and before anything reads a credential, so a database written // before sealing existed is brought over in place. Each row is updated only // if it still holds the value that was read, so a concurrent write wins. func SealStored(ctx context.Context, pool *pgxpool.Pool) (int, error) { total := 0 for _, c := range Columns { n, err := sealColumn(ctx, pool, c) total += n if err != nil { return total, fmt.Errorf("seal %s: %w", c.Purpose(), err) } } return total, nil } func sealColumn(ctx context.Context, pool *pgxpool.Pool, c Column) (int, error) { table := pgx.Identifier{c.Table}.Sanitize() col := pgx.Identifier{c.Column}.Sanitize() key := pgx.Identifier{c.KeyColumn}.Sanitize() rows, err := pool.Query(ctx, fmt.Sprintf( `SELECT %s::text, %s FROM %s WHERE %s IS NOT NULL AND %s <> '' AND %s NOT LIKE 'v1.%%'`, key, col, table, col, col, col)) if err != nil { return 0, err } type pending struct{ key, plain string } var todo []pending for rows.Next() { var p pending if err := rows.Scan(&p.key, &p.plain); err != nil { rows.Close() return 0, err } todo = append(todo, p) } rows.Close() if err := rows.Err(); err != nil { return 0, err } update := fmt.Sprintf(`UPDATE %s SET %s = $1 WHERE %s::text = $2 AND %s = $3`, table, col, key, col) sealed := 0 for _, p := range todo { v, err := Seal(c.Purpose(), c.Owner(p.key), p.plain) if err != nil { return sealed, err } if _, err := pool.Exec(ctx, update, v, p.key, p.plain); err != nil { return sealed, err } sealed++ } return sealed, nil }