feat(agent): Ed25519-signed checkpoints
Makes the README's "cryptographically verifiable memory" true. With HDF5Memory::set_signing_key(key), every checkpoint stores a signed manifest of the store: a SHA-256 per memory record (text, embedding as stored, channel, timestamp, session, tags, deleted flag, activation) in a Merkle tree, plus hashes of the settings (and WAL mark), sessions and knowledge graph. The signature, public key and manifest hashes go in /meta; the per-record hashes in /integrity/record_hashes, so HDF5Memory::verify(path, &public_key) can say which records changed, not just that something did. A forged manifest fails the signature. Decisions, as agreed: - the key is set on the open store and never persisted; - a signed store refuses to checkpoint without its key (MemoryError::SigningKeyRequired); remove_signature() is the deliberate way back to unsigned; - checkpoints only: saves still in the WAL are not covered, and verify reports how many there are. The hashes cover exactly what the file persists, in the form the loader returns it (strings lose trailing NULs; an empty WAL mark is not written), so untouched stores verify across any number of reopen and checkpoint cycles. MemoryError becomes #[non_exhaustive] (it already gains variants in this unreleased version). CLI: keygen (owner-only key file), --signing-key / CLAWHDF5_SIGNING_KEY on writing commands (create signs immediately), verify --public-key (JSON; exit 2 if not valid), `signed` in create/stats output. Tests: reopen/checkpoint cycles with awkward strings (f16 and f32), refusal without the key, wrong and rotated keys, eight kinds of edit each detected and located, a forged manifest, unsigned stores, NULs in text, and an edit made in place with h5py that verify pinpoints. Cost on tank (search_harness --signing-study --full, 3 runs): ~20% of a checkpoint (+9 ms at 10K, +89-112 ms at 100K), verify 18.6 ms / 247 ms, 32 bytes per record in the file. New deps ed25519-dalek, sha2, rand_core: pure Rust, the no-C check passes. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
@@ -23,6 +23,7 @@ pub const ZEROCLAW_VERSION: &str = "0.8.0";
|
||||
const WAL_APPLIED_LEN_ATTR: &str = "wal_applied_len";
|
||||
const WAL_APPLIED_CRC_ATTR: &str = "wal_applied_crc";
|
||||
const ANN_GENERATION_ATTR: &str = "ann_generation";
|
||||
const SIG_VERSION_ATTR: &str = "sig_version";
|
||||
|
||||
/// Build a complete HDF5 file from the in-memory state.
|
||||
pub fn build_hdf5_file(
|
||||
@@ -46,7 +47,7 @@ pub fn build_hdf5_file_with_mark(
|
||||
) -> Result<Vec<u8>, MemoryError> {
|
||||
let meta = CheckpointMeta {
|
||||
wal_applied,
|
||||
ann_generation: None,
|
||||
..CheckpointMeta::default()
|
||||
};
|
||||
build_hdf5_file_with_meta(config, cache, sessions, knowledge, &meta)
|
||||
}
|
||||
@@ -61,6 +62,10 @@ pub struct CheckpointMeta {
|
||||
/// one left over from another checkpoint can never be attached to records
|
||||
/// it wasn't built from.
|
||||
pub ann_generation: Option<u64>,
|
||||
/// The checkpoint carries an Ed25519 signature (see [`crate::signing`]).
|
||||
/// Read-only: whether a checkpoint is *written* signed is decided by the
|
||||
/// signature passed to [`build_hdf5_file_signed`].
|
||||
pub signed: bool,
|
||||
}
|
||||
|
||||
/// [`build_hdf5_file`] with checkpoint bookkeeping.
|
||||
@@ -70,6 +75,19 @@ pub fn build_hdf5_file_with_meta(
|
||||
sessions: &SessionCache,
|
||||
knowledge: &KnowledgeCache,
|
||||
checkpoint: &CheckpointMeta,
|
||||
) -> Result<Vec<u8>, MemoryError> {
|
||||
build_hdf5_file_signed(config, cache, sessions, knowledge, checkpoint, None)
|
||||
}
|
||||
|
||||
/// [`build_hdf5_file_with_meta`], plus a signed manifest of the contents
|
||||
/// (see [`crate::signing`]).
|
||||
pub fn build_hdf5_file_signed(
|
||||
config: &MemoryConfig,
|
||||
cache: &MemoryCache,
|
||||
sessions: &SessionCache,
|
||||
knowledge: &KnowledgeCache,
|
||||
checkpoint: &CheckpointMeta,
|
||||
signature: Option<&crate::signing::StoredSignature>,
|
||||
) -> Result<Vec<u8>, MemoryError> {
|
||||
let wal_applied = checkpoint.wal_applied;
|
||||
let mut builder = clawhdf5::FileBuilder::new();
|
||||
@@ -130,11 +148,42 @@ pub fn build_hdf5_file_with_meta(
|
||||
// round trip through every reader.
|
||||
meta.set_attr(ANN_GENERATION_ATTR, AttrValue::I64(generation as i64));
|
||||
}
|
||||
if let Some(sig) = signature {
|
||||
use crate::signing::to_hex;
|
||||
let m = &sig.manifest;
|
||||
meta.set_attr(
|
||||
SIG_VERSION_ATTR,
|
||||
AttrValue::I64(crate::signing::MANIFEST_VERSION),
|
||||
);
|
||||
meta.set_attr("sig_algorithm", AttrValue::String("ed25519".into()));
|
||||
meta.set_attr("sig_public_key", AttrValue::String(to_hex(&sig.public_key)));
|
||||
meta.set_attr("sig_signature", AttrValue::String(to_hex(&sig.signature)));
|
||||
meta.set_attr("sig_record_count", AttrValue::I64(m.record_count as i64));
|
||||
meta.set_attr(
|
||||
"sig_records_root",
|
||||
AttrValue::String(to_hex(&m.records_root)),
|
||||
);
|
||||
meta.set_attr("sig_settings", AttrValue::String(to_hex(&m.settings)));
|
||||
meta.set_attr("sig_sessions", AttrValue::String(to_hex(&m.sessions)));
|
||||
meta.set_attr("sig_graph", AttrValue::String(to_hex(&m.graph)));
|
||||
}
|
||||
// Need at least one dataset in the group for it to be a proper group
|
||||
meta.create_dataset("_marker").with_u8_data(&[1]).compact();
|
||||
let finished_meta = meta.finish();
|
||||
builder.add_group(finished_meta);
|
||||
|
||||
// /integrity: the signed per-record hashes, so verification can say
|
||||
// which records changed.
|
||||
if let Some(sig) = signature {
|
||||
let mut group = builder.create_group("integrity");
|
||||
let flat: Vec<u8> = sig.record_hashes.iter().flatten().copied().collect();
|
||||
group
|
||||
.create_dataset("record_hashes")
|
||||
.with_u8_data(&flat)
|
||||
.with_shape(&[sig.record_hashes.len() as u64, 32]);
|
||||
builder.add_group(group.finish());
|
||||
}
|
||||
|
||||
// /memory group
|
||||
build_memory_group(&mut builder, config, cache)?;
|
||||
|
||||
@@ -440,6 +489,64 @@ pub fn read_wal_mark(file: &clawhdf5::File) -> Option<WalMark> {
|
||||
Some(WalMark { len, crc })
|
||||
}
|
||||
|
||||
/// Read a checkpoint's signature, if it has one. A signature whose
|
||||
/// attributes are present but malformed is an error, not "unsigned".
|
||||
pub fn read_signature(
|
||||
file: &clawhdf5::File,
|
||||
) -> Result<Option<crate::signing::StoredSignature>, MemoryError> {
|
||||
use crate::signing::{Manifest, StoredSignature, from_hex};
|
||||
let attrs = file
|
||||
.group("meta")
|
||||
.and_then(|g| g.attrs())
|
||||
.map_err(|e| MemoryError::Schema(format!("cannot read /meta attrs: {e}")))?;
|
||||
let version = match attrs.get(SIG_VERSION_ATTR) {
|
||||
None => return Ok(None),
|
||||
Some(AttrValue::I64(v)) => *v,
|
||||
Some(_) => return Err(MemoryError::Schema("malformed sig_version".into())),
|
||||
};
|
||||
if version != crate::signing::MANIFEST_VERSION {
|
||||
return Err(MemoryError::Schema(format!(
|
||||
"unsupported signature version {version}"
|
||||
)));
|
||||
}
|
||||
fn hex<const N: usize>(
|
||||
attrs: &std::collections::HashMap<String, AttrValue>,
|
||||
name: &str,
|
||||
) -> Result<[u8; N], MemoryError> {
|
||||
match attrs.get(name) {
|
||||
Some(AttrValue::String(s)) => from_hex::<N>(s),
|
||||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| MemoryError::Schema(format!("malformed or missing {name}")))
|
||||
}
|
||||
let record_count = match attrs.get("sig_record_count") {
|
||||
Some(AttrValue::I64(v)) if *v >= 0 => *v as u64,
|
||||
_ => return Err(MemoryError::Schema("malformed sig_record_count".into())),
|
||||
};
|
||||
let group = file
|
||||
.group("integrity")
|
||||
.map_err(|e| MemoryError::Schema(format!("signed checkpoint without /integrity: {e}")))?;
|
||||
let flat = read_u8_dataset(&group, "record_hashes")?;
|
||||
if flat.len() % 32 != 0 {
|
||||
return Err(MemoryError::Schema(
|
||||
"/integrity/record_hashes is not a whole number of hashes".into(),
|
||||
));
|
||||
}
|
||||
let record_hashes = flat.as_chunks::<32>().0.to_vec();
|
||||
Ok(Some(StoredSignature {
|
||||
manifest: Manifest {
|
||||
record_count,
|
||||
records_root: hex::<32>(&attrs, "sig_records_root")?,
|
||||
settings: hex::<32>(&attrs, "sig_settings")?,
|
||||
sessions: hex::<32>(&attrs, "sig_sessions")?,
|
||||
graph: hex::<32>(&attrs, "sig_graph")?,
|
||||
},
|
||||
record_hashes,
|
||||
public_key: hex::<32>(&attrs, "sig_public_key")?,
|
||||
signature: hex::<64>(&attrs, "sig_signature")?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Read the checkpoint bookkeeping from `/meta`.
|
||||
pub fn read_checkpoint_meta(file: &clawhdf5::File) -> CheckpointMeta {
|
||||
let ann_generation = file
|
||||
@@ -450,9 +557,14 @@ pub fn read_checkpoint_meta(file: &clawhdf5::File) -> CheckpointMeta {
|
||||
Some(AttrValue::I64(v)) => Some(*v as u64),
|
||||
_ => None,
|
||||
});
|
||||
let signed = file
|
||||
.group("meta")
|
||||
.and_then(|g| g.attrs())
|
||||
.is_ok_and(|attrs| attrs.contains_key(SIG_VERSION_ATTR));
|
||||
CheckpointMeta {
|
||||
wal_applied: read_wal_mark(file),
|
||||
ann_generation,
|
||||
signed,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user