Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/tbtc/signer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ frost-core = { version = "=3.0.0", default-features = false }
chacha20poly1305 = "0.10"
rand_chacha = "0.3"
libc = "0.2"
zeroize = { version = "1.8", default-features = false, features = ["serde"] }
zeroize = { version = "1.8", default-features = false, features = ["alloc", "serde"] }
bitcoin = "0.32"

[dev-dependencies]
Expand Down
223 changes: 217 additions & 6 deletions pkg/tbtc/signer/src/api.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,46 @@
use std::fmt;

use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};

/// A hex-encoded secret whose owned Rust allocation is wiped on drop and whose
/// `Debug` representation never exposes its contents. Serde remains transparent
/// so the C-ABI JSON contract continues to carry an ordinary string.
#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(transparent)]
pub struct SecretHex(Zeroizing<String>);

impl SecretHex {
pub fn new(value: String) -> Self {
Self(Zeroizing::new(value))
}

/// Borrows the secret for the narrow decode/serialization boundary without
/// creating another unmanaged `String` allocation.
pub fn expose_secret(&self) -> &str {
self.0.as_str()
}
}

impl From<String> for SecretHex {
fn from(value: String) -> Self {
Self::new(value)
}
}

impl fmt::Debug for SecretHex {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("<redacted>")
}
}

impl Zeroize for SecretHex {
fn zeroize(&mut self) {
self.0.zeroize();
}
}

impl ZeroizeOnDrop for SecretHex {}

#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct DkgResult {
Expand All @@ -20,7 +62,7 @@ pub struct DkgRound2Package {
pub identifier: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sender_identifier: Option<String>,
pub package_hex: String,
pub package_hex: SecretHex,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
Expand All @@ -32,26 +74,26 @@ pub struct DkgPart1Request {

#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct DkgPart1Result {
pub secret_package_hex: String,
pub secret_package_hex: SecretHex,
pub package: DkgRound1Package,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct DkgPart2Request {
pub secret_package_hex: String,
pub secret_package_hex: SecretHex,
pub round1_packages: Vec<DkgRound1Package>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct DkgPart2Result {
pub secret_package_hex: String,
pub secret_package_hex: SecretHex,
pub packages: Vec<DkgRound2Package>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct NativeFrostKeyPackage {
pub identifier: String,
pub data_hex: String,
pub data_hex: SecretHex,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
Expand All @@ -62,7 +104,7 @@ pub struct NativeFrostPublicKeyPackage {

#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct DkgPart3Request {
pub secret_package_hex: String,
pub secret_package_hex: SecretHex,
pub round1_packages: Vec<DkgRound1Package>,
pub round2_packages: Vec<DkgRound2Package>,
}
Expand Down Expand Up @@ -848,3 +890,172 @@ pub struct InitSignerConfigResult {
pub config_fingerprint: String,
pub configured_key_count: u32,
}

#[cfg(test)]
mod tests {
use super::*;

const SECRET_SENTINEL: &str =
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";

fn secret_hex() -> SecretHex {
SecretHex::new(SECRET_SENTINEL.to_string())
}

fn round1_package() -> DkgRound1Package {
DkgRound1Package {
identifier: "round1-identifier".to_string(),
package_hex: "public-round1-package".to_string(),
}
}

fn round2_package() -> DkgRound2Package {
DkgRound2Package {
identifier: "round2-recipient".to_string(),
sender_identifier: Some("round2-sender".to_string()),
package_hex: secret_hex(),
}
}

fn public_key_package() -> NativeFrostPublicKeyPackage {
NativeFrostPublicKeyPackage {
verifying_shares: std::collections::BTreeMap::new(),
verifying_key: "public-verifying-key".to_string(),
}
}

fn key_package() -> NativeFrostKeyPackage {
NativeFrostKeyPackage {
identifier: "key-package-identifier".to_string(),
data_hex: secret_hex(),
}
}

#[test]
fn dkg_secret_hex_zeroizes_and_is_zeroize_on_drop() {
fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}

assert_zeroize_on_drop::<SecretHex>();

let mut secret = secret_hex();
secret.zeroize();
assert!(secret.expose_secret().is_empty());
}

#[test]
fn dkg_secret_fields_preserve_json_string_wire_shape() {
let encoded_holder =
serde_json::to_string(&secret_hex()).expect("secret holder serializes");
assert_eq!(encoded_holder, format!("\"{SECRET_SENTINEL}\""));
let decoded_holder: SecretHex =
serde_json::from_str(&encoded_holder).expect("secret holder deserializes");
assert_eq!(decoded_holder.expose_secret(), SECRET_SENTINEL);

let serialized_fields = [
serde_json::to_value(DkgRound2Package {
identifier: "recipient".to_string(),
sender_identifier: Some("sender".to_string()),
package_hex: secret_hex(),
})
.expect("round2 package serializes")["package_hex"]
.clone(),
serde_json::to_value(DkgPart1Result {
secret_package_hex: secret_hex(),
package: round1_package(),
})
.expect("part1 result serializes")["secret_package_hex"]
.clone(),
serde_json::to_value(DkgPart2Request {
secret_package_hex: secret_hex(),
round1_packages: vec![round1_package()],
})
.expect("part2 request serializes")["secret_package_hex"]
.clone(),
serde_json::to_value(DkgPart2Result {
secret_package_hex: secret_hex(),
packages: vec![round2_package()],
})
.expect("part2 result serializes")["secret_package_hex"]
.clone(),
serde_json::to_value(DkgPart3Request {
secret_package_hex: secret_hex(),
round1_packages: vec![round1_package()],
round2_packages: vec![round2_package()],
})
.expect("part3 request serializes")["secret_package_hex"]
.clone(),
serde_json::to_value(key_package()).expect("key package serializes")["data_hex"]
.clone(),
];

for serialized_field in serialized_fields {
assert_eq!(serialized_field, serde_json::json!(SECRET_SENTINEL));
}
}

#[test]
fn dkg_secret_fields_redact_direct_and_nested_debug_output() {
let rendered = [
format!("{:?}", round2_package()),
format!(
"{:?}",
DkgPart1Result {
secret_package_hex: secret_hex(),
package: round1_package(),
}
),
format!(
"{:?}",
DkgPart2Request {
secret_package_hex: secret_hex(),
round1_packages: vec![round1_package()],
}
),
format!(
"{:?}",
DkgPart2Result {
secret_package_hex: secret_hex(),
packages: vec![round2_package()],
}
),
format!("{:?}", key_package()),
format!(
"{:?}",
DkgPart3Request {
secret_package_hex: secret_hex(),
round1_packages: vec![round1_package()],
round2_packages: vec![round2_package()],
}
),
format!(
"{:?}",
DkgPart3Result {
key_package: key_package(),
public_key_package: public_key_package(),
}
),
format!(
"{:?}",
PersistDistributedDkgKeyPackageRequest {
session_id: "debug-redaction-session".to_string(),
participant_identifier: 1,
threshold: 2,
participant_count: 3,
key_package: key_package(),
public_key_package: public_key_package(),
}
),
];

for rendered_value in rendered {
assert!(
!rendered_value.contains(SECRET_SENTINEL),
"Debug output leaked DKG secret material: {rendered_value}"
);
assert!(
rendered_value.contains("<redacted>"),
"Debug output did not mark DKG secret material as redacted: {rendered_value}"
);
}
}
}
2 changes: 1 addition & 1 deletion pkg/tbtc/signer/src/engine/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ pub(crate) fn decode_round2_package_map(
let mut package_bytes = decode_hex_field(
operation,
&format!("round2_packages[{index}].package_hex"),
&package.package_hex,
package.package_hex.expose_secret(),
)?;
let round2_package_result = frost::keys::dkg::round2::Package::deserialize(&package_bytes);
package_bytes.zeroize();
Expand Down
14 changes: 9 additions & 5 deletions pkg/tbtc/signer/src/engine/dkg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ pub fn persist_distributed_dkg_key_package(
mut request: PersistDistributedDkgKeyPackageRequest,
) -> Result<DkgResult, EngineError> {
const OP: &str = "persist_distributed_dkg_key_package";
// data_hex is the serialized SECRET signing share. Move it into a zeroizing holder
// BEFORE any fallible check (validation, admission, quarantine can all return first),
// so serde's owned String is wiped on EVERY return path rather than dropped un-wiped.
let data_hex = Zeroizing::new(std::mem::take(&mut request.key_package.data_hex));
// data_hex is the serialized SECRET signing share. Move its redacting,
// zeroizing holder out BEFORE any fallible check so it is wiped on every
// return path and the rest of the request no longer retains it.
let data_hex = std::mem::take(&mut request.key_package.data_hex);
validate_session_id(&request.session_id)?;
// Gate BEFORE decoding or persisting any key material: this op writes signing
// material to durable state that interactive signing trusts after restart, so
Expand Down Expand Up @@ -102,7 +102,11 @@ pub fn persist_distributed_dkg_key_package(
auto_quarantine_config.as_ref(),
)?;

let key_package = decode_key_package(OP, &request.key_package.identifier, &data_hex)?;
let key_package = decode_key_package(
OP,
&request.key_package.identifier,
data_hex.expose_secret(),
)?;

// The key package must belong to this participant AND be consistent with the
// group public key package: matching identifier, embedded threshold, group
Expand Down
12 changes: 6 additions & 6 deletions pkg/tbtc/signer/src/engine/frost_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub fn dkg_part1(request: DkgPart1Request) -> Result<DkgPart1Result, EngineError
.map_err(|e| EngineError::Internal(format!("failed to serialize DKG part1 secret: {e}")))?;

let result = DkgPart1Result {
secret_package_hex: hex::encode(&secret_package_bytes),
secret_package_hex: SecretHex::new(hex::encode(&secret_package_bytes)),
package: DkgRound1Package {
identifier: frost_identifier_to_go_string(identifier),
package_hex: hex::encode(package_bytes),
Expand All @@ -63,7 +63,7 @@ pub fn dkg_part2(request: DkgPart2Request) -> Result<DkgPart2Result, EngineError
let mut secret_package_bytes = decode_hex_field(
"DKGPart2",
"secret_package_hex",
&request.secret_package_hex,
request.secret_package_hex.expose_secret(),
)?;
let secret_package_result =
frost::keys::dkg::round1::SecretPackage::deserialize(&secret_package_bytes);
Expand Down Expand Up @@ -96,7 +96,7 @@ pub fn dkg_part2(request: DkgPart2Request) -> Result<DkgPart2Result, EngineError
packages.push(DkgRound2Package {
identifier: frost_identifier_to_go_string(identifier),
sender_identifier: None,
package_hex: hex::encode(&package_bytes),
package_hex: SecretHex::new(hex::encode(&package_bytes)),
});
package_bytes.zeroize();
}
Expand All @@ -107,7 +107,7 @@ pub fn dkg_part2(request: DkgPart2Request) -> Result<DkgPart2Result, EngineError
.map_err(|e| EngineError::Internal(format!("failed to serialize DKG part2 secret: {e}")))?;

let result = DkgPart2Result {
secret_package_hex: hex::encode(&round2_secret_package_bytes),
secret_package_hex: SecretHex::new(hex::encode(&round2_secret_package_bytes)),
packages,
};
round2_secret_package_bytes.zeroize();
Expand All @@ -121,7 +121,7 @@ pub fn dkg_part3(request: DkgPart3Request) -> Result<DkgPart3Result, EngineError
let mut secret_package_bytes = decode_hex_field(
"DKGPart3",
"secret_package_hex",
&request.secret_package_hex,
request.secret_package_hex.expose_secret(),
)?;
let secret_package_result =
frost::keys::dkg::round2::SecretPackage::deserialize(&secret_package_bytes);
Expand Down Expand Up @@ -163,7 +163,7 @@ pub fn dkg_part3(request: DkgPart3Request) -> Result<DkgPart3Result, EngineError
let result = DkgPart3Result {
key_package: NativeFrostKeyPackage {
identifier: frost_identifier_to_go_string(*key_package.identifier()),
data_hex: hex::encode(&key_package_bytes),
data_hex: SecretHex::new(hex::encode(&key_package_bytes)),
},
public_key_package: native_public_key_package,
};
Expand Down
2 changes: 1 addition & 1 deletion pkg/tbtc/signer/src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ use crate::api::{
PersistDistributedDkgKeyPackageRequest, PromoteCanaryRequest, PromoteCanaryResult,
QuarantineStatusRequest, QuarantineStatusResult, RefreshCadenceStatusRequest,
RefreshCadenceStatusResult, RefreshSharesRequest, RefreshSharesResult,
RoastLivenessPolicyResult, RollbackCanaryRequest, RollbackCanaryResult, RoundState,
RoastLivenessPolicyResult, RollbackCanaryRequest, RollbackCanaryResult, RoundState, SecretHex,
SignatureResult, SignerHardeningMetricsResult, TransactionResult, TranscriptAuditRecord,
TranscriptAuditRequest, TranscriptAuditResult, TriggerEmergencyRekeyRequest,
TriggerEmergencyRekeyResult, VerifyBlameProofRequest,
Expand Down
Loading
Loading