A worked-through explanation of how an EACS'd Mac mini (or a fresh
Linux worker) gets its vault.yaml from a brokered Secret
Manager — what's actually happening at each step, what we're trusting
when, and where the threat-model edges sit. Written for someone who
will own or audit this system.
Every Mozilla RelOps worker — macOS, Linux, Windows — runs
masterless Puppet at first boot to provision itself: install
packages, set up users, register the Taskcluster generic-worker daemon,
and so on. Puppet pulls its per-host configuration through Hiera,
a data-lookup mechanism that reads YAML files. The most sensitive of
those is data/secrets/vault.yaml, which contains the
Taskcluster access_token for that role's worker pool, the
cltbld user's password (hashed and unhashed), the Bugzilla
API key, the livelog secret — all the stuff a worker needs to actually
do work.
Historically the operator delivered this file by hand. They'd open
1Password, find the role's vault entry, save the YAML to their laptop,
SCP it onto the worker at /var/root/vault.yaml (macOS) or
/root/vault.yaml (Linux). The Puppet bootstrap script
copied it into ./data/secrets/vault.yaml within the cloned
ronin_puppet repo, and the run proceeded.
The "hand-drop" framing above is a simplification. The real procedure operators followed before this work — and that we're explicitly replacing — was substantially worse than just one SCP. We include the full thing here for completeness, because part of the value of this project is making it visible how far we've moved from this baseline.
Old step 5 — disable SIP, apply Puppet via Bolt from the operator's laptop:
# SIP had to be disabled on the worker first via Recovery Mode
# (a separate, manual, hands-on hardware step we won't reproduce here).
# Then on the operator's laptop, with a checkout of ronin_puppet:
git clone ronin_puppet
# add the new host to the role's inventory (e.g. macmini-m4.yaml)
export VAULT_ADDR="https://vault.relops.mozops.net:8200"
vault login -method=oidc role="hiera_admin"
export VAULT_TOKEN=$(cat ~/.vault-token)
bolt plan run deploy::apply_no_verify \
--targets macmini-m4-129.test.releng.mdc1.mozilla.com \
noop=false --native-ssh
Tester pools sometimes needed a reboot + a second Puppet run before they
settled. TCC permissions for generic-worker-simple had to
be applied live while the static task user (cltbld)
was actively logged in — meaning the operator coordinated a console
session at the right moment.
Old step 6 — activate the worker by tripping a semaphore + reboot:
sudo mkdir /var/tmp/semaphore
sudo touch /var/tmp/semaphore/run-buildbot
reboot
# Then check Taskcluster's worker-pool UI for the new worker to appear.
# If it didn't, tail the worker stderr:
sudo tail -f /opt/worker/logs/stderr.log
Old step 7 — make Puppet auto-run on every reboot:
open 1Password → RELOPS vault, locate the right entry (e.g.
Puppet Vault Auth - gecko-t-osx-1500-m4), copy the YAML to
/var/root/vault.yaml on the worker. From then on,
subsequent reboots picked it up from there.
Tallying the trust handoffs in this procedure:
VAULT_TOKEN that lived on their laptop disk for the duration of the bootstrap.
That's three different secret stores touched (HashiCorp Vault, 1Password
RELOPS vault, the worker's filesystem), four credential handoffs
(OIDC → vault token → bolt SSH → file copy), and one mandatory
out-of-band SIP-disable step. The puppet-apply step accepted
--targets with a worker-group inventory, so the
apply itself could fan out across a pool in one invocation; the
per-worker chokepoints were everything else (vault.yaml drop, SIP
disable, TCC live-coordination). It worked at the scale Mozilla was
operating at, but barely — and not at all if you wanted a one-click
workflow.
This worked for ~10 hardware refreshes a year. It does not work for fleet-scale operations:
So: we want a way for a worker, knowing only its own role, to fetch its
vault.yaml automatically. The constraint that makes this interesting is
that "a worker" is not a trustworthy actor — anyone can stand
up a Mac mini and claim it's gecko_t_osx_1500_m4. The
worker needs to prove it is that role.
forge.relops.mozilla.com; the
Google-managed HTTPS Load Balancer validates the cert chain against
our CA, and a Cloud Run broker parses the SAN and serves back the
role-scoped vault.yaml from Secret Manager.
The remaining sections elaborate each of those. We'll work bottom-up: trust foundations first, then a single request walked through step by step, then platform-specific details, then where it can fail.
Before talking about how the system works, it's worth being explicit about what we're trusting at each layer. "Trust" here means: if this thing goes bad, what's the consequence?
We run our own step-ca instance on a GCE VM in the
relops-bootstrap GCP project. It maintains a two-level
hierarchy: a self-signed root CA (10-year validity)
and an intermediate CA signed by the root (1-year
validity, used as the actual signing CA for all worker leaf certs).
This is the standard PKI pattern — keeping the root offline (or, in our
case, behind a separate password-protected step-ca config) limits the
blast radius of a compromise of the day-to-day-active intermediate.
step-ca's root and intermediate public PEMs are baked into:
terraform/trust/{root,intermediate}.pem — these are public certs, no private key material.forge.relops.mozilla.com's Google-managed cert via the public CA roots).
The private keys for the root and intermediate live on the
step-ca VM, encrypted with a password loaded from
/home/step/.step/secrets/password (which is itself only
readable by the step user). The VM has no public-internet
ingress beyond the HTTPS LB endpoint; admin access is via
Google's Identity-Aware Proxy (IAP) tunneled SSH, which
requires the operator's Google identity + a project-level IAM grant.
The intermediate signing key is, in absolute terms, the most powerful piece of cryptographic material in this system. Anyone who steals it can mint a cert claiming any role and read any vault. We trust it because: (a) it lives on a VM with no public ingress except through the LB, (b) administrative SSH requires IAP + IAM, (c) the OS disk is encrypted at rest by GCP, (d) the key is encrypted at rest within the VM behind a password not stored anywhere else readable, (e) step-ca's process runs as a non-root user. There is no perfect protection — a cloud-platform-level compromise of GCP itself would unravel this, as it would unravel virtually any cloud-hosted secret. We accept that as the bound.
Worker certs are issued via SCEP, the Simple Certificate Enrollment
Protocol (RFC
8894). SCEP is what Apple's mdmclient already speaks
natively when an MDM-pushed profile contains a
com.apple.security.scep payload, which is why we use it
instead of inventing something custom.
A SCEP enrollment request is gated on a shared secret called the
"challenge." The challenge is set on each provisioner (one per
puppet role in our setup) and is included in the CSR's
challengePassword attribute. step-ca verifies the
challenge matches before signing. Our challenge is stored in GCP
Secret Manager (step-ca-scep-challenge) and the operator
reads it via their gcloud authentication when delivering a Linux host
(or it's embedded in the SimpleMDM SCEP profile for Mac, which
SimpleMDM holds securely on its servers).
The SCEP challenge is a single shared secret across all enrollments for a given provisioner. If it leaks, an attacker who can also reach forge.relops.mozilla.com from the MDC1 worker subnet (because Cloud Armor restricts it) can mint certs for that provisioner's role. The leak surface is narrow (it lives in SimpleMDM's secured profile delivery + GCP Secret Manager + briefly on a Linux operator's laptop during enrollment), and the network ACL bounds the damage, but it's worth noting that the challenge is the lightest piece of crypto in the system.
The macOS-equivalent of SCEP enrollment via ACME with Apple's hardware attestation would replace this shared secret with per-device hardware-bound proof. We tried that and it didn't survive Apple's keychain ACL model. So we live with the shared challenge for now.
The HTTPS Load Balancer at forge.relops.mozilla.com is
configured with a Trust Config (a GCP Certificate Manager
resource) that contains only our step-ca root cert as a trust
anchor, plus the intermediate cert as a known signer. The LB's TLS
policy is set to ALLOW_INVALID_OR_MISSING_CLIENT_CERT:
meaning request a client cert during the TLS handshake, but
don't require one to terminate. This lets us serve the
/scep/* endpoints (which clients hit before they
have a cert, to enroll) on the same hostname as
/secret/*.
When a client does present a cert, the LB:
CertificateRequest message.Certificate message.X-Client-Cert-Chain-Verified: false header.notBefore ≤ now ≤ notAfter).X-Client-Cert-Present, X-Client-Cert-Chain-Verified, X-Client-Cert-Leaf (base64-encoded PEM of the leaf), X-Client-Cert-Serial-Number, and several others.We want the broker to do the policy enforcement, not the LB. The LB validates cryptography (chain math) but doesn't know whether a particular cert is allowed to fetch a particular role's secret — that's the broker's job. By forwarding the request regardless and letting the broker decide, we keep all the role-binding logic in one auditable place. And we get a side benefit: requests without a cert reach the broker, which logs them and returns 401, so we have visibility into who's poking at the endpoint without one.
vault-broker is a small FastAPI service running on Cloud
Run. It reads the LB-forwarded headers, decodes the leaf cert PEM,
parses its SubjectAltName extension for a URI of the form
spiffe://relops.mozilla/host/<hostname>/role/<role>,
and:
X-Client-Cert-Present: true and X-Client-Cert-Chain-Verified: true (otherwise → 401).vault-<role> from Secret Manager via the broker's service account (which has per-secret IAM bindings — it can read only the vault-* secrets, nothing else in the project).text/yaml.Every accept and reject decision lands in Cloud Audit Logs with the cert serial number, the requested role, the requesting IP, and the bytes returned. This is the audit trail.
The broker has very little surface area. Its only sensitive operation is "read vault-<role> from Secret Manager," and its IAM binding allows only that. It does not sign anything, hold any private keys, or have administrative privileges on any other GCP resource. If the broker process is compromised, an attacker reads all vault-* secrets. That's bad — but it's the same blast radius as 1Password vault access, which any RelOps operator has. We've added rate limiting per cert serial (token bucket, refills 1/sec, burst 10) to slow down enumeration attacks — though note it's in-memory and per-Cloud-Run-instance, so under autoscaling the effective aggregate limit is higher than the per-instance numbers; it's a speed bump, not a hard global quota (a shared store like Memorystore would fix that). The broker is fundamentally a thin proxy and we trust the GCP infrastructure to keep it isolated.
Let's walk through a single request from the worker's perspective, in detail. Assume the worker has already been enrolled — if you want the prequel (what EACS and DEP re-enrollment actually do to get here), see what EACS actually does.
On macOS, this means a SCEP-issued identity in the System keychain,
with a private key whose ACL allows the OS network stack to sign with
it. On Linux, this means a PEM cert + PEM key at
/etc/relops-bootstrap/{cert,key}.pem owned by root with
0600 permissions. The cert's Subject Common Name is whatever the SCEP
profile's subject renders to — on macOS that's the device
%ComputerName% (frequently the default "Mac mini", so the CN is
not a unique hostname), on Linux the host FQDN. The CN is only used
to locate the identity in the keychain; the authoritative field is the SAN's
SPIFFE URI, which binds the cert to exactly one puppet role.
The cert and key were minted by step-ca's SCEP provisioner at
enrollment time. The role-binding is enforced by the provisioner's
x509 template — we have one provisioner per role, and each
provisioner's template hardcodes
"spiffe://relops.mozilla/host/{{ .Subject.CommonName }}/role/<role>"
into the SANs. There is no way for the worker to request a
cert with a role it shouldn't have — the choice of role is determined
by which provisioner it enrolls against, and the provisioner's URL
path is delivered via MDM profile (macOS) or specified by the operator
(Linux). The worker has no input on it.
The worker initiates a TLS 1.3 handshake with
forge.relops.mozilla.com:443. The client sends a
ClientHello advertising the cipher suites it supports and the SNI of
the hostname.
The LB responds with the server's certificate (a Google-managed cert
whose chain validates against the public CA roots already in every
worker's system trust store — this is the same way HTTPS works
everywhere on the internet) plus, critically, a
CertificateRequest message telling the client "I want
your cert too." The CertificateRequest also (sometimes) includes the
list of acceptable CA distinguished names. In our case, GCP's
LB sends an empty acceptable-CA list, which is a
subtle point that bit us in development.
The worker responds with its own Certificate message
containing its leaf cert (and optionally the intermediate, though our
workers send only the leaf — the LB has the intermediate in its Trust
Config). It then signs a hash of the entire handshake transcript with
its private key, and sends that signature in a
CertificateVerify message. This proves the client
actually possesses the private key matching the cert it just sent
(otherwise it could just replay someone else's cert).
This signing step is where the macOS keychain ACL gets
involved. The signing happens inside whichever process is
doing the TLS handshake. On macOS, when we use
CURL_SSL_BACKEND=securetransport curl --cert "<CN>",
the handshake runs inside Apple's SecureTransport library, which is
permitted by the keychain ACL to sign with our SCEP-installed key. (We
explore why this matters in detail in section 5.)
On Linux, the signing happens in LibreSSL / OpenSSL, reading the
private key from the PEM file — straightforward, no OS gatekeeping.
The LB verifies:
CertificateVerify validates against the leaf cert's public key over the handshake transcript — i.e. the client actually has the private key.
If any of these fails, the LB does not tear down the
connection — its policy is "allow invalid or missing." Instead, it
records the failure and prepares to inject
X-Client-Cert-Chain-Verified: false later. If validation
succeeds, it prepares to inject the cert's metadata as request
headers.
Both sides exchange Finished messages. The TLS session is
now established and encrypted. The worker sends an HTTP/2 request:
GET /secret/gecko_t_osx_1500_m4 HTTP/2
Host: forge.relops.mozilla.com
User-Agent: curl/8.7.1
Accept: */*
The LB rewrites the request as it forwards to Cloud Run. The rewritten request looks roughly like:
GET /secret/gecko_t_osx_1500_m4 HTTP/1.1
Host: vault-broker-internal
X-Forwarded-For: 63.245.209.101
X-Client-Cert-Present: true
X-Client-Cert-Chain-Verified: true
X-Client-Cert-Serial-Number: 65:20:86:3c:23:5e:fa:0a:c8:5d:47:70:1a:74:43:75
X-Client-Cert-SHA256: TGt3g713vVfsEadMuNovHAe5460Y/py52mXy+6jkJxw
X-Client-Cert-Leaf: MIIDSDCC...<base64 PEM of leaf cert>...DEhQ==
X-Client-Cert-SPIFFE: <possibly empty if LB couldn't parse>
X-Client-Cert-Subject-DN: MDYxEDAOBgNV...<base64 DN>...
This is the load-bearing invariant of the whole design, so it's worth
stating precisely. The broker does not re-verify the
cert chain — it trusts the LB's X-Client-Cert-Chain-Verified
header. Two facts make that safe, and a security review should confirm both:
custom_request_headers on the backend service
(terraform/lb.tf) set each X-Client-Cert-* value
the broker reads, overwriting any inbound value for those names — so a
client cannot present a forged X-Client-Cert-Chain-Verified: true.INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER
(live value internal-and-cloud-load-balancing), so a direct
request to the Cloud Run URL is refused at the platform layer — even though
allUsers holds roles/run.invoker (the standard
grant for an LB-fronted service, gated entirely by that ingress setting).Together these mean an attacker can't bypass the LB and forge the headers. The corollary a reviewer should note: if that ingress setting were ever relaxed, the header trust would become an authentication bypass. That single setting is what earns the broker the right to trust its headers.
Inside the broker, in
broker/app/auth.py:
# 1. The basic LB-said-yes checks
if cert_present != "true": raise AuthError("no client cert")
if chain_verified != "true": raise AuthError("chain not verified")
# 2. Decode the base64-PEM leaf into a cryptography.x509.Certificate
leaf = x509.load_pem_x509_certificate(base64.b64decode(cert_leaf))
# 3. Extract SAN, find a URI matching our trust domain prefix
san = leaf.extensions.get_extension_for_oid(SUBJECT_ALTERNATIVE_NAME)
for uri in san.value.get_values_for_type(x509.UniformResourceIdentifier):
decoded = urllib.parse.unquote(uri) # handle Mac%20mini → "Mac mini"
if decoded.startswith("spiffe://relops.mozilla/host/"):
hostname, _, role = decoded[len(prefix):].split("/", 2)
break
# 4. Role-match against URL path
if role != url_path_role: raise AuthError("role mismatch")
If everything matches, the broker calls read_secret(f"vault-{role}")
via the Google Secret Manager Python client, and returns the content
as the response body with content-type text/yaml.
The broker writes a 200 OK back to the LB, the LB tunnels it back
through the TLS session to the worker. The worker (in our bootstrap
script) atomically writes the body to /var/root/vault.yaml
(macOS) or /root/vault.yaml (Linux) at 0600
root:root. From here, the rest of the puppet bootstrap is unchanged
from how it worked before — Hiera reads the file, populates the
per-role lookups, puppet applies, worker is provisioned.
Of the two platforms we support, macOS is the harder one to make this work on, and the reasons are educational in their own right. The short version: macOS treats keychain-stored cryptographic keys as the OS's responsibility, not the application's, and the OS imposes restrictions that took us several days to fully understand. The mechanism we landed on works by going through the OS's own network stack rather than fighting it.
When SimpleMDM pushes a com.apple.security.scep profile
to an M4 mini, mdmclient on the device:
/Library/Keychains/System.keychain.Importantly, the private key never touches the filesystem in plaintext. It's stored in the System keychain's encrypted database file, and reading or using it requires going through the macOS Security framework.
Every keychain key has an access-control list. Apple's SCEP-enrollment flow installs the key with three ACL entries (which we discovered by dumping the keychain on a real worker):
| Entry | Apps allowed | Authorizations |
|---|---|---|
| 0. "MDM Allow All" | wildcard | encrypt only |
| 1. "Allow All" (despite the name) | specific list: configd, nehelper, NEIKEv2Provider, racoon, pppd, NetAuth, others | sign, decrypt, derive, export_clear, export_wrapped, mac |
| 2. "Config Profiles Cert Access" | com.apple.ConfigurationProfiles, nehelper | change_acl |
Read carefully: the only ACL entry that grants sign
authorization is the second one, and it grants it only to a hardcoded
list of Apple's own networking-stack helpers. Our custom code — a
Swift binary we wrote, a Python script, even a shell command running as
root from a LaunchDaemon — is not in this list and never can be.
When we tried to call SecKeyCreateSignature on this key
from any of those contexts, macOS returned
errSecInteractionNotAllowed (-25308) with the underlying
CSSM error being CSSMERR_CSP_NO_USER_INTERACTION — i.e.,
"I would need to show a UI prompt to authorize this, and I can't, so
no."
We spent meaningful time exploring whether we could get our own code into that ACL. We tried setting AllowAllAppsAccess=true on the SCEP profile — that only adds an ACL entry for encrypt, not sign. We tried KeyIsExtractable=true — that exposes a documented field but the actual SecItemExport call from a LaunchDaemon hits the same UI-prompt wall. We tried ACME enrollment with Apple device attestation — the cert lands in com.apple.identities, an access group requiring an Apple-only entitlement we can't get. The conclusion: this is by design. Apple wants the network stack to use these keys, not arbitrary developer code.
Once we accepted that we cannot sign with this key from our own process, the question became: which process can? The ACL lists Apple's network-stack helpers. Anything that initiates a TLS connection through those helpers can effectively sign with the key during the handshake.
macOS's curl binary, since macOS 12 or so, ships with two
TLS backends compiled in: LibreSSL (the default) and SecureTransport
(Apple's own TLS implementation, deprecated for new code but still
present and functional). When you invoke
curl --cert "<Common Name>" with no key file,
curl assumes the argument is a keychain identity reference — but only
when SecureTransport is the active backend. The
CURL_SSL_BACKEND=securetransport environment variable
flips it.
With SecureTransport active, curl asks the macOS Security framework to
find an identity with that Common Name, get a SecIdentityRef, and use
it during the TLS handshake. The handshake — including the
CertificateVerify signature — runs inside SecureTransport
itself, which goes through the network-stack helpers that are
in the keychain ACL. Result: the cert gets used, the key never leaves
the keychain, no UI prompt, no PKCS#12 extraction.
This is, in retrospect, exactly the affordance Apple designed for. We
were just slow to find it because we kept trying to make our own code
sign things, and the documentation for the SecureTransport curl
backend is sparse-bordering-on-undocumented. (We have notes on this
for posterity in scripts/INSTALL-on-worker.md.)
An attacker who compromises a worker as root cannot exfiltrate the private key. The key is in the keychain database, encrypted at rest, and the ACL prevents any process — including a root LaunchDaemon — from extracting it. The cert is still usable for fetching vault.yaml on that specific device (via SecureTransport curl), but the attacker can't take the key off the device to use it elsewhere. Compare to the Linux model where the key is a PEM file on disk and root has full read access — Linux is the weaker substrate here. (We accept the Linux model because there's no equivalent OS-managed key store on Linux that we can drive from a worker bootstrap.)
The Linux flow below is implemented and its six step-ca SCEP provisioners
exist, but the corresponding vault-* Secret Manager containers are
currently empty (0 versions) — so an enrollment would mint a
cert but the fetch would have nothing to return. The Linux path is not
functional end-to-end until those secrets are populated and a real-host test
(ronin_puppet PR #1253) is completed. The live, proven platform today is macOS
(m4). Read this section as the design, not a running system.
Linux has no MDM. There is no daemon that runs at first boot and
enrolls the device into a management plane that holds an SCEP profile.
We could install one — mosquitto-style or
chef-client-style — but Mozilla RelOps doesn't, and
shipping a new agent for one bootstrap problem is the wrong tradeoff.
Instead, on Linux, the operator is the trust anchor at
enrollment time. The operator's laptop runs
provisioners/linux/deliver_linux.sh, which:
gcloud auth.scripts/mint-scep-cert.sh (from a checkout of relops-bootstrap) which:
openssl.challengePassword attribute.sscep enroll against the appropriate per-role SCEP provisioner endpoint./etc/relops-bootstrap/ on the target worker, as cert.pem and key.pem at 0600 root:root.fetch_vault_yaml_via_mtls function detects the cert and key, runs curl --cert ... --key ... against forge.relops.mozilla.com, lands /root/vault.yaml, then proceeds with the existing puppet bootstrap flow.
The operator's gcloud authentication is the gate. To run this flow,
you need (a) GCP IAM permission to read Secret Manager in the
relops-bootstrap project, (b) SSH access to the target
Linux worker (which the operator already needs for any provisioning
work), and (c) the relops-bootstrap repo checked out locally with
sscep installed.
The Linux worker's PEM cert + PEM key live on the filesystem at
/etc/relops-bootstrap/{cert,key}.pem, root:root, mode
0600. An attacker with root on the worker can read both.
This is a step down from macOS's keychain protection, but it's not a
step down from the baseline: the file the cert is fetching —
/root/vault.yaml — also lives root-readable on the
filesystem in the same way it always has. An attacker who can read
the cert can also just read the vault.yaml directly. The cert's only
purpose is bootstrap-time enrollment; it doesn't grant any additional
privilege beyond what root already has on that host.
The choice we deliberately did not make: use TPM-backed key storage on
Linux. The hardware exists (TPM 2.0 is in most modern servers and
almost all modern Mozilla hardware), and tpm2-tss +
openssl-tpm2-engine would let us store the key in TPM and
sign via the TPM. We chose not to for two reasons: (1) it adds a
significant dependency to the bootstrap script, increasing the
surface area of "things that can break during enrollment," and (2)
the threat model doesn't really call for it — see the previous
paragraph. If the calculus changes (e.g. cert lifetimes become long
enough that key compromise becomes meaningful), we'd revisit.
An honest account of how we got here. Each of these is a path we pursued, sometimes deeply, before realizing it wouldn't work. Understanding why each failed informs what the current design is defending against.
Our first design was conceptually clean: the worker holds a SCEP
cert; at request time it constructs a short-lived JWT with claims
{sub, role, aud, exp, iat, jti}, signs it with the cert's
private key, embeds the cert chain in the JWT header as
x5c, and sends it to the broker as a Bearer
token. The broker validates the JWT signature against the cert's
public key, chain-verifies the cert to our root, parses claims, role-
binds, returns the secret.
This is a very standard "workload identity" pattern (it's how
Kubernetes service accounts work, broadly). It died on macOS because
signing the JWT requires calling SecKeyCreateSignature
from our own process, which the keychain ACL forbids (see
section 5). We tried for two days to get our
Swift bootstrap binary into the ACL — through profile flag changes,
through entitlement requests, through a LaunchDaemon at first boot,
through SCEP enrollment with various profile options. None of it
worked. The conclusion was that our own code fundamentally cannot
sign with this key.
We then briefly considered pivoting to "let curl do the JWT" but curl doesn't sign JWTs. We considered a small Apple-signed helper binary — we don't have an Apple Developer ID with the right entitlements, and getting one would be a 3-month process. mTLS — where the OS does the signing during the TLS handshake — became obviously better.
Apple's macOS supports ACME enrollment with device attestation challenges, where the device proves it's a real Apple device via a signed statement from the Secure Enclave. This is the "most secure" modern enrollment option — much better than our shared SCEP challenge. We invested significant time to make it work: configured step-ca's ACME provisioner with Apple attestation, fixed multiple iterations of the mobileconfig payload, debugged the device-attest-01 challenge flow with step-ca's logs.
We got it working end-to-end: the device successfully enrolled, step-ca
validated the Apple attestation, a cert was issued. But the
cert landed in a different access group within the keychain
(com.apple.identities) than the SCEP-issued one
(com.apple.systemkeychain). The
com.apple.identities group is gated by an Apple-only
entitlement that no third-party developer can request. Even
find-identity on a privileged process returned
errSecMissingEntitlement (-34018). Neither our code nor
even SecureTransport curl could use the cert. It was, by Apple's
design, only usable by Apple's own MDM-aware services.
We documented this and reverted to SCEP. ACME-with-attestation might become viable on a future macOS version if Apple loosens com.apple.identities, but for now SCEP is what works.
Before discovering the SecureTransport curl backend, we attempted to
do mTLS from a Swift binary using URLSession, which is
Apple's standard high-level networking API. URLSession has a delegate
callback for NSURLAuthenticationMethodClientCertificate
where the application provides a URLCredential with a
SecIdentityRef. We wrote this code, found the SCEP identity by Subject
CN, constructed the credential, returned it.
The behavior we observed: the TLS handshake failed with error
-9860 (errSSLClientCertRequested) and the diagnostic key
_kCFStreamPropertySSLClientCertificateState=0 — meaning
"no client cert was actually sent." After much debugging we concluded:
URLSession silently drops the credential if the LB's
CertificateRequest message contains an empty acceptable-CA list
(which GCP's LB sends). URLSession's policy appears to be "if I don't
know which CA the server wants, don't send anything." There is no API
to override this behavior. SecureTransport curl, by contrast, sends
the cert anyway and lets the server reject it if it doesn't like it.
This was a surprising finding — URLSession is the modern, recommended API, and SecureTransport is the deprecated one. But for our use case, the deprecated API is the one that actually works.
All three were attempts to use Apple's modern APIs (URLSession, JWT- signing-from-app-process, ACME with attestation). All three failed because Apple's modern APIs prioritize a different security model than the one we needed — they assume the app is the entity holding the cert, and Apple's frameworks gatekeep that. The working solution (SecureTransport curl + SCEP) treats the OS as the entity holding the cert and goes through Apple's own networking helpers. Once you frame it that way, our setup looks more like "use macOS as nature intended" than "fight macOS to get our way."
Walking through the realistic attackers, what they can do, and how the layered defenses respond. We've kept this honest — some threats are not fully mitigated, and we want that to be visible.
Mitigation: Cloud Armor source-CIDR allowlist on the LB.
The LB has a security policy that denies all traffic except from
63.245.209.101/32 (Mozilla's MDC1 NAT egress IP, plus
GCP's standard health-check ranges). An attacker from anywhere else
on the internet sees a 403 from Cloud Armor before TLS even
terminates. They can't even probe.
Residual risk: if MDC1's NAT egress IP changes and we forget to update the allowlist, legitimate traffic fails. Operational, not security, but worth knowing.
Mitigation: mTLS at the LB rejects unauthenticated /secret/*
requests at the broker. The attacker can reach the LB, can
open a TLS connection, can send an HTTP request to
/secret/<role> — but without a cert,
X-Client-Cert-Present arrives as false and
the broker returns 401. Every attempt is audit-logged.
Residual risk: the attempt itself reveals our endpoint
structure. Mitigation: we don't consider forge.relops.mozilla.com
itself secret; its existence is in DNS.
Mitigation: the cert grants no privilege the attacker doesn't
already have. Root on a worker can read
/root/vault.yaml (Linux) or
/var/root/vault.yaml (macOS) directly — that's how puppet
reads it too. The cert lets the attacker re-fetch that same vault.yaml
from the broker. There's no escalation.
On macOS specifically, the cert's private key is not exfiltrable — the keychain ACL prevents export. The attacker can use the cert on that host but can't take it elsewhere.
On Linux, the PEM key is on disk and the attacker can copy it. They
can then use it from elsewhere to fetch the same role's vault.yaml.
But they need to also be on the Cloud Armor allowlist
(63.245.209.101/32), so this only works if the attack
pivots to a system on MDC1 first.
Residual risk: Linux key portability. Could be addressed with TPM-backed key storage, deferred (see section 6).
Mitigation: Cloud Armor + per-role-binding. Even with the challenge, an attacker needs to be on the MDC1 source-IP allowlist to talk to the SCEP enrollment endpoint. If they are on MDC1 and have the challenge, they can mint a cert claiming any role whose provisioner uses the same challenge — and then use that cert to fetch any role's vault.yaml.
Residual risk: this is the single biggest exposure in the current design. The challenge lives in (a) GCP Secret Manager (read by operators via gcloud auth), (b) SimpleMDM's profile delivery system (read by mdmclient on each device, embedded in the mobileconfig). A compromise of either Mozilla's GCP IAM or SimpleMDM's profile storage exposes this. Mitigations available but not yet implemented: per-role challenges (currently we share one), short-TTL challenges that rotate (step-ca supports this), moving Linux enrollment to a JWT-token-based provisioner where the token is signed by a key only the operator's gcloud auth can reach.
Mitigation: the broker's IAM is scoped to read
vault-* secrets only. The broker SA cannot read
any other Secret Manager secret, cannot read Cloud Storage, cannot
modify GCP resources, cannot reach the step-ca VM. Compromise gives
the attacker the ability to read all vault-* secrets —
every role's vault.yaml. That's significant: it includes every role's
taskcluster access token, every cltbld password, etc.
Residual risk: "read all vault-* secrets" is real. We mitigate how easy a compromise is by keeping the broker surface area tiny (it's ~150 lines of FastAPI; no cryptography of its own beyond parsing the leaf cert to read its SAN; no user input beyond the URL path), but we do not mitigate the consequence. This is the same blast radius as compromising operator 1Password access, which RelOps has lived with for years.
This is the worst case. An attacker who runs arbitrary code on the step-ca VM can mint certs for any role, use them to read any vault.yaml, and arguably do worse — they could issue certs that are themselves trust anchors for downstream attacks.
Mitigations: step-ca VM has no public ingress except the LB.
Admin SSH requires Google IAP + IAM grant in the
relops-bootstrap project. The OS disk is encrypted at
rest by GCP. The intermediate signing key is encrypted at rest within
the VM behind a password loaded from
/home/step/.step/secrets/password, which is only readable
by the step service user.
Residual risk: if a Mozilla GCP admin's account is fully compromised, the attacker can SSH to step-ca and read the key. This is the same trust we already place in GCP admins across the entire Mozilla cloud footprint — we don't have a special defense against it here, and we don't pretend to.
An operator with a compromised laptop is, today, also an operator with 1Password access (which is the baseline). Our system doesn't make this worse: the attacker gets the operator's gcloud auth, which lets them read GCP Secret Manager, which gives them the SCEP challenge and the role secrets. The same compromise gave them 1Password vault access anyway. No regression.
| Threat | Outcome | Worse or better than the previous (operator-SCP) model? |
|---|---|---|
| Random internet attacker | blocked at Cloud Armor | same (no exposure before either) |
| MDC1 attacker, no cert | 401, audited | better (audit trail; previously unobserved) |
| Worker root compromise | reads vault.yaml on that host | same (vault.yaml was already root-readable) |
| SCEP challenge leak | can enroll certs from MDC1 | worse (new attack surface) — mitigated by Cloud Armor but real |
| Broker compromise | reads all vault-* secrets | same blast radius as 1P operator access |
| step-ca compromise | can mint any cert | new and worse — but mitigated by VM isolation |
| Operator laptop compromise | has gcloud + 1P access | same as before |
Net assessment: the new system trades one fairly small new exposure (SCEP challenge as a single point of failure) for several substantial operational and security improvements (audit trail, automation, removal of the "operator with a YAML on their laptop" chokepoint). We think the tradeoff is favorable, especially given the challenge surface is small and operational mitigations exist.
An operator clicks Reprovision in Hangar (one host or a
whole pool); the on-network runner drives the rest. It quarantines and drains
the worker in Taskcluster, then triggers EACS. The device wipes and DEP
re-enrollment kicks in. SimpleMDM pushes the assigned profiles, including
Dev - SCEP. mdmclient enrolls a fresh cert
into the System keychain. The runner then auto-mints the first
SecureToken over an on-host ssh login (DEP skips Setup Assistant, so admin
holds no token until a PAM login — and Apple only grants the first
SecureToken through an interactive session, which is exactly why the driver
must be on-network; a script/PKG can't), and the Bootstrap Token is escrowed
alongside it. The
bootstrap — a signed PKG (managed install) built from
scripts/simplemdm-m4-no-sip-bootstrap.sh that lands during
DEP convergence and runs as root — then runs
CURL_SSL_BACKEND=securetransport curl --cert "<CN>" ...
to fetch /var/root/vault.yaml. It clones the appropriate
ronin_puppet branch, installs a reboot-survivable
com.mozilla.m4-bootstrap LaunchDaemon that runs
run-puppet.sh via ssh root@localhost until
the safari semaphores fire. Once they do, the daemon writes a
sentinel, kicks the worker-runner trigger, and removes itself.
Total operator touches: one click in Hangar — the runner
handles quarantine, drain, EACS, mint, escrow, and un-quarantine, and streams
every phase back to a live view. The entire flow from click to "worker
re-registered in Taskcluster" is roughly 30-45 minutes wall-clock; most
of that is puppet doing real installation work, not our bootstrap. (The
individual steps are also runnable directly from the reprovision
CLI for a single host without Hangar.)
# operator terminal, MDC1-accessible laptop
export RELOPS_BOOTSTRAP_REPO=~/git/relops-bootstrap
cd ~/git/ronin_puppet/provisioners/linux
./deliver_linux.sh worker-04.mdc1.mozops.net gecko_t_linux_2404_talos
# script handles: SCEP enrollment locally, scp cert to host,
# scp bootstrap_linux.sh, kick the bootstrap. Operator watches.
Total operator touches: one command. The operator's laptop needs
sscep installed (brew or apt) and the relops-bootstrap
repo cloned somewhere.
Some realistic failures and what they look like to an operator:
| Symptom | What's broken | How to debug |
|---|---|---|
Bootstrap (PKG) logs fetch_vault_yaml_via_mtls: HTTP 401 |
The cert isn't in the keychain (macOS), or the cert chain didn't validate at the LB | SSH to worker; security find-identity -v; openssl x509 -in <cert> -noout -text to confirm SAN URI shape |
HTTP 403, "cert is authorized for role X, not Y" |
The cert's role doesn't match the URL path role | Check /etc/puppet_role on the worker vs the SimpleMDM device group assignment |
HTTP 500, "secret unavailable" |
The Secret Manager secret for that role has no version | Run gcloud secrets versions list vault-<role> from the operator laptop. If empty: populate from 1P. |
| Worker hangs at "Waiting for SCEP cert in System keychain..." | SimpleMDM hasn't pushed the SCEP profile (or device isn't in the right group), or mdmclient hasn't enrolled yet | Check SimpleMDM device group assignment; sudo profiles list on the worker to see installed profiles |
SecureTransport curl: SSL handshake failed |
Either the cert chain doesn't validate at the LB (Trust Config mismatch?) or LibreSSL is intercepting the cert reference because CURL_SSL_BACKEND isn't set |
Confirm env var; check curl -V shows SecureTransport as a backend |
Briefly worth knowing: Apple Silicon devices have a concept called the Bootstrap Token (BST), separate from SecureToken, separate from FileVault. The BST is generated by macOS the first time a SecureToken-eligible user logs in interactively, and is escrowed to the MDM so that MDM can subsequently grant SecureToken to other users programmatically.
Our SimpleMDM bootstrap script calls
sysadminctl -secureTokenOn admin -password admin, which
(on macOS 12.4+) automatically uses the escrowed BST to grant
SecureToken to admin without requiring another SecureToken user's
credentials. This is the standard way to get the admin user into a
state where it can manage FileVault and other privileged things later
on. Empirically: BST generation happens automatically on
first console login or SSH login, but BST escrow to MDM can
be in a separate state; the script explicitly calls
profiles install -type bootstraptoken to force the
escrow if it hasn't happened. This wasn't quite obvious from Apple's
docs and bit us empirically — see the
feedback_apple_silicon_bst_escrow memory if you've
forgotten.
Things we know we should think about, in roughly priority order:
ca.json, so it should be set explicitly if we ever want to rely on it) so revocation gets you ~half a day of reduction in window of misuse — not zero but not nothing.com.apple.identities entitlement model; revisit if Apple loosens it. Linux: feasible via TPM 2.0; not currently a priority.gecko_t_osx_1500_m4, …_staging, …_no_sip) has both a provisioner and a populated secret. r8 and 1015 aren't started (empty secret containers, no provisioner); the six Linux roles have provisioners but empty secrets (see §6). Each remaining role is a step-ca SCEP provisioner + SimpleMDM SCEP profile + populated Secret Manager secret — mechanical, ~30 min per role.scripts/bootstrap-step-ca.sh now codifies all eight live SCEP provisioners, the abandoned acme-no-sip provisioner and its mobileconfig template were removed (dead end 2). One tidy-up remains: the LB url-map still routes /acme/* to step-ca (harmless — step-ca no longer has an ACME provisioner, so it just 404s); drop that path rule from terraform/lb.tf when convenient.Invoke-RestMethod -Certificate, or curl.exe --cert). Pending finding where the existing Windows bootstrap.ps1 lives — it's not in ronin_puppet.challengePassword attribute for authentication.roles/<role>.yaml then os/<os>.yaml then common.yaml). Our vault.yaml lives in this hierarchy at the highest priority.-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----. Linux workers store their cert + key as PEM files at /etc/relops-bootstrap/.com.apple.security.scep profile. step-ca supports it.curl as one of its backends. Has the unique property of being able to use keychain identities by Common Name. The load-bearing piece for our macOS mTLS flow.spiffe://<trust-domain>/<path>). We use it in the cert SAN to bind a cert to a single puppet role: spiffe://relops.mozilla/host/<CN>/role/<puppet-role>.