AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
6 changed files with 376 additions and 27 deletions
Showing only changes of commit 8592b83538 - Show all commits
+8
View File
@@ -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)
}
}
@@ -0,0 +1,38 @@
package com.fabledsword.inkwell
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertThrows
import org.junit.Test
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
class SealedBoxTest {
private fun aesKey(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
@Test
fun `a sealed token opens with the key that sealed it`() {
val key = aesKey()
val sealed = SealedBox.seal(key, "tok-1")
assertNotEquals("tok-1", sealed)
assertEquals("tok-1", SealedBox.open(key, sealed))
}
@Test
fun `sealing twice gives two different values`() {
// A fresh IV each time, so equal tokens can't be spotted in the file.
val key = aesKey()
assertNotEquals(SealedBox.seal(key, "tok-1"), SealedBox.seal(key, "tok-1"))
}
@Test
fun `another key can't open it, as on a phone the backup was restored to`() {
val sealed = SealedBox.seal(aesKey(), "tok-1")
assertThrows(Exception::class.java) { SealedBox.open(aesKey(), sealed) }
}
@Test
fun `a value that was never sealed is refused`() {
assertThrows(Exception::class.java) { SealedBox.open(aesKey(), "dG9vIHNob3J0") }
}
}
+87 -26
View File
@@ -104,15 +104,46 @@ impl CoreError {
}
}
/// Seals the device token before it is stored, and opens it when it is read: Kotlin's
/// half of [`state::TokenSeal`] (family idea #5105, practice 12).
///
/// Implemented with a key in the Android Keystore. The key never leaves the phone,
/// so a copy of the app's files carries a token nothing else can open. That includes
/// Android's own backup, which stays on because an unlinked phone's notes exist only
/// there. Restored onto another phone, the app asks to sign in again.
///
/// Return null rather than throw: an exception escaping into the core would be a
/// panic there.
#[uniffi::export(with_foreign)]
pub trait TokenSeal: Send + Sync {
/// The token sealed for storage, or null when that isn't possible right now.
fn seal_token(&self, token: String) -> Option<String>;
/// The sealed token opened, or null when this phone can't open it.
fn open_token(&self, sealed: String) -> Option<String>;
}
/// Kotlin's seal, as the core asks for one.
struct ForeignSeal(Arc<dyn TokenSeal>);
impl state::TokenSeal for ForeignSeal {
fn seal(&self, token: &str) -> Option<String> {
self.0.seal_token(token.to_string())
}
fn open(&self, sealed: &str) -> Option<String> {
self.0.open_token(sealed.to_string())
}
}
/// The client handle: the on-device store plus the attachment directory beside it.
///
/// Held by Kotlin for the process lifetime. Both halves are `Send + Sync` — the store
/// behind its mutex, the blob store being a path — which is what lets uniffi share
/// one instance across coroutines.
/// Held by Kotlin for the process lifetime. Every part is `Send + Sync` (the store
/// behind its mutex, the blob store being a path, the seal by its trait bound),
/// which is what lets uniffi share one instance across coroutines.
#[derive(uniffi::Object)]
pub struct Inkwell {
db: Db,
blobs: BlobStore,
seal: ForeignSeal,
}
#[uniffi::export]
@@ -124,8 +155,11 @@ impl Inkwell {
/// storage is; the core must not guess at a platform path. The layout inside is
/// the core's business and matches the desktop's exactly — `inkwell.db` and
/// `blobs/` — so a store is readable by any client that opens it.
///
/// `seal` is required rather than set later, so there is no moment in which a
/// token could be stored unsealed by a caller that forgot.
#[uniffi::constructor]
pub fn new(data_dir: String) -> Result<Arc<Self>, CoreError> {
pub fn new(data_dir: String, seal: Arc<dyn TokenSeal>) -> Result<Arc<Self>, CoreError> {
let dir = PathBuf::from(data_dir);
std::fs::create_dir_all(&dir).map_err(CoreError::store)?;
@@ -133,7 +167,11 @@ impl Inkwell {
log::info!("local store ready — {}", local::summary(&db));
let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?;
Ok(Arc::new(Inkwell { db, blobs }))
Ok(Arc::new(Inkwell {
db,
blobs,
seal: ForeignSeal(seal),
}))
}
/// A one-line count summary, for the boot log.
@@ -506,11 +544,12 @@ impl Inkwell {
pub async fn unlink(&self) -> Result<RevokeOutcome, CoreError> {
// Read and release before the network call: a std MutexGuard isn't Send, so
// it cannot be held across an await, and holding the store through a
// round-trip would freeze every note operation in the UI.
let link = {
let conn = self.db.conn().map_err(CoreError::store)?;
let current = state::read(&conn).map_err(CoreError::store)?;
current.server_url.zip(current.device_token)
// round-trip would freeze every note operation in the UI. A token that won't
// open on this phone can't be revoked from here, so it is skipped.
let link = match self.credentials() {
Ok(link) => Some(link),
Err(CoreError::NotLinked) => None,
Err(e) => return Err(e),
};
let revoked = match &link {
Some((base_url, token)) => client::revoke_self(base_url, token).await,
@@ -699,12 +738,18 @@ pub fn body_tags(body: String) -> Vec<BodyTag> {
impl Inkwell {
/// The server URL + token, or the `NotLinked` state. Every networked call needs
/// exactly this, and none of them may hold the lock past it.
///
/// `NotLinked` also when the stored token won't open on this phone, which drops
/// it (`state::open_token`), so the app asks to sign in again.
fn credentials(&self) -> Result<(String, String), CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
let current = state::read(&conn).map_err(CoreError::store)?;
match (current.server_url, current.device_token) {
(Some(url), Some(token)) => Ok((url, token)),
_ => Err(CoreError::NotLinked),
let (Some(url), Some(stored)) = (current.server_url, current.device_token) else {
return Err(CoreError::NotLinked);
};
match state::open_token(&conn, &stored, &self.seal).map_err(CoreError::store)? {
Some(token) => Ok((url, token)),
None => Err(CoreError::NotLinked),
}
}
@@ -718,7 +763,7 @@ impl Inkwell {
retention_days: Option<u32>,
) -> Result<(), CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
state::set_link(&conn, base_url, token).map_err(CoreError::store)?;
state::set_sealed_link(&conn, base_url, token, &self.seal).map_err(CoreError::store)?;
if let Some(days) = retention_days {
state::set_server_retention(&conn, days as i64).map_err(CoreError::store)?;
}
@@ -736,6 +781,22 @@ mod tests {
/// Process id + a counter rather than a uuid dependency: the FFI crate has no
/// business pulling one in to name a temp folder, and this is the same approach
/// the desktop's updater tests settled on.
/// A seal that can't seal: tokens are stored as they are, as on the desktop.
struct Plain;
impl TokenSeal for Plain {
fn seal_token(&self, _token: String) -> Option<String> {
None
}
fn open_token(&self, _sealed: String) -> Option<String> {
None
}
}
fn plain() -> Arc<dyn TokenSeal> {
Arc::new(Plain)
}
fn scratch_dir() -> String {
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
@@ -761,7 +822,7 @@ mod tests {
#[test]
fn creates_a_store_and_round_trips_a_note() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone(), plain()).expect("a fresh data dir should open");
let created = app
.create_note(draft("Groceries\nmilk"))
@@ -783,7 +844,7 @@ mod tests {
#[test]
fn a_note_is_named_by_its_first_line() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone(), plain()).expect("a fresh data dir should open");
let created = app
.create_note(draft("just a thought"))
@@ -798,7 +859,7 @@ mod tests {
#[test]
fn a_note_with_only_items_is_named_by_its_first_item() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone(), plain()).expect("a fresh data dir should open");
let created = app
.create_note(NoteDraft {
@@ -817,7 +878,7 @@ mod tests {
#[test]
fn syncing_unlinked_reports_not_linked() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone(), plain()).expect("a fresh data dir should open");
let status = app.sync_status().expect("status should read");
assert!(!status.linked);
@@ -833,7 +894,7 @@ mod tests {
#[test]
fn ticking_an_item_rewrites_only_its_box() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone(), plain()).expect("a fresh data dir should open");
let note = app
.create_note(NoteDraft {
body: "Packing".to_string(),
@@ -865,7 +926,7 @@ mod tests {
#[test]
fn setting_labels_leaves_tag_derived_ones_alone() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone(), plain()).expect("a fresh data dir should open");
let note = app
.create_note(draft("Trip\nbook the ferry #travel"))
@@ -906,7 +967,7 @@ mod tests {
#[test]
fn deleting_forever_removes_the_note() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone(), plain()).expect("a fresh data dir should open");
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
app.delete_note_forever(note.id.clone())
@@ -924,7 +985,7 @@ mod tests {
#[test]
fn an_attached_file_is_stored_found_and_removable() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone(), plain()).expect("a fresh data dir should open");
let note = app.create_note(draft("Receipt")).expect("create");
let attached = app
@@ -973,7 +1034,7 @@ mod tests {
#[test]
fn reminders_can_be_snoozed_and_completed() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone(), plain()).expect("a fresh data dir should open");
let note = app.create_note(draft("Call back")).expect("create");
assert_eq!(note.remind_at, None);
@@ -999,7 +1060,7 @@ mod tests {
#[test]
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone(), plain()).expect("a fresh data dir should open");
let note = app.create_note(draft("Water the plants")).expect("create");
let armed = app
@@ -1061,7 +1122,7 @@ mod tests {
#[test]
fn renaming_onto_an_existing_tag_merges_into_the_older_one() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone(), plain()).expect("a fresh data dir should open");
let older = app.create_label("grocery".to_string()).expect("older");
// `created_at` is RFC3339 to the MILLISECOND. Without a gap the two rows can
@@ -1106,7 +1167,7 @@ mod tests {
#[test]
fn the_rename_merge_survivor_does_not_depend_on_the_direction() {
let dir = scratch_dir();
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
let app = Inkwell::new(dir.clone(), plain()).expect("a fresh data dir should open");
let older = app.create_label("grocery".to_string()).expect("older");
std::thread::sleep(std::time::Duration::from_millis(5));
+144
View File
@@ -8,6 +8,10 @@
//! the `keyring` crate needs libsecret/DBus on Linux, which adds a C dependency to a
//! binary that has to cross-compile, and fails outright on headless or minimal-WM
//! setups. Protecting the database file is the portable trade.
//!
//! A client that has somewhere better to keep a key passes a [`TokenSeal`], and the
//! token is stored sealed. Android does, with a key in the Keystore (family idea
//! #5105, practice 12); the desktop does not.
use rusqlite::{params, Connection};
use serde::Serialize;
@@ -107,6 +111,79 @@ pub fn set_link(conn: &Connection, server_url: &str, device_token: &str) -> rusq
Ok(())
}
/// Seals the device token for storage, and opens it again.
///
/// Neither method fails loudly: a client's key store can be briefly unavailable, and
/// a token sealed on another device can never be opened here. `None` says so, and
/// [`set_sealed_link`] and [`open_token`] decide what happens next.
pub trait TokenSeal {
/// The token sealed for storage, or None when that isn't possible right now.
fn seal(&self, token: &str) -> Option<String>;
/// The sealed token opened, or None when this device can't open it.
fn open(&self, sealed: &str) -> Option<String>;
}
/// Marks a stored token as sealed. A device token is URL-safe base64, which has no
/// colon, so a plain one can't be mistaken for a sealed one.
const SEALED: &str = "sealed:";
/// [`set_link`] for a client that seals its token.
///
/// When sealing fails, the token is stored as it is and [`open_token`] seals it on
/// its next read. Refusing the link instead would leave someone unable to sync
/// over a passing key-store error.
pub fn set_sealed_link(
conn: &Connection,
server_url: &str,
device_token: &str,
seal: &dyn TokenSeal,
) -> rusqlite::Result<()> {
let stored = match seal.seal(device_token) {
Some(sealed) => format!("{SEALED}{sealed}"),
None => {
log::warn!("couldn't seal the device token; it is stored as is until the next read");
device_token.to_string()
}
};
set_link(conn, server_url, &stored)
}
/// The device token the server expects, from the `stored` one.
///
/// - A sealed token is opened. If it won't open, it is dropped, so the app reads as
/// unlinked and asks to sign in again. That is what happens when Android restores
/// the app's files onto another phone, whose Keystore never held the key. The
/// server address and cursor stay, for the sign-in form and the same server.
/// - A plain token, stored before tokens were sealed or when sealing failed, is
/// sealed in place and returned.
pub fn open_token(
conn: &Connection,
stored: &str,
seal: &dyn TokenSeal,
) -> rusqlite::Result<Option<String>> {
if let Some(sealed) = stored.strip_prefix(SEALED) {
let token = seal.open(sealed);
if token.is_none() {
log::warn!("the stored device token won't open on this device; sign in again");
store_token(conn, None)?;
}
return Ok(token);
}
if let Some(sealed) = seal.seal(stored) {
store_token(conn, Some(&format!("{SEALED}{sealed}")))?;
}
Ok(Some(stored.to_string()))
}
/// Replace the stored token alone, leaving the server and cursor as they are.
fn store_token(conn: &Connection, token: Option<&str>) -> rusqlite::Result<()> {
conn.execute(
"UPDATE sync_state SET device_token = ?1 WHERE id = 1",
params![token],
)?;
Ok(())
}
/// Drop the notes other people shared with the account this device was linked to.
/// They were only ever here through that link: kept after it ends, they would sit
/// on the board as notes nobody here can edit and nothing would ever update.
@@ -240,6 +317,73 @@ mod tests {
assert_eq!(state.device_token.as_deref(), Some("tok-1"));
}
/// Reverses the token: enough to tell sealed from plain. `broken` stands in for a
/// key store that is unavailable, or a key this device never held.
struct Reverse {
broken: bool,
}
impl TokenSeal for Reverse {
fn seal(&self, token: &str) -> Option<String> {
(!self.broken).then(|| token.chars().rev().collect())
}
fn open(&self, sealed: &str) -> Option<String> {
(!self.broken).then(|| sealed.chars().rev().collect())
}
}
const WORKING: Reverse = Reverse { broken: false };
const BROKEN: Reverse = Reverse { broken: true };
fn stored_token(conn: &Connection) -> Option<String> {
read(conn).expect("read").device_token
}
#[test]
fn a_sealed_token_is_stored_sealed_and_opens() {
let conn = db();
set_sealed_link(&conn, "https://notes.example.com", "tok-1", &WORKING).expect("link");
assert_eq!(stored_token(&conn).as_deref(), Some("sealed:1-kot"));
let opened = open_token(&conn, "sealed:1-kot", &WORKING).expect("open");
assert_eq!(opened.as_deref(), Some("tok-1"));
}
#[test]
fn a_plain_token_is_sealed_on_its_next_read() {
let conn = db();
set_link(&conn, "https://notes.example.com", "tok-1").expect("link");
let opened = open_token(&conn, "tok-1", &WORKING).expect("open");
assert_eq!(opened.as_deref(), Some("tok-1"));
assert_eq!(stored_token(&conn).as_deref(), Some("sealed:1-kot"));
}
#[test]
fn a_token_that_cant_be_sealed_is_kept_plain_and_still_works() {
let conn = db();
set_sealed_link(&conn, "https://notes.example.com", "tok-1", &BROKEN).expect("link");
assert_eq!(stored_token(&conn).as_deref(), Some("tok-1"));
let opened = open_token(&conn, "tok-1", &BROKEN).expect("open");
assert_eq!(opened.as_deref(), Some("tok-1"));
}
#[test]
fn a_token_that_wont_open_here_is_dropped_but_the_server_is_kept() {
let conn = db();
set_sealed_link(&conn, "https://notes.example.com", "tok-1", &WORKING).expect("link");
let opened = open_token(&conn, "sealed:1-kot", &BROKEN).expect("open");
assert_eq!(opened, None);
let state = read(&conn).expect("read");
assert!(!state.is_linked());
assert_eq!(state.device_token, None);
assert_eq!(
state.server_url.as_deref(),
Some("https://notes.example.com")
);
}
#[test]
fn an_unlinked_device_uses_its_own_retention_window() {
let conn = db();