use std::{collections::BTreeMap, fs, io::Write, net::SocketAddr};
use exo_core::{Did, Hash256, SecretKey, Timestamp, crypto::KeyPair};
use exo_root::{
CeremonyEnvelope, CeremonyEnvelopeDraft, CeremonyPayloadKind, CeremonyPhase, CertifierContact,
GenesisCeremonyConfig, PairwiseEncryptedPayload, PortalStore, RootIssuerDelegation,
RootKeyPackage, RootParticipantDkgOutput, RootPublicKeyPackage, RootSignature,
RootSigningNonces, RootSigningPackage, RootTrustBundle, aggregate_signature,
assemble_root_bundle, build_final_key_confirmation, build_signing_package,
decrypt_pairwise_payload, dkg_finalize_participant, dkg_round1, dkg_round2,
encode_final_key_confirmation_payload, encrypt_pairwise_payload, seal_share, sign_commit,
sign_share, threshold_sign, unseal_share, verify_root_bundle,
};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
use crate::{
cli::{
GenesisCeremonyCommand, GenesisCeremonyInitArgs, GenesisCertifierCommand,
GenesisCertifierInitArgs, GenesisCommand, GenesisIoArgs, GenesisPortalArgs,
GenesisPullEnvelopesArgs, GenesisSignCommitArgs, GenesisSignEnvelopeArgs,
GenesisSignShareArgs, GenesisSubmitEnvelopeArgs,
},
root_genesis::{RootGenesisApiState, root_genesis_router},
};
const PORTAL_ENVELOPES_PATH: &str = "/api/v1/root-genesis/portal/envelopes";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct PrivateCertifierMaterial {
did: Did,
frost_identifier: u16,
signing_secret_hex: String,
transport_secret_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Round1CommandInput {
config: GenesisCeremonyConfig,
frost_identifier: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Round2CommandInput {
config: GenesisCeremonyConfig,
frost_identifier: u16,
round1_secret_package_hex: String,
round1_packages_hex: BTreeMapStringBytes,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct FinalizeDkgCommandInput {
config: GenesisCeremonyConfig,
frost_identifier: u16,
round2_secret_package_hex: String,
round1_packages_hex: BTreeMapStringBytes,
round2_packages_hex: BTreeMapStringBytes,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct BuildFinalKeyConfirmationCommandInput {
config: GenesisCeremonyConfig,
dkg_output: RootParticipantDkgOutput,
dkg_transcript_hash_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct SignRootArtifactCommandInput {
config: GenesisCeremonyConfig,
public_key_package: RootPublicKeyPackage,
key_packages: BTreeMap<u16, RootKeyPackage>,
artifact_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct AssembleBundleCommandInput {
config: GenesisCeremonyConfig,
public_key_package: RootPublicKeyPackage,
issuer_delegation: RootIssuerDelegation,
transcript_hash: Hash256,
root_signature: RootSignature,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct VerifyBundleCommandInput {
bundle: RootTrustBundle,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct TranscriptHashCommandInput {
config: GenesisCeremonyConfig,
envelopes: Vec<CeremonyEnvelope>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct SealShareCommandInput {
share_hex: String,
passphrase_hex: String,
associated_data_hex: String,
salt_hex: String,
nonce_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct UnsealShareCommandInput {
sealed: exo_root::SealedShare,
passphrase_hex: String,
associated_data_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct HexBytesOutput {
bytes_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct HashHexOutput {
hash_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct SignEnvelopeCommandInput {
ceremony_id: String,
phase: CeremonyPhase,
payload_kind: CeremonyPayloadKind,
sender_did: Did,
#[serde(default)]
recipient_did: Option<Did>,
sequence: u64,
payload_bytes: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct EncryptPairwiseCommandInput {
plaintext: Vec<u8>,
sender_transport_secret_hex: String,
recipient_transport_pubkey_hex: String,
associated_data_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct DecryptPairwiseCommandInput {
encrypted: PairwiseEncryptedPayload,
recipient_transport_secret_hex: String,
sender_transport_pubkey_hex: String,
associated_data_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct EmitArtifactBytesCommandInput {
config: GenesisCeremonyConfig,
public_key_package: RootPublicKeyPackage,
issuer_delegation: RootIssuerDelegation,
transcript_hash_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct ArtifactBytesOutput {
artifact_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct PlaintextOutput {
plaintext: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct EncodeEncryptedPayloadCommandInput {
encrypted: PairwiseEncryptedPayload,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct DecodeEncryptedPayloadCommandInput {
payload_bytes: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct PayloadBytesOutput {
payload_bytes: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct SignCommitCommandInput {
config: GenesisCeremonyConfig,
key_package: RootKeyPackage,
artifact_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct BuildSigningPackageCommandInput {
config: GenesisCeremonyConfig,
commitments_hex: BTreeMapStringBytes,
artifact_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct SignShareCommandInput {
config: GenesisCeremonyConfig,
key_package: RootKeyPackage,
signing_package: RootSigningPackage,
artifact_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct AggregateSignatureCommandInput {
config: GenesisCeremonyConfig,
public_key_package: RootPublicKeyPackage,
signing_package_hex: String,
shares_hex: BTreeMapStringBytes,
artifact_hex: String,
}
type BTreeMapStringBytes = BTreeMap<u16, String>;
pub async fn run_genesis_command(command: GenesisCommand) -> anyhow::Result<()> {
match command {
GenesisCommand::Certifier { command } => run_certifier_command(command),
GenesisCommand::Ceremony { command } => run_ceremony_command(command),
GenesisCommand::Portal(args) => serve_portal(args).await,
GenesisCommand::Round1(args) => run_round1(args),
GenesisCommand::Round2(args) => run_round2(args),
GenesisCommand::FinalizeDkg(args) => run_finalize_dkg(args),
GenesisCommand::BuildFinalKeyConfirmation(args) => run_build_final_key_confirmation(args),
GenesisCommand::SignRootArtifact(args) => run_sign_root_artifact(args),
GenesisCommand::AssembleBundle(args) => run_assemble_bundle(args),
GenesisCommand::VerifyBundle(args) => run_verify_bundle(args),
GenesisCommand::SealShare(args) => run_seal_share(args),
GenesisCommand::UnsealShare(args) => run_unseal_share(args),
GenesisCommand::SignEnvelope(args) => run_sign_envelope(args),
GenesisCommand::EncryptPairwise(args) => run_encrypt_pairwise(args),
GenesisCommand::DecryptPairwise(args) => run_decrypt_pairwise(args),
GenesisCommand::EmitArtifactBytes(args) => run_emit_artifact_bytes(args),
GenesisCommand::SubmitEnvelope(args) => run_submit_envelope(args).await,
GenesisCommand::PullEnvelopes(args) => run_pull_envelopes(args).await,
GenesisCommand::ComputeDkgTranscriptHash(args) => run_compute_dkg_transcript_hash(args),
GenesisCommand::ComputeFinalTranscriptHash(args) => run_compute_final_transcript_hash(args),
GenesisCommand::EncodeEncryptedPayload(args) => run_encode_encrypted_payload(args),
GenesisCommand::DecodeEncryptedPayload(args) => run_decode_encrypted_payload(args),
GenesisCommand::SignCommit(args) => run_sign_commit(args),
GenesisCommand::BuildSigningPackage(args) => run_build_signing_package(args),
GenesisCommand::SignShare(args) => run_sign_share(args),
GenesisCommand::AggregateSignature(args) => run_aggregate_signature(args),
}
}
fn run_certifier_command(command: GenesisCertifierCommand) -> anyhow::Result<()> {
match command {
GenesisCertifierCommand::Init(args) => init_certifier(args),
}
}
fn run_ceremony_command(command: GenesisCeremonyCommand) -> anyhow::Result<()> {
match command {
GenesisCeremonyCommand::Init(args) => init_ceremony(args),
}
}
fn init_certifier(args: GenesisCertifierInitArgs) -> anyhow::Result<()> {
let did = Did::new(&args.did)?;
let mut signing_seed = [0u8; 32];
let mut transport_secret = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut signing_seed);
rand::rngs::OsRng.fill_bytes(&mut transport_secret);
let signing_keypair = KeyPair::from_secret_bytes(signing_seed)?;
let transport_public = X25519PublicKey::from(&StaticSecret::from(transport_secret));
let contact = CertifierContact {
did: did.clone(),
frost_identifier: args.frost_identifier,
signing_public_key: *signing_keypair.public_key(),
transport_public_key: *transport_public.as_bytes(),
};
let private = PrivateCertifierMaterial {
did,
frost_identifier: args.frost_identifier,
signing_secret_hex: hex::encode(signing_seed),
transport_secret_hex: hex::encode(transport_secret),
};
write_json(&args.certifier_out, &contact)?;
write_json(&args.private_out, &private)?;
Ok(())
}
fn init_ceremony(args: GenesisCeremonyInitArgs) -> anyhow::Result<()> {
let certifiers: Vec<CertifierContact> = read_json(&args.roster)?;
let constitution_hash = parse_hash_hex(&args.constitution_hash)?;
let config = GenesisCeremonyConfig {
ceremony_id: args.ceremony_id,
network_id: args.network_id,
repo_commit: args.repo_commit,
constitution_hash,
threshold: exo_root::ROOT_GENESIS_THRESHOLD,
max_signers: exo_root::ROOT_GENESIS_SIGNERS,
created_at: Timestamp::new(args.created_physical_ms, 0),
certifiers,
signing_set: args.signing_set,
};
config.validate()?;
write_json(&args.out, &config)?;
Ok(())
}
async fn serve_portal(args: GenesisPortalArgs) -> anyhow::Result<()> {
let config: GenesisCeremonyConfig = read_json(&args.config)?;
config.validate()?;
let address = args.bind.parse::<SocketAddr>()?;
let listener = tokio::net::TcpListener::bind(address).await?;
let router = root_genesis_router(RootGenesisApiState::new(config));
axum::serve(listener, router).await?;
Ok(())
}
fn run_round1(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: Round1CommandInput = read_json(&required_input(&args)?)?;
input.config.validate()?;
let mut rng = rand::rngs::OsRng;
let output = dkg_round1(&input.config, input.frost_identifier, &mut rng)?;
write_secret_output(&args, &output)
}
fn run_round2(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: Round2CommandInput = read_json(&required_input(&args)?)?;
let output = dkg_round2(
&input.config,
input.frost_identifier,
decode_hex(&input.round1_secret_package_hex)?.as_slice(),
decode_package_map(input.round1_packages_hex)?,
)?;
write_secret_output(&args, &output)
}
fn run_finalize_dkg(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: FinalizeDkgCommandInput = read_json(&required_input(&args)?)?;
let output = dkg_finalize_participant(
&input.config,
input.frost_identifier,
decode_hex(&input.round2_secret_package_hex)?.as_slice(),
decode_package_map(input.round1_packages_hex)?,
decode_package_map(input.round2_packages_hex)?,
)?;
write_secret_output(&args, &output)
}
fn run_build_final_key_confirmation(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: BuildFinalKeyConfirmationCommandInput = read_json(&required_input(&args)?)?;
let dkg_transcript_hash = parse_hash_hex(&input.dkg_transcript_hash_hex)?;
let confirmation =
build_final_key_confirmation(&input.config, &input.dkg_output, dkg_transcript_hash)?;
let payload_bytes = encode_final_key_confirmation_payload(&confirmation)?;
write_output(&args, &PayloadBytesOutput { payload_bytes })
}
fn run_sign_root_artifact(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: SignRootArtifactCommandInput = read_json(&required_input(&args)?)?;
let artifact = decode_hex(&input.artifact_hex)?;
let mut rng = rand::rngs::OsRng;
let signature = threshold_sign(
&input.config,
&input.public_key_package,
input.key_packages,
artifact.as_slice(),
&mut rng,
)?;
write_output(&args, &signature)
}
fn run_assemble_bundle(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: AssembleBundleCommandInput = read_json(&required_input(&args)?)?;
let bundle = assemble_root_bundle(
input.config,
input.public_key_package,
input.issuer_delegation,
input.transcript_hash,
input.root_signature,
)?;
write_output(&args, &bundle)
}
fn run_verify_bundle(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: VerifyBundleCommandInput = read_json(&required_input(&args)?)?;
verify_root_bundle(&input.bundle)?;
write_output(&args, &serde_json::json!({ "verified": true }))
}
fn run_seal_share(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: SealShareCommandInput = read_json(&required_input(&args)?)?;
let salt = decode_fixed_16(&input.salt_hex)?;
let nonce = decode_fixed_24(&input.nonce_hex)?;
let sealed = seal_share(
decode_hex(&input.share_hex)?.as_slice(),
decode_hex(&input.passphrase_hex)?.as_slice(),
decode_hex(&input.associated_data_hex)?.as_slice(),
&salt,
&nonce,
)?;
write_secret_output(&args, &sealed)
}
fn run_unseal_share(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: UnsealShareCommandInput = read_json(&required_input(&args)?)?;
let opened = unseal_share(
&input.sealed,
decode_hex(&input.passphrase_hex)?.as_slice(),
decode_hex(&input.associated_data_hex)?.as_slice(),
)?;
write_secret_output(
&args,
&HexBytesOutput {
bytes_hex: hex::encode(opened),
},
)
}
fn run_sign_envelope(args: GenesisSignEnvelopeArgs) -> anyhow::Result<()> {
let io = GenesisIoArgs {
input: args.input.clone(),
output: args.output.clone(),
};
let input: SignEnvelopeCommandInput = read_json(&required_input(&io)?)?;
match input.payload_kind {
CeremonyPayloadKind::Round1SetAttestation | CeremonyPayloadKind::Round2PlaintextPackage => {
anyhow::bail!(
"sign-envelope refuses payload kind {:?}: it is disabled and rejected by the portal",
input.payload_kind
);
}
CeremonyPayloadKind::Round1Package
| CeremonyPayloadKind::Round2EncryptedPackage
| CeremonyPayloadKind::FinalKeyConfirmation
| CeremonyPayloadKind::RootSigningCommitment
| CeremonyPayloadKind::RootSignatureShare => {}
}
let private: PrivateCertifierMaterial = read_json(&args.private_input)?;
if private.did != input.sender_did {
anyhow::bail!(
"private material DID {} does not match envelope sender_did {}",
private.did,
input.sender_did
);
}
let signing_secret = SecretKey::from_bytes(decode_fixed_32(&private.signing_secret_hex)?);
let draft = CeremonyEnvelopeDraft {
ceremony_id: input.ceremony_id,
phase: input.phase,
payload_kind: input.payload_kind,
sender_did: input.sender_did,
recipient_did: input.recipient_did,
sequence: input.sequence,
payload_bytes: input.payload_bytes,
};
let envelope = CeremonyEnvelope::sign(draft, &signing_secret)?;
write_output(&io, &envelope)
}
fn run_encrypt_pairwise(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: EncryptPairwiseCommandInput = read_json(&required_input(&args)?)?;
let sender_secret = decode_fixed_32(&input.sender_transport_secret_hex)?;
let recipient_public = decode_fixed_32(&input.recipient_transport_pubkey_hex)?;
let mut nonce = [0u8; 24];
rand::rngs::OsRng.fill_bytes(&mut nonce);
let associated_data = decode_hex(&input.associated_data_hex)?;
let encrypted = encrypt_pairwise_payload(
&sender_secret,
&recipient_public,
input.plaintext.as_slice(),
associated_data.as_slice(),
&nonce,
)?;
write_output(&args, &encrypted)
}
fn run_decrypt_pairwise(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: DecryptPairwiseCommandInput = read_json(&required_input(&args)?)?;
let recipient_secret = decode_fixed_32(&input.recipient_transport_secret_hex)?;
let sender_public = decode_fixed_32(&input.sender_transport_pubkey_hex)?;
let associated_data = decode_hex(&input.associated_data_hex)?;
let plaintext = decrypt_pairwise_payload(
&recipient_secret,
&sender_public,
&input.encrypted,
associated_data.as_slice(),
)?;
write_secret_output(&args, &PlaintextOutput { plaintext })
}
fn run_emit_artifact_bytes(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: EmitArtifactBytesCommandInput = read_json(&required_input(&args)?)?;
let transcript_hash = parse_hash_hex(&input.transcript_hash_hex)?;
let artifact = input.issuer_delegation.root_artifact_payload(
&input.config,
&input.public_key_package,
transcript_hash,
)?;
write_output(
&args,
&ArtifactBytesOutput {
artifact_hex: hex::encode(artifact),
},
)
}
fn run_compute_dkg_transcript_hash(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: TranscriptHashCommandInput = read_json(&required_input(&args)?)?;
let store = replay_portal_envelopes(input.config, input.envelopes)?;
let hash = store.dkg_transcript_hash()?;
write_output(
&args,
&HashHexOutput {
hash_hex: hex::encode(hash.as_bytes()),
},
)
}
fn run_compute_final_transcript_hash(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: TranscriptHashCommandInput = read_json(&required_input(&args)?)?;
let store = replay_portal_envelopes(input.config, input.envelopes)?;
let hash = store.final_transcript_hash()?;
write_output(
&args,
&HashHexOutput {
hash_hex: hex::encode(hash.as_bytes()),
},
)
}
async fn run_submit_envelope(args: GenesisSubmitEnvelopeArgs) -> anyhow::Result<()> {
let io = GenesisIoArgs {
input: args.input.clone(),
output: None,
};
let envelope: CeremonyEnvelope = read_json(&required_input(&io)?)?;
let url = portal_envelopes_url(&args.portal_url);
let response = reqwest::Client::new()
.post(url)
.json(&envelope)
.send()
.await?;
let status = response.status();
let body = response.text().await?;
println!("{body}");
if !status.is_success() {
anyhow::bail!("portal rejected envelope: HTTP {status}");
}
Ok(())
}
async fn run_pull_envelopes(args: GenesisPullEnvelopesArgs) -> anyhow::Result<()> {
let url = portal_envelopes_url(&args.portal_url);
let mut params: Vec<(&str, String)> = Vec::new();
if let Some(phase) = &args.phase {
params.push(("phase", phase.clone()));
}
if let Some(kind) = &args.payload_kind {
params.push(("payload_kind", kind.clone()));
}
if let Some(recipient) = &args.recipient_did {
params.push(("recipient_did", recipient.clone()));
}
let response = reqwest::Client::new()
.get(url)
.query(¶ms)
.send()
.await?;
let status = response.status();
let body = response.text().await?;
if !status.is_success() {
anyhow::bail!("portal pull failed: HTTP {status}: {body}");
}
let envelopes: Vec<CeremonyEnvelope> = serde_json::from_str(&body)?;
let io = GenesisIoArgs {
input: None,
output: args.output.clone(),
};
write_output(&io, &envelopes)
}
fn run_encode_encrypted_payload(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: EncodeEncryptedPayloadCommandInput = read_json(&required_input(&args)?)?;
let mut payload_bytes = Vec::new();
ciborium::into_writer(&input.encrypted, &mut payload_bytes)
.map_err(|error| anyhow::anyhow!("encrypted payload encoding failed: {error}"))?;
write_output(&args, &PayloadBytesOutput { payload_bytes })
}
fn run_decode_encrypted_payload(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: DecodeEncryptedPayloadCommandInput = read_json(&required_input(&args)?)?;
let encrypted: PairwiseEncryptedPayload = ciborium::from_reader(input.payload_bytes.as_slice())
.map_err(|error| anyhow::anyhow!("encrypted payload decoding failed: {error}"))?;
write_output(&args, &encrypted)
}
fn run_sign_commit(args: GenesisSignCommitArgs) -> anyhow::Result<()> {
let io = GenesisIoArgs {
input: args.input.clone(),
output: None,
};
let input: SignCommitCommandInput = read_json(&required_input(&io)?)?;
let artifact = decode_hex(&input.artifact_hex)?;
let mut rng = rand::rngs::OsRng;
let (commitment, nonces) = sign_commit(
&input.config,
&input.key_package,
artifact.as_slice(),
&mut rng,
)?;
write_json(&args.commitment_out, &commitment)?;
write_json(&args.nonces_out, &nonces)?;
Ok(())
}
fn run_build_signing_package(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: BuildSigningPackageCommandInput = read_json(&required_input(&args)?)?;
let message = decode_hex(&input.artifact_hex)?;
let output = build_signing_package(
&input.config,
decode_package_map(input.commitments_hex)?,
message.as_slice(),
)?;
write_output(&args, &output)
}
fn run_sign_share(args: GenesisSignShareArgs) -> anyhow::Result<()> {
let io = GenesisIoArgs {
input: args.input.clone(),
output: args.output.clone(),
};
let input: SignShareCommandInput = read_json(&required_input(&io)?)?;
let nonces: RootSigningNonces = read_json(&args.nonces)?;
let artifact = decode_hex(&input.artifact_hex)?;
let output = sign_share(
&input.config,
&input.key_package,
&nonces,
&input.signing_package,
artifact.as_slice(),
)?;
write_output(&io, &output)?;
fs::remove_file(&args.nonces).map_err(|error| {
anyhow::anyhow!(
"sign-share produced a share but failed to consume the single-use nonces file {}: \
{error}; delete it manually and abort this signing session",
args.nonces.display()
)
})?;
Ok(())
}
fn run_aggregate_signature(args: GenesisIoArgs) -> anyhow::Result<()> {
let input: AggregateSignatureCommandInput = read_json(&required_input(&args)?)?;
let message = decode_hex(&input.artifact_hex)?;
let output = aggregate_signature(
&input.config,
&input.public_key_package,
decode_hex(&input.signing_package_hex)?.as_slice(),
decode_package_map(input.shares_hex)?,
message.as_slice(),
)?;
write_output(&args, &output)
}
fn replay_portal_envelopes(
config: GenesisCeremonyConfig,
mut envelopes: Vec<CeremonyEnvelope>,
) -> anyhow::Result<PortalStore> {
sort_portal_envelopes(envelopes.as_mut_slice());
let mut store = PortalStore::new(config);
for envelope in envelopes {
store.submit(envelope)?;
}
Ok(store)
}
fn sort_portal_envelopes(envelopes: &mut [CeremonyEnvelope]) {
envelopes.sort_by(|left, right| {
left.phase
.cmp(&right.phase)
.then(left.payload_kind.cmp(&right.payload_kind))
.then(left.sender_did.cmp(&right.sender_did))
.then(left.recipient_did.cmp(&right.recipient_did))
.then(left.sequence.cmp(&right.sequence))
});
}
fn portal_envelopes_url(base: &str) -> String {
let trimmed = base.trim_end_matches('/');
if trimmed.ends_with(PORTAL_ENVELOPES_PATH) {
trimmed.to_owned()
} else {
format!("{trimmed}{PORTAL_ENVELOPES_PATH}")
}
}
fn parse_hash_hex(value: &str) -> anyhow::Result<Hash256> {
let bytes = hex::decode(value)?;
if bytes.len() != 32 {
anyhow::bail!("hash must be 32 bytes");
}
let mut hash = [0u8; 32];
hash.copy_from_slice(&bytes);
Ok(Hash256::from_bytes(hash))
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &std::path::Path) -> anyhow::Result<T> {
let bytes = fs::read(path)?;
let value = serde_json::from_slice(&bytes)?;
Ok(value)
}
fn write_json<T: Serialize>(path: &std::path::Path, value: &T) -> anyhow::Result<()> {
let bytes = serde_json::to_vec_pretty(value)?;
write_json_bytes(path, bytes.as_slice())?;
Ok(())
}
#[cfg(unix)]
fn write_json_bytes(path: &std::path::Path, bytes: &[u8]) -> anyhow::Result<()> {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(path)?;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
file.write_all(bytes)?;
file.sync_all()?;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
Ok(())
}
#[cfg(not(unix))]
fn write_json_bytes(path: &std::path::Path, bytes: &[u8]) -> anyhow::Result<()> {
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(path)?;
file.write_all(bytes)?;
file.sync_all()?;
Ok(())
}
fn required_input(args: &GenesisIoArgs) -> anyhow::Result<std::path::PathBuf> {
args.input
.clone()
.ok_or_else(|| anyhow::anyhow!("--input is required for this command"))
}
fn write_output<T: Serialize>(args: &GenesisIoArgs, value: &T) -> anyhow::Result<()> {
match &args.output {
Some(path) => write_json(path, value),
None => {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
}
}
fn write_secret_output<T: Serialize>(args: &GenesisIoArgs, value: &T) -> anyhow::Result<()> {
if args.output.is_none() {
anyhow::bail!(
"--output is required for secret root genesis material; refusing to print to stdout"
);
}
write_output(args, value)
}
fn decode_hex(value: &str) -> anyhow::Result<Vec<u8>> {
Ok(hex::decode(value)?)
}
fn decode_fixed_16(value: &str) -> anyhow::Result<[u8; 16]> {
let bytes = decode_hex(value)?;
if bytes.len() != 16 {
anyhow::bail!("expected 16 bytes");
}
let mut result = [0u8; 16];
result.copy_from_slice(&bytes);
Ok(result)
}
fn decode_fixed_24(value: &str) -> anyhow::Result<[u8; 24]> {
let bytes = decode_hex(value)?;
if bytes.len() != 24 {
anyhow::bail!("expected 24 bytes");
}
let mut result = [0u8; 24];
result.copy_from_slice(&bytes);
Ok(result)
}
fn decode_fixed_32(value: &str) -> anyhow::Result<[u8; 32]> {
let bytes = decode_hex(value)?;
if bytes.len() != 32 {
anyhow::bail!("expected 32 bytes");
}
let mut result = [0u8; 32];
result.copy_from_slice(&bytes);
Ok(result)
}
fn decode_package_map(packages: BTreeMapStringBytes) -> anyhow::Result<BTreeMap<u16, Vec<u8>>> {
let mut decoded = BTreeMap::new();
for (identifier, package_hex) in packages {
decoded.insert(identifier, decode_hex(&package_hex)?);
}
Ok(decoded)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use exo_authority::permission::Permission;
use exo_core::PublicKey;
use rand::SeedableRng;
use tempfile::tempdir;
use super::*;
fn certifier(identifier: u16) -> CertifierContact {
let byte = u8::try_from(identifier).expect("identifier fits in byte");
CertifierContact {
did: Did::new(&format!("did:exo:root-cli-{identifier:02}")).expect("valid DID"),
frost_identifier: identifier,
signing_public_key: PublicKey::from_bytes([byte; 32]),
transport_public_key: [byte; 32],
}
}
fn io_args(input: PathBuf, output: PathBuf) -> GenesisIoArgs {
GenesisIoArgs {
input: Some(input),
output: Some(output),
}
}
fn rostered_config() -> GenesisCeremonyConfig {
let certifiers = (1..=13u16)
.map(|index| {
let seed = u8::try_from(index).expect("index fits u8");
let keypair = KeyPair::from_secret_bytes([seed; 32]).expect("valid keypair");
let transport_secret = [seed.wrapping_add(64); 32];
let transport_public = X25519PublicKey::from(&StaticSecret::from(transport_secret));
CertifierContact {
did: Did::new(&format!("did:exo:root-cli-signed-{index:02}")).expect("did"),
frost_identifier: index,
signing_public_key: *keypair.public_key(),
transport_public_key: *transport_public.as_bytes(),
}
})
.collect();
let config = GenesisCeremonyConfig {
ceremony_id: "root-cli-signed-ceremony".to_owned(),
network_id: "exo-mainnet".to_owned(),
repo_commit: "d8927686a34bdc28ba36d53938f665685d2c4c04".to_owned(),
constitution_hash: Hash256::digest(b"constitution"),
threshold: exo_root::ROOT_GENESIS_THRESHOLD,
max_signers: exo_root::ROOT_GENESIS_SIGNERS,
created_at: Timestamp::new(1_785_000_000_000, 0),
certifiers,
signing_set: (1..=7).collect(),
};
config.validate().expect("rostered config is valid");
config
}
fn valid_root_trust_bundle() -> RootTrustBundle {
let config = rostered_config();
let mut rng = rand::rngs::StdRng::seed_from_u64(9683);
let dkg = exo_root::run_complete_dkg(&config, &mut rng).expect("dkg");
let delegation = RootIssuerDelegation {
issuer_did: Did::new("did:exo:root-cli-avc-issuer").expect("issuer DID"),
issuer_public_key: PublicKey::from_bytes([0x44; 32]),
granted_permissions: vec![Permission::Read, Permission::Write, Permission::Delegate],
effective_at: Timestamp::new(1_785_000_010_000, 0),
expires_at: None,
purpose: "Delegate operational AVC issuing authority".to_owned(),
};
let transcript_hash = Hash256::digest(b"root-cli-verifier-policy-transcript");
let payload = delegation
.root_artifact_payload(&config, &dkg.public_key_package, transcript_hash)
.expect("payload");
let root_signature = threshold_sign(
&config,
&dkg.public_key_package,
dkg.key_packages
.iter()
.take(7)
.map(|(identifier, key_package)| (*identifier, key_package.clone()))
.collect(),
&payload,
&mut rng,
)
.expect("signature");
assemble_root_bundle(
config,
dkg.public_key_package,
delegation,
transcript_hash,
root_signature,
)
.expect("bundle")
}
fn valid_round1_package(config: &GenesisCeremonyConfig) -> Vec<u8> {
exo_root::dkg_round1(config, 1, &mut rand::rngs::OsRng)
.expect("round one")
.round1_package
}
fn signed_test_envelope(
config: &GenesisCeremonyConfig,
sender_identifier: u16,
phase: CeremonyPhase,
payload_kind: CeremonyPayloadKind,
recipient_identifier: Option<u16>,
sequence: u64,
payload_bytes: Vec<u8>,
) -> CeremonyEnvelope {
let sender = config
.certifier_by_identifier(sender_identifier)
.expect("sender");
let recipient = recipient_identifier.map(|identifier| {
config
.certifier_by_identifier(identifier)
.expect("recipient")
.did
.clone()
});
CeremonyEnvelope::sign(
CeremonyEnvelopeDraft {
ceremony_id: config.ceremony_id.clone(),
phase,
payload_kind,
sender_did: sender.did.clone(),
recipient_did: recipient,
sequence,
payload_bytes,
},
&SecretKey::from_bytes([u8::try_from(sender_identifier).expect("id fits"); 32]),
)
.expect("signed envelope")
}
fn encoded_pairwise_payload(ciphertext: impl Into<Vec<u8>>) -> Vec<u8> {
let payload = PairwiseEncryptedPayload {
nonce: [9u8; 24],
ciphertext: ciphertext.into(),
};
let mut bytes = Vec::new();
ciborium::into_writer(&payload, &mut bytes).expect("encode encrypted payload");
bytes
}
fn round1_broadcast(
config: &GenesisCeremonyConfig,
certifier_index: usize,
sequence: u64,
payload_bytes: Vec<u8>,
) -> CeremonyEnvelope {
let signer = &config.certifiers[certifier_index];
let seed = u8::try_from(certifier_index + 1).expect("index fits u8");
let secret = SecretKey::from_bytes([seed; 32]);
CeremonyEnvelope::sign(
CeremonyEnvelopeDraft {
ceremony_id: config.ceremony_id.clone(),
phase: CeremonyPhase::Round1,
payload_kind: CeremonyPayloadKind::Round1Package,
sender_did: signer.did.clone(),
recipient_did: None,
sequence,
payload_bytes,
},
&secret,
)
.expect("signed round1 envelope")
}
async fn spawn_test_portal(config: GenesisCeremonyConfig) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind portal listener");
let address = listener.local_addr().expect("portal address");
let router = root_genesis_router(RootGenesisApiState::new(config));
tokio::spawn(async move {
axum::serve(listener, router).await.expect("serve portal");
});
format!("http://{address}")
}
#[tokio::test]
async fn dispatcher_setup_commands_write_expected_files() {
let directory = tempdir().expect("temporary directory");
let certifier_path = directory.path().join("certifier-01.json");
let private_path = directory.path().join("certifier-01.private.json");
run_genesis_command(GenesisCommand::Certifier {
command: GenesisCertifierCommand::Init(GenesisCertifierInitArgs {
did: "did:exo:root-cli-new-certifier".to_owned(),
frost_identifier: 1,
certifier_out: certifier_path.clone(),
private_out: private_path.clone(),
}),
})
.await
.expect("certifier init dispatch");
let contact: CertifierContact = read_json(&certifier_path).expect("read contact");
let private: PrivateCertifierMaterial =
read_json(&private_path).expect("read private material");
assert_eq!(contact.did, private.did);
assert_eq!(contact.frost_identifier, private.frost_identifier);
let signing_secret = decode_fixed_32(&private.signing_secret_hex).expect("signing secret");
let signing_keypair = KeyPair::from_secret_bytes(signing_secret).expect("keypair");
assert_eq!(contact.signing_public_key, *signing_keypair.public_key());
let transport_secret =
decode_fixed_32(&private.transport_secret_hex).expect("transport secret");
let transport_public = X25519PublicKey::from(&StaticSecret::from(transport_secret));
assert_eq!(contact.transport_public_key, *transport_public.as_bytes());
let roster_path = directory.path().join("roster.json");
let config_path = directory.path().join("ceremony.json");
let roster: Vec<_> = (1..=13).map(certifier).collect();
write_json(&roster_path, &roster).expect("write roster");
run_genesis_command(GenesisCommand::Ceremony {
command: GenesisCeremonyCommand::Init(GenesisCeremonyInitArgs {
ceremony_id: "root-cli-dispatch-ceremony".to_owned(),
network_id: "exo-mainnet".to_owned(),
repo_commit: "d8927686a34bdc28ba36d53938f665685d2c4c04".to_owned(),
constitution_hash: hex::encode([9u8; 32]),
created_physical_ms: 101,
roster: roster_path,
signing_set: (1..=7).collect(),
out: config_path.clone(),
}),
})
.await
.expect("ceremony init dispatch");
let config: GenesisCeremonyConfig = read_json(&config_path).expect("read config");
assert_eq!(config.ceremony_id, "root-cli-dispatch-ceremony");
assert_eq!(config.created_at, Timestamp::new(101, 0));
config.validate().expect("dispatcher wrote valid config");
}
#[test]
fn init_ceremony_writes_valid_institutional_config() {
let directory = tempdir().expect("temporary directory");
let roster_path = directory.path().join("roster.json");
let config_path = directory.path().join("ceremony.json");
let roster: Vec<_> = (1..=13).map(certifier).collect();
write_json(&roster_path, &roster).expect("write roster");
init_ceremony(GenesisCeremonyInitArgs {
ceremony_id: "root-cli-ceremony".to_owned(),
network_id: "exo-mainnet".to_owned(),
repo_commit: "d8927686a34bdc28ba36d53938f665685d2c4c04".to_owned(),
constitution_hash: hex::encode([7u8; 32]),
created_physical_ms: 42,
roster: roster_path,
signing_set: (1..=7).collect(),
out: config_path.clone(),
})
.expect("ceremony init");
let config: GenesisCeremonyConfig = read_json(&config_path).expect("read config");
assert_eq!(config.threshold, exo_root::ROOT_GENESIS_THRESHOLD);
assert_eq!(config.max_signers, exo_root::ROOT_GENESIS_SIGNERS);
assert_eq!(
config.certifiers.len(),
usize::from(exo_root::ROOT_GENESIS_SIGNERS)
);
assert_eq!(config.created_at, Timestamp::new(42, 0));
config.validate().expect("valid root genesis config");
}
#[tokio::test]
async fn dispatcher_reaches_file_backed_missing_input_errors() {
let directory = tempdir().expect("temporary directory");
let missing_io = || GenesisIoArgs {
input: None,
output: Some(directory.path().join("unused-output.json")),
};
let commands = vec![
("round1", GenesisCommand::Round1(missing_io())),
("round2", GenesisCommand::Round2(missing_io())),
("finalize-dkg", GenesisCommand::FinalizeDkg(missing_io())),
(
"build-final-key-confirmation",
GenesisCommand::BuildFinalKeyConfirmation(missing_io()),
),
(
"sign-root-artifact",
GenesisCommand::SignRootArtifact(missing_io()),
),
(
"assemble-bundle",
GenesisCommand::AssembleBundle(missing_io()),
),
("verify-bundle", GenesisCommand::VerifyBundle(missing_io())),
("seal-share", GenesisCommand::SealShare(missing_io())),
("unseal-share", GenesisCommand::UnsealShare(missing_io())),
(
"sign-envelope",
GenesisCommand::SignEnvelope(GenesisSignEnvelopeArgs {
input: None,
output: None,
private_input: directory.path().join("missing-private.json"),
}),
),
(
"encrypt-pairwise",
GenesisCommand::EncryptPairwise(missing_io()),
),
(
"decrypt-pairwise",
GenesisCommand::DecryptPairwise(missing_io()),
),
(
"emit-artifact-bytes",
GenesisCommand::EmitArtifactBytes(missing_io()),
),
(
"submit-envelope",
GenesisCommand::SubmitEnvelope(GenesisSubmitEnvelopeArgs {
portal_url: "http://127.0.0.1:1".to_owned(),
input: None,
}),
),
(
"compute-dkg-transcript-hash",
GenesisCommand::ComputeDkgTranscriptHash(missing_io()),
),
(
"compute-final-transcript-hash",
GenesisCommand::ComputeFinalTranscriptHash(missing_io()),
),
(
"encode-encrypted-payload",
GenesisCommand::EncodeEncryptedPayload(missing_io()),
),
(
"decode-encrypted-payload",
GenesisCommand::DecodeEncryptedPayload(missing_io()),
),
(
"sign-commit",
GenesisCommand::SignCommit(GenesisSignCommitArgs {
input: None,
commitment_out: directory.path().join("commitment.json"),
nonces_out: directory.path().join("nonces.json"),
}),
),
(
"build-signing-package",
GenesisCommand::BuildSigningPackage(missing_io()),
),
(
"sign-share",
GenesisCommand::SignShare(GenesisSignShareArgs {
input: None,
nonces: directory.path().join("nonces.json"),
output: Some(directory.path().join("share.json")),
}),
),
(
"aggregate-signature",
GenesisCommand::AggregateSignature(missing_io()),
),
];
for (name, command) in commands {
let error = match run_genesis_command(command).await {
Ok(()) => panic!("{name} must require --input"),
Err(error) => error,
};
assert!(
error.to_string().contains("--input is required"),
"{name} returned unexpected error: {error}"
);
}
}
#[tokio::test]
async fn portal_dispatch_validates_config_before_binding() {
let directory = tempdir().expect("temporary directory");
let config_path = directory.path().join("ceremony.json");
write_json(&config_path, &rostered_config()).expect("write config");
let error = run_genesis_command(GenesisCommand::Portal(GenesisPortalArgs {
config: config_path,
bind: "not-a-socket".to_owned(),
}))
.await
.expect_err("portal dispatch must reject invalid bind address");
assert!(error.to_string().contains("socket"));
}
#[tokio::test]
async fn portal_dispatch_binds_and_serves_until_cancelled() {
let directory = tempdir().expect("temporary directory");
let config_path = directory.path().join("ceremony.json");
write_json(&config_path, &rostered_config()).expect("write config");
let reservation = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("reserve local port");
let address = reservation.local_addr().expect("reserved address");
drop(reservation);
let handle = tokio::spawn(run_genesis_command(GenesisCommand::Portal(
GenesisPortalArgs {
config: config_path,
bind: address.to_string(),
},
)));
let client = reqwest::Client::new();
let status_url = format!("http://{address}/api/v1/root-genesis/portal");
let mut served_status = false;
for _ in 0..50 {
if handle.is_finished() {
let result = handle.await;
panic!("portal exited before serving status: {result:?}");
}
match client.get(&status_url).send().await {
Ok(response) if response.status().is_success() => {
served_status = true;
break;
}
Ok(_) | Err(_) => {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
handle.abort();
let _ = handle.await;
assert!(
served_status,
"portal should bind and serve the status endpoint"
);
}
#[tokio::test]
async fn round1_dispatch_writes_secret_output_and_refuses_stdout() {
let config = rostered_config();
let directory = tempdir().expect("temporary directory");
let input_path = directory.path().join("round1-in.json");
let output_path = directory.path().join("round1-out.json");
write_json(
&input_path,
&Round1CommandInput {
config: config.clone(),
frost_identifier: 1,
},
)
.expect("write round1 input");
run_genesis_command(GenesisCommand::Round1(io_args(
input_path,
output_path.clone(),
)))
.await
.expect("round1 dispatch writes output");
let output: exo_root::RootDkgRound1Output =
read_json(&output_path).expect("read round1 output");
assert_eq!(output.frost_identifier, 1);
assert!(!output.round1_secret_package.is_empty());
assert!(!output.round1_package.is_empty());
let stdout_input_path = directory.path().join("round1-stdout-in.json");
write_json(
&stdout_input_path,
&Round1CommandInput {
config,
frost_identifier: 1,
},
)
.expect("write stdout round1 input");
let error = run_genesis_command(GenesisCommand::Round1(GenesisIoArgs {
input: Some(stdout_input_path),
output: None,
}))
.await
.expect_err("secret round1 material must not render to stdout");
assert!(error.to_string().contains("--output is required"));
}
#[tokio::test]
async fn round2_and_finalize_dispatch_decode_hex_maps_and_write_secret_files() {
let config = rostered_config();
let mut rng = rand::rngs::StdRng::seed_from_u64(6_951);
let mut round1_outputs = BTreeMap::new();
for identifier in 1..=config.max_signers {
let output = dkg_round1(&config, identifier, &mut rng).expect("round one");
round1_outputs.insert(identifier, output);
}
let peer_round1_hex = |participant: u16| -> BTreeMap<u16, String> {
round1_outputs
.iter()
.filter(|(identifier, _)| **identifier != participant)
.map(|(identifier, output)| (*identifier, hex::encode(&output.round1_package)))
.collect()
};
let peer_round1_bytes = |participant: u16| -> BTreeMap<u16, Vec<u8>> {
round1_outputs
.iter()
.filter(|(identifier, _)| **identifier != participant)
.map(|(identifier, output)| (*identifier, output.round1_package.clone()))
.collect()
};
let directory = tempdir().expect("temporary directory");
let round2_input = directory.path().join("round2-in.json");
let round2_output = directory.path().join("round2-out.json");
write_json(
&round2_input,
&Round2CommandInput {
config: config.clone(),
frost_identifier: 1,
round1_secret_package_hex: hex::encode(&round1_outputs[&1].round1_secret_package),
round1_packages_hex: peer_round1_hex(1),
},
)
.expect("write round2 input");
run_genesis_command(GenesisCommand::Round2(io_args(
round2_input,
round2_output.clone(),
)))
.await
.expect("round2 dispatch");
let first_round2: exo_root::RootDkgRound2Output =
read_json(&round2_output).expect("read round2 output");
assert_eq!(first_round2.frost_identifier, 1);
assert_eq!(first_round2.round2_packages.len(), 12);
let mut round2_outputs = BTreeMap::new();
round2_outputs.insert(1, first_round2.clone());
for identifier in 2..=config.max_signers {
let output = dkg_round2(
&config,
identifier,
round1_outputs[&identifier].round1_secret_package.as_slice(),
peer_round1_bytes(identifier),
)
.expect("round two");
round2_outputs.insert(identifier, output);
}
let round2_packages_hex: BTreeMap<u16, String> = round2_outputs
.iter()
.filter(|(identifier, _)| **identifier != 1)
.map(|(identifier, output)| {
(
*identifier,
hex::encode(output.round2_packages[&1].as_slice()),
)
})
.collect();
let finalize_input = directory.path().join("finalize-in.json");
let finalize_output = directory.path().join("finalize-out.json");
write_json(
&finalize_input,
&FinalizeDkgCommandInput {
config,
frost_identifier: 1,
round2_secret_package_hex: hex::encode(&first_round2.round2_secret_package),
round1_packages_hex: peer_round1_hex(1),
round2_packages_hex,
},
)
.expect("write finalize input");
run_genesis_command(GenesisCommand::FinalizeDkg(io_args(
finalize_input,
finalize_output.clone(),
)))
.await
.expect("finalize dispatch");
let participant: RootParticipantDkgOutput =
read_json(&finalize_output).expect("read participant output");
assert_eq!(participant.key_package.frost_identifier, 1);
assert!(!participant.public_key_package.root_public_key.is_empty());
}
#[test]
fn seal_and_unseal_share_commands_round_trip_hex_output() {
let directory = tempdir().expect("temporary directory");
let seal_input_path = directory.path().join("seal-input.json");
let sealed_path = directory.path().join("sealed.json");
let unseal_input_path = directory.path().join("unseal-input.json");
let opened_path = directory.path().join("opened.json");
let share = b"certifier-share-material";
let associated_data = b"root-genesis-share-v1";
let passphrase = b"long offline passphrase";
write_json(
&seal_input_path,
&SealShareCommandInput {
share_hex: hex::encode(share),
passphrase_hex: hex::encode(passphrase),
associated_data_hex: hex::encode(associated_data),
salt_hex: hex::encode([2u8; 16]),
nonce_hex: hex::encode([3u8; 24]),
},
)
.expect("write seal input");
run_seal_share(io_args(seal_input_path, sealed_path.clone())).expect("seal share");
let sealed: exo_root::SealedShare = read_json(&sealed_path).expect("read sealed share");
write_json(
&unseal_input_path,
&UnsealShareCommandInput {
sealed,
passphrase_hex: hex::encode(passphrase),
associated_data_hex: hex::encode(associated_data),
},
)
.expect("write unseal input");
run_unseal_share(io_args(unseal_input_path, opened_path.clone())).expect("unseal share");
let opened: HexBytesOutput = read_json(&opened_path).expect("read opened share");
assert_eq!(opened.bytes_hex, hex::encode(share));
}
#[test]
fn unseal_share_refuses_to_print_plaintext_share_to_stdout() {
let directory = tempdir().expect("temporary directory");
let unseal_input_path = directory.path().join("unseal-input.json");
let share = b"certifier-share-material";
let associated_data = b"root-genesis-share-v1";
let passphrase = b"long offline passphrase";
let sealed = seal_share(share, passphrase, associated_data, &[2u8; 16], &[3u8; 24])
.expect("seal share");
write_json(
&unseal_input_path,
&UnsealShareCommandInput {
sealed,
passphrase_hex: hex::encode(passphrase),
associated_data_hex: hex::encode(associated_data),
},
)
.expect("write unseal input");
let err = run_unseal_share(GenesisIoArgs {
input: Some(unseal_input_path),
output: None,
})
.expect_err("plaintext share output must require --output");
assert!(err.to_string().contains("--output is required"));
}
#[test]
fn command_helpers_fail_closed_on_missing_input_and_bad_hex() {
let missing_input = GenesisIoArgs {
input: None,
output: None,
};
assert!(
required_input(&missing_input)
.expect_err("input must be required")
.to_string()
.contains("--input is required")
);
assert!(
parse_hash_hex("abcd")
.expect_err("short hash must fail")
.to_string()
.contains("hash must be 32 bytes")
);
assert!(
decode_fixed_16("abcd")
.expect_err("short salt must fail")
.to_string()
.contains("expected 16 bytes")
);
assert!(
decode_fixed_24("abcd")
.expect_err("short nonce must fail")
.to_string()
.contains("expected 24 bytes")
);
assert!(
decode_fixed_32("abcd")
.expect_err("short 32-byte value must fail")
.to_string()
.contains("expected 32 bytes")
);
let mut packages = BTreeMap::new();
packages.insert(7, "not-hex".to_owned());
assert!(decode_package_map(packages).is_err());
}
#[test]
fn public_output_can_render_to_stdout_when_output_is_omitted() {
write_output(
&GenesisIoArgs {
input: None,
output: None,
},
&HashHexOutput {
hash_hex: hex::encode([4u8; 32]),
},
)
.expect("public output renders to stdout");
}
#[test]
fn verify_bundle_command_outputs_success_record() {
let directory = tempdir().expect("temporary directory");
let input_path = directory.path().join("verify-bundle-in.json");
let output_path = directory.path().join("verify-bundle-out.json");
write_json(
&input_path,
&VerifyBundleCommandInput {
bundle: valid_root_trust_bundle(),
},
)
.expect("write verify input");
run_verify_bundle(io_args(input_path, output_path.clone())).expect("verify bundle");
let output: serde_json::Value = read_json(&output_path).expect("read verify output");
assert_eq!(output, serde_json::json!({ "verified": true }));
}
#[test]
fn sign_envelope_output_is_accepted_by_the_portal() {
let config = rostered_config();
let signer = config.certifiers[0].clone();
let directory = tempdir().expect("temporary directory");
let input_path = directory.path().join("draft.json");
let private_path = directory.path().join("certifier-01.private.json");
let output_path = directory.path().join("envelope.json");
write_json(
&input_path,
&SignEnvelopeCommandInput {
ceremony_id: config.ceremony_id.clone(),
phase: CeremonyPhase::Round1,
payload_kind: CeremonyPayloadKind::Round1Package,
sender_did: signer.did.clone(),
recipient_did: None,
sequence: 1,
payload_bytes: valid_round1_package(&config),
},
)
.expect("write draft");
write_json(
&private_path,
&PrivateCertifierMaterial {
did: signer.did.clone(),
frost_identifier: signer.frost_identifier,
signing_secret_hex: hex::encode([1u8; 32]),
transport_secret_hex: hex::encode([65u8; 32]),
},
)
.expect("write private material");
run_sign_envelope(GenesisSignEnvelopeArgs {
input: Some(input_path),
output: Some(output_path.clone()),
private_input: private_path,
})
.expect("sign envelope");
let envelope: CeremonyEnvelope = read_json(&output_path).expect("read envelope");
assert_eq!(envelope.sender_did, signer.did);
let mut store = PortalStore::new(config);
store
.submit(envelope)
.expect("portal accepts the signed envelope");
}
#[test]
fn build_final_key_confirmation_emits_only_public_payload_bytes() {
let config = rostered_config();
let mut rng = rand::rngs::OsRng;
let dkg = exo_root::run_complete_dkg(&config, &mut rng).expect("dkg");
let transcript_hash = Hash256::digest(b"accepted dkg transcript");
let participant = RootParticipantDkgOutput {
key_package: dkg.key_packages[&1].clone(),
public_key_package: dkg.public_key_package.clone(),
};
let directory = tempdir().expect("temporary directory");
let input_path = directory.path().join("final-key-confirmation-in.json");
let output_path = directory.path().join("final-key-confirmation-out.json");
write_json(
&input_path,
&BuildFinalKeyConfirmationCommandInput {
config: config.clone(),
dkg_output: participant,
dkg_transcript_hash_hex: hex::encode(transcript_hash.as_bytes()),
},
)
.expect("write confirmation input");
run_build_final_key_confirmation(io_args(input_path, output_path.clone()))
.expect("build final key confirmation");
let output: PayloadBytesOutput = read_json(&output_path).expect("read payload bytes");
let confirmation: exo_root::FinalKeyConfirmation =
ciborium::from_reader(output.payload_bytes.as_slice()).expect("decode confirmation");
assert_eq!(confirmation.certifier_did, config.certifiers[0].did);
assert_eq!(confirmation.frost_identifier, 1);
assert_eq!(confirmation.dkg_transcript_hash, transcript_hash);
assert_eq!(confirmation.public_key_package, dkg.public_key_package);
let confirmation_json =
serde_json::to_value(&confirmation).expect("confirmation json projection");
let object = confirmation_json
.as_object()
.expect("confirmation is a JSON object");
assert!(
!object.contains_key("key_package"),
"final confirmation payload must not expose the secret FROST key package"
);
let rendered = serde_json::to_string(&confirmation_json).expect("render confirmation");
assert!(
!rendered.to_lowercase().contains("secret"),
"final confirmation payload must not expose secret-labeled fields"
);
}
#[test]
fn transcript_hash_commands_replay_envelopes_in_ceremony_order() {
let config = rostered_config();
let mut rng = rand::rngs::StdRng::seed_from_u64(5_151);
let mut envelopes = Vec::new();
let mut store = PortalStore::new(config.clone());
for certifier in &config.certifiers {
let round1 = dkg_round1(&config, certifier.frost_identifier, &mut rng)
.expect("round one")
.round1_package;
let envelope = signed_test_envelope(
&config,
certifier.frost_identifier,
CeremonyPhase::Round1,
CeremonyPayloadKind::Round1Package,
None,
10,
round1,
);
store.submit(envelope.clone()).expect("submit round one");
envelopes.push(envelope);
}
for sender in &config.certifiers {
for recipient in &config.certifiers {
if sender.frost_identifier == recipient.frost_identifier {
continue;
}
let sequence = 1_000
+ u64::from(sender.frost_identifier) * 100
+ u64::from(recipient.frost_identifier);
let envelope = signed_test_envelope(
&config,
sender.frost_identifier,
CeremonyPhase::Round2,
CeremonyPayloadKind::Round2EncryptedPackage,
Some(recipient.frost_identifier),
sequence,
encoded_pairwise_payload(format!(
"round2-{}-{}",
sender.frost_identifier, recipient.frost_identifier
)),
);
store.submit(envelope.clone()).expect("submit round two");
envelopes.push(envelope);
}
}
let dkg_hash = store.dkg_transcript_hash().expect("dkg hash");
let directory = tempdir().expect("temporary directory");
let dkg_in = directory.path().join("dkg-hash-in.json");
let dkg_out = directory.path().join("dkg-hash-out.json");
let mut reversed = envelopes.clone();
reversed.reverse();
write_json(
&dkg_in,
&TranscriptHashCommandInput {
config: config.clone(),
envelopes: reversed,
},
)
.expect("write dkg hash input");
run_compute_dkg_transcript_hash(io_args(dkg_in, dkg_out.clone()))
.expect("compute dkg hash");
let dkg_output: HashHexOutput = read_json(&dkg_out).expect("read dkg hash");
assert_eq!(dkg_output.hash_hex, hex::encode(dkg_hash.as_bytes()));
let dkg = exo_root::run_complete_dkg(&config, &mut rng).expect("dkg");
for identifier in 1..=13u16 {
let participant = RootParticipantDkgOutput {
key_package: dkg.key_packages[&identifier].clone(),
public_key_package: dkg.public_key_package.clone(),
};
let confirmation =
build_final_key_confirmation(&config, &participant, dkg_hash).expect("confirm");
let envelope = signed_test_envelope(
&config,
identifier,
CeremonyPhase::Finalize,
CeremonyPayloadKind::FinalKeyConfirmation,
None,
5_000 + u64::from(identifier),
encode_final_key_confirmation_payload(&confirmation).expect("encode"),
);
store.submit(envelope.clone()).expect("submit confirmation");
envelopes.push(envelope);
}
let final_hash = store.final_transcript_hash().expect("final hash");
let final_in = directory.path().join("final-hash-in.json");
let final_out = directory.path().join("final-hash-out.json");
envelopes.reverse();
write_json(&final_in, &TranscriptHashCommandInput { config, envelopes })
.expect("write final hash input");
run_compute_final_transcript_hash(io_args(final_in, final_out.clone()))
.expect("compute final hash");
let final_output: HashHexOutput = read_json(&final_out).expect("read final hash");
assert_eq!(final_output.hash_hex, hex::encode(final_hash.as_bytes()));
}
#[test]
fn sign_envelope_refuses_disabled_payload_kinds() {
let config = rostered_config();
let signer = config.certifiers[0].clone();
let directory = tempdir().expect("temporary directory");
let input_path = directory.path().join("attestation-draft.json");
let private_path = directory.path().join("certifier-01.private.json");
write_json(
&input_path,
&SignEnvelopeCommandInput {
ceremony_id: config.ceremony_id.clone(),
phase: CeremonyPhase::Round1SetAttestation,
payload_kind: CeremonyPayloadKind::Round1SetAttestation,
sender_did: signer.did.clone(),
recipient_did: None,
sequence: 1,
payload_bytes: b"unratified attestation payload".to_vec(),
},
)
.expect("write draft");
write_json(
&private_path,
&PrivateCertifierMaterial {
did: signer.did.clone(),
frost_identifier: signer.frost_identifier,
signing_secret_hex: hex::encode([1u8; 32]),
transport_secret_hex: hex::encode([65u8; 32]),
},
)
.expect("write private material");
let error = run_sign_envelope(GenesisSignEnvelopeArgs {
input: Some(input_path),
output: Some(directory.path().join("envelope.json")),
private_input: private_path,
})
.expect_err("sign-envelope must refuse a disabled payload kind");
assert!(error.to_string().contains("disabled"));
}
#[tokio::test]
async fn sign_envelope_refuses_private_material_for_another_did() {
let config = rostered_config();
let signer = config.certifiers[0].clone();
let other = config.certifiers[1].clone();
let directory = tempdir().expect("temporary directory");
let input_path = directory.path().join("draft.json");
let private_path = directory.path().join("other.private.json");
write_json(
&input_path,
&SignEnvelopeCommandInput {
ceremony_id: config.ceremony_id.clone(),
phase: CeremonyPhase::RootSigning,
payload_kind: CeremonyPayloadKind::RootSigningCommitment,
sender_did: signer.did.clone(),
recipient_did: None,
sequence: 7,
payload_bytes: b"public commitment bytes".to_vec(),
},
)
.expect("write draft");
write_json(
&private_path,
&PrivateCertifierMaterial {
did: other.did,
frost_identifier: other.frost_identifier,
signing_secret_hex: hex::encode([2u8; 32]),
transport_secret_hex: hex::encode([66u8; 32]),
},
)
.expect("write mismatched private material");
let error = run_genesis_command(GenesisCommand::SignEnvelope(GenesisSignEnvelopeArgs {
input: Some(input_path),
output: Some(directory.path().join("envelope.json")),
private_input: private_path,
}))
.await
.expect_err("sign-envelope must reject private material for another DID");
assert!(
error
.to_string()
.contains("does not match envelope sender_did")
);
}
#[test]
fn encrypt_then_decrypt_pairwise_round_trips() {
let directory = tempdir().expect("temporary directory");
let sender_secret = [5u8; 32];
let recipient_secret = [6u8; 32];
let sender_public = *X25519PublicKey::from(&StaticSecret::from(sender_secret)).as_bytes();
let recipient_public =
*X25519PublicKey::from(&StaticSecret::from(recipient_secret)).as_bytes();
let associated_data = b"exo-root-round2";
let plaintext = b"round2 secret package".to_vec();
let encrypt_in = directory.path().join("encrypt-in.json");
let encrypt_out = directory.path().join("encrypt-out.json");
write_json(
&encrypt_in,
&EncryptPairwiseCommandInput {
plaintext: plaintext.clone(),
sender_transport_secret_hex: hex::encode(sender_secret),
recipient_transport_pubkey_hex: hex::encode(recipient_public),
associated_data_hex: hex::encode(associated_data),
},
)
.expect("write encrypt input");
run_encrypt_pairwise(io_args(encrypt_in, encrypt_out.clone())).expect("encrypt pairwise");
let encrypted: PairwiseEncryptedPayload =
read_json(&encrypt_out).expect("read encrypted payload");
let decrypt_in = directory.path().join("decrypt-in.json");
let decrypt_out = directory.path().join("decrypt-out.json");
write_json(
&decrypt_in,
&DecryptPairwiseCommandInput {
encrypted,
recipient_transport_secret_hex: hex::encode(recipient_secret),
sender_transport_pubkey_hex: hex::encode(sender_public),
associated_data_hex: hex::encode(associated_data),
},
)
.expect("write decrypt input");
run_decrypt_pairwise(io_args(decrypt_in, decrypt_out.clone())).expect("decrypt pairwise");
let opened: PlaintextOutput = read_json(&decrypt_out).expect("read plaintext");
assert_eq!(opened.plaintext, plaintext);
}
#[test]
fn encrypt_pairwise_cannot_be_forced_to_reuse_a_nonce() {
let directory = tempdir().expect("temporary directory");
let sender_secret = [5u8; 32];
let recipient_public = *X25519PublicKey::from(&StaticSecret::from([6u8; 32])).as_bytes();
let make_input = || EncryptPairwiseCommandInput {
plaintext: b"identical round2 plaintext".to_vec(),
sender_transport_secret_hex: hex::encode(sender_secret),
recipient_transport_pubkey_hex: hex::encode(recipient_public),
associated_data_hex: hex::encode(b"exo-root-round2"),
};
let first_in = directory.path().join("reuse-in-1.json");
let first_out = directory.path().join("reuse-out-1.json");
write_json(&first_in, &make_input()).expect("write first input");
run_encrypt_pairwise(io_args(first_in, first_out.clone())).expect("first encrypt");
let first: PairwiseEncryptedPayload = read_json(&first_out).expect("read first");
let second_in = directory.path().join("reuse-in-2.json");
let second_out = directory.path().join("reuse-out-2.json");
write_json(&second_in, &make_input()).expect("write second input");
run_encrypt_pairwise(io_args(second_in, second_out.clone())).expect("second encrypt");
let second: PairwiseEncryptedPayload = read_json(&second_out).expect("read second");
assert_ne!(
first.nonce, second.nonce,
"internally generated nonces must differ across invocations"
);
assert_ne!(
first.ciphertext, second.ciphertext,
"distinct nonces must yield distinct ciphertext"
);
}
#[test]
fn decrypt_pairwise_refuses_to_print_plaintext_to_stdout() {
let directory = tempdir().expect("temporary directory");
let sender_secret = [5u8; 32];
let recipient_secret = [6u8; 32];
let sender_public = *X25519PublicKey::from(&StaticSecret::from(sender_secret)).as_bytes();
let recipient_public =
*X25519PublicKey::from(&StaticSecret::from(recipient_secret)).as_bytes();
let encrypted = encrypt_pairwise_payload(
&sender_secret,
&recipient_public,
b"round2 secret package",
b"exo-root-round2",
&[7u8; 24],
)
.expect("encrypted");
let decrypt_in = directory.path().join("decrypt-in.json");
write_json(
&decrypt_in,
&DecryptPairwiseCommandInput {
encrypted,
recipient_transport_secret_hex: hex::encode(recipient_secret),
sender_transport_pubkey_hex: hex::encode(sender_public),
associated_data_hex: hex::encode(b"exo-root-round2"),
},
)
.expect("write decrypt input");
let error = run_decrypt_pairwise(GenesisIoArgs {
input: Some(decrypt_in),
output: None,
})
.expect_err("decrypted round-two material must require --output");
assert!(error.to_string().contains("--output is required"));
}
#[test]
fn emit_artifact_bytes_matches_the_library_signing_payload() {
let config = rostered_config();
let public_key_package = RootPublicKeyPackage {
public_key_package: b"public-key-package-bytes".to_vec(),
root_public_key: b"root-public-key".to_vec(),
verifying_shares: (1..=13u16)
.map(|id| (id, vec![u8::try_from(id).expect("id fits u8")]))
.collect(),
};
let delegation = RootIssuerDelegation {
issuer_did: Did::new("did:exo:avc-issuer").expect("valid did"),
issuer_public_key: PublicKey::from_bytes([0x44; 32]),
granted_permissions: vec![Permission::Govern, Permission::Delegate],
effective_at: Timestamp::new(1_785_000_010_000, 0),
expires_at: None,
purpose: "Delegate operational AVC issuing authority".to_owned(),
};
let transcript_hash = Hash256::digest(b"transcript");
let expected = delegation
.root_artifact_payload(&config, &public_key_package, transcript_hash)
.expect("library artifact payload");
let directory = tempdir().expect("temporary directory");
let input_path = directory.path().join("artifact-in.json");
let output_path = directory.path().join("artifact-out.json");
write_json(
&input_path,
&EmitArtifactBytesCommandInput {
config: config.clone(),
public_key_package: public_key_package.clone(),
issuer_delegation: delegation.clone(),
transcript_hash_hex: hex::encode(transcript_hash.as_bytes()),
},
)
.expect("write artifact input");
run_emit_artifact_bytes(io_args(input_path, output_path.clone()))
.expect("emit artifact bytes");
let output: ArtifactBytesOutput = read_json(&output_path).expect("read artifact output");
assert_eq!(output.artifact_hex, hex::encode(expected.as_slice()));
}
#[tokio::test]
async fn sign_root_artifact_and_assemble_bundle_dispatch_write_public_outputs() {
let config = rostered_config();
let mut rng = rand::rngs::StdRng::seed_from_u64(6_952);
let dkg = exo_root::run_complete_dkg(&config, &mut rng).expect("dkg");
let delegation = RootIssuerDelegation {
issuer_did: Did::new("did:exo:root-cli-sign-artifact-issuer").expect("issuer DID"),
issuer_public_key: PublicKey::from_bytes([0x44; 32]),
granted_permissions: vec![Permission::Read, Permission::Write, Permission::Delegate],
effective_at: Timestamp::new(1_785_000_010_000, 0),
expires_at: None,
purpose: "Delegate operational AVC issuing authority".to_owned(),
};
let transcript_hash = Hash256::digest(b"root-cli-sign-artifact-transcript");
let artifact = delegation
.root_artifact_payload(&config, &dkg.public_key_package, transcript_hash)
.expect("root artifact payload");
let directory = tempdir().expect("temporary directory");
let sign_input = directory.path().join("sign-root-in.json");
let sign_output = directory.path().join("sign-root-out.json");
write_json(
&sign_input,
&SignRootArtifactCommandInput {
config: config.clone(),
public_key_package: dkg.public_key_package.clone(),
key_packages: dkg
.key_packages
.iter()
.take(7)
.map(|(identifier, key_package)| (*identifier, key_package.clone()))
.collect(),
artifact_hex: hex::encode(&artifact),
},
)
.expect("write sign root input");
run_genesis_command(GenesisCommand::SignRootArtifact(io_args(
sign_input,
sign_output.clone(),
)))
.await
.expect("sign root artifact dispatch");
let signature: RootSignature = read_json(&sign_output).expect("read root signature");
assert_eq!(signature.signer_ids.len(), 7);
exo_root::verify_root_signature(
&dkg.public_key_package.root_public_key,
artifact.as_slice(),
&signature.signature,
)
.expect("root artifact signature verifies");
let assemble_input = directory.path().join("assemble-in.json");
let assemble_output = directory.path().join("assemble-out.json");
write_json(
&assemble_input,
&AssembleBundleCommandInput {
config,
public_key_package: dkg.public_key_package,
issuer_delegation: delegation,
transcript_hash,
root_signature: signature,
},
)
.expect("write assemble input");
run_genesis_command(GenesisCommand::AssembleBundle(io_args(
assemble_input,
assemble_output.clone(),
)))
.await
.expect("assemble bundle dispatch");
let bundle: RootTrustBundle = read_json(&assemble_output).expect("read bundle");
verify_root_bundle(&bundle).expect("assembled bundle verifies");
}
#[test]
fn distributed_signing_handlers_produce_a_verifiable_signature() {
let config = rostered_config();
let dkg = exo_root::run_complete_dkg(&config, &mut rand::rngs::OsRng).expect("dkg");
let message = b"distributed root artifact";
let artifact_hex = hex::encode(message);
let directory = tempdir().expect("temporary directory");
let signers: Vec<u16> = (1..=7).collect();
let mut commitments_hex = BTreeMap::new();
let mut nonces_paths: BTreeMap<u16, PathBuf> = BTreeMap::new();
for id in &signers {
let in_path = directory.path().join(format!("commit-in-{id}.json"));
let commitment_out = directory.path().join(format!("commitment-{id}.json"));
let nonces_out = directory.path().join(format!("nonces-{id}.json"));
write_json(
&in_path,
&SignCommitCommandInput {
config: config.clone(),
key_package: dkg.key_packages[id].clone(),
artifact_hex: artifact_hex.clone(),
},
)
.expect("write commit input");
run_sign_commit(GenesisSignCommitArgs {
input: Some(in_path),
commitment_out: commitment_out.clone(),
nonces_out: nonces_out.clone(),
})
.expect("sign-commit");
let commitment_value: serde_json::Value =
serde_json::from_slice(&fs::read(&commitment_out).expect("read commitment file"))
.expect("commitment json");
let commitment_object = commitment_value
.as_object()
.expect("commitment is a JSON object");
assert!(
!commitment_object.contains_key("nonces"),
"coordinator-facing commitment file must not contain a nonces field"
);
for key in commitment_object.keys() {
let lowered = key.to_lowercase();
assert!(
!(lowered.contains("nonce")
|| lowered.contains("secret")
|| lowered.contains("private")),
"commitment file must not expose field `{key}`"
);
}
let nonces_value: serde_json::Value =
serde_json::from_slice(&fs::read(&nonces_out).expect("read nonces file"))
.expect("nonces json");
assert!(
nonces_value
.as_object()
.expect("nonces is a JSON object")
.contains_key("nonces"),
"the secret nonces file must carry the nonces"
);
let commit: exo_root::RootSigningCommitment =
read_json(&commitment_out).expect("read commitment");
commitments_hex.insert(*id, hex::encode(commit.commitments.as_slice()));
nonces_paths.insert(*id, nonces_out);
}
let pkg_in = directory.path().join("pkg-in.json");
let pkg_out = directory.path().join("pkg-out.json");
write_json(
&pkg_in,
&BuildSigningPackageCommandInput {
config: config.clone(),
commitments_hex,
artifact_hex: artifact_hex.clone(),
},
)
.expect("write package input");
run_build_signing_package(io_args(pkg_in, pkg_out.clone())).expect("build-signing-package");
let package: exo_root::RootSigningPackage = read_json(&pkg_out).expect("read package");
let mut shares_hex = BTreeMap::new();
for id in &signers {
let in_path = directory.path().join(format!("share-in-{id}.json"));
let out_path = directory.path().join(format!("share-out-{id}.json"));
write_json(
&in_path,
&SignShareCommandInput {
config: config.clone(),
key_package: dkg.key_packages[id].clone(),
signing_package: package.clone(),
artifact_hex: artifact_hex.clone(),
},
)
.expect("write share input");
run_sign_share(GenesisSignShareArgs {
input: Some(in_path),
nonces: nonces_paths[id].clone(),
output: Some(out_path.clone()),
})
.expect("sign-share");
assert!(
!nonces_paths[id].exists(),
"sign-share must consume (delete) the single-use nonces file"
);
let share: exo_root::RootSignatureShareOutput =
read_json(&out_path).expect("read share");
shares_hex.insert(*id, hex::encode(share.signature_share.as_slice()));
}
let agg_in = directory.path().join("agg-in.json");
let agg_out = directory.path().join("agg-out.json");
write_json(
&agg_in,
&AggregateSignatureCommandInput {
config: config.clone(),
public_key_package: dkg.public_key_package.clone(),
signing_package_hex: hex::encode(package.signing_package.as_slice()),
shares_hex,
artifact_hex,
},
)
.expect("write aggregate input");
run_aggregate_signature(io_args(agg_in, agg_out.clone())).expect("aggregate-signature");
let signature: exo_root::RootSignature = read_json(&agg_out).expect("read signature");
assert_eq!(signature.signer_ids.len(), 7);
exo_root::verify_root_signature(
&dkg.public_key_package.root_public_key,
message,
&signature.signature,
)
.expect("distributed signature verifies against the root key");
}
#[cfg(unix)]
#[test]
fn sign_share_reports_nonce_consumption_failure_after_writing_share() {
use std::os::unix::fs::PermissionsExt;
let config = rostered_config();
let dkg = exo_root::run_complete_dkg(&config, &mut rand::rngs::OsRng).expect("dkg");
let artifact_hex = hex::encode(b"distributed root artifact cleanup failure");
let directory = tempdir().expect("temporary directory");
let nonces_directory = directory.path().join("nonces-dir");
fs::create_dir(&nonces_directory).expect("create nonces directory");
let mut commitments_hex = BTreeMap::new();
let mut first_nonces = None;
for id in 1..=7u16 {
let in_path = directory.path().join(format!("commit-in-{id}.json"));
let commitment_out = directory.path().join(format!("commitment-{id}.json"));
let nonces_out = if id == 1 {
nonces_directory.join("nonces-1.json")
} else {
directory.path().join(format!("nonces-{id}.json"))
};
write_json(
&in_path,
&SignCommitCommandInput {
config: config.clone(),
key_package: dkg.key_packages[&id].clone(),
artifact_hex: artifact_hex.clone(),
},
)
.expect("write commit input");
run_sign_commit(GenesisSignCommitArgs {
input: Some(in_path),
commitment_out: commitment_out.clone(),
nonces_out: nonces_out.clone(),
})
.expect("sign commitment");
let commitment: exo_root::RootSigningCommitment =
read_json(&commitment_out).expect("read commitment");
commitments_hex.insert(id, hex::encode(commitment.commitments.as_slice()));
if id == 1 {
first_nonces = Some(nonces_out);
}
}
let package_in = directory.path().join("package-in.json");
let package_out = directory.path().join("package-out.json");
write_json(
&package_in,
&BuildSigningPackageCommandInput {
config: config.clone(),
commitments_hex,
artifact_hex: artifact_hex.clone(),
},
)
.expect("write signing package input");
run_build_signing_package(io_args(package_in, package_out.clone()))
.expect("build signing package");
let package: RootSigningPackage = read_json(&package_out).expect("read signing package");
fs::set_permissions(&nonces_directory, fs::Permissions::from_mode(0o500))
.expect("lock nonces directory");
let share_in = directory.path().join("share-in.json");
let share_out = directory.path().join("share-out.json");
write_json(
&share_in,
&SignShareCommandInput {
config,
key_package: dkg.key_packages[&1].clone(),
signing_package: package,
artifact_hex,
},
)
.expect("write share input");
let result = run_sign_share(GenesisSignShareArgs {
input: Some(share_in),
nonces: first_nonces.expect("first nonces path"),
output: Some(share_out.clone()),
});
fs::set_permissions(&nonces_directory, fs::Permissions::from_mode(0o700))
.expect("restore nonces directory permissions");
let error = result.expect_err("nonce deletion failure must be reported");
assert!(
error
.to_string()
.contains("failed to consume the single-use nonces file")
);
assert!(
share_out.exists(),
"share is written before cleanup failure"
);
}
#[test]
fn root_signing_commitment_json_has_no_secret_named_field() {
let commitment = exo_root::RootSigningCommitment {
frost_identifier: 1,
commitments: vec![1, 2, 3, 4],
};
let json = serde_json::to_string(&commitment).expect("serialize commitment");
let value: serde_json::Value = serde_json::from_str(&json).expect("parse commitment json");
let object = value
.as_object()
.expect("commitment serializes as a JSON object");
for key in object.keys() {
let lowered = key.to_lowercase();
assert!(
!(lowered.contains("nonce")
|| lowered.contains("secret")
|| lowered.contains("private")),
"relay-safe RootSigningCommitment must not expose field `{key}`"
);
}
}
#[test]
fn encrypted_payload_codec_round_trips_through_the_cli() {
let payload = PairwiseEncryptedPayload {
nonce: [5u8; 24],
ciphertext: b"recipient-bound ciphertext".to_vec(),
};
let directory = tempdir().expect("temporary directory");
let enc_in = directory.path().join("encode-in.json");
let enc_out = directory.path().join("encode-out.json");
write_json(
&enc_in,
&EncodeEncryptedPayloadCommandInput {
encrypted: payload.clone(),
},
)
.expect("write encode input");
run_encode_encrypted_payload(io_args(enc_in, enc_out.clone())).expect("encode");
let encoded: PayloadBytesOutput = read_json(&enc_out).expect("read encoded");
let dec_in = directory.path().join("decode-in.json");
let dec_out = directory.path().join("decode-out.json");
write_json(
&dec_in,
&DecodeEncryptedPayloadCommandInput {
payload_bytes: encoded.payload_bytes,
},
)
.expect("write decode input");
run_decode_encrypted_payload(io_args(dec_in, dec_out.clone())).expect("decode");
let decoded: PairwiseEncryptedPayload = read_json(&dec_out).expect("read decoded");
assert_eq!(decoded, payload);
}
#[tokio::test]
async fn submit_envelope_posts_signed_envelope_to_running_portal() {
let config = rostered_config();
let envelope = round1_broadcast(&config, 0, 1, valid_round1_package(&config));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind portal listener");
let address = listener.local_addr().expect("portal address");
let router = root_genesis_router(RootGenesisApiState::new(config));
tokio::spawn(async move {
axum::serve(listener, router).await.expect("serve portal");
});
let directory = tempdir().expect("temporary directory");
let input_path = directory.path().join("envelope.json");
write_json(&input_path, &envelope).expect("write envelope");
run_submit_envelope(GenesisSubmitEnvelopeArgs {
portal_url: format!("http://{address}"),
input: Some(input_path),
})
.await
.expect("portal accepts submitted envelope");
}
#[tokio::test]
async fn submit_envelope_reports_portal_rejection() {
let config = rostered_config();
let envelope = round1_broadcast(&config, 0, 1, b"not a frost round-one package".to_vec());
let portal_url = spawn_test_portal(config).await;
let directory = tempdir().expect("temporary directory");
let input_path = directory.path().join("bad-envelope.json");
write_json(&input_path, &envelope).expect("write bad envelope");
let error =
run_genesis_command(GenesisCommand::SubmitEnvelope(GenesisSubmitEnvelopeArgs {
portal_url,
input: Some(input_path),
}))
.await
.expect_err("portal rejection must be surfaced");
assert!(error.to_string().contains("portal rejected envelope"));
}
#[tokio::test]
async fn pull_envelopes_dispatch_writes_filtered_response() {
let config = rostered_config();
let recipient = config.certifiers[0].did.to_string();
let portal_url = spawn_test_portal(config).await;
let directory = tempdir().expect("temporary directory");
let output_path = directory.path().join("pulled.json");
run_genesis_command(GenesisCommand::PullEnvelopes(GenesisPullEnvelopesArgs {
portal_url,
phase: Some("Round1".to_owned()),
payload_kind: Some("Round1Package".to_owned()),
recipient_did: Some(recipient),
output: Some(output_path.clone()),
}))
.await
.expect("pull envelopes dispatch");
let envelopes: Vec<CeremonyEnvelope> = read_json(&output_path).expect("read envelopes");
assert!(envelopes.is_empty());
}
#[tokio::test]
async fn pull_envelopes_dispatch_reports_portal_query_errors() {
let portal_url = spawn_test_portal(rostered_config()).await;
let error = run_genesis_command(GenesisCommand::PullEnvelopes(GenesisPullEnvelopesArgs {
portal_url,
phase: Some("Bogus".to_owned()),
payload_kind: None,
recipient_did: None,
output: None,
}))
.await
.expect_err("invalid portal query must be surfaced");
assert!(error.to_string().contains("portal pull failed: HTTP 400"));
assert!(error.to_string().contains("invalid phase"));
}
#[test]
fn portal_envelopes_url_appends_path_at_most_once() {
let expected = "http://127.0.0.1:3017/api/v1/root-genesis/portal/envelopes";
assert_eq!(portal_envelopes_url("http://127.0.0.1:3017"), expected);
assert_eq!(portal_envelopes_url("http://127.0.0.1:3017/"), expected);
assert_eq!(portal_envelopes_url(expected), expected);
}
#[cfg(unix)]
#[test]
fn json_outputs_are_create_new_owner_only_and_refuse_existing_paths() {
use std::os::unix::fs::PermissionsExt;
let directory = tempdir().expect("temporary directory");
let output_path = directory.path().join("private-material.json");
write_json(
&output_path,
&HexBytesOutput {
bytes_hex: hex::encode(b"secret"),
},
)
.expect("write output");
let mode = fs::metadata(&output_path)
.expect("output metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
assert!(
write_json(
&output_path,
&HexBytesOutput {
bytes_hex: hex::encode(b"replacement"),
},
)
.is_err()
);
}
#[cfg(unix)]
#[test]
fn json_outputs_refuse_existing_symlink_paths() {
use std::os::unix::fs::symlink;
let directory = tempdir().expect("temporary directory");
let target_path = directory.path().join("target.json");
let output_path = directory.path().join("private-material.json");
fs::write(&target_path, b"do not overwrite").expect("seed symlink target");
symlink(&target_path, &output_path).expect("create output symlink");
assert!(
write_json(
&output_path,
&HexBytesOutput {
bytes_hex: hex::encode(b"secret"),
},
)
.is_err()
);
assert_eq!(
fs::read(&target_path).expect("read symlink target"),
b"do not overwrite"
);
}
#[cfg(unix)]
#[test]
fn json_outputs_refuse_existing_regular_files_without_rewriting() {
use std::os::unix::fs::PermissionsExt;
let directory = tempdir().expect("temporary directory");
let output_path = directory.path().join("private-material.json");
fs::write(&output_path, b"previous material").expect("seed existing file");
fs::set_permissions(&output_path, fs::Permissions::from_mode(0o644))
.expect("make existing file too broad");
assert!(
write_json(
&output_path,
&HexBytesOutput {
bytes_hex: hex::encode(b"secret"),
},
)
.is_err()
);
assert_eq!(
fs::read(&output_path).expect("read existing output"),
b"previous material"
);
let mode = fs::metadata(&output_path)
.expect("output metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o644);
}
}