first build

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

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.pyc
.venv
.git
.env
*.p12
*.pfx
.pytest_cache
.ruff_cache
dist
build
*.egg-info
tests
+74
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+54
View File
@@ -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.
+68
View File
@@ -0,0 +1,68 @@
# AD CS Web Enrollment and certificate template
The portal submits CSRs to **Active Directory Certificate Services Web Enrollment** (`certsrv`), not NDES, not `certreq`, and not `ICertRequest` COM.
## Web Enrollment
On the CA (or a dedicated enrollment host):
1. Install the **Certification Authority Web Enrollment** role service.
2. Require HTTPS on the `certsrv` application.
3. Allow the portal service account to authenticate (Windows Integrated / NTLM). Basic auth is not used.
4. From the Linux host, `curl -I https://ca.example.com/certsrv/` should not fail TLS. A 401 is expected without NTLM.
The portal:
- `POST {CERTSRV_URL}/certfnsh.asp` with `Mode=newreq`, the PEM CSR, and `CertAttrib=CertificateTemplate:{CERTSRV_TEMPLATE}`
- On success, `GET {CERTSRV_URL}/certnew.cer?ReqID={id}&Enc=b64`
If the HTML does not look like “Certificate Issued”, the portal errors out. **Pending** (manager approval) is treated as failure — the template should auto-issue.
## Service account
Use a dedicated domain user (for example `EXAMPLE\cert-portal`):
- **Enroll** on the certificate template
- **Request Certificates** on the CA (often already granted to Authenticated Users)
- Allowed to use the `certsrv` IIS app
This account is what AD CS records as the requester. That is expected. User identity is in the certificate SAN, not the requester field.
## Template
Create a template (example name `UserWifi` — use the **template common name**, not the display name, in `CERTSRV_TEMPLATE`).
Required so the CSR SAN survives issuance:
- Subject name: **Supply in the request**
- Do **not** build subject from Active Directory
- SAN: allow the enrollee to supply SAN (the “enroll subject from request” / `ENROLLEE_SUPPLIES_SUBJECT` style flags)
Typical user-auth / Wi-Fi client settings:
- Category: User (or as your org requires)
- Extension: Application Policy Client Authentication
- Key usage: per your NPS/EAP-TLS needs (Windows templates often want Digital Signature and/or Key Encipherment)
- Validity: as policy allows
- No manager approval (or the portal will raise `CertsrvPendingError`)
- Publish to AD: optional; not required for Apple clients
Compatibility: Windows Server 2016+ for URI SAN strong mapping as described below.
## Strong mapping (SID URI)
The CSR includes:
```
URI:tag:microsoft.com,2022-09-14:sid:S-1-5-21-…
```
and UPN otherName `1.3.6.1.4.1.311.20.2.3`.
That URI is the form Microsoft documents for mapping certificates to AD objects when the CA did not stamp `szOID_NTDS_CA_SECURITY_EXT` itself (offline / “supply in request” workflows). Confirm with `certutil -dump` on a test cert that both SAN values are present.
If the issued cert is missing the URI, the template is still overwriting subject/SAN from Active Directory — fix the template before issuing to phones.
## Testing without the portal
From a domain-joined Windows box you can still use `certreq` to prove the template. The portal’s path is only `certsrv`. If `certreq` works but the portal fails, check NTLM, HTTPS, Enroll ACL, and the HTML parser (pending vs issued).
+44
View File
@@ -0,0 +1,44 @@
# Architecture
The portal is a Linux FastAPI app. It is the **enrollment client**. Apple devices only download a finished, signed configuration profile.
## Flow
1. **OIDC.** The browser is redirected to a generic OpenID Provider (Keycloak, Okta, Authentik, Entra ID, …). Authorization code + PKCE. A small user dict (`sub`, `username`, `email`, `name`) is stored in a signed session cookie. Access tokens are not kept.
2. **LDAP.** Using the username claim (default `email`, then `preferred_username`), the portal binds as a service account and searches Active Directory. It reads `objectSid`, `userPrincipalName`, and `displayName`. An optional group gate uses `LDAP_MATCHING_RULE_IN_CHAIN` (nested membership).
3. **Key + CSR (memory only).** On **Download**, not at login, the portal generates RSA-2048 (configurable), and a CSR:
- Subject `CN=<UPN>`
- SAN otherName `1.3.6.1.4.1.311.20.2.3` = UPN
- SAN URI `tag:microsoft.com,2022-09-14:sid:<SID>` (Microsoft strong mapping / KB5014754)
4. **certsrv.** NTLM POST to `/certsrv/certfnsh.asp` with `CertificateTemplate:<name>`. On issued, GET `/certsrv/certnew.cer?ReqID=&Enc=b64`. Pending or denied is an error; this portal does not wait for manager approval.
5. **Identity PKCS#12.** Private key + issued cert (+ optional CA) encrypted with a one-time password. Not written to disk.
6. **Unsigned plist.** A `com.apple.security.pkcs12` payload holds the identity P12 and that password. Optional CA payload if `PROFILE_CA_CERT_PATH` is set.
7. **Sign.** The completed plist is wrapped in attached CMS (not detached), equivalent to `openssl smime -sign -nodetach -outform der`, using the **profile-signing** PKCS#12.
8. **HTTPS download.** `Content-Type: application/x-apple-aspen-config`.
The CA database requester is the **portal NTLM account**. The **user** is bound by SAN (UPN + SID URI), not by Windows impersonation. OIDC does not yield a Kerberos ticket for the end user, so there is no Enrollment Agent / on-behalf-of in this version.
## Two PKCS#12 files
| File | Lifetime | Role |
| --- | --- | --- |
| Identity P12 | One download, RAM only | Embedded in the profile as `com.apple.security.pkcs12`. User cert + private key. |
| Profile-signing P12 | Long-lived file on the server | CMS-signs the `.mobileconfig`. Must already be trusted on the device. |
Do not reuse the signing P12 as a user identity, and do not save identity P12s.
## Why the phone never talks to the CA
SCEP/NDES would have the device generate a key and speak SCEP to NDES. This portal instead returns the cert and key inside the profile. After install, nothing on the phone needs `certsrv` or NDES reachability.
## Code map
| Module | Responsibility |
| --- | --- |
| `auth_oidc.py` | OIDC client registration, claim → username |
| `directory.py` | LDAPS, `objectSid` decode, group gate |
| `enrollment.py` | RSA key, CSR SAN, PKCS#12 export |
| `certsrv.py` | NTLM submit/fetch, HTML disposition parser |
| `profile.py` | Unsigned plist + CMS sign |
| `issue.py` | Orchestrates one download |
| `app.py` | HTTP routes, session, templates |
+42
View File
@@ -0,0 +1,42 @@
# Signing the `.mobileconfig`
The portal **always signs** the profile after the PKCS#12 payload is embedded. Unsigned profiles install with a warning; a signature that the device trusts shows as verified.
## Signing identity
`PROFILE_SIGNING_P12_PATH` is a PKCS#12 (`.p12` / `.pfx`) containing:
- The signer private key
- The signer certificate
- Optional intermediates (included in the CMS)
The **device must already trust** this certificate (or its issuer). Typical sources:
- An internal TLS/code-signing/profile-signing CA already deployed by MDM or a root payload
- A dedicated “configuration profile signing” cert from that CA
This file is **not** the per-user identity P12. It is long-lived and stored on disk. Protect it like any other service credential (`chmod` / secret mount).
## What the portal does
After building the XML plist it creates an **attached** CMS `SignedData` (DER):
```
openssl smime -sign \
-signer cert.pem -inkey key.pem -certfile chain.pem \
-nodetach -outform der \
-in unsigned.mobileconfig -out signed.mobileconfig
```
Implementation: `cryptography` `PKCS7SignatureBuilder`, SHA-256, `Encoding.DER`, `PKCS7Options.Binary` (so the XML is not canonicalized). Detached signatures are not used; Apple needs the profile bytes inside the CMS.
## Trust on the device
- If the signer chains to a root already on the iPhone/Mac, Settings shows the profile as signed/verified.
- If not, the user can still install it (with a warning) unless an MDM policy forbids unsigned/untrusted profiles.
Deploy the signing CA with MDM or an earlier profile before you depend on a “verified” badge.
## Rotation
Replace the P12 on disk and restart the process. Old profiles remain valid; new downloads use the new signer. Keep the old CA trusted until you do not care about old profiles.
+58
View File
@@ -0,0 +1,58 @@
# Security
## Private key on the server
Design B generates the user private key in the portal:
- Created in RAM on POST `/download`
- Exported into an identity PKCS#12 (still RAM)
- Embedded in the plist, which is then CMS-signed
- Returned in the HTTP response
- Not written to disk; no cache of keys or P12 passwords
Anyone who can download a profile for a user obtains that user’s private key. Protect the app like an RA:
- OIDC on every download
- Optional `LDAP_REQUIRED_GROUP`
- HTTPS only in production (`SESSION_HTTPS_ONLY`, reverse-proxy TLS)
- Restrict who can reach the portal (VPN, SSO group, IP allow list)
Do not use this design for keys that must be device-bound.
## Logging
The portal logs UPN and SID at info when a profile is issued. It does **not** log:
- Identity P12 bytes or passwords
- Profile-signing P12 passwords
- OIDC client secrets, LDAP bind passwords, certsrv passwords
- Raw CSR PEM at info (avoid debug logging in production)
Session cookie: `HttpOnly`, `SameSite=lax`, `Secure` when `SESSION_HTTPS_ONLY` is true.
## Who can enroll
1. Must complete OIDC (valid session).
2. Must match exactly one LDAP user.
3. If `LDAP_REQUIRED_GROUP` is set, must be a member (nested groups allowed).
4. The **service account**, not the user, must have Enroll on the template. A signed-in user cannot enroll a template the portal account cannot enroll.
There is no Enrollment Agent. A caller cannot request a cert for a different SID than LDAP returned for their username.
## CA trust
Prefer `CERTSRV_VERIFY_TLS=true` and `LDAP_VERIFY_TLS=true`. Disabling TLS verification allows credential theft on the path to certsrv (NTLM) and LDAP.
## OpenAPI
Leave `DOCS_ENABLED=false` in production so `/docs` is not public.
## Threat notes
| Issue | Mitigation |
| --- | --- |
| Stolen session cookie | Short IdP session, HTTPS, `Secure` cookies |
| Stolen signing P12 | Filesystem permissions / secret store; rotate |
| Template too powerful | Dedicated Wi-Fi user template, not a smartcard/admin template |
| certsrv HTML change | Parser fails closed (unknown page = error), fixtures in tests |
| SSRF-ish CA URL | `CERTSRV_URL` is config, not user input |
+98
View File
@@ -0,0 +1,98 @@
# Setup
The portal runs on Linux (bare metal, VM, or container). It needs outbound HTTPS to the IdP, LDAPS to a domain controller, and HTTPS to AD CS Web Enrollment.
## 1. Configuration
Copy [`.env.example`](../.env.example) to `.env`. Every variable is commented there. Minimum:
- `SESSION_SECRET` — long random string
- `PUBLIC_BASE_URL` — the URL browsers use (the reverse-proxy origin)
- `OIDC_*` — issuer, client id/secret, redirect `https://<portal>/auth/callback`
- `LDAP_*` — `ldaps://` URL, bind DN/password, base DN
- `CERTSRV_*` — `https://ca.example.com/certsrv`, NTLM user, template common name
- `PROFILE_SIGNING_P12_PATH` / `PROFILE_SIGNING_P12_PASSWORD`
Set `SESSION_HTTPS_ONLY=true` behind HTTPS. For a local `http://127.0.0.1:8000` lab only, set it false and use matching `OIDC_REDIRECT_URI`.
## 2. OpenID Connect client
Register a confidential web app at your IdP:
- Redirect URI: `{PUBLIC_BASE_URL}/auth/callback`
- Scopes: `openid email profile` (override with `OIDC_SCOPES`)
- PKCE: S256 (the portal always sends a code challenge)
`OIDC_ISSUER` must be the discovery origin (no trailing slash). The portal loads `{issuer}/.well-known/openid-configuration`.
`OIDC_USERNAME_CLAIM` (default `email`) is what LDAP searches for. If your IdP puts the AD UPN in `preferred_username` or `upn`, set the claim accordingly and keep `LDAP_USER_FILTER` in sync.
## 3. LDAP
Use **LDAPS** (`ldaps://dc:636`) in production. The bind account only needs to read users (and, if `LDAP_REQUIRED_GROUP` is set, group membership).
Default filter:
```
(&(objectClass=user)(|(userPrincipalName={username})(mail={username})))
```
`LDAP_REQUIRED_GROUP` may be a full DN or a CN. Nested members are included (`LDAP_MATCHING_RULE_IN_CHAIN`).
If the DC uses a private CA, set `LDAP_CA_CERTS_FILE` to a PEM bundle. `LDAP_VERIFY_TLS=false` is lab-only.
## 4. AD CS Web Enrollment
See [adcs.md](adcs.md). From the Linux host you should be able to reach `CERTSRV_URL` over HTTPS. The portal uses NTLM (`CERTSRV_DOMAIN` + `CERTSRV_USERNAME`). If `CERTSRV_USERNAME` is already `DOMAIN\user` or `user@upn`, the domain field is ignored.
Install the issuing CA TLS cert into `CERTSRV_CA_CERTS_FILE` (or the system trust store). Do not disable `CERTSRV_VERIFY_TLS` except in a lab.
## 5. Profile signing material
Place a PKCS#12 on the host (for Docker, bind-mount it) and point `PROFILE_SIGNING_P12_PATH` at it. See [profile-signing.md](profile-signing.md).
Optional: `PROFILE_CA_CERT_PATH` PEM/DER of the issuing CA to embed so devices that do not already trust it still accept the user cert.
## 6. Reverse proxy / TLS
Terminate TLS at Caddy, nginx, or another proxy. Forward `Host`, `X-Forwarded-For`, and `X-Forwarded-Proto`. The app is started with `proxy_headers=True`.
Example Caddy:
```
certs.example.com {
reverse_proxy 127.0.0.1:8000
}
```
## 7. Run
**Local**
```bash
python3.12 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
python -m cert_portal
```
Listens on `HOST`/`PORT` (default `0.0.0.0:8000`).
**Docker**
```bash
docker build -t cert-portal .
docker run --rm -p 8000:8000 --env-file .env \
-v /etc/cert-portal:/etc/cert-portal:ro \
cert-portal
```
The image runs as UID 1000. The signing P12 path inside the container must match `PROFILE_SIGNING_P12_PATH`.
## 8. OpenAPI
`/docs`, `/redoc`, and `/openapi.json` are **off** when `ENVIRONMENT=production` unless you set `DOCS_ENABLED=true`. They are on in `development`. Do not expose Swagger to the internet without additional access control.
## Health
`GET /health` returns `{"status":"ok","version":"..."}` and does not require a session. Point your load balancer at it.
+51
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
"""Application version."""
__version__ = "0.1.0"
+29
View File
@@ -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()
+233
View File
@@ -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
+65
View File
@@ -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 {}
+181
View File
@@ -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}."
)
+97
View File
@@ -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()
+207
View File
@@ -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
+137
View File
@@ -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.")
+45
View File
@@ -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."""
+99
View File
@@ -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"
+152
View File
@@ -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"
+114
View File
@@ -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; }
}
+26
View File
@@ -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>
+49
View File
@@ -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 %}
+35
View File
@@ -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 %}
+22
View File
@@ -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 %}
+75
View File
@@ -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]",
)
+10
View File
@@ -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>
+12
View File
@@ -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&amp;Enc=bin">DER Encoded</A></P>
<P><A href="certnew.cer?ReqID=18&amp;Enc=b64">Base 64 encoded</A></P>
</body>
</html>
+12
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
<html>
<head>
<title>Microsoft Active Directory Certificate Services</title>
</head>
<body>
<p>Unexpected intern error 0x80094011</p>
</body>
</html>
+50
View File
@@ -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())
+134
View File
@@ -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"
+36
View File
@@ -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"
+18
View File
@@ -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"
+33
View File
@@ -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")
+43
View File
@@ -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"
+55
View File
@@ -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