first build

This commit is contained in:
nick committed 2026-09-18 19:45:17 -07:00
1 parent 7ef008cb73
commit 71c11457cd
39 files changed
+2522

No files matched your search

+68
View File
@@ -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).
+44
View File
@@ -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 |
+42
View File
@@ -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.
+58
View File
@@ -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 |
+98
View File
@@ -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.