Skip to main content

Identity & Cryptography

Every MiniLedger node has a unique cryptographic identity based on an Ed25519 keypair. This identity is used to sign transactions and blocks, verify the authenticity of data received from other nodes, and derive a short human-readable node ID.

Cryptographic Primitives

MiniLedger uses the following libraries from the @noble family:

LibraryPurpose
@noble/ed25519Ed25519 key generation, signing, and verification
@noble/hashes/sha512SHA-512 hash function (required internally by Ed25519)
@noble/hashes/sha256SHA-256 for transaction hashes, block hashes, and key derivation

These are pure JavaScript implementations with no native dependencies, making MiniLedger portable across all Node.js environments without compilation steps.

Why Ed25519?

PropertyBenefit
FastSigning and verification are among the fastest of any signature scheme
Small keys32-byte private keys, 32-byte public keys, 64-byte signatures
DeterministicSame message + same key always produces the same signature
No random number pitfallsUnlike ECDSA, Ed25519 does not require a random nonce per signature
Widely adoptedUsed by SSH, TLS, Signal, Solana, and many other systems

Key Generation

A new Ed25519 keypair is generated using @noble/ed25519:

import * as ed from "@noble/ed25519";
import { sha512 } from "@noble/hashes/sha512";

// Configure Ed25519 to use synchronous SHA-512
ed.etc.sha512Sync = (...m: Uint8Array[]) => {
const h = sha512.create();
for (const msg of m) h.update(msg);
return h.digest();
};

interface KeyPair {
publicKey: string; // 64-char hex (32 bytes)
privateKey: string; // 64-char hex (32 bytes)
}

function generateKeyPair(): KeyPair {
const privateKey = ed.utils.randomPrivateKey(); // 32 random bytes
const publicKey = ed.getPublicKey(privateKey); // Derive public key
return {
publicKey: toHex(publicKey),
privateKey: toHex(privateKey),
};
}

Both keys are stored as hex-encoded strings (64 characters each, representing 32 bytes).

Node Identity

The nodeId is the first 16 hex characters of the Ed25519 public key:

import { shortId } from "./utils.js";

this.nodeId = shortId(this.keyPair.publicKey);
// Example: "3a7f1b2c9e4d8f05" (first 16 chars of the 64-char public key)

The nodeId serves as a human-readable identifier in logs, peer connections, the REST API, and the Raft voter list. It is not used for cryptographic operations -- the full 64-character public key is always used for signing and verification.

Identity Hierarchy

Ed25519 Private Key (32 bytes, hex-encoded)

├──▶ Ed25519 Public Key (32 bytes, hex-encoded, 64 hex chars)
│ │
│ ├──▶ nodeId (first 16 hex chars of publicKey)
│ │
│ └──▶ Used in: transaction sender, block proposer,
│ handshake publicKey, signature verification

└──▶ Used in: sign(hash, privateKey) -> signature

Signing and Verification

Signing

The sign() function signs a message (typically a transaction hash or block hash) with a private key:

function sign(message: string, privateKeyHex: string): string {
const msgBytes = new TextEncoder().encode(message);
const sig = ed.sign(msgBytes, fromHex(privateKeyHex));
return toHex(sig); // 128-char hex (64 bytes)
}

Signatures are 64 bytes (128 hex characters).

Verification

The verify() function checks that a signature was produced by the holder of the corresponding private key:

function verify(
message: string,
signatureHex: string,
publicKeyHex: string
): boolean {
try {
const msgBytes = new TextEncoder().encode(message);
return ed.verify(fromHex(signatureHex), msgBytes, fromHex(publicKeyHex));
} catch {
return false; // Invalid input returns false instead of throwing
}
}

What Gets Signed

Data TypeSigned FieldSigned By
Transactiontx.hash (SHA-256 of canonical tx content)Transaction sender's private key
Blockblock.hash (SHA-256 of canonical block header)Block proposer's (leader) private key

The hash computation is deterministic -- it uses canonical JSON serialization (sorted keys) of the relevant fields:

// Transaction hash
computeTxHash({
type, sender, nonce, timestamp, payload
})

// Block hash
computeBlockHash({
height, previousHash, timestamp, merkleRoot, stateRoot, proposer
})

Keystore

The node's keypair is persisted in a keystore.json file in the data directory. The private key is encrypted before storage.

Keystore File Format

{
"version": 1,
"publicKey": "3a7f1b2c9e4d8f05...64 hex chars...",
"encryptedPrivateKey": "e8c2f9a1b3d4...64 hex chars...",
"salt": "a1b2c3d4e5f6...32 hex chars...",
"orgId": "my-org",
"name": "node-1"
}
FieldDescription
versionKeystore format version (currently 1)
publicKeyEd25519 public key in hex
encryptedPrivateKeyXOR-encrypted private key in hex
saltRandom 16-byte salt used for key derivation
orgIdOrganization identifier for this node
nameHuman-readable name for this node

Encryption

The private key is encrypted using XOR with a password-derived key:

function encryptKeystore(keyPair, password, orgId, name): KeystoreFile {
const salt = crypto.getRandomValues(new Uint8Array(16)); // 16 random bytes
const derived = deriveKey(password, salt); // Password -> key
const privBytes = fromHex(keyPair.privateKey);
const encrypted = new Uint8Array(privBytes.length);
for (let i = 0; i < privBytes.length; i++) {
encrypted[i] = privBytes[i] ^ derived[i % derived.length];
}
return { version: 1, publicKey, encryptedPrivateKey, salt, orgId, name };
}

Key Derivation

The password-derived key is produced by iteratively hashing the password concatenated with the salt:

function deriveKey(password: string, salt: Uint8Array): Uint8Array {
let key = sha256(new TextEncoder().encode(password + toHex(salt)));
for (let i = 0; i < 10000; i++) {
key = sha256(key);
}
return key; // 32 bytes (matches Ed25519 private key size)
}

This is a simplified PBKDF with 10,000 SHA-256 iterations. The iteration count provides resistance against brute-force attacks on weak passwords.

Decryption

Decryption reverses the XOR operation using the same derived key:

function decryptKeystore(keystore, password): KeyPair {
const salt = fromHex(keystore.salt);
const derived = deriveKey(password, salt);
const encrypted = fromHex(keystore.encryptedPrivateKey);
const decrypted = new Uint8Array(encrypted.length);
for (let i = 0; i < encrypted.length; i++) {
decrypted[i] = encrypted[i] ^ derived[i % derived.length];
}
return { publicKey: keystore.publicKey, privateKey: toHex(decrypted) };
}

Automatic Keypair Creation

On first startup, if no keystore.json exists, the node automatically generates a new keypair and saves it:

const keystorePath = path.join(this.config.dataDir, "keystore.json");
if (fs.existsSync(keystorePath)) {
// Load existing keypair
const data = fs.readFileSync(keystorePath, "utf-8");
const ks = deserializeKeystore(data);
this.keyPair = decryptKeystore(ks, "");
} else {
// Generate new keypair
this.keyPair = generateKeyPair();
const ks = encryptKeystore(
this.keyPair, "", this.config.node.orgId, this.config.node.name
);
fs.writeFileSync(keystorePath, serializeKeystore(ks));
}

By default, the password is an empty string (""), which means the keystore is obfuscated but not password-protected. For production deployments, a non-empty password should be configured.

Roles

MiniLedger supports three node roles defined in the NodeIdentity type:

RolePermissions
adminFull access: submit transactions, propose governance changes, manage peers
memberSubmit transactions, vote on proposals
observerRead-only access: query state and blocks, no transaction submission

Roles are stored in the NodeIdentity and used by the governance and privacy subsystems to enforce access control.

Transaction Signing Flow

1. Build unsigned transaction
┌────────────────────────────┐
│ type: "state:set" │
│ sender: publicKey │
│ nonce: nextNonce │
│ timestamp: Date.now() │
│ payload: { kind, key, val }│
│ signature: "" │
└────────────┬───────────────┘

2. Compute hash │

hash = SHA-256(canonicalize({type, sender, nonce, timestamp, payload}))

3. Sign hash with private key
signature = Ed25519.sign(hash, privateKey)

4. Attach hash + signature
┌────────────────────────────┐
│ hash: "a1b2c3..." │
│ type: "state:set" │
│ sender: publicKey │
│ nonce: 0 │
│ timestamp: 1706000000000 │
│ payload: { ... } │
│ signature: "d4e5f6..." │
└────────────────────────────┘

5. Validate
- Structural validation (hash length, sender length, nonce >= 0, payload.kind exists)
- Recompute hash and compare
- Verify signature: Ed25519.verify(signature, hash, sender)

Block Signing Flow

1. Build unsigned block
┌────────────────────────────┐
│ height: 42 │
│ previousHash: "..." │
│ timestamp: Date.now() │
│ merkleRoot: computeMerkle()│
│ stateRoot: computeState() │
│ proposer: publicKey │
│ transactions: [...] │
│ signature: "" │
└────────────┬───────────────┘

2. Compute hash │

hash = SHA-256(canonicalize({
height, previousHash, timestamp, merkleRoot, stateRoot, proposer
}))

3. Sign hash with leader's private key
signature = Ed25519.sign(hash, leaderPrivateKey)

4. Final signed block
┌────────────────────────────┐
│ height: 42 │
│ hash: "b3c4d5..." │
│ previousHash: "..." │
│ ... │
│ signature: "f7a8b9..." │
└────────────────────────────┘

Verification Chain

Every piece of data in MiniLedger can be independently verified:

Block N
├── hash = SHA-256(header fields) ✓ recomputable
├── previousHash = Block[N-1].hash ✓ chain linkage
├── merkleRoot = MerkleTree(tx hashes) ✓ recomputable
├── stateRoot = SHA-256(ordered world_state) ✓ recomputable
├── signature = Ed25519(hash, proposer.key) ✓ verifiable
└── transactions[]
└── Transaction
├── hash = SHA-256(canonical fields) ✓ recomputable
└── signature = Ed25519(hash, sender) ✓ verifiable

This means any node can independently verify the entire chain by:

  1. Replaying all transactions from the genesis block.
  2. Recomputing all hashes and merkle roots.
  3. Verifying all Ed25519 signatures against the stated public keys.

CLI Key Management

The CLI provides commands for managing keys:

# Generate a new keypair (creates keystore.json in data directory)
npx miniledger keys generate

# Show the node's public key and nodeId
npx miniledger keys show

# Export the public key
npx miniledger keys export

Security Best Practices

  1. Protect the keystore: The keystore.json file contains the encrypted private key. Keep it secure and backed up.
  2. Use a strong password: For production deployments, encrypt the keystore with a strong password instead of the default empty string.
  3. Rotate keys carefully: Changing a node's keypair changes its identity. All peers and governance records will see it as a new node.
  4. Verify block proposers: In a consortium network, maintain an allow-list of known proposer public keys.
  5. Monitor signatures: Log and alert on signature verification failures, which may indicate tampering or misconfiguration.