mod schema;
#[cfg(test)]
mod tests;
use std::path::Path;
use crate::storage::error::{StorageError, StorageResult};
use crate::storage::types::{BlobKind, CredentialRecord};
use schema::{ensure_schema, VAULT_SCHEMA_VERSION};
use secrecy::SecretBox;
use walletkit_db::{blobs, cipher, params, DbError, Row, StepResult, Value, Vault};
pub(crate) const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"];
#[derive(Debug)]
pub struct CredentialVault {
vault: Vault,
}
impl CredentialVault {
pub fn new(
path: &Path,
k_intermediate: &SecretBox<[u8; 32]>,
) -> StorageResult<Self> {
let vault = Vault::open(path, k_intermediate, |conn| {
blobs::ensure_schema(conn)?;
ensure_schema(conn)
})?;
Ok(Self { vault })
}
pub fn init_leaf_index(&self, leaf_index: u64, now: u64) -> StorageResult<()> {
let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?;
let now_i64 = to_i64(now, "now")?;
let conn = self.vault.connection();
let tx = conn.transaction().map_err(|err| map_db_err(&err))?;
let stored = tx
.query_row(
"INSERT INTO vault_meta (schema_version, leaf_index, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)
ON CONFLICT(schema_version) DO UPDATE SET
leaf_index = CASE
WHEN vault_meta.leaf_index IS NULL
THEN excluded.leaf_index
ELSE vault_meta.leaf_index
END
RETURNING leaf_index",
params![VAULT_SCHEMA_VERSION, leaf_index_i64, now_i64],
|stmt| Ok(stmt.column_i64(0)),
)
.map_err(|err| map_db_err(&err))?;
if stored != leaf_index_i64 {
let expected = to_u64(stored, "leaf_index")?;
return Err(StorageError::InvalidLeafIndex {
expected,
provided: leaf_index,
});
}
tx.commit().map_err(|err| map_db_err(&err))?;
Ok(())
}
#[expect(
clippy::too_many_arguments,
reason = "fields mirror the credential record schema"
)]
#[expect(
clippy::needless_pass_by_value,
reason = "byte buffers are consumed here; callers don't reuse them"
)]
pub fn store_credential(
&self,
issuer_schema_id: u64,
subject_blinding_factor: Vec<u8>,
genesis_issued_at: u64,
expires_at: u64,
credential_blob: Vec<u8>,
associated_data: Option<Vec<u8>>,
now: u64,
) -> StorageResult<u64> {
let now_i64 = to_i64(now, "now")?;
let issuer_schema_id_i64 = to_i64(issuer_schema_id, "issuer_schema_id")?;
let genesis_issued_at_i64 = to_i64(genesis_issued_at, "genesis_issued_at")?;
let expires_at_i64 = to_i64(expires_at, "expires_at")?;
let conn = self.vault.connection();
let tx = conn.transaction().map_err(|err| map_db_err(&err))?;
let credential_blob_id = blobs::put(
conn,
BlobKind::CredentialBlob as u8,
credential_blob.as_slice(),
now,
)?;
let associated_data_id = associated_data
.as_ref()
.map(|data| {
blobs::put(conn, BlobKind::AssociatedData as u8, data.as_slice(), now)
})
.transpose()?;
let ad_cid_value: Value = associated_data_id
.as_ref()
.map_or(Value::Null, |cid| Value::Blob(cid.to_vec()));
let credential_id = tx
.query_row(
"INSERT INTO credential_records (
issuer_schema_id,
subject_blinding_factor,
genesis_issued_at,
expires_at,
updated_at,
credential_blob_cid,
associated_data_cid
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
RETURNING credential_id",
params![
issuer_schema_id_i64,
subject_blinding_factor,
genesis_issued_at_i64,
expires_at_i64,
now_i64,
credential_blob_id.as_slice(),
ad_cid_value,
],
|stmt| Ok(stmt.column_i64(0)),
)
.map_err(|err| map_db_err(&err))?;
tx.commit().map_err(|err| map_db_err(&err))?;
to_u64(credential_id, "credential_id")
}
pub fn list_credentials(
&self,
issuer_schema_id: Option<u64>,
now: u64,
) -> StorageResult<Vec<CredentialRecord>> {
let now_i64 = to_i64(now, "now")?;
let issuer_schema_id_i64 = issuer_schema_id
.map(|value| to_i64(value, "issuer_schema_id"))
.transpose()?;
let mut records = Vec::new();
let issuer_filter = issuer_schema_id_i64.map_or(Value::Null, Value::Integer);
let sql = "SELECT
cr.credential_id,
cr.issuer_schema_id,
cr.genesis_issued_at,
cr.expires_at,
CASE WHEN cr.expires_at <= ?1 THEN 1 ELSE 0 END AS is_expired
FROM credential_records cr
WHERE (?2 IS NULL OR cr.issuer_schema_id = ?2)
ORDER BY cr.updated_at DESC";
let mut stmt = self
.vault
.connection()
.prepare(sql)
.map_err(|err| map_db_err(&err))?;
stmt.bind_values(&[Value::Integer(now_i64), issuer_filter])
.map_err(|err| map_db_err(&err))?;
while let StepResult::Row(row) = stmt.step().map_err(|err| map_db_err(&err))? {
records.push(map_record(&row)?);
}
Ok(records)
}
pub fn delete_credential(&self, credential_id: u64) -> StorageResult<()> {
let credential_id_i64 = to_i64(credential_id, "credential_id")?;
let conn = self.vault.connection();
let tx = conn.transaction().map_err(|err| map_db_err(&err))?;
let deleted = tx
.execute(
"DELETE FROM credential_records WHERE credential_id = ?1",
params![credential_id_i64],
)
.map_err(|err| map_db_err(&err))?;
if deleted == 0 {
return Err(StorageError::CredentialIdNotFound { credential_id });
}
tx.execute(
"DELETE FROM blob_objects
WHERE blob_kind = ?1
AND NOT EXISTS (
SELECT 1
FROM credential_records cr
WHERE cr.credential_blob_cid = blob_objects.content_id
)",
params![BlobKind::CredentialBlob.as_i64()],
)
.map_err(|err| map_db_err(&err))?;
tx.execute(
"DELETE FROM blob_objects
WHERE blob_kind = ?1
AND NOT EXISTS (
SELECT 1
FROM credential_records cr
WHERE cr.associated_data_cid = blob_objects.content_id
)",
params![BlobKind::AssociatedData.as_i64()],
)
.map_err(|err| map_db_err(&err))?;
tx.commit().map_err(|err| map_db_err(&err))?;
Ok(())
}
pub fn fetch_credential_and_blinding_factor(
&self,
issuer_schema_id: u64,
now: u64,
) -> StorageResult<Option<(Vec<u8>, Vec<u8>)>> {
let expires = to_i64(now, "now")?;
let issuer_schema_id_i64 = to_i64(issuer_schema_id, "issuer_schema_id")?;
let sql = "SELECT
cr.subject_blinding_factor,
blob.bytes as credential_blob
FROM credential_records cr
INNER JOIN blob_objects blob ON cr.credential_blob_cid = blob.content_id
WHERE cr.expires_at > ?1 AND cr.issuer_schema_id = ?2
ORDER BY cr.updated_at DESC
LIMIT 1";
let mut stmt = self
.vault
.connection()
.prepare(sql)
.map_err(|err| map_db_err(&err))?;
stmt.bind_values(params![expires, issuer_schema_id_i64])
.map_err(|err| map_db_err(&err))?;
match stmt.step().map_err(|err| map_db_err(&err))? {
StepResult::Row(row) => {
let blinding_factor = row.column_blob(0);
let credential_blob = row.column_blob(1);
Ok(Some((credential_blob, blinding_factor)))
}
StepResult::Done => Ok(None),
}
}
pub fn danger_delete_all_credentials(&self) -> StorageResult<u64> {
let conn = self.vault.connection();
let tx = conn.transaction().map_err(|err| map_db_err(&err))?;
let deleted = tx
.execute("DELETE FROM credential_records", &[])
.map_err(|err| map_db_err(&err))?;
tx.execute("DELETE FROM blob_objects", &[])
.map_err(|err| map_db_err(&err))?;
tx.commit().map_err(|err| map_db_err(&err))?;
Ok(deleted as u64)
}
pub fn check_integrity(&self) -> StorageResult<bool> {
cipher::integrity_check(self.vault.connection()).map_err(|e| map_db_err(&e))
}
pub fn export_plaintext(&self, dest: &Path) -> StorageResult<()> {
let conn = self.vault.connection();
if dest.exists() {
std::fs::remove_file(dest).map_err(|e| {
StorageError::VaultDb(format!("failed to remove stale backup: {e}"))
})?;
}
cipher::export_plaintext_copy(conn, dest, BACKUP_TABLES)
.map_err(|e| map_db_err(&e))
}
pub fn import_plaintext(&self, source: &Path) -> StorageResult<()> {
let conn = self.vault.connection();
cipher::import_plaintext_copy(conn, source, BACKUP_TABLES)
.map_err(|e| map_db_err(&e))
}
}
fn map_record(row: &Row<'_, '_>) -> StorageResult<CredentialRecord> {
let credential_id = row.column_i64(0);
let issuer_schema_id = row.column_i64(1);
let genesis_issued_at = row.column_i64(2);
let expires_at = row.column_i64(3);
let is_expired = row.column_i64(4);
Ok(CredentialRecord {
credential_id: to_u64(credential_id, "credential_id")?,
issuer_schema_id: to_u64(issuer_schema_id, "issuer_schema_id")?,
genesis_issued_at: to_u64(genesis_issued_at, "genesis_issued_at")?,
expires_at: to_u64(expires_at, "expires_at")?,
is_expired: is_expired != 0,
})
}
fn to_i64(value: u64, label: &str) -> StorageResult<i64> {
i64::try_from(value).map_err(|_| {
StorageError::VaultDb(format!("{label} out of range for i64: {value}"))
})
}
fn to_u64(value: i64, label: &str) -> StorageResult<u64> {
u64::try_from(value).map_err(|_| {
StorageError::VaultDb(format!("{label} out of range for u64: {value}"))
})
}
fn map_db_err(err: &DbError) -> StorageError {
StorageError::VaultDb(err.to_string())
}