first build
This commit is contained in:
39 files changed
+2522
No files matched your search
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv
|
||||
.git
|
||||
.env
|
||||
*.p12
|
||||
*.pfx
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
dist
|
||||
build
|
||||
*.egg-info
|
||||
tests
|
||||
@@ -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
|
||||
+20
@@ -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
|
||||
+20
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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=<UPN>`
|
||||
- SAN otherName `1.3.6.1.4.1.311.20.2.3` = UPN
|
||||
- SAN URI `tag:microsoft.com,2022-09-14:sid:<SID>` (Microsoft strong mapping / KB5014754)
|
||||
4. **certsrv.** NTLM POST to `/certsrv/certfnsh.asp` with `CertificateTemplate:<name>`. 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 |
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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://<portal>/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.
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Application version."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
@@ -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}."
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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."""
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Certificate Portal{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<a class="brand" href="/">Certificate Portal</a>
|
||||
<nav>
|
||||
{% if user is defined and user %}
|
||||
<a href="/help">Help</a>
|
||||
<a href="/logout">Sign out</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
<main class="wrap">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="foot">
|
||||
<p>User certificates for Apple devices. The private key is generated on this server, then delivered in a signed profile.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Help — Certificate Portal{% endblock %}
|
||||
{% block content %}
|
||||
<article class="card prose">
|
||||
<h1>Help</h1>
|
||||
<p>Signed in as <strong>{{ user.username }}</strong>.</p>
|
||||
|
||||
<h2>What this portal does</h2>
|
||||
<ol>
|
||||
<li>You sign in with your organization SSO (OpenID Connect).</li>
|
||||
<li>The portal looks up your Active Directory account and Windows SID.</li>
|
||||
<li>It generates an RSA key <em>on this server</em>, builds a certificate request, and submits it to Active Directory Certificate Services Web Enrollment (<code>certsrv</code>).</li>
|
||||
<li>The issued certificate and private key are packed into a PKCS#12, placed inside an Apple <code>.mobileconfig</code>, and that profile is cryptographically signed.</li>
|
||||
<li>Your device installs the profile. It never talks to the CA.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Private key on the server</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>Install on iPhone or iPad</h2>
|
||||
<ol>
|
||||
<li>Tap <strong>Download certificate profile</strong> on the home page.</li>
|
||||
<li>Open the <strong>Settings</strong> app. A profile download notice appears near the top.</li>
|
||||
<li>Tap the profile, then <strong>Install</strong>. Authenticate with Face ID, Touch ID, or your passcode.</li>
|
||||
<li>The PKCS#12 import password is inside the signed profile; iOS applies it automatically.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Install on Mac</h2>
|
||||
<ol>
|
||||
<li>Download the <code>.mobileconfig</code> file.</li>
|
||||
<li>Open it from Downloads (or double-click in Finder).</li>
|
||||
<li>System Settings → <strong>Device Management</strong> (or Profiles) → install the profile.</li>
|
||||
<li>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.</li>
|
||||
</ol>
|
||||
|
||||
<h2>If download fails</h2>
|
||||
<ul>
|
||||
<li><strong>No Active Directory user</strong> — your SSO name did not match <code>userPrincipalName</code> or <code>mail</code>.</li>
|
||||
<li><strong>Not in the required group</strong> — enrollment is limited to a group the administrator configured.</li>
|
||||
<li><strong>CA denied or pending</strong> — the certificate template may require manager approval, or the portal account may lack Enroll permission.</li>
|
||||
</ul>
|
||||
<p>Operators: see the <code>docs/</code> folder in the cert-portal repository (architecture, setup, AD CS, profile signing, security).</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Download profile — Certificate Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h1>Your certificate profile</h1>
|
||||
<p>Signed in as <strong>{{ user.name }}</strong> <span class="muted">({{ user.username }})</span></p>
|
||||
|
||||
{% if error %}
|
||||
<p class="banner error" role="alert">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if directory_user %}
|
||||
<dl class="facts">
|
||||
<dt>User principal name</dt>
|
||||
<dd><code>{{ directory_user.upn }}</code></dd>
|
||||
<dt>SID</dt>
|
||||
<dd><code>{{ directory_user.sid }}</code></dd>
|
||||
<dt>Display name</dt>
|
||||
<dd>{{ directory_user.display_name }}</dd>
|
||||
</dl>
|
||||
<p>
|
||||
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).
|
||||
</p>
|
||||
<form method="post" action="/download">
|
||||
<button class="button" type="submit">Download certificate profile</button>
|
||||
</form>
|
||||
{% elif not error %}
|
||||
<p class="muted">Looking up your directory account…</p>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="/help">How to install the profile</a></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sign in — Certificate Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h1>Sign in to get a user certificate</h1>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
{% if error is defined and error %}
|
||||
<p class="banner error" role="alert">{{ error }}</p>
|
||||
{% endif %}
|
||||
<p>
|
||||
<a class="button" href="/login">Sign in with SSO</a>
|
||||
</p>
|
||||
<p class="muted">
|
||||
The private key is created here, not on your phone. That is expected for
|
||||
user Wi-Fi certificates. See Help after you sign in.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -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="[email protected]",
|
||||
sid="S-1-5-21-3623811015-3361044348-30300820-1013",
|
||||
display_name="Nick Example",
|
||||
mail="[email protected]",
|
||||
)
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Microsoft Active Directory Certificate Services</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Microsoft Active Directory Certificate Services</h1>
|
||||
<B>Certificate Request Denied</B>
|
||||
Your certificate request was denied by an administrator.
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Microsoft Active Directory Certificate Services</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Microsoft Active Directory Certificate Services</h1>
|
||||
<B> Certificate Issued</B>
|
||||
The certificate you requested was issued to you.
|
||||
<P><A href="certnew.cer?ReqID=18&Enc=bin">DER Encoded</A></P>
|
||||
<P><A href="certnew.cer?ReqID=18&Enc=b64">Base 64 encoded</A></P>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Microsoft Active Directory Certificate Services</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Microsoft Active Directory Certificate Services</h1>
|
||||
<B>Certificate Pending</B>
|
||||
Your certificate request has been received. However, you must wait for an
|
||||
administrator to issue the certificate you requested.
|
||||
<P>Your Request Id is 42.</P>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Microsoft Active Directory Certificate Services</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Unexpected intern error 0x80094011</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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())
|
||||
@@ -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": "[email protected]",
|
||||
"email": "[email protected]",
|
||||
"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": "[email protected]",
|
||||
"email": "[email protected]",
|
||||
"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="[email protected]",
|
||||
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 "[email protected]" 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": "[email protected]"}, "email") == "[email protected]"
|
||||
assert claims_username({"preferred_username": "a"}, "email") == "a"
|
||||
|
||||
|
||||
def test_session_user_from_token():
|
||||
token = {"userinfo": {"sub": "1", "email": "[email protected]", "name": "A"}}
|
||||
user = session_user_from_token(token, "email")
|
||||
assert user["username"] == "[email protected]"
|
||||
assert user["name"] == "A"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 = "[email protected]"
|
||||
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")
|
||||
@@ -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"
|
||||
@@ -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="[email protected]")
|
||||
unsigned = build_unsigned_mobileconfig(
|
||||
identity_p12=p12,
|
||||
identity_password=password,
|
||||
display_name="User Certificate",
|
||||
organization="Example",
|
||||
identifier_prefix="com.example.cert-portal",
|
||||
username="[email protected]",
|
||||
)
|
||||
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="[email protected]",
|
||||
)
|
||||
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
|
||||
Reference in new issue
Block a user