diff --git a/activestate.yaml b/activestate.yaml index 731439730c..369cc03bc5 100644 --- a/activestate.yaml +++ b/activestate.yaml @@ -1,4 +1,4 @@ -project: https://platform.activestate.com/ActiveState/cli?branch=main&commitID=9eee7512-b2ab-4600-b78b-ab0cf2e817d8 +project: https://platform.activestate.com/ActiveState/cli?branch=main&commitID=ce30761b-3c98-4d7a-b737-bcb1beeef87c constants: - name: CLI_BUILDFLAGS value: -ldflags="-s -w" @@ -461,6 +461,7 @@ scripts: value: go run $project.path()/scripts/to-buildexpression/main.go $@ - name: serve-org-keys language: bash + if: ne .Shell "cmd" description: Runs a server that serves org keys for testing encrypted private ingredients value: | org="$1" @@ -470,21 +471,43 @@ scripts: exit 1 fi key="$2" - - echo "Generating HTTPS certificate." - if [ ! -d test/ssl ]; then mkdir test/ssl; fi - openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ - -keyout test/ssl/key.pem -out test/ssl/cert.pem \ - -subj "/CN=localhost" \ - -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" 2> /dev/null - + echo "Starting org key server." if [ -z "$key" ]; then echo "Using random encryption key (see below)" - python3 -- scripts/orgkeyserver.py --tls-cert test/ssl/cert.pem --tls-key test/ssl/key.pem --org $org + python3 -- scripts/orgkeyserver.py --org "$org" else - python3 -- scripts/orgkeyserver.py --tls-cert test/ssl/cert.pem --tls-key test/ssl/key.pem --org "$org" --key "$key" + python3 -- scripts/orgkeyserver.py --org "$org" --key "$key" fi + - name: serve-org-keys + language: batch + if: eq .Shell "cmd" + description: Runs a server that serves org keys for testing encrypted private ingredients + value: | + set "org=%~1" + if "%org%"=="" ( + echo Usage: state run serve-org-keys ^ [^] + echo Error: missing organization name + exit /b 1 + ) + set "key=%~2" + + set "PY=" + where python >nul 2>nul && set "PY=python" + if not defined PY (where py >nul 2>nul && set "PY=py -3") + if not defined PY (where python3 >nul 2>nul && set "PY=python3") + if not defined PY ( + echo Error: no Python interpreter found on PATH ^(tried python, py, python3^). + exit /b 1 + ) + + echo Starting org key server. + if "%key%"=="" ( + echo Using random encryption key ^(see below^) + %PY% scripts\orgkeyserver.py --org "%org%" + ) else ( + %PY% scripts\orgkeyserver.py --org "%org%" --key "%key%" + ) events: - name: activate diff --git a/scripts/orgkeyserver.py b/scripts/orgkeyserver.py index a25cdc6ab1..b57c9d4b38 100644 --- a/scripts/orgkeyserver.py +++ b/scripts/orgkeyserver.py @@ -4,21 +4,19 @@ Serves the organization encryption key over HTTPS at GET /v1/org-key in the contract the State Tool expects. For local testing only; not a shipped artifact. -Generate a self-signed certificate (the SAN must cover the host you connect to): - - openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ - -keyout key.pem -out cert.pem \ - -subj "/CN=localhost" \ - -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" +A self-signed certificate/key pair (SAN covering localhost + 127.0.0.1) is +generated automatically into test/ssl/ on startup, so no openssl binary is +needed. Run: - python3 scripts/orgkeyserver.py --tls-cert cert.pem --tls-key key.pem + python3 scripts/orgkeyserver.py -Point the State Tool at it (the base URL only; the tool appends /v1/org-key): +Point the State Tool at it (the base URL only; the tool appends /v1/org-key). +Use the absolute cert path the server prints on startup, e.g.: state config set privateingredient.key_service_url https://127.0.0.1:8443 - state config set privateingredient.key_service_ca /path/to/cert.pem + state config set privateingredient.key_service_ca /path/to/test/ssl/cert.pem # Optional bearer auth (start the server with --token ): state config set privateingredient.bearer_token_env ORGKEY_TOKEN export ORGKEY_TOKEN= @@ -31,6 +29,7 @@ import base64 import hashlib import json +import os import secrets import ssl import sys @@ -38,6 +37,70 @@ KEY_SIZE = 32 # AES-256 ENDPOINT = "/v1/org-key" +CERT_DIR = "test/ssl" + + +def generate_self_signed(host, cert_dir=CERT_DIR): + """Write a self-signed cert/key pair into cert_dir; return their paths. + + Uses the 'cryptography' package (bundled in the project runtime) so no + external openssl binary is required. + """ + import datetime + import ipaddress + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + cert_dir = os.path.abspath(cert_dir) + cert_path = os.path.join(cert_dir, "cert.pem") + key_path = os.path.join(cert_dir, "key.pem") + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + dns_names = ["localhost"] + ip_addrs = ["127.0.0.1"] + try: + ipaddress.ip_address(host) + if host not in ip_addrs: + ip_addrs.append(host) + except ValueError: + if host not in dns_names: + dns_names.append(host) + alt_names = [x509.DNSName(n) for n in dns_names] + alt_names += [x509.IPAddress(ipaddress.ip_address(ip)) for ip in ip_addrs] + now = datetime.datetime.utcnow() + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=1)) + .not_valid_after(now + datetime.timedelta(days=365)) + .add_extension(x509.SubjectAlternativeName(alt_names), critical=False) + .sign(key, hashes.SHA256()) + ) + + if cert_dir: + os.makedirs(cert_dir, exist_ok=True) + with open(key_path, "wb") as f: + f.write(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + )) + # Restrict the private key to owner-only; no-op-ish on Windows. + try: + os.chmod(key_path, 0o600) + except OSError: + pass + with open(cert_path, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + + return cert_path, key_path def build_contract(org, key_id, raw_key): @@ -81,8 +144,6 @@ def parse_key(encoded): def main(): p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - p.add_argument("--tls-cert", required=True, help="server TLS certificate (PEM)") - p.add_argument("--tls-key", required=True, help="server TLS private key (PEM)") p.add_argument("--org", default="ActiveState-CLI-Testing", help="organization the key belongs to; must match the project owner") p.add_argument("--key", help="base64-encoded 32-byte AES key; generated and printed if omitted") @@ -98,9 +159,12 @@ def main(): raw_key = secrets.token_bytes(KEY_SIZE) print("--key", base64.standard_b64encode(raw_key).decode("ascii"), file=sys.stderr) + cert_path, key_path = generate_self_signed(args.host) + print("key_service_ca", cert_path, file=sys.stderr) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.minimum_version = ssl.TLSVersion.TLSv1_2 - ctx.load_cert_chain(certfile=args.tls_cert, keyfile=args.tls_key) + ctx.load_cert_chain(certfile=cert_path, keyfile=key_path) handler = make_handler(build_contract(args.org, args.key_id, raw_key), args.token) httpd = HTTPServer((args.host, args.port), handler)