android: the device token is stored sealed under a Keystore key
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 20s
CI & Build / Python tests (push) Successful in 20s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 1m12s
CI & Build / integration (push) Successful in 1m44s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m17s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m15s
Android / Build the server image (push) Successful in 1s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 20s
CI & Build / Python tests (push) Successful in 20s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 1m12s
CI & Build / integration (push) Successful in 1m44s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m17s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m15s
Android / Build the server image (push) Successful in 1s
Family idea #5105, practice 12, as the operator chose on 2026-10-08: the token is encrypted, and Android backup stays on. The core: - Adds a TokenSeal trait in sync/state.rs, with set_sealed_link and open_token. - A sealed token is stored as "sealed:<value>". - A plain token, stored before this change or while sealing failed, is sealed in place on its next read. - A sealed token that won't open is dropped, and the server address and cursor are kept, so the app reads as unlinked and asks to sign in again. That is what happens after Android restores the app onto another phone. - The desktop passes no seal and keeps storing the token as before. The FFI: - Exports TokenSeal as a uniffi foreign trait (seal_token / open_token, null rather than an exception). - Requires it in Inkwell's constructor, so there is no moment a token could be stored unsealed. - Routes credentials(), unlink() and store_link() through it. Kotlin: - KeystoreTokenSeal is AES-GCM under an Android Keystore key, using the SealedBox framing from Minstrel's KeystoreSessionVault (Scribe snippet #5025), with no new dependency. - SealedBoxTest checks the framing on the JVM. allowBackup stays true, and the manifest says why. An unlinked phone's notes exist only on the phone, and the backup is their one other copy. The backup carries a token nothing can open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,14 @@
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!--
|
||||
allowBackup stays true, which family idea #5105 (practice 12) says to turn
|
||||
off. Inkwell is local-first, so an unlinked phone's notes exist only on the
|
||||
phone, and Android's backup is their one other copy. The device token is what
|
||||
the practice protects, and it is stored sealed under a Keystore key that never
|
||||
leaves the phone (KeystoreTokenSeal.kt). A backup therefore carries the notes
|
||||
and a token nothing can open, and a restored app asks to sign in again.
|
||||
-->
|
||||
<application
|
||||
android:name=".InkwellApplication"
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -40,7 +40,7 @@ class InkwellApplication : Application() {
|
||||
setClientAgent("inkwell-android", installedVersionName() ?: "unknown")
|
||||
|
||||
try {
|
||||
val handle = Inkwell(filesDir.absolutePath)
|
||||
val handle = Inkwell(filesDir.absolutePath, KeystoreTokenSeal())
|
||||
core = handle
|
||||
Log.i(TAG, "local store ready — ${handle.summary()}")
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Log
|
||||
import com.fabledsword.inkwell.core.TokenSeal
|
||||
import java.security.KeyStore
|
||||
import java.util.Base64
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
/**
|
||||
* Keeps the device token sealed under a key in the Android Keystore (family idea
|
||||
* #5105, practice 12). The core stores what this returns, never the token itself.
|
||||
*
|
||||
* The key never leaves this phone, so a copy of the app's files yields a token
|
||||
* nothing can open. That includes Android's backup, which stays on: an unlinked
|
||||
* phone's notes exist only here, and a backup is their one other copy. Restored
|
||||
* onto another phone, the notes come back and the app asks to sign in again.
|
||||
*
|
||||
* Both methods answer null rather than throw, because an exception crossing into
|
||||
* the core would be a panic there. The core reads null from [sealToken] as "store
|
||||
* it as is for now" and from [openToken] as "sign in again".
|
||||
*
|
||||
* Shaped after Minstrel's KeystoreSessionVault (Scribe snippet #5025).
|
||||
*/
|
||||
class KeystoreTokenSeal : TokenSeal {
|
||||
override fun sealToken(token: String): String? =
|
||||
runCatching { SealedBox.seal(key(), token) }
|
||||
.onFailure { Log.w(TAG, "couldn't seal the device token", it) }
|
||||
.getOrNull()
|
||||
|
||||
override fun openToken(sealed: String): String? =
|
||||
runCatching { SealedBox.open(key(), sealed) }
|
||||
.onFailure { Log.w(TAG, "the device token won't open on this phone", it) }
|
||||
.getOrNull()
|
||||
|
||||
private fun key(): SecretKey {
|
||||
val store = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
||||
(store.getKey(ALIAS, null) as? SecretKey)?.let { return it }
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
||||
generator.init(
|
||||
KeyGenParameterSpec
|
||||
.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(KEY_BITS)
|
||||
.build(),
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Inkwell"
|
||||
const val KEYSTORE = "AndroidKeyStore"
|
||||
const val ALIAS = "inkwell-device-token"
|
||||
const val KEY_BITS = 256
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-GCM with a fresh IV per seal, the IV stored in front of the ciphertext. Split
|
||||
* from the Keystore so it can be tested on the JVM with an ordinary key.
|
||||
*/
|
||||
internal object SealedBox {
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val TAG_BITS = 128
|
||||
private const val IV_BYTES = 12
|
||||
|
||||
// Binds a sealed value to what it is, so it can't be passed off as another secret.
|
||||
private val AAD = "inkwell-device-token-v1".toByteArray(Charsets.UTF_8)
|
||||
|
||||
fun seal(
|
||||
key: SecretKey,
|
||||
plaintext: String,
|
||||
): String {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
// The provider picks a random IV; the Keystore refuses a caller-chosen one.
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
cipher.updateAAD(AAD)
|
||||
val sealed = cipher.iv + cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||
return Base64.getEncoder().encodeToString(sealed)
|
||||
}
|
||||
|
||||
fun open(
|
||||
key: SecretKey,
|
||||
sealed: String,
|
||||
): String {
|
||||
val bytes = Base64.getDecoder().decode(sealed)
|
||||
require(bytes.size > IV_BYTES) { "sealed value too short" }
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, bytes, 0, IV_BYTES))
|
||||
cipher.updateAAD(AAD)
|
||||
return String(cipher.doFinal(bytes, IV_BYTES, bytes.size - IV_BYTES), Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user