The iOS VTA mobile agent — a login/authentication agent that provides biometric
AAL1→AAL2 step-up. It talks to its VTA only over the mediator (DIDComm or
TSP) — there is no REST client and no bearer token; see
Transport. It consumes the vta-mobile-core engine (Rust + UniFFI,
built in OpenVTC/verifiable-trust-infrastructure)
as a precompiled VtaMobileCore.xcframework, distributed via GitHub Releases
and pinned by SwiftPM checksum.
Package.swift SwiftPM manifest (pins the engine release)
Sources/VtaMobileCore/ generated UniFFI Swift bindings (vendored)
Sources/VtaMobileAgent/ agent façade over the engine
Sources/VtaMobileAgent/VtaTransport.swift DIDComm / TSP submission + TSP reply correlation
Sources/VtaMobileAgent/DisplayName.swift DID → agent name: trust rules + resolver cache
App/DidLabel.swift the one way a DID is put on screen
Tests/VtaMobileAgentTests/ on-simulator smoke test
App/ reference SwiftUI sources for the app target
.github/workflows/ci.yml runs the smoke test on an iOS Simulator
VtaMobileCoreFFI (the binaryTarget) is the compiled engine + C module.
VtaMobileCore compiles the generated Swift wrapper against it. VtaMobileAgent
is the thin façade the app imports.
VtaMobileCore.xcframework contains iOS slices only (device + simulator).
A plain swift build / swift test targets macOS and will fail to find a
slice — that's expected. Always build/test against an iOS Simulator:
xcodebuild test -workspace .swiftpm/xcode/package.xcworkspace \
-scheme VtaMobileAgent-Package \
-destination 'platform=iOS Simulator,name=iPhone 17'(Run xcrun simctl list devices available | grep -i iphone to pick an
installed simulator. Note the -workspace: with the generated .xcodeproj
present in the repo root, a bare -scheme resolves against it rather than
Package.swift, and the package schemes won't be found.) Or open Package.swift in Xcode, choose an iPhone
simulator, and press ⌘U.
The agent reaches its VTA over the holder's mediator and nothing else. There is
no authenticate step and no token to refresh: the VTA proves the sender
cryptographically on every message — authcrypt for DIDComm, the sealed sender
VID for TSP — and derives the caller's role, contexts and session from that DID
alone (intrinsic-sender auth). Possession of the holder key is the
credential.
Everything the device submits (auth/step-up/approve-response,
task-consent/decision, device/set-wake, whoami) goes through
VtaTransport.submit, which reaches the same VTA dispatcher that used to sit
behind POST /api/trust-tasks.
| DIDComm | TSP | |
|---|---|---|
| Framing | Trust Task in the message body |
Trust Task bytes directly |
| Reply correlation | native, by thid |
by threadId, via TspReplyRouter |
submit waits? |
yes, in-place | yes, but the reply arrives on the inbox |
TSP needs the router because it has no thid demux and receiveNext holds the
socket lock for its whole budget — so a submit that read its own reply would
deadlock the inbox loop. Instead the listen loop stays the sole reader and
offers every frame to the router first. The correlation rule is the framework's:
a response carries threadId = request.threadId ?? request.id.
What still isn't DIDComm. Two things deliberately remain over HTTP:
- Enrolment. The device
did:keymust be in the VTA's ACL (pnm acl create --did <did:key> …) before any message from it is accepted. An unenrolled device fails at the post-connectwhoami. - Push gateway registration.
push/registergoes to the gateway, a different service that is the only component permitted to see the raw APNs token. Only thedevice/set-wakeleg — the one addressed to the VTA — moved onto the transport.
demoSelfStepUp is gone: it provoked a 403 from an AAL2-gated endpoint, a
challenge carried by an HTTP status, which the messaging transports have no
equivalent for.
The app shows DIDs where it has no choice — who is asking for a step-up, who
delivered a task-consent request, which VTA it is bound to. A did:webvh in a
phone caption is unreadable, and on an approval sheet unreadable means the
operator is approving something they cannot identify.
So every DID renders through DidLabel (or DidNameNote under an editable
field), which shows the DID's agent name — a human-memorable
example.com/@alice — over the shortened DID. Two rules hold everywhere:
- The DID stays visible. A name the operator cannot cross-check against an
identifier is a name they cannot audit, so the shortened DID always sits under
the name and the full DID is selectable. Never interpolate a bare DID into a
Text— useDidLabel. - An unverified claim is never shown as fact. It renders amber with an
[unverified]tag viaDisplayName.rendered, which is the only rendering path. Don't reach forDisplayName.namewhen building UI text.
Why rule 2 exists. A name is only safe to show because it round-tripped:
the DID's document claimed it and resolving that name led back to the same DID.
alsoKnownAs alone is self-asserted, so a hostile DID can claim
mybank.com/@treasury, and printing that claim bare tells the operator — in an
authoritative voice, on the one screen where they are about to approve something
— that they are looking at their bank. The round trip happens in
vta_sdk::display_name, and the engine hands this app only the verdict
(verified: Bool); the check is deliberately not reimplemented in Swift, because
two implementations of a spoofing defence would have to agree forever.
shortenDid likewise delegates to the engine rather than porting the rule — the
CLIs, the admin console and this app must abbreviate a DID identically, or an
operator moving between them re-identifies every DID on the way across.
DisplayNameTests re-asserts the shared vector table (whose authority is
shorten_did_matches_shared_vectors in vta-sdk) so an engine bump that changes
it fails here.
Lookups cost a DID resolution plus an outbound fetch per claimed name, so
NameResolver caches them (hits 6 h, misses 10 min — a miss expires sooner
because the day names start being minted, a session-long negative cache would
never notice) and collapses concurrent asks for the same DID into one round trip.
Nothing blocks on a lookup: the shortened DID paints immediately and upgrades in
place.
No DID publishes a name yet. Nothing in the VTI workspace writes an
alsoKnownAs entry today, so every lookup currently returns None and every
surface shows a shortened DID. The seam is wired so that minting names lights the
app up without a new release.
Package.swift pins one engine release. The Swift wrapper
(Sources/VtaMobileCore/VtaMobileCore.swift) and the binary checksum are a
matched set — never mix versions. To adopt a new
vta-mobile-core-vX.Y.Z release:
TAG=vta-mobile-core-v0.1.0 # the release to adopt
REPO=OpenVTC/verifiable-trust-infrastructure
# 1. re-vendor the generated wrapper
gh release download "$TAG" -R "$REPO" -p 'VtaMobileCore.swift' \
-D Sources/VtaMobileCore/ --clobber
# 2. read the checksum to paste into Package.swift (engineChecksum)
gh release download "$TAG" -R "$REPO" -p 'VtaMobileCore.xcframework.zip.sha256' -O -Then set engineTag + engineChecksum in Package.swift to match.
On first setup,
engineChecksumis a placeholder — paste the real value from thevta-mobile-core-v0.1.0release (step 2 above) before the remote build will resolve.
To iterate against a locally-built xcframework (no release needed), build it in
the engine repo (vta-mobile-core/scripts/package-ios.sh) and drop a
VtaMobileCore.xcframework at this package's root (it's gitignored). When that
file is present, Package.swift uses it instead of the published release:
cp -R /abs/path/to/target/mobile/ios/VtaMobileCore.xcframework ./VtaMobileCore.xcframework
rm -rf .build ~/Library/Caches/org.swift.swiftpm/manifests # see the two gotchas below
xcodebuild test -workspace .swiftpm/xcode/package.xcworkspace \
-scheme VtaMobileAgent-Package \
-destination 'platform=iOS Simulator,name=iPhone 17'Two things that will otherwise cost you an hour:
- Copy, don't symlink.
Package.swiftselects the local xcframework withFileManager.fileExists, and a symlink at that path does not reliably satisfy it — SwiftPM silently falls back to downloading the pinned release, and you debug "missing" FFI symbols that are right there in the header. - Clear the SwiftPM manifest cache. Evaluated manifests are cached
globally in
~/Library/Caches/org.swift.swiftpm/manifests, so the local-vs-remote decision sticks acrossrm -rf .buildand fresh DerivedData.
(Remember to also vendor that build's VtaMobileCore.swift into
Sources/VtaMobileCore/ so the wrapper matches the binary.)
The SwiftUI app target (App/, bundle id org.openvtc.vta.agent) is described
declaratively in project.yml and generated with XcodeGen — the .xcodeproj
is not committed (regenerate it any time):
brew install xcodegen # once
xcodegen generate # writes VtaMobileAgentApp.xcodeproj
open VtaMobileAgentApp.xcodeprojPick an installed iPhone simulator (the latest-runtime one — e.g. iPhone 17
— is safest; name=iPhone 16 only resolves if a matching OS is installed) and
Run.
The app is organised as a bottom tab bar so each context is focused:
- Home — at-a-glance status hero + the single primary action. Once configured the agent runs itself; this screen just reflects that.
- Test — manual surfaces for development: who am I, live mediator listening, pasted ratification, push-wake registration.
- History — a chronological, color-coded record of authentications, approvals (live / pasted / push), and errors.
- Logs — the engine + app
stdout/stderrstream captured in-app (LogStore), with copy/clear — diagnose on-device without Xcode attached. - Settings — all configuration (VTA DID / mediator / gateway, auto-connect), the device identity, and the theme picker.
Everything is auto and recoverable. Once a VTA is configured the agent
auto-connects on launch and supervises the mediator listener with
exponential-backoff reconnects — a dropped network/VTA recovers with no user
action. There is no token refresh to schedule: connected simply means the
inbox is open and the VTA answered a whoami over it. A connection status pill is always visible in
the nav bar on every tab.
Themes (Theme.swift) are user-selectable at runtime (Vibrant / Neon /
Pastel / Minimal) and persisted; the choice restyles the whole app live.
CLI build/run without opening Xcode:
xcodegen generate
xcodebuild build -scheme VtaMobileAgentApp \
-destination 'platform=iOS Simulator,name=iPhone 17'The agent can be woken by a push to ratify a step-up while backgrounded,
instead of holding a live mediator connection. The contentless push is a
doorbell (push wake-up binding
/binding/push/0.1); the real
(encrypted) approve-request is pulled from the mediator after the app wakes.
On-device flow (PushRegistration.swift + AppDelegate.swift):
- Enable push wake → request notification authorization +
registerForRemoteNotifications. - APNs returns the device token → engine
build_push_register→ gateway (push/register, the token is held by the gateway only) → opaqueWakeHandle. - Engine
build_device_set_wake(holder-signed) → VTA (device/set-wake) — the VTA owns the trigger allowlist and provisions the gateway. - On a delegated step-up the VTA sends
push/wake→ the gateway delivers a contentless APNs push → the app wakes → drains its mediator → ratifies the approve-request with the holder key (receiveStepUpOnce).
Apple setup (token-based APNs):
- An App ID for
org.openvtc.vta.agentwith Push Notifications enabled, and Xcode automatic signing (it provisions the device + push entitlement). The app declares theaps-environmententitlement + theremote-notificationbackground mode (both viaproject.yml). - An APNs Auth Key (
.p8) + its Key ID + your Team ID — these go in the gateway (GATEWAY_APNS_KEY_FILE/GATEWAY_APNS_KEY_ID/GATEWAY_APNS_TEAM_ID), never in the app. - A real device — APNs doesn't deliver remote pushes to the Simulator. Dev
builds get a sandbox APNs token, so the gateway routes via the APNs sandbox
host automatically (the registration's
environmentis.sandbox).
Quick delivery check (no VTA). Tap Enable push wake; the app shows its
APNs token in the UI (also printed to the Xcode console). Run the gateway
with the APNs creds and fire a wake straight at the token — no did:webvh
gateway identity, no VTA:
cargo run -- test-wake-apns http://<gateway-host>:8300 <apns-token> org.openvtc.vta.agentThe phone wakes and drains its mediator. (For the full VTA-triggered loop, register the wake channel from the app instead and trigger a delegated step-up.)
Apache-2.0.