Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions crates/kult-crypto/src/sealed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

use alloc::vec::Vec;

use hmac::{Hmac, Mac};
use rand_core::CryptoRngCore;
use sha2::Sha256;
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};

use crate::{ratchet::Session, util, CryptoError, Result};
Expand Down Expand Up @@ -38,6 +40,18 @@ impl StorageKey {
StorageKey(*util::hkdf32(None, &self.0, label))
}

/// Compute HMAC-SHA-256 without exposing this key's bytes.
///
/// Higher layers use this for keyed at-rest indexes after deriving a
/// dedicated index subkey. Callers remain responsible for supplying a
/// canonical, domain-separated input.
pub fn hmac_sha256(&self, input: &[u8]) -> [u8; 32] {
let mut mac =
Hmac::<Sha256>::new_from_slice(&self.0).expect("HMAC accepts every key length");
mac.update(input);
mac.finalize().into_bytes().into()
}

/// AEAD-seal arbitrary bytes under this key (random 24-byte nonce,
/// caller-chosen associated data). Output: `nonce || ciphertext+tag`.
pub fn seal(&self, ad: &[u8], plaintext: &[u8], rng: &mut impl CryptoRngCore) -> Vec<u8> {
Expand Down Expand Up @@ -65,3 +79,17 @@ pub(crate) fn unseal_session(bytes: &[u8], key: &StorageKey) -> Result<Session>
let plain = Zeroizing::new(util::aead_open(key.as_bytes(), SEAL_AD, bytes)?);
postcard::from_bytes(&plain).map_err(|_| CryptoError::Serialization)
}

#[cfg(test)]
mod tests {
use super::StorageKey;

#[test]
fn hmac_sha256_matches_known_answer() {
let key = StorageKey::from_bytes([0x0b; 32]);
let expected =
hex::decode("198a607eb44bfbc69903a0f1cf2bbdc5ba0aa3f3d9ae3c1c7a3b1696a0b68cf7")
.unwrap();
assert_eq!(key.hmac_sha256(b"Hi There").as_slice(), expected);
}
}
41 changes: 41 additions & 0 deletions crates/kult-store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ mod local_metadata;
mod media;
mod note;
mod scheduled;
#[doc(hidden)]
pub mod store_v2;

pub use backup::BACKUP_MAGIC;
pub use devices::{
Expand Down Expand Up @@ -139,6 +141,24 @@ pub enum StoreError {
PinActive,
/// A note-to-self text record is empty or exceeds its documented bound.
NoteBounds,
/// A database or metadata record declares a schema newer than this build.
FutureSchema,
/// Authenticated metadata and the physical SQLite schema disagree.
SchemaMismatch,
/// A migration ledger contains a migration that did not complete.
IncompleteMigration,
/// A migration ledger repeats one stable migration identifier.
DuplicateMigration,
/// A migration ledger does not form one complete monotonic version chain.
InvalidMigrationLedger,
/// More than one row carries the same table-scoped opaque locator.
DuplicateIndex,
/// A decrypted record's logical key does not match its opaque locator or lookup key.
LogicalKeyMismatch,
/// A decrypted logical record uses a version this build cannot interpret.
UnsupportedRecordVersion,
/// A versioned logical record exceeds the internal migration bound.
RecordBounds,
}

impl std::fmt::Display for StoreError {
Expand Down Expand Up @@ -182,6 +202,21 @@ impl std::fmt::Display for StoreError {
Self::InvalidPinOrder => f.write_str("invalid complete pin order"),
Self::PinActive => f.write_str("conversation pin is active or absent"),
Self::NoteBounds => f.write_str("note-to-self text bounds exceeded"),
Self::FutureSchema => f.write_str("store schema is newer than this build"),
Self::SchemaMismatch => {
f.write_str("authenticated metadata and physical schema disagree")
}
Self::IncompleteMigration => f.write_str("store migration is incomplete"),
Self::DuplicateMigration => f.write_str("store migration id is duplicated"),
Self::InvalidMigrationLedger => f.write_str("store migration ledger is invalid"),
Self::DuplicateIndex => f.write_str("opaque row locator is duplicated"),
Self::LogicalKeyMismatch => {
f.write_str("sealed record logical key does not match its locator")
}
Self::UnsupportedRecordVersion => {
f.write_str("sealed logical record version is unsupported")
}
Self::RecordBounds => f.write_str("sealed logical record exceeds its bound"),
}
}
}
Expand Down Expand Up @@ -630,6 +665,9 @@ impl Store {
let lock = acquire_store_lock(path)?;
let conn = Connection::open(path)?;
let database_lock = acquire_database_identity_lock(path)?;
if store_v2::is_inactive_migration_target(&conn)? {
return Err(StoreError::SchemaMismatch);
}
conn.execute_batch(SCHEMA)?;
let existing: Option<Vec<u8>> = conn
.query_row("SELECT v FROM meta WHERE k = 'wrapped_sk'", [], |r| {
Expand Down Expand Up @@ -676,6 +714,9 @@ impl Store {
let lock = acquire_store_lock(path)?;
let conn = Connection::open(path)?;
let database_lock = acquire_database_identity_lock(path)?;
if store_v2::is_inactive_migration_target(&conn)? {
return Err(StoreError::SchemaMismatch);
}
// Idempotent: also creates any table added since this store was —
// the only schema evolution so far is purely additive.
conn.execute_batch(SCHEMA)?;
Expand Down
Loading
Loading