Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / cmd/rotate-credential/main.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | cmd/rotate-credential/main.go |
| Original SHA-256 | b0931b197a6db5def2f983a367065246b095363b2b07b7766658b7cb01376a0d |
| Git blob SHA | b6e954c5a8a504096d44ed7ac655f0a77ae906f3 |
| Reading-copy SHA-256 | b0931b197a6db5def2f983a367065246b095363b2b07b7766658b7cb01376a0d |
Recovered source transcript
Download complete source text ↓113 lines · 4.5 KB
// Command rotate-credential sets a new password for an existing account through the SAME primitives the
// login path uses: the versioned pepper, PBKDF2 HMAC SHA 256 at the configured iteration count, and the
// auth_set_credential SECURITY DEFINER function.
//
// It exists because three credentials were written into docs/BUILD_STATE.md in plaintext and are therefore
// burned. Rotating them by hand against the database would mean reimplementing the KDF, and a second
// implementation of a KDF is a second thing that can be subtly wrong; going through the real primitives
// means a rotated credential is byte for byte the shape a normal one has.
//
// Usage:
//
// rotate-credential -dsn <migrator dsn> -email <address> -password <new>
package main
import (
"context"
"encoding/base64"
"flag"
"log"
"os"
"strconv"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/missionworkforce/missionmatch/internal/platform/security"
)
func main() {
dsn := flag.String("dsn", "", "migrator DSN, defaults to MM_MIGRATOR_DATABASE_URL")
email := flag.String("email", "", "account email")
newPass := flag.String("password", "", "the replacement password")
pepperEnv := flag.String("pepper", "", "credential pepper, server side only, defaults to MM_CREDENTIAL_PEPPER")
flag.Parse()
if *dsn == "" {
*dsn = os.Getenv("MM_MIGRATOR_DATABASE_URL")
}
if *pepperEnv == "" {
*pepperEnv = os.Getenv("MM_CREDENTIAL_PEPPER")
}
// THE PASSWORD MAY COME FROM THE ENVIRONMENT INSTEAD OF argv, and it should.
//
// argv is world readable through ps for as long as the process runs, so -password publishes the credential
// to every account on the host. That is the same exposure found in mm-identity-sync earlier tonight, where
// two migrator DSNs were passed as flags and measured as one visible exposure per run. A tool whose entire
// purpose is to set a credential correctly should not leak it on the way in.
//
// The flag is kept, because it is in the recorded runbook and breaking it would be worse than the leak it
// avoids, but MM_ROTATE_PASSWORD is now the preferred path and it is what the Gate C sweep uses.
if *newPass == "" {
*newPass = os.Getenv("MM_ROTATE_PASSWORD")
}
if *dsn == "" || strings.TrimSpace(*email) == "" || *newPass == "" {
log.Fatal("rotate-credential: -dsn, -email, and a password from -password or MM_ROTATE_PASSWORD are required")
}
if *pepperEnv == "" {
log.Fatal("rotate-credential: the credential pepper is required, or the hash would not match what login computes")
}
if len(*newPass) > 64 {
log.Fatal("rotate-credential: password exceeds the 64 character cap the CAVP tested length bound imposes")
}
// The pepper is decoded exactly as config does it, base64 to 32 raw bytes. Decoding it any other way
// here would produce a hash the login path cannot verify, and the failure would look like a wrong
// password rather than a wrong tool.
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(*pepperEnv))
if err != nil {
log.Fatalf("rotate-credential: the pepper is not valid base64: %v", err)
}
version := 1
if v := strings.TrimSpace(os.Getenv("MM_CREDENTIAL_PEPPER_VERSION")); v != "" {
if n, cerr := strconv.Atoi(v); cerr == nil {
version = n
}
}
pepper, err := security.NewPepper(version, map[int][]byte{version: raw})
if err != nil {
log.Fatalf("rotate-credential: pepper: %v", err)
}
hasher, err := security.NewHasher(pepper, security.PBKDF2Iterations)
if err != nil {
log.Fatalf("rotate-credential: hasher: %v", err)
}
encoded, ver, err := hasher.Hash(*newPass)
if err != nil {
log.Fatalf("rotate-credential: hash: %v", err)
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, *dsn)
if err != nil {
log.Fatalf("rotate-credential: pool: %v", err)
}
defer pool.Close()
var uid, orgID, normalized string
if err := pool.QueryRow(ctx,
`SELECT id::text, organization_id::text, lower(email) FROM users WHERE lower(email)=lower($1)`,
strings.TrimSpace(*email)).Scan(&uid, &orgID, &normalized); err != nil {
log.Fatalf("rotate-credential: no such account %q: %v", *email, err)
}
if _, err := pool.Exec(ctx, `SELECT auth_set_credential($1,$2,$3,$4,$5)`,
uid, orgID, normalized, encoded, ver); err != nil {
log.Fatalf("rotate-credential: set credential: %v", err)
}
// The new secret is NEVER printed. Whoever ran this already has it; echoing it would put it in a shell
// history and a terminal scrollback, which is how the original problem started.
log.Printf("rotate-credential: rotated %s, pepper version %d, iterations %d",
normalized, ver, security.PBKDF2Iterations)
}