first build
This commit is contained in:
39 files changed
+2522
No files matched your search
@@ -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.
|
||||
Reference in new issue
Block a user