1
0
Files
vault_totp/cmd/user/funcs.go
2025-12-23 20:58:56 -05:00

667 lines
19 KiB
Go

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
}