Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfb80355bb | |||
| 77160395a0 | |||
| 37d5e04421 | |||
| dc54eeacbc | |||
| e2a3081ce5 | |||
| 3149d958f4 | |||
| f296344acf | |||
| 3fb2d88a3f | |||
| 150c02b377 | |||
| 83f88c49fe | |||
| 7c437ac45f | |||
| c999bf35b0 | |||
| 0dcd18c6f1 | |||
| 024d552293 | |||
| 9cd2ced695 | |||
| 619c08a13f | |||
| 944a57bf0e | |||
| 0857b29624 | |||
|
|
e95404bfc5 | ||
|
|
924654e7c4 | ||
| 9e0979e07f | |||
|
|
bbc82ff8de | ||
|
|
5fd928f69a | ||
|
|
acefe4a3b9 | ||
| a1452cebc9 | |||
| 6e9812e6f5 | |||
|
|
8c34415c34 | ||
|
|
2cf2c15def | ||
|
|
eaad1884d4 | ||
| 5d57d844d4 | |||
|
|
31b9d175dd | ||
|
|
79e106da2e | ||
|
|
939b1bc272 | ||
|
|
89e74f390b | ||
|
|
7881b6fdfc | ||
|
|
5bef33245f | ||
|
|
84250b0501 | ||
|
|
459e9f880f | ||
|
|
0982f47ce3 | ||
|
|
1dec15fd11 | ||
|
|
2ee9cae5ba | ||
|
|
dc04475120 | ||
|
|
dbbd5116b5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
.idea
|
||||
cmd/cert-bundler/testdata/pkg/*
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
|
||||
version: "2"
|
||||
|
||||
output:
|
||||
sort-order:
|
||||
- file
|
||||
- linter
|
||||
- severity
|
||||
|
||||
issues:
|
||||
# Maximum count of issues with the same text.
|
||||
# Set to 0 to disable.
|
||||
@@ -454,6 +460,8 @@ linters:
|
||||
- -QF1008
|
||||
# We often explicitly enable old/deprecated ciphers for research.
|
||||
- -SA1019
|
||||
# Covered by revive.
|
||||
- -ST1003
|
||||
|
||||
usetesting:
|
||||
# Enable/disable `os.TempDir()` detections.
|
||||
@@ -472,6 +480,8 @@ linters:
|
||||
rules:
|
||||
- path: 'ahash/ahash.go'
|
||||
linters: [ staticcheck, gosec ]
|
||||
- path: 'twofactor/.*.go'
|
||||
linters: [ exhaustive, mnd, revive ]
|
||||
- path: 'backoff/backoff_test.go'
|
||||
linters: [ testpackage ]
|
||||
- path: 'dbg/dbg_test.go'
|
||||
|
||||
37
CHANGELOG
37
CHANGELOG
@@ -1,5 +1,42 @@
|
||||
CHANGELOG
|
||||
|
||||
v1.13.1 - 2025-11-17
|
||||
|
||||
Add:
|
||||
- Dockerfile for cert-bundler.
|
||||
|
||||
v1.13.0 - 2025-11-16
|
||||
|
||||
Add:
|
||||
- cmd/certser: print serial numbers for certificates.
|
||||
- lib/HexEncode: add a new hex encode function handling multiple output
|
||||
formats, including with and without colons.
|
||||
|
||||
v1.12.4 - 2025-11-16
|
||||
|
||||
Changed:
|
||||
|
||||
- Linting fixes for twofactor that were previously masked.
|
||||
|
||||
v1.12.3 erroneously tagged and pushed
|
||||
|
||||
v1.12.2 - 2025-11-16
|
||||
|
||||
Changed:
|
||||
|
||||
- add rsc.io/qr dependency for twofactor.
|
||||
|
||||
v1.12.1 - 2025-11-16
|
||||
|
||||
Changed:
|
||||
- twofactor: Remove go.{mod,sum}.
|
||||
|
||||
v1.12.0 - 2025-11-16
|
||||
|
||||
Added
|
||||
- twofactor: the github.com/kisom/twofactor repo has been subtree'd
|
||||
into this repo.
|
||||
|
||||
v1.11.2 - 2025-11-16
|
||||
|
||||
Changed
|
||||
|
||||
@@ -20,7 +20,7 @@ Contents:
|
||||
atping/ Automated TCP ping, meant for putting in cronjobs.
|
||||
ca-signed/ Validate whether a certificate is signed by a CA.
|
||||
cert-bundler/
|
||||
Create certificate bundles from a source of PEM
|
||||
Create certificate bundles from a source of PEM
|
||||
certificates.
|
||||
cert-revcheck/
|
||||
Check whether a certificate has been revoked or is
|
||||
@@ -64,7 +64,7 @@ Contents:
|
||||
sprox/ Simple TCP proxy.
|
||||
stealchain/ Dump the verified chain from a TLS connection to a
|
||||
server.
|
||||
stealchain-server/
|
||||
stealchain-server/
|
||||
Dump the verified chain from a TLS connection from
|
||||
from a client.
|
||||
subjhash/ Print or match subject info from a certificate.
|
||||
@@ -90,6 +90,7 @@ Contents:
|
||||
syslog/ Syslog-type logging.
|
||||
tee/ Emulate tee(1)'s functionality in io.Writers.
|
||||
testio/ Various I/O utilities useful during testing.
|
||||
twofactor/ Two-factor authentication.
|
||||
|
||||
Each program should have a small README in the directory with more
|
||||
information.
|
||||
|
||||
28
cmd/cert-bundler/Dockerfile
Normal file
28
cmd/cert-bundler/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# Build and runtime image for cert-bundler
|
||||
# Usage (from repo root or cmd/cert-bundler directory):
|
||||
# docker build -t cert-bundler:latest -f cmd/cert-bundler/Dockerfile .
|
||||
# docker run --rm -v "$PWD":/work cert-bundler:latest
|
||||
# This expects a /work/bundle.yaml file in the mounted directory and
|
||||
# will write generated bundles to /work/bundle.
|
||||
|
||||
# Build stage
|
||||
FROM golang:1.24.3-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy go module files and download dependencies first for better caching
|
||||
RUN go install git.wntrmute.dev/kyle/goutils/cmd/cert-bundler@v1.13.1 && \
|
||||
mv /go/bin/cert-bundler /usr/local/bin/cert-bundler
|
||||
|
||||
# Runtime stage (kept as golang:alpine per requirement)
|
||||
FROM golang:1.24.3-alpine
|
||||
|
||||
# Create a work directory that users will typically mount into
|
||||
WORKDIR /work
|
||||
VOLUME ["/work"]
|
||||
|
||||
# Copy the built binary from the builder stage
|
||||
COPY --from=build /usr/local/bin/cert-bundler /usr/local/bin/cert-bundler
|
||||
|
||||
# Default command: read bundle.yaml from current directory and output to ./bundle
|
||||
ENTRYPOINT ["/usr/local/bin/cert-bundler"]
|
||||
CMD ["-c", "/work/bundle.yaml", "-o", "/work/bundle"]
|
||||
@@ -17,8 +17,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/certlib"
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/certlib"
|
||||
)
|
||||
|
||||
// Config represents the top-level YAML configuration.
|
||||
@@ -299,12 +300,18 @@ func prepareArchiveFiles(
|
||||
) ([]fileEntry, error) {
|
||||
var archiveFiles []fileEntry
|
||||
|
||||
// Track used filenames to avoid collisions inside archives
|
||||
usedNames := make(map[string]int)
|
||||
|
||||
// Handle a single bundle file
|
||||
if outputs.IncludeSingle && len(singleFileCerts) > 0 {
|
||||
files, err := encodeCertsToFiles(singleFileCerts, "bundle", encoding, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode single bundle: %w", err)
|
||||
}
|
||||
for i := range files {
|
||||
files[i].name = makeUniqueName(files[i].name, usedNames)
|
||||
}
|
||||
archiveFiles = append(archiveFiles, files...)
|
||||
}
|
||||
|
||||
@@ -316,6 +323,9 @@ func prepareArchiveFiles(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode individual cert %s: %w", cp.path, err)
|
||||
}
|
||||
for i := range files {
|
||||
files[i].name = makeUniqueName(files[i].name, usedNames)
|
||||
}
|
||||
archiveFiles = append(archiveFiles, files...)
|
||||
}
|
||||
}
|
||||
@@ -323,8 +333,9 @@ func prepareArchiveFiles(
|
||||
// Generate manifest if requested
|
||||
if outputs.Manifest {
|
||||
manifestContent := generateManifest(archiveFiles)
|
||||
manifestName := makeUniqueName("MANIFEST", usedNames)
|
||||
archiveFiles = append(archiveFiles, fileEntry{
|
||||
name: "MANIFEST",
|
||||
name: manifestName,
|
||||
content: manifestContent,
|
||||
})
|
||||
}
|
||||
@@ -573,3 +584,29 @@ func generateHashFile(path string, files []string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeUniqueName ensures that each file name within the archive is unique by appending
|
||||
// an incremental numeric suffix before the extension when collisions occur.
|
||||
// Example: "root.pem" -> "root-2.pem", "root-3.pem", etc.
|
||||
func makeUniqueName(name string, used map[string]int) string {
|
||||
// If unused, mark and return as-is
|
||||
if _, ok := used[name]; !ok {
|
||||
used[name] = 1
|
||||
return name
|
||||
}
|
||||
|
||||
ext := filepath.Ext(name)
|
||||
base := strings.TrimSuffix(name, ext)
|
||||
// Track a counter per base+ext key
|
||||
key := base + ext
|
||||
counter := max(used[key], 1)
|
||||
for {
|
||||
counter++
|
||||
candidate := fmt.Sprintf("%s-%d%s", base, counter, ext)
|
||||
if _, exists := used[candidate]; !exists {
|
||||
used[key] = counter
|
||||
used[candidate] = 1
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
This project is an exploration into the utility of Jetbrains' Junie
|
||||
to write smaller but tedious programs.
|
||||
|
||||
Task: build a certificate bundling tool in cmd/cert-bundler. It
|
||||
creates archives of certificates chains.
|
||||
|
||||
A YAML file for this looks something like:
|
||||
|
||||
``` yaml
|
||||
config:
|
||||
hashes: bundle.sha256
|
||||
expiry: 1y
|
||||
chains:
|
||||
core_certs:
|
||||
certs:
|
||||
- root: roots/core-ca.pem
|
||||
intermediates:
|
||||
- int/cca1.pem
|
||||
- int/cca2.pem
|
||||
- int/cca3.pem
|
||||
- root: roots/ssh-ca.pem
|
||||
intermediates:
|
||||
- ssh/ssh_dmz1.pem
|
||||
- ssh/ssh_internal.pem
|
||||
outputs:
|
||||
include_single: true
|
||||
include_individual: true
|
||||
manifest: true
|
||||
formats:
|
||||
- zip
|
||||
- tgz
|
||||
```
|
||||
|
||||
Some requirements:
|
||||
|
||||
1. First, all the certificates should be loaded.
|
||||
2. For each root, each of the indivudal intermediates should be
|
||||
checked to make sure they are properly signed by the root CA.
|
||||
3. The program should optionally take an expiration period (defaulting
|
||||
to one year), specified in config.expiration, and if any certificate
|
||||
is within that expiration period, a warning should be printed.
|
||||
4. If outputs.include_single is true, all certificates under chains
|
||||
should be concatenated into a single file.
|
||||
5. If outputs.include_individual is true, all certificates under
|
||||
chains should be included at the root level (e.g. int/cca2.pem
|
||||
would be cca2.pem in the archive).
|
||||
6. If bundle.manifest is true, a "MANIFEST" file is created with
|
||||
SHA256 sums of each file included in the archive.
|
||||
7. For each of the formats, create an archive file in the output
|
||||
directory (specified with `-o`) with that format.
|
||||
- If zip is included, create a .zip file.
|
||||
- If tgz is included, create a .tar.gz file with default compression
|
||||
levels.
|
||||
- All archive files should include any generated files (single
|
||||
and/or individual) in the top-level directory.
|
||||
8. In the output directory, create a file with the same name as
|
||||
config.hashes that contains the SHA256 sum of all files created.
|
||||
|
||||
-----
|
||||
|
||||
The outputs.include_single and outputs.include_individual describe
|
||||
what should go in the final archive. If both are specified, the output
|
||||
archive should include both a single bundle.pem and each individual
|
||||
certificate, for example.
|
||||
|
||||
-----
|
||||
|
||||
As it stands, given the following `bundle.yaml`:
|
||||
|
||||
``` yaml
|
||||
config:
|
||||
hashes: bundle.sha256
|
||||
expiry: 1y
|
||||
chains:
|
||||
core_certs:
|
||||
certs:
|
||||
- root: pems/gts-r1.pem
|
||||
intermediates:
|
||||
- pems/goog-wr2.pem
|
||||
outputs:
|
||||
include_single: true
|
||||
include_individual: true
|
||||
manifest: true
|
||||
formats:
|
||||
- zip
|
||||
- tgz
|
||||
- root: pems/isrg-root-x1.pem
|
||||
intermediates:
|
||||
- pems/le-e7.pem
|
||||
outputs:
|
||||
include_single: true
|
||||
include_individual: false
|
||||
manifest: true
|
||||
formats:
|
||||
- zip
|
||||
- tgz
|
||||
google_certs:
|
||||
certs:
|
||||
- root: pems/gts-r1.pem
|
||||
intermediates:
|
||||
- pems/goog-wr2.pem
|
||||
outputs:
|
||||
include_single: true
|
||||
include_individual: false
|
||||
manifest: true
|
||||
formats:
|
||||
- tgz
|
||||
lets_encrypt:
|
||||
certs:
|
||||
- root: pems/isrg-root-x1.pem
|
||||
intermediates:
|
||||
- pems/le-e7.pem
|
||||
outputs:
|
||||
include_single: false
|
||||
include_individual: true
|
||||
manifest: false
|
||||
formats:
|
||||
- zip
|
||||
```
|
||||
|
||||
The program outputs the following files:
|
||||
|
||||
- bundle.sha256
|
||||
- core_certs_0.tgz (contains individual certs)
|
||||
- core_certs_0.zip (contains individual certs)
|
||||
- core_certs_1.tgz (contains core_certs.pem)
|
||||
- core_certs_1.zip (contains core_certs.pem)
|
||||
- google_certs_0.tgz
|
||||
- lets_encrypt_0.zip
|
||||
|
||||
It should output
|
||||
|
||||
- bundle.sha256
|
||||
- core_certs.tgz
|
||||
- core_certs.zip
|
||||
- google_certs.tgz
|
||||
- lets_encrypt.zip
|
||||
|
||||
core_certs.* should contain `bundle.pem` and all the individual
|
||||
certs. There should be no _$n$ variants of archives.
|
||||
|
||||
-----
|
||||
|
||||
Add an additional field to outputs: encoding. It should accept one of
|
||||
`der`, `pem`, or `both`. If `der`, certificates should be output as a
|
||||
`.crt` file containing a DER-encoded certificate. If `pem`, certificates
|
||||
should be output as a `.pem` file containing a PEM-encoded certificate.
|
||||
If both, both the `.crt` and `.pem` certificate should be included.
|
||||
|
||||
For example, given the previous config, if `encoding` is der, the
|
||||
google_certs.tgz archive should contain
|
||||
|
||||
- bundle.crt
|
||||
- MANIFEST
|
||||
|
||||
Or with lets_encrypt.zip:
|
||||
|
||||
- isrg-root-x1.crt
|
||||
- le-e7.crt
|
||||
|
||||
However, if `encoding` is pem, the lets_encrypt.zip archive should contain:
|
||||
|
||||
- isrg-root-x1.pem
|
||||
- le-e7.pem
|
||||
|
||||
And if it `encoding` is both, the lets_encrypt.zip archive should contain:
|
||||
|
||||
- isrg-root-x1.crt
|
||||
- isrg-root-x1.pem
|
||||
- le-e7.crt
|
||||
- le-e7.pem
|
||||
|
||||
-----
|
||||
|
||||
The tgz format should output a `.tar.gz` file instead of a `.tgz` file.
|
||||
|
||||
-----
|
||||
|
||||
Move the format extensions to a global variable.
|
||||
|
||||
-----
|
||||
|
||||
Write a README.txt with a description of the bundle.yaml format.
|
||||
|
||||
Additionally, update the help text for the program (e.g. with `-h`)
|
||||
to provide the same detailed information.
|
||||
|
||||
-----
|
||||
|
||||
It may be easier to embed the README.txt in the program on build.
|
||||
|
||||
-----
|
||||
|
||||
For the archive (tar.gz and zip) writers, make sure errors are
|
||||
checked at the end, and don't just defer the close operations.
|
||||
|
||||
|
||||
13
cmd/cert-bundler/testdata/bundle.yaml
vendored
13
cmd/cert-bundler/testdata/bundle.yaml
vendored
@@ -2,6 +2,19 @@ config:
|
||||
hashes: bundle.sha256
|
||||
expiry: 1y
|
||||
chains:
|
||||
weird:
|
||||
certs:
|
||||
- root: pems/gts-r1.pem
|
||||
intermediates:
|
||||
- pems/goog-wr2.pem
|
||||
- root: pems/isrg-root-x1.pem
|
||||
outputs:
|
||||
include_single: true
|
||||
include_individual: true
|
||||
manifest: true
|
||||
formats:
|
||||
- zip
|
||||
- tgz
|
||||
core_certs:
|
||||
certs:
|
||||
- root: pems/gts-r1.pem
|
||||
|
||||
4
cmd/cert-bundler/testdata/pkg/bundle.sha256
vendored
4
cmd/cert-bundler/testdata/pkg/bundle.sha256
vendored
@@ -1,4 +0,0 @@
|
||||
5ed8bf9ed693045faa8a5cb0edc4a870052e56aef6291ce8b1604565affbc2a4 core_certs.zip
|
||||
e59eddc590d2f7b790a87c5b56e81697088ab54be382c0e2c51b82034006d308 core_certs.tgz
|
||||
51b9b63b1335118079e90700a3a5b847c363808e9116e576ca84f301bc433289 google_certs.tgz
|
||||
3d1910ca8835c3ded1755a8c7d6c48083c2f3ff68b2bfbf932aaf27e29d0a232 lets_encrypt.zip
|
||||
BIN
cmd/cert-bundler/testdata/pkg/core_certs.tgz
vendored
BIN
cmd/cert-bundler/testdata/pkg/core_certs.tgz
vendored
Binary file not shown.
BIN
cmd/cert-bundler/testdata/pkg/core_certs.zip
vendored
BIN
cmd/cert-bundler/testdata/pkg/core_certs.zip
vendored
Binary file not shown.
BIN
cmd/cert-bundler/testdata/pkg/google_certs.tgz
vendored
BIN
cmd/cert-bundler/testdata/pkg/google_certs.tgz
vendored
Binary file not shown.
BIN
cmd/cert-bundler/testdata/pkg/lets_encrypt.zip
vendored
BIN
cmd/cert-bundler/testdata/pkg/lets_encrypt.zip
vendored
Binary file not shown.
51
cmd/certser/main.go
Normal file
51
cmd/certser/main.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/certlib"
|
||||
"git.wntrmute.dev/kyle/goutils/die"
|
||||
"git.wntrmute.dev/kyle/goutils/lib"
|
||||
)
|
||||
|
||||
const displayInt lib.HexEncodeMode = iota
|
||||
|
||||
func parseDisplayMode(mode string) lib.HexEncodeMode {
|
||||
mode = strings.ToLower(mode)
|
||||
|
||||
if mode == "int" {
|
||||
return displayInt
|
||||
}
|
||||
|
||||
return lib.ParseHexEncodeMode(mode)
|
||||
}
|
||||
|
||||
func serialString(cert *x509.Certificate, mode lib.HexEncodeMode) string {
|
||||
if mode == displayInt {
|
||||
return cert.SerialNumber.String()
|
||||
}
|
||||
|
||||
return lib.HexEncode(cert.SerialNumber.Bytes(), mode)
|
||||
}
|
||||
|
||||
func main() {
|
||||
displayAs := flag.String("d", "int", "display mode (int, hex, uhex)")
|
||||
showExpiry := flag.Bool("e", false, "show expiry date")
|
||||
flag.Parse()
|
||||
|
||||
displayMode := parseDisplayMode(*displayAs)
|
||||
|
||||
for _, arg := range flag.Args() {
|
||||
cert, err := certlib.LoadCertificate(arg)
|
||||
die.If(err)
|
||||
|
||||
fmt.Printf("%s: %s", arg, serialString(cert, displayMode))
|
||||
if *showExpiry {
|
||||
fmt.Printf(" (%s)", cert.NotAfter.Format("2006-01-02"))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
1
go.mod
1
go.mod
@@ -22,4 +22,5 @@ require (
|
||||
github.com/kr/pretty v0.1.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
|
||||
2
go.sum
2
go.sum
@@ -44,3 +44,5 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
110
lib/lib.go
110
lib/lib.go
@@ -2,9 +2,11 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -109,3 +111,111 @@ func Duration(d time.Duration) string {
|
||||
s += fmt.Sprintf("%dh%s", hours, d)
|
||||
return s
|
||||
}
|
||||
|
||||
type HexEncodeMode uint8
|
||||
|
||||
const (
|
||||
// HexEncodeLower prints the bytes as lowercase hexadecimal.
|
||||
HexEncodeLower HexEncodeMode = iota + 1
|
||||
// HexEncodeUpper prints the bytes as uppercase hexadecimal.
|
||||
HexEncodeUpper
|
||||
// HexEncodeLowerColon prints the bytes as lowercase hexadecimal
|
||||
// with colons between each pair of bytes.
|
||||
HexEncodeLowerColon
|
||||
// HexEncodeUpperColon prints the bytes as uppercase hexadecimal
|
||||
// with colons between each pair of bytes.
|
||||
HexEncodeUpperColon
|
||||
)
|
||||
|
||||
func (m HexEncodeMode) String() string {
|
||||
switch m {
|
||||
case HexEncodeLower:
|
||||
return "lower"
|
||||
case HexEncodeUpper:
|
||||
return "upper"
|
||||
case HexEncodeLowerColon:
|
||||
return "lcolon"
|
||||
case HexEncodeUpperColon:
|
||||
return "ucolon"
|
||||
default:
|
||||
panic("invalid hex encode mode")
|
||||
}
|
||||
}
|
||||
|
||||
func ParseHexEncodeMode(s string) HexEncodeMode {
|
||||
switch strings.ToLower(s) {
|
||||
case "lower":
|
||||
return HexEncodeLower
|
||||
case "upper":
|
||||
return HexEncodeUpper
|
||||
case "lcolon":
|
||||
return HexEncodeLowerColon
|
||||
case "ucolon":
|
||||
return HexEncodeUpperColon
|
||||
}
|
||||
|
||||
panic("invalid hex encode mode")
|
||||
}
|
||||
|
||||
func hexColons(s string) string {
|
||||
if len(s)%2 != 0 {
|
||||
fmt.Fprintf(os.Stderr, "hex string: %s\n", s)
|
||||
fmt.Fprintf(os.Stderr, "hex length: %d\n", len(s))
|
||||
panic("invalid hex string length")
|
||||
}
|
||||
|
||||
n := len(s)
|
||||
if n <= 2 {
|
||||
return s
|
||||
}
|
||||
|
||||
pairCount := n / 2
|
||||
if n%2 != 0 {
|
||||
pairCount++
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(n + pairCount - 1)
|
||||
|
||||
for i := 0; i < n; i += 2 {
|
||||
b.WriteByte(s[i])
|
||||
|
||||
if i+1 < n {
|
||||
b.WriteByte(s[i+1])
|
||||
}
|
||||
|
||||
if i+2 < n {
|
||||
b.WriteByte(':')
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func hexEncode(b []byte) string {
|
||||
s := hex.EncodeToString(b)
|
||||
|
||||
if len(s)%2 != 0 {
|
||||
s = "0" + s
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// HexEncode encodes the given bytes as a hexadecimal string.
|
||||
func HexEncode(b []byte, mode HexEncodeMode) string {
|
||||
str := hexEncode(b)
|
||||
|
||||
switch mode {
|
||||
case HexEncodeLower:
|
||||
return str
|
||||
case HexEncodeUpper:
|
||||
return strings.ToUpper(str)
|
||||
case HexEncodeLowerColon:
|
||||
return hexColons(str)
|
||||
case HexEncodeUpperColon:
|
||||
return strings.ToUpper(hexColons(str))
|
||||
default:
|
||||
panic("invalid hex encode mode")
|
||||
}
|
||||
}
|
||||
|
||||
79
lib/lib_test.go
Normal file
79
lib/lib_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package lib_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/lib"
|
||||
)
|
||||
|
||||
func TestHexEncode_LowerUpper(t *testing.T) {
|
||||
b := []byte{0x0f, 0xa1, 0x00, 0xff}
|
||||
|
||||
gotLower := lib.HexEncode(b, lib.HexEncodeLower)
|
||||
if gotLower != "0fa100ff" {
|
||||
t.Fatalf("lib.HexEncode lower: expected %q, got %q", "0fa100ff", gotLower)
|
||||
}
|
||||
|
||||
gotUpper := lib.HexEncode(b, lib.HexEncodeUpper)
|
||||
if gotUpper != "0FA100FF" {
|
||||
t.Fatalf("lib.HexEncode upper: expected %q, got %q", "0FA100FF", gotUpper)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexEncode_ColonModes(t *testing.T) {
|
||||
// Includes leading zero nibble and a zero byte to verify padding and separators
|
||||
b := []byte{0x0f, 0xa1, 0x00, 0xff}
|
||||
|
||||
gotLColon := lib.HexEncode(b, lib.HexEncodeLowerColon)
|
||||
if gotLColon != "0f:a1:00:ff" {
|
||||
t.Fatalf("lib.HexEncode colon lower: expected %q, got %q", "0f:a1:00:ff", gotLColon)
|
||||
}
|
||||
|
||||
gotUColon := lib.HexEncode(b, lib.HexEncodeUpperColon)
|
||||
if gotUColon != "0F:A1:00:FF" {
|
||||
t.Fatalf("lib.HexEncode colon upper: expected %q, got %q", "0F:A1:00:FF", gotUColon)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexEncode_EmptyInput(t *testing.T) {
|
||||
var b []byte
|
||||
if got := lib.HexEncode(b, lib.HexEncodeLower); got != "" {
|
||||
t.Fatalf("empty lower: expected empty string, got %q", got)
|
||||
}
|
||||
if got := lib.HexEncode(b, lib.HexEncodeUpper); got != "" {
|
||||
t.Fatalf("empty upper: expected empty string, got %q", got)
|
||||
}
|
||||
if got := lib.HexEncode(b, lib.HexEncodeLowerColon); got != "" {
|
||||
t.Fatalf("empty colon lower: expected empty string, got %q", got)
|
||||
}
|
||||
if got := lib.HexEncode(b, lib.HexEncodeUpperColon); got != "" {
|
||||
t.Fatalf("empty colon upper: expected empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexEncode_SingleByte(t *testing.T) {
|
||||
b := []byte{0x0f}
|
||||
if got := lib.HexEncode(b, lib.HexEncodeLower); got != "0f" {
|
||||
t.Fatalf("single byte lower: expected %q, got %q", "0f", got)
|
||||
}
|
||||
if got := lib.HexEncode(b, lib.HexEncodeUpper); got != "0F" {
|
||||
t.Fatalf("single byte upper: expected %q, got %q", "0F", got)
|
||||
}
|
||||
// For a single byte, colon modes should not introduce separators
|
||||
if got := lib.HexEncode(b, lib.HexEncodeLowerColon); got != "0f" {
|
||||
t.Fatalf("single byte colon lower: expected %q, got %q", "0f", got)
|
||||
}
|
||||
if got := lib.HexEncode(b, lib.HexEncodeUpperColon); got != "0F" {
|
||||
t.Fatalf("single byte colon upper: expected %q, got %q", "0F", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexEncode_InvalidModePanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatalf("expected panic for invalid mode, but function returned normally")
|
||||
}
|
||||
}()
|
||||
// 0 is not a valid lib.HexEncodeMode (valid modes start at 1)
|
||||
_ = lib.HexEncode([]byte{0x01}, lib.HexEncodeMode(0))
|
||||
}
|
||||
33
twofactor/README.md
Normal file
33
twofactor/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
## `twofactor`
|
||||
|
||||
[](https://godoc.org/github.com/gokyle/twofactor)
|
||||
|
||||
|
||||
### Author
|
||||
|
||||
`twofactor` was written by Kyle Isom <kyle@tyrfingr.is>.
|
||||
|
||||
|
||||
### License
|
||||
|
||||
```
|
||||
Copyright (c) 2017 Kyle Isom <kyle@imap.cc>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
5
twofactor/doc.go
Normal file
5
twofactor/doc.go
Normal file
@@ -0,0 +1,5 @@
|
||||
// Package twofactor implements two-factor authentication.
|
||||
//
|
||||
// Currently supported are RFC 4226 HOTP one-time passwords and
|
||||
// RFC 6238 TOTP SHA-1 one-time passwords.
|
||||
package twofactor
|
||||
103
twofactor/hotp.go
Normal file
103
twofactor/hotp.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/sha1" // #nosec G505 - required by RFC
|
||||
"encoding/base32"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HOTP represents an RFC-4226 Hash-based One Time Password instance.
|
||||
type HOTP struct {
|
||||
*OATH
|
||||
}
|
||||
|
||||
// NewHOTP takes the key, the initial counter value, and the number
|
||||
// of digits (typically 6 or 8) and returns a new HOTP instance.
|
||||
func NewHOTP(key []byte, counter uint64, digits int) *HOTP {
|
||||
return &HOTP{
|
||||
OATH: &OATH{
|
||||
key: key,
|
||||
counter: counter,
|
||||
size: digits,
|
||||
hash: sha1.New,
|
||||
algo: crypto.SHA1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Type returns OATH_HOTP.
|
||||
func (otp *HOTP) Type() Type {
|
||||
return OATH_HOTP
|
||||
}
|
||||
|
||||
// OTP returns the next OTP and increments the counter.
|
||||
func (otp *HOTP) OTP() string {
|
||||
code := otp.OATH.OTP(otp.counter)
|
||||
otp.counter++
|
||||
return code
|
||||
}
|
||||
|
||||
// URL returns an HOTP URL (i.e. for putting in a QR code).
|
||||
func (otp *HOTP) URL(label string) string {
|
||||
return otp.OATH.URL(otp.Type(), label)
|
||||
}
|
||||
|
||||
// SetProvider sets up the provider component of the OTP URL.
|
||||
func (otp *HOTP) SetProvider(provider string) {
|
||||
otp.provider = provider
|
||||
}
|
||||
|
||||
// GenerateGoogleHOTP generates a new HOTP instance as used by
|
||||
// Google Authenticator.
|
||||
func GenerateGoogleHOTP() *HOTP {
|
||||
key := make([]byte, sha1.Size)
|
||||
if _, err := io.ReadFull(PRNG, key); err != nil {
|
||||
return nil
|
||||
}
|
||||
return NewHOTP(key, 0, 6)
|
||||
}
|
||||
|
||||
func hotpFromURL(u *url.URL) (*HOTP, string, error) {
|
||||
label := u.Path[1:]
|
||||
v := u.Query()
|
||||
|
||||
secret := strings.ToUpper(v.Get("secret"))
|
||||
if secret == "" {
|
||||
return nil, "", ErrInvalidURL
|
||||
}
|
||||
|
||||
var digits = 6
|
||||
if sdigit := v.Get("digits"); sdigit != "" {
|
||||
tmpDigits, err := strconv.ParseInt(sdigit, 10, 8)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
digits = int(tmpDigits)
|
||||
}
|
||||
|
||||
var counter uint64
|
||||
if scounter := v.Get("counter"); scounter != "" {
|
||||
var err error
|
||||
counter, err = strconv.ParseUint(scounter, 10, 64)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
|
||||
key, err := base32.StdEncoding.DecodeString(Pad(secret))
|
||||
if err != nil {
|
||||
// assume secret isn't base32 encoded
|
||||
key = []byte(secret)
|
||||
}
|
||||
otp := NewHOTP(key, counter, digits)
|
||||
return otp, label, nil
|
||||
}
|
||||
|
||||
// QR generates a new QR code for the HOTP.
|
||||
func (otp *HOTP) QR(label string) ([]byte, error) {
|
||||
return otp.OATH.QR(otp.Type(), label)
|
||||
}
|
||||
58
twofactor/hotp_internal_test.go
Normal file
58
twofactor/hotp_internal_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
var testKey = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}
|
||||
|
||||
var rfcHotpKey = []byte("12345678901234567890")
|
||||
var rfcHotpExpected = []string{
|
||||
"755224",
|
||||
"287082",
|
||||
"359152",
|
||||
"969429",
|
||||
"338314",
|
||||
"254676",
|
||||
"287922",
|
||||
"162583",
|
||||
"399871",
|
||||
"520489",
|
||||
}
|
||||
|
||||
// This test runs through the test cases presented in the RFC, and
|
||||
// ensures that this implementation is in compliance.
|
||||
func TestHotpRFC(t *testing.T) {
|
||||
otp := NewHOTP(rfcHotpKey, 0, 6)
|
||||
for i := range rfcHotpExpected {
|
||||
if otp.Counter() != uint64(i) {
|
||||
t.Fatalf("twofactor: invalid counter (should be %d, is %d",
|
||||
i, otp.Counter())
|
||||
}
|
||||
code := otp.OTP()
|
||||
if code == "" {
|
||||
t.Fatal("twofactor: failed to produce an OTP")
|
||||
} else if code != rfcHotpExpected[i] {
|
||||
t.Logf("twofactor: invalid OTP\n")
|
||||
t.Logf("\tExpected: %s\n", rfcHotpExpected[i])
|
||||
t.Logf("\t Actual: %s\n", code)
|
||||
t.Fatalf("\t Counter: %d\n", otp.counter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This test uses a different key than the test cases in the RFC,
|
||||
// but runs through the same test cases to ensure that they fail as
|
||||
// expected.
|
||||
func TestHotpBadRFC(t *testing.T) {
|
||||
otp := NewHOTP(testKey, 0, 6)
|
||||
for i := range rfcHotpExpected {
|
||||
code := otp.OTP()
|
||||
switch code {
|
||||
case "":
|
||||
t.Error("twofactor: failed to produce an OTP")
|
||||
case rfcHotpExpected[i]:
|
||||
t.Error("twofactor: should not have received a valid OTP")
|
||||
}
|
||||
}
|
||||
}
|
||||
150
twofactor/oath.go
Normal file
150
twofactor/oath.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/hmac"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"rsc.io/qr"
|
||||
)
|
||||
|
||||
const defaultSize = 6
|
||||
|
||||
// OATH provides a baseline structure for the two OATH algorithms.
|
||||
type OATH struct {
|
||||
key []byte
|
||||
counter uint64
|
||||
size int
|
||||
hash func() hash.Hash
|
||||
algo crypto.Hash
|
||||
provider string
|
||||
}
|
||||
|
||||
// Size returns the output size (in characters) of the password.
|
||||
func (o *OATH) Size() int {
|
||||
return o.size
|
||||
}
|
||||
|
||||
// Counter returns the OATH token's counter.
|
||||
func (o *OATH) Counter() uint64 {
|
||||
return o.counter
|
||||
}
|
||||
|
||||
// SetCounter updates the OATH token's counter to a new value.
|
||||
func (o *OATH) SetCounter(counter uint64) {
|
||||
o.counter = counter
|
||||
}
|
||||
|
||||
// Key returns the token's secret key.
|
||||
func (o *OATH) Key() []byte {
|
||||
return o.key
|
||||
}
|
||||
|
||||
// Hash returns the token's hash function.
|
||||
func (o *OATH) Hash() func() hash.Hash {
|
||||
return o.hash
|
||||
}
|
||||
|
||||
// URL constructs a URL appropriate for the token (i.e. for use in a
|
||||
// QR code).
|
||||
func (o *OATH) URL(t Type, label string) string {
|
||||
secret := base32.StdEncoding.EncodeToString(o.key)
|
||||
u := url.URL{}
|
||||
v := url.Values{}
|
||||
u.Scheme = "otpauth"
|
||||
switch t {
|
||||
case OATH_HOTP:
|
||||
u.Host = "hotp"
|
||||
case OATH_TOTP:
|
||||
u.Host = "totp"
|
||||
}
|
||||
u.Path = label
|
||||
v.Add("secret", secret)
|
||||
if o.Counter() != 0 && t == OATH_HOTP {
|
||||
v.Add("counter", strconv.FormatUint(o.Counter(), 10))
|
||||
}
|
||||
if o.Size() != defaultSize {
|
||||
v.Add("digits", strconv.Itoa(o.Size()))
|
||||
}
|
||||
|
||||
switch o.algo {
|
||||
case crypto.SHA256:
|
||||
v.Add("algorithm", "SHA256")
|
||||
case crypto.SHA512:
|
||||
v.Add("algorithm", "SHA512")
|
||||
}
|
||||
|
||||
if o.provider != "" {
|
||||
v.Add("provider", o.provider)
|
||||
}
|
||||
|
||||
u.RawQuery = v.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
var digits = []int64{
|
||||
0: 1,
|
||||
1: 10,
|
||||
2: 100,
|
||||
3: 1000,
|
||||
4: 10000,
|
||||
5: 100000,
|
||||
6: 1000000,
|
||||
7: 10000000,
|
||||
8: 100000000,
|
||||
9: 1000000000,
|
||||
10: 10000000000,
|
||||
}
|
||||
|
||||
// OTP top-level type should provide a counter; for example, HOTP
|
||||
// will provide the counter directly while TOTP will provide the
|
||||
// time-stepped counter.
|
||||
func (o *OATH) OTP(counter uint64) string {
|
||||
var ctr [8]byte
|
||||
binary.BigEndian.PutUint64(ctr[:], counter)
|
||||
|
||||
var mod int64 = 1
|
||||
if len(digits) > o.size {
|
||||
for i := 1; i <= o.size; i++ {
|
||||
mod *= 10
|
||||
}
|
||||
} else {
|
||||
mod = digits[o.size]
|
||||
}
|
||||
|
||||
h := hmac.New(o.hash, o.key)
|
||||
h.Write(ctr[:])
|
||||
dt := truncate(h.Sum(nil)) % mod
|
||||
fmtStr := fmt.Sprintf("%%0%dd", o.size)
|
||||
return fmt.Sprintf(fmtStr, dt)
|
||||
}
|
||||
|
||||
// truncate contains the DT function from the RFC; this is used to
|
||||
// deterministically select a sequence of 4 bytes from the HMAC
|
||||
// counter hash.
|
||||
func truncate(in []byte) int64 {
|
||||
offset := int(in[len(in)-1] & 0xF)
|
||||
p := in[offset : offset+4]
|
||||
var binCode int32
|
||||
binCode = int32((p[0] & 0x7f)) << 24
|
||||
binCode += int32((p[1] & 0xff)) << 16
|
||||
binCode += int32((p[2] & 0xff)) << 8
|
||||
binCode += int32((p[3] & 0xff))
|
||||
return int64(binCode) & 0x7FFFFFFF
|
||||
}
|
||||
|
||||
// QR generates a byte slice containing the a QR code encoded as a
|
||||
// PNG with level Q error correction.
|
||||
func (o *OATH) QR(t Type, label string) ([]byte, error) {
|
||||
u := o.URL(t, label)
|
||||
code, err := qr.Encode(u, qr.Q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return code.PNG(), nil
|
||||
}
|
||||
27
twofactor/oath_internal_test.go
Normal file
27
twofactor/oath_internal_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
var sha1Hmac = []byte{
|
||||
0x1f, 0x86, 0x98, 0x69, 0x0e,
|
||||
0x02, 0xca, 0x16, 0x61, 0x85,
|
||||
0x50, 0xef, 0x7f, 0x19, 0xda,
|
||||
0x8e, 0x94, 0x5b, 0x55, 0x5a,
|
||||
}
|
||||
|
||||
var truncExpect int64 = 0x50ef7f19
|
||||
|
||||
// This test runs through the truncation example given in the RFC.
|
||||
func TestTruncate(t *testing.T) {
|
||||
if result := truncate(sha1Hmac); result != truncExpect {
|
||||
t.Fatalf("hotp: expected truncate -> %d, saw %d\n",
|
||||
truncExpect, result)
|
||||
}
|
||||
|
||||
sha1Hmac[19]++
|
||||
if result := truncate(sha1Hmac); result == truncExpect {
|
||||
t.Fatal("hotp: expected truncation to fail")
|
||||
}
|
||||
}
|
||||
86
twofactor/otp.go
Normal file
86
twofactor/otp.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type Type uint
|
||||
|
||||
const (
|
||||
OATH_HOTP = iota
|
||||
OATH_TOTP
|
||||
)
|
||||
|
||||
// PRNG is an io.Reader that provides a cryptographically secure
|
||||
// random byte stream.
|
||||
var PRNG = rand.Reader
|
||||
|
||||
var (
|
||||
ErrInvalidURL = errors.New("twofactor: invalid URL")
|
||||
ErrInvalidAlgo = errors.New("twofactor: invalid algorithm")
|
||||
)
|
||||
|
||||
// OTP represents a one-time password token -- whether a
|
||||
// software taken (as in the case of Google Authenticator) or a
|
||||
// hardware token (as in the case of a YubiKey).
|
||||
type OTP interface {
|
||||
// Returns the current counter value; the meaning of the
|
||||
// returned value is algorithm-specific.
|
||||
Counter() uint64
|
||||
|
||||
// Set the counter to a specific value.
|
||||
SetCounter(uint64)
|
||||
|
||||
// the secret key contained in the OTP
|
||||
Key() []byte
|
||||
|
||||
// generate a new OTP
|
||||
OTP() string
|
||||
|
||||
// the output size of the OTP
|
||||
Size() int
|
||||
|
||||
// the hash function used by the OTP
|
||||
Hash() func() hash.Hash
|
||||
|
||||
// Returns the type of this OTP.
|
||||
Type() Type
|
||||
}
|
||||
|
||||
func otpString(otp OTP) string {
|
||||
var typeName string
|
||||
switch otp.Type() {
|
||||
case OATH_HOTP:
|
||||
typeName = "OATH-HOTP"
|
||||
case OATH_TOTP:
|
||||
typeName = "OATH-TOTP"
|
||||
default:
|
||||
typeName = "UNKNOWN"
|
||||
}
|
||||
return fmt.Sprintf("%s, %d", typeName, otp.Size())
|
||||
}
|
||||
|
||||
// FromURL constructs a new OTP token from a URL string.
|
||||
func FromURL(otpURL string) (OTP, string, error) {
|
||||
u, err := url.Parse(otpURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if u.Scheme != "otpauth" {
|
||||
return nil, "", ErrInvalidURL
|
||||
}
|
||||
|
||||
switch u.Host {
|
||||
case "totp":
|
||||
return totpFromURL(u)
|
||||
case "hotp":
|
||||
return hotpFromURL(u)
|
||||
default:
|
||||
return nil, "", ErrInvalidURL
|
||||
}
|
||||
}
|
||||
126
twofactor/otp_internal_test.go
Normal file
126
twofactor/otp_internal_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHOTPString(t *testing.T) {
|
||||
hotp := NewHOTP(nil, 0, 6)
|
||||
hotpString := otpString(hotp)
|
||||
if hotpString != "OATH-HOTP, 6" {
|
||||
t.Fatal("twofactor: invalid OTP string")
|
||||
}
|
||||
}
|
||||
|
||||
// This test generates a new OTP, outputs the URL for that OTP,
|
||||
// and attempts to parse that URL. It verifies that the two OTPs
|
||||
// are the same, and that they produce the same output.
|
||||
func TestURL(t *testing.T) {
|
||||
var ident = "testuser@foo"
|
||||
otp := NewHOTP(testKey, 0, 6)
|
||||
url := otp.URL("testuser@foo")
|
||||
otp2, id, err := FromURL(url)
|
||||
switch {
|
||||
case err != nil:
|
||||
t.Fatal("hotp: failed to parse HOTP URL\n")
|
||||
case id != ident:
|
||||
t.Logf("hotp: bad label\n")
|
||||
t.Logf("\texpected: %s\n", ident)
|
||||
t.Fatalf("\t actual: %s\n", id)
|
||||
case otp2.Counter() != otp.Counter():
|
||||
t.Logf("hotp: OTP counters aren't synced\n")
|
||||
t.Logf("\toriginal: %d\n", otp.Counter())
|
||||
t.Fatalf("\t second: %d\n", otp2.Counter())
|
||||
}
|
||||
|
||||
code1 := otp.OTP()
|
||||
code2 := otp2.OTP()
|
||||
if code1 != code2 {
|
||||
t.Logf("hotp: mismatched OTPs\n")
|
||||
t.Logf("\texpected: %s\n", code1)
|
||||
t.Fatalf("\t actual: %s\n", code2)
|
||||
}
|
||||
|
||||
// There's not much we can do test the QR code, except to
|
||||
// ensure it doesn't fail.
|
||||
_, err = otp.QR(ident)
|
||||
if err != nil {
|
||||
t.Fatalf("hotp: failed to generate QR code PNG (%v)\n", err)
|
||||
}
|
||||
|
||||
// This should fail because the maximum size of an alphanumeric
|
||||
// QR code with the lowest-level of error correction should
|
||||
// max out at 4296 bytes. 8k may be a bit overkill... but it
|
||||
// gets the job done. The value is read from the PRNG to
|
||||
// increase the likelihood that the returned data is
|
||||
// uncompressible.
|
||||
var tooBigIdent = make([]byte, 8192)
|
||||
_, err = io.ReadFull(PRNG, tooBigIdent)
|
||||
if err != nil {
|
||||
t.Fatalf("hotp: failed to read identity (%v)\n", err)
|
||||
} else if _, err = otp.QR(string(tooBigIdent)); err == nil {
|
||||
t.Fatal("hotp: QR code should fail to encode oversized URL")
|
||||
}
|
||||
}
|
||||
|
||||
// This test makes sure we can generate codes for padded and non-padded
|
||||
// entries.
|
||||
func TestPaddedURL(t *testing.T) {
|
||||
var urlList = []string{
|
||||
"otpauth://hotp/?secret=ME",
|
||||
"otpauth://hotp/?secret=MEFR",
|
||||
"otpauth://hotp/?secret=MFRGG",
|
||||
"otpauth://hotp/?secret=MFRGGZA",
|
||||
"otpauth://hotp/?secret=a6mryljlbufszudtjdt42nh5by=======",
|
||||
"otpauth://hotp/?secret=a6mryljlbufszudtjdt42nh5by",
|
||||
"otpauth://hotp/?secret=a6mryljlbufszudtjdt42nh5by%3D%3D%3D%3D%3D%3D%3D",
|
||||
}
|
||||
var codeList = []string{
|
||||
"413198",
|
||||
"770938",
|
||||
"670717",
|
||||
"402378",
|
||||
"069864",
|
||||
"069864",
|
||||
"069864",
|
||||
}
|
||||
|
||||
for i := range urlList {
|
||||
if o, id, err := FromURL(urlList[i]); err != nil {
|
||||
t.Log("hotp: URL should have parsed successfully (id=", id, ")")
|
||||
t.Logf("\turl was: %s\n", urlList[i])
|
||||
t.Fatalf("\t%s, %s\n", o.OTP(), id)
|
||||
} else {
|
||||
code2 := o.OTP()
|
||||
if code2 != codeList[i] {
|
||||
t.Logf("hotp: mismatched OTPs\n")
|
||||
t.Logf("\texpected: %s\n", codeList[i])
|
||||
t.Fatalf("\t actual: %s\n", code2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This test attempts a variety of invalid urls against the parser
|
||||
// to ensure they fail.
|
||||
func TestBadURL(t *testing.T) {
|
||||
var urlList = []string{
|
||||
"http://google.com",
|
||||
"",
|
||||
"-",
|
||||
"foo",
|
||||
"otpauth:/foo/bar/baz",
|
||||
"://",
|
||||
"otpauth://hotp/?digits=",
|
||||
"otpauth://hotp/?secret=MFRGGZDF&digits=ABCD",
|
||||
"otpauth://hotp/?secret=MFRGGZDF&counter=ABCD",
|
||||
}
|
||||
|
||||
for i := range urlList {
|
||||
if _, _, err := FromURL(urlList[i]); err == nil {
|
||||
t.Log("hotp: URL should not have parsed successfully")
|
||||
t.Fatalf("\turl was: %s\n", urlList[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
172
twofactor/totp.go
Normal file
172
twofactor/totp.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/sha1" // #nosec G505 - required by RFC
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/base32"
|
||||
"hash"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
)
|
||||
|
||||
var timeSource = clock.New()
|
||||
|
||||
// TOTP represents an RFC 6238 Time-based One-Time Password instance.
|
||||
type TOTP struct {
|
||||
*OATH
|
||||
step uint64
|
||||
}
|
||||
|
||||
// NewTOTP takes a new key, a starting time, a step, the number of
|
||||
// digits of output (typically 6 or 8) and the hash algorithm to
|
||||
// use, and builds a new OTP.
|
||||
func NewTOTP(key []byte, start uint64, step uint64, digits int, algo crypto.Hash) *TOTP {
|
||||
h := hashFromAlgo(algo)
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &TOTP{
|
||||
OATH: &OATH{
|
||||
key: key,
|
||||
counter: start,
|
||||
size: digits,
|
||||
hash: h,
|
||||
algo: algo,
|
||||
},
|
||||
step: step,
|
||||
}
|
||||
}
|
||||
|
||||
// NewGoogleTOTP takes a secret as a base32-encoded string and
|
||||
// returns an appropriate Google Authenticator TOTP instance.
|
||||
func NewGoogleTOTP(secret string) (*TOTP, error) {
|
||||
key, err := base32.StdEncoding.DecodeString(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewTOTP(key, 0, 30, 6, crypto.SHA1), nil
|
||||
}
|
||||
|
||||
// NewTOTPSHA1 will build a new TOTP using SHA-1.
|
||||
func NewTOTPSHA1(key []byte, start uint64, step uint64, digits int) *TOTP {
|
||||
return NewTOTP(key, start, step, digits, crypto.SHA1)
|
||||
}
|
||||
|
||||
// Type returns OATH_TOTP.
|
||||
func (otp *TOTP) Type() Type {
|
||||
return OATH_TOTP
|
||||
}
|
||||
|
||||
func (otp *TOTP) otp(counter uint64) string {
|
||||
return otp.OATH.OTP(counter)
|
||||
}
|
||||
|
||||
// OTP returns the OTP for the current timestep.
|
||||
func (otp *TOTP) OTP() string {
|
||||
return otp.otp(otp.OTPCounter())
|
||||
}
|
||||
|
||||
// URL returns a TOTP URL (i.e. for putting in a QR code).
|
||||
func (otp *TOTP) URL(label string) string {
|
||||
return otp.OATH.URL(otp.Type(), label)
|
||||
}
|
||||
|
||||
// SetProvider sets up the provider component of the OTP URL.
|
||||
func (otp *TOTP) SetProvider(provider string) {
|
||||
otp.provider = provider
|
||||
}
|
||||
|
||||
func (otp *TOTP) otpCounter(t uint64) uint64 {
|
||||
return (t - otp.counter) / otp.step
|
||||
}
|
||||
|
||||
// OTPCounter returns the current time value for the OTP.
|
||||
func (otp *TOTP) OTPCounter() uint64 {
|
||||
return otp.otpCounter(uint64(timeSource.Now().Unix() & 0x7FFFFFFF)) //#nosec G115 - masked out overflow bits
|
||||
}
|
||||
|
||||
func hashFromAlgo(algo crypto.Hash) func() hash.Hash {
|
||||
switch algo {
|
||||
case crypto.SHA1:
|
||||
return sha1.New
|
||||
case crypto.SHA256:
|
||||
return sha256.New
|
||||
case crypto.SHA512:
|
||||
return sha512.New
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateGoogleTOTP produces a new TOTP token with the defaults expected by
|
||||
// Google Authenticator.
|
||||
func GenerateGoogleTOTP() *TOTP {
|
||||
key := make([]byte, sha1.Size)
|
||||
if _, err := io.ReadFull(PRNG, key); err != nil {
|
||||
return nil
|
||||
}
|
||||
return NewTOTP(key, 0, 30, 6, crypto.SHA1)
|
||||
}
|
||||
|
||||
func totpFromURL(u *url.URL) (*TOTP, string, error) {
|
||||
label := u.Path[1:]
|
||||
v := u.Query()
|
||||
|
||||
secret := strings.ToUpper(v.Get("secret"))
|
||||
if secret == "" {
|
||||
return nil, "", ErrInvalidURL
|
||||
}
|
||||
|
||||
var algo = crypto.SHA1
|
||||
if algorithm := v.Get("algorithm"); algorithm != "" {
|
||||
switch {
|
||||
case strings.EqualFold(algorithm, "SHA256"):
|
||||
algo = crypto.SHA256
|
||||
case strings.EqualFold(algorithm, "SHA512"):
|
||||
algo = crypto.SHA512
|
||||
case !strings.EqualFold(algorithm, "SHA1"):
|
||||
return nil, "", ErrInvalidAlgo
|
||||
}
|
||||
}
|
||||
|
||||
var digits = 6
|
||||
if sdigit := v.Get("digits"); sdigit != "" {
|
||||
tmpDigits, err := strconv.ParseInt(sdigit, 10, 8)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
digits = int(tmpDigits)
|
||||
}
|
||||
|
||||
var period uint64 = 30
|
||||
if speriod := v.Get("period"); speriod != "" {
|
||||
var err error
|
||||
period, err = strconv.ParseUint(speriod, 10, 64)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
|
||||
key, err := base32.StdEncoding.DecodeString(Pad(secret))
|
||||
if err != nil {
|
||||
// assume secret isn't base32 encoded
|
||||
key = []byte(secret)
|
||||
}
|
||||
otp := NewTOTP(key, 0, period, digits, algo)
|
||||
return otp, label, nil
|
||||
}
|
||||
|
||||
// QR generates a new TOTP QR code.
|
||||
func (otp *TOTP) QR(label string) ([]byte, error) {
|
||||
return otp.OATH.QR(otp.Type(), label)
|
||||
}
|
||||
|
||||
func SetClock(c clock.Clock) {
|
||||
timeSource = c
|
||||
}
|
||||
85
twofactor/totp_internal_test.go
Normal file
85
twofactor/totp_internal_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
)
|
||||
|
||||
var rfcTotpKey = map[crypto.Hash][]byte{
|
||||
crypto.SHA1: []byte("12345678901234567890"),
|
||||
crypto.SHA256: []byte("12345678901234567890123456789012"),
|
||||
crypto.SHA512: []byte("1234567890123456789012345678901234567890123456789012345678901234"),
|
||||
}
|
||||
|
||||
var rfcTotpStep uint64 = 30
|
||||
|
||||
var rfcTotpTests = []struct {
|
||||
Time uint64
|
||||
Code string
|
||||
T uint64
|
||||
Algo crypto.Hash
|
||||
}{
|
||||
{59, "94287082", 1, crypto.SHA1},
|
||||
{59, "46119246", 1, crypto.SHA256},
|
||||
{59, "90693936", 1, crypto.SHA512},
|
||||
{1111111109, "07081804", 37037036, crypto.SHA1},
|
||||
{1111111109, "68084774", 37037036, crypto.SHA256},
|
||||
{1111111109, "25091201", 37037036, crypto.SHA512},
|
||||
{1111111111, "14050471", 37037037, crypto.SHA1},
|
||||
{1111111111, "67062674", 37037037, crypto.SHA256},
|
||||
{1111111111, "99943326", 37037037, crypto.SHA512},
|
||||
{1234567890, "89005924", 41152263, crypto.SHA1},
|
||||
{1234567890, "91819424", 41152263, crypto.SHA256},
|
||||
{1234567890, "93441116", 41152263, crypto.SHA512},
|
||||
{2000000000, "69279037", 66666666, crypto.SHA1},
|
||||
{2000000000, "90698825", 66666666, crypto.SHA256},
|
||||
{2000000000, "38618901", 66666666, crypto.SHA512},
|
||||
{20000000000, "65353130", 666666666, crypto.SHA1},
|
||||
{20000000000, "77737706", 666666666, crypto.SHA256},
|
||||
{20000000000, "47863826", 666666666, crypto.SHA512},
|
||||
}
|
||||
|
||||
func TestTotpRFC(t *testing.T) {
|
||||
for _, tc := range rfcTotpTests {
|
||||
otp := NewTOTP(rfcTotpKey[tc.Algo], 0, rfcTotpStep, 8, tc.Algo)
|
||||
if otp.otpCounter(tc.Time) != tc.T {
|
||||
t.Logf("twofactor: invalid TOTP (t=%d, h=%d)\n", tc.Time, tc.Algo)
|
||||
t.Logf("\texpected: %d\n", tc.T)
|
||||
t.Errorf("\t actual: %d\n", otp.otpCounter(tc.Time))
|
||||
}
|
||||
|
||||
if code := otp.otp(otp.otpCounter(tc.Time)); code != tc.Code {
|
||||
t.Logf("twofactor: invalid TOTP (t=%d, h=%d)\n", tc.Time, tc.Algo)
|
||||
t.Logf("\texpected: %s\n", tc.Code)
|
||||
t.Errorf("\t actual: %s\n", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPTime(t *testing.T) {
|
||||
otp := GenerateGoogleTOTP()
|
||||
|
||||
testClock := clock.NewMock()
|
||||
testClock.Add(2 * time.Minute)
|
||||
SetClock(testClock)
|
||||
|
||||
code := otp.OTP()
|
||||
|
||||
testClock.Add(-1 * time.Minute)
|
||||
if newCode := otp.OTP(); newCode == code {
|
||||
t.Errorf("twofactor: TOTP: previous code %s shouldn't match code %s", newCode, code)
|
||||
}
|
||||
|
||||
testClock.Add(2 * time.Minute)
|
||||
if newCode := otp.OTP(); newCode == code {
|
||||
t.Errorf("twofactor: TOTP: future code %s shouldn't match code %s", newCode, code)
|
||||
}
|
||||
|
||||
testClock.Add(-1 * time.Minute)
|
||||
if newCode := otp.OTP(); newCode != code {
|
||||
t.Errorf("twofactor: TOTP: current code %s shouldn't match code %s", newCode, code)
|
||||
}
|
||||
}
|
||||
16
twofactor/util.go
Normal file
16
twofactor/util.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Pad calculates the number of '='s to add to our encoded string
|
||||
// to make base32.StdEncoding.DecodeString happy.
|
||||
func Pad(s string) string {
|
||||
if !strings.HasSuffix(s, "=") && len(s)%8 != 0 {
|
||||
for len(s)%8 != 0 {
|
||||
s += "="
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
51
twofactor/util_test.go
Normal file
51
twofactor/util_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package twofactor_test
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/twofactor"
|
||||
)
|
||||
|
||||
const letters = "1234567890!@#$%^&*()abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
func randString() string {
|
||||
b := make([]byte, rand.Intn(len(letters)))
|
||||
for i := range b {
|
||||
b[i] = letters[rand.Intn(len(letters))]
|
||||
}
|
||||
return base32.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func TestPadding(t *testing.T) {
|
||||
for range 300 {
|
||||
b := randString()
|
||||
origEncoding := b
|
||||
modEncoding := strings.ReplaceAll(b, "=", "")
|
||||
str, err := base32.StdEncoding.DecodeString(origEncoding)
|
||||
if err != nil {
|
||||
t.Fatal("Can't decode: ", b)
|
||||
}
|
||||
|
||||
paddedEncoding := twofactor.Pad(modEncoding)
|
||||
if origEncoding != paddedEncoding {
|
||||
t.Log("Padding failed:")
|
||||
t.Logf("Expected: '%s'", origEncoding)
|
||||
t.Fatalf("Got: '%s'", paddedEncoding)
|
||||
} else {
|
||||
var mstr []byte
|
||||
mstr, err = base32.StdEncoding.DecodeString(paddedEncoding)
|
||||
if err != nil {
|
||||
t.Fatal("Can't decode: ", paddedEncoding)
|
||||
}
|
||||
|
||||
if string(mstr) != string(str) {
|
||||
t.Log("Re-padding failed:")
|
||||
t.Logf("Expected: '%s'", str)
|
||||
t.Fatalf("Got: '%s'", mstr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user