add canonical sigchain bundle format
This commit is contained in:
parent
76eb785ee2
commit
decff4b995
14 changed files with 1515 additions and 42 deletions
|
|
@ -15,15 +15,19 @@ mod sshsigchain;
|
|||
pub use sshsigchain::{
|
||||
AnchorAttesterPolicy, AnchorBackendPolicy, AnchorPolicy, AnchorReceipt, AnchorReceiptVerifier,
|
||||
AnchoredHistory, AuthorityDeviceState, AuthorityKey, AuthorityKeyId, AuthorityKeyState,
|
||||
AuthorityState, AuthorityTransition, ChainId as SshSigchainChainId,
|
||||
Digest as SshSigchainDigest, HeadClaim, MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES,
|
||||
MAX_NAMESPACE_BYTES, Permission, ProfileDisclosure, ProfileExtension,
|
||||
SSH_SIGCHAIN_ANCHOR_NAMESPACE, SSH_SIGCHAIN_KEY_PROOF_NAMESPACE, SSH_SIGCHAIN_NAMESPACE,
|
||||
SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainRecord,
|
||||
SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier, VerifiedAnchoredHistory,
|
||||
authority_key_id, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl, key_proof_signing_bytes,
|
||||
profile_payload_commitment, select_anchored_head, verify_anchor_receipts,
|
||||
verify_anchored_history, verify_head_claim, verify_sshsigchain,
|
||||
AuthorityState, AuthorityTransition, CANONICAL_BUNDLE_EXTENSION, CANONICAL_BUNDLE_MEDIA_TYPE,
|
||||
CanonicalSshSigchainBundle, ChainId as SshSigchainChainId, Digest as SshSigchainDigest,
|
||||
DistributionEndpoint, DistributionError, HeadClaim, MAX_CANONICAL_BUNDLE_BYTES,
|
||||
MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES, MAX_NAMESPACE_BYTES, Permission, ProfileDisclosure,
|
||||
ProfileExtension, SSH_SIGCHAIN_ANCHOR_NAMESPACE, SSH_SIGCHAIN_KEY_PROOF_NAMESPACE,
|
||||
SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION,
|
||||
SshSigchainBundleSource, SshSigchainError, SshSigchainRecord, SshSigchainTrust,
|
||||
SshSigchainVerification, SshSigchainVerifier, VerifiedAnchoredHistory, authority_key_id,
|
||||
canonical_bundle_hash, decode_canonical_bundle, decode_sshsigchain_jsonl,
|
||||
encode_canonical_bundle, encode_sshsigchain_jsonl, key_proof_signing_bytes,
|
||||
profile_payload_commitment, select_anchored_head, static_http_bundle_path,
|
||||
verify_anchor_receipts, verify_anchored_history, verify_canonical_bundle_records,
|
||||
verify_head_claim, verify_sshsigchain,
|
||||
};
|
||||
|
||||
use geth_types::{
|
||||
|
|
|
|||
|
|
@ -9,6 +9,15 @@ use base64::{Engine as _, engine::general_purpose};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
mod bundle;
|
||||
|
||||
pub use bundle::{
|
||||
CANONICAL_BUNDLE_EXTENSION, CANONICAL_BUNDLE_MEDIA_TYPE, CanonicalSshSigchainBundle,
|
||||
DistributionEndpoint, DistributionError, MAX_CANONICAL_BUNDLE_BYTES, SshSigchainBundleSource,
|
||||
canonical_bundle_hash, decode_canonical_bundle, encode_canonical_bundle,
|
||||
static_http_bundle_path, verify_canonical_bundle_records,
|
||||
};
|
||||
|
||||
pub const SSH_SIGCHAIN_VERSION: u8 = 1;
|
||||
pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v1";
|
||||
pub const SSH_SIGCHAIN_KEY_PROOF_NAMESPACE: &str = "sshsigchain.key-proof.v1";
|
||||
|
|
|
|||
987
crates/geth-keychain/src/sshsigchain/bundle.rs
Normal file
987
crates/geth-keychain/src/sshsigchain/bundle.rs
Normal file
|
|
@ -0,0 +1,987 @@
|
|||
//! Canonical SSHSIGCHAIN object and bundle encoding.
|
||||
|
||||
use super::*;
|
||||
|
||||
pub const CANONICAL_BUNDLE_EXTENSION: &str = "sscb";
|
||||
pub const CANONICAL_BUNDLE_MEDIA_TYPE: &str = "application/vnd.sshsigchain.bundle.v1";
|
||||
pub const MAX_CANONICAL_BUNDLE_BYTES: usize = 256 * 1024 * 1024;
|
||||
pub const MAX_CANONICAL_OBJECT_BYTES: usize = 5 * 1024 * 1024;
|
||||
pub const MAX_BUNDLE_DISCLOSURES: usize = 1_000_000;
|
||||
pub const MAX_BUNDLE_CLAIMS: usize = 100_000;
|
||||
pub const MAX_BUNDLE_RECEIPTS: usize = 100_000;
|
||||
|
||||
const BUNDLE_MAGIC: &[u8; 4] = b"SSCB";
|
||||
const RECORD_MAGIC: &[u8; 4] = b"SSCR";
|
||||
const DISCLOSURE_MAGIC: &[u8; 4] = b"SSCD";
|
||||
const CLAIM_MAGIC: &[u8; 4] = b"SSHC";
|
||||
const RECEIPT_MAGIC: &[u8; 4] = b"SSRC";
|
||||
const BUNDLE_HASH_DOMAIN: &[u8] = b"sshsigchain.bundle.v1\0";
|
||||
|
||||
/// One canonical, self-contained chain snapshot. The root public key remains
|
||||
/// an out-of-band trust input and is deliberately absent.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CanonicalSshSigchainBundle {
|
||||
pub chain_id: ChainId,
|
||||
pub namespace: String,
|
||||
pub records: Vec<SshSigchainRecord>,
|
||||
pub claims: Vec<HeadClaim>,
|
||||
pub receipts: Vec<AnchorReceipt>,
|
||||
}
|
||||
|
||||
/// Locates an untrusted distribution source. It is routing configuration, not
|
||||
/// a trust anchor; integrity and authority come from SSHSIGCHAIN verification.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DistributionEndpoint {
|
||||
pub backend_id: String,
|
||||
pub class: String,
|
||||
pub locator: String,
|
||||
}
|
||||
|
||||
impl DistributionEndpoint {
|
||||
pub fn new(
|
||||
backend_id: impl Into<String>,
|
||||
class: impl Into<String>,
|
||||
locator: impl Into<String>,
|
||||
) -> Result<Self, DistributionError> {
|
||||
let backend_id = validate_identifier(backend_id.into())?;
|
||||
let class = validate_identifier(class.into())?;
|
||||
let locator = locator.into();
|
||||
if locator.is_empty() || locator.len() > 2048 {
|
||||
return Err(DistributionError::InvalidEndpoint);
|
||||
}
|
||||
Ok(Self {
|
||||
backend_id,
|
||||
class,
|
||||
locator,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A distribution backend returns untrusted canonical bundle bytes. Callers
|
||||
/// MUST decode and verify them against local trust and accepted-head state.
|
||||
pub trait SshSigchainBundleSource {
|
||||
type Error: std::error::Error + Send + Sync + 'static;
|
||||
|
||||
fn fetch_bundle(
|
||||
&self,
|
||||
endpoint: &DistributionEndpoint,
|
||||
chain_id: ChainId,
|
||||
accepted_head: Option<Digest>,
|
||||
) -> Result<Option<Vec<u8>>, Self::Error>;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum DistributionError {
|
||||
#[error(
|
||||
"canonical SSHSIGCHAIN bundle is {0} bytes, over the {MAX_CANONICAL_BUNDLE_BYTES}-byte limit"
|
||||
)]
|
||||
BundleTooLarge(usize),
|
||||
#[error(
|
||||
"canonical SSHSIGCHAIN object is {0} bytes, over the {MAX_CANONICAL_OBJECT_BYTES}-byte limit"
|
||||
)]
|
||||
ObjectTooLarge(usize),
|
||||
#[error("canonical SSHSIGCHAIN object is malformed or truncated")]
|
||||
Malformed,
|
||||
#[error("canonical SSHSIGCHAIN object has unsupported version {0}")]
|
||||
UnsupportedVersion(u8),
|
||||
#[error("canonical SSHSIGCHAIN bundle is not in its unique encoding")]
|
||||
NonCanonical,
|
||||
#[error("canonical SSHSIGCHAIN bundle contains records from another chain")]
|
||||
MixedChain,
|
||||
#[error(
|
||||
"canonical SSHSIGCHAIN bundle does not match the configured chain ID or SSHSIG namespace"
|
||||
)]
|
||||
WrongTrust,
|
||||
#[error("SSHSIGCHAIN distribution endpoint is invalid")]
|
||||
InvalidEndpoint,
|
||||
#[error("canonical SSHSIGCHAIN records are empty or not in causal order")]
|
||||
InvalidRecordOrder,
|
||||
#[error("canonical SSHSIGCHAIN disclosure does not name a committed profile in its record")]
|
||||
UnknownDisclosure,
|
||||
#[error("canonical SSHSIGCHAIN bundle has duplicate disclosures, claims, or receipts")]
|
||||
DuplicateObject,
|
||||
#[error("canonical SSHSIGCHAIN claim does not name a record in the bundled history")]
|
||||
UnknownClaimHead,
|
||||
#[error("canonical SSHSIGCHAIN receipt does not name a bundled claim")]
|
||||
UnknownReceiptClaim,
|
||||
#[error("canonical SSHSIGCHAIN bundle has too many claims or receipts")]
|
||||
TooManyObjects,
|
||||
#[error("canonical SSHSIGCHAIN object violates the protocol: {0}")]
|
||||
Protocol(#[from] SshSigchainError),
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn static_http_bundle_path(chain_id: ChainId) -> String {
|
||||
format!(
|
||||
"/.well-known/sshsigchain/v1/{}/chain.{CANONICAL_BUNDLE_EXTENSION}",
|
||||
chain_id.to_hex()
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn canonical_bundle_hash(bytes: &[u8]) -> Digest {
|
||||
let mut input = Vec::with_capacity(BUNDLE_HASH_DOMAIN.len() + bytes.len());
|
||||
input.extend_from_slice(BUNDLE_HASH_DOMAIN);
|
||||
input.extend_from_slice(bytes);
|
||||
Digest(*blake3::hash(&input).as_bytes())
|
||||
}
|
||||
|
||||
pub fn verify_canonical_bundle_records<V: SshSigchainVerifier + ?Sized>(
|
||||
bundle: &CanonicalSshSigchainBundle,
|
||||
trust: &SshSigchainTrust,
|
||||
verifier: &V,
|
||||
) -> Result<SshSigchainVerification, DistributionError> {
|
||||
if bundle.chain_id != trust.chain_id || bundle.namespace != trust.namespace {
|
||||
return Err(DistributionError::WrongTrust);
|
||||
}
|
||||
verify_sshsigchain(&bundle.records, trust, verifier).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn encode_canonical_bundle(
|
||||
bundle: &CanonicalSshSigchainBundle,
|
||||
) -> Result<Vec<u8>, DistributionError> {
|
||||
validate_bundle_records(bundle)?;
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(BUNDLE_MAGIC);
|
||||
out.push(SSH_SIGCHAIN_VERSION);
|
||||
out.extend_from_slice(&bundle.chain_id.0);
|
||||
let namespace = validate_namespace(bundle.namespace.clone())?;
|
||||
push_u16_bytes(&mut out, namespace.as_bytes())?;
|
||||
|
||||
push_u32_count(&mut out, bundle.records.len())?;
|
||||
let mut disclosures = Vec::new();
|
||||
let mut history = BTreeSet::new();
|
||||
for record in &bundle.records {
|
||||
let record_hash = record.record_hash()?;
|
||||
history.insert(record_hash);
|
||||
let object = encode_record_object(record)?;
|
||||
validate_object_size(&object)?;
|
||||
push_u32_bytes(&mut out, &object)?;
|
||||
for extension in &record.extensions {
|
||||
if let Some(disclosure) = &extension.disclosure {
|
||||
disclosures.push((
|
||||
record_hash,
|
||||
extension.profile_id.clone(),
|
||||
disclosure.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
disclosures.sort_by(|left, right| (&left.0, &left.1).cmp(&(&right.0, &right.1)));
|
||||
if disclosures.len() > MAX_BUNDLE_DISCLOSURES {
|
||||
return Err(DistributionError::TooManyObjects);
|
||||
}
|
||||
if disclosures
|
||||
.windows(2)
|
||||
.any(|pair| (pair[0].0, &pair[0].1) == (pair[1].0, &pair[1].1))
|
||||
{
|
||||
return Err(DistributionError::DuplicateObject);
|
||||
}
|
||||
push_u32_count(&mut out, disclosures.len())?;
|
||||
for (record_hash, profile_id, disclosure) in disclosures {
|
||||
let object = encode_disclosure_object(record_hash, &profile_id, &disclosure)?;
|
||||
validate_object_size(&object)?;
|
||||
push_u32_bytes(&mut out, &object)?;
|
||||
}
|
||||
|
||||
if bundle.claims.len() > MAX_BUNDLE_CLAIMS || bundle.receipts.len() > MAX_BUNDLE_RECEIPTS {
|
||||
return Err(DistributionError::TooManyObjects);
|
||||
}
|
||||
let mut claims = Vec::with_capacity(bundle.claims.len());
|
||||
for claim in &bundle.claims {
|
||||
if claim.chain_id != bundle.chain_id || !history.contains(&claim.head) {
|
||||
return Err(DistributionError::UnknownClaimHead);
|
||||
}
|
||||
claims.push((claim.claim_hash()?, claim.clone()));
|
||||
}
|
||||
claims.sort_by_key(|(claim_hash, _)| *claim_hash);
|
||||
let mut claim_hashes = BTreeSet::new();
|
||||
push_u32_count(&mut out, claims.len())?;
|
||||
for (claim_hash, claim) in &claims {
|
||||
if !claim_hashes.insert(*claim_hash) {
|
||||
return Err(DistributionError::DuplicateObject);
|
||||
}
|
||||
let object = encode_claim_object(claim)?;
|
||||
validate_object_size(&object)?;
|
||||
push_u32_bytes(&mut out, &object)?;
|
||||
}
|
||||
|
||||
let mut receipts = bundle.receipts.clone();
|
||||
receipts.sort_by(|left, right| {
|
||||
(&left.claim_hash, &left.backend_id).cmp(&(&right.claim_hash, &right.backend_id))
|
||||
});
|
||||
if receipts.windows(2).any(|pair| {
|
||||
(pair[0].claim_hash, &pair[0].backend_id) == (pair[1].claim_hash, &pair[1].backend_id)
|
||||
}) {
|
||||
return Err(DistributionError::DuplicateObject);
|
||||
}
|
||||
push_u32_count(&mut out, receipts.len())?;
|
||||
for receipt in &receipts {
|
||||
if !claim_hashes.contains(&receipt.claim_hash) {
|
||||
return Err(DistributionError::UnknownReceiptClaim);
|
||||
}
|
||||
let object = encode_receipt_object(receipt)?;
|
||||
validate_object_size(&object)?;
|
||||
push_u32_bytes(&mut out, &object)?;
|
||||
}
|
||||
if out.len() > MAX_CANONICAL_BUNDLE_BYTES {
|
||||
return Err(DistributionError::BundleTooLarge(out.len()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn decode_canonical_bundle(
|
||||
bytes: &[u8],
|
||||
) -> Result<CanonicalSshSigchainBundle, DistributionError> {
|
||||
if bytes.len() > MAX_CANONICAL_BUNDLE_BYTES {
|
||||
return Err(DistributionError::BundleTooLarge(bytes.len()));
|
||||
}
|
||||
let mut cursor = Cursor::new(bytes);
|
||||
cursor.expect_magic(BUNDLE_MAGIC)?;
|
||||
cursor.expect_version()?;
|
||||
let chain_id = ChainId(cursor.array()?);
|
||||
let namespace = validate_namespace(cursor.string16()?)?;
|
||||
let record_count = cursor.count32(MAX_RECORDS)?;
|
||||
let mut records = Vec::new();
|
||||
for _ in 0..record_count {
|
||||
records.push(decode_record_object(cursor.bytes32()?)?);
|
||||
}
|
||||
let disclosure_count = cursor.count32(MAX_BUNDLE_DISCLOSURES)?;
|
||||
let mut disclosures = Vec::new();
|
||||
for _ in 0..disclosure_count {
|
||||
disclosures.push(decode_disclosure_object(cursor.bytes32()?)?);
|
||||
}
|
||||
let claim_count = cursor.count32(MAX_BUNDLE_CLAIMS)?;
|
||||
let mut claims = Vec::new();
|
||||
for _ in 0..claim_count {
|
||||
claims.push(decode_claim_object(cursor.bytes32()?)?);
|
||||
}
|
||||
let receipt_count = cursor.count32(MAX_BUNDLE_RECEIPTS)?;
|
||||
let mut receipts = Vec::new();
|
||||
for _ in 0..receipt_count {
|
||||
receipts.push(decode_receipt_object(cursor.bytes32()?)?);
|
||||
}
|
||||
cursor.finish()?;
|
||||
|
||||
let mut record_indexes = BTreeMap::new();
|
||||
for (index, record) in records.iter().enumerate() {
|
||||
record_indexes.insert(record.record_hash()?, index);
|
||||
}
|
||||
for (record_hash, profile_id, disclosure) in disclosures {
|
||||
let record = records
|
||||
.get_mut(
|
||||
*record_indexes
|
||||
.get(&record_hash)
|
||||
.ok_or(DistributionError::UnknownDisclosure)?,
|
||||
)
|
||||
.ok_or(DistributionError::UnknownDisclosure)?;
|
||||
let extension = record
|
||||
.extensions
|
||||
.iter_mut()
|
||||
.find(|extension| extension.profile_id == profile_id)
|
||||
.ok_or(DistributionError::UnknownDisclosure)?;
|
||||
if extension.disclosure.is_some() {
|
||||
return Err(DistributionError::DuplicateObject);
|
||||
}
|
||||
extension.disclosure = Some(disclosure);
|
||||
record.validate(true)?;
|
||||
}
|
||||
let bundle = CanonicalSshSigchainBundle {
|
||||
chain_id,
|
||||
namespace,
|
||||
records,
|
||||
claims,
|
||||
receipts,
|
||||
};
|
||||
let canonical = encode_canonical_bundle(&bundle)?;
|
||||
if canonical != bytes {
|
||||
return Err(DistributionError::NonCanonical);
|
||||
}
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
fn validate_bundle_records(bundle: &CanonicalSshSigchainBundle) -> Result<(), DistributionError> {
|
||||
if bundle.records.is_empty() || bundle.records.len() > MAX_RECORDS {
|
||||
return Err(DistributionError::InvalidRecordOrder);
|
||||
}
|
||||
let mut previous = None;
|
||||
for record in &bundle.records {
|
||||
record.validate(true)?;
|
||||
if record.chain_id != bundle.chain_id {
|
||||
return Err(DistributionError::MixedChain);
|
||||
}
|
||||
if record.previous != previous {
|
||||
return Err(DistributionError::InvalidRecordOrder);
|
||||
}
|
||||
previous = Some(record.record_hash()?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_record_object(record: &SshSigchainRecord) -> Result<Vec<u8>, DistributionError> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(RECORD_MAGIC);
|
||||
out.push(SSH_SIGCHAIN_VERSION);
|
||||
push_u32_bytes(&mut out, &record.signing_bytes()?)?;
|
||||
push_u32_bytes(&mut out, &record.signature)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn decode_record_object(bytes: &[u8]) -> Result<SshSigchainRecord, DistributionError> {
|
||||
validate_object_size(bytes)?;
|
||||
let mut cursor = Cursor::new(bytes);
|
||||
cursor.expect_magic(RECORD_MAGIC)?;
|
||||
cursor.expect_version()?;
|
||||
let signing_bytes = cursor.bytes32()?;
|
||||
let signature = cursor.bytes32()?.to_vec();
|
||||
cursor.finish()?;
|
||||
decode_record_signing_bytes(signing_bytes, signature)
|
||||
}
|
||||
|
||||
fn encode_disclosure_object(
|
||||
record_hash: Digest,
|
||||
profile_id: &str,
|
||||
disclosure: &ProfileDisclosure,
|
||||
) -> Result<Vec<u8>, DistributionError> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(DISCLOSURE_MAGIC);
|
||||
out.push(SSH_SIGCHAIN_VERSION);
|
||||
out.extend_from_slice(&record_hash.0);
|
||||
push_u16_bytes(
|
||||
&mut out,
|
||||
validate_identifier(profile_id.to_owned())?.as_bytes(),
|
||||
)?;
|
||||
out.extend_from_slice(&disclosure.nonce);
|
||||
push_u32_bytes(&mut out, &disclosure.payload)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn decode_disclosure_object(
|
||||
bytes: &[u8],
|
||||
) -> Result<(Digest, String, ProfileDisclosure), DistributionError> {
|
||||
validate_object_size(bytes)?;
|
||||
let mut cursor = Cursor::new(bytes);
|
||||
cursor.expect_magic(DISCLOSURE_MAGIC)?;
|
||||
cursor.expect_version()?;
|
||||
let record_hash = Digest(cursor.array()?);
|
||||
let profile_id = validate_identifier(cursor.string16()?)?;
|
||||
let nonce = cursor.array()?;
|
||||
let payload = cursor.bytes32()?.to_vec();
|
||||
if payload.len() > MAX_PAYLOAD_BYTES {
|
||||
return Err(SshSigchainError::PayloadTooLarge(payload.len()).into());
|
||||
}
|
||||
cursor.finish()?;
|
||||
Ok((
|
||||
record_hash,
|
||||
profile_id,
|
||||
ProfileDisclosure { nonce, payload },
|
||||
))
|
||||
}
|
||||
|
||||
fn encode_claim_object(claim: &HeadClaim) -> Result<Vec<u8>, DistributionError> {
|
||||
if claim.signature.is_empty() {
|
||||
return Err(SshSigchainError::MissingSignature.into());
|
||||
}
|
||||
if claim.signature.len() > MAX_SIGNATURE_BYTES {
|
||||
return Err(SshSigchainError::SignatureTooLarge(claim.signature.len()).into());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(CLAIM_MAGIC);
|
||||
out.push(SSH_SIGCHAIN_VERSION);
|
||||
push_u32_bytes(&mut out, &claim.signing_bytes()?)?;
|
||||
push_u32_bytes(&mut out, &claim.signature)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn decode_claim_object(bytes: &[u8]) -> Result<HeadClaim, DistributionError> {
|
||||
validate_object_size(bytes)?;
|
||||
let mut cursor = Cursor::new(bytes);
|
||||
cursor.expect_magic(CLAIM_MAGIC)?;
|
||||
cursor.expect_version()?;
|
||||
let signing = cursor.bytes32()?;
|
||||
let signature = cursor.bytes32()?.to_vec();
|
||||
cursor.finish()?;
|
||||
let mut signing_cursor = Cursor::new(signing);
|
||||
signing_cursor.expect_magic(ANCHOR_MAGIC.try_into().expect("fixed magic"))?;
|
||||
signing_cursor.expect_version()?;
|
||||
let chain_id = ChainId(signing_cursor.array()?);
|
||||
let head = Digest(signing_cursor.array()?);
|
||||
let signer_key_id = AuthorityKeyId(Digest(signing_cursor.array()?));
|
||||
let signer_public_key = signing_cursor.string16()?;
|
||||
signing_cursor.finish()?;
|
||||
HeadClaim {
|
||||
chain_id,
|
||||
head,
|
||||
signer_key_id,
|
||||
signer_public_key,
|
||||
signature: Vec::new(),
|
||||
}
|
||||
.with_signature(signature)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn encode_receipt_object(receipt: &AnchorReceipt) -> Result<Vec<u8>, DistributionError> {
|
||||
if receipt.evidence.len() > MAX_PAYLOAD_BYTES {
|
||||
return Err(SshSigchainError::PayloadTooLarge(receipt.evidence.len()).into());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(RECEIPT_MAGIC);
|
||||
out.push(SSH_SIGCHAIN_VERSION);
|
||||
push_u16_bytes(
|
||||
&mut out,
|
||||
validate_identifier(receipt.backend_id.clone())?.as_bytes(),
|
||||
)?;
|
||||
out.extend_from_slice(&receipt.claim_hash.0);
|
||||
push_u32_bytes(&mut out, &receipt.evidence)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn decode_receipt_object(bytes: &[u8]) -> Result<AnchorReceipt, DistributionError> {
|
||||
validate_object_size(bytes)?;
|
||||
let mut cursor = Cursor::new(bytes);
|
||||
cursor.expect_magic(RECEIPT_MAGIC)?;
|
||||
cursor.expect_version()?;
|
||||
let backend_id = validate_identifier(cursor.string16()?)?;
|
||||
let claim_hash = Digest(cursor.array()?);
|
||||
let evidence = cursor.bytes32()?.to_vec();
|
||||
if evidence.len() > MAX_PAYLOAD_BYTES {
|
||||
return Err(SshSigchainError::PayloadTooLarge(evidence.len()).into());
|
||||
}
|
||||
cursor.finish()?;
|
||||
Ok(AnchorReceipt {
|
||||
backend_id,
|
||||
claim_hash,
|
||||
evidence,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_record_signing_bytes(
|
||||
bytes: &[u8],
|
||||
signature: Vec<u8>,
|
||||
) -> Result<SshSigchainRecord, DistributionError> {
|
||||
let mut cursor = Cursor::new(bytes);
|
||||
cursor.expect_magic(SIGNING_MAGIC.try_into().expect("fixed magic"))?;
|
||||
cursor.expect_version()?;
|
||||
let chain_id = ChainId(cursor.array()?);
|
||||
let previous = cursor.optional_digest()?;
|
||||
let signer_key_id = AuthorityKeyId(Digest(cursor.array()?));
|
||||
let signer_public_key = cursor.string16()?;
|
||||
let authority = decode_transition(&mut cursor)?;
|
||||
let extension_count = cursor.count16(MAX_EXTENSIONS)?;
|
||||
let mut extensions = Vec::with_capacity(extension_count);
|
||||
for _ in 0..extension_count {
|
||||
extensions.push(ProfileExtension::withheld(
|
||||
cursor.string16()?,
|
||||
Digest(cursor.array()?),
|
||||
));
|
||||
}
|
||||
cursor.finish()?;
|
||||
let record = SshSigchainRecord {
|
||||
chain_id,
|
||||
previous,
|
||||
signer_key_id,
|
||||
signer_public_key,
|
||||
authority,
|
||||
extensions,
|
||||
signature,
|
||||
};
|
||||
record.validate(true)?;
|
||||
if record.signing_bytes()? != bytes {
|
||||
return Err(DistributionError::NonCanonical);
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn decode_transition(cursor: &mut Cursor<'_>) -> Result<AuthorityTransition, DistributionError> {
|
||||
Ok(match cursor.u8()? {
|
||||
0 => AuthorityTransition::Genesis {
|
||||
device_id: cursor.string16()?,
|
||||
root_key: decode_authority_key(cursor)?,
|
||||
anchor_policy: decode_anchor_policy(cursor)?,
|
||||
},
|
||||
1 => AuthorityTransition::DeviceAdd {
|
||||
device_id: cursor.string16()?,
|
||||
permission_ceiling: decode_permissions(cursor)?,
|
||||
},
|
||||
2 => AuthorityTransition::DeviceRevoke {
|
||||
device_id: cursor.string16()?,
|
||||
},
|
||||
3 => AuthorityTransition::KeyAdd {
|
||||
device_id: cursor.string16()?,
|
||||
key: decode_authority_key(cursor)?,
|
||||
proof: cursor.bytes32()?.to_vec(),
|
||||
},
|
||||
4 => AuthorityTransition::KeyRevoke {
|
||||
key_id: AuthorityKeyId(Digest(cursor.array()?)),
|
||||
},
|
||||
5 => AuthorityTransition::PermissionGrant {
|
||||
key_id: AuthorityKeyId(Digest(cursor.array()?)),
|
||||
permissions: decode_permissions(cursor)?,
|
||||
delegable_permissions: decode_permissions(cursor)?,
|
||||
},
|
||||
6 => AuthorityTransition::PermissionRevoke {
|
||||
key_id: AuthorityKeyId(Digest(cursor.array()?)),
|
||||
permissions: decode_permissions(cursor)?,
|
||||
delegable_permissions: decode_permissions(cursor)?,
|
||||
},
|
||||
7 => AuthorityTransition::AnchorPolicySet {
|
||||
policy: decode_anchor_policy(cursor)?,
|
||||
},
|
||||
8 => AuthorityTransition::Noop,
|
||||
_ => return Err(DistributionError::Malformed),
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_authority_key(cursor: &mut Cursor<'_>) -> Result<AuthorityKey, DistributionError> {
|
||||
Ok(AuthorityKey {
|
||||
public_key: cursor.string16()?,
|
||||
permissions: decode_permissions(cursor)?,
|
||||
delegable_permissions: decode_permissions(cursor)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_permissions(cursor: &mut Cursor<'_>) -> Result<Vec<Permission>, DistributionError> {
|
||||
let count = cursor.count16(u16::MAX as usize)?;
|
||||
let mut permissions = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
permissions.push(match cursor.u8()? {
|
||||
0 => Permission::All,
|
||||
1 => Permission::DeviceAdd,
|
||||
2 => Permission::DeviceRevoke,
|
||||
3 => Permission::KeyAddSelf,
|
||||
4 => Permission::KeyAddAny,
|
||||
5 => Permission::KeyRevokeSelf,
|
||||
6 => Permission::KeyRevokeAny,
|
||||
7 => Permission::ManagePermissions,
|
||||
8 => Permission::AnchorPolicy,
|
||||
9 => Permission::AnchorAttest,
|
||||
10 => Permission::ProfileWrite(cursor.string16()?),
|
||||
11 => Permission::ProfileDelegate(cursor.string16()?),
|
||||
_ => return Err(DistributionError::Malformed),
|
||||
});
|
||||
}
|
||||
permission_set(&permissions)?;
|
||||
Ok(permissions)
|
||||
}
|
||||
|
||||
fn decode_anchor_policy(cursor: &mut Cursor<'_>) -> Result<AnchorPolicy, DistributionError> {
|
||||
let attester_threshold = cursor.u16()?;
|
||||
let attester_count = cursor.count16(u16::MAX as usize)?;
|
||||
let mut attesters = Vec::with_capacity(attester_count);
|
||||
for _ in 0..attester_count {
|
||||
attesters.push(AnchorAttesterPolicy {
|
||||
key_id: AuthorityKeyId(Digest(cursor.array()?)),
|
||||
weight: cursor.u16()?,
|
||||
required: cursor.boolean()?,
|
||||
});
|
||||
}
|
||||
let required_class_count = cursor.count16(u16::MAX as usize)?;
|
||||
let mut required_classes = Vec::with_capacity(required_class_count);
|
||||
for _ in 0..required_class_count {
|
||||
required_classes.push(cursor.string16()?);
|
||||
}
|
||||
let backend_threshold = cursor.u16()?;
|
||||
let backend_count = cursor.count16(u16::MAX as usize)?;
|
||||
let mut backends = Vec::with_capacity(backend_count);
|
||||
for _ in 0..backend_count {
|
||||
backends.push(AnchorBackendPolicy {
|
||||
backend_id: cursor.string16()?,
|
||||
class: cursor.string16()?,
|
||||
locator: cursor.string16()?,
|
||||
weight: cursor.u16()?,
|
||||
required: cursor.boolean()?,
|
||||
});
|
||||
}
|
||||
let policy = AnchorPolicy {
|
||||
attester_threshold,
|
||||
attesters,
|
||||
required_classes,
|
||||
backend_threshold,
|
||||
backends,
|
||||
};
|
||||
validate_anchor_policy(&policy)?;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
struct Cursor<'a> {
|
||||
bytes: &'a [u8],
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
fn new(bytes: &'a [u8]) -> Self {
|
||||
Self { bytes, offset: 0 }
|
||||
}
|
||||
|
||||
fn take(&mut self, length: usize) -> Result<&'a [u8], DistributionError> {
|
||||
let end = self
|
||||
.offset
|
||||
.checked_add(length)
|
||||
.ok_or(DistributionError::Malformed)?;
|
||||
let value = self
|
||||
.bytes
|
||||
.get(self.offset..end)
|
||||
.ok_or(DistributionError::Malformed)?;
|
||||
self.offset = end;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn expect_magic(&mut self, magic: &[u8; 4]) -> Result<(), DistributionError> {
|
||||
if self.take(4)? == magic {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DistributionError::Malformed)
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_version(&mut self) -> Result<(), DistributionError> {
|
||||
let version = self.u8()?;
|
||||
if version == SSH_SIGCHAIN_VERSION {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DistributionError::UnsupportedVersion(version))
|
||||
}
|
||||
}
|
||||
|
||||
fn u8(&mut self) -> Result<u8, DistributionError> {
|
||||
Ok(self.take(1)?[0])
|
||||
}
|
||||
|
||||
fn boolean(&mut self) -> Result<bool, DistributionError> {
|
||||
match self.u8()? {
|
||||
0 => Ok(false),
|
||||
1 => Ok(true),
|
||||
_ => Err(DistributionError::Malformed),
|
||||
}
|
||||
}
|
||||
|
||||
fn u16(&mut self) -> Result<u16, DistributionError> {
|
||||
Ok(u16::from_be_bytes(
|
||||
self.take(2)?.try_into().expect("exact length"),
|
||||
))
|
||||
}
|
||||
|
||||
fn u32(&mut self) -> Result<u32, DistributionError> {
|
||||
Ok(u32::from_be_bytes(
|
||||
self.take(4)?.try_into().expect("exact length"),
|
||||
))
|
||||
}
|
||||
|
||||
fn count16(&mut self, maximum: usize) -> Result<usize, DistributionError> {
|
||||
let count = usize::from(self.u16()?);
|
||||
if count > maximum {
|
||||
Err(DistributionError::TooManyObjects)
|
||||
} else {
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
fn count32(&mut self, maximum: usize) -> Result<usize, DistributionError> {
|
||||
let count = usize::try_from(self.u32()?).map_err(|_| DistributionError::Malformed)?;
|
||||
if count > maximum {
|
||||
Err(DistributionError::TooManyObjects)
|
||||
} else {
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes32(&mut self) -> Result<&'a [u8], DistributionError> {
|
||||
let length = usize::try_from(self.u32()?).map_err(|_| DistributionError::Malformed)?;
|
||||
self.take(length)
|
||||
}
|
||||
|
||||
fn string16(&mut self) -> Result<String, DistributionError> {
|
||||
let length = usize::from(self.u16()?);
|
||||
String::from_utf8(self.take(length)?.to_vec()).map_err(|_| DistributionError::Malformed)
|
||||
}
|
||||
|
||||
fn array<const N: usize>(&mut self) -> Result<[u8; N], DistributionError> {
|
||||
self.take(N)?
|
||||
.try_into()
|
||||
.map_err(|_| DistributionError::Malformed)
|
||||
}
|
||||
|
||||
fn optional_digest(&mut self) -> Result<Option<Digest>, DistributionError> {
|
||||
match self.u8()? {
|
||||
0 => Ok(None),
|
||||
1 => Ok(Some(Digest(self.array()?))),
|
||||
_ => Err(DistributionError::Malformed),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(self) -> Result<(), DistributionError> {
|
||||
if self.offset == self.bytes.len() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DistributionError::NonCanonical)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_u32_count(out: &mut Vec<u8>, count: usize) -> Result<(), DistributionError> {
|
||||
out.extend_from_slice(
|
||||
&u32::try_from(count)
|
||||
.map_err(|_| SshSigchainError::LengthOverflow)?
|
||||
.to_be_bytes(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_object_size(bytes: &[u8]) -> Result<(), DistributionError> {
|
||||
if bytes.len() > MAX_CANONICAL_OBJECT_BYTES {
|
||||
Err(DistributionError::ObjectTooLarge(bytes.len()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ROOT_KEY: &str = "ssh-ed25519 AQID";
|
||||
|
||||
fn signed_genesis() -> SshSigchainRecord {
|
||||
SshSigchainRecord::unsigned(
|
||||
ChainId([7; 32]),
|
||||
None,
|
||||
ROOT_KEY,
|
||||
AuthorityTransition::Genesis {
|
||||
device_id: "device:root".to_owned(),
|
||||
root_key: AuthorityKey {
|
||||
public_key: ROOT_KEY.to_owned(),
|
||||
permissions: vec![Permission::All],
|
||||
delegable_permissions: vec![Permission::All],
|
||||
},
|
||||
anchor_policy: AnchorPolicy::default(),
|
||||
},
|
||||
vec![
|
||||
ProfileExtension::disclosed("example.profile", [9; 32], b"disclosed".to_vec())
|
||||
.expect("extension"),
|
||||
],
|
||||
)
|
||||
.expect("record")
|
||||
.with_signature(vec![1, 2, 3])
|
||||
.expect("signature")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bundle_roundtrips_and_preserves_disclosures() {
|
||||
let record = signed_genesis();
|
||||
let claim = HeadClaim::unsigned(
|
||||
record.chain_id,
|
||||
record.record_hash().expect("head"),
|
||||
ROOT_KEY,
|
||||
)
|
||||
.expect("claim")
|
||||
.with_signature(vec![4, 5, 6])
|
||||
.expect("claim signature");
|
||||
let receipt = AnchorReceipt {
|
||||
backend_id: "static-http".to_owned(),
|
||||
claim_hash: claim.claim_hash().expect("claim hash"),
|
||||
evidence: b"receipt".to_vec(),
|
||||
};
|
||||
let bundle = CanonicalSshSigchainBundle {
|
||||
chain_id: record.chain_id,
|
||||
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
|
||||
records: vec![record.clone()],
|
||||
claims: vec![claim],
|
||||
receipts: vec![receipt],
|
||||
};
|
||||
let encoded = encode_canonical_bundle(&bundle).expect("encode");
|
||||
let decoded = decode_canonical_bundle(&encoded).expect("decode");
|
||||
assert_eq!(decoded, bundle);
|
||||
assert_eq!(
|
||||
encode_canonical_bundle(&decoded).expect("reencode"),
|
||||
encoded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disclosure_bytes_are_separate_from_record_identity() {
|
||||
let disclosed = signed_genesis();
|
||||
let mut withheld = disclosed.clone();
|
||||
withheld.extensions[0].disclosure = None;
|
||||
assert_eq!(
|
||||
disclosed.record_hash().expect("hash"),
|
||||
withheld.record_hash().expect("hash")
|
||||
);
|
||||
assert_ne!(
|
||||
encode_canonical_bundle(&CanonicalSshSigchainBundle {
|
||||
chain_id: disclosed.chain_id,
|
||||
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
|
||||
records: vec![disclosed],
|
||||
claims: vec![],
|
||||
receipts: vec![],
|
||||
})
|
||||
.expect("disclosed bundle"),
|
||||
encode_canonical_bundle(&CanonicalSshSigchainBundle {
|
||||
chain_id: withheld.chain_id,
|
||||
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
|
||||
records: vec![withheld],
|
||||
claims: vec![],
|
||||
receipts: vec![],
|
||||
})
|
||||
.expect("withheld bundle")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_rejects_trailing_and_noncanonical_bytes() {
|
||||
let record = signed_genesis();
|
||||
let mut encoded = encode_canonical_bundle(&CanonicalSshSigchainBundle {
|
||||
chain_id: record.chain_id,
|
||||
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
|
||||
records: vec![record],
|
||||
claims: vec![],
|
||||
receipts: vec![],
|
||||
})
|
||||
.expect("encode");
|
||||
encoded.push(0);
|
||||
assert_eq!(
|
||||
decode_canonical_bundle(&encoded),
|
||||
Err(DistributionError::NonCanonical)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bundle_roundtrips_every_authority_transition() {
|
||||
let chain_id = ChainId([5; 32]);
|
||||
let key_id = authority_key_id(ROOT_KEY).expect("key ID");
|
||||
let transitions = vec![
|
||||
AuthorityTransition::Genesis {
|
||||
device_id: "device:root".to_owned(),
|
||||
root_key: AuthorityKey {
|
||||
public_key: ROOT_KEY.to_owned(),
|
||||
permissions: vec![Permission::All],
|
||||
delegable_permissions: vec![Permission::All],
|
||||
},
|
||||
anchor_policy: AnchorPolicy::default(),
|
||||
},
|
||||
AuthorityTransition::DeviceAdd {
|
||||
device_id: "device:second".to_owned(),
|
||||
permission_ceiling: vec![Permission::DeviceAdd],
|
||||
},
|
||||
AuthorityTransition::DeviceRevoke {
|
||||
device_id: "device:second".to_owned(),
|
||||
},
|
||||
AuthorityTransition::KeyAdd {
|
||||
device_id: "device:root".to_owned(),
|
||||
key: AuthorityKey {
|
||||
public_key: ROOT_KEY.to_owned(),
|
||||
permissions: vec![],
|
||||
delegable_permissions: vec![],
|
||||
},
|
||||
proof: vec![1],
|
||||
},
|
||||
AuthorityTransition::KeyRevoke { key_id },
|
||||
AuthorityTransition::PermissionGrant {
|
||||
key_id,
|
||||
permissions: vec![Permission::DeviceAdd],
|
||||
delegable_permissions: vec![Permission::AnchorPolicy],
|
||||
},
|
||||
AuthorityTransition::PermissionRevoke {
|
||||
key_id,
|
||||
permissions: vec![Permission::DeviceAdd],
|
||||
delegable_permissions: vec![Permission::AnchorPolicy],
|
||||
},
|
||||
AuthorityTransition::AnchorPolicySet {
|
||||
policy: AnchorPolicy {
|
||||
attester_threshold: 1,
|
||||
attesters: vec![AnchorAttesterPolicy {
|
||||
key_id,
|
||||
weight: 1,
|
||||
required: true,
|
||||
}],
|
||||
required_classes: vec!["http".to_owned()],
|
||||
backend_threshold: 1,
|
||||
backends: vec![AnchorBackendPolicy {
|
||||
backend_id: "static-http".to_owned(),
|
||||
class: "http".to_owned(),
|
||||
locator: "https://example.test".to_owned(),
|
||||
weight: 1,
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
},
|
||||
AuthorityTransition::Noop,
|
||||
];
|
||||
let mut records = Vec::new();
|
||||
for (index, transition) in transitions.into_iter().enumerate() {
|
||||
let previous = records
|
||||
.last()
|
||||
.map(SshSigchainRecord::record_hash)
|
||||
.transpose()
|
||||
.expect("previous hash");
|
||||
records.push(
|
||||
SshSigchainRecord::unsigned(chain_id, previous, ROOT_KEY, transition, vec![])
|
||||
.expect("record")
|
||||
.with_signature(vec![u8::try_from(index + 1).expect("small index")])
|
||||
.expect("signature"),
|
||||
);
|
||||
}
|
||||
let bundle = CanonicalSshSigchainBundle {
|
||||
chain_id,
|
||||
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
|
||||
records,
|
||||
claims: vec![],
|
||||
receipts: vec![],
|
||||
};
|
||||
let bytes = encode_canonical_bundle(&bundle).expect("encode");
|
||||
assert_eq!(decode_canonical_bundle(&bytes).expect("decode"), bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_http_path_is_stable_and_contains_only_lowercase_chain_id() {
|
||||
assert_eq!(
|
||||
static_http_bundle_path(ChainId([0xab; 32])),
|
||||
concat!(
|
||||
"/.well-known/sshsigchain/v1/",
|
||||
"abababababababababababababababababababababababababababababababab/",
|
||||
"chain.sscb"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bundle_matches_published_base_vector() {
|
||||
let record = SshSigchainRecord::unsigned(
|
||||
ChainId([0; 32]),
|
||||
None,
|
||||
ROOT_KEY,
|
||||
AuthorityTransition::Noop,
|
||||
vec![],
|
||||
)
|
||||
.expect("record")
|
||||
.with_signature(vec![1])
|
||||
.expect("signature");
|
||||
let bytes = encode_canonical_bundle(&CanonicalSshSigchainBundle {
|
||||
chain_id: ChainId([0; 32]),
|
||||
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
|
||||
records: vec![record],
|
||||
claims: vec![],
|
||||
receipts: vec![],
|
||||
})
|
||||
.expect("bundle");
|
||||
assert_eq!(
|
||||
hex::encode(bytes),
|
||||
concat!(
|
||||
"5353434201",
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"000e737368736967636861696e2e7631",
|
||||
"00000001",
|
||||
"00000069",
|
||||
"5353435201",
|
||||
"0000005b",
|
||||
"5353435301000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a2",
|
||||
"00107373682d656432353531392041514944080000",
|
||||
"0000000101",
|
||||
"00000000",
|
||||
"00000000",
|
||||
"00000000"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue