checking in
This commit is contained in:
34
cmd/add/args.go
Normal file
34
cmd/add/args.go
Normal 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
94
cmd/add/consts.go
Normal 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
4
cmd/add/doc.go
Normal 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
645
cmd/add/funcs.go
Normal 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
73
cmd/add/main.go
Normal 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
14
cmd/add/types.go
Normal 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
23
cmd/gen/args.go
Normal 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
90
cmd/gen/consts.go
Normal 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
4
cmd/gen/doc.go
Normal 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
180
cmd/gen/funcs.go
Normal 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
104
cmd/gen/main.go
Normal 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
39
cmd/gen/types.go
Normal 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
18
cmd/kill/args.go
Normal 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
23
cmd/kill/consts.go
Normal 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
4
cmd/kill/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
/*
|
||||
vault_totp_kill: Remove configured TOTP generators.
|
||||
*/
|
||||
package main
|
||||
30
cmd/kill/funcs.go
Normal file
30
cmd/kill/funcs.go
Normal 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
121
cmd/kill/main.go
Normal 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.")
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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
27
cmd/user/args.go
Normal 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
44
cmd/user/consts.go
Normal 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
11
cmd/user/doc.go
Normal 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
666
cmd/user/funcs.go
Normal 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
64
cmd/user/main.go
Normal 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.")
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user