feat(auth/csvauth): add token support,make secrets non-printing

This commit is contained in:
AJ ONeal 2026-02-21 05:44:10 -07:00
parent dd48b2420b
commit 85d42550bf
No known key found for this signature in database
4 changed files with 171 additions and 30 deletions

View File

@ -33,6 +33,7 @@ func showHelp() {
fmt.Fprintf(os.Stderr, `csvauth - create, update, and verify users, passwords, and tokens fmt.Fprintf(os.Stderr, `csvauth - create, update, and verify users, passwords, and tokens
EXAMPLES EXAMPLES
csvauth store --token 'my-new-token'
csvauth store --ask-password 'my-new-user' csvauth store --ask-password 'my-new-user'
csvauth verify '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) { func handleSet(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser) {
storeFlags := flag.NewFlagSet("csvauth-store", flag.ContinueOnError) 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'") 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)") 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]") 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") askPassword := storeFlags.Bool("ask-password", false, "Read password or token from stdin")
passwordFile := storeFlags.String("password-file", "", "Read password from file") 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") // storeFlags.StringVar(&tsvPath, "tsv", tsvPath, "Credentials file to use")
if err := storeFlags.Parse(args); err != nil { if err := storeFlags.Parse(args); err != nil {
if err == flag.ErrHelp { if err == flag.ErrHelp {
@ -276,20 +278,38 @@ func handleSet(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser) {
name := storeFlags.Arg(0) name := storeFlags.Arg(0)
switch name { switch name {
case "id", "name", "purpose": 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) fmt.Fprintf(os.Stderr, "invalid username %q\n", name)
}
os.Exit(1) 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 len(*algorithm) == 0 {
if *purpose == "login" { switch *purpose {
case csvauth.PurposeDefault:
*algorithm = "pbkdf2" *algorithm = "pbkdf2"
} else { case csvauth.PurposeToken:
fallthrough
// *algorithm = "plain" // *algorithm = "plain"
default:
*algorithm = "aes-128-gcm" *algorithm = "aes-128-gcm"
} }
} }
if *purpose != "login" { switch *purpose {
case csvauth.PurposeDefault, csvauth.PurposeToken:
// no change
default:
*askPassword = true *askPassword = true
} }
@ -339,7 +359,7 @@ func handleSet(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser) {
_ = csvFile.Close() _ = csvFile.Close()
var exists bool 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 _, err := auth.LoadServiceAccount(*purpose); err != nil {
if !errors.Is(err, csvauth.ErrNotFound) { if !errors.Is(err, csvauth.ErrNotFound) {
fmt.Fprintf(os.Stderr, "could not load %s: %v\n", *purpose, err) 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) { func handleCheck(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser) {
checkFlags := flag.NewFlagSet("csvauth-check", flag.ContinueOnError) 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'") 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 from stdin") _ = checkFlags.Bool("ask-password", true, "Read password or token from stdin")
passwordFile := checkFlags.String("password-file", "", "Read password from file") 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") // storeFlags.StringVar(&tsvPath, "tsv", tsvPath, "Credentials file to use")
if err := checkFlags.Parse(args); err != nil { if err := checkFlags.Parse(args); err != nil {
if err == flag.ErrHelp { if err == flag.ErrHelp {
@ -400,10 +421,24 @@ func handleCheck(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser)
name := checkFlags.Arg(0) name := checkFlags.Arg(0)
switch name { switch name {
case "id", "name", "purpose": case "", "id", "name", "purpose":
if !*useToken {
fmt.Fprintf(os.Stderr, "invalid username %q\n", name) fmt.Fprintf(os.Stderr, "invalid username %q\n", name)
os.Exit(1) 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 var pass string
if len(*passwordFile) > 0 { if len(*passwordFile) > 0 {
@ -434,7 +469,7 @@ func handleCheck(args []string, aesKey []byte, csvFile csvauth.NamedReadCloser)
var v csvauth.BasicAuthVerifier var v csvauth.BasicAuthVerifier
var err error var err error
if *purpose != "login" { if *purpose != csvauth.PurposeDefault && *purpose != csvauth.PurposeToken {
v, err = auth.LoadServiceAccount(*purpose) v, err = auth.LoadServiceAccount(*purpose)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "couldn't load %s: %v", *purpose, err) 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 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) fmt.Fprintf(os.Stderr, "user '%s' not found or incorrect secret\n", name)
os.Exit(1) os.Exit(1)
return return

View File

@ -14,7 +14,13 @@ type BasicAuthVerifier interface {
Verify(string, string) error Verify(string, string) error
} }
const DefaultPurpose = "login" const (
// deprecated, misspelling of PurposeDefault
DefaultPurpose = "login"
PurposeDefault = "login"
PurposeToken = "token"
hashIDSep = "~"
)
type Purpose = string type Purpose = string
type Name = string type Name = string
@ -43,6 +49,7 @@ type Credential struct {
Derived []byte Derived []byte
Roles []string Roles []string
Extra string Extra string
hashID string
} }
func (c Credential) Secret() 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) { func FromFields(purpose, name, paramList, saltBase64, derived, roleList, extra string) (Credential, error) {
var credential Credential var credential Credential
credential.Name = name
if len(purpose) == 0 { if len(purpose) == 0 {
purpose = DefaultPurpose purpose = PurposeDefault
} }
credential.Purpose = purpose 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 var roles []string
if len(roleList) > 0 { if len(roleList) > 0 {
roleList = strings.ReplaceAll(roleList, ",", " ") roleList = strings.ReplaceAll(roleList, ",", " ")
@ -156,6 +170,19 @@ func FromFields(purpose, name, paramList, saltBase64, derived, roleList, extra s
return credential, nil 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 { func (c Credential) ToRecord() []string {
var paramList, salt, derived string var paramList, salt, derived string
@ -178,9 +205,13 @@ func (c Credential) ToRecord() []string {
purpose := c.Purpose purpose := c.Purpose
if len(purpose) == 0 { 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 return record
} }

View File

@ -3,6 +3,8 @@ service1 acme aes-128-gcm 2z92DVgMF9Hn-GBy i37kF34cwa64j3tmnrvlJ5ZSekWD-w token
service2 acme plain token2 token2 service2 acme plain token2 token2
service3 user3 pbkdf2 1000 16 SHA-256 DYdA9iz1EN81bESTXcSgUg IzkeBCxRVmqybOBeAntfdA token3 service3 user3 pbkdf2 1000 16 SHA-256 DYdA9iz1EN81bESTXcSgUg IzkeBCxRVmqybOBeAntfdA token3
service4 user4 bcrypt $2a$12$HueMNxFGYIYtNNTySFW/Lu4vAMqpdcchBnJrW.VdYgP9xPQdITipu token4 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 user1 pbkdf2 1000 16 SHA-256 R-NgfDcY1A6L5a4jO89TNw -Pe9o-NwYvF6M4tlCwhm_g pass1
login user2 bcrypt $2a$12$pad8UgUphO43PioF1JlSHOblRPdaX.ikTqjA8D1EfrcBiNGI9WQ/y pass2 login user2 bcrypt $2a$12$pad8UgUphO43PioF1JlSHOblRPdaX.ikTqjA8D1EfrcBiNGI9WQ/y pass2
login user3 aes-128-gcm YC0xno0-W9pWR6rK D9CZFCtGGJecLpCv2Fk1I-wcXmN3 pass3 login user3 aes-128-gcm YC0xno0-W9pWR6rK D9CZFCtGGJecLpCv2Fk1I-wcXmN3 pass3

1 purpose name algo salt derived roles extra
3 service2 acme plain token2 token2
4 service3 user3 pbkdf2 1000 16 SHA-256 DYdA9iz1EN81bESTXcSgUg IzkeBCxRVmqybOBeAntfdA token3
5 service4 user4 bcrypt $2a$12$HueMNxFGYIYtNNTySFW/Lu4vAMqpdcchBnJrW.VdYgP9xPQdITipu token4
6 token api~vkdAIZ2O aes-128-gcm kRHkOnbVdKfxwWNo wC7edBEuz5htfvaagBfRUOHc60g api1
7 token api~b5ZF2sRQ aes-128-gcm GowoB3l3eDiE4bPI rtXBX9QbdrochVlr1SG8UWGllao api2
8 login user1 pbkdf2 1000 16 SHA-256 R-NgfDcY1A6L5a4jO89TNw -Pe9o-NwYvF6M4tlCwhm_g pass1
9 login user2 bcrypt $2a$12$pad8UgUphO43PioF1JlSHOblRPdaX.ikTqjA8D1EfrcBiNGI9WQ/y pass2
10 login user3 aes-128-gcm YC0xno0-W9pWR6rK D9CZFCtGGJecLpCv2Fk1I-wcXmN3 pass3

View File

@ -4,10 +4,12 @@ import (
"bytes" "bytes"
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/hmac"
"crypto/pbkdf2" "crypto/pbkdf2"
"crypto/rand" "crypto/rand"
"crypto/sha1" "crypto/sha1"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/csv" "encoding/csv"
"errors" "errors"
"fmt" "fmt"
@ -27,6 +29,7 @@ import (
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized") var ErrUnauthorized = errors.New("unauthorized")
var ErrUnknownAlgorithm = errors.New("unknown algorithm") var ErrUnknownAlgorithm = errors.New("unknown algorithm")
var ErrLockedCredential = errors.New("credential is locked")
const ( const (
defaultIters = 1000 // original 2000 recommendation defaultIters = 1000 // original 2000 recommendation
@ -64,6 +67,7 @@ func NewNamedReadCloser(r io.ReadCloser, name string) NamedReadCloser {
type Auth struct { type Auth struct {
aes128key [16]byte aes128key [16]byte
credentials map[Name]Credential credentials map[Name]Credential
tokens map[string]Credential
serviceAccounts map[Purpose]Credential serviceAccounts map[Purpose]Credential
mux sync.Mutex mux sync.Mutex
} }
@ -76,6 +80,7 @@ func New(aes128key []byte) *Auth {
return &Auth{ return &Auth{
aes128key: aes128Arr, aes128key: aes128Arr,
credentials: map[Name]Credential{}, credentials: map[Name]Credential{},
tokens: map[string]Credential{},
serviceAccounts: map[Purpose]Credential{}, serviceAccounts: map[Purpose]Credential{},
} }
} }
@ -115,12 +120,24 @@ func (a *Auth) LoadCSV(f NamedReadCloser, comma rune) error {
return err return err
} }
if len(credential.Purpose) == 0 || credential.Purpose == DefaultPurpose { switch credential.Purpose {
if _, ok := a.credentials[credential.Name]; ok { 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) fmt.Fprintf(os.Stderr, "overwriting cache of previous value for %s: %s\n", credential.Purpose, credential.Name)
} }
a.credentials[credential.Name] = credential a.credentials[name] = credential
} else { 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 { if _, ok := a.serviceAccounts[credential.Purpose]; ok {
fmt.Fprintf(os.Stderr, "overwriting cache of previous value for %s: %s\n", credential.Purpose, credential.Name) 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, Extra: extra,
} }
if purpose == PurposeToken {
c.hashID = a.tokenCacheID(secret)
}
switch c.Params[0] { switch c.Params[0] {
case "plain": case "plain":
if len(params) != 1 { if len(params) != 1 {
@ -304,8 +325,17 @@ func (a *Auth) LoadCredential(name Name) (Credential, error) {
func (a *Auth) CacheCredential(c Credential) error { func (a *Auth) CacheCredential(c Credential) error {
a.mux.Lock() a.mux.Lock()
a.credentials[c.Name] = c defer a.mux.Unlock()
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 return nil
} }
@ -383,12 +413,48 @@ func (a *Auth) Verify(name, secret string) error {
return c.Verify(name, secret) 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 // 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 known := c.Derived
var derived []byte var derived []byte
switch c.Params[0] { switch c.Params[0] {
case "aes-128-gcm": case "aes-128-gcm":
// we hash because encrypted comparisons are NOT timing safe
if c.plain == "" {
return ErrLockedCredential
}
knownHash := sha256.Sum256([]byte(c.plain)) knownHash := sha256.Sum256([]byte(c.plain))
known = knownHash[:] known = knownHash[:]
@ -421,6 +487,7 @@ func (c Credential) Verify(name, secret string) error {
return ErrUnknownAlgorithm return ErrUnknownAlgorithm
} }
// all values MUST be hashed before comparing, for timing safety
if bytes.Equal(known, derived) { if bytes.Equal(known, derived) {
return nil return nil
} }