Flash · Library docs
Security model
Threat model, device identity, trust-on-first-use pairing and end-to-end encryption.
From docs/security.md · updated 22 August 2026
Status: Living document. Established 2026-08-22 during Phase P2 (docs/core-upgrade-plan.md §C2). Update whenever the security surface changes.
1. Threat model (v1 scope)
| Asset | Threat | Mitigation | Where |
|---|---|---|---|
| Message content on disk | Device theft / forensic extraction | SQLCipher full-database encryption; key wrapped by AndroidKeyStore (wiring lands C7) | :core:persistence C1 |
| Message content on the wire (same LAN) | Passive sniffing / active MITM | TLS 1.3 with self-signed certs + TOFU fingerprint pinning (C4, planned) | :core:network C4 |
| Message content beyond a relayed/mesh hop (future, D5) | Malicious relay | Frame-level E2E over TLS (this phase, C2.7) — payload opaque to any intermediary | :core:security/crypto/E2eFrameCodec |
| Identity theft across reinstalls/backups | Cloned identity | Identity key non-exportable in AndroidKeyStore; excluded from Android backup (manifest flag at app layer) | KeystoreFlashCrypto |
| Evil-twin peer impersonation | MITM on first connection | TOFU pinning: first-connect consent UI shows fingerprint + 6-digit numeric comparison code derived from BOTH public keys; mismatch after pinning = hard fail + PeerKeyChanged event (UI-031 warning state) |
TofuPolicy, NumericComparisonCode |
| Legacy trust flag abuse | Old “trusted” flag silently becoming a crypto pin | Legacy migration writes LEGACY_UNBOUND_FINGERPRINT; TOFU re-prompts on first contact instead of trusting blindly |
LegacyTrustMigration |
| Timing side channels on comparisons | Fingerprint/code oracle | All secret-material comparisons via MessageDigest.isEqual (constant-time) |
FlashFingerprint.constantTimeEquals, NumericComparisonCode.hashesEqual |
Out of scope for v1: anonymity / metadata protection beyond the local network, forward secrecy across session-key compromise (see §4 Rekey), post-quantum.
2. Identity (C2.1–C2.3)
- Identity key: ECDSA P-256, alias
flash_identity, generated in AndroidKeyStore. Purposes SIGN|VERIFY only, digest SHA-256 only. StrongBox-backed when available (API 28+, literal feature stringandroid.software.keystorestrongbox), silent TEE fallback onStrongBoxUnavailableException/ProviderException. Biometric-enrollment invalidation disabled so identity survives fingerprint re-enrollment. - Certificate: self-signed X.509 generated by the platform as part of keystore keypair creation (
setCertificateSubject/NotBefore/NotAfter) and retrieved viaKeyStore.getCertificate(alias)— zero extra dependencies (BouncyCastle rejected: multi-MB APK cost for capability we do not use, since trust comes from fingerprint pinning, not chain validation). - Fingerprint: SHA-256 of the X.509-encoded identity public key (decision D3), displayed uppercase colon-grouped hex.
3. Pairing & TOFU (C2.5–C2.6)
- Initiator sends
PAIR_REQUEST{requestId, deviceId, name, model, fingerprintHex, ephemeralPubKey}. - Responder consents →
PAIR_ACCEPT. Both sides derive the 6-digit display code identically:SHA-256(lexicographicallySorted(fingerprintA, fingerprintB)), first 5 bytes big-endian mod 10^6,%06d— symmetric by construction (Bluetooth SSP numeric-comparison precedent), so role order never changes the code. - Initiator proves it derived the same code with
PAIR_CONFIRM{codeHashHex}(hash, not the code itself); responder verifies constant-time. Mismatch ⇒ sessionFailed(protocol violation), neverExpired. - Responder completes with
PAIR_PAIRIED{fingerprintHex, ephemeralPubKey}; both sides reachConfirmedand the peer is pinned inRoomTrustedStorewith its fingerprint. - Timeouts are engine-owned (
PairingTimeouts: request expiry 30 s default mirroring UI-032 countdown, per-side decision window configurable). Expiry is neutral (Expired ≠ Failed) per profile-ui.md semantics.
TOFU policy (TofuPolicy.evaluate): no record ⇒ FirstConnect (consent prompt); presented fingerprint matches pin ⇒ Match; anything else — including a missing presented fingerprint when a pin exists — fails closed (Mismatch). Revocation clears the pin; next contact re-prompts.
4. Frame-level E2E (C2.7, decision D4)
- Key agreement: ephemeral software ECDH P-256 keypairs exchanged during pairing (AndroidKeyStore
PURPOSE_AGREE_KEYexists only since API 31 < our minSdk 24 — ephemeral keys are memory-only by design and need no hardware backing). - Derivation: HKDF-SHA-256 (RFC 5869, extract-then-expand, info binds
"flash-e2e-v2"context) over the shared secret → 32-byte AES-256 key. - Wire format:
[12-byte random nonce | AES-GCM ciphertext+tag], AAD = protocol-version string, so frames cannot be replayed across protocol versions. - Nonce discipline: 96-bit random nonces from
SecureRandom; hard limit 2^32 encryptions per session key (NIST SP 800-38D random-nonce bound) — unreachable in practice at P2P message rates, enforced conceptually by per-pairing-session keys. - Rekey policy: new ephemeral exchange on each pairing; automatic periodic rekey is deferred to the mesh/relay workstream (D5, post-v1) — direct TLS sessions already rotate keys at the transport level.
- Layering: E2E encrypts
FlashEnvelope.payloadJsoninside TLS. TLS protects the hop; E2E makes payloads opaque to any future relay.
5. Known gaps / debt
- Keystore path and E2E codec are JVM-tested via
SoftwareFlashCrypto; device verification of the Keystore impl is pending (backlog item). PAIR_DECLINEhas no wire frame yet — declining resets locally; peer sees expiry instead (documented in KDoc, wire encoding lands C4/C6).- Backup-exclusion manifest flags belong to the app layer (C7 wiring).
- No forward secrecy across device compromise of a long-lived session key yet (accepted for v1 direct-P2P scope).