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 }