DEV Community

Cover image for I Replaced 15 Go Packages With Nothing But the Standard Library — Here's What It Actually Took
Moin Sajit Mulla
Moin Sajit Mulla

Posted on

I Replaced 15 Go Packages With Nothing But the Standard Library — Here's What It Actually Took

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Base32-decode the secret (encoding/base32)
  2. Compute a time counter: floor(unix_time / period)
  3. HMAC-SHA1 the counter with the secret
  4. Dynamic truncation: read 4 bytes at hmac[last] & 0xF, mask the top bit
  5. 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)))
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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]
}
Enter fullscreen mode Exit fullscreen mode

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>"}
Enter fullscreen mode Exit fullscreen mode

I bind this header into the GCM authentication tag before encrypting:

aead.Seal(ciphertext[:0], nonce, plaintext, aadBytes)
Enter fullscreen mode Exit fullscreen mode

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 → fsyncos.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:])
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Output:

NAME     ISSUER   TYPE  ALGO    DIGITS  PERIOD
aws      AWS      TOTP  SHA256  8       30
github   —        TOTP  SHA1    6       30
yubikey  YubiKey  HOTP  SHA1    6       —
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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) { ... }
Enter fullscreen mode Exit fullscreen mode

%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
Enter fullscreen mode Exit fullscreen mode

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/term is 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The SHA-256 is reproducible across any machine:

09258785B019BA542879A6260434D015FE7C8CD6E3BB122A8AC5E6C83FEE6958
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)