Skip to content

feat: Encrypted Reticulum identity key storage#276

Merged
torlando-tech merged 10 commits intomainfrom
feature/encrypted-identity-keys
Feb 17, 2026
Merged

feat: Encrypted Reticulum identity key storage#276
torlando-tech merged 10 commits intomainfrom
feature/encrypted-identity-keys

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

@torlando-tech torlando-tech commented Jan 17, 2026

Summary

  • Implements layered AES-256-GCM encryption for Reticulum identity keys using Android Keystore
  • Adds optional user password protection layer (PBKDF2 with 600K iterations)
  • Silently migrates existing unencrypted keys to encrypted storage on app startup
  • Updates migration export/import to use password-protected encryption

Security Properties

Property Implementation
At-rest encryption AES-256-GCM with Android Keystore
Hardware binding TEE/StrongBox backed (non-extractable)
Forward secrecy Random IV per encryption
Integrity GCM authentication tag prevents tampering
Password protection PBKDF2-HMAC-SHA256, 600K iterations
Secure deletion Memory wiped with random data before zeroing

Architecture

┌─────────────────────────────────────────────────────────────┐
│  64-byte Reticulum Identity Key                             │
└─────────────────────────────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│  Layer 1: Device Key (Android Keystore)                     │
│  - AES-256-GCM with hardware backing (TEE/StrongBox)        │
│  - Always active, transparent to user                       │
└─────────────────────────────────────────────────────────────┘
                          │
                          ▼ (optional)
┌─────────────────────────────────────────────────────────────┐
│  Layer 2: User Password (PBKDF2 + AES-256-GCM)              │
│  - User-provided PIN/password required to unlock            │
└─────────────────────────────────────────────────────────────┘

Files Changed

New files:

  • IdentityKeyEncryptor.kt - Core AES-256-GCM encryption with Keystore
  • IdentityKeyMigrator.kt - Auto-migration of unencrypted keys
  • IdentityKeyProvider.kt - Runtime key access with caching

Modified files:

  • LocalIdentityEntity.kt - Added encrypted storage columns
  • LocalIdentityDao.kt - New queries for encrypted key management
  • DatabaseModule.kt - Migration 30→31
  • IdentityRepository.kt - Integration with encryption system
  • MigrationData/Exporter/Importer.kt - Password-protected export

Test Plan

Manual Testing

Prerequisites: A device/emulator with an existing install from main branch with at least one identity created.

1. Database Migration (38 → 39)

  • Install current release build (main branch)
  • Create at least one identity and send some messages
  • Install this branch on top of the existing install
  • Verify app launches without crash (Room runs MIGRATION_38_39)
  • Verify all existing data is intact (conversations, messages, contacts)

2. New Identity Creation Encrypts Keys

  • On this branch, create a new identity
  • Via adb shell + sqlite3, verify local_identities.encryptedKeyData is populated and keyData is NULL for the new identity
  • Verify the identity works normally (can send/receive messages)

3. Export/Import Round-Trip

  • Export data from this branch (keys export in legacy plaintext since UI doesn't pass exportPassword yet)
  • Import on same or different device running this branch
  • Verify all identities, conversations, messages, and contacts are restored
  • Verify imported identities have encryptedKeyData populated (keys encrypted on import)

4. Fresh Install

  • Clean install of this branch on a device with no prior data
  • Complete onboarding, create identity
  • Verify normal app functionality (messaging, announces, contacts)

Not Yet Testable (backend only, no UI wired)

  • Silent migration of existing unencrypted keys (runEncryptionMigration() not called from app startup yet)
  • Password-protected export/import (ViewModel doesn't pass passwords through yet)
  • Enable/disable/change password protection UI

Automated Tests

  • IdentityKeyEncryptorTest — password verification, export round-trip, version detection, salt extraction, secure wipe, input validation
  • IdentityKeyMigratorTest — migration flow, edge cases (invalid key size, no key data), multi-identity, stats, reset
  • IdentityKeyProviderTest — decrypt, password protection enable/disable/change, verification

Note: Device-key encryption tests require instrumented tests (real Keystore). Unit tests cover password-based encryption and utility methods.

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown

❌ Threading Architecture Audit Failed

View Audit Report
Dispatcher Audit Report - Sat Jan 17 03:22:50 UTC 2026
========================================


═══════════════════════════════════════
1. Checking for runBlocking in Production Code
═══════════════════════════════════════
❌ VIOLATION: runBlocking found: data/src/main/java/com/lxmf/messenger/data/crypto/IdentityKeyProvider.kt:460:            kotlinx.coroutines.runBlocking {

═══════════════════════════════════════
2. Checking for Forbidden Patterns
═══════════════════════════════════════
✅ PASS: No GlobalScope usage found
✅ PASS: No Dispatchers.Unconfined usage found

═══════════════════════════════════════
3. Checking Python Initialization Uses Main.immediate
═══════════════════════════════════════
✅ PASS: Python initialization uses Dispatchers.Main.immediate

═══════════════════════════════════════
4. Summary - CI Optimized Check Complete
═══════════════════════════════════════
ℹ️  INFO: Sections 5-9 skipped for CI performance (non-critical checks)
ℹ️  INFO: All critical threading violations checked (runBlocking, GlobalScope, Unconfined, Python init)
ℹ️  INFO: For comprehensive analysis, run audit-dispatchers-full.sh locally

Please fix the dispatcher violations before merging.

@torlando-tech torlando-tech added this to the v0.7.0 milestone Jan 17, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Jan 17, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@torlando-tech torlando-tech modified the milestones: v0.7.0, v0.8.0 Jan 18, 2026
@torlando-tech torlando-tech modified the milestones: v0.8.0, v0.9.0 Feb 9, 2026
torlando-tech and others added 6 commits February 16, 2026 22:31
Implements layered AES-256-GCM encryption for identity key protection:

- IdentityKeyEncryptor: Core encryption using Android Keystore (device key)
  with optional PBKDF2-based password protection layer
- IdentityKeyMigrator: Silent auto-migration of unencrypted keys at startup
- IdentityKeyProvider: Runtime decrypted key access with in-memory caching
  and lifecycle-aware cleanup when app goes to background

Database changes:
- Add encryptedKeyData, keyEncryptionVersion, passwordSalt,
  passwordVerificationHash columns to LocalIdentityEntity
- Migration 30→31 for new columns

Migration export/import:
- Password-protected export encryption for portable backups
- MigrationData version bumped to 7

Security properties:
- Hardware-backed encryption via TEE/StrongBox
- Random IV per encryption for forward secrecy
- GCM authentication prevents tampering
- Secure memory wiping after use

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace runBlocking with synchronous cache clearing in onStop() lifecycle
callback. ConcurrentHashMap is thread-safe for this cleanup scenario.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fix compilation error caused by trailing lambda being interpreted
as the new importPassword parameter instead of onProgress.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The test was flaky because yield() doesn't guarantee the collector is
subscribed before downloadRegion starts emitting statuses. Use onStart{}
with CompletableDeferred to ensure the collector is fully subscribed
before starting the download.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@torlando-tech torlando-tech force-pushed the feature/encrypted-identity-keys branch from 0dd331a to c0f8990 Compare February 17, 2026 05:06
@torlando-tech torlando-tech marked this pull request as ready for review February 17, 2026 05:19
@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 17, 2026

Greptile Summary

This PR implements comprehensive at-rest encryption for Reticulum identity keys using AES-256-GCM with Android Keystore backing. The implementation follows security best practices with hardware-backed encryption (TEE/StrongBox), optional user password protection via PBKDF2 (600K iterations), and proper key lifecycle management.

Key Strengths:

  • Layered security model: mandatory device-bound encryption + optional password layer
  • Silent migration of existing unencrypted keys on app startup
  • Proper version detection (0x01/0x02) allows graceful upgrades
  • Export/import system supports password-protected portability between devices
  • In-memory key caching with lifecycle-aware cleanup (cleared on app background)
  • Comprehensive test coverage for password operations, migration logic, and provider behavior
  • Follows Android Keystore best practices with randomized encryption and non-extractable keys

Architecture Notes:

  • The IdentityKeyEncryptor handles all cryptographic operations with proper IV randomization and GCM authentication
  • The IdentityKeyMigrator provides idempotent migration with fallback logic for reading keys from database or filesystem
  • The IdentityKeyProvider manages runtime access with thread-safe caching using ReentrantLock
  • Database schema cleanly separates encrypted storage (encryptedKeyData) from deprecated plaintext (keyData)
  • Migration 38→39 adds encryption columns without breaking existing installations

Previously Reported Issues:
Two issues were already identified in previous review threads:

  1. Stale password metadata after disabling protection (IdentityKeyProvider.kt:230)
  2. secureWipe on .encoded only wipes a copy due to JCA limitations (IdentityKeyEncryptor.kt:215)

Both issues are documented with comments explaining the limitations.

Confidence Score: 5/5

  • This PR is safe to merge with excellent security architecture and comprehensive testing
  • The implementation demonstrates strong cryptographic practices with AES-256-GCM, hardware-backed key storage, proper random IV generation, and PBKDF2 with appropriate iteration count. The code is well-tested with unit tests covering password operations, migration scenarios, and provider behavior. The database migration is clean and follows established patterns. Previously identified issues are minor and already documented.
  • No files require special attention

Important Files Changed

Filename Overview
data/src/main/java/com/lxmf/messenger/data/crypto/IdentityKeyEncryptor.kt Implements AES-256-GCM encryption for identity keys with Android Keystore backing and optional password protection. Well-designed with proper version detection, input validation, and secure random IV generation.
data/src/main/java/com/lxmf/messenger/data/crypto/IdentityKeyMigrator.kt Handles silent migration of unencrypted keys to encrypted storage. Includes proper fallback logic for reading keys from both database and filesystem, with idempotent design.
data/src/main/java/com/lxmf/messenger/data/crypto/IdentityKeyProvider.kt Runtime key access with in-memory caching and lifecycle-aware cleanup. Uses ReentrantLock for thread safety. Handles password verification and protection management correctly.
data/src/main/java/com/lxmf/messenger/data/db/entity/LocalIdentityEntity.kt Added encryption-related columns (encryptedKeyData, keyEncryptionVersion, passwordSalt, passwordVerificationHash) with proper deprecation marking on legacy keyData field.
data/src/main/java/com/lxmf/messenger/data/db/dao/LocalIdentityDao.kt Added comprehensive DAO methods for encryption operations: updateEncryptedKeyData, clearPasswordProtection, updatePasswordProtection, clearUnencryptedKeyData, and migration queries.
data/src/main/java/com/lxmf/messenger/data/repository/IdentityRepository.kt Integrates encryption system throughout identity lifecycle. All new identities encrypted on creation, import operations re-encrypt with device key, delegation to IdentityKeyProvider for password management.
app/src/main/java/com/lxmf/messenger/migration/MigrationExporter.kt Updated to support password-protected exports. Uses encryptForExport for portable encryption. Properly decrypts from device storage before export encryption.
app/src/main/java/com/lxmf/messenger/migration/MigrationImporter.kt Handles import of password-protected exports. Decrypts with import password, then re-encrypts with device key for local storage. Proper error handling for wrong password scenarios.

Flowchart

flowchart TD
    A[64-byte Reticulum Identity Key] --> B{Encryption Layer}
    B --> C[Layer 1: Device Key<br/>AES-256-GCM + Android Keystore<br/>TEE/StrongBox backed]
    C --> D{Password Protection?}
    D -->|No| E[Version 1 Format<br/>0x01 + IV + Encrypted Data + Tag]
    D -->|Yes| F[Layer 2: User Password<br/>PBKDF2 600K iterations]
    F --> G[Version 2 Format<br/>0x02 + Salt + IV + Double-Encrypted + Tag]
    
    E --> H[(Database Storage)]
    G --> H
    
    H --> I[Migration Export]
    I --> J{Export Password?}
    J -->|Yes| K[Portable Format<br/>Salt + IV + Encrypted + Tag]
    J -->|No| L[Legacy Plaintext]
    
    K --> M[Import on New Device]
    L --> M
    M --> N[Decrypt with Password]
    N --> O[Re-encrypt with Device Key]
    O --> H
    
    H --> P[Runtime Access]
    P --> Q[IdentityKeyProvider]
    Q --> R{In Cache?}
    R -->|Yes| S[Return Cached Key]
    R -->|No| T[Decrypt from Storage]
    T --> U[Cache Key]
    U --> S
    
    V[App Goes to Background] --> W[Lifecycle: onStop]
    W --> X[Wipe Cache Memory]
    X --> Y[Delete Temp Files]
Loading

Last reviewed commit: 6361427

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

18 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +227 to +234
// Remove password protection
val deviceOnlyEncrypted = encryptor.removePasswordProtection(encryptedData, currentPassword)

// Update database (clear password-related fields)
identityDao.updateEncryptedKeyData(
identityHash = identityHash,
encryptedKeyData = deviceOnlyEncrypted,
version = IdentityKeyEncryptor.VERSION_DEVICE_ONLY.toInt(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale password metadata after disabling protection

When disabling password protection, updateEncryptedKeyData only updates encryptedKeyData and keyEncryptionVersion, but does not clear passwordSalt and passwordVerificationHash. This leaves stale password-related data in the database after the password layer is removed. While this doesn't break decryption (since keyEncryptionVersion is correctly set to VERSION_DEVICE_ONLY), it leaves sensitive password-derived material in the database unnecessarily and could cause confusion if passwordSalt/passwordVerificationHash are checked independently of the version field elsewhere.

Consider either:

  1. Adding a dedicated DAO method that clears password fields when downgrading, or
  2. Using updatePasswordProtection with null-able salt/hash parameters to clear them in the same update.
Suggested change
// Remove password protection
val deviceOnlyEncrypted = encryptor.removePasswordProtection(encryptedData, currentPassword)
// Update database (clear password-related fields)
identityDao.updateEncryptedKeyData(
identityHash = identityHash,
encryptedKeyData = deviceOnlyEncrypted,
version = IdentityKeyEncryptor.VERSION_DEVICE_ONLY.toInt(),
// Remove password protection
val deviceOnlyEncrypted = encryptor.removePasswordProtection(encryptedData, currentPassword)
// Update database (clear password-related fields by setting version to device-only)
// Note: passwordSalt and passwordVerificationHash should also be cleared
identityDao.updateEncryptedKeyData(
identityHash = identityHash,
encryptedKeyData = deviceOnlyEncrypted,
version = IdentityKeyEncryptor.VERSION_DEVICE_ONLY.toInt(),
)
Prompt To Fix With AI
This is a comment left during a code review.
Path: data/src/main/java/com/lxmf/messenger/data/crypto/IdentityKeyProvider.kt
Line: 227:234

Comment:
**Stale password metadata after disabling protection**

When disabling password protection, `updateEncryptedKeyData` only updates `encryptedKeyData` and `keyEncryptionVersion`, but does **not** clear `passwordSalt` and `passwordVerificationHash`. This leaves stale password-related data in the database after the password layer is removed. While this doesn't break decryption (since `keyEncryptionVersion` is correctly set to `VERSION_DEVICE_ONLY`), it leaves sensitive password-derived material in the database unnecessarily and could cause confusion if `passwordSalt`/`passwordVerificationHash` are checked independently of the version field elsewhere.

Consider either:
1. Adding a dedicated DAO method that clears password fields when downgrading, or
2. Using `updatePasswordProtection` with null-able salt/hash parameters to clear them in the same update.

```suggestion
                    // Remove password protection
                    val deviceOnlyEncrypted = encryptor.removePasswordProtection(encryptedData, currentPassword)

                    // Update database (clear password-related fields by setting version to device-only)
                    // Note: passwordSalt and passwordVerificationHash should also be cleared
                    identityDao.updateEncryptedKeyData(
                        identityHash = identityHash,
                        encryptedKeyData = deviceOnlyEncrypted,
                        version = IdentityKeyEncryptor.VERSION_DEVICE_ONLY.toInt(),
                    )
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +215 to +216
// Securely clear the password key from memory
secureWipe(passwordKey.encoded)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secureWipe on .encoded only wipes a copy

SecretKeySpec.getEncoded() and SecretKey.getEncoded() return a clone of the internal byte array per the JCA spec. Calling secureWipe(passwordKey.encoded) allocates a new copy and wipes that copy — the key material inside the SecretKeySpec/SecretKey object remains in memory until GC. This same pattern appears at lines 216, 300, and 507.

This is a known JCA limitation and doesn't make the encryption less secure in practice (JVM GC makes deterministic memory wiping unreliable regardless), but it's worth noting that the secureWipe calls on .encoded provide less protection than they appear to. Consider adding a comment to document this JCA limitation so future readers don't assume the key material is actually zeroed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: data/src/main/java/com/lxmf/messenger/data/crypto/IdentityKeyEncryptor.kt
Line: 215:216

Comment:
**`secureWipe` on `.encoded` only wipes a copy**

`SecretKeySpec.getEncoded()` and `SecretKey.getEncoded()` return a **clone** of the internal byte array per the JCA spec. Calling `secureWipe(passwordKey.encoded)` allocates a new copy and wipes that copy — the key material inside the `SecretKeySpec`/`SecretKey` object remains in memory until GC. This same pattern appears at lines 216, 300, and 507.

This is a known JCA limitation and doesn't make the encryption less secure in practice (JVM GC makes deterministic memory wiping unreliable regardless), but it's worth noting that the `secureWipe` calls on `.encoded` provide less protection than they appear to. Consider adding a comment to document this JCA limitation so future readers don't assume the key material is actually zeroed.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 17, 2026

Additional Comments (1)

data/src/main/java/com/lxmf/messenger/data/repository/IdentityRepository.kt
File recovery broken after encryption migration

ensureIdentityFileExists attempts to recover a missing identity file from identity.keyData, but after the encryption migration runs, keyData is set to NULL (via clearUnencryptedKeyData). This means the recovery path on line 335-345 will always fail for migrated identities, since it doesn't attempt to decrypt from encryptedKeyData.

After migration, if the identity file is lost/corrupted, this method will report "Identity file missing and no valid keyData backup available" even though the key data exists in encrypted form. This should fall back to keyProvider.getDecryptedKeyData(identity.identityHash) when keyData is null but encryptedKeyData is present.

Prompt To Fix With AI
This is a comment left during a code review.
Path: data/src/main/java/com/lxmf/messenger/data/repository/IdentityRepository.kt
Line: 335:345

Comment:
**File recovery broken after encryption migration**

`ensureIdentityFileExists` attempts to recover a missing identity file from `identity.keyData`, but after the encryption migration runs, `keyData` is set to `NULL` (via `clearUnencryptedKeyData`). This means the recovery path on line 335-345 will always fail for migrated identities, since it doesn't attempt to decrypt from `encryptedKeyData`.

After migration, if the identity file is lost/corrupted, this method will report "Identity file missing and no valid keyData backup available" even though the key data exists in encrypted form. This should fall back to `keyProvider.getDecryptedKeyData(identity.identityHash)` when `keyData` is null but `encryptedKeyData` is present.

How can I resolve this? If you propose a fix, please make it concise.

IdentityRepository and MigrationImporter gained new constructor
parameters (keyEncryptor, keyMigrator, keyProvider) during the rebase
but two test files were not updated to pass them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1. MigrationImporter.readMigrationBundle was catching
   data.crypto.WrongPasswordException but MigrationCrypto.decrypt
   throws migration.WrongPasswordException — added catch for both.

2. IdentityRepositoryDatabaseTest.createIdentity test now asserts
   encryptedKeyData (not deprecated keyData) since createIdentity
   encrypts keys before storage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
torlando-tech and others added 2 commits February 17, 2026 10:32
- Add clearPasswordProtection DAO method to null out password metadata
  when disabling password protection (previously left stale salt/hash)
- Replace coroutine Mutex with ReentrantLock in IdentityKeyProvider to
  fix race condition between suspend cache reads and onStop lifecycle
  callback running on main thread
- Wire runEncryptionMigration into ColumbaApplication.onCreate so
  existing unencrypted keys are migrated on app startup
- Fall back to keyProvider.getDecryptedKeyData in ensureIdentityFileExists
  when legacy plaintext keyData is null but encryptedKeyData exists
- Add JCA limitation documentation to secureWipe explaining that
  SecretKeySpec.getEncoded() returns a clone
- Fix test stubs for non-relaxed mocks (secureWipe, DAO mutations,
  password protection methods)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cancel viewModelScope in tearDown to prevent coroutines from leaking
between tests, and stub deleteRegion to avoid unstubbed mock exceptions
when cancelDownload() is invoked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

@torlando-tech torlando-tech merged commit d3e2117 into main Feb 17, 2026
14 checks passed
@torlando-tech torlando-tech deleted the feature/encrypted-identity-keys branch February 17, 2026 17:03
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