diff --git a/CHANGELOG b/CHANGELOG index 811308f..15191c7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,12 @@ CHANGELOG +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: diff --git a/cmd/certser/main.go b/cmd/certser/main.go index 0416be6..d651b50 100644 --- a/cmd/certser/main.go +++ b/cmd/certser/main.go @@ -2,52 +2,38 @@ package main import ( "crypto/x509" - "encoding/hex" "flag" "fmt" "strings" "git.wntrmute.dev/kyle/goutils/certlib" "git.wntrmute.dev/kyle/goutils/die" + "git.wntrmute.dev/kyle/goutils/lib" ) -const ( - displayInt = iota + 1 - displayLHex - displayUHex -) +const displayInt lib.HexEncodeMode = iota -func parseDisplayMode(mode string) int { +func parseDisplayMode(mode string) lib.HexEncodeMode { mode = strings.ToLower(mode) - switch mode { - case "int": + + if mode == "int" { return displayInt - case "hex": - return displayLHex - case "uhex": - return displayUHex - default: - die.With("invalid display mode ", mode) } - return displayInt + return lib.ParseHexEncodeMode(mode) } -func serialString(cert *x509.Certificate, mode int) string { - switch mode { - case displayInt: - return cert.SerialNumber.String() - case displayLHex: - return hex.EncodeToString(cert.SerialNumber.Bytes()) - case displayUHex: - return strings.ToUpper(hex.EncodeToString(cert.SerialNumber.Bytes())) - default: +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) @@ -56,6 +42,10 @@ func main() { cert, err := certlib.LoadCertificate(arg) die.If(err) - fmt.Printf("%s: %x\n", arg, serialString(cert, displayMode)) + fmt.Printf("%s: %s", arg, serialString(cert, displayMode)) + if *showExpiry { + fmt.Printf(" (%s)", cert.NotAfter.Format("2006-01-02")) + } + fmt.Println() } } diff --git a/lib/lib.go b/lib/lib.go index c10712c..61e19be 100644 --- a/lib/lib.go +++ b/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") + } +} diff --git a/lib/lib_test.go b/lib/lib_test.go new file mode 100644 index 0000000..6851e97 --- /dev/null +++ b/lib/lib_test.go @@ -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)) +}