diff --git a/TODO b/TODO new file mode 100644 index 0000000..c7a41a9 --- /dev/null +++ b/TODO @@ -0,0 +1,5 @@ +- https://github.com/hashicorp/vault/issues/3043 +- https://github.com/hashicorp/vault/issues/31684 + https://github.com/openbao/openbao/issues/2233 +- https://github.com/hashicorp/vault/issues/31685 + https://github.com/openbao/openbao/issues/2234 diff --git a/build.sh b/build.sh index 7a0324c..77807b2 100755 --- a/build.sh +++ b/build.sh @@ -10,6 +10,10 @@ declare -A os_sfx=( ["windows"]='exe' ["darwin"]='app' ) +declare -A tgts=( + ["amd64"]='x86_64' + ["arm64"]='arm64' +) BUILD_TIME="$(date '+%s')" BUILD_USER="$(whoami)" @@ -77,38 +81,39 @@ set -u # And finally build. mkdir -p ./bin/ +go mod tidy +go get -u ./... +go mod tidy export CGO_ENABLED=0 _origdir="$(pwd)" -_pfx='' -#for cmd in 'discord' 'irc' 'msteams' 'slack' 'xmpp'; do +_pfx='vault_totp_' for cmd_dir in cmd/*; do + cmd_dir="${_origdir}/${cmd_dir}" cmd="$(basename "${cmd_dir}")" - echo "${cmd}..." + _bin="${_pfx}${cmd}" + #echo "${cmd_dir} => ${_bin}..." + echo "${_bin}..." if [ ! -f "${cmd_dir}/main.go" ]; then continue fi cd "${cmd_dir}" - _bin="${_pfx}${cmd}" - for ga in 'amd64' 'arm64'; do - as="${ga}" - if [[ "${ga}" == 'amd64' ]]; then - as='x86_64' - fi - echo -e "\t${as}..." - for osnm in "${!os_sfx[@]}"; do - echo -e "\t\t${osnm}: " + for ga in "${!tgts[@]}"; do + echo -e "\t${ga}..." + for osnm in "${!os_sfx[@]}"; do + echo -e "\t\t${osnm}: " _sfx="${os_sfx[$osnm]}" - bin="${_origdir}/bin/${_bin}-${as}-${CURRENT_VER:-dev}.${_sfx}" + _a="${tgts[$ga]}" + bin="${_origdir}/bin/${_bin}-${_a}-${CURRENT_VER:-dev}.${_sfx}" export GOOS="${osnm}" export GOARCH="${ga}" - echo -e -n "\t\tBuilding '${bin}'..." + echo -e "\t\t\tBuilding '${bin}'..." go build \ -o "${bin}" \ -ldflags \ - "${LDFLAGS_STR}" -# "${LDFLAGS_STR}" \ + "${LDFLAGS_STR}" +# "${LDFLAGS_STR}" \ # *.go - echo " Done." + echo -e "\t\t\tDone." done echo -e "\tDone." done diff --git a/cmd/add/args.go b/cmd/add/args.go new file mode 100644 index 0000000..6c1cb87 --- /dev/null +++ b/cmd/add/args.go @@ -0,0 +1,34 @@ +package main + +import ( + `time` + + "r00t2.io/vault_totp/internal" +) + +type ( + Args struct { + internal.CommonArgs + AddArgs + ExplicitOtp `group:"Explicit OTP Options" env-namespace:"VTOTP"` + } + AddArgs struct { + VaultTotpMnt string `env:"VTOTP_MNT" short:"m" long:"mount" default:"totp" description:"The Vault TOTP generator mount (a 'TOTP secrets' mount) to add to."` + VaultKV2MntPath *string `env:"KV2_MNTP" short:"M" long:"store" description:"The spec to store the QR code and key via otpauth URI (Vault TOTP mounts can't return secret keys). '/' will be appended to the path.\nMust be provided in the form of '[:]'; e.g. 'foo:bar/baz' or 'bar/baz'.\nIf this argument is provided but the mount is not provided, 'secrets' will be used.\nIf this argument isn't provided at all, no QR code/URI storage will be performed."` + 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, -e/--secret-service, and/or -x/--explicit-otp 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, -e/--secret-service, and/or -x/--explicit-otp 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, -e/--secret-service, and/or -x/--explicit-otp must be specified." validate:"required_without=QrImgPath OtpFile OtpExplicit,url"` + SecretService bool `env:"VTOTP_SSVC" short:"e" long:"secret-service" description:"Export from the https://extensions.gnome.org/extension/6793/totp/ GNOME extension. Either -q/--qr-img, -f/--otp-file, -u/--otp-url, -e/--secret-service, and/or -x/--explicit-otp must be specified."` + OtpExplicit bool `short:"x" long:"explicit-otp" description:"If specified, use the explicit OTP specification under the 'Explicit OTP Options' group. Either -q/--qr-img, -f/--otp-file, -u/--otp-url, -e/--secret-service, and/or -x/--explicit-otp must be specified."` + } + ExplicitOtp struct { + Type string `env:"XTYP" short:"y" long:"type" choice:"totp" choice:"hotp" default:"totp" hidden:"true" description:"The OTP type. Only used if -x/--explicit-otp is specified." validate:"required_with=OtpExplicit,oneof=totp hotp"` + Counter uint64 `env:"XCTR" short:"c" long:"counter" hidden:"true" description:"The initial counter value (if -y/--type='hotp'). Only used if -x/--explicit-otp is specified." validate:"required_with=OtpExplicit,required_if=Type hotp"` + Account string `env:"XACCT" short:"n" long:"name" description:"Name of the TOTP account (should be just the username). Only used if -x/--explicit-otp is specified." validate:"required_with=OtpExplicit"` + Issuer string `env:"XISS" short:"i" long:"issuer" description:"Issuer of the TOTP (this is generally the service name you're authing to). Only used if -x/--explicit-otp is specified." validate:"required_with=OtpExplicit"` + Secret string `env:"XSSKEY" short:"s" long:"shared-secret" description:"The shared secret key in Base32 string format (with no padding). Only used if -x/--explicit-otp is specified." validate:"required_with=OtpExplicit,base32"` + Algorithm string `env:"XALGO" short:"g" long:"algo" choice:"md5" choice:"sha1" choice:"sha256" choice:"sha512" description:"The hashing/checksum algorithm. Only used if -x/--explicit-otp is specified." validate:"required_with=OtpExplicit,oneof=md5 sha1 sha256 sha512"` + Digits int `env:"XDIG" short:"l" long:"digits" choice:"6" choice:"8" description:"Number of digits for the generated code. Only used if -x/--explicit-otp is specified." validate:"required_with=OtpExplicit,oneof=6 8"` + Period time.Duration `env:"XTIME" short:"p" long:"period" default:"30s" description:"The period that a generated code is valid for. Only used if -x/--explicit-otp is specified." validate:"required_with=OtpExplicit,required_if=Type totp"` + } +) diff --git a/cmd/add/consts.go b/cmd/add/consts.go new file mode 100644 index 0000000..d8240bf --- /dev/null +++ b/cmd/add/consts.go @@ -0,0 +1,94 @@ +package main + +import ( + `sync` + + `github.com/jessevdk/go-flags` + `r00t2.io/goutils/logging` +) + +var ( + logger logging.Logger + args *Args = new(Args) + parser *flags.Parser = flags.NewParser(args, flags.Default) +) + +var ( + wg sync.WaitGroup + existingOtp map[string]struct{} = make(map[string]struct{}) + urlChan chan parsedUrl = make(chan parsedUrl) + doneChan chan bool = make(chan bool, 1) + vaultReady chan bool = make(chan bool, 1) +) + +const ( + // stdScheme is a public standard, documented at https://github.com/google/google-authenticator/wiki/Key-Uri-Format + stdScheme string = "otpauth://" + /* + googleScheme is a "wrapper" around that that has never been, + to my knowledge, publicly formally documented because we can't have nice things. + */ + googleScheme string = "otpauth-migration://" + + // vaultSep is used to join the issuer and account in a name. + vaultSep string = "." + // defStoreMnt is used if no mount was provided (but a path was) to -M/--store. + defStoreMnt string = "secrets" + + /* + ssCollectionNm is the SecretService collection name used + by https://extensions.gnome.org/extension/6793/totp/ + (https://github.com/dkosmari/gnome-shell-extension-totp) + */ + ssCollectionNm string = "OTP" + + // TODO: Adding to SS + /* + Will look something like this: + + if svc, err = gosecret.NewService(); err != nil { + return + } + defer svc.Close() + + if coll, err = svc.GetCollection(ssCollectionNm); err != nil { + return + } + + secret = gosecret.NewSecret( + svc.Session, + []byte{}, // nil? + gosecret.SecretValue(sharedTotpKey), + ssContentType, + ) + + itemAttrs = map[string]string{ + "type": "TOTP", + "algorithm": "(MD5|SHA-1|SHA-256|SHA-512)", + "digits": "(6|8)", + "issuer": "", + "name": "", + "period": "" + ssSchemaAttr: ssSchemaVal, + } + + if item, err = coll.CreateItem( + "::", + itemAttrs, + secret, + true, + ); err != nil { + return + } + */ + // ssSchemaAttr is used when adding a secret to the SecretService. + ssSchemaAttr string = "xdg:schema" + /* + ssSchemaVal is the value for the attribute specified by ssSchemaAttr. + + It is also the value used as the [r00t2.io/gosecret.Item.SecretType] when adding. + */ + ssSchemaVal string = "org.gnome.shell.extensions.totp" + // ssContentType is the value to use for [r00t2.io/gosecret.Secret.ContentType] when adding. + ssContentType string = "text/plain" +) diff --git a/cmd/add/doc.go b/cmd/add/doc.go new file mode 100644 index 0000000..65a1cc1 --- /dev/null +++ b/cmd/add/doc.go @@ -0,0 +1,4 @@ +/* +vault_totp_add: Add existing TOTP configurations to Vault (Vault as a TOTP generator). +*/ +package main diff --git a/cmd/add/funcs.go b/cmd/add/funcs.go new file mode 100644 index 0000000..e4ba5af --- /dev/null +++ b/cmd/add/funcs.go @@ -0,0 +1,645 @@ +package main + +import ( + `bufio` + `bytes` + `context` + `encoding/base64` + `fmt` + `image` + `image/color` + _ `image/gif` + _ `image/jpeg` + `image/png` + _ `image/png` + `io` + `os` + `strconv` + `strings` + `sync` + + `github.com/creachadair/otp/otpauth` + `github.com/hashicorp/vault-client-go` + `github.com/hashicorp/vault-client-go/schema` + `github.com/makiuchi-d/gozxing` + `github.com/makiuchi-d/gozxing/qrcode` + `github.com/pquerna/otp` + `r00t2.io/gosecret` + `r00t2.io/sysutils/paths` + `r00t2.io/vault_totp/common` + `r00t2.io/vault_totp/internal` +) + +// convertToGray converts image.Image `img` to an 8-bit grayscale image for QR codes. +func convertToGray(img image.Image) (g *image.Gray) { + + var x int + var y int + var rgba color.Color + var bounds image.Rectangle + + bounds = img.Bounds() + + g = image.NewGray(bounds) + + for x = 0; x < bounds.Max.X; x++ { + for y = 0; y < bounds.Max.Y; y++ { + rgba = img.At(x, y) + // g.Set(x, y, rgba) + g.Set(x, y, color.GrayModel.Convert(rgba)) + } + } + + return +} + +/* +convertToMono converts image.Gray `img` (see convertToGray) to a monochrome (2-bit) grayscale image for QR codes. + +If `invert` is true, the bits will be flipped. +(This works best if the original image is sent through convertToMono with invert == false *first*). +*/ +func convertToMono(img image.Image, invert bool) (bw *image.Paletted) { + + var x int + var y int + var g color.Gray + var palette color.Palette + var bounds image.Rectangle + var hiBit color.Color = color.White + var loBit color.Color = color.Black + + if invert { + hiBit = color.Black + loBit = color.White + } + palette = color.Palette{ + loBit, + hiBit, + } + + bounds = img.Bounds() + // bw = image.NewGray(bounds) + bw = image.NewPaletted(bounds, palette) + + for x = 0; x < bounds.Max.X; x++ { + for y = 0; y < bounds.Max.Y; y++ { + g = img.At(x, y).(color.Gray) + // between 0-255 (uint8) + if g.Y > 128 { + bw.Set(x, y, hiBit) + } else { + bw.Set(x, y, loBit) + } + } + } + + return +} + +// decQR reduces some repetition in parseQrFile. err == nil indicates successfull decode. +func decQR(img image.Image) (res *gozxing.Result, err error) { + + var lum gozxing.LuminanceSource + var bmp *gozxing.BinaryBitmap + var binarizer gozxing.Binarizer + var dec gozxing.Reader = qrcode.NewQRCodeReader() + var hints map[gozxing.DecodeHintType]interface{} = map[gozxing.DecodeHintType]interface{}{ + gozxing.DecodeHintType_TRY_HARDER: true, + } + + lum = gozxing.NewLuminanceSourceFromImage(img) + binarizer = gozxing.NewGlobalHistgramBinarizer(lum) + if bmp, err = gozxing.NewBinaryBitmap(binarizer); err != nil { + return + } + if res, err = dec.Decode(bmp, hints); err != nil { + return + } + + return +} + +// normalizeName returns a normalized Vault-safe version of the name. +func normalizeName(issuer, account string) (s string) { + + var i string = issuer + var a string = account + + common.VaultEscape(&i) + common.VaultEscape(&a) + + s = fmt.Sprintf("%s%s%s", i, vaultSep, a) + + return +} + +// parseExplicit parses an explicit OTP specification. +func parseExplicit(e ExplicitOtp) { + + var ok bool + var p parsedUrl = parsedUrl{ + u: nil, + source: fmt.Sprintf("%#v", e), + sourceType: "explicit", + } + + defer wg.Done() + + p.vaultName = normalizeName(e.Issuer, e.Account) + p.u = &otpauth.URL{ + Type: strings.ToLower(e.Type), + Issuer: e.Issuer, + Account: e.Account, + RawSecret: e.Secret, + Algorithm: e.Algorithm, + Digits: e.Digits, + Period: int(e.Period.Seconds()), + Counter: 0, + } + + if _, ok = existingOtp[p.vaultName]; ok { + logger.Debug("parseExplicit: Found existing otp entry for '%s'; skipping.", p.vaultName) + return + } + // Simple as. + urlChan <- p + + return +} + +// parseAsync is a helper for other parsers. +func parseAsync(uriStr, src, srcType string, pwg *sync.WaitGroup) { + + var ok bool + var sL string + var err error + var u *otpauth.URL + var multi []*otpauth.URL + var p parsedUrl = parsedUrl{ + vaultName: "", + u: nil, + source: src, + sourceType: srcType, + } + + defer pwg.Done() + + sL = strings.ToLower(uriStr) + if strings.HasPrefix(sL, stdScheme) { + if p.u, err = otpauth.ParseURL(uriStr); err != nil { + logger.Err("parseAsync: Failed to parse standard URL '%s' for %s '%s': %v", uriStr, srcType, src, err) + return + } + p.vaultName = normalizeName(p.u.Issuer, p.u.Account) + if _, ok = existingOtp[p.vaultName]; ok { + logger.Debug("parseAsync: Found existing otp entry for '%s'; skipping.", p.vaultName) + return + } + urlChan <- p + } else if strings.HasPrefix(sL, googleScheme) { + if multi, err = otpauth.ParseMigrationURL(uriStr); err != nil { + logger.Err("parseAsync: Failed to parse Google URLs for %s '%s': %v", srcType, src, err) + return + } + for _, u = range multi { + p = parsedUrl{ + vaultName: normalizeName(u.Issuer, u.Account), + u: new(otpauth.URL), + source: src, + sourceType: srcType, + } + if _, ok = existingOtp[p.vaultName]; ok { + logger.Debug("parseAsync: Found existing otp entry for '%s'; skipping.", p.vaultName) + continue + } + *p.u = *u + urlChan <- p + } + } else { + logger.Err("parseAsync: invalid uriStr '%s' for %s '%s'", uriStr, srcType, src) + return + } + + return +} + +// parseOtpFile reads in file at filepath `fpath`, reading a TOTP URI per-line. +func parseOtpFile(fpath string) { + + var err error + var f *os.File + var line string + var buf *bufio.Reader + var pwg sync.WaitGroup + var fp string = fpath + + defer wg.Done() + + if err = paths.RealPath(&fp); err != nil { + logger.Err("parseOtpFile: Failed to canonize filepath '%s': %v", fp, err) + return + } + + if f, err = os.Open(fp); err != nil { + logger.Err("parseOtpFile: Failed to open file '%s': %v", fp, err) + return + } + defer func() { + var fErr error + if fErr = f.Close(); fErr != nil { + logger.Err("parseOtpFile: Failed to close file '%s': %v", fp, fErr) + } + }() + buf = bufio.NewReader(f) + + for { + line, err = buf.ReadString('\n') + if err != nil && err != io.EOF { + logger.Err("parseOtpFile: Failed to read from file '%s': %v", fp, err) + return + } + line = strings.TrimSpace(line) + if line != "" { + pwg.Add(1) + go parseAsync(line, fp, "file", &pwg) + } + if err == io.EOF { + break + } + } + + pwg.Wait() + + return +} + +// parseOtpUri parses in an already-formatted TOTP URI. +func parseOtpUri(uriStr string) { + + var pwg sync.WaitGroup + + defer wg.Done() + + pwg.Add(1) + go parseAsync(uriStr, "URI", "", &pwg) + pwg.Wait() + + // parseAsync handles the channel send etc. + + return +} + +// parseQrFile reads in QR code image at filepath `fpath`. +func parseQrFile(fpath string) { + + var err error + var f *os.File + var s string + var bw image.Image + var img image.Image + var pwg sync.WaitGroup + var res *gozxing.Result + var fp string = fpath + + defer wg.Done() + + if err = paths.RealPath(&fp); err != nil { + logger.Err("parseQrFile: Failed to canonize filepath '%s': %v", fp, err) + return + } + + if f, err = os.Open(fp); err != nil { + logger.Err("parseQrFile: Failed to open file '%s': %v", fpath, err) + return + } + + if img, s, err = image.Decode(f); err != nil { + logger.Err("parseQrFile: Failed to decode image '%s': %v", fp, err) + return + } + logger.Debug("parseQrFile: Decoded image '%s', decoded as '%s'", fp, s) + + /* + PHASE 1: LUMINANCE + Luminance is performed at every phase, but this is the only modification + this phase. + This is done to reduce noise in QR images and does a much + better job with "vanity" QR, where they abuse EC to put a logo in + the center or summat. Note that it still doesn't work for QR codes + surrounded by a black boundary and uses black encoding. + https://github.com/makiuchi-d/gozxing/issues/76 + We have a couple things to try for that though if this fails. + */ + if res, err = decQR(img); err != nil { + /* + PHASE 2: CONVERT TO GRAYSCALE + If the above wasn't successful, try with a grayscale image. + */ + bw = convertToGray(img) + if res, err = decQR(bw); err != nil { + /* + PHASE 3: CONVERT TO MONOCHROME + If the above wasn't successful, try with a monochrome image. + */ + bw = convertToMono(bw, false) + if res, err = decQR(bw); err != nil { + /* + PHASE 4 + INVERTED MONOCHROME + If the above wasn't successful, try with the monochrome inverted. + */ + bw = convertToMono(bw, true) + if res, err = decQR(bw); err != nil { + // I'm all out of tricks. + logger.Err("parseQrFile: Failed to decode image '%s' as original, grayscale, monochrome, or inverted monochrome: %v", fp, err) + return + } + } + } + } + + pwg.Add(1) + go parseAsync(res.GetText(), fp, "QR code file", &pwg) + pwg.Wait() + + // parseAsync handles the channel send etc. + + return +} + +// parseSs reads in TOTP information from the TOTP GNOME extension storage in SecretService. +func parseSs() { + + var err error + var pwg sync.WaitGroup + var item *gosecret.Item + var svc *gosecret.Service + var items []*gosecret.Item + var coll *gosecret.Collection + + defer wg.Done() + + if svc, err = gosecret.NewService(); err != nil { + logger.Err("parseSs: Failed to attach to gosecret service: %v", err) + return + } + defer func() { + var sErr error + if sErr = svc.Close(); sErr != nil { + logger.Warning("parseSs: Received error closing gosecret service: %v", sErr) + } + }() + + if coll, err = svc.GetCollection(ssCollectionNm); err != nil { + logger.Err("parseSs: Failed to fetch gosecret collection '%s': %v", ssCollectionNm, err) + return + } + + if items, err = coll.Items(); err != nil { + logger.Err("parseSs: Failed to fetch gosecret items from '%s': %v", ssCollectionNm, err) + return + } + + for _, item = range items { + pwg.Add(1) + go parseSsItemAsync(*item, &pwg) + } + + pwg.Wait() + + return +} + +// parseSsItemAsync is used by parseSs. +func parseSsItemAsync(item gosecret.Item, pwg *sync.WaitGroup) { + + var err error + var i int + var ok bool + var s string + var iss string + var acct string + var p parsedUrl + var spl []string + + defer pwg.Done() + + // Fallback values from the name, though these *should* be attributes in the Item. + // OTP plugin uses secret names like: + // :: + // where is a zero-indexed unsigned integer that determines a secret's position. + // No, I don't know why he didn't just set the ordering/weight as an item attribute. + spl = strings.SplitN(item.LabelName, ":", 3) + + if iss, ok = item.Attrs["issuer"]; !ok { + switch len(spl) { + case 1: + iss = spl[0] + default: + iss = spl[1] + } + } + if acct, ok = item.Attrs["name"]; !ok { + switch len(spl) { + case 1: + acct = spl[0] + case 2: + acct = spl[1] + default: + acct = spl[2] + } + } + + p = parsedUrl{ + vaultName: normalizeName(iss, acct), + u: nil, + source: fmt.Sprintf("%s/%s", ssCollectionNm, item.LabelName), + sourceType: "SecretService", + } + if _, ok = existingOtp[p.vaultName]; ok { + logger.Debug("parseSsItemAsync: Found existing otp entry for '%s'; skipping.", p.vaultName) + return + } + + p.u = &otpauth.URL{ + Type: "", + Issuer: iss, + Account: acct, + RawSecret: string(item.Secret.Value), + Algorithm: "", + Digits: 0, + Period: 0, + Counter: 0, + } + + if s, ok = item.Attrs["type"]; ok { + p.u.Type = strings.ToLower(s) + } else { + p.u.Type = "totp" + } + + if s, ok = item.Attrs["algorithm"]; ok { + p.u.Algorithm = strings.ToUpper( + strings.ReplaceAll(s, "-", ""), + ) + } else { + p.u.Algorithm = "SHA256" // Probably the most common. + } + + if s, ok = item.Attrs["digits"]; ok { + if i, err = strconv.Atoi(s); err != nil { + logger.Err("parseSsItemAsync: Failed to parse digits '%s' for %s '%s': %v", s, p.sourceType, p.source, err) + return + } + p.u.Digits = i + } else { + p.u.Digits = 6 + } + + if s, ok = item.Attrs["period"]; ok { + if i, err = strconv.Atoi(s); err != nil { + logger.Err("parseSsItemAsync: Failed to parse perid '%s' for %s '%s': %v", s, p.sourceType, p.source, err) + return + } + } else { + p.u.Period = 30 + } + + urlChan <- p + + return +} + +// readUrls reads in parsed OTPAuth URLs from various sources and writes them to Vault. +func readUrls() { + + var err error + var u parsedUrl + var vc *vault.Client + var pwg sync.WaitGroup + var ctx context.Context = context.Background() + + if vc, err = internal.GetVaultClient(&args.VaultArgs); err != nil { + logger.Err("readUrls: Failed to get Vault client: %v", err) + vaultReady <- false + return + } + + if existingOtp, err = internal.ListTotpKeys(ctx, vc, args.AddArgs.VaultTotpMnt); err != nil { + logger.Err("readUrls: Failed to list totp keys: %v", err) + vaultReady <- false + return + } + vaultReady <- true + + for u = range urlChan { + if u.u != nil { + logger.Debug("readUrls: Read in '%s' from %s '%s'", u.u.String(), u.sourceType, u.source) + pwg.Add(1) + go storeUrl(u, ctx, vc, &pwg) + if _, err = vc.Secrets.TotpCreateKey( + ctx, + u.vaultName, + schema.TotpCreateKeyRequest{ + AccountName: "", + Algorithm: "", + Digits: 0, + Exported: false, + Generate: false, + Issuer: "", + Key: "", + KeySize: 0, + Period: "", + QrSize: 0, + Skew: 0, + Url: u.u.String(), + }, + vault.WithMountPath(args.AddArgs.VaultTotpMnt), + ); err != nil { + logger.Err("readUrls: Failed to create key '%s' on '%s': %v", u.vaultName, args.AddArgs.VaultTotpMnt, err) + err = nil + continue + } + } + } + + pwg.Wait() + + doneChan <- true +} + +func storeUrl(p parsedUrl, vctx context.Context, vc *vault.Client, pwg *sync.WaitGroup) { + + var err error + var mnt string + var qrB64 string + var secPath string + var img image.Image + var otpk *otp.Key + var dat map[string]interface{} + var buf *bytes.Buffer = new(bytes.Buffer) + + defer pwg.Done() + + if args.AddArgs.VaultKV2MntPath == nil { + return + } + + // QR CODE. Any failures here should be non-fatal; capturing the secret is more important. + if otpk, err = otp.NewKeyFromURL(p.u.String()); err != nil { + logger.Err("storeUrl: Failed to create OTP key for '%s': %v", p.u.String(), err) + } else { + if img, err = otpk.Image(512, 512); err != nil { + logger.Err("storeUrl: Failed to create QR code for '%s': %v", p.u.String(), err) + } else { + // WHY DOES IT CREATE A 16-BIT IMAGE. + // IT'S MONOCHROME; IT USES A WHOLE-ASS 1 BIT OF FIDELITY. + // We can save a whole-ass ~17% reduction by just converting to 8-bit, + // but we save ~32% reduction by going to 1-bit (true monochrome). + img = convertToMono(convertToGray(img), false) + if err = png.Encode(buf, img); err != nil { + logger.Err("storeUrl: Failed to encode QR code for '%s': %v", p.u.String(), err) + } else { + qrB64 = base64.StdEncoding.EncodeToString(buf.Bytes()) + } + } + } + + mnt, secPath = internal.SplitVaultPathspec2(*args.AddArgs.VaultKV2MntPath) + if mnt == "" { + mnt = defStoreMnt + } + secPath = path.Join(secPath, p.u.Issuer, p.u.Account) + + dat = map[string]interface{}{ + // PNG image data, 512 x 512, 16-bit grayscale, non-interlaced + "qr_b64": qrB64, + "uri_str": p.u.String(), + "generator": fmt.Sprintf( + "[%s]%s", + args.AddArgs.VaultTotpMnt, + ), + "uri": map[string]any{ + "account": p.u.Account, + "algorithm": p.u.Algorithm, + "digits": p.u.Digits, + "issuer": p.u.Issuer, + "period": p.u.Period, + "secret": p.u.RawSecret, + }, + } + + if _, err = vc.Secrets.KvV2Write( + vctx, + secPath, + schema.KvV2WriteRequest{ + Data: dat, + // Options: nil, + }, + vault.WithMountPath(mnt), + ); err != nil { + logger.Err("storeUrl: Failed to store key '%s' from %s '%s': %v", p.vaultName, p.sourceType, p.source, err) + return + } +} diff --git a/cmd/add/main.go b/cmd/add/main.go new file mode 100644 index 0000000..db05a55 --- /dev/null +++ b/cmd/add/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "log" + "os" + + "r00t2.io/vault_totp/internal" +) + +func main() { + + var err error + var s string + var doExit bool + var vaultOk bool + + log.SetOutput(os.Stdout) + + if doExit, err = internal.PrepParser("add", args.CommonArgs, parser); err != nil { + log.Panicln(err) + } + if doExit { + return + } + logger = internal.Logger + + if err = internal.Validate(args); err != nil { + log.Panicln(err) + } + + go readUrls() + + vaultOk = <-vaultReady + if !vaultOk { + log.Panicln("Could not get OK from Vault client.") + } + + if args.AddArgs.OtpFile != nil && len(args.AddArgs.OtpFile) > 0 { + for _, s = range args.AddArgs.OtpFile { + wg.Add(1) + go parseOtpFile(s) + } + } + if args.AddArgs.OtpExplicit { + wg.Add(1) + go parseExplicit(args.ExplicitOtp) + } + if args.AddArgs.OtpUrl != nil && len(args.AddArgs.OtpUrl) > 0 { + for _, s = range args.AddArgs.OtpUrl { + wg.Add(1) + go parseOtpUri(s) + } + } + if args.AddArgs.QrImgPath != nil && len(args.AddArgs.QrImgPath) > 0 { + for _, s = range args.AddArgs.QrImgPath { + wg.Add(1) + go parseQrFile(s) + } + } + if args.AddArgs.SecretService { + wg.Add(1) + go parseSs() + } + + go func() { + wg.Wait() + close(urlChan) + }() + + <-doneChan + + logger.Debug("main: Done.") +} diff --git a/cmd/add/types.go b/cmd/add/types.go new file mode 100644 index 0000000..a6049ef --- /dev/null +++ b/cmd/add/types.go @@ -0,0 +1,14 @@ +package main + +import ( + `github.com/creachadair/otp/otpauth` +) + +type ( + parsedUrl struct { + vaultName string + u *otpauth.URL + source string + sourceType string + } +) diff --git a/cmd/gen/args.go b/cmd/gen/args.go new file mode 100644 index 0000000..bd10f53 --- /dev/null +++ b/cmd/gen/args.go @@ -0,0 +1,23 @@ +package main + +import ( + "r00t2.io/vault_totp/internal" +) + +type ( + Args struct { + internal.CommonArgs + GenArgs + } + GenArgs struct { + VaultTotpMnt string `env:"VTOTP_MNT" short:"m" long:"mount" default:"totp" description:"The Vault TOTP generator mount (a 'TOTP secrets' mount) to fetch a code for -k/--key from (or list key names from)."` + KeyNm []string `env:"VTOTP_GENK" short:"k" long:"key" description:"Key name(s) to generate code(s) for."` + Readable bool `env:"VTOTP_RD" short:"R" long:"readable" description:"If specified, the code will be spaced out to be a bit more readable."` + Repeat int `env:"VTOTP_RPT" short:"r" long:"repeat" description:"If non-zero, repeat code generation this many times. A negative number will generate codes indefinitely until ctrl-c is pressed/the program is killed. The default is to not repeat (0 == a single code, 1 == 2 codes generated, etc.)."` + // The below are hidden (hidden:"true") until either https://github.com/hashicorp/vault/issues/31684 and/or https://github.com/openbao/openbao/issues/2233 + // I kind of fudge it for now. TODO. + NoCtr bool `hidden:"true" env:"VTOTP_NOCTR" short:"q" long:"no-ctr" description:"If specified, do not perform a countdown of validity; just print the generated code to the terminal and exit immediately after."` + PrintExpiry bool `hidden:"true" env:"VTOTP_EXPIRY" short:"e" long:"expiry" description:"If -q/--no-ctr is specified, also print the validity duration and expiration time (but do not animate a countdown, just print the validity/expiration and code and exit). The validity is always printed if a counter is."` + Plain []bool `hidden:"true" env:"VTOTP_PLN" short:"p" long:"plain" description:"If specified, use a countdown timer more friendly to non-unicode terminals. Can be repeated for to three levels of increasing 'plain-ness'. Has no effect if -q/--no-ctr is specified. (Level 3 plainness is restricted to ASCII; no UTF-8.)"` + } +) diff --git a/cmd/gen/consts.go b/cmd/gen/consts.go new file mode 100644 index 0000000..081366d --- /dev/null +++ b/cmd/gen/consts.go @@ -0,0 +1,90 @@ +package main + +import ( + `context` + `os` + `sync` + + `github.com/hashicorp/vault-client-go` + `github.com/jessevdk/go-flags` + `github.com/pterm/pterm` + `r00t2.io/goutils/logging` +) + +var ( + logger logging.Logger + args *Args = new(Args) + parser *flags.Parser = flags.NewParser(args, flags.Default) +) + +var ( + vc *vault.Client + wg sync.WaitGroup + cancelFunc context.CancelFunc + ctx context.Context = context.Background() + // keyed on vault TOTP key name + otpCfgs map[string]*otpCfg = make(map[string]*otpCfg) + // keyed on Vault TOTP key name + kinfo map[string]map[string]any = make(map[string]map[string]any) +) + +var ( + // This looks too goofy with the half-hour face. + // It'd be better if I could do it in 15m increments, + // but that doesn't exist in UTF-8 - half-hour-past is the closest fidelity we get... + clocksFull []string = []string{ + "๐Ÿ•›", "๐Ÿ•ง", "๐Ÿ•", "๐Ÿ•œ", + "๐Ÿ•‘", "๐Ÿ•", "๐Ÿ•’", "๐Ÿ•ž", + "๐Ÿ•“", "๐Ÿ•Ÿ", "๐Ÿ•”", "๐Ÿ• ", + "๐Ÿ••", "๐Ÿ•ก", "๐Ÿ•–", "๐Ÿ•ข", + "๐Ÿ•—", "๐Ÿ•ฃ", "๐Ÿ•˜", "๐Ÿ•ค", + "๐Ÿ•™", "๐Ÿ•ฅ", "๐Ÿ•š", "๐Ÿ•ฆ", + } + + // So do it hourly instead. + clocksHourly []string = []string{ + "๐Ÿ•›", "๐Ÿ•", "๐Ÿ•‘", "๐Ÿ•’", + "๐Ÿ•“", "๐Ÿ•”", "๐Ÿ••", "๐Ÿ•–", + "๐Ÿ•—", "๐Ÿ•˜", "๐Ÿ•™", "๐Ÿ•š", + } + + // Level 1 "plain" (the clocks are level 0) + plain1 []string = []string{ + "โ—ท", "โ—ถ", "โ—ต", "โ—ด", + } + plain2 []string = []string{ + "โ ˆโ ", "โ ˆโ ‘", "โ ˆโ ฑ", "โ ˆโกฑ", "โข€โกฑ", "โข„โกฑ", "โข„โกฑ", "โข†โกฑ", "โขŽโกฑ", + } + // plain3 should restrict to pure ASCII + plain3 []string = []string{ + "|", "/", "-", "\\", "-", "|", + } + + // indexed on level of plainness + spinnerChars [][]string = [][]string{ + clocksHourly, + plain1, + plain2, + plain3, + } + + // charSet is set to one of spinnerChars depending on args.GenArgs.Plain level. + charSet []string + + /* + Previously I was going to use https://pkg.go.dev/github.com/chelnak/ysmrr for this. + Namely because I *thought* it was the only spinner lib that can do multiple spinners at once. + (Even submitted a PR, https://github.com/chelnak/ysmrr/pull/88) + + HOWEVER, all spinners are synced to use the same animation... which means they all use the same + frequency/rate of update. + I want to sync it so the "animation" ends when the TOTP runs out (or thereabouts). + + pterm to the rescue. + */ + codeMulti pterm.MultiPrinter = pterm.MultiPrinter{ + IsActive: false, + Writer: os.Stdout, + UpdateDelay: 0, + } +) diff --git a/cmd/gen/doc.go b/cmd/gen/doc.go new file mode 100644 index 0000000..d3c39ca --- /dev/null +++ b/cmd/gen/doc.go @@ -0,0 +1,4 @@ +/* +vault_totp_gen: Generate a TOTP code from a TOTP secret. +*/ +package main diff --git a/cmd/gen/funcs.go b/cmd/gen/funcs.go new file mode 100644 index 0000000..ce41723 --- /dev/null +++ b/cmd/gen/funcs.go @@ -0,0 +1,180 @@ +package main + +import ( + `context` + `encoding/json` + `fmt` + `log` + `net/http` + `path` + `strconv` + `time` + + `github.com/davecgh/go-spew/spew` + `github.com/hashicorp/vault-client-go` + `github.com/pterm/pterm` + `r00t2.io/vault_totp/common` + `r00t2.io/vault_totp/errs` +) + +// displayCode fetches a code (getCode) and displays it (or rather, sets up the display for it) according to the settings from args global. +func displayCode(keyNm string) { + + var err error + var code string + var cfg *otpCfg + var itersLeft int + var infinite bool + var spinner pterm.SpinnerPrinter + + defer wg.Done() + + infinite = args.GenArgs.Repeat < 0 + + if args.GenArgs.NoCtr { + if args.GenArgs.Repeat + } + + if code, cfg, err = getCode(ctx, keyNm, args.GenArgs.VaultTotpMnt, args.GenArgs.Readable); err != nil { + logger.Err("displayCode: Received error getting TOTP code and configuration: %v") + return + } + + return +} + +/* +expiryCb is a callback used to pull the ['Date' header] from the response. + +See [hashicorp/vault#31684], [openbao/openbao#2233]. + +['Date' header]: https://datatracker.ietf.org/doc/html/rfc9110#section-6.6.1 +[hashicorp/vault#31684]: https://github.com/hashicorp/vault/issues/31684 +[openbao/openbao#2233]: https://github.com/openbao/openbao/issues/2233 +*/ +func expiryCb(req *http.Request, resp *http.Response) { + + var err error + var i any + var n int + var ok bool + var keyNm string + + keyNm = path.Base(req.URL.Path) + + otpCfgs[keyNm] = &otpCfg{ + keyNm: keyNm, + respDate: time.Time{}, + period: 0, + timeStep: 0, + expiry: time.Time{}, + } + + if i, ok = kinfo["period"]; !ok { + logger.Err("expiryCb: No period found for key '%s' in kinfo", keyNm) + return + } + + switch t := i.(type) { + case string: + // If it's an int string, it's seconds. + if n, err = strconv.Atoi(t); err != nil { + logger.Warning("expiryCb: Invalid period integer for key '%s': %#v: %v", keyNm, i, err) + // It's not a pure int string, so try a time.Duration string (e.g. "30s"). + if otpCfgs[keyNm].period, err = time.ParseDuration(t); err != nil { + logger.Err("expiryCb: Invalid period duration for key '%s': %#v: %v", keyNm, i, err) + return + } + } + case json.Number: + // But I think it's actually a json.Number... + if n, err = strconv.Atoi(string(t)); err != nil { + logger.Warning("expiryCb: Invalid period json.Number for key '%s': %#v: %v", i, keyNm, err) + return + } + case int: + n = t + default: + logger.Err("expiryCb: Invalid period type for key '%s' (%#T): %#v", keyNm, i, i) + return + } + if otpCfgs[keyNm].period == 0 && n != 0 { + // Golang is weird like this but basically time.Duration(n) isn't *actually* meaningful, + // it's just necessary for type matching. + otpCfgs[keyNm].period = time.Second * time.Duration(n) + } else if n == 0 { + logger.Err("expiryCb: Could not derive time primitive for key '%s' from '%#v'", keyNm, i) + return + } + + if otpCfgs[keyNm].respDate, err = http.ParseTime(resp.Header.Get("Date")); err != nil { + logger.Err( + "expiryCb: received error parsing 'Date' header ('%s') for key '%s': %v", + resp.Header.Get("Date"), keyNm, err, + ) + return + } + + otpCfgs[keyNm].timeStep = common.TimeStepFromTime(otpCfgs[keyNm].respDate, otpCfgs[keyNm].period) + otpCfgs[keyNm].startTimeStep, otpCfgs[keyNm].expiry = common.TimeStepToTime(otpCfgs[keyNm].timeStep, otpCfgs[keyNm].period) + + logger.Debug("expiryCb: Derived expiration for key '%s':\n%s", keyNm, spew.Sdump(otpCfgs[keyNm])) + +} + +/* +getCode gets an OTP code from Vault (and populates the corresponding cfg). + +The code will be split with a space if `readable` is true. +*/ +func getCode(ctx context.Context, keyNm, mntPt string, readable bool) (code string, cfg *otpCfg, err error) { + + var i any + var ok bool + var resp *vault.Response[map[string]interface{}] + + if resp, err = vc.Secrets.TotpGenerateCode( + ctx, + keyNm, + vault.WithMountPath(mntPt), + vault.WithResponseCallbacks(expiryCb), + ); err != nil { + log.Panicln(err) + } + + if i, ok = resp.Data["code"]; !ok { + err = errs.ErrNoCode + logger.Err("getCode: Key '%s': %v", keyNm, err) + return + } + + if cfg, ok = otpCfgs[keyNm]; !ok { + err = errs.ErrNoCfg + logger.Err("getCode: Key '%s': %v", keyNm, err) + return + } + + switch t := i.(type) { + case string: + code = t + case json.Number: + code = string(t) + case int: + code = strconv.Itoa(t) + default: + logger.Err("getCode: Invalid type for key '%s' (%#T): %#v", keyNm, i, i) + err = errs.ErrNoCode + return + } + + if readable { + switch len(code) { + case 6: + code = fmt.Sprintf("%s %s", code[:3], code[3:]) + case 8: + code = fmt.Sprintf("%s %s", code[:4], code[4:]) + } + } + + return +} diff --git a/cmd/gen/main.go b/cmd/gen/main.go new file mode 100644 index 0000000..2c09aec --- /dev/null +++ b/cmd/gen/main.go @@ -0,0 +1,104 @@ +package main + +import ( + `fmt` + `log` + `os` + `os/signal` + `sort` + `strings` + `time` + + `github.com/chelnak/ysmrr` + `github.com/chelnak/ysmrr/pkg/animations` + `r00t2.io/vault_totp/common` + `r00t2.io/vault_totp/internal` +) + +func main() { + + var err error + var idx int + var rpt int + var doExit bool + var keyNm string + var keyNms []string + var ticker *time.Ticker + var keys map[string]struct{} = make(map[string]struct{}) + + log.SetOutput(os.Stdout) + + ctx, cancelFunc = signal.NotifyContext(ctx, common.ProgEndSigs...) + + if doExit, err = internal.PrepParser("gen", args.CommonArgs, parser); err != nil { + log.Panicln(err) + } + if doExit { + return + } + logger = internal.Logger + + if err = internal.Validate(args); err != nil { + log.Panicln(err) + } + + if vc, err = internal.GetVaultClient(&args.VaultArgs); err != nil { + log.Panicln(err) + } + + if args.GenArgs.KeyNm == "" { + if keys, err = internal.ListTotpKeys(ctx, vc, args.GenArgs.VaultTotpMnt); err != nil { + log.Panicln(err) + } + keyNms = make([]string, 0, len(keys)) + for keyNm, _ = range args.GenArgs.KeyNm { + keyNms[idx] = keyNm + idx++ + } + sort.Strings(keyNms) + fmt.Printf( + "No key name provided.\n"+ + "Existing key names at mount '%s' are:\n\n"+ + "\t%s\n", + args.GenArgs.VaultTotpMnt, + strings.Join(keyNms, "\n\t"), + ) + os.Exit(0) + } + + if kinfo, err = internal.GetTotpKeys(ctx, vc, args.GenArgs.VaultTotpMnt); err != nil { + log.Panicln(err) + } + if kinfo == nil || len(kinfo) == 0 { + log.Panicln("no TOTP configuration found") + } + + if !args.GenArgs.NoCtr { + if len(args.GenArgs.Plain) > 3 { + args.GenArgs.Plain = args.GenArgs.Plain[:3] + } + charSet = spinnerChars[len(args.GenArgs.Plain)] + } + + if args.GenArgs.Repeat < 0 { + // ticker = time.NewTicker(cfg.period + (time.Millisecond * time.Duration(500))) + ticker = time.NewTicker(cfg.period) + breakLoop: + for { + select { + case <-ctx.Done(): + break breakLoop + case <-ticker.C: + // GET CODE + } + } + } else { + for rpt = args.GenArgs.Repeat; rpt >= 0; rpt-- { + fmt.Fprintf(os.Stderr, "(Issuance round #%d/%d)\n", args.GenArgs.Repeat-rpt, rpt) + // GET CODE + } + } + + // Force close any remaining timing loops, etc. + cancelFunc() +} diff --git a/cmd/gen/types.go b/cmd/gen/types.go new file mode 100644 index 0000000..3ccf707 --- /dev/null +++ b/cmd/gen/types.go @@ -0,0 +1,39 @@ +package main + +import ( + `time` +) + +type ( + // otpCfg is used to pull in several different TOTP information. TODO. + otpCfg struct { + // keyNm is the key name this configuration info is associated with. + keyNm string + /* + respDate is pulled in *separately* from the `date` HTTP header during *code generation*. + + It's very unlikely to be accurate to the TOTP code generation from Vault. + + https://github.com/hashicorp/vault/issues/31684 + https://github.com/openbao/openbao/issues/2233 + */ + respDate time.Time + // period is fetched during *initialization*. + period time.Duration + /* + timeStep is the time step identifier. + + It's created via: + + otpCfg.timeStep = int64(math.Floor(float64(otpCfg.respDate.Unix()) / float64(optCfg.period))) + */ + timeStep int64 + // startTimeStep is when beginning of the current period. + startTimeStep time.Time + /* + expiry is the exact timestamp (at least to the level that is... reasonably determinable, + currently, depending on the above issues under respDate) that the code expires. + */ + expiry time.Time + } +) diff --git a/cmd/kill/args.go b/cmd/kill/args.go new file mode 100644 index 0000000..8f5c0c3 --- /dev/null +++ b/cmd/kill/args.go @@ -0,0 +1,18 @@ +package main + +import ( + `r00t2.io/vault_totp/internal` +) + +type ( + Args struct { + internal.CommonArgs + KillArgs + } + KillArgs struct { + VaultTotpMnt string `env:"VTOTP_MNT" short:"m" long:"mount" default:"totp" description:"The Vault TOTP generator mount (a 'TOTP secrets' mount) to remove from."` + KeyNms []string `env:"VTOTP_DELKEYS" short:"k" long:"key" description:"Key name(s) to delete. If not specified, you will be prompted to delete all existing (see -f/--force)."` + Force bool `env:"VTOTP_FORCE" short:"f" long:"force" description:"Force the deletion of keys (don't prompt for confirmation). If explicit -k/--key key name(s) are provided, specifying this will consolidate all individual prompts into a single one (see -F/--force2). If deleting all keys (no -k/--keys specified), specifying this will bypass the confirmation."` + Force2 bool `env:"VTOTP_FORCE2" short:"F" long:"force2" description:"If -k/--key names were specified and -f/--force was specified, specifying this option in *addition* to -f/--force bypasses the consolidated confirmation as well. Has no effect if no -k/--key is specified or if -f/--force was not specified."` + } +) diff --git a/cmd/kill/consts.go b/cmd/kill/consts.go new file mode 100644 index 0000000..8d3a5f7 --- /dev/null +++ b/cmd/kill/consts.go @@ -0,0 +1,23 @@ +package main + +import ( + `context` + `sync` + + `github.com/hashicorp/vault-client-go` + `github.com/jessevdk/go-flags` + `r00t2.io/goutils/logging` +) + +var ( + logger logging.Logger + args *Args = new(Args) + parser *flags.Parser = flags.NewParser(args, flags.Default) +) + +var ( + vc *vault.Client + wg sync.WaitGroup + ctx context.Context = context.Background() + existingOtp map[string]struct{} = make(map[string]struct{}) +) diff --git a/cmd/kill/doc.go b/cmd/kill/doc.go new file mode 100644 index 0000000..5de737b --- /dev/null +++ b/cmd/kill/doc.go @@ -0,0 +1,4 @@ +/* +vault_totp_kill: Remove configured TOTP generators. +*/ +package main diff --git a/cmd/kill/funcs.go b/cmd/kill/funcs.go new file mode 100644 index 0000000..f2e0734 --- /dev/null +++ b/cmd/kill/funcs.go @@ -0,0 +1,30 @@ +package main + +import ( + `github.com/hashicorp/vault-client-go` +) + +// killKeyAsync kills key keyNm if found in the Vault mount. +func killKeyAsync(keyNm string) { + + var err error + var ok bool + + defer wg.Done() + + if _, ok = existingOtp[keyNm]; !ok { + logger.Warning("Key '%s' does not exist on '%s'; skipping.", keyNm, args.KillArgs.VaultTotpMnt) + return + } + + if _, err = vc.Secrets.TotpDeleteKey( + ctx, + keyNm, + vault.WithMountPath(args.KillArgs.VaultTotpMnt), + ); err != nil { + logger.Err("killKeyAsync: Failed to delete key '%s' on '%s': %v", keyNm, args.KillArgs.VaultTotpMnt, err) + return + } + + return +} diff --git a/cmd/kill/main.go b/cmd/kill/main.go new file mode 100644 index 0000000..7f0b2ed --- /dev/null +++ b/cmd/kill/main.go @@ -0,0 +1,121 @@ +package main + +import ( + `bufio` + `fmt` + `log` + `maps` + `os` + `slices` + `strings` + + `r00t2.io/vault_totp/internal` +) + +func main() { + + var err error + var p string + var doExit bool + var keyNm string + var doGlobal bool + var keyNms []string + var rdr *bufio.Reader = bufio.NewReader(os.Stdin) + var keys map[string]struct{} = make(map[string]struct{}) + + log.SetOutput(os.Stdout) + + if doExit, err = internal.PrepParser("kill", args.CommonArgs, parser); err != nil { + log.Panicln(err) + } + if doExit { + return + } + logger = internal.Logger + + if err = internal.Validate(args); err != nil { + log.Panicln(err) + } + + if vc, err = internal.GetVaultClient(&args.VaultArgs); err != nil { + log.Panicln(err) + } + if existingOtp, err = internal.ListTotpKeys(ctx, vc, args.KillArgs.VaultTotpMnt); err != nil { + log.Panicln(err) + } + if len(existingOtp) == 0 { + fmt.Printf("No existing TOTP keys found at '%s'.\n", args.KillArgs.VaultTotpMnt) + os.Exit(0) + } + if args.KillArgs.KeyNms == nil || len(args.KillArgs.KeyNms) == 0 { + for keyNm = range maps.Keys(existingOtp) { + keys[keyNm] = struct{}{} + keyNms = append(keyNms, keyNm) + } + doGlobal = !args.KillArgs.Force + } else { + if args.KillArgs.Force { + doGlobal = !args.KillArgs.Force2 + } + for _, keyNm = range args.KillArgs.KeyNms { + if !args.KillArgs.Force { + p = "" + fmt.Printf("Delete TOTP '[%s]/%s'? (y/N)\n", args.KillArgs.VaultTotpMnt, keyNm) + if p, err = rdr.ReadString('\n'); err != nil { + log.Panicln(err) + } + if strings.HasPrefix( + strings.ToLower( + strings.TrimSpace( + p, + ), + ), + "y", + ) { + keys[keyNm] = struct{}{} + keyNms = append(keyNms, keyNm) + } + } + } + } + fmt.Println() + if keyNms == nil || len(keyNms) == 0 { + fmt.Printf("No keys selected for deletion on '%s'.\n", args.KillArgs.VaultTotpMnt) + os.Exit(0) + } + slices.Sort(keyNms) + + if doGlobal { + fmt.Printf( + "Will delete the following TOTP keys on mount '%s':\n"+ + "\t* %s"+ + "\n", + args.KillArgs.VaultTotpMnt, + strings.Join(keyNms, "\n\t*"), + ) + fmt.Println("Is this OK? (y/N)") + p = "" + if p, err = rdr.ReadString('\n'); err != nil { + log.Panicln(err) + } + fmt.Println() + if !strings.HasPrefix( + strings.ToLower( + strings.TrimSpace( + p, + ), + ), + "y", + ) { + fmt.Println("Exiting.") + os.Exit(0) + } + } + + for _, keyNm = range keys { + wg.Add(1) + go killKeyAsync(keyNm) + } + + log.Println("Done.") +} diff --git a/cmd/kill_totp_secrets/main.go b/cmd/kill_totp_secrets/main.go deleted file mode 100644 index c2dd8e6..0000000 --- a/cmd/kill_totp_secrets/main.go +++ /dev/null @@ -1,70 +0,0 @@ -package main - -import ( - `fmt` - `log` - `os` - - `update_vault_totp/internal` - - "golang.org/x/term" - "r00t2.io/sysutils/envs" -) - -func getToken() (tok string, err error) { - - var p1 []byte - var oldState *term.State - - if envs.HasEnv(internal.VaultTokEnv) { - tok = os.Getenv(internal.VaultTokEnv) - return - } - - // Prompt for it instead - fmt.Println("Vault token needed.\nVault token (will not be echoed back):") - if oldState, err = term.GetState(internal.TermFd); err != nil { - return - } - defer func() { - if err = term.Restore(internal.TermFd, oldState); err != nil { - log.Println("restore failed:", err) - } - }() - - if p1, err = term.ReadPassword(internal.TermFd); err != nil { - return - } - tok = string(p1) - - return -} - -func main() { - - var err error - var errs []error - var tok string - var c *internal.Client - - if tok, err = getToken(); err != nil { - log.Panicln(err) - } - - if c, err = internal.New(tok, internal.DefAddr, internal.TgtMnt, internal.CollNm); err != nil { - log.Panicln(err) - } - - c.DeleteAllVaultKeys() - - if err = c.Close(); err != nil { - log.Println(err) - } - if errs = c.Errors(); len(errs) > 0 { - for _, err = range errs { - log.Println(err) - } - } - - log.Println("Done.") -} diff --git a/cmd/update_vault_totp/main.go b/cmd/update_vault_totp/main.go deleted file mode 100644 index 7552861..0000000 --- a/cmd/update_vault_totp/main.go +++ /dev/null @@ -1,70 +0,0 @@ -package main - -import ( - `fmt` - `log` - `os` - - `update_vault_totp/internal` - - "golang.org/x/term" - "r00t2.io/sysutils/envs" -) - -func getToken() (tok string, err error) { - - var p1 []byte - var oldState *term.State - - if envs.HasEnv(internal.VaultTokEnv) { - tok = os.Getenv(internal.VaultTokEnv) - return - } - - // Prompt for it instead - fmt.Println("Vault token needed.\nVault token (will not be echoed back):") - if oldState, err = term.GetState(internal.TermFd); err != nil { - return - } - defer func() { - if err = term.Restore(internal.TermFd, oldState); err != nil { - log.Println("restore failed:", err) - } - }() - - if p1, err = term.ReadPassword(internal.TermFd); err != nil { - return - } - tok = string(p1) - - return -} - -func main() { - - var err error - var errs []error - var tok string - var c *internal.Client - - if tok, err = getToken(); err != nil { - log.Panicln(err) - } - - if c, err = internal.New(tok, internal.DefAddr, internal.TgtMnt, internal.CollNm); err != nil { - log.Panicln(err) - } - - c.Sync() - - if err = c.Close(); err != nil { - log.Println(err) - } - if errs = c.Errors(); len(errs) > 0 { - for _, err = range errs { - log.Println(err) - } - } - - log.Println("Done.") -} diff --git a/cmd/user/args.go b/cmd/user/args.go new file mode 100644 index 0000000..2a1006c --- /dev/null +++ b/cmd/user/args.go @@ -0,0 +1,27 @@ +package main + +import ( + `r00t2.io/vault_totp/internal` +) + +type ( + Args struct { + internal.CommonArgs + Mounts Mounts `env-namespace:"VTOTP_MNT" group:"Vault Mounts" namespace:"vmnt"` + Gen GenOpts `env-namespace:"VTOTP_GEN" group:"TOTP Seed Generation"` + } + Mounts struct { + Auth string `env:"AUTHN" short:"a" long:"auth" default:"ldap" description:"The authentication mountpoint for users/entities. Must currently be mounted/configured."` + TotpPath string `env:"PATH" short:"T" long:"path" default:"admin:totp/{{- (index .aliases 0).name -}}" description:"The : specifier for where to save the TOTP secret/seed data. It is normally non-retrievable after generation. The mount name must be a pre-existing KVv2 mount. If the secret exists, it will be overwritten/merged at the root secret level. Supports text/template against the data returned by -l/--lookup-only. Set to an explicitly empty string to disable."` + } + GenOpts struct { + Force bool `env:"FORCE" short:"f" long:"force" description:"Force regenerating the TOTP seed/secret if already set."` + Silent bool `env:"SHH" short:"s" long:"silent" description:"If specified, do not print the TOTP secret information; only store (-T/--totp-path)/dump (-q/--qr-dir) it."` + QrDir string `env:"QRDIR" short:"q" long:"qr-dir" default:"qr_codes" description:"Use this directory to dump QR codes (relative paths are resolved to runtime's current working directory). The files will be named after the matched -e/--entities."` + NoQr bool `env:"NOQR" short:"Q" long:"disable-qr" description:"If specified, do not dump QR codes to -q/--qr-dir."` + PrintQr bool `env:"PQ" short:"p" long:"print-qr" description:"If specified, QR codes will be printed to the console. Overridden by -s/--silent."` + EntityLookup map[string]string `env:"ENT" short:"e" long:"entity" required:"yes" description:"The lookup criteria for an entity. At least one MUST be provided. Takes the format of e.g. '-e \":\"' where is one of 'name', 'id', 'alias_id', or 'alias' and is the matching criteria. (You are likely looking for '-e \"alias:\"'). The entity must exist or already be created."` + LookupOnly bool `env:"DRY" short:"l" long:"lookup-only" description:"If specified, only print the JSON object for the matched entity and exit. This can serve as both guidance for -T/--mnt-totp-path and to ensure you are matching the correct entity."` + LookupFmt []string `env:"LFMT" short:"L" long:"lookup-fmt" choice:"dump" choice:"json" description:"The output format for the result of -l/--lookup-only, if specified. More than one may be specified. The default is 'json'."` + } +) diff --git a/cmd/user/consts.go b/cmd/user/consts.go new file mode 100644 index 0000000..def48cc --- /dev/null +++ b/cmd/user/consts.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "log" + "net/url" + + "github.com/hashicorp/vault-client-go" + vAPI "github.com/hashicorp/vault/api" + "r00t2.io/goutils/logging" +) + +const ( + logFlags int = log.LstdFlags | log.Lmsgprefix + logFlagsDebug int = logFlags | log.Llongfile +) + +var ( + logger *logging.MultiLogger + args *Args = new(Args) + ctx context.Context = context.Background() +) + +var ( + apiTok string + apiUrl *url.URL + api *vAPI.Client + apiCfg *vAPI.Config + apiTls *vAPI.TLSConfig + client *vault.Client + clientOpts []vault.ClientOption + // clientCfg *vault.ClientConfiguration + // reqOpts []vault.RequestOption +) + +var ( + totpUrl *url.URL + authMntId string + authMntAccessor string + totpMethodId string + totpMethod map[string]interface{} + entity map[string]interface{} + totpSeed map[string]interface{} +) diff --git a/cmd/user/doc.go b/cmd/user/doc.go new file mode 100644 index 0000000..8709974 --- /dev/null +++ b/cmd/user/doc.go @@ -0,0 +1,11 @@ +/* +vault_totp_user: Configure TOTP authentication for logging into Vault for Vault users/entities (Vault as a TOTP provider). + +This allows to either [(re)set] TOTP for a Vault user (entity) or to [remove] the existing TOTP configuration for their user. +Note that if an auth mount requires 2FA, removing the TOTP configuration does *not* remove the TOTP requirement for the user! +They will not be able to log in without a valid TOTP code. + +[(re)set]: https://developer.hashicorp.com/vault/api-docs/system/mfa/totp#administratively-generate-a-totp-mfa-secret +[remove]: https://developer.hashicorp.com/vault/api-docs/system/mfa/totp#administratively-destroy-totp-mfa-secret +*/ +package main diff --git a/cmd/user/funcs.go b/cmd/user/funcs.go new file mode 100644 index 0000000..1ae6ddc --- /dev/null +++ b/cmd/user/funcs.go @@ -0,0 +1,666 @@ +package main + +import ( + "bytes" + `encoding/base64` + "encoding/json" + `errors` + "fmt" + "hash/crc32" + `maps` + "net/url" + "os" + `path/filepath` + "strings" + `text/template` + + "github.com/davecgh/go-spew/spew" + "github.com/hashicorp/vault-client-go" + "github.com/hashicorp/vault-client-go/schema" + vAPI "github.com/hashicorp/vault/api" + "github.com/mdp/qrterminal/v3" + "golang.org/x/term" + "r00t2.io/goutils/stringsx" + `r00t2.io/sysutils/paths` +) + +func configure() (err error) { + + var buf []byte + var apiSecret *vAPI.Secret + var apiHealth *vAPI.HealthResponse + var clientMap *vault.Response[map[string]interface{}] + var clientHealth *vault.Response[schema.SealStatusResponse] + + logger.Debug("vault_totp.configure: Configuring client(s).") + + if apiUrl, err = url.Parse(args.VaultConn.URI); err != nil { + logger.Err("vault_totp.configure: Received error parsing Vault URI '%s': %v", args.VaultConn.URI, err) + return + } + + if err = getTok(&args.VaultConn.Token); err != nil { + logger.Err("vault_totp.configure: Received error getting token: %v", err) + return + } + args.Mounts.Auth = strings.TrimRight(args.Mounts.Auth, "/") + if len(args.Gen.LookupFmt) == 0 { + args.Gen.LookupFmt = []string{ + "json", + "tpl_ref", + } + } + + if !args.NoConfirm { + buf = make([]byte, 1) + fmt.Printf( + "Do the following settings look correct? (Y/n)\n"+ + "\tURL: %s\n"+ + "\tToken (masked): %s\n"+ + "Y/n: ", + apiUrl.String(), + stringsx.Redact(apiTok, "*", 5, 5, false), + ) + if _, err = fmt.Scanln(&buf); err != nil { + if err.Error() == "unexpected newline" { + err = nil + } else { + logger.Err("vault_totp.configure: Received error receiving configuration confirmation: %v", err) + return + } + } + if buf[0] != 0x00 && strings.ToLower(string(buf[0])) != "y" { + logger.Warning("vault_totp.configure: User deferred execution.") + fmt.Println("Please re-run with any reconfiguration necessary. Quitting.") + err = fmt.Errorf("user deferred") + return + } + } + + apiCfg = vAPI.DefaultConfig() + apiCfg.Address = apiUrl.String() + + clientOpts = []vault.ClientOption{ + vault.WithEnvironment(), + vault.WithAddress(apiUrl.String()), + } + + if strings.ToLower(apiUrl.Scheme) == "https" { + // I don't know why Vault/Hashicorp insists on using their own TLS configuration structs instead of crypto/tls.Config. + // It's super lame and dumb and non-idiomatic. + apiTls = &vAPI.TLSConfig{ + Insecure: args.VaultConn.Insecure, + } + if err = apiCfg.ConfigureTLS(apiTls); err != nil { + logger.Err("vault_totp.configure: Received error setting API client TLS: %v", err) + return + } + // And this is even MORE dumb. You can only set this during the client initialization. + clientOpts = append( + clientOpts, + vault.WithTLS( + vault.TLSConfiguration{ + InsecureSkipVerify: args.VaultConn.Insecure, + }, + ), + ) + } + if api, err = vAPI.NewClient(apiCfg); err != nil { + logger.Err("vault_totp.configure: Received error initializing API client: %v", err) + return + } + api.SetToken(apiTok) + + if client, err = vault.New(clientOpts...); err != nil { + logger.Err("vault_totp.configure: Received error initializing client: %v", err) + return + } + if err = client.SetToken(apiTok); err != nil { + logger.Err("vault_totp.configure: Received error setting token on client: %v", err) + return + } + + // Health checks. + if apiHealth, err = api.Sys().Health(); err != nil { + logger.Err("vault_totp.configure: Received error getting health from API client: %v", err) + return + } + if clientHealth, err = client.System.SealStatus(ctx); err != nil { + logger.Err("vault_totp.configure: Received error getting health from client: %v", err) + return + } + logger.Debug("vault_totp.configure: API health:\n%s", spew.Sdump(apiHealth)) + logger.Debug("vault_totp.configure: Client health:\n%s", spew.Sdump(clientHealth)) + + // Token checks. + if apiSecret, err = api.Auth().Token().LookupSelf(); err != nil { + logger.Err("vault_totp.configure: Received error getting self token from API: %v", err) + return + } + if clientMap, err = client.Auth.TokenLookUpSelf(ctx); err != nil { + logger.Err("vault_totp.configure: Received error getting self token from client: %v", err) + return + } + logger.Debug("vault_totp.configure: API self token:\n%s", spew.Sdump(apiSecret)) + logger.Debug("vault_totp.configure: Client self token:\n%s", spew.Sdump(clientMap)) + + logger.Debug("vault_totp.configure: Finished configuring client(s).") + + return +} + +func genTotp() (err error) { + + var maxKeyLen int + var resp *vault.Response[map[string]interface{}] + var req schema.MfaAdminGenerateTotpSecretRequest = schema.MfaAdminGenerateTotpSecretRequest{ + EntityId: entity["id"].(string), + MethodId: totpMethodId, + } + + logger.Debug( + "vault_totp.genTotp: Setting/resetting TOTP for entity ID '%s' on '%s' ('%s' on '%s')...", + req.EntityId, req.MethodId, + entity["aliases"].([]interface{})[0].(map[string]interface{})["name"], args.Mounts.Auth, + ) + + if resp, err = client.Identity.MfaAdminGenerateTotpSecret(ctx, req); err != nil { + logger.Err("vault_totp.genTotp: Received error (re-)generating TOTP secret: %v", err) + return + } + + totpSeed = resp.Data + + if totpSeed == nil || len(totpSeed) == 0 { + // If there wasn't an error but the return's empty/nil, the TOTP exists. + if args.Gen.Force { + logger.Debug("vault_totp.genTotp: Clearing existing TOTP secret.") + if resp, err = client.Identity.MfaAdminDestroyTotpSecret( + ctx, + schema.MfaAdminDestroyTotpSecretRequest{ + EntityId: entity["id"].(string), + MethodId: totpMethodId, + }, + ); err != nil { + logger.Err("vault_totp.genTotp: Received error clearing existing TOTP: %v", err) + return + } + logger.Debug("vault_totp.genTotp: Successfully cleared TOTP secret; received response %#v", resp) + if err = genTotp(); err != nil { + logger.Err("vault_totp.genTotp: Received error re-trying generating TOTP secret: %v", err) + return + } + } else { + if !args.Gen.Silent { + fmt.Println("TOTP secret already exists and force was not enabled.") + totpSeed = nil + } + logger.Debug("vault_totp.genTotp: TOTP secret existed and force was not enabled.") + } + } else { + if totpUrl, err = url.Parse(totpSeed["url"].(string)); err != nil { + logger.Err("vault_totp.genTotp: Received error parsing TOTP URL: %v", err) + return + } + if !args.Gen.Silent { + // Print it to STDOUT + if args.Gen.PrintQr { + // qrterminal.Generate(totpUrl.String(), qrterminal.H, os.Stdout) + qrterminal.GenerateWithConfig( + totpUrl.String(), + qrterminal.Config{ + Level: qrterminal.L, + Writer: os.Stdout, + HalfBlocks: false, + BlackChar: qrterminal.BLACK, + BlackWhiteChar: qrterminal.BLACK_WHITE, + WhiteChar: qrterminal.WHITE, + WhiteBlackChar: qrterminal.WHITE_BLACK, + QuietZone: 1, + WithSixel: false, + }, + ) + fmt.Println() + } + fmt.Printf("\n----\nTOTP:\t%s\n\n", strings.TrimPrefix(totpUrl.Path, "/")) + // I hate that there's not an easier way to do this besides iterating twice. + for k := range maps.Keys(totpUrl.Query()) { + if len(k) > maxKeyLen { + maxKeyLen = len(k) + } + } + for k, v := range totpUrl.Query() { + for _, s := range v { + fmt.Printf("%-*s\t%s\n", maxKeyLen, k+":", s) + } + } + fmt.Print("----\n\n") + } + if err = writeTotp(); err != nil { + logger.Err("vault_totp.genTotp: Received error writing TOTP QR to persistent storage: %v", err) + return + } + + logger.Debug("vault_totp.genTotp: Generated TOTP.") + } + + return +} + +func getAuthMnt() (err error) { + + var ok bool + var i interface{} + var isl []interface{} + var auths map[string]*vAPI.AuthMount + var enforcements *vault.Response[schema.StandardListResponse] + var enforcement *vault.Response[map[string]interface{}] + // var totpList *vault.Response[schema.StandardListResponse] + var totpResp *vault.Response[map[string]interface{}] + var enforcedMfa map[string]struct{} = make(map[string]struct{}) + + logger.Debug("vault_totp.getAuthMnt: Fetching auth accessor for '%s'.", args.Mounts.Auth) + + if auths, err = api.Sys().ListAuth(); err != nil { + logger.Err("vault_totp.getAuthMnt: Received error getting list of enabled auths: %v", err) + return + } + + for k, v := range auths { + if strings.TrimRight(k, "/") != args.Mounts.Auth { + continue + } + // It's not actually the accessor (v.Accessor), despite what some docs say, that the MFA uses. + // But we need it for the entity lookup. + authMntAccessor = v.Accessor + // TOTP uses the UUID. + authMntId = v.UUID + logger.Debug("vault_totp.getAuthMnt: Found auth mount '%s':\n%s", args.Mounts.Auth, spew.Sdump(v)) + break + } + + // I think they intentionally make this a pain in the ass so you pay for Enterprise. + if enforcements, err = client.Identity.MfaListLoginEnforcements(ctx); err != nil { + logger.Err("vault_totp.getAuthMnt: Received error getting list of enabled TOTP enforcements: %v", err) + return + } + for _, e := range enforcements.Data.Keys { + if enforcement, err = client.Identity.MfaReadLoginEnforcement(ctx, e); err != nil { + logger.Err("vault_totp.getAuthMnt: Received error reading MFA login enforcement '%s': %v", e, err) + return + } + if i, ok = enforcement.Data["auth_method_accessors"]; !ok { + continue + } + if isl, ok = i.([]interface{}); !ok { + continue + } + for _, am := range isl { + if authMntAccessor == am.(string) { + logger.Debug( + "vault_totp.getAuthMnt: Found enforcement for auth at mount '%s' ('%s'):\n%s", + args.Mounts.Auth, authMntAccessor, spew.Sdump(am), + ) + if isl, ok = enforcement.Data["mfa_method_ids"].([]interface{}); ok { + for _, mMI := range isl { + enforcedMfa[mMI.(string)] = struct{}{} + } + } + } + } + } + /* + if totpList, err = client.Identity.MfaListTotpMethods(ctx); err != nil { + logger.Err("vault_totp.getAuthMnt: Received error getting list of enabled TOTP methods: %v", err) + return + } + for _, t := range totpList.Data.Keys { + */ + for t := range enforcedMfa { + if totpResp, err = client.Identity.MfaReadTotpMethod(ctx, t); err != nil { + logger.Err("vault_totp.getAuthMnt: Received error reading TOTP method ID '%s': %v", t, err) + return + } + totpMethod = totpResp.Data + totpMethodId = t + logger.Debug( + "vault_totp.getAuthMnt: Found TOTP MFA method ID '%s' for '%s':\n%s", + t, authMntAccessor, spew.Sdump(totpMethod), + ) + break + } + + logger.Debug("vault_totp.getAuthMnt: Set auth ID to '%s' for auth mount '%s'.", authMntId, args.Mounts.Auth) + + return +} + +func getEnt() (err error) { + + var resp *vault.Response[map[string]interface{}] + var req schema.EntityLookUpRequest + + logger.Debug("vault_totp.getEnt: Fetching/finding entity.") + + if err = getAuthMnt(); err != nil { + logger.Err("vault_totp.getEnt: Received error fetching auth mount accessor: %v", err) + return + } + + for k, v := range args.Gen.EntityLookup { + switch k { + case "name": + req.Name = v + case "id": + req.Id = v + case "alias": + req.AliasName = v + req.AliasMountAccessor = authMntAccessor + case "alias_id": + req.AliasId = v + } + } + + if resp, err = client.Identity.EntityLookUp(ctx, req); err != nil { + logger.Err("vault_totp.getEnt: Received error looking up entity with %#v: %v", req, err) + return + } + + entity = resp.Data + + logger.Debug("vault_totp.getEnt: Found entity:\n%s", spew.Sdump(entity)) + + return +} + +func getTok(tok *string) (err error) { + + var b []byte + var b2 []byte + var state *term.State + var fd int = int(os.Stdin.Fd()) + + if tok != nil && *tok != "" { + apiTok = *tok + return + } + + if state, err = term.GetState(fd); err != nil { + logger.Debug("vault_totp.getTok: Received error getting current state: %v", err) + return + } + defer func() { + var tErr error + if state != nil { + if tErr = term.Restore(fd, state); tErr != nil { + logger.Err("vault_totp.getTok: Received error restoring terminal state: %v", tErr) + } + } + fmt.Println() + }() + + fmt.Print("Please enter your Vault token (will not echo back): ") + if b, err = term.ReadPassword(fd); err != nil { + logger.Err("vault_totp.getTok: Received error reading Vault token (initial): %v", err) + return + } + fmt.Print("\nConfirm: ") + if b2, err = term.ReadPassword(fd); err != nil { + logger.Err("vault_totp.getTok: Received error reading Vault token (confirm): %v", err) + return + } + fmt.Println() + if !bytes.Equal(b, b2) { + err = fmt.Errorf( + "original token (CRC32 %x, length %d) "+ + "does not match confirmation (CRC32 %x, length %d)", + crc32.ChecksumIEEE(b), len(string(b)), + crc32.ChecksumIEEE(b2), len(string(b2)), + ) + logger.Err("vault_totp.getTok: Error when confirming token: %v") + return + } + + apiTok = string(b) + + return +} + +func printEntity() (err error) { + + var b []byte + var rendered bool + var sb strings.Builder + var seen map[string]struct{} = make(map[string]struct{}) + + sb.WriteString("----\n") + for _, i := range args.Gen.LookupFmt { + if _, rendered = seen[i]; rendered { + continue + } + switch i { + case "dump": + spew.Fdump(os.Stdout, entity) + case "json": + if b, err = json.MarshalIndent(entity, "", " "); err != nil { + logger.Err("vault_totp.printEntity: Received error rendering to JSON: %v", err) + return + } + sb.Write(b) + default: + continue + } + sb.WriteString("\n----\n") + seen[i] = struct{}{} + } + + fmt.Print(sb.String()) + + return +} + +func renderVPath() (mnt, vpath string, err error) { + + var sl []string + var buf bytes.Buffer + var tpl *template.Template = template.New("").Option("missingkey=error") + + logger.Debug("vault_totp.renderVPath: Rendering path for entity TOTP storage.") + + if args.Mounts.TotpPath == "" { + logger.Warning("vault_totp.renderVPath: TOTP persistent Vault storage disabled.") + return + } + + if _, err = tpl.Parse(args.Mounts.TotpPath); err != nil { + logger.Err("vault_totp.renderVPath: Error parsing template: %v", err) + return + } + + if err = tpl.Execute(&buf, entity); err != nil { + logger.Err("vault_totp.renderVPath: Error executing template: %v", err) + return + } + + sl = strings.SplitN(buf.String(), ":", 2) + + switch len(sl) { + case 0: + err = fmt.Errorf("no mount and/or path; empty TOTP pathspec") + return + case 1: + err = fmt.Errorf("only mount or path specified; invalid TOTP pathspec") + return + } + if sl[0] == "" { + err = fmt.Errorf("no mount specified; invalid TOTP pathspec") + return + } + if sl[1] == "" { + err = fmt.Errorf("no path specified; invalid TOTP pathspec") + return + } + + mnt = sl[0] + vpath = sl[1] + + logger.Debug("vault_totp.renderVPath: Rendered vpath for TOTP: '%s:%s'", mnt, vpath) + + return +} + +func writeTotp() (err error) { + + var b []byte + var fnm string + + logger.Debug("vault_totp.writeTotp: Writing TOTP to file/Vault.") + + if totpSeed == nil { + logger.Debug("vault_totp.writeTotp: No TOTP seed/secret; NO-OP.") + return + } + + if !args.Gen.NoQr { + if err = paths.RealPath(&args.Gen.QrDir); err != nil { + logger.Err("vault_totp.writeTotp: Received error canonizing QR directory '%s': %v", args.Gen.QrDir, err) + return + } + if err = os.MkdirAll(args.Gen.QrDir, 0o0700); err != nil { + logger.Err("vault_totp.writeTotp: Received error creating QR directory '%s': %v", args.Gen.QrDir, err) + return + } + fnm = filepath.Join(args.Gen.QrDir, fmt.Sprintf("totp_%s.png", entity["id"].(string))) + // wildly misnamed field + if b, err = base64.StdEncoding.DecodeString(totpSeed["barcode"].(string)); err != nil { + logger.Err("vault_totp.writeTotp: Received error decoding QR code base64: %v", err) + return + } + if err = os.WriteFile(fnm, b, 0o0600); err != nil { + logger.Err("vault_totp.writeTotp: Received error writing QR code to disk: %v", err) + return + } + } + + if strings.TrimSpace(args.Mounts.TotpPath) != "" { + if err = writeTotpVault(); err != nil { + logger.Err("vault_totp.writeTotp: Received error writing TOTP seed/secret to Vault: %v", err) + return + } + } + + logger.Debug("vault_totp.writeTotp: Wrote TOTP to persistent storage (if specified/configured).") + + return +} + +func writeTotpVault() (err error) { + + // var b []byte + var mnt string + var vpath string + var baseUri *url.URL + var uiItemUri *url.URL + var apiItemUri *url.URL + var presp *vAPI.KVSecret + var dat map[string]interface{} + var datUri map[string]interface{} + var rresp *vault.Response[schema.KvV2ReadResponse] + var wresp *vault.Response[schema.KvV2WriteResponse] + var respErr *vault.ResponseError = new(vault.ResponseError) + var params []vault.RequestOption = make([]vault.RequestOption, 0) + + logger.Debug("vault_totp.writeTotpVault: Rendering path for entity TOTP storage.") + + if mnt, vpath, err = renderVPath(); err != nil { + logger.Err("vault_totp.writeTotpVault: Received error rendering Vault path: %v", err) + return + } + + if baseUri, err = url.Parse(api.Address()); err != nil { + logger.Err("vault_totp.writeTotpVault: Received error parsing Vault non-specialized base URI: %v", err) + return + } + uiItemUri = baseUri.JoinPath("ui", "vault", "secrets", mnt, "kv", url.PathEscape(vpath)) + apiItemUri = baseUri.JoinPath("v1", mnt, "data", url.PathEscape(vpath)) + + dat = map[string]interface{}{ + "uri_str": totpUrl.String(), + "qr_b64": totpSeed["barcode"].(string), + } + datUri = make(map[string]interface{}) + for k, v := range totpUrl.Query() { + datUri[k] = v[0] // I don't think the otp:// URLs have multi-val query params... + } + dat["uri"] = datUri + + params = append( + params, + vault.WithMountPath(mnt), + ) + + if rresp, err = client.Secrets.KvV2Read(ctx, vpath, params...); err != nil { + if errors.As(err, &respErr) { + if respErr.StatusCode != 404 { + spew.Dump(err) + logger.Err("vault_totp.writeTotpVault: Received error reading Vault secret at mount '%s' path '%s': %v", mnt, vpath, err) + return + } else { + err = nil + } + } else { + return + } + } + + if rresp != nil && rresp.Data.Data != nil { + /* + // Yeah this is dumb. This is what happens when you try to do too much magic and try to "protect" developers from themselves. + // The proper solution is we need smarter developers, not more Fisher-Price Babby's First Code nonsense. + if b, err = json.Marshal(dat); err != nil { + logger.Err("vault_totp.writeTotpVault: Received error with merge encoding of existing data: %v", err) + return + } + dat = rresp.Data.Data + if err = json.Unmarshal(b, &dat); err != nil { + logger.Err("vault_totp.writeTotpVault: Received error while unmarshaling merge data: %v", err) + return + } + */ + // We'll do a patch instead (https://developer.hashicorp.com/vault/api-docs/secret/kv/kv-v2#patch-secret). + // Fuck it. + if presp, err = api.KVv2(mnt).Patch(ctx, vpath, dat); err != nil { + logger.Err("vault_totp.writeTotpVault: Received error patching/updating secret '%s' on mount '%s': %v", vpath, mnt, err) + return + } + logger.Debug("vault_totp.writeTotpVault: Updated secret '%s:%s':\n%s", mnt, vpath, spew.Sdump(presp)) + } else { + if wresp, err = client.Secrets.KvV2Write( + ctx, + vpath, + schema.KvV2WriteRequest{ + Data: dat, + }, + params..., + ); err != nil { + logger.Err("vault_totp.writeTotpVault: Received error creating new secret '%s' on mount '%s': %v", vpath, mnt, err) + return + } + logger.Debug("vault_totp.writeTotpVault: Wrote secret '%s:%s':\n%s", mnt, vpath, spew.Sdump(wresp)) + } + + if !args.Gen.Silent { + fmt.Printf( + "TOTP stored at %s:%s\n\n"+ + "Web UI URL:\t%s\n"+ + "API URL:\t%s\n", + mnt, vpath, + uiItemUri.String(), + apiItemUri.String(), + ) + } + + logger.Debug("vault_totp.writeTotpVault: Wrote TOTP seed/secret to Vault.") + + return +} diff --git a/cmd/user/main.go b/cmd/user/main.go new file mode 100644 index 0000000..71f340d --- /dev/null +++ b/cmd/user/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "errors" + "log" + + "github.com/davecgh/go-spew/spew" + "github.com/jessevdk/go-flags" + `r00t2.io/vault_totp/internal` +) + +func main() { + var err error + var parser *flags.Parser = flags.NewParser(args, flags.Default) + + parser.NamespaceDelimiter = "-" + parser.EnvNamespaceDelimiter = "_" + + if _, err = parser.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. + return + default: + log.Panicln(err) + } + default: + log.Panicln(err) + } + } + + defer internal.Logger.Shutdown() + + logger.Debug("Initialized with args:\n%v", spew.Sdump(args)) + + logger.Debug("main: Configuring.") + if err = configure(); err != nil { + logger.Err("main: Received error while configuring: %v", err) + log.Panicln(err) + } + + logger.Debug("main: Fetching entity.") + if err = getEnt(); err != nil { + logger.Err("main: Received error while fetching entity: %v", err) + log.Panicln(err) + } + if args.Gen.LookupOnly { + if err = printEntity(); err != nil { + logger.Err("main: Received error while rendering entity out: %v", err) + log.Panicln(err) + } + logger.Debug("main: Done.") + return + } + + logger.Debug("main: Generating TOTP seed/secret.") + if err = genTotp(); err != nil { + logger.Err("main: Received error while generating TOTP secret: %v", err) + log.Panicln(err) + } + + logger.Debug("main: Done.") +} diff --git a/cmd/vault_totp_util/consts.go b/cmd/vault_totp_util/consts.go deleted file mode 100644 index 93aa7a6..0000000 --- a/cmd/vault_totp_util/consts.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "log" - - `update_vault_totp/internal` - - sysdUtil "github.com/coreos/go-systemd/v22/util" - `github.com/jessevdk/go-flags` - `r00t2.io/goutils/logging` -) - -var ( - isSystemd bool = sysdUtil.IsRunningSystemd() -) - -const ( - logFlags int = log.LstdFlags | log.Lmsgprefix - logFlagsDebug int = logFlags | log.Llongfile -) - -var ( - logger *logging.MultiLogger - args *internal.Args = new(internal.Args) - logFlagsRuntime int = logFlags - parser *flags.Parser = flags.NewParser(args, flags.Default) -) diff --git a/cmd/vault_totp_util/main.go b/cmd/vault_totp_util/main.go deleted file mode 100644 index 1d67e35..0000000 --- a/cmd/vault_totp_util/main.go +++ /dev/null @@ -1,72 +0,0 @@ -package main - -import ( - `errors` - `fmt` - `log` - `os` - - `update_vault_totp/version` - - `github.com/davecgh/go-spew/spew` - `github.com/jessevdk/go-flags` - `r00t2.io/goutils/logging` -) - -func main() { - - var err error - - log.SetOutput(os.Stderr) - - parser.EnvNamespaceDelimiter = "" - if _, err = parser.Parse(); err != nil { - var flagsErr *flags.Error - 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. - return - default: - log.Panicln(err) - } - default: - log.Panicln(err) - } - } - - if version.Ver, err = version.Version(); err != nil { - log.Panicln(err) - } - - // If args.Version or args.DetailVersion are true, just print them and exit. - if args.DetailVersion || args.Version { - if args.Version { - fmt.Println(version.Ver.Short()) - return - } else if args.DetailVersion { - fmt.Println(version.Ver.Detail()) - return - } - } - - // We want to set up logging before anything else. - if args.DoDebug { - logFlagsRuntime = logFlagsDebug - } - logger = logging.GetMultiLogger(args.DoDebug, "ZorkBot") - if err = logger.AddDefaultLogger( - "default", - logFlagsRuntime, - "/var/log/zorkbot/zorkbot.log", "~/logs/zorkbot.log", - ); err != nil { - log.Panicln(err) - } - if err = logger.Setup(); err != nil { - log.Panicln(err) - } - defer logger.Shutdown() - logger.Info("main: ZorkBot version %v", version.Ver.Short()) - logger.Debug("main: ZorkBot version (extended):\n%v", version.Ver.Detail()) - logger.Debug("main: Invocation args:\n%s", spew.Sdump(args)) -} diff --git a/common/consts.go b/common/consts.go new file mode 100644 index 0000000..e55bb82 --- /dev/null +++ b/common/consts.go @@ -0,0 +1,34 @@ +package common + +import ( + `os` + `syscall` + `time` +) + +const ( + /* + DefaultTimePeriod is the default time period for TOTP. + + See [RFC 6238 ยง 4.1], [RFC 6238 ยง 4.1], [RFC 6238 ยง 6], and [RFC 6238 Appendix A]. + + [RFC 6238 ยง 4.1]: https://datatracker.ietf.org/doc/html/rfc6238#section-4.1 + [RFC 6238 ยง 5.2]: https://datatracker.ietf.org/doc/html/rfc6238#section-5.2 + [RFC 6238 ยง 6]: https://datatracker.ietf.org/doc/html/rfc6238#section-6 + [RFC 6238 Appendix A]: https://datatracker.ietf.org/doc/html/rfc6238#appendix-A + */ + DefaultTimePeriod time.Duration = time.Second * 30 + + // VaultRepl is used to replace any invalid characters in a name. + VaultRepl rune = '_' +) + +var ( + // ProgEndSigs are used to trap signals and gracefully quit. Note that these don't really have a Windows equivalent. + ProgEndSigs []os.Signal = []os.Signal{ + os.Interrupt, // SIGINT, signal 2 (ctrl+c) + syscall.SIGQUIT, // signal 3 (ctrl + \\) (SIGINT but with coredump) + os.Kill, // SIGKILL, signal 9 (useless to capture since the program can't ignore/trap it) + syscall.SIGTERM, // signal 15 (`kill` default) + } +) diff --git a/common/funcs.go b/common/funcs.go new file mode 100644 index 0000000..56b6849 --- /dev/null +++ b/common/funcs.go @@ -0,0 +1,145 @@ +package common + +import ( + `math` + `time` +) + +/* +CurrentTimeStep returns the current time step using the given +[time.Duration] `period`. + +If `period` is equal to time.Duration(0), it will be set to [DefaultTimePeriod]. +*/ +func CurrentTimeStep(period time.Duration) (ts int64) { + return TimeStepFromTime(time.Now(), period) +} + +/* +OffsetTimeStep is like [CurrentTimeStep] but returns the time step next/previous +to the current specified by `offset` (`offset` may be negative or positive). +*/ +func OffsetTimeStep(period time.Duration, offset int64) (ts int64) { + + var offsetDuration time.Duration + + period = normalizePeriod(period) + + offsetDuration = period * time.Duration(offset) + + ts = TimeStepFromTime(time.Now().Add(offsetDuration), period) + + return +} + +/* +OffsetTimeStepToTime is like [TimeStepToTime] but returns the next/previous +time range in relation to time step `ts` specified by `offset` (`offset` may be negative or positive). +*/ +func OffsetTimeStepToTime(ts int64, period time.Duration, offset int64) (start, end time.Time) { + return TimeStepToTime(ts+offset, period) +} + +/* +TimeStepFromTime returns a time step (see [RFC 6238 ยง 1.2], [RFC 6238 ยง 5.2]) +from a provided generation time `t` as a [time.Time] and a life length +`period` as a [time.Duration]. + +If `period` is equal to time.Duration(0), it will be set to [DefaultTimePeriod]. + +[RFC 6238 ยง 1.2]: https://datatracker.ietf.org/doc/html/rfc6238#section-1.2 +[RFC 6238 ยง 5.2]: https://datatracker.ietf.org/doc/html/rfc6238#section-5.2 +*/ +func TimeStepFromTime(t time.Time, period time.Duration) (ts int64) { + + period = normalizePeriod(period) + + ts = int64(math.Floor(float64(t.Unix()) / period.Seconds())) + + return +} + +/* +TimeStepToTime returns a [time.Time] `start` and `end` from a time step `ts` +and a lifetime period `period` (as a [time.Duration]). + +If `period` is equal to time.Duration(0), it will be set to [DefaultTimePeriod]. +*/ +func TimeStepToTime(ts int64, period time.Duration) (start, end time.Time) { + + var fromEpoch time.Duration + + period = normalizePeriod(period) + fromEpoch = (time.Second * time.Duration(ts*int64(period.Seconds()))).Truncate(time.Second) + + start = time.Unix(0, 0).Add(fromEpoch).Truncate(time.Second) + end = start.Add(period).Truncate(time.Second) + + return +} + +// VaultEscape is used to normalize a string to a Vault-safe name (for e.g. secrets). +func VaultEscape(s *string) { + + var c rune + var idx int + var last rune + var norm []rune + var reduced []rune = make([]rune, 0) + + if s == nil { + return + } + norm = make([]rune, 0, len(*s)) + + // https://github.com/hashicorp/vault/issues/5645#issuecomment-434404750 + // It seems they relaxed this a bit since then, as hyphens and periods are also allowed. + // https://github.com/hashicorp/vault/blob/main/sdk/framework/path.go#L26 + // At time of writing, the regex is: (?P\\w(([\\w-.@]+)?\\w)?) + // Per comments: "alphanumeric characters along with -, . and @." + for _, c = range *s { + // 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, VaultRepl) + } + + // And remove repeating sequential replacers. + for idx, c = range norm[:] { + if idx == 0 { + last = c + reduced = append(reduced, c) + continue + } + if c == last && last == VaultRepl { + continue + } + reduced = append(reduced, c) + last = c + } + + *s = string(reduced) + + return +} + +// normalizedPeriod returns `period` if non-zero, otherwise [DefaultTimePeriod]. +func normalizePeriod(period time.Duration) (normalized time.Duration) { + + if period != time.Duration(0) { + normalized = period + return + } + + normalized = DefaultTimePeriod + + return +} diff --git a/errs/errors.go b/errs/errors.go new file mode 100644 index 0000000..acf6959 --- /dev/null +++ b/errs/errors.go @@ -0,0 +1,11 @@ +package errs + +import ( + `errors` +) + +var ( + ErrNilVault error = errors.New("provided VaultArgs is nil") + ErrNoCfg error = errors.New("no TOTP configuration found") + ErrNoCode error = errors.New("no code found returned") +) diff --git a/go.mod b/go.mod index 2641bbe..98be417 100644 --- a/go.mod +++ b/go.mod @@ -1,31 +1,72 @@ -module update_vault_totp +module r00t2.io/vault_totp go 1.25.5 require ( - github.com/coreos/go-systemd/v22 v22.5.0 + github.com/chelnak/ysmrr v0.6.0 github.com/creachadair/otp v0.5.2 + github.com/davecgh/go-spew v1.1.1 + github.com/go-playground/validator/v10 v10.29.0 github.com/hashicorp/vault-client-go v0.4.3 + github.com/hashicorp/vault/api v1.22.0 github.com/jessevdk/go-flags v1.6.1 - golang.org/x/mod v0.23.0 + github.com/makiuchi-d/gozxing v0.1.1 + github.com/mdp/qrterminal/v3 v3.2.1 + github.com/pquerna/otp v1.5.0 + github.com/pterm/pterm v0.12.82 + golang.org/x/mod v0.31.0 golang.org/x/term v0.38.0 r00t2.io/gosecret v1.1.5 - r00t2.io/goutils v1.13.0 - r00t2.io/sysutils v1.15.0 + r00t2.io/goutils v1.14.0 + r00t2.io/sysutils v1.15.1 ) +// https://github.com/chelnak/ysmrr/pull/88 +replace github.com/chelnak/ysmrr v0.6.0 => /opt/dev/third-party/ysmrr + require ( + atomicgo.dev/cursor v0.2.0 // indirect + atomicgo.dev/keyboard v0.2.9 // indirect + atomicgo.dev/schedule v0.1.0 // indirect + github.com/boombuler/barcode v1.1.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/containerd/console v1.0.5 // indirect + github.com/coreos/go-systemd/v22 v22.6.0 // indirect github.com/creachadair/wirepb v0.0.0-20251201055919-954ef89c4c71 // indirect github.com/djherbis/times v1.6.0 // indirect - github.com/godbus/dbus/v5 v5.2.0 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/godbus/dbus/v5 v5.2.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gookit/color v1.5.4 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lithammer/fuzzysearch v1.1.8 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.14.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 21c646a..d2d37bd 100644 --- a/go.sum +++ b/go.sum @@ -1,66 +1,225 @@ -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= +atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= +atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= +atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= +atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= +atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= +atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= +atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= +github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= +github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= +github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= +github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= +github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= +github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= +github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= +github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= +github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= +github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= +github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= +github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= +github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= github.com/creachadair/mds v0.25.2 h1:xc0S0AfDq5GX9KUR5sLvi5XjA61/P6S5e0xFs1vA18Q= github.com/creachadair/mds v0.25.2/go.mod h1:+s4CFteFRj4eq2KcGHW8Wei3u9NyzSPzNV32EvjyK/Q= github.com/creachadair/otp v0.5.2 h1:3krOkIHA8X1YAnv9lGZi2NlyCZepcsAQkZwKGK5L+9c= github.com/creachadair/otp v0.5.2/go.mod h1:0cUM4kl9cQmePbIEy1J1O+esh+S3zDM9s2Foo71KzKA= github.com/creachadair/wirepb v0.0.0-20251201055919-954ef89c4c71 h1:H3EV2OFbtKgFAcxGJiLTNar9krJ9yfsgpE37/QY9vN8= github.com/creachadair/wirepb v0.0.0-20251201055919-954ef89c4c71/go.mod h1:dHvCVZSsk3Y/fhY0mUex0U2XBZszomOe7PzlghOtHnQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8= -github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk= +github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/godbus/dbus/v5 v5.2.1 h1:I4wwMdWSkmI57ewd+elNGwLRf2/dtSaFz1DujfWYvOk= +github.com/godbus/dbus/v5 v5.2.1/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= +github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= +github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/vault-client-go v0.4.3 h1:zG7STGVgn/VK6rnZc0k8PGbfv2x/sJExRKHSUg3ljWc= github.com/hashicorp/vault-client-go v0.4.3/go.mod h1:4tDw7Uhq5XOxS1fO+oMtotHL7j4sB9cp0T7U6m4FzDY= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= +github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I= +github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= +github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= +github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= +github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= +github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= +github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= +github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= +github.com/pterm/pterm v0.12.82 h1:+D9wYhCaeaK0FIQoZtqbNQuNpe2lB2tajKKsTd5paVQ= +github.com/pterm/pterm v0.12.82/go.mod h1:TyuyrPjnxfwP+ccJdBTeWHtd/e0ybQHkOS/TakajZCw= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= -golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= r00t2.io/gosecret v1.1.5 h1:pmlsCR8VKeI9+BN7uvUqThP/BtgoSch580XOWin+qH4= r00t2.io/gosecret v1.1.5/go.mod h1:24jo8x5lOmWAJZJZ1J3FxhoT9+KOXSZ5pQWWzFdvdtM= -r00t2.io/goutils v1.13.0 h1:103QR2eUu42TNF7h9r6YgahbSgRAJ8VtFxSzw9rMaXI= -r00t2.io/goutils v1.13.0/go.mod h1:/0M+6fW2VAgdtAvwGE9oNV259MoEZeJ84rLCZsxe8uI= -r00t2.io/sysutils v1.15.0 h1:FSnREfbXDhBQEO7LMpnRQeKlPshozxk9XHw3YgWRgRg= -r00t2.io/sysutils v1.15.0/go.mod h1:28qB0074EIRQ8Sy/ybaA5jC3qA32iW2aYLkMCRhyAFM= +r00t2.io/goutils v1.14.0 h1:mvEiJLrTy/hx7ZX2TzCm/y0be2TcTu822m++qsSQGLc= +r00t2.io/goutils v1.14.0/go.mod h1:68jkIl/QYxEEVmVz8k1a7QI9vAA4faQUIcjSHSbtgHw= +r00t2.io/sysutils v1.15.1 h1:0EVZZAxTFqQN6jjfjqUKkXye0LMshUA5MO7l3Wd6wH8= +r00t2.io/sysutils v1.15.1/go.mod h1:T0iOnaZaSG5NE1hbXTqojRZc0ia/u8TB73lV7zhMz58= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/args.go b/internal/args.go index bdab032..d3759f1 100644 --- a/internal/args.go +++ b/internal/args.go @@ -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."` } ) diff --git a/internal/consts.go b/internal/consts.go index d7dcc04..c7a4dbf 100644 --- a/internal/consts.go +++ b/internal/consts.go @@ -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 ( diff --git a/internal/funcs.go b/internal/funcs.go index 93a1bf4..be8e80b 100644 --- a/internal/funcs.go +++ b/internal/funcs.go @@ -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 [:] 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 [:] 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 } diff --git a/internal/funcs_client.go b/internal/funcs_client.go deleted file mode 100644 index 9229fa4..0000000 --- a/internal/funcs_client.go +++ /dev/null @@ -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 -}