diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb73c3c..5087ca4 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,23 +1,25 @@ - name: CI on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master] + +permissions: + contents: read jobs: build: runs-on: ubuntu-latest strategy: matrix: - go: [ '1.25' ] + go: ["1.26"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - name: Setup Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v7 with: go-version: ${{ matrix.go }} diff --git a/.golangci.yml b/.golangci.yml index 4aa6f35..759a38d 100755 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,9 +1,9 @@ version: "2" run: - go: "1.25" + go: "1.26" timeout: 5m - tests: false + tests: true issues-exit-code: 1 modules-download-mode: readonly allow-parallel-runners: true @@ -12,7 +12,7 @@ issues: max-issues-per-linter: 0 max-same-issues: 0 new: false - fix: false + fix: true output: formats: @@ -27,6 +27,7 @@ formatters: enable: - gofmt - goimports + - gofumpt linters: settings: @@ -36,7 +37,6 @@ linters: - -S1023 - -ST1000 - -ST1003 - - -SA1019 - -ST1020 gosec: excludes: @@ -47,9 +47,160 @@ linters: - G306 - G501 - G505 - - G506 - - G507 + tagliatelle: + case: + rules: + json: snake # JSON: snake_case (user_id) + yaml: snake # YAML: snake_case + xml: camel # XML: camelCase + mapstructure: snake # mapstructure: snake_case + env: upperSnake # ENV: UPPER_SNAKE_CASE + varnamelen: + min-name-length: 2 # Минимальная длина имени + max-distance: 5 # i, j, k допустимы в scope <= 5 строк + ignore-names: + - err # err — идиоматично + - ok # ok — идиоматично + - id # id — часто используется + - db # db — часто используется + - tx # tx — транзакция + - wg # wg — WaitGroup + - mu # mu — mutex + - rw # rw — RWMutex + - ch # ch — channel + - fn # fn — function + - sb # sb — strings.Builder + - ctx # ctx — context + - q # q — querier + - r # r — repo / reader + - s # s — strategy / service + - f # f — filter + - a # a — left operand (сравнение) + - b # b — right operand (сравнение) + ignore-type-assert-ok: true # Игнорировать v, ok := x.(T) + ignore-map-index-ok: true # Игнорировать v, ok := m[k] + ignore-chan-recv-ok: true # Игнорировать v, ok := <-ch + ignore-decls: + - i int # for i := ... + - j int # вложенные циклы + - n int # количество + - t testing.T # тесты + - b testing.B # бенчмарки + - r *http.Request # HTTP handler + - w http.ResponseWriter # HTTP handler + - c *gin.Context # Gin context + - c echo.Context # Echo context + - s *Server # конструктор Server + - m *metrics # конструктор metrics + revive: + severity: warning + rules: + # ------------------------------------------------------------------------- + # Предотвращение багов + # ------------------------------------------------------------------------- + - name: atomic # Проверяет правильное использование sync/atomic + - name: range-val-in-closure # Захват переменной цикла в замыкании + - name: range-val-address # Взятие адреса переменной цикла + - name: unreachable-code # Недостижимый код после return/panic + - name: unchecked-type-assertion # Type assertion без проверки ok + - name: datarace # Потенциальные data races + - name: identical-branches # Одинаковые ветки if/else + - name: defer # Проблемы с defer (в циклах, результат) + - name: call-to-gc # Явные вызовы runtime.GC() + - name: waitgroup-by-value # WaitGroup передан по значению + + # ------------------------------------------------------------------------- + # Обработка ошибок — Go proverb: "Don't just check errors, handle them gracefully" + # ------------------------------------------------------------------------- + - name: error-strings # Ошибки не должны начинаться с большой буквы + - name: error-return # error должен быть последним возвращаемым значением + - name: errorf # Использовать fmt.Errorf вместо errors.New + fmt.Sprintf + - name: unhandled-error # Необработанные ошибки + arguments: + - "fmt.Print" + - "fmt.Printf" + - "fmt.Println" + + # ------------------------------------------------------------------------- + # Сложность — Go proverb: "Clear is better than clever" + # ------------------------------------------------------------------------- + - name: cognitive-complexity + arguments: [15] # Cognitive complexity <= 15 + - name: cyclomatic + arguments: [10] # Cyclomatic complexity <= 10 + - name: function-result-limit + arguments: [3] # Максимум 3 возвращаемых значения + - name: argument-limit + arguments: [5] # Максимум 5 аргументов функции + + # ------------------------------------------------------------------------- + # Чистота кода — Go proverb: "A little copying is better than a little dependency" + # ------------------------------------------------------------------------- + - name: indent-error-flow # if err != nil { return } вместо else + - name: early-return # Ранний возврат вместо вложенности + - name: superfluous-else # Лишний else после return + - name: if-return # Упрощение if/return + - name: empty-block # Пустые блоки кода + - name: unnecessary-stmt # Ненужные операторы + - name: redundant-import-alias # import pkg "pkg" — лишний алиас + - name: confusing-results # Запутанные возвращаемые значения + - name: bool-literal-in-expr # if x == true → if x + - name: constant-logical-expr # Константные логические выражения + - name: modifies-parameter # Модификация параметров функции + - name: modifies-value-receiver # Модификация value receiver (бесполезно) + - name: redefines-builtin-id # Переопределение встроенных идентификаторов + - name: string-of-int # string(int) — частая ошибка + - name: time-equal # time.Time сравнение через == + - name: unconditional-recursion # Безусловная рекурсия (бесконечный цикл) + - name: useless-break # break в конце case (Go делает это автоматически) + + # ------------------------------------------------------------------------- + # Хорошие практики — Go proverbs + # ------------------------------------------------------------------------- + - name: context-as-argument # context.Context первым аргументом + - name: context-keys-type # Ключи контекста должны быть типизированы + - name: var-declaration # var x = 1 → x := 1 + - name: blank-imports # Запрет blank imports кроме main/test + - name: dot-imports # Запрет dot imports + - name: unexported-return # Публичная функция возвращает приватный тип + - name: exported # Экспортируемые идентификаторы должны быть задокументированы + arguments: + - "checkPrivateReceivers" + - "disableStutteringCheck" + + # ------------------------------------------------------------------------- + # Именование — Go proverb: "Good naming is like good coding: concise" + # ------------------------------------------------------------------------- + - name: var-naming + arguments: + - [ + "ID", + "URL", + "API", + "HTTP", + "JSON", + "XML", + "DB", + "SQL", + "UUID", + "UID", + "GUID", + "TTL", + "TCP", + "UDP", + "IP", + "RPC", + "QPS", + "EOF", + ] + - name: package-comments # Пакеты должны иметь комментарии + - name: receiver-naming # Имена receiver (r, s, c, не this/self) exclusions: + rules: + - path: pki/internal/xocsp/ocsp.go + linters: + - revive + - nestif paths: - vendors/ default: none @@ -60,7 +211,7 @@ linters: - gocyclo - ineffassign - unparam -# - unused + - unused - prealloc - durationcheck - staticcheck @@ -69,3 +220,39 @@ linters: - errorlint - bodyclose - gosec + - nilerr + - nilnesserr + - nilnil + - bidichk + - contextcheck + - fatcontext + - makezero + - forcetypeassert + - unconvert + - copyloopvar + - prealloc + - perfsprint + - gocritic + - goconst + - mnd + - revive + - predeclared + - reassign + - recvcheck + - asciicheck + - importas + - durationcheck + - tparallel + - thelper + - usetesting + - musttag + - errchkjson + - tagalign + - usestdlibvars + - nestif + - mirror + - whitespace + - decorder + - nonamedreturns + - inamedparam + - testpackage diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7f43948 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# Agent instructions + +## Project + +- This repository is the Go module `go.osspkg.com/encrypt`; `go.mod` requires Go 1.26. +- Public packages are `aesgcm` (AES-GCM), `hash` (hash adapter), `pgp` (OpenPGP), and `pki` (X.509/OCSP). +- `pki/internal/xocsp` contains the internal OCSP ASN.1 implementation used by `pki`. +- Keep changes scoped to the package being changed. Public behavior and security-relevant limits should stay documented in Godoc and README where relevant. + +## Commands + +Run commands from the repository root. + +- `go test ./...` runs all Go package tests. +- `make tests` runs the repository's `goppy test` target. +- `make lint` runs `goppy lint`. It can update files through configured formatting and fixes; inspect `git diff` afterward. +- `make build` runs `goppy build --arch=amd64`. +- `make ci` is the CI command from `.github/workflows/ci.yml`. It runs the `pre-commit` chain: install/setup, license, lint, tests, and build. The install step installs `goppy@latest`; the chain is not a read-only validation command. + +## Changes and validation + +- Keep `go.mod` and `go.sum` in sync when changing dependencies. +- Add or update tests for behavior changes and security fixes. Use `go test ./...` for full-suite validation; use package-scoped `go test` for focused changes. +- The linter configuration is in `.golangci.yml`; prefer fixing findings over adding suppressions. Explain any necessary suppression inline. +- Review the final worktree diff, especially after `make lint` or `make ci`, because these targets may modify files. +- Do not run publishing, deployment, or other external release operations as part of local validation. diff --git a/README.md b/README.md index 7958cb0..30e57f0 100644 --- a/README.md +++ b/README.md @@ -1 +1,161 @@ -# go-encrypt \ No newline at end of file +# go-encrypt + +[![CI](https://github.com/osspkg/go-encrypt/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/osspkg/go-encrypt/actions/workflows/ci.yml) +[![Go Reference](https://pkg.go.dev/badge/go.osspkg.com/encrypt.svg)](https://pkg.go.dev/go.osspkg.com/encrypt) +[![License](https://img.shields.io/github/license/osspkg/go-encrypt)](LICENSE) + +Cryptographic utilities for Go: AES-GCM encryption, OpenPGP key generation and +cleartext signatures, and X.509 certificate, CSR, CRL, and OCSP operations. + +## Requirements + +- Go 1.26 or newer + +## Installation + +```sh +go get go.osspkg.com/encrypt +``` + +Import the package you need, for example: + +```go +import "go.osspkg.com/encrypt/aesgcm" +``` + +The examples below show function bodies. Add the package import for the example +and the standard-library imports referenced by its code. + +## Packages + +| Package | Purpose | +| --- | --- | +| [`aesgcm`](aesgcm) | AES-256-GCM authenticated encryption | +| [`hash`](hash) | Write byte streams and Go values to a `hash.Hash`; get binary, hex, or base64 digests | +| [`pgp`](pgp) | Generate armored OpenPGP key pairs and create cleartext signatures | +| [`pki`](pki) | Generate and encode X.509 keys, certificates, CSRs, and CRLs; serve OCSP responses | + +## Usage + +### AES-GCM + +```go +key := make([]byte, 32) +if _, err := rand.Read(key); err != nil { + return err +} + +codec, err := aesgcm.New(key) +if err != nil { + return err +} + +ciphertext, err := codec.Encrypt([]byte("secret message")) +if err != nil { + return err +} + +plaintext, err := codec.Decrypt(ciphertext) +if err != nil { + return err +} +_ = plaintext +``` + +`New` requires a 32-byte key and copies it. Each encryption generates a fresh +nonce and prepends it to the returned ciphertext; store or transmit the whole +slice so it can be decrypted. `Decrypt` returns an error if authentication +fails. Generate and protect keys with a cryptographically secure source. + +### Hash values + +```go +adapter := &hash.Adapter{H: sha256.New()} +if err := adapter.WriteString("message"); err != nil { + return err +} +digest := adapter.ResultHex() +_ = digest +``` + +`WriteAny` hashes Go's formatted representation of each value; it is not a +canonical serialization format. Use a stable encoding when a digest must remain +reproducible across program or schema changes. + +### OpenPGP signing + +```go +keys, err := pgp.NewCert(pgp.Config{ + Name: "Example User", + Email: "user@example.com", +}, crypto.SHA256, 3072) +if err != nil { + return err +} + +signer := pgp.New() +if err := signer.SetKey(keys.Private, ""); err != nil { + return err +} +var output bytes.Buffer +if err := signer.Sign(strings.NewReader("message to sign"), &output); err != nil { + return err +} +``` + +`NewCert` returns armored public and private keys. Hashes unsupported or too +weak for key generation fall back to SHA-256. `NewCertSHA512` uses SHA-512 and a +4096-bit RSA key. Keep private keys protected and distribute only the public +key where needed. + +### X.509 certificate authority + +```go +ca, err := pki.NewCA(pki.Config{ + SignatureAlgorithm: x509.ECDSAWithSHA256, + CommonName: "Example Root CA", +}, 10*365*24*time.Hour, 1, 2) +if err != nil { + return err +} +if err := ca.SaveKey("ca-key.pem"); err != nil { + return err +} +if err := ca.SaveCert("ca-cert.pem"); err != nil { + return err +} +``` + +Private keys are saved as PKCS #8 PEM with restrictive file permissions. Keep +CA keys offline or in a protected key store. `NewIntermediateCA` creates an +intermediate CA; `NewCRT` and `SignCSR` issue leaf certificates. + +## Security notes + +- `pki.OCSPServer.HTTPHandler` reads at most 1 MiB from each request body and + returns HTTP 413 when the limit is exceeded. +- The `pgp` package uses the maintained ProtonMail OpenPGP implementation. +- Cryptographic operations do not replace key management, certificate + validation, or application-specific security review. + +## Contributing + +Pull requests should include tests for behavior changes and pass the repository +checks. GitHub Actions runs `make ci` on pushes and pull requests to `master`. + +## Development + +Run Make targets from the repository root: + +```sh +make tests +make lint +make build +``` + +`make lint` may update files; review `git diff` after running it. See +[AGENTS.md](AGENTS.md) for repository-specific development instructions. + +## License + +BSD 3-Clause. See [LICENSE](LICENSE). diff --git a/aesgcm/aesgcm.go b/aesgcm/aesgcm.go index ddb4a0e..0731ce6 100644 --- a/aesgcm/aesgcm.go +++ b/aesgcm/aesgcm.go @@ -3,11 +3,13 @@ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ +// Package aesgcm provides AES-GCM authenticated encryption. package aesgcm import ( "crypto/aes" "crypto/cipher" + "errors" "fmt" "go.osspkg.com/random" @@ -15,11 +17,15 @@ import ( const keySize = 32 +// Codec provides authenticated encryption and decryption with AES-GCM. Create +// it with New; it retains a copy of the key for its lifetime. type Codec struct { key []byte block cipher.Block } +// New creates an AES-GCM codec for a 256-bit key. It copies key so the caller +// can safely reuse or modify the input slice after New returns. func New(key []byte) (*Codec, error) { if len(key) != keySize { return nil, fmt.Errorf("invalid key len, want %d got %d", keySize, len(key)) @@ -36,6 +42,9 @@ func New(key []byte) (*Codec, error) { return obj, nil } +// Encrypt encrypts plaintext and prepends a fresh, cryptographically random +// nonce to the ciphertext. Store or transmit the entire returned slice so the +// nonce is available to Decrypt. A new nonce is generated for every call. func (v *Codec) Encrypt(plaintext []byte) ([]byte, error) { gcm, err := cipher.NewGCM(v.block) if err != nil { @@ -46,6 +55,8 @@ func (v *Codec) Encrypt(plaintext []byte) ([]byte, error) { return ciphertext, nil } +// Decrypt authenticates and decrypts ciphertext produced by Encrypt. It returns +// an error if ciphertext is shorter than the nonce or fails authentication. func (v *Codec) Decrypt(ciphertext []byte) ([]byte, error) { gcm, err := cipher.NewGCM(v.block) if err != nil { @@ -53,7 +64,7 @@ func (v *Codec) Decrypt(ciphertext []byte) ([]byte, error) { } nonceSize := gcm.NonceSize() if len(ciphertext) < nonceSize { - return nil, fmt.Errorf("invalid message len") + return nil, errors.New("invalid message len") } nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) diff --git a/aesgcm/aesgcm_coverage_test.go b/aesgcm/aesgcm_coverage_test.go new file mode 100644 index 0000000..e2f2305 --- /dev/null +++ b/aesgcm/aesgcm_coverage_test.go @@ -0,0 +1,40 @@ +package aesgcm_test + +import ( + "bytes" + "testing" + + "go.osspkg.com/encrypt/aesgcm" +) + +func TestCodecRejectsInvalidKeyAndCiphertext(t *testing.T) { + if _, err := aesgcm.New(make([]byte, 31)); err == nil { + t.Fatal("New accepted a key with the wrong length") + } + codec, err := aesgcm.New(make([]byte, 32)) + if err != nil { + t.Fatal(err) + } + if _, err := codec.Decrypt(make([]byte, 11)); err == nil { + t.Fatal("Decrypt accepted ciphertext shorter than a nonce") + } + ciphertext, err := codec.Encrypt([]byte("message")) + if err != nil { + t.Fatal(err) + } + ciphertext[len(ciphertext)-1] ^= 1 + if _, err := codec.Decrypt(ciphertext); err == nil { + t.Fatal("Decrypt accepted modified ciphertext") + } + first, err := codec.Encrypt(nil) + if err != nil { + t.Fatal(err) + } + second, err := codec.Encrypt(nil) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(first, second) { + t.Fatal("Encrypt reused a nonce for identical plaintext") + } +} diff --git a/go.mod b/go.mod index a300006..36995d4 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,18 @@ module go.osspkg.com/encrypt -go 1.25.0 +go 1.26.0 require ( - go.osspkg.com/casecheck v0.3.0 + github.com/ProtonMail/go-crypto v1.5.1 + go.osspkg.com/casecheck v0.3.2 go.osspkg.com/errors v0.4.0 - go.osspkg.com/ioutils v0.7.4 - go.osspkg.com/random v0.5.0 - go.osspkg.com/syncing v0.4.3 - golang.org/x/crypto v0.51.0 + go.osspkg.com/ioutils v0.8.0 + go.osspkg.com/random v0.5.1 + go.osspkg.com/syncing v0.5.0 + golang.org/x/crypto v0.57.0 ) -require golang.org/x/sys v0.44.0 // indirect +require ( + github.com/cloudflare/circl v1.6.3 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/go.sum b/go.sum index 67d3b8c..34f063b 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,18 @@ -go.osspkg.com/casecheck v0.3.0 h1:x15blEszElbrHrEH5H02JIIhGIg/lGZzIt1kQlD3pwM= -go.osspkg.com/casecheck v0.3.0/go.mod h1:TRFXDMFJEOtnlp3ET2Hix3osbxwPWhvaiT/HfD3+gBA= +github.com/ProtonMail/go-crypto v1.5.1 h1:pTrLDQHyOT8y3DFYIpijgPBTw/7E2GLMimutvOlceuE= +github.com/ProtonMail/go-crypto v1.5.1/go.mod h1:/RaSu30DaKO4RY+XdV/ACcCcZkGr7AhUIduq5sjzzCo= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +go.osspkg.com/casecheck v0.3.2 h1:KDdtEsEnGcDKjtg8FKL0hjnVZ7kusmXBD/VS/5IKSjE= +go.osspkg.com/casecheck v0.3.2/go.mod h1:nf1vimi3VPl1o0hV+bKZsy3+1Qi8wNRRWiNGqivFV1A= go.osspkg.com/errors v0.4.0 h1:E17+WyUzTXEHCTxGm8lOMPOOojzHG1lsOuQtTVGoATQ= go.osspkg.com/errors v0.4.0/go.mod h1:s75ZovPemYtrCtRPVsbQNq9MgMbmLMK1NEypr+uwjXI= -go.osspkg.com/ioutils v0.7.4 h1:Z8Y4jYYmLGWcvHZMLjbai+s48GmHxjMuepsxZcjF5X4= -go.osspkg.com/ioutils v0.7.4/go.mod h1:pPIsTL1w1+ESrGTeHDCd6cKsujeWvschxGGP5FqrAqc= -go.osspkg.com/random v0.5.0 h1:6x2CQ5Vb6PVyuGi6Ao3K6Pr2fzVviBPCEEJC5HQNSmg= -go.osspkg.com/random v0.5.0/go.mod h1:lsg3FI87PQdjhVWIVo2GXyPBclipljUxjMlWqRl2cck= -go.osspkg.com/syncing v0.4.3 h1:XioXG9zje1LNCsfQhNHkNPCQqPSJZHWTzM8Xig2zvAU= -go.osspkg.com/syncing v0.4.3/go.mod h1:/LBmgCAHFW6nQgVDILpEuo6eRCFK1yyFeNbDs4eVNls= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +go.osspkg.com/ioutils v0.8.0 h1:19RQ2dEw+7uAjPC2FiIM/vqplocpVLD8FLVd1+bJRpE= +go.osspkg.com/ioutils v0.8.0/go.mod h1:afZbM1uMrtfqSJS8OdQgSKfwQm50nHcCAHBSOMZYArE= +go.osspkg.com/random v0.5.1 h1:HC17mtURJvYpIPgouwd6PPHu74GP5pU2/XKninInUsQ= +go.osspkg.com/random v0.5.1/go.mod h1:lsg3FI87PQdjhVWIVo2GXyPBclipljUxjMlWqRl2cck= +go.osspkg.com/syncing v0.5.0 h1:mgrwnn6381GiLZsuAXGWKk3AnBdm9HbaYwPyBMPoa/k= +go.osspkg.com/syncing v0.5.0/go.mod h1:/LBmgCAHFW6nQgVDILpEuo6eRCFK1yyFeNbDs4eVNls= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/hash/hash.go b/hash/hash.go index 052a1c0..462a5e7 100644 --- a/hash/hash.go +++ b/hash/hash.go @@ -3,59 +3,76 @@ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ +// Package hash provides adapters for hashing structured values. package hash import ( "encoding/base64" + "encoding/hex" + "errors" "fmt" "hash" "io" "reflect" ) +// Adapter writes data into H and exposes the resulting digest. Initialize H +// with a hash implementation such as sha256.New before using the Adapter. type Adapter struct { H hash.Hash } +// Read copies all data from r into H. It returns an error if H or r is nil or +// if reading from r fails. func (a *Adapter) Read(r io.Reader) error { if a.H == nil { - return fmt.Errorf("hash is nil") + return errors.New("hash is nil") } if r == nil { - return fmt.Errorf("reader is nil") + return errors.New("reader is nil") } _, err := io.Copy(a.H, r) return err } +// Write writes b to H. It returns an error if H is nil or the hash rejects the +// write. func (a *Adapter) Write(b []byte) error { if a.H == nil { - return fmt.Errorf("hash is nil") + return errors.New("hash is nil") } _, err := a.H.Write(b) return err } +// WriteString writes a string to the hash. func (a *Adapter) WriteString(s string) error { if a.H == nil { - return fmt.Errorf("hash is nil") + return errors.New("hash is nil") } _, err := io.WriteString(a.H, s) return err } +// WriteAny writes supported values to the hash. func (a *Adapter) WriteAny(args ...any) error { if a.H == nil { - return fmt.Errorf("hash is nil") + return errors.New("hash is nil") } for _, arg := range args { ref := reflect.ValueOf(arg) + if !ref.IsValid() { + return errors.New("value is nil") + } if ref.Kind() == reflect.Ptr { ref = ref.Elem() + if !ref.IsValid() { + return errors.New("value is nil") + } } if _, err := fmt.Fprintf(a.H, "%#v", ref.Interface()); err != nil { return err @@ -65,6 +82,7 @@ func (a *Adapter) WriteAny(args ...any) error { return nil } +// Result returns the current hash digest. func (a *Adapter) Result() []byte { if a.H == nil { return nil @@ -73,14 +91,16 @@ func (a *Adapter) Result() []byte { return a.H.Sum(nil) } +// ResultHex returns the current hash digest as hexadecimal. func (a *Adapter) ResultHex() string { if a.H == nil { return "" } - return fmt.Sprintf("%x", a.H.Sum(nil)) + return hex.EncodeToString(a.H.Sum(nil)) } +// ResultBase64 returns the current hash digest as base64. func (a *Adapter) ResultBase64() string { if a.H == nil { return "" @@ -89,6 +109,7 @@ func (a *Adapter) ResultBase64() string { return base64.StdEncoding.EncodeToString(a.H.Sum(nil)) } +// Reset resets the hash state. func (a *Adapter) Reset() { if a.H == nil { return diff --git a/hash/hash_coverage_test.go b/hash/hash_coverage_test.go new file mode 100644 index 0000000..5d947c9 --- /dev/null +++ b/hash/hash_coverage_test.go @@ -0,0 +1,75 @@ +package hash_test + +import ( + "crypto/sha256" + "errors" + "strings" + "testing" + + "go.osspkg.com/encrypt/hash" +) + +type failingHash struct{} + +var errWrite = errors.New("write failed") + +func (failingHash) Write([]byte) (int, error) { return 0, errWrite } +func (failingHash) Sum(b []byte) []byte { return b } +func (failingHash) Reset() {} +func (failingHash) Size() int { return 0 } +func (failingHash) BlockSize() int { return 0 } + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("read failed") } + +type nilValue struct{} + +func TestAdapterNilAndReaderErrors(t *testing.T) { + a := &hash.Adapter{} + if err := a.Read(strings.NewReader("x")); err == nil { + t.Fatal("Read accepted nil hash") + } + if err := a.Read(nil); err == nil { + t.Fatal("Read accepted nil reader") + } + if err := a.Read(failingReader{}); err == nil { + t.Fatal("Read ignored reader error") + } + if err := a.Write([]byte("x")); err == nil { + t.Fatal("Write accepted nil hash") + } + if err := a.WriteString("x"); err == nil { + t.Fatal("WriteString accepted nil hash") + } + if err := a.WriteAny(nil); err == nil { + t.Fatal("WriteAny accepted nil hash") + } + if a.Result() != nil || a.ResultHex() != "" || a.ResultBase64() != "" { + t.Fatal("nil hash returned a digest") + } + a.Reset() +} + +func TestAdapterWriteErrorsAndNilPointers(t *testing.T) { + a := &hash.Adapter{H: failingHash{}} + if err := a.Write([]byte("x")); !errors.Is(err, errWrite) { + t.Fatalf("Write error = %v, want %v", err, errWrite) + } + if err := a.WriteString("x"); !errors.Is(err, errWrite) { + t.Fatalf("WriteString error = %v, want %v", err, errWrite) + } + + a = &hash.Adapter{H: failingHash{}} + if err := a.WriteAny("value"); !errors.Is(err, errWrite) { + t.Fatalf("WriteAny error = %v, want %v", err, errWrite) + } + a = &hash.Adapter{H: sha256.New()} + if err := a.WriteAny(nil); err == nil { + t.Fatal("WriteAny accepted nil interface value") + } + var p *nilValue + if err := a.WriteAny(p); err == nil { + t.Fatal("WriteAny accepted nil pointer") + } +} diff --git a/hash/hash_test.go b/hash/hash_test.go index 2dddbeb..bc85bd9 100644 --- a/hash/hash_test.go +++ b/hash/hash_test.go @@ -20,9 +20,13 @@ type testData struct { } func TestUnit_Adapter(t *testing.T) { + //nolint:gosec // The adapter supports caller-selected legacy digests. ha := &hash.Adapter{H: md5.New()} + //nolint:gosec // This is a fixed expected value for the legacy-digest adapter test. + expectedMD5 := md5.Sum([]byte("123")) casecheck.NoError(t, ha.Read(strings.NewReader("123"))) + casecheck.Equal(t, expectedMD5[:], ha.Result()) casecheck.Equal(t, "202cb962ac59075b964b07152d234b70", ha.ResultHex()) casecheck.Equal(t, "ICy5YqxZB1uWSwcVLSNLcA==", ha.ResultBase64()) ha.Reset() diff --git a/pgp/pgp.go b/pgp/pgp.go index af83568..a77a7b6 100644 --- a/pgp/pgp.go +++ b/pgp/pgp.go @@ -3,6 +3,7 @@ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ +// Package pgp generates OpenPGP keys and creates cleartext signatures. package pgp import ( @@ -11,18 +12,25 @@ import ( "io" "os" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/clearsign" + "github.com/ProtonMail/go-crypto/openpgp/packet" "go.osspkg.com/errors" - "golang.org/x/crypto/openpgp" - "golang.org/x/crypto/openpgp/armor" - "golang.org/x/crypto/openpgp/clearsign" - "golang.org/x/crypto/openpgp/packet" +) + +const ( + defaultRSABits = 4096 + headerPairSize = 2 ) type ( + // Config contains the identity used when generating an OpenPGP key. Config struct { Name, Email, Comment string } + // Cert contains armored public and private OpenPGP keys. Cert struct { Public []byte Private []byte @@ -36,39 +44,49 @@ type ( headers map[string]string } + // Signer loads an OpenPGP private key and creates cleartext signatures. Signer interface { + // SetKey loads a private key from OpenPGP-armored bytes. passwd is used + // only when the private key is encrypted. SetKey(b []byte, passwd string) error + // SetKeyFromFile loads an armored private key from filename. SetKeyFromFile(filename string, passwd string) error + // SetHash configures the hash and RSA key size used for signing and key + // generation. The size applies only when generating a key. SetHash(hash crypto.Hash, bits int) + // PublicKey returns the loaded key's public part in binary OpenPGP format. PublicKey() ([]byte, error) + // PublicKeyBase64 returns the loaded key's public part in armored format. PublicKeyBase64() ([]byte, error) + // Sign writes an OpenPGP cleartext signature of in to out. Sign(in io.Reader, out io.Writer) error } ) +// New creates a signer with SHA-512 and 4096-bit RSA defaults. func New() Signer { return &store{ conf: &packet.Config{ DefaultHash: crypto.SHA512, - RSABits: 4096, + RSABits: defaultRSABits, }, headers: make(map[string]string), } } +// SetKey loads a private key from armored bytes. func (v *store) SetKey(b []byte, passwd string) error { r := bytes.NewReader(b) - if err := v.readKey(r, passwd); err != nil { - return err - } - return nil + return v.readKey(r, passwd) } +// SetHash sets the hash and RSA key size used for signing and key generation. func (v *store) SetHash(hash crypto.Hash, bits int) { v.conf.DefaultHash = hash v.conf.RSABits = bits } +// SetHeaders sets armor headers for serialized keys. func (v *store) SetHeaders(headers ...string) error { h, err := createHeaders(headers) if err != nil { @@ -78,18 +96,17 @@ func (v *store) SetHeaders(headers ...string) error { return nil } +// SetKeyFromFile loads a private key from a file. func (v *store) SetKeyFromFile(filename string, passwd string) error { r, err := os.Open(filename) if err != nil { return errors.Wrapf(err, "read key from file") } defer r.Close() // nolint: errcheck - if err = v.readKey(r, passwd); err != nil { - return err - } - return nil + return v.readKey(r, passwd) } +// PublicKey returns the public key in binary OpenPGP format. func (v *store) PublicKey() ([]byte, error) { if v.key == nil { return nil, errors.New("key is empty") @@ -102,6 +119,7 @@ func (v *store) PublicKey() ([]byte, error) { return buf.Bytes(), nil } +// PublicKeyBase64 returns the public key in armored OpenPGP format. func (v *store) PublicKeyBase64() ([]byte, error) { if v.key == nil { return nil, errors.New("key is empty") @@ -127,7 +145,7 @@ func (v *store) readKey(r io.ReadSeeker, passwd string) error { return errors.Wrapf(err, "armor decode key") } if block.Type != openpgp.PrivateKeyType { - return errors.Wrapf(err, "invalid key type") + return errors.New("invalid key type") } if _, err = r.Seek(0, 0); err != nil { return errors.Wrapf(err, "seek key file") @@ -136,6 +154,9 @@ func (v *store) readKey(r io.ReadSeeker, passwd string) error { if err != nil { return errors.Wrapf(err, "read armored key") } + if len(keys) == 0 || keys[0] == nil || keys[0].PrivateKey == nil { + return errors.New("private key is missing") + } v.key = keys[0] if v.key.PrivateKey.Encrypted { if err = v.key.PrivateKey.Decrypt([]byte(passwd)); err != nil { @@ -146,10 +167,14 @@ func (v *store) readKey(r io.ReadSeeker, passwd string) error { return nil } +// Sign writes a cleartext signature for the input. func (v *store) Sign(in io.Reader, out io.Writer) error { if v.key == nil { return errors.New("key is empty") } + if in == nil || out == nil { + return errors.New("input reader and output writer are required") + } w, err := clearsign.Encode(out, v.key.PrivateKey, v.conf) if err != nil { @@ -158,10 +183,7 @@ func (v *store) Sign(in io.Reader, out io.Writer) error { if _, err = io.Copy(w, in); err != nil { return err } - if err = w.Close(); err != nil { - return err - } - return nil + return w.Close() } func generatePrivateKey(key *openpgp.Entity, w io.Writer, headers map[string]string) error { @@ -210,7 +232,7 @@ func createHeaders(v []string) (map[string]string, error) { if len(v)%2 != 0 { return nil, errors.New("odd headers count") } - result := make(map[string]string, len(v)/2) + result := make(map[string]string, len(v)/headerPairSize) for i := 0; i < len(v); i += 2 { result[v[i]] = v[i+1] } @@ -227,12 +249,20 @@ func mergeHeaders(h ...map[string]string) map[string]string { return result } +// NewCert generates an OpenPGP certificate with the requested hash and RSA key +// size. It returns the public and private keys in armored form. Hashes that are +// unsupported or considered too weak for key generation are replaced with +// SHA-256. func NewCert(c Config, hash crypto.Hash, bits int, headers ...string) (*Cert, error) { h, err := createHeaders(headers) if err != nil { return nil, errors.Wrapf(err, "parse headers") } + // The maintained OpenPGP implementation rejects weak hashes such as MD5 + // when generating keys. Keep signing keys on SHA-256 or stronger. + hash = supportedKeyHash(hash) + conf := &packet.Config{ DefaultHash: hash, RSABits: bits, @@ -263,6 +293,17 @@ func NewCert(c Config, hash crypto.Hash, bits int, headers ...string) (*Cert, er }, nil } +// NewCertSHA512 generates an OpenPGP certificate using SHA-512 and a 4096-bit +// RSA key. It returns the public and private keys in armored form. func NewCertSHA512(c Config, headers ...string) (*Cert, error) { - return NewCert(c, crypto.SHA512, 4096, headers...) + return NewCert(c, crypto.SHA512, defaultRSABits, headers...) +} + +func supportedKeyHash(hash crypto.Hash) crypto.Hash { + switch hash { + case crypto.SHA256, crypto.SHA384, crypto.SHA512, crypto.SHA3_256, crypto.SHA3_512: + return hash + default: + return crypto.SHA256 + } } diff --git a/pgp/pgp_coverage_test.go b/pgp/pgp_coverage_test.go new file mode 100644 index 0000000..9727491 --- /dev/null +++ b/pgp/pgp_coverage_test.go @@ -0,0 +1,118 @@ +package pgp_test + +import ( + "bytes" + "crypto" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + + "go.osspkg.com/encrypt/pgp" +) + +//nolint:revive // This test groups related coverage cases for one API. +func TestSignerKeyExportHeadersAndSigning(t *testing.T) { + cert, err := pgp.NewCertSHA512(pgp.Config{Name: "Coverage", Email: "coverage@example.test"}, "Client", "Unit Test") + if err != nil { + t.Fatal(err) + } + signer := pgp.New() + if err := signer.Sign(bytes.NewReader(nil), &bytes.Buffer{}); err == nil { + t.Fatal("Sign succeeded without a key") + } + if _, err := signer.PublicKey(); err == nil { + t.Fatal("PublicKey succeeded without a key") + } + if _, err := signer.PublicKeyBase64(); err == nil { + t.Fatal("PublicKeyBase64 succeeded without a key") + } + headerSigner, ok := signer.(interface{ SetHeaders(headers ...string) error }) + if !ok { + t.Fatal("signer does not support armor headers") + } + if err := headerSigner.SetHeaders("odd"); err == nil { + t.Fatal("SetHeaders accepted an odd number of values") + } + if err := headerSigner.SetHeaders("Client", "Coverage Test"); err != nil { + t.Fatal(err) + } + if err := signer.SetKey(cert.Private, ""); err != nil { + t.Fatal(err) + } + + binary, err := signer.PublicKey() + if err != nil || len(binary) == 0 { + t.Fatalf("PublicKey: len=%d err=%v", len(binary), err) + } + armored, err := signer.PublicKeyBase64() + if err != nil || !bytes.Contains(armored, []byte("Client: Unit Test")) { + t.Fatalf("PublicKeyBase64: err=%v output=%q", err, armored) + } + var signed bytes.Buffer + if err := signer.Sign(strings.NewReader("signed message"), &signed); err != nil { + t.Fatal(err) + } + if !bytes.Contains(signed.Bytes(), []byte("BEGIN PGP SIGNED MESSAGE")) { + t.Fatalf("unexpected cleartext signature: %q", signed.String()) + } + + path := filepath.Join(t.TempDir(), "private.asc") + if err := os.WriteFile(path, cert.Private, 0o600); err != nil { + t.Fatal(err) + } + fromFile := pgp.New() + if err := fromFile.SetKeyFromFile(path, ""); err != nil { + t.Fatal(err) + } + if _, err := fromFile.PublicKey(); err != nil { + t.Fatal(err) + } + if err := fromFile.SetKeyFromFile(path+".missing", ""); err == nil { + t.Fatal("SetKeyFromFile accepted missing file") + } +} + +func TestSignerRejectsMalformedAndEncryptedKeys(t *testing.T) { + signer := pgp.New() + if _, err := pgp.NewCert(pgp.Config{}, crypto.SHA256, 1024, "odd"); err == nil { + t.Fatal("NewCert accepted an odd number of armor header values") + } + if _, err := pgp.NewCert(pgp.Config{}, crypto.SHA256, 512); err == nil { + t.Fatal("NewCert accepted invalid RSA bit size") + } + if err := signer.SetKey([]byte("invalid"), ""); err == nil { + t.Fatal("SetKey accepted invalid armor") + } + if err := signer.SetKey([]byte("-----BEGIN PGP MESSAGE-----\n\nAA==\n-----END PGP MESSAGE-----"), ""); err == nil { + t.Fatal("SetKey accepted non-private-key armor") + } + + cert, err := pgp.NewCert(pgp.Config{Name: "Encrypted"}, crypto.SHA256, 1024) + if err != nil { + t.Fatal(err) + } + if err := signer.SetKey(cert.Private, "wrong"); err != nil { + t.Fatalf("unencrypted key should not need password: %v", err) + } + if err := signer.Sign(nil, &bytes.Buffer{}); err == nil { + t.Fatal("Sign accepted nil reader") + } +} + +func TestSignerRejectsEmptyKeyring(t *testing.T) { + var armored bytes.Buffer + enc, err := armor.Encode(&armored, openpgp.PrivateKeyType, nil) + if err != nil { + t.Fatal(err) + } + if err := enc.Close(); err != nil { + t.Fatal(err) + } + if err := pgp.New().SetKey(armored.Bytes(), ""); err == nil { + t.Fatal("SetKey accepted an empty keyring") + } +} diff --git a/pgp/pgp_internal_coverage_test.go b/pgp/pgp_internal_coverage_test.go new file mode 100644 index 0000000..2d0a748 --- /dev/null +++ b/pgp/pgp_internal_coverage_test.go @@ -0,0 +1,97 @@ +//nolint:testpackage // These tests need package-private helpers and data structures. +package pgp + +import ( + "bytes" + "crypto" + "errors" + "io" + "testing" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" +) + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") } + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("read failed") } + +type failedSeek struct{ *bytes.Reader } + +func (failedSeek) Seek(int64, int) (int64, error) { return 0, errors.New("seek failed") } + +func encryptedPrivateArmor(t *testing.T, password string) []byte { + t.Helper() + entity, err := openpgp.NewEntity("encrypted", "", "encrypted@example.test", &packet.Config{RSABits: 1024}) + if err != nil { + t.Fatal(err) + } + conf := &packet.Config{DefaultHash: crypto.SHA256} + if err := setupIdentities(entity, conf); err != nil { + t.Fatal(err) + } + if err := entity.PrivateKey.Encrypt([]byte(password)); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + enc, err := armor.Encode(&output, openpgp.PrivateKeyType, nil) + if err != nil { + t.Fatal(err) + } + if err := entity.SerializePrivateWithoutSigning(enc, conf); err != nil { + t.Fatal(err) + } + if err := enc.Close(); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +func TestReadKeyEncryptedAndSeekFailure(t *testing.T) { + encoded := encryptedPrivateArmor(t, "correct") + signer, ok := New().(*store) + if !ok { + t.Fatal("New returned unexpected signer implementation") + } + if err := signer.SetKey(encoded, "wrong"); err == nil { + t.Fatal("SetKey accepted wrong passphrase") + } + if err := signer.SetKey(encoded, "correct"); err != nil { + t.Fatal(err) + } + if err := signer.readKey(failedSeek{bytes.NewReader(encoded)}, "correct"); err == nil { + t.Fatal("readKey ignored seek failure") + } +} + +func TestArmorAndSigningWriterFailures(t *testing.T) { + entity, err := openpgp.NewEntity("writer", "", "writer@example.test", &packet.Config{RSABits: 1024}) + if err != nil { + t.Fatal(err) + } + if err := generatePrivateKey(entity, failingWriter{}, nil); err == nil { + t.Fatal("generatePrivateKey ignored writer failure") + } + if err := generatePublicKey(entity, failingWriter{}, nil); err == nil { + t.Fatal("generatePublicKey ignored writer failure") + } + + signer, ok := New().(*store) + if !ok { + t.Fatal("New returned unexpected signer implementation") + } + signer.key = entity + if err := signer.Sign(bytes.NewBufferString("message"), failingWriter{}); err == nil { + t.Fatal("Sign ignored writer failure") + } + if err := signer.Sign(failingReader{}, &bytes.Buffer{}); err == nil { + t.Fatal("Sign ignored reader failure") + } +} + +var _ io.Writer = failingWriter{} diff --git a/pgp/pgp_security_test.go b/pgp/pgp_security_test.go new file mode 100644 index 0000000..4e8e9ed --- /dev/null +++ b/pgp/pgp_security_test.go @@ -0,0 +1,26 @@ +package pgp_test + +import ( + "bytes" + "testing" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + + "go.osspkg.com/encrypt/pgp" +) + +func TestSetKeyRejectsEmptyPrivateKeyring(t *testing.T) { + var input bytes.Buffer + block, err := armor.Encode(&input, openpgp.PrivateKeyType, nil) + if err != nil { + t.Fatal(err) + } + if err := block.Close(); err != nil { + t.Fatal(err) + } + + if err := pgp.New().SetKey(input.Bytes(), ""); err == nil { + t.Fatal("SetKey accepted an empty private keyring") + } +} diff --git a/pki/alg_coverage_test.go b/pki/alg_coverage_test.go new file mode 100644 index 0000000..9b31c76 --- /dev/null +++ b/pki/alg_coverage_test.go @@ -0,0 +1,79 @@ +//nolint:testpackage // These tests need package-private helpers and data structures. +package pki + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "testing" +) + +//nolint:revive // This test groups related coverage cases for one API. +func TestAlgorithmTypeChecksAndPairs(t *testing.T) { + ecdsaKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + algorithm Algorithm + key crypto.Signer + public crypto.PublicKey + }{ + {name: "ecdsa", algorithm: &_ecdsa{}, key: ecdsaKey, public: &ecdsaKey.PublicKey}, + {name: "rsa", algorithm: &_rsa{}, key: rsaKey, public: &rsaKey.PublicKey}, + } { + t.Run(tc.name, func(t *testing.T) { + if !tc.algorithm.IsPrivateKey(tc.key) { + t.Fatal("private key not recognized") + } + if tc.algorithm.IsPrivateKey(nil) { + t.Fatal("nil private key recognized") + } + cert := x509.Certificate{PublicKey: tc.public} + if !tc.algorithm.IsCertificate(cert) { + t.Fatal("certificate public key not recognized") + } + if tc.algorithm.IsCertificate(x509.Certificate{PublicKey: &rsaKey.PublicKey}) && tc.name == "ecdsa" { + t.Fatal("wrong certificate key recognized") + } + request := x509.CertificateRequest{PublicKey: tc.public} + if !tc.algorithm.IsRequest(request) { + t.Fatal("request public key not recognized") + } + if tc.algorithm.IsRequest(x509.CertificateRequest{}) { + t.Fatal("empty request recognized") + } + if !tc.algorithm.IsValidPair(tc.key, cert) { + t.Fatal("matching key pair rejected") + } + var other crypto.Signer = rsaKey + if tc.name == "rsa" { + other = ecdsaKey + } + if tc.algorithm.IsValidPair(other, cert) { + t.Fatal("mismatched key pair accepted") + } + wrongCert := x509.Certificate{PublicKey: &ecdsaKey.PublicKey} + if tc.name == "ecdsa" { + wrongCert.PublicKey = &rsaKey.PublicKey + } + if tc.algorithm.IsValidPair(tc.key, wrongCert) { + t.Fatal("wrong certificate key type accepted") + } + }) + } + if _, err := (&_ecdsa{}).Generate(x509.SHA256WithRSA); err == nil { + t.Fatal("ECDSA Generate accepted RSA algorithm") + } + if _, err := (&_rsa{}).Generate(x509.ECDSAWithSHA256); err == nil { + t.Fatal("RSA Generate accepted ECDSA algorithm") + } +} diff --git a/pki/alg_ecdsa.go b/pki/alg_ecdsa.go index fd9e80c..a3b6b38 100644 --- a/pki/alg_ecdsa.go +++ b/pki/alg_ecdsa.go @@ -3,6 +3,7 @@ * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ +// Package pki creates and encodes X.509 certificates, keys, and revocation data. package pki import ( @@ -17,25 +18,30 @@ import ( type _ecdsa struct{} +// Name returns the public-key algorithm supported by this implementation. func (*_ecdsa) Name() x509.PublicKeyAlgorithm { return x509.ECDSA } +// IsPrivateKey reports whether key is a private key supported by this implementation. func (*_ecdsa) IsPrivateKey(key crypto.Signer) bool { _, ok := key.(*ecdsa.PrivateKey) return ok } +// IsCertificate reports whether cert uses a public-key algorithm supported by this implementation. func (*_ecdsa) IsCertificate(cert x509.Certificate) bool { _, ok := cert.PublicKey.(*ecdsa.PublicKey) return ok } +// IsRequest reports whether cert uses a public-key algorithm supported by this implementation. func (*_ecdsa) IsRequest(cert x509.CertificateRequest) bool { _, ok := cert.PublicKey.(*ecdsa.PublicKey) return ok } +// IsValidPair reports whether key matches the certificate public key. func (*_ecdsa) IsValidPair(key crypto.Signer, cert x509.Certificate) bool { raw, ok := key.(*ecdsa.PrivateKey) if !ok { @@ -53,6 +59,7 @@ func (*_ecdsa) IsValidPair(key crypto.Signer, cert x509.Certificate) bool { return reflect.DeepEqual(pk, ck) } +// Generate generates a private key for the requested signature algorithm. func (*_ecdsa) Generate(alg x509.SignatureAlgorithm) (crypto.Signer, error) { var curve elliptic.Curve switch alg { diff --git a/pki/alg_rsa.go b/pki/alg_rsa.go index a5a82f9..af74c76 100644 --- a/pki/alg_rsa.go +++ b/pki/alg_rsa.go @@ -16,25 +16,30 @@ import ( type _rsa struct{} +// Name returns the public-key algorithm supported by this implementation. func (*_rsa) Name() x509.PublicKeyAlgorithm { return x509.RSA } +// IsPrivateKey reports whether key is a private key supported by this implementation. func (*_rsa) IsPrivateKey(key crypto.Signer) bool { _, ok := key.(*rsa.PrivateKey) return ok } +// IsCertificate reports whether cert uses a public-key algorithm supported by this implementation. func (*_rsa) IsCertificate(cert x509.Certificate) bool { _, ok := cert.PublicKey.(*rsa.PublicKey) return ok } +// IsRequest reports whether cert uses a public-key algorithm supported by this implementation. func (*_rsa) IsRequest(cert x509.CertificateRequest) bool { _, ok := cert.PublicKey.(*rsa.PublicKey) return ok } +// IsValidPair reports whether key matches the certificate public key. func (*_rsa) IsValidPair(key crypto.Signer, cert x509.Certificate) bool { raw, ok := key.(*rsa.PrivateKey) if !ok { @@ -52,13 +57,14 @@ func (*_rsa) IsValidPair(key crypto.Signer, cert x509.Certificate) bool { return reflect.DeepEqual(pk, ck) } +// Generate generates a private key for the requested signature algorithm. func (*_rsa) Generate(alg x509.SignatureAlgorithm) (crypto.Signer, error) { var bits int switch alg { case x509.SHA512WithRSA, x509.SHA384WithRSA, x509.SHA512WithRSAPSS, x509.SHA384WithRSAPSS: bits = 4096 - case x509.SHA256WithRSA: + case x509.SHA256WithRSA, x509.SHA256WithRSAPSS: bits = 3072 default: return nil, fmt.Errorf("unknown certificate bits for '%s'", alg.String()) diff --git a/pki/alg_type.go b/pki/alg_type.go index 77b60db..e7ff0c2 100644 --- a/pki/alg_type.go +++ b/pki/alg_type.go @@ -8,15 +8,21 @@ package pki import ( "crypto" "crypto/x509" + "fmt" "go.osspkg.com/syncing" ) +const initialAlgorithmCapacity = 5 + var ( - signatures = syncing.NewMap[x509.SignatureAlgorithm, x509.PublicKeyAlgorithm](5) - algorithms = syncing.NewMap[x509.PublicKeyAlgorithm, Algorithm](5) + signatures = syncing.NewMap[x509.SignatureAlgorithm, x509.PublicKeyAlgorithm](initialAlgorithmCapacity) + algorithms = syncing.NewMap[x509.PublicKeyAlgorithm, Algorithm](initialAlgorithmCapacity) ) +// Register associates a signature algorithm with a key implementation. +// Registration affects subsequent certificate and request generation. Register +// custom algorithms before starting operations that use them. func Register(k x509.SignatureAlgorithm, v Algorithm) { signatures.Set(k, v.Name()) algorithms.Set(v.Name(), v) @@ -34,11 +40,31 @@ func init() { Register(x509.ECDSAWithSHA512, &_ecdsa{}) } +// Algorithm generates and validates keys supported by the certificate package. type Algorithm interface { + // Name returns the public-key algorithm handled by the implementation. Name() x509.PublicKeyAlgorithm + // IsPrivateKey reports whether key is a private key handled by the implementation. IsPrivateKey(key crypto.Signer) bool + // IsRequest reports whether the request public key is handled by the implementation. IsRequest(cert x509.CertificateRequest) bool + // IsCertificate reports whether the certificate public key is handled by the implementation. IsCertificate(cert x509.Certificate) bool + // IsValidPair reports whether key corresponds to cert's public key. IsValidPair(key crypto.Signer, cert x509.Certificate) bool + // Generate creates a private key suitable for alg or returns an error if alg + // is not supported. Generate(alg x509.SignatureAlgorithm) (crypto.Signer, error) } + +func algorithmForSignature(signatureAlgorithm x509.SignatureAlgorithm) (Algorithm, error) { + algorithmName, ok := signatures.Get(signatureAlgorithm) + if !ok { + return nil, fmt.Errorf("unknown signature algorithm: %s", signatureAlgorithm.String()) + } + algorithm, ok := algorithms.Get(algorithmName) + if !ok { + return nil, fmt.Errorf("unknown signature algorithm: %s", algorithmName.String()) + } + return algorithm, nil +} diff --git a/pki/common.go b/pki/common.go index a380fde..8353fb6 100644 --- a/pki/common.go +++ b/pki/common.go @@ -6,40 +6,16 @@ package pki import ( - _ "crypto/md5" - _ "crypto/sha1" - _ "crypto/sha256" - _ "crypto/sha512" - "encoding/asn1" + _ "crypto/md5" // Registers the digest for crypto.Hash.Available. + _ "crypto/sha1" // Registers the digest for crypto.Hash.Available. + _ "crypto/sha256" // Registers the digest for crypto.Hash.Available. + _ "crypto/sha512" // Registers the digest for crypto.Hash.Available. - _ "golang.org/x/crypto/blake2s" - _ "golang.org/x/crypto/md4" - _ "golang.org/x/crypto/ripemd160" - _ "golang.org/x/crypto/sha3" + _ "golang.org/x/crypto/blake2s" // Registers the digest for crypto.Hash.Available. + _ "golang.org/x/crypto/sha3" // Registers the digest for crypto.Hash.Available. ) -type policyQualifierInfo struct { - PolicyQualifierID asn1.ObjectIdentifier - Qualifier string `asn1:"ia5"` -} -type policyInformation struct { - PolicyIdentifier asn1.ObjectIdentifier - PolicyQualifiers []policyQualifierInfo `asn1:"optional"` -} - -func marshalPolicyCPSUrl(urls ...string) []byte { - cpsInfo := policyInformation{ - PolicyIdentifier: asn1.ObjectIdentifier{2, 23, 140, 1, 2, 1}, - PolicyQualifiers: make([]policyQualifierInfo, 0, len(urls)), - } - - for _, url := range urls { - cpsInfo.PolicyQualifiers = append(cpsInfo.PolicyQualifiers, policyQualifierInfo{ - PolicyQualifierID: asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 2, 1}, - Qualifier: url, - }) - } - - bytes, _ := asn1.Marshal([]policyInformation{cpsInfo}) - return bytes -} +const ( + privateFileMode = 0o600 + publicFileMode = 0o644 +) diff --git a/pki/config.go b/pki/config.go index 9db876e..fc545f0 100644 --- a/pki/config.go +++ b/pki/config.go @@ -8,27 +8,32 @@ package pki import ( "crypto/x509" "crypto/x509/pkix" - "encoding/asn1" ) +// Config contains the X.509 subject fields, signature algorithm, and certificate +// URLs used when generating certificates. A zero SignatureAlgorithm in a +// signing operation inherits the issuer's algorithm where supported. type Config struct { - SignatureAlgorithm x509.SignatureAlgorithm `yaml:"signature_algorithm" json:"signature_algorithm"` + SignatureAlgorithm x509.SignatureAlgorithm `json:"signature_algorithm" yaml:"signature_algorithm"` - Organization string `yaml:"organization,omitempty" json:"organization,omitempty"` - OrganizationalUnit string `yaml:"organizational_unit,omitempty" json:"organizational_unit,omitempty"` - Country string `yaml:"country,omitempty" json:"country,omitempty"` - Province string `yaml:"province,omitempty" json:"province,omitempty"` - Locality string `yaml:"locality,omitempty" json:"locality,omitempty"` - StreetAddress string `yaml:"street_address,omitempty" json:"street_address,omitempty"` - PostalCode string `yaml:"postal_code,omitempty" json:"postal_code,omitempty"` - CommonName string `yaml:"common_name,omitempty" json:"common_name,omitempty"` + Organization string `json:"organization,omitempty" yaml:"organization,omitempty"` + OrganizationalUnit string `json:"organizational_unit,omitempty" yaml:"organizational_unit,omitempty"` + Country string `json:"country,omitempty" yaml:"country,omitempty"` + Province string `json:"province,omitempty" yaml:"province,omitempty"` + Locality string `json:"locality,omitempty" yaml:"locality,omitempty"` + StreetAddress string `json:"street_address,omitempty" yaml:"street_address,omitempty"` + PostalCode string `json:"postal_code,omitempty" yaml:"postal_code,omitempty"` + CommonName string `json:"common_name,omitempty" yaml:"common_name,omitempty"` - OCSPServerURLs []string `yaml:"ocsp_server_ur_ls,omitempty" json:"ocsp_server_ur_ls,omitempty"` - IssuingCertificateURLs []string `yaml:"issuing_certificate_urls,omitempty" json:"issuing_certificate_urls,omitempty"` - CRLDistributionPointURLs []string `yaml:"crl_distribution_point_ur_ls,omitempty" json:"crl_distribution_point_ur_ls,omitempty"` - CertificatePoliciesURLs []string `yaml:"certificate_policies_urls,omitempty" json:"certificate_policies_urls,omitempty"` + OCSPServerURLs []string `json:"ocsp_server_ur_ls,omitempty" yaml:"ocsp_server_ur_ls,omitempty"` + IssuingCertificateURLs []string `json:"issuing_certificate_urls,omitempty" yaml:"issuing_certificate_urls,omitempty"` + CRLDistributionPointURLs []string `json:"crl_distribution_point_ur_ls,omitempty" yaml:"crl_distribution_point_ur_ls,omitempty"` + CertificatePoliciesURLs []string `json:"certificate_policies_urls,omitempty" yaml:"certificate_policies_urls,omitempty"` } +// Subject returns the distinguished name represented by the subject fields. +// Empty fields are omitted; each configured attribute is represented by one +// value. func (v Config) Subject() pkix.Name { result := pkix.Name{} @@ -59,15 +64,3 @@ func (v Config) Subject() pkix.Name { return result } - -func (v Config) extraExtensions() []pkix.Extension { - var result []pkix.Extension - - result = append(result, pkix.Extension{ - Id: asn1.ObjectIdentifier{2, 5, 29, 32}, - Critical: false, - Value: marshalPolicyCPSUrl(stringsPrepare(v.CertificatePoliciesURLs)...), - }) - - return result -} diff --git a/pki/config_coverage_test.go b/pki/config_coverage_test.go new file mode 100644 index 0000000..b2da84f --- /dev/null +++ b/pki/config_coverage_test.go @@ -0,0 +1,14 @@ +package pki_test + +import ( + "testing" + + "go.osspkg.com/encrypt/pki" +) + +func TestConfigSubjectIncludesConfiguredFields(t *testing.T) { + got := (pki.Config{Country: "US", Organization: "Example Org", OrganizationalUnit: "Security", Locality: "Boston", Province: "MA", StreetAddress: "1 Main St", PostalCode: "02110", CommonName: "root.example"}).Subject() + if got.Country[0] != "US" || got.Organization[0] != "Example Org" || got.OrganizationalUnit[0] != "Security" || got.Locality[0] != "Boston" || got.Province[0] != "MA" || got.StreetAddress[0] != "1 Main St" || got.PostalCode[0] != "02110" || got.CommonName != "root.example" { + t.Fatalf("unexpected subject: %#v", got) + } +} diff --git a/pki/encoders.go b/pki/encoders.go index 891520a..be3e590 100644 --- a/pki/encoders.go +++ b/pki/encoders.go @@ -9,21 +9,28 @@ import ( "crypto" "crypto/x509" "encoding/pem" + "errors" "fmt" "strings" ) var pemEndLine = []byte("\n-----END ") +// TypePEMBlock identifies the PEM block type used by an encoder. type TypePEMBlock string const ( - CertificatePEMBlock TypePEMBlock = "CERTIFICATE" - PrivateKeyPEMBlock TypePEMBlock = "PRIVATE KEY" - RevocationListPEMBlock TypePEMBlock = "X509 CRL" + // CertificatePEMBlock is the PEM type label for an X.509 certificate. + CertificatePEMBlock TypePEMBlock = "CERTIFICATE" + // PrivateKeyPEMBlock is the PEM type label for PKCS #8 private keys. + PrivateKeyPEMBlock TypePEMBlock = "PRIVATE KEY" + // RevocationListPEMBlock is the PEM type label for a certificate revocation list. + RevocationListPEMBlock TypePEMBlock = "X509 CRL" + // CertificateRequestPEMBlock is the PEM label for an X.509 certificate request. CertificateRequestPEMBlock TypePEMBlock = "CERTIFICATE REQUEST" ) +// CreatePEMBlock encodes bytes in a PEM block with the requested type and prefix. func CreatePEMBlock(b []byte, t TypePEMBlock, prefix string) []byte { s := string(t) if len(prefix) > 0 { @@ -37,9 +44,10 @@ func CreatePEMBlock(b []byte, t TypePEMBlock, prefix string) []byte { // --------------------------------------------------------------------------------------------------------------------- +// MarshalKeyDER encodes a private key as PKCS #8 DER. func MarshalKeyDER(key crypto.Signer) ([]byte, error) { if key == nil { - return nil, fmt.Errorf("no private key provided") + return nil, errors.New("no private key provided") } b, err := x509.MarshalPKCS8PrivateKey(key) @@ -50,9 +58,10 @@ func MarshalKeyDER(key crypto.Signer) ([]byte, error) { return b, nil } +// UnmarshalKeyDER parses a PKCS #8 DER private key. func UnmarshalKeyDER(b []byte) (crypto.Signer, error) { if len(b) == 0 { - return nil, fmt.Errorf("no private key provided") + return nil, errors.New("no private key provided") } raw, err := x509.ParsePKCS8PrivateKey(b) @@ -62,19 +71,21 @@ func UnmarshalKeyDER(b []byte) (crypto.Signer, error) { key, ok := raw.(crypto.Signer) if !ok { - return nil, fmt.Errorf("PKCS#8 private key does not implement crypto.Signer") + return nil, errors.New("PKCS#8 private key does not implement crypto.Signer") } return key, nil } +// MarshalCrtDER returns the certificate DER bytes. func MarshalCrtDER(cert x509.Certificate) []byte { return cert.Raw } +// UnmarshalCrtDER parses a DER encoded X.509 certificate. func UnmarshalCrtDER(b []byte) (*x509.Certificate, error) { if len(b) == 0 { - return nil, fmt.Errorf("no certificate provided") + return nil, errors.New("no certificate provided") } cert, err := x509.ParseCertificate(b) @@ -85,6 +96,7 @@ func UnmarshalCrtDER(b []byte) (*x509.Certificate, error) { return cert, nil } +// MarshalKeyPEM encodes a private key as PKCS #8 PEM. func MarshalKeyPEM(key crypto.Signer) ([]byte, error) { b, err := MarshalKeyDER(key) if err != nil { @@ -92,7 +104,7 @@ func MarshalKeyPEM(key crypto.Signer) ([]byte, error) { } var prefix string - //for name, a := range algorithms.Yield() { + // for name, a := range algorithms.Yield() { // if !a.IsPrivateKey(key) { // continue // } @@ -102,35 +114,40 @@ func MarshalKeyPEM(key crypto.Signer) ([]byte, error) { return CreatePEMBlock(b, PrivateKeyPEMBlock, prefix), nil } +// UnmarshalKeyPEM parses a PKCS #8 PEM private key. func UnmarshalKeyPEM(b []byte) (crypto.Signer, error) { block, _ := pem.Decode(b) if block == nil || !strings.HasSuffix(block.Type, string(PrivateKeyPEMBlock)) { - return nil, fmt.Errorf("no private key provided") + return nil, errors.New("no private key provided") } return UnmarshalKeyDER(block.Bytes) } +// MarshalCrtPEM encodes an X.509 certificate as PEM. func MarshalCrtPEM(cert x509.Certificate) ([]byte, error) { b := MarshalCrtDER(cert) return CreatePEMBlock(b, CertificatePEMBlock, ""), nil } +// UnmarshalCrtPEM parses a PEM encoded X.509 certificate. func UnmarshalCrtPEM(b []byte) (*x509.Certificate, error) { block, _ := pem.Decode(b) if block == nil || !strings.HasSuffix(block.Type, string(CertificatePEMBlock)) { - return nil, fmt.Errorf("no certificate provided") + return nil, errors.New("no certificate provided") } return UnmarshalCrtDER(block.Bytes) } +// MarshalCsrDER returns the certificate request DER bytes. func MarshalCsrDER(cert x509.CertificateRequest) []byte { return cert.Raw } +// UnmarshalCsrDER parses a DER encoded certificate request. func UnmarshalCsrDER(b []byte) (*x509.CertificateRequest, error) { if len(b) == 0 { - return nil, fmt.Errorf("no CSR provided") + return nil, errors.New("no CSR provided") } cert, err := x509.ParseCertificateRequest(b) if err != nil { @@ -139,16 +156,18 @@ func UnmarshalCsrDER(b []byte) (*x509.CertificateRequest, error) { return cert, nil } +// MarshalCsrPEM encodes a certificate request as PEM. func MarshalCsrPEM(cert x509.CertificateRequest) ([]byte, error) { b := MarshalCsrDER(cert) return CreatePEMBlock(b, CertificateRequestPEMBlock, ""), nil } +// UnmarshalCsrPEM parses a PEM encoded certificate request. func UnmarshalCsrPEM(b []byte) (*x509.CertificateRequest, error) { block, _ := pem.Decode(b) if block == nil || !strings.HasSuffix(block.Type, string(CertificateRequestPEMBlock)) { - return nil, fmt.Errorf("no certificate provided") + return nil, errors.New("no certificate provided") } return UnmarshalCsrDER(block.Bytes) } diff --git a/pki/encoders_coverage_test.go b/pki/encoders_coverage_test.go new file mode 100644 index 0000000..2072472 --- /dev/null +++ b/pki/encoders_coverage_test.go @@ -0,0 +1,165 @@ +package pki_test + +import ( + "crypto/x509" + "encoding/pem" + "testing" + "time" + + "go.osspkg.com/encrypt/pki" +) + +//nolint:revive // This test groups related coverage cases for one API. +func TestEncodersRoundTrip(t *testing.T) { + keyCert, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.ECDSAWithSHA256, CommonName: "root"}, 24*time.Hour, 10, 0) + if err != nil { + t.Fatal(err) + } + keyDER, err := pki.MarshalKeyDER(keyCert.Key) + if err != nil { + t.Fatal(err) + } + keyFromDER, err := pki.UnmarshalKeyDER(keyDER) + if err != nil { + t.Fatal(err) + } + keyPEM, err := pki.MarshalKeyPEM(keyFromDER) + if err != nil { + t.Fatal(err) + } + if _, err := pki.UnmarshalKeyPEM(keyPEM); err != nil { + t.Fatal(err) + } + + certDER := pki.MarshalCrtDER(*keyCert.Crt) + if _, err := pki.UnmarshalCrtDER(certDER); err != nil { + t.Fatal(err) + } + certPEM, err := pki.MarshalCrtPEM(*keyCert.Crt) + if err != nil { + t.Fatal(err) + } + if _, err := pki.UnmarshalCrtPEM(certPEM); err != nil { + t.Fatal(err) + } + + request, err := pki.NewCSR(x509.ECDSAWithSHA256, "example.test") + if err != nil { + t.Fatal(err) + } + csrDER := pki.MarshalCsrDER(*request.Csr) + if _, err := pki.UnmarshalCsrDER(csrDER); err != nil { + t.Fatal(err) + } + csrPEM, err := pki.MarshalCsrPEM(*request.Csr) + if err != nil { + t.Fatal(err) + } + if _, err := pki.UnmarshalCsrPEM(csrPEM); err != nil { + t.Fatal(err) + } + + customPEM := pki.CreatePEMBlock([]byte("data"), pki.PrivateKeyPEMBlock, "ENCRYPTED") + block, _ := pem.Decode(customPEM) + if block == nil || block.Type != "ENCRYPTED PRIVATE KEY" { + t.Fatalf("unexpected PEM type: %#v", block) + } +} + +func TestEncodersRejectInvalidInput(t *testing.T) { + if _, err := pki.MarshalKeyDER(nil); err == nil { + t.Fatal("MarshalKeyDER accepted nil key") + } + for name, fn := range map[string]func([]byte) error{ + "key DER": func(b []byte) error { _, err := pki.UnmarshalKeyDER(b); return err }, + "key PEM": func(b []byte) error { _, err := pki.UnmarshalKeyPEM(b); return err }, + "cert DER": func(b []byte) error { _, err := pki.UnmarshalCrtDER(b); return err }, + "cert PEM": func(b []byte) error { _, err := pki.UnmarshalCrtPEM(b); return err }, + "CSR DER": func(b []byte) error { _, err := pki.UnmarshalCsrDER(b); return err }, + "CSR PEM": func(b []byte) error { _, err := pki.UnmarshalCsrPEM(b); return err }, + } { + t.Run(name, func(t *testing.T) { + if err := fn([]byte("bad")); err == nil { + t.Fatal("accepted invalid input") + } + }) + } + + _, err := pki.UnmarshalKeyDER([]byte{0x30, 0x00}) + if err == nil { + t.Fatal("UnmarshalKeyDER accepted non-key PKCS#8") + } +} + +//nolint:revive // This test groups related coverage cases for one API. +func TestCertificateAndRequestFiles(t *testing.T) { + dir := t.TempDir() + ca, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.ECDSAWithSHA256}, 24*time.Hour, 11, 0) + if err != nil { + t.Fatal(err) + } + certKeyPath, certPath := dir+"/key.pem", dir+"/cert.pem" + if err := ca.SaveKey(certKeyPath); err != nil { + t.Fatal(err) + } + if err := ca.SaveCert(certPath); err != nil { + t.Fatal(err) + } + var loaded pki.Certificate + if err := loaded.LoadKey(certKeyPath); err != nil { + t.Fatal(err) + } + if err := loaded.LoadCert(certPath); err != nil { + t.Fatal(err) + } + if !loaded.IsValidPair() { + t.Fatal("saved certificate and key do not match") + } + + csr, err := pki.NewCSR(x509.ECDSAWithSHA256, "example.test") + if err != nil { + t.Fatal(err) + } + requestKeyPath, requestPath := dir+"/request-key.pem", dir+"/request.pem" + if err := csr.SaveKey(requestKeyPath); err != nil { + t.Fatal(err) + } + if err := csr.SaveCert(requestPath); err != nil { + t.Fatal(err) + } + var loadedRequest pki.Request + if err := loadedRequest.LoadKey(requestKeyPath); err != nil { + t.Fatal(err) + } + if err := loadedRequest.LoadCert(requestPath); err != nil { + t.Fatal(err) + } + if loadedRequest.Csr.Subject.CommonName != "example.test" { + t.Fatalf("unexpected CSR: %q", loadedRequest.Csr.Subject.CommonName) + } + + if err := (*pki.Certificate)(nil).SaveKey(certKeyPath); err == nil { + t.Fatal("nil certificate saved a key") + } + if err := (*pki.Certificate)(nil).SaveCert(certPath); err == nil { + t.Fatal("nil certificate saved a cert") + } + if err := (*pki.Request)(nil).SaveKey(requestKeyPath); err == nil { + t.Fatal("nil request saved a key") + } + if err := (*pki.Request)(nil).SaveCert(requestPath); err == nil { + t.Fatal("nil request saved a CSR") + } + if err := loaded.LoadKey(dir + "/missing"); err == nil { + t.Fatal("LoadKey accepted missing file") + } + if err := loaded.LoadCert(dir + "/missing"); err == nil { + t.Fatal("LoadCert accepted missing file") + } + if err := loadedRequest.LoadKey(dir + "/missing"); err == nil { + t.Fatal("Request.LoadKey accepted missing file") + } + if err := loadedRequest.LoadCert(dir + "/missing"); err == nil { + t.Fatal("Request.LoadCert accepted missing file") + } +} diff --git a/pki/generate_ca.go b/pki/generate_ca.go index 58ac125..061095e 100644 --- a/pki/generate_ca.go +++ b/pki/generate_ca.go @@ -13,6 +13,7 @@ import ( "time" ) +// NewCA generates a self-signed certificate authority. func NewCA( conf Config, deadline time.Duration, @@ -34,7 +35,7 @@ func NewCA( OCSPServer: stringsPrepare(conf.OCSPServerURLs), IssuingCertificateURL: stringsPrepare(conf.IssuingCertificateURLs), CRLDistributionPoints: stringsPrepare(conf.CRLDistributionPointURLs), - //ExtraExtensions: conf.extraExtensions(), + // ExtraExtensions: conf.extraExtensions(), MaxPathLen: intermediateCount, MaxPathLenZero: intermediateCount <= 0, } diff --git a/pki/generate_ca_inter.go b/pki/generate_ca_inter.go index 68b6167..cf3a85d 100644 --- a/pki/generate_ca_inter.go +++ b/pki/generate_ca_inter.go @@ -8,17 +8,23 @@ package pki import ( "crypto/rand" "crypto/x509" + "errors" "fmt" "math/big" "time" ) +// NewIntermediateCA generates an intermediate CA signed by rootCA. func NewIntermediateCA( conf Config, rootCA Certificate, deadline time.Duration, serialNumber int64, ) (*Certificate, error) { + if err := validateSigningCA(rootCA, false); err != nil { + return nil, err + } + confSigAlg := conf.SignatureAlgorithm if confSigAlg == x509.UnknownSignatureAlgorithm { confSigAlg = rootCA.Crt.SignatureAlgorithm @@ -41,25 +47,17 @@ func NewIntermediateCA( OCSPServer: stringsPrepare(conf.OCSPServerURLs), IssuingCertificateURL: stringsPrepare(conf.IssuingCertificateURLs), CRLDistributionPoints: stringsPrepare(conf.CRLDistributionPointURLs), - //ExtraExtensions: conf.extraExtensions(), + // ExtraExtensions: conf.extraExtensions(), MaxPathLen: level, MaxPathLenZero: level <= 0, } - if !rootCA.IsValidPair() { - return nil, fmt.Errorf("invalid Root CA certificate") - } - - if !rootCA.IsCA() { - return nil, fmt.Errorf("invalid Root CA certificate: is not CA") - } - if template.MaxPathLen < 0 { - return nil, fmt.Errorf("invalid Root CA certificate: not supported Intermediate CA") + return nil, errors.New("invalid Root CA certificate: not supported Intermediate CA") } if template.NotAfter.After(rootCA.Crt.NotAfter) { - return nil, fmt.Errorf("invalid Root CA certificate: NotAfter cannot be in the future") + return nil, errors.New("invalid Root CA certificate: NotAfter cannot be in the future") } algName, ok := signatures.Get(template.SignatureAlgorithm) @@ -89,3 +87,16 @@ func NewIntermediateCA( return &Certificate{Key: key, Crt: cert}, nil } + +func validateSigningCA(rootCA Certificate, requireLeafIssuer bool) error { + if !rootCA.IsValidPair() { + return errors.New("invalid Root CA certificate") + } + if !rootCA.IsCA() { + return errors.New("invalid Root CA certificate: is not CA") + } + if requireLeafIssuer && rootCA.Crt.MaxPathLen != 0 { + return errors.New("invalid Root CA certificate: not supported generate client certificate") + } + return nil +} diff --git a/pki/generate_coverage_test.go b/pki/generate_coverage_test.go new file mode 100644 index 0000000..e617242 --- /dev/null +++ b/pki/generate_coverage_test.go @@ -0,0 +1,110 @@ +package pki_test + +import ( + "crypto/x509" + "testing" + "time" + + "go.osspkg.com/encrypt/pki" +) + +func TestCRLAndGenerationFailures(t *testing.T) { + ca, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.ECDSAWithSHA256}, time.Hour*48, 30, 0) + if err != nil { + t.Fatal(err) + } + crl, err := pki.NewCRL(*ca, 1, time.Hour, []pki.RevocationEntity{{SerialNumber: 4, RevocationTime: time.Now()}}) + if err != nil || len(crl) == 0 { + t.Fatalf("NewCRL len=%d err=%v", len(crl), err) + } + if _, err := pki.NewCRL(pki.Certificate{}, 1, time.Hour, nil); err == nil { + t.Fatal("NewCRL accepted invalid CA") + } + if _, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.UnknownSignatureAlgorithm}, time.Hour, 1, 0); err == nil { + t.Fatal("NewCA accepted unknown algorithm") + } + if _, err := pki.NewCSR(x509.UnknownSignatureAlgorithm, "host.test"); err == nil { + t.Fatal("NewCSR accepted unknown algorithm") + } + if _, err := pki.NewCSR(x509.ECDSAWithSHA256); err == nil { + t.Fatal("NewCSR accepted no domains") + } + if _, err := pki.NewCRT(pki.Config{}, pki.Certificate{}, time.Hour, 1, "host.test"); err == nil { + t.Fatal("NewCRT accepted invalid CA") + } + if _, err := pki.NewIntermediateCA(pki.Config{}, pki.Certificate{}, time.Hour, 1); err == nil { + t.Fatal("NewIntermediateCA accepted invalid CA") + } +} + +func TestRSAPSSKeyGeneration(t *testing.T) { + request, err := pki.NewCSR(x509.SHA256WithRSAPSS, "pss.example.test") + if err != nil { + t.Fatal(err) + } + if request.Key == nil || request.Csr == nil { + t.Fatal("NewCSR returned incomplete RSA-PSS request") + } +} + +func TestGenerationPathAndSigningErrors(t *testing.T) { + root, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.ECDSAWithSHA256}, 24*time.Hour, 60, 0) + if err != nil { + t.Fatal(err) + } + if _, err := pki.NewIntermediateCA(pki.Config{}, *root, time.Hour, 61); err == nil { + t.Fatal("intermediate exceeded root path length") + } + rootWithPath, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.ECDSAWithSHA256}, time.Hour, 62, 1) + if err != nil { + t.Fatal(err) + } + if _, err := pki.NewIntermediateCA(pki.Config{}, *rootWithPath, 2*time.Hour, 63); err == nil { + t.Fatal("intermediate validity exceeded issuer validity") + } + if _, err := pki.NewCRT(pki.Config{SignatureAlgorithm: x509.UnknownSignatureAlgorithm}, *rootWithPath, time.Minute, 64, "host.test"); err == nil { + t.Fatal("NewCRT accepted unknown signature algorithm") + } + if _, err := pki.NewCRT(pki.Config{}, *rootWithPath, time.Hour, 65, "bad-ip:443"); err == nil { + t.Fatal("NewCRT accepted malformed host:port") + } + csr, err := pki.NewCSR(x509.ECDSAWithSHA256, "sign.example.test") + if err != nil { + t.Fatal(err) + } + if _, err := pki.SignCSR(pki.Config{SignatureAlgorithm: x509.UnknownSignatureAlgorithm}, *rootWithPath, *csr.Csr, time.Minute, 66); err == nil { + t.Fatal("SignCSR accepted unknown algorithm") + } + if _, err := pki.SignCSR(pki.Config{}, pki.Certificate{}, *csr.Csr, time.Minute, 67); err == nil { + t.Fatal("SignCSR accepted invalid CA") + } +} + +func TestGenerationInvalidDatesAndAlgorithms(t *testing.T) { + if _, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.ECDSAWithSHA256}, time.Hour, -1, 0); err == nil { + t.Fatal("NewCA accepted negative serial") + } + root, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.ECDSAWithSHA256}, 24*time.Hour, 71, 2) + if err != nil { + t.Fatal(err) + } + if _, err := pki.NewIntermediateCA(pki.Config{SignatureAlgorithm: x509.SignatureAlgorithm(999)}, *root, time.Hour, 72); err == nil { + t.Fatal("NewIntermediateCA accepted unknown algorithm") + } + if _, err := pki.NewCRT(pki.Config{}, *root, 48*time.Hour, 73, "long.example.test"); err == nil { + t.Fatal("NewCRT exceeded issuer validity") + } + if _, err := pki.NewCRT(pki.Config{SignatureAlgorithm: x509.SignatureAlgorithm(999)}, *root, time.Hour, 74, "unknown.example.test"); err == nil { + t.Fatal("NewCRT accepted unknown algorithm") + } + if _, err := pki.NewCSR(x509.SignatureAlgorithm(999), "unknown.example.test"); err == nil { + t.Fatal("NewCSR accepted unknown algorithm") + } + request, err := pki.NewCSR(x509.ECDSAWithSHA256, "csr.example.test") + if err != nil { + t.Fatal(err) + } + if _, err := pki.SignCSR(pki.Config{SignatureAlgorithm: x509.SignatureAlgorithm(999)}, *root, *request.Csr, time.Hour, 75); err == nil { + t.Fatal("SignCSR accepted unknown algorithm") + } +} diff --git a/pki/generate_crl.go b/pki/generate_crl.go index 120288d..b24f7f7 100644 --- a/pki/generate_crl.go +++ b/pki/generate_crl.go @@ -8,23 +8,26 @@ package pki import ( "crypto/rand" "crypto/x509" + "errors" "fmt" "math/big" "time" ) +// RevocationEntity describes a certificate revocation entry. type RevocationEntity struct { - SerialNumber int64 `yaml:"serial_number" json:"serial_number"` - RevocationTime time.Time `yaml:"revocation_time" json:"revocation_time"` + SerialNumber int64 `json:"serial_number" yaml:"serial_number"` + RevocationTime time.Time `json:"revocation_time" yaml:"revocation_time"` } +// NewCRL creates a signed certificate revocation list. func NewCRL(rootCA Certificate, id int64, updateInterval time.Duration, revs []RevocationEntity) ([]byte, error) { if !rootCA.IsValidPair() { - return nil, fmt.Errorf("invalid Root CA certificate") + return nil, errors.New("invalid Root CA certificate") } if !rootCA.IsCA() { - return nil, fmt.Errorf("invalid Root CA certificate: is not CA") + return nil, errors.New("invalid Root CA certificate: is not CA") } list := make([]x509.RevocationListEntry, 0, len(revs)) diff --git a/pki/generate_crt.go b/pki/generate_crt.go index 72414ee..0248e6a 100644 --- a/pki/generate_crt.go +++ b/pki/generate_crt.go @@ -8,11 +8,13 @@ package pki import ( "crypto/rand" "crypto/x509" + "errors" "fmt" "math/big" "time" ) +// NewCRT generates a leaf certificate signed by rootCA for the supplied domains. func NewCRT( conf Config, rootCA Certificate, @@ -20,16 +22,8 @@ func NewCRT( serialNumber int64, domains ...string, ) (*Certificate, error) { - if !rootCA.IsValidPair() { - return nil, fmt.Errorf("invalid Root CA certificate") - } - - if !rootCA.IsCA() { - return nil, fmt.Errorf("invalid Root CA certificate: is not CA") - } - - if rootCA.Crt.MaxPathLen != 0 { - return nil, fmt.Errorf("invalid Root CA certificate: not supported generate client certificate") + if err := validateSigningCA(rootCA, true); err != nil { + return nil, err } confSigAlg := conf.SignatureAlgorithm @@ -52,11 +46,11 @@ func NewCRT( OCSPServer: stringsPrepare(conf.OCSPServerURLs), IssuingCertificateURL: stringsPrepare(conf.IssuingCertificateURLs), CRLDistributionPoints: stringsPrepare(conf.CRLDistributionPointURLs), - //ExtraExtensions: conf.extraExtensions(), + // ExtraExtensions: conf.extraExtensions(), } if template.NotAfter.After(rootCA.Crt.NotAfter) { - return nil, fmt.Errorf("invalid deadline: cannot be in the future then NotAfter Root CA certificate") + return nil, errors.New("invalid deadline: cannot be in the future then NotAfter Root CA certificate") } var err error @@ -65,20 +59,11 @@ func NewCRT( return nil, fmt.Errorf("invalid domains: %w", err) } - if len(template.DNSNames) > 0 { - template.Subject.CommonName = template.DNSNames[0] - } else if len(template.IPAddresses) > 0 { - template.Subject.CommonName = template.IPAddresses[0].String() - } + setCertificateCommonName(template) - algName, ok := signatures.Get(template.SignatureAlgorithm) - if !ok { - return nil, fmt.Errorf("unknown signature algorithm: %s", template.SignatureAlgorithm.String()) - } - - alg, ok := algorithms.Get(algName) - if !ok { - return nil, fmt.Errorf("unknown signature algorithm: %s", algName.String()) + alg, err := algorithmForSignature(template.SignatureAlgorithm) + if err != nil { + return nil, err } key, err := alg.Generate(template.SignatureAlgorithm) @@ -86,12 +71,12 @@ func NewCRT( return nil, fmt.Errorf("failed generating private key: %w", err) } - //publicKeyBytes, err := x509.MarshalPKIXPublicKey(key.Public()) - //if err != nil { + // publicKeyBytes, err := x509.MarshalPKIXPublicKey(key.Public()) + // if err != nil { // return nil, fmt.Errorf("failed marshaling public key: %w", err) //} - //publicKeyHash := sha256.Sum256(publicKeyBytes) - //template.SubjectKeyId = publicKeyHash[:20] + // publicKeyHash := sha256.Sum256(publicKeyBytes) + // template.SubjectKeyId = publicKeyHash[:20] b, err := x509.CreateCertificate(rand.Reader, template, rootCA.Crt, key.Public(), rootCA.Key) if err != nil { @@ -105,3 +90,11 @@ func NewCRT( return &Certificate{Key: key, Crt: cert}, nil } + +func setCertificateCommonName(template *x509.Certificate) { + if len(template.DNSNames) > 0 { + template.Subject.CommonName = template.DNSNames[0] + } else if len(template.IPAddresses) > 0 { + template.Subject.CommonName = template.IPAddresses[0].String() + } +} diff --git a/pki/generate_csr.go b/pki/generate_csr.go index 2492395..ce9f197 100644 --- a/pki/generate_csr.go +++ b/pki/generate_csr.go @@ -8,14 +8,16 @@ package pki import ( "crypto/rand" "crypto/x509" + "errors" "fmt" "math/big" "time" ) +// NewCSR generates a certificate signing request for the supplied domains. func NewCSR(signatureAlgorithm x509.SignatureAlgorithm, domains ...string) (*Request, error) { if len(domains) == 0 { - return nil, fmt.Errorf("no certificate domains provided") + return nil, errors.New("no certificate domains provided") } algName, ok := signatures.Get(signatureAlgorithm) @@ -61,6 +63,7 @@ func NewCSR(signatureAlgorithm x509.SignatureAlgorithm, domains ...string) (*Req return &Request{Key: key, Csr: cert}, nil } +// SignCSR validates and signs a certificate signing request with the CA. func SignCSR( conf Config, rootCA Certificate, @@ -68,16 +71,12 @@ func SignCSR( deadline time.Duration, serialNumber int64, ) (*x509.Certificate, error) { - if !rootCA.IsValidPair() { - return nil, fmt.Errorf("invalid Root CA certificate") + if err := validateSigningCA(rootCA, true); err != nil { + return nil, err } - if !rootCA.IsCA() { - return nil, fmt.Errorf("invalid Root CA certificate: is not CA") - } - - if rootCA.Crt.MaxPathLen != 0 { - return nil, fmt.Errorf("invalid Root CA certificate: not supported generate client certificate") + if err := csr.CheckSignature(); err != nil { + return nil, fmt.Errorf("invalid certificate request signature: %w", err) } confSigAlg := conf.SignatureAlgorithm @@ -104,17 +103,17 @@ func SignCSR( OCSPServer: stringsPrepare(conf.OCSPServerURLs), IssuingCertificateURL: stringsPrepare(conf.IssuingCertificateURLs), CRLDistributionPoints: stringsPrepare(conf.CRLDistributionPointURLs), - //ExtraExtensions: conf.extraExtensions(), + // ExtraExtensions: conf.extraExtensions(), DNSNames: csr.DNSNames, IPAddresses: csr.IPAddresses, } - //publicKeyBytes, err := x509.MarshalPKIXPublicKey(csr.PublicKey) - //if err != nil { + // publicKeyBytes, err := x509.MarshalPKIXPublicKey(csr.PublicKey) + // if err != nil { // return nil, fmt.Errorf("failed marshaling public key: %w", err) //} - //publicKeyHash := sha256.Sum256(publicKeyBytes) - //template.SubjectKeyId = publicKeyHash[:20] + // publicKeyHash := sha256.Sum256(publicKeyBytes) + // template.SubjectKeyId = publicKeyHash[:20] b, err := x509.CreateCertificate(rand.Reader, template, rootCA.Crt, csr.PublicKey, rootCA.Key) if err != nil { diff --git a/pki/generate_test.go b/pki/generate_test.go index b1cb9e4..605711e 100644 --- a/pki/generate_test.go +++ b/pki/generate_test.go @@ -140,6 +140,7 @@ func TestUnit_SignCSR(t *testing.T) { } func dump(t *testing.T, crt *pki.Certificate) { + t.Helper() kb, err := pki.MarshalKeyPEM(crt.Key) casecheck.NoError(t, err) cb, err := pki.MarshalCrtPEM(*crt.Crt) diff --git a/pki/internal/xocsp/ocsp.go b/pki/internal/xocsp/ocsp.go index 7230fd6..6873955 100644 --- a/pki/internal/xocsp/ocsp.go +++ b/pki/internal/xocsp/ocsp.go @@ -1,12 +1,10 @@ -//FORK: golang.org/x/crypto/ocsp +// FORK: golang.org/x/crypto/ocsp // Copyright 2013 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -// Package ocsp parses OCSP responses as specified in RFC 2560. OCSP responses -// are signed messages attesting to the validity of a certificate for a small -// period of time. This is used to manage revocation for X.509 certificates. +// Package xocsp parses OCSP requests and creates OCSP responses. package xocsp import ( @@ -15,9 +13,9 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/rsa" - _ "crypto/sha1" - _ "crypto/sha256" - _ "crypto/sha512" + _ "crypto/sha1" // Registers SHA-1 for OCSP request compatibility. + _ "crypto/sha256" // Registers supported OCSP hash implementations. + _ "crypto/sha512" // Registers supported OCSP hash implementations. "crypto/x509" "crypto/x509/pkix" "encoding/asn1" @@ -28,9 +26,18 @@ import ( "time" ) +const ( + asn1TagNull = 5 + asn1TagResponderName = 1 + asn1TagKeyHash = 2 + asn1ClassContextSpecific = 2 + bitsPerByte = 8 +) + var ( idPKIXOCSPBasic = asn1.ObjectIdentifier([]int{1, 3, 6, 1, 5, 5, 7, 48, 1, 1}) - OIDNonce = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 2} + // OIDNonce is the ASN.1 object identifier for the OCSP nonce extension. + OIDNonce = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 2} ) // ResponseStatus contains the result of an OCSP request. See @@ -38,10 +45,15 @@ var ( type ResponseStatus int const ( - Success ResponseStatus = 0 - Malformed ResponseStatus = 1 + // Success is the OCSP response status for a successful request. + Success ResponseStatus = 0 + // Malformed indicates that the OCSP request could not be parsed. + Malformed ResponseStatus = 1 + // InternalError indicates an internal OCSP responder failure. InternalError ResponseStatus = 2 - TryLater ResponseStatus = 3 + // TryLater indicates the responder cannot process this request now. + TryLater ResponseStatus = 3 + // SignatureRequired indicates that a signed OCSP request is required. // Status code four is unused in OCSP. See // https://tools.ietf.org/html/rfc6960#section-4.2.1 SignatureRequired ResponseStatus = 5 @@ -189,8 +201,13 @@ var signatureAlgorithmDetails = []struct { } // TODO(rlb): This is also from crypto/x509, so same comment as AGL's below -func signingParamsForPublicKey(pub interface{}, requestedSigAlgo x509.SignatureAlgorithm) (hashFunc crypto.Hash, sigAlgo pkix.AlgorithmIdentifier, err error) { - var pubType x509.PublicKeyAlgorithm +func signingParamsForPublicKey(pub interface{}, requestedSigAlgo x509.SignatureAlgorithm) (crypto.Hash, pkix.AlgorithmIdentifier, error) { + var ( + hashFunc crypto.Hash + sigAlgo pkix.AlgorithmIdentifier + err error + pubType x509.PublicKeyAlgorithm + ) switch pub := pub.(type) { case *rsa.PublicKey: @@ -198,7 +215,7 @@ func signingParamsForPublicKey(pub interface{}, requestedSigAlgo x509.SignatureA hashFunc = crypto.SHA256 sigAlgo.Algorithm = oidSignatureSHA256WithRSA sigAlgo.Parameters = asn1.RawValue{ - Tag: 5, + Tag: asn1TagNull, } case *ecdsa.PublicKey: @@ -223,11 +240,11 @@ func signingParamsForPublicKey(pub interface{}, requestedSigAlgo x509.SignatureA } if err != nil { - return + return hashFunc, sigAlgo, err } if requestedSigAlgo == 0 { - return + return hashFunc, sigAlgo, nil } found := false @@ -235,12 +252,12 @@ func signingParamsForPublicKey(pub interface{}, requestedSigAlgo x509.SignatureA if details.algo == requestedSigAlgo { if details.pubKeyAlgo != pubType { err = errors.New("x509: requested SignatureAlgorithm does not match private key type") - return + return hashFunc, sigAlgo, err } sigAlgo.Algorithm, hashFunc = details.oid, details.hash if hashFunc == 0 { err = errors.New("x509: cannot sign with hash function requested") - return + return hashFunc, sigAlgo, err } found = true break @@ -251,7 +268,7 @@ func signingParamsForPublicKey(pub interface{}, requestedSigAlgo x509.SignatureA err = errors.New("x509: unknown SignatureAlgorithm") } - return + return hashFunc, sigAlgo, err } // TODO(agl): this is taken from crypto/x509 and so should probably be exported @@ -335,7 +352,7 @@ func (req *Request) Marshal() ([]byte, error) { Cert: certID{ pkix.AlgorithmIdentifier{ Algorithm: hashAlg, - Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */}, + Parameters: asn1.RawValue{Tag: asn1TagNull /* ASN.1 NULL */}, }, req.IssuerNameHash, req.IssuerKeyHash, @@ -544,13 +561,13 @@ func ParseResponseForCert(bytes []byte, cert, issuer *x509.Certificate) (*Respon // released. rawResponderID := basicResp.TBSResponseData.RawResponderID switch rawResponderID.Tag { - case 1: // Name + case asn1TagResponderName: // Name var rdn pkix.RDNSequence if rest, err := asn1.Unmarshal(rawResponderID.Bytes, &rdn); err != nil || len(rest) != 0 { return nil, ParseError("invalid responder name") } ret.RawResponderName = rawResponderID.Bytes - case 2: // KeyHash + case asn1TagKeyHash: // KeyHash if rest, err := asn1.Unmarshal(rawResponderID.Bytes, &ret.ResponderKeyHash); err != nil || len(rest) != 0 { return nil, ParseError("invalid responder key hash") } @@ -657,10 +674,12 @@ func CreateRequest(cert, issuer *x509.Certificate, opts *RequestOptions) ([]byte return nil, err } + //nolint:revive // hash.Hash.Write is documented to always return a nil error. h.Write(publicKeyInfo.PublicKey.RightAlign()) issuerKeyHash := h.Sum(nil) h.Reset() + //nolint:revive // hash.Hash.Write is documented to always return a nil error. h.Write(issuer.RawSubject) issuerNameHash := h.Sum(nil) @@ -710,10 +729,12 @@ func CreateResponse( return nil, fmt.Errorf("issuer hash algorithm %v not linked into binary", template.IssuerHash) } h := template.IssuerHash.New() + //nolint:revive // hash.Hash.Write is documented to always return a nil error. h.Write(publicKeyInfo.PublicKey.RightAlign()) issuerKeyHash := h.Sum(nil) h.Reset() + //nolint:revive // hash.Hash.Write is documented to always return a nil error. h.Write(issuer.RawSubject) issuerNameHash := h.Sum(nil) @@ -721,7 +742,7 @@ func CreateResponse( CertID: certID{ HashAlgorithm: pkix.AlgorithmIdentifier{ Algorithm: hashOID, - Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */}, + Parameters: asn1.RawValue{Tag: asn1TagNull /* ASN.1 NULL */}, }, NameHash: issuerNameHash, IssuerKeyHash: issuerKeyHash, @@ -745,8 +766,8 @@ func CreateResponse( } rawResponderID := asn1.RawValue{ - Class: 2, // context-specific - Tag: 1, // Name (explicit tag) + Class: asn1ClassContextSpecific, // context-specific + Tag: 1, // Name (explicit tag) IsCompound: true, Bytes: responderCert.RawSubject, } @@ -769,6 +790,7 @@ func CreateResponse( } responseHash := hashFunc.New() + //nolint:revive // hash.Hash.Write is documented to always return a nil error. responseHash.Write(tbsResponseDataDER) signature, err := priv.Sign(rand.Reader, responseHash.Sum(nil), hashFunc) if err != nil { @@ -780,7 +802,7 @@ func CreateResponse( SignatureAlgorithm: signatureAlgorithm, Signature: asn1.BitString{ Bytes: signature, - BitLength: 8 * len(signature), + BitLength: bitsPerByte * len(signature), }, } if template.Certificate != nil { diff --git a/pki/internal/xocsp/ocsp_test.go b/pki/internal/xocsp/ocsp_test.go new file mode 100644 index 0000000..f0e1482 --- /dev/null +++ b/pki/internal/xocsp/ocsp_test.go @@ -0,0 +1,317 @@ +//nolint:testpackage // These tests need package-private helpers and data structures. +package xocsp + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "math/big" + "testing" + "time" +) + +func testCertificate(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now() + template := &x509.Certificate{SerialNumber: big.NewInt(42), Subject: pkix.Name{CommonName: "xocsp test"}, NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, IsCA: true, BasicConstraintsValid: true} + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return cert, key +} + +func TestRequestRoundTripAndErrors(t *testing.T) { + issuer, _ := testCertificate(t) + leaf, _ := testCertificate(t) + leaf.SerialNumber = big.NewInt(77) + for _, hash := range []crypto.Hash{crypto.SHA1, crypto.SHA256, crypto.SHA384, crypto.SHA512} { + raw, err := CreateRequest(leaf, issuer, &RequestOptions{Hash: hash}) + if err != nil { + t.Fatalf("CreateRequest(%v): %v", hash, err) + } + req, err := ParseRequest(raw) + if err != nil { + t.Fatalf("ParseRequest(%v): %v", hash, err) + } + if req.HashAlgorithm != hash || req.SerialNumber.Cmp(leaf.SerialNumber) != 0 { + t.Fatalf("request mismatch: %#v", req) + } + } + if _, err := CreateRequest(leaf, issuer, &RequestOptions{Hash: crypto.MD5}); err == nil { + t.Fatal("CreateRequest accepted unsupported hash") + } + if _, err := (&Request{HashAlgorithm: crypto.MD5}).Marshal(); err == nil { + t.Fatal("Marshal accepted unsupported hash") + } + for _, raw := range [][]byte{nil, {0x30, 0x00}, {0x30, 0x03, 0x30, 0x01, 0x00}} { + if _, err := ParseRequest(raw); err == nil { + t.Errorf("ParseRequest accepted %x", raw) + } + } +} + +//nolint:revive // This test groups related coverage cases for one API. +func TestResponseRoundTripStatusesAndErrors(t *testing.T) { + issuer, key := testCertificate(t) + leaf, _ := testCertificate(t) + now := time.Now().Truncate(time.Minute).UTC() + for _, status := range []int{Good, Unknown, Revoked} { + template := Response{Status: status, SerialNumber: leaf.SerialNumber, ThisUpdate: now, NextUpdate: now.Add(time.Hour), IssuerHash: crypto.SHA256, Certificate: issuer, RevokedAt: now.Add(-time.Minute), RevocationReason: KeyCompromise} + raw, err := CreateResponse(Success, issuer, issuer, template, key) + if err != nil { + t.Fatalf("CreateResponse(%d): %v", status, err) + } + parsed, err := ParseResponse(raw, issuer) + if err != nil { + t.Fatalf("ParseResponse(%d): %v", status, err) + } + if parsed.Status != status || parsed.SerialNumber.Cmp(leaf.SerialNumber) != 0 || parsed.Certificate == nil { + t.Fatalf("response mismatch: %#v", parsed) + } + if status == Revoked && parsed.RevocationReason != KeyCompromise { + t.Fatalf("reason = %d", parsed.RevocationReason) + } + if _, err := ParseResponseForCert(raw, leaf, issuer); err != nil { + t.Fatalf("ParseResponseForCert: %v", err) + } + if _, err := ParseResponseForCert(raw, nil, nil); err != nil { + t.Fatalf("ParseResponseForCert without issuer: %v", err) + } + } + if _, err := CreateResponse(InternalError, issuer, issuer, Response{}, key); err == nil { + t.Fatal("error status response accepted as basic response") + } + if _, err := ParseResponse(nil, nil); err == nil { + t.Fatal("ParseResponse accepted malformed input") + } + for _, raw := range [][]byte{{0x30, 0x03, 0x0a, 0x01, 0x02}, {0x30, 0x03, 0x0a, 0x01, 0x00}} { + if _, err := ParseResponse(raw, nil); err == nil { + t.Errorf("ParseResponse accepted invalid response %x", raw) + } + } + unknownStatus, err := asn1.Marshal(responseASN1{Status: 99}) + if err != nil { + t.Fatal(err) + } + if _, err := ParseResponse(unknownStatus, nil); err == nil { + t.Fatal("ParseResponse accepted unknown status") + } + if _, err := CreateResponse(Success, issuer, issuer, Response{IssuerHash: crypto.MD5}, key); err == nil { + t.Fatal("CreateResponse accepted unsupported issuer hash") + } + if _, err := CreateResponse(Success, issuer, issuer, Response{SignatureAlgorithm: x509.ECDSAWithSHA512, IssuerHash: crypto.SHA256}, key); err == nil { + t.Fatal("CreateResponse accepted mismatched signing algorithm") + } +} + +//nolint:revive // This test groups related coverage cases for one API. +func TestAlgorithmHelpersAndErrors(t *testing.T) { + _, key := testCertificate(t) + for _, curve := range []elliptic.Curve{elliptic.P224(), elliptic.P256(), elliptic.P384(), elliptic.P521()} { + pub := &ecdsa.PublicKey{Curve: curve, X: big.NewInt(1), Y: big.NewInt(1)} + if _, _, err := signingParamsForPublicKey(pub, 0); err != nil { + t.Errorf("curve %s: %v", curve.Params().Name, err) + } + } + rsaPublic := &rsa.PublicKey{N: big.NewInt(3233), E: 17} + for _, alg := range []x509.SignatureAlgorithm{x509.SHA1WithRSA, x509.SHA256WithRSA, x509.SHA384WithRSA, x509.SHA512WithRSA} { + if _, _, err := signingParamsForPublicKey(rsaPublic, alg); err != nil { + t.Errorf("RSA algorithm %v: %v", alg, err) + } + } + if _, _, err := signingParamsForPublicKey(&key.PublicKey, x509.SHA256WithRSA); err == nil { + t.Fatal("accepted mismatched signature algorithm") + } + if _, _, err := signingParamsForPublicKey(&key.PublicKey, x509.MD2WithRSA); err == nil { + t.Fatal("accepted hashless signature algorithm") + } + if _, _, err := signingParamsForPublicKey(&key.PublicKey, x509.SignatureAlgorithm(999)); err == nil { + t.Fatal("accepted unknown signature algorithm") + } + if _, _, err := signingParamsForPublicKey(struct{}{}, 0); err == nil { + t.Fatal("accepted unsupported public key type") + } + if got := getSignatureAlgorithmFromOID(asn1.ObjectIdentifier{1, 2, 3}); got != x509.UnknownSignatureAlgorithm { + t.Fatalf("unknown signature OID mapped to %v", got) + } + if getHashAlgorithmFromOID(asn1.ObjectIdentifier{1, 2, 3}) != 0 || getOIDFromHashAlgorithm(crypto.MD5) != nil { + t.Fatal("unknown hash mapping unexpectedly succeeded") + } + if got := (*RequestOptions)(nil).hash(); got != crypto.SHA1 { + t.Fatalf("nil options hash = %v", got) + } + if got := (&RequestOptions{}).hash(); got != crypto.SHA1 { + t.Fatalf("zero hash = %v", got) + } + if (ResponseStatus(7)).String() != "unknown OCSP status: 7" { + t.Fatal("unexpected unknown status string") + } + if (ResponseError{Status: TryLater}).Error() != "ocsp: error from server: try later" { + t.Fatal("unexpected ResponseError text") + } + if ParseError("bad").Error() != "bad" { + t.Fatal("unexpected ParseError text") + } +} + +func TestCreateRequestDefaultsAndSignatureCheck(t *testing.T) { + issuer, key := testCertificate(t) + leaf, _ := testCertificate(t) + raw, err := CreateRequest(leaf, issuer, nil) + if err != nil { + t.Fatal(err) + } + req, err := ParseRequest(raw) + if err != nil || req.HashAlgorithm != crypto.SHA1 { + t.Fatalf("default request hash=%v err=%v", req.HashAlgorithm, err) + } + response, err := CreateResponse(Success, issuer, issuer, Response{Status: Good, SerialNumber: leaf.SerialNumber, ThisUpdate: time.Now(), NextUpdate: time.Now().Add(time.Hour)}, key) + if err != nil { + t.Fatal(err) + } + parsed, err := ParseResponse(response, nil) + if err != nil { + t.Fatal(err) + } + if err := parsed.CheckSignatureFrom(issuer); err != nil { + t.Fatal(err) + } + other, _ := testCertificate(t) + if err := parsed.CheckSignatureFrom(other); err == nil { + t.Fatal("CheckSignatureFrom accepted wrong issuer") + } +} + +func encodedFixture(t *testing.T, data responseData) []byte { + t.Helper() + algorithm := pkix.AlgorithmIdentifier{Algorithm: oidSignatureECDSAWithSHA256} + basic, err := asn1.Marshal(basicResponse{TBSResponseData: data, SignatureAlgorithm: algorithm, Signature: asn1.BitString{Bytes: []byte{1}, BitLength: 8}}) + if err != nil { + t.Fatal(err) + } + outer, err := asn1.Marshal(responseASN1{Status: 0, Response: responseBytes{ResponseType: idPKIXOCSPBasic, Response: basic}}) + if err != nil { + t.Fatal(err) + } + return outer +} + +func fixtureData() responseData { + nameDER, _ := asn1.Marshal(pkix.RDNSequence{}) + return responseData{ + RawResponderID: asn1.RawValue{Class: asn1ClassContextSpecific, Tag: asn1TagResponderName, IsCompound: true, Bytes: nameDER}, + ProducedAt: time.Now().UTC(), + Responses: []singleResponse{{CertID: certID{HashAlgorithm: pkix.AlgorithmIdentifier{Algorithm: hashOIDs[crypto.SHA1]}, SerialNumber: big.NewInt(7)}, Good: true, ThisUpdate: time.Now().UTC()}}, + } +} + +func TestParseRequestStructuralErrors(t *testing.T) { + empty, err := asn1.Marshal(ocspRequest{TBSRequest: tbsRequest{}}) + if err != nil { + t.Fatal(err) + } + if _, err := ParseRequest(empty); err == nil { + t.Fatal("accepted request without request list") + } + unknownHash, err := asn1.Marshal(ocspRequest{TBSRequest: tbsRequest{RequestList: []request{{Cert: certID{HashAlgorithm: pkix.AlgorithmIdentifier{Algorithm: asn1.ObjectIdentifier{1, 2, 3}}, SerialNumber: big.NewInt(9)}}}}}) + if err != nil { + t.Fatal(err) + } + if _, err := ParseRequest(unknownHash); err == nil { + t.Fatal("accepted request with unknown hash") + } + valid, err := (&Request{HashAlgorithm: crypto.SHA256, SerialNumber: big.NewInt(8)}).Marshal() + if err != nil { + t.Fatal(err) + } + if _, err := ParseRequest(append(valid, 0)); err == nil { + t.Fatal("accepted trailing request data") + } +} + +//nolint:revive // This test groups related coverage cases for one API. +func TestParseResponseStructuralErrorsAndSelections(t *testing.T) { + data := fixtureData() + if _, err := ParseResponseForCert(encodedFixture(t, data), &x509.Certificate{SerialNumber: big.NewInt(99)}, nil); err == nil { + t.Fatal("accepted missing serial") + } + data.Responses = nil + if _, err := ParseResponse(encodedFixture(t, data), nil); err == nil { + t.Fatal("accepted empty response list") + } + data = fixtureData() + data.Responses = append(data.Responses, data.Responses[0]) + if _, err := ParseResponse(encodedFixture(t, data), nil); err == nil { + t.Fatal("accepted multiple statuses through ParseResponse") + } + if _, err := ParseResponseForCert(encodedFixture(t, data), &x509.Certificate{SerialNumber: big.NewInt(7)}, nil); err != nil { + t.Fatalf("selected matching status: %v", err) + } + + data = fixtureData() + data.RawResponderID = asn1.RawValue{Class: asn1ClassContextSpecific, Tag: 7, IsCompound: true, Bytes: []byte{0x30, 0}} + if _, err := ParseResponse(encodedFixture(t, data), nil); err == nil { + t.Fatal("accepted unknown responder tag") + } + data = fixtureData() + data.RawResponderID = asn1.RawValue{Class: asn1ClassContextSpecific, Tag: asn1TagResponderName, IsCompound: true, Bytes: []byte{0xff}} + if _, err := ParseResponse(encodedFixture(t, data), nil); err == nil { + t.Fatal("accepted invalid responder name") + } + data = fixtureData() + data.RawResponderID = asn1.RawValue{Class: asn1ClassContextSpecific, Tag: asn1TagKeyHash, IsCompound: true, Bytes: []byte{0xff}} + if _, err := ParseResponse(encodedFixture(t, data), nil); err == nil { + t.Fatal("accepted invalid responder key hash") + } + data = fixtureData() + data.Responses[0].SingleExtensions = []pkix.Extension{{Id: asn1.ObjectIdentifier{1, 2, 3}, Critical: true}} + if _, err := ParseResponse(encodedFixture(t, data), nil); err == nil { + t.Fatal("accepted critical single-response extension") + } + data = fixtureData() + data.Responses[0].CertID.HashAlgorithm.Algorithm = asn1.ObjectIdentifier{1, 2, 3} + if _, err := ParseResponse(encodedFixture(t, data), nil); err == nil { + t.Fatal("accepted unknown issuer hash") + } + if _, err := ParseResponse(append(encodedFixture(t, fixtureData()), 0), nil); err == nil { + t.Fatal("accepted trailing response data") + } +} + +func TestResponseStatusStrings(t *testing.T) { + for status, want := range map[ResponseStatus]string{Success: "success", Malformed: "malformed", InternalError: "internal error", TryLater: "try later", SignatureRequired: "signature required", Unauthorized: "unauthorized"} { + if got := status.String(); got != want { + t.Errorf("%d.String()=%q want %q", status, got, want) + } + } +} + +func TestParseResponseKeyHashResponderID(t *testing.T) { + data := fixtureData() + keyHash, err := asn1.Marshal([]byte("responder-key-hash")) + if err != nil { + t.Fatal(err) + } + data.RawResponderID = asn1.RawValue{Class: asn1ClassContextSpecific, Tag: asn1TagKeyHash, IsCompound: true, Bytes: keyHash} + parsed, err := ParseResponse(encodedFixture(t, data), nil) + if err != nil { + t.Fatal(err) + } + if string(parsed.ResponderKeyHash) != "responder-key-hash" { + t.Fatalf("key hash = %q", parsed.ResponderKeyHash) + } +} diff --git a/pki/model_certificate.go b/pki/model_certificate.go index d39f20f..83b0c34 100644 --- a/pki/model_certificate.go +++ b/pki/model_certificate.go @@ -11,15 +11,18 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/asn1" + "errors" "fmt" "os" ) +// Certificate contains an X.509 certificate and its corresponding private key. type Certificate struct { Key crypto.Signer Crt *x509.Certificate } +// IsValidPair reports whether key matches the certificate public key. func (c *Certificate) IsValidPair() bool { if c == nil || c.Key == nil || c.Crt == nil { return false @@ -35,6 +38,7 @@ func (c *Certificate) IsValidPair() bool { return false } +// IsCA reports whether the certificate is a certificate authority. func (c *Certificate) IsCA() bool { if c == nil || c.Crt == nil { return false @@ -42,28 +46,31 @@ func (c *Certificate) IsCA() bool { return c.Crt.IsCA } +// FingerPrint returns the certificate digest using h. func (c *Certificate) FingerPrint(h crypto.Hash) ([]byte, error) { if c == nil || c.Crt == nil { - return nil, fmt.Errorf("no certificate provided") + return nil, errors.New("no certificate provided") } if !h.Available() { - return nil, fmt.Errorf("hash algorithm not defined") + return nil, errors.New("hash algorithm not defined") } w := h.New() + //nolint:revive // hash.Hash.Write is documented to always return a nil error. w.Write(c.Crt.Raw) return w.Sum(nil), nil } +// IssuerKeyHash returns the hash of the certificate public key bits. func (c *Certificate) IssuerKeyHash(h crypto.Hash) ([]byte, error) { if c == nil || c.Crt == nil { - return nil, fmt.Errorf("no certificate provided") + return nil, errors.New("no certificate provided") } if !h.Available() { - return nil, fmt.Errorf("hash algorithm not defined") + return nil, errors.New("hash algorithm not defined") } var info struct { @@ -76,56 +83,62 @@ func (c *Certificate) IssuerKeyHash(h crypto.Hash) ([]byte, error) { } w := h.New() + //nolint:revive // hash.Hash.Write is documented to always return a nil error. w.Write(info.PublicKey.RightAlign()) return w.Sum(nil), nil } +// IssuerNameHash returns the hash of the certificate subject name. func (c *Certificate) IssuerNameHash(h crypto.Hash) ([]byte, error) { if c == nil || c.Crt == nil { - return nil, fmt.Errorf("no certificate provided") + return nil, errors.New("no certificate provided") } if !h.Available() { - return nil, fmt.Errorf("hash algorithm not defined") + return nil, errors.New("hash algorithm not defined") } w := h.New() + //nolint:revive // hash.Hash.Write is documented to always return a nil error. w.Write(c.Crt.RawSubject) return w.Sum(nil), nil } +// SaveKey writes the private key to a file with restrictive permissions. func (c *Certificate) SaveKey(filepath string) error { if c == nil || c.Key == nil { - return fmt.Errorf("no private key provided") + return errors.New("no private key provided") } b, err := MarshalKeyPEM(c.Key) if err != nil { return fmt.Errorf("marshal private key: %w", err) } - err = os.WriteFile(filepath, b, 0600) + err = os.WriteFile(filepath, b, privateFileMode) if err != nil { return fmt.Errorf("save key to '%s': %w", filepath, err) } return nil } +// SaveCert writes the certificate to a PEM file. func (c *Certificate) SaveCert(filepath string) error { if c == nil || c.Crt == nil { - return fmt.Errorf("no certificate provided") + return errors.New("no certificate provided") } b, err := MarshalCrtPEM(*c.Crt) if err != nil { return fmt.Errorf("marshal certificate: %w", err) } - err = os.WriteFile(filepath, b, 0644) + err = os.WriteFile(filepath, b, publicFileMode) if err != nil { return fmt.Errorf("save certificate to '%s': %w", filepath, err) } return nil } +// LoadKey loads a private key from a PEM or DER file. func (c *Certificate) LoadKey(filepath string) error { b, err := os.ReadFile(filepath) if err != nil { @@ -139,6 +152,7 @@ func (c *Certificate) LoadKey(filepath string) error { return err } +// LoadCert loads a certificate from a PEM or DER file. func (c *Certificate) LoadCert(filepath string) error { b, err := os.ReadFile(filepath) if err != nil { diff --git a/pki/model_request.go b/pki/model_request.go index 17a2a00..08fd3b9 100644 --- a/pki/model_request.go +++ b/pki/model_request.go @@ -9,45 +9,50 @@ import ( "bytes" "crypto" "crypto/x509" + "errors" "fmt" "os" ) +// Request contains a certificate signing request and its corresponding private key. type Request struct { Key crypto.Signer Csr *x509.CertificateRequest } +// SaveKey writes the private key to a file with restrictive permissions. func (c *Request) SaveKey(filepath string) error { if c == nil || c.Key == nil { - return fmt.Errorf("no private key provided") + return errors.New("no private key provided") } b, err := MarshalKeyPEM(c.Key) if err != nil { return fmt.Errorf("marshal private key: %w", err) } - err = os.WriteFile(filepath, b, 0600) + err = os.WriteFile(filepath, b, privateFileMode) if err != nil { return fmt.Errorf("save key to '%s': %w", filepath, err) } return nil } +// SaveCert writes the certificate to a PEM file. func (c *Request) SaveCert(filepath string) error { if c == nil || c.Csr == nil { - return fmt.Errorf("no certificate request provided") + return errors.New("no certificate request provided") } b, err := MarshalCsrPEM(*c.Csr) if err != nil { return fmt.Errorf("marshal certificate request: %w", err) } - err = os.WriteFile(filepath, b, 0644) + err = os.WriteFile(filepath, b, publicFileMode) if err != nil { return fmt.Errorf("save certificate request to '%s': %w", filepath, err) } return nil } +// LoadKey loads a private key from a PEM or DER file. func (c *Request) LoadKey(filepath string) error { b, err := os.ReadFile(filepath) if err != nil { @@ -61,6 +66,7 @@ func (c *Request) LoadKey(filepath string) error { return err } +// LoadCert loads a certificate from a PEM or DER file. func (c *Request) LoadCert(filepath string) error { b, err := os.ReadFile(filepath) if err != nil { diff --git a/pki/models_coverage_test.go b/pki/models_coverage_test.go new file mode 100644 index 0000000..c0476d2 --- /dev/null +++ b/pki/models_coverage_test.go @@ -0,0 +1,172 @@ +package pki_test + +import ( + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "os" + "path/filepath" + "testing" + "time" + + "go.osspkg.com/encrypt/pki" +) + +//nolint:revive // This test groups related coverage cases for one API. +func TestCertificateMetadataAndFileModes(t *testing.T) { + ca, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.ECDSAWithSHA256, CommonName: "Coverage Root"}, time.Hour, 20, 0) + if err != nil { + t.Fatal(err) + } + for _, hash := range []crypto.Hash{crypto.SHA256, crypto.SHA384} { + if _, err := ca.FingerPrint(hash); err != nil { + t.Errorf("FingerPrint: %v", err) + } + if _, err := ca.IssuerKeyHash(hash); err != nil { + t.Errorf("IssuerKeyHash: %v", err) + } + if _, err := ca.IssuerNameHash(hash); err != nil { + t.Errorf("IssuerNameHash: %v", err) + } + } + if _, err := ca.FingerPrint(crypto.Hash(0)); err == nil { + t.Fatal("FingerPrint accepted unknown hash") + } + if _, err := ca.IssuerKeyHash(crypto.Hash(0)); err == nil { + t.Fatal("IssuerKeyHash accepted unknown hash") + } + if _, err := ca.IssuerNameHash(crypto.Hash(0)); err == nil { + t.Fatal("IssuerNameHash accepted unknown hash") + } + if (*pki.Certificate)(nil).IsCA() || (*pki.Certificate)(nil).IsValidPair() { + t.Fatal("nil certificate reported valid") + } + if (&pki.Certificate{}).IsCA() || (&pki.Certificate{}).IsValidPair() { + t.Fatal("empty certificate reported valid") + } + if (&pki.Certificate{Crt: ca.Crt}).IsValidPair() { + t.Fatal("certificate without key reported valid") + } + + dir := t.TempDir() + keyPath, crtPath := filepath.Join(dir, "key.pem"), filepath.Join(dir, "cert.pem") + if err := ca.SaveKey(keyPath); err != nil { + t.Fatal(err) + } + if err := ca.SaveCert(crtPath); err != nil { + t.Fatal(err) + } + keyInfo, err := os.Stat(keyPath) + if err != nil { + t.Fatal(err) + } + if keyInfo.Mode().Perm() != 0o600 { + t.Fatalf("private key permissions = %o", keyInfo.Mode().Perm()) + } + crtInfo, err := os.Stat(crtPath) + if err != nil { + t.Fatal(err) + } + if crtInfo.Mode().Perm() != 0o644 { + t.Fatalf("certificate permissions = %o", crtInfo.Mode().Perm()) + } +} + +//nolint:revive // This test groups related coverage cases for one API. +func TestCertificateFileLoadingDERAndInvalidData(t *testing.T) { + ca, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.ECDSAWithSHA256}, time.Hour, 21, 0) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + keyDER, err := pki.MarshalKeyDER(ca.Key) + if err != nil { + t.Fatal(err) + } + crtDER := pki.MarshalCrtDER(*ca.Crt) + keyPath, crtPath := filepath.Join(dir, "key.der"), filepath.Join(dir, "cert.der") + if err := os.WriteFile(keyPath, keyDER, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(crtPath, crtDER, 0o600); err != nil { + t.Fatal(err) + } + var loaded pki.Certificate + if err := loaded.LoadKey(keyPath); err != nil { + t.Fatal(err) + } + if err := loaded.LoadCert(crtPath); err != nil { + t.Fatal(err) + } + badKey, badCert := filepath.Join(dir, "bad-key"), filepath.Join(dir, "bad-cert") + if err := os.WriteFile(badKey, []byte("invalid DER"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(badCert, []byte("invalid DER"), 0o600); err != nil { + t.Fatal(err) + } + if err := loaded.LoadKey(badKey); err == nil { + t.Fatal("LoadKey accepted invalid DER") + } + if err := loaded.LoadCert(badCert); err == nil { + t.Fatal("LoadCert accepted invalid DER") + } +} + +func TestRequestFileErrors(t *testing.T) { + request, err := pki.NewCSR(x509.ECDSAWithSHA256, "load.example.test") + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + keyPath, csrPath := filepath.Join(dir, "bad-key.pem"), filepath.Join(dir, "bad-request.pem") + if err := os.WriteFile(keyPath, []byte("not a PEM key"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(csrPath, []byte("not a PEM request"), 0o600); err != nil { + t.Fatal(err) + } + if err := request.LoadKey(keyPath); err == nil { + t.Fatal("Request.LoadKey accepted invalid PEM") + } + if err := request.LoadCert(csrPath); err == nil { + t.Fatal("Request.LoadCert accepted invalid PEM") + } + missingParent := filepath.Join(dir, "missing", "key.pem") + if err := request.SaveKey(missingParent); err == nil { + t.Fatal("Request.SaveKey accepted missing directory") + } + if err := request.SaveCert(missingParent); err == nil { + t.Fatal("Request.SaveCert accepted missing directory") + } +} + +func TestCertificateNilAndMalformedMetadata(t *testing.T) { + var cert *pki.Certificate + if _, err := cert.FingerPrint(crypto.SHA256); err == nil { + t.Fatal("FingerPrint accepted nil certificate") + } + if _, err := cert.IssuerKeyHash(crypto.SHA256); err == nil { + t.Fatal("IssuerKeyHash accepted nil certificate") + } + if _, err := cert.IssuerNameHash(crypto.SHA256); err == nil { + t.Fatal("IssuerNameHash accepted nil certificate") + } + malformed := &pki.Certificate{Crt: &x509.Certificate{RawSubjectPublicKeyInfo: []byte{0xff}}} + if _, err := malformed.IssuerKeyHash(crypto.SHA256); err == nil { + t.Fatal("IssuerKeyHash accepted malformed SPKI") + } + if (&pki.Certificate{Key: ed25519PrivateKey(t), Crt: &x509.Certificate{}}).IsValidPair() { + t.Fatal("unsupported private key matched certificate") + } +} + +func ed25519PrivateKey(t *testing.T) crypto.Signer { + t.Helper() + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return key +} diff --git a/pki/ocsp.go b/pki/ocsp.go index f7116e8..8782953 100644 --- a/pki/ocsp.go +++ b/pki/ocsp.go @@ -9,6 +9,7 @@ import ( "context" "crypto" "crypto/x509/pkix" + "errors" "fmt" "math/big" "net/http" @@ -19,55 +20,67 @@ import ( "go.osspkg.com/encrypt/pki/internal/xocsp" ) +const maxOCSPRequestBytes = 1 << 20 + +// OCSPStatusResolver resolves the revocation status of an OCSP request. The +// implementation should honor ctx cancellation and return a non-nil response +// when err is nil. type OCSPStatusResolver interface { OCSPStatusResolve(ctx context.Context, r *OCSPRequest) (*OCSPResponse, error) } +// OCSPStatus represents the certificate status reported by OCSP. type OCSPStatus int const ( - OCSPStatusGood OCSPStatus = xocsp.Good + // OCSPStatusGood indicates the certificate is not revoked. + OCSPStatusGood OCSPStatus = xocsp.Good + // OCSPStatusUnknown indicates the responder does not know the certificate status. OCSPStatusUnknown OCSPStatus = xocsp.Unknown + // OCSPStatusRevoked indicates the certificate has been revoked. OCSPStatusRevoked OCSPStatus = xocsp.Revoked ) +// OCSPRevocationReason represents the reason a certificate was revoked. type OCSPRevocationReason int const ( - // OCSPRevocationReasonUnspecified + // OCSPRevocationReasonUnspecified is the default reason when no specific revocation reason applies. // Unspecified (code 0): A general, default reason when a more specific one isn't applicable. OCSPRevocationReasonUnspecified OCSPRevocationReason = 0 - // OCSPRevocationReasonKeyCompromise + // OCSPRevocationReasonKeyCompromise indicates that the certificate private key was compromised. // Key Compromise (code 1): The most critical reason, indicating that the // private key associated with the certificate has been compromised or is suspected of being compromised. OCSPRevocationReasonKeyCompromise OCSPRevocationReason = 1 - // OCSPRevocationReasonCACompromise + // OCSPRevocationReasonCACompromise indicates that the issuing CA was compromised. // CA Compromise (code 2): The certificate authority that issued the certificate has been compromised. OCSPRevocationReasonCACompromise OCSPRevocationReason = 2 - // OCSPRevocationReasonAffiliationChanged + // OCSPRevocationReasonAffiliationChanged indicates that the subject affiliation changed. // Affiliation Changed (code 3): The certificate holder's relationship with the organization has changed, // such as termination of employment. OCSPRevocationReasonAffiliationChanged OCSPRevocationReason = 3 - // OCSPRevocationReasonSuperseded + // OCSPRevocationReasonSuperseded indicates that the certificate was replaced. // Superseded (code 4): The certificate has been replaced by a new one, // often because of a normal lifecycle event like a password change or a legal name change. OCSPRevocationReasonSuperseded OCSPRevocationReason = 4 - // OCSPRevocationReasonCessationOfOperation + // OCSPRevocationReasonCessationOfOperation indicates that the certificate subject ceased operation. // Cessation of Operation (code 5): The system or service for which the certificate was issued is no longer in // operation. OCSPRevocationReasonCessationOfOperation OCSPRevocationReason = 5 - // OCSPRevocationReasonCertificateHold + // OCSPRevocationReasonCertificateHold indicates that the certificate is temporarily suspended. // Certificate Hold (code 6): Used for temporary invalidation, such as when a certificate's status is under review. OCSPRevocationReasonCertificateHold OCSPRevocationReason = 6 ) type ( + // OCSPServer serves OCSP status responses for certificates issued by its CA. OCSPServer struct { CA Certificate Resolver OCSPStatusResolver UpdateInterval time.Duration OnError func(err error) } + // OCSPRequest contains the certificate identifier and extensions in an OCSP request. OCSPRequest struct { HashAlgorithm crypto.Hash IssuerNameHash []byte @@ -76,6 +89,7 @@ type ( Extensions []pkix.Extension } + // OCSPResponse contains the status and revocation details for a certificate. OCSPResponse struct { Status OCSPStatus RevokedAt time.Time @@ -83,9 +97,12 @@ type ( } ) +// HTTPHandler handles an HTTP OCSP request and writes a signed response. It +// reads at most 1 MiB from the request body, returning HTTP 413 for larger +// bodies. Processing and response-writing failures are passed to OnError when +// it is set. Configure CA and Resolver before serving requests; UpdateInterval +// controls the response NextUpdate time. func (v *OCSPServer) HTTPHandler(w http.ResponseWriter, r *http.Request) { - reqStatus := xocsp.Success - template := xocsp.Response{ Status: int(OCSPStatusUnknown), ThisUpdate: time.Now().Truncate(time.Minute).UTC(), @@ -93,76 +110,87 @@ func (v *OCSPServer) HTTPHandler(w http.ResponseWriter, r *http.Request) { Certificate: v.CA.Crt, } - var ( - err error - raw []byte - ) - - if raw, err = ioutils.ReadAll(r.Body); err == nil { - - var req *xocsp.Request - if req, err = xocsp.ParseRequest(raw); err == nil { - - template.SerialNumber = req.SerialNumber - - for _, extension := range req.Extensions { - if extension.Id.Equal(xocsp.OIDNonce) { - template.Extensions = append(template.Extensions, pkix.Extension{ - Id: xocsp.OIDNonce, - Critical: false, - Value: extension.Value, - }) - break - } - } - - var resp *OCSPResponse - if resp, err = v.Resolver.OCSPStatusResolve(r.Context(), &OCSPRequest{ - HashAlgorithm: req.HashAlgorithm, - IssuerNameHash: req.IssuerNameHash, - IssuerKeyHash: req.IssuerKeyHash, - SerialNumber: req.SerialNumber, - Extensions: req.Extensions, - }); err == nil { - - template.Status = int(resp.Status) - - if resp.Status == OCSPStatusRevoked { - template.RevokedAt = resp.RevokedAt - - switch resp.RevocationReason { - case OCSPRevocationReasonKeyCompromise, OCSPRevocationReasonCACompromise, - OCSPRevocationReasonAffiliationChanged, OCSPRevocationReasonSuperseded, - OCSPRevocationReasonCessationOfOperation, OCSPRevocationReasonCertificateHold: - template.RevocationReason = int(resp.RevocationReason) - default: - template.RevocationReason = int(OCSPRevocationReasonUnspecified) - } - } - } + raw, err := ioutils.ReadAll(http.MaxBytesReader(w, r.Body, maxOCSPRequestBytes)) + if err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) + return } + } else { + err = v.resolveRequest(r.Context(), raw, &template) } + reqStatus := xocsp.Success if err != nil { - if v.OnError != nil { - v.OnError(fmt.Errorf("ocsp: request processing: %w", err)) - } + v.reportError(fmt.Errorf("ocsp: request processing: %w", err)) reqStatus = xocsp.InternalError } resp, err := xocsp.CreateResponse(reqStatus, v.CA.Crt, v.CA.Crt, template, v.CA.Key) if err != nil { - if v.OnError != nil { - v.OnError(fmt.Errorf("ocsp: create response: %w", err)) - } + v.reportError(fmt.Errorf("ocsp: create response: %w", err)) http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/ocsp-response") if _, err = w.Write(resp); err != nil { - if v.OnError != nil { - v.OnError(fmt.Errorf("ocsp: write response: %w", err)) + v.reportError(fmt.Errorf("ocsp: write response: %w", err)) + } +} + +func (v *OCSPServer) resolveRequest(ctx context.Context, raw []byte, template *xocsp.Response) error { + req, err := xocsp.ParseRequest(raw) + if err != nil { + return err + } + template.SerialNumber = req.SerialNumber + + for _, extension := range req.Extensions { + if extension.Id.Equal(xocsp.OIDNonce) { + template.Extensions = append(template.Extensions, pkix.Extension{ + Id: xocsp.OIDNonce, + Critical: false, + Value: extension.Value, + }) + break } } + + resp, err := v.Resolver.OCSPStatusResolve(ctx, &OCSPRequest{ + HashAlgorithm: req.HashAlgorithm, + IssuerNameHash: req.IssuerNameHash, + IssuerKeyHash: req.IssuerKeyHash, + SerialNumber: req.SerialNumber, + Extensions: req.Extensions, + }) + if err != nil { + return err + } + if resp == nil { + return errors.New("OCSP resolver returned nil response") + } + + template.Status = int(resp.Status) + if resp.Status != OCSPStatusRevoked { + return nil + } + template.RevokedAt = resp.RevokedAt + + switch resp.RevocationReason { + case OCSPRevocationReasonKeyCompromise, OCSPRevocationReasonCACompromise, + OCSPRevocationReasonAffiliationChanged, OCSPRevocationReasonSuperseded, + OCSPRevocationReasonCessationOfOperation, OCSPRevocationReasonCertificateHold: + template.RevocationReason = int(resp.RevocationReason) + default: + template.RevocationReason = int(OCSPRevocationReasonUnspecified) + } + return nil +} + +func (v *OCSPServer) reportError(err error) { + if v.OnError != nil { + v.OnError(err) + } } diff --git a/pki/ocsp_coverage_test.go b/pki/ocsp_coverage_test.go new file mode 100644 index 0000000..bb91fae --- /dev/null +++ b/pki/ocsp_coverage_test.go @@ -0,0 +1,159 @@ +package pki_test + +import ( + "bytes" + "context" + "crypto" + "crypto/x509" + "errors" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "go.osspkg.com/encrypt/pki" + "go.osspkg.com/encrypt/pki/internal/xocsp" +) + +type coverageResolver struct { + response *pki.OCSPResponse + err error + called int +} + +func (r *coverageResolver) OCSPStatusResolve(context.Context, *pki.OCSPRequest) (*pki.OCSPResponse, error) { + r.called++ + return r.response, r.err +} + +//nolint:revive // This test groups related coverage cases for one API. +func TestOCSPHandlerSuccessAndResolverErrors(t *testing.T) { + ca, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.ECDSAWithSHA256}, 24*time.Hour, 41, 0) + if err != nil { + t.Fatal(err) + } + leaf, err := pki.NewCRT(pki.Config{}, *ca, time.Hour, 42, "ocsp.example.test") + if err != nil { + t.Fatal(err) + } + requestDER, err := xocsp.CreateRequest(leaf.Crt, ca.Crt, nil) + if err != nil { + t.Fatal(err) + } + + t.Run("good", func(t *testing.T) { + resolver := &coverageResolver{response: &pki.OCSPResponse{Status: pki.OCSPStatusGood}} + server := &pki.OCSPServer{CA: *ca, Resolver: resolver, UpdateInterval: time.Hour} + recorder := httptest.NewRecorder() + server.HTTPHandler(recorder, httptest.NewRequest(http.MethodPost, "/ocsp", bytes.NewReader(requestDER))) + if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "application/ocsp-response" { + t.Fatalf("status=%d headers=%v body=%x", recorder.Code, recorder.Header(), recorder.Body.Bytes()) + } + parsed, err := xocsp.ParseResponse(recorder.Body.Bytes(), ca.Crt) + if err != nil || parsed.Status != xocsp.Good { + t.Fatalf("parsed=%#v err=%v", parsed, err) + } + if resolver.called != 1 { + t.Fatalf("resolver calls=%d", resolver.called) + } + }) + + t.Run("revoked", func(t *testing.T) { + resolver := &coverageResolver{response: &pki.OCSPResponse{Status: pki.OCSPStatusRevoked, RevokedAt: time.Now().Add(-time.Minute), RevocationReason: pki.OCSPRevocationReasonKeyCompromise}} + server := &pki.OCSPServer{CA: *ca, Resolver: resolver, UpdateInterval: time.Hour} + recorder := httptest.NewRecorder() + server.HTTPHandler(recorder, httptest.NewRequest(http.MethodPost, "/ocsp", bytes.NewReader(requestDER))) + parsed, err := xocsp.ParseResponse(recorder.Body.Bytes(), ca.Crt) + if err != nil || parsed.Status != xocsp.Revoked || parsed.RevocationReason != xocsp.KeyCompromise { + t.Fatalf("parsed=%#v err=%v", parsed, err) + } + }) + + for _, tc := range []struct { + name string + body []byte + response *pki.OCSPResponse + resolverErr error + status int + }{ + {name: "malformed", body: []byte("bad"), response: &pki.OCSPResponse{Status: pki.OCSPStatusUnknown}, status: 500}, + {name: "resolver error", body: requestDER, response: &pki.OCSPResponse{}, resolverErr: errors.New("resolver failed"), status: 200}, + {name: "nil resolver response", body: requestDER, status: 200}, + } { + t.Run(tc.name, func(t *testing.T) { + resolver := &coverageResolver{response: tc.response, err: tc.resolverErr} + var reported error + server := &pki.OCSPServer{CA: *ca, Resolver: resolver, UpdateInterval: time.Hour, OnError: func(err error) { reported = err }} + recorder := httptest.NewRecorder() + server.HTTPHandler(recorder, httptest.NewRequest(http.MethodPost, "/ocsp", bytes.NewReader(tc.body))) + if recorder.Code != tc.status { + t.Fatalf("status=%d want=%d", recorder.Code, tc.status) + } + if reported == nil { + t.Fatal("OnError was not called") + } + if tc.status == 200 { + _, err := xocsp.ParseResponse(recorder.Body.Bytes(), ca.Crt) + var responseErr xocsp.ResponseError + if !errors.As(err, &responseErr) || responseErr.Status != xocsp.InternalError { + t.Fatalf("response error = %v, want internal error", err) + } + } + }) + } +} + +func TestOCSPResolverReceivesRequestAndUnknownReasonDefaults(t *testing.T) { + ca, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.ECDSAWithSHA256}, 24*time.Hour, 51, 0) + if err != nil { + t.Fatal(err) + } + leaf, err := pki.NewCRT(pki.Config{}, *ca, time.Hour, 52, "reason.example.test") + if err != nil { + t.Fatal(err) + } + raw, err := xocsp.CreateRequest(leaf.Crt, ca.Crt, &xocsp.RequestOptions{Hash: crypto.SHA256}) + if err != nil { + t.Fatal(err) + } + resolver := &coverageResolver{response: &pki.OCSPResponse{Status: pki.OCSPStatusRevoked, RevokedAt: time.Now(), RevocationReason: pki.OCSPRevocationReason(99)}} + server := &pki.OCSPServer{CA: *ca, Resolver: resolver, UpdateInterval: time.Hour} + recorder := httptest.NewRecorder() + server.HTTPHandler(recorder, httptest.NewRequest(http.MethodPost, "/ocsp", bytes.NewReader(raw))) + parsed, err := xocsp.ParseResponse(recorder.Body.Bytes(), ca.Crt) + if err != nil { + t.Fatal(err) + } + if parsed.IssuerHash != crypto.SHA1 || parsed.RevocationReason != xocsp.Unspecified || parsed.SerialNumber.Cmp(big.NewInt(52)) != 0 { + t.Fatalf("unexpected response: %#v", parsed) + } +} + +type brokenResponseWriter struct{ header http.Header } + +func (w *brokenResponseWriter) Header() http.Header { return w.header } +func (*brokenResponseWriter) Write([]byte) (int, error) { return 0, errors.New("client disconnected") } +func (*brokenResponseWriter) WriteHeader(int) {} + +func TestOCSPHandlerReportsResponseWriteFailure(t *testing.T) { + ca, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.ECDSAWithSHA256}, time.Hour, 80, 0) + if err != nil { + t.Fatal(err) + } + leaf, err := pki.NewCRT(pki.Config{}, *ca, time.Minute, 81, "write.example.test") + if err != nil { + t.Fatal(err) + } + request, err := xocsp.CreateRequest(leaf.Crt, ca.Crt, nil) + if err != nil { + t.Fatal(err) + } + var reported error + server := &pki.OCSPServer{CA: *ca, Resolver: &coverageResolver{response: &pki.OCSPResponse{Status: pki.OCSPStatusGood}}, OnError: func(err error) { reported = err }} + server.HTTPHandler(&brokenResponseWriter{header: make(http.Header)}, httptest.NewRequest(http.MethodPost, "/ocsp", bytes.NewReader(request))) + if reported == nil || !strings.Contains(reported.Error(), "write response") { + t.Fatalf("OnError = %v", reported) + } +} diff --git a/pki/ocsp_test.go b/pki/ocsp_test.go new file mode 100644 index 0000000..33b4bad --- /dev/null +++ b/pki/ocsp_test.go @@ -0,0 +1,36 @@ +package pki_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "go.osspkg.com/encrypt/pki" +) + +type rejectingOCSPResolver struct { + called bool +} + +func (r *rejectingOCSPResolver) OCSPStatusResolve(context.Context, *pki.OCSPRequest) (*pki.OCSPResponse, error) { + r.called = true + return &pki.OCSPResponse{}, nil +} + +func TestOCSPHandlerRejectsOversizedRequest(t *testing.T) { + resolver := &rejectingOCSPResolver{} + server := &pki.OCSPServer{Resolver: resolver} + request := httptest.NewRequest(http.MethodPost, "/ocsp", bytes.NewReader(make([]byte, (1<<20)+1))) + response := httptest.NewRecorder() + + server.HTTPHandler(response, request) + + if resolver.called { + t.Fatal("resolver called for oversized request") + } + if response.Code != 413 { + t.Fatalf("want status 413 for oversized request, got %d", response.Code) + } +} diff --git a/pki/security_regression_test.go b/pki/security_regression_test.go new file mode 100644 index 0000000..1d322b1 --- /dev/null +++ b/pki/security_regression_test.go @@ -0,0 +1,31 @@ +package pki_test + +import ( + "crypto/x509" + "testing" + "time" + + "go.osspkg.com/encrypt/pki" +) + +func TestSignCSRRejectsInvalidSignature(t *testing.T) { + root, err := pki.NewCA(pki.Config{SignatureAlgorithm: x509.ECDSAWithSHA256}, 24*time.Hour, 1, 0) + if err != nil { + t.Fatal(err) + } + request, err := pki.NewCSR(x509.ECDSAWithSHA256, "example.test") + if err != nil { + t.Fatal(err) + } + request.Csr.RawTBSCertificateRequest[0] ^= 1 + + if _, err := pki.SignCSR(pki.Config{}, *root, *request.Csr, time.Hour, 2); err == nil { + t.Fatal("SignCSR accepted a CSR with an invalid signature") + } +} + +func TestNewCSRRejectsMalformedIPWithPort(t *testing.T) { + if _, err := pki.NewCSR(x509.ECDSAWithSHA256, "not-an-ip:443"); err == nil { + t.Fatal("NewCSR accepted an invalid IP address") + } +} diff --git a/pki/utils.go b/pki/utils.go index 9a175d2..7a22959 100644 --- a/pki/utils.go +++ b/pki/utils.go @@ -6,6 +6,7 @@ package pki import ( + "errors" "fmt" "net" "strings" @@ -13,7 +14,7 @@ import ( func splitDomains(commonNames []string) ([]net.IP, []string, error) { if len(commonNames) == 0 { - return nil, nil, fmt.Errorf("domains is empty") + return nil, nil, errors.New("domains is empty") } ips := make([]net.IP, 0, len(commonNames)) @@ -21,7 +22,11 @@ func splitDomains(commonNames []string) ([]net.IP, []string, error) { for _, commonName := range stringsPrepare(commonNames) { if ip, _, err := net.SplitHostPort(commonName); err == nil { - ips = append(ips, net.ParseIP(ip)) + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return nil, nil, fmt.Errorf("invalid IP address %q", ip) + } + ips = append(ips, parsedIP) continue } @@ -31,7 +36,8 @@ func splitDomains(commonNames []string) ([]net.IP, []string, error) { return ips, domains, nil } -func stringsPrepare(list []string) (out []string) { +func stringsPrepare(list []string) []string { + out := make([]string, 0, len(list)) for _, s := range list { s = strings.TrimSpace(s) if len(s) == 0 { @@ -39,5 +45,5 @@ func stringsPrepare(list []string) (out []string) { } out = append(out, strings.ToLower(s)) } - return + return out } diff --git a/skills/go-encrypt-usage/SKILL.md b/skills/go-encrypt-usage/SKILL.md new file mode 100644 index 0000000..5bda30e --- /dev/null +++ b/skills/go-encrypt-usage/SKILL.md @@ -0,0 +1,41 @@ +--- +name: go-encrypt-usage +description: Use go.osspkg.com/encrypt from Go applications. Select the right AES-GCM, hash, OpenPGP, or PKI API and follow the library's input, key, nonce, and certificate contracts. +--- + +# go-encrypt usage + +Use this skill when implementing code that imports `go.osspkg.com/encrypt` or when explaining how to use one of its packages. + +## Workflow + +1. Check the project's `go.mod` for the library version and Go version. +2. Identify the package needed and read its matching reference below. Read `pki.md` for certificate or OCSP work. +3. Follow the package contract in the reference; do not infer behavior from a similarly named API in another library. +4. Use a focused example as a starting point, then adapt names, key storage, error handling, and trust configuration to the application. +5. For code changes, run the relevant package tests and then the repository's documented checks. + +## Package selection + +- `aesgcm`: authenticated encryption for byte slices with AES-256-GCM. +- `hash`: adapt a Go `hash.Hash` to byte, string, reader, and value inputs. +- `pgp`: create OpenPGP keys and produce cleartext signatures. +- `pki`: create and persist X.509 certificates, CSRs, CRLs, and serve OCSP responses. + +## Security and compatibility contracts + +- Keep the entire AES-GCM value returned by `Encrypt`; it contains both the random nonce and authenticated ciphertext. Do not reuse or strip its nonce prefix. +- Create the hash implementation explicitly, such as `sha256.New()`. `WriteAny` hashes Go-formatted values; it is not a canonical encoding for durable identifiers or cross-language protocols. Prefer a specified byte encoding for those uses. +- OpenPGP keys are armored byte slices. `NewCert` may replace unsupported or weak requested hashes with SHA-256. Do not describe that fallback as the requested algorithm being used. +- Keep PKI private keys private. The library's `SaveKey` writes PKCS#8 PEM with restrictive permissions; certificate PEM is public data. Protect backups and any alternate key storage too. +- Treat OCSP requests and responses as network input/output. The HTTP handler enforces a 1 MiB request-body limit; resolvers should honor context cancellation and return a valid response or an error. +- Never claim a certificate is trusted merely because it was generated or parsed. Applications still need an appropriate trust store, hostname verification, validity checks, and revocation policy. + +## References and examples + +- [AES-GCM reference](references/aesgcm.md) · [runnable example](examples/aesgcm/main.go) +- [Hash reference](references/hash.md) · [runnable example](examples/hash/main.go) +- [OpenPGP reference](references/pgp.md) · [runnable example](examples/pgp/main.go) +- [PKI and OCSP reference](references/pki.md) · [runnable example](examples/pki/main.go) +- [Project README](../../README.md) +- [Go package documentation](https://pkg.go.dev/go.osspkg.com/encrypt) diff --git a/skills/go-encrypt-usage/examples/aesgcm/main.go b/skills/go-encrypt-usage/examples/aesgcm/main.go new file mode 100644 index 0000000..3ae4c77 --- /dev/null +++ b/skills/go-encrypt-usage/examples/aesgcm/main.go @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +// Package main demonstrates AES-GCM encryption and decryption. +package main + +import ( + "crypto/rand" + "fmt" + + "go.osspkg.com/encrypt/aesgcm" +) + +const keySize = 32 // AES-256 key size in bytes. + +func main() { + key := make([]byte, keySize) + if _, err := rand.Read(key); err != nil { + panic(err) + } + + codec, err := aesgcm.New(key) + if err != nil { + panic(err) + } + + sealed, err := codec.Encrypt([]byte("secret message")) + if err != nil { + panic(err) + } + plain, err := codec.Decrypt(sealed) + if err != nil { + panic(err) + } + fmt.Printf("decrypted: %s\n", plain) +} diff --git a/skills/go-encrypt-usage/examples/hash/main.go b/skills/go-encrypt-usage/examples/hash/main.go new file mode 100644 index 0000000..b17e6a0 --- /dev/null +++ b/skills/go-encrypt-usage/examples/hash/main.go @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +// Package main demonstrates hashing a string with SHA-256. +package main + +import ( + "crypto/sha256" + "fmt" + + "go.osspkg.com/encrypt/hash" +) + +func main() { + digest := hash.Adapter{H: sha256.New()} + if err := digest.WriteString("payload"); err != nil { + panic(err) + } + fmt.Println(digest.ResultHex()) +} diff --git a/skills/go-encrypt-usage/examples/pgp/main.go b/skills/go-encrypt-usage/examples/pgp/main.go new file mode 100644 index 0000000..a4cc7f1 --- /dev/null +++ b/skills/go-encrypt-usage/examples/pgp/main.go @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +// Package main demonstrates generating a key and signing a message with OpenPGP. +package main + +import ( + "bytes" + "crypto" + "fmt" + "strings" + + "go.osspkg.com/encrypt/pgp" +) + +const rsaKeySize = 3072 + +func main() { + cert, err := pgp.NewCert(pgp.Config{ + Name: "Example Service", + Email: "service@example.test", + }, crypto.SHA256, rsaKeySize) + if err != nil { + panic(err) + } + + signer := pgp.New() + if err := signer.SetKey(cert.Private, ""); err != nil { + panic(err) + } + + var signed bytes.Buffer + if err := signer.Sign(strings.NewReader("release metadata\n"), &signed); err != nil { + panic(err) + } + fmt.Printf("created cleartext signature (%d bytes)\n", signed.Len()) +} diff --git a/skills/go-encrypt-usage/examples/pki/main.go b/skills/go-encrypt-usage/examples/pki/main.go new file mode 100644 index 0000000..13ace5c --- /dev/null +++ b/skills/go-encrypt-usage/examples/pki/main.go @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024-2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +// Package main demonstrates creating a root CA and issuing a leaf certificate. +package main + +import ( + "crypto/x509" + "fmt" + "time" + + "go.osspkg.com/encrypt/pki" +) + +const ( + rootSerialNumber = 1 + leafSerialNumber = 2 + caPathLength = 0 +) + +func main() { + root, err := pki.NewCA(pki.Config{ + SignatureAlgorithm: x509.ECDSAWithSHA256, + CommonName: "Example Root CA", + }, 10*365*24*time.Hour, rootSerialNumber, caPathLength) + if err != nil { + panic(err) + } + + leaf, err := pki.NewCRT(pki.Config{ + SignatureAlgorithm: x509.ECDSAWithSHA256, + CommonName: "service.example.test", + }, *root, 90*24*time.Hour, leafSerialNumber, "service.example.test") + if err != nil { + panic(err) + } + fmt.Printf("issued certificate for %s\n", leaf.Crt.Subject.CommonName) +} diff --git a/skills/go-encrypt-usage/references/aesgcm.md b/skills/go-encrypt-usage/references/aesgcm.md new file mode 100644 index 0000000..74c82f8 --- /dev/null +++ b/skills/go-encrypt-usage/references/aesgcm.md @@ -0,0 +1,30 @@ +# AES-GCM + +Package: [`go.osspkg.com/encrypt/aesgcm`](https://pkg.go.dev/go.osspkg.com/encrypt/aesgcm) + +`aesgcm.New` accepts exactly a 32-byte AES-256 key and copies it into a `Codec`. Keep the key secret for the lifetime of the codec. `Encrypt` generates a fresh cryptographically random nonce for each call and prefixes it to the authenticated ciphertext. Persist or transmit the complete returned slice. `Decrypt` expects exactly that combined representation and reports an error for truncated or modified data. + +```go +key := make([]byte, 32) +if _, err := rand.Read(key); err != nil { + return err +} +codec, err := aesgcm.New(key) +if err != nil { + return err +} + +sealed, err := codec.Encrypt([]byte("message")) +if err != nil { + return err +} +plain, err := codec.Decrypt(sealed) +if err != nil { + return err +} +_ = plain +``` + +Use an established key-management mechanism to provision and rotate keys. The example generates a key only to demonstrate the API; production services should not generate an unrelated new key on each process start if they need to decrypt previously stored data. + +Run the example with `go run ./skills/go-encrypt-usage/examples/aesgcm` from the module root. diff --git a/skills/go-encrypt-usage/references/hash.md b/skills/go-encrypt-usage/references/hash.md new file mode 100644 index 0000000..1f89f39 --- /dev/null +++ b/skills/go-encrypt-usage/references/hash.md @@ -0,0 +1,17 @@ +# Hash adapters + +Package: [`go.osspkg.com/encrypt/hash`](https://pkg.go.dev/go.osspkg.com/encrypt/hash) + +Initialize `hash.Adapter.H` with the desired implementation before calling methods. The adapter supports writing bytes, strings, readers, or values, and returning the digest as bytes, hexadecimal, or Base64. Call `Reset` before reusing an adapter for a separate digest. + +For stable digests, define the algorithm and byte representation as part of the protocol. `WriteAny` accepts supported Go values but formats them using Go representation; map formatting, type changes, and application-version changes make it unsuitable as a canonical cross-process or cross-language serialization format. Encode values explicitly (for example, with a documented schema) and write the resulting bytes instead. + +```go +sum := hash.Adapter{H: sha256.New()} +if err := sum.WriteString("payload"); err != nil { + return err +} +fmt.Println(sum.ResultHex()) +``` + +Run the example with `go run ./skills/go-encrypt-usage/examples/hash` from the module root. diff --git a/skills/go-encrypt-usage/references/pgp.md b/skills/go-encrypt-usage/references/pgp.md new file mode 100644 index 0000000..eea0de9 --- /dev/null +++ b/skills/go-encrypt-usage/references/pgp.md @@ -0,0 +1,30 @@ +# OpenPGP + +Package: [`go.osspkg.com/encrypt/pgp`](https://pkg.go.dev/go.osspkg.com/encrypt/pgp) + +`pgp.NewCert` creates armored public and private key data from a `pgp.Config`, hash, and RSA key size. The library substitutes SHA-256 when the requested key-generation hash is unsupported or too weak. Key generation is intentionally CPU intensive; generate keys as a provisioning operation and store the private armor in a secret store rather than generating a new identity for every request. + +`pgp.New` returns a `Signer`, whose defaults are SHA-512 and 4096-bit RSA for generation/signing. Load private armored key bytes with `SetKey`, then use `Sign` to write an OpenPGP cleartext signature to an `io.Writer`. Pass the passphrase only when the private key is encrypted. Cleartext signatures include the signed text, so they are not detached signatures. + +```go +cert, err := pgp.NewCert(pgp.Config{ + Name: "Example Service", + Email: "service@example.test", +}, crypto.SHA256, 3072) +if err != nil { + return err +} + +signer := pgp.New() +if err := signer.SetKey(cert.Private, ""); err != nil { + return err +} +var signed bytes.Buffer +if err := signer.Sign(strings.NewReader("release metadata\n"), &signed); err != nil { + return err +} +``` + +Validate signatures and signer identity at the receiving side using the OpenPGP implementation and trust policy appropriate to the application. Do not treat parsing a public key as proof that the key belongs to a trusted identity. + +Run the example with `go run ./skills/go-encrypt-usage/examples/pgp` from the module root. diff --git a/skills/go-encrypt-usage/references/pki.md b/skills/go-encrypt-usage/references/pki.md new file mode 100644 index 0000000..7890a7b --- /dev/null +++ b/skills/go-encrypt-usage/references/pki.md @@ -0,0 +1,45 @@ +# X.509 PKI and OCSP + +Package: [`go.osspkg.com/encrypt/pki`](https://pkg.go.dev/go.osspkg.com/encrypt/pki) + +## Creating certificates + +`pki.NewCA` creates a CA key/certificate pair. `intermediateCount` configures the permitted CA path length; use the smallest value consistent with the intended hierarchy. `pki.NewCRT` creates a leaf certificate signed by a CA. Set a short, deliberate validity interval and unique positive serial numbers. Validate SANs, key usages, validity, and the trust chain when consuming certificates. + +```go +root, err := pki.NewCA(pki.Config{ + SignatureAlgorithm: x509.ECDSAWithSHA256, + CommonName: "Example Root CA", +}, 10*365*24*time.Hour, 1, 0) +if err != nil { + return err +} + +leaf, err := pki.NewCRT(pki.Config{ + SignatureAlgorithm: x509.ECDSAWithSHA256, + CommonName: "service.example.test", +}, *root, 90*24*time.Hour, 2, "service.example.test") +if err != nil { + return err +} +_ = leaf +``` + +`Certificate.SaveKey` writes PKCS#8 PEM with restrictive file permissions; `SaveCert` writes the public certificate PEM. Check and propagate persistence errors. Protect private-key copies, backups, and directory permissions. `NewCSR` and `SignCSR` support a flow where a requester creates a CSR and a CA signs it; verify the CSR signature and apply issuance policy before signing. + +## OCSP HTTP handler + +`OCSPServer` is configured with its CA and a resolver, then exposes `HTTPHandler()`. Implement the resolver using request context so canceled clients do not leave work running. Return an OCSP response for the request or an error; configure `OnError` for operational reporting. The handler caps request bodies at 1 MiB and rejects oversized requests. + +```go +server := &pki.OCSPServer{ + CA: ca, + Resolver: resolver, + OnError: func(err error) { logger.Error("OCSP request failed", "err", err) }, +} +mux.HandleFunc("/ocsp", server.HTTPHandler) +``` + +See the package documentation for the exact resolver and OCSP response contracts. Expose the endpoint only with the transport and monitoring controls required by the deployment. + +Run the certificate example with `go run ./skills/go-encrypt-usage/examples/pki` from the module root.