From 85d42550bfd0c2f884e7710ce49e7e03dc537385 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 21 Feb 2026 05:44:10 -0700 Subject: [PATCH] feat(auth/csvauth): add token support,make secrets non-printing --- auth/csvauth/cmd/csvauth/main.go | 75 ++++++++++++++++++++++------- auth/csvauth/credential.go | 43 ++++++++++++++--- auth/csvauth/credentials.tsv | 2 + auth/csvauth/csvauth.go | 81 +++++++++++++++++++++++++++++--- 4 files changed, 171 insertions(+), 30 deletions(-) diff --git a/auth/csvauth/cmd/csvauth/main.go b/auth/csvauth/cmd/csvauth/main.go index 5d6504f..888169a 100644 --- a/auth/csvauth/cmd/csvauth/main.go +++ b/auth/csvauth/cmd/csvauth/main.go @@ -33,6 +33,7 @@ func showHelp() { fmt.Fprintf(os.Stderr, `csvauth - create, update, and verify users, passwords, and tokens EXAMPLES + csvauth store --token 'my-new-token' csvauth store --ask-password 'my-new-user' csvauth verify 'my-new-user' @@ -255,12 +256,13 @@ func handleInit(keyenv, keypath, csvpath string) error { func handleSet(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser) { storeFlags := flag.NewFlagSet("csvauth-store", flag.ContinueOnError) - purpose := storeFlags.String("purpose", "login", "'login' for users, or a service account name, such as 'basecamp_api_key'") + purpose := storeFlags.String("purpose", "login", "'login' for users, 'token' for tokens, or a service account name, such as 'basecamp_api_key'") roleList := storeFlags.String("roles", "", "a comma- or space-separated list of roles (defined by you), such as 'triage audit'") extra := storeFlags.String("extra", "", "free form data to retrieve with the user (hint: JSON might be nice)") algorithm := storeFlags.String("algorithm", "", "Hash algorithm: aes, plain, pbkdf2[,iters[,size[,hash]]], or bcrypt[,cost]") - askPassword := storeFlags.Bool("ask-password", false, "Read password from stdin") - passwordFile := storeFlags.String("password-file", "", "Read password from file") + askPassword := storeFlags.Bool("ask-password", false, "Read password or token from stdin") + useToken := storeFlags.Bool("token", false, "generate token") + passwordFile := storeFlags.String("password-file", "", "Read password or token from file") // storeFlags.StringVar(&tsvPath, "tsv", tsvPath, "Credentials file to use") if err := storeFlags.Parse(args); err != nil { if err == flag.ErrHelp { @@ -276,20 +278,38 @@ func handleSet(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser) { name := storeFlags.Arg(0) switch name { - case "id", "name", "purpose": - fmt.Fprintf(os.Stderr, "invalid username %q\n", name) + case "", "id", "name", "purpose": + if *useToken { + fmt.Fprintf(os.Stderr, "invalid token name %q\n", name) + } else { + fmt.Fprintf(os.Stderr, "invalid username %q\n", name) + } os.Exit(1) } + if *useToken { + if *purpose != csvauth.PurposeDefault && *purpose != csvauth.PurposeToken { + fmt.Fprintf(os.Stderr, "token purpose must be 'token', not %q\n", *purpose) + os.Exit(1) + } + *purpose = csvauth.PurposeToken + } + if len(*algorithm) == 0 { - if *purpose == "login" { + switch *purpose { + case csvauth.PurposeDefault: *algorithm = "pbkdf2" - } else { + case csvauth.PurposeToken: + fallthrough // *algorithm = "plain" + default: *algorithm = "aes-128-gcm" } } - if *purpose != "login" { + switch *purpose { + case csvauth.PurposeDefault, csvauth.PurposeToken: + // no change + default: *askPassword = true } @@ -339,7 +359,7 @@ func handleSet(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser) { _ = csvFile.Close() var exists bool - if len(*purpose) > 0 && *purpose != "login" { + if len(*purpose) > 0 && *purpose != csvauth.PurposeDefault && *purpose != csvauth.PurposeToken { if _, err := auth.LoadServiceAccount(*purpose); err != nil { if !errors.Is(err, csvauth.ErrNotFound) { fmt.Fprintf(os.Stderr, "could not load %s: %v\n", *purpose, err) @@ -382,9 +402,10 @@ func handleSet(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser) { func handleCheck(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser) { checkFlags := flag.NewFlagSet("csvauth-check", flag.ContinueOnError) - purpose := checkFlags.String("purpose", "login", "'login' for users, or a service account name, such as 'basecamp_api_key'") - _ = checkFlags.Bool("ask-password", true, "Read password from stdin") - passwordFile := checkFlags.String("password-file", "", "Read password from file") + purpose := checkFlags.String("purpose", "login", "'login' for users, 'token' for tokens, or a service account name, such as 'basecamp_api_key'") + _ = checkFlags.Bool("ask-password", true, "Read password or token from stdin") + useToken := checkFlags.Bool("token", false, "generate token") + passwordFile := checkFlags.String("password-file", "", "Read password or token from file") // storeFlags.StringVar(&tsvPath, "tsv", tsvPath, "Credentials file to use") if err := checkFlags.Parse(args); err != nil { if err == flag.ErrHelp { @@ -400,9 +421,23 @@ func handleCheck(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser) name := checkFlags.Arg(0) switch name { - case "id", "name", "purpose": - fmt.Fprintf(os.Stderr, "invalid username %q\n", name) - os.Exit(1) + case "", "id", "name", "purpose": + if !*useToken { + fmt.Fprintf(os.Stderr, "invalid username %q\n", name) + os.Exit(1) + } + if name != "" { + fmt.Fprintf(os.Stderr, "invalid token name %q\n", name) + os.Exit(1) + } + } + + if *useToken { + if *purpose != csvauth.PurposeDefault && *purpose != csvauth.PurposeToken { + fmt.Fprintf(os.Stderr, "token purpose must be 'token', not %q\n", *purpose) + os.Exit(1) + } + *purpose = csvauth.PurposeToken } var pass string @@ -434,7 +469,7 @@ func handleCheck(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser) var v csvauth.BasicAuthVerifier var err error - if *purpose != "login" { + if *purpose != csvauth.PurposeDefault && *purpose != csvauth.PurposeToken { v, err = auth.LoadServiceAccount(*purpose) if err != nil { fmt.Fprintf(os.Stderr, "couldn't load %s: %v", *purpose, err) @@ -444,7 +479,13 @@ func handleCheck(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser) v = auth } - if err := v.Verify(name, pass); err != nil { + if *purpose == csvauth.PurposeToken { + if err := auth.VerifyToken(pass); err != nil { + fmt.Fprintf(os.Stderr, "token not verified: %v\n", err) + os.Exit(1) + return + } + } else if err := v.Verify(name, pass); err != nil { fmt.Fprintf(os.Stderr, "user '%s' not found or incorrect secret\n", name) os.Exit(1) return diff --git a/auth/csvauth/credential.go b/auth/csvauth/credential.go index 96f87c7..d30cd29 100644 --- a/auth/csvauth/credential.go +++ b/auth/csvauth/credential.go @@ -14,7 +14,13 @@ type BasicAuthVerifier interface { Verify(string, string) error } -const DefaultPurpose = "login" +const ( + // deprecated, misspelling of PurposeDefault + DefaultPurpose = "login" + PurposeDefault = "login" + PurposeToken = "token" + hashIDSep = "~" +) type Purpose = string type Name = string @@ -43,6 +49,7 @@ type Credential struct { Derived []byte Roles []string Extra string + hashID string } func (c Credential) Secret() string { @@ -64,13 +71,20 @@ func FromRecord(record []string) (Credential, error) { func FromFields(purpose, name, paramList, saltBase64, derived, roleList, extra string) (Credential, error) { var credential Credential - credential.Name = name - if len(purpose) == 0 { - purpose = DefaultPurpose + purpose = PurposeDefault } credential.Purpose = purpose + if credential.Purpose == PurposeToken { + name, hashID, _ := splitLast(name, hashIDSep) + credential.Name = name + // this can only be verified if plain or aes + credential.hashID = hashID + } else { + credential.Name = name + } + var roles []string if len(roleList) > 0 { roleList = strings.ReplaceAll(roleList, ",", " ") @@ -156,6 +170,19 @@ func FromFields(purpose, name, paramList, saltBase64, derived, roleList, extra s return credential, nil } +func splitLast(s, sep string) (before, after string, found bool) { + if sep == "" { + return s, "", false + } + + idx := strings.LastIndex(s, sep) + if idx == -1 { + return s, "", false + } + + return s[:idx], s[idx+len(sep):], true +} + func (c Credential) ToRecord() []string { var paramList, salt, derived string @@ -178,9 +205,13 @@ func (c Credential) ToRecord() []string { purpose := c.Purpose if len(purpose) == 0 { - purpose = DefaultPurpose + purpose = PurposeDefault } - record := []string{purpose, c.Name, paramList, salt, derived, strings.Join(c.Roles, " "), c.Extra} + name := c.Name + if c.hashID != "" { + name += hashIDSep + c.hashID + } + record := []string{purpose, name, paramList, salt, derived, strings.Join(c.Roles, " "), c.Extra} return record } diff --git a/auth/csvauth/credentials.tsv b/auth/csvauth/credentials.tsv index 95af371..04952fa 100644 --- a/auth/csvauth/credentials.tsv +++ b/auth/csvauth/credentials.tsv @@ -3,6 +3,8 @@ service1 acme aes-128-gcm 2z92DVgMF9Hn-GBy i37kF34cwa64j3tmnrvlJ5ZSekWD-w token service2 acme plain token2 token2 service3 user3 pbkdf2 1000 16 SHA-256 DYdA9iz1EN81bESTXcSgUg IzkeBCxRVmqybOBeAntfdA token3 service4 user4 bcrypt $2a$12$HueMNxFGYIYtNNTySFW/Lu4vAMqpdcchBnJrW.VdYgP9xPQdITipu token4 +token api~vkdAIZ2O aes-128-gcm kRHkOnbVdKfxwWNo wC7edBEuz5htfvaagBfRUOHc60g api1 +token api~b5ZF2sRQ aes-128-gcm GowoB3l3eDiE4bPI rtXBX9QbdrochVlr1SG8UWGllao api2 login user1 pbkdf2 1000 16 SHA-256 R-NgfDcY1A6L5a4jO89TNw -Pe9o-NwYvF6M4tlCwhm_g pass1 login user2 bcrypt $2a$12$pad8UgUphO43PioF1JlSHOblRPdaX.ikTqjA8D1EfrcBiNGI9WQ/y pass2 login user3 aes-128-gcm YC0xno0-W9pWR6rK D9CZFCtGGJecLpCv2Fk1I-wcXmN3 pass3 diff --git a/auth/csvauth/csvauth.go b/auth/csvauth/csvauth.go index d5b6c14..edcadfc 100644 --- a/auth/csvauth/csvauth.go +++ b/auth/csvauth/csvauth.go @@ -4,10 +4,12 @@ import ( "bytes" "crypto/aes" "crypto/cipher" + "crypto/hmac" "crypto/pbkdf2" "crypto/rand" "crypto/sha1" "crypto/sha256" + "encoding/base64" "encoding/csv" "errors" "fmt" @@ -27,6 +29,7 @@ import ( var ErrNotFound = errors.New("not found") var ErrUnauthorized = errors.New("unauthorized") var ErrUnknownAlgorithm = errors.New("unknown algorithm") +var ErrLockedCredential = errors.New("credential is locked") const ( defaultIters = 1000 // original 2000 recommendation @@ -64,6 +67,7 @@ func NewNamedReadCloser(r io.ReadCloser, name string) NamedReadCloser { type Auth struct { aes128key [16]byte credentials map[Name]Credential + tokens map[string]Credential serviceAccounts map[Purpose]Credential mux sync.Mutex } @@ -76,6 +80,7 @@ func New(aes128key []byte) *Auth { return &Auth{ aes128key: aes128Arr, credentials: map[Name]Credential{}, + tokens: map[string]Credential{}, serviceAccounts: map[Purpose]Credential{}, } } @@ -115,12 +120,24 @@ func (a *Auth) LoadCSV(f NamedReadCloser, comma rune) error { return err } - if len(credential.Purpose) == 0 || credential.Purpose == DefaultPurpose { - if _, ok := a.credentials[credential.Name]; ok { + switch credential.Purpose { + case "", PurposeDefault, PurposeToken: + name := credential.Name + if credential.Purpose == PurposeToken { + name += hashIDSep + credential.hashID + } + + if _, ok := a.credentials[name]; ok { fmt.Fprintf(os.Stderr, "overwriting cache of previous value for %s: %s\n", credential.Purpose, credential.Name) } - a.credentials[credential.Name] = credential - } else { + a.credentials[name] = credential + if credential.Purpose == PurposeToken { + if _, ok := a.tokens[credential.hashID]; ok { + fmt.Fprintf(os.Stderr, "overwriting cache of previous value for %s: %s\n", credential.Purpose, credential.Name) + } + a.tokens[credential.hashID] = credential + } + default: if _, ok := a.serviceAccounts[credential.Purpose]; ok { fmt.Fprintf(os.Stderr, "overwriting cache of previous value for %s: %s\n", credential.Purpose, credential.Name) } @@ -144,6 +161,10 @@ func (a *Auth) NewCredential(purpose, name, secret string, params []string, role Extra: extra, } + if purpose == PurposeToken { + c.hashID = a.tokenCacheID(secret) + } + switch c.Params[0] { case "plain": if len(params) != 1 { @@ -304,8 +325,17 @@ func (a *Auth) LoadCredential(name Name) (Credential, error) { func (a *Auth) CacheCredential(c Credential) error { a.mux.Lock() - a.credentials[c.Name] = c - a.mux.Unlock() + defer a.mux.Unlock() + + name := c.Name + if c.Purpose == PurposeToken { + name += hashIDSep + c.hashID + } + a.credentials[name] = c + + if c.Purpose == PurposeToken { + a.tokens[c.hashID] = c + } return nil } @@ -383,12 +413,48 @@ func (a *Auth) Verify(name, secret string) error { return c.Verify(name, secret) } +func (a *Auth) VerifyToken(secret string) error { + hashID := a.tokenCacheID(secret) + + a.mux.Lock() + c, ok := a.tokens[hashID] + a.mux.Unlock() + + if !ok { + return ErrNotFound + } + + if c.plain == "" { + var err error + if c.plain, err = a.maybeDecryptCredential(c); err != nil { + return err + } + } + return c.Verify(hashID, secret) +} + +func (a *Auth) tokenCacheID(secret string) string { + key := a.aes128key[:] + mac := hmac.New(sha256.New, key) + message := []byte(secret) + mac.Write(message) + nameBytes := mac.Sum(nil)[:6] + + name := base64.RawURLEncoding.EncodeToString(nameBytes) + return name +} + // Verify checks Basic Auth credentials -func (c Credential) Verify(name, secret string) error { +// (name is ignored, as it is assumed to have been used for lookup) +func (c Credential) Verify(_, secret string) error { known := c.Derived var derived []byte switch c.Params[0] { case "aes-128-gcm": + // we hash because encrypted comparisons are NOT timing safe + if c.plain == "" { + return ErrLockedCredential + } knownHash := sha256.Sum256([]byte(c.plain)) known = knownHash[:] @@ -421,6 +487,7 @@ func (c Credential) Verify(name, secret string) error { return ErrUnknownAlgorithm } + // all values MUST be hashed before comparing, for timing safety if bytes.Equal(known, derived) { return nil }