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
14 changes: 12 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,13 @@ protocol; explicit full verification remains available to check local storage.
observed storage head as valid, invalid, unverifiable, unsupported, unavailable,
or discontinuous. Recovery publication is permitted only for an invalid head.
Unverifiable, unsupported, unavailable, and discontinuous heads MUST never be
overridden. In particular, a device that cannot open the current envelope
overridden. A head is invalid only when its content is positively proven bad:
a content-ID, decryption, key-commitment, or signature failure, malformed
bytes, or a failed semantic validation. Any other failure MUST be classified
unverifiable. A manifest or policy whose format version, authorization
variant, or administrator threshold is unknown is unsupported, and the version
MUST be read before the full schema so a future schema is not mistaken for
malformed bytes. In particular, a device that cannot open the current envelope
MUST NOT recover from an older floor, because the head may be a valid
revocation. A signer ID extracted before successful signature verification
MUST be labelled as a claim, not as the signer.
Expand All @@ -494,7 +500,11 @@ content ID matches, so a corrupt copy cannot hide an older valid one. Bytes
read from rejected copies count against the byte budget.
Discovery MUST report authenticated descendants and replays. If authenticated
descendants conflict, recovery MUST refuse to choose a fork. A replayed older
manifest MUST NOT be treated as a newer candidate. Directory storage has no
manifest MUST NOT be treated as a newer candidate. A historical head in the
floor's authenticated chain is a replay and MUST be recognised without opening
it, because a device added after genesis cannot open older generations. Any
other historical head that is not proven invalid and is not an authenticated
descendant of the floor MUST block publication. Directory storage has no
outer history and MUST offer only the pinned floor.

The default base MUST be the newest validated descendant of the floor, or the
Expand Down
8 changes: 8 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,11 @@ pub struct ForeignRepository;
#[derive(Debug, Error)]
#[error("{0}")]
pub struct BudgetExhausted(pub String);

/// Authenticated content is proven bad: it fails decryption, a signature,
/// a key commitment, or a content ID, or it is malformed or semantically
/// invalid. Recovery classifies a head as invalid only through this type;
/// every unrecognized failure blocks recovery.
#[derive(Debug, Error)]
#[error("authenticated repository state is invalid")]
pub struct InvalidState;
114 changes: 110 additions & 4 deletions src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,55 @@ pub fn seal_manifest(
})?)
}

/// Checks the format version before the full schema, so a future format is
/// reported as unsupported instead of malformed.
fn require_supported_format(encrypted: &[u8]) -> Result<()> {
#[derive(Deserialize)]
struct EnvelopeProbe {
header: String,
}
#[derive(Deserialize)]
struct HeaderProbe {
format_version: u64,
}
let envelope: EnvelopeProbe =
serde_json::from_slice(encrypted).context("parse manifest envelope")?;
let header = BASE64
.decode(envelope.header)
.context("decode manifest header")?;
let probe: HeaderProbe =
serde_json::from_slice(&header).context("parse manifest format version")?;
if probe.format_version != u64::from(FORMAT_VERSION) {
bail!(Unsupported(format!(
"unsupported manifest format {}",
probe.format_version
)))
}
Ok(())
}

/// Checks the transition discriminator before the full sealed-header schema.
fn require_supported_authorization(exact_sealed_content: &[u8]) -> Result<()> {
#[derive(Deserialize)]
struct AuthorizationProbe {
authorization: String,
}
let probe: AuthorizationProbe = serde_json::from_slice(exact_sealed_content)
.context("parse sealed manifest authorization")?;
let known = serde_json::from_value::<ManifestAuthorization>(serde_json::Value::String(
probe.authorization.clone(),
));
if known.is_err() {
bail!(Unsupported(format!(
"unsupported manifest authorization {:?}",
probe.authorization
)))
}
Ok(())
}

pub fn peek_manifest_header(encrypted: &[u8]) -> Result<ManifestHeader> {
require_supported_format(encrypted)?;
let envelope: ManifestEnvelope =
serde_json::from_slice(encrypted).context("parse manifest envelope")?;
let exact_header = BASE64
Expand Down Expand Up @@ -369,6 +417,7 @@ pub fn unwrap_generation_key(header: &ManifestHeader, key: &KeyFile) -> Result<S
}

pub fn open_manifest(encrypted: &[u8], generation_key: &[u8; 32]) -> Result<OpenedManifest> {
require_supported_format(encrypted)?;
let envelope: ManifestEnvelope =
serde_json::from_slice(encrypted).context("parse manifest envelope")?;
let exact_header = BASE64
Expand Down Expand Up @@ -405,6 +454,7 @@ pub fn open_manifest(encrypted: &[u8], generation_key: &[u8; 32]) -> Result<Open
if hex::encode(Sha256::digest(&exact_sealed_content)) != header.sealed_header_digest {
bail!("sealed manifest header digest mismatch")
}
require_supported_authorization(&exact_sealed_content)?;
let content: SealedHeaderContent =
serde_json::from_slice(&exact_sealed_content).context("parse sealed manifest content")?;
if serde_json::to_vec(&content)? != exact_sealed_content {
Expand Down Expand Up @@ -941,6 +991,19 @@ mod tests {
signer: &KeyFile,
generation_key: &[u8; 32],
edit: impl FnOnce(&mut ManifestHeader, &mut SealedHeaderContent),
) -> Vec<u8> {
rewrite_signed_sealed_content(encrypted, signer, generation_key, |header, exact| {
let mut content: SealedHeaderContent = serde_json::from_slice(exact).unwrap();
edit(header, &mut content);
*exact = serde_json::to_vec(&content).unwrap();
})
}

fn rewrite_signed_sealed_content(
encrypted: &[u8],
signer: &KeyFile,
generation_key: &[u8; 32],
edit: impl FnOnce(&mut ManifestHeader, &mut Vec<u8>),
) -> Vec<u8> {
let mut envelope: ManifestEnvelope = serde_json::from_slice(encrypted).unwrap();
let mut header_bytes = BASE64.decode(&envelope.header).unwrap();
Expand All @@ -956,10 +1019,8 @@ mod tests {
.unwrap();
let sealed_bytes = open_with_key(&sealed_key, &sealed_ciphertext, &header_bytes).unwrap();
let mut sealed: SealedHeaderEnvelope = serde_json::from_slice(&sealed_bytes).unwrap();
let mut content: SealedHeaderContent =
serde_json::from_slice(&BASE64.decode(&sealed.content).unwrap()).unwrap();
edit(&mut header, &mut content);
let exact_content = serde_json::to_vec(&content).unwrap();
let mut exact_content = BASE64.decode(&sealed.content).unwrap();
edit(&mut header, &mut exact_content);
header.sealed_header_digest = hex::encode(Sha256::digest(&exact_content));
header_bytes = serde_json::to_vec(&header).unwrap();
let body_ciphertext = BASE64.decode(&envelope.body_ciphertext).unwrap();
Expand Down Expand Up @@ -1342,6 +1403,51 @@ mod tests {
);
}

fn is_unsupported(result: Result<impl std::fmt::Debug>) -> bool {
result
.unwrap_err()
.chain()
.any(|cause| cause.is::<Unsupported>())
}

#[test]
fn a_future_header_schema_is_unsupported_not_malformed() {
let owner = KeyFile::generate();
let (policy, _) = PolicyState::genesis(&owner, "owner").unwrap();
let generation_key = random_key();
let valid = writer_manifest(&owner, &policy, &generation_key);
let mut envelope: serde_json::Value = serde_json::from_slice(&valid).unwrap();
let mut header: serde_json::Value =
serde_json::from_slice(&BASE64.decode(envelope["header"].as_str().unwrap()).unwrap())
.unwrap();
header["format_version"] = serde_json::json!(FORMAT_VERSION + 1);
header["admin_quorum"] = serde_json::json!(2);
header.as_object_mut().unwrap().remove("key_commitment");
envelope["header"] = BASE64.encode(serde_json::to_vec(&header).unwrap()).into();
envelope["future_part"] = "".into();
let future = serde_json::to_vec(&envelope).unwrap();

assert!(is_unsupported(peek_manifest_header(&future)));
assert!(is_unsupported(open_manifest(&future, &generation_key)));
}

#[test]
fn an_unknown_authorization_variant_is_unsupported_not_malformed() {
let owner = KeyFile::generate();
let (policy, _) = PolicyState::genesis(&owner, "owner").unwrap();
let generation_key = random_key();
let valid = writer_manifest(&owner, &policy, &generation_key);
let future = rewrite_signed_sealed_content(&valid, &owner, &generation_key, |_, exact| {
let mut content: serde_json::Value = serde_json::from_slice(exact).unwrap();
content["authorization"] = "quorum_transition".into();
content["quorum_signatures"] = serde_json::json!([]);
*exact = serde_json::to_vec(&content).unwrap();
});

assert!(peek_manifest_header(&future).is_ok());
assert!(is_unsupported(open_manifest(&future, &generation_key)));
}

#[test]
fn generation_overflow_is_a_validation_error() {
let owner = KeyFile::generate();
Expand Down
70 changes: 65 additions & 5 deletions src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,15 +161,36 @@ impl PolicyState {
}

pub fn parse(bytes: &[u8]) -> Result<Self> {
let object: PolicyObject = serde_json::from_slice(bytes).context("parse policy object")?;
let exact_body = BASE64.decode(object.body).context("decode policy body")?;
let body: PolicyBody = serde_json::from_slice(&exact_body).context("parse policy body")?;
if body.format_version != POLICY_FORMAT_VERSION {
#[derive(Deserialize)]
struct ObjectProbe {
body: String,
}
#[derive(Deserialize)]
struct BodyProbe {
format_version: u64,
}
// Probe the version before the full schema, so a future format is
// reported as unsupported instead of malformed.
let probe: ObjectProbe = serde_json::from_slice(bytes).context("parse policy object")?;
let probe_body = BASE64.decode(probe.body).context("decode policy body")?;
let version: BodyProbe =
serde_json::from_slice(&probe_body).context("parse policy format version")?;
if version.format_version != u64::from(POLICY_FORMAT_VERSION) {
bail!(Unsupported(format!(
"unsupported policy format {}",
body.format_version
version.format_version
)))
}
let object: PolicyObject = serde_json::from_slice(bytes).context("parse policy object")?;
let exact_body = BASE64.decode(object.body).context("decode policy body")?;
let body: PolicyBody = serde_json::from_slice(&exact_body).context("parse policy body")?;
match body.admin_threshold {
0 => bail!("policy administrator threshold must be at least one"),
1 => {}
threshold => bail!(Unsupported(format!(
"unsupported policy administrator threshold {threshold}"
))),
}
validate_devices(&body.devices)?;
validate_device_roots(&body)?;
validate_active_roles(&body)?;
Expand Down Expand Up @@ -536,6 +557,45 @@ mod tests {
.unwrap()
}

fn is_unsupported(result: Result<PolicyState>) -> bool {
result
.unwrap_err()
.chain()
.any(|cause| cause.is::<Unsupported>())
}

#[test]
fn a_future_policy_schema_is_unsupported_not_malformed() {
let owner = KeyFile::generate();
let (genesis, _) = PolicyState::genesis(&owner, "owner").unwrap();
let mut body = serde_json::to_value(&genesis.body).unwrap();
body["format_version"] = serde_json::json!(POLICY_FORMAT_VERSION + 1);
body["admin_quorum"] = serde_json::json!({"threshold": 2});
body.as_object_mut().unwrap().remove("admin_threshold");
let future = serde_json::to_vec(&serde_json::json!({
"body": BASE64.encode(serde_json::to_vec(&body).unwrap()),
"signatures": [],
"quorum_certificate": null
}))
.unwrap();
assert!(is_unsupported(PolicyState::parse(&future)));
}

#[test]
fn a_multi_admin_threshold_is_unsupported_and_zero_is_invalid() {
let owner = KeyFile::generate();
let (genesis, _) = PolicyState::genesis(&owner, "owner").unwrap();
let mut body = genesis.body.clone();
body.admin_threshold = 2;
assert!(is_unsupported(PolicyState::parse(&sign_unchecked(
&body, &owner
))));
body.admin_threshold = 0;
assert!(!is_unsupported(PolicyState::parse(&sign_unchecked(
&body, &owner
))));
}

#[test]
fn received_genesis_with_an_invalid_owner_label_is_rejected() {
let owner = KeyFile::generate();
Expand Down
Loading
Loading