2026-10-16 · investigation report · follows 03 — Nostr · spec: PROTOCOL-v2
All of it works on emulators, end to end, across two independent implementations. A non-technical flow (scan a QR → both screens show the same 4-digit code → Allow) shares glucose without any secret ever being shown; removing a follower rotates the key while everyone else keeps going; a new phone restores everything from one password-manager entry and followers notice nothing. Real-phone tests with Proton Pass / Bitwarden are the remaining step.
03 proved Nostr carries the data. It left the user-facing parts open, and the user asked for them:
| Topic | Finding | Consequence |
|---|---|---|
| Password managers | Android Credential Manager password items work with Proton Pass (≥ 1.32), Bitwarden, Google Password Manager, KeePassDX on Android 14+; no domain / assetlinks.json needed; no length limit that matters |
Back up the identity key as a password item |
| Proton Pass quirk | Returns a saved item only to an app with the same signing certificate | Sign every build with one key |
| Passkeys | Need a domain + assetlinks.json; Bitwarden Android doesn’t return PRF; Proton Pass has no passkey export (CXP) yet → a passkey in Proton Pass can’t move to Bitwarden today |
No passkeys for now — exactly the lock-in the user feared |
| Group crypto (MLS / Marmot) | Mature-ish but every member must process every commit in order; followers (Mac, Home Assistant) can be offline for days | Not used: one shared read key, delivered privately per follower (gift wrap), rotated on revoke — the pattern of the open NIP-DA proposal (nips#2411) |
| Delegation / key migration NIPs | NIP-26 unrecommended; all migration NIPs unmerged | App-defined: identity key signs a roster of device keys |
| The 03 QR | It contained the read key → a screenshot = permanent access | v2 invites contain no secrets |
identity key I ──(password manager backup) follower F (own key, per device)
│ signs roster: devices=[D], epoch n ▲
│ seals key deliveries ──── gift wrap (NIP-59) ───────┘ {sid, epoch n, read key G_n, devices}
device key D (phone) ── signs live + chunks, encrypted to G_n ──► relays ──► followers (accept only roster devices)
spikes/nostr/v2/: cgm2.py (protocol), nodes.py (owner / follower), scenarios.py.
Every node has its own keys, own SQLite and own relay connections — the only shared thing is the public relays,
so a follower can only show data that really travelled through Nostr. Result: 19/19 checks pass
(results/v2-scenarios.txt):
| # | Scenario | Result |
|---|---|---|
| 1 | Share: request → approve → 12 h backfill + live | ✅ approved in ~1 s; 724/724 readings identical to the owner’s; every row records the relay it came via |
| 2 | Negative: owner denies a request | ✅ no key, 0 readings |
| 3 | Negative: forged key delivery (sealed by an attacker) | ✅ rejected — not sealed by the identity key |
| 4 | Negative: reading signed by a non-roster key | ✅ never delivered (subscriptions are roster-filtered) and rejected if a relay delivers it anyway |
| 5 | Revoke follower A while B keeps working | ✅ B gets epoch 2 and continues; A gets “access removed”, 0 new readings, its rekey requests refused |
| 6 | Phone change (restore identity only) | ✅ followers + stream restored from the relay backup; B continues on the new phone without re-inviting; readings signed by the old phone are rejected |
| 7 | Follower missed a rotation | ✅ asks for the key, gets it, recovers |
Bugs these tests caught (fixed, and written into the spec):
created_at must strictly increase per d.spikes/nostr/android-app (dev.spike.cgmshare, Kotlin, ~3.6k lines). One app,
two roles picked at onboarding — “I wear the sensor” / “I follow someone” — with separate databases, separate
Keystore keys and separate state files; only the chosen role’s engine is ever created.
cgmshare: link, paste, camera QR (CameraX + zxing-cpp, FOSS) or QR-from-image; waits
with the match code; big number + continuous trend arrow + 12 h chart + “via relay X · key epoch n”;
alarms: low/high (65/185) and no data for 20 min; “access removed” screen.onEvent(eventJson, via) with all
acceptance rules behind it, and publish through a Transport interface — Bluetooth will plug in next to the relays.org.nostrdevkit:nostr-sdk 0.45.1 (rust-nostr, renamed from org.rust-nostr) for keys/NIP-44/NIP-49/
NIP-59; own OkHttp relay layer (paced writes, subscriptions, NIP-42 AUTH). Release APK 14 MB (7.6 MB native lib).Tested on two emulators = two separate devices (and against the Python reference); evidence in
android-app/evidence/:
| Scenario | Result |
|---|---|
| Share → join → approve | request reached the owner in 1.2 s; follower 720/720 readings identical to the owner’s 12 h |
| Negative: owner denies | follower stays “waiting”, 0 readings |
| Negative: forged key delivery | rejected: “not sealed by the identity key” |
| Negative: insider who knows the read key publishes live/chunk/roster from a non-roster key | 4× “author not in roster”, 1× “roster not signed by identity”, 0 forged values stored (with relay-side author filtering deliberately disabled to exercise the app’s own rule) |
| Revoke A, B keeps going | A got “access removed” in 0.2 s and nothing after; B (Python follower) moved to epoch 2 and continued |
| Phone change | cleared owner app data → restored from a password manager (KeePassDX) → same identity, new device key, epoch 3; app follower + Python follower continued without re-inviting, 719/719 identical, no gap |
| Password manager | KeePassDX as the emulator’s credential provider: Save and Restore work through the same API Proton Pass and Bitwarden implement |
| Cross-implementation | app follower ↔ Python owner: 770/770 readings equal; Python follower ↔ app owner: same match codes, epochs 1→2→3 |
| Alarms | “HIGH glucose” and “No glucose data” notifications fired |
Bugs the tests caught (fixed):
Battery-relevant choices: no polling, no WebSocket pings; owner wakes briefly per reading and checks for access requests when it publishes a chunk (~5 min); the follower keeps one inexact, idle-allowed alarm re-armed to “last reading + 6 min”, so it never fires while data flows.
Not done: Proton Pass / Bitwarden on the real phone (needs the user’s accounts), camera decoding on a real camera (only proven via “from image”), restart after reboot, real-phone Doze. Emulator latency numbers are inflated.
writers) that reach LibreView
through the phone.ncryptsec + generated passphrase) for people without a password manager.StoredPasswordAppAssociationAuthorizer)