From e3c7f373d35abb0a4321fc648e5b5dff25d032eb Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 19 Jul 2026 21:44:51 +0000 Subject: [PATCH] Add optional per-origin Ed25519 signing for gossip (Tier C federation) --- Cargo.lock | 125 +++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/config.rs | 12 ++++ src/engine/db/mod.rs | 44 +++++++++++++-- src/engine/db/sign.rs | 110 ++++++++++++++++++++++++++++++++++++ src/engine/db/tests.rs | 48 +++++++++++++++- src/main.rs | 21 +++++++ 7 files changed, 353 insertions(+), 8 deletions(-) create mode 100644 src/engine/db/sign.rs diff --git a/Cargo.lock b/Cargo.lock index a94204e..e239780 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,6 +177,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.0" @@ -225,6 +231,12 @@ dependencies = [ "cc", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -244,6 +256,43 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -299,6 +348,7 @@ dependencies = [ "echo-operserv", "echo-reportserv", "echo-statserv", + "ed25519-dalek", "hmac", "pbkdf2", "prost", @@ -452,6 +502,31 @@ dependencies = [ "echo-api", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -480,6 +555,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1029,6 +1110,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -1276,6 +1367,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1347,6 +1447,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1458,6 +1564,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + [[package]] name = "slab" version = "0.4.12" @@ -1490,6 +1605,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 97e0549..7878f27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ base64 = "0.22" sha2 = "0.10" hmac = "0.12" pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } +ed25519-dalek = { version = "2", features = ["rand_core"] } subtle = "2" regex = "1" anyhow = "1" diff --git a/src/config.rs b/src/config.rs index a306197..5c83b7f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -325,6 +325,18 @@ pub struct Gossip { // Mutual-TLS for the peer link. Absent = plaintext. #[serde(default)] pub tls: Option, + // Tier C per-origin signing (see docs/federation.md). Absent = flat trust. + #[serde(default)] + pub signing: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Signing { + // This node's base64 Ed25519 secret key (from `echo --gen-gossip-key`). + pub key: String, + // origin SID -> its base64 Ed25519 public key. Include your own SID plus each peer's. + #[serde(default)] + pub trust: std::collections::HashMap, } #[derive(Debug, Deserialize, Clone)] diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 94e106a..341aa7e 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -31,6 +31,7 @@ mod account; mod channel; mod event; mod network; +pub mod sign; mod store; pub use event::Event; @@ -747,6 +748,12 @@ pub struct LogEntry { seq: u64, #[serde(default)] lamport: u64, + // Optional Ed25519 signature (base64) over the entry, for Tier C federation. When + // signing is off it's None and `skip_serializing_if` omits it entirely, so the log + // format is byte-identical to an unsigned deployment. Must precede the flattened + // event so serde extracts it as a named field before the event captures the rest. + #[serde(default, skip_serializing_if = "Option::is_none")] + sig: Option, #[serde(flatten)] event: Event, } @@ -754,7 +761,7 @@ pub struct LogEntry { #[cfg(test)] impl LogEntry { pub(crate) fn for_test(origin: &str, seq: u64, lamport: u64, event: Event) -> Self { - LogEntry { origin: origin.to_string(), seq, lamport, event } + LogEntry { origin: origin.to_string(), seq, lamport, sig: None, event } } } @@ -796,11 +803,14 @@ pub struct EventLog { // event (~20% of the append cost on real disk). Reset to None after compact() // replaces the file, since the old handle would point at a discarded inode. file: Option, + // Tier C federation: when set, Global entries this node authors are signed and + // ingested Global entries must carry a signature from a trusted origin key. + signing: Option, } impl EventLog { fn open(path: PathBuf, origin: String) -> (Self, Vec) { - let mut log = Self { path, origin, lamport: 0, versions: HashMap::new(), entries: Vec::new(), outbound: None, readonly: false, file: None }; + let mut log = Self { path, origin, lamport: 0, versions: HashMap::new(), entries: Vec::new(), outbound: None, readonly: false, file: None, signing: None }; if let Ok(data) = std::fs::read_to_string(&log.path) { for line in data.lines().filter(|l| !l.trim().is_empty()) { match serde_json::from_str::(line) { @@ -842,9 +852,11 @@ impl EventLog { let global = event.scope() == Scope::Global; let entry = if global { self.lamport = self.lamport.saturating_add(1); - LogEntry { origin: self.origin.clone(), seq: self.next_seq(), lamport: self.lamport, event } + let seq = self.next_seq(); + let sig = self.signing.as_ref().map(|s| s.sign(&self.origin, seq, self.lamport, &event)); + LogEntry { origin: self.origin.clone(), seq, lamport: self.lamport, sig, event } } else { - LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, event } + LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, sig: None, event } }; if let Err(e) = self.persist(&entry) { // Surface the real cause (disk full, permissions, ...) here at the @@ -874,6 +886,14 @@ impl EventLog { if entry.event.scope() != Scope::Global { return Ok(None); } + // Tier C: when signing is configured, a Global entry must carry a valid + // signature from a trusted origin key, or it's rejected before it can touch + // the version vector. Inert when signing is off (flat-trust / Tier B). + if let Some(s) = &self.signing { + if !s.verify(&entry.origin, entry.seq, entry.lamport, &entry.event, entry.sig.as_deref()) { + return Ok(None); + } + } // Apply strictly in per-origin sequence. The FIRST entry ever seen from an // origin sets the baseline — a peer syncing from a COMPACTED node starts // mid-stream (its seqs restart at next_seq(), not 0), so we can't demand 0. @@ -910,6 +930,10 @@ impl EventLog { self.outbound = Some(tx); } + fn set_signing(&mut self, signing: sign::Signing) { + self.signing = Some(signing); + } + // The events appended at or after `mark`, cloned. Used by the audit feed to // see exactly what a just-run command changed (the log only ever grows during // a command, so `[mark..]` is that command's footprint). @@ -973,12 +997,14 @@ impl EventLog { for event in events { let entry = if event.scope() == Scope::Global { self.lamport = self.lamport.saturating_add(1); - let e = LogEntry { origin: self.origin.clone(), seq, lamport: self.lamport, event }; + // Compaction re-seqs entries, so any signature must be recomputed. + let sig = self.signing.as_ref().map(|s| s.sign(&self.origin, seq, self.lamport, &event)); + let e = LogEntry { origin: self.origin.clone(), seq, lamport: self.lamport, sig, event }; last_global = Some(seq); seq += 1; e } else { - LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, event } + LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, sig: None, event } }; snapshot.push(entry); } @@ -1409,6 +1435,12 @@ impl Db { self.log.set_outbound(tx); } + /// Enable Tier C gossip signing: sign Global entries we author, and verify + /// ingested Global entries against the trusted per-origin keys. + pub fn set_gossip_signing(&mut self, signing: sign::Signing) { + self.log.set_signing(signing); + } + /// The number of entries in the log, a mark to later diff against. pub fn log_len(&self) -> usize { self.log.len() diff --git a/src/engine/db/sign.rs b/src/engine/db/sign.rs new file mode 100644 index 0000000..7085aad --- /dev/null +++ b/src/engine/db/sign.rs @@ -0,0 +1,110 @@ +// Per-origin Ed25519 signatures for gossiped (Global) log entries — the Tier C +// federation trust model (see docs/federation.md). Each node signs the entries it +// authors; peers verify against the author origin's public key, so a peer can only +// assert records for an origin whose private key it holds. Entirely optional: with +// no `[gossip.signing]` configured, entries are unsigned and this is inert. + +use std::collections::HashMap; + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine as _; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; + +use super::Event; + +// The bytes a signature covers: everything that fixes the entry's identity and +// content. `serde_json` is deterministic for the Global event set (all `Vec`/scalar +// fields — no unordered maps), so sender and receiver derive the same bytes. +fn payload(origin: &str, seq: u64, lamport: u64, event: &Event) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(origin.as_bytes()); + v.push(0); + v.extend_from_slice(&seq.to_le_bytes()); + v.extend_from_slice(&lamport.to_le_bytes()); + v.push(0); + v.extend_from_slice(serde_json::to_string(event).unwrap_or_default().as_bytes()); + v +} + +// A node's signing configuration: its own key (to sign what it authors) plus the +// public keys it trusts, keyed by origin SID (to verify what it ingests). +pub struct Signing { + signer: SigningKey, + trust: HashMap, +} + +impl Signing { + // Build from base64 config: this node's 32-byte secret key, and a map of + // origin -> its 32-byte public key. Errors describe the offending field. + pub fn new(secret_b64: &str, trust_b64: &HashMap) -> Result { + let secret = B64.decode(secret_b64.trim()).map_err(|_| "gossip.signing.key is not valid base64".to_string())?; + let bytes: [u8; 32] = secret.as_slice().try_into().map_err(|_| "gossip.signing.key must be 32 bytes".to_string())?; + let signer = SigningKey::from_bytes(&bytes); + let mut trust = HashMap::new(); + for (origin, pk_b64) in trust_b64 { + let raw = B64.decode(pk_b64.trim()).map_err(|_| format!("gossip.signing.trust key for {origin} is not valid base64"))?; + let arr: [u8; 32] = raw.as_slice().try_into().map_err(|_| format!("gossip.signing.trust key for {origin} must be 32 bytes"))?; + let vk = VerifyingKey::from_bytes(&arr).map_err(|_| format!("gossip.signing.trust key for {origin} is not a valid ed25519 public key"))?; + trust.insert(origin.clone(), vk); + } + Ok(Signing { signer, trust }) + } + + // Sign an entry we're authoring; base64 of the 64-byte signature. + pub fn sign(&self, origin: &str, seq: u64, lamport: u64, event: &Event) -> String { + B64.encode(self.signer.sign(&payload(origin, seq, lamport, event)).to_bytes()) + } + + // True if `sig` is a valid signature over the entry by the trusted key for its + // origin. False if the origin isn't trusted, the signature is missing, or it + // doesn't verify (`verify_strict` also rejects malleable/degenerate signatures). + pub fn verify(&self, origin: &str, seq: u64, lamport: u64, event: &Event, sig: Option<&str>) -> bool { + let Some(vk) = self.trust.get(origin) else { return false }; + let Some(sig) = sig else { return false }; + let Ok(raw) = B64.decode(sig.trim()) else { return false }; + let Ok(bytes) = <[u8; 64]>::try_from(raw.as_slice()) else { return false }; + vk.verify_strict(&payload(origin, seq, lamport, event), &Signature::from_bytes(&bytes)).is_ok() + } +} + +// A fresh keypair as (secret_b64, public_b64), for the `--gen-gossip-key` CLI. +pub fn generate() -> (String, String) { + let signer = SigningKey::generate(&mut rand_core::OsRng); + (B64.encode(signer.to_bytes()), B64.encode(signer.verifying_key().to_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::db::Event; + + fn ev() -> Event { + Event::AccountDropped { account: "alice".into() } + } + + #[test] + fn sign_then_verify_roundtrips_and_rejects_tampering() { + let (sec, pubk) = generate(); + let trust = HashMap::from([("A".to_string(), pubk)]); + let s = Signing::new(&sec, &trust).unwrap(); + + let sig = s.sign("A", 3, 4, &ev()); + assert!(s.verify("A", 3, 4, &ev(), Some(&sig)), "a genuine signature verifies"); + // Any change to the covered fields invalidates it. + assert!(!s.verify("A", 4, 4, &ev(), Some(&sig)), "a different seq is rejected"); + assert!(!s.verify("A", 3, 4, &Event::AccountDropped { account: "bob".into() }, Some(&sig)), "a different event is rejected"); + assert!(!s.verify("A", 3, 4, &ev(), None), "a missing signature is rejected"); + assert!(!s.verify("B", 3, 4, &ev(), Some(&sig)), "an untrusted origin is rejected"); + } + + #[test] + fn a_forged_key_does_not_verify() { + let (sec_a, pub_a) = generate(); + let (sec_b, _pub_b) = generate(); + // We trust A's key; B tries to forge an entry claiming origin A. + let s = Signing::new(&sec_a, &HashMap::from([("A".to_string(), pub_a)])).unwrap(); + let forger = Signing::new(&sec_b, &HashMap::new()).unwrap(); + let forged = forger.sign("A", 1, 1, &ev()); + assert!(!s.verify("A", 1, 1, &ev(), Some(&forged)), "a signature from the wrong key is rejected"); + } +} diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index b7d0262..f47f3ec 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -424,7 +424,7 @@ #[test] fn ingest_is_idempotent() { let (mut log, _) = EventLog::open(tmp("ingest"), "local".into()); - let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 5, event: cert("x", "f") }; + let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 5, sig: None, event: cert("x", "f") }; assert!(log.ingest(entry.clone()).unwrap().is_some(), "first ingest applies"); assert_eq!(log.versions.get("peer"), Some(&0)); assert_eq!(log.lamport, 6, "receive rule: max(local, remote) + 1"); @@ -441,7 +441,7 @@ name: "bob".into(), email: None, ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None, }; - let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, event: Event::AccountRegistered(Box::new(bob)) }; + let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, sig: None, event: Event::AccountRegistered(Box::new(bob)) }; db.ingest(entry).unwrap(); assert!(db.exists("bob"), "peer's account is present locally"); assert!(db.exists("alice"), "local account still there"); @@ -582,6 +582,50 @@ assert_eq!(b.certfps("alice"), &[fp][..], "certs arrive folded into the snapshot"); } + // Tier C: a node accepts a signed entry from a trusted origin, and rejects one + // from an origin it doesn't trust — even if that origin signed it validly. + #[test] + fn gossip_signing_accepts_trusted_and_rejects_untrusted() { + use super::sign::{self, Signing}; + use std::collections::HashMap; + let (a_sec, a_pub) = sign::generate(); + let (b_sec, _) = sign::generate(); + let (c_sec, _) = sign::generate(); + + let mut a = Db::open(tmp("signA"), "AAA"); + a.scram_iterations = 4096; + a.set_gossip_signing(Signing::new(&a_sec, &HashMap::new()).unwrap()); + a.register("alice", "pw", None).unwrap(); + + // B trusts A's public key (and its own, though unused here). + let mut b = Db::open(tmp("signB"), "BBB"); + b.scram_iterations = 4096; + b.set_gossip_signing(Signing::new(&b_sec, &HashMap::from([("AAA".to_string(), a_pub)])).unwrap()); + for entry in a.missing_for(&b.version_vector()) { + b.ingest(entry).unwrap(); + } + assert!(b.exists("alice"), "a signed entry from a trusted origin is accepted"); + + // C signs validly with its own key, but B doesn't trust origin CCC. + let mut c = Db::open(tmp("signC"), "CCC"); + c.scram_iterations = 4096; + c.set_gossip_signing(Signing::new(&c_sec, &HashMap::new()).unwrap()); + c.register("mallory", "pw", None).unwrap(); + for entry in c.missing_for(&b.version_vector()) { + b.ingest(entry).unwrap(); + } + assert!(!b.exists("mallory"), "an entry from an untrusted origin is rejected"); + } + + // With signing off, the serialized log carries no `sig` field — byte-identical + // to a pre-signing deployment (the compatibility guarantee). + #[test] + fn unsigned_entries_omit_the_sig_field() { + let entry = LogEntry::for_test("A", 0, 1, Event::AccountDropped { account: "x".into() }); + let json = serde_json::to_string(&entry).unwrap(); + assert!(!json.contains("\"sig\""), "no sig field when unsigned: {json}"); + } + // Channels register (case-insensitively unique), persist, and drop. #[test] fn channels_register_drop_and_persist() { diff --git a/src/main.rs b/src/main.rs index 9ef0ab6..b1dce74 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,6 +75,15 @@ async fn main() -> Result<()> { return Ok(()); } + // `echo --gen-gossip-key` prints a fresh Ed25519 keypair for Tier C federation. + if matches!(argv.get(1).map(String::as_str), Some("--gen-gossip-key") | Some("gen-gossip-key")) { + let (secret, public) = engine::db::sign::generate(); + println!("# gossip signing keypair — keep `key` secret; publish `public` to your peers"); + println!("key = \"{secret}\""); + println!("public = \"{public}\""); + return Ok(()); + } + let path = std::env::args().nth(1).unwrap_or_else(|| "config.toml".to_string()); let cfg = config::Config::load(&path)?; let ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); @@ -185,6 +194,18 @@ async fn main() -> Result<()> { let mut db = engine::db::Db::open("echo.db.jsonl", &cfg.server.sid); db.scram_iterations = cfg.server.scram_iterations; db.set_outbound(gossip_tx.clone()); + if let Some(sig) = cfg.gossip.as_ref().and_then(|g| g.signing.as_ref()) { + match engine::db::sign::Signing::new(&sig.key, &sig.trust) { + Ok(s) => { + db.set_gossip_signing(s); + tracing::info!(trusted = sig.trust.len(), "gossip signing enabled (Tier C per-origin signatures)"); + } + Err(e) => { + tracing::error!(%e, "invalid [gossip.signing] config"); + std::process::exit(1); + } + } + } db.set_email_enabled(cfg.email.is_some()); db.set_external_accounts(cfg.auth.as_ref().is_some_and(|a| a.external)); db.set_confusable_check(cfg.register.confusable_check);