diff --git a/Makefile b/Makefile index 0f7eaa8..90c61b9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY := help test-min test-all build-linux build-windows build-darwin build-all +.PHONY := help test-min test-all build-linux build-windows build-darwin build-tools build-all # Default minimal test packages that run safely in sandboxes PKG_MIN := ./fakeca ./ietf-cms/timestamp @@ -8,14 +8,29 @@ GODEBUG ?= x509usefallbackroots=1 BUILD_DIR ?= build GIT_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null) LDFLAGS ?= -X main.versionString=$(GIT_VERSION) +WINDOWS_CC_AMD64 ?= x86_64-w64-mingw32-gcc +WINDOWS_CC_386 ?= i686-w64-mingw32-gcc +DARWIN_CC_AMD64 ?= o64-clang +DARWIN_CC_ARM64 ?= oa64-clang + +define require-tool + @command -v $(1) >/dev/null 2>&1 || { \ + echo "Missing required tool: $(1)"; \ + echo "$(2)"; \ + exit 1; \ + } +endef help: @echo "Targets:" @echo " test-min - Run a minimal, sandbox-safe subset of tests" @echo " test-all - Run all tests (may require macOS keychain access)" + @echo " build-tools - Build helper tools such as git-x509-cert" @echo "" @echo "Environment:" @echo " GODEBUG - Defaults to 'x509usefallbackroots=1' to avoid system truststore access issues" + @echo " WINDOWS_CC_AMD64 / WINDOWS_CC_386 - Windows cgo cross-compilers" + @echo " DARWIN_CC_AMD64 / DARWIN_CC_ARM64 - macOS cgo cross-compilers" test-min: @echo "[test-min] Running: $(PKG_MIN)" @@ -32,16 +47,23 @@ build-linux: CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -o $(BUILD_DIR)/linux/386/smimesign -ldflags "$(LDFLAGS)" . build-windows: + $(call require-tool,$(WINDOWS_CC_AMD64),Install a MinGW-w64 cross-compiler or override WINDOWS_CC_AMD64.) + $(call require-tool,$(WINDOWS_CC_386),Install a MinGW-w64 i686 cross-compiler or override WINDOWS_CC_386.) @echo "[build-windows] GOOS=windows GOARCH=amd64" - CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/windows/amd64/smimesign.exe -ldflags "$(LDFLAGS)" . + CC=$(WINDOWS_CC_AMD64) CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/windows/amd64/smimesign.exe -ldflags "$(LDFLAGS)" . @echo "[build-windows] GOOS=windows GOARCH=386" - CGO_ENABLED=1 GOOS=windows GOARCH=386 go build -o $(BUILD_DIR)/windows/386/smimesign.exe -ldflags "$(LDFLAGS)" . + CC=$(WINDOWS_CC_386) CGO_ENABLED=1 GOOS=windows GOARCH=386 go build -o $(BUILD_DIR)/windows/386/smimesign.exe -ldflags "$(LDFLAGS)" . build-darwin: + $(call require-tool,$(DARWIN_CC_AMD64),Install osxcross / an Apple-targeting SDK toolchain or override DARWIN_CC_AMD64.) + $(call require-tool,$(DARWIN_CC_ARM64),Install osxcross / an Apple-targeting SDK toolchain or override DARWIN_CC_ARM64.) @echo "[build-darwin] GOOS=darwin GOARCH=amd64" - CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -o $(BUILD_DIR)/darwin/amd64/smimesign -ldflags "$(LDFLAGS)" . + CC=$(DARWIN_CC_AMD64) CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -o $(BUILD_DIR)/darwin/amd64/smimesign -ldflags "$(LDFLAGS)" . @echo "[build-darwin] GOOS=darwin GOARCH=arm64" - CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/darwin/arm64/smimesign -ldflags "$(LDFLAGS)" . + CC=$(DARWIN_CC_ARM64) CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/darwin/arm64/smimesign -ldflags "$(LDFLAGS)" . -build-all: build-linux build-windows build-darwin +build-tools: + @echo "[build-tools] Building git-x509-cert" + go build -o $(BUILD_DIR)/tools/git-x509-cert ./cmd/git-x509-cert +build-all: build-linux build-windows build-darwin build-tools diff --git a/README.md b/README.md index 9c0090d..b16e434 100644 --- a/README.md +++ b/README.md @@ -1,307 +1,502 @@ -# smimesign (S/MIME Sign) +# smimesign -![PkgGoDev](https://pkg.go.dev/badge/github.com/droren/smimesign?utm_source=godoc) +`smimesign` is an S/MIME / X.509 signing tool for Git commits and tags. -This repository is a fork of [github/smimesign](https://github.com/github/smimesign) -maintained at [droren/smimesign](https://github.com/droren/smimesign). It adds -Linux support that is missing from the original project. +This fork is maintained at `droren/smimesign` and adds Linux support beyond the +original `github/smimesign` project, including PKCS#11 smart-card signing. -Added simple update to code to cover : https://github.com/github/smimesign/pull/114 , i.e. build error on Windows for smime using go version > 1.17 +The current implementation supports: -Smimesign is an S/MIME signing utility for macOS, Windows, and Linux that is compatible with Git. This allows developers to sign their Git commits and tags using X.509 certificates issued by public certificate authorities or their organization's internal certificate authority. Smimesign uses keys and certificates already stored in the _macOS Keychain_ or the _Windows Certificate Store_. If a certificate isn't found, or when running on Linux, a PKCS#12 file can be loaded via the `SMIMESIGN_P12` environment variable. +- Windows certificate store signing +- macOS keychain signing +- Linux PKCS#12 signing +- Linux PKCS#11 smart-card signing +- Git X.509 signing with `gpg.format=x509` +- extraction of embedded signing certificates from Git commits -This project is pre-1.0, meaning that APIs and functionality may change without warning. +## What To Use This For -This package also contains reusable libraries in nested packages: +Use `smimesign` when your organization issues X.509 certificates for user +identity and you want Git commits and tags signed with those certificates +instead of OpenPGP keys. -- [`github.com/droren/smimesign/certstore`](./certstore) -- [`github.com/droren/smimesign/fakeca`](./fakeca) -- [`github.com/droren/smimesign/ietf-cms`](./ietf-cms) +Typical setups: -## Repository layout +- Windows workstation with a user certificate in `CurrentUser\My` +- Linux workstation with a YubiKey or CAC exposed through PKCS#11 +- Linux or macOS workstation using a PKCS#12 file -The root directory contains the command line application. Important files and -folders include: +## Repository Layout -- `main.go` – entry point and flag parsing for the CLI -- `command_sign.go` / `command_verify.go` – signing and verification commands -- `list_keys_command.go` – shows available certificates -- `command_list_smartcard_keys.go` – shows available smartcard certificates -- `certstore/` – cross‑platform access to the system certificate stores -- `ietf-cms/` – implementation of the Cryptographic Message Syntax used for - signatures -- `fakeca/` – helpers for generating test certificates -- `windows-installer/` – resources for building the Windows installer +- `main.go` and `command_*.go`: main CLI +- `certstore/`: OS-specific certificate store access +- `ietf-cms/`: CMS / PKCS#7 signing and verification +- `cmd/git-x509-cert/`: helper for extracting and displaying commit signing certs +- `Makefile`: build and test targets -## Contributing +## Installation -Different organizations do PKI differently and we weren't able to test everyone's setup. Contributions making this tool work better for your organization are welcome. See the [contributing docs](CONTRIBUTING.md) for more information on how to get involved. +### Windows -## Git Signing, GnuPG, PKI, and S/MIME +Build the binary: -Git allows developers to sign their work using GnuPG. This is a form of public key cryptography whereby the notion of trust is distributed. The party verifying a signature may directly know of the signer's identity and public key, or the signer's identity may be vouched for by a third party known to the verifier. Through layers of "vouching", a web-of-trust is established. +```powershell +git clone https://github.com/droren/smimesign.git +cd smimesign +go build -o smimesign.exe . +``` -Such a model is well suited to an unstructured environment. In hierarchical environments though, such as a corporation or other large organizations, a simpler approach is for digital identities to be issued and vouched for by a centralized authority. With this approach — known as Public Key Infrastructure, or PKI — an organization's certificate authority (CA) issues signed certificates that identify subjects such as people or computers. Embedded in these certificates is the identity's public key, allowing others who trust the CA to verify that identity's signatures. +Put `smimesign.exe` somewhere on `PATH`, for example: -PKI is used in a variety of applications for encrypting or authenticating communications. Secure Mime (S/MIME) standardized a protocol for encrypting and signing emails using PKI. While protecting email was the original intent, S/MIME can protect any type of data, including Git commits and tags. Signing Git data with S/MIME provides the same protections as GnuPG while allowing for the more hierarchical trust model of PKI. +```powershell +New-Item -ItemType Directory -Force $HOME\bin | Out-Null +Copy-Item .\smimesign.exe $HOME\bin\smimesign.exe +$env:Path = "$HOME\bin;$env:Path" +``` -## Installation +`smimesign` on Windows reads identities from the Windows certificate store. +The normal expectation is that the signing certificate is present in the +current user's personal store: -### macOS +- Store: `Current User` +- Logical store: `Personal` / `My` -You can install `smimesign` using [Homebrew](https://brew.sh/): +To list what `smimesign` can currently use: -```bash -brew install smimesign +```powershell +smimesign.exe --list-keys ``` -You can also download a prebuilt macOS binary [here](https://github.com/droren/smimesign/releases/latest). Put the binary on your `$PATH`, so Git will be able to find it. - -### Windows +If multiple certificates match the same Git identity, the current +implementation prefers the most signing-oriented certificate automatically. If +ambiguity still remains, set a persistent certificate fingerprint with +`SMIMESIGN_CERT_ID`. -You can install `smimesign` using [`scoop`](https://github.com/lukesampson/scoop): +Example: -```batch -scoop install smimesign +```powershell +$env:SMIMESIGN_CERT_ID = "0x0C900B6316B1708E09BF5F0695BA0CBC20DCE99F" ``` -You can download prebuilt Windows binaries [here](https://github.com/droren/smimesign/releases/latest). Put the appropriate binary on your `%PATH%`, so Git will be able to find it. +To persist it for future PowerShell sessions: + +```powershell +setx SMIMESIGN_CERT_ID 0x0C900B6316B1708E09BF5F0695BA0CBC20DCE99F +``` ### Linux -Linux builds are provided via the Go toolchain. Clone the repository and build the binary: +Build the binary: ```bash git clone https://github.com/droren/smimesign.git cd smimesign -go build +go build -o smimesign . ``` -To run `smimesign` with a PKCS#12 file (software-based key), supply the file containing your certificate and private key. On macOS and Windows this is only required when the certificate isn't already in the system store: +Put it on `PATH`: + +```bash +install -m 0755 ./smimesign ~/.local/bin/smimesign +export PATH="$HOME/.local/bin:$PATH" +``` + +Linux supports two primary identity sources. + +#### Linux With PKCS#12 ```bash export SMIMESIGN_P12=/path/to/user.p12 -export SMIMESIGN_P12_PASSWORD=yourpassword -./smimesign --list-keys +export SMIMESIGN_P12_PASSWORD='your-password' +smimesign --list-keys ``` -To run `smimesign` with a smart card (hardware-based key) on Linux, you need a PKCS#11 driver for your smart card (e.g., OpenSC). Set the `SMIMESIGN_PKCS11_MODULE` environment variable to the path of the driver's `.so` file and `SMIMESIGN_PKCS11_PIN` to your smart card's PIN: +#### Linux With PKCS#11 Smart Cards + +Set the PKCS#11 module path: ```bash -export SMIMESIGN_PKCS11_MODULE=/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so -export SMIMESIGN_PKCS11_PIN=your_smartcard_pin -./smimesign --list-smartcard-keys +export SMIMESIGN_PKCS11_MODULE=/usr/lib64/pkcs11/opensc-pkcs11.so +smimesign --list-smartcard-keys ``` -**Note:** Replace `/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so` with the actual path to your PKCS#11 module. You may need to install `opensc` or other smart card middleware for this to work. -If you do not want to keep the PIN in an environment variable, use a wrapper script that prompts on the terminal and then execs `smimesign` with `SMIMESIGN_PKCS11_PIN` exported for that invocation only. This is the most practical setup when Git invokes `smimesign` automatically for `git commit -S` and signed tags. +If your token requires a PIN, you can either: +- export `SMIMESIGN_PKCS11_PIN` for the current shell, or +- use a wrapper script that prompts on `/dev/tty` and then execs `smimesign` -### Building from source +Example wrapper: -- Make sure you have the [Go compiler](https://golang.org/dl/) installed. -- Make sure that you have a gcc compiler setup in your path to compile embedded c in go files. -- You'll probably want to put `$GOPATH/bin` on your `$PATH`. -- Run `go get github.com/droren/smimesign` +```bash +#!/usr/bin/env bash +set -euo pipefail +if [[ -z "${SMIMESIGN_PKCS11_PIN:-}" ]]; then + printf 'YubiKey PIN: ' > /dev/tty + IFS= read -r -s SMIMESIGN_PKCS11_PIN < /dev/tty + printf '\n' > /dev/tty + export SMIMESIGN_PKCS11_PIN +fi +exec /path/to/smimesign "$@" +``` -### Cross-compiling +When multiple certificates match the same identity, `smimesign` prefers a +signing-capable certificate automatically. To pin one explicitly, set +`SMIMESIGN_CERT_ID`: -Go uses `GOOS=darwin` for macOS (not `macos`). +```bash +export SMIMESIGN_CERT_ID=0x0C900B6316B1708E09BF5F0695BA0CBC20DCE99F +``` + +### macOS -Linux builds are pure-Go, so you can cross-compile from any host with: +macOS uses the system keychain. Build and install like any other Go binary: ```bash -GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o build/linux/amd64/smimesign . -GOOS=linux GOARCH=386 CGO_ENABLED=0 go build -o build/linux/386/smimesign . +git clone https://github.com/droren/smimesign.git +cd smimesign +go build -o smimesign . ``` -Windows and macOS builds use cgo for the native certificate store, so -cross-compiling requires a suitable C toolchain and SDK on your host -(e.g., MinGW for Windows, and an Apple SDK or osxcross for macOS). +List available identities: ```bash -GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build -o build/windows/amd64/smimesign.exe . -GOOS=windows GOARCH=386 CGO_ENABLED=1 go build -o build/windows/386/smimesign.exe . +./smimesign --list-keys +``` -GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -o build/darwin/amd64/smimesign . -GOOS=darwin GOARCH=arm64 CGO_ENABLED=1 go build -o build/darwin/arm64/smimesign . +## Configure Git To Use X.509 Signing By Default + +For Git 2.19 and newer, configure `smimesign` as the global X.509 signing +program: + +### Windows + +```powershell +git config --global gpg.x509.program smimesign.exe +git config --global gpg.format x509 +git config --global commit.gpgsign true +git config --global tag.gpgSign true +git config --global log.showSignature true +git config --global user.name "Dennis Hjort" +git config --global user.email dennis.hjort@saabgroup.com +git config --global user.signingkey dennis.hjort@saabgroup.com ``` -Alternatively, use the Makefile targets: +### Linux and macOS ```bash -make build-linux -make build-windows -make build-darwin -make build-all +git config --global gpg.x509.program smimesign +git config --global gpg.format x509 +git config --global commit.gpgsign true +git config --global tag.gpgSign true +git config --global log.showSignature true +git config --global user.name "Dennis Hjort" +git config --global user.email dennis.hjort@saabgroup.com +git config --global user.signingkey dennis.hjort@saabgroup.com ``` -### Running tests +After configuration, validate with: -You can run tests via Makefile targets or the Go toolchain directly. +```bash +git commit -S -m "x509 signing test" +git log --show-signature -1 +``` -- Minimal subset (safe in sandboxes): +## Choosing The Right Signing Certificate - ```bash - make test-min - ``` +When more than one certificate matches the same Git user identity, the current +implementation does this: -- Full test suite: +1. If `SMIMESIGN_CERT_ID` is set, that fingerprint wins. +2. Otherwise, `smimesign` prefers the most signing-oriented certificate. +3. If the choice is still ambiguous, signing fails with guidance to set + `SMIMESIGN_CERT_ID`. - ```bash - make test-all - ``` +In practice, this means a certificate with `KU=contentCommitment` is preferred +over a client-authentication certificate with `EKU=clientAuth`. -Notes: -- On macOS or constrained environments, set Go's fallback trust roots to avoid accessing the system trust store: +Recommended practice: - ```bash - export GODEBUG=x509usefallbackroots=1 - ``` +- set `user.signingkey` to the Git email address present in the certificate +- set `SMIMESIGN_CERT_ID` if your environment has multiple matching certs -- The `certstore` tests interact with the macOS Keychain and Windows Certificate Store and may require running locally without sandboxing. -- When running tests that require signing with a PKCS#12 file, ensure these variables point to valid values: +## Common Commands - ```bash - export SMIMESIGN_P12=/path/to/user.p12 - export SMIMESIGN_P12_PASSWORD=yourpassword - ``` +List identities from the OS store or PKCS#12: -### Creating test X.509 certificates +```bash +smimesign --list-keys +``` -You can create a PKCS#12 file for testing with OpenSSL: +List smart-card identities on Linux: ```bash -openssl genrsa -out test.key 2048 -openssl req -new -x509 -key test.key -out test.crt -subj "/CN=testuser" -openssl pkcs12 -export -inkey test.key -in test.crt -out test.p12 -passout pass:testpassword +smimesign --list-smartcard-keys ``` -Use `test.p12` with `SMIMESIGN_P12` and `testpassword` with `SMIMESIGN_P12_PASSWORD`. +Create and verify a detached signature: -## Configuring Git +```bash +smimesign --sign -u user@example.com -b file.txt > file.txt.sig +smimesign --verify file.txt.sig file.txt +``` -Git needs to be told to sign commits and tags using smimesign instead of GnuPG. This can be configured on a global or per-repository level. The Git configuration directives for changing signing tools was changed in version 2.19. +Extract certificates from an existing CMS signature: -### Git versions 2.19 and newer +```bash +smimesign --dump-certs file.txt.sig > certs.pem +``` -**Configure Git to use smimesign for a single repository:** +## Validating The Certificate Used For A Git Commit + +There are two supported ways to inspect the signing certificate embedded in a +Git commit. + +### Option 1: Use The Cross-Platform Helper Tool + +Build it once: ```bash -$ cd /path/to/my/repository -$ git config --local gpg.x509.program smimesign -$ git config --local gpg.format x509 +make build-tools ``` -**Configure Git to use smimesign for all repositories:** +This creates `build/tools/git-x509-cert`. + +Show the signer certificate from `HEAD` in human-readable form: ```bash -$ git config --global gpg.x509.program smimesign -$ git config --global gpg.format x509 -$ git config --global commit.gpgsign true -$ git config --global tag.gpgSign true -$ git config --global log.showSignature true +build/tools/git-x509-cert ``` -### Git versions 2.18 and older +Show a specific commit: -**Configure Git to use smimesign for a single repository:** +```bash +build/tools/git-x509-cert 6f274166f5db657127bfd29a07a49e46db03500d +``` + +Show all embedded certificates: ```bash -$ cd /path/to/my/repository -$ git config --local gpg.program smimesign +build/tools/git-x509-cert --all HEAD ``` -**Configure Git to use smimesign for all repositories:** +Export the signer certificate as PEM: ```bash -$ git config --global gpg.program smimesign +build/tools/git-x509-cert --pem HEAD > signer.pem ``` -## Configuring smimesign +The helper uses: + +- `certutil -dump` on Windows +- `openssl x509 -text -noout` on Linux +- `openssl x509 -text -noout` on macOS -No configuration is needed to use smimesign. However, you must already have a certificate and private key in order to make signatures. Furthermore, to sign Git commits or tags, it is best to have a certificate that includes your Git email address. +If those tools are unavailable, it falls back to a built-in certificate summary +plus PEM output. -**Find your Git email address:** +This helper extracts and displays the certificate embedded in the commit +signature. It does not by itself establish that the commit is trusted. Pair it +with `git log --show-signature` or `git verify-commit` when you need both +certificate inspection and signature verification. + +### Option 2: Extract PEM And Inspect It Yourself + +Export the signing certificate from a commit: ```bash -$ git config --get user.email +build/tools/git-x509-cert --pem > signer.pem ``` -**List available signing identities** +View it on Linux or macOS: ```bash -$ smimesign --list-keys +openssl x509 -in signer.pem -text -noout ``` -If multiple certificates match the same email or user ID, select the desired -certificate by ID (fingerprint). You can pass the full fingerprint or a unique -suffix, but if the suffix is ambiguous you must provide a longer ID. +View it on Windows: -```bash -$ smimesign --sign -u user@example.com --cert-id 0x0123456789abcdef +```powershell +certutil -dump .\signer.pem ``` -You can also set a default certificate ID for the current environment: +You can also inspect the commit signature at a higher level with: ```bash -$ export SMIMESIGN_CERT_ID=0x0123456789abcdef +git log --show-signature -1 ``` -## Usage +## Trust Warnings -The `smimesign` command has three primary modes: +If the cryptographic signature is valid but the issuing CA is not trusted on +the local machine, `smimesign --verify` reports: -- `--sign` – create a signature for a file -- `--verify` – verify a signature -- `--dump-certs` – extract embedded X.509 certificates from a signature (PEM) -- `--list-keys` – list available certificates (from PKCS#12 files) -- `--list-smartcard-keys` – list available certificates from smart cards (PKCS#11) +- a good signature +- a trust warning +- exit status `0` -Selection options: -- `--cert-id` – disambiguate when multiple certificates match `--local-user` -- `SMIMESIGN_CERT_ID` – environment fallback when `--cert-id` is not set +This is intentional. It distinguishes: -Example of creating a detached signature and verifying it: +- "the signature bytes are valid" +- "this workstation trusts the issuing CA" -```bash -$ smimesign --sign -u user@example.com -b file.txt > file.txt.sig -$ smimesign --verify file.txt.sig file.txt +### Install Your CA To Resolve Unknown Authority Warnings + +#### Windows + +Import the issuing CA into the appropriate Windows trust store, typically +`Trusted Root Certification Authorities` or `Intermediate Certification +Authorities` depending on what you are installing. + +Example: + +```powershell +certutil -addstore Root company-root-ca.cer +certutil -addstore CA company-issuing-ca.cer ``` -If the signature is cryptographically valid but the issuing CA is not trusted locally, `smimesign --verify` reports a good signature together with a trust warning and exits successfully. This distinguishes "the signature is valid" from "the certificate chain is trusted on this machine". +#### Linux -To resolve an unknown-authority warning, install the issuing CA certificate into the host trust store: +RHEL / Fedora / CentOS: ```bash -# RHEL / Fedora / CentOS sudo cp company-ca.pem /etc/pki/ca-trust/source/anchors/ sudo update-ca-trust +``` -# Debian / Ubuntu +Debian / Ubuntu: + +```bash sudo cp company-ca.pem /usr/local/share/ca-certificates/company-ca.crt sudo update-ca-certificates ``` -After installing the CA, rerun `smimesign --verify` or `git log --show-signature`. +#### macOS -Extract certificates embedded in a signature (outputs PEM to stdout): +```bash +sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain company-root-ca.cer +``` + +After trust is installed, rerun: ```bash -$ smimesign --dump-certs file.txt.sig > certs.pem -$ openssl x509 -in certs.pem -noout -text +git log --show-signature -1 +``` + +## Troubleshooting + +### Windows: "multiple identities match" + +Current behavior: + +- `smimesign` tries to prefer the most signing-oriented certificate +- if still ambiguous, it tells you to set `SMIMESIGN_CERT_ID` + +Recommended fix: + +```powershell +smimesign.exe --list-keys +$env:SMIMESIGN_CERT_ID = "0xYOUR_FINGERPRINT" +git commit -S -m "retry" ``` -## Smart cards (PIV/CAC/Yubikey) +### Windows: Git cannot find `smimesign.exe` -Many large organizations and government agencies distribute certificates and keys to end users via smart cards. These cards allow applications on the user's computer to use private keys for signing or encryption without giving them the ability to export those keys. The native certificate stores on both Windows and macOS can talk to smart cards, though special drivers or middleware may be required. +Check: + +```powershell +Get-Command smimesign.exe +git config --global --get gpg.x509.program +``` + +### Linux: No smart-card identities found + +Check the PKCS#11 module and token visibility: + +```bash +pkcs11-tool --module "$SMIMESIGN_PKCS11_MODULE" -L +smimesign --list-smartcard-keys +``` + +If the token is visible with `pkcs11-tool` but not in `smimesign`, verify the +module path and any PKCS#11 forwarding environment required by your setup. + +### Linux: PIN entry problems + +If Git signs non-interactively, use a wrapper script that prompts on `/dev/tty` +and exports `SMIMESIGN_PKCS11_PIN` only for that process. + +### Verify shows "certificate signed by unknown authority" + +The signature is valid, but your system does not trust the issuing CA yet. +Install the relevant CA certificate into the system trust store and rerun the +verification command. + +### Git commit succeeds but `git log --show-signature` still looks wrong + +Check: + +```bash +git config --global --get gpg.format +git config --global --get gpg.x509.program +git config --global --get log.showSignature +``` -If you can find your certificate in the Keychain Access app on macOS or in the Certificate Manager (`certmgr`) on Windows, it will probably work with smimesign. If you can't find it, you may need to install some drivers or middlware. +Expected: -### Yubikey +- `gpg.format = x509` +- `gpg.x509.program = smimesign` or `smimesign.exe` +- `log.showSignature = true` -Many Yubikey models support the PIV smart card interface. To get your operating system to discover certificates and keys on your Yubikey, you may have to install the [OpenSC middleware](https://github.com/OpenSC/OpenSC/releases/latest). On macOS avoid installing OpenSC using homebrew, as it [omits an important component](https://discourse.brew.sh/t/opensc-formula-is-missing-the-opensc-tokend-component/1683/2). Instead use the installer provided by OpenSC or use the homebrew-cask formula. +## Building -Additionally, to manage the manage certificates and keys on the Yubikey on macOS, you'll need the [Yubikey PIV Manager](https://www.yubico.com/support/knowledge-base/categories/articles/smart-card-tools/) (GUI) or the [Yubikey PIV Tool](https://www.yubico.com/support/knowledge-base/categories/articles/smart-card-tools/) (command line). +Build the main binary: + +```bash +go build . +``` + +Build platform targets: + +```bash +make build-linux +make build-windows +make build-darwin +make build-tools +make build-all +``` + +`make build-windows` and `make build-darwin` require cgo cross-toolchains. +The Makefile now checks explicitly for those tools and fails early with a clear +message. Override the compiler commands if your environment uses different +tool names: + +```bash +make build-windows WINDOWS_CC_AMD64=x86_64-w64-mingw32-gcc WINDOWS_CC_386=i686-w64-mingw32-gcc +make build-darwin DARWIN_CC_AMD64=o64-clang DARWIN_CC_ARM64=oa64-clang +``` + +## Tests + +Run the minimal safe subset: + +```bash +make test-min +``` + +Run the full suite: + +```bash +make test-all +``` + +On macOS or other constrained environments, you may want: + +```bash +export GODEBUG=x509usefallbackroots=1 +``` + +## Contributing -![Yubikey PIV Keychain in macOS Keychain Access app](https://user-images.githubusercontent.com/248078/45328493-13705300-b511-11e8-97f4-1a04cf35cc6c.png) +PKI environments vary widely. Contributions that improve compatibility with +enterprise Windows, Linux PKCS#11, smart cards, and certificate-chain handling +are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/cmd/git-x509-cert/main.go b/cmd/git-x509-cert/main.go new file mode 100644 index 0000000..0bdc204 --- /dev/null +++ b/cmd/git-x509-cert/main.go @@ -0,0 +1,225 @@ +package main + +import ( + "bytes" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + + cms "github.com/github/smimesign/ietf-cms" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + var ( + showAll = flag.Bool("all", false, "display all embedded certificates instead of only the signer certificate") + pemOnly = flag.Bool("pem", false, "print PEM instead of a human-readable certificate dump") + ) + flag.Parse() + + rev := "HEAD" + if flag.NArg() > 1 { + return errors.New("usage: git-x509-cert [--all] [--pem] []") + } + if flag.NArg() == 1 { + rev = flag.Arg(0) + } + + rawCommit, err := gitOutput("cat-file", "commit", rev) + if err != nil { + return fmt.Errorf("failed to read commit %q: %w", rev, err) + } + + sigPEM, err := extractCommitSignaturePEM(rawCommit) + if err != nil { + return fmt.Errorf("failed to extract commit signature from %q: %w", rev, err) + } + + signerCerts, allCerts, err := parseSignatureCertificates(sigPEM) + if err != nil { + return fmt.Errorf("failed to parse signature certificates from %q: %w", rev, err) + } + if len(signerCerts) == 0 { + return fmt.Errorf("no certificates embedded in commit signature for %q", rev) + } + + selected := signerCerts + if !*showAll { + selected = signerCerts[:1] + } else if len(allCerts) > 0 { + selected = allCerts + } + + if *pemOnly { + for _, cert := range selected { + if err := pem.Encode(os.Stdout, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}); err != nil { + return fmt.Errorf("failed to write PEM certificate: %w", err) + } + } + return nil + } + + for i, cert := range selected { + if len(selected) > 1 { + fmt.Printf("Certificate %d/%d\n", i+1, len(selected)) + } + + if dump, err := platformCertificateDump(cert); err == nil { + fmt.Print(dump) + } else { + fmt.Print(fallbackCertificateDump(cert)) + } + + if i+1 < len(selected) { + fmt.Println() + } + } + + return nil +} + +func gitOutput(args ...string) ([]byte, error) { + cmd := exec.Command("git", args...) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + return nil, err + } + return out, nil +} + +func extractCommitSignaturePEM(commit []byte) ([]byte, error) { + lines := strings.Split(string(commit), "\n") + var sigLines []string + inSig := false + + for _, line := range lines { + if line == "" { + break + } + + if strings.HasPrefix(line, "gpgsig ") { + inSig = true + sigLines = append(sigLines, strings.TrimPrefix(line, "gpgsig ")) + continue + } + + if inSig { + if strings.HasPrefix(line, " ") { + sigLines = append(sigLines, strings.TrimPrefix(line, " ")) + continue + } + break + } + } + + if len(sigLines) == 0 { + return nil, errors.New("commit is not signed") + } + + sig := strings.Join(sigLines, "\n") + if !strings.HasSuffix(sig, "\n") { + sig += "\n" + } + return []byte(sig), nil +} + +func parseSignatureCertificates(sigPEM []byte) ([]*x509.Certificate, []*x509.Certificate, error) { + block, _ := pem.Decode(sigPEM) + if block == nil { + return nil, nil, errors.New("signature is not valid PEM") + } + + sd, err := cms.ParseSignedData(block.Bytes) + if err != nil { + return nil, nil, err + } + + signerCerts, err := sd.GetSignerCertificates() + if err != nil { + return nil, nil, err + } + allCerts, err := sd.GetCertificates() + if err != nil { + return nil, nil, err + } + + return signerCerts, allCerts, nil +} + +func platformCertificateDump(cert *x509.Certificate) (string, error) { + tmp, err := os.CreateTemp("", "git-x509-cert-*.pem") + if err != nil { + return "", err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + defer tmp.Close() + + if err := pem.Encode(tmp, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}); err != nil { + return "", err + } + if err := tmp.Close(); err != nil { + return "", err + } + + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.Command("certutil", "-dump", tmpName) + default: + cmd = exec.Command("openssl", "x509", "-in", tmpName, "-text", "-noout") + } + cmd.Env = os.Environ() + + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} + +func fallbackCertificateDump(cert *x509.Certificate) string { + var buf bytes.Buffer + + fmt.Fprintf(&buf, "Subject: %s\n", cert.Subject.String()) + fmt.Fprintf(&buf, "Issuer: %s\n", cert.Issuer.String()) + fmt.Fprintf(&buf, "Serial Number: %s\n", cert.SerialNumber.Text(16)) + fmt.Fprintf(&buf, "Not Before: %s\n", cert.NotBefore.UTC().Format("2006-01-02 15:04:05 MST")) + fmt.Fprintf(&buf, "Not After: %s\n", cert.NotAfter.UTC().Format("2006-01-02 15:04:05 MST")) + fmt.Fprintf(&buf, "Signature Algorithm: %s\n", cert.SignatureAlgorithm.String()) + fmt.Fprintf(&buf, "Public Key Algorithm: %s\n", cert.PublicKeyAlgorithm.String()) + if len(cert.EmailAddresses) > 0 { + fmt.Fprintf(&buf, "Email Addresses: %s\n", strings.Join(cert.EmailAddresses, ", ")) + } + if len(cert.DNSNames) > 0 { + fmt.Fprintf(&buf, "DNS Names: %s\n", strings.Join(cert.DNSNames, ", ")) + } + if len(cert.URIs) > 0 { + var uris []string + for _, uri := range cert.URIs { + uris = append(uris, uri.String()) + } + fmt.Fprintf(&buf, "URIs: %s\n", strings.Join(uris, ", ")) + } + fmt.Fprintf(&buf, "SHA1 Fingerprint: %X\n", sha1.Sum(cert.Raw)) + fmt.Fprintf(&buf, "SHA256 Fingerprint: %X\n", sha256.Sum256(cert.Raw)) + buf.WriteString("\nPEM:\n") + _ = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) + + return buf.String() +} diff --git a/cmd/git-x509-cert/main_test.go b/cmd/git-x509-cert/main_test.go new file mode 100644 index 0000000..d4c9ee1 --- /dev/null +++ b/cmd/git-x509-cert/main_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "strings" + "testing" +) + +func TestExtractCommitSignaturePEM(t *testing.T) { + commit := strings.Join([]string{ + "tree deadbeef", + "author Test User 0 +0000", + "committer Test User 0 +0000", + "gpgsig -----BEGIN SIGNED MESSAGE-----", + " MIIB", + " AAAA", + " -----END SIGNED MESSAGE-----", + "", + "message", + }, "\n") + + got, err := extractCommitSignaturePEM([]byte(commit)) + if err != nil { + t.Fatalf("extractCommitSignaturePEM returned error: %v", err) + } + + want := strings.Join([]string{ + "-----BEGIN SIGNED MESSAGE-----", + "MIIB", + "AAAA", + "-----END SIGNED MESSAGE-----", + "", + }, "\n") + + if string(got) != want { + t.Fatalf("unexpected signature contents:\nwant:\n%s\ngot:\n%s", want, string(got)) + } +} + +func TestExtractCommitSignaturePEMUnsignedCommit(t *testing.T) { + _, err := extractCommitSignaturePEM([]byte("tree deadbeef\n\nmessage\n")) + if err == nil { + t.Fatal("expected error for unsigned commit") + } +} diff --git a/command_sign.go b/command_sign.go index 8f4d7c1..4a129ca 100644 --- a/command_sign.go +++ b/command_sign.go @@ -167,7 +167,16 @@ func findUserIdentity() (certstore.Identity, error) { } if len(matches) > 1 { - return nil, fmt.Errorf("multiple identities match %q. Use --cert-id to select one: %s", *localUserOpt, strings.Join(identityInfo(matches), ", ")) + if preferred, reason, ok := preferSigningIdentity(matches); ok { + fmt.Fprintf(stderr, "smimesign: WARNING: multiple identities match %q; selecting %s (%s). Set SMIMESIGN_CERT_ID=%s to make this choice explicit.\n", + *localUserOpt, + preferred.cert.Subject.CommonName, + reason, + certHexFingerprint(preferred.cert), + ) + return preferred.ident, nil + } + return nil, fmt.Errorf("multiple identities match %q. Set SMIMESIGN_CERT_ID to the desired fingerprint (or use --cert-id for a one-off selection): %s", *localUserOpt, strings.Join(identityInfo(matches), ", ")) } return matches[0].ident, nil @@ -180,6 +189,93 @@ func certIDSelectionError(userID, certID string, filtered []identityMatch) strin return fmt.Sprintf("cert-id %q matches multiple identities for %q (use a longer id). Matching identities", certID, userID) } +func preferSigningIdentity(matches []identityMatch) (identityMatch, string, bool) { + if len(matches) == 0 { + return identityMatch{}, "", false + } + + best := matches[0] + bestScore := signingPreferenceScore(best.cert) + tied := false + + for _, entry := range matches[1:] { + score := signingPreferenceScore(entry.cert) + if score > bestScore { + best = entry + bestScore = score + tied = false + continue + } + if score == bestScore { + tied = true + } + } + + if tied || bestScore <= 0 { + return identityMatch{}, "", false + } + + return best, signingPreferenceReason(best.cert), true +} + +func signingPreferenceScore(cert *x509.Certificate) int { + if cert == nil { + return 0 + } + + score := 0 + if cert.KeyUsage&x509.KeyUsageContentCommitment != 0 { + score += 100 + } + if cert.KeyUsage&x509.KeyUsageDigitalSignature != 0 { + score += 25 + } + if cert.KeyUsage&x509.KeyUsageKeyEncipherment != 0 { + score -= 5 + } + + for _, usage := range cert.ExtKeyUsage { + switch usage { + case x509.ExtKeyUsageCodeSigning, x509.ExtKeyUsageEmailProtection, x509.ExtKeyUsageTimeStamping: + score += 20 + case x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth: + score -= 15 + } + } + + return score +} + +func signingPreferenceReason(cert *x509.Certificate) string { + if cert == nil { + return "preferred signing identity" + } + + var parts []string + if cert.KeyUsage&x509.KeyUsageContentCommitment != 0 { + parts = append(parts, "preferred for signing because it has KU=contentCommitment") + } + if cert.KeyUsage&x509.KeyUsageDigitalSignature != 0 && cert.KeyUsage&x509.KeyUsageContentCommitment == 0 { + parts = append(parts, "preferred for signing because it has KU=digitalSignature") + } + if hasExtKeyUsage(cert, x509.ExtKeyUsageClientAuth) { + parts = append(parts, "clientAuth identities are deprioritized") + } + if len(parts) == 0 { + return "preferred signing identity" + } + return strings.Join(parts, "; ") +} + +func hasExtKeyUsage(cert *x509.Certificate, want x509.ExtKeyUsage) bool { + for _, usage := range cert.ExtKeyUsage { + if usage == want { + return true + } + } + return false +} + func identityInfo(matches []identityMatch) []string { info := make([]string, 0, len(matches)) for _, entry := range matches { diff --git a/command_sign_test.go b/command_sign_test.go index 1b7af4a..221e62a 100644 --- a/command_sign_test.go +++ b/command_sign_test.go @@ -244,7 +244,33 @@ func TestFindUserIdentityRequiresCertID(t *testing.T) { got, err := findUserIdentity() require.Error(t, err) require.Nil(t, got) - require.Contains(t, err.Error(), "Use --cert-id") + require.Contains(t, err.Error(), "Set SMIMESIGN_CERT_ID") +} + +func TestFindUserIdentityPrefersSigningIdentity(t *testing.T) { + defer testSetup(t, "--sign", "-u", "alice@example.com")() + + authIdent := intermediate.Issue( + fakeca.Subject(pkix.Name{CommonName: "alice@example.com"}), + fakeca.KeyUsage(x509.KeyUsageDigitalSignature|x509.KeyUsageKeyEncipherment), + ) + authIdent.Certificate.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} + signIdent := intermediate.Issue( + fakeca.Subject(pkix.Name{CommonName: "alice@example.com"}), + fakeca.KeyUsage(x509.KeyUsageContentCommitment), + ) + + idents = []certstore.Identity{identity{authIdent}, identity{signIdent}} + + got, err := findUserIdentity() + require.NoError(t, err) + require.NotNil(t, got) + + cert, err := got.Certificate() + require.NoError(t, err) + require.True(t, cert.Equal(signIdent.Certificate)) + require.Contains(t, stderrBuf.String(), "Set SMIMESIGN_CERT_ID=") + require.Contains(t, stderrBuf.String(), certHexFingerprint(signIdent.Certificate)) } func TestFindUserIdentitySelectsByCertID(t *testing.T) { @@ -277,6 +303,25 @@ func TestFindUserIdentityCertIDNoMatch(t *testing.T) { require.Contains(t, err.Error(), "does not match any identity") } +func TestFindUserIdentityAmbiguousMessagePrefersEnvOverride(t *testing.T) { + defer testSetup(t, "--sign", "-u", "alice@example.com")() + + identA := intermediate.Issue( + fakeca.Subject(pkix.Name{CommonName: "alice@example.com"}), + fakeca.KeyUsage(x509.KeyUsageDigitalSignature), + ) + identB := intermediate.Issue( + fakeca.Subject(pkix.Name{CommonName: "alice@example.com"}), + fakeca.KeyUsage(x509.KeyUsageDigitalSignature), + ) + idents = []certstore.Identity{identity{identA}, identity{identB}} + + got, err := findUserIdentity() + require.Error(t, err) + require.Nil(t, got) + require.Contains(t, err.Error(), "Set SMIMESIGN_CERT_ID") +} + func TestFindUserIdentitySelectsByEnvCertID(t *testing.T) { identA := intermediate.Issue(fakeca.Subject(pkix.Name{CommonName: "alice@example.com"})) identB := intermediate.Issue(fakeca.Subject(pkix.Name{CommonName: "alice@example.com"})) diff --git a/ietf-cms/sign_test.go b/ietf-cms/sign_test.go index 21d210f..e354d41 100644 --- a/ietf-cms/sign_test.go +++ b/ietf-cms/sign_test.go @@ -103,6 +103,31 @@ func TestSignDetached(t *testing.T) { } } +func TestGetSignerCertificates(t *testing.T) { + data := []byte("hello, world!") + + ci, err := SignDetached(data, leaf.Chain(), leaf.PrivateKey) + if err != nil { + t.Fatal(err) + } + + sd, err := ParseSignedData(ci) + if err != nil { + t.Fatal(err) + } + + signers, err := sd.GetSignerCertificates() + if err != nil { + t.Fatal(err) + } + if len(signers) != 1 { + t.Fatalf("expected 1 signer certificate, found %d", len(signers)) + } + if !signers[0].Equal(leaf.Certificate) { + t.Fatal("expected signer certificate to match the leaf certificate") + } +} + func TestSignDetachedWithOpenSSL(t *testing.T) { // Do not require this test to pass if openssl is not in the path opensslPath, err := exec.LookPath("openssl") diff --git a/ietf-cms/signed_data.go b/ietf-cms/signed_data.go index edfcf41..a41c937 100644 --- a/ietf-cms/signed_data.go +++ b/ietf-cms/signed_data.go @@ -54,6 +54,26 @@ func (sd *SignedData) GetCertificates() ([]*x509.Certificate, error) { return sd.psd.X509Certificates() } +// GetSignerCertificates gets the embedded certificates that correspond to the +// SignerInfos contained in this SignedData. +func (sd *SignedData) GetSignerCertificates() ([]*x509.Certificate, error) { + certs, err := sd.psd.X509Certificates() + if err != nil { + return nil, err + } + + signers := make([]*x509.Certificate, 0, len(sd.psd.SignerInfos)) + for _, si := range sd.psd.SignerInfos { + cert, err := si.FindCertificate(certs) + if err != nil { + return nil, err + } + signers = append(signers, cert) + } + + return signers, nil +} + // SetCertificates replaces the certificates stored in the SignedData with new // ones. func (sd *SignedData) SetCertificates(certs []*x509.Certificate) error { diff --git a/ietf-cms/timestamp/timestamp.go b/ietf-cms/timestamp/timestamp.go index 5a92fb3..656b315 100644 --- a/ietf-cms/timestamp/timestamp.go +++ b/ietf-cms/timestamp/timestamp.go @@ -31,8 +31,15 @@ const ( contentTypeTSQuery = "application/timestamp-query" contentTypeTSReply = "application/timestamp-reply" nonceBytes = 16 + maxTSResponseBytes = 10 << 20 ) +func init() { + DefaultHTTPClient = &http.Client{ + Timeout: 30 * time.Second, + } +} + // GenerateNonce generates a new nonce for this TSR. func GenerateNonce() *big.Int { buf := make([]byte, nonceBytes) @@ -98,14 +105,26 @@ func (req Request) Do(url string) (Response, error) { if err != nil { return nilResp, err } + defer httpResp.Body.Close() if ct := httpResp.Header.Get("Content-Type"); ct != contentTypeTSReply { return nilResp, fmt.Errorf("Bad content-type: %s", ct) } + if httpResp.ContentLength > maxTSResponseBytes { + return nilResp, fmt.Errorf("timestamp response too large: %d bytes", httpResp.ContentLength) + } - buf := bytes.NewBuffer(make([]byte, 0, httpResp.ContentLength)) - if _, err = io.Copy(buf, httpResp.Body); err != nil { + capHint := httpResp.ContentLength + if capHint < 0 || capHint > maxTSResponseBytes { + capHint = 0 + } + + buf := bytes.NewBuffer(make([]byte, 0, capHint)) + if _, err = io.Copy(buf, io.LimitReader(httpResp.Body, maxTSResponseBytes+1)); err != nil { return nilResp, err } + if int64(buf.Len()) > maxTSResponseBytes { + return nilResp, fmt.Errorf("timestamp response exceeded limit of %d bytes", maxTSResponseBytes) + } return ParseResponse(buf.Bytes()) } diff --git a/main.go b/main.go index b3ea98c..04cf9a2 100644 --- a/main.go +++ b/main.go @@ -75,28 +75,17 @@ func runCommand() error { return nil } - // Open certificate store - store, err := certstore.Open() - if err != nil { - return errors.Wrap(err, "failed to open certificate store") - } - defer store.Close() - - // Get list of identities - idents, err = store.Identities() - if err != nil { - return errors.Wrap(err, "failed to get identities from certificate store") - } - for _, ident := range idents { - defer ident.Close() - } - if *signFlag { if *verifyFlag || *dumpCertsFlag || *listKeysFlag { return errors.New("specify --help, --sign, --verify, --dump-certs, or --list-keys") } else if len(*localUserOpt) == 0 { return errors.New("specify a USER-ID to sign with") } else { + cleanup, err := openIdentities() + if err != nil { + return err + } + defer cleanup() return commandSign() } } @@ -113,6 +102,11 @@ func runCommand() error { } else if *armorFlag { return errors.New("armor cannot be specified for verification") } else { + cleanup, err := openIdentities() + if err != nil { + return err + } + defer cleanup() return commandVerify() } } @@ -145,6 +139,11 @@ func runCommand() error { } else if *armorFlag { return errors.New("armor cannot be specified for list-keys") } else { + cleanup, err := openIdentities() + if err != nil { + return err + } + defer cleanup() return commandListKeys() } } @@ -161,9 +160,35 @@ func runCommand() error { } else if *armorFlag { return errors.New("armor cannot be specified for list-smartcard-keys") } else { + cleanup, err := openIdentities() + if err != nil { + return err + } + defer cleanup() return commandListSmartcardKeys() } } return errors.New("specify --help, --sign, --verify, --dump-certs, or --list-keys") } + +func openIdentities() (func(), error) { + store, err := certstore.Open() + if err != nil { + return nil, errors.Wrap(err, "failed to open certificate store") + } + + openedIdents, err := store.Identities() + if err != nil { + store.Close() + return nil, errors.Wrap(err, "failed to get identities from certificate store") + } + + idents = openedIdents + return func() { + for _, ident := range idents { + ident.Close() + } + store.Close() + }, nil +}