|
2 | 2 |
|
3 | 3 | Kotlin library for hardware-backed Nostr key management via Android StrongBox / TEE. |
4 | 4 |
|
5 | | -## Package: `nostr-secure-enclave-android` (Maven) |
| 5 | +**Package:** `nostr-secure-enclave-android` (Maven) |
6 | 6 |
|
7 | 7 | ## How It Works |
8 | 8 |
|
9 | | -1. P-256 key generated in StrongBox / TEE via `android.security.keystore` |
10 | | -2. AES-256-GCM key derived from KeyStore P-256 key |
11 | | -3. secp256k1 keypair generated (via secp256k1-kmp or libsecp256k1 JNI) |
12 | | -4. secp256k1 private key encrypted with KeyStore-derived AES key |
13 | | -5. Encrypted blob stored in EncryptedSharedPreferences |
14 | | -6. Biometric-gated unlock via BiometricPrompt |
| 9 | +1. P-256 key generated in StrongBox (API 28+, fallback to TEE) via `android.security.keystore` |
| 10 | +2. Ephemeral P-256 key generated in software for ECDH |
| 11 | +3. ECDH shared secret → HKDF-SHA256 (salt: "nse-v1") → AES-256-GCM symmetric key |
| 12 | +4. secp256k1 keypair generated (via `secp256k1-kmp`) |
| 13 | +5. secp256k1 private key encrypted with AES-GCM key |
| 14 | +6. Encrypted blob + ephemeral public key stored in SharedPreferences |
| 15 | +7. Plaintext secp256k1 key zeroed via `ByteArray.fill(0)` |
15 | 16 |
|
16 | | -## First Consumer |
| 17 | +## Quick Start |
17 | 18 |
|
18 | | -NostrKeep Signer (`nostrkey.app.android.src`) |
| 19 | +```kotlin |
| 20 | +import dev.nse.NSE |
| 21 | +import dev.nse.NSEConfig |
| 22 | +import dev.nse.NostrEvent |
| 23 | + |
| 24 | +// Create NSE instance |
| 25 | +val config = NSEConfig(context = applicationContext) |
| 26 | +val nse = NSE(config) |
| 27 | + |
| 28 | +// Generate a new keypair |
| 29 | +val keyInfo = nse.generate() |
| 30 | +println(keyInfo.npub) // npub1... |
| 31 | + |
| 32 | +// Sign a Nostr event |
| 33 | +val event = NostrEvent( |
| 34 | + kind = 1, |
| 35 | + content = "hello nostr", |
| 36 | + tags = emptyList(), |
| 37 | + createdAt = System.currentTimeMillis() / 1000 |
| 38 | +) |
| 39 | +val signed = nse.sign(event) |
| 40 | +println(signed.id) // 64-char hex |
| 41 | +println(signed.sig) // 128-char hex (Schnorr) |
| 42 | + |
| 43 | +// Read-only (no unlock needed) |
| 44 | +val pubkey = nse.getPublicKey() // hex |
| 45 | +val npub = nse.getNpub() // bech32 |
| 46 | + |
| 47 | +// Check / destroy |
| 48 | +nse.exists() // true |
| 49 | +nse.destroy() // wipes all key material |
| 50 | +``` |
| 51 | + |
| 52 | +## API |
| 53 | + |
| 54 | +``` |
| 55 | +nse.generate() → KeyInfo { pubkey, npub, createdAt, hardwareBacked } |
| 56 | +nse.sign(event) → SignedEvent { id, pubkey, sig, kind, content, tags, createdAt } |
| 57 | +nse.getPublicKey() → String (hex pubkey) |
| 58 | +nse.getNpub() → String (bech32 npub) |
| 59 | +nse.exists() → Boolean |
| 60 | +nse.destroy() → Unit (wipes all key material) |
| 61 | +``` |
| 62 | + |
| 63 | +## Configuration |
| 64 | + |
| 65 | +```kotlin |
| 66 | +// Default (StrongBox → TEE → error) |
| 67 | +val config = NSEConfig(context = ctx) |
| 68 | + |
| 69 | +// Custom alias (for multi-key support) |
| 70 | +val config = NSEConfig(context = ctx, keyAlias = "com.myapp.nostr") |
| 71 | + |
| 72 | +// Software fallback (for unit testing) |
| 73 | +val config = NSEConfig(context = ctx, useSoftwareKey = true) |
| 74 | +``` |
| 75 | + |
| 76 | +## Architecture |
| 77 | + |
| 78 | +``` |
| 79 | +generate() |
| 80 | + ├── KeyStore P-256 key (StrongBox preferred, TEE fallback) |
| 81 | + ├── Ephemeral P-256 key pair (software) |
| 82 | + ├── ECDH(KeyStore private, ephemeral public) → shared secret |
| 83 | + ├── HKDF-SHA256(shared secret, salt: "nse-v1") → AES-256-GCM key |
| 84 | + ├── secp256k1 keypair via secp256k1-kmp |
| 85 | + ├── AES-GCM encrypt(secp256k1 privkey) → encrypted blob |
| 86 | + ├── SharedPreferences.save(blob JSON, ephemeral pubkey) |
| 87 | + └── Zero plaintext secp256k1 key |
| 88 | +
|
| 89 | +sign(event) |
| 90 | + ├── Load KeyStore P-256 key + ephemeral pubkey + blob |
| 91 | + ├── ECDH → same shared secret → same AES key |
| 92 | + ├── AES-GCM decrypt → secp256k1 privkey (plaintext) |
| 93 | + ├── SHA-256([0, pubkey, created_at, kind, tags, content]) → event ID |
| 94 | + ├── Secp256k1.signSchnorr(event ID, privkey) → 64-byte BIP-340 signature |
| 95 | + ├── Zero plaintext secp256k1 key |
| 96 | + └── Return SignedEvent { id, pubkey, sig, ... } |
| 97 | +``` |
19 | 98 |
|
20 | 99 | ## Dependencies |
21 | 100 |
|
22 | | -- `android.security.keystore` (Android, built-in) |
23 | | -- `androidx.biometric:biometric` (BiometricPrompt) |
24 | | -- `secp256k1-kmp` or `libsecp256k1` JNI (secp256k1 signing) |
25 | | -- `androidx.security:security-crypto` (EncryptedSharedPreferences) |
| 101 | +- `android.security.keystore` (Android, built-in) — StrongBox/TEE P-256 |
| 102 | +- `javax.crypto` (Android, built-in) — AES-GCM, ECDH |
| 103 | +- [`secp256k1-kmp`](https://github.com/niclas/secp256k1-kmp) — Schnorr signing (BIP-340) |
| 104 | +- `androidx.biometric:biometric` — BiometricPrompt (optional, app-level) |
| 105 | + |
| 106 | +## Tests |
| 107 | + |
| 108 | +22 unit tests covering AES-GCM round-trip, HKDF, ECDH, hex conversion, bech32 encoding, Nostr event ID computation, Schnorr signing, and blob serialization. |
| 109 | + |
| 110 | +```bash |
| 111 | +./gradlew test |
| 112 | +``` |
| 113 | + |
| 114 | +## First Consumer |
| 115 | + |
| 116 | +NostrKeep Signer (`nostrkey.app.android.src`) |
26 | 117 |
|
27 | | -## Status: Planned (Phase 2) |
| 118 | +## Status: Implemented |
0 commit comments