initial
This commit is contained in:
26
internal/args.go
Normal file
26
internal/args.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package internal
|
||||
|
||||
type (
|
||||
Args struct {
|
||||
Version bool `short:"v" long:"version" description:"Print the version and exit."`
|
||||
DetailVersion bool `short:"V" long:"detail" description:"Print detailed version info and exit."`
|
||||
DoDebug bool `env:"VTOTP_DEBUG" short:"d" long:"debug" description:"If specified, enable debug logging. This may log potentially sensitive information, so be careful."`
|
||||
VaultToken string `env:"VAULT_TOKEN" short:"t" long:"vault-token" description:"Vault token to use. If empty/not defined, you will be securely prompted."`
|
||||
VaultAddr string `env:"VAULT_ADDR" short:"a" long:"vault-addr" default:"https://clandestine.r00t2.io/" description:"Vault address to use."`
|
||||
QrImgPath string `env:"VTOTP_QR" short:"q" long:"qr-img" description:"Path to QR image to extract OTPAuth URLs from. Either -q/--qr-img, -f/--otp-file, -u/--otp-url, and/or -x/--explicit must be specified." validate:"required_without=OtpFile OtpUrl OtpExplicit,filepath"`
|
||||
OtpFile string `env:"VTOTP_FILE" short:"f" long:"otp-file" description:"Path to file containing OTPAuth URLs in plaintext, one per line. Either -q/--qr-img, -f/--otp-file, -u/--otp-url, and/or -x/--explicit must be specified." validate:"required_without=QrImgPath OtpUrl OtpExplicit,filepath"`
|
||||
OtpUrl string `env:"VTOTP_URL" short:"u" long:"otp-url" description:"Explicit OTPAuth URL. Either -q/--qr-img, -f/--otp-file, -u/--otp-url, and/or -x/--explicit must be specified." validate:"required_without=QrImgPath OtpFile OtpExplicit,url"`
|
||||
OtpExplicit bool `short:"x" long:"otp-explicit" description:"If specified, use the explicit OTP specification under the EXPLICIT TOTP group."`
|
||||
ExplicitOtp ExplicitOtp `group:"EXPLICIT TOTP" env-namespace:"VTOTP_X"`
|
||||
}
|
||||
ExplicitOtp struct {
|
||||
Type string `env:"TYP" short:"y" long:"type" choice:"totp" choice:"hotp" default:"totp" hidden:"true" description:"The OTP type." validate:"required_with=OtpExplicit,oneof=totp hotp"`
|
||||
Counter uint64 `env:CTR" short:"c" long:"counter" hidden:"true" description:"The initial counter value (if -y/--type='hotp')." validate:"required_with=OtpExplicit,required_if=Type hotp"`
|
||||
Account string `env:"ACCT" short:"n" long:"name" description:"Name of the TOTP account (should be just the username)." validate:"required_with=OtpExplicit"`
|
||||
Issuer string `env:"ISS" short:"i" long:"issuer" description:"Issuer of the TOTP (this is generally the service name you're authing to)." validate:"required_with=OtpExplicit"`
|
||||
Secret string `env:"SSKEY" short:"s" long:"shared-secret" description:"The shared secret key in Base32 string format (with no padding)." validate:"required_with=OtpExplicit,base32"`
|
||||
Algorithm string `env:"ALGO" short:"g" long:"algo" choice:"md5" choice:"sha1" choice:"sha256" choice:"sha512" description:"The hashing/checksum algorithm." validate:"required_with=OtpExplicit,oneof=md5 sha1 sha256 sha512"`
|
||||
Digits int `env:"DIG" short:"l" long:"digits" choice:"6" choice:"8" description:"Number of digits for the generated code." validate:"required_with=OtpExplicit,oneof=6 8"`
|
||||
Period time.Duration `env:TIME" short:"p" long:"period" default:"30s" description:"The period that a generated code is valid for." validate:"required_with=OtpExplicit,required_if=Type totp"`
|
||||
}
|
||||
)
|
||||
32
internal/consts.go
Normal file
32
internal/consts.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
`os`
|
||||
)
|
||||
|
||||
const (
|
||||
DefAddr string = "https://clandestine.r00t2.io/"
|
||||
VaultTokEnv string = "VAULT_TOKEN"
|
||||
CollNm string = "OTP"
|
||||
TgtMnt string = "totp_bts.work"
|
||||
// These attrs need to be added to new SecretService TOTP secrets
|
||||
// ~/.local/share/gnome-shell/extensions/totp@dkosmari.github.com/schemas/org.gnome.shell.extensions.totp.gschema.xml
|
||||
SsSchemaName string = "xdg:schema"
|
||||
SsSchemaVal string = "org.gnome.shell.extensions.totp" // also the gosecret.ItemType
|
||||
)
|
||||
|
||||
var (
|
||||
TermFd int = int(os.Stdin.Fd())
|
||||
)
|
||||
|
||||
var (
|
||||
/*
|
||||
These map from one form to the other using introspection.
|
||||
ext: The GNOME Shell extension, ~/.local/share/gnome-shell/extensions/totp@dkosmari.github.com/secretUtils.js (makeSchema())
|
||||
Also found at https://github.com/dkosmari/gnome-shell-extension-totp/blob/master/secretUtils.js#L33-L45
|
||||
vault: A https://pkg.go.dev/github.com/hashicorp/vault-client-go@/schema#TotpCreateKeyRequest
|
||||
(reading a key returns a map[string]interface{} for ...some reason)
|
||||
url: A https://pkg.go.dev/github.com/creachadair/otp/otpauth#URL
|
||||
*/
|
||||
// TODO?
|
||||
)
|
||||
103
internal/funcs.go
Normal file
103
internal/funcs.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
`context`
|
||||
`sync`
|
||||
|
||||
`github.com/hashicorp/vault-client-go`
|
||||
`r00t2.io/gosecret`
|
||||
`r00t2.io/goutils/multierr`
|
||||
)
|
||||
|
||||
func New(vaultTok, vaultAddr, vaultMnt, collNm string) (c *Client, err error) {
|
||||
|
||||
c = &Client{
|
||||
// lastIdx: 0,
|
||||
vtok: vaultTok,
|
||||
vaddr: vaultAddr,
|
||||
scollNm: collNm,
|
||||
vmnt: vaultMnt,
|
||||
errsDone: make(chan bool, 1),
|
||||
errChan: make(chan error),
|
||||
// vc: nil,
|
||||
wg: sync.WaitGroup{},
|
||||
ctx: context.Background(),
|
||||
// ssvc: nil,
|
||||
// scoll: nil,
|
||||
mErr: multierr.NewMultiError(nil),
|
||||
// inSS: nil,
|
||||
// inVault: nil,
|
||||
}
|
||||
|
||||
if c.vc, err = vault.New(vault.WithAddress(c.vaddr)); err != nil {
|
||||
return
|
||||
}
|
||||
if err = c.vc.SetToken(c.vtok); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.ssvc, err = gosecret.NewService(); err != nil {
|
||||
return
|
||||
}
|
||||
if c.scoll, err = c.ssvc.GetCollection(collNm); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go c.readErrs()
|
||||
|
||||
c.wg.Add(2)
|
||||
go c.getSS()
|
||||
go c.getVault()
|
||||
|
||||
c.wg.Wait()
|
||||
|
||||
if !c.mErr.IsEmpty() {
|
||||
err = c.mErr
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func normalizeVaultNm(nm string) (normalized string) {
|
||||
|
||||
var c rune
|
||||
var idx int
|
||||
var last rune
|
||||
var repl rune = '_'
|
||||
var reduced []rune = make([]rune, 0)
|
||||
var norm []rune = make([]rune, 0, len(nm))
|
||||
|
||||
for _, c = range nm {
|
||||
// If it's "safe" chars, it's fine
|
||||
if (c == '-' || c == '.') || // 0x2d, 0x2e
|
||||
(c >= '0' && c <= '9') || // 0x30 to 0x39
|
||||
(c == '@') || // 0x40
|
||||
(c >= 'A' && c <= 'Z') || // 0x41 to 0x5a
|
||||
(c == '_') || // 0x5f
|
||||
(c >= 'a' && c <= 'z') { // 0x61 to 0x7a
|
||||
norm = append(norm, c)
|
||||
continue
|
||||
}
|
||||
// Otherwise normalize it to a safe char
|
||||
norm = append(norm, repl)
|
||||
}
|
||||
|
||||
// And remove repeating sequential replacers.
|
||||
for idx, c = range norm[:] {
|
||||
if idx == 0 {
|
||||
last = c
|
||||
reduced = append(reduced, c)
|
||||
continue
|
||||
}
|
||||
if c == last && last == repl {
|
||||
continue
|
||||
}
|
||||
reduced = append(reduced, c)
|
||||
last = c
|
||||
}
|
||||
|
||||
normalized = string(reduced)
|
||||
|
||||
return
|
||||
}
|
||||
269
internal/funcs_client.go
Normal file
269
internal/funcs_client.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
`errors`
|
||||
`fmt`
|
||||
`math`
|
||||
`net/http`
|
||||
`strconv`
|
||||
`strings`
|
||||
|
||||
`github.com/creachadair/otp/otpauth`
|
||||
`github.com/hashicorp/vault-client-go`
|
||||
`github.com/hashicorp/vault-client-go/schema`
|
||||
`r00t2.io/gosecret`
|
||||
`r00t2.io/goutils/multierr`
|
||||
)
|
||||
|
||||
func (c *Client) Close() (err error) {
|
||||
|
||||
if err = c.ssvc.Close(); err != nil {
|
||||
c.errChan <- err
|
||||
}
|
||||
|
||||
close(c.errChan)
|
||||
|
||||
<-c.errsDone
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) DeleteAllVaultKeys() {
|
||||
|
||||
var name string
|
||||
|
||||
for name, _ = range c.inVault {
|
||||
c.wg.Add(1)
|
||||
go func(nm string) {
|
||||
var vErr error
|
||||
var resp *vault.Response[map[string]interface{}]
|
||||
|
||||
defer c.wg.Done()
|
||||
|
||||
if resp, vErr = c.vc.Secrets.TotpDeleteKey(
|
||||
c.ctx,
|
||||
nm,
|
||||
vault.WithMountPath(c.vmnt),
|
||||
); vErr != nil {
|
||||
c.errChan <- vErr
|
||||
return
|
||||
}
|
||||
_ = resp
|
||||
}(name)
|
||||
}
|
||||
|
||||
c.wg.Wait()
|
||||
}
|
||||
|
||||
func (c *Client) Errors() (errs []error) {
|
||||
|
||||
errs = make([]error, 0)
|
||||
|
||||
if c.mErr == nil || c.mErr.IsEmpty() {
|
||||
return
|
||||
}
|
||||
|
||||
errs = c.mErr.Errors
|
||||
c.mErr = multierr.NewMultiError(nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// currently it doesn't update out-of-sync keys and only syncs from SecretService to Vault. TODO.
|
||||
func (c *Client) Sync() {
|
||||
|
||||
c.addVault()
|
||||
|
||||
c.wg.Wait()
|
||||
}
|
||||
|
||||
func (c *Client) addVault() {
|
||||
|
||||
var name string
|
||||
var wsec wrappedSsSecret
|
||||
|
||||
for name, wsec = range c.inSS {
|
||||
c.wg.Add(1)
|
||||
go func(nm string, sec wrappedSsSecret) {
|
||||
var vErr error
|
||||
var i int
|
||||
var ok bool
|
||||
var digSize int
|
||||
var numSeconds int
|
||||
// var digSize int32
|
||||
var otpUrl *otpauth.URL
|
||||
var req schema.TotpCreateKeyRequest
|
||||
var resp *vault.Response[map[string]interface{}]
|
||||
|
||||
defer c.wg.Done()
|
||||
|
||||
if _, ok = c.inVault[nm]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
if i, vErr = strconv.Atoi(sec.secret.Attrs["digits"]); vErr != nil {
|
||||
c.errChan <- vErr
|
||||
return
|
||||
}
|
||||
if i > math.MaxInt32 {
|
||||
c.errChan <- fmt.Errorf("digits too large (%d > %d)", i, math.MaxInt32)
|
||||
return
|
||||
}
|
||||
// digSize = int32(i)
|
||||
digSize = i
|
||||
|
||||
if i, vErr = strconv.Atoi(sec.secret.Attrs["period"]); vErr != nil {
|
||||
c.errChan <- vErr
|
||||
return
|
||||
}
|
||||
if i > math.MaxInt32 {
|
||||
c.errChan <- fmt.Errorf("period too large (%d > %d)", i, math.MaxInt32)
|
||||
return
|
||||
}
|
||||
numSeconds = i
|
||||
|
||||
otpUrl = &otpauth.URL{
|
||||
Type: "TOTP", // TODO: https://github.com/dkosmari/gnome-shell-extension-totp/issues/15
|
||||
Issuer: sec.secret.Attrs["issuer"],
|
||||
Account: sec.secret.Attrs["name"],
|
||||
RawSecret: string(sec.secret.Secret.Value),
|
||||
Algorithm: strings.ToUpper(strings.ReplaceAll(sec.secret.Attrs["algorithm"], "-", "")),
|
||||
Digits: digSize,
|
||||
Period: numSeconds,
|
||||
Counter: 0,
|
||||
}
|
||||
fmt.Printf("Adding '%s' to '%s'\n", nm, c.vmnt)
|
||||
// https://developer.hashicorp.com/vault/api-docs/secret/totp#create-key
|
||||
// https://pkg.go.dev/github.com/hashicorp/vault-client-go/schema#TotpCreateKeyRequest
|
||||
req = schema.TotpCreateKeyRequest{
|
||||
// AccountName: sec.secret.Attrs["name"],
|
||||
// Algorithm: strings.ToUpper(strings.ReplaceAll(sec.secret.Attrs["algorithm"], "-", "")),
|
||||
// Digits: digSize,
|
||||
// Exported: false,
|
||||
// Generate: false,
|
||||
// Issuer: sec.secret.Attrs["issuer"],
|
||||
// Key: string(sec.secret.Secret.Value),
|
||||
// // KeySize: 0,
|
||||
// Period: sec.secret.Attrs["period"],
|
||||
// // QrSize: 0,
|
||||
// // Skew: 0,
|
||||
Url: otpUrl.String(),
|
||||
}
|
||||
if resp, vErr = c.vc.Secrets.TotpCreateKey(
|
||||
c.ctx,
|
||||
nm,
|
||||
req,
|
||||
vault.WithMountPath(c.vmnt),
|
||||
); vErr != nil {
|
||||
c.errChan <- fmt.Errorf("error adding '%s': %v", nm, vErr)
|
||||
return
|
||||
}
|
||||
_ = resp
|
||||
}(name, wsec)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) getSS() {
|
||||
|
||||
var err error
|
||||
var idx int
|
||||
var nm string
|
||||
var spl []string
|
||||
var item *gosecret.Item
|
||||
var secrets []*gosecret.Item
|
||||
|
||||
defer c.wg.Done()
|
||||
|
||||
if secrets, err = c.scoll.Items(); err != nil {
|
||||
c.errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
c.inSS = make(map[string]wrappedSsSecret)
|
||||
for _, item = range secrets {
|
||||
// Unlike KVv2 names, Vault is *very* picky about key names. Boo.
|
||||
// nm = fmt.Sprintf("%s/%s", item.Attrs["issuer"], item.Attrs["name"])
|
||||
nm = fmt.Sprintf(
|
||||
"%s"+
|
||||
"."+
|
||||
"%s",
|
||||
normalizeVaultNm(item.Attrs["issuer"]),
|
||||
normalizeVaultNm(item.Attrs["name"]),
|
||||
)
|
||||
fmt.Printf("'%s' from '%s' => '%s'\n", item.Attrs["name"], item.Attrs["issuer"], nm)
|
||||
spl = strings.SplitN(item.LabelName, ":", 2)
|
||||
if idx, err = strconv.Atoi(spl[0]); err != nil {
|
||||
c.errChan <- err
|
||||
continue
|
||||
}
|
||||
c.inSS[nm] = wrappedSsSecret{
|
||||
id: idx,
|
||||
strippedNm: nm,
|
||||
nm: item.LabelName,
|
||||
secret: item,
|
||||
}
|
||||
if idx > c.lastIdx {
|
||||
c.lastIdx = idx
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) getVault() {
|
||||
|
||||
var err error
|
||||
var nm string
|
||||
var nms []string
|
||||
var vErr *vault.ResponseError
|
||||
var totpKey *vault.Response[map[string]interface{}]
|
||||
|
||||
defer c.wg.Done()
|
||||
|
||||
var resp *vault.Response[schema.StandardListResponse]
|
||||
|
||||
resp, err = c.vc.Secrets.TotpListKeys(
|
||||
c.ctx,
|
||||
vault.WithMountPath(c.vmnt),
|
||||
)
|
||||
if err != nil {
|
||||
// no TOTP yet
|
||||
if errors.As(err, &vErr) && vErr.StatusCode == http.StatusNotFound {
|
||||
c.inVault = make(map[string]map[string]interface{})
|
||||
err = nil
|
||||
return
|
||||
}
|
||||
c.errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
nms = resp.Data.Keys
|
||||
c.inVault = make(map[string]map[string]interface{})
|
||||
|
||||
for _, nm = range nms {
|
||||
if totpKey, err = c.vc.Secrets.TotpReadKey(
|
||||
c.ctx,
|
||||
nm,
|
||||
vault.WithMountPath(c.vmnt),
|
||||
); err != nil {
|
||||
c.errChan <- err
|
||||
continue
|
||||
}
|
||||
c.inVault[nm] = totpKey.Data
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) readErrs() {
|
||||
|
||||
var err error
|
||||
|
||||
for err = range c.errChan {
|
||||
if err != nil {
|
||||
c.mErr.AddError(err)
|
||||
}
|
||||
}
|
||||
|
||||
c.errsDone <- true
|
||||
}
|
||||
36
internal/types.go
Normal file
36
internal/types.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
`context`
|
||||
`sync`
|
||||
|
||||
`github.com/hashicorp/vault-client-go`
|
||||
`r00t2.io/gosecret`
|
||||
`r00t2.io/goutils/multierr`
|
||||
)
|
||||
|
||||
type (
|
||||
Client struct {
|
||||
lastIdx int
|
||||
vtok string
|
||||
vaddr string
|
||||
scollNm string
|
||||
vmnt string
|
||||
errsDone chan bool
|
||||
errChan chan error
|
||||
vc *vault.Client
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
ssvc *gosecret.Service
|
||||
scoll *gosecret.Collection
|
||||
mErr *multierr.MultiError
|
||||
inSS map[string]wrappedSsSecret
|
||||
inVault map[string]map[string]interface{}
|
||||
}
|
||||
wrappedSsSecret struct {
|
||||
id int
|
||||
strippedNm string
|
||||
nm string
|
||||
secret *gosecret.Item
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user