End-to-end specification for a multi-persona Pixel built on pKVM/AVF. No phases. No compromises. No placeholders. Every component, every interface, every test is specified here. An agent reading this spec has everything it needs to build the system.
The development model is a Cuttlefish-based dev loop: every component is implemented and tested on Cuttlefish (Google's reference virtual Android, running on a Linux dev box) first. Real Pixel hardware is used only for hardware-specific validation (NFC eSE, Shannon modem, Titan M2 TEE) once the Cuttlefish stack is fully working. This makes iteration fast and the whole system reproducible.
Ship an open-source phone OS that boots a Pixel 9/10 into a thin base layer hosting multiple full Android personas as hardware-isolated pKVM guests, with sub-5-second switching between them, per-persona peripherals (NFC, modem, biometrics) routed to the active persona via host arbitrators, and per-persona network namespaces (WireGuard, Tor, raw — independently configurable). One persona can be GrapheneOS-hardened, another stock Android with Google Play (Play Integrity strong passes inside that pVM because it has its own boot chain). Snapshot/restore is the switching mechanism; suspended personas live as encrypted snapshot files on /data. None of the personas can see each other; pKVM enforces it in hardware.
This spec produces that system end-to-end. No prototype-then-rebuild path: the components specified here are the components that ship.
The system is done when all of the following pass, measured by the test harness in tests/:
-
Boots clean. Pixel 9/10 with custom-signed images boots to the PersonaPhone launcher in <12s from power-on, Verified Boot green tick visible during boot, no rollback warnings.
-
Multi-persona running. At least 3 distinct personas defined (GrapheneOS-hardened, stock Android with Google Play, a "burner" persona). Each persona boots to its own SystemUI inside its pVM. pKVM memory isolation verified: from the host, attempting to read a guest's physical pages returns the expected stage-2-fault, not data.
-
Sub-5-second switch. Switching between two warm-snapshot personas completes in ≤5 seconds, measured from user-gesture to persona UI fully responsive. Cold-restore from disk in ≤8 seconds.
-
Peripheral handoff works. Foreground persona owns NFC tap-to-pay, can place/receive cellular calls, can use the fingerprint sensor. Background personas have those HALs return
STATE_UNAVAILABLEcleanly without crashing. -
Per-persona network isolation. Each persona's network egress can be independently configured: persona A → WireGuard (Mullvad), persona B → Tor, persona C → raw. Each persona's DNS and routing are isolated in a host network namespace; cross-persona traffic is dropped by nftables.
-
Snapshot encryption. Each persona's snapshot file on /data is encrypted with a key sealed to Trusty TEE and derived from the persona's DICE chain. Snapshot is unreadable when the device is locked.
-
Persona-to-persona isolation under fuzzing. Standard virtio fuzzer (
syzkalleradapted) running for 72 hours against the host with all personas active produces no exploitable crash; arbitrator daemons crash-only behavior is verified. -
Cuttlefish parity. Every test in
tests/cuttlefish/runs on a Linux dev host via Cuttlefish and produces the same green result thattests/e2e/produces on real Pixel hardware. CI runs Cuttlefish tests on every commit. -
First-publish artifacts. The four pieces that have not been shipped publicly elsewhere are working and documented: (a) full Android as pVM guest on Pixel, (b) crosvm snapshot/restore through VirtualizationService, (c) NFC/RIL/biometric arbitrators with per-persona namespacing, (d) SurfaceFlinger-based sub-second hot-switch.
No "v1 ships X, v2 ships Y." This is the full system.
- Pixel 9 or Pixel 10 (Tensor G4/G5). Pixel 8 acceptable but suboptimal — Pixel 10 has hardware-supported FEAT_RME for the future CCA-realm-world variant.
- Bootloader unlockable (any non-Verizon Pixel). 16GB RAM mandatory (Pro variants), UFS 4.0 storage mandatory for snapshot performance targets.
- Linux x86_64 with hardware virtualization (VT-x + EPT or AMD-V + nested paging). Ubuntu 24.04 LTS or Debian 13. Nested virtualization enabled in kernel:
kvm-intel.nested=1orkvm-amd.nested=1. - 64GB+ RAM (Cuttlefish runs the base layer + guest pVMs nested), 1TB+ NVMe storage for AOSP source + builds, 16-core+ CPU.
- Docker / Podman for the build container, build-essential, repo tool, OpenJDK 21, Python 3.12, Rust toolchain (stable + nightly for crosvm work), ADB/fastboot, avbroot.
- A second Pixel (a Pixel 6+ from a drawer) — used as a hardware-validation parallel device so dev work on one doesn't brick the daily-driver.
PersonaPhone/
├── PLAN.md ← this file
├── README.md
├── docs/
│ ├── architecture.md system architecture deep dive
│ ├── dev-loop.md Cuttlefish dev environment
│ ├── references.md forkable repos inventory
│ ├── threat-model.md
│ └── runbook.md build/flash/test commands
├── tools/
│ ├── build/ AOSP build orchestration
│ ├── signing/ avbroot wrapper, key management
│ ├── flash/ Pixel flash automation
│ └── test/ test harness drivers
├── host/ ← the base layer
│ ├── device/personaphone/ AOSP device config for Pixel
│ ├── packages/PersonaLauncher/ persona-picker system app
│ ├── overlays/ AOSP overlays
│ └── arbiters/
│ ├── nfc/ host NFC arbitrator daemon
│ ├── ril/ host RIL arbitrator daemon
│ ├── biometric/ host biometric arbitrator daemon
│ └── tee-broker/ Trusty TEE multi-tenant broker
├── guest/ ← the persona pVMs
│ ├── kernel-config/ guest Android kernel configs
│ ├── hals/ virt HALs (display, audio, camera, sensors,
│ │ gnss, light, vibrator, nfc-stub, ril-stub,
│ │ biometric-stub)
│ └── system/ guest system overlays
├── pkvm/ pKVM patches (kernel diffs)
├── crosvm/ crosvm patches (snapshot/restore for AVF/ARM)
├── avf/ AVF / VirtualizationService extensions
├── networking/ per-persona netns + wg + Tor + nftables
├── tests/
│ ├── cuttlefish/ the dev loop test suite
│ ├── e2e/ real-Pixel end-to-end tests
│ ├── unit/ per-component unit tests
│ └── fuzz/ syzkaller harness + corpus
├── third_party/ vendored references (avbroot, etc.)
├── ci/ GitHub Actions / Drone CI configs
└── .gitignore
The development model is: every component is built and verified inside Cuttlefish on the dev host before being deployed to real Pixel hardware. Cuttlefish runs as a process on your Linux box and presents itself as a virtual Pixel, using crosvm as its VMM. Critically, Cuttlefish supports nested KVM, so the PersonaPhone base layer can run inside Cuttlefish and spawn its own pVM guests inside that, exactly as it would on real hardware.
┌─────────────────────────────────────────────────────────────────┐
│ Linux dev host │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Cuttlefish (virtual Pixel) │ │
│ │ - boots PersonaPhone base layer │ │
│ │ - hosts pVM guests via nested pKVM │ │
│ │ - emulates NFC, RIL, biometric, display, audio │ │
│ │ - exercised by the test harness │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Edit → build → cvd start → run tests → cvd stop │
│ Total cycle: < 5 minutes for incremental builds │
└─────────────────────────────────────────────────────────────────┘
│
│ Once Cuttlefish tests are green:
▼
┌─────────────────────────────────────────────────────────────────┐
│ Real Pixel 9/10 │
│ - fastboot flash signed images │
│ - run the same test suite (tests/e2e/) on hardware │
│ - validates: real NFC eSE, real Shannon modem, real Titan M2 │
│ - any divergence from Cuttlefish surfaces here │
└─────────────────────────────────────────────────────────────────┘
| Aspect | Covered by Cuttlefish? | Notes |
|---|---|---|
| pKVM hypervisor behavior | ✅ Yes — Cuttlefish uses crosvm + nested KVM | Same code paths |
| AVF / VirtualizationService | ✅ Yes | Same code paths |
| Guest pVM lifecycle | ✅ Yes | Boot, suspend, resume |
| Snapshot/restore | ✅ Yes — that's the primary thing we're patching | Identical to real hardware |
| Virtio device backends | ✅ Yes | Identical |
| Per-persona networking | ✅ Yes — host netns work the same | Identical |
| SurfaceFlinger composition | ✅ Yes | Identical |
| Input routing | ✅ Yes | Cuttlefish exposes touch via webRTC |
| Audio | ✅ Yes | Cuttlefish has virtio-snd |
| NFC arbitrator logic | 🟡 Partial — Cuttlefish has no real NFC | We add a fake NFC backend in Cuttlefish |
| RIL arbitrator logic | 🟡 Partial — Cuttlefish has fake RIL already | Logic tested, hardware not |
| Biometric arbitrator logic | 🟡 Partial — Cuttlefish has fake biometric | Logic tested, TEE not |
| Real Titan M2 / Trusty TEE | ❌ No | Pixel-only |
| Real PN553/PN557 NFC controller + eSE | ❌ No | Pixel-only |
| Real Shannon modem | ❌ No | Pixel-only |
| Real Mali GPU performance | ❌ No | Cuttlefish uses swiftshader/host GPU |
This means ~85% of the build is fully verifiable on Cuttlefish. The remaining 15% (real peripherals, TEE, GPU performance) requires the Pixel — but at that point only those specific interactions need real-hardware iteration.
- Edit a launcher app file → rebuild + Cuttlefish boot: ~2 minutes
- Edit a virt HAL → rebuild guest image → Cuttlefish boot: ~5 minutes
- Edit pKVM kernel code → rebuild GKI kernel → Cuttlefish boot: ~15 minutes
- Edit and test full snapshot/restore patch in crosvm → ~5 minutes (Rust incremental)
Detail in docs/dev-loop.md.
The Pixel bootloader chain is unlocked, then our own AVB key is provisioned, then the bootloader is re-locked. From that point the device boots only artifacts we have signed, with Verified Boot's full green-tick UI.
Implementation:
- Fork
chenxiaolong/avbrootintotools/signing/avbroot/. Pin to a known-good commit. - Generate a 4096-bit RSA AVB key at
tools/signing/keys/avb_pkmd.bin. Generate Android signing keys (platform,releasekey,media,shared) per AOSP convention. - Build the avbroot pipeline: download stock Pixel factory image → patch boot/init_boot/vendor_boot to add our changes → re-sign with our AVB key → produce a flashable factory image.
- Provision:
fastboot flashing unlock,fastboot flash avb_custom_key pkmd.bin,fastboot flashing lock. - OTA delivery: fork
chenxiaolong/Custota, self-host the OTA endpoint. Updates are signed with the same AVB key.
Tests: tests/unit/boot/ includes:
- Verify a malformed-signature image is rejected by fastboot flash.
- Verify Verified Boot rollback counter prevents downgrade.
- Verify OTA delta apply on Cuttlefish virtual A/B partitions.
The boot image bundles: GKI kernel (forked Android KVM tree, our pKVM patches applied), generic ramdisk (AOSP standard), vendor ramdisk (Pixel-specific), dtbo with our virtio host backend additions, pvmfw blob (built from packages/modules/Virtualization/guest/pvmfw).
Build orchestration in tools/build/:
tools/build/build-kernel.sh— checks outandroid-kvm/linuxat a pinned commit, appliespkvm/*.patch, builds for Pixel (gki_defconfig+personaphone.configoverlay), emits Image.lz4 + dtbo.img.tools/build/build-pvmfw.sh— builds pvmfw with our AVB key in its trust root.tools/build/build-boot.sh— assembles boot.img / init_boot.img / vendor_boot.img.tools/build/sign-boot.sh— calls avbroot to re-sign.
References: see docs/references.md § Boot/AVB.
The host OS is a stripped AOSP build that exists only to host pVMs. It boots, runs SurfaceFlinger / InputDispatcher / AudioFlinger, runs the PersonaLauncher app and the arbitrator daemons, and never directly exposes a "phone" UX to the user. The user always interacts with a guest persona; the host's UI is the persona picker shell.
In host/device/personaphone/ create the product makefiles:
personaphone.mk—PRODUCT_NAME=personaphone, inherits fromaosp_arm64with Pixel device overlay, declares the slimPRODUCT_PACKAGESset.BoardConfig.mk—BOARD_USES_PVMFW=true,BOARD_AVF_ENABLED=true, virtio host backends enabled.device.mk— pulls in our arbitrator daemons, PersonaLauncher app, networking config.
The PRODUCT_PACKAGES list is the stripped set: SurfaceFlinger, system_server (with most providers disabled via overlay), AudioFlinger, MediaCodec, VirtualizationService, our daemons. Specifically excluded: Settings, SystemUI (replaced by PersonaLauncher), Telephony framework (replaced by our RIL arbitrator), Phone, Contacts, Messaging, anything user-facing that a guest persona will provide for itself.
host/packages/PersonaLauncher/ is the system-signed app that owns the host UX. It is the only thing the user sees from the host — everything else they interact with is inside a pVM.
Responsibilities:
- Boot UI: a persona picker showing all configured personas + "create new persona" affordance
- Persona lifecycle: spawn, suspend, resume, destroy via
IVirtualizationServiceextensions - Surface composition: on switch, swap which pVM's
SurfaceControlis foreground - Input routing: hand touch events to the active persona's virtio-input device
- Settings: per-persona config (network namespace assignment, snapshot encryption key handle, auto-suspend policy)
Permissions: signed with the platform key in our custom AOSP build. Holds INTERACT_ACROSS_USERS_FULL, MANAGE_VIRTUAL_MACHINES, MANAGE_NETWORK_POLICY, ACCESS_SURFACE_FLINGER, and a new permission personaphone.MANAGE_PERSONAS we define in host/overlays/frameworks/base/core/res/.
Tests: tests/unit/launcher/ includes Espresso UI tests that drive persona create/switch/suspend/destroy flows.
host/packages/personad/ is the central system daemon (binder service IPersonaService) that PersonaLauncher talks to. It abstracts the orchestration:
interface IPersonaService {
int createPersona(in PersonaConfig config);
void startPersona(int personaId);
void suspendPersona(int personaId); // → crosvm snapshot
void resumePersona(int personaId); // → crosvm restore
void destroyPersona(int personaId);
PersonaStatus getStatus(int personaId);
void switchToPersona(int personaId); // ties together surface + input + peripherals
int registerObserver(IPersonaObserver observer);
}personad orchestrates: it calls IVirtualizationService for pVM lifecycle, calls the network manager to apply the persona's netns + wg/Tor config, calls each arbitrator daemon to switch peripheral ownership, calls SurfaceFlinger via SurfaceControl to flip foreground.
Tests: tests/unit/personad/ covers state machine transitions; tests/cuttlefish/personad-integration.sh runs end-to-end persona create/start/switch/suspend cycles against a Cuttlefish-booted host.
Each peripheral has its own arbitrator. Architecture is shared: a host-resident daemon owns the real hardware (or in Cuttlefish, the emulated hardware), exposes a vsock service that guest virt HALs talk to, and routes/denies per the current foreground persona. Detail in § 8.
We use mainline pKVM from the Android KVM tree (android-kvm.googlesource.com/linux). The only modifications we need are stability fixes and one performance patch (faster stage-2 page invalidation during snapshot capture).
pkvm/ contains the patch series. Built into the GKI kernel.
No deep pKVM rewrite is needed for the v1 system. The hypervisor is the same one Google ships; we use it as designed. The "novel work" is upstream of pKVM (host arbitrators) and downstream (snapshot through AVF).
Tests: tests/unit/pkvm/ includes the pkvm-tester initramfs harness (forked from rems-project/pkvm-tester) that exercises hyp-call paths and stage-2 page-table ownership transitions.
Fork packages/modules/Virtualization into avf/. Pin to AOSP main at a known commit. We add two AIDL methods to IVirtualMachine:
void suspend(in SuspendOptions opts);
// opts.snapshotFd: ParcelFileDescriptor to write the snapshot
// opts.compressed: bool — use zstd L1 compression
// opts.encryptionKeyHandle: Trusty key handle for sealing
// blocks until VM is suspended and snapshot is written
void resume(in ResumeOptions opts);
// opts.snapshotFd: ParcelFileDescriptor to read snapshot from
// opts.encryptionKeyHandle: Trusty key handle for unsealing
// blocks until VM is running againImplementation in avf/virtualizationservice/src/aidl.rs calls into crosvm's snapshot API (§ 7.3) and routes the file descriptor through.
Tests: tests/cuttlefish/avf-suspend-resume.sh boots a Microdroid VM, suspends it, restarts crosvm, resumes from snapshot, verifies the VM is at the exact same state.
The chewiest engineering work is in crosvm/. We fork google/crosvm at the Android-pinned commit and:
- Complete the snapshot path for AVF/ARM. crosvm's existing
Suspendabletrait implementations cover most virtio devices on x86; ARM-specific code (virtio-gpu with gfxstream, virtio-input via vhost-vsock) needsSuspendableimplementations added. Patches incrosvm/patches/0001-suspendable-virtio-gpu-arm64.patch,0002-suspendable-virtio-input.patch,0003-suspendable-vhost-vsock.patch, ... (one per device). - Add
--snapshot-encryptflag. Snapshot file is written via aKeystoreSealedFilewrapper that wraps anAsyncWriteand encrypts on the fly using a Trusty-sealed AEAD key. - Add
--restore-on-startflag. crosvm launched with this flag does the restore-from-snapshot path instead of a cold boot. - Reflink-aware snapshot writer. When the underlying filesystem supports reflink (
fscryptoverf2fson /data does), snapshot writes useFICLONEto make snapshot creation O(1) modulo dirty pages. Pattern lifted fromcocoonstack/cocoon.
Tests: tests/unit/crosvm/ includes Rust unit tests for each Suspendable impl; tests/cuttlefish/crosvm-snapshot-roundtrip.sh runs a stress loop of suspend/resume cycles against a guest Android pVM.
Each arbitrator follows the same architecture:
┌──────────────────────────────────────────────────────────────┐
│ Host │
│ ┌────────────────────┐ ┌─────────────────────┐ │
│ │ Real hardware HAL │◄────│ Arbitrator daemon │ │
│ │ (e.g., real NFC │ │ (vsock service) │ │
│ │ PN553 HAL) │ │ │ │
│ └────────────────────┘ │ - tracks foreground │ │
│ │ - routes / denies │ │
│ └─────────┬───────────┘ │
└──────────────────────────────────────────────────┬────────────┘
│ vsock
┌──────────────────────────────────────────┴───────────┐
│ │
┌────▼──────────────┐ ┌───────────▼──────┐
│ Persona A pVM │ │ Persona B pVM │
│ ┌───────────────┐ │ │ ┌──────────────┐ │
│ │ virt NFC HAL │ │ │ │ virt NFC HAL │ │
│ │ (vsock client)│ │ │ │ (vsock client│ │
│ └───────────────┘ │ │ └──────────────┘ │
└───────────────────┘ └──────────────────┘
Vsock protocol details in docs/architecture.md § Arbitrator Protocols.
host/arbiters/nfc/ is a daemon (personaphone-nfcd) that:
- Opens the real PN553 controller via
/dev/nq-nciand implements the host NCI HAL - Listens on vsock port
5001for guest virt HAL connections - Tracks the foreground persona ID (subscribes to
IPersonaServicefor switch events) - Routes NCI commands from the foreground persona's virt HAL through to the real controller
- Responds
STATE_DISABLEDto non-foreground personas - On persona switch: deinitialize NCI session with previous foreground, init fresh session for new foreground (preserves eSE state via card-removal/insertion semantics)
The virt HAL inside each guest pVM (guest/hals/nfc/) is a thin shim that implements android.hardware.nfc.INfc and forwards every call over vsock.
References lifted:
nfcgate/nfcgateandnfcgate/libnfc-nci— the MITM logic transfers directly- AOSP
external/libnfc-nci— for the real-HAL side NXPNFCLinux/linux_libnfc-nci— for guest-side stub
Tests: tests/unit/nfc-arbiter/ covers state machine + protocol correctness with a fake NFC backend; tests/cuttlefish/nfc-foreground-switch.sh boots two personas, switches between them, asserts only the foreground sees NFC events; tests/e2e/nfc-tap-payment.sh on real Pixel uses a test card to verify end-to-end tap works on the foreground persona only.
host/arbiters/ril/ is personaphone-rild — owns the real Shannon modem via cpif, exposes a per-persona virtual RIL over vsock.
Architecture:
- Single physical SIM (or eSIM) → one IMS/VoLTE registration → foreground persona only
- Multiple cellular data PDPs → one per persona, distinct APN labels if carrier supports, otherwise routed to foreground only
- SMS storage in host arbitrator, forwarded to foreground at delivery, optionally replayed to a persona on its next foreground-switch (configurable)
- Voice calls always foreground
The virt RIL HAL inside each guest (guest/hals/ril/) implements android.hardware.radio.* AIDLs and forwards over vsock.
References lifted:
- AOSP
device/google/cuttlefish/vendor/google/services/ril— working RIL-over-vsock proxy, the reference impl rilmodem/ofono— Linux-side patterns- AOSP RIL header — for the protocol surface
Tests: tests/unit/ril-arbiter/ covers state machine; tests/cuttlefish/ril-foreground-call.sh uses Cuttlefish's fake modem to verify foreground-only call delivery; tests/e2e/ril-real-call.sh on real Pixel places a real call from foreground, verifies background pVM cannot ring.
host/arbiters/biometric/ is personaphone-biometricd — owns the fingerprint sensor + face camera + Trusty TEE session, exposes per-persona biometric enrollment + auth.
Architecture:
- Each persona has its own enrollment namespace inside Trusty (extended via our
tee-broker, § 8.4) - Foreground persona's biometric auth requests are routed to the real sensor + TEE
- Background personas get
BIOMETRIC_HW_UNAVAILABLE - DICE-derived persona ID is included in the Trusty attestation, so a persona cannot pretend to be another at enrollment
The virt biometric HAL (guest/hals/biometric/) implements android.hardware.biometrics.fingerprint.IFingerprint and ...face.IFace.
References:
- Android Biometric HAL docs
- Aster paper for the multi-tenant TEE design
Tests: Cuttlefish unit tests with fake sensor; on-device test with real fingerprint enrollment in two personas, switch between them, verify each persona's templates only match their own enrollments.
host/arbiters/tee-broker/ is the daemon that gives each persona its own keyspace inside the single Trusty TEE. This requires a small Trusty trustlet on the secure side (pkvm/trusty/persona_broker/) that namespaces every keymint operation by persona DICE hash.
References:
- AOSP Trusty
- OP-TEE/optee_os multi-CA model
- Aster paper
Tests: unit tests verify cross-persona key leakage is impossible (persona A cannot import/export persona B's keymint key handles).
guest/ contains the build for a full Android image that runs as a pVM guest under our host. Two flavors built from the same tree with different overlays:
guest/personas/grapheneos.mk— GrapheneOS-hardened, no Googleguest/personas/stock-pixel.mk— Stock Pixel Android, Google Play Services preloaded
Adding a new persona is a new product makefile that inherits from guest/base.mk and overrides apps + config.
guest/kernel-config/personaphone-guest.config:
CONFIG_KVM=n— not a hypervisor hostCONFIG_VIRTIO_PCI=y,CONFIG_VIRTIO_MMIO=yCONFIG_VIRTIO_BLK=y,_NET=y,_CONSOLE=y,_GPU=y,_SND=y,_INPUT=y,_VSOCKETS=y,_BALLOON=y,_RNG=y- Drop all Tensor
.komodules — guest has no Tensor hardware visible
guest/hals/ contains AOSP HAL implementations that route to vsock. One per peripheral:
| HAL | Path | Backed by |
|---|---|---|
| Display | guest/hals/display/ |
virtio-gpu (gfxstream) |
| Audio | guest/hals/audio/ |
virtio-snd |
| Camera | guest/hals/camera/ |
vsock to host camera arbitrator |
| Sensors | guest/hals/sensors/ |
virtio-input + vsock |
| GNSS | guest/hals/gnss/ |
vsock to host GNSS |
| Light | guest/hals/light/ |
vsock (foreground only) |
| Vibrator | guest/hals/vibrator/ |
vsock (foreground only) |
| NFC | guest/hals/nfc/ |
vsock to NFC arbitrator (§ 8.1) |
| Radio (RIL) | guest/hals/ril/ |
vsock to RIL arbitrator (§ 8.2) |
| Biometric | guest/hals/biometric/ |
vsock to biometric arbitrator (§ 8.3) |
| Keymint / TEE | guest/hals/keymint/ |
vsock to tee-broker (§ 8.4) |
Template: AOSP device/google/cuttlefish/guest/hals/* is the canonical reference for "Android HAL over virtio/vsock." Every HAL in our guest/hals/ is structurally identical to the Cuttlefish equivalent, with our protocol additions.
Tests: tests/unit/hals/<hal>/ per-HAL conformance tests run inside a Cuttlefish-spawned guest pVM and verify against the standard AOSP VTS suite.
guest/system/ overlays AOSP framework configuration to:
- Disable cold-boot animation (snapshots make this jarring)
- Pre-tune the doze + standby thresholds for "ok to be suspended"
- Add a Settings extension that surfaces the persona ID to the user (so they can tell which persona they're in)
networking/ contains the per-persona network stack. Each persona owns a host-side tap device, which is plumbed into a dedicated network namespace, which has its own optional WireGuard / Tor / nftables config.
For each persona, on creation:
ip netns add persona_${id}
ip link set tap_persona_${id} netns persona_${id}
ip netns exec persona_${id} ip link set lo up
ip netns exec persona_${id} ip addr add 10.${id}.0.2/24 dev tap_persona_${id}
ip netns exec persona_${id} ip link set tap_persona_${id} upPlus a dnsmasq per namespace bound to tap_persona_${id} for DHCP+DNS to the guest.
Lifecycle managed by personad via a small helper binary host/arbiters/netns/.
Each persona's config (in host/etc/personas/${id}.json) specifies an egress:
{
"egress": {
"type": "wireguard",
"config_path": "/data/personas/${id}/mullvad-se-mma-001.conf"
}
}personad brings up the WireGuard interface inside the persona's netns. Pattern lifted from dadevel/wg-netns and lschomaker1/mullvad-netns.
Supported egress types:
wireguard— config file specifies peertor—tordaemon launched in the netns, transparent SOCKS via redsocks + nftablesraw— no VPN, direct egress through host's default routechain— multi-hop (wireguard → tor, orwireguard → wireguardfor "double VPN")
For every persona namespace:
- Drop all traffic to other persona namespaces
- Allow only the configured egress
- DNS forced through namespace-local resolver
- IPv6 disabled per-persona unless explicitly enabled
Template in networking/nftables/persona.template.nft.
Tests: tests/cuttlefish/networking-isolation.sh boots two personas with different egress configs, verifies traffic patterns via tcpdump on host taps.
This is the centerpiece. Persona switching is implemented as: suspend current pVM → snapshot to disk (encrypted) → restore target pVM from its snapshot → re-attach peripherals → swap SurfaceControl.
[8 bytes] magic "PRSNAPv1"
[64 bytes] persona DICE hash (binds snapshot to persona identity)
[16 bytes] AEAD nonce
[8 bytes] ciphertext length
[N bytes] AEAD-sealed contents:
- crosvm device-state serialization (Suspendable trait outputs)
- guest RAM dump (zstd L1 compressed)
- vCPU register state
[16 bytes] AEAD tag
Key derivation: per-persona seal key derived from CDI_Seal via Trusty trustlet pkvm/trusty/persona_seal/.
Targets: 4GB guest RAM snapshot in ≤3 seconds (compressed ~1.2GB), restore in ≤2 seconds. Achieved via:
- zstd L1 compression (good ratio, fast)
- Direct I/O to UFS 4.0 partition, bypassing pagecache
- mlock the next-likely-restore snapshot into pagecache pre-switch (warm-snapshot pattern)
personad keeps a configurable number of personas "hot" — their snapshots are pre-loaded into memory but the pVMs themselves are suspended. Switch to a hot persona = ~1-2s (restore from RAM). Switch to a cold persona = ~5-8s (read from disk + decrypt + restore).
- Created on
IPersonaService.suspendPersona() - Validated by DICE hash on restore (cannot restore persona A's snapshot into persona B's slot)
- Old snapshots GC'd via LRU when /data is under pressure (configurable retention policy)
Tests: tests/cuttlefish/snapshot-stress.sh runs 200 suspend/resume cycles, verifies no state divergence; tests/cuttlefish/snapshot-encryption.sh verifies snapshot files are unreadable without the Trusty key.
When the user gestures "switch to persona B":
1. PersonaLauncher gets the user gesture (swipe or notification toggle)
2. PersonaLauncher → IPersonaService.switchToPersona(B)
3. personad orchestrates:
3a. Notify arbitrators of incoming switch (give current FG persona a 200ms
grace period to finish in-flight ops)
3b. Detach peripherals from persona A:
- nfc arbiter: deinit NCI session
- ril arbiter: signal no-radio to persona A's virt HAL
- biometric arbiter: revoke session
- audio: pause persona A's stream
3c. IVirtualMachine.suspend(A) — snapshot A
3d. Either:
- persona B is hot → resume from in-RAM snapshot
- persona B is cold → IVirtualMachine.resume(B) from disk snapshot
3e. Re-attach peripherals to persona B:
- nfc arbiter: init NCI session for B
- ril arbiter: route real radio to B's virt HAL
- biometric arbiter: grant session for B
- audio: route default sink to B's stream
3f. SurfaceFlinger: SurfaceControl swap — B's pVM surface becomes foreground
3g. Input: InputDispatcher routes touches to B's virtio-input
4. personad notifies PersonaLauncher of completion
5. PersonaLauncher animates the switch UI (slide / fade)
End-to-end target: <3 seconds for hot, <5 seconds for cold.
Tests: tests/cuttlefish/switch-roundtrip.sh performs 50 switches across 3 personas, asserts no state corruption, measures switch latency distribution.
tests/ is the dev-loop's bedrock. Every component has unit tests (Rust for crosvm/networking, AIDL Android tests for system services, Espresso for launcher UI), and the integration suite runs against Cuttlefish.
tools/test/run.sh is the single entry point:
./tools/test/run.sh --suite cuttlefish # full Cuttlefish suite
./tools/test/run.sh --suite cuttlefish --test snapshot-roundtrip
./tools/test/run.sh --suite e2e --device <serial> # real Pixel
./tools/test/run.sh --suite unit
./tools/test/run.sh --suite fuzz --duration 24h
Internally: starts a fresh Cuttlefish instance, flashes our images, runs the matching test scripts, collects logs+screenshots, reports pass/fail.
tests/cuttlefish/ includes:
boot.sh— flash + boot + green Verified Boot tickpersonad-lifecycle.sh— create/start/suspend/destroymulti-persona-boot.sh— 3 personas boot concurrently (limited only by RAM)switch-roundtrip.sh— switching between personassnapshot-stress.sh— 200 suspend/resume cyclessnapshot-encryption.sh— snapshot file confidentialitynfc-foreground-switch.sh— NFC routingril-foreground-call.sh— RIL routingbiometric-isolation.sh— biometric templates per-personanetworking-isolation.sh— netns + wg/Tor routingpkvm-isolation.sh— host can't read pVM memoryupdate-ota.sh— OTA delta apply + rollback- ... (one per success criterion in § 1)
tests/e2e/ mirrors tests/cuttlefish/ but uses real hardware where Cuttlefish can't:
nfc-tap-real-card.sh— uses a real EMV test card via NFC, verifies foreground-only tapril-real-call.sh— places a real call to a configured test numberbiometric-real-fingerprint.sh— guided manual test for fingerprint enrollment / authgpu-performance.sh— Mali GPU benchmark inside a pVM, verifies ≥60% native perftee-real-attestation.sh— Titan M2 attestation from inside each persona
tests/fuzz/ integrates syzkaller with PersonaPhone-specific descriptions:
tests/fuzz/syzlang/personaphone-vsock.txt— fuzzes the arbitrator vsock protocolstests/fuzz/syzlang/personaphone-aidl.txt— fuzzesIPersonaServicetests/fuzz/syzlang/personaphone-pkvm.txt— fuzzes pKVM hyp-call surface
CI runs a 4-hour fuzz session per night, full 72-hour session weekly.
The PersonaLauncher has a debug screen (adb shell am start com.personaphone.launcher/.DebugActivity) that runs a "self-test" — boots an ephemeral test persona, runs a quick switch + isolation check, reports results in the UI. Useful for end-users to verify their device is healthy.
ci/ contains the CI configuration. We target GitHub Actions for public builds + a self-hosted Drone CI runner on a beefy Linux box for the heavy AOSP builds.
| Trigger | Pipeline |
|---|---|
| Push to any branch | Lint + unit tests (~5 min) |
Push to main |
Lint + unit + Cuttlefish suite (~45 min) |
Tag matching v* |
Lint + unit + Cuttlefish + e2e on staging Pixel + sign release artifacts |
| Nightly cron | Full Cuttlefish + 4h fuzz + Mali GPU benchmark on test Pixel |
Manual workflow_dispatch |
Full e2e on a specific Pixel device pool |
On a successful main-branch build:
- Signed factory image (
personaphone-<commit>-factory.zip) — flashable viaflash-all.bat - Signed OTA package (
personaphone-<commit>-ota.zip) — applied via Custota - Microdroid + guest images for each persona (
grapheneos.img,stock-pixel.img, ...) - Cuttlefish images for the same commit (so anyone can reproduce the dev loop)
- SBOM of all dependencies for security audit
All builds are reproducible — same source → bit-identical artifacts. We pin every dependency commit, use deterministic builds, and verify reproducibility in CI by building each release twice and diffing.
Full doc in docs/threat-model.md. Summary:
| Threat | Mitigation |
|---|---|
| Compromised guest persona reads another persona's RAM | pKVM stage-2 isolation (hardware-enforced) |
| Compromised guest reads its own snapshot file via /data | Snapshots encrypted with Trusty-sealed AEAD; key never leaves TEE |
| Compromised guest pretends to be another persona at TEE | Trusty trustlet validates persona via DICE hash before any keymint op |
| Compromised arbitrator daemon leaks across personas | Arbitrators are crash-only, restart on every persona switch, no persistent state across switches except logged audit trail |
| Compromised crosvm escapes to host | Rust safety + crosvm sandboxing + virtio-fuzzer in CI |
| Physical attacker with debugger | Bootloader locked, Verified Boot enforced, Titan M2 hardware-bound seal keys, factory reset zeroizes /data |
| Network adversary | Per-persona WireGuard/Tor egress, nftables-enforced |
| Sidechannels (cache timing, EM) | Out of scope for v1; ARM CCA realm world is the v2 mitigation (see § 17) |
Full inventory with descriptions in docs/references.md. Top picks by stage:
Boot/AVB: chenxiaolong/avbroot, chenxiaolong/Custota, schnatterer/rooted-graphene (the reference CI gluing these together)
Host AOSP: GrapheneOS source mirror, GrapheneOS/device_google_gs101 (PR #25 "GEN6PKVM" for pKVM enable reference)
AVF: AOSP packages/modules/Virtualization, LineageOS/android_packages_modules_Virtualization (clean GitHub mirror)
crosvm: upstream google/crosvm, AOSP external/crosvm, cloud-hypervisor/cloud-hypervisor (snapshot design reference), cocoonstack/cocoon (reflink fast-switch)
Guest HALs: AOSP device/google/cuttlefish/guest/hals/ (canonical reference for every virt HAL we write)
NFC: nfcgate/nfcgate (MITM reference), AOSP external/libnfc-nci, NXPNFCLinux/linux_libnfc-nci
RIL: Cuttlefish vendor/google/services/ril, rilmodem/ofono
TEE: AOSP Trusty, OP-TEE/optee_os
Networking: dadevel/wg-netns, mullvad/mullvadvpn-app (talpid crates), WireGuard netns
Persona UX: PeterCxy/Shelter, GrapheneOS user-profile UI in platform_frameworks_base
Indie precedent (most relevant active project): wasdwasd0105/limbo_tensor (pVM tinkering on Pixel), outlawsanzhang/koiTerminal (custom VM image loader), Smacksmack206/P9Debian (Pixel 9 + Debian via AVF — confirms guest path), nix-community/nixos-avf (arbitrary-OS-in-AVF reference)
Research (for future v2 with realm-world): TF-RMM/tf-rmm, Aster paper artifacts (when published)
This spec produces a multi-persona Pixel with:
- pKVM as the hypervisor — Type-1.5 (technically a host kernel hosts pKVM, but pKVM is at EL2 with stronger privilege than host)
- Sub-5s persona switching via snapshot/restore
- Real peripheral arbitration
- Per-persona network isolation
The architectural fork the user asked about — "share the base, fork at the right layer" — is realized by the host base layer providing pKVM + arbitrators + the launcher, and each persona being a snapshotted Android guest underneath. Adding personas is adding configs + snapshots, not adding stacks.
The "true peer containers (no host)" version — using ARM CCA realm world to make all personas mutually equal with no privileged host — is a separate spec (v2). The realm-world hardware (FEAT_RME on Cortex-X925/A725 in Pixel 10 silicon) is present but Google has not yet enabled third-party realm access. When they do, the v2 spec swaps pKVM for an RMM at EL3 and removes the privileged-host distinction. That migration is upstream of personad — the application layer of this spec does not change.
Full runbook in docs/runbook.md. Quick start:
# 1. Set up dev host (one-time)
./tools/build/setup-host.sh
# 2. Fetch all dependencies (one-time, ~250GB)
./tools/build/sync-sources.sh
# 3. Build everything
./tools/build/build-all.sh
# 4. Boot in Cuttlefish (dev loop)
./tools/build/cuttlefish-up.sh
# 5. Run full test suite on Cuttlefish
./tools/test/run.sh --suite cuttlefish
# 6. Once Cuttlefish is green, flash a Pixel
./tools/flash/flash-pixel.sh --device <serial>
# 7. Run e2e tests on real hardware
./tools/test/run.sh --suite e2e --device <serial>The deliverable is:
- A flashable Pixel 9/10 factory image that produces a working multi-persona system
- All success criteria (§ 1) passing in CI on Cuttlefish + on at least one real Pixel
- Documentation under
docs/complete enough that someone else can rebuild from scratch - Open source on GitHub (Apache 2.0), all dependencies tracked, SBOM generated per release
- A simple website at
personaphone.devdescribing the project and pointing at the repo
When all of the above is true, the project is shipped.