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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user