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 Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ path = "src/main.rs"

[dependencies]
# LeanSig library (from GitHub repository, pinned to commit)
leansig = { git = "https://github.com/leanEthereum/leanSig.git", rev = "f10dcbefac2502d356d93f686e8b4ecd8dc8840a" }
leansig = { git = "https://github.com/leanEthereum/leanSig.git", rev = "73bedc26ed961b110df7ac2e234dc11361a4bf25" }

# CLI framework
clap = { version = "4.5", features = ["derive"] }
Expand Down
19 changes: 7 additions & 12 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,15 @@ FROM rust:1.87 AS builder

WORKDIR /usr/src/hash-sig-cli

# Copy manifest and pre-fetch dependencies (cached if unchanged)
COPY Cargo.toml ./

# Create a new empty shell project to cache dependencies
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo fetch
RUN rm -rf src

# Build the dependencies
RUN cargo build --release
RUN rm -f target/release/deps/hash_sig_cli*

# Copy the source code
COPY . .

# Build the actual application
RUN cargo build --release
# Copy actual sources and build the binary
COPY src ./src
RUN cargo build --release --bin hashsig

# Use a smaller base image for the final image
FROM debian:bookworm-slim
Expand All @@ -24,4 +19,4 @@ FROM debian:bookworm-slim
COPY --from=builder /usr/src/hash-sig-cli/target/release/hashsig /usr/local/bin/hashsig

# Set the entry point for the container
ENTRYPOINT ["hashsig"]
ENTRYPOINT ["hashsig"]
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ cargo build --release

Generate validator key pairs for hash-based signatures:

**Indexed format (default, index-based naming):**
```bash
cargo run --release --bin hashsig -- generate \
--num-validators 5 \
Expand All @@ -29,6 +30,16 @@ cargo run --release --bin hashsig -- generate \
--export-format both
```

**Distributed format (first-3 last-3 bytes naming):**
```bash
cargo run --release --bin hashsig -- generate \
--num-validators 5 \
--log-num-active-epochs 18 \
--output-dir ./generated_keys \
--export-format both \
--distributed
```

**Parameters:**
- `--num-validators`: Number of validator key pairs to generate
- `--log-num-active-epochs`: Log2 of the number of active epochs (e.g., 18 for 2^18 = 262,144 active epochs)
Expand All @@ -37,8 +48,11 @@ cargo run --release --bin hashsig -- generate \
- `both` (default): export **SSZ binaries** (`.ssz`) and **legacy JSON** (`.json`)
- `ssz`: export **only** SSZ binaries (`.ssz`)
- `--create-manifest`: Create a manifest file (optional, defaults to `true`)
- `--distributed`: Use distributed naming format based on first-3 and last-3 bytes of public key (e.g., `validator-987678-de4578-pk.ssz`). When enabled, the manifest will not include the `index` field.

**Output (default `--export-format both`):**

**Indexed format (default, without `--distributed`):**
The tool creates a directory with key pairs exported as **SSZ-encoded binary files** plus **legacy JSON**:
```
generated_keys/
Expand All @@ -54,6 +68,72 @@ generated_keys/
└── ...
```

**Distributed format (with `--distributed`):**
When using `--distributed`, validators are named using the first-3 and last-3 bytes of the public key (hex-encoded):
```
generated_keys/
├── validator-keys-manifest.yaml # Manifest file (if --create-manifest is true)
├── validator-987678-de4578-pk.ssz # Public key (SSZ bytes)
├── validator-987678-de4578-sk.ssz # Secret key (SSZ bytes)
├── validator-987678-de4578-pk.json # Public key (legacy JSON)
├── validator-987678-de4578-sk.json # Secret key (legacy JSON)
├── validator-52d9eb-dd0a4f-pk.ssz # Public key (SSZ bytes)
├── validator-52d9eb-dd0a4f-sk.ssz # Secret key (SSZ bytes)
├── validator-52d9eb-dd0a4f-pk.json # Public key (legacy JSON)
├── validator-52d9eb-dd0a4f-sk.json # Secret key (legacy JSON)
└── ...
```

**Manifest Format:**

**Indexed format manifest** (default, without `--distributed`):
```yaml
# Hash-Signature Validator Keys Manifest
# Generated by hash-sig-cli

key_scheme: SIGTopLevelTargetSumLifetime32Dim64Base8
hash_function: Poseidon2
encoding: TargetSum
lifetime: 4294967296
log_num_active_epochs: 18
num_active_epochs: 262144
num_validators: 2

validators:
- index: 0
pubkey_hex: 0x...
privkey_file: validator_0_sk.ssz

- index: 1
pubkey_hex: 0x...
privkey_file: validator_1_sk.ssz
```

**Distributed format manifest** (with `--distributed`):
```yaml
# Hash-Signature Validator Keys Manifest
# Generated by hash-sig-cli

key_scheme: SIGTopLevelTargetSumLifetime32Dim64Base8
hash_function: Poseidon2
encoding: TargetSum
lifetime: 4294967296
log_num_active_epochs: 18
num_active_epochs: 262144
num_validators: 2

validators:
- pubkey_hex: 0x...
privkey_file: validator-987678-de4578-sk.ssz

- pubkey_hex: 0x...
privkey_file: validator-52d9eb-dd0a4f-sk.ssz
```

**Key differences:**
- **Indexed format**: Manifest includes an `index` field for each validator
- **Distributed format**: Manifest does **not** include the `index` field (only `pubkey_hex` and `privkey_file`)

The `.ssz` files contain the **canonical SSZ serialization** (`to_bytes()`) of the underlying key types from `leanSig`, written directly as raw bytes (not JSON or hex).

The `.json` files are provided **only for backwards compatibility** and may be removed in a future version once all clients consume SSZ.
Expand Down
103 changes: 68 additions & 35 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ enum Commands {
/// Create a manifest file for validator keys
#[arg(long, default_value = "true")]
create_manifest: bool,

/// Use distributed format: name validators with first-3 last-3 bytes of public key
#[arg(long)]
distributed: bool,
},
}

Expand All @@ -61,29 +65,43 @@ fn main() -> std::io::Result<()> {
output_dir,
export_format,
create_manifest,
distributed,
} => {
generate_keys(
let validator_info = generate_keys(
num_validators,
log_num_active_epochs,
export_format,
output_dir.clone(),
distributed,
)?;

if create_manifest {
create_validator_manifest(&output_dir, num_validators, log_num_active_epochs)?;
create_validator_manifest(
&output_dir,
num_validators,
log_num_active_epochs,
distributed,
&validator_info,
)?;
}
}
}

Ok(())
}

struct ValidatorInfo {
pubkey_hex: String,
privkey_file: String,
}

fn generate_keys(
num_validators: usize,
log_num_active_epochs: usize,
export_format: ExportFormat,
output_dir: PathBuf,
) -> std::io::Result<()> {
distributed: bool,
) -> std::io::Result<Vec<ValidatorInfo>> {
// Create the output directory if it doesn't exist
fs::create_dir_all(&output_dir)?;

Expand All @@ -103,21 +121,40 @@ fn generate_keys(
let mut rng = rand::rng();

let write_json = matches!(export_format, ExportFormat::Both);
let mut validator_info_list = Vec::new();

for i in 0..num_validators {
let key_prefix = format!("validator_{}", i);

println!("Generating {}...", key_prefix);

// Generate the key pair
let (pk, sk) = SIGTopLevelTargetSumLifetime32Dim64Base8::key_gen(
&mut rng,
0,
activation_duration,
);

// Serialize the public key to SSZ bytes and write to a binary .ssz file
// Serialize the public key to SSZ bytes
let pk_bytes = pk.to_bytes();

// Determine key prefix based on format
let key_prefix = if distributed {
// Extract first 3 and last 3 bytes from pk_bytes
if pk_bytes.len() < 3 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Public key bytes too short to extract first-3 last-3 bytes"
));
}
let first_3 = &pk_bytes[0..3];
let last_3 = &pk_bytes[pk_bytes.len() - 3..];
let first_3_hex = hex::encode(first_3);
let last_3_hex = hex::encode(last_3);
format!("validator-{}-{}", first_3_hex, last_3_hex)
} else {
format!("validator_{}", i)
};

println!("Generating {}...", key_prefix);

// Write public key to SSZ file
let mut pk_file = File::create(output_dir.join(format!("{}_pk.ssz", key_prefix)))?;
pk_file.write_all(&pk_bytes)?;

Expand Down Expand Up @@ -146,30 +183,27 @@ fn generate_keys(
println!(" ⚠️ (legacy) {}_pk.json", key_prefix);
println!(" ⚠️ (legacy) {}_sk.json", key_prefix);
}

// Store validator info for manifest
let pubkey_hex = format!("0x{}", hex::encode(&pk_bytes));
let privkey_file = format!("{}_sk.ssz", key_prefix);
validator_info_list.push(ValidatorInfo {
pubkey_hex,
privkey_file,
});
}

println!("\n✅ Successfully generated and saved {} validator key pairs.", num_validators);

Ok(())
}

/// Convert pubkey SSZ file to hex string
/// Reads the `.ssz` file as raw bytes (already in SSZ/canonical form)
/// and returns a hex string with "0x" prefix.
fn pubkey_ssz_to_hex(pk_file_path: &PathBuf) -> Result<String, Box<dyn std::error::Error>> {
// Read SSZ bytes from file
let pubkey_bytes = fs::read(pk_file_path)?;

// Convert bytes to hex string with "0x" prefix
let hex_string = format!("0x{}", hex::encode(&pubkey_bytes));

Ok(hex_string)
Ok(validator_info_list)
}

fn create_validator_manifest(
output_dir: &PathBuf,
num_validators: usize,
log_num_active_epochs: usize,
distributed: bool,
validator_info: &[ValidatorInfo],
) -> std::io::Result<()> {
println!("\n📄 Creating validator manifest...");

Expand All @@ -188,19 +222,18 @@ fn create_validator_manifest(
writeln!(manifest_file, "num_validators: {}\n", num_validators)?;
writeln!(manifest_file, "validators:")?;

for i in 0..num_validators {
// Read the pubkey SSZ file and convert to hex
let pk_file_path = output_dir.join(format!("validator_{}_pk.ssz", i));
let pubkey_hex = pubkey_ssz_to_hex(&pk_file_path)
.map_err(|e| std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to convert pubkey to hex for validator {}: {}", i, e)
))?;

writeln!(manifest_file, " - index: {}", i)?;
writeln!(manifest_file, " pubkey_hex: {}", pubkey_hex)?;
writeln!(manifest_file, " privkey_file: validator_{}_sk.ssz", i)?;
if i < num_validators - 1 {
for (i, info) in validator_info.iter().enumerate() {
if distributed {
// Distributed format: no index field
writeln!(manifest_file, " - pubkey_hex: {}", info.pubkey_hex)?;
writeln!(manifest_file, " privkey_file: {}", info.privkey_file)?;
} else {
// Indexed format: include index field
writeln!(manifest_file, " - index: {}", i)?;
writeln!(manifest_file, " pubkey_hex: {}", info.pubkey_hex)?;
writeln!(manifest_file, " privkey_file: {}", info.privkey_file)?;
}
if i < validator_info.len() - 1 {
writeln!(manifest_file)?;
}
}
Expand Down