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 string android.software.keystorestrongbox), silent TEE fallback on StrongBoxUnavailableException/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 via KeyStore.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)

  1. Initiator sends PAIR_REQUEST{requestId, deviceId, name, model, fingerprintHex, ephemeralPubKey}.
  2. 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.
  3. Initiator proves it derived the same code with PAIR_CONFIRM{codeHashHex} (hash, not the code itself); responder verifies constant-time. Mismatch ⇒ session Failed (protocol violation), never Expired.
  4. Responder completes with PAIR_PAIRIED{fingerprintHex, ephemeralPubKey}; both sides reach Confirmed and the peer is pinned in RoomTrustedStore with its fingerprint.
  5. 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_KEY exists 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.payloadJson inside 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_DECLINE has 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).