Compare commits

...

203 Commits

Author SHA1 Message Date
f296344acf twofactor: linting fixes 2025-11-16 21:51:38 -08:00
3fb2d88a3f go get rsc.io/qr 2025-11-16 20:44:13 -08:00
150c02b377 Fix subtree. 2025-11-16 18:55:43 -08:00
83f88c49fe Import twofactor. 2025-11-16 18:45:34 -08:00
7c437ac45f Add 'twofactor/' from commit 'c999bf35b0e47de4f63d59abbe0d7efc76c13ced'
git-subtree-dir: twofactor
git-subtree-mainline: 4dc135cfe0
git-subtree-split: c999bf35b0
2025-11-16 18:43:03 -08:00
c999bf35b0 linter fixes. 2025-11-16 18:39:18 -08:00
4dc135cfe0 Update CHANGELOG for v1.11.2. 2025-11-16 13:18:38 -08:00
790113e189 cmd: refactor for code reuse. 2025-11-16 13:15:08 -08:00
8348c5fd65 Update CHANGELOG. 2025-11-16 11:09:02 -08:00
1eafb638a8 cmd: finish linting fixes 2025-11-16 11:03:12 -08:00
3ad562b6fa cmd: continuing linter fixes 2025-11-16 02:54:02 -08:00
0f77bd49dc cmd: continue lint fixes. 2025-11-16 01:32:19 -08:00
f31d74243f cmd: start linting fixes. 2025-11-16 00:36:19 -08:00
a573f1cd20 Update CHANGELOG. 2025-11-15 23:48:54 -08:00
f93cf5fa9c adding lru/mru cache. 2025-11-15 23:48:00 -08:00
b879d62384 cert-bundler: lint fixes 2025-11-15 23:27:50 -08:00
c99ffd4394 cmd: cleaning up programs 2025-11-15 23:17:40 -08:00
ed8c07c1c5 Add 'mru/' from commit '2899885c4220560df4f60e4c052a6ab9773a0386'
git-subtree-dir: mru
git-subtree-mainline: cf2b016433
git-subtree-split: 2899885c42
2025-11-15 22:54:26 -08:00
cf2b016433 certlib: complete overhaul. 2025-11-15 22:54:12 -08:00
2899885c42 linter fixes 2025-11-15 22:46:42 -08:00
f3b4838cf6 Overhauling certlib.
LICENSE to Apache 2.0.
2025-11-15 22:00:29 -08:00
8ed30e9960 certlib: linter autofixes 2025-11-15 21:10:09 -08:00
c7de3919b0 log: linting fixes 2025-11-15 21:06:16 -08:00
840066004a logging: linter fixes 2025-11-15 21:02:19 -08:00
9fb93a3802 mwc: linter fixes 2025-11-15 20:39:21 -08:00
ecc7e5ab1e rand: remove unused package 2025-11-15 20:37:02 -08:00
a934c42aa1 temp fix before removing 2025-11-15 20:36:14 -08:00
948986ba60 testutil: remove unused code
It was probably a WIP for something else; it was started in
2016 and not touched since.
2025-11-15 20:25:37 -08:00
3be86573aa testio: linting fixes 2025-11-15 20:24:00 -08:00
e3a6355edb tee: add tests; linter fixes.
Additionally, disable reassign in testing files.
2025-11-15 20:18:09 -08:00
66d16acebc seekbuf: linter fixes 2025-11-15 19:58:41 -08:00
fdff2e0afe sbuf: linter fixes 2025-11-15 19:53:18 -08:00
3d9625b40b Fix calls to die.With. 2025-11-15 16:10:14 -08:00
547a0d8f32 disable linting until cleanups are finished 2025-11-15 16:00:58 -08:00
876a0a2c2b fileutil: linter fixes. 2025-11-15 15:58:51 -08:00
a37d28e3d7 die: linter feedback fixes. 2025-11-15 15:55:17 -08:00
ddf26e00af dbg: linter feedback updates. 2025-11-15 15:53:57 -08:00
e4db163efe Cleaning up. 2025-11-15 15:48:18 -08:00
571443c282 config: apply linting feedback. 2025-11-15 15:47:29 -08:00
aba5e519a4 First round of linter cleanups. 2025-11-15 15:11:07 -08:00
5fcba0e814 Trying a different config. 2025-11-15 13:34:18 -08:00
928c643d8d Fix linter config. 2025-11-15 13:16:30 -08:00
fd9f9f6d66 Fix linting. 2025-11-15 13:08:38 -08:00
a5b7727c8f Add linting stage. 2025-11-15 13:05:00 -08:00
3135c18d95 ignore goland directory 2025-11-15 01:53:40 -08:00
1d32a64dc0 add cert-revcheck 2025-11-14 22:56:10 -08:00
d70ca5ee87 adding golangci-lint 2025-11-14 22:54:06 -08:00
eca3a229a4 config: golangci-lint cleanups. 2025-11-14 22:53:02 -08:00
4c1eb03671 Cleaning up golangci-lint warnings. 2025-11-14 22:49:54 -08:00
f463eeed88 minor updates 2025-11-14 22:01:12 -08:00
289c9d2343 cmd: add cert-bundler
Builds certificate bundles.
2025-11-14 21:59:03 -08:00
e375963243 cmd: add tlsinfo. 2025-11-14 15:21:38 -08:00
31baa10b3b Update README. 2025-11-14 14:55:16 -08:00
0556c7c56d ca-signed: note self-signed certs. 2025-11-14 14:49:46 -08:00
83c95d9db8 cmd: add ca-signed tool.
Verify certificates are signed by a CA.
2025-11-14 14:48:34 -08:00
beccb551e2 add minmax 2025-04-10 01:17:11 -07:00
0dcd18c6f1 clean up code
- travisci is long dead
- golangci-lint the repo
2024-12-02 13:47:43 -08:00
024d552293 add circle ci config 2024-12-02 13:26:34 -08:00
9cd2ced695 There are different keys for different hash sizes. 2024-12-02 13:16:32 -08:00
c761d98b82 additional debugging for basic constraints 2024-08-22 18:06:09 -07:00
e68d22337b cmd: add die roller 2024-06-14 20:27:00 -07:00
4cb6f5b6f0 fix minor issues in Print calls. 2024-05-19 20:51:35 -07:00
6d5708800f circleci: Some days I really hate computers. 2024-05-19 20:48:59 -07:00
fa3eb821e6 circleci: Update go container. 2024-05-19 20:48:25 -07:00
dd5ed403b9 circleci: Update go version. 2024-05-19 20:46:28 -07:00
b4fde22c31 circleci: don't use bazel 2024-05-19 20:45:32 -07:00
9715293773 Add zsearch and dumpbytes. 2024-05-19 20:41:50 -07:00
f6d227946b Get rid of bazel.
Good riddance. More of a headache than it's worth.
2024-05-19 20:24:38 -07:00
6f7a8fa4d4 Add host tool. 2024-04-26 14:15:08 -07:00
622f6a2638 seekbuf: add Bytes method. 2023-08-27 20:56:47 -07:00
b92e16fa4d Handle evictions properly when cache is empty. 2023-08-27 18:01:16 -07:00
e3162b6164 log: remove extraneous print 2023-05-26 22:37:56 +00:00
9d1e3ab2f0 backoff: new package. 2023-05-26 17:28:49 +00:00
dd98356479 cmd/data_sync: update README 2023-05-11 19:42:31 -07:00
9307f44601 README: add data_sync 2023-05-11 19:41:33 -07:00
b9f69e4aa1 data_sync: sync homedir to external storage. 2023-05-11 19:18:29 -07:00
7a4e7977c3 log: fixups, add FatalError
- Support suppressing console output.
- DefaultDebugOptions sets the correct tag now.
- FatalError(error, string) calls log.Fatal(message, err) if err != nil.
2023-05-11 19:03:18 -07:00
72fdc255e7 db: clean out dependency on stretchr 2023-05-11 17:14:19 -07:00
63957ff22a remove unused dependencies 2023-05-07 11:57:38 -07:00
83d42dc489 bazel: running gazelle to pick up dependency changes 2023-05-06 13:37:58 -07:00
984baa6bb4 working on removing dependency on cfssl. 2023-05-06 13:25:30 -07:00
34982c122f config: add ListKeys 2023-05-06 01:13:41 -07:00
fb11c0c27c syslog: support not using syslog 2023-05-06 00:57:42 -07:00
27cc67d2cf this is why i hate computers 2023-05-04 23:58:51 -07:00
0fe13439b6 moving circleci file to the right place. 2023-05-04 23:57:40 -07:00
fb9caec663 Update CircleCI configuration. 2023-05-04 23:55:26 -07:00
e3e83630b5 Add CircleCI configuration. 2023-05-04 23:54:47 -07:00
c1f06604e3 fix dependency issues. 2023-05-04 23:45:08 -07:00
9091cc7682 syslog: add Spew option.
This is a debug print that spews any arguments passed in.
2023-05-04 23:40:20 -07:00
74ce7bc58a Cleaning up the codebase. 2023-05-04 23:22:35 -07:00
8b2d10209e Clean up the README. 2023-05-04 22:58:21 -07:00
770100b688 fileutil: start working on symlink checking 2023-05-04 22:52:16 -07:00
29630c55a9 packaging is a dead end thus far
I think I'm going to have to write my own rules to do this.
2023-05-04 21:03:08 -07:00
3c2ec896f8 cmd/cruntar: avoid writing files outside archive 2023-05-04 17:20:22 -07:00
7828726ba4 Updating x/sys. 2023-05-04 17:13:06 -07:00
458f3ceaed update dependencies to fix dependabot warnings. 2023-05-04 17:10:41 -07:00
2c45ae7b4e syslog: add new logging package. 2023-05-04 16:20:39 -07:00
c1b8b72cf1 bazel-test: add sizes to all tests.
This gets rid of the warning about mismatched test sizes.
2023-05-04 16:08:18 -07:00
bfc7fedbf9 seekbuf: add test file 2023-05-04 16:08:11 -07:00
965312f48e update README/LICENSE
LICENSE: update years.

README: add missing pieces.
2023-05-04 16:07:34 -07:00
237aa46ddd cmd/diskimg: new disk imaging tool 2023-05-04 16:06:56 -07:00
f8c64d3be5 bazel: updating build files 2023-05-04 15:11:15 -07:00
d66cfe1145 Cut over to Bazel. 2023-05-04 14:00:30 -07:00
ad03c5f991 Mass rewrite imports -> git.wntrmute.dev repo. 2023-05-04 13:58:43 -07:00
Kyle Isom
0dd4e1c6ca Update README.md
Depart for other waters.
2023-05-04 13:46:40 -07:00
078230217d ahash: add SumLimitedReader 2023-05-04 13:41:33 -07:00
90318f861b Add EEPROM image generator. 2022-06-24 14:36:51 -07:00
6fbdece4be Initial import. 2022-02-24 21:39:10 -08:00
3bb1362c0e tee: allow writing strings. 2022-02-20 17:43:11 -08:00
30ffbbdbc5 config: add test data for iniconf. 2022-02-20 17:43:11 -08:00
b893e99864 config: add default path, customized configs.
A customised config is an ini file with a [default] section and some
other name sections; a config file is loaded from the default section
with any keys in the named section being added in, overriding keys in
the host. This allows for, e.g. setting different paths based on the
host name or operating system.
2022-02-20 17:43:11 -08:00
c7c51568d8 seekbuf lost the Clear function. 2022-02-05 15:40:13 -08:00
7793021260 New package: seekbuf (a seekable buffer). 2022-02-05 15:00:39 -08:00
692562818c clean README 2020-11-26 20:32:46 -08:00
9e19346fc0 add sum file 2020-11-26 20:09:46 -08:00
cb827169dc switching hosting providers 2020-11-26 20:09:37 -08:00
027d0173bc logging: finish multi implementation 2020-11-11 21:15:02 -08:00
6f19b69bbd logging: add CREATE flag to file-based loggers. 2020-11-11 10:06:13 -08:00
7e118bfdb0 logging: add Multi 2020-11-11 09:52:07 -08:00
Kyle Isom
e0868841bf Merge pull request #7 from santosh653/master 2020-11-04 07:24:04 -08:00
santosh653
c558405d11 Update .travis.yml
Excluding go version 1.9 as only go version1.13 onwards are supported.
2020-11-04 05:22:10 -05:00
619c08a13f Update travis to latest Go versions. 2020-10-31 08:06:11 -07:00
944a57bf0e Switch to go modules. 2020-10-31 07:41:31 -07:00
0857b29624 Actually support clock mocking. 2020-10-31 07:24:34 -07:00
CodeLingo Bot
e95404bfc5 Fix function comments based on best practices from Effective Go
Signed-off-by: CodeLingo Bot <bot@codelingo.io>
2020-10-30 14:57:11 -07:00
ujjwalsh
924654e7c4 Added Support for Linux on Power 2020-10-30 07:50:32 -07:00
santosh653
a1eb035af7 Update .travis.yml
Adding Power support
2020-10-20 04:56:21 -04:00
5eedcff042 add rand package, utilities for math/rand. 2020-06-02 17:26:17 -07:00
Kyle Isom
6ac8eb04b4 Updated by OWASP Threat Dragon 2020-03-05 15:29:57 -08:00
Kyle Isom
4a4e4cd3fd Updated by OWASP Threat Dragon 2020-03-05 12:20:49 -08:00
Kyle Isom
1207093a56 Created by OWASP Threat Dragon 2020-03-05 12:19:57 -08:00
2b6ae03d1a config: add a Require message.
Also update explanation of the intended use-case for this package.
2020-03-02 17:36:29 -08:00
ef0f14a512 Add global config package.
This is a simple config system that I find myself wanting a lot.
2020-03-02 17:11:55 -08:00
6ae393ebf2 Have to explicitly allow darwin/amd64 in the build tag. 2020-01-16 06:19:21 -08:00
76d88c220d Add go mod, update ftime.
+ Stat_t on Darwin amd64 now uses the same struct fields
  for file times as Lunix, not the BSD ones. I need to
  follow up on this.
2020-01-16 06:13:31 -08:00
Kyle Isom
40e015373f Point to goutils-pkg. 2020-01-16 06:13:18 -08:00
50c226b726 Add parts. 2019-01-08 12:46:53 -08:00
070ffb9dff Cleanup testio. 2018-12-17 18:16:52 -08:00
5ac05bd298 Add debug printer. 2018-12-17 18:12:51 -08:00
cf1edf2d31 Add simple proxy. 2018-12-12 14:57:08 -08:00
9e0979e07f Support clock mocking.
This addresses #15.
2018-12-07 08:23:01 -08:00
Kyle Isom
03e8958dd7 Merge pull request #6 from golint-fixer/master
Fix golint import path
2018-10-31 18:31:23 -07:00
golint fixer
6cef585071 Fix golint import path 2018-10-25 09:37:10 -05:00
06678499d4 Add missing format arg. 2018-09-15 16:49:46 -07:00
fad17065fe Update README. 2018-09-15 15:26:42 -07:00
63e0cbeacb Update CHANGELOG for release. 2018-09-15 15:10:44 -07:00
231b98dd68 Add kgz, a gzip tool. 2018-09-15 15:08:37 -07:00
Kyle Isom
160a42ec26 travis: drop go 1.8 2018-04-24 10:47:35 -07:00
Kyle Isom
b6b33e00c8 cmd/showimp: support ignoring directories. 2018-04-24 09:44:45 -07:00
Kyle Isom
9e1aed257b rhash: use io.Copy to avoid reading the full file. 2018-04-24 09:44:45 -07:00
Aaron Bieber
bbc82ff8de Pad non-padded secrets. This lets us continue building on <= go1.8.
- Add tests for secrets using various padding methods.
- Add a new method/test to append padding to non-padded secrets.
2018-04-18 13:39:21 -07:00
Aaron Bieber
5fd928f69a Decode using WithPadding as pointed out by @gl-sergei.
This makes us print the same 6 digits as oathtool for non-padded
secrets like "a6mryljlbufszudtjdt42nh5by".
2018-04-18 13:39:21 -07:00
Aaron Bieber
acefe4a3b9 Don't assume our secret is base32 encoded.
According to https://en.wikipedia.org/wiki/Time-based_One-time_Password_algorithm
secrets are only base32 encoded in gauthenticator and gauth friendly providers.
2018-04-16 13:14:03 -07:00
a1452cebc9 Travis requires a string for Go 1.10. 2018-04-16 13:03:16 -07:00
6e9812e6f5 Vendor dependencies and add more tests. 2018-04-16 13:03:16 -07:00
Aaron Bieber
8c34415c34 add readme 2018-04-16 12:52:39 -07:00
Paul TREHIOU
2cf2c15def Case insensitive algorithm match 2018-04-16 12:43:27 -07:00
Kyle Isom
411907c0ad Merge pull request #5 from cbroglie/master
Make key and extended usages output stable
2017-11-22 16:24:57 -08:00
Christopher Broglie
06c7f8f42f Make key and extended usages output stable 2017-11-21 15:14:51 -08:00
Kyle Isom
8b638065d1 Fix ahash test Sprintf's. 2017-11-21 13:00:20 -08:00
Kyle Isom
9ac378eaa5 go lint → golint 2017-11-21 12:55:51 -08:00
Kyle Isom
eaaaabe439 go vet → golint 2017-11-21 12:52:26 -08:00
Kyle Isom
4122f01644 Fix travis errors. 2017-11-21 12:42:53 -08:00
Kyle Isom
263a5d3973 Add travis config. 2017-11-21 12:30:06 -08:00
Kyle Isom
afef3eea62 Fix Logger interface. 2017-11-21 12:19:38 -08:00
d6c5360a06 Start a change log. 2017-11-16 08:34:46 -08:00
0ab21e12f3 Reformat cmd/utc/main.go. 2017-11-16 08:32:42 -08:00
832475db56 gofmt cleanups. 2017-11-16 08:32:33 -08:00
cb16cfa183 golint cleanups. 2017-11-16 08:32:05 -08:00
d083a39a7d Fix README formatting. 2017-11-16 08:24:35 -08:00
fc77225740 Add rhash for hashing remote files.
This was originally rsha256, but it's been updated to support multiple
algorithms.
2017-11-16 08:24:13 -08:00
41df73d7a8 Add ahash package. 2017-11-16 07:52:36 -08:00
0dc478746a Add rsha256 - remote sha256 utility. 2017-11-15 17:40:38 -08:00
f44bbc9eca Add cruntar - untar for ChromeOS. 2017-11-15 17:37:54 -08:00
Kyle Isom
1df0350fc7 Cleanups and docs. 2017-10-20 12:38:41 -07:00
Kyle Isom
d42c1fa1c5 Add subjhash tool. Minor cleanups. 2017-10-20 12:18:56 -07:00
Kyle Isom
4fa6e4ab0e Update README. 2017-09-26 16:01:36 -07:00
Kyle Isom
a3ead16faf Add ski utility. 2017-09-26 15:59:39 -07:00
Aaron Bieber
eaad1884d4 Make sure our secret is always uppercase
Non-uppercase secrets that are base32 encoded will fial to decode
unless we upper them.
2017-09-17 18:19:23 -07:00
c8f839de73 Cleanups and improvements. 2017-09-12 03:38:47 -07:00
0c56a477bc Add tee. 2017-09-12 03:25:12 -07:00
Kyle Isom
763dbec310 Merge pull request #3 from joshlf/stealchain-server
cmd/stealchain-server: Present server cert, request client cert
2017-08-29 12:47:16 -07:00
Joshua Liebow-Feeser
0e6b60a2c4 cmd/stealchain-server: Present server cert, request client cert 2017-08-29 12:42:19 -07:00
Kyle Isom
be34ad263d Merge pull request #2 from joshlf/stealchain-server
cmd/stealchain-server: Explicitly perform TLS handshake.
2017-08-29 10:55:49 -07:00
Joshua Liebow-Feeser
48b03c908d cmd/stealchain-server: Explicitly perform TLS handshake. 2017-08-29 09:53:51 -07:00
Kyle Isom
70d7ff505b Merge pull request #1 from joshlf/stealchain-server
Add stealchain-server utility
2017-08-28 17:39:30 -07:00
Joshua Liebow-Feeser
68e5822176 cmd/stealchain-server: Initial commit. 2017-08-28 17:05:34 -07:00
Kyle Isom
54dd461733 Add option to print the SHA256 hash of a certificate.
This hash isn't the SKI --- it's a SHA256 hash of the raw certificate
contents.
2017-05-03 11:01:33 -07:00
Kyle Isom
eba03a2f4a Update README.
Adds atpoing, renfnv, and yamll to list of programs.
2017-05-03 11:01:01 -07:00
5d57d844d4 Add license (MIT). 2017-04-13 10:02:20 -07:00
Kyle Isom
31b9d175dd Add travis config. 2017-03-20 14:20:49 -07:00
Aaron Bieber
79e106da2e point to new qr location 2017-03-20 13:18:56 -07:00
Kyle Isom
939b1bc272 Updating imports. 2015-08-12 12:29:34 -07:00
Kyle
89e74f390b Add doc.go, finish YubiKey removal. 2014-04-24 20:43:13 -06:00
Kyle
7881b6fdfc Remove test TOTP client. 2014-04-24 20:40:44 -06:00
Kyle
5bef33245f Remove YubiKey (not currently functional). 2014-04-24 20:37:53 -06:00
Kyle
84250b0501 More documentation. 2014-04-24 20:37:00 -06:00
Kyle Isom
459e9f880f Add function to build Google TOTPs from secret 2014-04-23 16:54:16 -07:00
Kyle Isom
0982f47ce3 Add last night's progress.
Basic functionality for HOTP, TOTP, and YubiKey OTP. Still need YubiKey
HMAC, serialisation, check, and scan.
2013-12-20 17:00:01 -07:00
Kyle Isom
1dec15fd11 add missing files
new files are
	oath_test
	totp code
2013-12-19 00:21:26 -07:00
Kyle Isom
2ee9cae5ba Add basic Google Authenticator TOTP client. 2013-12-19 00:20:00 -07:00
Kyle Isom
dc04475120 HOTP and TOTP-SHA-1 working.
why the frak aren't the SHA-256 and SHA-512 variants working
2013-12-19 00:04:26 -07:00
Kyle Isom
dbbd5116b5 Initial import.
Basic HOTP functionality.
2013-12-18 21:48:14 -07:00
163 changed files with 12803 additions and 785 deletions

67
.circleci/config.yml Normal file
View File

@@ -0,0 +1,67 @@
# Use the latest 2.1 version of CircleCI pipeline process engine.
# See: https://circleci.com/docs/2.0/configuration-reference
version: 2.1
# Define a job to be invoked later in a workflow.
# See: https://circleci.com/docs/2.0/configuration-reference/#jobs
jobs:
lint:
working_directory: ~/repo
docker:
- image: cimg/go:1.22.2
steps:
- checkout
- restore_cache:
keys:
- go-mod-v4-{{ checksum "go.sum" }}
- run:
name: Install Dependencies
command: go mod download
- save_cache:
key: go-mod-v4-{{ checksum "go.sum" }}
paths:
- "/go/pkg/mod"
- run:
name: Install golangci-lint
command: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin
- run:
name: Run golangci-lint
command: golangci-lint run --timeout=5m
testbuild:
working_directory: ~/repo
# Specify the execution environment. You can specify an image from Dockerhub or use one of our Convenience Images from CircleCI's Developer Hub.
# See: https://circleci.com/docs/2.0/configuration-reference/#docker-machine-macos-windows-executor
docker:
- image: cimg/go:1.22.2
# Add steps to the job
# See: https://circleci.com/docs/2.0/configuration-reference/#steps
steps:
- checkout
- restore_cache:
keys:
- go-mod-v4-{{ checksum "go.sum" }}
- run:
name: Install Dependencies
command: go mod download
- save_cache:
key: go-mod-v4-{{ checksum "go.sum" }}
paths:
- "/go/pkg/mod"
- run:
name: Run tests
command: go test -race ./...
- run:
name: Run build
command: go build ./...
- store_test_results:
path: /tmp/test-reports
# Invoke jobs via workflows
# See: https://circleci.com/docs/2.0/configuration-reference/#workflows
# Linting is disabled while cleanups are ongoing.
workflows:
testbuild:
jobs:
- testbuild
- lint

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.idea

519
.golangci.yml Normal file
View File

@@ -0,0 +1,519 @@
# This file is licensed under the terms of the MIT license https://opensource.org/license/mit
# Copyright (c) 2021-2025 Marat Reymers
## Golden config for golangci-lint v2.6.2
#
# This is the best config for golangci-lint based on my experience and opinion.
# It is very strict, but not extremely strict.
# Feel free to adapt it to suit your needs.
# If this config helps you, please consider keeping a link to this file (see the next comment).
# Based on https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322
version: "2"
output:
sort-order:
- file
- linter
- severity
issues:
# Maximum count of issues with the same text.
# Set to 0 to disable.
# Default: 3
max-same-issues: 50
# Exclude some lints for CLI programs under cmd/ (package main).
# The project allows fmt.Print* in command-line tools; keep forbidigo for libraries.
exclude-rules:
- path: ^cmd/
linters:
- forbidigo
- path: cmd/.*
linters:
- forbidigo
- path: .*/cmd/.*
linters:
- forbidigo
formatters:
enable:
- goimports # checks if the code and import statements are formatted according to the 'goimports' command
- golines # checks if code is formatted, and fixes long lines
## you may want to enable
#- gci # checks if code and import statements are formatted, with additional rules
#- gofmt # checks if the code is formatted according to 'gofmt' command
#- gofumpt # enforces a stricter format than 'gofmt', while being backwards compatible
#- swaggo # formats swaggo comments
# All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml
settings:
goimports:
# A list of prefixes, which, if set, checks import paths
# with the given prefixes are grouped after 3rd-party packages.
# Default: []
local-prefixes:
- github.com/my/project
golines:
# Target maximum line length.
# Default: 100
max-len: 120
linters:
enable:
- asasalint # checks for pass []any as any in variadic func(...any)
- asciicheck # checks that your code does not contain non-ASCII identifiers
- bidichk # checks for dangerous unicode character sequences
- bodyclose # checks whether HTTP response body is closed successfully
- canonicalheader # checks whether net/http.Header uses canonical header
- copyloopvar # detects places where loop variables are copied (Go 1.22+)
- cyclop # checks function and package cyclomatic complexity
- depguard # checks if package imports are in a list of acceptable packages
- dupl # tool for code clone detection
- durationcheck # checks for two durations multiplied together
- errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases
- errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error
- errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13
- exhaustive # checks exhaustiveness of enum switch statements
- exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions
- fatcontext # detects nested contexts in loops
- forbidigo # forbids identifiers
- funcorder # checks the order of functions, methods, and constructors
- funlen # tool for detection of long functions
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
- gochecksumtype # checks exhaustiveness on Go "sum types"
- gocognit # computes and checks the cognitive complexity of functions
- goconst # finds repeated strings that could be replaced by a constant
- gocritic # provides diagnostics that check for bugs, performance and style issues
- gocyclo # computes and checks the cyclomatic complexity of functions
- godoclint # checks Golang's documentation practice
- godot # checks if comments end in a period
- gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod
- gosec # inspects source code for security problems
- govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string
- iface # checks the incorrect use of interfaces, helping developers avoid interface pollution
- ineffassign # detects when assignments to existing variables are not used
- intrange # finds places where for loops could make use of an integer range
- iotamixing # checks if iotas are being used in const blocks with other non-iota declarations
- loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap)
- makezero # finds slice declarations with non-zero initial length
- mirror # reports wrong mirror patterns of bytes/strings usage
- mnd # detects magic numbers
- modernize # suggests simplifications to Go code, using modern language and library features
- musttag # enforces field tags in (un)marshaled structs
- nakedret # finds naked returns in functions greater than a specified function length
- nestif # reports deeply nested if statements
- nilerr # finds the code that returns nil even if it checks that the error is not nil
- nilnesserr # reports that it checks for err != nil, but it returns a different nil value error (powered by nilness and nilerr)
- nilnil # checks that there is no simultaneous return of nil error and an invalid value
- noctx # finds sending http request without context.Context
- nolintlint # reports ill-formed or insufficient nolint directives
- nonamedreturns # reports all named returns
- nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL
- perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative
- predeclared # finds code that shadows one of Go's predeclared identifiers
- promlinter # checks Prometheus metrics naming via promlint
- protogetter # reports direct reads from proto message fields when getters should be used
- reassign # checks that package variables are not reassigned
- recvcheck # checks for receiver type consistency
- revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint
- rowserrcheck # checks whether Err of rows is checked successfully
- sloglint # ensure consistent code style when using log/slog
- spancheck # checks for mistakes with OpenTelemetry/Census spans
- sqlclosecheck # checks that sql.Rows and sql.Stmt are closed
- staticcheck # is a go vet on steroids, applying a ton of static analysis checks
- testableexamples # checks if examples are testable (have an expected output)
- testifylint # checks usage of github.com/stretchr/testify
- testpackage # makes you use a separate _test package
- tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes
- unconvert # removes unnecessary type conversions
- unparam # reports unused function parameters
- unqueryvet # detects SELECT * in SQL queries and SQL builders, encouraging explicit column selection
- unused # checks for unused constants, variables, functions and types
- usestdlibvars # detects the possibility to use variables/constants from the Go standard library
- usetesting # reports uses of functions with replacement inside the testing package
- wastedassign # finds wasted assignment statements
- whitespace # detects leading and trailing whitespace
## you may want to enable
#- arangolint # opinionated best practices for arangodb client
#- decorder # checks declaration order and count of types, constants, variables and functions
#- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized
#- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega
#- godox # detects usage of FIXME, TODO and other keywords inside comments
#- goheader # checks is file header matches to pattern
#- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters
#- interfacebloat # checks the number of methods inside an interface
#- ireturn # accept interfaces, return concrete types
#- noinlineerr # disallows inline error handling `if err := ...; err != nil {`
#- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated
#- tagalign # checks that struct tags are well aligned
#- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope
#- wrapcheck # checks that errors returned from external packages are wrapped
#- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event
## disabled
#- containedctx # detects struct contained context.Context field
#- contextcheck # [too many false positives] checks the function whether use a non-inherited context
#- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f())
#- dupword # [useless without config] checks for duplicate words in the source code
#- err113 # [too strict] checks the errors handling expressions
#- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted
#- forcetypeassert # [replaced by errcheck] finds forced type assertions
#- gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies
#- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase
#- grouper # analyzes expression groups
#- importas # enforces consistent import aliases
#- lll # [replaced by golines] reports long lines
#- maintidx # measures the maintainability index of each function
#- misspell # [useless] finds commonly misspelled English words in comments
#- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity
#- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test
#- tagliatelle # checks the struct tags
#- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers
#- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines
#- wsl_v5 # [too strict and mostly code is not more readable] add or remove empty lines
# All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml
settings:
cyclop:
# The maximal code complexity to report.
# Default: 10
max-complexity: 30
# The maximal average package complexity.
# If it's higher than 0.0 (float) the check is enabled.
# Default: 0.0
package-average: 10.0
depguard:
# Rules to apply.
#
# Variables:
# - File Variables
# Use an exclamation mark `!` to negate a variable.
# Example: `!$test` matches any file that is not a go test file.
#
# `$all` - matches all go files
# `$test` - matches all go test files
#
# - Package Variables
#
# `$gostd` - matches all of go's standard library (Pulled from `GOROOT`)
#
# Default (applies if no custom rules are defined): Only allow $gostd in all files.
rules:
"deprecated":
# List of file globs that will match this list of settings to compare against.
# By default, if a path is relative, it is relative to the directory where the golangci-lint command is executed.
# The placeholder '${base-path}' is substituted with a path relative to the mode defined with `run.relative-path-mode`.
# The placeholder '${config-path}' is substituted with a path relative to the configuration file.
# Default: $all
files:
- "$all"
# List of packages that are not allowed.
# Entries can be a variable (starting with $), a string prefix, or an exact match (if ending with $).
# Default: []
deny:
- pkg: github.com/golang/protobuf
desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules
- pkg: github.com/satori/go.uuid
desc: Use github.com/google/uuid instead, satori's package is not maintained
- pkg: github.com/gofrs/uuid$
desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5
"non-test files":
files:
- "!$test"
deny:
- pkg: math/rand$
desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2
"non-main files":
files:
- "!**/main.go"
deny:
- pkg: log$
desc: Use log/slog instead, see https://go.dev/blog/slog
embeddedstructfieldcheck:
# Checks that sync.Mutex and sync.RWMutex are not used as embedded fields.
# Default: false
forbid-mutex: true
errcheck:
# Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
# Such cases aren't reported by default.
# Default: false
check-type-assertions: true
exclude-functions:
- (*git.wntrmute.dev/kyle/goutils/sbuf.Buffer).Write
- git.wntrmute.dev/kyle/goutils/lib.Warn
- git.wntrmute.dev/kyle/goutils/lib.Warnx
- git.wntrmute.dev/kyle/goutils/lib.Err
- git.wntrmute.dev/kyle/goutils/lib.Errx
exhaustive:
# Program elements to check for exhaustiveness.
# Default: [ switch ]
check:
- switch
- map
exhaustruct:
# List of regular expressions to match type names that should be excluded from processing.
# Anonymous structs can be matched by '<anonymous>' alias.
# Has precedence over `include`.
# Each regular expression must match the full type name, including package path.
# For example, to match type `net/http.Cookie` regular expression should be `.*/http\.Cookie`,
# but not `http\.Cookie`.
# Default: []
exclude:
# std libs
- ^net/http.Client$
- ^net/http.Cookie$
- ^net/http.Request$
- ^net/http.Response$
- ^net/http.Server$
- ^net/http.Transport$
- ^net/url.URL$
- ^os/exec.Cmd$
- ^reflect.StructField$
# public libs
- ^github.com/Shopify/sarama.Config$
- ^github.com/Shopify/sarama.ProducerMessage$
- ^github.com/mitchellh/mapstructure.DecoderConfig$
- ^github.com/prometheus/client_golang/.+Opts$
- ^github.com/spf13/cobra.Command$
- ^github.com/spf13/cobra.CompletionOptions$
- ^github.com/stretchr/testify/mock.Mock$
- ^github.com/testcontainers/testcontainers-go.+Request$
- ^github.com/testcontainers/testcontainers-go.FromDockerfile$
- ^golang.org/x/tools/go/analysis.Analyzer$
- ^google.golang.org/protobuf/.+Options$
- ^gopkg.in/yaml.v3.Node$
# Allows empty structures in return statements.
# Default: false
allow-empty-returns: true
funcorder:
# Checks if the exported methods of a structure are placed before the non-exported ones.
# Default: true
struct-method: false
funlen:
# Checks the number of lines in a function.
# If lower than 0, disable the check.
# Default: 60
lines: 100
# Checks the number of statements in a function.
# If lower than 0, disable the check.
# Default: 40
statements: 50
gochecksumtype:
# Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed.
# Default: true
default-signifies-exhaustive: false
gocognit:
# Minimal code complexity to report.
# Default: 30 (but we recommend 10-20)
min-complexity: 20
gocritic:
# Settings passed to gocritic.
# The settings key is the name of a supported gocritic checker.
# The list of supported checkers can be found at https://go-critic.com/overview.
settings:
captLocal:
# Whether to restrict checker to params only.
# Default: true
paramsOnly: false
underef:
# Whether to skip (*x).method() calls where x is a pointer receiver.
# Default: true
skipRecvDeref: false
godoclint:
# List of rules to enable in addition to the default set.
# Default: empty
enable:
# Assert no unused link in godocs.
# https://github.com/godoc-lint/godoc-lint?tab=readme-ov-file#no-unused-link
- no-unused-link
gosec:
excludes:
- G104 # handled by errcheck
- G301
- G306
govet:
# Enable all analyzers.
# Default: false
enable-all: true
# Disable analyzers by name.
# Run `GL_DEBUG=govet golangci-lint run --enable=govet` to see default, all available analyzers, and enabled analyzers.
# Default: []
disable:
- fieldalignment # too strict
# Settings per analyzer.
settings:
shadow:
# Whether to be strict about shadowing; can be noisy.
# Default: false
strict: true
inamedparam:
# Skips check for interface methods with only a single parameter.
# Default: false
skip-single-param: true
mnd:
ignored-functions:
- args.Error
- flag.Arg
- flag.Duration.*
- flag.Float.*
- flag.Int.*
- flag.Uint.*
- os.Chmod
- os.Mkdir.*
- os.OpenFile
- os.WriteFile
- prometheus.ExponentialBuckets.*
- prometheus.LinearBuckets
ignored-numbers:
- 1
- 2
- 3
- 4
- 8
nakedret:
# Make an issue if func has more lines of code than this setting, and it has naked returns.
# Default: 30
max-func-lines: 0
nolintlint:
# Exclude the following linters from requiring an explanation.
# Default: []
allow-no-explanation: [ funlen, gocognit, golines ]
# Enable to require an explanation of nonzero length after each nolint directive.
# Default: false
require-explanation: true
# Enable to require nolint directives to mention the specific linter being suppressed.
# Default: false
require-specific: true
perfsprint:
# Optimizes into strings concatenation.
# Default: true
strconcat: false
reassign:
# Patterns for global variable names that are checked for reassignment.
# See https://github.com/curioswitch/go-reassign#usage
# Default: ["EOF", "Err.*"]
patterns:
- ".*"
rowserrcheck:
# database/sql is always checked.
# Default: []
packages:
- github.com/jmoiron/sqlx
sloglint:
# Enforce not using global loggers.
# Values:
# - "": disabled
# - "all": report all global loggers
# - "default": report only the default slog logger
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global
# Default: ""
no-global: all
# Enforce using methods that accept a context.
# Values:
# - "": disabled
# - "all": report all contextless calls
# - "scope": report only if a context exists in the scope of the outermost function
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only
# Default: ""
context: scope
staticcheck:
# SAxxxx checks in https://staticcheck.dev/docs/configuration/options/#checks
# Example (to disable some checks): [ "all", "-SA1000", "-SA1001"]
# Default: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"]
checks:
- all
# Incorrect or missing package comment.
# https://staticcheck.dev/docs/checks/#ST1000
- -ST1000
# Use consistent method receiver names.
# https://staticcheck.dev/docs/checks/#ST1016
- -ST1016
# Omit embedded fields from selector expression.
# https://staticcheck.dev/docs/checks/#QF1008
- -QF1008
# We often explicitly enable old/deprecated ciphers for research.
- -SA1019
# Covered by revive.
- -ST1003
usetesting:
# Enable/disable `os.TempDir()` detections.
# Default: false
os-temp-dir: true
exclusions:
# Log a warning if an exclusion rule is unused.
# Default: false
warn-unused: true
# Predefined exclusion rules.
# Default: []
presets:
- std-error-handling
- common-false-positives
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'
linters: [ testpackage ]
- path: 'log/logger.go'
linters: [ forbidigo ]
- path: 'logging/example_test.go'
linters: [ testableexamples ]
- path: 'main.go'
linters: [ forbidigo, mnd, reassign ]
- path: 'cmd/cruntar/main.go'
linters: [ unparam ]
- source: 'TODO'
linters: [ godot ]
- text: 'should have a package comment'
linters: [ revive ]
- text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported'
linters: [ revive ]
- text: 'package comment should be of the form ".+"'
source: '// ?(nolint|TODO)'
linters: [ revive ]
- text: 'comment on exported \S+ \S+ should be of the form ".+"'
source: '// ?(nolint|TODO)'
linters: [ revive, staticcheck ]
- path: '_test\.go'
linters:
- bodyclose
- dupl
- errcheck
- funlen
- goconst
- gosec
- noctx
- reassign
- wrapcheck

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

9
.idea/goutils.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/goutils.iml" filepath="$PROJECT_DIR$/.idea/goutils.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

99
CHANGELOG Normal file
View File

@@ -0,0 +1,99 @@
CHANGELOG
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
- cmd/ski, cmd/csrpubdump, cmd/tlskeypair: centralize
certificate/private-key/CSR parsing by reusing certlib helpers.
This reduces duplication and improves consistency across commands.
- csr: CSR parsing in the above commands now uses certlib.ParseCSR,
which verifies CSR signatures (behavioral hardening compared to
prior parsing without signature verification).
v1.11.1 - 2025-11-16
Changed
- cmd: complete linting fixes across programs; no functional changes.
v1.11.0 - 2025-11-15
Added
- cache/mru: introduce MRU cache implementation with timestamp utilities.
Changed
- certlib: complete overhaul to simplify APIs and internals.
- repo: widespread linting cleanups across many packages (config, dbg, die,
fileutil, log/logging, mwc, sbuf, seekbuf, tee, testio, etc.).
- cmd: general program cleanups; `cert-bundler` lint fixes.
Removed
- rand: remove unused package.
- testutil: remove unused code.
v1.10.1 — 2025-11-15
Changed
- certlib: major overhaul and refactor.
- repo: linter autofixes ahead of release.
v1.10.0 — 2025-11-14
Added
- cmd: add `cert-revcheck` command.
Changed
- ci/lint: add golangci-lint stage and initial cleanup.
v1.9.1 — 2025-11-15
Fixed
- die: correct calls to `die.With`.
v1.9.0 — 2025-11-14
Added
- cmd: add `cert-bundler` tool.
Changed
- misc: minor updates and maintenance.
v1.8.1 — 2025-11-14
Added
- cmd: add `tlsinfo` tool.
v1.8.0 — 2025-11-14
Baseline
- Initial baseline for this changelog series.

228
LICENSE
View File

@@ -1,13 +1,219 @@
Copyright (c) 2015 Kyle Isom <kyle@tyrfingr.is>
Copyright 2025 K. Isom <kyle@imap.cc>
Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
=======================================================================
The backoff package (written during my time at Cloudflare) is released
under the following license:
Copyright (c) 2016 CloudFlare Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

114
README.md
View File

@@ -2,48 +2,134 @@ GOUTILS
This is a collection of small utility code I've written in Go; the `cmd/`
directory has a number of command-line utilities. Rather than keep all
of these in superfluous repositories of their own, I'm putting them here.
of these in superfluous repositories of their own or rewriting them
for each project, I'm putting them here.
The project can be built with the standard Go tooling.
Contents:
ahash/ Provides hashes from string algorithm specifiers.
assert/ Error handling, assertion-style.
backoff/ Implementation of an intelligent backoff strategy.
cache/ Implementations of various caches.
lru/ Least-recently-used cache.
mru/ Most-recently-used cache.
certlib/ Library for working with TLS certificates.
cmd/
certchain/ Display the certificate chain from a
TLS connection.
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
certificates.
cert-revcheck/
Check whether a certificate has been revoked or is
expired.
certchain/ Display the certificate chain from a TLS connection.
certdump/ Dump certificate information.
certexpiry/ Print a list of certificate subjects and expiry times
or warn about certificates expiring within a certain
window.
certverify/ Verify a TLS X.509 certificate, optionally printing
certverify/ Verify a TLS X.509 certificate file, optionally printing
the time to expiry and checking for revocations.
clustersh/ Run commands or transfer files across multiple
servers via SSH.
cruntar/ (Un)tar an archive with hard links, copying instead of
linking.
csrpubdump/ Dump the public key from an X.509 certificate request.
data_sync/ Sync the user's homedir to external storage.
diskimg/ Write a disk image to a device.
dumpbytes/ Dump the contents of a file as hex bytes, printing it as
a Go []byte literal.
eig/ EEPROM image generator.
fragment/ Print a fragment of a file.
host/ Go imlpementation of the host(1) command.
jlp/ JSON linter/prettifier.
kgz/ Custom gzip compressor / decompressor that handles 99%
of my use cases.
minmax/ Generate a minmax code for use in uLisp.
parts/ Simple parts database management for my collection of
electronic components.
pem2bin/ Dump the binary body of a PEM-encoded block.
pembody/ Print the body of a PEM certificate.
pemit/ Dump data to a PEM file.
showimp/ List the external (e.g. non-stdlib and outside the
current working directory) imports for a Go file.
readchain/ Print the common name for the certificates
in a bundle.
showimp Display the external imports in a package.
stealchain/ Dump the verified chain from a TLS
connection.
renfnv/ Rename a file to base32-encoded 64-bit FNV-1a hash.
rhash/ Compute the digest of remote files.
rolldie/ Roll some dice.
showimp/ List the external (e.g. non-stdlib and outside the
current working directory) imports for a Go file.
ski Display the SKI for PEM-encoded TLS material.
sprox/ Simple TCP proxy.
stealchain/ Dump the verified chain from a TLS connection to a
server.
stealchain-server/
Dump the verified chain from a TLS connection from
from a client.
subjhash/ Print or match subject info from a certificate.
tlsinfo/ Print information about a TLS connection (the TLS version
and cipher suite).
tlskeypair/ Check whether a TLS certificate and key file match.
utc/ Convert times to UTC.
yamll/ A small YAML linter.
zsearch/ Search for a string in directory of gzipped files.
config/ A simple global configuration system where configuration
data is pulled from a file or an environment variable
transparently.
iniconf/ A simple INI-style configuration system.
dbg/ A debug printer.
die/ Death of a program.
fileutil/ Common file functions.
fileutil/ Common file functions.
lib/ Commonly-useful functions for writing Go programs.
log/ A syslog library.
logging/ A logging library.
mwc/ MultiwriteCloser implementation.
sbuf/ A byte buffer that can be wiped.
testio/ Various I/O utilities useful during testing.
testutil/ Various utility functions useful during testing.
seekbuf/ A read-seekable byte buffer.
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.
All code here is licensed under the MIT license.
All code here is licensed under the Apache 2.0 license.
Error handling
--------------
This repo standardizes on Go 1.13+ error wrapping and matching. Libraries and
CLIs should:
- Wrap causes with context using `fmt.Errorf("context: %w", err)`.
- Use typed, structured errors from `certlib/certerr` for certificate-related
operations. These include a typed `*certerr.Error` with `Source` and `Kind`.
- Match errors programmatically:
- `errors.Is(err, certerr.ErrEncryptedPrivateKey)` to detect sentinel states.
- `errors.As(err, &e)` (where `var e *certerr.Error`) to inspect
`e.Source`/`e.Kind`.
Examples:
```
cert, err := certlib.LoadCertificate(path)
if err != nil {
// sentinel match:
if errors.Is(err, certerr.ErrEmptyCertificate) {
// handle empty input
}
// typed error match
var ce *certerr.Error
if errors.As(err, &ce) {
switch ce.Kind {
case certerr.KindParse:
// parse error handling
case certerr.KindLoad:
// file loading error handling
}
}
}
```

244
ahash/ahash.go Normal file
View File

@@ -0,0 +1,244 @@
// Package ahash provides support for hashing data with a selectable
//
// hash function.
package ahash
import (
"crypto/md5" // #nosec G505
"crypto/sha1" // #nosec G501
"crypto/sha256"
"crypto/sha512"
"errors"
"hash"
"hash/adler32"
"hash/crc32"
"hash/crc64"
"hash/fnv"
"io"
"sort"
"golang.org/x/crypto/blake2b"
"golang.org/x/crypto/blake2s"
"golang.org/x/crypto/md4" // #nosec G506
"golang.org/x/crypto/ripemd160" // #nosec G507
"golang.org/x/crypto/sha3"
"git.wntrmute.dev/kyle/goutils/assert"
)
// Hash represents a generic hash function that may or may not be secure. It
// satisfies the hash.Hash interface.
type Hash struct {
hash.Hash
secure bool
algo string
}
// HashAlgo returns the name of the underlying hash algorithm.
func (h *Hash) HashAlgo() string {
return h.algo
}
// IsSecure returns true if the Hash is a cryptographic hash.
func (h *Hash) IsSecure() bool {
return h.secure
}
// Sum32 returns true if the underlying hash is a 32-bit hash; if is, the
// uint32 parameter will contain the hash.
func (h *Hash) Sum32() (uint32, bool) {
h32, ok := h.Hash.(hash.Hash32)
if !ok {
return 0, false
}
return h32.Sum32(), true
}
// IsHash32 returns true if the underlying hash is a 32-bit hash function.
func (h *Hash) IsHash32() bool {
_, ok := h.Hash.(hash.Hash32)
return ok
}
// Sum64 returns true if the underlying hash is a 64-bit hash; if is, the
// uint64 parameter will contain the hash.
func (h *Hash) Sum64() (uint64, bool) {
h64, ok := h.Hash.(hash.Hash64)
if !ok {
return 0, false
}
return h64.Sum64(), true
}
// IsHash64 returns true if the underlying hash is a 64-bit hash function.
func (h *Hash) IsHash64() bool {
_, ok := h.Hash.(hash.Hash64)
return ok
}
func blakeFunc(bf func(key []byte) (hash.Hash, error)) func() hash.Hash {
return func() hash.Hash {
h, err := bf(nil)
assert.NoError(err, "while constructing a BLAKE2 hash function")
return h
}
}
var secureHashes = map[string]func() hash.Hash{
"ripemd160": ripemd160.New,
"sha224": sha256.New224,
"sha256": sha256.New,
"sha384": sha512.New384,
"sha512": sha512.New,
"sha3-224": sha3.New224,
"sha3-256": sha3.New256,
"sha3-384": sha3.New384,
"sha3-512": sha3.New512,
"blake2s-256": blakeFunc(blake2s.New256),
"blake2b-256": blakeFunc(blake2b.New256),
"blake2b-384": blakeFunc(blake2b.New384),
"blake2b-512": blakeFunc(blake2b.New512),
}
func newHash32(f func() hash.Hash32) func() hash.Hash {
return func() hash.Hash {
return f()
}
}
func newHash64(f func() hash.Hash64) func() hash.Hash {
return func() hash.Hash {
return f()
}
}
func newCRC64(tab uint64) func() hash.Hash {
return newHash64(
func() hash.Hash64 {
return crc64.New(crc64.MakeTable(tab))
})
}
var insecureHashes = map[string]func() hash.Hash{
"md4": md4.New,
"md5": md5.New,
"sha1": sha1.New,
"adler32": newHash32(adler32.New),
"crc32-ieee": newHash32(crc32.NewIEEE),
"crc64": newCRC64(crc64.ISO),
"crc64-ecma": newCRC64(crc64.ECMA),
"fnv1-32a": newHash32(fnv.New32a),
"fnv1-32": newHash32(fnv.New32),
"fnv1-64a": newHash64(fnv.New64a),
"fnv1-64": newHash64(fnv.New64),
}
// New returns a new Hash for the specified algorithm.
func New(algo string) (*Hash, error) {
h := &Hash{algo: algo}
hf, ok := secureHashes[algo]
if ok {
h.Hash = hf()
h.secure = true
return h, nil
}
hf, ok = insecureHashes[algo]
if ok {
h.Hash = hf()
h.secure = false
return h, nil
}
return nil, errors.New("chash: unsupport hash algorithm " + algo)
}
// Sum returns the digest (not the hex digest) of the data using the given
// algorithm.
func Sum(algo string, data []byte) ([]byte, error) {
h, err := New(algo)
if err != nil {
return nil, err
}
_, err = h.Write(data)
if err != nil {
return nil, err
}
return h.Sum(nil), nil
}
// SumReader reads all the data from the given io.Reader and returns the
// digest (not the hex digest) from the specified algorithm.
func SumReader(algo string, r io.Reader) ([]byte, error) {
h, err := New(algo)
if err != nil {
return nil, err
}
_, err = io.Copy(h, r)
if err != nil {
return nil, err
}
return h.Sum(nil), nil
}
// SumLimitedReader reads n bytes of data from the io.reader and returns the
// digest (not the hex digest) from the specified algorithm.
func SumLimitedReader(algo string, r io.Reader, n int64) ([]byte, error) {
limit := &io.LimitedReader{
R: r,
N: n,
}
return SumReader(algo, limit)
}
var insecureHashList, secureHashList, hashList []string
func init() {
shl := len(secureHashes) // secure hash list length
ihl := len(insecureHashes) // insecure hash list length
ahl := shl + ihl // all hash list length
insecureHashList = make([]string, 0, ihl)
secureHashList = make([]string, 0, shl)
hashList = make([]string, 0, ahl)
for algo := range insecureHashes {
insecureHashList = append(insecureHashList, algo)
}
sort.Strings(insecureHashList)
for algo := range secureHashes {
secureHashList = append(secureHashList, algo)
}
sort.Strings(secureHashList)
hashList = append(hashList, insecureHashList...)
hashList = append(hashList, secureHashList...)
sort.Strings(hashList)
}
// HashList returns a sorted list of all the hash algorithms supported by the
// package.
func HashList() []string {
return hashList
}
// SecureHashList returns a sorted list of all the secure (cryptographic) hash
// algorithms supported by the package.
func SecureHashList() []string {
return secureHashList
}
// InsecureHashList returns a sorted list of all the insecure hash algorithms
// supported by the package.
func InsecureHashList() []string {
return insecureHashList
}

159
ahash/ahash_test.go Normal file
View File

@@ -0,0 +1,159 @@
package ahash_test
import (
"bytes"
"encoding/hex"
"fmt"
"testing"
"git.wntrmute.dev/kyle/goutils/ahash"
"git.wntrmute.dev/kyle/goutils/assert"
)
func TestSecureHash(t *testing.T) {
algo := "sha256"
h, err := ahash.New(algo)
assert.NoErrorT(t, err)
assert.BoolT(t, h.IsSecure(), algo+" should be a secure hash")
assert.BoolT(t, h.HashAlgo() == algo, "hash returned the wrong HashAlgo")
assert.BoolT(t, !h.IsHash32(), algo+" isn't actually a 32-bit hash")
assert.BoolT(t, !h.IsHash64(), algo+" isn't actually a 64-bit hash")
var data []byte
var expected = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
sum, err := ahash.Sum(algo, data)
assert.NoErrorT(t, err)
assert.BoolT(t, hex.EncodeToString(sum) == expected, fmt.Sprintf("expected hash %s but have %x", expected, sum))
data = []byte("hello, world")
buf := bytes.NewBuffer(data)
expected = "09ca7e4eaa6e8ae9c7d261167129184883644d07dfba7cbfbc4c8a2e08360d5b"
sum, err = ahash.SumReader(algo, buf)
assert.NoErrorT(t, err)
assert.BoolT(t, hex.EncodeToString(sum) == expected, fmt.Sprintf("expected hash %s but have %x", expected, sum))
data = []byte("hello world")
_, err = h.Write(data)
assert.NoErrorT(t, err)
unExpected := "09ca7e4eaa6e8ae9c7d261167129184883644d07dfba7cbfbc4c8a2e08360d5b"
sum = h.Sum(nil)
assert.BoolT(t, hex.EncodeToString(sum) != unExpected, fmt.Sprintf("hash shouldn't have returned %x", unExpected))
}
func TestInsecureHash(t *testing.T) {
algo := "md5"
h, err := ahash.New(algo)
assert.NoErrorT(t, err)
assert.BoolT(t, !h.IsSecure(), algo+" shouldn't be a secure hash")
assert.BoolT(t, h.HashAlgo() == algo, "hash returned the wrong HashAlgo")
assert.BoolT(t, !h.IsHash32(), algo+" isn't actually a 32-bit hash")
assert.BoolT(t, !h.IsHash64(), algo+" isn't actually a 64-bit hash")
var data []byte
var expected = "d41d8cd98f00b204e9800998ecf8427e"
sum, err := ahash.Sum(algo, data)
assert.NoErrorT(t, err)
assert.BoolT(t, hex.EncodeToString(sum) == expected, fmt.Sprintf("expected hash %s but have %x", expected, sum))
data = []byte("hello, world")
buf := bytes.NewBuffer(data)
expected = "e4d7f1b4ed2e42d15898f4b27b019da4"
sum, err = ahash.SumReader(algo, buf)
assert.NoErrorT(t, err)
assert.BoolT(t, hex.EncodeToString(sum) == expected, fmt.Sprintf("expected hash %s but have %x", expected, sum))
data = []byte("hello world")
_, err = h.Write(data)
assert.NoErrorT(t, err)
unExpected := "e4d7f1b4ed2e42d15898f4b27b019da4"
sum = h.Sum(nil)
assert.BoolT(t, hex.EncodeToString(sum) != unExpected, fmt.Sprintf("hash shouldn't have returned %x", unExpected))
}
func TestHash32(t *testing.T) {
algo := "crc32-ieee"
h, err := ahash.New(algo)
assert.NoErrorT(t, err)
assert.BoolT(t, !h.IsSecure(), algo+" shouldn't be a secure hash")
assert.BoolT(t, h.HashAlgo() == algo, "hash returned the wrong HashAlgo")
assert.BoolT(t, h.IsHash32(), algo+" is actually a 32-bit hash")
assert.BoolT(t, !h.IsHash64(), algo+" isn't actually a 64-bit hash")
var data []byte
var expected uint32
h.Write(data)
sum, ok := h.Sum32()
assert.BoolT(t, ok, algo+" should be able to return a Sum32")
assert.BoolT(t, expected == sum, fmt.Sprintf("%s returned the %d but expected %d", algo, sum, expected))
data = []byte("hello, world")
expected = 0xffab723a
h.Write(data)
sum, ok = h.Sum32()
assert.BoolT(t, ok, algo+" should be able to return a Sum32")
assert.BoolT(t, expected == sum, fmt.Sprintf("%s returned the %d but expected %d", algo, sum, expected))
h.Reset()
data = []byte("hello world")
h.Write(data)
sum, ok = h.Sum32()
assert.BoolT(t, ok, algo+" should be able to return a Sum32")
assert.BoolT(t, expected != sum, fmt.Sprintf("%s returned %d but shouldn't have", algo, sum))
}
func TestHash64(t *testing.T) {
algo := "crc64"
h, err := ahash.New(algo)
assert.NoErrorT(t, err)
assert.BoolT(t, !h.IsSecure(), algo+" shouldn't be a secure hash")
assert.BoolT(t, h.HashAlgo() == algo, "hash returned the wrong HashAlgo")
assert.BoolT(t, h.IsHash64(), algo+" is actually a 64-bit hash")
assert.BoolT(t, !h.IsHash32(), algo+" isn't actually a 32-bit hash")
var data []byte
var expected uint64
h.Write(data)
sum, ok := h.Sum64()
assert.BoolT(t, ok, algo+" should be able to return a Sum64")
assert.BoolT(t, expected == sum, fmt.Sprintf("%s returned the %d but expected %d", algo, sum, expected))
data = []byte("hello, world")
expected = 0x16c45c0eb1d9c2ec
h.Write(data)
sum, ok = h.Sum64()
assert.BoolT(t, ok, algo+" should be able to return a Sum64")
assert.BoolT(t, expected == sum, fmt.Sprintf("%s returned the %d but expected %d", algo, sum, expected))
h.Reset()
data = []byte("hello world")
h.Write(data)
sum, ok = h.Sum64()
assert.BoolT(t, ok, algo+" should be able to return a Sum64")
assert.BoolT(t, expected != sum, fmt.Sprintf("%s returned %d but shouldn't have", algo, sum))
}
func TestListLengthSanity(t *testing.T) {
all := ahash.HashList()
secure := ahash.SecureHashList()
insecure := ahash.InsecureHashList()
assert.BoolT(t, len(all) == len(secure)+len(insecure))
}
func TestSumLimitedReader(t *testing.T) {
data := bytes.NewBufferString("hello, world")
dataLen := data.Len()
extendedData := bytes.NewBufferString("hello, world! this is an extended message")
expected := "09ca7e4eaa6e8ae9c7d261167129184883644d07dfba7cbfbc4c8a2e08360d5b"
hash, err := ahash.SumReader("sha256", data)
assert.NoErrorT(t, err)
assert.BoolT(t, hex.EncodeToString(hash) == expected, fmt.Sprintf("have hash %x, want %s", hash, expected))
extendedHash, err := ahash.SumLimitedReader("sha256", extendedData, int64(dataLen))
assert.NoErrorT(t, err)
assert.BoolT(t, bytes.Equal(hash, extendedHash), fmt.Sprintf("have hash %x, want %x", extendedHash, hash))
}

View File

@@ -9,6 +9,7 @@
package assert
import (
"errors"
"fmt"
"os"
"runtime"
@@ -16,11 +17,13 @@ import (
"testing"
)
// NoDebug, if set to true, will cause all asserts to be ignored.
const callerSkip = 2
// NoDebug can be set to true to cause all asserts to be ignored.
var NoDebug bool
func die(what string, a ...string) {
_, file, line, ok := runtime.Caller(2)
_, file, line, ok := runtime.Caller(callerSkip)
if !ok {
panic(what)
}
@@ -31,30 +34,32 @@ func die(what string, a ...string) {
s = ": " + s
}
panic(what + s)
} else {
fmt.Fprintf(os.Stderr, "%s", what)
if len(a) > 0 {
s := strings.Join(a, ", ")
fmt.Fprintln(os.Stderr, ": "+s)
} else {
fmt.Fprintf(os.Stderr, "\n")
}
fmt.Fprintf(os.Stderr, "\t%s line %d\n", file, line)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "%s", what)
if len(a) > 0 {
s := strings.Join(a, ", ")
fmt.Fprintln(os.Stderr, ": "+s)
} else {
fmt.Fprintf(os.Stderr, "\n")
}
fmt.Fprintf(os.Stderr, "\t%s line %d\n", file, line)
os.Exit(1)
}
// Bool asserts that cond is false.
//
// For example, this would replace
// if x < 0 {
// log.Fatal("x is subzero")
// }
//
// if x < 0 {
// log.Fatal("x is subzero")
// }
//
// The same assertion would be
// assert.Bool(x, "x is subzero")
//
// assert.Bool(x, "x is subzero")
func Bool(cond bool, s ...string) {
if NoDebug {
return
@@ -68,11 +73,12 @@ func Bool(cond bool, s ...string) {
// Error asserts that err is not nil, e.g. that an error has occurred.
//
// For example,
// if err == nil {
// log.Fatal("call to <something> should have failed")
// }
// // becomes
// assert.Error(err, "call to <something> should have failed")
//
// if err == nil {
// log.Fatal("call to <something> should have failed")
// }
// // becomes
// assert.Error(err, "call to <something> should have failed")
func Error(err error, s ...string) {
if NoDebug {
return
@@ -94,13 +100,13 @@ func NoError(err error, s ...string) {
}
if nil != err {
die(err.Error())
die(err.Error(), s...)
}
}
// ErrorEq asserts that the actual error is the expected error.
func ErrorEq(expected, actual error) {
if NoDebug || (expected == actual) {
if NoDebug || (errors.Is(expected, actual)) {
return
}
@@ -155,7 +161,7 @@ func NoErrorT(t *testing.T, err error) {
// ErrorEqT compares a pair of errors, calling Fatal on it if they
// don't match.
func ErrorEqT(t *testing.T, expected, actual error) {
if NoDebug || (expected == actual) {
if NoDebug || (errors.Is(expected, actual)) {
return
}
@@ -170,5 +176,5 @@ func ErrorEqT(t *testing.T, expected, actual error) {
should = fmt.Sprintf("have '%s'", actual)
}
die(fmt.Sprintf("assert.Error2: expected '%s', but %s", expected, should))
t.Fatalf("assert.Error2: expected '%s', but %s", expected, should)
}

24
backoff/LICENSE Normal file
View File

@@ -0,0 +1,24 @@
Copyright (c) 2016 CloudFlare Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

83
backoff/README.md Normal file
View File

@@ -0,0 +1,83 @@
# backoff
## Go implementation of "Exponential Backoff And Jitter"
This package implements the backoff strategy described in the AWS
Architecture Blog article
["Exponential Backoff And Jitter"](http://www.awsarchitectureblog.com/2015/03/backoff.html). Essentially,
the backoff has an interval `time.Duration`; the *n<sup>th</sup>* call
to backoff will return an a `time.Duration` that is *2 <sup>n</sup> *
interval*. If jitter is enabled (which is the default behaviour), the
duration is a random value between 0 and *2 <sup>n</sup> * interval*.
The backoff is configured with a maximum duration that will not be
exceeded; e.g., by default, the longest duration returned is
`backoff.DefaultMaxDuration`.
## Usage
A `Backoff` is initialised with a call to `New`. Using zero values
causes it to use `DefaultMaxDuration` and `DefaultInterval` as the
maximum duration and interval.
```
package something
import "github.com/cloudflare/backoff"
func retryable() {
b := backoff.New(0, 0)
for {
err := someOperation()
if err == nil {
break
}
log.Printf("error in someOperation: %v", err)
<-time.After(b.Duration())
}
log.Printf("succeeded after %d tries", b.Tries()+1)
b.Reset()
}
```
It can also be used to rate limit code that should retry infinitely, but which does not
use `Backoff` itself.
```
package something
import (
"time"
"github.com/cloudflare/backoff"
)
func retryable() {
b := backoff.New(0, 0)
b.SetDecay(30 * time.Second)
for {
// b will reset if someOperation returns later than
// the last call to b.Duration() + 30s.
err := someOperation()
if err == nil {
break
}
log.Printf("error in someOperation: %v", err)
<-time.After(b.Duration())
}
}
```
## Tunables
* `NewWithoutJitter` creates a Backoff that doesn't use jitter.
The default behaviour is controlled by two variables:
* `DefaultInterval` sets the base interval for backoffs created with
the zero `time.Duration` value in the `Interval` field.
* `DefaultMaxDuration` sets the maximum duration for backoffs created
with the zero `time.Duration` value in the `MaxDuration` field.

184
backoff/backoff.go Normal file
View File

@@ -0,0 +1,184 @@
// Package backoff contains an implementation of an intelligent backoff
// strategy. It is based on the approach in the AWS architecture blog
// article titled "Exponential Backoff And Jitter", which is found at
// http://www.awsarchitectureblog.com/2015/03/backoff.html.
//
// Essentially, the backoff has an interval `time.Duration`; the nth
// call to backoff will return a `time.Duration` that is 2^n *
// interval. If jitter is enabled (which is the default behaviour),
// the duration is a random value between 0 and 2^n * interval. The
// backoff is configured with a maximum duration that will not be
// exceeded.
//
// This package uses math/rand/v2 for jitter, which is automatically
// seeded from a cryptographically secure source.
package backoff
import (
"math"
"math/rand/v2"
"time"
)
// DefaultInterval is used when a Backoff is initialised with a
// zero-value Interval.
var DefaultInterval = 5 * time.Minute
// DefaultMaxDuration is the maximum amount of time that the backoff will
// delay for.
var DefaultMaxDuration = 6 * time.Hour
// A Backoff contains the information needed to intelligently backoff
// and retry operations using an exponential backoff algorithm. It should
// be initialised with a call to `New`.
//
// Only use a Backoff from a single goroutine, it is not safe for concurrent
// access.
type Backoff struct {
// maxDuration is the largest possible duration that can be
// returned from a call to Duration.
maxDuration time.Duration
// interval controls the time step for backing off.
interval time.Duration
// noJitter controls whether to use the "Full Jitter" improvement to attempt
// to smooth out spikes in a high-contention scenario. If noJitter is set to
// true, no jitter will be introduced.
noJitter bool
// decay controls the decay of n. If it is non-zero, n is
// reset if more than the last backoff + decay has elapsed since
// the last try.
decay time.Duration
n uint64
lastTry time.Time
}
// New creates a new backoff with the specified maxDuration duration and
// interval. Zero values may be used to use the default values.
//
// Panics if either dMax or interval is negative.
func New(dMax time.Duration, interval time.Duration) *Backoff {
if dMax < 0 || interval < 0 {
panic("backoff: dMax or interval is negative")
}
b := &Backoff{
maxDuration: dMax,
interval: interval,
}
b.setup()
return b
}
// NewWithoutJitter works similarly to New, except that the created
// Backoff will not use jitter.
func NewWithoutJitter(dMax time.Duration, interval time.Duration) *Backoff {
b := New(dMax, interval)
b.noJitter = true
return b
}
func (b *Backoff) setup() {
if b.interval == 0 {
b.interval = DefaultInterval
}
if b.maxDuration == 0 {
b.maxDuration = DefaultMaxDuration
}
}
// Duration returns a time.Duration appropriate for the backoff,
// incrementing the attempt counter.
func (b *Backoff) Duration() time.Duration {
b.setup()
b.decayN()
d := b.duration(b.n)
if b.n < math.MaxUint64 {
b.n++
}
if !b.noJitter {
d = time.Duration(rand.Int64N(int64(d))) // #nosec G404
}
return d
}
const maxN uint64 = 63
// requires b to be locked.
func (b *Backoff) duration(n uint64) time.Duration {
// Use left shift on the underlying integer representation to avoid
// multiplying time.Duration by time.Duration (which is semantically
// incorrect and flagged by linters).
if n >= maxN {
// Saturate when n would overflow a 64-bit shift or exceed maxDuration.
return b.maxDuration
}
// Calculate 2^n * interval using a shift. Detect overflow by checking
// for sign change or monotonicity loss and clamp to maxDuration.
shifted := b.interval << n
if shifted < 0 || shifted < b.interval {
// Overflow occurred during the shift; clamp to maxDuration.
return b.maxDuration
}
if shifted > b.maxDuration {
return b.maxDuration
}
return shifted
}
// Reset resets the attempt counter of a backoff.
//
// It should be called when the rate-limited action succeeds.
func (b *Backoff) Reset() {
b.lastTry = time.Time{}
b.n = 0
}
// SetDecay sets the duration after which the try counter will be reset.
// Panics if decay is smaller than 0.
//
// The decay only kicks in if at least the last backoff + decay has elapsed
// since the last try.
func (b *Backoff) SetDecay(decay time.Duration) {
if decay < 0 {
panic("backoff: decay < 0")
}
b.decay = decay
}
// requires b to be locked.
func (b *Backoff) decayN() {
if b.decay == 0 {
return
}
if b.lastTry.IsZero() {
b.lastTry = time.Now()
return
}
lastDuration := b.duration(b.n - 1)
// Reset when the elapsed time is at least the previous backoff plus decay.
// Using ">=" avoids boundary flakiness in tests and real usage.
decayed := time.Since(b.lastTry) >= lastDuration+b.decay
b.lastTry = time.Now()
if !decayed {
return
}
b.n = 0
}

179
backoff/backoff_test.go Normal file
View File

@@ -0,0 +1,179 @@
package backoff
import (
"fmt"
"math"
"testing"
"time"
)
// If given New with 0's and no jitter, ensure that certain invariants are met:
//
// - the default maxDuration duration and interval should be used
// - noJitter should be true
// - the RNG should not be initialised
// - the first duration should be equal to the default interval
func TestDefaults(t *testing.T) {
b := NewWithoutJitter(0, 0)
if b.maxDuration != DefaultMaxDuration {
t.Fatalf(
"expected new backoff to use the default maxDuration duration (%s), but have %s",
DefaultMaxDuration,
b.maxDuration,
)
}
if b.interval != DefaultInterval {
t.Fatalf("exepcted new backoff to use the default interval (%s), but have %s", DefaultInterval, b.interval)
}
if b.noJitter != true {
t.Fatal("backoff should have been initialised without jitter")
}
dur := b.Duration()
if dur != DefaultInterval {
t.Fatalf("expected first duration to be %s, have %s", DefaultInterval, dur)
}
}
// Given a zero-value initialised Backoff, it should be transparently
// setup.
func TestSetup(t *testing.T) {
b := new(Backoff)
dur := b.Duration()
if dur < 0 || dur > (5*time.Minute) {
t.Fatalf("want duration between 0 and 5 minutes, have %s", dur)
}
}
// Ensure that tries increments as expected.
func TestTries(t *testing.T) {
b := NewWithoutJitter(5, 1)
for i := range uint64(3) {
if b.n != i {
t.Fatalf("want tries=%d, have tries=%d", i, b.n)
}
pow := 1 << i
expected := time.Duration(pow)
dur := b.Duration()
if dur != expected {
t.Fatalf("want duration=%d, have duration=%d at i=%d", expected, dur, i)
}
}
for i := uint(3); i < 5; i++ {
dur := b.Duration()
if dur != 5 {
t.Fatalf("want duration=5, have %d at i=%d", dur, i)
}
}
}
// Ensure that a call to Reset will actually reset the Backoff.
func TestReset(t *testing.T) {
const iter = 10
b := New(1000, 1)
for range iter {
_ = b.Duration()
}
if b.n != iter {
t.Fatalf("expected tries=%d, have tries=%d", iter, b.n)
}
b.Reset()
if b.n != 0 {
t.Fatalf("expected tries=0 after reset, have tries=%d", b.n)
}
}
const decay = 25 * time.Millisecond
const maxDuration = 10 * time.Millisecond
const interval = time.Millisecond
func TestDecay(t *testing.T) {
const iter = 10
b := NewWithoutJitter(maxDuration, 1)
b.SetDecay(decay)
var backoff time.Duration
for range iter {
backoff = b.Duration()
}
if b.n != iter {
t.Fatalf("expected tries=%d, have tries=%d", iter, b.n)
}
// Don't decay below backoff
b.lastTry = time.Now().Add(-backoff + 1)
backoff = b.Duration()
if b.n != iter+1 {
t.Fatalf("expected tries=%d, have tries=%d", iter+1, b.n)
}
// Reset after backoff + decay
b.lastTry = time.Now().Add(-backoff - decay)
b.Duration()
if b.n != 1 {
t.Fatalf("expected tries=%d, have tries=%d", 1, b.n)
}
}
// Ensure that decay works even if the retry counter is saturated.
func TestDecaySaturation(t *testing.T) {
b := NewWithoutJitter(1<<2, 1)
b.SetDecay(decay)
var duration time.Duration
for range 3 {
duration = b.Duration()
}
if duration != 1<<2 {
t.Fatalf("expected duration=%v, have duration=%v", 1<<2, duration)
}
b.lastTry = time.Now().Add(-duration - decay)
b.n = math.MaxUint64
duration = b.Duration()
if duration != 1 {
t.Errorf("expected duration=%v, have duration=%v", 1, duration)
}
}
func ExampleBackoff_SetDecay() {
b := NewWithoutJitter(maxDuration, interval)
b.SetDecay(decay)
// try 0
fmt.Println(b.Duration())
// try 1
fmt.Println(b.Duration())
// try 2
duration := b.Duration()
fmt.Println(duration)
// try 3, below decay
time.Sleep(duration)
duration = b.Duration()
fmt.Println(duration)
// try 4, resets
time.Sleep(duration + decay)
fmt.Println(b.Duration())
// Output: 1ms
// 2ms
// 4ms
// 8ms
// 1ms
}

179
cache/lru/lru.go vendored Normal file
View File

@@ -0,0 +1,179 @@
// Package lru implements a Least Recently Used cache.
package lru
import (
"errors"
"fmt"
"sort"
"sync"
"github.com/benbjohnson/clock"
)
type item[V any] struct {
V V
access int64
}
// A Cache is a map that retains a limited number of items. It must be
// initialized with New, providing a maximum capacity for the cache.
// Only the least recently used items are retained.
type Cache[K comparable, V any] struct {
store map[K]*item[V]
access *timestamps[K]
cap int
clock clock.Clock
// All public methods that have the possibility of modifying the
// cache should lock it.
mtx *sync.Mutex
}
// New must be used to create a new Cache.
func New[K comparable, V any](icap int) *Cache[K, V] {
return &Cache[K, V]{
store: map[K]*item[V]{},
access: newTimestamps[K](icap),
cap: icap,
clock: clock.New(),
mtx: &sync.Mutex{},
}
}
// StringKeyCache is a convenience wrapper for cache keyed by string.
type StringKeyCache[V any] struct {
*Cache[string, V]
}
// NewStringKeyCache creates a new LRU cache keyed by string.
func NewStringKeyCache[V any](icap int) *StringKeyCache[V] {
return &StringKeyCache[V]{Cache: New[string, V](icap)}
}
func (c *Cache[K, V]) lock() {
c.mtx.Lock()
}
func (c *Cache[K, V]) unlock() {
c.mtx.Unlock()
}
// Len returns the number of items currently in the cache.
func (c *Cache[K, V]) Len() int {
return len(c.store)
}
// evict should remove the least-recently-used cache item.
func (c *Cache[K, V]) evict() {
if c.access.Len() == 0 {
return
}
k := c.access.K(0)
c.evictKey(k)
}
// evictKey should remove the entry given by the key item.
func (c *Cache[K, V]) evictKey(k K) {
delete(c.store, k)
i, ok := c.access.Find(k)
if !ok {
return
}
c.access.Delete(i)
}
func (c *Cache[K, V]) sanityCheck() {
if len(c.store) != c.access.Len() {
panic(fmt.Sprintf("LRU cache is out of sync; store len = %d, access len = %d",
len(c.store), c.access.Len()))
}
}
// ConsistencyCheck runs a series of checks to ensure that the cache's
// data structures are consistent. It is not normally required, and it
// is primarily used in testing.
func (c *Cache[K, V]) ConsistencyCheck() error {
c.lock()
defer c.unlock()
if err := c.access.ConsistencyCheck(); err != nil {
return err
}
if len(c.store) != c.access.Len() {
return fmt.Errorf("lru: cache is out of sync; store len = %d, access len = %d",
len(c.store), c.access.Len())
}
for i := range c.access.ts {
itm, ok := c.store[c.access.K(i)]
if !ok {
return errors.New("lru: key in access is not in store")
}
if c.access.T(i) != itm.access {
return fmt.Errorf("timestamps are out of sync (%d != %d)",
itm.access, c.access.T(i))
}
}
if !sort.IsSorted(c.access) {
return errors.New("lru: timestamps aren't sorted")
}
return nil
}
// Store adds the value v to the cache under the k.
func (c *Cache[K, V]) Store(k K, v V) {
c.lock()
defer c.unlock()
c.sanityCheck()
if len(c.store) == c.cap {
c.evict()
}
if _, ok := c.store[k]; ok {
c.evictKey(k)
}
itm := &item[V]{
V: v,
access: c.clock.Now().UnixNano(),
}
c.store[k] = itm
c.access.Update(k, itm.access)
}
// Get returns the value stored in the cache. If the item isn't present,
// it will return false.
func (c *Cache[K, V]) Get(k K) (V, bool) {
c.lock()
defer c.unlock()
c.sanityCheck()
itm, ok := c.store[k]
if !ok {
var zero V
return zero, false
}
c.store[k].access = c.clock.Now().UnixNano()
c.access.Update(k, itm.access)
return itm.V, true
}
// Has returns true if the cache has an entry for k. It will not update
// the timestamp on the item.
func (c *Cache[K, V]) Has(k K) bool {
// Don't need to lock as we don't modify anything.
c.sanityCheck()
_, ok := c.store[k]
return ok
}

87
cache/lru/lru_internal_test.go vendored Normal file
View File

@@ -0,0 +1,87 @@
package lru
import (
"testing"
"time"
"github.com/benbjohnson/clock"
)
// These tests mirror the MRU-style behavior present in this LRU package
// implementation (eviction removes the most-recently-used entry).
func TestBasicCacheEviction(t *testing.T) {
mock := clock.NewMock()
c := NewStringKeyCache[int](2)
c.clock = mock
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
if c.Len() != 0 {
t.Fatal("cache should have size 0")
}
c.evict()
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
c.Store("raven", 1)
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
if len(c.store) != 1 {
t.Fatalf("store should have length=1, have length=%d", len(c.store))
}
mock.Add(time.Second)
c.Store("owl", 2)
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
if len(c.store) != 2 {
t.Fatalf("store should have length=2, have length=%d", len(c.store))
}
mock.Add(time.Second)
c.Store("goat", 3)
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
if len(c.store) != 2 {
t.Fatalf("store should have length=2, have length=%d", len(c.store))
}
// Since this implementation evicts the most-recently-used item, inserting
// "goat" when full evicts "owl" (the most recent at that time).
mock.Add(time.Second)
if _, ok := c.Get("owl"); ok {
t.Fatal("store should not have an entry for owl (MRU-evicted)")
}
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
mock.Add(time.Second)
c.Store("elk", 4)
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
if !c.Has("elk") {
t.Fatal("store should contain an entry for 'elk'")
}
// Before storing elk, keys were: raven (older), goat (newer). Evict MRU -> goat.
if !c.Has("raven") {
t.Fatal("store should contain an entry for 'raven'")
}
if c.Has("goat") {
t.Fatal("store should not contain an entry for 'goat'")
}
}

101
cache/lru/timestamps.go vendored Normal file
View File

@@ -0,0 +1,101 @@
package lru
import (
"errors"
"fmt"
"io"
"sort"
)
// timestamps contains datastructures for maintaining a list of keys sortable
// by timestamp.
type timestamp[K comparable] struct {
t int64
k K
}
type timestamps[K comparable] struct {
ts []timestamp[K]
cap int
}
func newTimestamps[K comparable](icap int) *timestamps[K] {
return &timestamps[K]{
ts: make([]timestamp[K], 0, icap),
cap: icap,
}
}
func (ts *timestamps[K]) K(i int) K {
return ts.ts[i].k
}
func (ts *timestamps[K]) T(i int) int64 {
return ts.ts[i].t
}
func (ts *timestamps[K]) Len() int {
return len(ts.ts)
}
func (ts *timestamps[K]) Less(i, j int) bool {
return ts.ts[i].t > ts.ts[j].t
}
func (ts *timestamps[K]) Swap(i, j int) {
ts.ts[i], ts.ts[j] = ts.ts[j], ts.ts[i]
}
func (ts *timestamps[K]) Find(k K) (int, bool) {
for i := range ts.ts {
if ts.ts[i].k == k {
return i, true
}
}
return -1, false
}
func (ts *timestamps[K]) Update(k K, t int64) bool {
i, ok := ts.Find(k)
if !ok {
ts.ts = append(ts.ts, timestamp[K]{t, k})
sort.Sort(ts)
return false
}
ts.ts[i].t = t
sort.Sort(ts)
return true
}
func (ts *timestamps[K]) ConsistencyCheck() error {
if !sort.IsSorted(ts) {
return errors.New("lru: timestamps are not sorted")
}
keys := map[K]bool{}
for i := range ts.ts {
if keys[ts.ts[i].k] {
return fmt.Errorf("lru: duplicate key %v detected", ts.ts[i].k)
}
keys[ts.ts[i].k] = true
}
if len(keys) != len(ts.ts) {
return fmt.Errorf("lru: timestamp contains %d duplicate keys",
len(ts.ts)-len(keys))
}
return nil
}
func (ts *timestamps[K]) Delete(i int) {
ts.ts = append(ts.ts[:i], ts.ts[i+1:]...)
}
func (ts *timestamps[K]) Dump(w io.Writer) {
for i := range ts.ts {
fmt.Fprintf(w, "%d: %v, %d\n", i, ts.K(i), ts.T(i))
}
}

50
cache/lru/timestamps_internal_test.go vendored Normal file
View File

@@ -0,0 +1,50 @@
package lru
import (
"testing"
"time"
"github.com/benbjohnson/clock"
)
// These tests validate timestamps ordering semantics for the LRU package.
// Note: The LRU timestamps are sorted with most-recent-first (descending by t).
func TestTimestamps(t *testing.T) {
ts := newTimestamps[string](3)
mock := clock.NewMock()
// raven
ts.Update("raven", mock.Now().UnixNano())
// raven, owl
mock.Add(time.Millisecond)
ts.Update("owl", mock.Now().UnixNano())
// raven, owl, goat
mock.Add(time.Second)
ts.Update("goat", mock.Now().UnixNano())
if err := ts.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
// make owl the most recent
mock.Add(time.Millisecond)
ts.Update("owl", mock.Now().UnixNano())
if err := ts.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
// For LRU timestamps: most recent first. Expected order: owl, goat, raven.
if ts.K(0) != "owl" {
t.Fatalf("first key should be owl, have %s", ts.K(0))
}
if ts.K(1) != "goat" {
t.Fatalf("second key should be goat, have %s", ts.K(1))
}
if ts.K(2) != "raven" {
t.Fatalf("third key should be raven, have %s", ts.K(2))
}
}

178
cache/mru/mru.go vendored Normal file
View File

@@ -0,0 +1,178 @@
package mru
import (
"errors"
"fmt"
"sort"
"sync"
"github.com/benbjohnson/clock"
)
type item[V any] struct {
V V
access int64
}
// A Cache is a map that retains a limited number of items. It must be
// initialized with New, providing a maximum capacity for the cache.
// Only the most recently used items are retained.
type Cache[K comparable, V any] struct {
store map[K]*item[V]
access *timestamps[K]
cap int
clock clock.Clock
// All public methods that have the possibility of modifying the
// cache should lock it.
mtx *sync.Mutex
}
// New must be used to create a new Cache.
func New[K comparable, V any](icap int) *Cache[K, V] {
return &Cache[K, V]{
store: map[K]*item[V]{},
access: newTimestamps[K](icap),
cap: icap,
clock: clock.New(),
mtx: &sync.Mutex{},
}
}
// StringKeyCache is a convenience wrapper for cache keyed by string.
type StringKeyCache[V any] struct {
*Cache[string, V]
}
// NewStringKeyCache creates a new MRU cache keyed by string.
func NewStringKeyCache[V any](icap int) *StringKeyCache[V] {
return &StringKeyCache[V]{Cache: New[string, V](icap)}
}
func (c *Cache[K, V]) lock() {
c.mtx.Lock()
}
func (c *Cache[K, V]) unlock() {
c.mtx.Unlock()
}
// Len returns the number of items currently in the cache.
func (c *Cache[K, V]) Len() int {
return len(c.store)
}
// evict should remove the least-recently-used cache item.
func (c *Cache[K, V]) evict() {
if c.access.Len() == 0 {
return
}
k := c.access.K(0)
c.evictKey(k)
}
// evictKey should remove the entry given by the key item.
func (c *Cache[K, V]) evictKey(k K) {
delete(c.store, k)
i, ok := c.access.Find(k)
if !ok {
return
}
c.access.Delete(i)
}
func (c *Cache[K, V]) sanityCheck() {
if len(c.store) != c.access.Len() {
panic(fmt.Sprintf("MRU cache is out of sync; store len = %d, access len = %d",
len(c.store), c.access.Len()))
}
}
// ConsistencyCheck runs a series of checks to ensure that the cache's
// data structures are consistent. It is not normally required, and it
// is primarily used in testing.
func (c *Cache[K, V]) ConsistencyCheck() error {
c.lock()
defer c.unlock()
if err := c.access.ConsistencyCheck(); err != nil {
return err
}
if len(c.store) != c.access.Len() {
return fmt.Errorf("mru: cache is out of sync; store len = %d, access len = %d",
len(c.store), c.access.Len())
}
for i := range c.access.ts {
itm, ok := c.store[c.access.K(i)]
if !ok {
return errors.New("mru: key in access is not in store")
}
if c.access.T(i) != itm.access {
return fmt.Errorf("timestamps are out of sync (%d != %d)",
itm.access, c.access.T(i))
}
}
if !sort.IsSorted(c.access) {
return errors.New("mru: timestamps aren't sorted")
}
return nil
}
// Store adds the value v to the cache under the k.
func (c *Cache[K, V]) Store(k K, v V) {
c.lock()
defer c.unlock()
c.sanityCheck()
if len(c.store) == c.cap {
c.evict()
}
if _, ok := c.store[k]; ok {
c.evictKey(k)
}
itm := &item[V]{
V: v,
access: c.clock.Now().UnixNano(),
}
c.store[k] = itm
c.access.Update(k, itm.access)
}
// Get returns the value stored in the cache. If the item isn't present,
// it will return false.
func (c *Cache[K, V]) Get(k K) (V, bool) {
c.lock()
defer c.unlock()
c.sanityCheck()
itm, ok := c.store[k]
if !ok {
var zero V
return zero, false
}
c.store[k].access = c.clock.Now().UnixNano()
c.access.Update(k, itm.access)
return itm.V, true
}
// Has returns true if the cache has an entry for k. It will not update
// the timestamp on the item.
func (c *Cache[K, V]) Has(k K) bool {
// Don't need to lock as we don't modify anything.
c.sanityCheck()
_, ok := c.store[k]
return ok
}

92
cache/mru/mru_internal_test.go vendored Normal file
View File

@@ -0,0 +1,92 @@
package mru
import (
"testing"
"time"
"github.com/benbjohnson/clock"
)
func TestBasicCacheEviction(t *testing.T) {
mock := clock.NewMock()
c := NewStringKeyCache[int](2)
c.clock = mock
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
if c.Len() != 0 {
t.Fatal("cache should have size 0")
}
c.evict()
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
c.Store("raven", 1)
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
if len(c.store) != 1 {
t.Fatalf("store should have length=1, have length=%d", len(c.store))
}
mock.Add(time.Second)
c.Store("owl", 2)
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
if len(c.store) != 2 {
t.Fatalf("store should have length=2, have length=%d", len(c.store))
}
mock.Add(time.Second)
c.Store("goat", 3)
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
if len(c.store) != 2 {
t.Fatalf("store should have length=2, have length=%d", len(c.store))
}
mock.Add(time.Second)
v, ok := c.Get("owl")
if !ok {
t.Fatal("store should have an entry for owl")
}
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
itm := v
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
if itm != 2 {
t.Fatalf("stored item should be 2, have %d", itm)
}
mock.Add(time.Second)
c.Store("elk", 4)
if err := c.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
if !c.Has("elk") {
t.Fatal("store should contain an entry for 'elk'")
}
if !c.Has("owl") {
t.Fatal("store should contain an entry for 'owl'")
}
if c.Has("goat") {
t.Fatal("store should not contain an entry for 'goat'")
}
}

101
cache/mru/timestamps.go vendored Normal file
View File

@@ -0,0 +1,101 @@
package mru
import (
"errors"
"fmt"
"io"
"sort"
)
// timestamps contains datastructures for maintaining a list of keys sortable
// by timestamp.
type timestamp[K comparable] struct {
t int64
k K
}
type timestamps[K comparable] struct {
ts []timestamp[K]
cap int
}
func newTimestamps[K comparable](icap int) *timestamps[K] {
return &timestamps[K]{
ts: make([]timestamp[K], 0, icap),
cap: icap,
}
}
func (ts *timestamps[K]) K(i int) K {
return ts.ts[i].k
}
func (ts *timestamps[K]) T(i int) int64 {
return ts.ts[i].t
}
func (ts *timestamps[K]) Len() int {
return len(ts.ts)
}
func (ts *timestamps[K]) Less(i, j int) bool {
return ts.ts[i].t < ts.ts[j].t
}
func (ts *timestamps[K]) Swap(i, j int) {
ts.ts[i], ts.ts[j] = ts.ts[j], ts.ts[i]
}
func (ts *timestamps[K]) Find(k K) (int, bool) {
for i := range ts.ts {
if ts.ts[i].k == k {
return i, true
}
}
return -1, false
}
func (ts *timestamps[K]) Update(k K, t int64) bool {
i, ok := ts.Find(k)
if !ok {
ts.ts = append(ts.ts, timestamp[K]{t, k})
sort.Sort(ts)
return false
}
ts.ts[i].t = t
sort.Sort(ts)
return true
}
func (ts *timestamps[K]) ConsistencyCheck() error {
if !sort.IsSorted(ts) {
return errors.New("mru: timestamps are not sorted")
}
keys := map[K]bool{}
for i := range ts.ts {
if keys[ts.ts[i].k] {
return fmt.Errorf("duplicate key %v detected", ts.ts[i].k)
}
keys[ts.ts[i].k] = true
}
if len(keys) != len(ts.ts) {
return fmt.Errorf("mru: timestamp contains %d duplicate keys",
len(ts.ts)-len(keys))
}
return nil
}
func (ts *timestamps[K]) Delete(i int) {
ts.ts = append(ts.ts[:i], ts.ts[i+1:]...)
}
func (ts *timestamps[K]) Dump(w io.Writer) {
for i := range ts.ts {
fmt.Fprintf(w, "%d: %v, %d\n", i, ts.K(i), ts.T(i))
}
}

49
cache/mru/timestamps_internal_test.go vendored Normal file
View File

@@ -0,0 +1,49 @@
package mru
import (
"testing"
"time"
"github.com/benbjohnson/clock"
)
func TestTimestamps(t *testing.T) {
ts := newTimestamps[string](3)
mock := clock.NewMock()
// raven
ts.Update("raven", mock.Now().UnixNano())
// raven, owl
mock.Add(time.Millisecond)
ts.Update("owl", mock.Now().UnixNano())
// raven, owl, goat
mock.Add(time.Second)
ts.Update("goat", mock.Now().UnixNano())
if err := ts.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
mock.Add(time.Millisecond)
// raven, goat, owl
ts.Update("owl", mock.Now().UnixNano())
if err := ts.ConsistencyCheck(); err != nil {
t.Fatal(err)
}
// at this point, the keys should be raven, goat, owl.
if ts.K(0) != "raven" {
t.Fatalf("first key should be raven, have %s", ts.K(0))
}
if ts.K(1) != "goat" {
t.Fatalf("second key should be goat, have %s", ts.K(1))
}
if ts.K(2) != "owl" {
t.Fatalf("third key should be owl, have %s", ts.K(2))
}
}

33
certlib/certerr/doc.go Normal file
View File

@@ -0,0 +1,33 @@
// Package certerr provides typed errors and helpers for certificate-related
// operations across the repository. It standardizes error construction and
// matching so callers can reliably branch on error source/kind using the
// Go 1.13+ `errors.Is` and `errors.As` helpers.
//
// Guidelines
// - Always wrap underlying causes using the helper constructors or with
// fmt.Errorf("context: %w", err).
// - Do not include sensitive data (keys, passwords, tokens) in error
// messages; add only non-sensitive, actionable context.
// - Prefer programmatic checks via errors.Is (for sentinel errors) and
// errors.As (to retrieve *certerr.Error) rather than relying on error
// string contents.
//
// Typical usage
//
// if err := doParse(); err != nil {
// return certerr.ParsingError(certerr.ErrorSourceCertificate, err)
// }
//
// Callers may branch on error kinds and sources:
//
// var e *certerr.Error
// if errors.As(err, &e) {
// switch e.Kind {
// case certerr.KindParse:
// // handle parse error
// }
// }
//
// Sentinel errors are provided for common conditions like
// `certerr.ErrEncryptedPrivateKey` and can be matched with `errors.Is`.
package certerr

120
certlib/certerr/errors.go Normal file
View File

@@ -0,0 +1,120 @@
package certerr
import (
"errors"
"fmt"
"strings"
)
// ErrEmptyCertificate indicates that a certificate could not be processed
// because there was no data to process.
var ErrEmptyCertificate = errors.New("certlib: empty certificate")
type ErrorSourceType uint8
func (t ErrorSourceType) String() string {
switch t {
case ErrorSourceCertificate:
return "certificate"
case ErrorSourcePrivateKey:
return "private key"
case ErrorSourceCSR:
return "CSR"
case ErrorSourceSCTList:
return "SCT list"
case ErrorSourceKeypair:
return "TLS keypair"
default:
panic(fmt.Sprintf("unknown error source %d", t))
}
}
const (
ErrorSourceCertificate ErrorSourceType = 1
ErrorSourcePrivateKey ErrorSourceType = 2
ErrorSourceCSR ErrorSourceType = 3
ErrorSourceSCTList ErrorSourceType = 4
ErrorSourceKeypair ErrorSourceType = 5
)
// ErrorKind is a broad classification describing what went wrong.
type ErrorKind uint8
const (
KindParse ErrorKind = iota + 1
KindDecode
KindVerify
KindLoad
)
func (k ErrorKind) String() string {
switch k {
case KindParse:
return "parse"
case KindDecode:
return "decode"
case KindVerify:
return "verify"
case KindLoad:
return "load"
default:
return "unknown"
}
}
// Error is a typed, wrapped error with structured context for programmatic checks.
// It implements error and supports errors.Is/As via Unwrap.
type Error struct {
Source ErrorSourceType // which domain produced the error (certificate, private key, etc.)
Kind ErrorKind // operation category (parse, decode, verify, load)
Op string // optional operation or function name
Err error // wrapped cause
}
func (e *Error) Error() string {
// Keep message format consistent with existing helpers: "failed to <kind> <source>: <err>"
// Do not include Op by default to preserve existing output expectations.
return fmt.Sprintf("failed to %s %s: %v", e.Kind.String(), e.Source.String(), e.Err)
}
func (e *Error) Unwrap() error { return e.Err }
// InvalidPEMTypeError is used to indicate that we were expecting one type of PEM
// file, but saw another.
type InvalidPEMTypeError struct {
have string
want []string
}
func (err *InvalidPEMTypeError) Error() string {
if len(err.want) == 1 {
return fmt.Sprintf("invalid PEM type: have %s, expected %s", err.have, err.want[0])
}
return fmt.Sprintf("invalid PEM type: have %s, expected one of %s", err.have, strings.Join(err.want, ", "))
}
// ErrInvalidPEMType returns a new InvalidPEMTypeError error.
func ErrInvalidPEMType(have string, want ...string) error {
return &InvalidPEMTypeError{
have: have,
want: want,
}
}
func LoadingError(t ErrorSourceType, err error) error {
return &Error{Source: t, Kind: KindLoad, Err: err}
}
func ParsingError(t ErrorSourceType, err error) error {
return &Error{Source: t, Kind: KindParse, Err: err}
}
func DecodeError(t ErrorSourceType, err error) error {
return &Error{Source: t, Kind: KindDecode, Err: err}
}
func VerifyError(t ErrorSourceType, err error) error {
return &Error{Source: t, Kind: KindVerify, Err: err}
}
var ErrEncryptedPrivateKey = errors.New("private key is encrypted")

View File

@@ -0,0 +1,56 @@
//nolint:testpackage // keep tests in the same package for internal symbol access
package certerr
import (
"errors"
"strings"
"testing"
)
func TestTypedErrorWrappingAndFormatting(t *testing.T) {
cause := errors.New("bad data")
err := DecodeError(ErrorSourceCertificate, cause)
// Ensure we can retrieve the typed error
var e *Error
if !errors.As(err, &e) {
t.Fatalf("expected errors.As to retrieve *certerr.Error, got %T", err)
}
if e.Kind != KindDecode {
t.Fatalf("unexpected kind: %v", e.Kind)
}
if e.Source != ErrorSourceCertificate {
t.Fatalf("unexpected source: %v", e.Source)
}
// Check message format (no trailing punctuation enforced by content)
msg := e.Error()
if !strings.Contains(msg, "failed to decode certificate") || !strings.Contains(msg, "bad data") {
t.Fatalf("unexpected error message: %q", msg)
}
}
func TestErrorsIsOnWrappedSentinel(t *testing.T) {
err := DecodeError(ErrorSourcePrivateKey, ErrEncryptedPrivateKey)
if !errors.Is(err, ErrEncryptedPrivateKey) {
t.Fatalf("expected errors.Is to match ErrEncryptedPrivateKey")
}
}
func TestInvalidPEMTypeMessageSingle(t *testing.T) {
err := ErrInvalidPEMType("FOO", "CERTIFICATE")
want := "invalid PEM type: have FOO, expected CERTIFICATE"
if err.Error() != want {
t.Fatalf("unexpected error message: got %q, want %q", err.Error(), want)
}
}
func TestInvalidPEMTypeMessageMultiple(t *testing.T) {
err := ErrInvalidPEMType("FOO", "CERTIFICATE", "NEW CERTIFICATE REQUEST")
if !strings.Contains(
err.Error(),
"invalid PEM type: have FOO, expected one of CERTIFICATE, NEW CERTIFICATE REQUEST",
) {
t.Fatalf("unexpected error message: %q", err.Error())
}
}

95
certlib/certlib.go Normal file
View File

@@ -0,0 +1,95 @@
package certlib
import (
"crypto/x509"
"encoding/pem"
"errors"
"os"
"git.wntrmute.dev/kyle/goutils/certlib/certerr"
)
// ReadCertificate reads a DER or PEM-encoded certificate from the
// byte slice.
func ReadCertificate(in []byte) (*x509.Certificate, []byte, error) {
if len(in) == 0 {
return nil, nil, certerr.ParsingError(certerr.ErrorSourceCertificate, certerr.ErrEmptyCertificate)
}
if in[0] == '-' {
p, remaining := pem.Decode(in)
if p == nil {
return nil, nil, certerr.ParsingError(certerr.ErrorSourceCertificate, errors.New("invalid PEM file"))
}
rest := remaining
if p.Type != "CERTIFICATE" {
return nil, rest, certerr.ParsingError(
certerr.ErrorSourceCertificate,
certerr.ErrInvalidPEMType(p.Type, "CERTIFICATE"),
)
}
in = p.Bytes
cert, err := x509.ParseCertificate(in)
if err != nil {
return nil, rest, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
return cert, rest, nil
}
cert, err := x509.ParseCertificate(in)
if err != nil {
return nil, nil, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
return cert, nil, nil
}
// ReadCertificates tries to read all the certificates in a
// PEM-encoded collection.
func ReadCertificates(in []byte) ([]*x509.Certificate, error) {
var cert *x509.Certificate
var certs []*x509.Certificate
var err error
for {
cert, in, err = ReadCertificate(in)
if err != nil {
break
}
if cert == nil {
break
}
certs = append(certs, cert)
if len(in) == 0 {
break
}
}
return certs, err
}
// LoadCertificate tries to read a single certificate from disk. If
// the file contains multiple certificates (e.g. a chain), only the
// first certificate is returned.
func LoadCertificate(path string) (*x509.Certificate, error) {
in, err := os.ReadFile(path)
if err != nil {
return nil, certerr.LoadingError(certerr.ErrorSourceCertificate, err)
}
cert, _, err := ReadCertificate(in)
return cert, err
}
// LoadCertificates tries to read all the certificates in a file,
// returning them in the order that it found them in the file.
func LoadCertificates(path string) ([]*x509.Certificate, error) {
in, err := os.ReadFile(path)
if err != nil {
return nil, certerr.LoadingError(certerr.ErrorSourceCertificate, err)
}
return ReadCertificates(in)
}

140
certlib/certlib_test.go Normal file
View File

@@ -0,0 +1,140 @@
//nolint:testpackage // keep tests in the same package for internal symbol access
package certlib
import (
"fmt"
"testing"
"git.wntrmute.dev/kyle/goutils/assert"
)
// some CA certs I found on my computerbox.
var testCerts = `-----BEGIN CERTIFICATE-----
MIIH0zCCBbugAwIBAgIIXsO3pkN/pOAwDQYJKoZIhvcNAQEFBQAwQjESMBAGA1UE
AwwJQUNDVlJBSVoxMRAwDgYDVQQLDAdQS0lBQ0NWMQ0wCwYDVQQKDARBQ0NWMQsw
CQYDVQQGEwJFUzAeFw0xMTA1MDUwOTM3MzdaFw0zMDEyMzEwOTM3MzdaMEIxEjAQ
BgNVBAMMCUFDQ1ZSQUlaMTEQMA4GA1UECwwHUEtJQUNDVjENMAsGA1UECgwEQUND
VjELMAkGA1UEBhMCRVMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCb
qau/YUqXry+XZpp0X9DZlv3P4uRm7x8fRzPCRKPfmt4ftVTdFXxpNRFvu8gMjmoY
HtiP2Ra8EEg2XPBjs5BaXCQ316PWywlxufEBcoSwfdtNgM3802/J+Nq2DoLSRYWo
G2ioPej0RGy9ocLLA76MPhMAhN9KSMDjIgro6TenGEyxCQ0jVn8ETdkXhBilyNpA
lHPrzg5XPAOBOp0KoVdDaaxXbXmQeOW1tDvYvEyNKKGno6e6Ak4l0Squ7a4DIrhr
IA8wKFSVf+DuzgpmndFALW4ir50awQUZ0m/A8p/4e7MCQvtQqR0tkw8jq8bBD5L/
0KIV9VMJcRz/RROE5iZe+OCIHAr8Fraocwa48GOEAqDGWuzndN9wrqODJerWx5eH
k6fGioozl2A3ED6XPm4pFdahD9GILBKfb6qkxkLrQaLjlUPTAYVtjrs78yM2x/47
4KElB0iryYl0/wiPgL/AlmXz7uxLaL2diMMxs0Dx6M/2OLuc5NF/1OVYm3z61PMO
m3WR5LpSLhl+0fXNWhn8ugb2+1KoS5kE3fj5tItQo05iifCHJPqDQsGH+tUtKSpa
cXpkatcnYGMN285J9Y0fkIkyF/hzQ7jSWpOGYdbhdQrqeWZ2iE9x6wQl1gpaepPl
uUsXQA+xtrn13k/c4LOsOxFwYIRKQ26ZIMApcQrAZQIDAQABo4ICyzCCAscwfQYI
KwYBBQUHAQEEcTBvMEwGCCsGAQUFBzAChkBodHRwOi8vd3d3LmFjY3YuZXMvZmls
ZWFkbWluL0FyY2hpdm9zL2NlcnRpZmljYWRvcy9yYWl6YWNjdjEuY3J0MB8GCCsG
AQUFBzABhhNodHRwOi8vb2NzcC5hY2N2LmVzMB0GA1UdDgQWBBTSh7Tj3zcnk1X2
VuqB5TbMjB4/vTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFNKHtOPfNyeT
VfZW6oHlNsyMHj+9MIIBcwYDVR0gBIIBajCCAWYwggFiBgRVHSAAMIIBWDCCASIG
CCsGAQUFBwICMIIBFB6CARAAQQB1AHQAbwByAGkAZABhAGQAIABkAGUAIABDAGUA
cgB0AGkAZgBpAGMAYQBjAGkA8wBuACAAUgBhAO0AegAgAGQAZQAgAGwAYQAgAEEA
QwBDAFYAIAAoAEEAZwBlAG4AYwBpAGEAIABkAGUAIABUAGUAYwBuAG8AbABvAGcA
7QBhACAAeQAgAEMAZQByAHQAaQBmAGkAYwBhAGMAaQDzAG4AIABFAGwAZQBjAHQA
cgDzAG4AaQBjAGEALAAgAEMASQBGACAAUQA0ADYAMAAxADEANQA2AEUAKQAuACAA
QwBQAFMAIABlAG4AIABoAHQAdABwADoALwAvAHcAdwB3AC4AYQBjAGMAdgAuAGUA
czAwBggrBgEFBQcCARYkaHR0cDovL3d3dy5hY2N2LmVzL2xlZ2lzbGFjaW9uX2Mu
aHRtMFUGA1UdHwROMEwwSqBIoEaGRGh0dHA6Ly93d3cuYWNjdi5lcy9maWxlYWRt
aW4vQXJjaGl2b3MvY2VydGlmaWNhZG9zL3JhaXphY2N2MV9kZXIuY3JsMA4GA1Ud
DwEB/wQEAwIBBjAXBgNVHREEEDAOgQxhY2N2QGFjY3YuZXMwDQYJKoZIhvcNAQEF
BQADggIBAJcxAp/n/UNnSEQU5CmH7UwoZtCPNdpNYbdKl02125DgBS4OxnnQ8pdp
D70ER9m+27Up2pvZrqmZ1dM8MJP1jaGo/AaNRPTKFpV8M9xii6g3+CfYCS0b78gU
JyCpZET/LtZ1qmxNYEAZSUNUY9rizLpm5U9EelvZaoErQNV/+QEnWCzI7UiRfD+m
AM/EKXMRNt6GGT6d7hmKG9Ww7Y49nCrADdg9ZuM8Db3VlFzi4qc1GwQA9j9ajepD
vV+JHanBsMyZ4k0ACtrJJ1vnE5Bc5PUzolVt3OAJTS+xJlsndQAJxGJ3KQhfnlms
tn6tn1QwIgPBHnFk/vk4CpYY3QIUrCPLBhwepH2NDd4nQeit2hW3sCPdK6jT2iWH
7ehVRE2I9DZ+hJp4rPcOVkkO1jMl1oRQQmwgEh0q1b688nCBpHBgvgW1m54ERL5h
I6zppSSMEYCUWqKiuUnSwdzRp+0xESyeGabu4VXhwOrPDYTkF7eifKXeVSUG7szA
h1xA2syVP1XgNce4hL60Xc16gwFy7ofmXx2utYXGJt/mwZrpHgJHnyqobalbz+xF
d3+YJ5oyXSrjhO7FmGYvliAd3djDJ9ew+f7Zfc3Qn48LFFhRny+Lwzgt3uiP1o2H
pPVWQxaZLPSkVrQ0uGE3ycJYgBugl6H8WY3pEfbRD0tVNEYqi4Y7
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFtTCCA52gAwIBAgIIYY3HhjsBggUwDQYJKoZIhvcNAQEFBQAwRDEWMBQGA1UE
AwwNQUNFRElDT00gUm9vdDEMMAoGA1UECwwDUEtJMQ8wDQYDVQQKDAZFRElDT00x
CzAJBgNVBAYTAkVTMB4XDTA4MDQxODE2MjQyMloXDTI4MDQxMzE2MjQyMlowRDEW
MBQGA1UEAwwNQUNFRElDT00gUm9vdDEMMAoGA1UECwwDUEtJMQ8wDQYDVQQKDAZF
RElDT00xCzAJBgNVBAYTAkVTMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC
AgEA/5KV4WgGdrQsyFhIyv2AVClVYyT/kGWbEHV7w2rbYgIB8hiGtXxaOLHkWLn7
09gtn70yN78sFW2+tfQh0hOR2QetAQXW8713zl9CgQr5auODAKgrLlUTY4HKRxx7
XBZXehuDYAQ6PmXDzQHe3qTWDLqO3tkE7hdWIpuPY/1NFgu3e3eM+SW10W2ZEi5P
Grjm6gSSrj0RuVFCPYewMYWveVqc/udOXpJPQ/yrOq2lEiZmueIM15jO1FillUAK
t0SdE3QrwqXrIhWYENiLxQSfHY9g5QYbm8+5eaA9oiM/Qj9r+hwDezCNzmzAv+Yb
X79nuIQZ1RXve8uQNjFiybwCq0Zfm/4aaJQ0PZCOrfbkHQl/Sog4P75n/TSW9R28
MHTLOO7VbKvU/PQAtwBbhTIWdjPp2KOZnQUAqhbm84F9b32qhm2tFXTTxKJxqvQU
fecyuB+81fFOvW8XAjnXDpVCOscAPukmYxHqC9FK/xidstd7LzrZlvvoHpKuE1XI
2Sf23EgbsCTBheN3nZqk8wwRHQ3ItBTutYJXCb8gWH8vIiPYcMt5bMlL8qkqyPyH
K9caUPgn6C9D4zq92Fdx/c6mUlv53U3t5fZvie27k5x2IXXwkkwp9y+cAS7+UEae
ZAwUswdbxcJzbPEHXEUkFDWug/FqTYl6+rPYLWbwNof1K1MCAwEAAaOBqjCBpzAP
BgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFKaz4SsrSbbXc6GqlPUB53NlTKxQ
MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUprPhKytJttdzoaqU9QHnc2VMrFAw
RAYDVR0gBD0wOzA5BgRVHSAAMDEwLwYIKwYBBQUHAgEWI2h0dHA6Ly9hY2VkaWNv
bS5lZGljb21ncm91cC5jb20vZG9jMA0GCSqGSIb3DQEBBQUAA4ICAQDOLAtSUWIm
fQwng4/F9tqgaHtPkl7qpHMyEVNEskTLnewPeUKzEKbHDZ3Ltvo/Onzqv4hTGzz3
gvoFNTPhNahXwOf9jU8/kzJPeGYDdwdY6ZXIfj7QeQCM8htRM5u8lOk6e25SLTKe
I6RF+7YuE7CLGLHdztUdp0J/Vb77W7tH1PwkzQSulgUV1qzOMPPKC8W64iLgpq0i
5ALudBF/TP94HTXa5gI06xgSYXcGCRZj6hitoocf8seACQl1ThCojz2GuHURwCRi
ipZ7SkXp7FnFvmuD5uHorLUwHv4FB4D54SMNUI8FmP8sX+g7tq3PgbUhh8oIKiMn
MCArz+2UW6yyetLHKKGKC5tNSixthT8Jcjxn4tncB7rrZXtaAWPWkFtPF2Y9fwsZ
o5NjEFIqnxQWWOLcpfShFosOkYuByptZ+thrkQdlVV9SH686+5DdaaVbnG0OLLb6
zqylfDJKZ0DcMDQj3dcEI2bw/FWAp/tmGYI1Z2JwOV5vx+qQQEQIHriy1tvuWacN
GHk0vFQYXlPKNFHtRQrmjseCNj6nOGOpMCwXEGCSn1WHElkQwg9naRHMTh5+Spqt
r0CodaxWkHS4oJyleW/c6RrIaQXpuvoDs3zk4E7Czp3otkYNbn5XOmeUwssfnHdK
Z05phkOTOPu220+DkdRgfks+KzgHVZhepA==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFuzCCA6OgAwIBAgIIVwoRl0LE48wwDQYJKoZIhvcNAQELBQAwazELMAkGA1UE
BhMCSVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8w
MzM1ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290
IENBMB4XDTExMDkyMjExMjIwMloXDTMwMDkyMjExMjIwMlowazELMAkGA1UEBhMC
SVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8wMzM1
ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290IENB
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAp8bEpSmkLO/lGMWwUKNv
UTufClrJwkg4CsIcoBh/kbWHuUA/3R1oHwiD1S0eiKD4j1aPbZkCkpAW1V8IbInX
4ay8IMKx4INRimlNAJZaby/ARH6jDuSRzVju3PvHHkVH3Se5CAGfpiEd9UEtL0z9
KK3giq0itFZljoZUj5NDKd45RnijMCO6zfB9E1fAXdKDa0hMxKufgFpbOr3JpyI/
gCczWw63igxdBzcIy2zSekciRDXFzMwujt0q7bd9Zg1fYVEiVRvjRuPjPdA1Yprb
rxTIW6HMiRvhMCb8oJsfgadHHwTrozmSBp+Z07/T6k9QnBn+locePGX2oxgkg4YQ
51Q+qDp2JE+BIcXjDwL4k5RHILv+1A7TaLndxHqEguNTVHnd25zS8gebLra8Pu2F
be8lEfKXGkJh90qX6IuxEAf6ZYGyojnP9zz/GPvG8VqLWeICrHuS0E4UT1lF9gxe
KF+w6D9Fz8+vm2/7hNN3WpVvrJSEnu68wEqPSpP4RCHiMUVhUE4Q2OM1fEwZtN4F
v6MGn8i1zeQf1xcGDXqVdFUNaBr8EBtiZJ1t4JWgw5QHVw0U5r0F+7if5t+L4sbn
fpb2U8WANFAoWPASUHEXMLrmeGO89LKtmyuy/uE5jF66CyCU3nuDuP/jVo23Eek7
jPKxwV2dpAtMK9myGPW1n0sCAwEAAaNjMGEwHQYDVR0OBBYEFFLYiDrIn3hm7Ynz
ezhwlMkCAjbQMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUUtiIOsifeGbt
ifN7OHCUyQICNtAwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQAL
e3KHwGCmSUyIWOYdiPcUZEim2FgKDk8TNd81HdTtBjHIgT5q1d07GjLukD0R0i70
jsNjLiNmsGe+b7bAEzlgqqI0JZN1Ut6nna0Oh4lScWoWPBkdg/iaKWW+9D+a2fDz
WochcYBNy+A4mz+7+uAwTc+G02UQGRjRlwKxK3JCaKygvU5a2hi/a5iB0P2avl4V
SM0RFbnAKVy06Ij3Pjaut2L9HmLecHgQHEhb2rykOLpn7VU+Xlff1ANATIGk0k9j
pwlCCRT8AKnCgHNPLsBA2RF7SOp6AsDT6ygBJlh0wcBzIm2Tlf05fbsq4/aC4yyX
X04fkZT6/iyj2HYauE2yOE+b+h1IYHkm4vP9qdCa6HCPSXrW5b0KDtst842/6+Ok
fcvHlXHo2qN8xcL4dJIEG4aspCJTQLas/kx2z/uUMsA1n3Y/buWQbqCmJqK4LL7R
K4X9p2jIugErsWx0Hbhzlefut8cl8ABMALJ+tguLHPPAUJ4lueAI3jZm/zel0btU
ZCzJJ7VLkn5l/9Mt4blOvH+kQSGQQXemOR/qnuOf0GZvBeyqdn6/axag67XH/JJU
LysRJyU3eExRarDzzFhdFPFqSBX/wge2sY0PjlxQRrM9vwGYT7JZVEc+NHt4bVaT
LnPqZih4zR0Uv6CPLy64Lo7yFIrM6bV8+2ydDKXhlg==
-----END CERTIFICATE-----
`
func TestReadCertificate(t *testing.T) {
cert, remaining, err := ReadCertificate([]byte(testCerts))
assert.NoErrorT(t, err)
assert.BoolT(t, len(remaining) > 0, "lib: expected extra data from ReadCertificate")
assert.BoolT(t, cert != nil, "lib: expected an actual certificate to have been returned")
}
func TestReadCertificates(t *testing.T) {
certs, err := ReadCertificates([]byte(testCerts))
assert.NoErrorT(t, err)
assert.BoolT(t, len(certs) == 3, fmt.Sprintf("lib: expected three certificates, have %d", len(certs)))
for _, cert := range certs {
assert.BoolT(t, cert != nil, "lib: expected an actual certificate to have been returned")
}
}

83
certlib/der_helpers.go Normal file
View File

@@ -0,0 +1,83 @@
package certlib
// Originally from CFSSL, mostly written by me originally, and licensed under:
/*
Copyright (c) 2014 CloudFlare Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
// I've modified it for use in my own code e.g. by removing the CFSSL errors
// and replacing them with sane ones.
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"errors"
"fmt"
"git.wntrmute.dev/kyle/goutils/certlib/certerr"
)
// ParsePrivateKeyDER parses a PKCS #1, PKCS #8, ECDSA, or Ed25519 DER-encoded
// private key. The key must not be in PEM format. If an error is returned, it
// may contain information about the private key, so care should be taken when
// displaying it directly.
func ParsePrivateKeyDER(keyDER []byte) (crypto.Signer, error) {
// Try common encodings in order without deep nesting.
if k, err := x509.ParsePKCS8PrivateKey(keyDER); err == nil {
switch kk := k.(type) {
case *rsa.PrivateKey:
return kk, nil
case *ecdsa.PrivateKey:
return kk, nil
case ed25519.PrivateKey:
return kk, nil
default:
return nil, certerr.ParsingError(certerr.ErrorSourcePrivateKey, fmt.Errorf("unknown key type %T", k))
}
}
if k, err := x509.ParsePKCS1PrivateKey(keyDER); err == nil {
return k, nil
}
if k, err := x509.ParseECPrivateKey(keyDER); err == nil {
return k, nil
}
if k, err := ParseEd25519PrivateKey(keyDER); err == nil {
if kk, ok := k.(ed25519.PrivateKey); ok {
return kk, nil
}
return nil, certerr.ParsingError(certerr.ErrorSourcePrivateKey, fmt.Errorf("unknown key type %T", k))
}
// If all parsers failed, return the last error from Ed25519 attempt (approximate cause).
if _, err := ParseEd25519PrivateKey(keyDER); err != nil {
return nil, certerr.ParsingError(certerr.ErrorSourcePrivateKey, err)
}
// Fallback (should be unreachable)
return nil, certerr.ParsingError(certerr.ErrorSourcePrivateKey, errors.New("unknown key encoding"))
}

167
certlib/ed25519.go Normal file
View File

@@ -0,0 +1,167 @@
package certlib
import (
"crypto"
"crypto/ed25519"
"crypto/x509/pkix"
"encoding/asn1"
"errors"
)
// Originally from CFSSL, mostly written by me originally, and licensed under:
/*
Copyright (c) 2014 CloudFlare Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
// I've modified it for use in my own code e.g. by removing the CFSSL errors
// and replacing them with sane ones.
var errEd25519WrongID = errors.New("incorrect object identifier")
var errEd25519WrongKeyType = errors.New("incorrect key type")
// ed25519OID is the OID for the Ed25519 signature scheme: see
// https://datatracker.ietf.org/doc/draft-ietf-curdle-pkix-04.
var ed25519OID = asn1.ObjectIdentifier{1, 3, 101, 112}
// subjectPublicKeyInfo reflects the ASN.1 object defined in the X.509 standard.
//
// This is defined in crypto/x509 as "publicKeyInfo".
type subjectPublicKeyInfo struct {
Algorithm pkix.AlgorithmIdentifier
PublicKey asn1.BitString
}
// MarshalEd25519PublicKey creates a DER-encoded SubjectPublicKeyInfo for an
// ed25519 public key, as defined in
// https://tools.ietf.org/html/draft-ietf-curdle-pkix-04. This is analogous to
// MarshalPKIXPublicKey in crypto/x509, which doesn't currently support Ed25519.
func MarshalEd25519PublicKey(pk crypto.PublicKey) ([]byte, error) {
pub, ok := pk.(ed25519.PublicKey)
if !ok {
return nil, errEd25519WrongKeyType
}
const bitsPerByte = 8
spki := subjectPublicKeyInfo{
Algorithm: pkix.AlgorithmIdentifier{
Algorithm: ed25519OID,
},
PublicKey: asn1.BitString{
BitLength: len(pub) * bitsPerByte,
Bytes: pub,
},
}
return asn1.Marshal(spki)
}
// ParseEd25519PublicKey returns the Ed25519 public key encoded by the input.
func ParseEd25519PublicKey(der []byte) (crypto.PublicKey, error) {
var spki subjectPublicKeyInfo
if rest, err := asn1.Unmarshal(der, &spki); err != nil {
return nil, err
} else if len(rest) > 0 {
return nil, errors.New("SubjectPublicKeyInfo too long")
}
if !spki.Algorithm.Algorithm.Equal(ed25519OID) {
return nil, errEd25519WrongID
}
const bitsPerByte = 8
if spki.PublicKey.BitLength != ed25519.PublicKeySize*bitsPerByte {
return nil, errors.New("SubjectPublicKeyInfo PublicKey length mismatch")
}
return ed25519.PublicKey(spki.PublicKey.Bytes), nil
}
// oneAsymmetricKey reflects the ASN.1 structure for storing private keys in
// https://tools.ietf.org/html/draft-ietf-curdle-pkix-04, excluding the optional
// fields, which we don't use here.
//
// This is identical to pkcs8 in crypto/x509.
type oneAsymmetricKey struct {
Version int
Algorithm pkix.AlgorithmIdentifier
PrivateKey []byte
}
// curvePrivateKey is the innter type of the PrivateKey field of
// oneAsymmetricKey.
type curvePrivateKey []byte
// MarshalEd25519PrivateKey returns a DER encoding of the input private key as
// specified in https://tools.ietf.org/html/draft-ietf-curdle-pkix-04.
func MarshalEd25519PrivateKey(sk crypto.PrivateKey) ([]byte, error) {
priv, ok := sk.(ed25519.PrivateKey)
if !ok {
return nil, errEd25519WrongKeyType
}
// Marshal the innter CurvePrivateKey.
curvePrivateKey, err := asn1.Marshal(priv.Seed())
if err != nil {
return nil, err
}
// Marshal the OneAsymmetricKey.
asym := oneAsymmetricKey{
Version: 0,
Algorithm: pkix.AlgorithmIdentifier{
Algorithm: ed25519OID,
},
PrivateKey: curvePrivateKey,
}
return asn1.Marshal(asym)
}
// ParseEd25519PrivateKey returns the Ed25519 private key encoded by the input.
func ParseEd25519PrivateKey(der []byte) (crypto.PrivateKey, error) {
asym := new(oneAsymmetricKey)
if rest, err := asn1.Unmarshal(der, asym); err != nil {
return nil, err
} else if len(rest) > 0 {
return nil, errors.New("OneAsymmetricKey too long")
}
// Check that the key type is correct.
if !asym.Algorithm.Algorithm.Equal(ed25519OID) {
return nil, errEd25519WrongID
}
// Unmarshal the inner CurvePrivateKey.
seed := new(curvePrivateKey)
if rest, err := asn1.Unmarshal(asym.PrivateKey, seed); err != nil {
return nil, err
} else if len(rest) > 0 {
return nil, errors.New("CurvePrivateKey too long")
}
return ed25519.NewKeyFromSeed(*seed), nil
}

674
certlib/helpers.go Normal file
View File

@@ -0,0 +1,674 @@
package certlib
// Originally from CFSSL, mostly written by me originally, and licensed under:
/*
Copyright (c) 2014 CloudFlare Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
// I've modified it for use in my own code e.g. by removing the CFSSL errors
// and replacing them with sane ones.
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"errors"
"fmt"
"os"
"strings"
"time"
ct "github.com/google/certificate-transparency-go"
cttls "github.com/google/certificate-transparency-go/tls"
ctx509 "github.com/google/certificate-transparency-go/x509"
"golang.org/x/crypto/ocsp"
"golang.org/x/crypto/pkcs12"
"git.wntrmute.dev/kyle/goutils/certlib/certerr"
"git.wntrmute.dev/kyle/goutils/certlib/pkcs7"
)
// OneYear is a time.Duration representing a year's worth of seconds.
const OneYear = 8760 * time.Hour
// OneDay is a time.Duration representing a day's worth of seconds.
const OneDay = 24 * time.Hour
// DelegationUsage is the OID for the DelegationUseage extensions.
var DelegationUsage = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 44363, 44}
// DelegationExtension is a non-critical extension marking delegation usage.
var DelegationExtension = pkix.Extension{
Id: DelegationUsage,
Critical: false,
Value: []byte{0x05, 0x00}, // ASN.1 NULL
}
// InclusiveDate returns the time.Time representation of a date - 1
// nanosecond. This allows time.After to be used inclusively.
func InclusiveDate(year int, month time.Month, day int) time.Time {
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC).Add(-1 * time.Nanosecond)
}
const (
year2012 = 2012
year2015 = 2015
day1 = 1
)
// Jul2012 is the July 2012 CAB Forum deadline for when CAs must stop
// issuing certificates valid for more than 5 years.
var Jul2012 = InclusiveDate(year2012, time.July, day1)
// Apr2015 is the April 2015 CAB Forum deadline for when CAs must stop
// issuing certificates valid for more than 39 months.
var Apr2015 = InclusiveDate(year2015, time.April, day1)
// KeyLength returns the bit size of ECDSA or RSA PublicKey.
func KeyLength(key any) int {
switch k := key.(type) {
case *ecdsa.PublicKey:
if k == nil {
return 0
}
return k.Curve.Params().BitSize
case *rsa.PublicKey:
if k == nil {
return 0
}
return k.N.BitLen()
default:
return 0
}
}
// ExpiryTime returns the time when the certificate chain is expired.
func ExpiryTime(chain []*x509.Certificate) time.Time {
var notAfter time.Time
if len(chain) == 0 {
return notAfter
}
notAfter = chain[0].NotAfter
for _, cert := range chain {
if notAfter.After(cert.NotAfter) {
notAfter = cert.NotAfter
}
}
return notAfter
}
// MonthsValid returns the number of months for which a certificate is valid.
func MonthsValid(c *x509.Certificate) int {
issued := c.NotBefore
expiry := c.NotAfter
years := (expiry.Year() - issued.Year())
months := years*12 + int(expiry.Month()) - int(issued.Month())
// Round up if valid for less than a full month
if expiry.Day() > issued.Day() {
months++
}
return months
}
// ValidExpiry determines if a certificate is valid for an acceptable
// length of time per the CA/Browser Forum baseline requirements.
// See https://cabforum.org/wp-content/uploads/CAB-Forum-BR-1.3.0.pdf
func ValidExpiry(c *x509.Certificate) bool {
issued := c.NotBefore
var maxMonths int
switch {
case issued.After(Apr2015):
maxMonths = 39
case issued.After(Jul2012):
maxMonths = 60
default:
maxMonths = 120
}
return MonthsValid(c) <= maxMonths
}
// SignatureString returns the TLS signature string corresponding to
// an X509 signature algorithm.
var signatureString = map[x509.SignatureAlgorithm]string{
x509.UnknownSignatureAlgorithm: "Unknown Signature",
x509.MD2WithRSA: "MD2WithRSA",
x509.MD5WithRSA: "MD5WithRSA",
x509.SHA1WithRSA: "SHA1WithRSA",
x509.SHA256WithRSA: "SHA256WithRSA",
x509.SHA384WithRSA: "SHA384WithRSA",
x509.SHA512WithRSA: "SHA512WithRSA",
x509.SHA256WithRSAPSS: "SHA256WithRSAPSS",
x509.SHA384WithRSAPSS: "SHA384WithRSAPSS",
x509.SHA512WithRSAPSS: "SHA512WithRSAPSS",
x509.DSAWithSHA1: "DSAWithSHA1",
x509.DSAWithSHA256: "DSAWithSHA256",
x509.ECDSAWithSHA1: "ECDSAWithSHA1",
x509.ECDSAWithSHA256: "ECDSAWithSHA256",
x509.ECDSAWithSHA384: "ECDSAWithSHA384",
x509.ECDSAWithSHA512: "ECDSAWithSHA512",
x509.PureEd25519: "PureEd25519",
}
// SignatureString returns the TLS signature string corresponding to
// an X509 signature algorithm.
func SignatureString(alg x509.SignatureAlgorithm) string {
if s, ok := signatureString[alg]; ok {
return s
}
return "Unknown Signature"
}
// HashAlgoString returns the hash algorithm name contains in the signature
// method.
var hashAlgoString = map[x509.SignatureAlgorithm]string{
x509.UnknownSignatureAlgorithm: "Unknown Hash Algorithm",
x509.MD2WithRSA: "MD2",
x509.MD5WithRSA: "MD5",
x509.SHA1WithRSA: "SHA1",
x509.SHA256WithRSA: "SHA256",
x509.SHA384WithRSA: "SHA384",
x509.SHA512WithRSA: "SHA512",
x509.SHA256WithRSAPSS: "SHA256",
x509.SHA384WithRSAPSS: "SHA384",
x509.SHA512WithRSAPSS: "SHA512",
x509.DSAWithSHA1: "SHA1",
x509.DSAWithSHA256: "SHA256",
x509.ECDSAWithSHA1: "SHA1",
x509.ECDSAWithSHA256: "SHA256",
x509.ECDSAWithSHA384: "SHA384",
x509.ECDSAWithSHA512: "SHA512",
x509.PureEd25519: "SHA512", // per x509 docs Ed25519 uses SHA-512 internally
}
// HashAlgoString returns the hash algorithm name contains in the signature
// method.
func HashAlgoString(alg x509.SignatureAlgorithm) string {
if s, ok := hashAlgoString[alg]; ok {
return s
}
return "Unknown Hash Algorithm"
}
// StringTLSVersion returns underlying enum values from human names for TLS
// versions, defaults to current golang default of TLS 1.0.
func StringTLSVersion(version string) uint16 {
switch version {
case "1.3":
return tls.VersionTLS13
case "1.2":
return tls.VersionTLS12
case "1.1":
return tls.VersionTLS11
case "1.0":
return tls.VersionTLS10
default:
// Default to Go's historical default of TLS 1.0 for unknown values
return tls.VersionTLS10
}
}
// EncodeCertificatesPEM encodes a number of x509 certificates to PEM.
func EncodeCertificatesPEM(certs []*x509.Certificate) []byte {
var buffer bytes.Buffer
for _, cert := range certs {
if err := pem.Encode(&buffer, &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}); err != nil {
return nil
}
}
return buffer.Bytes()
}
// EncodeCertificatePEM encodes a single x509 certificates to PEM.
func EncodeCertificatePEM(cert *x509.Certificate) []byte {
return EncodeCertificatesPEM([]*x509.Certificate{cert})
}
// ParseCertificatesPEM parses a sequence of PEM-encoded certificate and returns them,
// can handle PEM encoded PKCS #7 structures.
func ParseCertificatesPEM(certsPEM []byte) ([]*x509.Certificate, error) {
var certs []*x509.Certificate
var err error
certsPEM = bytes.TrimSpace(certsPEM)
for len(certsPEM) > 0 {
var cert []*x509.Certificate
cert, certsPEM, err = ParseOneCertificateFromPEM(certsPEM)
if err != nil {
return nil, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
} else if cert == nil {
break
}
certs = append(certs, cert...)
}
if len(certsPEM) > 0 {
return nil, certerr.DecodeError(
certerr.ErrorSourceCertificate,
errors.New("trailing data at end of certificate"),
)
}
return certs, nil
}
// ParseCertificatesDER parses a DER encoding of a certificate object and possibly private key,
// either PKCS #7, PKCS #12, or raw x509.
func ParseCertificatesDER(certsDER []byte, password string) ([]*x509.Certificate, crypto.Signer, error) {
certsDER = bytes.TrimSpace(certsDER)
// First, try PKCS #7
if pkcs7data, err7 := pkcs7.ParsePKCS7(certsDER); err7 == nil {
if pkcs7data.ContentInfo != "SignedData" {
return nil, nil, certerr.DecodeError(
certerr.ErrorSourceCertificate,
errors.New("can only extract certificates from signed data content info"),
)
}
certs := pkcs7data.Content.SignedData.Certificates
if certs == nil {
return nil, nil, certerr.DecodeError(certerr.ErrorSourceCertificate, errors.New("no certificates decoded"))
}
return certs, nil, nil
}
// Next, try PKCS #12
if pkcs12data, cert, err12 := pkcs12.Decode(certsDER, password); err12 == nil {
signer, ok := pkcs12data.(crypto.Signer)
if !ok {
return nil, nil, certerr.DecodeError(
certerr.ErrorSourcePrivateKey,
errors.New("PKCS12 data does not contain a private key"),
)
}
return []*x509.Certificate{cert}, signer, nil
}
// Finally, attempt to parse raw X.509 certificates
certs, err := x509.ParseCertificates(certsDER)
if err != nil {
return nil, nil, certerr.DecodeError(certerr.ErrorSourceCertificate, err)
}
return certs, nil, nil
}
// ParseSelfSignedCertificatePEM parses a PEM-encoded certificate and check if it is self-signed.
func ParseSelfSignedCertificatePEM(certPEM []byte) (*x509.Certificate, error) {
cert, err := ParseCertificatePEM(certPEM)
if err != nil {
return nil, err
}
err = cert.CheckSignature(cert.SignatureAlgorithm, cert.RawTBSCertificate, cert.Signature)
if err != nil {
return nil, certerr.VerifyError(certerr.ErrorSourceCertificate, err)
}
return cert, nil
}
// ParseCertificatePEM parses and returns a PEM-encoded certificate,
// can handle PEM encoded PKCS #7 structures.
func ParseCertificatePEM(certPEM []byte) (*x509.Certificate, error) {
certPEM = bytes.TrimSpace(certPEM)
certs, rest, err := ParseOneCertificateFromPEM(certPEM)
if err != nil {
return nil, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
if certs == nil {
return nil, certerr.DecodeError(certerr.ErrorSourceCertificate, errors.New("no certificate decoded"))
}
if len(rest) > 0 {
return nil, certerr.ParsingError(
certerr.ErrorSourceCertificate,
errors.New("the PEM file should contain only one object"),
)
}
if len(certs) > 1 {
return nil, certerr.ParsingError(
certerr.ErrorSourceCertificate,
errors.New("the PKCS7 object in the PEM file should contain only one certificate"),
)
}
return certs[0], nil
}
// ParseOneCertificateFromPEM attempts to parse one PEM encoded certificate object,
// either a raw x509 certificate or a PKCS #7 structure possibly containing
// multiple certificates, from the top of certsPEM, which itself may
// contain multiple PEM encoded certificate objects.
func ParseOneCertificateFromPEM(certsPEM []byte) ([]*x509.Certificate, []byte, error) {
block, rest := pem.Decode(certsPEM)
if block == nil {
return nil, rest, nil
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
pkcs7data, err2 := pkcs7.ParsePKCS7(block.Bytes)
if err2 != nil {
return nil, rest, err
}
if pkcs7data.ContentInfo != "SignedData" {
return nil, rest, errors.New("only PKCS #7 Signed Data Content Info supported for certificate parsing")
}
certs := pkcs7data.Content.SignedData.Certificates
if certs == nil {
return nil, rest, errors.New("PKCS #7 structure contains no certificates")
}
return certs, rest, nil
}
var certs = []*x509.Certificate{cert}
return certs, rest, nil
}
// LoadPEMCertPool loads a pool of PEM certificates from file.
func LoadPEMCertPool(certsFile string) (*x509.CertPool, error) {
if certsFile == "" {
return nil, nil //nolint:nilnil // no CA file provided -> treat as no pool and no error
}
pemCerts, err := os.ReadFile(certsFile)
if err != nil {
return nil, err
}
return PEMToCertPool(pemCerts)
}
// PEMToCertPool concerts PEM certificates to a CertPool.
func PEMToCertPool(pemCerts []byte) (*x509.CertPool, error) {
if len(pemCerts) == 0 {
return nil, nil //nolint:nilnil // empty input means no pool needed
}
certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM(pemCerts) {
return nil, certerr.LoadingError(certerr.ErrorSourceCertificate, errors.New("failed to load cert pool"))
}
return certPool, nil
}
// ParsePrivateKeyPEM parses and returns a PEM-encoded private
// key. The private key may be either an unencrypted PKCS#8, PKCS#1,
// or elliptic private key.
func ParsePrivateKeyPEM(keyPEM []byte) (crypto.Signer, error) {
return ParsePrivateKeyPEMWithPassword(keyPEM, nil)
}
// ParsePrivateKeyPEMWithPassword parses and returns a PEM-encoded private
// key. The private key may be a potentially encrypted PKCS#8, PKCS#1,
// or elliptic private key.
func ParsePrivateKeyPEMWithPassword(keyPEM []byte, password []byte) (crypto.Signer, error) {
keyDER, err := GetKeyDERFromPEM(keyPEM, password)
if err != nil {
return nil, err
}
return ParsePrivateKeyDER(keyDER)
}
// GetKeyDERFromPEM parses a PEM-encoded private key and returns DER-format key bytes.
func GetKeyDERFromPEM(in []byte, password []byte) ([]byte, error) {
// Ignore any EC PARAMETERS blocks when looking for a key (openssl includes
// them by default).
var keyDER *pem.Block
for {
keyDER, in = pem.Decode(in)
if keyDER == nil || keyDER.Type != "EC PARAMETERS" {
break
}
}
if keyDER == nil {
return nil, certerr.DecodeError(certerr.ErrorSourcePrivateKey, errors.New("failed to decode private key"))
}
if procType, ok := keyDER.Headers["Proc-Type"]; ok && strings.Contains(procType, "ENCRYPTED") {
if password != nil {
return x509.DecryptPEMBlock(keyDER, password)
}
return nil, certerr.DecodeError(certerr.ErrorSourcePrivateKey, certerr.ErrEncryptedPrivateKey)
}
return keyDER.Bytes, nil
}
// ParseCSR parses a PEM- or DER-encoded PKCS #10 certificate signing request.
func ParseCSR(in []byte) (*x509.CertificateRequest, []byte, error) {
in = bytes.TrimSpace(in)
p, rest := pem.Decode(in)
if p == nil {
csr, err := x509.ParseCertificateRequest(in)
if err != nil {
return nil, rest, certerr.ParsingError(certerr.ErrorSourceCSR, err)
}
if sigErr := csr.CheckSignature(); sigErr != nil {
return nil, rest, certerr.VerifyError(certerr.ErrorSourceCSR, sigErr)
}
return csr, rest, nil
}
if p.Type != "NEW CERTIFICATE REQUEST" && p.Type != "CERTIFICATE REQUEST" {
return nil, rest, certerr.ParsingError(
certerr.ErrorSourceCSR,
certerr.ErrInvalidPEMType(p.Type, "NEW CERTIFICATE REQUEST", "CERTIFICATE REQUEST"),
)
}
csr, err := x509.ParseCertificateRequest(p.Bytes)
if err != nil {
return nil, rest, certerr.ParsingError(certerr.ErrorSourceCSR, err)
}
if sigErr := csr.CheckSignature(); sigErr != nil {
return nil, rest, certerr.VerifyError(certerr.ErrorSourceCSR, sigErr)
}
return csr, rest, nil
}
// ParseCSRPEM parses a PEM-encoded certificate signing request.
// It does not check the signature. This is useful for dumping data from a CSR
// locally.
func ParseCSRPEM(csrPEM []byte) (*x509.CertificateRequest, error) {
block, _ := pem.Decode(csrPEM)
if block == nil {
return nil, certerr.DecodeError(certerr.ErrorSourceCSR, errors.New("PEM block is empty"))
}
csrObject, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
return nil, certerr.ParsingError(certerr.ErrorSourceCSR, err)
}
return csrObject, nil
}
// SignerAlgo returns an X.509 signature algorithm from a crypto.Signer.
func SignerAlgo(priv crypto.Signer) x509.SignatureAlgorithm {
const (
rsaBits2048 = 2048
rsaBits3072 = 3072
rsaBits4096 = 4096
)
switch pub := priv.Public().(type) {
case *rsa.PublicKey:
bitLength := pub.N.BitLen()
switch {
case bitLength >= rsaBits4096:
return x509.SHA512WithRSA
case bitLength >= rsaBits3072:
return x509.SHA384WithRSA
case bitLength >= rsaBits2048:
return x509.SHA256WithRSA
default:
return x509.SHA1WithRSA
}
case *ecdsa.PublicKey:
switch pub.Curve {
case elliptic.P521():
return x509.ECDSAWithSHA512
case elliptic.P384():
return x509.ECDSAWithSHA384
case elliptic.P256():
return x509.ECDSAWithSHA256
default:
return x509.ECDSAWithSHA1
}
default:
return x509.UnknownSignatureAlgorithm
}
}
// LoadClientCertificate load key/certificate from pem files.
func LoadClientCertificate(certFile string, keyFile string) (*tls.Certificate, error) {
if certFile != "" && keyFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, certerr.LoadingError(certerr.ErrorSourceKeypair, err)
}
return &cert, nil
}
return nil, nil //nolint:nilnil // absence of client cert is not an error
}
// CreateTLSConfig creates a tls.Config object from certs and roots.
func CreateTLSConfig(remoteCAs *x509.CertPool, cert *tls.Certificate) *tls.Config {
var certs []tls.Certificate
if cert != nil {
certs = []tls.Certificate{*cert}
}
return &tls.Config{
Certificates: certs,
RootCAs: remoteCAs,
MinVersion: tls.VersionTLS12, // secure default
}
}
// SerializeSCTList serializes a list of SCTs.
func SerializeSCTList(sctList []ct.SignedCertificateTimestamp) ([]byte, error) {
list := ctx509.SignedCertificateTimestampList{}
for _, sct := range sctList {
sctBytes, err := cttls.Marshal(sct)
if err != nil {
return nil, err
}
list.SCTList = append(list.SCTList, ctx509.SerializedSCT{Val: sctBytes})
}
return cttls.Marshal(list)
}
// DeserializeSCTList deserializes a list of SCTs.
func DeserializeSCTList(serializedSCTList []byte) ([]ct.SignedCertificateTimestamp, error) {
var sctList ctx509.SignedCertificateTimestampList
rest, err := cttls.Unmarshal(serializedSCTList, &sctList)
if err != nil {
return nil, err
}
if len(rest) != 0 {
return nil, certerr.ParsingError(
certerr.ErrorSourceSCTList,
errors.New("serialized SCT list contained trailing garbage"),
)
}
list := make([]ct.SignedCertificateTimestamp, len(sctList.SCTList))
for i, serializedSCT := range sctList.SCTList {
var sct ct.SignedCertificateTimestamp
rest2, err2 := cttls.Unmarshal(serializedSCT.Val, &sct)
if err2 != nil {
return nil, err2
}
if len(rest2) != 0 {
return nil, certerr.ParsingError(
certerr.ErrorSourceSCTList,
errors.New("serialized SCT list contained trailing garbage"),
)
}
list[i] = sct
}
return list, nil
}
// SCTListFromOCSPResponse extracts the SCTList from an ocsp.Response,
// returning an empty list if the SCT extension was not found or could not be
// unmarshalled.
func SCTListFromOCSPResponse(response *ocsp.Response) ([]ct.SignedCertificateTimestamp, error) {
// This loop finds the SCTListExtension in the OCSP response.
var sctListExtension, ext pkix.Extension
for _, ext = range response.Extensions {
// sctExtOid is the ObjectIdentifier of a Signed Certificate Timestamp.
sctExtOid := asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 5}
if ext.Id.Equal(sctExtOid) {
sctListExtension = ext
break
}
}
// This code block extracts the sctList from the SCT extension.
var sctList []ct.SignedCertificateTimestamp
var err error
if numBytes := len(sctListExtension.Value); numBytes != 0 {
var serializedSCTList []byte
rest := make([]byte, numBytes)
copy(rest, sctListExtension.Value)
for len(rest) != 0 {
rest, err = asn1.Unmarshal(rest, &serializedSCTList)
if err != nil {
return nil, certerr.ParsingError(certerr.ErrorSourceSCTList, err)
}
}
sctList, err = DeserializeSCTList(serializedSCTList)
}
return sctList, err
}
// ReadBytes reads a []byte either from a file or an environment variable.
// If valFile has a prefix of 'env:', the []byte is read from the environment
// using the subsequent name. If the prefix is 'file:' the []byte is read from
// the subsequent file. If no prefix is provided, valFile is assumed to be a
// file path.
func ReadBytes(valFile string) ([]byte, error) {
prefix, rest, found := strings.Cut(valFile, ":")
if !found {
return os.ReadFile(valFile)
}
switch prefix {
case "env":
return []byte(os.Getenv(rest)), nil
case "file":
return os.ReadFile(rest)
default:
return nil, fmt.Errorf("unknown prefix: %s", prefix)
}
}

84
certlib/hosts/hosts.go Normal file
View File

@@ -0,0 +1,84 @@
package hosts
import (
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
)
const defaultHTTPSPort = 443
type Target struct {
Host string
Port int
}
func (t *Target) String() string {
return fmt.Sprintf("%s:%d", t.Host, t.Port)
}
func parseURL(host string) (string, int, error) {
url, err := url.Parse(host)
if err != nil {
return "", 0, fmt.Errorf("certlib/hosts: invalid host: %s", host)
}
if strings.ToLower(url.Scheme) != "https" {
return "", 0, errors.New("certlib/hosts: only https scheme supported")
}
if url.Port() == "" {
return url.Hostname(), defaultHTTPSPort, nil
}
portInt, err2 := strconv.ParseInt(url.Port(), 10, 16)
if err2 != nil {
return "", 0, fmt.Errorf("certlib/hosts: invalid port: %s", url.Port())
}
return url.Hostname(), int(portInt), nil
}
func parseHostPort(host string) (string, int, error) {
host, sport, err := net.SplitHostPort(host)
if err == nil {
portInt, err2 := strconv.ParseInt(sport, 10, 16)
if err2 != nil {
return "", 0, fmt.Errorf("certlib/hosts: invalid port: %s", sport)
}
return host, int(portInt), nil
}
return host, defaultHTTPSPort, nil
}
func ParseHost(host string) (*Target, error) {
host, port, err := parseURL(host)
if err == nil {
return &Target{Host: host, Port: port}, nil
}
host, port, err = parseHostPort(host)
if err == nil {
return &Target{Host: host, Port: port}, nil
}
return nil, fmt.Errorf("certlib/hosts: invalid host: %s", host)
}
func ParseHosts(hosts ...string) ([]*Target, error) {
targets := make([]*Target, 0, len(hosts))
for _, host := range hosts {
target, err := ParseHost(host)
if err != nil {
return nil, err
}
targets = append(targets, target)
}
return targets, nil
}

252
certlib/pkcs7/pkcs7.go Normal file
View File

@@ -0,0 +1,252 @@
package pkcs7
// Originally from CFSSL, and licensed under:
/*
Copyright (c) 2014 CloudFlare Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
// I've modified it for use in my own code e.g. by removing the CFSSL errors
// and replacing them with sane ones.
// Package pkcs7 implements the subset of the CMS PKCS #7 datatype that is typically
// used to package certificates and CRLs. Using openssl, every certificate converted
// to PKCS #7 format from another encoding such as PEM conforms to this implementation.
// reference: https://www.openssl.org/docs/man1.1.0/apps/crl2pkcs7.html
//
// PKCS #7 Data type, reference: https://tools.ietf.org/html/rfc2315
//
// The full pkcs#7 cryptographic message syntax allows for cryptographic enhancements,
// for example data can be encrypted and signed and then packaged through pkcs#7 to be
// sent over a network and then verified and decrypted. It is asn1, and the type of
// PKCS #7 ContentInfo, which comprises the PKCS #7 structure, is:
//
// ContentInfo ::= SEQUENCE {
// contentType ContentType,
// content [0] EXPLICIT ANY DEFINED BY contentType OPTIONAL
// }
//
// There are 6 possible ContentTypes, data, signedData, envelopedData,
// signedAndEnvelopedData, digestedData, and encryptedData. Here signedData, Data, and encrypted
// Data are implemented, as the degenerate case of signedData without a signature is the typical
// format for transferring certificates and CRLS, and Data and encryptedData are used in PKCS #12
// formats.
// The ContentType signedData has the form:
//
// signedData ::= SEQUENCE {
// version Version,
// digestAlgorithms DigestAlgorithmIdentifiers,
// contentInfo ContentInfo,
// certificates [0] IMPLICIT ExtendedCertificatesAndCertificates OPTIONAL
// crls [1] IMPLICIT CertificateRevocationLists OPTIONAL,
// signerInfos SignerInfos
// }
//
// As of yet signerInfos and digestAlgorithms are not parsed, as they are not relevant to
// this system's use of PKCS #7 data. Version is an integer type, note that PKCS #7 is
// recursive, this second layer of ContentInfo is similar ignored for our degenerate
// usage. The ExtendedCertificatesAndCertificates type consists of a sequence of choices
// between PKCS #6 extended certificates and x509 certificates. Any sequence consisting
// of any number of extended certificates is not yet supported in this implementation.
//
// The ContentType Data is simply a raw octet string and is parsed directly into a Go []byte slice.
//
// The ContentType encryptedData is the most complicated and its form can be gathered by
// the go type below. It essentially contains a raw octet string of encrypted data and an
// algorithm identifier for use in decrypting this data.
import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"errors"
"git.wntrmute.dev/kyle/goutils/certlib/certerr"
)
// Types used for asn1 Unmarshaling.
type signedData struct {
Version int
DigestAlgorithms asn1.RawValue
ContentInfo asn1.RawValue
Certificates asn1.RawValue `asn1:"optional"`
Crls asn1.RawValue `asn1:"optional"`
SignerInfos asn1.RawValue
}
type initPKCS7 struct {
Raw asn1.RawContent
ContentType asn1.ObjectIdentifier
Content asn1.RawValue `asn1:"tag:0,explicit,optional"`
}
// Object identifier strings of the three implemented PKCS7 types.
const (
ObjIDData = "1.2.840.113549.1.7.1"
ObjIDSignedData = "1.2.840.113549.1.7.2"
ObjIDEncryptedData = "1.2.840.113549.1.7.6"
)
// PKCS7 represents the ASN1 PKCS #7 Content type. It contains one of three
// possible types of Content objects, as denoted by the object identifier in
// the ContentInfo field, the other two being nil. SignedData
// is the degenerate SignedData Content info without signature used
// to hold certificates and crls. Data is raw bytes, and EncryptedData
// is as defined in PKCS #7 standard.
type PKCS7 struct {
Raw asn1.RawContent
ContentInfo string
Content Content
}
// Content implements three of the six possible PKCS7 data types. Only one is non-nil.
type Content struct {
Data []byte
SignedData SignedData
EncryptedData EncryptedData
}
// SignedData defines the typical carrier of certificates and crls.
type SignedData struct {
Raw asn1.RawContent
Version int
Certificates []*x509.Certificate
Crl *x509.RevocationList
}
// Data contains raw bytes. Used as a subtype in PKCS12.
type Data struct {
Bytes []byte
}
// EncryptedData contains encrypted data. Used as a subtype in PKCS12.
type EncryptedData struct {
Raw asn1.RawContent
Version int
EncryptedContentInfo EncryptedContentInfo
}
// EncryptedContentInfo is a subtype of PKCS7EncryptedData.
type EncryptedContentInfo struct {
Raw asn1.RawContent
ContentType asn1.ObjectIdentifier
ContentEncryptionAlgorithm pkix.AlgorithmIdentifier
EncryptedContent []byte `asn1:"tag:0,optional"`
}
func unmarshalInit(raw []byte) (initPKCS7, error) {
var init initPKCS7
if _, err := asn1.Unmarshal(raw, &init); err != nil {
return initPKCS7{}, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
return init, nil
}
func populateData(msg *PKCS7, content asn1.RawValue) error {
msg.ContentInfo = "Data"
_, err := asn1.Unmarshal(content.Bytes, &msg.Content.Data)
if err != nil {
return certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
return nil
}
func populateSignedData(msg *PKCS7, contentBytes []byte) error {
msg.ContentInfo = "SignedData"
var sd signedData
if _, err := asn1.Unmarshal(contentBytes, &sd); err != nil {
return certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
if len(sd.Certificates.Bytes) != 0 {
certs, err := x509.ParseCertificates(sd.Certificates.Bytes)
if err != nil {
return certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
msg.Content.SignedData.Certificates = certs
}
if len(sd.Crls.Bytes) != 0 {
crl, err := x509.ParseRevocationList(sd.Crls.Bytes)
if err != nil {
return certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
msg.Content.SignedData.Crl = crl
}
msg.Content.SignedData.Version = sd.Version
msg.Content.SignedData.Raw = contentBytes
return nil
}
func populateEncryptedData(msg *PKCS7, contentBytes []byte) error {
msg.ContentInfo = "EncryptedData"
var ed EncryptedData
if _, err := asn1.Unmarshal(contentBytes, &ed); err != nil {
return certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
if ed.Version != 0 {
return certerr.ParsingError(
certerr.ErrorSourceCertificate,
errors.New("only PKCS #7 encryptedData version 0 is supported"),
)
}
msg.Content.EncryptedData = ed
return nil
}
// ParsePKCS7 attempts to parse the DER encoded bytes of a
// PKCS7 structure.
func ParsePKCS7(raw []byte) (*PKCS7, error) {
pkcs7, err := unmarshalInit(raw)
if err != nil {
return nil, err
}
msg := new(PKCS7)
msg.Raw = pkcs7.Raw
msg.ContentInfo = pkcs7.ContentType.String()
switch msg.ContentInfo {
case ObjIDData:
if e := populateData(msg, pkcs7.Content); e != nil {
return nil, e
}
case ObjIDSignedData:
if e := populateSignedData(msg, pkcs7.Content.Bytes); e != nil {
return nil, e
}
case ObjIDEncryptedData:
if e := populateEncryptedData(msg, pkcs7.Content.Bytes); e != nil {
return nil, e
}
default:
return nil, certerr.ParsingError(
certerr.ErrorSourceCertificate,
errors.New("only PKCS# 7 content of type data, signed data or encrypted data can be parsed"),
)
}
return msg, nil
}

384
certlib/revoke/revoke.go Normal file
View File

@@ -0,0 +1,384 @@
// Package revoke provides functionality for checking the validity of
// a cert. Specifically, the temporal validity of the certificate is
// checked first, then any CRL and OCSP url in the cert is checked.
package revoke
import (
"bytes"
"context"
"crypto"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
neturl "net/url"
"sync"
"time"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/log"
"golang.org/x/crypto/ocsp"
)
// Originally from CFSSL, mostly written by me originally, and licensed under:
/*
Copyright (c) 2014 CloudFlare Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
// I've modified it for use in my own code e.g. by removing the CFSSL errors
// and replacing them with sane ones.
// HTTPClient is an instance of http.Client that will be used for all HTTP requests.
var HTTPClient = http.DefaultClient
// HardFail determines whether the failure to check the revocation
// status of a certificate (i.e. due to network failure) causes
// verification to fail (a hard failure).
var HardFail = false
// CRLSet associates a PKIX certificate list with the URL the CRL is
// fetched from.
var CRLSet = map[string]*x509.RevocationList{}
var crlLock = new(sync.Mutex)
// We can't handle LDAP certificates, so this checks to see if the
// URL string points to an LDAP resource so that we can ignore it.
func ldapURL(url string) bool {
u, err := neturl.Parse(url)
if err != nil {
log.Warningf("error parsing url %s: %v", url, err)
return false
}
if u.Scheme == "ldap" {
return true
}
return false
}
// revCheck should check the certificate for any revocations. It
// returns a pair of booleans: the first indicates whether the certificate
// is revoked, the second indicates whether the revocations were
// successfully checked.. This leads to the following combinations:
//
// - false, false: an error was encountered while checking revocations.
// - false, true: the certificate was checked successfully, and it is not revoked.
// - true, true: the certificate was checked successfully, and it is revoked.
// - true, false: failure to check revocation status causes verification to fail.
func revCheck(cert *x509.Certificate) (bool, bool, error) {
for _, url := range cert.CRLDistributionPoints {
if ldapURL(url) {
log.Infof("skipping LDAP CRL: %s", url)
continue
}
if rvk, ok2, err2 := certIsRevokedCRL(cert, url); !ok2 {
log.Warning("error checking revocation via CRL")
if HardFail {
return true, false, err2
}
return false, false, err2
} else if rvk {
log.Info("certificate is revoked via CRL")
return true, true, err2
}
}
if rvk, ok2, err2 := certIsRevokedOCSP(cert, HardFail); !ok2 {
log.Warning("error checking revocation via OCSP")
if HardFail {
return true, false, err2
}
return false, false, err2
} else if rvk {
log.Info("certificate is revoked via OCSP")
return true, true, err2
}
return false, true, nil
}
// fetchCRL fetches and parses a CRL.
func fetchCRL(url string) (*x509.RevocationList, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusMultipleChoices {
return nil, errors.New("failed to retrieve CRL")
}
body, err := crlRead(resp.Body)
if err != nil {
return nil, err
}
return x509.ParseRevocationList(body)
}
func getIssuer(cert *x509.Certificate) *x509.Certificate {
var issuer *x509.Certificate
var err error
for _, issuingCert := range cert.IssuingCertificateURL {
issuer, err = fetchRemote(issuingCert)
if err != nil {
continue
}
break
}
return issuer
}
// check a cert against a specific CRL. Returns the same bool pair
// as revCheck, plus an error if one occurred.
func certIsRevokedCRL(cert *x509.Certificate, url string) (bool, bool, error) {
crlLock.Lock()
crl, ok := CRLSet[url]
if ok && crl == nil {
ok = false
delete(CRLSet, url)
}
crlLock.Unlock()
var shouldFetchCRL = true
if ok {
if time.Now().After(crl.ThisUpdate) {
shouldFetchCRL = false
}
}
issuer := getIssuer(cert)
if shouldFetchCRL {
var err error
crl, err = fetchCRL(url)
if err != nil {
log.Warningf("failed to fetch CRL: %v", err)
return false, false, err
}
// check CRL signature
if issuer != nil {
if sigErr := crl.CheckSignatureFrom(issuer); sigErr != nil {
log.Warningf("failed to verify CRL: %v", sigErr)
return false, false, sigErr
}
}
crlLock.Lock()
CRLSet[url] = crl
crlLock.Unlock()
}
for _, entry := range crl.RevokedCertificateEntries {
if cert.SerialNumber.Cmp(entry.SerialNumber) == 0 {
log.Info("Serial number match: intermediate is revoked.")
return true, true, nil
}
}
return false, true, nil
}
// VerifyCertificate ensures that the certificate passed in hasn't
// expired and checks the CRL for the server.
func VerifyCertificate(cert *x509.Certificate) (bool, bool) {
revoked, ok, _ := VerifyCertificateError(cert)
return revoked, ok
}
// VerifyCertificateError ensures that the certificate passed in hasn't
// expired and checks the CRL for the server.
func VerifyCertificateError(cert *x509.Certificate) (bool, bool, error) {
if !time.Now().Before(cert.NotAfter) {
msg := fmt.Sprintf("Certificate expired %s\n", cert.NotAfter)
log.Info(msg)
return true, true, errors.New(msg)
} else if !time.Now().After(cert.NotBefore) {
msg := fmt.Sprintf("Certificate isn't valid until %s\n", cert.NotBefore)
log.Info(msg)
return true, true, errors.New(msg)
}
return revCheck(cert)
}
func fetchRemote(url string) (*x509.Certificate, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
in, err := remoteRead(resp.Body)
if err != nil {
return nil, err
}
p, _ := pem.Decode(in)
if p != nil {
return certlib.ParseCertificatePEM(in)
}
return x509.ParseCertificate(in)
}
var ocspOpts = ocsp.RequestOptions{
Hash: crypto.SHA1,
}
const ocspGetURLMaxLen = 256
func certIsRevokedOCSP(leaf *x509.Certificate, strict bool) (bool, bool, error) {
var revoked bool
var ok bool
var lastErr error
ocspURLs := leaf.OCSPServer
if len(ocspURLs) == 0 {
// OCSP not enabled for this certificate.
return false, true, nil
}
issuer := getIssuer(leaf)
if issuer == nil {
return false, false, nil
}
ocspRequest, err := ocsp.CreateRequest(leaf, issuer, &ocspOpts)
if err != nil {
return false, false, err
}
for _, server := range ocspURLs {
resp, e := sendOCSPRequest(server, ocspRequest, leaf, issuer)
if e != nil {
if strict {
return false, false, e
}
lastErr = e
continue
}
// There wasn't an error fetching the OCSP status.
ok = true
if resp.Status != ocsp.Good {
// The certificate was revoked.
revoked = true
}
return revoked, ok, nil
}
return revoked, ok, lastErr
}
// sendOCSPRequest attempts to request an OCSP response from the
// server. The error only indicates a failure to *fetch* the
// certificate, and *does not* mean the certificate is valid.
func sendOCSPRequest(server string, req []byte, leaf, issuer *x509.Certificate) (*ocsp.Response, error) {
var resp *http.Response
var err error
if len(req) > ocspGetURLMaxLen {
buf := bytes.NewBuffer(req)
httpReq, e := http.NewRequestWithContext(context.Background(), http.MethodPost, server, buf)
if e != nil {
return nil, e
}
httpReq.Header.Set("Content-Type", "application/ocsp-request")
resp, err = HTTPClient.Do(httpReq)
} else {
reqURL := server + "/" + neturl.QueryEscape(base64.StdEncoding.EncodeToString(req))
httpReq, e := http.NewRequestWithContext(context.Background(), http.MethodGet, reqURL, nil)
if e != nil {
return nil, e
}
resp, err = HTTPClient.Do(httpReq)
}
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("failed to retrieve OSCP")
}
body, err := ocspRead(resp.Body)
if err != nil {
return nil, err
}
switch {
case bytes.Equal(body, ocsp.UnauthorizedErrorResponse):
return nil, errors.New("OSCP unauthorized")
case bytes.Equal(body, ocsp.MalformedRequestErrorResponse):
return nil, errors.New("OSCP malformed")
case bytes.Equal(body, ocsp.InternalErrorErrorResponse):
return nil, errors.New("OSCP internal error")
case bytes.Equal(body, ocsp.TryLaterErrorResponse):
return nil, errors.New("OSCP try later")
case bytes.Equal(body, ocsp.SigRequredErrorResponse):
return nil, errors.New("OSCP signature required")
}
return ocsp.ParseResponseForCert(body, leaf, issuer)
}
var crlRead = io.ReadAll
// SetCRLFetcher sets the function to use to read from the http response body.
func SetCRLFetcher(fn func(io.Reader) ([]byte, error)) {
crlRead = fn
}
var remoteRead = io.ReadAll
// SetRemoteFetcher sets the function to use to read from the http response body.
func SetRemoteFetcher(fn func(io.Reader) ([]byte, error)) {
remoteRead = fn
}
var ocspRead = io.ReadAll
// SetOCSPFetcher sets the function to use to read from the http response body.
func SetOCSPFetcher(fn func(io.Reader) ([]byte, error)) {
ocspRead = fn
}

View File

@@ -0,0 +1,259 @@
//nolint:testpackage // keep tests in the same package for internal symbol access
package revoke
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"testing"
"time"
)
// Originally from CFSSL, mostly written by me originally, and licensed under:
/*
Copyright (c) 2014 CloudFlare Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
// I've modified it for use in my own code e.g. by removing the CFSSL errors
// and replacing them with sane ones.
// The first three test cases represent known revoked, expired, and good
// certificates that were checked on the date listed in the log. The
// good certificate will eventually need to be replaced in year 2029.
// If there is a soft-fail, the test will pass to mimic the default
// behaviour used in this software. However, it will print a warning
// to indicate that this is the case.
// 2014/05/22 14:18:17 Certificate expired 2014-04-04 14:14:20 +0000 UTC
// 2014/05/22 14:18:17 Revoked certificate: misc/intermediate_ca/ActalisServerAuthenticationCA.crt.
var expiredCert = mustParse(`-----BEGIN CERTIFICATE-----
MIIEXTCCA8agAwIBAgIEBycURTANBgkqhkiG9w0BAQUFADB1MQswCQYDVQQGEwJV
UzEYMBYGA1UEChMPR1RFIENvcnBvcmF0aW9uMScwJQYDVQQLEx5HVEUgQ3liZXJU
cnVzdCBTb2x1dGlvbnMsIEluYy4xIzAhBgNVBAMTGkdURSBDeWJlclRydXN0IEds
b2JhbCBSb290MB4XDTA3MDQwNDE0MTUxNFoXDTE0MDQwNDE0MTQyMFowejELMAkG
A1UEBhMCSVQxFzAVBgNVBAoTDkFjdGFsaXMgUy5wLkEuMScwJQYDVQQLEx5DZXJ0
aWZpY2F0aW9uIFNlcnZpY2UgUHJvdmlkZXIxKTAnBgNVBAMTIEFjdGFsaXMgU2Vy
dmVyIEF1dGhlbnRpY2F0aW9uIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAv6P0bhXbUQkVW8ox0HJ+sP5+j6pTwS7yg/wGEUektB/G1duQiT1v21fo
LANr6F353jILQDCpHIfal3MhbSsHEMKU7XaqsyLWV93bcIKbIloS/eXDfkog6KB3
u0JHgrtNz584Jg/OLm9feffNbCJ38TiLo0/UWkAQ6PQWaOwZEgyKjVI5F3swoTB3
g0LZAzegvkU00Kfp13cSg+cJeU4SajwtfQ+g6s6dlaekaHy/0ef46PfiHHRuhEhE
JWIpDtUN2ywTT33MSSUe5glDIiXYfcamJQrebzGsHEwyqI195Yaxb+FLNND4n3HM
e7EI2OrLyT+r/WMvQbl+xNihwtv+HwIDAQABo4IBbzCCAWswEgYDVR0TAQH/BAgw
BgEB/wIBADBTBgNVHSAETDBKMEgGCSsGAQQBsT4BADA7MDkGCCsGAQUFBwIBFi1o
dHRwOi8vd3d3LnB1YmxpYy10cnVzdC5jb20vQ1BTL09tbmlSb290Lmh0bWwwDgYD
VR0PAQH/BAQDAgEGMIGJBgNVHSMEgYEwf6F5pHcwdTELMAkGA1UEBhMCVVMxGDAW
BgNVBAoTD0dURSBDb3Jwb3JhdGlvbjEnMCUGA1UECxMeR1RFIEN5YmVyVHJ1c3Qg
U29sdXRpb25zLCBJbmMuMSMwIQYDVQQDExpHVEUgQ3liZXJUcnVzdCBHbG9iYWwg
Um9vdIICAaUwRQYDVR0fBD4wPDA6oDigNoY0aHR0cDovL3d3dy5wdWJsaWMtdHJ1
c3QuY29tL2NnaS1iaW4vQ1JMLzIwMTgvY2RwLmNybDAdBgNVHQ4EFgQUpi6OuXYt
oxHC3cTezVLuraWpAFEwDQYJKoZIhvcNAQEFBQADgYEAAtjJBwjsvw7DBs+v7BQz
gSGeg6nbYUuPL7+1driT5XsUKJ7WZjiwW2zW/WHZ+zGo1Ev8Dc574RpSrg/EIlfH
TpBiBuFgiKtJksKdoxPZGSI8FitwcgeW+y8wotmm0CtDzWN27g2kfSqHb5eHfZY5
sESPRwHkcMUNdAp37FLweUw=
-----END CERTIFICATE-----`)
// 2014/05/22 14:18:31 Serial number match: intermediate is revoked.
// 2014/05/22 14:18:31 certificate is revoked via CRL
// 2014/05/22 14:18:31 Revoked certificate: misc/intermediate_ca/MobileArmorEnterpriseCA.crt.
var revokedCert = mustParse(`-----BEGIN CERTIFICATE-----
MIIEEzCCAvugAwIBAgILBAAAAAABGMGjftYwDQYJKoZIhvcNAQEFBQAwcTEoMCYG
A1UEAxMfR2xvYmFsU2lnbiBSb290U2lnbiBQYXJ0bmVycyBDQTEdMBsGA1UECxMU
Um9vdFNpZ24gUGFydG5lcnMgQ0ExGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2Ex
CzAJBgNVBAYTAkJFMB4XDTA4MDMxODEyMDAwMFoXDTE4MDMxODEyMDAwMFowJTEj
MCEGA1UEAxMaTW9iaWxlIEFybW9yIEVudGVycHJpc2UgQ0EwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQCaEjeDR73jSZVlacRn5bc5VIPdyouHvGIBUxyS
C6483HgoDlWrWlkEndUYFjRPiQqJFthdJxfglykXD+btHixMIYbz/6eb7hRTdT9w
HKsfH+wTBIdb5AZiNjkg3QcCET5HfanJhpREjZWP513jM/GSrG3VwD6X5yttCIH1
NFTDAr7aqpW/UPw4gcPfkwS92HPdIkb2DYnsqRrnKyNValVItkxJiotQ1HOO3YfX
ivGrHIbJdWYg0rZnkPOgYF0d+aIA4ZfwvdW48+r/cxvLevieuKj5CTBZZ8XrFt8r
JTZhZljbZvnvq/t6ZIzlwOj082f+lTssr1fJ3JsIPnG2lmgTAgMBAAGjgfcwgfQw
DgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFIZw
ns4uzXdLX6xDRXUzFgZxWM7oME0GA1UdIARGMEQwQgYJKwYBBAGgMgE8MDUwMwYI
KwYBBQUHAgIwJxolaHR0cDovL3d3dy5nbG9iYWxzaWduLmNvbS9yZXBvc2l0b3J5
LzA/BgNVHR8EODA2MDSgMqAwhi5odHRwOi8vY3JsLmdsb2JhbHNpZ24ubmV0L1Jv
b3RTaWduUGFydG5lcnMuY3JsMB8GA1UdIwQYMBaAFFaE7LVxpedj2NtRBNb65vBI
UknOMA0GCSqGSIb3DQEBBQUAA4IBAQBZvf+2xUJE0ekxuNk30kPDj+5u9oI3jZyM
wvhKcs7AuRAbcxPtSOnVGNYl8By7DPvPun+U3Yci8540y143RgD+kz3jxIBaoW/o
c4+X61v6DBUtcBPEt+KkV6HIsZ61SZmc/Y1I2eoeEt6JYoLjEZMDLLvc1cK/+wpg
dUZSK4O9kjvIXqvsqIOlkmh/6puSugTNao2A7EIQr8ut0ZmzKzMyZ0BuQhJDnAPd
Kz5vh+5tmytUPKA8hUgmLWe94lMb7Uqq2wgZKsqun5DAWleKu81w7wEcOrjiiB+x
jeBHq7OnpWm+ccTOPCE6H4ZN4wWVS7biEBUdop/8HgXBPQHWAdjL
-----END CERTIFICATE-----`)
// A Comodo intermediate CA certificate with issuer url, CRL url and OCSP url.
var goodComodoCA = (`-----BEGIN CERTIFICATE-----
MIIGCDCCA/CgAwIBAgIQKy5u6tl1NmwUim7bo3yMBzANBgkqhkiG9w0BAQwFADCB
hTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNV
BAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTQwMjEy
MDAwMDAwWhcNMjkwMjExMjM1OTU5WjCBkDELMAkGA1UEBhMCR0IxGzAZBgNVBAgT
EkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMR
Q09NT0RPIENBIExpbWl0ZWQxNjA0BgNVBAMTLUNPTU9ETyBSU0EgRG9tYWluIFZh
bGlkYXRpb24gU2VjdXJlIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBAI7CAhnhoFmk6zg1jSz9AdDTScBkxwtiBUUWOqigwAwCfx3M28Sh
bXcDow+G+eMGnD4LgYqbSRutA776S9uMIO3Vzl5ljj4Nr0zCsLdFXlIvNN5IJGS0
Qa4Al/e+Z96e0HqnU4A7fK31llVvl0cKfIWLIpeNs4TgllfQcBhglo/uLQeTnaG6
ytHNe+nEKpooIZFNb5JPJaXyejXdJtxGpdCsWTWM/06RQ1A/WZMebFEh7lgUq/51
UHg+TLAchhP6a5i84DuUHoVS3AOTJBhuyydRReZw3iVDpA3hSqXttn7IzW3uLh0n
c13cRTCAquOyQQuvvUSH2rnlG51/ruWFgqUCAwEAAaOCAWUwggFhMB8GA1UdIwQY
MBaAFLuvfgI9+qbxPISOre44mOzZMjLUMB0GA1UdDgQWBBSQr2o6lFoL2JDqElZz
30O0Oija5zAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNV
HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwGwYDVR0gBBQwEjAGBgRVHSAAMAgG
BmeBDAECATBMBgNVHR8ERTBDMEGgP6A9hjtodHRwOi8vY3JsLmNvbW9kb2NhLmNv
bS9DT01PRE9SU0FDZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDBxBggrBgEFBQcB
AQRlMGMwOwYIKwYBBQUHMAKGL2h0dHA6Ly9jcnQuY29tb2RvY2EuY29tL0NPTU9E
T1JTQUFkZFRydXN0Q0EuY3J0MCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21v
ZG9jYS5jb20wDQYJKoZIhvcNAQEMBQADggIBAE4rdk+SHGI2ibp3wScF9BzWRJ2p
mj6q1WZmAT7qSeaiNbz69t2Vjpk1mA42GHWx3d1Qcnyu3HeIzg/3kCDKo2cuH1Z/
e+FE6kKVxF0NAVBGFfKBiVlsit2M8RKhjTpCipj4SzR7JzsItG8kO3KdY3RYPBps
P0/HEZrIqPW1N+8QRcZs2eBelSaz662jue5/DJpmNXMyYE7l3YphLG5SEXdoltMY
dVEVABt0iN3hxzgEQyjpFv3ZBdRdRydg1vs4O2xyopT4Qhrf7W8GjEXCBgCq5Ojc
2bXhc3js9iPc0d1sjhqPpepUfJa3w/5Vjo1JXvxku88+vZbrac2/4EjxYoIQ5QxG
V/Iz2tDIY+3GH5QFlkoakdH368+PUq4NCNk+qKBR6cGHdNXJ93SrLlP7u3r7l+L4
HyaPs9Kg4DdbKDsx5Q5XLVq4rXmsXiBmGqW5prU5wfWYQ//u+aen/e7KJD2AFsQX
j4rBYKEMrltDR5FL1ZoXX/nUh8HCjLfn4g8wGTeGrODcQgPmlKidrv0PJFGUzpII
0fxQ8ANAe4hZ7Q7drNJ3gjTcBpUC2JD5Leo31Rpg0Gcg19hCC0Wvgmje3WYkN5Ap
lBlGGSW4gNfL1IYoakRwJiNiqZ+Gb7+6kHDSVneFeO/qJakXzlByjAA6quPbYzSf
+AZxAeKCINT+b72x
-----END CERTIFICATE-----`)
var goodCert = mustParse(goodComodoCA)
func mustParse(pemData string) *x509.Certificate {
block, _ := pem.Decode([]byte(pemData))
if block == nil {
panic("Invalid PEM data.")
} else if block.Type != "CERTIFICATE" {
panic("Invalid PEM type.")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
panic(err.Error())
}
return cert
}
func TestRevoked(t *testing.T) {
if revoked, ok := VerifyCertificate(revokedCert); !ok {
fmt.Fprintf(os.Stderr, "Warning: soft fail checking revocation")
} else if !revoked {
t.Fatalf("revoked certificate should have been marked as revoked")
}
}
func TestExpired(t *testing.T) {
if revoked, ok := VerifyCertificate(expiredCert); !ok {
fmt.Fprintf(os.Stderr, "Warning: soft fail checking revocation")
} else if !revoked {
t.Fatalf("expired certificate should have been marked as revoked")
}
}
func TestGood(t *testing.T) {
if revoked, ok := VerifyCertificate(goodCert); !ok {
fmt.Fprintf(os.Stderr, "Warning: soft fail checking revocation")
} else if revoked {
t.Fatalf("good certificate should not have been marked as revoked")
}
}
func TestLdap(t *testing.T) {
ldapCert := mustParse(goodComodoCA)
ldapCert.CRLDistributionPoints = append(ldapCert.CRLDistributionPoints, "ldap://myldap.example.com")
if revoked, ok := VerifyCertificate(ldapCert); revoked || !ok {
t.Fatalf("ldap certificate should have been recognized")
}
}
func TestLdapURLErr(t *testing.T) {
if ldapURL(":") {
t.Fatalf("bad url does not cause error")
}
}
func TestCertNotYetValid(t *testing.T) {
notReadyCert := expiredCert
notReadyCert.NotBefore = time.Date(3000, time.January, 1, 1, 1, 1, 1, time.Local)
notReadyCert.NotAfter = time.Date(3005, time.January, 1, 1, 1, 1, 1, time.Local)
if revoked, _ := VerifyCertificate(expiredCert); !revoked {
t.Fatalf("not yet verified certificate should have been marked as revoked")
}
}
func TestCRLFetchError(t *testing.T) {
ldapCert := mustParse(goodComodoCA)
ldapCert.CRLDistributionPoints[0] = ""
if revoked, ok := VerifyCertificate(ldapCert); ok || revoked {
t.Fatalf("Fetching error not encountered")
}
HardFail = true
if revoked, ok := VerifyCertificate(ldapCert); ok || !revoked {
t.Fatalf("Fetching error not encountered, hardfail not registered")
}
HardFail = false
}
func TestBadCRLSet(t *testing.T) {
ldapCert := mustParse(goodComodoCA)
ldapCert.CRLDistributionPoints[0] = ""
CRLSet[""] = nil
certIsRevokedCRL(ldapCert, "")
if _, ok := CRLSet[""]; ok {
t.Fatalf("key emptystring should be deleted from CRLSet")
}
delete(CRLSet, "")
}
func TestCachedCRLSet(t *testing.T) {
VerifyCertificate(goodCert)
if revoked, ok := VerifyCertificate(goodCert); !ok || revoked {
t.Fatalf("Previously fetched CRL's should be read smoothly and unrevoked")
}
}
func TestRemoteFetchError(t *testing.T) {
badurl := ":"
if _, err := fetchRemote(badurl); err == nil {
t.Fatalf("fetching bad url should result in non-nil error")
}
}
func TestNoOCSPServers(t *testing.T) {
badIssuer := goodCert
badIssuer.IssuingCertificateURL = []string{" "}
certIsRevokedOCSP(badIssuer, true)
noOCSPCert := goodCert
noOCSPCert.OCSPServer = make([]string, 0)
if revoked, ok, _ := certIsRevokedOCSP(noOCSPCert, true); revoked || !ok {
t.Fatalf("OCSP falsely registered as enabled for this certificate")
}
}

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"flag"
"fmt"
"net"
@@ -28,10 +29,16 @@ func connect(addr string, dport string, six bool, timeout time.Duration) error {
if verbose {
fmt.Printf("connecting to %s/%s... ", addr, proto)
os.Stdout.Sync()
if err = os.Stdout.Sync(); err != nil {
return err
}
}
conn, err := net.DialTimeout(proto, addr, timeout)
dialer := &net.Dialer{
Timeout: timeout,
}
conn, err := dialer.DialContext(context.Background(), proto, addr)
if err != nil {
if verbose {
fmt.Println("failed.")
@@ -42,8 +49,8 @@ func connect(addr string, dport string, six bool, timeout time.Duration) error {
if verbose {
fmt.Println("OK")
}
conn.Close()
return nil
return conn.Close()
}
func main() {

40
cmd/ca-signed/README.txt Normal file
View File

@@ -0,0 +1,40 @@
ca-signed: verify certificates against a CA
-------------------------------------------
Description
ca-signed verifies whether one or more certificates are signed by a given
Certificate Authority (CA). It prints a concise status per input certificate
along with the certificates expiration date when validation succeeds.
Usage
ca-signed CA.pem cert1.pem [cert2.pem ...]
- CA.pem: A file containing one or more CA certificates in PEM, DER, or PKCS#7/PKCS#12 formats.
- certN.pem: A file containing the end-entity (leaf) certificate to verify. If the file contains a chain,
the first certificate is treated as the leaf and the remaining ones are used as intermediates.
Output format
For each input certificate file, one line is printed:
<filename>: OK (expires YYYY-MM-DD)
<filename>: INVALID
Special self-test mode
ca-signed selftest
Runs a built-in test suite using embedded certificates. This mode requires no
external files or network access. The program exits with code 0 if all tests
pass, or a non-zero exit code if any test fails. Example output lines include
whether validation succeeds and the leafs expiration when applicable.
Examples
# Verify a server certificate against a root CA
ca-signed isrg-root-x1.pem le-e7.pem
# Run the embedded self-test suite
ca-signed selftest
Notes
- The tool attempts to parse certificates in PEM first, then falls back to
DER/PKCS#7/PKCS#12 (with an empty password) where applicable.
- Expiration is shown for the leaf certificate only.
- In selftest mode, test certificates are compiled into the binary using go:embed.

325
cmd/ca-signed/main.go Normal file
View File

@@ -0,0 +1,325 @@
package main
import (
"crypto/x509"
"embed"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"git.wntrmute.dev/kyle/goutils/certlib"
)
// loadCertsFromFile attempts to parse certificates from a file that may be in
// PEM or DER/PKCS#7 format. Returns the parsed certificates or an error.
func loadCertsFromFile(path string) ([]*x509.Certificate, error) {
var certs []*x509.Certificate
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if certs, err = certlib.ParseCertificatesPEM(data); err == nil {
return certs, nil
}
if certs, _, err = certlib.ParseCertificatesDER(data, ""); err == nil {
return certs, nil
}
return nil, err
}
func makePoolFromFile(path string) (*x509.CertPool, error) {
// Try PEM via helper (it builds a pool)
if pool, err := certlib.LoadPEMCertPool(path); err == nil && pool != nil {
return pool, nil
}
// Fallback: read as DER(s), add to a new pool
certs, err := loadCertsFromFile(path)
if err != nil || len(certs) == 0 {
return nil, fmt.Errorf("failed to load CA certificates from %s", path)
}
pool := x509.NewCertPool()
for _, c := range certs {
pool.AddCert(c)
}
return pool, nil
}
//go:embed testdata/*.pem
var embeddedTestdata embed.FS
// loadCertsFromBytes attempts to parse certificates from bytes that may be in
// PEM or DER/PKCS#7 format.
func loadCertsFromBytes(data []byte) ([]*x509.Certificate, error) {
certs, err := certlib.ParseCertificatesPEM(data)
if err == nil {
return certs, nil
}
certs, _, err = certlib.ParseCertificatesDER(data, "")
if err == nil {
return certs, nil
}
return nil, err
}
func makePoolFromBytes(data []byte) (*x509.CertPool, error) {
certs, err := loadCertsFromBytes(data)
if err != nil || len(certs) == 0 {
return nil, errors.New("failed to load CA certificates from embedded bytes")
}
pool := x509.NewCertPool()
for _, c := range certs {
pool.AddCert(c)
}
return pool, nil
}
// isSelfSigned returns true if the given certificate is self-signed.
// It checks that the subject and issuer match and that the certificate's
// signature verifies against its own public key.
func isSelfSigned(cert *x509.Certificate) bool {
if cert == nil {
return false
}
// Quick check: subject and issuer match
if cert.Subject.String() != cert.Issuer.String() {
return false
}
// Cryptographic check: the certificate is signed by itself
if err := cert.CheckSignatureFrom(cert); err != nil {
return false
}
return true
}
func verifyAgainstCA(caPool *x509.CertPool, path string) (bool, string) {
certs, err := loadCertsFromFile(path)
if err != nil || len(certs) == 0 {
return false, ""
}
leaf := certs[0]
ints := x509.NewCertPool()
if len(certs) > 1 {
for _, ic := range certs[1:] {
ints.AddCert(ic)
}
}
opts := x509.VerifyOptions{
Roots: caPool,
Intermediates: ints,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}
if _, err = leaf.Verify(opts); err != nil {
return false, ""
}
return true, leaf.NotAfter.Format("2006-01-02")
}
func verifyAgainstCABytes(caPool *x509.CertPool, certData []byte) (bool, string) {
certs, err := loadCertsFromBytes(certData)
if err != nil || len(certs) == 0 {
return false, ""
}
leaf := certs[0]
ints := x509.NewCertPool()
if len(certs) > 1 {
for _, ic := range certs[1:] {
ints.AddCert(ic)
}
}
opts := x509.VerifyOptions{
Roots: caPool,
Intermediates: ints,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}
if _, err = leaf.Verify(opts); err != nil {
return false, ""
}
return true, leaf.NotAfter.Format("2006-01-02")
}
type testCase struct {
name string
caFile string
certFile string
expectOK bool
}
func (tc testCase) Run() error {
caBytes, err := embeddedTestdata.ReadFile(tc.caFile)
if err != nil {
return fmt.Errorf("selftest: failed to read embedded %s: %w", tc.caFile, err)
}
certBytes, err := embeddedTestdata.ReadFile(tc.certFile)
if err != nil {
return fmt.Errorf("selftest: failed to read embedded %s: %w", tc.certFile, err)
}
pool, err := makePoolFromBytes(caBytes)
if err != nil || pool == nil {
return fmt.Errorf("selftest: failed to build CA pool for %s: %w", tc.caFile, err)
}
ok, exp := verifyAgainstCABytes(pool, certBytes)
if ok != tc.expectOK {
return fmt.Errorf("%s: unexpected result: got %v, want %v", tc.name, ok, tc.expectOK)
}
if ok {
fmt.Printf("%s: OK (expires %s)\n", tc.name, exp)
}
fmt.Printf("%s: INVALID (as expected)\n", tc.name)
return nil
}
var cases = []testCase{
{
name: "ISRG Root X1 validates LE E7",
caFile: "testdata/isrg-root-x1.pem",
certFile: "testdata/le-e7.pem",
expectOK: true,
},
{
name: "ISRG Root X1 does NOT validate Google WR2",
caFile: "testdata/isrg-root-x1.pem",
certFile: "testdata/goog-wr2.pem",
expectOK: false,
},
{
name: "GTS R1 validates Google WR2",
caFile: "testdata/gts-r1.pem",
certFile: "testdata/goog-wr2.pem",
expectOK: true,
},
{
name: "GTS R1 does NOT validate LE E7",
caFile: "testdata/gts-r1.pem",
certFile: "testdata/le-e7.pem",
expectOK: false,
},
}
// selftest runs built-in validation using embedded certificates.
func selftest() int {
failures := 0
for _, tc := range cases {
err := tc.Run()
if err != nil {
fmt.Fprintln(os.Stderr, err)
failures++
continue
}
}
// Verify that both embedded root CAs are detected as self-signed
roots := []string{"testdata/gts-r1.pem", "testdata/isrg-root-x1.pem"}
for _, root := range roots {
b, err := embeddedTestdata.ReadFile(root)
if err != nil {
fmt.Fprintf(os.Stderr, "selftest: failed to read embedded %s: %v\n", root, err)
failures++
continue
}
certs, err := loadCertsFromBytes(b)
if err != nil || len(certs) == 0 {
fmt.Fprintf(os.Stderr, "selftest: failed to parse cert(s) from %s: %v\n", root, err)
failures++
continue
}
leaf := certs[0]
if isSelfSigned(leaf) {
fmt.Printf("%s: SELF-SIGNED (as expected)\n", root)
} else {
fmt.Printf("%s: expected SELF-SIGNED, but was not detected as such\n", root)
failures++
}
}
if failures == 0 {
fmt.Println("selftest: PASS")
return 0
}
fmt.Fprintf(os.Stderr, "selftest: FAIL (%d failure(s))\n", failures)
return 1
}
// expiryString returns a YYYY-MM-DD date string to display for certificate
// expiry. If an explicit exp string is provided, it is used. Otherwise, if a
// leaf certificate is available, its NotAfter is formatted. As a last resort,
// it falls back to today's date (should not normally happen).
func expiryString(leaf *x509.Certificate, exp string) string {
if exp != "" {
return exp
}
if leaf != nil {
return leaf.NotAfter.Format("2006-01-02")
}
return time.Now().Format("2006-01-02")
}
// processCert verifies a single certificate file against the provided CA pool
// and prints the result in the required format, handling self-signed
// certificates specially.
func processCert(caPool *x509.CertPool, certPath string) {
ok, exp := verifyAgainstCA(caPool, certPath)
name := filepath.Base(certPath)
// Try to load the leaf cert for self-signed detection and expiry fallback
var leaf *x509.Certificate
if certs, err := loadCertsFromFile(certPath); err == nil && len(certs) > 0 {
leaf = certs[0]
}
// Prefer the SELF-SIGNED label if applicable
if isSelfSigned(leaf) {
fmt.Printf("%s: SELF-SIGNED\n", name)
return
}
if ok {
fmt.Printf("%s: OK (expires %s)\n", name, expiryString(leaf, exp))
return
}
fmt.Printf("%s: INVALID\n", name)
}
func main() {
// Special selftest mode: single argument "selftest"
if len(os.Args) == 2 && os.Args[1] == "selftest" {
os.Exit(selftest())
}
if len(os.Args) < 3 {
prog := filepath.Base(os.Args[0])
fmt.Fprintf(os.Stderr, "Usage:\n %s ca.pem cert1.pem cert2.pem ...\n %s selftest\n", prog, prog)
os.Exit(2)
}
caPath := os.Args[1]
caPool, err := makePoolFromFile(caPath)
if err != nil || caPool == nil {
fmt.Fprintf(os.Stderr, "failed to load CA certificate(s): %v\n", err)
os.Exit(1)
}
for _, certPath := range os.Args[2:] {
processCert(caPool, certPath)
}
}

29
cmd/ca-signed/testdata/goog-wr2.pem vendored Normal file
View File

@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFCzCCAvOgAwIBAgIQf/AFoHxM3tEArZ1mpRB7mDANBgkqhkiG9w0BAQsFADBH
MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExM
QzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMjMxMjEzMDkwMDAwWhcNMjkwMjIw
MTQwMDAwWjA7MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNl
cnZpY2VzMQwwCgYDVQQDEwNXUjIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQCp/5x/RR5wqFOfytnlDd5GV1d9vI+aWqxG8YSau5HbyfsvAfuSCQAWXqAc
+MGr+XgvSszYhaLYWTwO0xj7sfUkDSbutltkdnwUxy96zqhMt/TZCPzfhyM1IKji
aeKMTj+xWfpgoh6zySBTGYLKNlNtYE3pAJH8do1cCA8Kwtzxc2vFE24KT3rC8gIc
LrRjg9ox9i11MLL7q8Ju26nADrn5Z9TDJVd06wW06Y613ijNzHoU5HEDy01hLmFX
xRmpC5iEGuh5KdmyjS//V2pm4M6rlagplmNwEmceOuHbsCFx13ye/aoXbv4r+zgX
FNFmp6+atXDMyGOBOozAKql2N87jAgMBAAGjgf4wgfswDgYDVR0PAQH/BAQDAgGG
MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/
AgEAMB0GA1UdDgQWBBTeGx7teRXUPjckwyG77DQ5bUKyMDAfBgNVHSMEGDAWgBTk
rysmcRorSCeFL1JmLO/wiRNxPjA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAKG
GGh0dHA6Ly9pLnBraS5nb29nL3IxLmNydDArBgNVHR8EJDAiMCCgHqAchhpodHRw
Oi8vYy5wa2kuZ29vZy9yL3IxLmNybDATBgNVHSAEDDAKMAgGBmeBDAECATANBgkq
hkiG9w0BAQsFAAOCAgEARXWL5R87RBOWGqtY8TXJbz3S0DNKhjO6V1FP7sQ02hYS
TL8Tnw3UVOlIecAwPJQl8hr0ujKUtjNyC4XuCRElNJThb0Lbgpt7fyqaqf9/qdLe
SiDLs/sDA7j4BwXaWZIvGEaYzq9yviQmsR4ATb0IrZNBRAq7x9UBhb+TV+PfdBJT
DhEl05vc3ssnbrPCuTNiOcLgNeFbpwkuGcuRKnZc8d/KI4RApW//mkHgte8y0YWu
ryUJ8GLFbsLIbjL9uNrizkqRSvOFVU6xddZIMy9vhNkSXJ/UcZhjJY1pXAprffJB
vei7j+Qi151lRehMCofa6WBmiA4fx+FOVsV2/7R6V2nyAiIJJkEd2nSi5SnzxJrl
Xdaqev3htytmOPvoKWa676ATL/hzfvDaQBEcXd2Ppvy+275W+DKcH0FBbX62xevG
iza3F4ydzxl6NJ8hk8R+dDXSqv1MbRT1ybB5W0k8878XSOjvmiYTDIfyc9acxVJr
Y/cykHipa+te1pOhv7wYPYtZ9orGBV5SGOJm4NrB3K1aJar0RfzxC3ikr7Dyc6Qw
qDTBU39CluVIQeuQRgwG3MuSxl7zRERDRilGoKb8uY45JzmxWuKxrfwT/478JuHU
/oTxUFqOl2stKnn7QGTq8z29W+GgBLCXSBxC9epaHM0myFH/FJlniXJfHeytWt0=
-----END CERTIFICATE-----

31
cmd/ca-signed/testdata/gts-r1.pem vendored Normal file
View File

@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw
MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp
Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo
27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w
Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw
TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl
qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH
szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8
Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk
MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92
wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p
aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN
VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID
AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb
C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe
QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy
h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4
7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J
ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef
MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/
Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT
6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ
0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm
2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb
bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c
-----END CERTIFICATE-----

31
cmd/ca-signed/testdata/isrg-root-x1.pem vendored Normal file
View File

@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----

26
cmd/ca-signed/testdata/le-e7.pem vendored Normal file
View File

@@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEVzCCAj+gAwIBAgIRAKp18eYrjwoiCWbTi7/UuqEwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCRTcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARB6AST
CFh/vjcwDMCgQer+VtqEkz7JANurZxLP+U9TCeioL6sp5Z8VRvRbYk4P1INBmbef
QHJFHCxcSjKmwtvGBWpl/9ra8HW0QDsUaJW2qOJqceJ0ZVFT3hbUHifBM/2jgfgw
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSuSJ7chx1EoG/aouVgdAR4
wpwAgDAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAjx66fDdLk5ywFn3CzA1w1qfylHUD
aEf0QZpXcJseddJGSfbUUOvbNR9N/QQ16K1lXl4VFyhmGXDT5Kdfcr0RvIIVrNxF
h4lqHtRRCP6RBRstqbZ2zURgqakn/Xip0iaQL0IdfHBZr396FgknniRYFckKORPG
yM3QKnd66gtMst8I5nkRQlAg/Jb+Gc3egIvuGKWboE1G89NTsN9LTDD3PLj0dUMr
OIuqVjLB8pEC6yk9enrlrqjXQgkLEYhXzq7dLafv5Vkig6Gl0nuuqjqfp0Q1bi1o
yVNAlXe6aUXw92CcghC9bNsKEO1+M52YY5+ofIXlS/SEQbvVYYBLZ5yeiglV6t3S
M6H+vTG0aP9YHzLn/KVOHzGQfXDP7qM5tkf+7diZe7o2fw6O7IvN6fsQXEQQj8TJ
UXJxv2/uJhcuy/tSDgXwHM8Uk34WNbRT7zGTGkQRX0gsbjAea/jYAoWv0ZvQRwpq
Pe79D/i7Cep8qWnA+7AE/3B3S/3dEEYmc0lpe1366A/6GEgk3ktr9PEoQrLChs6I
tu3wnNLB2euC8IKGLQFpGtOO/2/hiAKjyajaBP25w1jF0Wl8Bbqne3uZ2q1GyPFJ
YRmT7/OXpmOH/FVLtwS+8ng1cAmpCujPwteJZNcDG0sF2n/sc0+SQf49fdyUK0ty
+VUwFj9tmWxyR/M=
-----END CERTIFICATE-----

148
cmd/cert-bundler/README.txt Normal file
View File

@@ -0,0 +1,148 @@
cert-bundler: create certificate chain archives
------------------------------------------------
Description
cert-bundler creates archives of certificate chains from a YAML configuration
file. It validates certificates, checks expiration dates, and generates
archives in multiple formats (zip, tar.gz) with optional manifest files
containing SHA256 checksums.
Usage
cert-bundler [options]
Options:
-c <file> Path to YAML configuration file (default: bundle.yaml)
-o <dir> Output directory for archives (default: pkg)
YAML Configuration Format
The configuration file uses the following structure:
config:
hashes: <filename>
expiry: <duration>
chains:
<group_name>:
certs:
- root: <path>
intermediates:
- <path>
- <path>
- root: <path>
intermediates:
- <path>
outputs:
include_single: <bool>
include_individual: <bool>
manifest: <bool>
encoding: <encoding>
formats:
- <format>
- <format>
Configuration Fields
config:
hashes: (optional) Name of the file to write SHA256 checksums of all
generated archives. If omitted, no hash file is created.
expiry: (optional) Expiration warning threshold. Certificates expiring
within this period will trigger a warning. Supports formats like
"1y" (year), "6m" (month), "30d" (day). Default: 1y
chains:
Each key under "chains" defines a named certificate group. All certificates
in a group are bundled together into archives with the group name.
certs:
List of certificate chains. Each chain has:
root: Path to root CA certificate (PEM or DER format)
intermediates: List of paths to intermediate certificates
All intermediates are validated against their root CA. An error is
reported if signature verification fails.
outputs:
Defines output formats and content for the group's archives:
include_single: (bool) If true, all certificates in the group are
concatenated into a single file named "bundle.pem"
(or "bundle.crt" for DER encoding).
include_individual: (bool) If true, each certificate is included as
a separate file in the archive, named after the
original file (e.g., "int/cca2.pem" becomes
"cca2.pem").
manifest: (bool) If true, a MANIFEST file is included containing
SHA256 checksums of all files in the archive.
encoding: Specifies certificate encoding in the archive:
- "pem": PEM format with .pem extension (default)
- "der": DER format with .crt extension
- "both": Both PEM and DER versions are included
formats: List of archive formats to generate:
- "zip": Creates a .zip archive
- "tgz": Creates a .tar.gz archive
Output Files
For each group and format combination, an archive is created:
<group_name>.zip or <group_name>.tar.gz
If config.hashes is specified, a hash file is created in the output directory
containing SHA256 checksums of all generated archives.
Example Configuration
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
encoding: pem
formats:
- zip
- tgz
This configuration:
- Creates core_certs.zip and core_certs.tar.gz in the output directory
- Each archive contains bundle.pem (all certificates concatenated)
- Each archive contains individual certificates (core-ca.pem, cca1.pem, etc.)
- Each archive includes a MANIFEST file with SHA256 checksums
- Creates bundle.sha256 with checksums of the two archives
- Warns if any certificate expires within 1 year
Examples
# Create bundles using default configuration (bundle.yaml -> pkg/)
cert-bundler
# Use custom configuration and output directory
cert-bundler -c myconfig.yaml -o output
# Create bundles from testdata configuration
cert-bundler -c testdata/bundle.yaml -o testdata/pkg
Notes
- Certificate paths in the YAML are relative to the current working directory
- All intermediates must be properly signed by their specified root CA
- Certificates are checked for expiration; warnings are printed to stderr
- Expired certificates do not prevent archive creation but generate warnings
- Both PEM and DER certificate formats are supported as input
- Archive filenames use the group name, not individual chain names
- If both include_single and include_individual are true, archives contain both

575
cmd/cert-bundler/main.go Normal file
View File

@@ -0,0 +1,575 @@
package main
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"crypto/x509"
_ "embed"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.wntrmute.dev/kyle/goutils/certlib"
"gopkg.in/yaml.v2"
)
// Config represents the top-level YAML configuration.
type Config struct {
Config struct {
Hashes string `yaml:"hashes"`
Expiry string `yaml:"expiry"`
} `yaml:"config"`
Chains map[string]ChainGroup `yaml:"chains"`
}
// ChainGroup represents a named group of certificate chains.
type ChainGroup struct {
Certs []CertChain `yaml:"certs"`
Outputs Outputs `yaml:"outputs"`
}
// CertChain represents a root certificate and its intermediates.
type CertChain struct {
Root string `yaml:"root"`
Intermediates []string `yaml:"intermediates"`
}
// Outputs defines output format options.
type Outputs struct {
IncludeSingle bool `yaml:"include_single"`
IncludeIndividual bool `yaml:"include_individual"`
Manifest bool `yaml:"manifest"`
Formats []string `yaml:"formats"`
Encoding string `yaml:"encoding"`
}
var (
configFile string
outputDir string
)
var formatExtensions = map[string]string{
"zip": ".zip",
"tgz": ".tar.gz",
}
//go:embed README.txt
var readmeContent string
func usage() {
fmt.Fprint(os.Stderr, readmeContent)
}
func main() {
flag.Usage = usage
flag.StringVar(&configFile, "c", "bundle.yaml", "path to YAML configuration file")
flag.StringVar(&outputDir, "o", "pkg", "output directory for archives")
flag.Parse()
if configFile == "" {
fmt.Fprintf(os.Stderr, "Error: configuration file required (-c flag)\n")
os.Exit(1)
}
// Load and parse configuration
cfg, err := loadConfig(configFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
// Parse expiry duration (default 1 year)
expiryDuration := 365 * 24 * time.Hour
if cfg.Config.Expiry != "" {
expiryDuration, err = parseDuration(cfg.Config.Expiry)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing expiry: %v\n", err)
os.Exit(1)
}
}
// Create output directory if it doesn't exist
err = os.MkdirAll(outputDir, 0750)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err)
os.Exit(1)
}
// Process each chain group
// Pre-allocate createdFiles based on total number of formats across all groups
totalFormats := 0
for _, group := range cfg.Chains {
totalFormats += len(group.Outputs.Formats)
}
createdFiles := make([]string, 0, totalFormats)
for groupName, group := range cfg.Chains {
files, perr := processChainGroup(groupName, group, expiryDuration)
if perr != nil {
fmt.Fprintf(os.Stderr, "Error processing chain group %s: %v\n", groupName, perr)
os.Exit(1)
}
createdFiles = append(createdFiles, files...)
}
// Generate hash file for all created archives
if cfg.Config.Hashes != "" {
hashFile := filepath.Join(outputDir, cfg.Config.Hashes)
if gerr := generateHashFile(hashFile, createdFiles); gerr != nil {
fmt.Fprintf(os.Stderr, "Error generating hash file: %v\n", gerr)
os.Exit(1)
}
}
fmt.Println("Certificate bundling completed successfully")
}
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if uerr := yaml.Unmarshal(data, &cfg); uerr != nil {
return nil, uerr
}
return &cfg, nil
}
func parseDuration(s string) (time.Duration, error) {
// Support simple formats like "1y", "6m", "30d"
if len(s) < 2 {
return 0, fmt.Errorf("invalid duration format: %s", s)
}
unit := s[len(s)-1]
value := s[:len(s)-1]
var multiplier time.Duration
switch unit {
case 'y', 'Y':
multiplier = 365 * 24 * time.Hour
case 'm', 'M':
multiplier = 30 * 24 * time.Hour
case 'd', 'D':
multiplier = 24 * time.Hour
default:
return time.ParseDuration(s)
}
var num int
_, err := fmt.Sscanf(value, "%d", &num)
if err != nil {
return 0, fmt.Errorf("invalid duration value: %s", s)
}
return time.Duration(num) * multiplier, nil
}
func processChainGroup(groupName string, group ChainGroup, expiryDuration time.Duration) ([]string, error) {
// Default encoding to "pem" if not specified
encoding := group.Outputs.Encoding
if encoding == "" {
encoding = "pem"
}
// Collect certificates from all chains in the group
singleFileCerts, individualCerts, err := loadAndCollectCerts(group.Certs, group.Outputs, expiryDuration)
if err != nil {
return nil, err
}
// Prepare files for inclusion in archives
archiveFiles, err := prepareArchiveFiles(singleFileCerts, individualCerts, group.Outputs, encoding)
if err != nil {
return nil, err
}
// Create archives for the entire group
createdFiles, err := createArchiveFiles(groupName, group.Outputs.Formats, archiveFiles)
if err != nil {
return nil, err
}
return createdFiles, nil
}
// loadAndCollectCerts loads all certificates from chains and collects them for processing.
func loadAndCollectCerts(
chains []CertChain,
outputs Outputs,
expiryDuration time.Duration,
) ([]*x509.Certificate, []certWithPath, error) {
var singleFileCerts []*x509.Certificate
var individualCerts []certWithPath
for _, chain := range chains {
s, i, cerr := collectFromChain(chain, outputs, expiryDuration)
if cerr != nil {
return nil, nil, cerr
}
if len(s) > 0 {
singleFileCerts = append(singleFileCerts, s...)
}
if len(i) > 0 {
individualCerts = append(individualCerts, i...)
}
}
return singleFileCerts, individualCerts, nil
}
// collectFromChain loads a single chain, performs checks, and returns the certs to include.
func collectFromChain(
chain CertChain,
outputs Outputs,
expiryDuration time.Duration,
) (
[]*x509.Certificate,
[]certWithPath,
error,
) {
var single []*x509.Certificate
var indiv []certWithPath
// Load root certificate
rootCert, rerr := certlib.LoadCertificate(chain.Root)
if rerr != nil {
return nil, nil, fmt.Errorf("failed to load root certificate %s: %w", chain.Root, rerr)
}
// Check expiry for root
checkExpiry(chain.Root, rootCert, expiryDuration)
// Add root to collections if needed
if outputs.IncludeSingle {
single = append(single, rootCert)
}
if outputs.IncludeIndividual {
indiv = append(indiv, certWithPath{cert: rootCert, path: chain.Root})
}
// Load and validate intermediates
for _, intPath := range chain.Intermediates {
intCert, lerr := certlib.LoadCertificate(intPath)
if lerr != nil {
return nil, nil, fmt.Errorf("failed to load intermediate certificate %s: %w", intPath, lerr)
}
// Validate that intermediate is signed by root
if sigErr := intCert.CheckSignatureFrom(rootCert); sigErr != nil {
return nil, nil, fmt.Errorf(
"intermediate %s is not properly signed by root %s: %w",
intPath,
chain.Root,
sigErr,
)
}
// Check expiry for intermediate
checkExpiry(intPath, intCert, expiryDuration)
// Add intermediate to collections if needed
if outputs.IncludeSingle {
single = append(single, intCert)
}
if outputs.IncludeIndividual {
indiv = append(indiv, certWithPath{cert: intCert, path: intPath})
}
}
return single, indiv, nil
}
// prepareArchiveFiles prepares all files to be included in archives.
func prepareArchiveFiles(
singleFileCerts []*x509.Certificate,
individualCerts []certWithPath,
outputs Outputs,
encoding string,
) ([]fileEntry, error) {
var archiveFiles []fileEntry
// 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)
}
archiveFiles = append(archiveFiles, files...)
}
// Handle individual files
if outputs.IncludeIndividual {
for _, cp := range individualCerts {
baseName := strings.TrimSuffix(filepath.Base(cp.path), filepath.Ext(cp.path))
files, err := encodeCertsToFiles([]*x509.Certificate{cp.cert}, baseName, encoding, false)
if err != nil {
return nil, fmt.Errorf("failed to encode individual cert %s: %w", cp.path, err)
}
archiveFiles = append(archiveFiles, files...)
}
}
// Generate manifest if requested
if outputs.Manifest {
manifestContent := generateManifest(archiveFiles)
archiveFiles = append(archiveFiles, fileEntry{
name: "MANIFEST",
content: manifestContent,
})
}
return archiveFiles, nil
}
// createArchiveFiles creates archive files in the specified formats.
func createArchiveFiles(groupName string, formats []string, archiveFiles []fileEntry) ([]string, error) {
createdFiles := make([]string, 0, len(formats))
for _, format := range formats {
ext, ok := formatExtensions[format]
if !ok {
return nil, fmt.Errorf("unsupported format: %s", format)
}
archivePath := filepath.Join(outputDir, groupName+ext)
switch format {
case "zip":
if err := createZipArchive(archivePath, archiveFiles); err != nil {
return nil, fmt.Errorf("failed to create zip archive: %w", err)
}
case "tgz":
if err := createTarGzArchive(archivePath, archiveFiles); err != nil {
return nil, fmt.Errorf("failed to create tar.gz archive: %w", err)
}
default:
return nil, fmt.Errorf("unsupported format: %s", format)
}
createdFiles = append(createdFiles, archivePath)
}
return createdFiles, nil
}
func checkExpiry(path string, cert *x509.Certificate, expiryDuration time.Duration) {
now := time.Now()
expiryThreshold := now.Add(expiryDuration)
if cert.NotAfter.Before(expiryThreshold) {
daysUntilExpiry := int(cert.NotAfter.Sub(now).Hours() / 24)
if daysUntilExpiry < 0 {
fmt.Fprintf(
os.Stderr,
"WARNING: Certificate %s has EXPIRED (expired %d days ago)\n",
path,
-daysUntilExpiry,
)
} else {
fmt.Fprintf(os.Stderr, "WARNING: Certificate %s will expire in %d days (on %s)\n", path, daysUntilExpiry, cert.NotAfter.Format("2006-01-02"))
}
}
}
type fileEntry struct {
name string
content []byte
}
type certWithPath struct {
cert *x509.Certificate
path string
}
// encodeCertsToFiles converts certificates to file entries based on encoding type
// If isSingle is true, certs are concatenated into a single file; otherwise one cert per file.
func encodeCertsToFiles(
certs []*x509.Certificate,
baseName string,
encoding string,
isSingle bool,
) ([]fileEntry, error) {
var files []fileEntry
switch encoding {
case "pem":
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
})
case "der":
if isSingle {
// For single file in DER, concatenate all cert DER bytes
var derContent []byte
for _, cert := range certs {
derContent = append(derContent, cert.Raw...)
}
files = append(files, fileEntry{
name: baseName + ".crt",
content: derContent,
})
} else if len(certs) > 0 {
// Individual DER file (should only have one cert)
files = append(files, fileEntry{
name: baseName + ".crt",
content: certs[0].Raw,
})
}
case "both":
// Add PEM version
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
})
// Add DER version
if isSingle {
var derContent []byte
for _, cert := range certs {
derContent = append(derContent, cert.Raw...)
}
files = append(files, fileEntry{
name: baseName + ".crt",
content: derContent,
})
} else if len(certs) > 0 {
files = append(files, fileEntry{
name: baseName + ".crt",
content: certs[0].Raw,
})
}
default:
return nil, fmt.Errorf("unsupported encoding: %s (must be 'pem', 'der', or 'both')", encoding)
}
return files, nil
}
// encodeCertsToPEM encodes certificates to PEM format.
func encodeCertsToPEM(certs []*x509.Certificate) []byte {
var pemContent []byte
for _, cert := range certs {
pemBlock := &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
pemContent = append(pemContent, pem.EncodeToMemory(pemBlock)...)
}
return pemContent
}
func generateManifest(files []fileEntry) []byte {
var manifest strings.Builder
for _, file := range files {
if file.name == "MANIFEST" {
continue
}
hash := sha256.Sum256(file.content)
manifest.WriteString(fmt.Sprintf("%x %s\n", hash, file.name))
}
return []byte(manifest.String())
}
// closeWithErr attempts to close all provided closers, joining any close errors with baseErr.
func closeWithErr(baseErr error, closers ...io.Closer) error {
for _, c := range closers {
if c == nil {
continue
}
if cerr := c.Close(); cerr != nil {
baseErr = errors.Join(baseErr, cerr)
}
}
return baseErr
}
func createZipArchive(path string, files []fileEntry) error {
f, zerr := os.Create(path)
if zerr != nil {
return zerr
}
w := zip.NewWriter(f)
for _, file := range files {
fw, werr := w.Create(file.name)
if werr != nil {
return closeWithErr(werr, w, f)
}
if _, werr = fw.Write(file.content); werr != nil {
return closeWithErr(werr, w, f)
}
}
// Check errors on close operations
if cerr := w.Close(); cerr != nil {
_ = f.Close()
return cerr
}
return f.Close()
}
func createTarGzArchive(path string, files []fileEntry) error {
f, terr := os.Create(path)
if terr != nil {
return terr
}
gw := gzip.NewWriter(f)
tw := tar.NewWriter(gw)
for _, file := range files {
hdr := &tar.Header{
Name: file.name,
Mode: 0644,
Size: int64(len(file.content)),
}
if herr := tw.WriteHeader(hdr); herr != nil {
return closeWithErr(herr, tw, gw, f)
}
if _, werr := tw.Write(file.content); werr != nil {
return closeWithErr(werr, tw, gw, f)
}
}
// Check errors on close operations in the correct order
if cerr := tw.Close(); cerr != nil {
_ = gw.Close()
_ = f.Close()
return cerr
}
if cerr := gw.Close(); cerr != nil {
_ = f.Close()
return cerr
}
return f.Close()
}
func generateHashFile(path string, files []string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
for _, file := range files {
data, rerr := os.ReadFile(file)
if rerr != nil {
return rerr
}
hash := sha256.Sum256(data)
fmt.Fprintf(f, "%x %s\n", hash, filepath.Base(file))
}
return nil
}

197
cmd/cert-bundler/prompt.txt Normal file
View File

@@ -0,0 +1,197 @@
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.

43
cmd/cert-bundler/testdata/bundle.yaml vendored Normal file
View File

@@ -0,0 +1,43 @@
config:
hashes: bundle.sha256
expiry: 1y
chains:
core_certs:
certs:
- root: pems/gts-r1.pem
intermediates:
- pems/goog-wr2.pem
- root: pems/isrg-root-x1.pem
intermediates:
- pems/le-e7.pem
outputs:
include_single: true
include_individual: true
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
encoding: der
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
encoding: both
formats:
- zip

View File

@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFCzCCAvOgAwIBAgIQf/AFoHxM3tEArZ1mpRB7mDANBgkqhkiG9w0BAQsFADBH
MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExM
QzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMjMxMjEzMDkwMDAwWhcNMjkwMjIw
MTQwMDAwWjA7MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNl
cnZpY2VzMQwwCgYDVQQDEwNXUjIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQCp/5x/RR5wqFOfytnlDd5GV1d9vI+aWqxG8YSau5HbyfsvAfuSCQAWXqAc
+MGr+XgvSszYhaLYWTwO0xj7sfUkDSbutltkdnwUxy96zqhMt/TZCPzfhyM1IKji
aeKMTj+xWfpgoh6zySBTGYLKNlNtYE3pAJH8do1cCA8Kwtzxc2vFE24KT3rC8gIc
LrRjg9ox9i11MLL7q8Ju26nADrn5Z9TDJVd06wW06Y613ijNzHoU5HEDy01hLmFX
xRmpC5iEGuh5KdmyjS//V2pm4M6rlagplmNwEmceOuHbsCFx13ye/aoXbv4r+zgX
FNFmp6+atXDMyGOBOozAKql2N87jAgMBAAGjgf4wgfswDgYDVR0PAQH/BAQDAgGG
MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/
AgEAMB0GA1UdDgQWBBTeGx7teRXUPjckwyG77DQ5bUKyMDAfBgNVHSMEGDAWgBTk
rysmcRorSCeFL1JmLO/wiRNxPjA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAKG
GGh0dHA6Ly9pLnBraS5nb29nL3IxLmNydDArBgNVHR8EJDAiMCCgHqAchhpodHRw
Oi8vYy5wa2kuZ29vZy9yL3IxLmNybDATBgNVHSAEDDAKMAgGBmeBDAECATANBgkq
hkiG9w0BAQsFAAOCAgEARXWL5R87RBOWGqtY8TXJbz3S0DNKhjO6V1FP7sQ02hYS
TL8Tnw3UVOlIecAwPJQl8hr0ujKUtjNyC4XuCRElNJThb0Lbgpt7fyqaqf9/qdLe
SiDLs/sDA7j4BwXaWZIvGEaYzq9yviQmsR4ATb0IrZNBRAq7x9UBhb+TV+PfdBJT
DhEl05vc3ssnbrPCuTNiOcLgNeFbpwkuGcuRKnZc8d/KI4RApW//mkHgte8y0YWu
ryUJ8GLFbsLIbjL9uNrizkqRSvOFVU6xddZIMy9vhNkSXJ/UcZhjJY1pXAprffJB
vei7j+Qi151lRehMCofa6WBmiA4fx+FOVsV2/7R6V2nyAiIJJkEd2nSi5SnzxJrl
Xdaqev3htytmOPvoKWa676ATL/hzfvDaQBEcXd2Ppvy+275W+DKcH0FBbX62xevG
iza3F4ydzxl6NJ8hk8R+dDXSqv1MbRT1ybB5W0k8878XSOjvmiYTDIfyc9acxVJr
Y/cykHipa+te1pOhv7wYPYtZ9orGBV5SGOJm4NrB3K1aJar0RfzxC3ikr7Dyc6Qw
qDTBU39CluVIQeuQRgwG3MuSxl7zRERDRilGoKb8uY45JzmxWuKxrfwT/478JuHU
/oTxUFqOl2stKnn7QGTq8z29W+GgBLCXSBxC9epaHM0myFH/FJlniXJfHeytWt0=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw
MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp
Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo
27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w
Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw
TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl
qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH
szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8
Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk
MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92
wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p
aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN
VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID
AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb
C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe
QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy
h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4
7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J
ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef
MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/
Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT
6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ
0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm
2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb
bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c
-----END CERTIFICATE-----

View File

@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEVzCCAj+gAwIBAgIRAKp18eYrjwoiCWbTi7/UuqEwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCRTcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARB6AST
CFh/vjcwDMCgQer+VtqEkz7JANurZxLP+U9TCeioL6sp5Z8VRvRbYk4P1INBmbef
QHJFHCxcSjKmwtvGBWpl/9ra8HW0QDsUaJW2qOJqceJ0ZVFT3hbUHifBM/2jgfgw
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSuSJ7chx1EoG/aouVgdAR4
wpwAgDAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAjx66fDdLk5ywFn3CzA1w1qfylHUD
aEf0QZpXcJseddJGSfbUUOvbNR9N/QQ16K1lXl4VFyhmGXDT5Kdfcr0RvIIVrNxF
h4lqHtRRCP6RBRstqbZ2zURgqakn/Xip0iaQL0IdfHBZr396FgknniRYFckKORPG
yM3QKnd66gtMst8I5nkRQlAg/Jb+Gc3egIvuGKWboE1G89NTsN9LTDD3PLj0dUMr
OIuqVjLB8pEC6yk9enrlrqjXQgkLEYhXzq7dLafv5Vkig6Gl0nuuqjqfp0Q1bi1o
yVNAlXe6aUXw92CcghC9bNsKEO1+M52YY5+ofIXlS/SEQbvVYYBLZ5yeiglV6t3S
M6H+vTG0aP9YHzLn/KVOHzGQfXDP7qM5tkf+7diZe7o2fw6O7IvN6fsQXEQQj8TJ
UXJxv2/uJhcuy/tSDgXwHM8Uk34WNbRT7zGTGkQRX0gsbjAea/jYAoWv0ZvQRwpq
Pe79D/i7Cep8qWnA+7AE/3B3S/3dEEYmc0lpe1366A/6GEgk3ktr9PEoQrLChs6I
tu3wnNLB2euC8IKGLQFpGtOO/2/hiAKjyajaBP25w1jF0Wl8Bbqne3uZ2q1GyPFJ
YRmT7/OXpmOH/FVLtwS+8ng1cAmpCujPwteJZNcDG0sF2n/sc0+SQf49fdyUK0ty
+VUwFj9tmWxyR/M=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,4 @@
5ed8bf9ed693045faa8a5cb0edc4a870052e56aef6291ce8b1604565affbc2a4 core_certs.zip
e59eddc590d2f7b790a87c5b56e81697088ab54be382c0e2c51b82034006d308 core_certs.tgz
51b9b63b1335118079e90700a3a5b847c363808e9116e576ca84f301bc433289 google_certs.tgz
3d1910ca8835c3ded1755a8c7d6c48083c2f3ff68b2bfbf932aaf27e29d0a232 lets_encrypt.zip

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,36 @@
cert-revcheck: check certificate expiry and revocation
-----------------------------------------------------
Description
cert-revcheck accepts a list of certificate files (PEM or DER) or
site addresses (host[:port]) and checks whether the leaf certificate
is expired or revoked. Revocation checks use CRL and OCSP via the
certlib/revoke package.
Usage
cert-revcheck [options] <target> [<target>...]
Options
-hardfail treat revocation check failures as fatal (default: false)
-timeout dur HTTP/OCSP/CRL timeout for network operations (default: 10s)
-v verbose output
Targets
- File paths to certificates in PEM or DER format.
- Site addresses in the form host or host:port. If no port is
provided, 443 is assumed.
Examples
# Check a PEM file
cert-revcheck ./server.pem
# Check a DER (single) certificate
cert-revcheck ./server.der
# Check a live site (leaf certificate)
cert-revcheck example.com:443
Notes
- For sites, only the leaf certificate is checked.
- When -hardfail is set, network issues during OCSP/CRL fetch will
cause the check to fail (treated as revoked).

151
cmd/cert-revcheck/main.go Normal file
View File

@@ -0,0 +1,151 @@
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"flag"
"fmt"
"net"
"os"
"strings"
"time"
"git.wntrmute.dev/kyle/goutils/certlib"
hosts "git.wntrmute.dev/kyle/goutils/certlib/hosts"
"git.wntrmute.dev/kyle/goutils/certlib/revoke"
"git.wntrmute.dev/kyle/goutils/fileutil"
)
var (
hardfail bool
timeout time.Duration
verbose bool
)
var (
strOK = "OK"
strExpired = "EXPIRED"
strRevoked = "REVOKED"
strUnknown = "UNKNOWN"
)
func main() {
flag.BoolVar(&hardfail, "hardfail", false, "treat revocation check failures as fatal")
flag.DurationVar(&timeout, "timeout", 10*time.Second, "network timeout for OCSP/CRL fetches and TLS site connects")
flag.BoolVar(&verbose, "v", false, "verbose output")
flag.Parse()
revoke.HardFail = hardfail
// Set HTTP client timeout for revocation library
revoke.HTTPClient.Timeout = timeout
if flag.NArg() == 0 {
fmt.Fprintf(os.Stderr, "Usage: %s [options] <target> [<target>...]\n", os.Args[0])
os.Exit(2)
}
exitCode := 0
for _, target := range flag.Args() {
status, err := processTarget(target)
switch status {
case strOK:
fmt.Printf("%s: %s\n", target, strOK)
case strExpired:
fmt.Printf("%s: %s: %v\n", target, strExpired, err)
exitCode = 1
case strRevoked:
fmt.Printf("%s: %s\n", target, strRevoked)
exitCode = 1
case strUnknown:
fmt.Printf("%s: %s: %v\n", target, strUnknown, err)
if hardfail {
// In hardfail, treat unknown as failure
exitCode = 1
}
}
}
os.Exit(exitCode)
}
func processTarget(target string) (string, error) {
if fileutil.FileDoesExist(target) {
return checkFile(target)
}
return checkSite(target)
}
func checkFile(path string) (string, error) {
// Prefer high-level helpers from certlib to load certificates from disk
if certs, err := certlib.LoadCertificates(path); err == nil && len(certs) > 0 {
// Evaluate the first certificate (leaf) by default
return evaluateCert(certs[0])
}
cert, err := certlib.LoadCertificate(path)
if err != nil || cert == nil {
return strUnknown, err
}
return evaluateCert(cert)
}
func checkSite(hostport string) (string, error) {
// Use certlib/hosts to parse host/port (supports https URLs and host:port)
target, err := hosts.ParseHost(hostport)
if err != nil {
return strUnknown, err
}
d := &net.Dialer{Timeout: timeout}
tcfg := &tls.Config{
InsecureSkipVerify: true,
ServerName: target.Host,
} // #nosec G402 -- CLI tool only verifies revocation
td := &tls.Dialer{NetDialer: d, Config: tcfg}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
conn, err := td.DialContext(ctx, "tcp", target.String())
if err != nil {
return strUnknown, err
}
defer conn.Close()
tconn, ok := conn.(*tls.Conn)
if !ok {
return strUnknown, errors.New("connection is not TLS")
}
state := tconn.ConnectionState()
if len(state.PeerCertificates) == 0 {
return strUnknown, errors.New("no peer certificates presented")
}
return evaluateCert(state.PeerCertificates[0])
}
func evaluateCert(cert *x509.Certificate) (string, error) {
// Delegate validity and revocation checks to certlib/revoke helper.
// It returns revoked=true for both revoked and expired/not-yet-valid.
// Map those cases back to our statuses using the returned error text.
revoked, ok, err := revoke.VerifyCertificateError(cert)
if revoked {
if err != nil {
msg := err.Error()
if strings.Contains(msg, "expired") || strings.Contains(msg, "isn't valid until") ||
strings.Contains(msg, "not valid until") {
return strExpired, err
}
}
return strRevoked, err
}
if !ok {
// Revocation status could not be determined
return strUnknown, err
}
return strOK, nil
}

View File

@@ -1,13 +1,16 @@
package main
import (
"context"
"crypto/tls"
"encoding/pem"
"flag"
"fmt"
"os"
"regexp"
"strings"
"github.com/kisom/goutils/die"
"git.wntrmute.dev/kyle/goutils/die"
)
var hasPort = regexp.MustCompile(`:\d+$`)
@@ -20,20 +23,26 @@ func main() {
server += ":443"
}
var chain string
conn, err := tls.Dial("tcp", server, nil)
d := &tls.Dialer{Config: &tls.Config{}} // #nosec G402
nc, err := d.DialContext(context.Background(), "tcp", server)
die.If(err)
conn, ok := nc.(*tls.Conn)
if !ok {
die.With("invalid TLS connection (not a *tls.Conn)")
}
defer conn.Close()
details := conn.ConnectionState()
var chain strings.Builder
for _, cert := range details.PeerCertificates {
p := pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
chain += string(pem.EncodeToMemory(&p))
chain.Write(pem.EncodeToMemory(&p))
}
fmt.Println(chain)
fmt.Fprintln(os.Stdout, chain.String())
}
}

View File

@@ -1,21 +1,26 @@
//lint:file-ignore SA1019 allow strict compatibility for old certs
package main
import (
"bytes"
"context"
"crypto/dsa"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"flag"
"fmt"
"io/ioutil"
"io"
"os"
"sort"
"strings"
"github.com/cloudflare/cfssl/helpers"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/lib"
)
func certPublic(cert *x509.Certificate) string {
@@ -82,6 +87,7 @@ func keyUsages(ku x509.KeyUsage) string {
uses = append(uses, s)
}
}
sort.Strings(uses)
return strings.Join(uses, ", ")
}
@@ -91,65 +97,82 @@ func extUsage(ext []x509.ExtKeyUsage) string {
for i := range ext {
ns = append(ns, extKeyUsages[ext[i]])
}
sort.Strings(ns)
return strings.Join(ns, ", ")
}
func showBasicConstraints(cert *x509.Certificate) {
fmt.Printf("\tBasic constraints: ")
fmt.Fprint(os.Stdout, "\tBasic constraints: ")
if cert.BasicConstraintsValid {
fmt.Printf("valid")
fmt.Fprint(os.Stdout, "valid")
} else {
fmt.Printf("invalid")
fmt.Fprint(os.Stdout, "invalid")
}
if cert.IsCA {
fmt.Printf(", is a CA certificate")
fmt.Fprint(os.Stdout, ", is a CA certificate")
if !cert.BasicConstraintsValid {
fmt.Fprint(os.Stdout, " (basic constraint failure)")
}
} else {
fmt.Fprint(os.Stdout, "is not a CA certificate")
if cert.KeyUsage&x509.KeyUsageKeyEncipherment != 0 {
fmt.Fprint(os.Stdout, " (key encipherment usage enabled!)")
}
}
if (cert.MaxPathLen == 0 && cert.MaxPathLenZero) || (cert.MaxPathLen > 0) {
fmt.Printf(", max path length %d", cert.MaxPathLen)
fmt.Fprintf(os.Stdout, ", max path length %d", cert.MaxPathLen)
}
fmt.Printf("\n")
fmt.Fprintln(os.Stdout)
}
const oneTrueDateFormat = "2006-01-02T15:04:05-0700"
var dateFormat string
var (
dateFormat string
showHash bool // if true, print a SHA256 hash of the certificate's Raw field
)
func wrapPrint(text string, indent int) {
tabs := ""
for i := 0; i < indent; i++ {
tabs += "\t"
var tabsSb140 strings.Builder
for range indent {
tabsSb140.WriteString("\t")
}
tabs += tabsSb140.String()
fmt.Printf(tabs+"%s\n", wrap(text, indent))
fmt.Fprintf(os.Stdout, tabs+"%s\n", wrap(text, indent))
}
func displayCert(cert *x509.Certificate) {
fmt.Println("CERTIFICATE")
fmt.Println(wrap("Subject: "+displayName(cert.Subject), 0))
fmt.Println(wrap("Issuer: "+displayName(cert.Issuer), 0))
fmt.Printf("\tSignature algorithm: %s / %s\n", sigAlgoPK(cert.SignatureAlgorithm),
fmt.Fprintln(os.Stdout, "CERTIFICATE")
if showHash {
fmt.Fprintln(os.Stdout, wrap(fmt.Sprintf("SHA256: %x", sha256.Sum256(cert.Raw)), 0))
}
fmt.Fprintln(os.Stdout, wrap("Subject: "+displayName(cert.Subject), 0))
fmt.Fprintln(os.Stdout, wrap("Issuer: "+displayName(cert.Issuer), 0))
fmt.Fprintf(os.Stdout, "\tSignature algorithm: %s / %s\n", sigAlgoPK(cert.SignatureAlgorithm),
sigAlgoHash(cert.SignatureAlgorithm))
fmt.Println("Details:")
fmt.Fprintln(os.Stdout, "Details:")
wrapPrint("Public key: "+certPublic(cert), 1)
fmt.Printf("\tSerial number: %s\n", cert.SerialNumber)
fmt.Fprintf(os.Stdout, "\tSerial number: %s\n", cert.SerialNumber)
if len(cert.AuthorityKeyId) > 0 {
fmt.Printf("\t%s\n", wrap("AKI: "+dumpHex(cert.AuthorityKeyId), 1))
fmt.Fprintf(os.Stdout, "\t%s\n", wrap("AKI: "+dumpHex(cert.AuthorityKeyId), 1))
}
if len(cert.SubjectKeyId) > 0 {
fmt.Printf("\t%s\n", wrap("SKI: "+dumpHex(cert.SubjectKeyId), 1))
fmt.Fprintf(os.Stdout, "\t%s\n", wrap("SKI: "+dumpHex(cert.SubjectKeyId), 1))
}
wrapPrint("Valid from: "+cert.NotBefore.Format(dateFormat), 1)
fmt.Printf("\t until: %s\n", cert.NotAfter.Format(dateFormat))
fmt.Printf("\tKey usages: %s\n", keyUsages(cert.KeyUsage))
fmt.Fprintf(os.Stdout, "\t until: %s\n", cert.NotAfter.Format(dateFormat))
fmt.Fprintf(os.Stdout, "\tKey usages: %s\n", keyUsages(cert.KeyUsage))
if len(cert.ExtKeyUsage) > 0 {
fmt.Printf("\tExtended usages: %s\n", extUsage(cert.ExtKeyUsage))
fmt.Fprintf(os.Stdout, "\tExtended usages: %s\n", extUsage(cert.ExtKeyUsage))
}
showBasicConstraints(cert)
@@ -198,17 +221,17 @@ func displayCert(cert *x509.Certificate) {
}
func displayAllCerts(in []byte, leafOnly bool) {
certs, err := helpers.ParseCertificatesPEM(in)
certs, err := certlib.ParseCertificatesPEM(in)
if err != nil {
certs, _, err = helpers.ParseCertificatesDER(in, "")
certs, _, err = certlib.ParseCertificatesDER(in, "")
if err != nil {
Warn(TranslateCFSSLError(err), "failed to parse certificates")
_, _ = lib.Warn(err, "failed to parse certificates")
return
}
}
if len(certs) == 0 {
Warnx("no certificates found")
_, _ = lib.Warnx("no certificates found")
return
}
@@ -224,29 +247,45 @@ func displayAllCerts(in []byte, leafOnly bool) {
func displayAllCertsWeb(uri string, leafOnly bool) {
ci := getConnInfo(uri)
conn, err := tls.Dial("tcp", ci.Addr, permissiveConfig())
d := &tls.Dialer{Config: permissiveConfig()}
nc, err := d.DialContext(context.Background(), "tcp", ci.Addr)
if err != nil {
Warn(err, "couldn't connect to %s", ci.Addr)
_, _ = lib.Warn(err, "couldn't connect to %s", ci.Addr)
return
}
conn, ok := nc.(*tls.Conn)
if !ok {
_, _ = lib.Warnx("invalid TLS connection (not a *tls.Conn)")
return
}
defer conn.Close()
state := conn.ConnectionState()
conn.Close()
if err = conn.Close(); err != nil {
_, _ = lib.Warn(err, "couldn't close TLS connection")
}
conn, err = tls.Dial("tcp", ci.Addr, verifyConfig(ci.Host))
d = &tls.Dialer{Config: verifyConfig(ci.Host)}
nc, err = d.DialContext(context.Background(), "tcp", ci.Addr)
if err == nil {
conn, ok = nc.(*tls.Conn)
if !ok {
_, _ = lib.Warnx("invalid TLS connection (not a *tls.Conn)")
return
}
err = conn.VerifyHostname(ci.Host)
if err == nil {
state = conn.ConnectionState()
}
conn.Close()
} else {
Warn(err, "TLS verification error with server name %s", ci.Host)
_, _ = lib.Warn(err, "TLS verification error with server name %s", ci.Host)
}
if len(state.PeerCertificates) == 0 {
Warnx("no certificates found")
_, _ = lib.Warnx("no certificates found")
return
}
@@ -256,14 +295,14 @@ func displayAllCertsWeb(uri string, leafOnly bool) {
}
if len(state.VerifiedChains) == 0 {
Warnx("no verified chains found; using peer chain")
_, _ = lib.Warnx("no verified chains found; using peer chain")
for i := range state.PeerCertificates {
displayCert(state.PeerCertificates[i])
}
} else {
fmt.Println("TLS chain verified successfully.")
fmt.Fprintln(os.Stdout, "TLS chain verified successfully.")
for i := range state.VerifiedChains {
fmt.Printf("--- Verified certificate chain %d ---\n", i+1)
fmt.Fprintf(os.Stdout, "--- Verified certificate chain %d ---%s", i+1, "\n")
for j := range state.VerifiedChains[i] {
displayCert(state.VerifiedChains[i][j])
}
@@ -271,38 +310,56 @@ func displayAllCertsWeb(uri string, leafOnly bool) {
}
}
func shouldReadStdin(argc int, argv []string) bool {
if argc == 0 {
return true
}
if argc == 1 && argv[0] == "-" {
return true
}
return false
}
func readStdin(leafOnly bool) {
certs, err := io.ReadAll(os.Stdin)
if err != nil {
_, _ = lib.Warn(err, "couldn't read certificates from standard input")
os.Exit(1)
}
// This is needed for getting certs from JSON/jq.
certs = bytes.TrimSpace(certs)
certs = bytes.ReplaceAll(certs, []byte(`\n`), []byte{0xa})
certs = bytes.Trim(certs, `"`)
displayAllCerts(certs, leafOnly)
}
func main() {
var leafOnly bool
flag.BoolVar(&showHash, "d", false, "show hashes of raw DER contents")
flag.StringVar(&dateFormat, "s", oneTrueDateFormat, "date `format` in Go time format")
flag.BoolVar(&leafOnly, "l", false, "only show the leaf certificate")
flag.Parse()
if flag.NArg() == 0 || (flag.NArg() == 1 && flag.Arg(0) == "-") {
certs, err := ioutil.ReadAll(os.Stdin)
if err != nil {
Warn(err, "couldn't read certificates from standard input")
os.Exit(1)
}
if shouldReadStdin(flag.NArg(), flag.Args()) {
readStdin(leafOnly)
return
}
// This is needed for getting certs from JSON/jq.
certs = bytes.TrimSpace(certs)
certs = bytes.Replace(certs, []byte(`\n`), []byte{0xa}, -1)
certs = bytes.Trim(certs, `"`)
displayAllCerts(certs, leafOnly)
} else {
for _, filename := range flag.Args() {
fmt.Printf("--%s ---\n", filename)
if strings.HasPrefix(filename, "https://") {
displayAllCertsWeb(filename, leafOnly)
} else {
in, err := ioutil.ReadFile(filename)
if err != nil {
Warn(err, "couldn't read certificate")
continue
}
displayAllCerts(in, leafOnly)
for _, filename := range flag.Args() {
fmt.Fprintf(os.Stdout, "--%s ---%s", filename, "\n")
if strings.HasPrefix(filename, "https://") {
displayAllCertsWeb(filename, leafOnly)
} else {
in, err := os.ReadFile(filename)
if err != nil {
_, _ = lib.Warn(err, "couldn't read certificate")
continue
}
displayAllCerts(in, leafOnly)
}
}
}

View File

@@ -3,19 +3,21 @@ package main
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"os"
"strings"
cferr "github.com/cloudflare/cfssl/errors"
"github.com/kr/text"
)
// following two lifted from CFSSL, (replace-regexp "\(.+\): \(.+\),"
// "\2: \1,")
const (
sSHA256 = "SHA256"
sSHA512 = "SHA512"
)
var keyUsage = map[x509.KeyUsage]string{
x509.KeyUsageDigitalSignature: "digital signature",
x509.KeyUsageContentCommitment: "content committment",
@@ -29,42 +31,36 @@ var keyUsage = map[x509.KeyUsage]string{
}
var extKeyUsages = map[x509.ExtKeyUsage]string{
x509.ExtKeyUsageAny: "any",
x509.ExtKeyUsageServerAuth: "server auth",
x509.ExtKeyUsageClientAuth: "client auth",
x509.ExtKeyUsageCodeSigning: "code signing",
x509.ExtKeyUsageEmailProtection: "s/mime",
x509.ExtKeyUsageIPSECEndSystem: "ipsec end system",
x509.ExtKeyUsageIPSECTunnel: "ipsec tunnel",
x509.ExtKeyUsageIPSECUser: "ipsec user",
x509.ExtKeyUsageTimeStamping: "timestamping",
x509.ExtKeyUsageOCSPSigning: "ocsp signing",
x509.ExtKeyUsageMicrosoftServerGatedCrypto: "microsoft sgc",
x509.ExtKeyUsageNetscapeServerGatedCrypto: "netscape sgc",
}
func pubKeyAlgo(a x509.PublicKeyAlgorithm) string {
switch a {
case x509.RSA:
return "RSA"
case x509.ECDSA:
return "ECDSA"
case x509.DSA:
return "DSA"
default:
return "unknown public key algorithm"
}
x509.ExtKeyUsageAny: "any",
x509.ExtKeyUsageServerAuth: "server auth",
x509.ExtKeyUsageClientAuth: "client auth",
x509.ExtKeyUsageCodeSigning: "code signing",
x509.ExtKeyUsageEmailProtection: "s/mime",
x509.ExtKeyUsageIPSECEndSystem: "ipsec end system",
x509.ExtKeyUsageIPSECTunnel: "ipsec tunnel",
x509.ExtKeyUsageIPSECUser: "ipsec user",
x509.ExtKeyUsageTimeStamping: "timestamping",
x509.ExtKeyUsageOCSPSigning: "ocsp signing",
x509.ExtKeyUsageMicrosoftServerGatedCrypto: "microsoft sgc",
x509.ExtKeyUsageNetscapeServerGatedCrypto: "netscape sgc",
x509.ExtKeyUsageMicrosoftCommercialCodeSigning: "microsoft commercial code signing",
x509.ExtKeyUsageMicrosoftKernelCodeSigning: "microsoft kernel code signing",
}
func sigAlgoPK(a x509.SignatureAlgorithm) string {
switch a {
case x509.MD2WithRSA, x509.MD5WithRSA, x509.SHA1WithRSA, x509.SHA256WithRSA, x509.SHA384WithRSA, x509.SHA512WithRSA:
return "RSA"
case x509.SHA256WithRSAPSS, x509.SHA384WithRSAPSS, x509.SHA512WithRSAPSS:
return "RSA-PSS"
case x509.ECDSAWithSHA1, x509.ECDSAWithSHA256, x509.ECDSAWithSHA384, x509.ECDSAWithSHA512:
return "ECDSA"
case x509.DSAWithSHA1, x509.DSAWithSHA256:
return "DSA"
case x509.PureEd25519:
return "Ed25519"
case x509.UnknownSignatureAlgorithm:
return "unknown public key algorithm"
default:
return "unknown public key algorithm"
}
@@ -79,51 +75,35 @@ func sigAlgoHash(a x509.SignatureAlgorithm) string {
case x509.SHA1WithRSA, x509.ECDSAWithSHA1, x509.DSAWithSHA1:
return "SHA1"
case x509.SHA256WithRSA, x509.ECDSAWithSHA256, x509.DSAWithSHA256:
return "SHA256"
return sSHA256
case x509.SHA256WithRSAPSS:
return sSHA256
case x509.SHA384WithRSA, x509.ECDSAWithSHA384:
return "SHA384"
case x509.SHA384WithRSAPSS:
return "SHA384"
case x509.SHA512WithRSA, x509.ECDSAWithSHA512:
return "SHA512"
return sSHA512
case x509.SHA512WithRSAPSS:
return sSHA512
case x509.PureEd25519:
return sSHA512
case x509.UnknownSignatureAlgorithm:
return "unknown hash algorithm"
default:
return "unknown hash algorithm"
}
}
// TranslateCFSSLError turns a CFSSL error into a more readable string.
func TranslateCFSSLError(err error) error {
if err == nil {
return nil
}
// printing errors as json is terrible
if cfsslError, ok := err.(*cferr.Error); ok {
err = errors.New(cfsslError.Message)
}
return err
}
// Warnx displays a formatted error message to standard error, à la
// warnx(3).
func Warnx(format string, a ...interface{}) (int, error) {
format += "\n"
return fmt.Fprintf(os.Stderr, format, a...)
}
// Warn displays a formatted error message to standard output,
// appending the error string, à la warn(3).
func Warn(err error, format string, a ...interface{}) (int, error) {
format += ": %v\n"
a = append(a, err)
return fmt.Fprintf(os.Stderr, format, a...)
}
const maxLine = 78
func makeIndent(n int) string {
s := " "
for i := 0; i < n; i++ {
s += " "
var sSb97 strings.Builder
for range n {
sSb97.WriteString(" ")
}
s += sSb97.String()
return s
}
@@ -131,7 +111,7 @@ func indentLen(n int) int {
return 4 + (8 * n)
}
// this isn't real efficient, but that's not a problem here
// this isn't real efficient, but that's not a problem here.
func wrap(s string, indent int) string {
if indent > 3 {
indent = 3
@@ -154,9 +134,11 @@ func wrap(s string, indent int) string {
func dumpHex(in []byte) string {
var s string
var sSb130 strings.Builder
for i := range in {
s += fmt.Sprintf("%02X:", in[i])
sSb130.WriteString(fmt.Sprintf("%02X:", in[i]))
}
s += sSb130.String()
return strings.Trim(s, ":")
}
@@ -167,14 +149,14 @@ func dumpHex(in []byte) string {
func permissiveConfig() *tls.Config {
return &tls.Config{
InsecureSkipVerify: true,
}
} // #nosec G402
}
// verifyConfig returns a config that will verify the connection.
func verifyConfig(hostname string) *tls.Config {
return &tls.Config{
ServerName: hostname,
}
} // #nosec G402
}
type connInfo struct {

View File

@@ -5,14 +5,13 @@ import (
"crypto/x509/pkix"
"flag"
"fmt"
"io/ioutil"
"os"
"strings"
"time"
"github.com/cloudflare/cfssl/helpers"
"github.com/kisom/goutils/die"
"github.com/kisom/goutils/lib"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
)
var warnOnly bool
@@ -54,7 +53,7 @@ func displayName(name pkix.Name) string {
}
func expires(cert *x509.Certificate) time.Duration {
return cert.NotAfter.Sub(time.Now())
return time.Until(cert.NotAfter)
}
func inDanger(cert *x509.Certificate) bool {
@@ -81,15 +80,15 @@ func main() {
flag.Parse()
for _, file := range flag.Args() {
in, err := ioutil.ReadFile(file)
in, err := os.ReadFile(file)
if err != nil {
lib.Warn(err, "failed to read file")
_, _ = lib.Warn(err, "failed to read file")
continue
}
certs, err := helpers.ParseCertificatesPEM(in)
certs, err := certlib.ParseCertificatesPEM(in)
if err != nil {
lib.Warn(err, "while parsing certificates")
_, _ = lib.Warn(err, "while parsing certificates")
continue
}

View File

@@ -4,18 +4,16 @@ import (
"crypto/x509"
"flag"
"fmt"
"io/ioutil"
"os"
"time"
"github.com/cloudflare/cfssl/helpers"
"github.com/cloudflare/cfssl/revoke"
"github.com/kisom/goutils/die"
"github.com/kisom/goutils/lib"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/certlib/revoke"
"git.wntrmute.dev/kyle/goutils/lib"
)
func printRevocation(cert *x509.Certificate) {
remaining := cert.NotAfter.Sub(time.Now())
remaining := time.Until(cert.NotAfter)
fmt.Printf("certificate expires in %s.\n", lib.Duration(remaining))
revoked, ok := revoke.VerifyCertificate(cert)
@@ -30,83 +28,116 @@ func printRevocation(cert *x509.Certificate) {
}
}
func main() {
var caFile, intFile string
var forceIntermediateBundle, revexp, verbose bool
flag.StringVar(&caFile, "ca", "", "CA certificate `bundle`")
flag.StringVar(&intFile, "i", "", "intermediate `bundle`")
flag.BoolVar(&forceIntermediateBundle, "f", false,
type appConfig struct {
caFile, intFile string
forceIntermediateBundle bool
revexp, verbose bool
}
func parseFlags() appConfig {
var cfg appConfig
flag.StringVar(&cfg.caFile, "ca", "", "CA certificate `bundle`")
flag.StringVar(&cfg.intFile, "i", "", "intermediate `bundle`")
flag.BoolVar(&cfg.forceIntermediateBundle, "f", false,
"force the use of the intermediate bundle, ignoring any intermediates bundled with certificate")
flag.BoolVar(&revexp, "r", false, "print revocation and expiry information")
flag.BoolVar(&verbose, "v", false, "verbose")
flag.BoolVar(&cfg.revexp, "r", false, "print revocation and expiry information")
flag.BoolVar(&cfg.verbose, "v", false, "verbose")
flag.Parse()
return cfg
}
var roots *x509.CertPool
if caFile != "" {
var err error
if verbose {
fmt.Println("[+] loading root certificates from", caFile)
}
roots, err = helpers.LoadPEMCertPool(caFile)
die.If(err)
func loadRoots(caFile string, verbose bool) (*x509.CertPool, error) {
if caFile == "" {
return x509.SystemCertPool()
}
var ints *x509.CertPool
if intFile != "" {
var err error
if verbose {
fmt.Println("[+] loading intermediate certificates from", intFile)
}
ints, err = helpers.LoadPEMCertPool(caFile)
die.If(err)
} else {
ints = x509.NewCertPool()
}
if flag.NArg() != 1 {
fmt.Fprintf(os.Stderr, "Usage: %s [-ca bundle] [-i bundle] cert",
lib.ProgName())
}
fileData, err := ioutil.ReadFile(flag.Arg(0))
die.If(err)
chain, err := helpers.ParseCertificatesPEM(fileData)
die.If(err)
if verbose {
fmt.Printf("[+] %s has %d certificates\n", flag.Arg(0), len(chain))
fmt.Println("[+] loading root certificates from", caFile)
}
return certlib.LoadPEMCertPool(caFile)
}
cert := chain[0]
if len(chain) > 1 {
if !forceIntermediateBundle {
for _, intermediate := range chain[1:] {
if verbose {
fmt.Printf("[+] adding intermediate with SKI %x\n", intermediate.SubjectKeyId)
}
func loadIntermediates(intFile string, verbose bool) (*x509.CertPool, error) {
if intFile == "" {
return x509.NewCertPool(), nil
}
if verbose {
fmt.Println("[+] loading intermediate certificates from", intFile)
}
// Note: use intFile here (previously used caFile mistakenly)
return certlib.LoadPEMCertPool(intFile)
}
ints.AddCert(intermediate)
}
func addBundledIntermediates(chain []*x509.Certificate, pool *x509.CertPool, verbose bool) {
for _, intermediate := range chain[1:] {
if verbose {
fmt.Printf("[+] adding intermediate with SKI %x\n", intermediate.SubjectKeyId)
}
pool.AddCert(intermediate)
}
}
func verifyCert(cert *x509.Certificate, roots, ints *x509.CertPool) error {
opts := x509.VerifyOptions{
Intermediates: ints,
Roots: roots,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}
_, err := cert.Verify(opts)
return err
}
_, err = cert.Verify(opts)
func run(cfg appConfig) error {
roots, err := loadRoots(cfg.caFile, cfg.verbose)
if err != nil {
fmt.Fprintf(os.Stderr, "Verification failed: %v\n", err)
os.Exit(1)
return err
}
if verbose {
ints, err := loadIntermediates(cfg.intFile, cfg.verbose)
if err != nil {
return err
}
if flag.NArg() != 1 {
fmt.Fprintf(os.Stderr, "Usage: %s [-ca bundle] [-i bundle] cert", lib.ProgName())
}
fileData, err := os.ReadFile(flag.Arg(0))
if err != nil {
return err
}
chain, err := certlib.ParseCertificatesPEM(fileData)
if err != nil {
return err
}
if cfg.verbose {
fmt.Printf("[+] %s has %d certificates\n", flag.Arg(0), len(chain))
}
cert := chain[0]
if len(chain) > 1 && !cfg.forceIntermediateBundle {
addBundledIntermediates(chain, ints, cfg.verbose)
}
if err = verifyCert(cert, roots, ints); err != nil {
return fmt.Errorf("certificate verification failed: %w", err)
}
if cfg.verbose {
fmt.Println("OK")
}
if revexp {
if cfg.revexp {
printRevocation(cert)
}
return nil
}
func main() {
cfg := parseFlags()
if err := run(cfg); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}

View File

@@ -2,6 +2,8 @@ package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"io"
@@ -11,7 +13,7 @@ import (
"strings"
"sync"
"github.com/kisom/goutils/lib"
"git.wntrmute.dev/kyle/goutils/lib"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
@@ -56,7 +58,7 @@ var modes = ssh.TerminalModes{
}
func sshAgent() ssh.AuthMethod {
a, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))
a, err := (&net.Dialer{}).DialContext(context.Background(), "unix", os.Getenv("SSH_AUTH_SOCK"))
if err == nil {
return ssh.PublicKeysCallback(agent.NewClient(a).Signers)
}
@@ -82,7 +84,7 @@ func scanner(host string, in io.Reader, out io.Writer) {
}
}
func logError(host string, err error, format string, args ...interface{}) {
func logError(host string, err error, format string, args ...any) {
msg := fmt.Sprintf(format, args...)
log.Printf("[%s] FAILED: %s: %v\n", host, msg, err)
}
@@ -93,7 +95,7 @@ func exec(wg *sync.WaitGroup, user, host string, commands []string) {
defer func() {
for i := len(shutdown) - 1; i >= 0; i-- {
err := shutdown[i]()
if err != nil && err != io.EOF {
if err != nil && !errors.Is(err, io.EOF) {
logError(host, err, "shutting down")
}
}
@@ -115,7 +117,7 @@ func exec(wg *sync.WaitGroup, user, host string, commands []string) {
}
shutdown = append(shutdown, session.Close)
if err := session.RequestPty("xterm", 80, 40, modes); err != nil {
if err = session.RequestPty("xterm", 80, 40, modes); err != nil {
session.Close()
logError(host, err, "request for pty failed")
return
@@ -150,7 +152,7 @@ func upload(wg *sync.WaitGroup, user, host, local, remote string) {
defer func() {
for i := len(shutdown) - 1; i >= 0; i-- {
err := shutdown[i]()
if err != nil && err != io.EOF {
if err != nil && !errors.Is(err, io.EOF) {
logError(host, err, "shutting down")
}
}
@@ -199,7 +201,7 @@ func upload(wg *sync.WaitGroup, user, host, local, remote string) {
fmt.Printf("[%s] wrote %d-byte chunk\n", host, n)
}
if err == io.EOF {
if errors.Is(err, io.EOF) {
break
} else if err != nil {
logError(host, err, "reading chunk")
@@ -215,7 +217,7 @@ func download(wg *sync.WaitGroup, user, host, local, remote string) {
defer func() {
for i := len(shutdown) - 1; i >= 0; i-- {
err := shutdown[i]()
if err != nil && err != io.EOF {
if err != nil && !errors.Is(err, io.EOF) {
logError(host, err, "shutting down")
}
}
@@ -265,7 +267,7 @@ func download(wg *sync.WaitGroup, user, host, local, remote string) {
fmt.Printf("[%s] wrote %d-byte chunk\n", host, n)
}
if err == io.EOF {
if errors.Is(err, io.EOF) {
break
} else if err != nil {
logError(host, err, "reading chunk")

20
cmd/cruntar/README Normal file
View File

@@ -0,0 +1,20 @@
ChromeOS untar
This is a tool that is intended to support untarring on SquashFS file
systems. In particular, every time it encounters a hard link, it
will just create a copy of the file.
Usage: cruntar [-jmvpz] archive [dest]
Flags:
-a Shortcut for -m -p: preserve owners and file mode.
-j The archive is compressed with bzip2.
-m Preserve file modes.
-p Preserve ownership.
-v Print the name of each file as it is being processed.
-z The archive is compressed with gzip.
I wrote this after running into problems with untarring the
gcc-arm-eabi-none toolchain. The shared storage in Termux under
ChromeOS doesn't support hard links, so I opted to just make a copy
rather than dealing with links and whatnot.

310
cmd/cruntar/main.go Normal file
View File

@@ -0,0 +1,310 @@
package main
import (
"archive/tar"
"compress/bzip2"
"compress/gzip"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/fileutil"
)
var (
preserveOwners bool
preserveMode bool
verbose bool
)
func setupFile(hdr *tar.Header, file *os.File) error {
if preserveMode {
if verbose {
fmt.Printf("\tchmod %0#o\n", hdr.Mode)
}
err := file.Chmod(os.FileMode(hdr.Mode & 0xFFFFFFFF)) // #nosec G115
if err != nil {
return err
}
}
if preserveOwners {
fmt.Printf("\tchown %d:%d\n", hdr.Uid, hdr.Gid)
err := file.Chown(hdr.Uid, hdr.Gid)
if err != nil {
return err
}
}
return nil
}
func linkTarget(target, top string) string {
if filepath.IsAbs(target) {
return target
}
return filepath.Clean(filepath.Join(top, target))
}
// safeJoin joins base and elem and ensures the resulting path does not escape base.
func safeJoin(base, elem string) (string, error) {
cleanBase := filepath.Clean(base)
joined := filepath.Clean(filepath.Join(cleanBase, elem))
absBase, err := filepath.Abs(cleanBase)
if err != nil {
return "", err
}
absJoined, err := filepath.Abs(joined)
if err != nil {
return "", err
}
rel, err := filepath.Rel(absBase, absJoined)
if err != nil {
return "", err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("path traversal detected: %s escapes %s", elem, base)
}
return joined, nil
}
func handleTypeReg(tfr *tar.Reader, hdr *tar.Header, filePath string) error {
file, err := os.Create(filePath)
if err != nil {
return err
}
defer file.Close()
if _, err = io.Copy(file, tfr); err != nil {
return err
}
return setupFile(hdr, file)
}
func handleTypeLink(hdr *tar.Header, top, filePath string) error {
file, err := os.Create(filePath)
if err != nil {
return err
}
defer file.Close()
srcPath, err := safeJoin(top, hdr.Linkname)
if err != nil {
return err
}
source, err := os.Open(srcPath)
if err != nil {
return err
}
defer source.Close()
if _, err = io.Copy(file, source); err != nil {
return err
}
return setupFile(hdr, file)
}
func handleTypeSymlink(hdr *tar.Header, top, filePath string) error {
if !fileutil.ValidateSymlink(hdr.Linkname, top) {
return fmt.Errorf("symlink %s is outside the top-level %s", hdr.Linkname, top)
}
path := linkTarget(hdr.Linkname, top)
if ok, err := filepath.Match(top+"/*", filepath.Clean(path)); !ok {
return fmt.Errorf("symlink %s isn't in %s", hdr.Linkname, top)
} else if err != nil {
return err
}
return os.Symlink(linkTarget(hdr.Linkname, top), filePath)
}
func handleTypeDir(hdr *tar.Header, filePath string) error {
return os.MkdirAll(filePath, os.FileMode(hdr.Mode&0xFFFFFFFF)) // #nosec G115
}
func processFile(tfr *tar.Reader, hdr *tar.Header, top string) error {
if verbose {
fmt.Println(hdr.Name)
}
filePath, err := safeJoin(top, hdr.Name)
if err != nil {
return err
}
switch hdr.Typeflag {
case tar.TypeReg:
return handleTypeReg(tfr, hdr, filePath)
case tar.TypeLink:
return handleTypeLink(hdr, top, filePath)
case tar.TypeSymlink:
return handleTypeSymlink(hdr, top, filePath)
case tar.TypeDir:
return handleTypeDir(hdr, filePath)
}
return nil
}
var compression = map[string]bool{
"gzip": false,
"bzip2": false,
}
type bzipCloser struct {
r io.Reader
}
func (brc *bzipCloser) Read(p []byte) (int, error) {
return brc.r.Read(p)
}
func (brc *bzipCloser) Close() error {
return nil
}
func newBzipCloser(r io.ReadCloser) (io.ReadCloser, error) {
br := bzip2.NewReader(r)
return &bzipCloser{r: br}, nil
}
var compressFuncs = map[string]func(io.ReadCloser) (io.ReadCloser, error){
"gzip": func(r io.ReadCloser) (io.ReadCloser, error) { return gzip.NewReader(r) },
"bzip2": newBzipCloser,
}
func verifyCompression() bool {
var compressed bool
for _, v := range compression {
if compressed && v {
return false
}
compressed = compressed || v
}
return true
}
func getReader(r io.ReadCloser) (io.ReadCloser, error) {
for c, v := range compression {
if v {
return compressFuncs[c](r)
}
}
return r, nil
}
func openArchive(path string) (io.ReadCloser, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
r, err := getReader(file)
if err != nil {
return nil, err
}
return r, nil
}
var compressFlags struct {
z bool
j bool
}
func parseCompressFlags() error {
if compressFlags.z {
compression["gzip"] = true
}
if compressFlags.j {
compression["bzip2"] = true
}
if !verifyCompression() {
return errors.New("multiple compression formats specified")
}
return nil
}
func usage(w io.Writer) {
fmt.Fprintf(w, `ChromeOS untar
This is a tool that is intended to support untarring on SquashFS file
systems. In particular, every time it encounters a hard link, it
will just create a copy of the file.
Usage: cruntar [-jmvpz] archive [dest]
Flags:
-a Shortcut for -m -p: preserve owners and file mode.
-j The archive is compressed with bzip2.
-m Preserve file modes.
-p Preserve ownership.
-v Print the name of each file as it is being processed.
-z The archive is compressed with gzip.
`)
}
func init() {
flag.Usage = func() { usage(os.Stderr) }
}
func main() {
var archive, help bool
flag.BoolVar(&archive, "a", false, "Shortcut for -m -p: preserve owners and file mode.")
flag.BoolVar(&help, "h", false, "print a help message")
flag.BoolVar(&compressFlags.j, "j", false, "bzip2 compression")
flag.BoolVar(&preserveMode, "m", false, "preserve file modes")
flag.BoolVar(&preserveOwners, "p", false, "preserve ownership")
flag.BoolVar(&verbose, "v", false, "verbose mode")
flag.BoolVar(&compressFlags.z, "z", false, "gzip compression")
flag.Parse()
if help {
usage(os.Stdout)
os.Exit(0)
}
if archive {
preserveMode = true
preserveOwners = true
}
err := parseCompressFlags()
die.If(err)
if flag.NArg() == 0 {
return
}
top := "./"
if flag.NArg() > 1 {
top = flag.Arg(1)
}
r, err := openArchive(flag.Arg(0))
die.If(err)
tfr := tar.NewReader(r)
var hdr *tar.Header
for {
hdr, err = tfr.Next()
if errors.Is(err, io.EOF) {
break
}
die.If(err)
err = processFile(tfr, hdr, top)
die.If(err)
}
r.Close()
}

View File

@@ -7,27 +7,20 @@ import (
"encoding/pem"
"flag"
"fmt"
"io/ioutil"
"log"
"os"
"github.com/kisom/goutils/die"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/die"
)
func main() {
flag.Parse()
for _, fileName := range flag.Args() {
in, err := ioutil.ReadFile(fileName)
in, err := os.ReadFile(fileName)
die.If(err)
if p, _ := pem.Decode(in); p != nil {
if p.Type != "CERTIFICATE REQUEST" {
log.Fatal("INVALID FILE TYPE")
}
in = p.Bytes
}
csr, err := x509.ParseCertificateRequest(in)
csr, _, err := certlib.ParseCSR(in)
die.If(err)
out, err := x509.MarshalPKIXPublicKey(csr.PublicKey)
@@ -48,8 +41,8 @@ func main() {
Bytes: out,
}
err = ioutil.WriteFile(fileName+".pub", pem.EncodeToMemory(p), 0644)
err = os.WriteFile(fileName+".pub", pem.EncodeToMemory(p), 0o644) // #nosec G306
die.If(err)
fmt.Printf("[+] wrote %s.\n", fileName+".pub")
fmt.Fprintf(os.Stdout, "[+] wrote %s.\n", fileName+".pub")
}
}

32
cmd/data_sync/README Normal file
View File

@@ -0,0 +1,32 @@
data_sync
This is a tool I wrote primarily to sync my home directory to a backup
drive plugged into my laptop. This system is provisioned by Ansible,
and the goal is to be able to just copy my home directory back in the
event of a failure without having lost a great deal of work or to wait
for ansible to finish installing the right backup software. Specifically,
I use a Framework laptop with the 1TB storage module, encrypted with
LUKS, and run this twice daily (timed to correspond with my commute,
though that's not really necessary). It started off as a shell script,
then I decided to just write it as a program.
Usage: data_sync [-d path] [-l level] [-m path] [-nqsv]
[-t path]
-d path path to sync source directory
(default "~")
-l level log level to output (default "INFO"). Valid log
levels are DEBUG, INFO, NOTICE, WARNING, ERR,
CRIT, ALERT, EMERG. The default is INFO.
-m path path to sync mount directory
(default "/media/$USER/$(hostname -s)_data")
-n dry-run mode: only check paths and print files to
exclude
-q suppress console output
-s suppress syslog output
-t path path to sync target directory
(default "/media/$USER/$(hostname -s)_data/$USER")
-v verbose rsync output
data_sync rsyncs the tree at the sync source directory (-d) to the sync target
directory (-t); it checks the mount directory (-m) exists; the sync target
target directory must exist on the mount directory.

230
cmd/data_sync/main.go Normal file
View File

@@ -0,0 +1,230 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"git.wntrmute.dev/kyle/goutils/config"
"git.wntrmute.dev/kyle/goutils/fileutil"
"git.wntrmute.dev/kyle/goutils/log"
)
func mustHostname() string {
hostname, err := os.Hostname()
log.FatalError(err, "couldn't retrieve hostname")
if hostname == "" {
log.Fatal("no hostname returned")
}
return strings.Split(hostname, ".")[0]
}
var (
defaultDataDir = mustHostname() + "_data"
defaultProgName = defaultDataDir + "_sync"
defaultMountDir = filepath.Join("/media", os.Getenv("USER"), defaultDataDir)
defaultSyncDir = os.Getenv("HOME")
defaultTargetDir = filepath.Join(defaultMountDir, os.Getenv("USER"))
)
func usage(w io.Writer) {
prog := filepath.Base(os.Args[0])
fmt.Fprintf(w, `Usage: %s [-d path] [-l level] [-m path] [-nqsv]
[-t path]
-d path path to sync source directory
(default "%s")
-l level log level to output (default "INFO"). Valid log
levels are DEBUG, INFO, NOTICE, WARNING, ERR,
CRIT, ALERT, EMERG. The default is INFO.
-m path path to sync mount directory
(default "%s")
-n dry-run mode: only check paths and print files to
exclude
-q suppress console output
-s suppress syslog output
-t path path to sync target directory
(default "%s")
-v verbose rsync output
%s rsyncs the tree at the sync source directory (-d) to the sync target
directory (-t); it checks the mount directory (-m) exists; the sync target
target directory must exist on the mount directory.
`, prog, defaultSyncDir, defaultMountDir, defaultTargetDir, prog)
}
func checkPaths(mount, target string, dryRun bool) error {
if !fileutil.DirectoryDoesExist(mount) {
return fmt.Errorf("sync dir %s isn't mounted", mount)
}
if !strings.HasPrefix(target, mount) {
return fmt.Errorf("target dir %s must exist in %s", target, mount)
}
if !fileutil.DirectoryDoesExist(target) {
if dryRun {
log.Infof("would mkdir %s", target)
} else {
log.Infof("mkdir %s", target)
if err := os.Mkdir(target, 0755); err != nil {
return err
}
}
}
return nil
}
func buildExcludes(syncDir string) ([]string, error) {
var excluded []string
walker := func(path string, info fs.FileInfo, err error) error {
if err != nil {
excluded = append(excluded, strings.TrimPrefix(path, syncDir))
if info != nil && info.IsDir() {
return filepath.SkipDir
}
return nil
}
if info.Mode().IsRegular() {
if err = fileutil.Access(path, fileutil.AccessRead); err != nil {
excluded = append(excluded, strings.TrimPrefix(path, syncDir))
}
}
if info.IsDir() {
if err = fileutil.Access(path, fileutil.AccessExec); err != nil {
excluded = append(excluded, strings.TrimPrefix(path, syncDir))
}
}
return nil
}
err := filepath.Walk(syncDir, walker)
return excluded, err
}
func writeExcludes(excluded []string) (string, error) {
if len(excluded) == 0 {
return "", nil
}
excludeFile, err := os.CreateTemp("", defaultProgName)
if err != nil {
return "", err
}
for _, name := range excluded {
fmt.Fprintln(excludeFile, name)
}
defer excludeFile.Close()
return excludeFile.Name(), nil
}
func rsync(syncDir, target, excludeFile string, verboseRsync bool) error {
var args []string
if excludeFile != "" {
args = append(args, "--exclude-from")
args = append(args, excludeFile)
}
if verboseRsync {
args = append(args, "--progress")
args = append(args, "-v")
}
args = append(args, []string{"-au", syncDir + "/", target + "/"}...)
path, err := exec.LookPath("rsync")
if err != nil {
return err
}
cmd := exec.CommandContext(context.Background(), path, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func init() {
flag.Usage = func() { usage(os.Stderr) }
}
func main() {
var logLevel, mountDir, syncDir, target string
var dryRun, quietMode, noSyslog, verboseRsync bool
flag.StringVar(&syncDir, "d", config.GetDefault("sync_dir", defaultSyncDir),
"`path to sync source directory`")
flag.StringVar(&logLevel, "l", config.GetDefault("log_level", "INFO"),
"log level to output")
flag.StringVar(&mountDir, "m", config.GetDefault("mount_dir", defaultMountDir),
"`path` to sync mount directory")
flag.BoolVar(&dryRun, "n", false, "dry-run mode: only check paths and print files to exclude")
flag.BoolVar(&quietMode, "q", quietMode, "suppress console output")
flag.BoolVar(&noSyslog, "s", noSyslog, "suppress syslog output")
flag.StringVar(&target, "t", config.GetDefault("sync_target", defaultTargetDir),
"`path` to sync target directory")
flag.BoolVar(&verboseRsync, "v", false, "verbose rsync output")
flag.Parse()
if quietMode && noSyslog {
fmt.Fprintln(os.Stderr, "both console and syslog output are suppressed")
fmt.Fprintln(os.Stderr, "errors will NOT be reported")
}
logOpts := &log.Options{
Level: logLevel,
Tag: defaultProgName,
Facility: "user",
WriteSyslog: !noSyslog,
WriteConsole: !quietMode,
}
err := log.Setup(logOpts)
log.FatalError(err, "failed to set up logging")
log.Infof("checking paths: mount=%s, target=%s", mountDir, target)
err = checkPaths(mountDir, target, dryRun)
log.FatalError(err, "target dir isn't ready")
log.Infof("checking for files to exclude from %s", syncDir)
excluded, err := buildExcludes(syncDir)
log.FatalError(err, "couldn't build excludes")
if dryRun {
fmt.Println("excluded files:")
for _, path := range excluded {
fmt.Printf("\t%s\n", path)
}
return
}
excludeFile, err := writeExcludes(excluded)
log.FatalError(err, "couldn't write exclude file")
log.Infof("excluding %d files via %s", len(excluded), excludeFile)
if excludeFile != "" {
defer func() {
log.Infof("removing exclude file %s", excludeFile)
if rmErr := os.Remove(excludeFile); rmErr != nil {
log.Warningf("failed to remove temp file %s", excludeFile)
}
}()
}
err = rsync(syncDir, target, excludeFile, verboseRsync)
log.FatalError(err, "couldn't sync data")
}

34
cmd/diskimg/README Normal file
View File

@@ -0,0 +1,34 @@
diskimg: write disk images
Usage:
diskimg [-a algo] [-v] image device
Flags:
-a algo Select the hashing algorithm to use. The default
is 'sha256'. Specifying an algorithm of 'list'
will print the supported algorithms to standard
output and exit with error code 2.
-v Enable verbose (debug) output.
Examples:
Copying images/server.img to /dev/sda:
$ sudo diskimg images/server.img /dev/sda
Write a bladerunner node image to /dev/sda:
$ sudo diskimg -v ~/code/bladerunner/packer/build/cm4-cnode-ubuntu-22.04.2.img /dev/sda
opening image /home/kyle/code/bladerunner/packer/build/cm4-cnode-ubuntu-22.04.2.img for read
/home/kyle/code/bladerunner/packer/build/cm4-cnode-ubuntu-22.04.2.img 416d4c8f890904167419e3d488d097e9c847273376b650546fdb1f6f9809c184
opening device /dev/sda for rw
writing /home/kyle/code/bladerunner/packer/build/cm4-cnode-ubuntu-22.04.2.img -> /dev/sda
wrote 4151312384 bytes to /dev/sda
syncing /dev/sda
verifying the image was written successfully
OK
Motivation:
I wanted to write something like balena's Etcher, but commandline only.

114
cmd/diskimg/main.go Normal file
View File

@@ -0,0 +1,114 @@
package main
import (
"bytes"
"flag"
"fmt"
"io"
"os"
"git.wntrmute.dev/kyle/goutils/ahash"
"git.wntrmute.dev/kyle/goutils/dbg"
"git.wntrmute.dev/kyle/goutils/die"
)
const defaultHashAlgorithm = "sha256"
var (
hAlgo string
debug = dbg.New()
)
func openImage(imageFile string) (*os.File, []byte, error) {
f, err := os.Open(imageFile)
if err != nil {
return nil, nil, err
}
h, err := ahash.SumReader(hAlgo, f)
if err != nil {
return nil, nil, err
}
if _, err = f.Seek(0, 0); err != nil {
return nil, nil, err
}
debug.Printf("%s %x\n", imageFile, h)
return f, h, nil
}
func openDevice(devicePath string) (*os.File, error) {
fi, err := os.Stat(devicePath)
if err != nil {
return nil, err
}
device, err := os.OpenFile(devicePath, os.O_RDWR|os.O_SYNC, fi.Mode())
if err != nil {
return nil, err
}
return device, nil
}
func main() {
flag.StringVar(&hAlgo, "a", defaultHashAlgorithm, "default hash algorithm")
flag.BoolVar(&debug.Enabled, "v", false, "enable debug logging")
flag.Parse()
if hAlgo == "list" {
fmt.Println("Supported hashing algorithms:")
for _, algo := range ahash.SecureHashList() {
fmt.Printf("\t- %s\n", algo)
}
os.Exit(2)
}
if flag.NArg() != 2 {
die.With("usage: diskimg image device")
}
imageFile := flag.Arg(0)
devicePath := flag.Arg(1)
debug.Printf("opening image %s for read\n", imageFile)
image, hash, err := openImage(imageFile)
if image != nil {
defer image.Close()
}
die.If(err)
debug.Printf("opening device %s for rw\n", devicePath)
device, err := openDevice(devicePath)
if device != nil {
defer device.Close()
}
die.If(err)
debug.Printf("writing %s -> %s\n", imageFile, devicePath)
n, err := io.Copy(device, image)
die.If(err)
debug.Printf("wrote %d bytes to %s\n", n, devicePath)
debug.Printf("syncing %s\n", devicePath)
err = device.Sync()
die.If(err)
debug.Println("verifying the image was written successfully")
_, err = device.Seek(0, 0)
die.If(err)
deviceHash, err := ahash.SumLimitedReader(hAlgo, device, n)
die.If(err)
if !bytes.Equal(deviceHash, hash) {
buf := &bytes.Buffer{}
fmt.Fprintln(buf, "Hash mismatch:")
fmt.Fprintf(buf, "\t%s: %s\n", imageFile, hash)
fmt.Fprintf(buf, "\t%s: %s\n", devicePath, deviceHash)
die.With(buf.String())
}
debug.Println("OK")
}

75
cmd/dumpbytes/main.go Normal file
View File

@@ -0,0 +1,75 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"git.wntrmute.dev/kyle/goutils/die"
)
func usage(w io.Writer, exc int) {
fmt.Fprintln(w, `usage: dumpbytes -n tabs <file>`)
os.Exit(exc)
}
func printBytes(buf []byte) {
fmt.Printf("\t")
for i := range buf {
fmt.Printf("0x%02x, ", buf[i])
}
fmt.Println()
}
func dumpFile(path string, indentLevel int) error {
var indent strings.Builder
for range indentLevel {
indent.WriteByte('\t')
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
fmt.Printf("%svar buffer = []byte{\n", indent.String())
var n int
for {
buf := make([]byte, 8)
n, err = file.Read(buf)
if errors.Is(err, io.EOF) {
if n > 0 {
fmt.Printf("%s", indent.String())
printBytes(buf[:n])
}
break
}
if err != nil {
return err
}
fmt.Printf("%s", indent.String())
printBytes(buf[:n])
}
fmt.Printf("%s}\n", indent.String())
return nil
}
func main() {
indent := 0
flag.Usage = func() { usage(os.Stderr, 0) }
flag.IntVar(&indent, "n", 0, "indent level")
flag.Parse()
for _, file := range flag.Args() {
err := dumpFile(file, indent)
die.If(err)
}
}

52
cmd/eig/main.go Normal file
View File

@@ -0,0 +1,52 @@
package main
import (
"flag"
"os"
"git.wntrmute.dev/kyle/goutils/die"
)
// size of a kilobit in bytes.
const kilobit = 128
const pageSize = 4096
func main() {
size := flag.Int("s", 256*kilobit, "size of EEPROM image in kilobits")
fill := flag.Uint("f", 0, "byte to fill image with")
flag.Parse()
if *fill > 256 {
die.With("`fill` argument must be a byte value")
}
path := "eeprom.img"
if flag.NArg() > 0 {
path = flag.Arg(0)
}
fillByte := uint8(*fill & 0xff) // #nosec G115 clearing out of bounds bits
buf := make([]byte, pageSize)
for i := range pageSize {
buf[i] = fillByte
}
pages := *size / pageSize
last := *size % pageSize
file, err := os.Create(path)
die.If(err)
defer file.Close()
for range pages {
_, err = file.Write(buf)
die.If(err)
}
if last != 0 {
_, err = file.Write(buf[:last])
die.If(err)
}
}

View File

@@ -4,16 +4,21 @@ import (
"bufio"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"github.com/kisom/goutils/die"
"git.wntrmute.dev/kyle/goutils/die"
)
func usage() {
func init() {
flag.Usage = func() { usage(os.Stdout); os.Exit(1) }
}
func usage(w io.Writer) {
progname := filepath.Base(os.Args[0])
fmt.Printf(`Usage: %s [-nl] file start [end]
fmt.Fprintf(w, `Usage: %s [-nl] file start [end]
Print a fragment of a file starting a line 'start' and ending
at line 'end', or EOF if no end is specified.
@@ -27,7 +32,7 @@ func main() {
flag.Parse()
if flag.NArg() < 2 || flag.NArg() > 3 {
usage()
usage(os.Stderr)
os.Exit(1)
}
@@ -67,15 +72,13 @@ func main() {
if end < start {
fmt.Fprintln(os.Stderr, "[!] end < start, swapping values")
tmp := end
end = start
start = tmp
start, end = end, start
}
var fmtStr string
if !*quiet {
maxLine := fmt.Sprintf("%d", len(lines))
maxLine := strconv.Itoa(len(lines))
fmtStr = fmt.Sprintf("%%0%dd: %%s", len(maxLine))
}
@@ -93,9 +96,9 @@ func main() {
fmtStr += "\n"
for i := start; !endFunc(i); i++ {
if *quiet {
fmt.Println(lines[i])
fmt.Fprintln(os.Stdout, lines[i])
} else {
fmt.Printf(fmtStr, i, lines[i])
fmt.Fprintf(os.Stdout, fmtStr, i, lines[i])
}
}
}

5
cmd/host/README Normal file
View File

@@ -0,0 +1,5 @@
host
This is a utility to display CNAME records and IPs for a hostname. It
was born of my frustration in trying to figure out how to get the host(1)
tool installed on Fedora.

43
cmd/host/main.go Normal file
View File

@@ -0,0 +1,43 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net"
)
func lookupHost(host string) error {
r := &net.Resolver{}
cname, err := r.LookupCNAME(context.Background(), host)
if err != nil {
return err
}
if cname != host {
fmt.Printf("%s is a CNAME for %s\n", host, cname)
host = cname
}
addrs, err := r.LookupHost(context.Background(), host)
if err != nil {
return err
}
for _, addr := range addrs {
fmt.Printf("\t%s\n", addr)
}
return nil
}
func main() {
flag.Parse()
for _, arg := range flag.Args() {
if err := lookupHost(arg); err != nil {
log.Printf("%s: %s", arg, err)
}
}
}

View File

@@ -5,10 +5,10 @@ import (
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"io"
"os"
"github.com/kisom/goutils/lib"
"git.wntrmute.dev/kyle/goutils/lib"
)
func prettify(file string, validateOnly bool) error {
@@ -16,20 +16,20 @@ func prettify(file string, validateOnly bool) error {
var err error
if file == "-" {
in, err = ioutil.ReadAll(os.Stdin)
in, err = io.ReadAll(os.Stdin)
} else {
in, err = ioutil.ReadFile(file)
in, err = os.ReadFile(file)
}
if err != nil {
lib.Warn(err, "ReadFile")
_, _ = lib.Warn(err, "ReadFile")
return err
}
var buf = &bytes.Buffer{}
err = json.Indent(buf, in, "", " ")
if err != nil {
lib.Warn(err, "%s", file)
_, _ = lib.Warn(err, "%s", file)
return err
}
@@ -40,11 +40,11 @@ func prettify(file string, validateOnly bool) error {
if file == "-" {
_, err = os.Stdout.Write(buf.Bytes())
} else {
err = ioutil.WriteFile(file, buf.Bytes(), 0644)
err = os.WriteFile(file, buf.Bytes(), 0o644)
}
if err != nil {
lib.Warn(err, "WriteFile")
_, _ = lib.Warn(err, "WriteFile")
}
return err
@@ -55,20 +55,20 @@ func compact(file string, validateOnly bool) error {
var err error
if file == "-" {
in, err = ioutil.ReadAll(os.Stdin)
in, err = io.ReadAll(os.Stdin)
} else {
in, err = ioutil.ReadFile(file)
in, err = os.ReadFile(file)
}
if err != nil {
lib.Warn(err, "ReadFile")
_, _ = lib.Warn(err, "ReadFile")
return err
}
var buf = &bytes.Buffer{}
err = json.Compact(buf, in)
if err != nil {
lib.Warn(err, "%s", file)
_, _ = lib.Warn(err, "%s", file)
return err
}
@@ -79,11 +79,11 @@ func compact(file string, validateOnly bool) error {
if file == "-" {
_, err = os.Stdout.Write(buf.Bytes())
} else {
err = ioutil.WriteFile(file, buf.Bytes(), 0644)
err = os.WriteFile(file, buf.Bytes(), 0o644)
}
if err != nil {
lib.Warn(err, "WriteFile")
_, _ = lib.Warn(err, "WriteFile")
}
return err
@@ -91,7 +91,7 @@ func compact(file string, validateOnly bool) error {
func usage() {
progname := lib.ProgName()
fmt.Printf(`Usage: %s [-h] files...
fmt.Fprintf(os.Stdout, `Usage: %s [-h] files...
%s is used to lint and prettify (or compact) JSON files. The
files will be updated in-place.
@@ -100,7 +100,6 @@ func usage() {
-h Print this help message.
-n Don't prettify; only perform validation.
`, progname, progname)
}
func init() {

26
cmd/kgz/README Normal file
View File

@@ -0,0 +1,26 @@
kgz
kgz is like gzip, but supports compressing and decompressing to a different
directory than the source file is in.
Usage: kgz [-l] source [target]
If target is a directory, the basename of the sourcefile will be used
as the target filename. Compression and decompression is selected
based on whether the source filename ends in ".gz".
Flags:
-l level Compression level (0-9). Only meaninful when
compressing a file.
-u Do not restrict the size during decompression. As
a safeguard against gzip bombs, the maximum size
allowed is 32 * the compressed file size.

196
cmd/kgz/main.go Normal file
View File

@@ -0,0 +1,196 @@
package main
import (
"compress/flate"
"compress/gzip"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const gzipExt = ".gz"
func compress(path, target string, level int) error {
sourceFile, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening file for read: %w", err)
}
defer sourceFile.Close()
destFile, err := os.Create(target)
if err != nil {
return fmt.Errorf("opening file for write: %w", err)
}
defer destFile.Close()
gzipCompressor, err := gzip.NewWriterLevel(destFile, level)
if err != nil {
return fmt.Errorf("invalid compression level: %w", err)
}
defer gzipCompressor.Close()
_, err = io.Copy(gzipCompressor, sourceFile)
if err != nil {
return fmt.Errorf("compressing file: %w", err)
}
return nil
}
func uncompress(path, target string, unrestrict bool) error {
sourceFile, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening file for read: %w", err)
}
defer sourceFile.Close()
fi, err := sourceFile.Stat()
if err != nil {
return fmt.Errorf("reading file stats: %w", err)
}
maxDecompressionSize := fi.Size() * 32
gzipUncompressor, err := gzip.NewReader(sourceFile)
if err != nil {
return fmt.Errorf("reading gzip headers: %w", err)
}
defer gzipUncompressor.Close()
var reader io.Reader = &io.LimitedReader{
R: gzipUncompressor,
N: maxDecompressionSize,
}
if unrestrict {
reader = gzipUncompressor
}
destFile, err := os.Create(target)
if err != nil {
return fmt.Errorf("opening file for write: %w", err)
}
defer destFile.Close()
_, err = io.Copy(destFile, reader)
if err != nil {
return fmt.Errorf("uncompressing file: %w", err)
}
return nil
}
func usage(w io.Writer) {
fmt.Fprintf(w, `Usage: %s [-l] source [target]
kgz is like gzip, but supports compressing and decompressing to a different
directory than the source file is in.
Flags:
-l level Compression level (0-9). Only meaninful when
compressing a file.
`, os.Args[0])
}
func init() {
flag.Usage = func() { usage(os.Stderr) }
}
func isDir(path string) bool {
file, err := os.Open(path)
if err == nil {
defer file.Close()
stat, err2 := file.Stat()
if err2 != nil {
return false
}
if stat.IsDir() {
return true
}
}
return false
}
func pathForUncompressing(source, dest string) (string, error) {
if !isDir(dest) {
return dest, nil
}
source = filepath.Base(source)
if !strings.HasSuffix(source, gzipExt) {
return "", fmt.Errorf("%s is a not gzip-compressed file", source)
}
outFile := source[:len(source)-len(gzipExt)]
outFile = filepath.Join(dest, outFile)
return outFile, nil
}
func pathForCompressing(source, dest string) (string, error) {
if !isDir(dest) {
return dest, nil
}
source = filepath.Base(source)
if strings.HasSuffix(source, gzipExt) {
return "", fmt.Errorf("%s is a gzip-compressed file", source)
}
dest = filepath.Join(dest, source+gzipExt)
return dest, nil
}
func main() {
var level int
var path string
var target = "."
var err error
var unrestrict bool
flag.IntVar(&level, "l", flate.DefaultCompression, "compression level")
flag.BoolVar(&unrestrict, "u", false, "do not restrict decompression")
flag.Parse()
if flag.NArg() < 1 || flag.NArg() > 2 {
usage(os.Stderr)
os.Exit(1)
}
path = flag.Arg(0)
if flag.NArg() == 2 {
target = flag.Arg(1)
}
if strings.HasSuffix(path, gzipExt) {
target, err = pathForUncompressing(path, target)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
err = uncompress(path, target, unrestrict)
if err != nil {
os.Remove(target)
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
return
}
target, err = pathForCompressing(path, target)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
err = compress(path, target, level)
if err != nil {
os.Remove(target)
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
}

3
cmd/minmax/README Normal file
View File

@@ -0,0 +1,3 @@
minmax
A quick tool to calculate minmax codes if needed for uLisp.

53
cmd/minmax/main.go Normal file
View File

@@ -0,0 +1,53 @@
package main
import (
"flag"
"fmt"
"os"
"strconv"
)
var kinds = map[string]int{
"sym": 0,
"tf": 1,
"fn": 2,
"sp": 3,
}
func dieIf(err error) {
if err == nil {
return
}
fmt.Fprintf(os.Stderr, "[!] %s\n", err)
os.Exit(1)
}
func usage() {
fmt.Fprintf(os.Stderr, "usage: minmax type min max\n")
fmt.Fprintf(os.Stderr, " type is one of fn, sp, sym, tf\n")
os.Exit(1)
}
func main() {
flag.Parse()
if flag.NArg() != 3 {
usage()
}
kind, ok := kinds[flag.Arg(0)]
if !ok {
usage()
}
minVal, err := strconv.Atoi(flag.Arg(1))
dieIf(err)
maxVal, err := strconv.Atoi(flag.Arg(2))
dieIf(err)
code := kind << 6
code += (minVal << 3)
code += maxVal
fmt.Fprintf(os.Stdout, "%0o\n", code)
}

9
cmd/parts/README Normal file
View File

@@ -0,0 +1,9 @@
parts: simple parts database for electronic components
Usage: parts [id] -- query the database for a part
parts [-c class] [id] [description] -- store a part in the database
Options:
-f path Path to parts database (default is
/home/kyle/.parts.json).

141
cmd/parts/main.go Normal file
View File

@@ -0,0 +1,141 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"git.wntrmute.dev/kyle/goutils/die"
)
const dbVersion = "1"
var dbFile = filepath.Join(os.Getenv("HOME"), ".parts.json")
var partsDB = &database{Version: dbVersion}
type part struct {
Name string `json:"name"`
Description string `json:"description"`
Class string `json:"class,omitempty"`
}
func (p part) String() string {
return fmt.Sprintf("%s: %s", p.Name, p.Description)
}
type database struct {
Version string `json:"version"`
LastUpdate int64 `json:"json"`
Parts map[string]part `json:"parts"`
}
func help(w io.Writer) {
fmt.Fprintf(w, `Usage: parts [id] -- query the database for a part
parts [-c class] [id] [description] -- store a part in the database
Options:
-f path Path to parts database (default is
%s).
`, dbFile)
}
func loadDatabase() {
data, err := os.ReadFile(dbFile)
if err != nil && os.IsNotExist(err) {
partsDB = &database{
Version: dbVersion,
Parts: map[string]part{},
}
return
}
die.If(err)
err = json.Unmarshal(data, partsDB)
die.If(err)
}
func findPart(partName string) {
partName = strings.ToLower(partName)
for name, part := range partsDB.Parts {
if strings.Contains(strings.ToLower(name), partName) {
fmt.Println(part.String())
}
}
}
func writeDB() {
data, err := json.Marshal(partsDB)
die.If(err)
err = os.WriteFile(dbFile, data, 0644)
die.If(err)
}
func storePart(name, class, description string) {
p, exists := partsDB.Parts[name]
if exists {
fmt.Printf("warning: replacing part %s\n", name)
fmt.Printf("\t%s\n", p.String())
}
partsDB.Parts[name] = part{
Name: name,
Class: class,
Description: description,
}
writeDB()
}
func listParts() {
parts := make([]string, 0, len(partsDB.Parts))
for partName := range partsDB.Parts {
parts = append(parts, partName)
}
sort.Strings(parts)
for _, partName := range parts {
fmt.Println(partsDB.Parts[partName].String())
}
}
func main() {
var class string
var helpFlag bool
flag.StringVar(&class, "c", "", "device class")
flag.StringVar(&dbFile, "f", dbFile, "`path` to database")
flag.BoolVar(&helpFlag, "h", false, "Print a help message.")
flag.Parse()
if helpFlag {
help(os.Stdout)
return
}
loadDatabase()
switch flag.NArg() {
case 0:
help(os.Stdout)
return
case 1:
partName := flag.Arg(0)
if partName == "list" {
listParts()
} else {
findPart(flag.Arg(0))
}
return
default:
description := strings.Join(flag.Args()[1:], " ")
storePart(flag.Arg(0), class, description)
return
}
}

View File

@@ -4,14 +4,13 @@ import (
"encoding/pem"
"flag"
"fmt"
"io/ioutil"
"os"
)
var ext = ".bin"
func stripPEM(path string) error {
data, err := ioutil.ReadFile(path)
data, err := os.ReadFile(path)
if err != nil {
return err
}
@@ -22,7 +21,7 @@ func stripPEM(path string) error {
fmt.Fprintf(os.Stderr, " (only the first object will be decoded)\n")
}
return ioutil.WriteFile(path+ext, p.Bytes, 0644)
return os.WriteFile(path+ext, p.Bytes, 0644)
}
func main() {

View File

@@ -3,11 +3,10 @@ package main
import (
"encoding/pem"
"flag"
"fmt"
"io/ioutil"
"io"
"os"
"github.com/kisom/goutils/lib"
"git.wntrmute.dev/kyle/goutils/lib"
)
func main() {
@@ -21,9 +20,9 @@ func main() {
path := flag.Arg(0)
if path == "-" {
in, err = ioutil.ReadAll(os.Stdin)
in, err = io.ReadAll(os.Stdin)
} else {
in, err = ioutil.ReadFile(flag.Arg(0))
in, err = os.ReadFile(flag.Arg(0))
}
if err != nil {
lib.Err(lib.ExitFailure, err, "couldn't read file")
@@ -33,5 +32,7 @@ func main() {
if p == nil {
lib.Errx(lib.ExitFailure, "%s isn't a PEM-encoded file", flag.Arg(0))
}
fmt.Printf("%s", p.Bytes)
if _, err = os.Stdout.Write(p.Bytes); err != nil {
lib.Err(lib.ExitFailure, err, "writing body")
}
}

View File

@@ -8,9 +8,9 @@ import (
"io"
"os"
"github.com/kisom/goutils/assert"
"github.com/kisom/goutils/die"
"github.com/kisom/goutils/lib"
"git.wntrmute.dev/kyle/goutils/assert"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
)
func usage(w io.Writer) {
@@ -70,7 +70,7 @@ func main() {
lib.Err(lib.ExitFailure, err, "failed to read input")
}
case argc > 1:
for i := 0; i < argc; i++ {
for i := range argc {
path := flag.Arg(i)
err = copyFile(path, buf)
if err != nil {

View File

@@ -5,7 +5,6 @@ import (
"encoding/pem"
"flag"
"fmt"
"io/ioutil"
"os"
)
@@ -13,14 +12,14 @@ func main() {
flag.Parse()
for _, fileName := range flag.Args() {
data, err := ioutil.ReadFile(fileName)
data, err := os.ReadFile(fileName)
if err != nil {
fmt.Fprintf(os.Stderr, "[!] %s: %v\n", fileName, err)
continue
}
fmt.Printf("[+] %s:\n", fileName)
rest := data[:]
fmt.Fprintf(os.Stdout, "[+] %s:\n", fileName)
rest := data
for {
var p *pem.Block
p, rest = pem.Decode(rest)
@@ -28,13 +27,14 @@ func main() {
break
}
cert, err := x509.ParseCertificate(p.Bytes)
var cert *x509.Certificate
cert, err = x509.ParseCertificate(p.Bytes)
if err != nil {
fmt.Fprintf(os.Stderr, "[!] %s: %v\n", fileName, err)
break
}
fmt.Printf("\t%+v\n", cert.Subject.CommonName)
fmt.Fprintf(os.Stdout, "\t%+v\n", cert.Subject.CommonName)
}
}
}

View File

@@ -11,8 +11,8 @@ import (
"path/filepath"
"strings"
"github.com/kisom/goutils/fileutil"
"github.com/kisom/goutils/lib"
"git.wntrmute.dev/kyle/goutils/fileutil"
"git.wntrmute.dev/kyle/goutils/lib"
)
func hashName(path, encodedHash string) string {
@@ -43,31 +43,32 @@ func newName(path string) (string, error) {
return hashName(path, encodedHash), nil
}
func move(dst, src string, force bool) (err error) {
func move(dst, src string, force bool) error {
if fileutil.FileDoesExist(dst) && !force {
return fmt.Errorf("%s exists (pass the -f flag to overwrite)", dst)
return nil
}
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer func(e error) {
var retErr error
defer func(e *error) {
dstFile.Close()
if e != nil {
if *e != nil {
os.Remove(dst)
}
}(err)
}(&retErr)
srcFile, err := os.Open(src)
if err != nil {
retErr = err
return err
}
defer srcFile.Close()
_, err = io.Copy(dstFile, srcFile)
if err != nil {
if _, err = io.Copy(dstFile, srcFile); err != nil {
retErr = err
return err
}
@@ -92,7 +93,45 @@ Options:
}
func init() {
flag.Usage = func () { usage(os.Stdout) }
flag.Usage = func() { usage(os.Stdout) }
}
type options struct {
dryRun, force, printChanged, verbose bool
}
func processOne(file string, opt options) error {
renamed, err := newName(file)
if err != nil {
_, _ = lib.Warn(err, "failed to get new file name")
return err
}
if opt.verbose && !opt.printChanged {
fmt.Fprintln(os.Stdout, file)
}
if renamed == file {
return nil
}
if !opt.dryRun {
if err = move(renamed, file, opt.force); err != nil {
_, _ = lib.Warn(err, "failed to rename file from %s to %s", file, renamed)
return err
}
}
if opt.printChanged && !opt.verbose {
fmt.Fprintln(os.Stdout, file, "->", renamed)
}
return nil
}
func run(dryRun, force, printChanged, verbose bool, files []string) {
if verbose && printChanged {
printChanged = false
}
opt := options{dryRun: dryRun, force: force, printChanged: printChanged, verbose: verbose}
for _, file := range files {
_ = processOne(file, opt)
}
}
func main() {
@@ -103,34 +142,5 @@ func main() {
flag.BoolVar(&verbose, "v", false, "list all processed files")
flag.Parse()
if verbose && printChanged {
printChanged = false
}
for _, file := range flag.Args() {
renamed, err := newName(file)
if err != nil {
lib.Warn(err, "failed to get new file name")
continue
}
if verbose && !printChanged {
fmt.Println(file)
}
if renamed != file {
if !dryRun {
err = move(renamed, file, force)
if err != nil {
lib.Warn(err, "failed to rename file from %s to %s", file, renamed)
continue
}
}
if printChanged && !verbose {
fmt.Println(file, "->", renamed)
}
}
}
run(dryRun, force, printChanged, verbose, flag.Args())
}

22
cmd/rhash/README Normal file
View File

@@ -0,0 +1,22 @@
rhash: remote hashing tool
Usage: rhash [-a algo] [-h] [-l set] urls...
Compute the hash over each URL.
Flags:
-a algo Specify the hash algorithm to use; the default is sha256.
-h Print this help message.
-l set List the hash functions under set. Set can be one of all,
secure to list only cryptographic hash functions, or
insecure to list only non-cryptographic hash functions.
Examples:
Compute the SHA256 digest of the LICENSE in this repository:
$ rhash https://raw.githubusercontent.com/kisom/goutils/7391da8567952f69990194ead2842d21df217c89/LICENSE
LICENSE: sha256=620bfadeb698df6c6db73908689a29371a9d4cff32b08c48a5c4307946093980
Compute the SHA-1 digest of the LICENSE in this repository:
$ rhash -a sha1 https://raw.githubusercontent.com/kisom/goutils/7391da8567952f69990194ead2842d21df217c89/LICENSE
LICENSE: sha1=83c6e2e410715058ed6e7c1572176122c024e367

99
cmd/rhash/main.go Normal file
View File

@@ -0,0 +1,99 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"git.wntrmute.dev/kyle/goutils/ahash"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
)
func usage(w io.Writer) {
fmt.Fprintf(w, `Usage: %s [-a algo] [-h] [-l set] urls...
Compute the hash over each URL.
Flags:
-a algo Specify the hash algorithm to use; the default is sha256.
-h Print this help message.
-l set List the hash functions under set. Set can be one of all,
secure to list only cryptographic hash functions, or
insecure to list only non-cryptographic hash functions.
`, lib.ProgName())
}
func init() {
flag.Usage = func() { usage(os.Stderr) }
}
func main() {
var algo, list string
var help bool
flag.StringVar(&algo, "a", "sha256", "hash algorithm to use")
flag.BoolVar(&help, "h", false, "print a help message")
flag.StringVar(&list, "l", "", "list known hash algorithms (one of all, secure, insecure)")
flag.Parse()
if help {
usage(os.Stdout)
}
if list != "" {
var hashes []string
switch list {
case "all":
hashes = ahash.HashList()
case "secure":
hashes = ahash.SecureHashList()
case "insecure":
hashes = ahash.InsecureHashList()
default:
die.With("list option must be one of all, secure, or insecure.")
}
for _, algo := range hashes {
fmt.Printf("- %s\n", algo)
}
os.Exit(1)
}
for _, remote := range flag.Args() {
u, err := url.Parse(remote)
if err != nil {
_, _ = lib.Warn(err, "parsing %s", remote)
continue
}
name := filepath.Base(u.Path)
if name == "" {
_, _ = lib.Warnx("source URL doesn't appear to name a file")
continue
}
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, remote, nil)
if reqErr != nil {
_, _ = lib.Warn(reqErr, "building request for %s", remote)
continue
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
_, _ = lib.Warn(err, "fetching %s", remote)
continue
}
sum, err := ahash.SumReader(algo, resp.Body)
resp.Body.Close()
if err != nil {
lib.Err(lib.ExitFailure, err, "while hashing data")
}
fmt.Printf("%s: %s=%x\n", name, algo, sum)
}
}

48
cmd/rolldie/main.go Normal file
View File

@@ -0,0 +1,48 @@
package main
import (
"flag"
"fmt"
"math/rand/v2"
"os"
"regexp"
"strconv"
"git.wntrmute.dev/kyle/goutils/die"
)
var dieRollFormat = regexp.MustCompile(`^(\d+)[dD](\d+)$`)
func rollDie(count, sides int) []int {
sum := 0
var rolls []int
for range count {
roll := rand.IntN(sides) + 1 // #nosec G404
sum += roll
rolls = append(rolls, roll)
}
rolls = append(rolls, sum)
return rolls
}
func main() {
flag.Parse()
for _, arg := range flag.Args() {
if !dieRollFormat.MatchString(arg) {
fmt.Fprintf(os.Stderr, "invalid die format %s: should be XdY\n", arg)
os.Exit(1)
}
dieRoll := dieRollFormat.FindAllStringSubmatch(arg, -1)
count, err := strconv.Atoi(dieRoll[0][1])
die.If(err)
sides, err := strconv.Atoi(dieRoll[0][2])
die.If(err)
fmt.Println(rollDie(count, sides))
}
}

View File

@@ -12,36 +12,23 @@ import (
"sort"
"strings"
"github.com/kisom/goutils/die"
"github.com/kisom/goutils/logging"
"git.wntrmute.dev/kyle/goutils/dbg"
"git.wntrmute.dev/kyle/goutils/die"
)
var (
gopath string
project string
debug bool
)
var (
stdLibRegexp = regexp.MustCompile(`^\w+(/\w+)*$`)
sourceRegexp = regexp.MustCompile(`^[^.].*\.go$`)
log = logging.NewConsole()
imports = map[string]bool{}
debug = dbg.New()
fset = &token.FileSet{}
imports = map[string]bool{}
sourceRegexp = regexp.MustCompile(`^[^.].*\.go$`)
stdLibRegexp = regexp.MustCompile(`^\w+(/\w+)*$`)
)
func debugf(format string, args ...interface{}) {
if debug {
fmt.Printf(format, args...)
}
}
func debugln(args ...interface{}) {
if debug {
fmt.Println(args...)
}
}
func init() {
gopath = os.Getenv("GOPATH")
if gopath == "" {
@@ -66,41 +53,64 @@ func init() {
project = wd[len(gopath):]
}
func walkFile(path string, info os.FileInfo, err error) error {
func walkFile(path string, _ os.FileInfo, err error) error {
if ignores[path] {
return filepath.SkipDir
}
if !sourceRegexp.MatchString(path) {
return nil
}
debugln(path)
f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err != nil {
return err
}
debug.Println(path)
f, err2 := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err2 != nil {
return err2
}
for _, importSpec := range f.Imports {
importPath := strings.Trim(importSpec.Path.Value, `"`)
if stdLibRegexp.MatchString(importPath) {
debugln("standard lib:", importPath)
switch {
case stdLibRegexp.MatchString(importPath):
debug.Println("standard lib:", importPath)
continue
} else if strings.HasPrefix(importPath, project) {
debugln("internal import:", importPath)
case strings.HasPrefix(importPath, project):
debug.Println("internal import:", importPath)
continue
} else if strings.HasPrefix(importPath, "golang.org/") {
debugln("extended lib:", importPath)
case strings.HasPrefix(importPath, "golang.org/"):
debug.Println("extended lib:", importPath)
continue
}
debugln("import:", importPath)
debug.Println("import:", importPath)
imports[importPath] = true
}
return nil
}
var ignores = map[string]bool{}
func main() {
flag.BoolVar(&debug, "v", false, "log debugging information")
var ignoreLine string
var noVendor bool
flag.StringVar(&ignoreLine, "i", "", "comma-separated list of directories to ignore")
flag.BoolVar(&noVendor, "nv", false, "ignore the vendor directory")
flag.BoolVar(&debug.Enabled, "v", false, "log debugging information")
flag.Parse()
if noVendor {
ignores["vendor"] = true
}
for word := range strings.SplitSeq(ignoreLine, ",") {
ignores[strings.TrimSpace(word)] = true
}
err := filepath.Walk(".", walkFile)
die.If(err)

30
cmd/ski/README Normal file
View File

@@ -0,0 +1,30 @@
ski: print subject public key info
Usage:
ski [-hm] files...
Flags:
-h Print a help message and exit.
-m All SKIs should match.
Examples:
Printing the SKI of a private key and certificate:
$ ski *
server.key 3A:AB:D1:B2:E5:7A:F2:5A:D5:8E:8B:7B:25:D9:41:90:F8:6B:A3:5E (RSA private key)
[ski] trailing data in PEM file
server.pem 3A:AB:D1:B2:E5:7A:F2:5A:D5:8E:8B:7B:25:D9:41:90:F8:6B:A3:5E (RSA certificate)
Making sure the SKIs match:
$ ski -m *
tyrfingr.key 3A:AB:D1:B2:E5:7A:F2:5A:D5:8E:8B:7B:25:D9:41:90:F8:6B:A3:5E (RSA private key)
[ski] trailing data in PEM file
tyrfingr.pem 3A:AB:D1:B2:E5:7A:F2:5A:D5:8E:8B:7B:25:D9:41:90:F8:6B:A3:5E (RSA certificate)
Making sure the SKIs match with a bad certificate:
$ ski -m server.key bad.pem
server.key 3A:AB:D1:B2:E5:7A:F2:5A:D5:8E:8B:7B:25:D9:41:90:F8:6B:A3:5E (RSA private key)
[ski] bad.pem: SKI mismatch (3A:AB:D1:B2:E5:7A:F2:5A:D5:8E:8B:7B:25:D9:41:90:F8:6B:A3:5E != 90:AF:6A:3A:94:5A:0B:D8:90:EA:12:56:73:DF:43:B4:3A:28:DA:E7)
bad.pem 90:AF:6A:3A:94:5A:0B:D8:90:EA:12:56:73:DF:43:B4:3A:28:DA:E7 (RSA certificate)

198
cmd/ski/main.go Normal file
View File

@@ -0,0 +1,198 @@
package main
import (
"bytes"
"crypto/ecdsa"
"crypto/rsa"
"crypto/sha1" // #nosec G505
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"flag"
"fmt"
"io"
"os"
"strings"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
)
const (
keyTypeRSA = "RSA"
keyTypeECDSA = "ECDSA"
)
func usage(w io.Writer) {
fmt.Fprintf(w, `ski: print subject key info for PEM-encoded files
Usage:
ski [-hm] files...
Flags:
-h Print this help message.
-m All SKIs should match; as soon as an SKI mismatch is found,
it is reported.
`)
}
func init() {
flag.Usage = func() { usage(os.Stderr) }
}
func parse(path string) ([]byte, string, string) {
data, err := os.ReadFile(path)
die.If(err)
data = bytes.TrimSpace(data)
p, rest := pem.Decode(data)
if len(rest) > 0 {
_, _ = lib.Warnx("trailing data in PEM file")
}
if p == nil {
die.With("no PEM data found")
}
data = p.Bytes
var (
public []byte
kt string
ft string
)
switch p.Type {
case "PRIVATE KEY", "RSA PRIVATE KEY", "EC PRIVATE KEY":
public, kt = parseKey(data)
ft = "private key"
case "CERTIFICATE":
public, kt = parseCertificate(data)
ft = "certificate"
case "CERTIFICATE REQUEST":
public, kt = parseCSR(data)
ft = "certificate request"
default:
die.With("unknown PEM type %s", p.Type)
}
return public, kt, ft
}
func parseKey(data []byte) ([]byte, string) {
priv, err := certlib.ParsePrivateKeyDER(data)
if err != nil {
die.If(err)
}
var kt string
switch priv.Public().(type) {
case *rsa.PublicKey:
kt = keyTypeRSA
case *ecdsa.PublicKey:
kt = keyTypeECDSA
default:
die.With("unknown private key type %T", priv)
}
public, err := x509.MarshalPKIXPublicKey(priv.Public())
die.If(err)
return public, kt
}
func parseCertificate(data []byte) ([]byte, string) {
cert, err := x509.ParseCertificate(data)
die.If(err)
pub := cert.PublicKey
var kt string
switch pub.(type) {
case *rsa.PublicKey:
kt = keyTypeRSA
case *ecdsa.PublicKey:
kt = keyTypeECDSA
default:
die.With("unknown public key type %T", pub)
}
public, err := x509.MarshalPKIXPublicKey(pub)
die.If(err)
return public, kt
}
func parseCSR(data []byte) ([]byte, string) {
// Use certlib to support both PEM and DER and to centralize validation.
csr, _, err := certlib.ParseCSR(data)
die.If(err)
pub := csr.PublicKey
var kt string
switch pub.(type) {
case *rsa.PublicKey:
kt = keyTypeRSA
case *ecdsa.PublicKey:
kt = keyTypeECDSA
default:
die.With("unknown public key type %T", pub)
}
public, err := x509.MarshalPKIXPublicKey(pub)
die.If(err)
return public, kt
}
func dumpHex(in []byte) string {
var s string
var sSb153 strings.Builder
for i := range in {
sSb153.WriteString(fmt.Sprintf("%02X:", in[i]))
}
s += sSb153.String()
return strings.Trim(s, ":")
}
type subjectPublicKeyInfo struct {
Algorithm pkix.AlgorithmIdentifier
SubjectPublicKey asn1.BitString
}
func main() {
var help, shouldMatch bool
flag.BoolVar(&help, "h", false, "print a help message and exit")
flag.BoolVar(&shouldMatch, "m", false, "all SKIs should match")
flag.Parse()
if help {
usage(os.Stdout)
os.Exit(0)
}
var ski string
for _, path := range flag.Args() {
public, kt, ft := parse(path)
var subPKI subjectPublicKeyInfo
_, err := asn1.Unmarshal(public, &subPKI)
if err != nil {
_, _ = lib.Warn(err, "failed to get subject PKI")
continue
}
pubHash := sha1.Sum(subPKI.SubjectPublicKey.Bytes) // #nosec G401 this is the standard
pubHashString := dumpHex(pubHash[:])
if ski == "" {
ski = pubHashString
}
if shouldMatch && ski != pubHashString {
_, _ = lib.Warnx("%s: SKI mismatch (%s != %s)",
path, ski, pubHashString)
}
fmt.Printf("%s %s (%s %s)\n", path, pubHashString, kt, ft)
}
}

53
cmd/sprox/main.go Normal file
View File

@@ -0,0 +1,53 @@
package main
import (
"context"
"flag"
"io"
"net"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
)
func proxy(conn net.Conn, inside string) error {
proxyConn, err := (&net.Dialer{}).DialContext(context.Background(), "tcp", inside)
if err != nil {
return err
}
defer proxyConn.Close()
defer conn.Close()
go func() {
_, _ = io.Copy(conn, proxyConn)
}()
_, err = io.Copy(proxyConn, conn)
return err
}
func main() {
var outside, inside string
flag.StringVar(&outside, "f", "8080", "outside port")
flag.StringVar(&inside, "p", "4000", "inside port")
flag.Parse()
lc := &net.ListenConfig{}
l, err := lc.Listen(context.Background(), "tcp", "0.0.0.0:"+outside)
die.If(err)
for {
var conn net.Conn
conn, err = l.Accept()
if err != nil {
_, _ = lib.Warn(err, "accept failed")
continue
}
go func() {
if err = proxy(conn, "127.0.0.1:"+inside); err != nil {
_, _ = lib.Warn(err, "proxy error")
}
}()
}
}

View File

@@ -0,0 +1,17 @@
stealchain-server
This is a utility to extract the verified X.509 chain from a TLS
connection initiated by another client. It listens on a port, and
for each connection, it will dump the certificates that the peer
actually sent (and not the verified chain that is built from this).
It was written to assist in debugging issues with certificate chains.
There are a few knobs:
-listen specifies the address to listen on.
-ca allows the trusted CA roots to be specified via a PEM bundle of
root certificates.
-verify requires that the client present a valid certificate chain.

View File

@@ -0,0 +1,113 @@
package main
import (
"context"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"flag"
"fmt"
"net"
"os"
"git.wntrmute.dev/kyle/goutils/die"
)
func main() {
cfg := &tls.Config{} // #nosec G402
var sysRoot, listenAddr, certFile, keyFile string
var verify bool
flag.StringVar(&sysRoot, "ca", "", "provide an alternate CA bundle")
flag.StringVar(&listenAddr, "listen", ":443", "address to listen on")
flag.StringVar(&certFile, "cert", "", "server certificate to present to clients")
flag.StringVar(&keyFile, "key", "", "key for server certificate")
flag.BoolVar(&verify, "verify", false, "verify client certificates")
flag.Parse()
if verify {
cfg.ClientAuth = tls.RequireAndVerifyClientCert
} else {
cfg.ClientAuth = tls.RequestClientCert
}
if certFile == "" {
fmt.Println("[!] missing required flag -cert")
os.Exit(1)
}
if keyFile == "" {
fmt.Println("[!] missing required flag -key")
os.Exit(1)
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
fmt.Printf("[!] could not load server key pair: %v", err)
os.Exit(1)
}
cfg.Certificates = append(cfg.Certificates, cert)
if sysRoot != "" {
var pemList []byte
pemList, err = os.ReadFile(sysRoot)
die.If(err)
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(pemList) {
fmt.Printf("[!] no valid roots found")
roots = nil
}
cfg.RootCAs = roots
}
lc := &net.ListenConfig{}
l, err := lc.Listen(context.Background(), "tcp", listenAddr)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
for {
var conn net.Conn
conn, err = l.Accept()
if err != nil {
fmt.Println(err.Error())
continue
}
handleConn(conn, cfg)
}
}
// handleConn performs a TLS handshake, extracts the peer chain, and writes it to a file.
func handleConn(conn net.Conn, cfg *tls.Config) {
defer conn.Close()
raddr := conn.RemoteAddr()
tconn := tls.Server(conn, cfg)
if err := tconn.HandshakeContext(context.Background()); err != nil {
fmt.Printf("[+] %v: failed to complete handshake: %v\n", raddr, err)
return
}
cs := tconn.ConnectionState()
if len(cs.PeerCertificates) == 0 {
fmt.Printf("[+] %v: no chain presented\n", raddr)
return
}
var chain []byte
for _, cert := range cs.PeerCertificates {
p := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
chain = append(chain, pem.EncodeToMemory(p)...)
}
var nonce [16]byte
if _, err := rand.Read(nonce[:]); err != nil {
fmt.Printf("[+] %v: failed to generate filename nonce: %v\n", raddr, err)
return
}
fname := fmt.Sprintf("%v-%v.pem", raddr, hex.EncodeToString(nonce[:]))
if err := os.WriteFile(fname, chain, 0o644); err != nil {
fmt.Printf("[+] %v: failed to write %v: %v\n", raddr, fname, err)
return
}
fmt.Printf("%v: [+] wrote %v.\n", raddr, fname)
}

View File

@@ -1,20 +1,20 @@
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"flag"
"fmt"
"io/ioutil"
"net"
"os"
"github.com/kisom/goutils/die"
"git.wntrmute.dev/kyle/goutils/die"
)
func main() {
var cfg = &tls.Config{}
var cfg = &tls.Config{} // #nosec G402
var sysRoot, serverName string
flag.StringVar(&sysRoot, "ca", "", "provide an alternate CA bundle")
@@ -23,7 +23,7 @@ func main() {
flag.Parse()
if sysRoot != "" {
pemList, err := ioutil.ReadFile(sysRoot)
pemList, err := os.ReadFile(sysRoot)
die.If(err)
roots := x509.NewCertPool()
@@ -44,10 +44,13 @@ func main() {
if err != nil {
site += ":443"
}
conn, err := tls.Dial("tcp", site, cfg)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
d := &tls.Dialer{Config: cfg}
nc, err := d.DialContext(context.Background(), "tcp", site)
die.If(err)
conn, ok := nc.(*tls.Conn)
if !ok {
die.With("invalid TLS connection (not a *tls.Conn)")
}
cs := conn.ConnectionState()
@@ -61,8 +64,9 @@ func main() {
chain = append(chain, pem.EncodeToMemory(p)...)
}
err = ioutil.WriteFile(site+".pem", chain, 0644)
err = os.WriteFile(site+".pem", chain, 0644)
die.If(err)
fmt.Printf("[+] wrote %s.pem.\n", site)
}
}

20
cmd/subjhash/README Normal file
View File

@@ -0,0 +1,20 @@
subjhash
This tool prints the SHA-256 hash of an X.509 certificate's subject
info or issuer fields. It can also verify that the hashes of the
subject are the same between two certificates.
Usage: subjhash [-im] certs...
Flags:
-i Print hash of issuer field.
-m Matching mode. This expects arguments to be in the form of
pairs of certificates (e.g. previous, new) whose subjects
will be compared. For example,
subjhash -m ca1.pem ca1-renewed.pem \
ca2.pem ca2-renewed.pem
will exit with a non-zero status if the subject in the
ca1-renewed.pem certificate doesn't match the subject in the
ca.pem certificate; similarly for ca2.

Some files were not shown because too many files have changed in this diff Show More