Skip to content

Commit b723820

Browse files
author
EchoBT
committed
feat: sr25519 crypto + Docker image whitelist security
BREAKING CHANGES: - Crypto system migrated from ed25519 to sr25519 (Substrate/Bittensor compatible) - Docker images now restricted to ghcr.io/platformnetwork/ only Security: - Added ALLOWED_DOCKER_PREFIXES whitelist for challenge containers - Added ALLOWED_BASE_IMAGES whitelist for agent execution - Validation at consensus level (AddChallenge/UpdateChallenge) - Validation at Docker pull and container start - Multi-layer protection against malicious containers Crypto: - Keypair::from_mnemonic() - derive from BIP39 mnemonic (sr25519) - Keypair::from_seed() - derive from 32-byte seed - keypair.ss58_address() - returns SS58 Substrate address - Hotkey::to_ss58() / from_ss58() - SS58 encoding/decoding - Signatures now use sr25519 (compatible with Bittensor metagraph) Changes: - platform-core: sr25519 via sp-core, Docker whitelist - platform-consensus: validate challenges against whitelist - challenge-orchestrator: verify images before pull/start - challenge-runtime: whitelist base images for agents - csudo: support mnemonic input, show SS58 address - validator-node: derive hotkey from mnemonic for metagraph verification
1 parent 95321e5 commit b723820

File tree

12 files changed

+1626
-250
lines changed

12 files changed

+1626
-250
lines changed

Cargo.lock

Lines changed: 750 additions & 82 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bins/csudo/src/main.rs

Lines changed: 249 additions & 67 deletions
Large diffs are not rendered by default.

bins/validator-node/src/main.rs

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -143,44 +143,42 @@ async fn main() -> Result<()> {
143143
let bittensor_seed = args.secret_key.clone();
144144

145145
// Derive keypair using proper Substrate SR25519 derivation (same as Bittensor)
146-
// This ensures the hotkey matches what Bittensor expects
147-
let (keypair, identity_seed) = {
148-
let secret = &args.secret_key;
146+
// Derive sr25519 keypair - compatible with Bittensor/Substrate
147+
// Hotkey will be the sr25519 public key that can be verified on Bittensor metagraph
148+
let keypair = {
149+
let secret = args.secret_key.trim();
149150

150151
// Strip 0x prefix if present
151152
let hex_str = secret.strip_prefix("0x").unwrap_or(secret);
152153

153-
// Try hex decode first (32 bytes = raw seed)
154-
if let Ok(bytes) = hex::decode(hex_str) {
155-
if bytes.len() != 32 {
156-
anyhow::bail!("Hex secret key must be 32 bytes");
154+
// Try hex decode first (64 hex chars = 32 bytes seed)
155+
if hex_str.len() == 64 {
156+
if let Ok(bytes) = hex::decode(hex_str) {
157+
if bytes.len() != 32 {
158+
anyhow::bail!("Hex seed must be 32 bytes");
159+
}
160+
let mut arr = [0u8; 32];
161+
arr.copy_from_slice(&bytes);
162+
info!("Loading sr25519 keypair from hex seed");
163+
Keypair::from_seed(&arr)?
164+
} else {
165+
// Not valid hex, try as mnemonic
166+
info!("Loading sr25519 keypair from mnemonic");
167+
Keypair::from_mnemonic(secret)?
157168
}
158-
let mut arr = [0u8; 32];
159-
arr.copy_from_slice(&bytes);
160-
let kp = Keypair::from_bytes(&arr)?;
161-
(kp, arr)
162169
} else {
163-
// Mnemonic phrase - use proper Substrate SR25519 derivation
164-
use sp_core::crypto::Pair as CryptoPair;
165-
use sp_core::sr25519;
166-
167-
// Derive SR25519 keypair using same method as bittensor-rs
168-
let sr25519_pair = sr25519::Pair::from_string(secret, None)
169-
.map_err(|e| anyhow::anyhow!("Invalid mnemonic: {:?}", e))?;
170-
171-
// Get the public key bytes (32 bytes) - this is the hotkey
172-
let pubkey_bytes: [u8; 32] = sr25519_pair.public().0;
173-
174-
// Use public key bytes as seed for internal Ed25519 keypair
175-
// This ensures peer ID is derived from the hotkey
176-
let kp = Keypair::from_bytes(&pubkey_bytes)?;
177-
178-
info!("Derived keypair from Substrate mnemonic (SR25519)");
179-
(kp, pubkey_bytes)
170+
// Assume it's a mnemonic phrase
171+
info!("Loading sr25519 keypair from mnemonic");
172+
Keypair::from_mnemonic(secret)?
180173
}
181174
};
182175

183-
info!("Internal keypair derived (P2P signing)");
176+
// Log the derived hotkey for verification against Bittensor metagraph
177+
info!("Validator hotkey (hex): {}", keypair.hotkey().to_hex());
178+
info!("Validator SS58 address: {}", keypair.ss58_address());
179+
180+
// The identity seed for P2P is derived from the keypair seed
181+
let identity_seed = keypair.seed();
184182

185183
// Canonicalize data directory to ensure absolute paths for Docker
186184
let data_dir = if args.data_dir.exists() {

crates/challenge-orchestrator/src/docker.rs

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
//! Docker client wrapper for container management
2+
//!
3+
//! SECURITY: Only images from whitelisted registries (ghcr.io/platformnetwork/)
4+
//! are allowed to be pulled or run. This prevents malicious container attacks.
25
36
use crate::{ChallengeContainerConfig, ChallengeInstance, ContainerStatus};
47
use bollard::container::{
@@ -9,8 +12,9 @@ use bollard::image::CreateImageOptions;
912
use bollard::models::{DeviceRequest, HostConfig, PortBinding};
1013
use bollard::Docker;
1114
use futures::StreamExt;
15+
use platform_core::ALLOWED_DOCKER_PREFIXES;
1216
use std::collections::HashMap;
13-
use tracing::{debug, info, warn};
17+
use tracing::{debug, error, info, warn};
1418

1519
/// Docker client for managing challenge containers
1620
pub struct DockerClient {
@@ -71,9 +75,31 @@ impl DockerClient {
7175
Ok(())
7276
}
7377

74-
/// Pull a Docker image
78+
/// Check if a Docker image is from an allowed registry
79+
/// SECURITY: This prevents pulling/running malicious containers
80+
fn is_image_allowed(image: &str) -> bool {
81+
let image_lower = image.to_lowercase();
82+
ALLOWED_DOCKER_PREFIXES
83+
.iter()
84+
.any(|prefix| image_lower.starts_with(&prefix.to_lowercase()))
85+
}
86+
87+
/// Pull a Docker image (only from whitelisted registries)
7588
pub async fn pull_image(&self, image: &str) -> anyhow::Result<()> {
76-
info!(image = %image, "Pulling Docker image");
89+
// SECURITY: Verify image is from allowed registry before pulling
90+
if !Self::is_image_allowed(image) {
91+
error!(
92+
image = %image,
93+
"SECURITY: Attempted to pull image from non-whitelisted registry!"
94+
);
95+
anyhow::bail!(
96+
"Docker image '{}' is not from an allowed registry. \
97+
Only images from ghcr.io/platformnetwork/ are permitted.",
98+
image
99+
);
100+
}
101+
102+
info!(image = %image, "Pulling Docker image (whitelisted)");
77103

78104
let options = CreateImageOptions {
79105
from_image: image,
@@ -99,11 +125,43 @@ impl DockerClient {
99125
Ok(())
100126
}
101127

102-
/// Start a challenge container
128+
/// Start a challenge container (only from whitelisted registries)
103129
pub async fn start_challenge(
104130
&self,
105131
config: &ChallengeContainerConfig,
106132
) -> anyhow::Result<ChallengeInstance> {
133+
// SECURITY: Verify image is from allowed registry before starting
134+
if !Self::is_image_allowed(&config.docker_image) {
135+
error!(
136+
image = %config.docker_image,
137+
challenge = %config.name,
138+
"SECURITY: Attempted to start container from non-whitelisted registry!"
139+
);
140+
anyhow::bail!(
141+
"Docker image '{}' is not from an allowed registry. \
142+
Only images from ghcr.io/platformnetwork/ are permitted. \
143+
Challenge '{}' rejected.",
144+
config.docker_image,
145+
config.name
146+
);
147+
}
148+
149+
// Also run full config validation
150+
if let Err(reason) = config.validate() {
151+
error!(
152+
challenge = %config.name,
153+
reason = %reason,
154+
"Challenge config validation failed"
155+
);
156+
anyhow::bail!("Challenge config validation failed: {}", reason);
157+
}
158+
159+
info!(
160+
image = %config.docker_image,
161+
challenge = %config.name,
162+
"Starting challenge container (whitelisted)"
163+
);
164+
107165
// Ensure network exists
108166
self.ensure_network().await?;
109167

crates/challenge-runtime/src/docker_runner.rs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
//! - Resource limits (CPU, memory, time)
66
//! - LLM API proxy injection
77
//! - Output capture
8+
//!
9+
//! SECURITY: Only whitelisted base images are allowed for agent execution.
810
911
use crate::host_functions::{
1012
ExecuteAgentRequest, ExecuteAgentResult, LLMCallRecord, TestResult, VerifyTaskRequest,
@@ -22,10 +24,24 @@ use tokio::sync::RwLock;
2224
use tracing::{debug, error, info, warn};
2325
use uuid::Uuid;
2426

27+
/// Allowed base images for agent execution
28+
/// SECURITY: Only these images are allowed to run agents
29+
pub const ALLOWED_BASE_IMAGES: &[&str] = &[
30+
"python:3.11-slim",
31+
"python:3.12-slim",
32+
"python:3.11",
33+
"python:3.12",
34+
"node:20-slim",
35+
"node:22-slim",
36+
"rust:1.75-slim",
37+
"rust:1.76-slim",
38+
"ghcr.io/platformnetwork/agent-runner:", // Custom runner images
39+
];
40+
2541
/// Docker runner configuration
2642
#[derive(Debug, Clone, Serialize, Deserialize)]
2743
pub struct DockerRunnerConfig {
28-
/// Base image for agent execution
44+
/// Base image for agent execution (must be whitelisted)
2945
pub base_image: String,
3046
/// Docker socket path
3147
pub docker_socket: String,
@@ -87,11 +103,39 @@ struct RunningContainer {
87103
}
88104

89105
impl DockerRunner {
90-
pub fn new(config: DockerRunnerConfig) -> Self {
91-
Self {
106+
/// Check if a base image is allowed
107+
/// SECURITY: Prevents running agents with unauthorized images
108+
fn is_base_image_allowed(image: &str) -> bool {
109+
let image_lower = image.to_lowercase();
110+
ALLOWED_BASE_IMAGES.iter().any(|allowed| {
111+
let allowed_lower = allowed.to_lowercase();
112+
// Exact match or prefix match (for versioned images like ghcr.io/platformnetwork/agent-runner:v1)
113+
image_lower == allowed_lower || image_lower.starts_with(&allowed_lower)
114+
})
115+
}
116+
117+
pub fn new(config: DockerRunnerConfig) -> Result<Self, String> {
118+
// SECURITY: Validate base image on creation
119+
if !Self::is_base_image_allowed(&config.base_image) {
120+
return Err(format!(
121+
"Base image '{}' is not whitelisted. Allowed images: {:?}",
122+
config.base_image, ALLOWED_BASE_IMAGES
123+
));
124+
}
125+
126+
Ok(Self {
92127
config,
93128
running_containers: Arc::new(RwLock::new(HashMap::new())),
94129
llm_records: Arc::new(RwLock::new(Vec::new())),
130+
})
131+
}
132+
133+
/// Create with default config (always uses whitelisted image)
134+
pub fn with_defaults() -> Self {
135+
Self {
136+
config: DockerRunnerConfig::default(),
137+
running_containers: Arc::new(RwLock::new(HashMap::new())),
138+
llm_records: Arc::new(RwLock::new(Vec::new())),
95139
}
96140
}
97141

crates/consensus/src/pbft.rs

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,51 @@ impl PBFTEngine {
301301
*state = new_state;
302302
warn!("Force state update applied");
303303
}
304+
SudoAction::SetChallengeWeight {
305+
challenge_id,
306+
mechanism_id,
307+
weight_ratio,
308+
} => {
309+
let allocation = platform_core::ChallengeWeightAllocation::new(
310+
challenge_id,
311+
mechanism_id,
312+
weight_ratio,
313+
);
314+
state.challenge_weights.insert(challenge_id, allocation);
315+
info!(
316+
"Challenge weight set: {:?} on mechanism {} = {:.2}%",
317+
challenge_id,
318+
mechanism_id,
319+
weight_ratio * 100.0
320+
);
321+
}
322+
SudoAction::SetMechanismBurnRate {
323+
mechanism_id,
324+
burn_rate,
325+
} => {
326+
let config = state
327+
.mechanism_configs
328+
.entry(mechanism_id)
329+
.or_insert_with(|| platform_core::MechanismWeightConfig::new(mechanism_id));
330+
config.base_burn_rate = burn_rate.clamp(0.0, 1.0);
331+
info!(
332+
"Mechanism {} burn rate set to {:.2}%",
333+
mechanism_id,
334+
burn_rate * 100.0
335+
);
336+
}
337+
SudoAction::SetMechanismConfig {
338+
mechanism_id,
339+
config,
340+
} => {
341+
state.mechanism_configs.insert(mechanism_id, config.clone());
342+
info!(
343+
"Mechanism {} config updated: burn={:.2}%, cap={:.2}%",
344+
mechanism_id,
345+
config.base_burn_rate * 100.0,
346+
config.max_weight_cap * 100.0
347+
);
348+
}
304349
}
305350

306351
state.update_hash();
@@ -334,10 +379,37 @@ impl PBFTEngine {
334379
fn validate_sudo_action(&self, _state: &ChainState, action: &SudoAction) -> bool {
335380
match action {
336381
SudoAction::AddChallenge { config } => {
337-
// Validate docker image is specified
338-
!config.docker_image.is_empty() && !config.name.is_empty()
382+
// Full validation including Docker image whitelist
383+
match config.validate() {
384+
Ok(()) => {
385+
info!(
386+
"Challenge config validated: {} ({})",
387+
config.name, config.docker_image
388+
);
389+
true
390+
}
391+
Err(reason) => {
392+
warn!("Challenge config rejected: {}", reason);
393+
false
394+
}
395+
}
396+
}
397+
SudoAction::UpdateChallenge { config } => {
398+
// Validate updated config including Docker image whitelist
399+
match config.validate() {
400+
Ok(()) => {
401+
info!(
402+
"Challenge update validated: {} ({})",
403+
config.name, config.docker_image
404+
);
405+
true
406+
}
407+
Err(reason) => {
408+
warn!("Challenge update rejected: {}", reason);
409+
false
410+
}
411+
}
339412
}
340-
SudoAction::UpdateChallenge { config } => !config.docker_image.is_empty(),
341413
_ => true,
342414
}
343415
}

crates/core/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ edition.workspace = true
77
serde = { workspace = true }
88
serde_json = { workspace = true }
99
bincode = { workspace = true }
10-
ed25519-dalek = { workspace = true }
1110
sha2 = { workspace = true }
1211
rand = { workspace = true }
1312
hex = { workspace = true }
@@ -17,3 +16,7 @@ thiserror = { workspace = true }
1716
anyhow = { workspace = true }
1817
tracing = { workspace = true }
1918
bs58 = "0.5"
19+
20+
# Sr25519 crypto (Substrate standard)
21+
sp-core = { version = "31.0", default-features = false, features = ["std"] }
22+
schnorrkel = "0.11"

crates/core/src/constants.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,18 @@ pub fn protocol_version() -> (u32, u32, u32) {
4242
}
4343

4444
// ============================================================================
45-
// SUDO KEY
45+
// SUDO KEY (sr25519 - Substrate/Bittensor compatible)
4646
// ============================================================================
4747

48-
/// Production Sudo Key (Coldkey: 5GziQCcRpN8NCJktX343brnfuVe3w6gUYieeStXPD1Dag2At)
48+
/// Production Sudo Key (sr25519 public key)
49+
/// SS58 Address: 5GziQCcRpN8NCJktX343brnfuVe3w6gUYieeStXPD1Dag2At
4950
/// All requests signed by this key are treated as root and can update the network.
5051
pub const SUDO_KEY_BYTES: [u8; 32] = [
5152
0xda, 0x22, 0x04, 0x09, 0x67, 0x8d, 0xf5, 0xf0, 0x60, 0x74, 0xa6, 0x71, 0xab, 0xdc, 0x1f, 0x19,
5253
0xbc, 0x2b, 0xa1, 0x51, 0x72, 0x9f, 0xdb, 0x9a, 0x8e, 0x4b, 0xe2, 0x84, 0xe6, 0x0c, 0x94, 0x01,
5354
];
5455

55-
/// Production Sudo Key as hex string
56+
/// Production Sudo Key as hex string (sr25519)
5657
pub const SUDO_KEY_HEX: &str = "da220409678df5f06074a671abdc1f19bc2ba151729fdb9a8e4be284e60c9401";
5758

5859
/// Production Sudo Key SS58 address

0 commit comments

Comments
 (0)