From d94a46af0b1ce1fb1bc7d7bd957f866e8aa7c9fb Mon Sep 17 00:00:00 2001 From: brent saner Date: Tue, 16 Dec 2025 21:45:08 -0500 Subject: [PATCH] initial --- .gitignore | 48 ++++++ build.sh | 118 +++++++++++++++ cmd/kill_totp_secrets/main.go | 70 +++++++++ cmd/update_vault_totp/main.go | 70 +++++++++ cmd/vault_totp_util/consts.go | 27 ++++ cmd/vault_totp_util/main.go | 72 +++++++++ go.mod | 31 ++++ go.sum | 66 +++++++++ internal/args.go | 26 ++++ internal/consts.go | 32 ++++ internal/funcs.go | 103 +++++++++++++ internal/funcs_client.go | 269 ++++++++++++++++++++++++++++++++++ internal/types.go | 36 +++++ version/consts.go | 32 ++++ version/funcs.go | 144 ++++++++++++++++++ version/funcs_buildinfo.go | 103 +++++++++++++ version/types.go | 51 +++++++ 17 files changed, 1298 insertions(+) create mode 100644 .gitignore create mode 100755 build.sh create mode 100644 cmd/kill_totp_secrets/main.go create mode 100644 cmd/update_vault_totp/main.go create mode 100644 cmd/vault_totp_util/consts.go create mode 100644 cmd/vault_totp_util/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/args.go create mode 100644 internal/consts.go create mode 100644 internal/funcs.go create mode 100644 internal/funcs_client.go create mode 100644 internal/types.go create mode 100644 version/consts.go create mode 100644 version/funcs.go create mode 100644 version/funcs_buildinfo.go create mode 100644 version/types.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9416697 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +*.7z +*.bak +*.deb +*.jar +*.rar +*.run +*.sig +*.tar +*.tar.bz2 +*.tar.gz +*.tar.xz +*.tbz +*.tbz2 +*.tgz +*.txz +*.zip +.*.swp +.editix + +# https://github.com/github/gitignore/blob/master/Go.gitignore +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +#*.test +test.sh +/test/ + +# Built binaries +bin/* + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Example configs. +_exampledata/ + +# Don't include rendered doc +/README.html + +# Example code. +_demo/ diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..7a0324c --- /dev/null +++ b/build.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash + +set -e + +# This is not portable. It has bashisms. + +# Like this (bash >= 4.x) +declare -A os_sfx=( + ["linux"]='bin' + ["windows"]='exe' + ["darwin"]='app' +) + +BUILD_TIME="$(date '+%s')" +BUILD_USER="$(whoami)" +BUILD_SUDO_USER="${SUDO_USER}" +BUILD_HOST="$(hostname)" + +# Check to make sure git is available. +if ! command -v git &> /dev/null; then + echo "Git is not available; automatic version handling unsupported." + echo "You must build by calling 'go build' directly in the respective directories." + exit 0 +fi + +# Check git directory/repository. +if ! git rev-parse --is-inside-work-tree &> /dev/null; then + echo "Not running inside a git work tree; automatic version handling unsupported/build script unsupported." + echo "You must build by calling 'go build' directly in the respective directories instead." + exit 0 +fi + +# If it has a tag in the path of the current HEAD that matches a version string... +# I wish git describe supported regex. It does not; only globs. Gross. +# If there's a bug anywhere, it's here. +if git describe --tags --abbrev=0 --match "v[0-9]*" HEAD &> /dev/null; then + # It has a tag we can use. + CURRENT_VER="$(git describe --tags --abbrev=0 --match "v[0-9]*" HEAD)" + COMMITS_SINCE="$(git rev-list --count ${CURRENT_VER}..HEAD)" +else + # No tag available. + CURRENT_VER="" + COMMITS_SINCE="" +fi + +# If it's dirty (staged but not committed or unstaged files)... +if ! git diff-index --quiet HEAD; then + # It's dirty. + IS_DIRTY="1" +else + # It's clean. + IS_DIRTY="0" +fi + +# Get the commit hash of the *most recent* commit in the path of current HEAD... +CURRENT_HASH="$(git rev-parse --verify HEAD)" +# The same as above, but abbreviated. +CURRENT_SHORT="$(git rev-parse --verify --short HEAD)" + +# Get the module name. +MODPATH="$(sed -n -re 's@^\s*module\s+(.*)(//.*)?$@\1@p' go.mod)" + +# Build the ldflags string. +# BEHOLD! BASH WITCHCRAFT. +LDFLAGS_STR="\ +-X '${MODPATH}/version.version=${CURRENT_VER}' \ +-X '${MODPATH}/version.commitHash=${CURRENT_HASH}' \ +-X '${MODPATH}/version.commitShort=${CURRENT_SHORT}' \ +-X '${MODPATH}/version.numCommitsAfterTag=${COMMITS_SINCE}' \ +-X '${MODPATH}/version.isDirty=${IS_DIRTY}' \ +-X '${MODPATH}/version.buildTime=${BUILD_TIME}' \ +-X '${MODPATH}/version.buildUser=${BUILD_USER}' \ +-X '${MODPATH}/version.buildSudoUser=${BUILD_SUDO_USER}' \ +-X '${MODPATH}/version.buildHost=${BUILD_HOST}'" + +set -u + +# And finally build. +mkdir -p ./bin/ +export CGO_ENABLED=0 +_origdir="$(pwd)" +_pfx='' +#for cmd in 'discord' 'irc' 'msteams' 'slack' 'xmpp'; do +for cmd_dir in cmd/*; do + cmd="$(basename "${cmd_dir}")" + echo "${cmd}..." + if [ ! -f "${cmd_dir}/main.go" ]; then + continue + fi + cd "${cmd_dir}" + _bin="${_pfx}${cmd}" + for ga in 'amd64' 'arm64'; do + as="${ga}" + if [[ "${ga}" == 'amd64' ]]; then + as='x86_64' + fi + echo -e "\t${as}..." + for osnm in "${!os_sfx[@]}"; do + echo -e "\t\t${osnm}: " + _sfx="${os_sfx[$osnm]}" + bin="${_origdir}/bin/${_bin}-${as}-${CURRENT_VER:-dev}.${_sfx}" + export GOOS="${osnm}" + export GOARCH="${ga}" + echo -e -n "\t\tBuilding '${bin}'..." + go build \ + -o "${bin}" \ + -ldflags \ + "${LDFLAGS_STR}" +# "${LDFLAGS_STR}" \ +# *.go + echo " Done." + done + echo -e "\tDone." + done + echo "Done." +done + +echo -e "\nBuild complete." diff --git a/cmd/kill_totp_secrets/main.go b/cmd/kill_totp_secrets/main.go new file mode 100644 index 0000000..c2dd8e6 --- /dev/null +++ b/cmd/kill_totp_secrets/main.go @@ -0,0 +1,70 @@ +package main + +import ( + `fmt` + `log` + `os` + + `update_vault_totp/internal` + + "golang.org/x/term" + "r00t2.io/sysutils/envs" +) + +func getToken() (tok string, err error) { + + var p1 []byte + var oldState *term.State + + if envs.HasEnv(internal.VaultTokEnv) { + tok = os.Getenv(internal.VaultTokEnv) + return + } + + // Prompt for it instead + fmt.Println("Vault token needed.\nVault token (will not be echoed back):") + if oldState, err = term.GetState(internal.TermFd); err != nil { + return + } + defer func() { + if err = term.Restore(internal.TermFd, oldState); err != nil { + log.Println("restore failed:", err) + } + }() + + if p1, err = term.ReadPassword(internal.TermFd); err != nil { + return + } + tok = string(p1) + + return +} + +func main() { + + var err error + var errs []error + var tok string + var c *internal.Client + + if tok, err = getToken(); err != nil { + log.Panicln(err) + } + + if c, err = internal.New(tok, internal.DefAddr, internal.TgtMnt, internal.CollNm); err != nil { + log.Panicln(err) + } + + c.DeleteAllVaultKeys() + + if err = c.Close(); err != nil { + log.Println(err) + } + if errs = c.Errors(); len(errs) > 0 { + for _, err = range errs { + log.Println(err) + } + } + + log.Println("Done.") +} diff --git a/cmd/update_vault_totp/main.go b/cmd/update_vault_totp/main.go new file mode 100644 index 0000000..7552861 --- /dev/null +++ b/cmd/update_vault_totp/main.go @@ -0,0 +1,70 @@ +package main + +import ( + `fmt` + `log` + `os` + + `update_vault_totp/internal` + + "golang.org/x/term" + "r00t2.io/sysutils/envs" +) + +func getToken() (tok string, err error) { + + var p1 []byte + var oldState *term.State + + if envs.HasEnv(internal.VaultTokEnv) { + tok = os.Getenv(internal.VaultTokEnv) + return + } + + // Prompt for it instead + fmt.Println("Vault token needed.\nVault token (will not be echoed back):") + if oldState, err = term.GetState(internal.TermFd); err != nil { + return + } + defer func() { + if err = term.Restore(internal.TermFd, oldState); err != nil { + log.Println("restore failed:", err) + } + }() + + if p1, err = term.ReadPassword(internal.TermFd); err != nil { + return + } + tok = string(p1) + + return +} + +func main() { + + var err error + var errs []error + var tok string + var c *internal.Client + + if tok, err = getToken(); err != nil { + log.Panicln(err) + } + + if c, err = internal.New(tok, internal.DefAddr, internal.TgtMnt, internal.CollNm); err != nil { + log.Panicln(err) + } + + c.Sync() + + if err = c.Close(); err != nil { + log.Println(err) + } + if errs = c.Errors(); len(errs) > 0 { + for _, err = range errs { + log.Println(err) + } + } + + log.Println("Done.") +} diff --git a/cmd/vault_totp_util/consts.go b/cmd/vault_totp_util/consts.go new file mode 100644 index 0000000..93aa7a6 --- /dev/null +++ b/cmd/vault_totp_util/consts.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" + + `update_vault_totp/internal` + + sysdUtil "github.com/coreos/go-systemd/v22/util" + `github.com/jessevdk/go-flags` + `r00t2.io/goutils/logging` +) + +var ( + isSystemd bool = sysdUtil.IsRunningSystemd() +) + +const ( + logFlags int = log.LstdFlags | log.Lmsgprefix + logFlagsDebug int = logFlags | log.Llongfile +) + +var ( + logger *logging.MultiLogger + args *internal.Args = new(internal.Args) + logFlagsRuntime int = logFlags + parser *flags.Parser = flags.NewParser(args, flags.Default) +) diff --git a/cmd/vault_totp_util/main.go b/cmd/vault_totp_util/main.go new file mode 100644 index 0000000..1d67e35 --- /dev/null +++ b/cmd/vault_totp_util/main.go @@ -0,0 +1,72 @@ +package main + +import ( + `errors` + `fmt` + `log` + `os` + + `update_vault_totp/version` + + `github.com/davecgh/go-spew/spew` + `github.com/jessevdk/go-flags` + `r00t2.io/goutils/logging` +) + +func main() { + + var err error + + log.SetOutput(os.Stderr) + + parser.EnvNamespaceDelimiter = "" + if _, err = parser.Parse(); err != nil { + var flagsErr *flags.Error + switch { + case errors.As(err, &flagsErr): + switch { + case errors.Is(flagsErr.Type, flags.ErrHelp), errors.Is(flagsErr.Type, flags.ErrCommandRequired), errors.Is(flagsErr.Type, flags.ErrRequired): // These print their relevant messages by themselves. + return + default: + log.Panicln(err) + } + default: + log.Panicln(err) + } + } + + if version.Ver, err = version.Version(); err != nil { + log.Panicln(err) + } + + // If args.Version or args.DetailVersion are true, just print them and exit. + if args.DetailVersion || args.Version { + if args.Version { + fmt.Println(version.Ver.Short()) + return + } else if args.DetailVersion { + fmt.Println(version.Ver.Detail()) + return + } + } + + // We want to set up logging before anything else. + if args.DoDebug { + logFlagsRuntime = logFlagsDebug + } + logger = logging.GetMultiLogger(args.DoDebug, "ZorkBot") + if err = logger.AddDefaultLogger( + "default", + logFlagsRuntime, + "/var/log/zorkbot/zorkbot.log", "~/logs/zorkbot.log", + ); err != nil { + log.Panicln(err) + } + if err = logger.Setup(); err != nil { + log.Panicln(err) + } + defer logger.Shutdown() + logger.Info("main: ZorkBot version %v", version.Ver.Short()) + logger.Debug("main: ZorkBot version (extended):\n%v", version.Ver.Detail()) + logger.Debug("main: Invocation args:\n%s", spew.Sdump(args)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2641bbe --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module update_vault_totp + +go 1.25.5 + +require ( + github.com/coreos/go-systemd/v22 v22.5.0 + github.com/creachadair/otp v0.5.2 + github.com/hashicorp/vault-client-go v0.4.3 + github.com/jessevdk/go-flags v1.6.1 + golang.org/x/mod v0.23.0 + golang.org/x/term v0.38.0 + r00t2.io/gosecret v1.1.5 + r00t2.io/goutils v1.13.0 + r00t2.io/sysutils v1.15.0 +) + +require ( + github.com/creachadair/wirepb v0.0.0-20251201055919-954ef89c4c71 // indirect + github.com/djherbis/times v1.6.0 // indirect + github.com/godbus/dbus/v5 v5.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/time v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..21c646a --- /dev/null +++ b/go.sum @@ -0,0 +1,66 @@ +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creachadair/mds v0.25.2 h1:xc0S0AfDq5GX9KUR5sLvi5XjA61/P6S5e0xFs1vA18Q= +github.com/creachadair/mds v0.25.2/go.mod h1:+s4CFteFRj4eq2KcGHW8Wei3u9NyzSPzNV32EvjyK/Q= +github.com/creachadair/otp v0.5.2 h1:3krOkIHA8X1YAnv9lGZi2NlyCZepcsAQkZwKGK5L+9c= +github.com/creachadair/otp v0.5.2/go.mod h1:0cUM4kl9cQmePbIEy1J1O+esh+S3zDM9s2Foo71KzKA= +github.com/creachadair/wirepb v0.0.0-20251201055919-954ef89c4c71 h1:H3EV2OFbtKgFAcxGJiLTNar9krJ9yfsgpE37/QY9vN8= +github.com/creachadair/wirepb v0.0.0-20251201055919-954ef89c4c71/go.mod h1:dHvCVZSsk3Y/fhY0mUex0U2XBZszomOe7PzlghOtHnQ= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8= +github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/vault-client-go v0.4.3 h1:zG7STGVgn/VK6rnZc0k8PGbfv2x/sJExRKHSUg3ljWc= +github.com/hashicorp/vault-client-go v0.4.3/go.mod h1:4tDw7Uhq5XOxS1fO+oMtotHL7j4sB9cp0T7U6m4FzDY= +github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= +github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= +golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +r00t2.io/gosecret v1.1.5 h1:pmlsCR8VKeI9+BN7uvUqThP/BtgoSch580XOWin+qH4= +r00t2.io/gosecret v1.1.5/go.mod h1:24jo8x5lOmWAJZJZ1J3FxhoT9+KOXSZ5pQWWzFdvdtM= +r00t2.io/goutils v1.13.0 h1:103QR2eUu42TNF7h9r6YgahbSgRAJ8VtFxSzw9rMaXI= +r00t2.io/goutils v1.13.0/go.mod h1:/0M+6fW2VAgdtAvwGE9oNV259MoEZeJ84rLCZsxe8uI= +r00t2.io/sysutils v1.15.0 h1:FSnREfbXDhBQEO7LMpnRQeKlPshozxk9XHw3YgWRgRg= +r00t2.io/sysutils v1.15.0/go.mod h1:28qB0074EIRQ8Sy/ybaA5jC3qA32iW2aYLkMCRhyAFM= diff --git a/internal/args.go b/internal/args.go new file mode 100644 index 0000000..bdab032 --- /dev/null +++ b/internal/args.go @@ -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"` + } +) diff --git a/internal/consts.go b/internal/consts.go new file mode 100644 index 0000000..d7dcc04 --- /dev/null +++ b/internal/consts.go @@ -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? +) diff --git a/internal/funcs.go b/internal/funcs.go new file mode 100644 index 0000000..93a1bf4 --- /dev/null +++ b/internal/funcs.go @@ -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 +} diff --git a/internal/funcs_client.go b/internal/funcs_client.go new file mode 100644 index 0000000..9229fa4 --- /dev/null +++ b/internal/funcs_client.go @@ -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 +} diff --git a/internal/types.go b/internal/types.go new file mode 100644 index 0000000..aebdb3e --- /dev/null +++ b/internal/types.go @@ -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 + } +) diff --git a/version/consts.go b/version/consts.go new file mode 100644 index 0000000..04ec47a --- /dev/null +++ b/version/consts.go @@ -0,0 +1,32 @@ +package version + +import ( + "regexp" +) + +/* +These variables are automatically handled by the build script. + +DO NOT MODIFY THESE VARIABLES. +Refer to /build.sh for how these are generated at build time and populated. +*/ +var ( + sourceControl string = "git" + version string = "(unknown)" + commitHash string + commitShort string + numCommitsAfterTag string + isDirty string + buildTime string + buildUser string + buildSudoUser string + buildHost string +) + +var ( + patchRe *regexp.Regexp = regexp.MustCompile(`^(?P[0-9+])(?P
-[0-9A-Za-z.-]+)?(?P\+[0-9A-Za-z.-]+)?$`)
+	patchReIsolated *regexp.Regexp = regexp.MustCompile(`^([0-9]+)(?:[-+](.*)?)?$`)
+)
+
+// Ver is populated by main() from the build script and used in other places.
+var Ver *BuildInfo
diff --git a/version/funcs.go b/version/funcs.go
new file mode 100644
index 0000000..6624bd3
--- /dev/null
+++ b/version/funcs.go
@@ -0,0 +1,144 @@
+package version
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+
+	"golang.org/x/mod/semver"
+)
+
+// Version returns the build information. See build.sh.
+func Version() (b *BuildInfo, err error) {
+
+	var n int
+	var s string
+	var sb strings.Builder
+	var ok bool
+	var canonical string
+	var build strings.Builder
+	// Why a map?
+	// I forget but I had a reason for it once upon a time.
+	var raw map[string]string = map[string]string{
+		"sourceControl":  sourceControl,
+		"tag":            version,
+		"hash":           commitHash,
+		"shortHash":      commitShort,
+		"postTagCommits": numCommitsAfterTag,
+		"dirty":          isDirty,
+		"time":           buildTime,
+		"user":           buildUser,
+		"sudoUser":       buildSudoUser,
+		"host":           buildHost,
+	}
+	var i BuildInfo = BuildInfo{
+		SourceControl: raw["sourceControl"],
+		TagVersion:    raw["tag"],
+		// PostTagCommits: 0,
+		CommitHash:    raw["hash"],
+		CommitId:      raw["shortHash"],
+		BuildUser:     raw["user"],
+		RealBuildUser: raw["sudoUser"],
+		// BuildTime:     time.Time{},
+		BuildHost: raw["host"],
+		Dirty:     false,
+		isDefined: false,
+		raw:       raw,
+	}
+
+	if s, ok = raw["postTagCommits"]; ok && strings.TrimSpace(s) != "" {
+		if n, err = strconv.Atoi(s); err == nil {
+			i.PostTagCommits = uint(n)
+		}
+	}
+
+	if s, ok = raw["time"]; ok && strings.TrimSpace(s) != "" {
+		if n, err = strconv.Atoi(s); err == nil {
+			i.BuildTime = time.Unix(int64(n), 0).UTC()
+		}
+	}
+
+	switch strings.ToLower(raw["dirty"]) {
+	case "1":
+		i.Dirty = true
+	case "0", "":
+		i.Dirty = false
+	}
+
+	// Build the short form. We use this for both BuildInfo.short and BuildInfo.verSplit.
+	if i.TagVersion == "" {
+		sb.WriteString(i.SourceControl)
+	} else {
+		sb.WriteString(i.TagVersion)
+	}
+	/*
+		Now the mess. In order to conform to SemVer 2.0 (the spec this code targets):
+
+		1.) MAJOR.
+		2.) MINOR.
+		3.) PATCH
+		4.) -PRERELEASE (OPTIONAL)
+			(git commit, if building against a commit made past 1-3. Always included if untagged.)
+		5.) +BUILDINFO (OPTIONAL)
+			("+x[.y]", where x is # of commits past 4, or tag commit if 4 is empty. 0 is valid.
+			 y is optional, and is the string "dirty" if it is a "dirty" build - that is, uncommitted/unstaged changes.
+			 if x and y would be 0 and empty, respectively, then 5 is not included.)
+
+		1-3 are already written, or the source control software used if not.
+
+		Technically 4 and 5 are only included if 3 is present. We force patch to 0 if it's a tagged release and patch isn't present --
+		so this is not relevant.
+	*/
+	// PRERELEASE
+	if i.TagVersion == "" || i.PostTagCommits > 0 {
+		// We use the full commit hash for git versions, short identifier for tagged releases.
+		if i.TagVersion == "" {
+			i.Pre = i.CommitHash
+		} else {
+			i.Pre = i.CommitId
+		}
+		sb.WriteString(fmt.Sprintf("-%v", i.Pre))
+	}
+	// BUILD
+	if i.PostTagCommits > 0 || i.Dirty {
+		build.WriteString(strconv.Itoa(int(i.PostTagCommits)))
+		if i.Dirty {
+			build.WriteString(".dirty")
+		}
+		i.Build = build.String()
+		sb.WriteString(fmt.Sprintf("+%v", i.Build))
+	}
+
+	i.short = sb.String()
+	if semver.IsValid(i.short) {
+		// DON'T DO THIS. It strips the prerelease and build info.
+		// i.short = semver.Canonical(i.short)
+		// Do this instead.
+		canonical = semver.Canonical(i.short)
+		// Numeric versions...
+		if n, err = strconv.Atoi(strings.TrimPrefix(semver.Major(canonical), "v")); err != nil {
+			err = nil
+		} else {
+			i.Major = uint(n)
+		}
+		if n, err = strconv.Atoi(strings.Split(semver.MajorMinor(canonical), ".")[1]); err != nil {
+			err = nil
+		} else {
+			i.Minor = uint(n)
+		}
+		if n, err = strconv.Atoi(patchReIsolated.FindStringSubmatch(strings.Split(canonical, ".")[2])[1]); err != nil {
+			err = nil
+		} else {
+			i.Patch = uint(n)
+		}
+		// The other tag assignments were performed above.
+	}
+	// The default is 0 for the numerics, so no big deal.
+
+	i.isDefined = true
+
+	b = &i
+
+	return
+}
diff --git a/version/funcs_buildinfo.go b/version/funcs_buildinfo.go
new file mode 100644
index 0000000..0d27cdf
--- /dev/null
+++ b/version/funcs_buildinfo.go
@@ -0,0 +1,103 @@
+package version
+
+import (
+	"fmt"
+	"strings"
+
+	"golang.org/x/mod/semver"
+)
+
+// Detail returns a multiline string containing every possible piece of information we collect.
+func (b *BuildInfo) Detail() (ver string) {
+
+	var sb strings.Builder
+
+	sb.WriteString(fmt.Sprintf("%v\n\n", b.short))
+	sb.WriteString(fmt.Sprintf("====\nSource Control: %v\n", b.SourceControl))
+	if b.TagVersion != "" {
+		if b.PostTagCommits > 0 {
+			sb.WriteString(fmt.Sprintf("Version Base: %v\nCommit Hash: %v\n", b.TagVersion, b.CommitHash))
+		} else {
+			sb.WriteString(fmt.Sprintf("Version: %v\n", b.TagVersion))
+		}
+	} else {
+		sb.WriteString(fmt.Sprintf("Version: (Unversioned)\nCommit Hash: %v\n", b.CommitHash))
+	}
+
+	// Post-commits
+	if b.TagVersion != "" {
+		sb.WriteString(fmt.Sprintf("# of Commits Since %v: %v\n", b.TagVersion, b.PostTagCommits))
+	} else {
+		sb.WriteString(fmt.Sprintf("# of Commits Since %v: %v\n", b.CommitId, b.PostTagCommits))
+	}
+
+	sb.WriteString("Uncommitted/Unstaged Changes: ")
+	if b.Dirty {
+		sb.WriteString("yes (dirty/monkeypatched build)\n")
+	} else {
+		sb.WriteString("no (clean build)\n")
+	}
+
+	if b.TagVersion != "" {
+		sb.WriteString(
+			fmt.Sprintf(
+				"====\nMajor: %v\nMinor: %v\nPatch: %v\n",
+				b.Major, b.Minor, b.Patch,
+			),
+		)
+	}
+	sb.WriteString("====\n")
+	sb.WriteString(b.Meta())
+
+	ver = sb.String()
+
+	return
+}
+
+// Short returns a uniquely identifiable version string.
+func (b *BuildInfo) Short() (ver string) {
+
+	ver = b.short
+
+	return
+}
+
+// Meta returns the build/compile-time info.
+func (b *BuildInfo) Meta() (meta string) {
+
+	var sb strings.Builder
+
+	if b.RealBuildUser != b.BuildUser && b.RealBuildUser != "" {
+		sb.WriteString(fmt.Sprintf("Real Build User: %v\n", b.RealBuildUser))
+		sb.WriteString(fmt.Sprintf("Sudo Build User: %v\n", b.BuildUser))
+	} else {
+		sb.WriteString(fmt.Sprintf("Build User: %v\n", b.BuildUser))
+	}
+	sb.WriteString(fmt.Sprintf("Build Time: %v\nBuild Host: %v\n", b.BuildTime, b.BuildHost))
+
+	meta = sb.String()
+
+	return
+}
+
+// getReMap gets a regex map of map[pattern]match.
+func (b *BuildInfo) getReMap() (matches map[string]string) {
+
+	var s string = b.Short()
+	var sections []string
+
+	if !semver.IsValid(s) {
+		return
+	}
+
+	sections = strings.Split(s, ".")
+
+	// The split should contain everything in the third element.
+	// Or, if using a "simplified" semver, the last element.
+	matches = make(map[string]string)
+	for idx, str := range patchRe.FindStringSubmatch(sections[len(sections)-1]) {
+		matches[patchRe.SubexpNames()[idx]] = str
+	}
+
+	return
+}
diff --git a/version/types.go b/version/types.go
new file mode 100644
index 0000000..084e51d
--- /dev/null
+++ b/version/types.go
@@ -0,0 +1,51 @@
+package version
+
+import (
+	"time"
+)
+
+// BuildInfo contains nativized version information.
+type BuildInfo struct {
+	// TagVersion is the most recent tag name on the current branch.
+	TagVersion string
+	// PostTagCommits is the number of commits after BuildInfo.TagVersion's commit on the current branch.
+	PostTagCommits uint
+	// CommitHash is the full commit hash.
+	CommitHash string
+	// CommitId is the "short" version of BuildInfo.CommitHash.
+	CommitId string
+	// BuildUser is the user the program was compiled under.
+	BuildUser string
+	// If compiled under sudo, BuildInfo.RealBuildUser is the user that called sudo.
+	RealBuildUser string
+	// BuildTime is the time and date of the program's build time.
+	BuildTime time.Time
+	// BuildHost is the host the binary was compiled on.
+	BuildHost string
+	// Dirty specifies if the source was "dirty" (uncommitted/unstaged etc. files) at the time of compilation.
+	Dirty bool
+	// SourceControl is the source control version used. Only relevant if not a "clean" build or untagged.
+	SourceControl string
+	// Major is the major version, expressed as an uint per spec.
+	Major uint
+	// Minor is the minor version, expressed as an uint per spec.
+	Minor uint
+	// Patch is the patch version, expressed as an uint per spec.
+	Patch uint
+	// Pre
+	Pre string
+	// Build
+	Build string
+	// isDefined specifies if this version was retrieved from the built-in values.
+	isDefined bool
+	// raw is the raw variable values.
+	raw map[string]string
+	/*
+		verSplit is a slice of []string{Major, Minor, Patch, PreRelease, Build}
+
+		If using an actual point release, PreRelease and Build are probably blank.
+	*/
+	verSplit [5]string
+	// short is the condensed version of verSplit.
+	short string
+}