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
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user