Submitted to Zero Dependency Hackathon 2026 · Track E: Security & Crypto Utilities
Every time I start a new Go project involving 2FA tokens, I do the same thing. I open a terminal and type:
go get github.com/pquerna/otp
go get github.com/google/uuid
Done. Tokens work in 20 minutes. I move on.
This time, I didn't do that. For the Zero Dependency Hackathon 2026, I built stdotp — a complete CLI 2FA authenticator and AES-256-GCM encrypted vault — using nothing but Go 1.27's standard library. No go get. No vendor/. No internet connection required at build time.
Here is every package I replaced, and what it actually cost me.
The Full Replacement Table
Package I'd normally go get
|
What stdotp uses instead |
|---|---|
github.com/pquerna/otp |
crypto/hmac + crypto/sha1/256/512 + encoding/base32
|
github.com/google/uuid |
Native uuid package (Go 1.27 stdlib, RFC 9562) |
golang.org/x/crypto/pbkdf2 |
Hand-rolled PBKDF2 loop over crypto/hmac (RFC 2898 §5.2) |
golang.org/x/crypto/nacl |
crypto/aes + crypto/cipher (AES-256-GCM) |
github.com/spf13/cobra |
flag + manual subcommand dispatch in main()
|
github.com/urfave/cli |
flag + os.Args dispatch |
github.com/stretchr/testify |
Plain testing package with table-driven tests |
gopkg.in/yaml.v3 |
encoding/json for the vault envelope |
github.com/mattn/go-sqlite3 |
Flat encrypted file via os + encoding/json
|
github.com/pkg/errors |
errors + fmt.Errorf("context: %w", err)
|
github.com/olekukonko/tablewriter |
text/tabwriter |
github.com/fatih/color |
Plain fmt output |
golang.org/x/term |
Documented trade-off: no masked password echo |
github.com/joho/godotenv |
os.Getenv directly |
net/http (outbound) |
Nothing — zero network calls by design |
15 packages. Zero require entries in go.mod. Here is what each one actually took.
Replacement 1: TOTP and HOTP — the hard one
github.com/pquerna/otp is elegant. You call totp.GenerateCode(secret, time.Now()) and get a 6-digit token. Under the hood it does:
- Base32-decode the secret (
encoding/base32) - Compute a time counter:
floor(unix_time / period) - HMAC-SHA1 the counter with the secret
- Dynamic truncation: read 4 bytes at
hmac[last] & 0xF, mask the top bit - Modulo
10^digits
RFC 4226 is 35 pages. The actual algorithm is 15 lines:
func hotp(secret []byte, counter uint64, digits int, h func() hash.Hash) string {
mac := hmac.New(h, secret)
binary.Write(mac, binary.BigEndian, counter)
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0xF
code := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7FFFFFFF
return fmt.Sprintf("%0*d", digits, int(code)%int(math.Pow10(digits)))
}
TOTP is HOTP with counter = floor(time.Now().Unix() / period).
RFC 6238 also specifies SHA256 and SHA512 variants — you just swap sha1.New for sha256.New. Supporting all three algorithms cost zero extra dependencies.
What it took: 2 days. Reading the RFCs carefully, writing test vectors by hand against RFC 4226 Appendix D and RFC 6238 Appendix B. The algorithm is not hard — trusting your own implementation is.
Replacement 2: UUID — simpler than you think
github.com/google/uuid has thousands of GitHub stars. Go 1.27 made it unnecessary:
// Go 1.27 stdlib uuid package — RFC 9562 compliant
import "uuid"
id := uuid.New().String() // "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
I use UUIDs for two things: immutable account identifiers and collision-free temp-file names during atomic vault writes.
What it took: 30 minutes. This is the easiest win of the project.
Replacement 3: PBKDF2 — the iteration-count rabbit hole
golang.org/x/crypto/pbkdf2 lives in the extended library and requires go get. I hand-rolled it over crypto/hmac following RFC 2898 §5.2:
func pbkdf2Key(password, salt []byte, iter, keyLen int) []byte {
prf := hmac.New(sha256.New, password)
hashLen := prf.Size()
blocks := (keyLen + hashLen - 1) / hashLen
dk := make([]byte, 0, blocks*hashLen)
var buf [4]byte
for block := 1; block <= blocks; block++ {
prf.Reset()
prf.Write(salt)
binary.BigEndian.PutUint32(buf[:], uint32(block))
prf.Write(buf[:])
U := prf.Sum(nil)
T := make([]byte, hashLen)
copy(T, U)
for i := 1; i < iter; i++ {
prf.Reset()
prf.Write(U)
U = prf.Sum(U[:0]) // reuse slice — no allocation
for j := range T { T[j] ^= U[j] }
}
dk = append(dk, T[:hashLen]...)
}
return dk[:keyLen]
}
Why not scrypt or Argon2? Both live in golang.org/x/crypto — they require go get and would break the empty require block. PBKDF2 is the only RFC-standardised KDF available within the pure stdlib boundary.
OWASP's 2026 recommendation is 600,000 iterations for PBKDF2-HMAC-SHA256. On my i5-13450HX that is ~320ms per unlock — painful for attackers, usable for humans. Reusing the hmac.Hash object instead of allocating a new one per iteration cut the time by 35%.
What it took: 1 day. Verified against RFC 7914 §12 official test vectors.
Replacement 4: AES-256-GCM Vault with AAD
Everyone uses crypto/aes and crypto/cipher directly — no popular library here. What most tutorials skip is Additional Authenticated Data (AAD).
My vault file starts with a canonical JSON header:
{"format":"v1","kdf":"pbkdf2-hmac-sha256","iters":600000,"salt":"<base64>"}
I bind this header into the GCM authentication tag before encrypting:
aead.Seal(ciphertext[:0], nonce, plaintext, aadBytes)
If anyone edits the header — even changing "iters":600000 to "iters":1 to weaken the KDF — decryption fails with cipher: message authentication failed. The vault is tamper-evident by construction, with no extra MAC step needed.
Fresh 12-byte CSPRNG nonce on every write. chmod 0600 on the file. UUID temp-file → fsync → os.Rename for atomic writes that survive power failures.
What it took: 3 hours for the code. A full day to understand NIST SP 800-38D well enough to explain why AAD matters.
Replacement 5: CLI Parsing — no Cobra
switch os.Args[1] {
case "init": cmdInit(os.Args[2:])
case "add": cmdAdd(os.Args[2:])
case "code": cmdCode(os.Args[2:])
case "verify": cmdVerify(os.Args[2:])
case "list": cmdList(os.Args[2:])
case "remove": cmdRemove(os.Args[2:])
case "status": cmdStatus(os.Args[2:])
case "change-password": cmdChangePassword(os.Args[2:])
case "self-test": cmdSelfTest(os.Args[2:])
}
Flag parsing via flag.NewFlagSet. The help text is uglier than Cobra. But it forced me to think about the actual command surface — I ended up with fewer commands, each doing one thing clearly.
What it took: Half a day. The hard part is discipline, not code.
Replacement 6: Testing — no testify
github.com/stretchr/testify gives you assert.Equal, require.NoError, and beautiful diffs. The stdlib testing package gives you t.Errorf and table-driven tests:
tests := []struct {
counter uint64
want string
}{
{0, "755224"}, {1, "287082"}, {2, "359152"}, // RFC 4226 Appendix D
}
for _, tt := range tests {
got := hotp(secret, tt.counter, 6, sha1.New)
if got != tt.want {
t.Errorf("counter=%d: got %s, want %s", tt.counter, got, tt.want)
}
}
56 test cases. 81.1% statement coverage. No assertion library. Turns out t.Errorf is all you need when you know exactly what you are testing.
Replacement 7: Table Output — text/tabwriter
github.com/olekukonko/tablewriter draws nice box-drawing character tables. text/tabwriter aligns columns with tabs:
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tISSUER\tTYPE\tALGO\tDIGITS\tPERIOD")
for _, a := range accounts {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n", ...)
}
w.Flush()
Output:
NAME ISSUER TYPE ALGO DIGITS PERIOD
aws AWS TOTP SHA256 8 30
github — TOTP SHA1 6 30
yubikey YubiKey HOTP SHA1 6 —
What it took: 20 minutes.
Replacement 8: JSON Instead of YAML
gopkg.in/yaml.v3 is the default choice for config files. I used encoding/json — the vault envelope is machine-written and machine-read, so readability matters less than zero-dependency correctness.
Replacement 9: No SQLite — Flat Encrypted File
github.com/mattn/go-sqlite3 requires CGO. I used a flat JSON file encrypted with AES-256-GCM. No CGO, fully cross-compiled, zero binary size overhead from a C library.
Replacement 10: Windows UTF-8 BOM Handling
Files created by Windows Notepad or PowerShell Out-File -Encoding UTF8 include a UTF-8 BOM (\xEF\xBB\xBF). Instead of golang.org/x/text/encoding, a one-liner:
func stripBOM(s string) string {
return strings.TrimPrefix(s, "\xef\xbb\xbf")
}
Replacement 11: Error Wrapping — no pkg/errors
github.com/pkg/errors was popular before Go 1.13. Since then, errors and fmt.Errorf in the standard library do everything it did:
// Instead of: errors.Wrap(err, "decrypt vault")
return fmt.Errorf("decrypt vault: %w", err)
// Unwrapping works natively
if errors.Is(err, ErrBadPassword) { ... }
var ve *VaultError
if errors.As(err, &ve) { ... }
%w wraps the error. errors.Is and errors.As unwrap it. No third-party package needed since Go 1.13.
What it took: 0 extra time — I was already writing Go 1.13+ style errors.
Replacement 12: No Terminal Colors — no fatih/color
github.com/fatih/color makes colored terminal output easy. I chose plain fmt deliberately.
Color codes break in CI pipelines, log files, and Windows terminals without ANSI support. stdotp output is designed to be piped:
stdotp code github | clip # pipe raw token to clipboard
If I added color escape codes, clip would receive \033[32m482751\033[0m instead of 482751. Plain fmt output is predictable across every environment.
What it took: A conscious decision not to add it.
Replacement 13: No Masked Password Input — honest trade-off
golang.org/x/term provides term.ReadPassword() which hides typed characters with • on screen. It is not stdlib — it requires go get.
stdotp reads passwords via plain fmt.Scan. Characters are visible while typing. This is an honest trade-off I document explicitly in STDLIB.md:
x/termis not stdlib; omitting it is an honest trade-off, stated explicitly.
In a real production tool I would use x/term. For a zero-dependency hackathon, documenting the trade-off is the right call.
Replacement 14: No .env Files — no godotenv
github.com/joho/godotenv loads .env files into environment variables. stdotp does not use .env files at all — secrets come from stdin (password prompt) or --flags only:
masterPass := os.Getenv("STDOTP_PASSWORD") // optional env override
This is actually more secure — .env files sitting on disk are a common credential leak vector.
What it took: os.Getenv. One function call.
Replacement 15: Zero Network Calls — net/http is absent
stdotp makes zero outbound network connections. net/http is not imported anywhere in stdotp.go. You can verify this:
grep -r "net/http" stdotp.go
# (no output)
Air-gap is a feature, not an accident. An authenticator that phones home would be a security anti-pattern. Every token is computed locally from the stored secret and the system clock.
The Thing Nobody Tells You
It is not the algorithms. RFC 4226 is clear. AES-GCM is well-documented. The hard part is the test infrastructure.
With pquerna/otp I would import it and trust its tests. Going stdlib means I personally verify my HOTP output against RFC 4226 Appendix D, my TOTP output against RFC 6238 Appendix B, and my PBKDF2 output against RFC 7914 §12. 56 test cases. Not because the hackathon required it. Because I had no external library to trust.
The Result
$ go list -m all
stdotp
$ GOPROXY=off go build -v ./...
# stdotp
$ go test -cover .
ok stdotp 28.278s coverage: 81.1% of statements
The SHA-256 is reproducible across any machine:
09258785B019BA542879A6260434D015FE7C8CD6E3BB122A8AC5E6C83FEE6958
Verify it yourself:
CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags="-buildid=" -o stdotp.exe .
certutil -hashfile stdotp.exe SHA256 # Windows
sha256sum stdotp # Linux / macOS
Was It Worth It?
Yes.
I understand TOTP at a level I never did when I was calling totp.GenerateCode(). I understand why GCM needs a fresh nonce on every write and why binding AAD prevents header tampering. I understand that 600,000 PBKDF2 iterations is a security parameter, not a performance knob.
Going zero-dependency did not teach me to avoid libraries. It taught me to understand what they are doing before I trust them.
GitHub: github.com/moin08s/stdotp
Interactive Demo: moin08s.github.io/stdotp
Developer: Moin · @moin08s · Team ZeroClock
Event: Zero Dependency Hackathon 2026 · Track E: Security & Crypto Utilities
Full 15-package substitution table with technical rationales: STDLIB.md
Top comments (0)