1
0

checking in

This commit is contained in:
brent saner
2025-12-23 20:58:56 -05:00
parent d94a46af0b
commit 84845f9fe5
37 changed files with 3117 additions and 642 deletions

View File

@@ -1,26 +1,16 @@
package internal
type (
Args struct {
Version bool `short:"v" long:"version" description:"Print the version and exit."`
DetailVersion bool `short:"V" long:"detail" description:"Print detailed version info and exit."`
DoDebug bool `env:"VTOTP_DEBUG" short:"d" long:"debug" description:"If specified, enable debug logging. This may log potentially sensitive information, so be careful."`
VaultToken string `env:"VAULT_TOKEN" short:"t" long:"vault-token" description:"Vault token to use. If empty/not defined, you will be securely prompted."`
VaultAddr string `env:"VAULT_ADDR" short:"a" long:"vault-addr" default:"https://clandestine.r00t2.io/" description:"Vault address to use."`
QrImgPath string `env:"VTOTP_QR" short:"q" long:"qr-img" description:"Path to QR image to extract OTPAuth URLs from. Either -q/--qr-img, -f/--otp-file, -u/--otp-url, and/or -x/--explicit must be specified." validate:"required_without=OtpFile OtpUrl OtpExplicit,filepath"`
OtpFile string `env:"VTOTP_FILE" short:"f" long:"otp-file" description:"Path to file containing OTPAuth URLs in plaintext, one per line. Either -q/--qr-img, -f/--otp-file, -u/--otp-url, and/or -x/--explicit must be specified." validate:"required_without=QrImgPath OtpUrl OtpExplicit,filepath"`
OtpUrl string `env:"VTOTP_URL" short:"u" long:"otp-url" description:"Explicit OTPAuth URL. Either -q/--qr-img, -f/--otp-file, -u/--otp-url, and/or -x/--explicit must be specified." validate:"required_without=QrImgPath OtpFile OtpExplicit,url"`
OtpExplicit bool `short:"x" long:"otp-explicit" description:"If specified, use the explicit OTP specification under the EXPLICIT TOTP group."`
ExplicitOtp ExplicitOtp `group:"EXPLICIT TOTP" env-namespace:"VTOTP_X"`
CommonArgs struct {
Version bool `short:"v" long:"version" description:"Print the version and exit."`
DetailVersion bool `short:"V" long:"detail" description:"Print detailed version info and exit."`
DoDebug bool `env:"VTOTP_DEBUG" short:"d" long:"debug" description:"If specified, enable debug logging. This may log potentially sensitive information, so be careful."`
VaultArgs `group:"Common Vault Options" env-namespace:"VAULT" namespace:"vault"`
}
ExplicitOtp struct {
Type string `env:"TYP" short:"y" long:"type" choice:"totp" choice:"hotp" default:"totp" hidden:"true" description:"The OTP type." validate:"required_with=OtpExplicit,oneof=totp hotp"`
Counter uint64 `env:CTR" short:"c" long:"counter" hidden:"true" description:"The initial counter value (if -y/--type='hotp')." validate:"required_with=OtpExplicit,required_if=Type hotp"`
Account string `env:"ACCT" short:"n" long:"name" description:"Name of the TOTP account (should be just the username)." validate:"required_with=OtpExplicit"`
Issuer string `env:"ISS" short:"i" long:"issuer" description:"Issuer of the TOTP (this is generally the service name you're authing to)." validate:"required_with=OtpExplicit"`
Secret string `env:"SSKEY" short:"s" long:"shared-secret" description:"The shared secret key in Base32 string format (with no padding)." validate:"required_with=OtpExplicit,base32"`
Algorithm string `env:"ALGO" short:"g" long:"algo" choice:"md5" choice:"sha1" choice:"sha256" choice:"sha512" description:"The hashing/checksum algorithm." validate:"required_with=OtpExplicit,oneof=md5 sha1 sha256 sha512"`
Digits int `env:"DIG" short:"l" long:"digits" choice:"6" choice:"8" description:"Number of digits for the generated code." validate:"required_with=OtpExplicit,oneof=6 8"`
Period time.Duration `env:TIME" short:"p" long:"period" default:"30s" description:"The period that a generated code is valid for." validate:"required_with=OtpExplicit,required_if=Type totp"`
VaultArgs struct {
Insecure bool `env:"SKIP_VERIFY" short:"S" long:"insecure" description:"If specified and -u/--uri is using HTTPS, do not require TLS verification (self-signed certificates, etc.)"`
Token string `env:"TOKEN" short:"t" long:"token" description:"Vault token to use. If empty/not defined, you will be securely prompted."`
Addr string `env:"ADDR" short:"a" long:"addr" default:"https://clandestine.r00t2.io/" description:"Vault address to use."`
SniName *string `env:"TLS_SERVER_NAME" short:"S" long:"sni" description:"If specified, use this as the SNI name instead of the host from -a/--addr."`
}
)

View File

@@ -1,22 +1,34 @@
package internal
import (
`os`
`log`
`github.com/go-playground/validator/v10`
`r00t2.io/goutils/logging`
)
const (
ParseNsDelim string = "-"
ParseEnvNsDelim string = "_"
)
const (
cmdPfx string = "vault_totp"
logFlags int = log.LstdFlags | log.Lmsgprefix
logFlagsDebug int = logFlags | log.Llongfile
)
const (
DefAddr string = "https://clandestine.r00t2.io/"
VaultTokEnv string = "VAULT_TOKEN"
CollNm string = "OTP"
TgtMnt string = "totp_bts.work"
// These attrs need to be added to new SecretService TOTP secrets
// ~/.local/share/gnome-shell/extensions/totp@dkosmari.github.com/schemas/org.gnome.shell.extensions.totp.gschema.xml
SsSchemaName string = "xdg:schema"
SsSchemaVal string = "org.gnome.shell.extensions.totp" // also the gosecret.ItemType
)
var (
TermFd int = int(os.Stdin.Fd())
Logger *logging.MultiLogger
validate *validator.Validate = validator.New(validator.WithRequiredStructEnabled())
)
var (

View File

@@ -2,102 +2,371 @@ package internal
import (
`context`
`errors`
`fmt`
`log`
`os`
`strings`
`sync`
`github.com/hashicorp/vault-client-go`
`r00t2.io/gosecret`
`github.com/hashicorp/vault-client-go/schema`
`github.com/jessevdk/go-flags`
`golang.org/x/term`
`r00t2.io/goutils/logging`
`r00t2.io/goutils/multierr`
`r00t2.io/vault_totp/errs`
`r00t2.io/vault_totp/version`
)
func New(vaultTok, vaultAddr, vaultMnt, collNm string) (c *Client, err error) {
/*
GetTotpKey fetches the key info for a key named `keyNm` at TOTP secrets mountpoint `mntPt` using Vault client `vc`.
c = &Client{
// lastIdx: 0,
vtok: vaultTok,
vaddr: vaultAddr,
scollNm: collNm,
vmnt: vaultMnt,
errsDone: make(chan bool, 1),
errChan: make(chan error),
// vc: nil,
wg: sync.WaitGroup{},
ctx: context.Background(),
// ssvc: nil,
// scoll: nil,
mErr: multierr.NewMultiError(nil),
// inSS: nil,
// inVault: nil,
If `mntPt` is empty, it will be set to "totp".
*/
func GetTotpKey(ctx context.Context, keyNm, mntPt string, vc *vault.Client) (kinfo map[string]any, err error) {
var resp *vault.Response[map[string]interface{}]
if strings.TrimSpace(mntPt) == "" {
mntPt = "totp"
}
if c.vc, err = vault.New(vault.WithAddress(c.vaddr)); err != nil {
return
}
if err = c.vc.SetToken(c.vtok); err != nil {
if resp, err = vc.Secrets.TotpReadKey(
ctx,
keyNm,
vault.WithMountPath(mntPt),
); err != nil {
return
}
if c.ssvc, err = gosecret.NewService(); err != nil {
return
}
if c.scoll, err = c.ssvc.GetCollection(collNm); err != nil {
return
kinfo = resp.Data
return
}
/*
GetTotpKeys is like [ListTotpKeys] except it returns the configuration info
for each key as well. (Except the secret - https://github.com/hashicorp/vault/issues/3043)
keyNms, if not specified, will fetch info for all keys on the mountpoint `mntPt`.
*/
func GetTotpKeys(ctx context.Context, vc *vault.Client, mntPt string, keyNms ...string) (keyInfo map[string]map[string]any, err error) {
var totpNm string
var mut sync.Mutex
var wg sync.WaitGroup
var errChan chan error
var mErr *multierr.MultiError = multierr.NewMultiError(nil)
var opts []vault.RequestOption = make([]vault.RequestOption, 0)
var listResp *vault.Response[schema.StandardListResponse]
var respErr *vault.ResponseError = new(vault.ResponseError)
if strings.TrimSpace(mntPt) != "" {
opts = append(opts, vault.WithMountPath(mntPt))
}
go c.readErrs()
if keyNms == nil || len(keyNms) == 0 {
if listResp, err = vc.Secrets.TotpListKeys(
ctx,
opts...,
); err != nil {
if errors.As(err, &respErr) && respErr.StatusCode == 404 {
// Is OK; no keys exist yet.
keyInfo = make(map[string]map[string]any)
err = nil
}
return
}
keyNms = listResp.Data.Keys
}
c.wg.Add(2)
go c.getSS()
go c.getVault()
keyInfo = make(map[string]map[string]any)
if keyNms == nil || len(keyNms) == 0 {
return
}
errChan = make(chan error, len(keyNms))
for _, totpNm = range keyNms {
wg.Add(1)
go getTotpKeyAsync(ctx, totpNm, mntPt, vc, &mut, errChan, &wg, keyInfo)
}
c.wg.Wait()
if !c.mErr.IsEmpty() {
err = c.mErr
wg.Wait()
close(errChan)
for err = range errChan {
if err != nil {
mErr.AddError(err)
err = nil
}
}
if !mErr.IsEmpty() {
err = mErr
return
}
return
}
func normalizeVaultNm(nm string) (normalized string) {
// GetVaultClient returns a Vault client from the provided args.
func GetVaultClient(args *VaultArgs) (c *vault.Client, err error) {
var c rune
var idx int
var last rune
var repl rune = '_'
var reduced []rune = make([]rune, 0)
var norm []rune = make([]rune, 0, len(nm))
var tok string
var vc *vault.Client
var opts []vault.ClientOption
var vaultTls vault.TLSConfiguration
for _, c = range nm {
// If it's "safe" chars, it's fine
if (c == '-' || c == '.') || // 0x2d, 0x2e
(c >= '0' && c <= '9') || // 0x30 to 0x39
(c == '@') || // 0x40
(c >= 'A' && c <= 'Z') || // 0x41 to 0x5a
(c == '_') || // 0x5f
(c >= 'a' && c <= 'z') { // 0x61 to 0x7a
norm = append(norm, c)
continue
}
// Otherwise normalize it to a safe char
norm = append(norm, repl)
if args == nil {
err = errs.ErrNilVault
return
}
// And remove repeating sequential replacers.
for idx, c = range norm[:] {
if idx == 0 {
last = c
reduced = append(reduced, c)
continue
}
if c == last && last == repl {
continue
}
reduced = append(reduced, c)
last = c
tok = args.Token
if err = GetVaultToken(&tok); err != nil {
return
}
normalized = string(reduced)
opts = []vault.ClientOption{vault.WithAddress(args.Addr)}
if args.Insecure || args.SniName != nil {
vaultTls = vault.TLSConfiguration{
InsecureSkipVerify: args.Insecure,
}
if args.SniName != nil {
vaultTls.ServerName = *args.SniName
}
opts = append(
opts,
vault.WithTLS(vaultTls),
)
}
if vc, err = vault.New(opts...); err != nil {
return
}
if err = vc.SetToken(tok); err != nil {
return
}
c = vc
return
}
// GetVaultToken standardizes the Vault token fetching/lookup.
func GetVaultToken(tok *string) (err error) {
var p1 []byte
var oldState *term.State
var termFd int = int(os.Stdin.Fd())
if tok != nil && len(strings.TrimSpace(*tok)) > 0 {
return
}
if oldState, err = term.GetState(termFd); err != nil {
return
}
fmt.Println("Vault token needed.\nVault token (will not be echoed back):")
defer func() {
if err = term.Restore(termFd, oldState); err != nil {
log.Println("restore failed:", err)
}
}()
if p1, err = term.ReadPassword(termFd); err != nil {
return
}
if tok == nil {
tok = new(string)
}
*tok = string(p1)
return
}
/*
ListTotpKeys returns a list (map, really, for easier lookup) of TOTP key names at `mntpt`
with [github.com/hashicorp/vault-client-go.Client] `vc` and [context.Context] `ctx`.
If `mntpt` is empty, the default ("totp") will be used.
If no TOTP keys are found at the mount, `keyNms` will be empty but not nil.
See [ListTotpKeys] if you want additional information about each key.
*/
func ListTotpKeys(ctx context.Context, vc *vault.Client, mntPt string) (keyNms map[string]struct{}, err error) {
var totpNm string
var opts []vault.RequestOption = make([]vault.RequestOption, 0)
var listResp *vault.Response[schema.StandardListResponse]
var respErr *vault.ResponseError = new(vault.ResponseError)
if strings.TrimSpace(mntPt) != "" {
opts = append(opts, vault.WithMountPath(mntPt))
}
if listResp, err = vc.Secrets.TotpListKeys(
ctx,
opts...,
); err != nil {
if errors.As(err, &respErr) && respErr.StatusCode == 404 {
// Is OK; no keys exist yet.
keyNms = make(map[string]struct{})
err = nil
} else {
return
}
} else {
keyNms = make(map[string]struct{})
for _, totpNm = range listResp.Data.Keys {
keyNms[totpNm] = struct{}{}
}
}
return
}
// PrepParser properly initializes the parser and logger in a standardized way.
func PrepParser(cmd string, args CommonArgs, p *flags.Parser) (doExit bool, err error) {
var logFlagsRuntime int = logFlags
var flagsErr *flags.Error = new(flags.Error)
p.NamespaceDelimiter = ParseNsDelim
p.EnvNamespaceDelimiter = ParseEnvNsDelim
if _, err = p.Parse(); err != nil {
switch {
case errors.As(err, &flagsErr):
switch {
case errors.Is(flagsErr.Type, flags.ErrHelp),
errors.Is(flagsErr.Type, flags.ErrCommandRequired),
errors.Is(flagsErr.Type, flags.ErrRequired):
// These print their relevant messages by themselves.
err = nil
return
default:
return
}
default:
return
}
}
if version.Ver, err = version.Version(); err != nil {
return
}
// If args.Version or args.DetailVersion are true, just print them and exit.
if args.DetailVersion || args.Version {
doExit = true
if args.Version {
fmt.Println(version.Ver.Short())
return
} else if args.DetailVersion {
fmt.Println(version.Ver.Detail())
return
}
}
if args.DoDebug {
logFlagsRuntime = logFlagsDebug
}
Logger = logging.GetMultiLogger(
args.DoDebug,
fmt.Sprintf(
"Vault TOTP [%s_%s]",
cmdPfx, cmd,
),
)
if err = Logger.AddDefaultLogger(
"default",
logFlagsRuntime,
"/var/log/vault_totp/vault_totp.log", "~/logs/vault_totp.log",
); err != nil {
log.Panicln(err)
}
if err = Logger.Setup(); err != nil {
log.Panicln(err)
}
Logger.Info("main: Vault TOTP version %v", version.Ver.Short())
Logger.Debug("main: Vault TOTP version (extended):\n%v", version.Ver.Detail())
return
}
// SplitVaultPathspec splits a <mount>[:<path>] into separate components. If no path is provided or it is empty, "/" will be used.
func SplitVaultPathspec(spec string) (mount, secretPath string) {
var spl []string = strings.SplitN(spec, ":", 2)
mount = spl[0]
switch len(spl) {
case 1:
secretPath = "/"
case 2:
if strings.TrimSpace(spl[1]) == "" {
secretPath = "/"
} else {
secretPath = spl[1]
}
}
return
}
// SplitVaultPathspec2 splits a [<mount>:]<path> into separate components.
func SplitVaultPathspec2(spec string) (mount, secretPath string) {
var spl []string = strings.SplitN(spec, ":", 2)
switch len(spl) {
case 1:
secretPath = spl[0]
case 2:
mount = spl[0]
if strings.TrimSpace(spl[1]) == "" {
secretPath = "/"
} else {
secretPath = spl[1]
}
}
return
}
// Validate validates the passed struct `s`. A nil err means validation succeeded.
func Validate(s any) (err error) {
if err = validate.Struct(s); err != nil {
return
}
return
}
// getTotpKeyAsync fetches key info for key named `nm` from Vault `vc`.
func getTotpKeyAsync(
ctx context.Context,
keyNm, mntPt string,
vc *vault.Client,
mut *sync.Mutex, errChan chan error, wg *sync.WaitGroup,
m map[string]map[string]any,
) {
var err error
var kinfo map[string]any
defer wg.Done()
if kinfo, err = GetTotpKey(ctx, keyNm, mntPt, vc); err != nil {
errChan <- err
return
}
// We can wait to hold the lock until we're actually inserting into the map.
mut.Lock()
defer mut.Unlock()
m[keyNm] = kinfo
return
}

View File

@@ -1,269 +0,0 @@
package internal
import (
`errors`
`fmt`
`math`
`net/http`
`strconv`
`strings`
`github.com/creachadair/otp/otpauth`
`github.com/hashicorp/vault-client-go`
`github.com/hashicorp/vault-client-go/schema`
`r00t2.io/gosecret`
`r00t2.io/goutils/multierr`
)
func (c *Client) Close() (err error) {
if err = c.ssvc.Close(); err != nil {
c.errChan <- err
}
close(c.errChan)
<-c.errsDone
return
}
func (c *Client) DeleteAllVaultKeys() {
var name string
for name, _ = range c.inVault {
c.wg.Add(1)
go func(nm string) {
var vErr error
var resp *vault.Response[map[string]interface{}]
defer c.wg.Done()
if resp, vErr = c.vc.Secrets.TotpDeleteKey(
c.ctx,
nm,
vault.WithMountPath(c.vmnt),
); vErr != nil {
c.errChan <- vErr
return
}
_ = resp
}(name)
}
c.wg.Wait()
}
func (c *Client) Errors() (errs []error) {
errs = make([]error, 0)
if c.mErr == nil || c.mErr.IsEmpty() {
return
}
errs = c.mErr.Errors
c.mErr = multierr.NewMultiError(nil)
return
}
// currently it doesn't update out-of-sync keys and only syncs from SecretService to Vault. TODO.
func (c *Client) Sync() {
c.addVault()
c.wg.Wait()
}
func (c *Client) addVault() {
var name string
var wsec wrappedSsSecret
for name, wsec = range c.inSS {
c.wg.Add(1)
go func(nm string, sec wrappedSsSecret) {
var vErr error
var i int
var ok bool
var digSize int
var numSeconds int
// var digSize int32
var otpUrl *otpauth.URL
var req schema.TotpCreateKeyRequest
var resp *vault.Response[map[string]interface{}]
defer c.wg.Done()
if _, ok = c.inVault[nm]; ok {
return
}
if i, vErr = strconv.Atoi(sec.secret.Attrs["digits"]); vErr != nil {
c.errChan <- vErr
return
}
if i > math.MaxInt32 {
c.errChan <- fmt.Errorf("digits too large (%d > %d)", i, math.MaxInt32)
return
}
// digSize = int32(i)
digSize = i
if i, vErr = strconv.Atoi(sec.secret.Attrs["period"]); vErr != nil {
c.errChan <- vErr
return
}
if i > math.MaxInt32 {
c.errChan <- fmt.Errorf("period too large (%d > %d)", i, math.MaxInt32)
return
}
numSeconds = i
otpUrl = &otpauth.URL{
Type: "TOTP", // TODO: https://github.com/dkosmari/gnome-shell-extension-totp/issues/15
Issuer: sec.secret.Attrs["issuer"],
Account: sec.secret.Attrs["name"],
RawSecret: string(sec.secret.Secret.Value),
Algorithm: strings.ToUpper(strings.ReplaceAll(sec.secret.Attrs["algorithm"], "-", "")),
Digits: digSize,
Period: numSeconds,
Counter: 0,
}
fmt.Printf("Adding '%s' to '%s'\n", nm, c.vmnt)
// https://developer.hashicorp.com/vault/api-docs/secret/totp#create-key
// https://pkg.go.dev/github.com/hashicorp/vault-client-go/schema#TotpCreateKeyRequest
req = schema.TotpCreateKeyRequest{
// AccountName: sec.secret.Attrs["name"],
// Algorithm: strings.ToUpper(strings.ReplaceAll(sec.secret.Attrs["algorithm"], "-", "")),
// Digits: digSize,
// Exported: false,
// Generate: false,
// Issuer: sec.secret.Attrs["issuer"],
// Key: string(sec.secret.Secret.Value),
// // KeySize: 0,
// Period: sec.secret.Attrs["period"],
// // QrSize: 0,
// // Skew: 0,
Url: otpUrl.String(),
}
if resp, vErr = c.vc.Secrets.TotpCreateKey(
c.ctx,
nm,
req,
vault.WithMountPath(c.vmnt),
); vErr != nil {
c.errChan <- fmt.Errorf("error adding '%s': %v", nm, vErr)
return
}
_ = resp
}(name, wsec)
}
}
func (c *Client) getSS() {
var err error
var idx int
var nm string
var spl []string
var item *gosecret.Item
var secrets []*gosecret.Item
defer c.wg.Done()
if secrets, err = c.scoll.Items(); err != nil {
c.errChan <- err
return
}
c.inSS = make(map[string]wrappedSsSecret)
for _, item = range secrets {
// Unlike KVv2 names, Vault is *very* picky about key names. Boo.
// nm = fmt.Sprintf("%s/%s", item.Attrs["issuer"], item.Attrs["name"])
nm = fmt.Sprintf(
"%s"+
"."+
"%s",
normalizeVaultNm(item.Attrs["issuer"]),
normalizeVaultNm(item.Attrs["name"]),
)
fmt.Printf("'%s' from '%s' => '%s'\n", item.Attrs["name"], item.Attrs["issuer"], nm)
spl = strings.SplitN(item.LabelName, ":", 2)
if idx, err = strconv.Atoi(spl[0]); err != nil {
c.errChan <- err
continue
}
c.inSS[nm] = wrappedSsSecret{
id: idx,
strippedNm: nm,
nm: item.LabelName,
secret: item,
}
if idx > c.lastIdx {
c.lastIdx = idx
}
}
return
}
func (c *Client) getVault() {
var err error
var nm string
var nms []string
var vErr *vault.ResponseError
var totpKey *vault.Response[map[string]interface{}]
defer c.wg.Done()
var resp *vault.Response[schema.StandardListResponse]
resp, err = c.vc.Secrets.TotpListKeys(
c.ctx,
vault.WithMountPath(c.vmnt),
)
if err != nil {
// no TOTP yet
if errors.As(err, &vErr) && vErr.StatusCode == http.StatusNotFound {
c.inVault = make(map[string]map[string]interface{})
err = nil
return
}
c.errChan <- err
return
}
nms = resp.Data.Keys
c.inVault = make(map[string]map[string]interface{})
for _, nm = range nms {
if totpKey, err = c.vc.Secrets.TotpReadKey(
c.ctx,
nm,
vault.WithMountPath(c.vmnt),
); err != nil {
c.errChan <- err
continue
}
c.inVault[nm] = totpKey.Data
}
return
}
func (c *Client) readErrs() {
var err error
for err = range c.errChan {
if err != nil {
c.mErr.AddError(err)
}
}
c.errsDone <- true
}