Two paths, and only one of them needs a browser.
Signing certificates for Apple platforms are created and stored without a human wherever Apple allows it: the App Store Connect API issues development and distribution certificates from a CSR, and Skarbiec records each one with its Apple identifiers. Developer ID is the exception Apple reserves for the Account Holder, and it has its own automated path through Weles.
What a signing build reads#
Two names for one certificate, and only one of them signs. Stado resolves signing material by its own constant, DEVELOPER_ID_ITEM = desktop-release-developer-id, and reads the manifests' secret_env coordinate nowhere. A certificate stored under the declared name satisfies a declaration and signs nothing.
The three fields are the same either way, and the build receives them as process environment variables, writing nothing to disk.
# what Stado reads when it signs
desktop-release-developer-id#certificate_p12_base64 -> MACOS_CERT_P12
desktop-release-developer-id#certificate_password -> MACOS_CERT_PASSWORD
desktop-release-developer-id#sign_identity -> MACOS_SIGN_IDENTITY
# what the manifests declare, which Stado reads nowhere
MACOS_CERT_P12 wisent-apple-developer-id#certificate_p12_base64
MACOS_CERT_PASSWORD wisent-apple-developer-id#certificate_password
MACOS_SIGN_IDENTITY wisent-apple-developer-id#sign_identityCertificates the API issues#
Development and distribution certificates need no browser. A CSR is generated locally, App Store Connect returns the certificate, and the item is written with the Apple identifiers that make it auditable later. The account currently holds seven development certificates created this way and two iOS distribution certificates: 27Y97WLK78, whose private key no vault here holds, and DFB6433C63, minted on 2026-09-02 and stored as wisent-ios-distribution, the coordinate every *-ios release manifest declares.
The recorded item is the evidence, not a note: source_kind names the channel that issued it, certificate_id and serial_number identify it at Apple, key_id names the App Store Connect key that asked, and expires_at is the renewal date nobody has to remember.
skarbiec get desktop-signing-apple-development
kind: certificate
fields: certificate, private_key
context:
source_kind: app-store-connect-api
certificate_id: U86HFLB9XJ
certificate_type: DEVELOPMENT
serial_number: 55732A38D5EC5B23F68A73A37BE255C0
key_id: 685D4U2G83
team_id: LNTWB5B9DV
usage: xcode-development-signing
expires_at: 2027-08-05T17:40:11.000+00:00# list and revoke through the ASC API, no browser
node weles/scripts/trajectories/apple/asc/certs/asc_certs.mjs
# what the account holds, who holds which role, and mint
python3 skarbiec/scripts/apple-developer-id.py list
python3 skarbiec/scripts/apple-developer-id.py roles
# the iOS side: certificate, bundle id, App Store profile, GitHub secrets
python3 skarbiec/scripts/apple-ios-signing.py list
python3 skarbiec/scripts/apple-ios-signing.py mint-certificate
python3 skarbiec/scripts/apple-ios-signing.py profile --repository jeden-ios --bundle-id ai.wisent.jeden --app-name Jeden
python3 skarbiec/scripts/apple-ios-signing.py publish --repository jeden-ioswisent-ios-distribution#certificate_p12_base64 -> IOS_DIST_P12_B64
wisent-ios-distribution#certificate_password -> IOS_DIST_P12_PASSWORD
wisent-ios-distribution#sign_identity -> IOS_SIGN_IDENTITY
<repository>-signing#provisioning_profile_base64 -> IOS_PROFILE_B64
# the App Store Connect app record a TestFlight upload needs is not API-creatable:
# POST /v1/apps -> 403 The resource 'apps' does not allow 'CREATE'.
# skarbiec/scripts/apple_web.py creates it the way the site does, with no browser:
# SRP-6a sign-in at idmsa.apple.com with the vault's Apple ID (weles-apple-control-account),
# the second factor read by Stado's signed helper in the registry-bound Aqua session,
# then iris/v1/apps — fastlane produce's request.
python3 skarbiec/scripts/apple-ios-signing.py app-record --bundle-id ai.wisent.jeden --app-name Jeden --sku jeden-iosWhy Developer ID is different#
Apple refuses to create a DEVELOPER_ID_APPLICATION certificate for any App Store Connect key, whatever its role. Both keys this vault holds are team keys and each is answered the same way.
There is nobody to delegate to either: the account has a single user, and that user holds ACCOUNT_HOLDER and ADMIN. So this certificate is the one case where the portal, and therefore a browser, is unavoidable.
POST /v1/certificates -> 403
This request is forbidden for security reasons:
This operation can only be performed by the Account Holder.The Account Holder path#
Weles owns the portal session. The trajectory authenticates as the Account Holder, uploads a CSR to the certificates page, and writes the issued certificate to a path the caller names. It is registered as provider apple, action create_developer_id, and it takes the account's email, password, and 2FA code as three authorization-bound, one-use Skarbiec capabilities rather than as configuration.
The browser runs on the explicitly pinned Stado target. The machine invoking the authorizer creates the private key and CSR, submits the job, waits for its terminal state, and writes the validated DER certificate; it does not launch the browser.
weles/scripts/trajectories/apple/create_developer_id.mjs
WELES_LOGIN_ITEM weles-apple-control-account
APPLE_AUTH_GUARD_ID authorization UUID shared by all three capabilities
ACTION_LOG_ID the Stado job or Weles run this trajectory belongs to
APPLE_CSR_PATH absolute path or ~/ path on the worker
APPLE_CERTIFICATE_PATH absolute path or ~/ path on the workerWhere the six digits come from#
Before Weles opens a browser, Stado verifies that the Apple account is observed in the registry-bound macOS user's current console session, that version 2 of its signed helper is installed there, and that the helper can use Accessibility. The helper's --preflight mode calls AXIsProcessTrusted without asking macOS to open a consent window.
After the one password submit makes Apple display its trusted-device prompt, Stado runs that helper in the same Aqua session. The code travels on stdin to `skarbiec apple-challenge-put` in the execution host's Weles broker, and the trajectory consumes the pending capability once. There is no relay user, SSH gate, Weles-owned Swift source, or relay configuration file.
authorization stado identity issue-apple-capabilities
preflight stado identity relay-apple-challenge --preflight
capture /usr/local/libexec/stado-apple-challenge-capture
store skarbiec apple-challenge-put challenge:apple/<authorization-id>
consume Weles capability redemption
# Stado installs the helper and reports the exact GUI user
stado host gui-automation grant-accessibility <holder>
stado host gui-automation status <holder>How the issued certificate is recorded#
An API-issued certificate is stored as kind certificate, which carries exactly certificate, private_key, chain and passphrase. The Developer ID item is stored as kind bundle instead, because the release manifests read a p12, its password and an identity string, and bundle is the kind with no field allowlist. Both are written as one canonical payload through stdin, so no secret is ever a command-line argument.
skarbiec set-json wisent-apple-developer-id --type bundle < payload.json
# payload.json
{
"schema": "skarbiec.item.v2",
"kind": "bundle",
"fields": {
"certificate_p12_base64": "…",
"certificate_password": "…",
"sign_identity": "Developer ID Application: …"
},
"context": { "purpose": "macos-developer-id-signing" }
}What a capability needs before it can be issued#
An email or password capability is refused at issue time unless a route maps its resource to a vault field and that field has a value. This prevents a login from reaching the password submit with a capability that resolves to nothing.
`challenge:` is the documented exception because its value arrives after Apple displays the prompt. The challenge capability needs no route; it is pending until the Stado relay writes the code for that authorization.
All three capabilities must be issued against the broker files on the execution host. A capability minted on the operator's machine cannot be redeemed through another host's socket.
skarbiec routes add --resource origin:https://idmsa.apple.com/email \
--item weles-apple-control-account --field email --reason <text>
skarbiec routes add --resource origin:https://idmsa.apple.com/password \
--item weles-apple-control-account --field password --reason <text>
# what the refusal looks like without them
Error: no capability route maps origin:https://idmsa.apple.com/email
to a vault field# the one-use capability an authorization issues; needs a route
skarbiec grant capability --agent weles-worker --purpose weles.browser.fill \
--resource origin:https://idmsa.apple.com/email --target weles \
--ttl 900 --max-uses 1 --authorization-id <guard-id>
# the workload-bound acquire grant a worker redeems, bound to its key
skarbiec grant issue <consumer> \
--capabilities "acquire:<item>#<field>" \
--workload-public-key-file ~/.stado/weles-credential-workload-public.pemHow the run is dispatched#
The authorizer issues the three capabilities and enqueues the trajectory as a Stado job pinned to the host that will run it; the worker decodes the payload and spawns the registered trajectory with the environment the dispatch table builds.
authorize-apple-developer-id.mjs generates the keypair and CSR on the machine that asks, sends only the public CSR, waits through Stado's machine interface, validates exactly one returned DER X.509 certificate, and writes it to the caller's unused output path. A shell trap removes the worker's request and certificate directory on every exit.
node weles/scripts/auth/authorize-apple-developer-id.mjs \
--account-item weles-apple-control-account \
--confirm "AUTHORIZE ONE APPLE DEVELOPER ID" \
--execution-host <browser host> --execution-agent weles-worker \
--expires-in-minutes 15 \
--private-key-out /absolute/key.pem \
--certificate-out /absolute/certificate.cerWhat is checked before the password#
The worker maps the authorizer's account_item to WELES_LOGIN_ITEM and accepts either an eight-hex Stado job id or the UUID Weles assigns to a run. The trajectory validates the complete three-capability envelope before it opens a browser.
Stado then checks the live apple-account observation, the exact registry-bound macOS user, that user's console session, helper version, Accessibility result, and the execution host's broker. Any failure here ends the run before an email or password capability is redeemed.
stado identity verify --kind apple-account \
--identity <account email> --json
stado host gui-automation status <holder>Failure behavior#
The login submits the password once. An HTTP 401 or 403 from Apple's signin/complete endpoint is reported as the password refusal; Weles does not turn it into a timeout and does not try another stored item.
Weles closes the browser and asks the broker to cancel the authorization's capabilities on every failure. A capability already spent or a cleanup that cannot be confirmed is still bounded by its original expiry and one-use limit.
The authorizer removes a newly written private key only if failure occurs before Developer ID job submission starts. Once submission starts it preserves that key, because a missing or failed receipt cannot prove that Apple created no certificate for its CSR.