Files
signer/src/role_table.rs
T

686 lines
24 KiB
Rust

//! Role table — binds role names to derivation path templates.
//!
//! Port of `role_table.c`. Each role entry maps a human-readable name
//! (acting as an access token) to a BIP-44 derivation path template.
//! The template may contain a `%d` placeholder for a variable index.
use crate::SignerError;
use std::collections::HashSet;
// ── Limits ───────────────────────────────────────────────────────────────────
/// Map role_curve + role_purpose to crypto_alg.
pub fn crypto_alg_from_role(curve: RoleCurve, purpose: RolePurpose) -> crate::pq_crypto::CryptoAlg {
use crate::pq_crypto::CryptoAlg;
match (curve, purpose) {
(RoleCurve::Secp256k1, RolePurpose::Nostr) => CryptoAlg::Secp256k1,
(RoleCurve::Ed25519, RolePurpose::Ssh) => CryptoAlg::Ed25519,
(RoleCurve::X25519, RolePurpose::Age) => CryptoAlg::X25519,
(RoleCurve::MlDsa65, RolePurpose::PqSig) => CryptoAlg::MlDsa65,
(RoleCurve::SlhDsa128s, RolePurpose::PqSig) => CryptoAlg::SlhDsa128s,
(RoleCurve::MlKem768, RolePurpose::PqKem) => CryptoAlg::MlKem768,
_ => CryptoAlg::Unknown,
}
}
pub const ROLE_NAME_MAX: usize = 64;
pub const ROLE_PATH_MAX: usize = 128;
pub const ROLE_PURPOSE_MAX: usize = 32;
pub const ROLE_CURVE_MAX: usize = 16;
pub const ROLE_TABLE_MAX_ENTRIES: usize = 256;
pub const PATH_ALLOWED_MAX: usize = 64;
// ── Enums ────────────────────────────────────────────────────────────────────
/// Purpose enum for fast comparison (string form kept for display).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RolePurpose {
Nostr,
Bitcoin,
Ssh,
Age,
Fips,
PqSig,
PqKem,
Unknown,
}
impl RolePurpose {
pub fn from_str(s: &str) -> Self {
match s {
"nostr" => Self::Nostr,
"bitcoin" => Self::Bitcoin,
"ssh" => Self::Ssh,
"age" => Self::Age,
"fips" => Self::Fips,
"pq_sig" => Self::PqSig,
"pq_kem" => Self::PqKem,
_ => Self::Unknown,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Nostr => "nostr",
Self::Bitcoin => "bitcoin",
Self::Ssh => "ssh",
Self::Age => "age",
Self::Fips => "fips",
Self::PqSig => "pq_sig",
Self::PqKem => "pq_kem",
Self::Unknown => "unknown",
}
}
}
/// Curve enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RoleCurve {
Secp256k1,
Ed25519,
X25519,
MlDsa65,
SlhDsa128s,
MlKem768,
Unknown,
}
impl RoleCurve {
pub fn from_str(s: &str) -> Self {
match s {
"secp256k1" => Self::Secp256k1,
"ed25519" => Self::Ed25519,
"x25519" => Self::X25519,
"ml-dsa-65" => Self::MlDsa65,
"slh-dsa-128s" => Self::SlhDsa128s,
"ml-kem-768" => Self::MlKem768,
_ => Self::Unknown,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Secp256k1 => "secp256k1",
Self::Ed25519 => "ed25519",
Self::X25519 => "x25519",
Self::MlDsa65 => "ml-dsa-65",
Self::SlhDsa128s => "slh-dsa-128s",
Self::MlKem768 => "ml-kem-768",
Self::Unknown => "unknown",
}
}
}
/// Selector type — how this role's key is addressed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoleSelectorType {
NostrIndex,
RolePath,
}
// ── Role Entry ───────────────────────────────────────────────────────────────
/// A single role entry.
#[derive(Debug, Clone)]
pub struct RoleEntry {
pub name: String,
pub purpose_str: String,
pub curve_str: String,
pub purpose: RolePurpose,
pub curve: RoleCurve,
pub selector_type: RoleSelectorType,
/// Valid if selector_type == NostrIndex.
pub nostr_index: i32,
/// Valid if selector_type == RolePath. May contain `%d` placeholder.
pub role_path: String,
/// Filled after derivation, empty until then.
pub pubkey_hex: String,
/// 1 if pubkey_hex has been populated.
pub derived: bool,
/// The concrete path the key was last derived for (variable-path roles).
pub derived_path: Option<String>,
/// Inclusive lower bound for %d; -1 = fixed path (no variable).
pub path_range_lo: i32,
/// Inclusive upper bound; == path_range_lo for single.
pub path_range_hi: i32,
/// Default index when client sends {"role":...} without "index"; -1 = require explicit.
pub path_default_index: i32,
/// Explicit set of allowed indices (for sets); empty = use range.
pub path_allowed_indices: Vec<i32>,
/// 0 = role-as-password (no prompt), 1 = require interactive approval.
pub requires_approval: bool,
}
impl Default for RoleEntry {
fn default() -> Self {
RoleEntry {
name: String::new(),
purpose_str: String::new(),
curve_str: String::new(),
purpose: RolePurpose::Unknown,
curve: RoleCurve::Unknown,
selector_type: RoleSelectorType::RolePath,
nostr_index: -1,
role_path: String::new(),
pubkey_hex: String::new(),
derived: false,
derived_path: None,
path_range_lo: -1,
path_range_hi: -1,
path_default_index: -1,
path_allowed_indices: Vec::new(),
requires_approval: false,
}
}
}
impl RoleEntry {
/// Check if the role path contains a `%d` variable placeholder.
pub fn has_variable_path(&self) -> bool {
self.selector_type == RoleSelectorType::RolePath && self.role_path.contains("%d")
}
/// Display the derivation path, replacing `%d` with the applicable range
/// or set description (matches the C `role_table_view_get_cell`).
///
/// - Fixed path (no `%d`): returned as-is.
/// - Set form: `1+34+54`.
/// - Single index: `N`.
/// - Range: `lo-hi`.
pub fn display_path(&self) -> String {
if self.selector_type == RoleSelectorType::NostrIndex {
return format!("m/44'/1237'/{}'/0/0", self.nostr_index);
}
if self.path_range_lo < 0 && self.path_allowed_indices.is_empty() {
// Fixed path (no %d placeholder)
return self.role_path.clone();
}
// Build the range/set description
let range_str = if !self.path_allowed_indices.is_empty() {
self.path_allowed_indices
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("+")
} else if self.path_range_lo == self.path_range_hi {
self.path_range_lo.to_string()
} else {
format!("{}-{}", self.path_range_lo, self.path_range_hi)
};
// Replace the first %d in role_path with range_str
if let Some(pct) = self.role_path.find("%d") {
let prefix = &self.role_path[..pct];
let tail = &self.role_path[pct + 2..];
format!("{}{}{}", prefix, range_str, tail)
} else {
self.role_path.clone()
}
}
/// Check if a concrete derivation path matches this role's path template.
///
/// The template may contain a `%d` placeholder (with optional `'` hardened marker).
/// Returns the extracted index if matched, or None.
pub fn path_matches_template(&self, concrete: &str) -> Option<i32> {
role_path_matches_template(&concrete, &self.role_path)
}
/// Check whether a concrete path matches the template AND the
/// extracted index is within the role's allowed range/set.
pub fn path_matches_with_range(&self, concrete: &str) -> bool {
// Fixed path (no %d) — direct string comparison
if !self.has_variable_path() {
return concrete == self.role_path;
}
let index = match self.path_matches_template(concrete) {
Some(i) => i,
None => return false,
};
if !self.path_allowed_indices.is_empty() {
// Set form: check if index is in the allowed set
self.path_allowed_indices.contains(&index)
} else if self.path_range_lo >= 0 {
// Range form: check lo..hi
index >= self.path_range_lo && index <= self.path_range_hi
} else {
// Wildcard with no range restriction
true
}
}
}
// ── Role Table ───────────────────────────────────────────────────────────────
/// The role table.
#[derive(Debug, Default)]
pub struct RoleTable {
pub entries: Vec<RoleEntry>,
}
impl RoleTable {
pub fn new() -> Self {
RoleTable::default()
}
/// Add a role entry. Returns error if table full or name duplicate.
pub fn add(&mut self, entry: RoleEntry) -> Result<(), SignerError> {
if self.entries.len() >= ROLE_TABLE_MAX_ENTRIES {
return Err(SignerError::Internal("role table full".into()));
}
if self.find_by_name(&entry.name).is_some() {
return Err(SignerError::Internal("duplicate role name".into()));
}
self.entries.push(entry);
Ok(())
}
/// Find a role by name.
pub fn find_by_name(&self, name: &str) -> Option<&RoleEntry> {
self.entries.iter().find(|e| e.name == name)
}
/// Find a role by name (mutable).
pub fn find_by_name_mut(&mut self, name: &str) -> Option<&mut RoleEntry> {
self.entries.iter_mut().find(|e| e.name == name)
}
/// Find a role by nostr_index.
pub fn find_by_nostr_index(&self, index: i32) -> Option<&RoleEntry> {
self.entries
.iter()
.find(|e| e.selector_type == RoleSelectorType::NostrIndex && e.nostr_index == index)
}
/// Get the default role (named "main").
pub fn get_default(&self) -> Option<&RoleEntry> {
self.find_by_name("main")
}
/// Number of entries.
pub fn count(&self) -> usize {
self.entries.len()
}
/// Register a nostr-index role if missing.
pub fn register_nostr_index(&mut self, nostr_index: i32) -> Result<(), SignerError> {
if self.find_by_nostr_index(nostr_index).is_some() {
return Ok(());
}
let mut entry = RoleEntry::default();
entry.name = if nostr_index == 0 {
"main".to_string()
} else {
format!("nostr_idx_{}", nostr_index)
};
entry.purpose = RolePurpose::Nostr;
entry.purpose_str = "nostr".to_string();
entry.curve = RoleCurve::Secp256k1;
entry.curve_str = "secp256k1".to_string();
entry.selector_type = RoleSelectorType::NostrIndex;
entry.nostr_index = nostr_index;
entry.role_path = format!("m/44'/1237'/{}'/0/0", nostr_index);
entry.requires_approval = false;
self.add(entry)
}
/// Register a RolePath role bound to an explicit derivation path template.
#[allow(clippy::too_many_arguments)]
pub fn register_role_path(
&mut self,
name: &str,
path: &str,
purpose: RolePurpose,
curve: RoleCurve,
range_lo: i32,
range_hi: i32,
default_index: i32,
allowed_indices: &[i32],
) -> Result<(), SignerError> {
let mut entry = RoleEntry::default();
entry.name = name.to_string();
entry.purpose = purpose;
entry.purpose_str = purpose.as_str().to_string();
entry.curve = curve;
entry.curve_str = curve.as_str().to_string();
entry.selector_type = RoleSelectorType::RolePath;
entry.role_path = path.to_string();
entry.nostr_index = -1;
entry.path_range_lo = range_lo;
entry.path_range_hi = range_hi;
entry.path_default_index = default_index;
entry.path_allowed_indices = allowed_indices.to_vec();
entry.requires_approval = false;
entry.derived = false;
self.add(entry)
}
}
// ── Path Template Matching ───────────────────────────────────────────────────
/// Check whether a concrete derivation path matches a role's path template.
///
/// The template may contain a `%d` placeholder (with optional `'` hardened marker).
/// Returns `Some(index)` if matched, or `None`.
pub fn role_path_matches_template(concrete: &str, template: &str) -> Option<i32> {
let template_segs: Vec<&str> = template.split('/').collect();
let concrete_segs: Vec<&str> = concrete.split('/').collect();
if template_segs.len() != concrete_segs.len() {
return None;
}
let mut extracted_index: Option<i32> = None;
for (tseg, cseg) in template_segs.iter().zip(concrete_segs.iter()) {
if *tseg == "%d" || *tseg == "%d'" {
// Variable segment — extract the index
let (num_part, hardened) = if let Some(stripped) = cseg
.strip_suffix('\'')
.or_else(|| cseg.strip_suffix('h'))
.or_else(|| cseg.strip_suffix('H'))
{
(stripped, true)
} else {
(*cseg, false)
};
// Template hardened marker must match
let template_hardened = tseg.ends_with('\'');
if template_hardened != hardened {
return None;
}
let val: i32 = num_part.parse().ok()?;
if val < 0 {
return None;
}
if extracted_index.is_some() {
// Only one %d per template
return None;
}
extracted_index = Some(val);
} else {
// Literal segment — must match exactly
if *tseg != *cseg {
return None;
}
}
}
extracted_index
}
/// Extract the numeric index from a concrete path matching a `%d` template.
/// Returns the index, or -1 if no `%d` or no match.
pub fn role_path_extract_index(concrete: &str, template: &str) -> i32 {
role_path_matches_template(concrete, template).unwrap_or(-1)
}
// ── Path Template Parser ─────────────────────────────────────────────────────
/// Parse a path template token (e.g. "m/44'/1237'/0-3/1/0" or
/// "m/44'/1237'/1+34+54/1/0") into a template with `%d` placeholder
/// and allowed indices.
///
/// On success:
/// - `template_out` — the path with `%d` replacing the numeric/range/set segment
/// - `range_lo`/`range_hi` — min/max of the allowed indices
/// - `allowed_indices` — explicit set (if set form was used); empty for pure range/single
/// - Returns `Ok(())` on success, `Err` on parse error
#[allow(clippy::too_many_arguments)]
pub fn parse_path_template(
token: &str,
) -> Result<(String, i32, i32, Vec<i32>), SignerError> {
let segs: Vec<&str> = token.split('/').collect();
if segs.is_empty() {
return Err(SignerError::InvalidInput);
}
let mut template_out = String::new();
let mut range_lo: i32 = 0;
let mut range_hi: i32 = 0;
let mut allowed_indices: Vec<i32> = Vec::new();
let mut found_variable = false;
for (i, seg) in segs.iter().enumerate() {
if i == 0 && (*seg == "m" || *seg == "M") {
template_out.push_str(seg);
template_out.push('/');
continue;
}
if !found_variable {
// Check for wildcard *
if *seg == "*" || *seg == "*'" || *seg == "*h" || *seg == "*H" {
let hardened = seg.contains('\'') || seg.contains('h') || seg.contains('H');
found_variable = true;
range_lo = 0;
range_hi = i32::MAX;
template_out.push_str("%d");
if hardened {
template_out.push('\'');
}
template_out.push('/');
continue;
}
// Check for range/set markers
let has_plus = seg.contains('+');
let has_dash = seg.contains('-');
let is_range_or_set = has_plus || has_dash;
// Strip hardened marker for range/set forms
let (seg_clean, seg_hardened) = if is_range_or_set {
if let Some(stripped) = seg
.strip_suffix('\'')
.or_else(|| seg.strip_suffix('h'))
.or_else(|| seg.strip_suffix('H'))
{
(stripped, true)
} else {
(*seg, false)
}
} else {
(*seg, false)
};
if has_plus {
// Set form: "1+34+54" or "1+3-5+10"
let mut set = HashSet::new();
for tok in seg_clean.split('+') {
if let Some(dash) = tok.find('-') {
let lo: i32 = tok[..dash].parse().map_err(|_| SignerError::InvalidInput)?;
let hi: i32 = tok[dash + 1..].parse().map_err(|_| SignerError::InvalidInput)?;
if lo < 0 || hi < 0 || lo > hi {
return Err(SignerError::InvalidInput);
}
for v in lo..=hi {
set.insert(v);
}
} else {
let val: i32 = tok.parse().map_err(|_| SignerError::InvalidInput)?;
if val < 0 {
return Err(SignerError::InvalidInput);
}
set.insert(val);
}
}
if set.is_empty() {
// Not a valid set — treat as literal
template_out.push_str(seg);
template_out.push('/');
} else {
found_variable = true;
allowed_indices = set.iter().copied().collect();
allowed_indices.sort();
range_lo = *allowed_indices.first().unwrap();
range_hi = *allowed_indices.last().unwrap();
template_out.push_str("%d");
if seg_hardened {
template_out.push('\'');
}
template_out.push('/');
}
} else if has_dash {
// Range form: "N-M"
let dash_pos = seg_clean.find('-').unwrap();
let lo: i32 = seg_clean[..dash_pos]
.parse()
.map_err(|_| SignerError::InvalidInput)?;
let hi: i32 = seg_clean[dash_pos + 1..]
.parse()
.map_err(|_| SignerError::InvalidInput)?;
if lo < 0 || hi < 0 || lo > hi {
// Not a valid numeric range — treat as literal
template_out.push_str(seg);
template_out.push('/');
} else {
found_variable = true;
range_lo = lo;
range_hi = hi;
template_out.push_str("%d");
if seg_hardened {
template_out.push('\'');
}
template_out.push('/');
}
} else {
// Single number or literal — always treat as a literal segment.
// The variable is only introduced via wildcard (*), range (N-M),
// or set (N+M) forms. A plain number like "0" is a fixed literal.
template_out.push_str(seg);
template_out.push('/');
}
} else {
// Literal segment after the variable
template_out.push_str(seg);
template_out.push('/');
}
}
// Remove trailing '/'
if template_out.ends_with('/') {
template_out.pop();
}
if !found_variable {
// Fixed path — no variable segment. Treat as a single fixed key.
range_lo = -1;
range_hi = -1;
}
Ok((template_out, range_lo, range_hi, allowed_indices))
}
/// Auto-detect purpose from a derivation path prefix.
/// m/44'/1237' → nostr, m/44'/102001' → ssh, etc.
pub fn purpose_from_path(path: &str) -> RolePurpose {
if path.starts_with("m/44'/1237'") {
RolePurpose::Nostr
} else if path.starts_with("m/44'/102001'") {
RolePurpose::Ssh
} else if path.starts_with("m/44'/102002'") {
RolePurpose::Age
} else if path.starts_with("m/44'/102003'") {
RolePurpose::PqSig
} else if path.starts_with("m/44'/102004'") {
RolePurpose::PqSig
} else if path.starts_with("m/44'/102005'") {
RolePurpose::PqKem
} else if path.starts_with("m/84'") || path.starts_with("m/86'") {
RolePurpose::Bitcoin
} else {
RolePurpose::Nostr // default
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_and_find() {
let mut table = RoleTable::new();
let mut entry = RoleEntry::default();
entry.name = "main".to_string();
entry.purpose = RolePurpose::Nostr;
entry.curve = RoleCurve::Secp256k1;
entry.selector_type = RoleSelectorType::RolePath;
entry.role_path = "m/44'/1237'/0'/0/0".to_string();
table.add(entry).unwrap();
assert!(table.find_by_name("main").is_some());
assert!(table.find_by_name("nonexistent").is_none());
}
#[test]
fn test_duplicate_rejected() {
let mut table = RoleTable::new();
let mut e1 = RoleEntry::default();
e1.name = "main".to_string();
table.add(e1).unwrap();
let mut e2 = RoleEntry::default();
e2.name = "main".to_string();
assert!(table.add(e2).is_err());
}
#[test]
fn test_path_template_matching() {
let template = "m/44'/1237'/%d'/0/0";
assert_eq!(role_path_matches_template("m/44'/1237'/5'/0/0", template), Some(5));
assert_eq!(role_path_matches_template("m/44'/1237'/0'/0/0", template), Some(0));
assert_eq!(role_path_matches_template("m/44'/1237'/5/1/0", template), None); // wrong segment
}
#[test]
fn test_parse_path_template_range() {
let (tmpl, lo, hi, allowed) = parse_path_template("m/44'/1237'/0-3/0/0").unwrap();
assert_eq!(tmpl, "m/44'/1237'/%d/0/0");
assert_eq!(lo, 0);
assert_eq!(hi, 3);
assert!(allowed.is_empty());
}
#[test]
fn test_parse_path_template_wildcard() {
let (tmpl, lo, hi, _) = parse_path_template("m/44'/1237'/*'/0/0").unwrap();
assert_eq!(tmpl, "m/44'/1237'/%d'/0/0");
assert_eq!(lo, 0);
assert_eq!(hi, i32::MAX);
}
#[test]
fn test_parse_path_template_set() {
let (tmpl, lo, hi, allowed) = parse_path_template("m/44'/1237'/1+34+54/0/0").unwrap();
assert_eq!(tmpl, "m/44'/1237'/%d/0/0");
assert_eq!(lo, 1);
assert_eq!(hi, 54);
assert_eq!(allowed.len(), 3);
assert!(allowed.contains(&1));
assert!(allowed.contains(&34));
assert!(allowed.contains(&54));
}
#[test]
fn test_parse_path_template_fixed() {
let (tmpl, lo, hi, _) = parse_path_template("m/44'/1237'/0'/0/0").unwrap();
assert_eq!(tmpl, "m/44'/1237'/0'/0/0");
assert_eq!(lo, -1); // fixed path
assert_eq!(hi, -1);
}
#[test]
fn test_purpose_from_path() {
assert_eq!(purpose_from_path("m/44'/1237'/0'/0/0"), RolePurpose::Nostr);
assert_eq!(purpose_from_path("m/44'/102001'/0'/0'/0'"), RolePurpose::Ssh);
assert_eq!(purpose_from_path("m/44'/102002'/0'/0'/0'"), RolePurpose::Age);
assert_eq!(purpose_from_path("m/44'/102003'/0'/0'/0'"), RolePurpose::PqSig);
assert_eq!(purpose_from_path("m/44'/102005'/0'/0'/0'"), RolePurpose::PqKem);
}
}