Compare commits

..

6 Commits

10 changed files with 435 additions and 165 deletions
+20
View File
@@ -0,0 +1,20 @@
on:
push:
paths:
- 'src/simple-ca/**'
jobs:
test-go:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
- name: Run Go tests
run: go test -v ./...
working-directory: src/simple-ca
+23
View File
@@ -0,0 +1,23 @@
on:
push:
paths:
- 'simple-ca.py'
- 'test_simple_ca.py'
jobs:
test-python:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install pytest
run: pip install pytest
- name: Run python tests
run: python3 -m pytest test_simple_ca.py -v
+15
View File
@@ -0,0 +1,15 @@
on:
push:
paths:
- 'simple-ca.sh'
- 'run-tests.sh'
jobs:
test-shell:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Run shell tests
run: bash run-tests.sh
-51
View File
@@ -1,51 +0,0 @@
on:
push:
paths:
- 'simple-ca.sh'
- 'simple-ca.py'
- 'run-tests.sh'
- 'test_simple_ca.py'
- 'src/simple-ca/**'
- '.gitea/workflows/test.yaml'
jobs:
test-python:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install pytest
run: pip install pytest
- name: Run python tests
run: python3 -m pytest test_simple_ca.py -v
test-shell:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Run shell tests
run: bash run-tests.sh
test-go:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
- name: Run Go tests
run: go test -v ./...
working-directory: src/simple-ca
+156 -49
View File
@@ -1,64 +1,172 @@
# Simple CA # Simple CA
[![test](https://gitea.koszewscy.waw.pl/slawek/simple-ca/actions/workflows/test.yaml/badge.svg)](https://gitea.koszewscy.waw.pl/slawek/simple-ca/actions?workflow=test.yaml) [![shell](https://gitea.koszewscy.waw.pl/slawek/simple-ca/actions/workflows/test-shell.yaml/badge.svg)](https://gitea.koszewscy.waw.pl/slawek/simple-ca/actions?workflow=test-shell.yaml)
[![python](https://gitea.koszewscy.waw.pl/slawek/simple-ca/actions/workflows/test-python.yaml/badge.svg)](https://gitea.koszewscy.waw.pl/slawek/simple-ca/actions?workflow=test-python.yaml)
[![go](https://gitea.koszewscy.waw.pl/slawek/simple-ca/actions/workflows/test-go.yaml/badge.svg)](https://gitea.koszewscy.waw.pl/slawek/simple-ca/actions?workflow=test-go.yaml)
`simple-ca.sh` is a Bash script that provides functions for creating and managing a simple Certificate Authority (CA) and generating certificates. It can create a single or two-level CA hierarchy, and generate client-server TLS certificates. The script is designed to be simple and easy to use, making it suitable for testing and development purposes, where a self-signed certificate is not sufficient. Tools for creating and managing a simple Certificate Authority for testing and development. Three implementations are provided — pick whichever fits your environment:
All certificates generated by this script have a random serial number. | | `simple-ca.sh` | `simple-ca.py` | Go binary |
|---|---|---|---|
| Usage | sourced function library | CLI | compiled CLI |
| Config file | none | `simple-ca.json` | none |
| AIA base URL | `aia_base_url.txt` | `simple-ca.json` | `aia_base_url.txt` |
| Certificate history | no | yes | no |
| CRL generation | no | yes | no |
| Revocation | no | yes | no |
| PFX (`--apple-openssl`) | yes | yes | yes |
| Requires | bash/zsh + openssl | Python 3.8+ + openssl | none (native crypto) |
## Functions All variants share the same directory layout and produce interchangeable files.
### `make_ca()` ## Directory layout
This function creates a root CA certificate and private key. It can optionally create an intermediate CA certificate and private key, which is signed by the root CA. The function takes several parameters to customize the CA creation process, such as the CA name, validity period, and whether to create an intermediate CA. ```
CA_DIR/
Usage: ca_cert.pem root CA certificate
ca_key.pem root CA private key
```bash ca_bundle.pem root + all issuing CA certs concatenated
make_ca [--days <validity_days>] [--issuing-ca <name>] <ca_directory> <ca_name> aia_base_url.txt base URL for AIA extension (shell/Go only)
{name}_cert.pem certificate issued by root CA
{name}_key.pem key for certificate issued by root CA
{issuing_ca}/
ca_cert.pem issuing CA certificate
ca_key.pem issuing CA private key
{name}_cert.pem certificate issued by issuing CA
{name}_key.pem key for certificate issued by issuing CA
``` ```
- `<ca_directory>`: The directory where the CA files will be stored. Python additionally writes:
- `<ca_name>`: The name of the CA.
- `--days <validity_days>`: Optional. The number of days the CA certificate will be valid. Default is 3650 days (10 years).
- `--issuing-ca <name>`: Optional. If specified, creates an intermediate CA with <ca_name> as the intermediate CA name and using <name> as certificate and key file prefix for the issuing CA (instead of root's `ca`).
It also maintains a `ca_bundle.pem` file in the CA directory containing the root CA and any issuing CA certificates concatenated together. Use this bundle with `openssl verify -CAfile <ca_directory>/ca_bundle.pem` instead of relying on hash symlinks — this works identically on Linux, macOS, and Windows without symlink privileges. ```
CA_DIR/
### `make_cert()` simple-ca.json config + certificate history
crl.pem root CA CRL (make-crl)
This function generates a certificate and private key with TLS Web Server Authentication and Client Authentication EKUs. The certificate is signed by the specified CA (either root or intermediate). {issuing_ca}/
crl.pem issuing CA CRL (make-crl)
Usage:
```bash
make_cert --ca-dir <ca_directory> [--days <validity_days>] [--issuing-ca <name>] <cert_directory> <subject_name>
``` ```
- `<ca_directory>`: The directory where the CA files are stored (used to find the CA certificate and key for signing). Use `ca_bundle.pem` with `openssl verify -CAfile CA_DIR/ca_bundle.pem` — this works identically on Linux, macOS, and Windows without relying on hash symlinks.
- `<cert_directory>`: The directory where the generated certificate and key will be stored.
- `<subject_name>`: The subject name (Common Name) for the certificate.
- `--days <validity_days>`: Optional. The number of days the certificate will be valid. Default is 365 days.
- `--issuing-ca <name>`: Optional. If specified, uses the CA with the key `<name>_key.pem` and certificate `<name>_cert.pem` for signing instead of the root CA.
### `make_pfx()` ---
This function creates a PKCS#12 (PFX) file containing the certificate, private key, and CA certificate chain. This is useful for importing the certificate into applications that require a PFX file. ## Shell — `simple-ca.sh`
Usage: Source the file and call the functions directly. `SIMPLE_CA_DIR` is set by the first `--ca-dir` call and inherited by all subsequent calls in the same session. It can also be set in the environment before sourcing.
```bash ```bash
make_pfx --ca-dir <ca_directory> [--issuing-ca <file_prefix>] --path <pfx_file_path> [--password <pfx_password>] source simple-ca.sh
``` ```
- `--ca-dir <ca_directory>`: The directory where the CA files are stored (used to find the CA certificate for the chain). ### `make_ca`
- `--issuing-ca <file_prefix>`: The file prefix of the issuing CA to include in the chain.
- `--path <pfx_file_path>`: The path where the generated PFX file will be saved. ```bash
- `--password <pfx_password>`: Optional. The custom password to protect the PFX, instead of the default `changeit`. make_ca [--ca-dir DIR] [--days N] [--aia-base-url URL] [--issuing-ca NAME] CA_NAME
```
Without `--issuing-ca`: creates the root CA in `DIR`.
With `--issuing-ca NAME`: creates an issuing CA in `DIR/NAME/`, signed by the root CA.
### `make_cert`
```bash
make_cert [--ca-dir DIR] [--cert-dir DIR] [--days N] [--issuing-ca NAME] SUBJECT [SAN ...]
```
Creates a TLS certificate (serverAuth + clientAuth). `SUBJECT` is used as the CN and first SAN. Additional SANs can be DNS names or IPv4 addresses. Without `--cert-dir`, files are written to the signing CA's directory.
### `make_pfx`
```bash
make_pfx [--ca-dir DIR] [--issuing-ca NAME] [--password PASS] [--apple-openssl] CERT_PATH
```
Creates a PKCS#12 bundle from an existing certificate. `--apple-openssl` uses `/usr/bin/openssl` to produce a PFX that Apple Keychain and iOS accept (legacy RC2/3DES encryption instead of modern PBES2).
### Example — two-level CA
```bash
source simple-ca.sh
make_ca --ca-dir /tmp/ca "My Root CA"
make_ca --issuing-ca servers "My Servers CA"
make_cert --issuing-ca servers web.example.com web.example.com 192.168.1.1
make_pfx --issuing-ca servers --password s3cr3t /tmp/ca/servers/web_cert.pem
```
---
## Python — `simple-ca.py`
A standalone CLI with the same commands as the shell variant plus CRL generation and revocation. Configuration and certificate history are stored in `simple-ca.json` inside the CA directory.
```bash
python3 simple-ca.py COMMAND [OPTIONS] [ARGS]
```
### Commands
```bash
make-ca [--ca-dir DIR] [--days N] [--ca-publish-base-url URL] [--issuing-ca NAME] CA_NAME
make-cert [--ca-dir DIR] [--cert-dir DIR] [--days N] [--issuing-ca NAME] SUBJECT [SAN ...]
make-pfx [--ca-dir DIR] [--issuing-ca NAME] [--password PASS] [--apple-openssl] CERT_PATH
make-crl [--ca-dir DIR] [--issuing-ca NAME] [--days N]
revoke-cert [--ca-dir DIR] [--issuing-ca NAME] CERT_PATH
```
`--ca-publish-base-url` embeds both AIA (Authority Information Access) and CRL Distribution Point extensions in issued certificates, pointing to the published location of the CA cert and CRL file.
### Example — two-level CA with CRL
```bash
python3 simple-ca.py make-ca --ca-dir /tmp/ca "My Root CA"
python3 simple-ca.py make-ca --ca-dir /tmp/ca --issuing-ca servers "My Servers CA"
python3 simple-ca.py make-cert --ca-dir /tmp/ca --issuing-ca servers web.example.com web.example.com 192.168.1.1
python3 simple-ca.py make-cert --ca-dir /tmp/ca --issuing-ca servers alice.example.com alice.example.com
python3 simple-ca.py revoke-cert --ca-dir /tmp/ca --issuing-ca servers /tmp/ca/servers/alice_cert.pem
python3 simple-ca.py make-crl --ca-dir /tmp/ca --issuing-ca servers
```
---
## Go binary
Equivalent to the shell variant in features. Uses native Go crypto — no `openssl` subprocess required except for `--apple-openssl` PFX generation.
```bash
cd src/simple-ca && go build -o simple-ca .
```
### Commands
```bash
simple-ca make-ca [--ca-dir DIR] [--days N] [--aia-base-url URL] [--issuing-ca NAME] CA_NAME
simple-ca make-cert [--ca-dir DIR] [--cert-dir DIR] [--days N] [--issuing-ca NAME] SUBJECT [SAN ...]
simple-ca make-pfx [--ca-dir DIR] [--issuing-ca NAME] [--password PASS] [--apple-openssl] CERT_PATH
```
`SIMPLE_CA_DIR` environment variable is used as the default CA directory when `--ca-dir` is not specified.
### Example — two-level CA
```bash
export SIMPLE_CA_DIR=/tmp/ca
simple-ca make-ca "My Root CA"
simple-ca make-ca --issuing-ca servers "My Servers CA"
simple-ca make-cert --issuing-ca servers web.example.com web.example.com 192.168.1.1
simple-ca make-pfx --issuing-ca servers --password s3cr3t /tmp/ca/servers/web_cert.pem
```
---
## generate-mobileconfig.py ## generate-mobileconfig.py
`generate-mobileconfig.py` generates Apple `.mobileconfig` profiles for distributing CA certificates and optionally client certificates and IKEv2 VPN configuration to Apple devices (macOS / iOS / iPadOS). Generates Apple `.mobileconfig` profiles for distributing CA certificates and optionally client certificates and IKEv2 VPN configuration to Apple devices (macOS / iOS / iPadOS).
### Modes ### Modes
@@ -66,7 +174,7 @@ make_pfx --ca-dir <ca_directory> [--issuing-ca <file_prefix>] --path <pfx_file_p
|---|---| |---|---|
| `--ca-cert` only | CA trust anchor | | `--ca-cert` only | CA trust anchor |
| `--ca-cert` + `--client-cert` + `--client-key` | CA trust anchor + PKCS#12 client certificate | | `--ca-cert` + `--client-cert` + `--client-key` | CA trust anchor + PKCS#12 client certificate |
| All of the above + `--remote-address` + `--match-domains` | CA + client cert + IKEv2 VPN | | All of the above + `--remote-address` + `--dns` + `--match-domains` | CA + client cert + IKEv2 VPN |
### Usage ### Usage
@@ -74,10 +182,9 @@ make_pfx --ca-dir <ca_directory> [--issuing-ca <file_prefix>] --path <pfx_file_p
generate-mobileconfig.py --ca-cert CA.pem --output profile.mobileconfig \ generate-mobileconfig.py --ca-cert CA.pem --output profile.mobileconfig \
--identifier com.example.vpn \ --identifier com.example.vpn \
[--client-cert CLIENT.pem --client-key CLIENT_KEY.pem] \ [--client-cert CLIENT.pem --client-key CLIENT_KEY.pem] \
[--remote-address vpn.example.com --match-domains example.com] \ [--remote-address vpn.example.com --dns 10.0.0.1 --match-domains example.com] \
[--profile-name "My VPN"] [--ca-name "My CA"] \ [--profile-name "My VPN"] [--ca-name "My CA"] \
[--client-name "My Cert"] [--vpn-name "My VPN Connection"] \ [--client-name "My Cert"] [--vpn-name "My VPN Connection"]
[--openssl /usr/bin/openssl]
``` ```
#### Required arguments #### Required arguments
@@ -94,6 +201,7 @@ generate-mobileconfig.py --ca-cert CA.pem --output profile.mobileconfig \
#### VPN (requires client certificate) #### VPN (requires client certificate)
- `--remote-address FQDN` — VPN gateway hostname. - `--remote-address FQDN` — VPN gateway hostname.
- `--dns IP [IP …]` — DNS server(s) for split DNS.
- `--match-domains DOMAIN [DOMAIN …]` — Split-DNS domains routed through the VPN. - `--match-domains DOMAIN [DOMAIN …]` — Split-DNS domains routed through the VPN.
#### Display name overrides (all optional) #### Display name overrides (all optional)
@@ -103,10 +211,6 @@ generate-mobileconfig.py --ca-cert CA.pem --output profile.mobileconfig \
- `--client-name NAME` — Client cert payload display name (default: certificate CN). - `--client-name NAME` — Client cert payload display name (default: certificate CN).
- `--vpn-name NAME` — VPN connection display name (default: profile name). - `--vpn-name NAME` — VPN connection display name (default: profile name).
#### Other
- `--openssl PATH` — Path to the `openssl` binary (default: `/usr/bin/openssl`).
### Examples ### Examples
**CA trust profile only:** **CA trust profile only:**
@@ -137,13 +241,16 @@ python3 generate-mobileconfig.py \
--client-cert certs/alice_cert.pem \ --client-cert certs/alice_cert.pem \
--client-key certs/alice_key.pem \ --client-key certs/alice_key.pem \
--remote-address vpn.example.com \ --remote-address vpn.example.com \
--dns 10.0.0.1 \
--match-domains example.com internal.example.com \ --match-domains example.com internal.example.com \
--output alice-vpn.mobileconfig --output alice-vpn.mobileconfig
``` ```
## Self Signed Ceritifcate ---
The following command will create a *full-featured* self-signed certificate that can act as CA certificate and be used for client and server authentication: ## Self-signed certificate
The following command creates a full-featured self-signed certificate usable as a CA and for client/server authentication:
```bash ```bash
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
+7 -2
View File
@@ -67,6 +67,7 @@ def main():
g_vpn = parser.add_argument_group("VPN (optional, requires client certificate)") g_vpn = parser.add_argument_group("VPN (optional, requires client certificate)")
g_vpn.add_argument("--remote-address", metavar="FQDN", help="VPN gateway FQDN") g_vpn.add_argument("--remote-address", metavar="FQDN", help="VPN gateway FQDN")
g_vpn.add_argument("--dns", metavar="IP", nargs="+", help="DNS server(s) for split DNS")
g_vpn.add_argument("--match-domains", metavar="DOMAIN", nargs="+", help="Split DNS domains") g_vpn.add_argument("--match-domains", metavar="DOMAIN", nargs="+", help="Split DNS domains")
g_meta = parser.add_argument_group("Profile metadata") g_meta = parser.add_argument_group("Profile metadata")
@@ -91,7 +92,7 @@ def main():
if args.client_key and not args.client_cert: if args.client_key and not args.client_cert:
parser.error("--client-cert is required when --client-key is specified") parser.error("--client-cert is required when --client-key is specified")
vpn_args = [args.remote_address, args.match_domains] vpn_args = [args.remote_address, args.dns, args.match_domains]
if any(vpn_args) and not all(vpn_args): if any(vpn_args) and not all(vpn_args):
parser.error("--remote-address and --match-domains must be specified together") parser.error("--remote-address and --match-domains must be specified together")
if args.remote_address and not args.client_cert: if args.remote_address and not args.client_cert:
@@ -164,9 +165,13 @@ def main():
"AuthenticationMethod": "None", "AuthenticationMethod": "None",
"ExtendedAuthEnabled": 1, "ExtendedAuthEnabled": 1,
"PayloadCertificateUUID": uuid_cert, "PayloadCertificateUUID": uuid_cert,
"SupplementalMatchDomains": args.match_domains,
"OnDemandEnabled": 0, "OnDemandEnabled": 0,
}, },
"DNS": {
"ServerAddresses": args.dns,
"SupplementalMatchDomains": args.match_domains,
"SupplementalMatchDomainsNoSearch": 1,
},
}) })
profile = { profile = {
+44 -8
View File
@@ -41,6 +41,13 @@ reset_dirs() {
SIMPLE_CA_DIR="" SIMPLE_CA_DIR=""
} }
assert_file() {
if [[ ! -f "$1" ]]; then
echo "ERROR: expected file not found: $1" >&2
exit 1
fi
}
verify_cert() { verify_cert() {
local CERT_PATH="$1" local CERT_PATH="$1"
if ! openssl verify -CAfile "$CA_DIR/ca_bundle.pem" "$CERT_PATH" 2>/dev/null; then if ! openssl verify -CAfile "$CA_DIR/ca_bundle.pem" "$CERT_PATH" 2>/dev/null; then
@@ -50,6 +57,18 @@ verify_cert() {
echo "Verified: $CERT_PATH" echo "Verified: $CERT_PATH"
} }
assert_eku() {
local CERT_PATH="$1"
local EKU="$2"
local TEXT
TEXT="$(openssl x509 -in "$CERT_PATH" -noout -text 2>/dev/null)"
if ! echo "$TEXT" | grep -q "$EKU"; then
echo "ERROR: EKU '$EKU' not found in $CERT_PATH" >&2
exit 1
fi
echo "EKU OK: $EKU"
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Standalone CA — certs issued by root CA go into CA_DIR # Standalone CA — certs issued by root CA go into CA_DIR
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -58,12 +77,12 @@ echo
echo "--- [shell] Standalone CA ---" echo "--- [shell] Standalone CA ---"
reset_dirs reset_dirs
make_ca --ca-dir "$CA_DIR" "Test CA" 2>/dev/null make_ca --ca-dir "$CA_DIR" "Test CA" 2>/dev/null
[[ -f "$CA_DIR/ca_cert.pem" ]] || { echo "ERROR: ca_cert.pem not created" >&2; exit 1; } assert_file "$CA_DIR/ca_cert.pem"
[[ -f "$CA_DIR/ca_bundle.pem" ]] || { echo "ERROR: ca_bundle.pem not created" >&2; exit 1; } assert_file "$CA_DIR/ca_bundle.pem"
verify_cert "$CA_DIR/ca_cert.pem" verify_cert "$CA_DIR/ca_cert.pem"
make_cert "test" "test.example.com" "127.0.0.1" 2>/dev/null make_cert "test" "test.example.com" "127.0.0.1" 2>/dev/null
[[ -f "$CA_DIR/test_cert.pem" ]] || { echo "ERROR: test_cert.pem not created in CA_DIR" >&2; exit 1; } assert_file "$CA_DIR/test_cert.pem"
verify_cert "$CA_DIR/test_cert.pem" verify_cert "$CA_DIR/test_cert.pem"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -77,18 +96,35 @@ make_ca --ca-dir "$CA_DIR" "Test Root CA" 2>/dev/null
verify_cert "$CA_DIR/ca_cert.pem" verify_cert "$CA_DIR/ca_cert.pem"
make_ca --issuing-ca "issuing_ca" "Issuing CA" 2>/dev/null make_ca --issuing-ca "issuing_ca" "Issuing CA" 2>/dev/null
[[ -f "$CA_DIR/issuing_ca/ca_cert.pem" ]] || { echo "ERROR: issuing_ca/ca_cert.pem not created" >&2; exit 1; } assert_file "$CA_DIR/issuing_ca/ca_cert.pem"
verify_cert "$CA_DIR/issuing_ca/ca_cert.pem" verify_cert "$CA_DIR/issuing_ca/ca_cert.pem"
make_cert --issuing-ca "issuing_ca" "test" "test.example.com" "127.0.0.1" 2>/dev/null make_cert --issuing-ca "issuing_ca" "test" "test.example.com" "127.0.0.1" 2>/dev/null
[[ -f "$CA_DIR/issuing_ca/test_cert.pem" ]] || { echo "ERROR: issuing_ca/test_cert.pem not created" >&2; exit 1; } assert_file "$CA_DIR/issuing_ca/test_cert.pem"
verify_cert "$CA_DIR/issuing_ca/test_cert.pem" verify_cert "$CA_DIR/issuing_ca/test_cert.pem"
assert_eku "$CA_DIR/issuing_ca/test_cert.pem" "TLS Web Server Authentication"
assert_eku "$CA_DIR/issuing_ca/test_cert.pem" "TLS Web Client Authentication"
make_pfx --issuing-ca "issuing_ca" --password "s3cr3t" "$CA_DIR/issuing_ca/test_cert.pem" 2>/dev/null make_pfx --issuing-ca "issuing_ca" --password "s3cr3t" "$CA_DIR/issuing_ca/test_cert.pem" 2>/dev/null
[[ -f "$CA_DIR/issuing_ca/test.pfx" ]] || { echo "ERROR: issuing_ca/test.pfx not created" >&2; exit 1; } assert_file "$CA_DIR/issuing_ca/test.pfx"
openssl pkcs12 -in "$CA_DIR/issuing_ca/test.pfx" -noout -info -password pass:"s3cr3t" 2>/dev/null \ if ! openssl pkcs12 -in "$CA_DIR/issuing_ca/test.pfx" -noout -info -password pass:"s3cr3t" 2>/dev/null; then
|| { echo "ERROR: PFX verification failed" >&2; exit 1; } echo "ERROR: PFX verification failed" >&2
exit 1
fi
echo "PFX: OK" echo "PFX: OK"
# ---------------------------------------------------------------------------
# User certificate
# ---------------------------------------------------------------------------
echo
echo "--- [shell] User certificate ---"
make_cert --issuing-ca "issuing_ca" --type user "Alice Example" "alice@example.com" 2>/dev/null
assert_file "$CA_DIR/issuing_ca/Alice Example_cert.pem"
verify_cert "$CA_DIR/issuing_ca/Alice Example_cert.pem"
assert_eku "$CA_DIR/issuing_ca/Alice Example_cert.pem" "TLS Web Client Authentication"
assert_eku "$CA_DIR/issuing_ca/Alice Example_cert.pem" "E-mail Protection"
assert_eku "$CA_DIR/issuing_ca/Alice Example_cert.pem" "Code Signing"
echo echo
echo "All shell tests passed." echo "All shell tests passed."
+72 -31
View File
@@ -39,7 +39,7 @@
SIMPLE_CA_DIR="${SIMPLE_CA_DIR:-}" SIMPLE_CA_DIR="${SIMPLE_CA_DIR:-}"
function _rebuild_ca_bundle() { _rebuild_ca_bundle() {
local BUNDLE="$SIMPLE_CA_DIR/ca_bundle.pem" local BUNDLE="$SIMPLE_CA_DIR/ca_bundle.pem"
cat "$SIMPLE_CA_DIR/ca_cert.pem" > "$BUNDLE" cat "$SIMPLE_CA_DIR/ca_cert.pem" > "$BUNDLE"
for issuing_ca_dir in $SIMPLE_CA_DIR/*; do for issuing_ca_dir in $SIMPLE_CA_DIR/*; do
@@ -49,7 +49,7 @@ function _rebuild_ca_bundle() {
done done
} }
function _require_ca_dir() { _require_ca_dir() {
SIMPLE_CA_DIR="${SIMPLE_CA_DIR:-$(pwd)}" SIMPLE_CA_DIR="${SIMPLE_CA_DIR:-$(pwd)}"
if [[ ! -d "$SIMPLE_CA_DIR" ]]; then if [[ ! -d "$SIMPLE_CA_DIR" ]]; then
echo "ERROR: CA directory '$SIMPLE_CA_DIR' does not exist." >&2 echo "ERROR: CA directory '$SIMPLE_CA_DIR' does not exist." >&2
@@ -57,10 +57,11 @@ function _require_ca_dir() {
fi fi
} }
function _is_ip() { [[ "$1" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; } _is_ip() { [[ "$1" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; }
function _is_dns() { [[ "$1" =~ ^[a-z0-9-]+(\.[a-z0-9-]+)*$ ]]; } _is_dns() { [[ "$1" =~ ^[a-z0-9-]+(\.[a-z0-9-]+)*$ ]]; }
_is_email() { [[ "$1" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; }
function make_ca() { make_ca() {
local CA_DAYS=3650 local CA_DAYS=3650
local ISSUING_CA="" local ISSUING_CA=""
local AIA_BASE_URL="" local AIA_BASE_URL=""
@@ -183,10 +184,11 @@ function make_ca() {
return 0 return 0
} }
function make_cert() { make_cert() {
local ISSUING_CA="" local ISSUING_CA=""
local CERT_DAYS=365 local CERT_DAYS=365
local CERT_DIR="" local CERT_DIR=""
local CERT_TYPE="server"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@@ -218,6 +220,16 @@ function make_cert() {
return 1 return 1
fi fi
CERT_DAYS="$2"; shift 2 ;; CERT_DAYS="$2"; shift 2 ;;
--type)
if [[ -z "$2" ]]; then
echo "ERROR: --type requires a value." >&2
return 1
fi
if [[ "$2" != "server" && "$2" != "user" ]]; then
echo "ERROR: --type must be 'server' or 'user'." >&2
return 1
fi
CERT_TYPE="$2"; shift 2 ;;
*) break ;; *) break ;;
esac esac
done done
@@ -233,7 +245,7 @@ function make_cert() {
echo "ERROR: Subject name is required." >&2 echo "ERROR: Subject name is required." >&2
return 1 return 1
fi fi
if ! _is_dns "$CERT_SUBJECT_NAME"; then if [[ "$CERT_TYPE" == "server" ]] && ! _is_dns "$CERT_SUBJECT_NAME"; then
echo "ERROR: Invalid subject name '$CERT_SUBJECT_NAME'. Must be a valid DNS name." >&2 echo "ERROR: Invalid subject name '$CERT_SUBJECT_NAME'. Must be a valid DNS name." >&2
return 1 return 1
fi fi
@@ -256,11 +268,17 @@ function make_cert() {
fi fi
local CERT_NAME="${CERT_SUBJECT_NAME%%.*}" local CERT_NAME="${CERT_SUBJECT_NAME%%.*}"
local SANS=("DNS:${CERT_SUBJECT_NAME}") local SANS=()
if [[ "$CERT_TYPE" == "server" ]]; then
SANS=("DNS:${CERT_SUBJECT_NAME}")
fi
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
if _is_ip "$1"; then if _is_ip "$1"; then
SANS+=("IP:$1") SANS+=("IP:$1")
elif _is_email "$1"; then
SANS+=("email:$1")
elif _is_dns "$1"; then elif _is_dns "$1"; then
SANS+=("DNS:$1") SANS+=("DNS:$1")
else else
@@ -270,7 +288,11 @@ function make_cert() {
shift shift
done done
echo "Generating server certificate for '$CERT_SUBJECT_NAME' with SANs:" if [[ "$CERT_TYPE" == "server" ]]; then
echo "Generating server certificate for '$CERT_SUBJECT_NAME' with SANs:"
else
echo "Generating user certificate for '$CERT_SUBJECT_NAME':"
fi
for san in "${SANS[@]}"; do echo " - $san"; done for san in "${SANS[@]}"; do echo " - $san"; done
if [[ -f "$CERT_DIR/${CERT_NAME}_cert.pem" && -f "$CERT_DIR/${CERT_NAME}_key.pem" ]]; then if [[ -f "$CERT_DIR/${CERT_NAME}_cert.pem" && -f "$CERT_DIR/${CERT_NAME}_key.pem" ]]; then
@@ -278,33 +300,52 @@ function make_cert() {
return 0 return 0
fi fi
local SANS_EXT="subjectAltName=$(IFS=,; echo "${SANS[*]}")" local REQ_ARGS=(
-newkey rsa:4096
-keyout "$CERT_DIR/${CERT_NAME}_key.pem"
-noenc
-subj "/CN=${CERT_SUBJECT_NAME}"
-addext "basicConstraints=critical,CA:FALSE"
)
echo "Generating server certificate and key..." local X509_ARGS=(
if ! openssl req \ -req
-newkey rsa:4096 \ -CA "$SIGNING_CERT"
-keyout "$CERT_DIR/${CERT_NAME}_key.pem" \ -CAkey "$SIGNING_KEY"
-noenc \ -copy_extensions copyall
-subj "/CN=${CERT_SUBJECT_NAME}" \ -days "$CERT_DAYS"
-addext "basicConstraints=critical,CA:FALSE" \ -text
-addext "keyUsage=critical,digitalSignature,keyEncipherment" \ -out "$CERT_DIR/${CERT_NAME}_cert.pem"
-addext "extendedKeyUsage=serverAuth,clientAuth" \ )
-addext "$SANS_EXT" \
${AIA_URL:+-addext "authorityInfoAccess=caIssuers;URI:${AIA_URL}"} \ if [[ "$CERT_TYPE" == "server" ]]; then
| openssl x509 \ REQ_ARGS+=(
-req \ -addext "keyUsage=critical,digitalSignature,keyEncipherment"
-CA "$SIGNING_CERT" \ -addext "extendedKeyUsage=serverAuth,clientAuth"
-CAkey "$SIGNING_KEY" \ -addext "subjectAltName=$(IFS=,; echo "${SANS[*]}")"
-copy_extensions copyall \ )
-days "$CERT_DAYS" \ else
-text \ REQ_ARGS+=(
-out "$CERT_DIR/${CERT_NAME}_cert.pem"; then -addext "keyUsage=critical,digitalSignature,nonRepudiation"
echo "ERROR: Failed to generate server certificate and key." >&2 -addext "extendedKeyUsage=clientAuth,emailProtection,codeSigning"
)
if [[ ${#SANS[@]} -gt 0 ]]; then
REQ_ARGS+=(-addext "subjectAltName=$(IFS=,; echo "${SANS[*]}")")
fi
fi
if [[ -n "$AIA_URL" ]]; then
REQ_ARGS+=(-addext "authorityInfoAccess=caIssuers;URI:${AIA_URL}")
fi
echo "Generating certificate and key..."
if ! openssl req "${REQ_ARGS[@]}" | openssl x509 "${X509_ARGS[@]}"; then
echo "ERROR: Failed to generate certificate and key." >&2
return 1 return 1
fi fi
} }
function make_pfx() { make_pfx() {
local ISSUING_CA="" local ISSUING_CA=""
local PFX_PASSWORD="" local PFX_PASSWORD=""
local APPLE_OPENSSL=0 local APPLE_OPENSSL=0
+57 -20
View File
@@ -286,21 +286,25 @@ func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string) error
// ---- makeCert --------------------------------------------------------------- // ---- makeCert ---------------------------------------------------------------
var ( var (
ipRE = regexp.MustCompile(`^[0-9]{1,3}(\.[0-9]{1,3}){3}$`) ipRE = regexp.MustCompile(`^[0-9]{1,3}(\.[0-9]{1,3}){3}$`)
dnsRE = regexp.MustCompile(`^[a-z0-9-]+(\.[a-z0-9-]+)*$`) dnsRE = regexp.MustCompile(`^[a-z0-9-]+(\.[a-z0-9-]+)*$`)
emailRE = regexp.MustCompile(`^[^@]+@[^@]+\.[^@]+$`)
) )
func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA string, days int) error { func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA, certType string, days int) error {
if issuingCA == "ca" { if issuingCA == "ca" {
return errors.New("--issuing-ca cannot be 'ca'") return errors.New("--issuing-ca cannot be 'ca'")
} }
if certType != "server" && certType != "user" {
return fmt.Errorf("--type must be 'server' or 'user', got '%s'", certType)
}
if !dirExists(caDir) { if !dirExists(caDir) {
return fmt.Errorf("CA directory %s does not exist", caDir) return fmt.Errorf("CA directory %s does not exist", caDir)
} }
if subjectName == "" { if subjectName == "" {
return errors.New("subject name is required") return errors.New("subject name is required")
} }
if !dnsRE.MatchString(subjectName) { if certType == "server" && !dnsRE.MatchString(subjectName) {
return fmt.Errorf("invalid subject name '%s'. Must be a valid DNS name", subjectName) return fmt.Errorf("invalid subject name '%s'. Must be a valid DNS name", subjectName)
} }
@@ -337,12 +341,20 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA strin
certName = certName[:i] certName = certName[:i]
} }
dnsNames := []string{subjectName} var dnsNames []string
var emails []string
var ips []net.IP var ips []net.IP
if certType == "server" {
dnsNames = []string{subjectName}
}
for _, entry := range sans { for _, entry := range sans {
switch { switch {
case ipRE.MatchString(entry): case ipRE.MatchString(entry):
ips = append(ips, net.ParseIP(entry)) ips = append(ips, net.ParseIP(entry))
case emailRE.MatchString(entry):
emails = append(emails, entry)
case dnsRE.MatchString(entry): case dnsRE.MatchString(entry):
dnsNames = append(dnsNames, entry) dnsNames = append(dnsNames, entry)
default: default:
@@ -350,10 +362,17 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA strin
} }
} }
fmt.Printf("Generating server certificate for '%s' with SANs:\n", subjectName) if certType == "server" {
fmt.Printf("Generating server certificate for '%s' with SANs:\n", subjectName)
} else {
fmt.Printf("Generating user certificate for '%s':\n", subjectName)
}
for _, dns := range dnsNames { for _, dns := range dnsNames {
fmt.Printf(" - DNS:%s\n", dns) fmt.Printf(" - DNS:%s\n", dns)
} }
for _, email := range emails {
fmt.Printf(" - email:%s\n", email)
}
for _, ip := range ips { for _, ip := range ips {
fmt.Printf(" - IP:%s\n", ip) fmt.Printf(" - IP:%s\n", ip)
} }
@@ -365,7 +384,7 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA strin
return nil return nil
} }
fmt.Println("Generating server certificate and key...") fmt.Println("Generating certificate and key...")
caCert, err := loadCert(caCertPath) caCert, err := loadCert(caCertPath)
if err != nil { if err != nil {
return err return err
@@ -383,18 +402,35 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA strin
return err return err
} }
now := time.Now().UTC() now := time.Now().UTC()
tmpl := &x509.Certificate{
SerialNumber: serial, var tmpl *x509.Certificate
Subject: pkix.Name{CommonName: subjectName}, if certType == "server" {
NotBefore: now, tmpl = &x509.Certificate{
NotAfter: now.AddDate(0, 0, days), SerialNumber: serial,
IsCA: false, Subject: pkix.Name{CommonName: subjectName},
BasicConstraintsValid: true, NotBefore: now,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, NotAfter: now.AddDate(0, 0, days),
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, BasicConstraintsValid: true,
DNSNames: dnsNames, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
IPAddresses: ips, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
DNSNames: dnsNames,
IPAddresses: ips,
}
} else {
tmpl = &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: subjectName},
NotBefore: now,
NotAfter: now.AddDate(0, 0, days),
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageContentCommitment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageEmailProtection, x509.ExtKeyUsageCodeSigning},
EmailAddresses: emails,
DNSNames: dnsNames,
IPAddresses: ips,
}
} }
if aiaURL != "" { if aiaURL != "" {
tmpl.IssuingCertificateURL = []string{aiaURL} tmpl.IssuingCertificateURL = []string{aiaURL}
} }
@@ -575,19 +611,20 @@ func newMakeCACmd() *cobra.Command {
} }
func newMakeCertCmd() *cobra.Command { func newMakeCertCmd() *cobra.Command {
var certDir, caDir, issuingCA string var certDir, caDir, issuingCA, certType string
var days int var days int
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "make-cert SUBJECT [SAN...]", Use: "make-cert SUBJECT [SAN...]",
Short: "Create a server/client certificate signed by the CA.", Short: "Create a server/client certificate signed by the CA.",
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { RunE: func(_ *cobra.Command, args []string) error {
return makeCert(args[0], args[1:], resolveCADir(caDir), certDir, issuingCA, days) return makeCert(args[0], args[1:], resolveCADir(caDir), certDir, issuingCA, certType, days)
}, },
} }
cmd.Flags().StringVar(&certDir, "cert-dir", "", "output directory (default: signing CA directory)") cmd.Flags().StringVar(&certDir, "cert-dir", "", "output directory (default: signing CA directory)")
cmd.Flags().StringVar(&caDir, "ca-dir", "", "CA root directory") cmd.Flags().StringVar(&caDir, "ca-dir", "", "CA root directory")
cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "issuing CA directory name") cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "issuing CA directory name")
cmd.Flags().StringVar(&certType, "type", "server", "certificate type: server or user")
cmd.Flags().IntVar(&days, "days", 365, "validity period in days") cmd.Flags().IntVar(&days, "days", 365, "validity period in days")
return cmd return cmd
} }
+41 -4
View File
@@ -4,6 +4,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -15,6 +16,17 @@ func verifyCert(t *testing.T, bundle, cert string) {
} }
} }
func assertEKU(t *testing.T, cert, eku string) {
t.Helper()
out, err := exec.Command("openssl", "x509", "-in", cert, "-noout", "-text").CombinedOutput()
if err != nil {
t.Fatalf("openssl x509 failed: %v", err)
}
if !strings.Contains(string(out), eku) {
t.Fatalf("EKU %q not found in %s", eku, cert)
}
}
func TestStandaloneCA(t *testing.T) { func TestStandaloneCA(t *testing.T) {
caDir := t.TempDir() caDir := t.TempDir()
@@ -29,7 +41,7 @@ func TestStandaloneCA(t *testing.T) {
} }
verifyCert(t, filepath.Join(caDir, "ca_bundle.pem"), filepath.Join(caDir, "ca_cert.pem")) verifyCert(t, filepath.Join(caDir, "ca_bundle.pem"), filepath.Join(caDir, "ca_cert.pem"))
if err := makeCert("test", []string{"test.example.com", "127.0.0.1"}, caDir, "", "", 365); err != nil { if err := makeCert("test", []string{"test.example.com", "127.0.0.1"}, caDir, "", "", "server", 365); err != nil {
t.Fatalf("makeCert: %v", err) t.Fatalf("makeCert: %v", err)
} }
certPath := filepath.Join(caDir, "test_cert.pem") certPath := filepath.Join(caDir, "test_cert.pem")
@@ -56,7 +68,7 @@ func TestTwoLevelCA(t *testing.T) {
} }
verifyCert(t, filepath.Join(caDir, "ca_bundle.pem"), issuingCert) verifyCert(t, filepath.Join(caDir, "ca_bundle.pem"), issuingCert)
if err := makeCert("test", []string{"test.example.com", "127.0.0.1"}, caDir, "", "issuing_ca", 365); err != nil { if err := makeCert("test", []string{"test.example.com", "127.0.0.1"}, caDir, "", "issuing_ca", "server", 365); err != nil {
t.Fatalf("makeCert: %v", err) t.Fatalf("makeCert: %v", err)
} }
certPath := filepath.Join(caDir, "issuing_ca", "test_cert.pem") certPath := filepath.Join(caDir, "issuing_ca", "test_cert.pem")
@@ -64,6 +76,8 @@ func TestTwoLevelCA(t *testing.T) {
t.Fatal("issuing_ca/test_cert.pem not created") t.Fatal("issuing_ca/test_cert.pem not created")
} }
verifyCert(t, filepath.Join(caDir, "ca_bundle.pem"), certPath) verifyCert(t, filepath.Join(caDir, "ca_bundle.pem"), certPath)
assertEKU(t, certPath, "TLS Web Server Authentication")
assertEKU(t, certPath, "TLS Web Client Authentication")
if err := makePFX(certPath, caDir, "issuing_ca", "s3cr3t", false); err != nil { if err := makePFX(certPath, caDir, "issuing_ca", "s3cr3t", false); err != nil {
t.Fatalf("makePFX: %v", err) t.Fatalf("makePFX: %v", err)
@@ -79,6 +93,29 @@ func TestTwoLevelCA(t *testing.T) {
} }
} }
func TestUserCert(t *testing.T) {
caDir := t.TempDir()
if err := makeCA(caDir, "Test CA", 3650, "", ""); err != nil {
t.Fatalf("makeCA: %v", err)
}
if err := makeCA(caDir, "Issuing CA", 3650, "issuing_ca", ""); err != nil {
t.Fatalf("makeCA issuing: %v", err)
}
if err := makeCert("Alice Example", []string{"alice@example.com"}, caDir, "", "issuing_ca", "user", 365); err != nil {
t.Fatalf("makeCert user: %v", err)
}
certPath := filepath.Join(caDir, "issuing_ca", "Alice Example_cert.pem")
if !fileExists(certPath) {
t.Fatal("Alice Example_cert.pem not created")
}
verifyCert(t, filepath.Join(caDir, "ca_bundle.pem"), certPath)
assertEKU(t, certPath, "TLS Web Client Authentication")
assertEKU(t, certPath, "E-mail Protection")
assertEKU(t, certPath, "Code Signing")
}
func TestCertDirOverride(t *testing.T) { func TestCertDirOverride(t *testing.T) {
caDir := t.TempDir() caDir := t.TempDir()
certDir := t.TempDir() certDir := t.TempDir()
@@ -86,7 +123,7 @@ func TestCertDirOverride(t *testing.T) {
if err := makeCA(caDir, "Test CA", 3650, "", ""); err != nil { if err := makeCA(caDir, "Test CA", 3650, "", ""); err != nil {
t.Fatalf("makeCA: %v", err) t.Fatalf("makeCA: %v", err)
} }
if err := makeCert("test", []string{"test.example.com"}, caDir, certDir, "", 365); err != nil { if err := makeCert("test", []string{"test.example.com"}, caDir, certDir, "", "server", 365); err != nil {
t.Fatalf("makeCert: %v", err) t.Fatalf("makeCert: %v", err)
} }
certPath := filepath.Join(certDir, "test_cert.pem") certPath := filepath.Join(certDir, "test_cert.pem")
@@ -109,7 +146,7 @@ func TestAppleOpenSSL(t *testing.T) {
if err := makeCA(caDir, "Issuing CA", 3650, "issuing_ca", ""); err != nil { if err := makeCA(caDir, "Issuing CA", 3650, "issuing_ca", ""); err != nil {
t.Fatalf("makeCA issuing: %v", err) t.Fatalf("makeCA issuing: %v", err)
} }
if err := makeCert("test", []string{"test.example.com"}, caDir, "", "issuing_ca", 365); err != nil { if err := makeCert("test", []string{"test.example.com"}, caDir, "", "issuing_ca", "server", 365); err != nil {
t.Fatalf("makeCert: %v", err) t.Fatalf("makeCert: %v", err)
} }
certPath := filepath.Join(caDir, "issuing_ca", "test_cert.pem") certPath := filepath.Join(caDir, "issuing_ca", "test_cert.pem")