-
Notifications
You must be signed in to change notification settings - Fork 1
Persist identity #67
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Persist identity #67
Changes from all commits
f3aa5d5
e099d5f
f4c08bd
37eb274
3a9ddad
7019b04
3673d73
fdacfae
0e62c44
c3e5f36
030ab47
10a403e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,9 +13,12 @@ use safer_ffi::{ | |
| prelude::{c_slice, repr_c}, | ||
| }; | ||
|
|
||
| use storage::StorageConfig; | ||
|
|
||
| use crate::{ | ||
| context::{Context, Introduction}, | ||
| errors::ChatError, | ||
| ffi::utils::CResult, | ||
| types::ContentData, | ||
| }; | ||
|
|
||
|
|
@@ -54,6 +57,41 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box<ContextHandle> { | |
| Box::new(ContextHandle(Context::new_with_name(&*name))).into() | ||
| } | ||
|
|
||
| /// Creates a new libchat Context with file-based persistent storage. | ||
| /// | ||
| /// The identity will be loaded from storage if it exists, or created and saved if not. | ||
| /// | ||
| /// # Parameters | ||
| /// - name: Friendly name for the identity (used if creating new identity) | ||
| /// - db_path: Path to the SQLite database file | ||
| /// - db_secret: Secret key for encrypting the database | ||
| /// | ||
| /// # Returns | ||
| /// CResult with context handle on success, or error string on failure. | ||
| /// On success, the context handle must be freed with `destroy_context()` after usage. | ||
| /// On error, the error string must be freed with `destroy_string()` after usage. | ||
| #[ffi_export] | ||
| pub fn create_context_with_storage( | ||
| name: repr_c::String, | ||
| db_path: repr_c::String, | ||
| db_secret: repr_c::String, | ||
| ) -> CResult<repr_c::Box<ContextHandle>, repr_c::String> { | ||
|
Comment on lines
73
to
78
|
||
| let config = StorageConfig::Encrypted { | ||
| path: db_path.to_string(), | ||
| key: db_secret.to_string(), | ||
| }; | ||
| match Context::open(&*name, config) { | ||
| Ok(ctx) => CResult { | ||
| ok: Some(Box::new(ContextHandle(ctx)).into()), | ||
| err: None, | ||
| }, | ||
| Err(e) => CResult { | ||
| ok: None, | ||
| err: Some(e.to_string().into()), | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| /// Returns the friendly name of the contexts installation. | ||
| /// | ||
| #[ffi_export] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| pub mod utils; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| use safer_ffi::prelude::*; | ||
|
|
||
| #[derive_ReprC] | ||
| #[repr(C)] | ||
| pub struct CResult<T: ReprC, Err: ReprC> { | ||
| pub ok: Option<T>, | ||
| pub err: Option<Err>, | ||
| } | ||
|
Comment on lines
+5
to
+8
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From a consistency POV, api.rs uses the safer_ffi to create unique types per each api_call. I'm not saying that is the best approach, however this does represent a different approach of using generic containers as return values.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Exactly, that's why I put in ffi/utils, I hope it can be reused in other apis, so that we can have uniform apis and reused code. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| //! Chat-specific storage implementation. | ||
|
|
||
| use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; | ||
| use zeroize::Zeroize; | ||
|
|
||
| use super::migrations; | ||
| use super::types::IdentityRecord; | ||
| use crate::identity::Identity; | ||
|
|
||
| /// Chat-specific storage operations. | ||
| /// | ||
| /// This struct wraps a SqliteDb and provides domain-specific | ||
| /// storage operations for chat state (identity, inbox keys, chat metadata). | ||
| /// | ||
| /// Note: Ratchet state persistence is delegated to double_ratchets::RatchetStorage. | ||
| pub struct ChatStorage { | ||
| db: SqliteDb, | ||
| } | ||
|
|
||
| impl ChatStorage { | ||
| /// Creates a new ChatStorage with the given configuration. | ||
| pub fn new(config: StorageConfig) -> Result<Self, StorageError> { | ||
| let db = SqliteDb::new(config)?; | ||
| Self::run_migrations(db) | ||
| } | ||
|
|
||
| /// Applies all migrations and returns the storage instance. | ||
| fn run_migrations(mut db: SqliteDb) -> Result<Self, StorageError> { | ||
| migrations::apply_migrations(db.connection_mut())?; | ||
| Ok(Self { db }) | ||
| } | ||
|
|
||
| // ==================== Identity Operations ==================== | ||
|
|
||
| /// Saves the identity (secret key). | ||
| /// | ||
| /// Note: The secret key bytes are explicitly zeroized after use to minimize | ||
| /// the time sensitive data remains in stack memory. | ||
| pub fn save_identity(&mut self, identity: &Identity) -> Result<(), StorageError> { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Sand] Clearing up the language used in this library:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clear the Installation or identity terms used is out of scope for this PR. |
||
| let mut secret_bytes = identity.secret().DANGER_to_bytes(); | ||
| let result = self.db.connection().execute( | ||
| "INSERT OR REPLACE INTO identity (id, name, secret_key) VALUES (1, ?1, ?2)", | ||
| params![identity.get_name(), secret_bytes.as_slice()], | ||
| ); | ||
| secret_bytes.zeroize(); | ||
| result?; | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Loads the identity if it exists. | ||
| /// | ||
| /// Note: Secret key bytes are zeroized after being copied into IdentityRecord, | ||
| /// which handles its own zeroization via ZeroizeOnDrop. | ||
| pub fn load_identity(&self) -> Result<Option<Identity>, StorageError> { | ||
| let mut stmt = self | ||
| .db | ||
| .connection() | ||
| .prepare("SELECT name, secret_key FROM identity WHERE id = 1")?; | ||
|
|
||
| let result = stmt.query_row([], |row| { | ||
| let name: String = row.get(0)?; | ||
| let secret_key: Vec<u8> = row.get(1)?; | ||
| Ok((name, secret_key)) | ||
kaichaosun marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }); | ||
|
|
||
| match result { | ||
| Ok((name, mut secret_key_vec)) => { | ||
| let bytes: Result<[u8; 32], _> = secret_key_vec.as_slice().try_into(); | ||
| let bytes = match bytes { | ||
| Ok(b) => b, | ||
| Err(_) => { | ||
| secret_key_vec.zeroize(); | ||
| return Err(StorageError::InvalidData( | ||
| "Invalid secret key length".into(), | ||
| )); | ||
| } | ||
| }; | ||
| secret_key_vec.zeroize(); | ||
| let record = IdentityRecord { | ||
| name, | ||
| secret_key: bytes, | ||
| }; | ||
| Ok(Some(Identity::from(record))) | ||
| } | ||
| Err(RusqliteError::QueryReturnedNoRows) => Ok(None), | ||
| Err(e) => Err(e.into()), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn test_identity_roundtrip() { | ||
| let mut storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); | ||
|
|
||
| // Initially no identity | ||
| assert!(storage.load_identity().unwrap().is_none()); | ||
|
|
||
| // Save identity | ||
| let identity = Identity::new("default"); | ||
| let pubkey = identity.public_key(); | ||
| storage.save_identity(&identity).unwrap(); | ||
|
|
||
| // Load identity | ||
| let loaded = storage.load_identity().unwrap().unwrap(); | ||
| assert_eq!(loaded.public_key(), pubkey); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| //! Database migrations module. | ||
| //! | ||
| //! SQL migrations are embedded at compile time and applied in order. | ||
| //! Each migration is applied atomically within a transaction. | ||
|
|
||
| use storage::{Connection, StorageError}; | ||
|
|
||
| /// Embeds and returns all migration SQL files in order. | ||
| pub fn get_migrations() -> Vec<(&'static str, &'static str)> { | ||
| vec![( | ||
| "001_initial_schema", | ||
| include_str!("migrations/001_initial_schema.sql"), | ||
| )] | ||
| } | ||
|
|
||
| /// Applies all migrations to the database. | ||
| /// | ||
| /// Uses a simple version tracking table to avoid re-running migrations. | ||
| pub fn apply_migrations(conn: &mut Connection) -> Result<(), StorageError> { | ||
| // Create migrations tracking table if it doesn't exist | ||
| conn.execute_batch( | ||
| "CREATE TABLE IF NOT EXISTS _migrations ( | ||
| name TEXT PRIMARY KEY, | ||
| applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) | ||
| );", | ||
| )?; | ||
|
|
||
| for (name, sql) in get_migrations() { | ||
| // Check if migration already applied | ||
| let already_applied: bool = conn.query_row( | ||
| "SELECT EXISTS(SELECT 1 FROM _migrations WHERE name = ?1)", | ||
| [name], | ||
| |row| row.get(0), | ||
| )?; | ||
|
|
||
| if !already_applied { | ||
| // Apply migration and record it atomically in a transaction | ||
| let tx = conn.transaction()?; | ||
| tx.execute_batch(sql)?; | ||
| tx.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?; | ||
| tx.commit()?; | ||
| } | ||
| } | ||
|
|
||
| Ok(()) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| -- Initial schema for chat storage | ||
| -- Migration: 001_initial_schema | ||
|
|
||
| -- Identity table (single row) | ||
| CREATE TABLE IF NOT EXISTS identity ( | ||
| id INTEGER PRIMARY KEY CHECK (id = 1), | ||
| name TEXT NOT NULL, | ||
| secret_key BLOB NOT NULL | ||
| ); |
Uh oh!
There was an error while loading. Please reload this page.