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
[![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.
Usage:
```bash
make_ca [--days <validity_days>] [--issuing-ca <name>] <ca_directory> <ca_name>
```
CA_DIR/
ca_cert.pem root CA certificate
ca_key.pem root CA private key
ca_bundle.pem root + all issuing CA certs concatenated
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.
- `<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`).
Python additionally writes:
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.
### `make_cert()`
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).
Usage:
```bash
make_cert --ca-dir <ca_directory> [--days <validity_days>] [--issuing-ca <name>] <cert_directory> <subject_name>
```
CA_DIR/
simple-ca.json config + certificate history
crl.pem root CA CRL (make-crl)
{issuing_ca}/
crl.pem issuing CA CRL (make-crl)
```
- `<ca_directory>`: The directory where the CA files are stored (used to find the CA certificate and key for signing).
- `<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.
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.
### `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
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).
- `--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.
- `--password <pfx_password>`: Optional. The custom password to protect the PFX, instead of the default `changeit`.
### `make_ca`
```bash
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` 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
@@ -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` + `--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
@@ -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 \
--identifier com.example.vpn \
[--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"] \
[--client-name "My Cert"] [--vpn-name "My VPN Connection"] \
[--openssl /usr/bin/openssl]
[--client-name "My Cert"] [--vpn-name "My VPN Connection"]
```
#### Required arguments
@@ -94,6 +201,7 @@ generate-mobileconfig.py --ca-cert CA.pem --output profile.mobileconfig \
#### VPN (requires client certificate)
- `--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.
#### 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).
- `--vpn-name NAME` — VPN connection display name (default: profile name).
#### Other
- `--openssl PATH` — Path to the `openssl` binary (default: `/usr/bin/openssl`).
### Examples
**CA trust profile only:**
@@ -137,13 +241,16 @@ python3 generate-mobileconfig.py \
--client-cert certs/alice_cert.pem \
--client-key certs/alice_key.pem \
--remote-address vpn.example.com \
--dns 10.0.0.1 \
--match-domains example.com internal.example.com \
--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
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.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_meta = parser.add_argument_group("Profile metadata")
@@ -91,7 +92,7 @@ def main():
if args.client_key and not args.client_cert:
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):
parser.error("--remote-address and --match-domains must be specified together")
if args.remote_address and not args.client_cert:
@@ -164,9 +165,13 @@ def main():
"AuthenticationMethod": "None",
"ExtendedAuthEnabled": 1,
"PayloadCertificateUUID": uuid_cert,
"SupplementalMatchDomains": args.match_domains,
"OnDemandEnabled": 0,
},
"DNS": {
"ServerAddresses": args.dns,
"SupplementalMatchDomains": args.match_domains,
"SupplementalMatchDomainsNoSearch": 1,
},
})
profile = {
+44 -8
View File
@@ -41,6 +41,13 @@ reset_dirs() {
SIMPLE_CA_DIR=""
}
assert_file() {
if [[ ! -f "$1" ]]; then
echo "ERROR: expected file not found: $1" >&2
exit 1
fi
}
verify_cert() {
local CERT_PATH="$1"
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"
}
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
# ---------------------------------------------------------------------------
@@ -58,12 +77,12 @@ echo
echo "--- [shell] Standalone CA ---"
reset_dirs
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; }
[[ -f "$CA_DIR/ca_bundle.pem" ]] || { echo "ERROR: ca_bundle.pem not created" >&2; exit 1; }
assert_file "$CA_DIR/ca_cert.pem"
assert_file "$CA_DIR/ca_bundle.pem"
verify_cert "$CA_DIR/ca_cert.pem"
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"
# ---------------------------------------------------------------------------
@@ -77,18 +96,35 @@ make_ca --ca-dir "$CA_DIR" "Test Root CA" 2>/dev/null
verify_cert "$CA_DIR/ca_cert.pem"
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"
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"
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
[[ -f "$CA_DIR/issuing_ca/test.pfx" ]] || { echo "ERROR: issuing_ca/test.pfx not created" >&2; exit 1; }
openssl pkcs12 -in "$CA_DIR/issuing_ca/test.pfx" -noout -info -password pass:"s3cr3t" 2>/dev/null \
|| { echo "ERROR: PFX verification failed" >&2; exit 1; }
assert_file "$CA_DIR/issuing_ca/test.pfx"
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
fi
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 "All shell tests passed."
+71 -30
View File
@@ -39,7 +39,7 @@
SIMPLE_CA_DIR="${SIMPLE_CA_DIR:-}"
function _rebuild_ca_bundle() {
_rebuild_ca_bundle() {
local BUNDLE="$SIMPLE_CA_DIR/ca_bundle.pem"
cat "$SIMPLE_CA_DIR/ca_cert.pem" > "$BUNDLE"
for issuing_ca_dir in $SIMPLE_CA_DIR/*; do
@@ -49,7 +49,7 @@ function _rebuild_ca_bundle() {
done
}
function _require_ca_dir() {
_require_ca_dir() {
SIMPLE_CA_DIR="${SIMPLE_CA_DIR:-$(pwd)}"
if [[ ! -d "$SIMPLE_CA_DIR" ]]; then
echo "ERROR: CA directory '$SIMPLE_CA_DIR' does not exist." >&2
@@ -57,10 +57,11 @@ function _require_ca_dir() {
fi
}
function _is_ip() { [[ "$1" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; }
function _is_dns() { [[ "$1" =~ ^[a-z0-9-]+(\.[a-z0-9-]+)*$ ]]; }
_is_ip() { [[ "$1" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; }
_is_dns() { [[ "$1" =~ ^[a-z0-9-]+(\.[a-z0-9-]+)*$ ]]; }
_is_email() { [[ "$1" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; }
function make_ca() {
make_ca() {
local CA_DAYS=3650
local ISSUING_CA=""
local AIA_BASE_URL=""
@@ -183,10 +184,11 @@ function make_ca() {
return 0
}
function make_cert() {
make_cert() {
local ISSUING_CA=""
local CERT_DAYS=365
local CERT_DIR=""
local CERT_TYPE="server"
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -218,6 +220,16 @@ function make_cert() {
return 1
fi
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 ;;
esac
done
@@ -233,7 +245,7 @@ function make_cert() {
echo "ERROR: Subject name is required." >&2
return 1
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
return 1
fi
@@ -256,11 +268,17 @@ function make_cert() {
fi
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
if _is_ip "$1"; then
SANS+=("IP:$1")
elif _is_email "$1"; then
SANS+=("email:$1")
elif _is_dns "$1"; then
SANS+=("DNS:$1")
else
@@ -270,7 +288,11 @@ function make_cert() {
shift
done
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
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
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..."
if ! openssl req \
-newkey rsa:4096 \
-keyout "$CERT_DIR/${CERT_NAME}_key.pem" \
-noenc \
-subj "/CN=${CERT_SUBJECT_NAME}" \
-addext "basicConstraints=critical,CA:FALSE" \
-addext "keyUsage=critical,digitalSignature,keyEncipherment" \
-addext "extendedKeyUsage=serverAuth,clientAuth" \
-addext "$SANS_EXT" \
${AIA_URL:+-addext "authorityInfoAccess=caIssuers;URI:${AIA_URL}"} \
| openssl x509 \
-req \
-CA "$SIGNING_CERT" \
-CAkey "$SIGNING_KEY" \
-copy_extensions copyall \
-days "$CERT_DAYS" \
-text \
-out "$CERT_DIR/${CERT_NAME}_cert.pem"; then
echo "ERROR: Failed to generate server certificate and key." >&2
local X509_ARGS=(
-req
-CA "$SIGNING_CERT"
-CAkey "$SIGNING_KEY"
-copy_extensions copyall
-days "$CERT_DAYS"
-text
-out "$CERT_DIR/${CERT_NAME}_cert.pem"
)
if [[ "$CERT_TYPE" == "server" ]]; then
REQ_ARGS+=(
-addext "keyUsage=critical,digitalSignature,keyEncipherment"
-addext "extendedKeyUsage=serverAuth,clientAuth"
-addext "subjectAltName=$(IFS=,; echo "${SANS[*]}")"
)
else
REQ_ARGS+=(
-addext "keyUsage=critical,digitalSignature,nonRepudiation"
-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
fi
}
function make_pfx() {
make_pfx() {
local ISSUING_CA=""
local PFX_PASSWORD=""
local APPLE_OPENSSL=0
+45 -8
View File
@@ -288,19 +288,23 @@ func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string) error
var (
ipRE = regexp.MustCompile(`^[0-9]{1,3}(\.[0-9]{1,3}){3}$`)
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" {
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) {
return fmt.Errorf("CA directory %s does not exist", caDir)
}
if subjectName == "" {
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)
}
@@ -337,12 +341,20 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA strin
certName = certName[:i]
}
dnsNames := []string{subjectName}
var dnsNames []string
var emails []string
var ips []net.IP
if certType == "server" {
dnsNames = []string{subjectName}
}
for _, entry := range sans {
switch {
case ipRE.MatchString(entry):
ips = append(ips, net.ParseIP(entry))
case emailRE.MatchString(entry):
emails = append(emails, entry)
case dnsRE.MatchString(entry):
dnsNames = append(dnsNames, entry)
default:
@@ -350,10 +362,17 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA strin
}
}
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 {
fmt.Printf(" - DNS:%s\n", dns)
}
for _, email := range emails {
fmt.Printf(" - email:%s\n", email)
}
for _, ip := range ips {
fmt.Printf(" - IP:%s\n", ip)
}
@@ -365,7 +384,7 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA strin
return nil
}
fmt.Println("Generating server certificate and key...")
fmt.Println("Generating certificate and key...")
caCert, err := loadCert(caCertPath)
if err != nil {
return err
@@ -383,18 +402,35 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA strin
return err
}
now := time.Now().UTC()
tmpl := &x509.Certificate{
var tmpl *x509.Certificate
if certType == "server" {
tmpl = &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: subjectName},
NotBefore: now,
NotAfter: now.AddDate(0, 0, days),
IsCA: false,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
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 != "" {
tmpl.IssuingCertificateURL = []string{aiaURL}
}
@@ -575,19 +611,20 @@ func newMakeCACmd() *cobra.Command {
}
func newMakeCertCmd() *cobra.Command {
var certDir, caDir, issuingCA string
var certDir, caDir, issuingCA, certType string
var days int
cmd := &cobra.Command{
Use: "make-cert SUBJECT [SAN...]",
Short: "Create a server/client certificate signed by the CA.",
Args: cobra.MinimumNArgs(1),
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(&caDir, "ca-dir", "", "CA root directory")
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")
return cmd
}
+41 -4
View File
@@ -4,6 +4,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"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) {
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"))
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)
}
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)
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)
}
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")
}
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 {
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) {
caDir := t.TempDir()
certDir := t.TempDir()
@@ -86,7 +123,7 @@ func TestCertDirOverride(t *testing.T) {
if err := makeCA(caDir, "Test CA", 3650, "", ""); err != nil {
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)
}
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 {
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)
}
certPath := filepath.Join(caDir, "issuing_ca", "test_cert.pem")