Skip to content
Closed
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
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ members = [
"walletkit-core",
"walletkit",
"walletkit-db",
"walletkit-secure-store",
"walletkit-cli",
]
resolver = "2"
Expand Down Expand Up @@ -44,6 +45,7 @@ world-id-proof = { version = "0.10.2", default-features = false }
# internal
walletkit-core = { version = "0.16.1", path = "walletkit-core", default-features = false }
walletkit-db = { version = "0.16.1", path = "walletkit-db" }
walletkit-secure-store = { version = "0.16.1", path = "walletkit-secure-store" }

[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
Expand Down
36 changes: 36 additions & 0 deletions walletkit-secure-store/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
[package]
name = "walletkit-secure-store"
description = "Reusable encrypted on-device storage primitives for WalletKit consumers (CredentialStore, OrbKit, NFC, etc.)."
publish = true

version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
exclude.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true

[dependencies]
ciborium = "0.2.2"
rand = "0.8"
secrecy = "0.10"
serde = { version = "1", features = ["derive"] }
sha2 = "0.10"
thiserror = "2"
walletkit-db = { workspace = true }
zeroize = { version = "1", features = ["derive"] }

[target.'cfg(target_os = "android")'.dependencies]
sha2 = { version = "0.10", features = ["force-soft"] }

[dev-dependencies]
tempfile = "3"
uuid = { version = "1.10", features = ["v4"] }

[lints]
workspace = true
99 changes: 99 additions & 0 deletions walletkit-secure-store/src/blobs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! Content-addressed blob table shared across consumer schemas.
//!
//! [`Blobs::ensure_schema`] creates the `blob_objects` table; [`Blobs::put`]
//! and [`Blobs::get`] insert and read rows by [`ContentId`]. Consumers
//! reference blob rows from their own tables via a `BLOB NOT NULL` column
//! holding the content id (no foreign-key constraint — matches existing
//! `walletkit-core` behaviour).

use walletkit_db::{params, Connection, StepResult, Transaction, Value};

use crate::content_id::{compute_content_id, ContentId};
use crate::error::{StoreError, StoreResult};

/// Helper functions for the shared `blob_objects` table.
///
/// `Blobs` is a zero-sized namespace, not a stateful struct.
pub struct Blobs;

impl Blobs {
/// Idempotently creates the `blob_objects` table.
///
/// **Backup sensitivity:** the `blob_objects` table participates in
/// `walletkit-db`'s plaintext export/import. Schema changes here flow
/// through to existing backups.
///
/// # Errors
///
/// Returns an error if the `CREATE TABLE` statement fails.
pub fn ensure_schema(conn: &Connection) -> StoreResult<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS blob_objects (
content_id BLOB NOT NULL,
blob_kind INTEGER NOT NULL,
created_at INTEGER NOT NULL,
bytes BLOB NOT NULL,
PRIMARY KEY (content_id)
);",
)
.map_err(StoreError::from)
}

/// Inserts `bytes` into the `blob_objects` table (idempotent on
/// `content_id`) and returns the computed [`ContentId`].
///
/// `kind_tag` is a consumer-defined `u8` identifier. It is stored as
/// `INTEGER` and folded into the content id via [`compute_content_id`].
///
/// # Errors
///
/// Returns an error if the insert fails or `now` cannot be represented
/// as `i64`.
pub fn put(
tx: &Transaction,
kind_tag: u8,
bytes: &[u8],
now: u64,
) -> StoreResult<ContentId> {
let content_id = compute_content_id(kind_tag, bytes);
let now_i64 = u64_to_i64(now, "now")?;
tx.execute(
"INSERT OR IGNORE INTO blob_objects (content_id, blob_kind, created_at, bytes)
VALUES (?1, ?2, ?3, ?4)",
params![
content_id.as_ref(),
i64::from(kind_tag),
now_i64,
bytes,
],
)
.map_err(StoreError::from)?;
Ok(content_id)
}

/// Reads the bytes for `content_id`, returning `None` if absent.
///
/// # Errors
///
/// Returns an error if the query fails.
pub fn get(
conn: &Connection,
content_id: &ContentId,
) -> StoreResult<Option<Vec<u8>>> {
let mut stmt = conn
.prepare("SELECT bytes FROM blob_objects WHERE content_id = ?1")
.map_err(StoreError::from)?;
stmt.bind_values(&[Value::Blob(content_id.to_vec())])
.map_err(StoreError::from)?;
match stmt.step().map_err(StoreError::from)? {
StepResult::Row(row) => Ok(Some(row.column_blob(0))),
StepResult::Done => Ok(None),
}
}
}

fn u64_to_i64(value: u64, label: &str) -> StoreResult<i64> {
i64::try_from(value).map_err(|_| {
StoreError::Db(format!("{label} out of range for i64: {value}"))
})
}
33 changes: 33 additions & 0 deletions walletkit-secure-store/src/content_id.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! Content-addressed identifier for stored blobs.

use sha2::{Digest, Sha256};

/// Length in bytes of a [`ContentId`].
pub const CONTENT_ID_LEN: usize = 32;

/// Content identifier for a stored blob — `SHA-256` over the kind tag and
/// plaintext bytes.
pub type ContentId = [u8; CONTENT_ID_LEN];

const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob";

/// Computes a [`ContentId`] for `plaintext` namespaced by `kind_tag`.
///
/// The kind tag namespace is owned by the caller (each consumer defines its
/// own `u8` constants). Tags do not collide across consumers because each
/// consumer keeps its own database file.
///
/// **On-disk compatibility:** the byte layout fed into `SHA-256` is
/// `CONTENT_ID_PREFIX || [kind_tag] || plaintext`. Changing this layout would
/// invalidate existing content IDs on every device.
#[must_use]
pub fn compute_content_id(kind_tag: u8, plaintext: &[u8]) -> ContentId {
let mut hasher = Sha256::new();
hasher.update(CONTENT_ID_PREFIX);
hasher.update([kind_tag]);
hasher.update(plaintext);
let digest = hasher.finalize();
let mut out = [0u8; CONTENT_ID_LEN];
out.copy_from_slice(&digest);
out
}
96 changes: 96 additions & 0 deletions walletkit-secure-store/src/envelope.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//! Persistent envelope holding a `Keystore`-sealed intermediate key.

use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, ZeroizeOnDrop};

use crate::error::{StoreError, StoreResult};

const ENVELOPE_VERSION: u32 = 1;

/// Persisted envelope produced by [`crate::init_or_open_envelope_key`].
///
/// `wrapped_k_intermediate` is the intermediate key sealed by the
/// [`Keystore`](crate::Keystore). The envelope is serialised as `CBOR` and
/// written via an [`AtomicBlobStore`](crate::AtomicBlobStore).
#[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
pub struct KeyEnvelope {
/// On-disk format version. Currently always `1`.
pub version: u32,
/// Keystore-sealed intermediate key bytes.
pub wrapped_k_intermediate: Vec<u8>,
/// Creation timestamp (seconds).
pub created_at: u64,
/// Last update timestamp (seconds).
pub updated_at: u64,
}

impl KeyEnvelope {
/// Creates a new envelope at version `1`.
#[must_use]
pub const fn new(wrapped_k_intermediate: Vec<u8>, now: u64) -> Self {
Self {
version: ENVELOPE_VERSION,
wrapped_k_intermediate,
created_at: now,
updated_at: now,
}
}

/// Serialises the envelope to `CBOR`.
///
/// # Errors
///
/// Returns an error if `CBOR` encoding fails.
pub fn serialize(&self) -> StoreResult<Vec<u8>> {
let mut bytes = Vec::new();
ciborium::ser::into_writer(self, &mut bytes)
.map_err(|err| StoreError::Serialization(err.to_string()))?;
Ok(bytes)
}

/// Deserialises an envelope from `CBOR` bytes.
///
/// # Errors
///
/// Returns [`StoreError::UnsupportedEnvelopeVersion`] if the version is
/// unrecognised, or [`StoreError::Serialization`] if `CBOR` decoding
/// fails.
pub fn deserialize(bytes: &[u8]) -> StoreResult<Self> {
let envelope: Self = ciborium::de::from_reader(bytes)
.map_err(|err| StoreError::Serialization(err.to_string()))?;
if envelope.version != ENVELOPE_VERSION {
return Err(StoreError::UnsupportedEnvelopeVersion(envelope.version));
}
Ok(envelope)
}
}

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

#[test]
fn round_trip() {
let envelope = KeyEnvelope::new(vec![1, 2, 3], 123);
let bytes = envelope.serialize().expect("serialize");
let decoded = KeyEnvelope::deserialize(&bytes).expect("deserialize");
assert_eq!(decoded.version, ENVELOPE_VERSION);
assert_eq!(decoded.wrapped_k_intermediate, vec![1, 2, 3]);
assert_eq!(decoded.created_at, 123);
assert_eq!(decoded.updated_at, 123);
}

#[test]
fn version_mismatch() {
let mut envelope = KeyEnvelope::new(vec![1, 2, 3], 123);
envelope.version = ENVELOPE_VERSION + 1;
let bytes = envelope.serialize().expect("serialize");
match KeyEnvelope::deserialize(&bytes) {
Err(StoreError::UnsupportedEnvelopeVersion(version)) => {
assert_eq!(version, ENVELOPE_VERSION + 1);
}
Err(err) => panic!("unexpected error: {err}"),
Ok(_) => panic!("expected error"),
}
}
}
57 changes: 57 additions & 0 deletions walletkit-secure-store/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//! Error type for `walletkit-secure-store` primitives.

use thiserror::Error;
use walletkit_db::DbError;

/// Result alias for [`StoreError`].
pub type StoreResult<T> = Result<T, StoreError>;

/// Errors produced by the primitives in this crate.
///
/// Consumers typically wrap this in a richer error enum at their boundary
/// (e.g. `walletkit-core`'s `StorageError`) so the `uniffi` surface stays
/// under their control.
#[derive(Debug, Error)]
pub enum StoreError {
/// Errors coming from the device keystore.
#[error("keystore error: {0}")]
Keystore(String),

/// Errors coming from the atomic blob store.
#[error("blob store error: {0}")]
BlobStore(String),

/// Errors coming from the cross-process lock.
#[error("lock error: {0}")]
Lock(String),

/// Serialization or deserialization failures (e.g. CBOR envelope).
#[error("serialization error: {0}")]
Serialization(String),

/// Cryptographic failures (AEAD, HKDF, etc.).
#[error("crypto error: {0}")]
Crypto(String),

/// Invalid or malformed envelope.
#[error("invalid envelope: {0}")]
InvalidEnvelope(String),

/// Unsupported envelope version.
#[error("unsupported envelope version: {0}")]
UnsupportedEnvelopeVersion(u32),

/// Errors coming from the underlying database.
#[error("db error: {0}")]
Db(String),

/// Database integrity check failed.
#[error("integrity check failed: {0}")]
IntegrityCheckFailed(String),
}

impl From<DbError> for StoreError {
fn from(err: DbError) -> Self {
Self::Db(err.to_string())
}
}
Loading
Loading