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