1
0

checking in

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

5
TODO Normal file
View File

@@ -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

View File

@@ -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

34
cmd/add/args.go Normal file
View File

@@ -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). '<account>/<issuer>' will be appended to the path.\nMust be provided in the form of '[<mount>:]<path>'; 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"`
}
)

94
cmd/add/consts.go Normal file
View File

@@ -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": "<Service Name>",
"name": "<Username>",
"period": "<seconds>"
ssSchemaAttr: ssSchemaVal,
}
if item, err = coll.CreateItem(
"<LAST_ID+1>:<issuer>:<name>",
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"
)

4
cmd/add/doc.go Normal file
View File

@@ -0,0 +1,4 @@
/*
vault_totp_add: Add existing TOTP configurations to Vault (Vault as a TOTP generator).
*/
package main

645
cmd/add/funcs.go Normal file
View File

@@ -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", "<CLI>", &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:
// <id>:<issuer>:<account>
// where <id> 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
}
}

73
cmd/add/main.go Normal file
View File

@@ -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.")
}

14
cmd/add/types.go Normal file
View File

@@ -0,0 +1,14 @@
package main
import (
`github.com/creachadair/otp/otpauth`
)
type (
parsedUrl struct {
vaultName string
u *otpauth.URL
source string
sourceType string
}
)

23
cmd/gen/args.go Normal file
View File

@@ -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.)"`
}
)

90
cmd/gen/consts.go Normal file
View File

@@ -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,
}
)

4
cmd/gen/doc.go Normal file
View File

@@ -0,0 +1,4 @@
/*
vault_totp_gen: Generate a TOTP code from a TOTP secret.
*/
package main

180
cmd/gen/funcs.go Normal file
View File

@@ -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
}

104
cmd/gen/main.go Normal file
View File

@@ -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()
}

39
cmd/gen/types.go Normal file
View File

@@ -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
}
)

18
cmd/kill/args.go Normal file
View File

@@ -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."`
}
)

23
cmd/kill/consts.go Normal file
View File

@@ -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{})
)

4
cmd/kill/doc.go Normal file
View File

@@ -0,0 +1,4 @@
/*
vault_totp_kill: Remove configured TOTP generators.
*/
package main

30
cmd/kill/funcs.go Normal file
View File

@@ -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
}

121
cmd/kill/main.go Normal file
View File

@@ -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.")
}

View File

@@ -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.")
}

View File

@@ -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.")
}

27
cmd/user/args.go Normal file
View File

@@ -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 <mount>:<path> 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 \"<key>:<val>\"' where <key> is one of 'name', 'id', 'alias_id', or 'alias' and <val> is the matching criteria. (You are likely looking for '-e \"alias:<username>\"'). 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'."`
}
)

44
cmd/user/consts.go Normal file
View File

@@ -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{}
)

11
cmd/user/doc.go Normal file
View File

@@ -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

666
cmd/user/funcs.go Normal file
View File

@@ -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
}

64
cmd/user/main.go Normal file
View File

@@ -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.")
}

View File

@@ -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)
)

View File

@@ -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))
}

34
common/consts.go Normal file
View File

@@ -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)
}
)

145
common/funcs.go Normal file
View File

@@ -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<name>\\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
}

11
errs/errors.go Normal file
View File

@@ -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")
)

53
go.mod
View File

@@ -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
)

193
go.sum
View File

@@ -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=

View File

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

View File

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

View File

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

View File

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