646 lines
15 KiB
Go
646 lines
15 KiB
Go
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
|
|
}
|
|
}
|