471 lines
12 KiB
Go

package main
import (
"bufio"
"crypto/rand"
"encoding/base64"
"encoding/csv"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"github.com/therootcompany/golib/auth/csvauth"
)
const (
defaultAESKeyENVName = "CSVAUTH_AES_128_KEY"
defaultCSVFileENVName = "CSVAUTH_CSV_FILE"
defaultCSVPath = "credentials.tsv"
passwordEntropy = 12 // 96-bit
)
var (
keyRelPath = filepath.Join(".config", "csvauth", "aes-128.key")
)
func main() {
var subcmd string
if len(os.Args) > 1 {
subcmd = os.Args[1]
}
if len(os.Args) > 2 {
switch os.Args[2] {
case "", "help":
os.Args[2] = "--help"
}
} else {
os.Args = append(os.Args, "--help")
}
homedir, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
return
}
filename := filepath.Join(homedir, keyRelPath)
csvPath := getCSVPath()
var aesKey []byte
var csvFile csvauth.NamedReadCloser
switch subcmd {
case "store", "check":
var keyErr error
aesKey, keyErr = getAESKey(defaultAESKeyENVName, filename)
if keyErr != nil {
if os.IsNotExist(keyErr) {
fmt.Fprintf(os.Stderr, "no AES key found, run 'csvauth init' to create it, or provide %s or ~/%s\n", defaultAESKeyENVName, keyRelPath)
} else {
fmt.Fprintf(os.Stderr, "%v\n", keyErr)
}
}
var csvErr error
csvFile, csvErr = getCSVFile(csvPath)
if csvErr != nil {
if os.IsNotExist(csvErr) {
fmt.Fprintf(os.Stderr, "no credentials file found, run 'csvauth init' to create it, or provide %s or %s\n", defaultCSVFileENVName, csvPath)
} else {
fmt.Fprintf(os.Stderr, "%v\n", csvErr)
}
}
if keyErr != nil || csvErr != nil {
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "\n")
}
switch subcmd {
case "init":
if err := handleInit(defaultAESKeyENVName, filename, csvPath); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
case "store":
handleSet(os.Args[2:], aesKey, csvFile)
case "check":
handleCheck(os.Args[2:], aesKey, csvFile)
case "--help", "-help", "help", "":
fallthrough
default:
if len(subcmd) > 0 {
fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", subcmd)
os.Exit(1)
return
}
fmt.Fprintf(os.Stderr, "USAGE\n\tcsvauth [store|check] [--help] [--algorithm <aes|plain|pbkdf2[,iters[,size[,hash]]]|bcrypt[,cost]] [--ask-password] [--password-file <filepath>] [--roles 'role1,role2'] [--extra '{\"foo\": \"bar\"}'] <username>\n\n")
handleSet([]string{"--help"}, nil, nil)
fmt.Fprintf(os.Stderr, "\n")
handleCheck([]string{"--help"}, nil, nil)
fmt.Fprintf(os.Stderr, "\n")
switch subcmd {
case "--help", "-help", "help":
return
default:
os.Exit(1)
}
}
}
func getCSVPath() string {
path := os.Getenv(defaultCSVFileENVName)
if len(path) == 0 {
path = defaultCSVPath
}
return path
}
func getOrCreateAESKey(envname, filename string) ([]byte, error) {
aesKey, err := getAESKey(envname, filename)
if err != nil {
if !os.IsNotExist(err) {
return nil, err
}
}
if aesKey != nil {
return aesKey, nil
}
if err := os.MkdirAll(filepath.Dir(filename), 0750); err != nil {
return nil, fmt.Errorf("failed to create directory for %s: %v", filename, err)
}
fmt.Fprintf(os.Stderr, "Creating new AES-128 key at %s\n", filename)
key := make([]byte, 16)
if _, err = io.ReadFull(rand.Reader, key); err != nil {
panic(err) // the universe has run out of entropy
}
hexKey := hex.EncodeToString(key) + "\n"
if err := os.WriteFile(filename, []byte(hexKey), 0640); err != nil {
return nil, fmt.Errorf("failed to write %s: %v", filename, err)
}
return aesKey, nil
}
func getAESKey(envname, filename string) ([]byte, error) {
envKey := os.Getenv(envname)
if envKey != "" {
key, err := hex.DecodeString(strings.TrimSpace(envKey))
if err != nil || len(key) != 16 {
return nil, fmt.Errorf("invalid %s: must be 32-char hex string", envname)
}
fmt.Fprintf(os.Stderr, "Found AES Key in %s\n", envname)
return key, nil
}
if _, err := os.Stat(filename); err != nil {
return nil, err
}
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %v", filename, err)
}
key, err := hex.DecodeString(strings.TrimSpace(string(data)))
if err != nil || len(key) != 16 {
return nil, fmt.Errorf("invalid key in %s: must be 32-char hex string", filename)
}
// relpath := strings.Replace(filename, homedir, "~", 1)
fmt.Fprintf(os.Stderr, "Found AES Key at %s\n", filename)
return key, nil
}
func getOrCreateCSVFile(csvPath string) (csvauth.NamedReadCloser, error) {
r, err := getCSVFile(csvPath)
if err != nil {
if !os.IsNotExist(err) {
return nil, err
}
csvAbs, err := filepath.Abs(csvPath)
if err != nil {
panic(err)
}
fmt.Fprintf(os.Stderr, "Creating new credentials csv at %s\n", csvAbs)
r, err = os.OpenFile(csvPath, os.O_RDWR|os.O_CREATE, 0640)
if err != nil {
return nil, err
}
}
return r, nil
}
func getCSVFile(csvPath string) (csvauth.NamedReadCloser, error) {
f, csvErr := os.Open(csvPath)
if csvErr != nil {
return nil, csvErr
}
csvAbs, err := filepath.Abs(csvPath)
if err != nil {
panic(err)
}
fmt.Fprintf(os.Stderr, "Found credentials db at %s\n", csvAbs)
return f, nil
}
func handleInit(keyenv, keypath, csvpath string) error {
_, keyErr := getOrCreateAESKey(keyenv, keypath)
_, csvErr := getOrCreateCSVFile(csvpath)
if keyErr != nil {
return keyErr
}
if csvErr != nil {
return csvErr
}
return nil
}
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'")
roleList := storeFlags.String("roles", "", "a space- or comma-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")
// storeFlags.StringVar(&tsvPath, "tsv", tsvPath, "Credentials file to use")
if err := storeFlags.Parse(args); err != nil {
if err == flag.ErrHelp {
flag.PrintDefaults()
}
return
}
if len(storeFlags.Args()) > 1 {
fmt.Fprintf(os.Stderr, "too many arguments: %q\n", strings.Join(storeFlags.Args(), " "))
fmt.Fprintf(os.Stderr, "note: flags should come before arguments\n")
os.Exit(1)
}
name := storeFlags.Arg(0)
switch name {
case "id", "name", "purpose":
fmt.Fprintf(os.Stderr, "invalid username %q\n", name)
os.Exit(1)
}
if len(*algorithm) == 0 {
if *purpose == "login" {
*algorithm = "pbkdf2"
} else {
// *algorithm = "plain"
*algorithm = "aes-128-gcm"
}
}
if *purpose != "login" {
*askPassword = true
}
var pass string
if len(*passwordFile) > 0 {
data, err := os.ReadFile(*passwordFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading password file: %v\n", err)
os.Exit(1)
}
pass = strings.TrimSpace(string(data))
} else if *askPassword {
fmt.Fprintf(os.Stderr, "New Password: ")
reader := bufio.NewReader(os.Stdin)
data, err := reader.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading password from stdin: %v\n", err)
os.Exit(1)
}
pass = strings.TrimSpace(data)
} else {
pass = generatePassword()
fmt.Println(pass)
}
*algorithm = strings.ReplaceAll(*algorithm, ",", " ")
params := strings.Split(*algorithm, " ")
switch params[0] {
case "aes", "aes128", "aes-128":
params[0] = "aes-128-gcm"
}
var roles []string
if len(*roleList) > 0 {
*roleList = strings.ReplaceAll(*roleList, ",", " ")
roles = strings.Split(*roleList, " ")
}
defer func() { _ = csvFile.Close() }()
auth := csvauth.New(aesKey)
c := auth.NewCredential(*purpose, name, pass, params, roles, *extra)
if err := auth.LoadCSV(csvFile, '\t'); err != nil {
fmt.Fprintf(os.Stderr, "Error loading CSV: %v\n", err)
os.Exit(1)
}
_ = csvFile.Close()
var exists bool
if len(*purpose) > 0 && *purpose != "login" {
if _, err := auth.LoadServiceAccount(*purpose); err != nil {
if !errors.Is(csvauth.ErrNotFound, err) {
fmt.Fprintf(os.Stderr, "could not load %s: %v\n", *purpose, err)
}
} else {
exists = true
}
c.Purpose = *purpose
_ = auth.CacheServiceAccount(*c)
} else {
if _, err := auth.LoadCredential(name); err != nil {
if !errors.Is(csvauth.ErrNotFound, err) {
fmt.Fprintf(os.Stderr, "could not load %s: %v\n", name, err)
}
} else {
exists = true
}
_ = auth.CacheCredential(*c)
}
var records [][]string
for _, purpose := range slices.Sorted(auth.ServiceAccountKeys()) {
c, _ := auth.LoadServiceAccount(purpose)
record := c.ToRecord()
records = append(records, record)
}
for _, u := range slices.Sorted(auth.CredentialKeys()) {
c, _ := auth.LoadCredential(u)
record := c.ToRecord()
records = append(records, record)
}
writeCSV(csvFile.Name(), records)
if exists {
fmt.Fprintf(os.Stderr, "Wrote %q with new password for %q\n", csvFile.Name(), name)
} else {
fmt.Fprintf(os.Stderr, "Added password for %q to %q\n", name, csvFile.Name())
}
}
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")
// storeFlags.StringVar(&tsvPath, "tsv", tsvPath, "Credentials file to use")
if err := checkFlags.Parse(args); err != nil {
if err == flag.ErrHelp {
flag.PrintDefaults()
}
return
}
if len(checkFlags.Args()) > 1 {
fmt.Fprintf(os.Stderr, "too many arguments: %q\n", strings.Join(checkFlags.Args(), " "))
fmt.Fprintf(os.Stderr, "note: flags should come before arguments\n")
os.Exit(1)
}
name := checkFlags.Arg(0)
switch name {
case "id", "name", "purpose":
fmt.Fprintf(os.Stderr, "invalid username %q\n", name)
os.Exit(1)
}
var pass string
if len(*passwordFile) > 0 {
data, err := os.ReadFile(*passwordFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading password file: %v\n", err)
os.Exit(1)
}
pass = strings.TrimSpace(string(data))
} else {
fmt.Fprintf(os.Stderr, "Current Password: ")
reader := bufio.NewReader(os.Stdin)
data, err := reader.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading password from stdin: %v\n", err)
os.Exit(1)
}
pass = strings.TrimSpace(data)
}
defer func() { _ = csvFile.Close() }()
auth := csvauth.New(aesKey)
if err := auth.LoadCSV(csvFile, '\t'); err != nil {
fmt.Fprintf(os.Stderr, "Error loading CSV: %v\n", err)
os.Exit(1)
}
var v csvauth.BasicAuthVerifier
var err error
if *purpose != "login" {
v, err = auth.LoadServiceAccount(*purpose)
if err != nil {
fmt.Fprintf(os.Stderr, "couldn't load %s: %v", *purpose, err)
os.Exit(1)
}
} else {
v = auth
}
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
}
fmt.Println("verified")
}
func writeCSV(csvPath string, records [][]string) {
f, err := os.Create(csvPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating CSV: %v\n", err)
os.Exit(1)
}
defer func() { _ = f.Close() }()
writer := csv.NewWriter(f)
writer.Comma = '\t'
_ = writer.Write([]string{"purpose", "name", "algo", "salt", "derived", "roles", "extra"})
for _, record := range records {
_ = writer.Write(record)
}
writer.Flush()
if err := writer.Error(); err != nil {
fmt.Fprintf(os.Stderr, "Error writing CSV: %v\n", err)
os.Exit(1)
}
}
func generatePassword() string {
bytes := make([]byte, passwordEntropy)
if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
panic(err) // the universe has run out of entropy
}
encoded := base64.RawURLEncoding.EncodeToString(bytes)
parts := make([]string, 4)
start := 0
for i := range 4 {
parts[i] = encoded[start : start+4]
start += 4
}
return strings.Join(parts, "-")
}