1647 lines
66 KiB
Rust
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);
|
|
}
|
|
}
|