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
19 changes: 19 additions & 0 deletions entangled-core/src/document/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ use crate::canon::{
use crate::crypto::{CryptoError, VerifyingKey};
use crate::types::document::{ContentDocument, TransactionDocument};
use crate::types::keys::RuntimePubkey;
use crate::types::state::StatePolicyEntry;
use crate::types::timestamp::EntangledTimestamp;
use crate::validation::policy_check::validate_state_updates_against_policy;
use crate::validation::schema::{
parse_and_validate_content_with_value, parse_and_validate_manifest_with_value,
parse_and_validate_transaction_with_value,
Expand Down Expand Up @@ -223,9 +225,21 @@ pub fn parse_and_verify_content(
/// public key failing the §05 strict profile is rejected here as
/// `E_SIG_VERIFICATION`; `E_SIG_INVALID_KEY` is emitted only by callers
/// that detect "no verifying key is available" before reaching this stage.
///
/// `state_policy` carries the declared `state_policy` of the manifest under
/// which this transaction is being verified. When `Some`, each
/// `state_updates` entry is cross-checked against the declared policy after
/// signature verification, which can additionally produce
/// `E_STATE_UNDECLARED`, `E_STATE_VALUE_SIZE`, or `E_STATE_TTL` (the
/// policy-relative checks, §07, §11). Pass `None` to verify a transaction in
/// isolation, where no manifest policy is available; the absolute hard-range
/// state checks are still applied during Stage 5 schema validation. A caller
/// that has the manifest SHOULD pass `Some` so the undeclared-reference check
/// is not silently skipped.
pub fn parse_and_verify_transaction(
raw: &[u8],
runtime_pubkey: &RuntimePubkey,
state_policy: Option<&[StatePolicyEntry]>,
) -> Result<TransactionDocument, Diagnostic> {
let (tx, mut value) = parse_and_validate_transaction_with_value(raw)?;
// See `parse_and_verify_manifest` for the rationale on canonicalizing
Expand All @@ -242,6 +256,11 @@ pub fn parse_and_verify_transaction(
.map_err(|e| crypto_to_diagnostic(e, DocumentKindLabel::Transaction))?;
vk.verify(&input, &sig)
.map_err(|e| crypto_to_diagnostic(e, DocumentKindLabel::Transaction))?;
// Stage 5 (policy-aware): when the manifest's state_policy is available,
// every state_updates entry must reference a declared (namespace, key).
if let Some(policy) = state_policy {
validate_state_updates_against_policy(&tx.state_updates, policy)?;
}
Ok(tx)
}

Expand Down
12 changes: 12 additions & 0 deletions entangled-core/tests/conformance/corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ pub struct Context {
/// `wrap_successor_stage9_failure`.
#[serde(default)]
pub successor_manifest_path: Option<String>,
/// Content-index vectors: corpus-relative path of the
/// `content_index.json` served from the manifest's carrier origin.
/// Present on both the manifest-level vectors (230-231) and the
/// per-document seq vectors (232-235).
#[serde(default)]
pub content_index_path: Option<String>,
/// Per-document content-index vectors (232-235): the manifest's
/// declared `content_root`, the SHA-256 of the served index bytes.
/// Lets the runner verify the index and resolve the document's seq
/// without loading a separate manifest fixture.
#[serde(default)]
pub content_root: Option<String>,
}

impl Corpus {
Expand Down
31 changes: 31 additions & 0 deletions entangled-core/tests/conformance/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ mod runner;
use corpus::Corpus;
use runner::{run_vector, VectorOutcome};

/// Vectors that exercise functionality this crate documents as out of scope
/// at the crate root (the Stage 7 trust-state machine). They are reported as
/// skipped with a printed count rather than counted as failures, so the
/// coverage gap is visible and never silently passes. Each entry is
/// `(vector_id, reason)`. Remove an id here when the corresponding capability
/// lands in the crate.
const OUT_OF_SCOPE: &[(&str, &str)] = &[
(
"210-trust-publisher-key-mismatch",
"Stage 7 trust-state machine is out of scope for this crate",
),
(
"211-trust-user-rejected-new-identity",
"Stage 7 trust-state machine is out of scope for this crate",
),
];

#[test]
fn corpus_vectors_match_spec() {
let Some(corpus) = Corpus::try_load() else {
Expand All @@ -48,7 +65,12 @@ fn corpus_vectors_match_spec() {
);

let mut failures: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for vector in &corpus.vectors {
if let Some((_, reason)) = OUT_OF_SCOPE.iter().find(|(id, _)| *id == vector.id) {
skipped.push(format!("[{}] {}", vector.id, reason));
continue;
}
match run_vector(vector, &corpus) {
Ok(VectorOutcome::Match) => {}
Ok(VectorOutcome::Mismatch { detail }) => {
Expand All @@ -63,6 +85,15 @@ fn corpus_vectors_match_spec() {
}
}

if !skipped.is_empty() {
eprintln!(
"{} of {} vectors skipped as out of scope:\n - {}",
skipped.len(),
corpus.vectors.len(),
skipped.join("\n - ")
);
}

assert!(
failures.is_empty(),
"{} of {} vectors failed:\n - {}",
Expand Down
98 changes: 87 additions & 11 deletions entangled-core/tests/conformance/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@ use entangled_core::document::{
};
use entangled_core::state::SubmitBody;
use entangled_core::types::keys::RuntimePubkey;
use entangled_core::types::keys::{ContentHash, ContentRoot};
use entangled_core::types::manifest::{Manifest, OnionAddress};
use entangled_core::types::path::EntangledPath;
use entangled_core::types::state::StatePolicyEntry;
use entangled_core::types::timestamp::EntangledTimestamp;
use entangled_core::validation::canary::{
check_anti_downgrade, check_canary_conflict, check_runtime_pubkey_rotation,
RetainedManifestRecord,
};
use entangled_core::validation::content_index::{
validate_content_index, verify_content_against_index,
};
use entangled_core::validation::{
check_migration_chain_cycle, check_origin_not_after, verify_migration_announcement,
wrap_successor_stage9_failure, Diagnostic, DiagnosticCode, DocumentKindLabel,
Expand All @@ -49,8 +54,8 @@ pub fn run_vector(vector: &Vector, corpus: &Corpus) -> Result<VectorOutcome, Str

let actual = match vector.kind.as_str() {
"manifest" => run_manifest(vector, corpus, &raw, &now),
"content" => run_content(vector, &raw),
"transaction" => run_transaction(vector, corpus, &raw),
"content" => run_content(vector, corpus, &raw),
"transaction" => run_transaction(vector, corpus, &raw, &now),
other => return Err(format!("unknown vector kind {other}")),
}?;

Expand Down Expand Up @@ -251,12 +256,22 @@ fn run_manifest_pipeline(
let onion = OnionAddress::try_from(addr)
.map_err(|e| format!("context.fetched_origin_address invalid: {e}"))?;
match canary_checked.verify_origin(&onion, now) {
// Stage 9b (content-index verification) is exercised in a
// separate code path against the standalone helper; the
// main pipeline runner skips it here to keep the harness
// contract per-stage and avoid coupling to a content_index
// corpus payload at every vector.
Ok(b) => b.skip_content_index_check(),
// Stage 9b (content-index verification). Vectors that declare
// content_root carry the served index at context.content_index_path;
// run the real check for them. Vectors with no content index keep
// skipping Stage 9b, which is correct for a manifest that declares
// no content_root.
Ok(b) => {
if let Some(index_rel) = vector.context.content_index_path.as_deref() {
let index_bytes = read_input(corpus, index_rel)?;
match b.verify_content_index(Some(&index_bytes)) {
Ok(verified) => verified.into_parts().0,
Err(d) => return Ok(Err(d)),
}
} else {
b.skip_content_index_check()
}
}
Err(d) => return Ok(Err(d)),
}
} else {
Expand Down Expand Up @@ -356,7 +371,7 @@ fn canary_checked_publisher_pubkey(
read_successor_pubkey_unchecked(raw)
}

fn run_content(vector: &Vector, raw: &[u8]) -> Result<Verdict, String> {
fn run_content(vector: &Vector, corpus: &Corpus, raw: &[u8]) -> Result<Verdict, String> {
// Parse-stage rejections (Stages 2-5) never reach signature
// verification, so vectors that fail early may legitimately omit
// `expected_runtime_pubkey` from their context. Fall back to a
Expand Down Expand Up @@ -407,17 +422,58 @@ fn run_content(vector: &Vector, raw: &[u8]) -> Result<Verdict, String> {
}
}

// Stage 9b: content-index sequencing. Vectors that carry a verified
// content index supply the manifest's content_root in context and the
// served index bytes at context.content_index_path. Verify the index
// against content_root, then compare this document's seq and body hash
// against the committed entry for its path.
if let Some(content_root_str) = vector.context.content_root.as_deref() {
let index_rel = vector
.context
.content_index_path
.as_deref()
.ok_or_else(|| {
"content vector sets context.content_root but not content_index_path".to_owned()
})?;
let index_bytes = read_input(corpus, index_rel)?;
let content_root = ContentRoot::try_from(content_root_str)
.map_err(|e| format!("context.content_root invalid: {e}"))?;
let index = match validate_content_index(&index_bytes, &content_root) {
Ok(i) => i,
Err(d) => return Ok(Verdict::Reject(d)),
};
let body_hash = ContentHash::from_bytes(sha256(raw));
if let Err(d) =
verify_content_against_index(&index, content.path.as_str(), content.seq, &body_hash)
{
return Ok(Verdict::Reject(d));
}
}

Ok(Verdict::Accept)
}

fn run_transaction(vector: &Vector, corpus: &Corpus, raw: &[u8]) -> Result<Verdict, String> {
fn run_transaction(
vector: &Vector,
corpus: &Corpus,
raw: &[u8],
now: &EntangledTimestamp,
) -> Result<Verdict, String> {
let runtime_pk = match vector.context.expected_runtime_pubkey.as_deref() {
Some(b64) => RuntimePubkey::try_from(b64)
.map_err(|e| format!("context.expected_runtime_pubkey invalid: {e}"))?,
None => RuntimePubkey::from_bytes([0u8; 32]),
};

let tx = match parse_and_verify_transaction(raw, &runtime_pk) {
// When the vector references the manifest under which this transaction is
// verified, load its state_policy so parse_and_verify_transaction can
// cross-check state_updates against the declared (namespace, key) set.
let state_policy = match vector.context.previously_verified.as_deref() {
Some(prev_rel) => Some(load_state_policy(corpus, prev_rel, now)?),
None => None,
};

let tx = match parse_and_verify_transaction(raw, &runtime_pk, state_policy.as_deref()) {
Ok(t) => t,
Err(d) => return Ok(Verdict::Reject(d)),
};
Expand Down Expand Up @@ -552,6 +608,26 @@ fn manifest_payload_hash(raw: &[u8]) -> Result<[u8; 32], String> {
Ok(sha256(&canonical))
}

/// Load and verify the manifest referenced by `context.previously_verified`
/// and return its declared `state_policy`. Used to give transaction vectors
/// the manifest policy that `parse_and_verify_transaction` cross-checks
/// `state_updates` against (E_STATE_UNDECLARED and the policy-relative state
/// checks).
fn load_state_policy(
corpus: &Corpus,
prev_rel: &str,
now: &EntangledTimestamp,
) -> Result<Vec<StatePolicyEntry>, String> {
let raw = read_input(corpus, prev_rel)?;
let sig_verified = parse_and_verify_manifest(&raw, now)
.map_err(|d: Diagnostic| format!("previously_verified {prev_rel} failed parse: {d}"))?;
let canary_checked = sig_verified
.verify_canary(now)
.map_err(|d: Diagnostic| format!("previously_verified {prev_rel} failed canary: {d}"))?;
let manifest = canary_checked.skip_origin_check();
Ok(manifest.state_policy.clone())
}

fn build_retained_record(
corpus: &Corpus,
prev_rel: &str,
Expand Down
4 changes: 2 additions & 2 deletions entangled-core/tests/document/build_parse_roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ fn transaction_round_trip() {
let unsigned = unsigned_transaction();

let (tx, bytes) = build_transaction(&unsigned, &runtime_key).expect("build_transaction");
let parsed =
parse_and_verify_transaction(&bytes, &runtime_pk).expect("parse_and_verify_transaction");
let parsed = parse_and_verify_transaction(&bytes, &runtime_pk, None)
.expect("parse_and_verify_transaction");

assert_eq!(parsed, tx);
}
Expand Down
2 changes: 1 addition & 1 deletion entangled-core/tests/document/cross_kind_rejection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ fn content_bytes_parsed_as_transaction_rejected() {
let unsigned = unsigned_content();
let (_content, bytes) = build_content(&unsigned, &runtime_key).expect("build content");

let err = parse_and_verify_transaction(&bytes, &runtime_pk)
let err = parse_and_verify_transaction(&bytes, &runtime_pk, None)
.expect_err("transaction parse must reject content body");
assert_eq!(err.code, DiagnosticCode::EKindUnknown);
}
Expand Down
Loading