Skip to content

Every e2e from an Android emulator, with real Credential Manager passkeys, the enclave and the platform - #62

Merged
aruokhai merged 20 commits into
mainfrom
android-passkey-e2e
Oct 17, 2026
Merged

aruokhai merged 20 commits into
mainfrom
android-passkey-e2e

Conversation

@aruokhai

@aruokhai aruokhai commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Every e2e now runs from an Android emulator, the way the app runs. Every passkey goes through Android's real Credential Manager, against the dev enclave and MerlinPlatform. The host's Dart suite is replaced by its on-device port.

What runs (make e2e = scripts/e2e-all.sh)

It uses one boot of the dev enclave image's app bundle (ENCLAVE_BUNDLE=app: rp id vtxos.com, the app's origins), with MerlinPlatform, and runs two files on the emulator:

app/integration_test/app_test.dart, the app's full flow through its screens:

  1. A passkey is created, then DKG, then the exit address.
  2. 500k sats are boarded; the funds are protected and their exits signed.
  3. The cosigner renews them by itself, and an entry with the passkey re-arms the renewal.
  4. The Exit tab.
  5. A payout through the platform: KSh 1,500 to M-PESA.
  6. The app restarted: it opens from its own storage, with the same balance.
  7. A new phone: wiped and restored with "I already have a wallet", the same balance, and it pays again.

app/integration_test/enclave_test.dart, the wallet against the cosigner. This is the host suite's tests, ported with every wallet the app's own client:

  • attestation down to the component, and refusing another component or an unattested certificate;
  • DKG, device enrolment and tenants;
  • addresses, and a fresh wallet holding nothing;
  • an exit for every VTXO, an exit bitcoind mines, and a wallet with no exit address;
  • three sends, and a mixed exit-delay set;
  • nothing secret at rest, and a wrong seed refused;
  • a client rebuilt from storage, recovery on a new phone, a PRF that answers differently, and another tenant's contribution refused;
  • an unknown service refused.

Not ported:

  • The cosigner refreshing funds by itself: app_test.dart proves it through the app.
  • The escrow service's own protocol (both halves arriving, a half refused, a payment paying once): the cosigner's tests prove it in isolation, and the payout end to end.

How the passkey is tested

  • app/android/testpasskey is a passkey provider in software, set as the emulator's over adb.
    • PasskeyPlugin's calls take the real path: Credential Manager, a provider, and back.
    • It signs as the runtime's SoftwareAuthenticator does.
    • It claims Android's origin, android:apk-key-hash of the calling APK's certificate.
    • It answers WebAuthn's PRF extension.
    • It never ships in the app.
  • scripts/approve-passkeys.sh taps each sheet's Continue.

CI

  • One e2e job runs make e2e-all on an API 35 Play emulator on the runner's KVM, next to the enclave.
  • rust, dart and flutter are unchanged.

Fixes this found

  • Monospace font: eight text styles asked for RobotoMono, which the app doesn't bundle, so it was fetched from Google at runtime. They now use FiraCode, which is bundled.
  • Exit tab: opening it with a signed exit threw in debug builds. The tile now has its own Material.
  • gradle.properties: named /home/joshua/dev/jdk-17, so Gradle couldn't start on any other machine.
  • A send's change: the delegate was renewed before the change was indexed (fix(core): renew over a send's change only once it is indexed #63, cherry-picked here so the e2e passes).

Blocked on

vtxos/infra#5, the dev enclave image with the app bundle. Until it is published and its digest pinned in docker-compose.enclave.yml, the e2e job boots the old image, whose relying party is enclave.test, and the app's passkey is refused.

What was run

make e2e on a KVM workstation: an API 35 Play emulator, against infra#5's image built locally. Both files pass, in 14:22, with 93 passkey sheets approved, and the enclave is taken down after.

🤖 Generated with Claude Code

aruokhai and others added 12 commits October 17, 2026 14:40
testpasskey is a passkey provider in software, set as the emulator's
provider over adb (credential_service, credential_service_primary), so
PasskeyPlugin's calls take the real path: Credential Manager, a provider,
and back. It signs as the runtime's SoftwareAuthenticator does, claims
the origin Android would (android:apk-key-hash of the calling APK's
certificate), and answers WebAuthn's PRF extension.

integration_test/passkey_test.dart drives PlatformPasskey through it:
create, then two approvals that must give the same 32-byte seed, with
the debug key's origin in clientDataJSON and no PRF output in what is
returned. scripts/approve-passkeys.sh taps the sheet's Continue, which
androidx.credentials 1.3 cannot skip for a passkey sign-in.

Passes on the API 35 Play image in 15 s, three sheets approved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ens in debug

Eight styles asked for RobotoMono, which the app does not bundle, so it
was fetched from Google at runtime, and not found at all where runtime
fetching is off, as in the integration tests. They use FiraCode, the
monospace font that is bundled; the one bold label is Medium, the
heaviest FiraCode bundled.

The Exit tab's "full path on-chain" tile sits in a coloured card, which
hides a ListTile's ink, and Flutter asserts so in a debug build: opening
the tab with a signed exit threw. The tile has its own transparent
Material now.

Found by running integration_test/app_test.dart on an emulator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With scrcpy mirroring the emulator, the sheet's window was never
reported as focused, so nothing was approved. The button is looked for
in Play services' sheet and in Android's own Credential Manager UI, for
an image without Play.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the Exit tab, KSh 1,500 to an M-PESA number from Send: the
platform's quote, one approval that mints the payout's escrow, pairs the
platform into it, seals and funds the deal, then the fake Grid's payout
and the platform's repayment out of the escrow. The test app provides
PayoutService as main.dart does, without which the send screens do not
load.

Passes on the emulator against make up's enclave and platform, with
scripts/approve-passkeys.sh approving each sheet: 5:55, five approvals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…form

scripts/e2e-app.sh (make e2e-app) brings up MerlinPlatform, boots the
dev enclave image's app bundle (ENCLAVE_BUNDLE=app: rp id vtxos.com and
the app's origins) with the cosigner set as make up sets it, makes
testpasskey the emulator's passkey provider, and runs
integration_test/app_test.dart with approve-passkeys.sh approving each
sheet. The app-e2e job runs it on an API 35 Play emulator on the
runner's KVM.

Needs the dev enclave image with the app bundle, from vtxos/infra#5;
docker-compose.enclave.yml pins it once that is published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…provider

gradlew is Flutter's to generate and not checked in, so a fresh checkout
has none: flutter build apk --config-only writes it, with the
local.properties Gradle reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The suite and the app's flow ran as two jobs, each booting the dev
enclave. scripts/e2e-all.sh (make e2e-all) boots it once: the image's
app bundle, whose relying party vtxos.com allows https://vtxos.com, the
origin the suite's software passkeys claim, as well as the app's
android:apk-key-hash ones; and the suite's own settings, which already
name MerlinPlatform (e2e/bin/guest_env.dart prints them). The suite runs
attached to it, then the app's flow; both run whatever the first does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a send, the delegate and the exits are renewed over what is held,
once the indexer reflects the send. That waited only for the inputs to
be gone: in between that and the change being listed, the held set is
empty or short of the change, counted as settled, and the renewal went
over it. The change then had no exit, and after a last send the wallet
had no delegate at all: the e2e's "every renewal signs an exit for every
VTXO it covers" and "three sends Alice to Bob" failed on it, on a busy
machine every time.

A send with change now also waits for it to arrive: arkTxid:1, as the
cosigner orders an ark transaction's outputs (recipient, change, anchor).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 01465f0)
org.gradle.java.home named /home/joshua/dev/jdk-17, which exists on one
machine: anywhere else, CI included, Gradle refused to start. Where the
JDK is belongs to each machine (JAVA_HOME, flutter config --jdk-dir, or
~/.gradle/gradle.properties).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
integration_test/enclave_test.dart is the host's e2e suite on the
emulator: every wallet is the app's own client, and every passkey the
platform's, made and asserted through Credential Manager. Attestation
and its refusals, DKG and tenants, addresses, exits (one that bitcoind
mines), sends, nothing secret at rest, a mixed exit-delay set, a client
rebuilt from storage, recovery on a new phone and what it must refuse,
and an unknown service refused. The served component's hash comes from
the host (E2E_GUEST_SHA256), which the device cannot read.

app_test.dart goes on past its payout: the app restarted, opening from
its own storage, then a new phone, wiped and restored with "I already
have a wallet", which pays again.

testpasskey answers a request for any passkey with its newest: a
restore, on an emulator many tests have made passkeys on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
make e2e is e2e-all now: on one boot of the image's app bundle, with
MerlinPlatform, app_test.dart and enclave_test.dart run on the emulator.
The host's suite (e2e/test/enclave_ark_test.dart) is what enclave_test
became, and goes with its fake escrow service: the service's own
protocol is proven in the cosigner's tests, and end to end by the
payout. The e2e package keeps the walkthroughs and their helpers.

e2e-all.sh takes the enclave down by the compose file's full path (from
app/, the relative one named nothing and the enclave stayed up, run
after run, until its pool disk was full), and serves COSIGNER_WASM as
the host's suite did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@aruokhai aruokhai changed the title CI: the app's full flow on an emulator, with a real Credential Manager passkey, the enclave and the platform Every e2e from an Android emulator, with real Credential Manager passkeys, the enclave and the platform Oct 17, 2026
aruokhai and others added 8 commits October 17, 2026 19:02
Unpinned, the jobs took whatever stable was that day: 3.47.7, which
refuses this project's Gradle 8.12 outright, so the e2e could not build
the app. Moving Flutter on comes with the Gradle (8.14) and AGP upgrade
it asks for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run whose app never reported in sat 72 minutes with nothing printed,
until cancelled: flutter test waits for the app with no limit, and
nothing said why. The emulator's logcat is now written for the whole run
and uploaded when the job fails, and the tests are bounded at 60 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
infra#5's image: the bundle any software passkey can use, and the app's,
whose relying party is vtxos.com and which allows the app's origins.
scripts/e2e-all.sh boots the app's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its Wi-Fi is netsim's, which on the runner came up with no route even to
the host at 10.0.2.2: the app started, and its first call failed with
"Network is unreachable". With Wi-Fi off the emulator uses mobile data,
its own network, which reaches the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e way

Twice the app started and nothing more was heard: the emulator's log
stopped three seconds in, as if adb had gone. The run now writes memory,
load and adb's view of the emulator every half minute, keeps the
kernel's log when it fails, and builds without a Gradle daemon holding
4 GB of a 16 GB runner. The tests are bounded at 40 minutes in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the runner, 19 of 21 passed. The app's flow could not reach the exit
address's Save button on the default small device, and two passkeys
with two ceremonies took longer than package:test's 30 seconds. The
emulator is a Pixel 6, as the tests run on locally, and enclave_test's
tests default to five minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rove more lightly

On the runner, the app's flow waited for a deposit it could not see: on
regtest the app watches electrs at 127.0.0.1:50001, which a phone
reaches only through adb reverse. make up sets that up; e2e-all.sh did
not, and local runs passed on a reverse left over from it.

Twice more a file's app installed and nothing followed: flutter test
reads the device's log for the app's VM service, from one logcat it
never restarts, and that stream ended at launch with the machine idle
and adb still up. Each file now runs on its own, and one that prints
nothing for ten minutes is stopped and run once more; a failure is not
retried.

approve-passkeys.sh dumped the screen every second, a JVM started on
the device each time. It dumps only when the sheet's activity is in
front now, and every tenth time regardless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Looking for the sheet's activity first, through dumpsys, approved
nothing on the runner in half an hour: the check's grep -m1 broke its
pipe, which pipefail counts as no match, and the dumpsys left behind
appear to have slowed the emulator past the every-tenth-time dump too.
The version that approved every sheet there is back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@aruokhai
aruokhai merged commit e3732ca into main Oct 17, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant