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
79 changes: 79 additions & 0 deletions bin/validator/src/private_record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,31 @@ impl PrivateRecordContext {
context.extend_from_slice(&self.format_version.as_u32().to_be_bytes());
context
}

/// Parses a canonical schema version 1 context produced by [`Self::to_bytes`].
///
/// Rejects any input whose domain tag, length, or format version differ from schema
/// version 1, or whose transaction id is not canonical.
pub fn try_from_bytes(bytes: &[u8]) -> Result<Self, PrivateRecordError> {
let malformed = || PrivateRecordError::MalformedDecryptionContext;
let (domain, rest) =
bytes.split_at_checked(CONTEXT_DOMAIN_V1.len()).ok_or_else(malformed)?;
let (chain_id, rest) = rest.split_at_checked(32).ok_or_else(malformed)?;
let (key_epoch, rest) = rest.split_at_checked(32).ok_or_else(malformed)?;
let (transaction_id, version) = rest.split_at_checked(32).ok_or_else(malformed)?;
if domain != CONTEXT_DOMAIN_V1
|| version != PrivateRecordFormatVersion::V1.as_u32().to_be_bytes()
{
return Err(malformed());
}
let transaction_id =
TransactionId::read_from_bytes(transaction_id).map_err(|_error| malformed())?;
Ok(Self::new(
PrivateRecordChainId::new(chain_id.try_into().expect("split yields 32 bytes")),
StorageKeyEpoch::new(key_epoch.try_into().expect("split yields 32 bytes")),
transaction_id,
))
}
}

/// Exact record values that an operator must approve before issuing a share.
Expand Down Expand Up @@ -593,6 +618,9 @@ pub enum PrivateRecordError {
/// The request does not carry the record's exact canonical context.
#[error("private record decryption context does not match the record")]
DecryptionContextMismatch,
/// The decryption context bytes are not a canonical schema version 1 context.
#[error("private record decryption context is malformed")]
MalformedDecryptionContext,
/// The authenticated record cipher failed.
#[error("failed to encrypt private record")]
RecordEncryption,
Expand Down Expand Up @@ -727,6 +755,57 @@ mod tests {
assert_eq!(&bytes[CONTEXT_DOMAIN_V1.len() + 96..], &1_u32.to_be_bytes());
}

#[test]
fn context_parses_its_canonical_encoding_and_rejects_others() {
let bytes = context().to_bytes();

assert_eq!(PrivateRecordContext::try_from_bytes(&bytes).unwrap(), context());

// Truncated, extended, wrong-domain, and wrong-version encodings are all rejected.
let mut extended = bytes.clone();
extended.push(0);
let mut wrong_domain = bytes.clone();
wrong_domain[0] ^= 1;
let mut wrong_version = bytes.clone();
*wrong_version.last_mut().unwrap() ^= 1;
// A transaction id that is the right length but not canonical: an all-ones field element
// exceeds the modulus.
let mut wrong_transaction_id = bytes.clone();
wrong_transaction_id[CONTEXT_DOMAIN_V1.len() + 64..CONTEXT_DOMAIN_V1.len() + 96].fill(0xff);

let mut candidates = vec![
bytes[..bytes.len() - 1].to_vec(),
extended,
wrong_domain,
wrong_version,
wrong_transaction_id,
];
// Inputs too short for each successive field, so that every length check is exercised and
// not just the trailing version comparison: nothing at all, then a partial domain tag,
// chain id, key epoch, and transaction id.
candidates.extend(
[
0,
CONTEXT_DOMAIN_V1.len() - 1,
CONTEXT_DOMAIN_V1.len() + 16,
CONTEXT_DOMAIN_V1.len() + 48,
CONTEXT_DOMAIN_V1.len() + 80,
]
.map(|len| bytes[..len].to_vec()),
);

for candidate in candidates {
assert!(
matches!(
PrivateRecordContext::try_from_bytes(&candidate),
Err(PrivateRecordError::MalformedDecryptionContext),
),
"a {}-byte context should not parse",
candidate.len(),
);
}
}

#[test]
fn seal_uses_a_fresh_key_and_nonce_and_wraps_only_the_key() {
let plaintext = b"private transaction inputs";
Expand Down
57 changes: 55 additions & 2 deletions bin/validator/src/server/admin_service/issue_decryption_share.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ use axum::extract::State;
use rand_core_06::OsRng;
use serde::{Deserialize, Serialize};

use crate::PrivateRecordError;
use crate::server::admin_service::error::ApiError;
use crate::server::admin_service::{ValidatorAdminService, decode_hex};
use crate::{PrivateRecordContext, PrivateRecordError};

#[derive(Clone, Debug, Deserialize, Serialize)]
pub(super) struct IssueDecryptionShareRequest {
Expand All @@ -26,6 +26,29 @@ pub(super) async fn issue_decryption_share(
) -> Result<Json<IssueDecryptionShareResponse>, ApiError> {
let ciphertext = decode_hex("ciphertext", &request.ciphertext)?;
let decryption_context = decode_hex("decryption_context", &request.decryption_context)?;

// Only issue shares over transactions this validator itself validated. The context is
// cryptographically bound to the ciphertext, so an attacker cannot smuggle an arbitrary
// ciphertext under a validated transaction's context; and because every validator in the quorum
// validated the transaction, cross-validator recovery (combining shares over one validator's
// ciphertext) keeps working.
let context = PrivateRecordContext::try_from_bytes(&decryption_context)
.map_err(|error| map_share_error(&error))?;
// A share from this validator's key opens only records sealed under the same key epoch.
if context.key_epoch() != service.operator_key.key_epoch() {
return Err(map_share_error(&PrivateRecordError::KeyEpochMismatch));
}
let validated = service
.reader
.transaction_exists(context.transaction_id())
.await
.map_err(|_error| ApiError::internal("failed to look up the transaction"))?;
if !validated {
return Err(ApiError::not_found(
"decryption context references a transaction this validator has not validated",
));
}

let decryption_share = service
.operator_key
.issue_decryption_share(&mut OsRng, &ciphertext, &decryption_context)
Expand All @@ -36,11 +59,41 @@ pub(super) async fn issue_decryption_share(
}))
}

/// Maps a share-issuance failure onto a response.
///
/// Matched exhaustively rather than through a wildcard, so that a new [`PrivateRecordError`]
/// variant — or a `golden-ehtdh1` upgrade that introduces a new failure — has to be classified
/// here, instead of silently defaulting to an internal error over what may be a malformed
/// request.
///
/// The bad-request arm holds the failures that a request can cause. `issue_decryption_share`
/// rejects a ciphertext that does not decode, a wrapped content key of the wrong size, and a
/// ciphertext that is not bound to the supplied context. `MalformedDecryptionContext` and
/// `KeyEpochMismatch` come from the checks that this endpoint makes on the context. The internal
/// arm holds `ShareGeneration`, which a request that passes these checks does not cause, and the
/// failures of sealing, share combination, and stored record decoding, which this endpoint does
/// not do.
fn map_share_error(error: &PrivateRecordError) -> ApiError {
match error {
PrivateRecordError::InvalidGoldenEncoding(_)
| PrivateRecordError::InvalidEncryptedRecordKey
| PrivateRecordError::MalformedDecryptionContext
| PrivateRecordError::KeyEpochMismatch
| PrivateRecordError::DecryptionContextMismatch => ApiError::bad_request(error.to_string()),
_ => ApiError::internal("failed to issue Golden decryption share"),
PrivateRecordError::RecordIdMismatch
| PrivateRecordError::InvalidValidatorId(_)
| PrivateRecordError::SetupContextMismatch
| PrivateRecordError::RecordEncryption
| PrivateRecordError::ContentKeyEncryption(_)
| PrivateRecordError::InvalidCombinerSetup(_)
| PrivateRecordError::InvalidDecryptionShare(_)
| PrivateRecordError::ShareGeneration(_)
| PrivateRecordError::ShareCombination(_)
| PrivateRecordError::UnsupportedFormat(_)
| PrivateRecordError::InvalidNonceLength { .. }
| PrivateRecordError::InvalidRecordCiphertext
| PrivateRecordError::RecordDecryption => {
ApiError::internal("failed to issue Golden decryption share")
},
}
}
74 changes: 69 additions & 5 deletions bin/validator/src/server/admin_service/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ use crate::{
PrivateRecordId,
PrivateRecordSealer,
PrivateRecordShareRequest,
StorageKeyEpoch,
StoredPrivateRecord,
};

Expand Down Expand Up @@ -472,17 +473,77 @@ async fn get_transaction_returns_the_full_record() {
assert_eq!(error.status, StatusCode::BAD_REQUEST);
}

/// The share endpoint refuses to act as a decryption oracle: it only issues shares whose decryption
/// context references a transaction this validator itself validated.
#[tokio::test]
async fn share_refused_for_unvalidated_transaction() {
let mut keys = operator_keys();
let record_owner = keys.pop().unwrap();
let (_directory, _writer, reader) = test_database().await;
let service = ValidatorAdminService::new(keys.pop().unwrap(), reader);
let transaction_id = TransactionId::from_raw(Word::from([2u32, 4, 6, 8]));
let record = target_record(&record_owner, transaction_id, 30, b"record");

let error = issue(&service, share_request(&record)).await.unwrap_err();

assert_eq!(error.status, StatusCode::NOT_FOUND);
}

/// A share from this validator's key opens only records sealed under the same key epoch, so the
/// share endpoint refuses a context that names another epoch.
#[tokio::test]
async fn share_refused_for_another_key_epoch() {
let mut keys = operator_keys();
let transaction_id = TransactionId::from_raw(Word::from([1u32, 2, 3, 4]));
let record = target_record(&keys[0], transaction_id, 6, b"record");
let (_directory, writer, reader) = test_database().await;
writer.insert_validated_private_transaction(record.clone()).await.unwrap();

// The transaction is validated and the ciphertext is bound to the context, so the request fails
// only on the epoch that the context names.
let other_epoch = StorageKeyEpoch::new([0xaa; 32]);
assert_ne!(other_epoch, keys[0].key_epoch());
let context =
PrivateRecordContext::new(record.context().chain_id(), other_epoch, transaction_id)
.to_bytes();
let ciphertext = keys[0]
.sealing_key()
.seal_bytes_with_associated_data(&mut ChaCha20Rng::from_seed([7; 32]), &[0; 32], &context)
.unwrap();
let request = IssueDecryptionShareRequest {
ciphertext: hex::encode(to_wire_bytes(&ciphertext)),
decryption_context: hex::encode(context),
};

let error = issue(&ValidatorAdminService::new(keys.remove(0), reader), request)
.await
.unwrap_err();

assert_eq!(error.status, StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn shares_for_different_ciphertexts_are_not_reusable() {
let mut keys = operator_keys();
let record_owner = keys.pop().unwrap();
let second = ValidatorAdminService::new(keys.pop().unwrap(), test_database().await.2);
let first = ValidatorAdminService::new(keys.pop().unwrap(), test_database().await.2);
let transaction_id = TransactionId::from_raw(Word::from([1u32, 2, 3, 4]));
let first_record = target_record(&record_owner, transaction_id, 2, b"same plaintext");
let second_record = target_record(&record_owner, transaction_id, 3, b"same plaintext");
assert_eq!(first_record.context(), second_record.context());
assert_ne!(first_record.encrypted_record_key(), second_record.encrypted_record_key());
// Each validator has validated (and stored its own record for) the transaction.
let (_first_dir, first_writer, first_reader) = test_database().await;
first_writer
.insert_validated_private_transaction(first_record.clone())
.await
.unwrap();
let (_second_dir, second_writer, second_reader) = test_database().await;
second_writer
.insert_validated_private_transaction(second_record.clone())
.await
.unwrap();
let second = ValidatorAdminService::new(keys.pop().unwrap(), second_reader);
let first = ValidatorAdminService::new(keys.pop().unwrap(), first_reader);

let shares = [
issue(&first, share_request(&first_record)).await.unwrap().decryption_share,
Expand All @@ -502,10 +563,13 @@ async fn shares_for_different_ciphertexts_are_not_reusable() {
#[tokio::test]
async fn invalid_share_requests_return_bad_request() {
let mut keys = operator_keys();
let record =
target_record(&keys[0], TransactionId::from_raw(Word::from([1u32, 2, 3, 4])), 4, b"record");
let transaction_id = TransactionId::from_raw(Word::from([1u32, 2, 3, 4]));
let record = target_record(&keys[0], transaction_id, 4, b"record");
let context = record.context().to_bytes();
let (_directory, _writer, reader) = test_database().await;
let (_directory, writer, reader) = test_database().await;
// The referenced transaction is validated, so these requests fail on their own defects rather
// than on the validated-transaction check.
writer.insert_validated_private_transaction(record.clone()).await.unwrap();

let invalid_hex = IssueDecryptionShareRequest {
ciphertext: "not hex".to_owned(),
Expand Down
4 changes: 4 additions & 0 deletions docs/external/src/network-operator/validator.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,7 @@ a transaction, and a share over the ciphertext of one validator does not combine
another validator. Read the record from one validator, then send the `encrypted_record_key` of that record as
`ciphertext`, with its `decryption_context`, to each validator. A validator therefore issues a share over a ciphertext
that it does not store.

A validator issues a share only when the `decryption_context` names a transaction that it validated. Otherwise it
returns `404`. It returns `400` when the context is not a canonical record context, or when the context names a key
epoch other than the `--storage-key.epoch` of the validator.
Loading