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
16 changes: 11 additions & 5 deletions pkg/tbtc/signer/docs/rust-rewrite-bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,17 @@ rewrite architecture.
- reject over-limit runtime insertions and over-limit persisted payloads
instead of evicting entries (no silent replay-protection weakening).
- Added fail-closed global session-registry bounds:
- bounded total persisted session count via `TBTC_SIGNER_MAX_SESSIONS`
(default `1024`),
- reject over-limit persisted state payloads during decode/encode, and reject
new runtime session creation at capacity while preserving idempotent retries
for existing `session_id` values.
- bounded the total persisted session count via
`TBTC_SIGNER_MAX_SESSIONS` (default `1024`) so schema-1 state remains
readable after an emergency rollback,
- idle per-message interactive sessions use the portion of that shared budget
not occupied by active sessions, retaining delayed-retry routing, policy
artifacts, and replay/aggregate authorization tombstones until active
admission needs the slot; the oldest eligible retired entry is then evicted,
- compact over-limit retired entries during load, reject over-limit state
during encode, and reject new runtime session creation only when the shared
budget has no eligible retired entry, while preserving idempotent retries for
existing `session_id` values.
- Bootstrap dealer-model constraint: the current engine holds all generated key
packages for a session in one process. This is temporary bootstrap behavior
and does not provide production threshold key isolation.
Expand Down
2 changes: 1 addition & 1 deletion pkg/tbtc/signer/scripts/formal/run_tla_models.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ TLA_TOOLS_URL="${TLA_TOOLS_URL:-https://github.com/tlaplus/tlaplus/releases/down
# release v1.8.0). Re-pin this when the upstream release asset is rebuilt and the
# download-verification gate below reports a mismatch, after confirming the new
# jar comes from the official release URL.
TLA_TOOLS_SHA256="${TLA_TOOLS_SHA256:-33de7da9ce1b7fffb9d1c184021178dbb051747be48504e65c584c423721a32e}"
TLA_TOOLS_SHA256="${TLA_TOOLS_SHA256:-150b0294c3d407c15f0c971351ccd4ae8c6d885397546dff87871a14be2b4ee4}"

if ! command -v java >/dev/null 2>&1; then
echo "java is required to run TLC model checks" >&2
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
21 changes: 15 additions & 6 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 Expand Up @@ -174,7 +178,6 @@ pub fn persist_distributed_dkg_key_package(
let mut guard = state()?
.lock()
.map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?;
ensure_session_insert_capacity(&guard.sessions, &request.session_id)?;

// A group verifying key identifies one wallet. Keeping the same key_group in
// two sessions would make wallet lookup depend on randomized HashMap order and
Expand Down Expand Up @@ -208,6 +211,11 @@ pub fn persist_distributed_dkg_key_package(
});
}

// Reserve a total-registry slot only after every rejection that can apply
// to a fresh DKG session. If the ensuing durable write fails before file
// replacement, restore any retired tombstone evicted for this slot.
let compacted_retired_sessions =
ensure_session_insert_capacity(&mut guard, &request.session_id)?;
let dkg_session_id = request.session_id.clone();
let session_existed = guard.sessions.contains_key(&dkg_session_id);
let session = guard
Expand Down Expand Up @@ -296,6 +304,7 @@ pub fn persist_distributed_dkg_key_package(
} else {
guard.sessions.remove(&dkg_session_id);
}
restore_compacted_retired_sessions(&mut guard, compacted_retired_sessions);
}
return Err(persist_error);
}
Expand Down
Loading
Loading