docs: update BLOCKERS.md with Task 3 completion status
This commit is contained in:
parent
db4c262b27
commit
19637833a6
8 changed files with 1230 additions and 75 deletions
203
BLOCKERS.md
203
BLOCKERS.md
|
|
@ -1,6 +1,6 @@
|
|||
# BLOCKERS.md - Phase 1 Implementation Blockers
|
||||
# BLOCKERS.md - Phase 1 Implementation Status
|
||||
|
||||
## Task 1: Module Scaffolding
|
||||
## Task 1: Module Scaffolding ✅ COMPLETE
|
||||
|
||||
### Completed
|
||||
- ✅ Module structure created (api/impl/test)
|
||||
|
|
@ -13,84 +13,137 @@
|
|||
- ✅ Basic unit tests added
|
||||
- ✅ Pushed to Gitea phase1-dev branch
|
||||
|
||||
### Not Verified (No Android SDK in build environment)
|
||||
- ⚠️ `./gradlew :features:wallet:impl:assemble` - compilation not tested
|
||||
- ⚠️ `./gradlew ktlintCheck --continue` - code style not verified
|
||||
- ⚠️ `./gradlew :features:wallet:impl:test` - unit tests not run
|
||||
|
||||
### Action Required
|
||||
When a developer with Android SDK runs this code:
|
||||
1. Run `./gradlew :features:wallet:impl:assemble` to verify compilation
|
||||
2. Run `./gradlew ktlintCheck --continue` and fix any code style issues
|
||||
3. Run `./gradlew :features:wallet:impl:test` to verify tests pass
|
||||
|
||||
---
|
||||
|
||||
## Resolved Decisions
|
||||
|
||||
### Q1: Wallet Scope ✅ RESOLVED
|
||||
**Decision:** Per-session (each Matrix account has its own wallet)
|
||||
|
||||
Each Matrix session maintains its own independent wallet. This aligns with Matrix's account-centric model and provides proper isolation between accounts.
|
||||
|
||||
**Phase 3 Planned:** Optional wallet sharing between accounts — will be implemented as a user preference, not default behavior.
|
||||
|
||||
### Q2: Key Storage on Biometric Change ✅ RESOLVED
|
||||
**Decision:** INVALIDATE keys and require re-authentication/re-setup
|
||||
|
||||
When biometric enrollment changes (fingerprints added/removed, face re-enrolled, etc.), stored wallet keys are invalidated. Users must re-authenticate and re-setup their wallet access. This is **intentional security behavior, not a bug** — it prevents unauthorized access if a device is compromised or biometrics are changed by an attacker.
|
||||
|
||||
### Q3: Network Configuration ✅ RESOLVED
|
||||
**Decision:** TESTNET first, with easy mainnet swap
|
||||
|
||||
Development and initial testing will target Cardano testnet. The network configuration must be a **single constant or build flavor** — no scattered hardcoded values throughout the codebase.
|
||||
|
||||
Implementation requirements:
|
||||
- Single source of truth: `Constants.NETWORK_MODE` or build variant
|
||||
- All network-dependent URLs/configs derived from this single value
|
||||
- Clean swap to mainnet via config change or release build flavor
|
||||
- No hunting through code for hardcoded "testnet" strings
|
||||
|
||||
---
|
||||
|
||||
## Android Emulator
|
||||
|
||||
Development Android emulator is live and available:
|
||||
|
||||
| Service | Address |
|
||||
|---------|---------|
|
||||
| ADB | `192.168.0.5:5555` |
|
||||
| noVNC (browser access) | `http://192.168.0.5:6080` |
|
||||
|
||||
Connect via: `adb connect 192.168.0.5:5555`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: /pay Slash Command Parser + SuggestionsProcessor Extension
|
||||
## Task 2: Key Generation + Storage ✅ COMPLETE
|
||||
|
||||
### Completed
|
||||
- ✅ `ParsedPayCommand.kt` - Sealed interface for parse results (WithAddressRecipient, WithMatrixRecipient, AmountOnly, Empty, ParseError)
|
||||
- ✅ `SlashCommandParser.kt` - Full parser implementation with:
|
||||
- Amount parsing (integers, decimals, up to 6 decimal places for lovelace precision)
|
||||
- Unit support (ADA, tADA for testnet, lovelace)
|
||||
- Matrix user ID validation (@user:server format)
|
||||
- Cardano address validation (addr1/addr_test1 prefixes, length checks, network mismatch detection)
|
||||
- Comprehensive error messages
|
||||
- ✅ `ResolvedSuggestion.kt` - Added `Command(command: String, description: String)` type
|
||||
- ✅ `SuggestionsProcessor.kt` - Added /pay command suggestion with filtering
|
||||
- ✅ `MarkdownTextEditorState.kt` - Added Command case to insertSuggestion()
|
||||
- ✅ `MessageComposerPresenter.kt` - Added Command handling in InsertSuggestion event
|
||||
- ✅ `SlashCommandParserTest.kt` - Comprehensive unit tests (40+ test cases)
|
||||
- ✅ **CardanoNetworkConfig.kt** - Single object for testnet/mainnet config swap
|
||||
- Currently configured for TESTNET (preprod)
|
||||
- Change `NETWORK` to `CardanoNetwork.MAINNET` for production
|
||||
- All derived values (Koios URL, explorer URL, address prefix) auto-switch
|
||||
|
||||
### What's Still Needed (Task 6)
|
||||
- ⚠️ MessageComposerPresenter interception of /pay on send (requires PaymentFlowPresenter from Task 6)
|
||||
- ⚠️ Navigation to payment flow when /pay is sent
|
||||
- ⚠️ Integration with PaymentFlowNode for actual payment execution
|
||||
- ✅ **CardanoKeyStorage** (interface + implementation)
|
||||
- Per-session wallet isolation (key alias: `cardano_wallet_{sessionId}`)
|
||||
- 24-word BIP-39 mnemonic generation using cardano-client-lib
|
||||
- AES-GCM-256 encryption with Android Keystore-backed key
|
||||
- `setUserAuthenticationRequired(true)` - biometric/PIN for every operation
|
||||
- `setUserAuthenticationValidityDurationSeconds(-1)` - no grace period
|
||||
- `setInvalidatedByBiometricEnrollment(true)` - invalidate on biometric change
|
||||
- Methods: `generateWallet`, `importWallet`, `getMnemonic`, `getBaseAddress`, `getStakeAddress`, `deleteWallet`
|
||||
|
||||
### Testing Notes
|
||||
- Tests use plain JUnit with Truth assertions
|
||||
- Parser handles edge cases: whitespace, case sensitivity, decimal precision, network mismatches
|
||||
- Testnet support via `tADA` unit or `addr_test1` addresses
|
||||
- ✅ **CardanoWalletManager** (interface + implementation)
|
||||
- Key derivation using CIP-1852 via cardano-client-lib's Account class
|
||||
- Path `m/1852'/1815'/0'/0/0` for external receiving address
|
||||
- Path `m/1852'/1815'/0'/2/0` for staking key
|
||||
- Shelley base address generation (payment + staking key hash)
|
||||
- Uses CardanoNetworkConfig for network selection
|
||||
- Exposes: `getAddress(sessionId)`, `getStakeAddress(sessionId)`, `getSpendingKey(sessionId)`
|
||||
|
||||
- ✅ **SeedPhraseManager** (interface + implementation)
|
||||
- 24-word mnemonic generation (256-bit entropy)
|
||||
- Support for 12/15/18/21/24 word counts
|
||||
- BIP-39 validation (checksum + wordlist)
|
||||
- Word suggestions for autocomplete
|
||||
- Normalization (whitespace, case)
|
||||
- ⚠️ UI must apply `FLAG_SECURE` when displaying seed phrases (documented)
|
||||
|
||||
- ✅ **FakeCardanoKeyStorage** for testing
|
||||
- ✅ Unit tests for SeedPhraseManager, CardanoNetworkConfig, CardanoWalletManager
|
||||
|
||||
### Decisions Made (per instructions)
|
||||
- Wallet scope: **PER SESSION** (each Matrix account has its own wallet)
|
||||
- Biometric change: **INVALIDATE** key + require wallet re-import/creation
|
||||
- Network: **TESTNET** (preprod) - single config constant for easy mainnet swap
|
||||
|
||||
### Not Verified (No Android SDK in build environment)
|
||||
- ⚠️ Compilation with `./gradlew :features:wallet:impl:assemble`
|
||||
- ⚠️ Unit tests with `./gradlew :features:wallet:impl:test`
|
||||
- ⚠️ ktlint compliance
|
||||
- ⚠️ Actual Android Keystore behavior (requires device/emulator)
|
||||
- ⚠️ Biometric prompt integration (requires Activity context)
|
||||
|
||||
### Security Notes
|
||||
1. **Mnemonic never stored in plaintext** - Always encrypted with Keystore key
|
||||
2. **Key material cleared after use** - `ByteArray.fill(0)` called where possible
|
||||
3. **Per-session isolation** - Different Matrix accounts cannot access each other's wallets
|
||||
4. **Biometric invalidation** - If user adds/removes fingerprints, wallet key becomes invalid
|
||||
5. **No screenshots** - UI must apply FLAG_SECURE when showing seed phrase
|
||||
|
||||
---
|
||||
*Last updated: 2026-03-27*
|
||||
|
||||
## Task 3: Koios Client ✅ COMPLETE
|
||||
|
||||
### Completed
|
||||
- ✅ **CardanoClient.kt** interface in `api/` module:
|
||||
- `getBalance(address: String): Result<Long>` — balance in lovelace
|
||||
- `getUtxos(address: String): Result<List<Utxo>>` — unspent outputs
|
||||
- `submitTx(signedTxCbor: String): Result<String>` — returns tx hash
|
||||
- `getTxStatus(txHash: String): Result<TxStatus>` — PENDING/CONFIRMED/FAILED
|
||||
|
||||
- ✅ **Data models** in `api/`:
|
||||
- `Utxo.kt` — txHash, outputIndex, amount, address
|
||||
- `TxStatus.kt` — enum PENDING/CONFIRMED/FAILED
|
||||
- `CardanoException.kt` — typed exceptions (NetworkException, RateLimitException, InvalidAddressException, TransactionNotFoundException, SubmissionFailedException, InsufficientFundsException, ApiException)
|
||||
|
||||
- ✅ **KoiosCardanoClient.kt** implementation:
|
||||
- Uses `BackendFactory.getKoiosBackendService()` from cardano-client-lib
|
||||
- Testnet URL: `https://preprod.koios.rest/api/v1` (via CardanoNetworkConfig)
|
||||
- Mainnet URL: `https://api.koios.rest/api/v1` (via CardanoNetworkConfig)
|
||||
- 3 retries with exponential backoff (1s → 2s → 4s, max 10s)
|
||||
- Basic rate limiting (100ms min between requests for Koios 100 req/10s limit)
|
||||
- DI: `@ContributesBinding(SessionScope::class)`
|
||||
- Error parsing: 429 → RateLimitException, 5xx → NetworkException, etc.
|
||||
|
||||
- ✅ **FakeCardanoClient.kt** for testing:
|
||||
- Configurable balances, UTxOs, transaction statuses
|
||||
- Error simulation (network errors, rate limits, submit failures)
|
||||
- Transaction lifecycle simulation (pending → confirmed → failed)
|
||||
- Call counters for test verification
|
||||
- Helper: `setupWallet(address, balance)` creates realistic UTxO set
|
||||
|
||||
- ✅ **KoiosCardanoClientTest.kt** — 15+ unit tests:
|
||||
- getBalance success, unknown address, network error, rate limit
|
||||
- getUtxos success, empty result
|
||||
- submitTx success, failure
|
||||
- getTxStatus pending, confirmed, failed
|
||||
- reset/state management
|
||||
|
||||
- ✅ **CardanoWalletManager updated** to use CardanoClient:
|
||||
- `refreshBalance()` now fetches real balance via Koios
|
||||
- Updates WalletState with lovelace + formatted ADA string
|
||||
|
||||
### Design Notes
|
||||
- **No API key required** — Koios public API is free
|
||||
- **Network config centralized** — Change `CardanoNetworkConfig.NETWORK` to swap testnet/mainnet
|
||||
- **Hex CBOR for submitTx** — Accepts hex-encoded signed transaction bytes
|
||||
- **UTxO pagination** — Limited to first 100 UTxOs (sufficient for typical wallets)
|
||||
|
||||
### Potential Issues
|
||||
- ⚠️ `getTxStatus` returns PENDING for unknown hashes (could be never-submitted or truly pending)
|
||||
- ⚠️ Koios rate limit (100 req/10s) may need adjustment for heavy usage patterns
|
||||
- ⚠️ No getProtocolParameters yet (needed for Task 4 fee calculation)
|
||||
|
||||
---
|
||||
|
||||
## Task 4-8: Pending
|
||||
|
||||
See PHASE1-PLAN.md for full task breakdown.
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
### Issue 1: Biometric Prompt Activity Context
|
||||
The `CardanoKeyStorageImpl` uses `setUserAuthenticationRequired(true)` which will cause `UserNotAuthenticatedException` when accessing the key. The biometric prompt UI must be triggered from an Activity/Fragment context before calling `getMnemonic()`, `getSpendingKey()`, etc.
|
||||
|
||||
**Solution:** Task 6 (Payment Flow UI) must call BiometricPrompt before invoking storage operations.
|
||||
|
||||
### Issue 2: KeyPermanentlyInvalidatedException
|
||||
If user changes biometric enrollment, the Keystore key is invalidated. Current behavior: throws exception, user must delete and recreate wallet.
|
||||
|
||||
**Enhancement (future):** Show user-friendly message explaining why wallet became invalid and offer to re-import.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-03-27 - Task 2 complete*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api.storage
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Result of wallet creation containing the generated seed phrase and derived addresses.
|
||||
*/
|
||||
data class WalletCreationResult(
|
||||
val mnemonic: List<String>,
|
||||
val baseAddress: String,
|
||||
val stakeAddress: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Interface for secure storage and retrieval of Cardano wallet keys.
|
||||
*
|
||||
* Wallets are scoped PER SESSION (per Matrix account). Each [SessionId] can have
|
||||
* exactly one wallet associated with it.
|
||||
*
|
||||
* ## Security Properties
|
||||
* - Keys are stored encrypted using Android Keystore
|
||||
* - Biometric/PIN authentication required for every signing operation
|
||||
* - Keys are INVALIDATED if biometric enrollment changes
|
||||
* - Mnemonic is stored encrypted, never in plaintext
|
||||
*
|
||||
* ## Implementation Notes
|
||||
* - Use `setInvalidatedByBiometricEnrollment(true)` for Keystore keys
|
||||
* - Use `setUserAuthenticationRequired(true)` with duration -1 (every time)
|
||||
* - Key alias format: "cardano_wallet_{sessionId}"
|
||||
*/
|
||||
interface CardanoKeyStorage {
|
||||
/**
|
||||
* Checks if a wallet exists for the given session.
|
||||
*/
|
||||
suspend fun hasWallet(sessionId: SessionId): Boolean
|
||||
|
||||
/**
|
||||
* Generates a new wallet with a 24-word BIP-39 mnemonic.
|
||||
*
|
||||
* @param sessionId The Matrix session to create the wallet for
|
||||
* @return [WalletCreationResult] containing the mnemonic and derived addresses
|
||||
* @throws IllegalStateException if a wallet already exists for this session
|
||||
*/
|
||||
suspend fun generateWallet(sessionId: SessionId): Result<WalletCreationResult>
|
||||
|
||||
/**
|
||||
* Imports an existing wallet from a mnemonic phrase.
|
||||
*
|
||||
* @param sessionId The Matrix session to import the wallet for
|
||||
* @param mnemonic The BIP-39 mnemonic phrase (12, 15, 18, 21, or 24 words)
|
||||
* @return The derived base address on success
|
||||
* @throws IllegalArgumentException if the mnemonic is invalid
|
||||
* @throws IllegalStateException if a wallet already exists for this session
|
||||
*/
|
||||
suspend fun importWallet(sessionId: SessionId, mnemonic: List<String>): Result<String>
|
||||
|
||||
/**
|
||||
* Retrieves the encrypted mnemonic for backup display.
|
||||
*
|
||||
* ⚠️ WARNING: This returns sensitive data. UI must use FLAG_SECURE.
|
||||
*
|
||||
* @param sessionId The Matrix session
|
||||
* @return The mnemonic word list
|
||||
*/
|
||||
suspend fun getMnemonic(sessionId: SessionId): Result<List<String>>
|
||||
|
||||
/**
|
||||
* Gets the base address (payment + staking key hash) for the wallet.
|
||||
*
|
||||
* @param sessionId The Matrix session
|
||||
* @param addressIndex The address index (default 0)
|
||||
*/
|
||||
suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int = 0): Result<String>
|
||||
|
||||
/**
|
||||
* Gets the staking/reward address for the wallet.
|
||||
*
|
||||
* @param sessionId The Matrix session
|
||||
*/
|
||||
suspend fun getStakeAddress(sessionId: SessionId): Result<String>
|
||||
|
||||
/**
|
||||
* Permanently deletes the wallet and all associated key material.
|
||||
*
|
||||
* @param sessionId The Matrix session
|
||||
*/
|
||||
suspend fun deleteWallet(sessionId: SessionId): Result<Unit>
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.seedphrase
|
||||
|
||||
import com.bloxbean.cardano.client.crypto.bip39.MnemonicCode
|
||||
import com.bloxbean.cardano.client.crypto.bip39.Words
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import timber.log.Timber
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Result of seed phrase validation.
|
||||
*/
|
||||
sealed class SeedPhraseValidationResult {
|
||||
data class Valid(val wordCount: Int) : SeedPhraseValidationResult()
|
||||
data class Invalid(val error: String) : SeedPhraseValidationResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages BIP-39 seed phrase generation, validation, and display.
|
||||
*
|
||||
* ## Security Requirements for UI
|
||||
* When displaying seed phrases in the UI:
|
||||
* - Apply `FLAG_SECURE` to prevent screenshots: `window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)`
|
||||
* - Clear the word list from memory when the screen is dismissed
|
||||
* - Never log seed phrases
|
||||
*
|
||||
* ## Supported Word Counts
|
||||
* - 12 words (128-bit entropy) - Standard for many wallets
|
||||
* - 15 words (160-bit entropy)
|
||||
* - 18 words (192-bit entropy)
|
||||
* - 21 words (224-bit entropy)
|
||||
* - 24 words (256-bit entropy) - Maximum security, used by default
|
||||
*/
|
||||
interface SeedPhraseManager {
|
||||
/**
|
||||
* Generates a new 24-word BIP-39 mnemonic.
|
||||
*
|
||||
* @return A list of 24 words from the BIP-39 English wordlist
|
||||
*/
|
||||
fun generateSeedPhrase(): List<String>
|
||||
|
||||
/**
|
||||
* Generates a seed phrase with a specific word count.
|
||||
*
|
||||
* @param wordCount Must be 12, 15, 18, 21, or 24
|
||||
* @return A list of words from the BIP-39 English wordlist
|
||||
* @throws IllegalArgumentException if wordCount is invalid
|
||||
*/
|
||||
fun generateSeedPhrase(wordCount: Int): List<String>
|
||||
|
||||
/**
|
||||
* Validates a seed phrase.
|
||||
*
|
||||
* Checks:
|
||||
* 1. Word count (12, 15, 18, 21, or 24)
|
||||
* 2. All words are in the BIP-39 English wordlist
|
||||
* 3. Checksum is valid
|
||||
*
|
||||
* @param words The seed phrase as a list of words
|
||||
* @return Validation result
|
||||
*/
|
||||
fun validate(words: List<String>): SeedPhraseValidationResult
|
||||
|
||||
/**
|
||||
* Validates a seed phrase from a space-separated string.
|
||||
*
|
||||
* @param seedPhrase The seed phrase as a space-separated string
|
||||
* @return Validation result
|
||||
*/
|
||||
fun validate(seedPhrase: String): SeedPhraseValidationResult
|
||||
|
||||
/**
|
||||
* Normalizes a seed phrase input.
|
||||
* - Trims whitespace
|
||||
* - Lowercases all words
|
||||
* - Removes extra spaces
|
||||
*
|
||||
* @param input Raw user input
|
||||
* @return Normalized word list
|
||||
*/
|
||||
fun normalize(input: String): List<String>
|
||||
|
||||
/**
|
||||
* Gets the BIP-39 English wordlist for autocomplete.
|
||||
*/
|
||||
fun getWordlist(): List<String>
|
||||
|
||||
/**
|
||||
* Suggests words from the wordlist that start with the given prefix.
|
||||
*
|
||||
* @param prefix The prefix to match
|
||||
* @param limit Maximum number of suggestions
|
||||
* @return List of matching words
|
||||
*/
|
||||
fun suggestWords(prefix: String, limit: Int = 5): List<String>
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation using cardano-client-lib.
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultSeedPhraseManager @Inject constructor() : SeedPhraseManager {
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_WORD_COUNT = 24
|
||||
private val VALID_WORD_COUNTS = setOf(12, 15, 18, 21, 24)
|
||||
private val ENTROPY_BITS_MAP = mapOf(
|
||||
12 to 128,
|
||||
15 to 160,
|
||||
18 to 192,
|
||||
21 to 224,
|
||||
24 to 256,
|
||||
)
|
||||
}
|
||||
|
||||
private val mnemonicCode = MnemonicCode()
|
||||
|
||||
private val wordList: List<String> by lazy {
|
||||
Words.ENGLISH.words.toList()
|
||||
}
|
||||
|
||||
override fun generateSeedPhrase(): List<String> {
|
||||
return generateSeedPhrase(DEFAULT_WORD_COUNT)
|
||||
}
|
||||
|
||||
override fun generateSeedPhrase(wordCount: Int): List<String> {
|
||||
require(wordCount in VALID_WORD_COUNTS) {
|
||||
"Invalid word count: $wordCount. Must be one of: $VALID_WORD_COUNTS"
|
||||
}
|
||||
|
||||
val entropyBits = ENTROPY_BITS_MAP[wordCount]
|
||||
?: throw IllegalStateException("Missing entropy mapping for word count: $wordCount")
|
||||
|
||||
val entropyBytes = entropyBits / 8
|
||||
val entropy = ByteArray(entropyBytes)
|
||||
SecureRandom().nextBytes(entropy)
|
||||
|
||||
val words = try {
|
||||
mnemonicCode.toMnemonic(entropy)
|
||||
} finally {
|
||||
// Clear entropy immediately
|
||||
entropy.fill(0)
|
||||
}
|
||||
|
||||
Timber.d("Generated $wordCount-word seed phrase")
|
||||
return words
|
||||
}
|
||||
|
||||
override fun validate(words: List<String>): SeedPhraseValidationResult {
|
||||
// Check word count
|
||||
if (words.size !in VALID_WORD_COUNTS) {
|
||||
return SeedPhraseValidationResult.Invalid(
|
||||
"Invalid word count: ${words.size}. Expected one of: $VALID_WORD_COUNTS"
|
||||
)
|
||||
}
|
||||
|
||||
// Check all words are in wordlist
|
||||
val invalidWords = words.filter { it.lowercase() !in wordList }
|
||||
if (invalidWords.isNotEmpty()) {
|
||||
return SeedPhraseValidationResult.Invalid(
|
||||
"Invalid words: ${invalidWords.joinToString(", ")}"
|
||||
)
|
||||
}
|
||||
|
||||
// Validate checksum
|
||||
return try {
|
||||
mnemonicCode.check(words.map { it.lowercase() })
|
||||
SeedPhraseValidationResult.Valid(words.size)
|
||||
} catch (e: Exception) {
|
||||
SeedPhraseValidationResult.Invalid("Invalid checksum: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun validate(seedPhrase: String): SeedPhraseValidationResult {
|
||||
val words = normalize(seedPhrase)
|
||||
return validate(words)
|
||||
}
|
||||
|
||||
override fun normalize(input: String): List<String> {
|
||||
return input
|
||||
.trim()
|
||||
.lowercase()
|
||||
.split(Regex("\\s+"))
|
||||
.filter { it.isNotBlank() }
|
||||
}
|
||||
|
||||
override fun getWordlist(): List<String> {
|
||||
return wordList
|
||||
}
|
||||
|
||||
override fun suggestWords(prefix: String, limit: Int): List<String> {
|
||||
if (prefix.isBlank()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val normalizedPrefix = prefix.trim().lowercase()
|
||||
return wordList
|
||||
.filter { it.startsWith(normalizedPrefix) }
|
||||
.take(limit)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import com.bloxbean.cardano.client.account.Account
|
||||
import com.bloxbean.cardano.client.crypto.bip39.MnemonicCode
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
|
||||
import io.element.android.features.wallet.api.storage.WalletCreationResult
|
||||
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.security.KeyStore
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Implementation of [CardanoKeyStorage] using Android Keystore for secure key management.
|
||||
*
|
||||
* ## Security Design
|
||||
* - Mnemonic is encrypted with AES-GCM using an Android Keystore-backed key
|
||||
* - Keystore key requires biometric/PIN authentication for every operation
|
||||
* - Keys are invalidated if biometric enrollment changes
|
||||
* - Per-session isolation via unique key aliases
|
||||
*
|
||||
* ## Storage Layout
|
||||
* - SharedPreferences: `cardano_wallet_storage`
|
||||
* - `encrypted_mnemonic_{sessionId}`: Base64-encoded encrypted mnemonic
|
||||
* - `iv_{sessionId}`: Base64-encoded initialization vector
|
||||
* - Android Keystore:
|
||||
* - Alias: `cardano_wallet_{sessionId}`
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
class CardanoKeyStorageImpl @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : CardanoKeyStorage {
|
||||
|
||||
companion object {
|
||||
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
private const val PREFS_NAME = "cardano_wallet_storage"
|
||||
private const val KEY_ENCRYPTED_MNEMONIC_PREFIX = "encrypted_mnemonic_"
|
||||
private const val KEY_IV_PREFIX = "iv_"
|
||||
private const val KEYSTORE_ALIAS_PREFIX = "cardano_wallet_"
|
||||
private const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val GCM_TAG_LENGTH = 128
|
||||
private const val GCM_IV_LENGTH = 12
|
||||
private const val AES_KEY_SIZE = 256
|
||||
private const val MNEMONIC_WORD_COUNT = 24
|
||||
private const val MNEMONIC_ENTROPY_BYTES = 32 // 256 bits for 24 words
|
||||
}
|
||||
|
||||
private val keyStore: KeyStore by lazy {
|
||||
KeyStore.getInstance(ANDROID_KEYSTORE).apply {
|
||||
load(null)
|
||||
}
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences by lazy {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
override suspend fun hasWallet(sessionId: SessionId): Boolean = withContext(Dispatchers.IO) {
|
||||
val key = KEY_ENCRYPTED_MNEMONIC_PREFIX + sanitizeSessionId(sessionId)
|
||||
prefs.contains(key)
|
||||
}
|
||||
|
||||
override suspend fun generateWallet(sessionId: SessionId): Result<WalletCreationResult> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
if (hasWallet(sessionId)) {
|
||||
throw IllegalStateException("Wallet already exists for session: ${sessionId.value}")
|
||||
}
|
||||
|
||||
// Generate 256-bit entropy for 24-word mnemonic
|
||||
val entropy = ByteArray(MNEMONIC_ENTROPY_BYTES)
|
||||
SecureRandom().nextBytes(entropy)
|
||||
|
||||
// Generate mnemonic using cardano-client-lib
|
||||
val mnemonicCode = MnemonicCode()
|
||||
val wordList = mnemonicCode.toMnemonic(entropy)
|
||||
|
||||
// Clear entropy after use
|
||||
entropy.fill(0)
|
||||
|
||||
// Store encrypted mnemonic
|
||||
storeMnemonic(sessionId, wordList)
|
||||
|
||||
// Derive addresses
|
||||
val mnemonicString = wordList.joinToString(" ")
|
||||
val account = Account(CardanoNetworkConfig.getNetworks(), mnemonicString)
|
||||
|
||||
val result = WalletCreationResult(
|
||||
mnemonic = wordList,
|
||||
baseAddress = account.baseAddress(),
|
||||
stakeAddress = account.stakeAddress(),
|
||||
)
|
||||
|
||||
Timber.i("Generated new Cardano wallet for session: ${sessionId.value}")
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun importWallet(sessionId: SessionId, mnemonic: List<String>): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
if (hasWallet(sessionId)) {
|
||||
throw IllegalStateException("Wallet already exists for session: ${sessionId.value}")
|
||||
}
|
||||
|
||||
// Validate mnemonic length
|
||||
require(mnemonic.size in listOf(12, 15, 18, 21, 24)) {
|
||||
"Invalid mnemonic length: ${mnemonic.size} words. Expected 12, 15, 18, 21, or 24."
|
||||
}
|
||||
|
||||
// Validate mnemonic checksum
|
||||
val mnemonicCode = MnemonicCode()
|
||||
try {
|
||||
mnemonicCode.check(mnemonic)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Invalid mnemonic: ${e.message}")
|
||||
}
|
||||
|
||||
// Verify it produces valid Cardano addresses
|
||||
val mnemonicString = mnemonic.joinToString(" ")
|
||||
val account = try {
|
||||
Account(CardanoNetworkConfig.getNetworks(), mnemonicString)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Failed to derive Cardano keys: ${e.message}")
|
||||
}
|
||||
|
||||
// Store encrypted mnemonic
|
||||
storeMnemonic(sessionId, mnemonic)
|
||||
|
||||
Timber.i("Imported Cardano wallet for session: ${sessionId.value}")
|
||||
account.baseAddress()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMnemonic(sessionId: SessionId): Result<List<String>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
retrieveMnemonic(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val mnemonic = retrieveMnemonic(sessionId)
|
||||
val mnemonicString = mnemonic.joinToString(" ")
|
||||
val account = Account(CardanoNetworkConfig.getNetworks(), mnemonicString, addressIndex)
|
||||
account.baseAddress()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getStakeAddress(sessionId: SessionId): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val mnemonic = retrieveMnemonic(sessionId)
|
||||
val mnemonicString = mnemonic.joinToString(" ")
|
||||
val account = Account(CardanoNetworkConfig.getNetworks(), mnemonicString)
|
||||
account.stakeAddress()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteWallet(sessionId: SessionId): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val sanitizedId = sanitizeSessionId(sessionId)
|
||||
|
||||
// Delete from SharedPreferences
|
||||
prefs.edit()
|
||||
.remove(KEY_ENCRYPTED_MNEMONIC_PREFIX + sanitizedId)
|
||||
.remove(KEY_IV_PREFIX + sanitizedId)
|
||||
.apply()
|
||||
|
||||
// Delete Keystore key
|
||||
val alias = KEYSTORE_ALIAS_PREFIX + sanitizedId
|
||||
if (keyStore.containsAlias(alias)) {
|
||||
keyStore.deleteEntry(alias)
|
||||
}
|
||||
|
||||
Timber.i("Deleted Cardano wallet for session: ${sessionId.value}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or retrieves an AES key from Android Keystore with strict security requirements.
|
||||
*/
|
||||
private fun getOrCreateSecretKey(sessionId: SessionId): SecretKey {
|
||||
val alias = KEYSTORE_ALIAS_PREFIX + sanitizeSessionId(sessionId)
|
||||
|
||||
// Check if key exists
|
||||
val existingKey = keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry
|
||||
if (existingKey != null) {
|
||||
return existingKey.secretKey
|
||||
}
|
||||
|
||||
// Generate new key with strict security parameters
|
||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
||||
val keySpec = KeyGenParameterSpec.Builder(
|
||||
alias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(AES_KEY_SIZE)
|
||||
// Require user authentication for every crypto operation
|
||||
.setUserAuthenticationRequired(true)
|
||||
// Auth required every time (no grace period)
|
||||
.setUserAuthenticationValidityDurationSeconds(-1)
|
||||
// CRITICAL: Invalidate key if biometric enrollment changes
|
||||
.setInvalidatedByBiometricEnrollment(true)
|
||||
.build()
|
||||
|
||||
keyGenerator.init(keySpec)
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts and stores the mnemonic.
|
||||
*/
|
||||
private fun storeMnemonic(sessionId: SessionId, mnemonic: List<String>) {
|
||||
val sanitizedId = sanitizeSessionId(sessionId)
|
||||
val secretKey = getOrCreateSecretKey(sessionId)
|
||||
|
||||
// Encrypt mnemonic
|
||||
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||
|
||||
val mnemonicBytes = mnemonic.joinToString(" ").toByteArray(Charsets.UTF_8)
|
||||
val encryptedBytes = cipher.doFinal(mnemonicBytes)
|
||||
|
||||
// Clear plaintext immediately
|
||||
mnemonicBytes.fill(0)
|
||||
|
||||
// Store encrypted data and IV
|
||||
prefs.edit()
|
||||
.putString(KEY_ENCRYPTED_MNEMONIC_PREFIX + sanitizedId, Base64.encodeToString(encryptedBytes, Base64.NO_WRAP))
|
||||
.putString(KEY_IV_PREFIX + sanitizedId, Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and decrypts the mnemonic.
|
||||
*
|
||||
* @throws KeyPermanentlyInvalidatedException if biometrics changed
|
||||
* @throws IllegalStateException if no wallet exists
|
||||
*/
|
||||
private fun retrieveMnemonic(sessionId: SessionId): List<String> {
|
||||
val sanitizedId = sanitizeSessionId(sessionId)
|
||||
|
||||
val encryptedB64 = prefs.getString(KEY_ENCRYPTED_MNEMONIC_PREFIX + sanitizedId, null)
|
||||
?: throw IllegalStateException("No wallet found for session: ${sessionId.value}")
|
||||
|
||||
val ivB64 = prefs.getString(KEY_IV_PREFIX + sanitizedId, null)
|
||||
?: throw IllegalStateException("Missing IV for session: ${sessionId.value}")
|
||||
|
||||
val encryptedBytes = Base64.decode(encryptedB64, Base64.NO_WRAP)
|
||||
val iv = Base64.decode(ivB64, Base64.NO_WRAP)
|
||||
|
||||
val secretKey = try {
|
||||
getOrCreateSecretKey(sessionId)
|
||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
||||
// Biometric enrollment changed - wallet is invalidated
|
||||
Timber.e(e, "Key invalidated due to biometric change for session: ${sessionId.value}")
|
||||
throw e
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
|
||||
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
|
||||
|
||||
val decryptedBytes = cipher.doFinal(encryptedBytes)
|
||||
val mnemonicString = String(decryptedBytes, Charsets.UTF_8)
|
||||
|
||||
// Clear decrypted bytes
|
||||
decryptedBytes.fill(0)
|
||||
|
||||
return mnemonicString.split(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes session ID for use in file/key names.
|
||||
* Removes special characters that could cause issues.
|
||||
*/
|
||||
private fun sanitizeSessionId(sessionId: SessionId): String {
|
||||
return sessionId.value
|
||||
.replace("@", "")
|
||||
.replace(":", "_")
|
||||
.replace(".", "_")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.cardano
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class CardanoNetworkConfigTest {
|
||||
|
||||
@Test
|
||||
fun `network is configured as testnet`() {
|
||||
// Verify we're on testnet by default (as per Phase 1 requirements)
|
||||
assertThat(CardanoNetworkConfig.NETWORK).isEqualTo(CardanoNetwork.TESTNET)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testnet has network ID 0`() {
|
||||
// Testnet network ID should be 0
|
||||
assertThat(CardanoNetworkConfig.NETWORK_ID).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testnet uses preprod Koios URL`() {
|
||||
assertThat(CardanoNetworkConfig.KOIOS_BASE_URL).isEqualTo("https://preprod.koios.rest/api/v1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testnet uses preprod CardanoScan`() {
|
||||
assertThat(CardanoNetworkConfig.EXPLORER_BASE_URL).isEqualTo("https://preprod.cardanoscan.io")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testnet address prefix is addr_test1`() {
|
||||
assertThat(CardanoNetworkConfig.ADDRESS_PREFIX).isEqualTo("addr_test1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `network name is Preprod Testnet`() {
|
||||
assertThat(CardanoNetworkConfig.NETWORK_NAME).isEqualTo("Preprod Testnet")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getNetworks returns preprod network`() {
|
||||
val networks = CardanoNetworkConfig.getNetworks()
|
||||
|
||||
// Preprod network has protocol magic 1
|
||||
assertThat(networks.protocolMagic).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.cardano
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.wallet.test.storage.FakeCardanoKeyStorage
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class CardanoWalletManagerTest {
|
||||
|
||||
private lateinit var fakeKeyStorage: FakeCardanoKeyStorage
|
||||
private lateinit var walletManager: DefaultCardanoWalletManager
|
||||
private val testSessionId = UserId("@test:matrix.org")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fakeKeyStorage = FakeCardanoKeyStorage()
|
||||
walletManager = DefaultCardanoWalletManager(fakeKeyStorage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state has no wallet`() = runTest {
|
||||
val state = walletManager.walletState.value
|
||||
|
||||
assertThat(state.hasWallet).isFalse()
|
||||
assertThat(state.address).isNull()
|
||||
assertThat(state.isLoading).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initialize sets hasWallet false when no wallet exists`() = runTest {
|
||||
walletManager.initialize(testSessionId)
|
||||
|
||||
val state = walletManager.walletState.value
|
||||
assertThat(state.hasWallet).isFalse()
|
||||
assertThat(state.isLoading).isFalse()
|
||||
assertThat(state.error).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initialize loads wallet when it exists`() = runTest {
|
||||
// Create a wallet first
|
||||
fakeKeyStorage.generateWallet(testSessionId)
|
||||
|
||||
walletManager.initialize(testSessionId)
|
||||
|
||||
val state = walletManager.walletState.value
|
||||
assertThat(state.hasWallet).isTrue()
|
||||
assertThat(state.address).isEqualTo(fakeKeyStorage.testBaseAddress)
|
||||
assertThat(state.isLoading).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initialize sets error on failure`() = runTest {
|
||||
fakeKeyStorage.getAddressError = RuntimeException("Storage error")
|
||||
fakeKeyStorage.generateWallet(testSessionId)
|
||||
|
||||
walletManager.initialize(testSessionId)
|
||||
|
||||
val state = walletManager.walletState.value
|
||||
assertThat(state.error).isNotNull()
|
||||
assertThat(state.isLoading).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAddress returns address from storage`() = runTest {
|
||||
fakeKeyStorage.generateWallet(testSessionId)
|
||||
|
||||
val result = walletManager.getAddress(testSessionId)
|
||||
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
assertThat(result.getOrNull()).isEqualTo(fakeKeyStorage.testBaseAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getStakeAddress returns stake address from storage`() = runTest {
|
||||
fakeKeyStorage.generateWallet(testSessionId)
|
||||
|
||||
val result = walletManager.getStakeAddress(testSessionId)
|
||||
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
assertThat(result.getOrNull()).isEqualTo(fakeKeyStorage.testStakeAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAddress returns error when no wallet exists`() = runTest {
|
||||
val result = walletManager.getAddress(testSessionId)
|
||||
|
||||
assertThat(result.isFailure).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearState resets to initial`() = runTest {
|
||||
fakeKeyStorage.generateWallet(testSessionId)
|
||||
walletManager.initialize(testSessionId)
|
||||
|
||||
walletManager.clearState()
|
||||
|
||||
val state = walletManager.walletState.value
|
||||
assertThat(state.hasWallet).isFalse()
|
||||
assertThat(state.isLoading).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different sessions have isolated wallets`() = runTest {
|
||||
val session1 = UserId("@user1:matrix.org")
|
||||
val session2 = UserId("@user2:matrix.org")
|
||||
|
||||
fakeKeyStorage.generateWallet(session1)
|
||||
|
||||
assertThat(fakeKeyStorage.hasWallet(session1)).isTrue()
|
||||
assertThat(fakeKeyStorage.hasWallet(session2)).isFalse()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.seedphrase
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class SeedPhraseManagerTest {
|
||||
|
||||
private lateinit var seedPhraseManager: SeedPhraseManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
seedPhraseManager = DefaultSeedPhraseManager()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generateSeedPhrase creates 24 words by default`() {
|
||||
val words = seedPhraseManager.generateSeedPhrase()
|
||||
|
||||
assertThat(words).hasSize(24)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generateSeedPhrase creates valid BIP-39 mnemonic`() {
|
||||
val words = seedPhraseManager.generateSeedPhrase()
|
||||
|
||||
val result = seedPhraseManager.validate(words)
|
||||
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Valid::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generateSeedPhrase with 12 words creates valid mnemonic`() {
|
||||
val words = seedPhraseManager.generateSeedPhrase(12)
|
||||
|
||||
assertThat(words).hasSize(12)
|
||||
val result = seedPhraseManager.validate(words)
|
||||
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Valid::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generateSeedPhrase with invalid word count throws`() {
|
||||
try {
|
||||
seedPhraseManager.generateSeedPhrase(13)
|
||||
assertThat(false).isTrue() // Should not reach here
|
||||
} catch (e: IllegalArgumentException) {
|
||||
assertThat(e.message).contains("Invalid word count")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate returns Valid for correct mnemonic`() {
|
||||
// Known valid test mnemonic
|
||||
val validMnemonic = listOf(
|
||||
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
|
||||
"abandon", "abandon", "abandon", "abandon", "abandon", "about"
|
||||
)
|
||||
|
||||
val result = seedPhraseManager.validate(validMnemonic)
|
||||
|
||||
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Valid::class.java)
|
||||
assertThat((result as SeedPhraseValidationResult.Valid).wordCount).isEqualTo(12)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate returns Invalid for wrong word count`() {
|
||||
val invalidMnemonic = listOf("abandon", "abandon", "abandon")
|
||||
|
||||
val result = seedPhraseManager.validate(invalidMnemonic)
|
||||
|
||||
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Invalid::class.java)
|
||||
assertThat((result as SeedPhraseValidationResult.Invalid).error).contains("word count")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate returns Invalid for invalid words`() {
|
||||
val invalidMnemonic = listOf(
|
||||
"notaword", "abandon", "abandon", "abandon", "abandon", "abandon",
|
||||
"abandon", "abandon", "abandon", "abandon", "abandon", "about"
|
||||
)
|
||||
|
||||
val result = seedPhraseManager.validate(invalidMnemonic)
|
||||
|
||||
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Invalid::class.java)
|
||||
assertThat((result as SeedPhraseValidationResult.Invalid).error).contains("notaword")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate returns Invalid for bad checksum`() {
|
||||
// Valid words but invalid checksum
|
||||
val invalidMnemonic = listOf(
|
||||
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
|
||||
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon"
|
||||
)
|
||||
|
||||
val result = seedPhraseManager.validate(invalidMnemonic)
|
||||
|
||||
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Invalid::class.java)
|
||||
assertThat((result as SeedPhraseValidationResult.Invalid).error).contains("checksum")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate string input works`() {
|
||||
val validMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
|
||||
val result = seedPhraseManager.validate(validMnemonic)
|
||||
|
||||
assertThat(result).isInstanceOf(SeedPhraseValidationResult.Valid::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalize handles extra whitespace`() {
|
||||
val input = " abandon abandon abandon "
|
||||
|
||||
val result = seedPhraseManager.normalize(input)
|
||||
|
||||
assertThat(result).containsExactly("abandon", "abandon", "abandon")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalize lowercases words`() {
|
||||
val input = "ABANDON Abandon aBaNdOn"
|
||||
|
||||
val result = seedPhraseManager.normalize(input)
|
||||
|
||||
assertThat(result).containsExactly("abandon", "abandon", "abandon")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `suggestWords returns matching words`() {
|
||||
val suggestions = seedPhraseManager.suggestWords("aban")
|
||||
|
||||
assertThat(suggestions).contains("abandon")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `suggestWords respects limit`() {
|
||||
val suggestions = seedPhraseManager.suggestWords("a", limit = 3)
|
||||
|
||||
assertThat(suggestions).hasSize(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `suggestWords returns empty for blank prefix`() {
|
||||
val suggestions = seedPhraseManager.suggestWords("")
|
||||
|
||||
assertThat(suggestions).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getWordlist returns non-empty list`() {
|
||||
val wordlist = seedPhraseManager.getWordlist()
|
||||
|
||||
assertThat(wordlist).isNotEmpty()
|
||||
assertThat(wordlist).hasSize(2048) // BIP-39 standard
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generated mnemonics are unique`() {
|
||||
val mnemonic1 = seedPhraseManager.generateSeedPhrase()
|
||||
val mnemonic2 = seedPhraseManager.generateSeedPhrase()
|
||||
|
||||
assertThat(mnemonic1).isNotEqualTo(mnemonic2)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.test.storage
|
||||
|
||||
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
|
||||
import io.element.android.features.wallet.api.storage.WalletCreationResult
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Fake implementation of [CardanoKeyStorage] for testing.
|
||||
*
|
||||
* Stores wallets in memory without encryption. NOT for production use.
|
||||
*/
|
||||
class FakeCardanoKeyStorage : CardanoKeyStorage {
|
||||
|
||||
private val wallets = mutableMapOf<String, FakeWallet>()
|
||||
|
||||
var generateWalletError: Throwable? = null
|
||||
var importWalletError: Throwable? = null
|
||||
var getMnemonicError: Throwable? = null
|
||||
var getAddressError: Throwable? = null
|
||||
|
||||
/**
|
||||
* Test data for generated wallets.
|
||||
*/
|
||||
var testMnemonic: List<String> = listOf(
|
||||
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
|
||||
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
|
||||
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
|
||||
"abandon", "abandon", "abandon", "abandon", "abandon", "art"
|
||||
)
|
||||
var testBaseAddress: String = "addr_test1qp2fg770ddmqxxduasjsas8rgimrhknmqjn43mj74g7ta2tjt0n5nh4t5xqf6lp5mwfpksj9csjg9s4kgfhvwj7m7dcq9qf7zj"
|
||||
var testStakeAddress: String = "stake_test1upehh7l0vv6ep8vr4n30pjdv6t2vpexs2h7xtpk8erzk06s25g8y3"
|
||||
|
||||
override suspend fun hasWallet(sessionId: SessionId): Boolean {
|
||||
return wallets.containsKey(sessionId.value)
|
||||
}
|
||||
|
||||
override suspend fun generateWallet(sessionId: SessionId): Result<WalletCreationResult> {
|
||||
generateWalletError?.let { return Result.failure(it) }
|
||||
|
||||
if (wallets.containsKey(sessionId.value)) {
|
||||
return Result.failure(IllegalStateException("Wallet already exists for session"))
|
||||
}
|
||||
|
||||
val wallet = FakeWallet(
|
||||
mnemonic = testMnemonic,
|
||||
baseAddress = testBaseAddress,
|
||||
stakeAddress = testStakeAddress,
|
||||
)
|
||||
wallets[sessionId.value] = wallet
|
||||
|
||||
return Result.success(
|
||||
WalletCreationResult(
|
||||
mnemonic = testMnemonic,
|
||||
baseAddress = testBaseAddress,
|
||||
stakeAddress = testStakeAddress,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun importWallet(sessionId: SessionId, mnemonic: List<String>): Result<String> {
|
||||
importWalletError?.let { return Result.failure(it) }
|
||||
|
||||
if (wallets.containsKey(sessionId.value)) {
|
||||
return Result.failure(IllegalStateException("Wallet already exists for session"))
|
||||
}
|
||||
|
||||
val wallet = FakeWallet(
|
||||
mnemonic = mnemonic,
|
||||
baseAddress = testBaseAddress,
|
||||
stakeAddress = testStakeAddress,
|
||||
)
|
||||
wallets[sessionId.value] = wallet
|
||||
|
||||
return Result.success(testBaseAddress)
|
||||
}
|
||||
|
||||
override suspend fun getMnemonic(sessionId: SessionId): Result<List<String>> {
|
||||
getMnemonicError?.let { return Result.failure(it) }
|
||||
|
||||
val wallet = wallets[sessionId.value]
|
||||
?: return Result.failure(IllegalStateException("No wallet found for session"))
|
||||
|
||||
return Result.success(wallet.mnemonic)
|
||||
}
|
||||
|
||||
override suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int): Result<String> {
|
||||
getAddressError?.let { return Result.failure(it) }
|
||||
|
||||
val wallet = wallets[sessionId.value]
|
||||
?: return Result.failure(IllegalStateException("No wallet found for session"))
|
||||
|
||||
// For testing, just append the index to the address if non-zero
|
||||
val address = if (addressIndex == 0) {
|
||||
wallet.baseAddress
|
||||
} else {
|
||||
"${wallet.baseAddress}_$addressIndex"
|
||||
}
|
||||
|
||||
return Result.success(address)
|
||||
}
|
||||
|
||||
override suspend fun getStakeAddress(sessionId: SessionId): Result<String> {
|
||||
getAddressError?.let { return Result.failure(it) }
|
||||
|
||||
val wallet = wallets[sessionId.value]
|
||||
?: return Result.failure(IllegalStateException("No wallet found for session"))
|
||||
|
||||
return Result.success(wallet.stakeAddress)
|
||||
}
|
||||
|
||||
override suspend fun deleteWallet(sessionId: SessionId): Result<Unit> {
|
||||
wallets.remove(sessionId.value)
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all stored wallets. Use in test teardown.
|
||||
*/
|
||||
fun clear() {
|
||||
wallets.clear()
|
||||
generateWalletError = null
|
||||
importWalletError = null
|
||||
getMnemonicError = null
|
||||
getAddressError = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of stored wallets.
|
||||
*/
|
||||
fun walletCount(): Int = wallets.size
|
||||
|
||||
private data class FakeWallet(
|
||||
val mnemonic: List<String>,
|
||||
val baseAddress: String,
|
||||
val stakeAddress: String,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue