Skip to content
Draft
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
7 changes: 6 additions & 1 deletion crates/hashi-guardian-proxy/src/widlog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,12 @@ pub(crate) mod test_store {
&signing_key,
);
record.timestamp_ms = timestamp_ms;
let key = record.object_key();
record.object_key = format!(
"{}{}",
record.message.log_dir(timestamp_ms),
record.message.log_name(&record.session_id, None).unwrap()
);
let key = record.object_key().to_string();
(key, serde_json::to_vec(&record).unwrap())
}

Expand Down
6 changes: 6 additions & 0 deletions crates/hashi-guardian/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

Guardian enclave service that emits immutable S3 logs for audit/state-restart workflows.

The S3 bucket operator is untrusted. Log signatures bind the intent, schema
version, session ID, timestamp, actual object key, and event; readers reject
relocated or non-canonical records. The key is S3 metadata, not JSON. Random
failure suffixes are sampled once before signing. Unsigned OI attestations bind
placement through their Nitro-authenticated signing key and derived session ID.

## S3 log key format

Canonical key layout:
Expand Down
31 changes: 18 additions & 13 deletions crates/hashi-guardian/src/s3_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ impl GuardianS3Client {
// ========================================================================

pub async fn write_log_record(&self, log: LogRecord) -> GuardianResult<()> {
let key = log.object_key().to_string();
let object_lock_duration = log.object_lock_duration();
let key = log.object_key();
self.write_at_key(&key, &log, object_lock_duration).await
}

Expand Down Expand Up @@ -275,7 +275,7 @@ impl GuardianS3Client {
/// tampering / lock-expiry beyond what the immutable-log model allows).
/// Returned keys are unique and sorted lexicographically.
///
/// Keys-only counterpart to [`Self::list_all_objects_in_dir`].
/// Keys-only counterpart to [`Self::list_all_log_records_in_dir`].
pub async fn list_all_keys_in_dir(&self, prefix: &str) -> GuardianResult<Vec<String>> {
self.ensure_no_duplicates_or_deletions(prefix).await
}
Expand Down Expand Up @@ -408,17 +408,18 @@ impl GuardianS3Client {
/// Batch read. Callers must ensure that all objects with prefix `dir.to_string()` have
/// unexpired compliance-mode object locks.
///
/// Returns: List of objects.
pub async fn list_all_objects_in_dir<T: DeserializeOwned>(
/// Each returned record carries the actual S3 key from which it was read.
pub async fn list_all_log_records_in_dir(
&self,
dir: &S3HourScopedDirectory,
) -> GuardianResult<Vec<T>> {
) -> GuardianResult<Vec<LogRecord>> {
let prefix = dir.to_string();
let keys = self.ensure_no_duplicates_or_deletions(&prefix).await?;
let mut out = Vec::with_capacity(keys.len());
for key in &keys {
let obj: T = self.get_object_unsafe(key).await?;
out.push(obj);
for key in keys {
let mut record: LogRecord = self.get_object_unsafe(&key).await?;
record.object_key = key;
out.push(record);
}
Ok(out)
}
Expand Down Expand Up @@ -473,7 +474,7 @@ impl GuardianS3Client {
/// signature rather than S3 immutability (e.g. `kp-shares/`, which carry only a
/// short lock that is expected to expire). A tampered object fails the
/// caller's signature check; a purged one surfaces as a get error.
pub async fn get_object_no_lock<T: DeserializeOwned>(&self, key: &str) -> GuardianResult<T> {
pub async fn get_log_record_no_lock(&self, key: &str) -> GuardianResult<LogRecord> {
let response = self
.client
.get_object()
Expand All @@ -497,12 +498,14 @@ impl GuardianS3Client {
))
})?;

serde_json::from_slice::<T>(&bytes.into_bytes()).map_err(|e| {
let mut record = serde_json::from_slice::<LogRecord>(&bytes.into_bytes()).map_err(|e| {
S3Error(format!(
"Failed to deserialize object {} into target type: {}",
key, e
))
})
})?;
record.object_key = key.to_owned();
Ok(record)
}

/// Reads an immutable-log object, asserting its Compliance lock metadata is
Expand All @@ -519,7 +522,9 @@ impl GuardianS3Client {
)));
}

self.get_object_unsafe::<LogRecord>(key).await
let mut record = self.get_object_unsafe::<LogRecord>(key).await?;
record.object_key = key.to_owned();
Ok(record)
}

/// Note: Callers must set signing_pubkey to None only for unsigned messages.
Expand All @@ -533,7 +538,7 @@ impl GuardianS3Client {
if log.session_id != expected_session_id {
return Err(S3Error(format!("log session_id mismatch for key {}", key)));
}
let (_, _, message) = match signing_pubkey {
let (_, _, _, message) = match signing_pubkey {
Some(pk) => log.verify(pk),
None => log.verify_unsigned(),
}?;
Expand Down
13 changes: 6 additions & 7 deletions crates/hashi-guardian/src/s3_reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ impl GuardianReader {
) -> anyhow::Result<Vec<VerifiedLogRecord>> {
let all_logs = self
.s3
.list_all_objects_in_dir::<LogRecord>(dir)
.list_all_log_records_in_dir(dir)
.await
.with_context(|| format!("failed to list guardian logs in {dir}"))?;

Expand Down Expand Up @@ -233,18 +233,14 @@ impl GuardianReader {
let Some(key) = keys.into_iter().max() else {
return Ok(None);
};
let record: LogRecord = self.s3.get_object_no_lock(&key).await?;
let record = self.s3.get_log_record_no_lock(&key).await?;
let record = self.cache.verify_record(&self.s3, record).await?;
let session_id = record.session_id.clone();
let build_pcrs = record.build_pcrs.clone();
self.enforce_build_policy("kp-shares log", build_policy, &build_pcrs)?;
let LogMessage::KpShareState(msg) = record.message else {
anyhow::bail!("expected a kp-shares log at {key}");
};
let expected_key = KpShareState::object_key(&session_id, msg.sharing_seq, msg.cert_seq);
if key != expected_key {
anyhow::bail!("kp-shares key mismatch: expected {expected_key}, got {key}");
}
if msg.sharing_seq != sharing_seq {
anyhow::bail!(
"sharing_seq mismatch: {} != {}",
Expand Down Expand Up @@ -364,13 +360,16 @@ impl GuardianSessionCache {
s3: &GuardianS3Client,
record: LogRecord,
) -> anyhow::Result<VerifiedLogRecord> {
let object_key = record.object_key.clone();
let session_info = self
.get_or_load_session_info(s3, &record.session_id)
.await?;
record
.verify(&session_info.signing_pubkey)
.map(|(session_id, timestamp_ms, message)| {
.map(|(schema_version, session_id, timestamp_ms, message)| {
VerifiedLogRecord::new(
schema_version,
object_key,
session_id,
timestamp_ms,
message,
Expand Down
4 changes: 4 additions & 0 deletions crates/hashi-guardian/src/s3_reader/heartbeat_checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ mod tests {

fn heartbeat_log(session_id: &str, timestamp_secs: UnixSeconds) -> VerifiedLogRecord {
VerifiedLogRecord {
schema_version: hashi_types::guardian::LOG_SCHEMA_VERSION,
object_key: format!("heartbeat/{session_id}.json"),
session_id: session_id.to_string(),
timestamp_ms: timestamp_secs * 1_000,
message: LogMessage::Heartbeat { seq: 0 },
Expand All @@ -168,6 +170,8 @@ mod tests {

fn non_heartbeat_log() -> VerifiedLogRecord {
VerifiedLogRecord {
schema_version: hashi_types::guardian::LOG_SCHEMA_VERSION,
object_key: "init/test-session-pi-enclave-fully-initialized.json".to_string(),
session_id: "test-session".to_string(),
timestamp_ms: 0,
message: LogMessage::Init(Box::new(InitLogMessage::PIEnclaveFullyInitialized {
Expand Down
4 changes: 4 additions & 0 deletions crates/hashi-guardian/src/s3_reader/limiter_recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ mod tests {
post_state: state_with_seq(next_seq),
};
VerifiedLogRecord {
schema_version: hashi_types::guardian::LOG_SCHEMA_VERSION,
object_key: format!("withdraw/success-{next_seq}.json"),
session_id: "test-session".to_string(),
timestamp_ms: 0,
message: LogMessage::Withdrawal(Box::new(msg)),
Expand All @@ -202,6 +204,8 @@ mod tests {
error: GuardianError::RateLimitExceeded,
};
VerifiedLogRecord {
schema_version: hashi_types::guardian::LOG_SCHEMA_VERSION,
object_key: "withdraw/failure.json".to_string(),
session_id: "test-session".to_string(),
timestamp_ms: 0,
message: LogMessage::Withdrawal(Box::new(msg)),
Expand Down
Loading
Loading