From 71c11457cde2f7d9766799dfb4ce44eaa872ada1 Mon Sep 17 00:00:00 2001 From: Chewbaccalakis Date: Fri, 18 Sep 2026 19:45:17 -0700 Subject: [PATCH] first build --- .dockerignore | 13 ++ .env.example | 74 +++++++++ .gitignore | 20 +++ Dockerfile | 20 +++ README.md | 54 +++++++ docs/adcs.md | 68 ++++++++ docs/architecture.md | 44 +++++ docs/profile-signing.md | 42 +++++ docs/security.md | 58 +++++++ docs/setup.md | 98 +++++++++++ pyproject.toml | 51 ++++++ src/cert_portal/__init__.py | 3 + src/cert_portal/__main__.py | 29 ++++ src/cert_portal/app.py | 233 +++++++++++++++++++++++++++ src/cert_portal/auth_oidc.py | 65 ++++++++ src/cert_portal/certsrv.py | 181 +++++++++++++++++++++ src/cert_portal/config.py | 97 +++++++++++ src/cert_portal/directory.py | 207 ++++++++++++++++++++++++ src/cert_portal/enrollment.py | 137 ++++++++++++++++ src/cert_portal/exceptions.py | 45 ++++++ src/cert_portal/issue.py | 99 ++++++++++++ src/cert_portal/profile.py | 152 +++++++++++++++++ src/cert_portal/static/style.css | 114 +++++++++++++ src/cert_portal/templates/base.html | 26 +++ src/cert_portal/templates/help.html | 49 ++++++ src/cert_portal/templates/home.html | 35 ++++ src/cert_portal/templates/login.html | 22 +++ tests/conftest.py | 75 +++++++++ tests/fixtures/certfnsh_denied.html | 10 ++ tests/fixtures/certfnsh_issued.html | 12 ++ tests/fixtures/certfnsh_pending.html | 12 ++ tests/fixtures/certfnsh_unknown.html | 8 + tests/helpers.py | 50 ++++++ tests/test_app.py | 134 +++++++++++++++ tests/test_certsrv.py | 36 +++++ tests/test_directory.py | 18 +++ tests/test_enrollment.py | 33 ++++ tests/test_issue.py | 43 +++++ tests/test_profile.py | 55 +++++++ 39 files changed, 2522 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docs/adcs.md create mode 100644 docs/architecture.md create mode 100644 docs/profile-signing.md create mode 100644 docs/security.md create mode 100644 docs/setup.md create mode 100644 pyproject.toml create mode 100644 src/cert_portal/__init__.py create mode 100644 src/cert_portal/__main__.py create mode 100644 src/cert_portal/app.py create mode 100644 src/cert_portal/auth_oidc.py create mode 100644 src/cert_portal/certsrv.py create mode 100644 src/cert_portal/config.py create mode 100644 src/cert_portal/directory.py create mode 100644 src/cert_portal/enrollment.py create mode 100644 src/cert_portal/exceptions.py create mode 100644 src/cert_portal/issue.py create mode 100644 src/cert_portal/profile.py create mode 100644 src/cert_portal/static/style.css create mode 100644 src/cert_portal/templates/base.html create mode 100644 src/cert_portal/templates/help.html create mode 100644 src/cert_portal/templates/home.html create mode 100644 src/cert_portal/templates/login.html create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/certfnsh_denied.html create mode 100644 tests/fixtures/certfnsh_issued.html create mode 100644 tests/fixtures/certfnsh_pending.html create mode 100644 tests/fixtures/certfnsh_unknown.html create mode 100644 tests/helpers.py create mode 100644 tests/test_app.py create mode 100644 tests/test_certsrv.py create mode 100644 tests/test_directory.py create mode 100644 tests/test_enrollment.py create mode 100644 tests/test_issue.py create mode 100644 tests/test_profile.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3ad512a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +.venv +.git +.env +*.p12 +*.pfx +.pytest_cache +.ruff_cache +dist +build +*.egg-info +tests diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d29c8a1 --- /dev/null +++ b/.env.example @@ -0,0 +1,74 @@ +# cert-portal configuration +# Copy to .env and fill in real values. Never commit .env. + +# --- HTTP --- +# Bind address for uvicorn when you run `python -m cert_portal` +HOST=0.0.0.0 +PORT=8000 +# production | development. In production, OpenAPI/Swagger is off unless DOCS_ENABLED=true. +ENVIRONMENT=production +# Expose /docs and /redoc (also forced on when ENVIRONMENT=development). +DOCS_ENABLED=false +# Public URL the browser uses to reach this app (OIDC redirect and cookie security). +PUBLIC_BASE_URL=https://certs.example.com +# Set false only for local http:// testing. Cookies will not be marked Secure. +SESSION_HTTPS_ONLY=true +# Random 32+ byte string used to sign the session cookie. +SESSION_SECRET=change-me-to-a-long-random-string + +# --- OIDC (generic: Keycloak, Okta, Authentik, Entra, ...) --- +# Issuer URL with no trailing slash. Discovery is {issuer}/.well-known/openid-configuration +OIDC_ISSUER=https://idp.example.com/realms/example +OIDC_CLIENT_ID=cert-portal +OIDC_CLIENT_SECRET=change-me +# Must match a redirect URI registered on the client. Path is /auth/callback +OIDC_REDIRECT_URI=https://certs.example.com/auth/callback +OIDC_SCOPES=openid email profile +# Claim used to search LDAP. Falls back to email, then preferred_username, then sub. +OIDC_USERNAME_CLAIM=email + +# --- LDAP / Active Directory (objectSid lookup) --- +# ldaps:// is required in production. ldap:// is accepted only for a lab. +LDAP_URL=ldaps://dc.example.com:636 +LDAP_BIND_DN=CN=cert-portal,OU=Services,DC=example,DC=com +LDAP_BIND_PASSWORD=change-me +LDAP_BASE_DN=DC=example,DC=com +# {username} is replaced with the OIDC username claim. +LDAP_USER_FILTER=(&(objectClass=user)(|(userPrincipalName={username})(mail={username}))) +# Optional. Distinguished name or CN of a group the user must belong to. +# Nested membership uses LDAP_MATCHING_RULE_IN_CHAIN. +LDAP_REQUIRED_GROUP= +# Verify the DC TLS certificate. Set false only in a lab. +LDAP_VERIFY_TLS=true +# Optional path to a CA bundle (PEM) for LDAPS. +LDAP_CA_CERTS_FILE= + +# --- AD CS Web Enrollment (certsrv) --- +# Base URL with no trailing slash. Example: https://ca.example.com/certsrv +CERTSRV_URL=https://ca.example.com/certsrv +CERTSRV_USERNAME=cert-portal +CERTSRV_PASSWORD=change-me +# NetBIOS or DNS domain used as DOMAIN\user for NTLM. Leave empty if USERNAME is already DOMAIN\user. +CERTSRV_DOMAIN=EXAMPLE +# Certificate template common name (not the display name), e.g. UserWifi +CERTSRV_TEMPLATE=UserWifi +# Verify the certsrv TLS certificate. +CERTSRV_VERIFY_TLS=true +# Optional CA bundle (PEM) for the certsrv HTTPS connection. +CERTSRV_CA_CERTS_FILE= + +# --- Enrollment --- +ENROLLMENT_KEY_SIZE=2048 + +# --- Identity profile (embedded in the .mobileconfig) --- +PROFILE_ORGANIZATION=Example Org +PROFILE_DISPLAY_NAME=User Certificate +# Reverse-DNS prefix for PayloadIdentifier values. +PROFILE_IDENTIFIER_PREFIX=com.example.cert-portal +# Optional PEM/DER CA cert to embed so the device trusts the issued chain. +PROFILE_CA_CERT_PATH= + +# --- Profile signing (different PKCS#12 from the per-user identity) --- +# Long-lived P12/PFX whose certificate is already trusted on devices. +PROFILE_SIGNING_P12_PATH=/etc/cert-portal/profile-signing.p12 +PROFILE_SIGNING_P12_PASSWORD=change-me diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3657260 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.venv/ +venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +dist/ +build/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.env +*.p12 +*.pfx +*.pem +!tests/fixtures/** +.idea/ +.vscode/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..89e4f8f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim-bookworm + +RUN useradd --create-home --uid 1000 --shell /usr/sbin/nologin appuser \ + && mkdir -p /app /etc/cert-portal \ + && chown -R appuser:appuser /app /etc/cert-portal + +WORKDIR /app + +COPY pyproject.toml README.md /app/ +COPY src /app/src +COPY docs /app/docs + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir /app \ + && chown -R appuser:appuser /app + +USER appuser +EXPOSE 8000 +ENV HOST=0.0.0.0 PORT=8000 +CMD ["python", "-m", "cert_portal"] diff --git a/README.md b/README.md index e69de29..4352041 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,54 @@ +# Certificate Portal + +OIDC-authenticated web portal that issues a **user certificate** from Active Directory Certificate Services and delivers it as a **signed Apple `.mobileconfig`**. + +This is **Design B**: the portal talks to the CA itself. There is no NDES and no SCEP. The device never contacts AD CS. + +``` +Sign in (OIDC) → LDAP objectSid → RSA key + CSR in memory + → POST /certsrv/certfnsh.asp (NTLM) → PKCS#12 inside .mobileconfig + → CMS-sign the profile → download over HTTPS +``` + +## Tradeoff + +The **private key is generated on the server**, packed into a PKCS#12, and sent to the device. It is kept in memory only for that request and is not written to disk. That is commonly accepted for user Wi-Fi certificates. Do not use this portal for keys that must never leave the device (SCEP / on-device generation is a different design). + +## Documentation + +| Doc | Contents | +| --- | --- | +| [docs/architecture.md](docs/architecture.md) | Request flow, the two PKCS#12s, why phones never talk to the CA | +| [docs/setup.md](docs/setup.md) | OIDC, LDAP, certsrv, TLS, Docker / `python -m cert_portal` | +| [docs/adcs.md](docs/adcs.md) | Web Enrollment, template flags, SID URI / strong mapping | +| [docs/profile-signing.md](docs/profile-signing.md) | Signing identity, device trust, CMS format | +| [docs/security.md](docs/security.md) | Key lifetime, logging, sessions, who can download | +| [.env.example](.env.example) | Every setting, commented | + +After sign-in, operators and users can also open **Help** in the app (`/help`) without cloning this repo. + +## Quick start + +1. Copy `.env.example` to `.env` and fill in OIDC, LDAP, certsrv, and the profile-signing PKCS#12 path. +2. Python 3.12+: `pip install -e ".[dev]"` then `python -m cert_portal` +3. Or: `docker build -t cert-portal .` and run with `--env-file .env`, publishing port 8000, mounting the signing P12. + +Put a reverse proxy in front for TLS. Set `PUBLIC_BASE_URL` and `OIDC_REDIRECT_URI` to that HTTPS origin (`/auth/callback`). + +## What you need on the Windows side + +- AD CS **Web Enrollment** (`https://ca.example.com/certsrv`) reachable from the Linux host +- A template the portal service account can **Enroll**, with subject and SAN **supplied in the request** +- A domain account for NTLM to certsrv (and a separate LDAP bind account, or the same one) +- A certificate (and private key) used only to **sign** `.mobileconfig` files, already trusted on devices + +Details: [docs/adcs.md](docs/adcs.md) and [docs/profile-signing.md](docs/profile-signing.md). + +## Development + +```bash +pip install -e ".[dev]" +pytest +``` + +Tests mock the CA. They do not need live AD CS, LDAP, or an IdP. diff --git a/docs/adcs.md b/docs/adcs.md new file mode 100644 index 0000000..82c7cee --- /dev/null +++ b/docs/adcs.md @@ -0,0 +1,68 @@ +# AD CS Web Enrollment and certificate template + +The portal submits CSRs to **Active Directory Certificate Services Web Enrollment** (`certsrv`), not NDES, not `certreq`, and not `ICertRequest` COM. + +## Web Enrollment + +On the CA (or a dedicated enrollment host): + +1. Install the **Certification Authority Web Enrollment** role service. +2. Require HTTPS on the `certsrv` application. +3. Allow the portal service account to authenticate (Windows Integrated / NTLM). Basic auth is not used. +4. From the Linux host, `curl -I https://ca.example.com/certsrv/` should not fail TLS. A 401 is expected without NTLM. + +The portal: + +- `POST {CERTSRV_URL}/certfnsh.asp` with `Mode=newreq`, the PEM CSR, and `CertAttrib=CertificateTemplate:{CERTSRV_TEMPLATE}` +- On success, `GET {CERTSRV_URL}/certnew.cer?ReqID={id}&Enc=b64` + +If the HTML does not look like “Certificate Issued”, the portal errors out. **Pending** (manager approval) is treated as failure — the template should auto-issue. + +## Service account + +Use a dedicated domain user (for example `EXAMPLE\cert-portal`): + +- **Enroll** on the certificate template +- **Request Certificates** on the CA (often already granted to Authenticated Users) +- Allowed to use the `certsrv` IIS app + +This account is what AD CS records as the requester. That is expected. User identity is in the certificate SAN, not the requester field. + +## Template + +Create a template (example name `UserWifi` — use the **template common name**, not the display name, in `CERTSRV_TEMPLATE`). + +Required so the CSR SAN survives issuance: + +- Subject name: **Supply in the request** +- Do **not** build subject from Active Directory +- SAN: allow the enrollee to supply SAN (the “enroll subject from request” / `ENROLLEE_SUPPLIES_SUBJECT` style flags) + +Typical user-auth / Wi-Fi client settings: + +- Category: User (or as your org requires) +- Extension: Application Policy Client Authentication +- Key usage: per your NPS/EAP-TLS needs (Windows templates often want Digital Signature and/or Key Encipherment) +- Validity: as policy allows +- No manager approval (or the portal will raise `CertsrvPendingError`) +- Publish to AD: optional; not required for Apple clients + +Compatibility: Windows Server 2016+ for URI SAN strong mapping as described below. + +## Strong mapping (SID URI) + +The CSR includes: + +``` +URI:tag:microsoft.com,2022-09-14:sid:S-1-5-21-… +``` + +and UPN otherName `1.3.6.1.4.1.311.20.2.3`. + +That URI is the form Microsoft documents for mapping certificates to AD objects when the CA did not stamp `szOID_NTDS_CA_SECURITY_EXT` itself (offline / “supply in request” workflows). Confirm with `certutil -dump` on a test cert that both SAN values are present. + +If the issued cert is missing the URI, the template is still overwriting subject/SAN from Active Directory — fix the template before issuing to phones. + +## Testing without the portal + +From a domain-joined Windows box you can still use `certreq` to prove the template. The portal’s path is only `certsrv`. If `certreq` works but the portal fails, check NTLM, HTTPS, Enroll ACL, and the HTML parser (pending vs issued). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..84d0398 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,44 @@ +# Architecture + +The portal is a Linux FastAPI app. It is the **enrollment client**. Apple devices only download a finished, signed configuration profile. + +## Flow + +1. **OIDC.** The browser is redirected to a generic OpenID Provider (Keycloak, Okta, Authentik, Entra ID, …). Authorization code + PKCE. A small user dict (`sub`, `username`, `email`, `name`) is stored in a signed session cookie. Access tokens are not kept. +2. **LDAP.** Using the username claim (default `email`, then `preferred_username`), the portal binds as a service account and searches Active Directory. It reads `objectSid`, `userPrincipalName`, and `displayName`. An optional group gate uses `LDAP_MATCHING_RULE_IN_CHAIN` (nested membership). +3. **Key + CSR (memory only).** On **Download**, not at login, the portal generates RSA-2048 (configurable), and a CSR: + - Subject `CN=` + - SAN otherName `1.3.6.1.4.1.311.20.2.3` = UPN + - SAN URI `tag:microsoft.com,2022-09-14:sid:` (Microsoft strong mapping / KB5014754) +4. **certsrv.** NTLM POST to `/certsrv/certfnsh.asp` with `CertificateTemplate:`. On issued, GET `/certsrv/certnew.cer?ReqID=&Enc=b64`. Pending or denied is an error; this portal does not wait for manager approval. +5. **Identity PKCS#12.** Private key + issued cert (+ optional CA) encrypted with a one-time password. Not written to disk. +6. **Unsigned plist.** A `com.apple.security.pkcs12` payload holds the identity P12 and that password. Optional CA payload if `PROFILE_CA_CERT_PATH` is set. +7. **Sign.** The completed plist is wrapped in attached CMS (not detached), equivalent to `openssl smime -sign -nodetach -outform der`, using the **profile-signing** PKCS#12. +8. **HTTPS download.** `Content-Type: application/x-apple-aspen-config`. + +The CA database requester is the **portal NTLM account**. The **user** is bound by SAN (UPN + SID URI), not by Windows impersonation. OIDC does not yield a Kerberos ticket for the end user, so there is no Enrollment Agent / on-behalf-of in this version. + +## Two PKCS#12 files + +| File | Lifetime | Role | +| --- | --- | --- | +| Identity P12 | One download, RAM only | Embedded in the profile as `com.apple.security.pkcs12`. User cert + private key. | +| Profile-signing P12 | Long-lived file on the server | CMS-signs the `.mobileconfig`. Must already be trusted on the device. | + +Do not reuse the signing P12 as a user identity, and do not save identity P12s. + +## Why the phone never talks to the CA + +SCEP/NDES would have the device generate a key and speak SCEP to NDES. This portal instead returns the cert and key inside the profile. After install, nothing on the phone needs `certsrv` or NDES reachability. + +## Code map + +| Module | Responsibility | +| --- | --- | +| `auth_oidc.py` | OIDC client registration, claim → username | +| `directory.py` | LDAPS, `objectSid` decode, group gate | +| `enrollment.py` | RSA key, CSR SAN, PKCS#12 export | +| `certsrv.py` | NTLM submit/fetch, HTML disposition parser | +| `profile.py` | Unsigned plist + CMS sign | +| `issue.py` | Orchestrates one download | +| `app.py` | HTTP routes, session, templates | diff --git a/docs/profile-signing.md b/docs/profile-signing.md new file mode 100644 index 0000000..edc10a1 --- /dev/null +++ b/docs/profile-signing.md @@ -0,0 +1,42 @@ +# Signing the `.mobileconfig` + +The portal **always signs** the profile after the PKCS#12 payload is embedded. Unsigned profiles install with a warning; a signature that the device trusts shows as verified. + +## Signing identity + +`PROFILE_SIGNING_P12_PATH` is a PKCS#12 (`.p12` / `.pfx`) containing: + +- The signer private key +- The signer certificate +- Optional intermediates (included in the CMS) + +The **device must already trust** this certificate (or its issuer). Typical sources: + +- An internal TLS/code-signing/profile-signing CA already deployed by MDM or a root payload +- A dedicated “configuration profile signing” cert from that CA + +This file is **not** the per-user identity P12. It is long-lived and stored on disk. Protect it like any other service credential (`chmod` / secret mount). + +## What the portal does + +After building the XML plist it creates an **attached** CMS `SignedData` (DER): + +``` +openssl smime -sign \ + -signer cert.pem -inkey key.pem -certfile chain.pem \ + -nodetach -outform der \ + -in unsigned.mobileconfig -out signed.mobileconfig +``` + +Implementation: `cryptography` `PKCS7SignatureBuilder`, SHA-256, `Encoding.DER`, `PKCS7Options.Binary` (so the XML is not canonicalized). Detached signatures are not used; Apple needs the profile bytes inside the CMS. + +## Trust on the device + +- If the signer chains to a root already on the iPhone/Mac, Settings shows the profile as signed/verified. +- If not, the user can still install it (with a warning) unless an MDM policy forbids unsigned/untrusted profiles. + +Deploy the signing CA with MDM or an earlier profile before you depend on a “verified” badge. + +## Rotation + +Replace the P12 on disk and restart the process. Old profiles remain valid; new downloads use the new signer. Keep the old CA trusted until you do not care about old profiles. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..330e036 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,58 @@ +# Security + +## Private key on the server + +Design B generates the user private key in the portal: + +- Created in RAM on POST `/download` +- Exported into an identity PKCS#12 (still RAM) +- Embedded in the plist, which is then CMS-signed +- Returned in the HTTP response +- Not written to disk; no cache of keys or P12 passwords + +Anyone who can download a profile for a user obtains that user’s private key. Protect the app like an RA: + +- OIDC on every download +- Optional `LDAP_REQUIRED_GROUP` +- HTTPS only in production (`SESSION_HTTPS_ONLY`, reverse-proxy TLS) +- Restrict who can reach the portal (VPN, SSO group, IP allow list) + +Do not use this design for keys that must be device-bound. + +## Logging + +The portal logs UPN and SID at info when a profile is issued. It does **not** log: + +- Identity P12 bytes or passwords +- Profile-signing P12 passwords +- OIDC client secrets, LDAP bind passwords, certsrv passwords +- Raw CSR PEM at info (avoid debug logging in production) + +Session cookie: `HttpOnly`, `SameSite=lax`, `Secure` when `SESSION_HTTPS_ONLY` is true. + +## Who can enroll + +1. Must complete OIDC (valid session). +2. Must match exactly one LDAP user. +3. If `LDAP_REQUIRED_GROUP` is set, must be a member (nested groups allowed). +4. The **service account**, not the user, must have Enroll on the template. A signed-in user cannot enroll a template the portal account cannot enroll. + +There is no Enrollment Agent. A caller cannot request a cert for a different SID than LDAP returned for their username. + +## CA trust + +Prefer `CERTSRV_VERIFY_TLS=true` and `LDAP_VERIFY_TLS=true`. Disabling TLS verification allows credential theft on the path to certsrv (NTLM) and LDAP. + +## OpenAPI + +Leave `DOCS_ENABLED=false` in production so `/docs` is not public. + +## Threat notes + +| Issue | Mitigation | +| --- | --- | +| Stolen session cookie | Short IdP session, HTTPS, `Secure` cookies | +| Stolen signing P12 | Filesystem permissions / secret store; rotate | +| Template too powerful | Dedicated Wi-Fi user template, not a smartcard/admin template | +| certsrv HTML change | Parser fails closed (unknown page = error), fixtures in tests | +| SSRF-ish CA URL | `CERTSRV_URL` is config, not user input | diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..4548212 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,98 @@ +# Setup + +The portal runs on Linux (bare metal, VM, or container). It needs outbound HTTPS to the IdP, LDAPS to a domain controller, and HTTPS to AD CS Web Enrollment. + +## 1. Configuration + +Copy [`.env.example`](../.env.example) to `.env`. Every variable is commented there. Minimum: + +- `SESSION_SECRET` — long random string +- `PUBLIC_BASE_URL` — the URL browsers use (the reverse-proxy origin) +- `OIDC_*` — issuer, client id/secret, redirect `https:///auth/callback` +- `LDAP_*` — `ldaps://` URL, bind DN/password, base DN +- `CERTSRV_*` — `https://ca.example.com/certsrv`, NTLM user, template common name +- `PROFILE_SIGNING_P12_PATH` / `PROFILE_SIGNING_P12_PASSWORD` + +Set `SESSION_HTTPS_ONLY=true` behind HTTPS. For a local `http://127.0.0.1:8000` lab only, set it false and use matching `OIDC_REDIRECT_URI`. + +## 2. OpenID Connect client + +Register a confidential web app at your IdP: + +- Redirect URI: `{PUBLIC_BASE_URL}/auth/callback` +- Scopes: `openid email profile` (override with `OIDC_SCOPES`) +- PKCE: S256 (the portal always sends a code challenge) + +`OIDC_ISSUER` must be the discovery origin (no trailing slash). The portal loads `{issuer}/.well-known/openid-configuration`. + +`OIDC_USERNAME_CLAIM` (default `email`) is what LDAP searches for. If your IdP puts the AD UPN in `preferred_username` or `upn`, set the claim accordingly and keep `LDAP_USER_FILTER` in sync. + +## 3. LDAP + +Use **LDAPS** (`ldaps://dc:636`) in production. The bind account only needs to read users (and, if `LDAP_REQUIRED_GROUP` is set, group membership). + +Default filter: + +``` +(&(objectClass=user)(|(userPrincipalName={username})(mail={username}))) +``` + +`LDAP_REQUIRED_GROUP` may be a full DN or a CN. Nested members are included (`LDAP_MATCHING_RULE_IN_CHAIN`). + +If the DC uses a private CA, set `LDAP_CA_CERTS_FILE` to a PEM bundle. `LDAP_VERIFY_TLS=false` is lab-only. + +## 4. AD CS Web Enrollment + +See [adcs.md](adcs.md). From the Linux host you should be able to reach `CERTSRV_URL` over HTTPS. The portal uses NTLM (`CERTSRV_DOMAIN` + `CERTSRV_USERNAME`). If `CERTSRV_USERNAME` is already `DOMAIN\user` or `user@upn`, the domain field is ignored. + +Install the issuing CA TLS cert into `CERTSRV_CA_CERTS_FILE` (or the system trust store). Do not disable `CERTSRV_VERIFY_TLS` except in a lab. + +## 5. Profile signing material + +Place a PKCS#12 on the host (for Docker, bind-mount it) and point `PROFILE_SIGNING_P12_PATH` at it. See [profile-signing.md](profile-signing.md). + +Optional: `PROFILE_CA_CERT_PATH` PEM/DER of the issuing CA to embed so devices that do not already trust it still accept the user cert. + +## 6. Reverse proxy / TLS + +Terminate TLS at Caddy, nginx, or another proxy. Forward `Host`, `X-Forwarded-For`, and `X-Forwarded-Proto`. The app is started with `proxy_headers=True`. + +Example Caddy: + +``` +certs.example.com { + reverse_proxy 127.0.0.1:8000 +} +``` + +## 7. Run + +**Local** + +```bash +python3.12 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev]" +python -m cert_portal +``` + +Listens on `HOST`/`PORT` (default `0.0.0.0:8000`). + +**Docker** + +```bash +docker build -t cert-portal . +docker run --rm -p 8000:8000 --env-file .env \ + -v /etc/cert-portal:/etc/cert-portal:ro \ + cert-portal +``` + +The image runs as UID 1000. The signing P12 path inside the container must match `PROFILE_SIGNING_P12_PATH`. + +## 8. OpenAPI + +`/docs`, `/redoc`, and `/openapi.json` are **off** when `ENVIRONMENT=production` unless you set `DOCS_ENABLED=true`. They are on in `development`. Do not expose Swagger to the internet without additional access control. + +## Health + +`GET /health` returns `{"status":"ok","version":"..."}` and does not require a session. Point your load balancer at it. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0d8b1cb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "cert-portal" +version = "0.1.0" +description = "OIDC-authenticated portal that enrolls a user cert from AD CS and serves it in a signed Apple .mobileconfig" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "jinja2>=3.1.4", + "python-multipart>=0.0.12", + "authlib>=1.4.0", + "httpx>=0.27.0", + "itsdangerous>=2.2.0", + "pydantic-settings>=2.6.0", + "cryptography>=44.0.0", + "ldap3>=2.9.1", + "requests>=2.32.0", + "requests-ntlm>=1.3.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-cov>=6.0.0", + "ruff>=0.8.0", +] + +[project.scripts] +cert-portal = "cert_portal.__main__:main" + +[tool.hatch.build.targets.wheel.force-include] +"src/cert_portal/templates" = "cert_portal/templates" +"src/cert_portal/static" = "cert_portal/static" + +[tool.pytest.ini_options] +pythonpath = ["src", "."] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 100 +target-version = "py312" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/src/cert_portal/__init__.py b/src/cert_portal/__init__.py new file mode 100644 index 0000000..85a4e8d --- /dev/null +++ b/src/cert_portal/__init__.py @@ -0,0 +1,3 @@ +"""Application version.""" + +__version__ = "0.1.0" diff --git a/src/cert_portal/__main__.py b/src/cert_portal/__main__.py new file mode 100644 index 0000000..02c7099 --- /dev/null +++ b/src/cert_portal/__main__.py @@ -0,0 +1,29 @@ +"""Run with `python -m cert_portal` or the `cert-portal` console script.""" + +from __future__ import annotations + +import logging + +import uvicorn + +from cert_portal.config import get_settings + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + settings = get_settings() + uvicorn.run( + "cert_portal.app:get_app", + factory=True, + host=settings.host, + port=settings.port, + proxy_headers=True, + forwarded_allow_ips="*", + ) + + +if __name__ == "__main__": + main() diff --git a/src/cert_portal/app.py b/src/cert_portal/app.py new file mode 100644 index 0000000..c784c21 --- /dev/null +++ b/src/cert_portal/app.py @@ -0,0 +1,233 @@ +"""HTTP application: OIDC login, directory lookup, signed .mobileconfig download.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from pathlib import Path + +from authlib.integrations.starlette_client import OAuthError +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware + +from cert_portal import __version__ +from cert_portal.auth_oidc import ( + SESSION_USER_KEY, + create_oauth, + session_user_from_token, +) +from cert_portal.config import Settings, get_settings +from cert_portal.directory import DirectoryClient, DirectoryUser +from cert_portal.exceptions import PortalError +from cert_portal.issue import ProfileIssuer +from cert_portal.profile import APPLE_CONFIG_TYPE + +logger = logging.getLogger(__name__) + +PACKAGE_DIR = Path(__file__).resolve().parent +templates = Jinja2Templates(directory=str(PACKAGE_DIR / "templates")) + + +def create_app( + settings: Settings | None = None, + *, + directory_client: DirectoryClient | None = None, + issuer: ProfileIssuer | None = None, + lookup_user: Callable[[str], DirectoryUser] | None = None, +) -> FastAPI: + """Build the FastAPI app. Tests inject fakes for directory and CA.""" + settings = settings or get_settings() + docs_url = "/docs" if settings.enable_api_docs else None + redoc_url = "/redoc" if settings.enable_api_docs else None + app = FastAPI( + title="Certificate Portal", + description=( + "OIDC-authenticated portal that enrolls a user certificate from AD CS " + "Web Enrollment and returns it in a signed Apple configuration profile." + ), + version=__version__, + docs_url=docs_url, + redoc_url=redoc_url, + openapi_url="/openapi.json" if settings.enable_api_docs else None, + ) + app.add_middleware( + SessionMiddleware, + secret_key=settings.session_secret, + session_cookie="cert_portal_session", + https_only=settings.session_https_only, + same_site="lax", + ) + oauth = create_oauth(settings) + directory = directory_client or DirectoryClient(settings) + profile_issuer = issuer # lazily created so tests can skip the signing P12 + + def _issuer() -> ProfileIssuer: + nonlocal profile_issuer + if profile_issuer is None: + profile_issuer = ProfileIssuer(settings) + return profile_issuer + + def _lookup(username: str) -> DirectoryUser: + if lookup_user is not None: + return lookup_user(username) + return directory.lookup(username) + + app.mount( + "/static", + StaticFiles(directory=str(PACKAGE_DIR / "static")), + name="static", + ) + + @app.get("/health", tags=["ops"]) + def health() -> dict[str, str]: + return {"status": "ok", "version": __version__} + + @app.get("/", response_class=HTMLResponse) + def home(request: Request) -> HTMLResponse: + user = request.session.get(SESSION_USER_KEY) + if not user: + return templates.TemplateResponse( + request, + "login.html", + {"settings": settings}, + ) + directory_user = None + error = None + try: + directory_user = _lookup(user["username"]) + request.session["directory"] = { + "upn": directory_user.upn, + "sid": directory_user.sid, + "display_name": directory_user.display_name, + "dn": directory_user.dn, + } + except PortalError as exc: + error = exc.message + logger.warning("Directory lookup failed for %s: %s", user.get("username"), exc.message) + return templates.TemplateResponse( + request, + "home.html", + { + "settings": settings, + "user": user, + "directory_user": directory_user, + "error": error, + }, + ) + + @app.get("/login") + async def login(request: Request): + if request.session.get(SESSION_USER_KEY): + return RedirectResponse("/", status_code=302) + try: + return await oauth.idp.authorize_redirect(request, settings.oidc_redirect_uri) + except Exception: + logger.exception("OIDC redirect failed") + return templates.TemplateResponse( + request, + "login.html", + { + "settings": settings, + "error": ( + "Could not start sign-in. Check OIDC_ISSUER and that the " + "identity provider is reachable." + ), + }, + status_code=502, + ) + + @app.get("/auth/callback") + async def auth_callback(request: Request): + try: + token = await oauth.idp.authorize_access_token(request) + session_user = session_user_from_token(token, settings.oidc_username_claim) + except OAuthError as exc: + logger.warning("OIDC callback failed: %s", exc) + return templates.TemplateResponse( + request, + "login.html", + { + "settings": settings, + "error": ( + "Sign-in failed. Try again, or ask an administrator " + "to check OIDC settings." + ), + }, + status_code=401, + ) + except Exception: + logger.exception("OIDC callback crashed") + return templates.TemplateResponse( + request, + "login.html", + { + "settings": settings, + "error": "Sign-in failed because of an unexpected error.", + }, + status_code=401, + ) + request.session[SESSION_USER_KEY] = session_user + return RedirectResponse("/", status_code=302) + + @app.get("/logout") + def logout(request: Request): + request.session.clear() + return RedirectResponse("/", status_code=302) + + @app.get("/help", response_class=HTMLResponse) + def help_page(request: Request) -> HTMLResponse: + user = request.session.get(SESSION_USER_KEY) + if not user: + return RedirectResponse("/", status_code=302) + return templates.TemplateResponse( + request, + "help.html", + {"settings": settings, "user": user}, + ) + + @app.post("/download") + def download(request: Request) -> Response: + user = request.session.get(SESSION_USER_KEY) + if not user: + return RedirectResponse("/", status_code=302) + try: + directory_user = _lookup(user["username"]) + issued = _issuer().issue(directory_user) + except PortalError as exc: + logger.warning("Enrollment failed for %s: %s", user.get("username"), exc.message) + return templates.TemplateResponse( + request, + "home.html", + { + "settings": settings, + "user": user, + "directory_user": None, + "error": exc.message, + }, + status_code=400, + ) + headers = { + "Content-Disposition": f'attachment; filename="{issued.filename}"', + "Cache-Control": "no-store", + } + return Response( + content=issued.signed_mobileconfig, + media_type=APPLE_CONFIG_TYPE, + headers=headers, + ) + + return app + + +app = None + + +def get_app() -> FastAPI: + """ASGI entrypoint used by uvicorn (`cert_portal.app:get_app`).""" + global app + if app is None: + app = create_app() + return app diff --git a/src/cert_portal/auth_oidc.py b/src/cert_portal/auth_oidc.py new file mode 100644 index 0000000..f4747be --- /dev/null +++ b/src/cert_portal/auth_oidc.py @@ -0,0 +1,65 @@ +"""Generic OpenID Connect sign-in (authorization code + PKCE). + +Works with any IdP that publishes `.well-known/openid-configuration` +(Keycloak, Okta, Authentik, Entra ID, etc.). The portal stores a small +JSON-safe user dict in a signed session cookie — not the access token. +""" + +from __future__ import annotations + +from typing import Any + +from authlib.integrations.starlette_client import OAuth, OAuthError + +from cert_portal.config import Settings + +SESSION_USER_KEY = "user" + + +def create_oauth(settings: Settings) -> OAuth: + """Register a single OIDC client using issuer discovery.""" + oauth = OAuth() + oauth.register( + name="idp", + client_id=settings.oidc_client_id, + client_secret=settings.oidc_client_secret, + server_metadata_url=f"{settings.oidc_issuer}/.well-known/openid-configuration", + client_kwargs={ + "scope": " ".join(settings.oidc_scope_list), + "code_challenge_method": "S256", + }, + ) + return oauth + + +def claims_username(claims: dict[str, Any], username_claim: str) -> str: + """Pick the directory lookup key from ID token / userinfo claims.""" + for key in (username_claim, "email", "preferred_username", "upn", "sub"): + value = claims.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + raise OAuthError("error", "Sign-in succeeded but no username claim was present.") + + +def session_user_from_token(token: dict[str, Any], username_claim: str) -> dict[str, str]: + """Flatten OIDC token/userinfo into JSON-serializable session data.""" + claims = _extract_claims(token) + username = claims_username(claims, username_claim) + return { + "sub": str(claims.get("sub") or username), + "username": username, + "email": str(claims.get("email") or ""), + "name": str(claims.get("name") or claims.get("preferred_username") or username), + } + + +def _extract_claims(token: dict[str, Any]) -> dict[str, Any]: + userinfo = token.get("userinfo") + if isinstance(userinfo, dict) and userinfo: + return userinfo + # Authlib may stash parsed ID token claims under several keys. + for key in ("id_token_claims", "userinfo_claims"): + parsed = token.get(key) + if isinstance(parsed, dict) and parsed: + return parsed + return {} diff --git a/src/cert_portal/certsrv.py b/src/cert_portal/certsrv.py new file mode 100644 index 0000000..2c8b69e --- /dev/null +++ b/src/cert_portal/certsrv.py @@ -0,0 +1,181 @@ +"""AD CS Web Enrollment (certsrv) client. + +Submits a CSR over NTLM-authenticated HTTPS and fetches the issued certificate. +HTML parsing is isolated so tests can pin the issued / pending / denied pages +without a live CA. This is a form POST, not NDES OTP scraping, but the page +markup can still change across Windows versions. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from typing import Literal +from urllib.parse import urljoin + +import requests +from requests_ntlm import HttpNtlmAuth + +from cert_portal.config import Settings +from cert_portal.exceptions import ( + CertsrvAuthError, + CertsrvDeniedError, + CertsrvError, + CertsrvPendingError, +) + +logger = logging.getLogger(__name__) + +Disposition = Literal["issued", "pending", "denied", "unknown"] + + +@dataclass(frozen=True) +class CertfnshParse: + """Result of reading certfnsh.asp after a new request.""" + + disposition: Disposition + req_id: int | None = None + detail: str = "" + + +_REQ_ID_LINK = re.compile( + r"certnew\.cer\?ReqID=(\d+)", + re.IGNORECASE, +) +_REQ_ID_LABEL = re.compile( + r"Request Id is\s+(\d+)", + re.IGNORECASE, +) +_ISSUED = re.compile(r"Certificate Issued", re.IGNORECASE) +_PENDING = re.compile(r"Certificate Pending|must wait for an administrator", re.IGNORECASE) +_DENIED = re.compile(r"Certificate Request Denied|request was denied", re.IGNORECASE) + + +def parse_certfnsh_html(html: str) -> CertfnshParse: + """Classify a certfnsh.asp response and extract ReqID when present. + + Issued pages include a `certnew.cer?ReqID=` link. Pending pages usually + include "Certificate Pending" and "Your Request Id is N". Denied pages say + the request was denied. Prefer the link over a generic first integer so a + locale string cannot be mistaken for the request id. + """ + if not html or not html.strip(): + return CertfnshParse("unknown", detail="Empty response from certsrv.") + + req_id: int | None = None + link = _REQ_ID_LINK.search(html) + if link: + req_id = int(link.group(1)) + else: + labeled = _REQ_ID_LABEL.search(html) + if labeled: + req_id = int(labeled.group(1)) + + if _DENIED.search(html): + return CertfnshParse( + "denied", + req_id=req_id, + detail="The CA denied the certificate request.", + ) + if _PENDING.search(html): + return CertfnshParse( + "pending", + req_id=req_id, + detail="The CA queued the request instead of issuing it immediately.", + ) + if _ISSUED.search(html) or link: + if req_id is None: + return CertfnshParse( + "unknown", + detail="certsrv reported a certificate but no ReqID was found in the HTML.", + ) + return CertfnshParse("issued", req_id=req_id, detail="Certificate issued.") + + snippet = re.sub(r"\s+", " ", html)[:240] + return CertfnshParse( + "unknown", + req_id=req_id, + detail=f"Unrecognized certsrv response. First bytes: {snippet!r}", + ) + + +class CertsrvClient: + """NTLM client for `/certsrv/certfnsh.asp` and `/certsrv/certnew.cer`.""" + + def __init__(self, settings: Settings, session: requests.Session | None = None) -> None: + self._settings = settings + self._session = session or requests.Session() + self._session.auth = HttpNtlmAuth(self._ntlm_user(), settings.certsrv_password) + self._session.verify = self._verify() + + def submit_csr(self, csr_pem: bytes | str, template: str | None = None) -> bytes: + """Submit a PEM CSR and return the issued certificate bytes (PEM or DER).""" + pem = csr_pem.decode("ascii") if isinstance(csr_pem, bytes) else csr_pem + template_name = template or self._settings.certsrv_template + url = self._url("certfnsh.asp") + data = { + "Mode": "newreq", + "CertRequest": pem, + "CertAttrib": f"CertificateTemplate:{template_name}", + "FriendlyType": "Saved-Request Certificate", + "TargetStoreFlags": "0", + "SaveCert": "yes", + } + logger.info("Submitting CSR to certsrv template=%s", template_name) + response = self._session.post(url, data=data, timeout=60) + self._raise_for_status(response) + parsed = parse_certfnsh_html(response.text) + if parsed.disposition == "denied": + raise CertsrvDeniedError(parsed.detail) + if parsed.disposition == "pending": + extra = f" Request ID {parsed.req_id}." if parsed.req_id is not None else "" + raise CertsrvPendingError(parsed.detail + extra) + if parsed.disposition != "issued" or parsed.req_id is None: + logger.warning("certsrv HTML was not recognized as issued") + raise CertsrvError( + "The CA web enrollment page did not look like a successful issuance. " + "The template may require approval, or the HTML layout may have changed. " + f"{parsed.detail}" + ) + return self.fetch_certificate(parsed.req_id) + + def fetch_certificate(self, req_id: int) -> bytes: + """Download the issued cert as base64 (`Enc=b64`).""" + url = self._url("certnew.cer") + response = self._session.get( + url, + params={"ReqID": str(req_id), "Enc": "b64"}, + timeout=60, + ) + self._raise_for_status(response) + if not response.content: + raise CertsrvError(f"certsrv returned an empty certificate for ReqID {req_id}.") + return response.content + + def _url(self, path: str) -> str: + base = self._settings.certsrv_url.rstrip("/") + "/" + return urljoin(base, path) + + def _ntlm_user(self) -> str: + user = self._settings.certsrv_username + if "\\" in user or "@" in user: + return user + domain = self._settings.certsrv_domain.strip() + return f"{domain}\\{user}" if domain else user + + def _verify(self) -> bool | str: + if self._settings.certsrv_ca_certs_file: + return str(self._settings.certsrv_ca_certs_file) + return self._settings.certsrv_verify_tls + + def _raise_for_status(self, response: requests.Response) -> None: + if response.status_code in {401, 403}: + raise CertsrvAuthError( + "Authentication to AD CS Web Enrollment failed. " + "Check CERTSRV_USERNAME / PASSWORD / DOMAIN and Enroll rights on the template." + ) + if response.status_code >= 400: + raise CertsrvError( + f"certsrv returned HTTP {response.status_code} for {response.url}." + ) diff --git a/src/cert_portal/config.py b/src/cert_portal/config.py new file mode 100644 index 0000000..a989643 --- /dev/null +++ b/src/cert_portal/config.py @@ -0,0 +1,97 @@ +"""Environment-driven settings for the certificate portal.""" + +from functools import lru_cache +from pathlib import Path + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Runtime configuration. Values come from environment variables or a `.env` file.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + ) + + host: str = "0.0.0.0" + port: int = 8000 + environment: str = "production" + docs_enabled: bool = False + public_base_url: str = "http://localhost:8000" + session_https_only: bool = True + session_secret: str = Field(min_length=16) + + oidc_issuer: str + oidc_client_id: str + oidc_client_secret: str + oidc_redirect_uri: str + oidc_scopes: str = "openid email profile" + oidc_username_claim: str = "email" + + ldap_url: str + ldap_bind_dn: str + ldap_bind_password: str + ldap_base_dn: str + ldap_user_filter: str = ( + "(&(objectClass=user)(|(userPrincipalName={username})(mail={username})))" + ) + ldap_required_group: str = "" + ldap_verify_tls: bool = True + ldap_ca_certs_file: Path | None = None + + certsrv_url: str + certsrv_username: str + certsrv_password: str + certsrv_domain: str = "" + certsrv_template: str + certsrv_verify_tls: bool = True + certsrv_ca_certs_file: Path | None = None + + enrollment_key_size: int = 2048 + + profile_organization: str = "Certificate Portal" + profile_display_name: str = "User Certificate" + profile_identifier_prefix: str = "com.example.cert-portal" + profile_ca_cert_path: Path | None = None + + profile_signing_p12_path: Path + profile_signing_p12_password: str = "" + + @field_validator("oidc_issuer", "public_base_url", "certsrv_url") + @classmethod + def strip_trailing_slash(cls, value: str) -> str: + return value.rstrip("/") + + @field_validator( + "ldap_ca_certs_file", + "certsrv_ca_certs_file", + "profile_ca_cert_path", + mode="before", + ) + @classmethod + def empty_optional_path(cls, value: object) -> object: + if value == "": + return None + return value + + @property + def is_development(self) -> bool: + return self.environment.lower() in {"development", "dev", "local"} + + @property + def enable_api_docs(self) -> bool: + return self.docs_enabled or self.is_development + + @property + def oidc_scope_list(self) -> list[str]: + return [part for part in self.oidc_scopes.split() if part] + + +@lru_cache +def get_settings() -> Settings: + """Load settings once per process. Tests should call `get_settings.cache_clear()`.""" + return Settings() diff --git a/src/cert_portal/directory.py b/src/cert_portal/directory.py new file mode 100644 index 0000000..2bbaba6 --- /dev/null +++ b/src/cert_portal/directory.py @@ -0,0 +1,207 @@ +"""Active Directory lookups: objectSid, UPN, and an optional group gate. + +The portal never impersonates the end user at the CA. LDAP is only used to bind +the OIDC identity to a Windows SID so the CSR SAN can carry strong mapping data. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from ldap3 import ALL, SUBTREE, Connection, Server, Tls +from ldap3.core.exceptions import LDAPException +from ldap3.utils.conv import escape_filter_chars + +from cert_portal.config import Settings +from cert_portal.exceptions import DirectoryError, GroupDeniedError, UserNotFoundError + +logger = logging.getLogger(__name__) + +# LDAP_MATCHING_RULE_IN_CHAIN — nested group membership in Active Directory. +IN_CHAIN = "1.2.840.113556.1.4.1941" + + +@dataclass(frozen=True) +class DirectoryUser: + """Subset of the AD user needed to build a CSR and profile.""" + + dn: str + upn: str + sid: str + display_name: str + mail: str | None = None + + +def decode_object_sid(raw: bytes) -> str: + """Convert a binary `objectSid` value to the `S-1-5-21-…` string form. + + Layout (little-endian sub-authorities, big-endian identifier authority): + + - 1 byte revision + - 1 byte sub-authority count + - 6 byte identifier authority + - N × 4 byte sub-authorities + """ + if raw is None or len(raw) < 8: + raise DirectoryError("Directory returned an empty or truncated objectSid.") + revision = raw[0] + sub_count = raw[1] + expected = 8 + (sub_count * 4) + if len(raw) < expected: + raise DirectoryError( + f"objectSid is truncated: expected {expected} bytes, got {len(raw)}." + ) + authority = int.from_bytes(raw[2:8], "big") + subs = [ + int.from_bytes(raw[8 + (i * 4) : 12 + (i * 4)], "little") for i in range(sub_count) + ] + return f"S-{revision}-{authority}-" + "-".join(str(s) for s in subs) + + +def encode_object_sid(sid: str) -> bytes: + """Encode an `S-R-I-…` string to the binary form AD stores. Used by tests.""" + parts = sid.split("-") + if len(parts) < 3 or parts[0].upper() != "S": + raise ValueError(f"Not a SID: {sid}") + revision = int(parts[1]) + authority = int(parts[2]) + subs = [int(p) for p in parts[3:]] + return bytes([revision, len(subs)]) + authority.to_bytes(6, "big") + b"".join( + s.to_bytes(4, "little") for s in subs + ) + + +class DirectoryClient: + """LDAP client bound with the portal service account.""" + + def __init__(self, settings: Settings) -> None: + self._settings = settings + + def lookup(self, username: str) -> DirectoryUser: + """Find the AD user for `username` and optionally enforce group membership.""" + if not username or not username.strip(): + raise UserNotFoundError("Sign-in did not provide a username to look up.") + username = username.strip() + conn = self._connect() + try: + user = self._search_user(conn, username) + if self._settings.ldap_required_group: + self._require_group(conn, user.dn) + return user + finally: + conn.unbind() + + def _connect(self) -> Connection: + tls = None + use_ssl = self._settings.ldap_url.lower().startswith("ldaps://") + if use_ssl or self._settings.ldap_ca_certs_file: + ca_certs = ( + str(self._settings.ldap_ca_certs_file) + if self._settings.ldap_ca_certs_file + else None + ) + tls = Tls( + validate=2 if self._settings.ldap_verify_tls else 0, + ca_certs_file=ca_certs, + ) + try: + server = Server( + self._settings.ldap_url, + get_info=ALL, + use_ssl=use_ssl, + tls=tls, + ) + conn = Connection( + server, + user=self._settings.ldap_bind_dn, + password=self._settings.ldap_bind_password, + auto_bind=True, + raise_exceptions=True, + ) + except LDAPException as exc: + logger.exception("LDAP bind failed") + raise DirectoryError( + "Could not connect to the directory. Check LDAP_URL and bind credentials." + ) from exc + return conn + + def _search_user(self, conn: Connection, username: str) -> DirectoryUser: + filt = self._settings.ldap_user_filter.replace( + "{username}", escape_filter_chars(username) + ) + attrs = ["objectSid", "userPrincipalName", "displayName", "mail", "cn"] + try: + conn.search( + search_base=self._settings.ldap_base_dn, + search_filter=filt, + search_scope=SUBTREE, + attributes=attrs, + ) + except LDAPException as exc: + logger.exception("LDAP user search failed") + raise DirectoryError("Directory search failed.") from exc + if not conn.entries: + raise UserNotFoundError( + "No Active Directory user matched your sign-in name. " + "Ask an administrator to check LDAP_USER_FILTER and your UPN/mail." + ) + if len(conn.entries) > 1: + raise DirectoryError( + "More than one Active Directory user matched your sign-in name." + ) + entry = conn.entries[0] + sid_attr = entry["objectSid"] + if not sid_attr or sid_attr.value is None: + raise DirectoryError("Directory user has no objectSid.") + raw_sid = sid_attr.raw_values[0] if sid_attr.raw_values else sid_attr.value + if isinstance(raw_sid, str): + # ldap3 may already stringify; accept S-… form. + sid = raw_sid if raw_sid.startswith("S-") else decode_object_sid(raw_sid.encode()) + else: + sid = decode_object_sid(bytes(raw_sid)) + upn = _attr_str(entry, "userPrincipalName") or username + display = _attr_str(entry, "displayName") or _attr_str(entry, "cn") or upn + mail = _attr_str(entry, "mail") + return DirectoryUser( + dn=str(entry.entry_dn), + upn=upn, + sid=sid, + display_name=display, + mail=mail, + ) + + def _require_group(self, conn: Connection, user_dn: str) -> None: + group = self._settings.ldap_required_group.strip() + escaped_dn = escape_filter_chars(user_dn) + # Match a group by DN or by CN, and allow nested members. + group_filter = ( + f"(&(|(distinguishedName={escape_filter_chars(group)})" + f"(cn={escape_filter_chars(group)}))" + f"(member:{IN_CHAIN}:={escaped_dn}))" + ) + try: + conn.search( + search_base=self._settings.ldap_base_dn, + search_filter=group_filter, + search_scope=SUBTREE, + attributes=["cn"], + ) + except LDAPException as exc: + logger.exception("LDAP group search failed") + raise DirectoryError("Directory group check failed.") from exc + if not conn.entries: + raise GroupDeniedError( + "Your account is not in the group allowed to download a certificate." + ) + + +def _attr_str(entry: object, name: str) -> str | None: + try: + value = entry[name].value # type: ignore[index] + except Exception: + return None + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/src/cert_portal/enrollment.py b/src/cert_portal/enrollment.py new file mode 100644 index 0000000..cf14815 --- /dev/null +++ b/src/cert_portal/enrollment.py @@ -0,0 +1,137 @@ +"""In-memory RSA key + CSR generation, and PKCS#12 export. + +The private key never touches disk. Callers should drop all references after the +download response is built so the GC can collect it. +""" + +from __future__ import annotations + +import secrets +from dataclasses import dataclass + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import NameOID, ObjectIdentifier + +from cert_portal.exceptions import EnrollmentError + +# Microsoft UPN otherName (szOID_NT_PRINCIPAL_NAME). +UPN_OID = ObjectIdentifier("1.3.6.1.4.1.311.20.2.3") + +# Strong mapping SAN URI prefix (KB5014754 / Intune / Jamf guidance). +SID_SAN_URI_PREFIX = "tag:microsoft.com,2022-09-14:sid:" + + +@dataclass +class GeneratedKey: + """RSA key kept only for the duration of one enrollment.""" + + key: rsa.RSAPrivateKey + key_size: int + + +@dataclass +class IssuedIdentity: + """Issued certificate bundled with the still-in-memory private key.""" + + key: rsa.RSAPrivateKey + certificate: x509.Certificate + chain: list[x509.Certificate] + pkcs12_bytes: bytes + pkcs12_password: str + csr_pem: str + + +def generate_rsa_key(key_size: int = 2048) -> GeneratedKey: + """Create an RSA key in memory. 2048 is the default; 4096 is allowed.""" + if key_size not in {2048, 3072, 4096}: + raise EnrollmentError(f"Unsupported key size {key_size}; use 2048, 3072, or 4096.") + key = rsa.generate_private_key(public_exponent=65537, key_size=key_size) + return GeneratedKey(key=key, key_size=key_size) + + +def sid_san_uri(sid: str) -> str: + """Return the Microsoft strong-mapping SAN URI for a SID.""" + return f"{SID_SAN_URI_PREFIX}{sid}" + + +def build_csr(*, key: rsa.RSAPrivateKey, upn: str, sid: str) -> bytes: + """Build a PEM CSR with CN=UPN, UPN otherName, and SID URI SAN. + + The AD CS template must allow the subject and SAN to be supplied in the + request, otherwise the CA will ignore these fields. + """ + if not upn: + raise EnrollmentError("Cannot build a CSR without a user principal name.") + if not sid: + raise EnrollmentError("Cannot build a CSR without a SID.") + + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, upn)]) + builder = x509.CertificateSigningRequestBuilder().subject_name(subject) + san = x509.SubjectAlternativeName( + [ + x509.OtherName(UPN_OID, _der_utf8_string(upn)), + x509.UniformResourceIdentifier(sid_san_uri(sid)), + ] + ) + builder = builder.add_extension(san, critical=False) + csr = builder.sign(key, hashes.SHA256()) + return csr.public_bytes(serialization.Encoding.PEM) + + +def export_pkcs12( + *, + key: rsa.RSAPrivateKey, + certificate: x509.Certificate, + chain: list[x509.Certificate] | None = None, + password: str | None = None, + friendly_name: str = "user-identity", +) -> tuple[bytes, str]: + """Return `(p12_bytes, password)` using a one-time password if none is given.""" + secret = password if password is not None else secrets.token_urlsafe(18) + try: + blob = pkcs12.serialize_key_and_certificates( + name=friendly_name.encode("utf-8"), + key=key, + cert=certificate, + cas=list(chain) if chain else None, + encryption_algorithm=serialization.BestAvailableEncryption(secret.encode("utf-8")), + ) + except Exception as exc: + raise EnrollmentError("Failed to export the identity PKCS#12.") from exc + return blob, secret + + +def load_certificate(data: bytes) -> x509.Certificate: + """Parse a PEM, DER, or bare-base64 certificate body from certsrv.""" + text = data.strip() + if text.startswith(b"-----BEGIN"): + return x509.load_pem_x509_certificate(text) + try: + return x509.load_der_x509_certificate(text) + except ValueError: + import base64 + + try: + der = base64.b64decode(text, validate=False) + return x509.load_der_x509_certificate(der) + except Exception as exc: + raise EnrollmentError("CA returned a certificate that could not be parsed.") from exc + + +def _der_utf8_string(value: str) -> bytes: + """DER-encode a UTF8String for use as an otherName value.""" + payload = value.encode("utf-8") + return bytes([0x0C]) + _der_length(len(payload)) + payload + + +def _der_length(length: int) -> bytes: + if length < 0x80: + return bytes([length]) + if length < 0x100: + return bytes([0x81, length]) + if length < 0x10000: + return bytes([0x82, (length >> 8) & 0xFF, length & 0xFF]) + raise EnrollmentError("UTF8String is too long to encode.") diff --git a/src/cert_portal/exceptions.py b/src/cert_portal/exceptions.py new file mode 100644 index 0000000..c2c20e1 --- /dev/null +++ b/src/cert_portal/exceptions.py @@ -0,0 +1,45 @@ +"""Typed errors raised by the portal. Messages are safe to show to a signed-in user.""" + + +class PortalError(Exception): + """Base class for expected, user-visible failures.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +class DirectoryError(PortalError): + """LDAP lookup failed or the user is not allowed to enroll.""" + + +class UserNotFoundError(DirectoryError): + """No AD user matched the OIDC username.""" + + +class GroupDeniedError(DirectoryError): + """The user is not a member of the required group.""" + + +class EnrollmentError(PortalError): + """CSR generation or PKCS#12 export failed.""" + + +class CertsrvError(PortalError): + """AD CS Web Enrollment rejected the request or returned an unexpected page.""" + + +class CertsrvAuthError(CertsrvError): + """NTLM authentication to certsrv failed (HTTP 401/403).""" + + +class CertsrvPendingError(CertsrvError): + """The CA queued the request instead of issuing immediately.""" + + +class CertsrvDeniedError(CertsrvError): + """The CA denied the request.""" + + +class ProfileError(PortalError): + """Building or signing the .mobileconfig failed.""" diff --git a/src/cert_portal/issue.py b/src/cert_portal/issue.py new file mode 100644 index 0000000..67aaae9 --- /dev/null +++ b/src/cert_portal/issue.py @@ -0,0 +1,99 @@ +"""One-shot enrollment: CSR → certsrv → identity PKCS#12 → signed .mobileconfig.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path + +from cert_portal.certsrv import CertsrvClient +from cert_portal.config import Settings +from cert_portal.directory import DirectoryUser +from cert_portal.enrollment import ( + build_csr, + export_pkcs12, + generate_rsa_key, + load_certificate, +) +from cert_portal.profile import ( + SigningIdentity, + build_unsigned_mobileconfig, + load_ca_certificate, + sign_mobileconfig, +) + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class IssuedProfile: + """Signed profile bytes ready to return over HTTPS.""" + + signed_mobileconfig: bytes + filename: str + upn: str + sid: str + + +class ProfileIssuer: + """Coordinates enrollment and profile signing. Safe to reuse across requests.""" + + def __init__( + self, + settings: Settings, + *, + certsrv: CertsrvClient | None = None, + signing: SigningIdentity | None = None, + ) -> None: + self._settings = settings + self._certsrv = certsrv or CertsrvClient(settings) + self._signing = signing + self._ca_cert = None + if settings.profile_ca_cert_path: + self._ca_cert = load_ca_certificate(Path(settings.profile_ca_cert_path)) + + def signing_identity(self) -> SigningIdentity: + if self._signing is None: + from cert_portal.profile import load_signing_identity + + self._signing = load_signing_identity( + Path(self._settings.profile_signing_p12_path), + self._settings.profile_signing_p12_password, + ) + return self._signing + + def issue(self, user: DirectoryUser) -> IssuedProfile: + """Generate a key, enroll, embed PKCS#12, sign. Key stays in this stack frame.""" + generated = generate_rsa_key(self._settings.enrollment_key_size) + csr_pem = build_csr(key=generated.key, upn=user.upn, sid=user.sid) + cert_bytes = self._certsrv.submit_csr(csr_pem) + certificate = load_certificate(cert_bytes) + chain = [self._ca_cert] if self._ca_cert is not None else [] + p12_bytes, p12_password = export_pkcs12( + key=generated.key, + certificate=certificate, + chain=chain, + friendly_name=user.upn, + ) + unsigned = build_unsigned_mobileconfig( + identity_p12=p12_bytes, + identity_password=p12_password, + display_name=self._settings.profile_display_name, + organization=self._settings.profile_organization, + identifier_prefix=self._settings.profile_identifier_prefix, + username=user.upn, + ca_cert=self._ca_cert, + ) + signed = sign_mobileconfig(unsigned, self.signing_identity()) + logger.info("Issued signed profile for upn=%s sid=%s", user.upn, user.sid) + return IssuedProfile( + signed_mobileconfig=signed, + filename=_profile_filename(user.upn), + upn=user.upn, + sid=user.sid, + ) + + +def _profile_filename(upn: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in "._-+@" else "_" for ch in upn) + return f"{cleaned or 'user'}.mobileconfig" diff --git a/src/cert_portal/profile.py b/src/cert_portal/profile.py new file mode 100644 index 0000000..cb92ce2 --- /dev/null +++ b/src/cert_portal/profile.py @@ -0,0 +1,152 @@ +"""Apple configuration profile: embed an identity PKCS#12, then CMS-sign it. + +Two different PKCS#12 files are involved: + +- Identity P12: generated per download, placed in a `com.apple.security.pkcs12` + payload so the device can import the user cert and private key. +- Profile-signing P12: a long-lived file on the server used only to attach a + CMS signature to the finished `.mobileconfig`. Devices must already trust it. +""" + +from __future__ import annotations + +import plistlib +import uuid +from dataclasses import dataclass +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12 + +from cert_portal.exceptions import ProfileError + +APPLE_CONFIG_TYPE = "application/x-apple-aspen-config" +PKCS12_PAYLOAD_TYPE = "com.apple.security.pkcs12" +ROOT_PAYLOAD_TYPE = "com.apple.security.root" +PKCS1_PAYLOAD_TYPE = "com.apple.security.pkcs1" + + +@dataclass(frozen=True) +class SigningIdentity: + """Certificate and key used to sign completed profiles.""" + + certificate: x509.Certificate + key: object + extra_certs: list[x509.Certificate] + + +def load_signing_identity(path: Path, password: str) -> SigningIdentity: + """Load the long-lived profile-signing PKCS#12 from disk.""" + try: + data = path.read_bytes() + except OSError as exc: + raise ProfileError(f"Could not read profile signing PKCS#12 at {path}.") from exc + try: + key, cert, additional = pkcs12.load_key_and_certificates( + data, password.encode("utf-8") if password is not None else None + ) + except Exception as exc: + raise ProfileError("Could not parse the profile signing PKCS#12.") from exc + if key is None or cert is None: + raise ProfileError("Profile signing PKCS#12 is missing a private key or certificate.") + extras = [c for c in (additional or []) if c is not None] + return SigningIdentity(certificate=cert, key=key, extra_certs=extras) + + +def load_ca_certificate(path: Path) -> x509.Certificate: + """Load an optional CA cert to embed in the profile (PEM or DER).""" + data = path.read_bytes() + try: + if data.strip().startswith(b"-----BEGIN"): + return x509.load_pem_x509_certificate(data) + return x509.load_der_x509_certificate(data) + except Exception as exc: + raise ProfileError(f"Could not parse CA certificate at {path}.") from exc + + +def build_unsigned_mobileconfig( + *, + identity_p12: bytes, + identity_password: str, + display_name: str, + organization: str, + identifier_prefix: str, + username: str, + ca_cert: x509.Certificate | None = None, +) -> bytes: + """Return an unsigned XML plist `.mobileconfig` with a PKCS#12 payload.""" + identity_uuid = str(uuid.uuid4()).upper() + profile_uuid = str(uuid.uuid4()).upper() + payloads: list[dict] = [ + { + "Password": identity_password, + "PayloadContent": identity_p12, + "PayloadDescription": "User identity certificate and private key.", + "PayloadDisplayName": display_name, + "PayloadIdentifier": f"{identifier_prefix}.identity", + "PayloadType": PKCS12_PAYLOAD_TYPE, + "PayloadUUID": identity_uuid, + "PayloadVersion": 1, + "PayloadCertificateFileName": f"{_safe_filename(username)}.p12", + } + ] + if ca_cert is not None: + ca_uuid = str(uuid.uuid4()).upper() + der = ca_cert.public_bytes(serialization.Encoding.DER) + is_ca = _is_ca(ca_cert) + payloads.append( + { + "PayloadContent": der, + "PayloadDescription": "Certificate authority for the issued user certificate.", + "PayloadDisplayName": "Issuing CA", + "PayloadIdentifier": f"{identifier_prefix}.ca", + "PayloadType": ROOT_PAYLOAD_TYPE if is_ca else PKCS1_PAYLOAD_TYPE, + "PayloadUUID": ca_uuid, + "PayloadVersion": 1, + } + ) + profile = { + "PayloadContent": payloads, + "PayloadDisplayName": display_name, + "PayloadDescription": ( + "Installs a user certificate issued by the certificate portal. " + "The private key was generated on the portal, not on this device." + ), + "PayloadIdentifier": f"{identifier_prefix}.profile", + "PayloadOrganization": organization, + "PayloadRemovalDisallowed": False, + "PayloadType": "Configuration", + "PayloadUUID": profile_uuid, + "PayloadVersion": 1, + "PayloadScope": "User", + } + return plistlib.dumps(profile, fmt=plistlib.FMT_XML, sort_keys=False) + + +def sign_mobileconfig(plist_bytes: bytes, identity: SigningIdentity) -> bytes: + """Attach a CMS signature (not detached), equivalent to openssl smime -nodetach -outform der.""" + try: + builder = pkcs7.PKCS7SignatureBuilder().set_data(plist_bytes) + builder = builder.add_signer(identity.certificate, identity.key, hashes.SHA256()) + for extra in identity.extra_certs: + builder = builder.add_certificate(extra) + return builder.sign( + serialization.Encoding.DER, + [pkcs7.PKCS7Options.Binary], + ) + except Exception as exc: + raise ProfileError("Failed to CMS-sign the configuration profile.") from exc + + +def _is_ca(cert: x509.Certificate) -> bool: + try: + bc = cert.extensions.get_extension_for_class(x509.BasicConstraints).value + return bool(bc.ca) + except x509.ExtensionNotFound: + return False + + +def _safe_filename(name: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in "._-+@" else "_" for ch in name) + return cleaned or "user" diff --git a/src/cert_portal/static/style.css b/src/cert_portal/static/style.css new file mode 100644 index 0000000..b98390e --- /dev/null +++ b/src/cert_portal/static/style.css @@ -0,0 +1,114 @@ +:root { + --bg: #f4f1ea; + --card: #fffcf7; + --ink: #1c1916; + --muted: #5c564e; + --line: #d9d0c4; + --accent: #1f4d3a; + --accent-ink: #f4f1ea; + --error-bg: #f8e6e2; + --error-ink: #7a2a1c; + --code-bg: #ece6dc; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + font-family: "Segoe UI", "Helvetica Neue", sans-serif; + line-height: 1.5; +} + +.top { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--line); + background: var(--card); +} + +.brand { + font-weight: 650; + color: var(--ink); + text-decoration: none; +} + +nav a { + color: var(--accent); + margin-left: 1rem; + text-decoration: none; +} + +.wrap { + max-width: 42rem; + margin: 2rem auto; + padding: 0 1.25rem 3rem; +} + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + padding: 1.5rem 1.6rem; +} + +h1 { font-size: 1.6rem; margin-top: 0; } +h2 { font-size: 1.15rem; margin-top: 1.6rem; } + +.button { + display: inline-block; + background: var(--accent); + color: var(--accent-ink); + border: 0; + border-radius: 8px; + padding: 0.65rem 1.1rem; + font-size: 1rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; +} + +.button:hover { filter: brightness(1.08); } + +.muted { color: var(--muted); } + +.banner.error { + background: var(--error-bg); + color: var(--error-ink); + padding: 0.75rem 1rem; + border-radius: 8px; +} + +.facts { + display: grid; + grid-template-columns: 11rem 1fr; + gap: 0.35rem 1rem; +} + +.facts dt { color: var(--muted); } +.facts dd { margin: 0; } + +code { + background: var(--code-bg); + padding: 0.1rem 0.35rem; + border-radius: 4px; + font-size: 0.92em; + word-break: break-all; +} + +.prose ol, .prose ul { padding-left: 1.2rem; } + +.foot { + text-align: center; + color: var(--muted); + font-size: 0.85rem; + padding: 0 1rem 2rem; +} + +@media (max-width: 640px) { + .facts { grid-template-columns: 1fr; } +} diff --git a/src/cert_portal/templates/base.html b/src/cert_portal/templates/base.html new file mode 100644 index 0000000..38b2d8f --- /dev/null +++ b/src/cert_portal/templates/base.html @@ -0,0 +1,26 @@ + + + + + + {% block title %}Certificate Portal{% endblock %} + + + +
+ Certificate Portal + +
+
+ {% block content %}{% endblock %} +
+
+

User certificates for Apple devices. The private key is generated on this server, then delivered in a signed profile.

+
+ + diff --git a/src/cert_portal/templates/help.html b/src/cert_portal/templates/help.html new file mode 100644 index 0000000..4d929e5 --- /dev/null +++ b/src/cert_portal/templates/help.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Help — Certificate Portal{% endblock %} +{% block content %} +
+

Help

+

Signed in as {{ user.username }}.

+ +

What this portal does

+
    +
  1. You sign in with your organization SSO (OpenID Connect).
  2. +
  3. The portal looks up your Active Directory account and Windows SID.
  4. +
  5. It generates an RSA key on this server, builds a certificate request, and submits it to Active Directory Certificate Services Web Enrollment (certsrv).
  6. +
  7. The issued certificate and private key are packed into a PKCS#12, placed inside an Apple .mobileconfig, and that profile is cryptographically signed.
  8. +
  9. Your device installs the profile. It never talks to the CA.
  10. +
+ +

Private key on the server

+

+ Unlike SCEP, the key is not generated on the phone. It exists in the portal’s + memory until the download finishes, then it is discarded and is not written + to disk. That tradeoff is commonly accepted for user Wi-Fi certificates. + Do not use this portal for keys that must never leave the device. +

+ +

Install on iPhone or iPad

+
    +
  1. Tap Download certificate profile on the home page.
  2. +
  3. Open the Settings app. A profile download notice appears near the top.
  4. +
  5. Tap the profile, then Install. Authenticate with Face ID, Touch ID, or your passcode.
  6. +
  7. The PKCS#12 import password is inside the signed profile; iOS applies it automatically.
  8. +
+ +

Install on Mac

+
    +
  1. Download the .mobileconfig file.
  2. +
  3. Open it from Downloads (or double-click in Finder).
  4. +
  5. System Settings → Device Management (or Profiles) → install the profile.
  6. +
  7. If the profile is signed by an identity your Mac already trusts, it shows as verified. If not, macOS still lets you install it after a warning.
  8. +
+ +

If download fails

+
    +
  • No Active Directory user — your SSO name did not match userPrincipalName or mail.
  • +
  • Not in the required group — enrollment is limited to a group the administrator configured.
  • +
  • CA denied or pending — the certificate template may require manager approval, or the portal account may lack Enroll permission.
  • +
+

Operators: see the docs/ folder in the cert-portal repository (architecture, setup, AD CS, profile signing, security).

+
+{% endblock %} diff --git a/src/cert_portal/templates/home.html b/src/cert_portal/templates/home.html new file mode 100644 index 0000000..d03bbd9 --- /dev/null +++ b/src/cert_portal/templates/home.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Download profile — Certificate Portal{% endblock %} +{% block content %} +
+

Your certificate profile

+

Signed in as {{ user.name }} ({{ user.username }})

+ + {% if error %} + + {% endif %} + + {% if directory_user %} +
+
User principal name
+
{{ directory_user.upn }}
+
SID
+
{{ directory_user.sid }}
+
Display name
+
{{ directory_user.display_name }}
+
+

+ Downloading enrolls a new certificate now. The CA will see the portal + service account as the requester; your identity is in the certificate SAN + (UPN and SID URI). +

+
+ +
+ {% elif not error %} +

Looking up your directory account…

+ {% endif %} + +

How to install the profile

+
+{% endblock %} diff --git a/src/cert_portal/templates/login.html b/src/cert_portal/templates/login.html new file mode 100644 index 0000000..cc2729c --- /dev/null +++ b/src/cert_portal/templates/login.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Sign in — Certificate Portal{% endblock %} +{% block content %} +
+

Sign in to get a user certificate

+

+ After you authenticate, this portal looks up your Active Directory SID, asks + the CA to issue a certificate, and gives you a signed Apple configuration + profile to install. +

+ {% if error is defined and error %} + + {% endif %} +

+ Sign in with SSO +

+

+ The private key is created here, not on your phone. That is expected for + user Wi-Fi certificates. See Help after you sign in. +

+
+{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f93c0a7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,75 @@ +"""Shared fixtures: settings, a tiny CA, and a profile-signing PKCS#12.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.serialization import pkcs12 +from tests.helpers import issue_test_ca + +from cert_portal.config import Settings +from cert_portal.directory import DirectoryUser + +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def test_ca(): + return issue_test_ca() + + +@pytest.fixture +def signing_p12(tmp_path: Path) -> Path: + key, cert = issue_test_ca() + blob = pkcs12.serialize_key_and_certificates( + name=b"profile-signer", + key=key, + cert=cert, + cas=None, + encryption_algorithm=serialization.BestAvailableEncryption(b"signer-secret"), + ) + path = tmp_path / "profile-signing.p12" + path.write_bytes(blob) + return path + + +@pytest.fixture +def settings(signing_p12: Path) -> Settings: + return Settings( + session_secret="test-session-secret-32bytes-min", + session_https_only=False, + environment="development", + docs_enabled=True, + public_base_url="http://testserver", + oidc_issuer="https://idp.example.test/realms/test", + oidc_client_id="cert-portal", + oidc_client_secret="secret", + oidc_redirect_uri="http://testserver/auth/callback", + ldap_url="ldaps://dc.example.test", + ldap_bind_dn="CN=portal,DC=example,DC=test", + ldap_bind_password="ldap-secret", + ldap_base_dn="DC=example,DC=test", + certsrv_url="https://ca.example.test/certsrv", + certsrv_username="portal", + certsrv_password="ca-secret", + certsrv_domain="EXAMPLE", + certsrv_template="UserWifi", + profile_signing_p12_path=signing_p12, + profile_signing_p12_password="signer-secret", + profile_organization="Test Org", + profile_display_name="Test User Certificate", + profile_identifier_prefix="com.example.cert-portal", + ) + + +@pytest.fixture +def directory_user() -> DirectoryUser: + return DirectoryUser( + dn="CN=Nick,OU=People,DC=example,DC=test", + upn="nick@example.test", + sid="S-1-5-21-3623811015-3361044348-30300820-1013", + display_name="Nick Example", + mail="nick@example.test", + ) diff --git a/tests/fixtures/certfnsh_denied.html b/tests/fixtures/certfnsh_denied.html new file mode 100644 index 0000000..e89d615 --- /dev/null +++ b/tests/fixtures/certfnsh_denied.html @@ -0,0 +1,10 @@ + + + Microsoft Active Directory Certificate Services + + +

Microsoft Active Directory Certificate Services

+ Certificate Request Denied + Your certificate request was denied by an administrator. + + diff --git a/tests/fixtures/certfnsh_issued.html b/tests/fixtures/certfnsh_issued.html new file mode 100644 index 0000000..3232c92 --- /dev/null +++ b/tests/fixtures/certfnsh_issued.html @@ -0,0 +1,12 @@ + + + Microsoft Active Directory Certificate Services + + +

Microsoft Active Directory Certificate Services

+ Certificate Issued + The certificate you requested was issued to you. +

DER Encoded

+

Base 64 encoded

+ + diff --git a/tests/fixtures/certfnsh_pending.html b/tests/fixtures/certfnsh_pending.html new file mode 100644 index 0000000..fdae844 --- /dev/null +++ b/tests/fixtures/certfnsh_pending.html @@ -0,0 +1,12 @@ + + + Microsoft Active Directory Certificate Services + + +

Microsoft Active Directory Certificate Services

+ Certificate Pending + Your certificate request has been received. However, you must wait for an + administrator to issue the certificate you requested. +

Your Request Id is 42.

+ + diff --git a/tests/fixtures/certfnsh_unknown.html b/tests/fixtures/certfnsh_unknown.html new file mode 100644 index 0000000..4920f92 --- /dev/null +++ b/tests/fixtures/certfnsh_unknown.html @@ -0,0 +1,8 @@ + + + Microsoft Active Directory Certificate Services + + +

Unexpected intern error 0x80094011

+ + diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..6fb87ef --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,50 @@ +"""Certificate helpers shared by tests (not pytest fixtures).""" + +from __future__ import annotations + +import datetime + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + + +def _name(cn: str) -> x509.Name: + return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]) + + +def issue_test_ca() -> tuple[rsa.RSAPrivateKey, x509.Certificate]: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = datetime.datetime.now(datetime.UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(_name("Test CA")) + .issuer_name(_name("Test CA")) + .public_key(key.public_key()) + .serial_number(1) + .not_valid_before(now - datetime.timedelta(minutes=1)) + .not_valid_after(now + datetime.timedelta(days=30)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(key, hashes.SHA256()) + ) + return key, cert + + +def sign_csr(csr: x509.CertificateSigningRequest, ca_key, ca_cert) -> x509.Certificate: + now = datetime.datetime.now(datetime.UTC) + builder = ( + x509.CertificateBuilder() + .subject_name(csr.subject) + .issuer_name(ca_cert.subject) + .public_key(csr.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=1)) + .not_valid_after(now + datetime.timedelta(days=7)) + ) + try: + san = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName) + builder = builder.add_extension(san.value, critical=False) + except x509.ExtensionNotFound: + pass + return builder.sign(ca_key, hashes.SHA256()) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..c2a021d --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,134 @@ +import json +from base64 import b64encode + +from fastapi.testclient import TestClient +from itsdangerous import TimestampSigner + +from cert_portal.app import create_app +from cert_portal.auth_oidc import SESSION_USER_KEY, claims_username, session_user_from_token +from cert_portal.directory import DirectoryUser +from cert_portal.exceptions import UserNotFoundError +from cert_portal.issue import IssuedProfile +from cert_portal.profile import APPLE_CONFIG_TYPE + +SESSION_COOKIE = "cert_portal_session" + +NICK = { + "sub": "abc", + "username": "nick@example.test", + "email": "nick@example.test", + "name": "Nick", +} + + +def _client(settings, lookup, issuer=None): + app = create_app(settings, lookup_user=lookup, issuer=issuer) + return TestClient(app) + + +def _sign_in(client: TestClient, settings, user: dict) -> None: + """Starlette 1.x TestClient no longer has session_transaction; sign the cookie.""" + signer = TimestampSigner(str(settings.session_secret)) + payload = b64encode(json.dumps({SESSION_USER_KEY: user}).encode("utf-8")) + client.cookies.set(SESSION_COOKIE, signer.sign(payload).decode("utf-8")) + + +def test_health(settings, directory_user): + client = _client(settings, lambda username: directory_user) + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +def test_home_unauthenticated_shows_login(settings, directory_user): + client = _client(settings, lambda username: directory_user) + response = client.get("/") + assert response.status_code == 200 + assert "Sign in with SSO" in response.text + + +def test_help_requires_login(settings, directory_user): + client = _client(settings, lambda username: directory_user) + response = client.get("/help", follow_redirects=False) + assert response.status_code == 302 + assert response.headers["location"] == "/" + + +def test_home_authenticated_shows_sid(settings, directory_user): + client = _client(settings, lambda username: directory_user) + _sign_in(client, settings, NICK) + response = client.get("/") + assert response.status_code == 200 + assert directory_user.sid in response.text + assert "Download certificate profile" in response.text + + +def test_home_directory_error(settings): + def lookup(username: str) -> DirectoryUser: + raise UserNotFoundError("No Active Directory user matched your sign-in name.") + + client = _client(settings, lookup) + _sign_in( + client, + settings, + { + "sub": "abc", + "username": "missing@example.test", + "email": "missing@example.test", + "name": "Missing", + }, + ) + response = client.get("/") + assert "No Active Directory user" in response.text + + +def test_download_returns_signed_mobileconfig(settings, directory_user): + class FakeIssuer: + def issue(self, user: DirectoryUser) -> IssuedProfile: + return IssuedProfile( + signed_mobileconfig=b"\x30fake-cms", + filename="nick@example.test.mobileconfig", + upn=user.upn, + sid=user.sid, + ) + + client = _client(settings, lambda username: directory_user, issuer=FakeIssuer()) + _sign_in(client, settings, NICK) + response = client.post("/download") + assert response.status_code == 200 + assert response.content == b"\x30fake-cms" + assert response.headers["content-type"].startswith(APPLE_CONFIG_TYPE) + assert "nick@example.test.mobileconfig" in response.headers["content-disposition"] + + +def test_download_requires_session(settings, directory_user): + client = _client(settings, lambda username: directory_user) + response = client.post("/download", follow_redirects=False) + assert response.status_code == 302 + + +def test_help_authenticated(settings, directory_user): + client = _client(settings, lambda username: directory_user) + _sign_in(client, settings, NICK) + response = client.get("/help") + assert response.status_code == 200 + assert "Private key on the server" in response.text + + +def test_login_oidc_failure_shows_error(settings, directory_user): + client = _client(settings, lambda username: directory_user) + response = client.get("/login") + assert response.status_code == 502 + assert "Could not start sign-in" in response.text + + +def test_username_claim_fallback(): + assert claims_username({"email": "a@b.c"}, "email") == "a@b.c" + assert claims_username({"preferred_username": "a"}, "email") == "a" + + +def test_session_user_from_token(): + token = {"userinfo": {"sub": "1", "email": "a@b.c", "name": "A"}} + user = session_user_from_token(token, "email") + assert user["username"] == "a@b.c" + assert user["name"] == "A" diff --git a/tests/test_certsrv.py b/tests/test_certsrv.py new file mode 100644 index 0000000..a72f456 --- /dev/null +++ b/tests/test_certsrv.py @@ -0,0 +1,36 @@ +from pathlib import Path + +from cert_portal.certsrv import parse_certfnsh_html + +FIXTURES = Path(__file__).parent / "fixtures" + + +def test_parse_issued_uses_reqid_from_certnew_link(): + html = (FIXTURES / "certfnsh_issued.html").read_text(encoding="utf-8") + parsed = parse_certfnsh_html(html) + assert parsed.disposition == "issued" + assert parsed.req_id == 18 + + +def test_parse_pending(): + html = (FIXTURES / "certfnsh_pending.html").read_text(encoding="utf-8") + parsed = parse_certfnsh_html(html) + assert parsed.disposition == "pending" + assert parsed.req_id == 42 + + +def test_parse_denied(): + html = (FIXTURES / "certfnsh_denied.html").read_text(encoding="utf-8") + parsed = parse_certfnsh_html(html) + assert parsed.disposition == "denied" + assert parsed.req_id is None + + +def test_parse_unknown_layout(): + html = (FIXTURES / "certfnsh_unknown.html").read_text(encoding="utf-8") + parsed = parse_certfnsh_html(html) + assert parsed.disposition == "unknown" + + +def test_empty_html(): + assert parse_certfnsh_html("").disposition == "unknown" diff --git a/tests/test_directory.py b/tests/test_directory.py new file mode 100644 index 0000000..cb59442 --- /dev/null +++ b/tests/test_directory.py @@ -0,0 +1,18 @@ +from cert_portal.directory import decode_object_sid, encode_object_sid + + +def test_sid_round_trip_typical_account(): + sid = "S-1-5-21-3623811015-3361044348-30300820-1013" + assert decode_object_sid(encode_object_sid(sid)) == sid + + +def test_sid_well_known_administrators(): + sid = "S-1-5-32-544" + assert decode_object_sid(encode_object_sid(sid)) == sid + + +def test_sid_revision_and_authority(): + raw = encode_object_sid("S-1-5-18") + assert raw[0] == 1 + assert raw[1] == 1 # one sub-authority + assert decode_object_sid(raw) == "S-1-5-18" diff --git a/tests/test_enrollment.py b/tests/test_enrollment.py new file mode 100644 index 0000000..d75d1e1 --- /dev/null +++ b/tests/test_enrollment.py @@ -0,0 +1,33 @@ +from cryptography import x509 +from cryptography.x509 import load_pem_x509_csr + +from cert_portal.enrollment import ( + SID_SAN_URI_PREFIX, + UPN_OID, + build_csr, + generate_rsa_key, + sid_san_uri, +) + + +def test_csr_contains_upn_othername_and_sid_uri(): + key = generate_rsa_key(2048).key + upn = "nick@example.test" + sid = "S-1-5-21-1-2-3-4" + pem = build_csr(key=key, upn=upn, sid=sid) + csr = load_pem_x509_csr(pem) + assert csr.subject.rfc4514_string() == f"CN={upn}" + san = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + names = list(san) + uris = [n.value for n in names if isinstance(n, x509.UniformResourceIdentifier)] + others = [n for n in names if isinstance(n, x509.OtherName)] + assert uris == [f"{SID_SAN_URI_PREFIX}{sid}"] + assert sid_san_uri(sid) in uris + assert any(o.type_id == UPN_OID for o in others) + upn_other = next(o for o in others if o.type_id == UPN_OID) + # DER UTF8String: tag 0x0c, short length, payload + assert upn_other.value[0] == 0x0C + length = upn_other.value[1] + assert upn_other.value[2 : 2 + length].decode("utf-8") == upn + assert b"-----BEGIN CERTIFICATE REQUEST-----" in pem + assert pem.startswith(b"-----BEGIN") diff --git a/tests/test_issue.py b/tests/test_issue.py new file mode 100644 index 0000000..b60a308 --- /dev/null +++ b/tests/test_issue.py @@ -0,0 +1,43 @@ +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import Encoding +from cryptography.x509 import load_pem_x509_csr +from tests.helpers import sign_csr + +from cert_portal.certsrv import CertsrvClient +from cert_portal.enrollment import SID_SAN_URI_PREFIX +from cert_portal.issue import ProfileIssuer + + +class _FakeCertsrv: + def __init__(self, ca_key, ca_cert): + self.ca_key = ca_key + self.ca_cert = ca_cert + self.last_csr = None + + def submit_csr(self, csr_pem, template=None): + pem = csr_pem if isinstance(csr_pem, bytes) else csr_pem.encode("ascii") + self.last_csr = pem + csr = load_pem_x509_csr(pem) + cert = sign_csr(csr, self.ca_key, self.ca_cert) + return cert.public_bytes(Encoding.PEM) + + +def test_issue_signs_profile_for_user(settings, directory_user, test_ca): + ca_key, ca_cert = test_ca + fake = _FakeCertsrv(ca_key, ca_cert) + issuer = ProfileIssuer(settings, certsrv=fake) + issued = issuer.issue(directory_user) + assert issued.upn == directory_user.upn + assert issued.sid == directory_user.sid + assert issued.filename.endswith(".mobileconfig") + assert issued.signed_mobileconfig.startswith(b"\x30") + assert b"com.apple.security.pkcs12" in issued.signed_mobileconfig + csr = load_pem_x509_csr(fake.last_csr) + san = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + uris = [n.value for n in san if isinstance(n, x509.UniformResourceIdentifier)] + assert f"{SID_SAN_URI_PREFIX}{directory_user.sid}" in uris + + +def test_ntlm_username_includes_domain(settings): + client = CertsrvClient(settings) + assert client._ntlm_user() == "EXAMPLE\\portal" diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 0000000..63716fb --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,55 @@ +import plistlib + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.serialization import pkcs7 +from tests.helpers import issue_test_ca + +from cert_portal.enrollment import export_pkcs12 +from cert_portal.profile import ( + PKCS12_PAYLOAD_TYPE, + build_unsigned_mobileconfig, + load_signing_identity, + sign_mobileconfig, +) + + +def test_unsigned_profile_embeds_pkcs12_and_password(): + key, cert = issue_test_ca() + p12, password = export_pkcs12(key=key, certificate=cert, friendly_name="nick@example.test") + unsigned = build_unsigned_mobileconfig( + identity_p12=p12, + identity_password=password, + display_name="User Certificate", + organization="Example", + identifier_prefix="com.example.cert-portal", + username="nick@example.test", + ) + profile = plistlib.loads(unsigned) + payloads = profile["PayloadContent"] + types = [p["PayloadType"] for p in payloads] + assert PKCS12_PAYLOAD_TYPE in types + assert "com.apple.security.scep" not in types + identity = next(p for p in payloads if p["PayloadType"] == PKCS12_PAYLOAD_TYPE) + assert identity["Password"] == password + assert identity["PayloadContent"] == p12 + + +def test_signed_profile_is_cms_and_contains_plist(signing_p12): + key, cert = issue_test_ca() + p12, password = export_pkcs12(key=key, certificate=cert) + unsigned = build_unsigned_mobileconfig( + identity_p12=p12, + identity_password=password, + display_name="User Certificate", + organization="Example", + identifier_prefix="com.example.cert-portal", + username="nick@example.test", + ) + identity = load_signing_identity(signing_p12, "signer-secret") + signed = sign_mobileconfig(unsigned, identity) + assert signed != unsigned + assert signed[:1] == b"\x30" # DER SEQUENCE + assert b"com.apple.security.pkcs12" in signed + certs = pkcs7.load_der_pkcs7_certificates(signed) + fps = {c.fingerprint(hashes.SHA256()) for c in certs} + assert identity.certificate.fingerprint(hashes.SHA256()) in fps