Files
signer/src/dispatcher.rs

1647 lines
66 KiB
Rust

//! Dispatcher — JSON-RPC 2.0 request routing.
//!
//! Port of `dispatcher.c`. Routes verbs to the appropriate handler
//! (algorithm-based, nostr, OTP, or metadata).
use crate::alg_cache::AlgorithmKeyCache;
use crate::enforcement;
use crate::error::RpcError;
use crate::key_store::KeyStore;
use crate::mnemonic::MnemonicState;
use crate::pq_crypto::CryptoAlg;
use crate::role_table::RoleTable;
use crate::selector::{selector_resolve, SelectorRequest};
use crate::SignerError;
use serde_json::{json, Value};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
/// A fully validated `nostr_mine_event` request prepared for background
/// execution.
///
/// The dispatcher does all synchronous work on the main thread — selector
/// resolution, purpose enforcement, key derivation, event JSON parsing,
/// termination-condition checks — and hands this struct to the server layer,
/// which runs the (long) mining run on a background thread. The private key
/// is a temporary stack copy (zeroized on drop); the key store itself is
/// never shared across threads.
pub struct PreparedMineJob {
/// JSON-RPC request id — the background thread builds the response.
pub rpc_id: String,
pub role_name: String,
pub event_json: String,
pub private_key: zeroize::Zeroizing<[u8; 32]>,
pub difficulty: i32,
pub threads: i32,
pub timeout_sec: u64,
}
/// Dispatcher context — holds references to shared state.
pub struct DispatcherContext<'a> {
pub role_table: &'a mut RoleTable,
pub mnemonic: &'a MnemonicState,
pub key_store: &'a mut KeyStore,
pub alg_key_cache: &'a mut AlgorithmKeyCache,
/// Side channel for the activity layer: statistics from the most recent
/// `nostr_mine_event` dispatch. `None` before dispatch and after a failed
/// request. Never part of the JSON-RPC response (which stays the signed
/// event JSON string, same shape as `nostr_sign_event`).
pub last_mine_result: Option<crate::miner::MineResult>,
/// When `true` (server poll loops), a valid `nostr_mine_event` request is
/// not mined synchronously: the validated job is stashed in
/// [`Self::pending_mine_job`] and a success response is produced by the
/// server layer once the background run completes.
pub offload_mining: bool,
/// Prepared background mining job produced by the most recent dispatch
/// when [`Self::offload_mining`] is set. `None` for every other request.
pub pending_mine_job: Option<PreparedMineJob>,
}
/// Process a JSON-RPC request string and produce a JSON-RPC response string.
///
/// Response format on success: `{"id":"...","result":"..."}`
/// Response format on error: `{"id":"...","error":{"code":N,"message":"..."}}`
pub fn handle_request(ctx: &mut DispatcherContext, json_request: &str) -> String {
let root: Value = match serde_json::from_str(json_request) {
Ok(v) => v,
Err(_) => return make_error_response("null", RpcError::PARSE_ERROR),
};
let id = root
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("null")
.to_string();
let method = match root.get("method").and_then(|v| v.as_str()) {
Some(m) => m,
None => return make_error_response(&id, RpcError::INVALID_REQUEST),
};
let params = match root.get("params") {
Some(p) if p.is_array() => p.as_array().unwrap(),
_ => return make_error_response(&id, RpcError::INVALID_REQUEST),
};
// Extract options (last param if it's an object)
let options = params
.last()
.and_then(|v| if v.is_object() { Some(v) } else { None });
// ── Route ──────────────────────────────────────────────────────────
// Algorithm-based verbs (bypass role table)
if enforcement::is_algorithm_verb(method) {
return handle_algorithm_verb(ctx, &id, method, params, options);
}
// get_info (metadata; no key material)
if method == enforcement::VERB_GET_INFO {
return handle_get_info(&id);
}
// OTP verbs (encrypt/decrypt/split with algorithm:"otp")
if method == enforcement::VERB_ENCRYPT
|| method == enforcement::VERB_DECRYPT
|| method == enforcement::VERB_SPLIT
{
if let Some(opts) = options {
if opts.get("algorithm").and_then(|v| v.as_str()) == Some("otp") {
return handle_otp_verb(&id, method, params, options);
}
}
// Non-OTP encrypt/decrypt/split with other algorithms
return make_error_response(&id, RpcError::ALGORITHM_NOT_SUPPORTED);
}
// Nostr verbs (role-based)
if is_nostr_verb(method) {
return handle_nostr_verb(ctx, &id, method, params, options);
}
make_error_response(&id, RpcError::METHOD_NOT_FOUND)
}
// ── Algorithm Verb Handler ───────────────────────────────────────────────────
fn handle_algorithm_verb(
ctx: &mut DispatcherContext,
id: &str,
method: &str,
params: &[Value],
options: Option<&Value>,
) -> String {
let alg = match options.and_then(|o| o.get("algorithm")).and_then(|v| v.as_str()) {
Some(s) => CryptoAlg::from_str(s),
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
if alg == CryptoAlg::Unknown {
return make_error_response(
id,
RpcError {
code: -32602,
message: "missing_or_invalid_algorithm",
},
);
}
let index = options
.and_then(|o| o.get("index"))
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32;
// Enforcement: check verb+algorithm validity
if let Err(_) = enforcement::enforce_verb_algorithm(method, alg) {
return make_error_response(id, RpcError::ALGORITHM_NOT_SUPPORTED);
}
// Mnemonic must be loaded
if !ctx.mnemonic.is_loaded() {
return make_error_response(id, RpcError::MNEMONIC_NOT_LOADED);
}
// Derive the key on demand
if let Err(_) = ctx.alg_key_cache.derive(ctx.mnemonic, alg, index) {
return make_error_response(
id,
RpcError {
code: -32602,
message: "key_derivation_failed",
},
);
}
let key_entry = match ctx.alg_key_cache.get(alg, index) {
Some(e) => e,
None => {
return make_error_response(
id,
RpcError {
code: -32602,
message: "key_derivation_failed",
},
)
}
};
let alg_name = alg.as_str();
let key_id = &key_entry.key_id;
// ── Dispatch by verb ──────────────────────────────────────────────
match method {
enforcement::VERB_GET_PUBLIC_KEY => {
let result = json!({
"algorithm": alg_name,
"public_key": key_entry.pubkey_hex,
"key_id": key_id,
});
make_success_response(id, &result.to_string())
}
enforcement::VERB_SIGN => {
let msg_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let msg_bytes = match hex::decode(msg_hex) {
Ok(b) => b,
Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS),
};
if msg_bytes.is_empty() {
return make_error_response(id, RpcError::INVALID_PARAMS);
}
let priv_slice = key_entry.private_key.as_slice();
let sig = sign_with_alg(alg, priv_slice, &msg_bytes);
match sig {
Ok(s) => {
let sig_hex = hex::encode(&s);
let result = json!({
"algorithm": alg_name,
"key_id": key_id,
"signature": sig_hex,
});
make_success_response(id, &result.to_string())
}
Err(_) => make_error_response(
id,
RpcError {
code: -32602,
message: "signing_failed",
},
),
}
}
enforcement::VERB_VERIFY => {
let msg_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let sig_hex = match params.get(1).and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let msg_bytes = match hex::decode(msg_hex) {
Ok(b) => b,
Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let sig_bytes = match hex::decode(sig_hex) {
Ok(b) => b,
Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let pub_slice = key_entry.public_key.as_slice();
let valid = verify_with_alg(alg, pub_slice, &msg_bytes, &sig_bytes);
let result = json!({
"valid": valid,
"algorithm": alg_name,
});
make_success_response(id, &result.to_string())
}
enforcement::VERB_DERIVE_SHARED => {
if alg != CryptoAlg::X25519 {
return make_error_response(id, RpcError::ALGORITHM_NOT_SUPPORTED);
}
let peer_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let peer_bytes = match hex::decode(peer_hex) {
Ok(b) if b.len() == 32 => b,
_ => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let priv_slice = key_entry.private_key.as_slice();
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&priv_slice[..32]);
let peer_arr: [u8; 32] = peer_bytes[..32].try_into().unwrap();
let shared = crate::pq_crypto::x25519_ecdh(&priv_arr, &peer_arr);
let shared_hex = hex::encode(&shared);
let result = json!({
"shared_secret": shared_hex,
"algorithm": "x25519",
});
make_success_response(id, &result.to_string())
}
enforcement::VERB_DERIVE => {
// HMAC-SHA256(privkey, data) — index is required
let data_str = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
// index is required for derive (no default)
let has_index = options
.and_then(|o| o.get("index"))
.and_then(|v| v.as_i64())
.is_some();
if !has_index {
return make_error_response(
id,
RpcError {
code: -32602,
message: "missing_index",
},
);
}
let priv_slice = key_entry.private_key.as_slice();
let mac = nostr_core::crypto::hmac::hmac_sha256(priv_slice, data_str.as_bytes());
let mac_hex = hex::encode(&mac);
let result = json!({
"algorithm": alg_name,
"key_id": key_id,
"digest": mac_hex,
});
make_success_response(id, &result.to_string())
}
enforcement::VERB_ENCAPSULATE => {
// ML-KEM-768 only (enforced by enforce_verb_algorithm).
// params[0] = peer public key hex (1184 bytes → 2368 hex chars).
let pub_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let pub_bytes = match hex::decode(pub_hex) {
Ok(b) => b,
Err(_) => return make_error_response(
id,
RpcError { code: -32602, message: "invalid_pubkey_hex" },
),
};
let sizes = alg.sizes().unwrap();
if pub_bytes.len() != sizes.pub_key_len {
return make_error_response(
id,
RpcError { code: -32602, message: "invalid_pubkey_length" },
);
}
match crate::pq_crypto::ml_kem_768_encaps(&pub_bytes) {
Ok((ct, ss)) => {
let result = json!({
"ciphertext": hex::encode(&ct),
"shared_secret": hex::encode(&ss),
"algorithm": "ml-kem-768",
});
make_success_response(id, &result.to_string())
}
Err(_) => make_error_response(
id,
RpcError { code: -32602, message: "encaps_failed" },
),
}
}
enforcement::VERB_DECAPSULATE => {
// ML-KEM-768 only (enforced by enforce_verb_algorithm).
// params[0] = ciphertext hex (1088 bytes → 2176 hex chars).
let ct_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let ct_bytes = match hex::decode(ct_hex) {
Ok(b) => b,
Err(_) => return make_error_response(
id,
RpcError { code: -32602, message: "invalid_ciphertext_hex" },
),
};
let sizes = alg.sizes().unwrap();
if ct_bytes.len() != sizes.ciphertext_len {
return make_error_response(
id,
RpcError { code: -32602, message: "invalid_ciphertext_length" },
);
}
let priv_slice = key_entry.private_key.as_slice();
match crate::pq_crypto::ml_kem_768_decaps(priv_slice, &ct_bytes) {
Ok(ss) => {
let result = json!({
"shared_secret": hex::encode(&ss),
"algorithm": "ml-kem-768",
});
make_success_response(id, &result.to_string())
}
Err(_) => make_error_response(
id,
RpcError { code: -32602, message: "decaps_failed" },
),
}
}
_ => make_error_response(id, RpcError::METHOD_NOT_FOUND),
}
}
// ── Nostr Verb Handler ───────────────────────────────────────────────────────
fn handle_nostr_verb(
ctx: &mut DispatcherContext,
id: &str,
method: &str,
params: &[Value],
options: Option<&Value>,
) -> String {
// Parse selector from options
let mut sel = SelectorRequest::new();
if let Some(opts) = options {
if let Some(role) = opts.get("role").and_then(|v| v.as_str()) {
sel.has_role = true;
sel.role_name = role.to_string();
}
if let Some(path) = opts.get("role_path").and_then(|v| v.as_str()) {
sel.has_role_path = true;
sel.role_path = path.to_string();
}
}
// Resolve selector
let role_index = match selector_resolve(&sel, ctx.role_table) {
Ok(i) => i,
Err(e) => {
return make_error_response(
id,
match e {
crate::selector::SelectorError::Ambiguous => RpcError::AMBIGUOUS_ROLE_SELECTOR,
crate::selector::SelectorError::NotFound => RpcError::UNKNOWN_ROLE,
crate::selector::SelectorError::NoDefault => RpcError::NO_DEFAULT_ROLE,
crate::selector::SelectorError::PathMismatch => RpcError::PATH_NOT_ALLOWED,
crate::selector::SelectorError::RoleRequired => RpcError::ROLE_REQUIRED,
crate::selector::SelectorError::PathRequired => RpcError::PATH_REQUIRED,
_ => RpcError::INVALID_PARAMS,
},
)
}
};
// Enforce verb+role
let role = &ctx.role_table.entries[role_index];
if let Err(_) = enforcement::enforce_verb_role(method, role) {
return make_error_response(
id,
RpcError {
code: 1004,
message: "purpose_mismatch",
},
);
}
// Role name is needed after the mutable key-derivation borrow below.
let role_name = role.name.clone();
// Mnemonic must be loaded
if !ctx.mnemonic.is_loaded() {
return make_error_response(id, RpcError::MNEMONIC_NOT_LOADED);
}
// Ensure key is derived. For variable-path roles, use the concrete
// path supplied by the client; for fixed-path roles, use the stored
// template. Variable-path roles are re-derived whenever the requested
// path differs from the currently-derived one (the cache is per-path,
// not per-role).
let has_variable = role.has_variable_path();
let needs_derive = if has_variable && sel.has_role_path {
role.derived_path.as_deref() != Some(sel.role_path.as_str())
} else {
!role.derived
};
if needs_derive {
let result = if has_variable && sel.has_role_path {
ctx.key_store
.derive_one_with_path(ctx.role_table, ctx.mnemonic, role_index, &sel.role_path)
} else {
ctx.key_store
.derive_one(ctx.role_table, ctx.mnemonic, role_index)
};
if let Err(_) = result {
return make_error_response(
id,
RpcError {
code: -32602,
message: "key_derivation_failed",
},
);
}
}
// Dispatch by verb
match method {
enforcement::VERB_NOSTR_GET_PUBLIC_KEY => {
let pub_hex = ctx.key_store.get_pubkey_hex(role_index).unwrap_or("");
// Check for structured format option
let want_structured = options
.and_then(|o| o.get("format"))
.and_then(|v| v.as_str())
== Some("structured");
if want_structured {
let key_id = if pub_hex.len() >= 16 {
&pub_hex[..16]
} else {
&pub_hex
};
let result = json!({
"algorithm": "secp256k1",
"public_key": pub_hex,
"key_id": key_id,
});
make_success_response(id, &result.to_string())
} else {
// Plain hex string
make_success_response(id, &format!("\"{}\"", pub_hex))
}
}
enforcement::VERB_NOSTR_SIGN_EVENT => {
let event_json = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
match ctx.key_store.sign_event(role_index, event_json) {
// The signed event is itself JSON; serialize it as a proper
// JSON string value so embedded quotes are escaped.
Ok(signed) => {
let wrapped = serde_json::to_string(&signed).unwrap_or_else(|_| "\"\"".into());
make_success_response(id, &wrapped)
}
Err(_) => make_error_response(id, RpcError::INVALID_PARAMS),
}
}
enforcement::VERB_NOSTR_MINE_EVENT => {
// params[0] = unsigned event JSON (string) or a JSON object.
let event_json = match params.first() {
Some(Value::String(s)) => s.clone(),
Some(v @ Value::Object(_)) => match serde_json::to_string(v) {
Ok(s) => s,
Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS),
},
_ => return make_error_response(id, RpcError::INVALID_PARAMS),
};
// The event must parse as a Nostr event. `pubkey` may be
// omitted — the miner signs with the role's key anyway, so we
// substitute the role's public key (matching nostr-tools
// behavior of deriving the author from the signing key).
let mut event_value: Value = match serde_json::from_str(&event_json) {
Ok(v) => v,
Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS),
};
if event_value.get("pubkey").and_then(|v| v.as_str()).is_none() {
let role_pubkey = ctx.key_store.get_pubkey_hex(role_index).unwrap_or("");
if role_pubkey.is_empty() {
return make_error_response(id, RpcError::INVALID_PARAMS);
}
if let Some(obj) = event_value.as_object_mut() {
obj.insert("pubkey".into(), Value::String(role_pubkey.to_string()));
}
}
let event_json = event_value.to_string();
// Options: difficulty (leading zero bits), threads, timeout_sec.
let difficulty = options
.and_then(|o| o.get("difficulty"))
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32;
let threads = options
.and_then(|o| o.get("threads"))
.and_then(|v| v.as_i64())
.unwrap_or(1) as i32;
let timeout_sec = options
.and_then(|o| o.get("timeout_sec"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
// Without a target difficulty or a timeout there is no
// termination condition — refuse rather than mine for the
// 600 s internal default.
if difficulty <= 0 && timeout_sec == 0 {
return make_error_response(id, RpcError::NO_TERMINATION_CONDITION);
}
// Validate the (pubkey-filled) event JSON up front so malformed
// requests fail synchronously (the background thread would have
// no way to report a parse error to the activity log).
if serde_json::from_str::<nostr_core::types::Event>(&event_json).is_err() {
return make_error_response(id, RpcError::INVALID_PARAMS);
}
// ── Background offload path ───────────────────────────────
// Server poll loops set `offload_mining`: stash the validated
// job (with a zeroized-on-drop key copy) for the server layer
// to run on a background thread. The response is sent by the
// background thread when mining completes.
if ctx.offload_mining {
let priv_bytes = match ctx.key_store.get_private_key(role_index) {
Some(k) if k.len() >= 32 => {
let mut arr = [0u8; 32];
arr.copy_from_slice(&k[..32]);
zeroize::Zeroizing::new(arr)
}
_ => return make_error_response(id, RpcError::MINING_FAILED),
};
ctx.pending_mine_job = Some(PreparedMineJob {
rpc_id: id.to_string(),
role_name: role_name.clone(),
event_json,
private_key: priv_bytes,
difficulty,
threads,
timeout_sec,
});
// The server layer replaces this placeholder with the real
// response after the background run.
return make_error_response(id, RpcError::MINING_PENDING);
}
// ── Synchronous path (tests, stdio/qrexec one-shot) ───────
match ctx.key_store.mine_event(role_index, &event_json, difficulty, threads, timeout_sec) {
// The signed mined event is itself JSON; serialize it as a
// proper JSON string value so embedded quotes are escaped
// (same shape as nostr_sign_event). The mining statistics
// are stashed in the context's side channel for the activity
// layer — the RPC response format is unchanged.
Ok(mined) => {
let wrapped =
serde_json::to_string(&mined.best_event_json).unwrap_or_else(|_| "\"\"".into());
ctx.last_mine_result = Some(mined);
make_success_response(id, &wrapped)
}
Err(SignerError::InvalidInput) => make_error_response(id, RpcError::INVALID_PARAMS),
Err(_) => make_error_response(id, RpcError::MINING_FAILED),
}
}
enforcement::VERB_NOSTR_NIP44_ENCRYPT => {
let peer_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let plaintext = match params.get(1).and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
match ctx.key_store.nip44_encrypt(role_index, peer_hex, plaintext) {
Ok(ct) => {
let ct_b64 = base64::engine::general_purpose::STANDARD.encode(&ct);
make_success_response(id, &format!("\"{}\"", ct_b64))
}
Err(_) => make_error_response(id, RpcError::INVALID_PARAMS),
}
}
enforcement::VERB_NOSTR_NIP44_DECRYPT => {
let peer_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let ciphertext_b64 = match params.get(1).and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let ct = match base64::engine::general_purpose::STANDARD.decode(ciphertext_b64) {
Ok(b) => b,
Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS),
};
match ctx.key_store.nip44_decrypt(role_index, peer_hex, &ct) {
Ok(pt) => {
let pt_b64 = base64::engine::general_purpose::STANDARD.encode(&pt);
make_success_response(id, &format!("\"{}\"", pt_b64))
}
Err(_) => make_error_response(id, RpcError::INVALID_PARAMS),
}
}
enforcement::VERB_NOSTR_NIP04_ENCRYPT => {
let peer_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let plaintext = match params.get(1).and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
match ctx.key_store.nip04_encrypt(role_index, peer_hex, plaintext) {
Ok(ct) => make_success_response(id, &format!("\"{}\"", ct)),
Err(_) => make_error_response(id, RpcError::INVALID_PARAMS),
}
}
enforcement::VERB_NOSTR_NIP04_DECRYPT => {
let peer_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let ciphertext = match params.get(1).and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
match ctx.key_store.nip04_decrypt(role_index, peer_hex, ciphertext) {
// The plaintext is arbitrary caller text (newlines, quotes,
// control characters), so it must be JSON-escaped — wrapping
// it in raw quotes produced invalid JSON whenever a decrypted
// message contained a newline or `"` (the client then failed
// to parse the response and logged the plaintext to stderr).
Ok(pt) => {
let escaped = serde_json::to_string(&pt).unwrap_or_else(|_| "\"\"".into());
make_success_response(id, &escaped)
}
Err(_) => make_error_response(id, RpcError::INVALID_PARAMS),
}
}
_ => make_error_response(id, RpcError::METHOD_NOT_FOUND),
}
}
// ── OTP Verb Handler ─────────────────────────────────────────────────────────
//
// Wire-compatible with the C n_signer (api.md §4.7):
// encrypt: params = [ "<plaintext-base64>", { "algorithm":"otp", "encoding":"ascii|binary" } ]
// decrypt: params = [ "<ciphertext>", { "algorithm":"otp", "encoding":"ascii|binary" } ]
//
// encrypt result (JSON object string):
// {"ciphertext":"<armor-or-base64-blob>","encoding":"ascii|binary",
// "pad_chksum":"<64hex>","pad_offset_before":N,"pad_offset_after":M}
// decrypt result (JSON object string):
// {"plaintext":"<base64>","pad_chksum":"<64hex>"}
//
// The encrypt plaintext is base64 (arbitrary bytes); the decrypt input is the
// ASCII armor text or a base64-encoded binary .otp blob. Encoding omitted on
// decrypt → auto-detection by magic bytes.
fn handle_otp_verb(id: &str, method: &str, params: &[Value], options: Option<&Value>) -> String {
// split takes no payload (params[0] is the options object) — handle it
// before payload extraction.
if method == enforcement::VERB_SPLIT {
if !crate::otp_pad::is_global_bound() {
return make_error_response(
id,
RpcError {
code: -32601,
message: "otp_pad_not_bound",
},
);
}
let out = match crate::otp_pad::global_split() {
Ok(out) => out,
Err(_) => {
return make_error_response(
id,
RpcError {
code: -32602,
message: "otp_split_failed",
},
)
}
};
let result = json!({
"grant": out.grant_armor,
"pad_chksum": out.pad_chksum,
"grant_start": out.grant_start,
"grant_end": out.grant_end,
"end_offset": out.new_end_offset,
});
let result_str = serde_json::to_string(&result).unwrap();
return make_success_response(id, &serde_json::to_string(&result_str).unwrap());
}
let payload = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let encoding = options
.and_then(|o| o.get("encoding"))
.and_then(|v| v.as_str());
if !crate::otp_pad::is_global_bound() {
return make_error_response(
id,
RpcError {
code: -32601,
message: "otp_pad_not_bound",
},
);
}
match method {
enforcement::VERB_ENCRYPT => {
// Plaintext arrives base64-encoded (arbitrary bytes).
let pt = match STANDARD.decode(payload) {
Ok(pt) => pt,
Err(_) => {
return make_error_response(
id,
RpcError {
code: -32602,
message: "invalid_plaintext_base64",
},
)
}
};
let out = match crate::otp_pad::global_encrypt(&pt, encoding) {
Ok(out) => out,
Err(_) => {
return make_error_response(
id,
RpcError {
code: -32602,
message: "otp_encrypt_failed",
},
)
}
};
let result = json!({
"ciphertext": out.payload,
"encoding": out.encoding,
"pad_chksum": out.pad_chksum,
"pad_offset_before": out.offset_before,
"pad_offset_after": out.offset_after,
});
// The C n_signer returns the result object as a JSON *string*
// (cJSON_AddStringToObject) — match that wire shape.
let result_str = serde_json::to_string(&result).unwrap();
make_success_response(id, &serde_json::to_string(&result_str).unwrap())
}
enforcement::VERB_DECRYPT => {
// Binary input is a base64-encoded .otp blob; decode it first.
// ASCII/auto input is the armor text itself.
let input = if encoding == Some("binary") {
match STANDARD.decode(payload) {
Ok(blob) => blob,
Err(_) => {
return make_error_response(
id,
RpcError {
code: -32602,
message: "invalid_ciphertext_base64",
},
)
}
}
} else {
payload.as_bytes().to_vec()
};
let pt = match crate::otp_pad::global_decrypt(&input, encoding) {
Ok(pt) => pt,
Err(_) => {
return make_error_response(
id,
RpcError {
code: -32602,
message: "otp_decrypt_failed",
},
)
}
};
let chksum = crate::otp_pad::global_chksum().unwrap_or_default();
let result = json!({
"plaintext": STANDARD.encode(&pt),
"pad_chksum": chksum,
});
let result_str = serde_json::to_string(&result).unwrap();
make_success_response(id, &serde_json::to_string(&result_str).unwrap())
}
_ => make_error_response(id, RpcError::METHOD_NOT_FOUND),
}
}
// ── get_info ────────────────────────────────────────────────────────────────
fn handle_get_info(id: &str) -> String {
let info = json!({
"name": "signer",
"implementation": "host",
"version": crate::VERSION,
"api": "json-rpc-2.0",
"verbs": [
"get_info", "get_public_key", "sign", "verify",
"encapsulate", "decapsulate", "derive_shared_secret", "derive",
"encrypt", "decrypt", "split",
"nostr_get_public_key", "nostr_sign_event", "nostr_mine_event",
"nostr_nip04_encrypt", "nostr_nip04_decrypt",
"nostr_nip44_encrypt", "nostr_nip44_decrypt",
],
"algorithms": [
"secp256k1", "ed25519", "x25519",
"ml-dsa-65", "slh-dsa-128s", "ml-kem-768", "otp",
],
});
make_success_response(id, &info.to_string())
}
// ── Helpers ─────────────────────────────────────────────────────────────────
fn is_nostr_verb(verb: &str) -> bool {
matches!(
verb,
enforcement::VERB_NOSTR_GET_PUBLIC_KEY
| enforcement::VERB_NOSTR_SIGN_EVENT
| enforcement::VERB_NOSTR_MINE_EVENT
| enforcement::VERB_NOSTR_NIP44_ENCRYPT
| enforcement::VERB_NOSTR_NIP44_DECRYPT
| enforcement::VERB_NOSTR_NIP04_ENCRYPT
| enforcement::VERB_NOSTR_NIP04_DECRYPT
)
}
fn sign_with_alg(alg: CryptoAlg, priv_key: &[u8], msg: &[u8]) -> Result<Vec<u8>, SignerError> {
match alg {
CryptoAlg::Secp256k1 => {
if priv_key.len() != 32 {
return Err(SignerError::CryptoFailed);
}
let arr: [u8; 32] = priv_key.try_into().unwrap();
// Check for scheme option (schnorr default, ecdsa alternative)
// For now, default to schnorr
let sk = nostr_core::types::SecretKey::from_bytes(arr);
let digest = nostr_core::crypto::sha256::sha256(msg);
let sig = nostr_core::crypto::keys::schnorr_sign(&sk, &digest)?;
Ok(sig.as_bytes().to_vec())
}
CryptoAlg::Ed25519 => {
if priv_key.len() != 32 {
return Err(SignerError::CryptoFailed);
}
let arr: [u8; 32] = priv_key.try_into().unwrap();
let sig = crate::pq_crypto::ed25519_sign(&arr, msg);
Ok(sig.to_vec())
}
CryptoAlg::MlDsa65 => crate::pq_crypto::ml_dsa_65_sign(priv_key, msg),
CryptoAlg::SlhDsa128s => crate::pq_crypto::slh_dsa_128s_sign(priv_key, msg),
_ => Err(SignerError::CryptoFailed),
}
}
fn verify_with_alg(alg: CryptoAlg, pub_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
match alg {
CryptoAlg::Secp256k1 => {
if pub_key.len() != 32 || sig.len() != 64 {
return false;
}
// Use schnorr verify (default)
let pub_arr: [u8; 32] = pub_key[..32].try_into().unwrap();
let sig_arr: [u8; 64] = sig[..64].try_into().unwrap();
let digest = nostr_core::crypto::sha256::sha256(msg);
let pk = nostr_core::types::PublicKey::from_bytes(pub_arr);
let signature = nostr_core::types::Signature::from_bytes(sig_arr);
nostr_core::crypto::keys::schnorr_verify(&pk, &digest, &signature).unwrap_or(false)
}
CryptoAlg::Ed25519 => {
if pub_key.len() != 32 || sig.len() != 64 {
return false;
}
let pub_arr: [u8; 32] = pub_key[..32].try_into().unwrap();
let sig_arr: [u8; 64] = sig[..64].try_into().unwrap();
crate::pq_crypto::ed25519_verify(&pub_arr, msg, &sig_arr)
}
CryptoAlg::MlDsa65 => crate::pq_crypto::ml_dsa_65_verify(pub_key, msg, sig),
CryptoAlg::SlhDsa128s => crate::pq_crypto::slh_dsa_128s_verify(pub_key, msg, sig),
_ => false,
}
}
fn make_error_response(id: &str, err: RpcError) -> String {
format!(
r#"{{"id":"{}","error":{}}}"#,
id,
err.to_json()
)
}
fn make_success_response(id: &str, result: &str) -> String {
// result is already a JSON value string — wrap it as a JSON string
format!(r#"{{"id":"{}","result":{}}}"#, id, result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::alg_cache::AlgorithmKeyCache;
use crate::key_store::KeyStore;
use crate::mnemonic::MnemonicState;
use crate::role_table::*;
fn make_ctx<'a>(
role_table: &'a mut RoleTable,
mnemonic: &'a MnemonicState,
key_store: &'a mut KeyStore,
alg_cache: &'a mut AlgorithmKeyCache,
) -> DispatcherContext<'a> {
DispatcherContext {
role_table,
mnemonic,
key_store,
alg_key_cache: alg_cache,
last_mine_result: None,
offload_mining: false,
pending_mine_job: None,
}
}
fn setup() -> (
RoleTable,
MnemonicState,
KeyStore,
AlgorithmKeyCache,
) {
let mut mnemonic = MnemonicState::new();
mnemonic
.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
.unwrap();
let mut table = RoleTable::new();
table
.register_role_path(
"main",
"m/44'/1237'/0'/0/0",
RolePurpose::Nostr,
RoleCurve::Secp256k1,
-1, -1, -1, &[],
)
.unwrap();
let mut store = KeyStore::new();
store.derive_all(&mut table, &mnemonic).unwrap();
(table, mnemonic, store, AlgorithmKeyCache::new())
}
#[test]
fn test_get_info() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"1","method":"get_info","params":[]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""));
assert!(resp.contains("signer"));
assert!(resp.contains("json-rpc-2.0"));
}
#[test]
fn test_nostr_get_public_key() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"2","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""));
assert!(!resp.contains("\"error\""));
}
#[test]
fn test_unknown_method() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"3","method":"nonexistent_method","params":[]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"error\""));
assert!(resp.contains("-32601"));
}
#[test]
fn test_parse_error() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"not valid json"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"error\""));
assert!(resp.contains("-32700"));
}
#[test]
fn test_ed25519_get_public_key() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"4","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""));
assert!(resp.contains("ed25519"));
}
#[test]
fn test_ed25519_sign_verify() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let msg_hex = hex::encode(b"hello world");
let sign_req = format!(
r#"{{"id":"5","method":"sign","params":["{}",{{"algorithm":"ed25519","index":0}}]}}"#,
msg_hex
);
let resp = handle_request(&mut ctx, &sign_req);
assert!(resp.contains("\"result\""), "sign response: {}", resp);
assert!(resp.contains("signature"));
// Extract signature from response
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let sig_hex = resp_json["result"]["signature"].as_str().unwrap();
// Verify
let verify_req = format!(
r#"{{"id":"6","method":"verify","params":["{}","{}",{{"algorithm":"ed25519","index":0}}]}}"#,
msg_hex, sig_hex
);
let resp = handle_request(&mut ctx, &verify_req);
assert!(resp.contains("\"valid\":true"));
}
#[test]
fn test_missing_algorithm() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"7","method":"get_public_key","params":[{}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"error\""));
}
// ── PQ algorithm verbs (v2 seeded derivation) ────────────────────
#[test]
fn test_ml_dsa_65_sign_verify() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
// get_public_key must succeed (was key_derivation_failed before).
let req = r#"{"id":"p1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
assert_eq!(pub_hex.len(), 1952 * 2);
// sign
let msg_hex = hex::encode(b"hello world");
let sign_req = format!(
r#"{{"id":"p2","method":"sign","params":["{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
msg_hex
);
let resp = handle_request(&mut ctx, &sign_req);
assert!(resp.contains("\"result\""), "sign: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let sig_hex = resp_json["result"]["signature"].as_str().unwrap();
assert_eq!(sig_hex.len(), 3309 * 2);
// verify (valid)
let verify_req = format!(
r#"{{"id":"p3","method":"verify","params":["{}","{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
msg_hex, sig_hex
);
let resp = handle_request(&mut ctx, &verify_req);
assert!(resp.contains("\"valid\":true"), "verify: {}", resp);
// verify (wrong message)
let verify_req = format!(
r#"{{"id":"p4","method":"verify","params":["{}","{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
hex::encode(b"wrong message"),
sig_hex
);
let resp = handle_request(&mut ctx, &verify_req);
assert!(resp.contains("\"valid\":false"), "verify wrong: {}", resp);
}
#[test]
fn test_slh_dsa_128s_sign_verify() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"s1","method":"get_public_key","params":[{"algorithm":"slh-dsa-128s","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
assert_eq!(pub_hex.len(), 32 * 2);
let msg_hex = hex::encode(b"hello world");
let sign_req = format!(
r#"{{"id":"s2","method":"sign","params":["{}",{{"algorithm":"slh-dsa-128s","index":0}}]}}"#,
msg_hex
);
let resp = handle_request(&mut ctx, &sign_req);
assert!(resp.contains("\"result\""), "sign: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let sig_hex = resp_json["result"]["signature"].as_str().unwrap();
assert_eq!(sig_hex.len(), 7856 * 2);
let verify_req = format!(
r#"{{"id":"s3","method":"verify","params":["{}","{}",{{"algorithm":"slh-dsa-128s","index":0}}]}}"#,
msg_hex, sig_hex
);
let resp = handle_request(&mut ctx, &verify_req);
assert!(resp.contains("\"valid\":true"), "verify: {}", resp);
}
#[test]
fn test_ml_kem_768_encaps_decaps() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
// Derive our ML-KEM keypair to get a public key to encapsulate to.
let req = r#"{"id":"k1","method":"get_public_key","params":[{"algorithm":"ml-kem-768","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
assert_eq!(pub_hex.len(), 1184 * 2);
// encapsulate
let enc_req = format!(
r#"{{"id":"k2","method":"encapsulate","params":["{}",{{"algorithm":"ml-kem-768","index":0}}]}}"#,
pub_hex
);
let resp = handle_request(&mut ctx, &enc_req);
assert!(resp.contains("\"result\""), "encapsulate: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let ct_hex = resp_json["result"]["ciphertext"].as_str().unwrap();
let ss_hex = resp_json["result"]["shared_secret"].as_str().unwrap();
assert_eq!(ct_hex.len(), 1088 * 2);
assert_eq!(ss_hex.len(), 32 * 2);
// decapsulate
let dec_req = format!(
r#"{{"id":"k3","method":"decapsulate","params":["{}",{{"algorithm":"ml-kem-768","index":0}}]}}"#,
ct_hex
);
let resp = handle_request(&mut ctx, &dec_req);
assert!(resp.contains("\"result\""), "decapsulate: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let ss2_hex = resp_json["result"]["shared_secret"].as_str().unwrap();
assert_eq!(ss_hex, ss2_hex, "shared secrets must match");
}
#[test]
fn test_ml_kem_768_encaps_bad_pubkey_length() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"k4","method":"encapsulate","params":["00ff",{"algorithm":"ml-kem-768","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("invalid_pubkey_length"), "resp: {}", resp);
}
#[test]
fn test_pq_key_derivation_deterministic() {
// Same mnemonic + index → same pubkey across dispatcher calls.
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"d1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]}"#;
let resp1 = handle_request(&mut ctx, req);
let resp2 = handle_request(&mut ctx, req);
assert_eq!(resp1, resp2);
}
// ── NIP-13 mining (nostr_mine_event) ─────────────────────────────
#[test]
fn test_nostr_mine_event() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let role_pubkey = store.get_pubkey_hex(0).unwrap().to_string();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
// Unsigned event JSON (no id/sig — the miner fills those in and
// signs with the role's key).
let event_json = format!(
r#"{{"pubkey":"{}","created_at":1234567890,"kind":1,"tags":[],"content":"hello pow"}}"#,
role_pubkey
);
let req = json!({
"id": "m1",
"method": "nostr_mine_event",
"params": [
event_json,
{"role": "main", "role_path": "m/44'/1237'/0'/0/0",
"difficulty": 8, "threads": 2, "timeout_sec": 10}
]
})
.to_string();
let resp = handle_request(&mut ctx, &req);
assert!(resp.contains("\"result\""), "mine failed: {}", resp);
assert!(!resp.contains("\"error\""), "mine error: {}", resp);
// Result is the signed mined event, wrapped as a JSON string.
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let result_str = resp_json["result"].as_str().expect("result is a JSON string");
let mined: nostr_core::types::Event = serde_json::from_str(result_str).unwrap();
// Nonce tag present (NIP-13).
let nonce_tag = mined
.tags
.iter()
.find(|t| t.kind() == "nonce")
.expect("mined event must carry a nonce tag");
assert_eq!(nonce_tag.0.len(), 3, "nonce tag should be [\"nonce\", value, difficulty]");
// Event id has at least the target leading zero bits.
let id = mined.id.as_ref().expect("mined event must have an id");
let zeros = nips::nip013::count_leading_zero_bits(id);
assert!(zeros >= 8, "expected >= 8 leading zero bits, got {}", zeros);
// Signature verifies and pubkey matches the role.
nips::nip001::verify_event_id(&mined).unwrap();
nips::nip001::verify_event_signature(&mined).unwrap();
assert_eq!(mined.pubkey.to_hex(), role_pubkey, "pubkey must match the role");
}
#[test]
fn test_nostr_mine_event_pubkey_omitted() {
// The client may omit `pubkey` — the role's key signs the event, so
// the dispatcher substitutes the role's public key before mining.
let (mut table, mnemonic, mut store, mut cache) = setup();
let role_pubkey = store.get_pubkey_hex(0).unwrap().to_string();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = json!({
"id": "m4",
"method": "nostr_mine_event",
"params": [
{"created_at": 1234567890, "kind": 1, "tags": [], "content": "no pubkey"},
{"role": "main", "role_path": "m/44'/1237'/0'/0/0",
"difficulty": 8, "threads": 1, "timeout_sec": 10}
]
})
.to_string();
let resp = handle_request(&mut ctx, &req);
assert!(resp.contains("\"result\""), "mine failed: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let result_str = resp_json["result"].as_str().expect("result is a JSON string");
let mined: nostr_core::types::Event = serde_json::from_str(result_str).unwrap();
assert_eq!(mined.pubkey.to_hex(), role_pubkey, "pubkey must be filled from the role");
}
#[test]
fn test_nostr_mine_event_no_termination_condition() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
// No difficulty and no timeout → nothing bounds the mining run.
let req = json!({
"id": "m2",
"method": "nostr_mine_event",
"params": [
{"pubkey": "e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f",
"created_at": 1234567890, "kind": 1, "tags": [], "content": "x"},
{"role": "main", "role_path": "m/44'/1237'/0'/0/0"}
]
})
.to_string();
let resp = handle_request(&mut ctx, &req);
assert!(resp.contains("no_termination_condition"), "resp: {}", resp);
}
#[test]
fn test_nostr_mine_event_invalid_params() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
// params[0] is a number, not an event string/object.
let req = json!({
"id": "m3",
"method": "nostr_mine_event",
"params": [
42,
{"role": "main", "role_path": "m/44'/1237'/0'/0/0", "difficulty": 8}
]
})
.to_string();
let resp = handle_request(&mut ctx, &req);
assert!(resp.contains("invalid_params"), "resp: {}", resp);
}
// ── OTP verbs (encrypt/decrypt with algorithm:"otp") ─────────────
/// Serializes tests that touch the global OTP pad (it is process-wide
/// state; cargo runs tests in parallel threads).
static OTP_GLOBAL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Bind a deterministic test pad as the global pad. Returns the dir
/// (for cleanup) and the pad checksum.
fn bind_test_pad(name: &str) -> (std::path::PathBuf, String) {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("signer_otp_disp_{}", name));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// Deterministic pad: bytes 0,1,2,...,255,0,1,...
let pad_data: Vec<u8> = (0..4096usize).map(|i| (i % 256) as u8).collect();
let chksum = hex::encode(&nostr_core::crypto::sha256::sha256(&pad_data));
let mut file = std::fs::File::create(dir.join(format!("{}.pad", chksum))).unwrap();
file.write_all(&pad_data).unwrap();
crate::otp_pad::bind_global(dir.to_str().unwrap(), &chksum, false).unwrap();
(dir, chksum)
}
#[test]
fn test_otp_encrypt_decrypt_roundtrip() {
let _guard = OTP_GLOBAL_LOCK.lock().unwrap();
let (dir, chksum) = bind_test_pad("roundtrip");
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
// encrypt (plaintext is base64)
let pt_b64 = base64::engine::general_purpose::STANDARD.encode(b"hello otp world");
let enc_req = json!({
"id": "o1",
"method": "encrypt",
"params": [pt_b64, {"algorithm": "otp", "encoding": "ascii"}]
})
.to_string();
let resp = handle_request(&mut ctx, &enc_req);
assert!(resp.contains("\"result\""), "encrypt: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let enc: Value = serde_json::from_str(resp_json["result"].as_str().unwrap()).unwrap();
assert_eq!(enc["encoding"], "ascii");
assert_eq!(enc["pad_chksum"], chksum.as_str());
// Fresh pads start after the 32 reserved checksum-key bytes; a
// 15-byte msg consumes one 256-byte chunk.
assert_eq!(enc["pad_offset_before"], 32);
assert_eq!(enc["pad_offset_after"], 32 + 256);
let armor = enc["ciphertext"].as_str().unwrap();
assert!(armor.starts_with("-----BEGIN OTP MESSAGE-----"));
// decrypt (auto-detect: armor has no magic bytes)
let dec_req = json!({
"id": "o2",
"method": "decrypt",
"params": [armor, {"algorithm": "otp"}]
})
.to_string();
let resp = handle_request(&mut ctx, &dec_req);
assert!(resp.contains("\"result\""), "decrypt: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let dec: Value = serde_json::from_str(resp_json["result"].as_str().unwrap()).unwrap();
assert_eq!(dec["pad_chksum"], chksum.as_str());
let pt = base64::engine::general_purpose::STANDARD
.decode(dec["plaintext"].as_str().unwrap())
.unwrap();
assert_eq!(pt, b"hello otp world");
crate::otp_pad::unbind_global();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_otp_binary_encoding_roundtrip() {
let _guard = OTP_GLOBAL_LOCK.lock().unwrap();
let (dir, _chksum) = bind_test_pad("binary");
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let pt_b64 = base64::engine::general_purpose::STANDARD.encode(b"binary mode");
let enc_req = json!({
"id": "b1",
"method": "encrypt",
"params": [pt_b64, {"algorithm": "otp", "encoding": "binary"}]
})
.to_string();
let resp = handle_request(&mut ctx, &enc_req);
assert!(resp.contains("\"result\""), "encrypt: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let enc: Value = serde_json::from_str(resp_json["result"].as_str().unwrap()).unwrap();
assert_eq!(enc["encoding"], "binary");
let ct_b64 = enc["ciphertext"].as_str().unwrap();
// The binary ciphertext is a base64 .otp blob with the magic header.
let blob = base64::engine::general_purpose::STANDARD.decode(ct_b64).unwrap();
assert_eq!(&blob[..4], b"OTP\x00");
// decrypt with explicit binary encoding
let dec_req = json!({
"id": "b2",
"method": "decrypt",
"params": [ct_b64, {"algorithm": "otp", "encoding": "binary"}]
})
.to_string();
let resp = handle_request(&mut ctx, &dec_req);
assert!(resp.contains("\"result\""), "decrypt: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let dec: Value = serde_json::from_str(resp_json["result"].as_str().unwrap()).unwrap();
let pt = base64::engine::general_purpose::STANDARD
.decode(dec["plaintext"].as_str().unwrap())
.unwrap();
assert_eq!(pt, b"binary mode");
crate::otp_pad::unbind_global();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_otp_not_bound() {
let _guard = OTP_GLOBAL_LOCK.lock().unwrap();
crate::otp_pad::unbind_global();
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = json!({
"id": "n1",
"method": "encrypt",
"params": ["aGVsbG8=", {"algorithm": "otp"}]
})
.to_string();
let resp = handle_request(&mut ctx, &req);
assert!(resp.contains("otp_pad_not_bound"), "resp: {}", resp);
}
#[test]
fn test_otp_invalid_plaintext_base64() {
let _guard = OTP_GLOBAL_LOCK.lock().unwrap();
let (dir, _) = bind_test_pad("badb64");
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = json!({
"id": "n2",
"method": "encrypt",
"params": ["!!!not base64!!!", {"algorithm": "otp"}]
})
.to_string();
let resp = handle_request(&mut ctx, &req);
assert!(resp.contains("invalid_plaintext_base64"), "resp: {}", resp);
crate::otp_pad::unbind_global();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_otp_non_otp_algorithm_rejected() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
// encrypt with a non-otp algorithm → ALGORITHM_NOT_SUPPORTED (1010).
let req = json!({
"id": "n3",
"method": "encrypt",
"params": ["aGVsbG8=", {"algorithm": "secp256k1", "index": 0}]
})
.to_string();
let resp = handle_request(&mut ctx, &req);
assert!(resp.contains("algorithm_not_supported_for_verb"), "resp: {}", resp);
}
#[test]
fn test_otp_split_and_grant_import_roundtrip() {
let _guard = OTP_GLOBAL_LOCK.lock().unwrap();
let (dir, chksum) = bind_test_pad("split");
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
// split (no params needed beyond the options object)
let req = json!({
"id": "s1",
"method": "split",
"params": [{"algorithm": "otp"}]
})
.to_string();
let resp = handle_request(&mut ctx, &req);
assert!(resp.contains("\"result\""), "split: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let out: Value = serde_json::from_str(resp_json["result"].as_str().unwrap()).unwrap();
assert_eq!(out["pad_chksum"], chksum.as_str());
let grant_start = out["grant_start"].as_u64().unwrap();
let grant_end = out["grant_end"].as_u64().unwrap();
let end_offset = out["end_offset"].as_u64().unwrap();
// Midpoint of [0..4096) = 2048; grant starts at 2048+256.
assert_eq!(grant_start, 2304);
assert_eq!(grant_end, 4096);
assert_eq!(end_offset, 2048);
// The grant armor must decrypt (via the same pad) into a valid
// OTP-GRANT-V1 message naming this pad.
let armor = out["grant"].as_str().unwrap();
assert!(armor.starts_with("-----BEGIN OTP MESSAGE-----"));
let dec_req = json!({
"id": "s2",
"method": "decrypt",
"params": [armor, {"algorithm": "otp"}]
})
.to_string();
let resp = handle_request(&mut ctx, &dec_req);
assert!(resp.contains("\"result\""), "grant decrypt: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let dec: Value = serde_json::from_str(resp_json["result"].as_str().unwrap()).unwrap();
let plain = base64::engine::general_purpose::STANDARD
.decode(dec["plaintext"].as_str().unwrap())
.unwrap();
let (c, s, e) = crate::otp_pad::parse_grant_message(&plain).unwrap();
assert_eq!(c, chksum);
assert_eq!((s, e), (grant_start, grant_end));
crate::otp_pad::unbind_global();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_otp_split_not_bound() {
let _guard = OTP_GLOBAL_LOCK.lock().unwrap();
crate::otp_pad::unbind_global();
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = json!({
"id": "s3",
"method": "split",
"params": [{"algorithm": "otp"}]
})
.to_string();
let resp = handle_request(&mut ctx, &req);
assert!(resp.contains("otp_pad_not_bound"), "resp: {}", resp);
}
}