diff --git a/AGENTS.md b/AGENTS.md index 6160b8a..1a2e2d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,10 @@ setup for other apps. ## Safety +The CLI refuses input that looks like a private key or recovery phrase (exit +2, `BAD_INPUT`) without sending it anywhere. Never pass one to it, or to any +website or API. + `tx` replies can carry `warnings`. A `lookalike_sender` warning means a transaction likely comes from an address made to look like one the receiver really uses (address poisoning). Surface it to the user, and never suggest diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f4871..b48278b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ names, environment variables, exit codes, error codes, and the shape of ## [Unreleased] +## [0.7.0] - 2026-10-02 + +### Added + +- Lookups (`find`, `address`, `tx`, `block`, `labels`, `chart`) refuse input + that looks like a private key or recovery phrase, with exit code 2, before + anything is sent. + +### Changed + +- `supply` gives the yearly issuance and inflation at the past year's actual + block pace instead of assuming a block every 60 seconds. + ## [0.6.1] - 2026-10-01 ### Changed @@ -139,7 +152,8 @@ names, environment variables, exit codes, error codes, and the shape of - Installers: Homebrew (`blockio/tap/dogechain`), npm (`dogechain`), shell script, PowerShell, and prebuilt binaries for macOS, Linux and Windows. -[Unreleased]: https://github.com/BlockIo/dogechain-cli/compare/v0.6.1...HEAD +[Unreleased]: https://github.com/BlockIo/dogechain-cli/compare/v0.7.0...HEAD +[0.7.0]: https://github.com/BlockIo/dogechain-cli/compare/v0.6.1...v0.7.0 [0.6.1]: https://github.com/BlockIo/dogechain-cli/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/BlockIo/dogechain-cli/compare/v0.5.2...v0.6.0 [0.5.2]: https://github.com/BlockIo/dogechain-cli/compare/v0.5.1...v0.5.2 diff --git a/Cargo.lock b/Cargo.lock index 64f6832..4be0fcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,7 +342,7 @@ dependencies = [ [[package]] name = "dogechain-cli" -version = "0.6.1" +version = "0.7.0" dependencies = [ "assert_cmd", "clap", diff --git a/Cargo.toml b/Cargo.toml index ab1284d..0b59aa4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dogechain-cli" -version = "0.6.1" +version = "0.7.0" edition = "2024" rust-version = "1.88" description = "Command-line interface for Dogechain.com" diff --git a/src/commands.rs b/src/commands.rs index c0ac170..42ab57a 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -16,6 +16,7 @@ use crate::cli::{Cli, Command, McpCommand, SkillCommand, SkillLocation, WatchWha use crate::error::{CliError, Result}; use crate::mcp; use crate::model::*; +use crate::secret; use crate::skill::{self, Scope}; use crate::time::{ago, now, utc, utc_date, utc_minute}; @@ -24,6 +25,10 @@ const ADDRESS_PAGE_SIZE: u64 = 10; const RICHLIST_PAGE_SIZE: u64 = 100; pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> { + // Nothing that looks like a private key or recovery phrase is ever sent. + for input in lookup_inputs(&cli.command) { + secret::refuse_secrets(input)?; + } let json = cli.json; let api = || Api::new(Duration::from_secs(cli.timeout)); match cli.command { @@ -151,6 +156,19 @@ pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> { } } +/// The user-supplied identifiers a command sends to the API. +fn lookup_inputs(command: &Command) -> Vec<&str> { + match command { + Command::Find { query } => vec![query], + Command::Block { id } => vec![id], + Command::Tx { txid, .. } => vec![txid], + Command::Address { address, .. } => vec![address], + Command::Labels { addresses } => addresses.iter().map(String::as_str).collect(), + Command::Chart { series, .. } => vec![series], + _ => vec![], + } +} + fn scope(location: SkillLocation) -> Scope { match (location.project, location.dir) { (_, Some(dir)) => Scope::Dir(dir), @@ -508,13 +526,25 @@ fn show_supply(out: &mut dyn Write, r: &SupplyReply) -> Result<()> { r.supply.display(0), group_thousands(r.height.into()) )?; - writeln!( - out, - "Each block adds {} DOGE, about {} DOGE a year: {:.2}% inflation over the next 12 months.", - r.per_block.display(0), - r.per_year.display(0), - r.inflation_next_12_months * 100.0 - )?; + match &r.recent_pace { + // The realistic figure: the past year's actual block times. + Some(pace) => writeln!( + out, + "Each block adds {} DOGE. At the past year's pace (a block every {:.1} seconds), \ + that is about {} DOGE a year: {:.2}% inflation over the next 12 months.", + r.per_block.display(0), + pace.block_seconds, + pace.per_year.display(0), + pace.inflation_next_12_months * 100.0 + )?, + None => writeln!( + out, + "Each block adds {} DOGE, about {} DOGE a year: {:.2}% inflation over the next 12 months.", + r.per_block.display(0), + r.per_year.display(0), + r.inflation_next_12_months * 100.0 + )?, + } Ok(()) } diff --git a/src/main.rs b/src/main.rs index 04338ec..f54cb1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod mcp; mod model; mod sanitize; mod schema; +mod secret; mod skill; mod time; diff --git a/src/model.rs b/src/model.rs index 274514e..19b33da 100644 --- a/src/model.rs +++ b/src/model.rs @@ -253,6 +253,17 @@ pub struct SupplyReply { pub supply: Doge, pub height: u64, pub per_block: Doge, + /// Assumes a block every 60 seconds. + pub per_year: Doge, + pub inflation_next_12_months: f64, + /// The same figures at the past 365 days' actual block pace. + #[serde(default)] + pub recent_pace: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RecentPace { + pub block_seconds: f64, pub per_year: Doge, pub inflation_next_12_months: f64, } diff --git a/src/schema.rs b/src/schema.rs index b8aa3cb..e794a1f 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -49,7 +49,7 @@ pub fn schema() -> Value { "does": "resolve a block height or hash, transaction id or address", "api": "GET /api/v3/find?q=", "data": { "kind": "block | transaction | address", "value": "string or number" }, - "notes": "no match exits 3; text mode then shows the matching block, transaction or address" + "notes": "no match exits 3 (data.kind none, without value); text mode shows the matching block, transaction or address. Input that looks like a private key or recovery phrase is refused locally with exit 2 and never sent; this applies to every lookup command" }, "block ": { "api": "GET /api/v3/block/", @@ -89,7 +89,7 @@ pub fn schema() -> Value { }, "supply": { "api": "GET /api/v3/supply", - "data": "supply, height, per_block, per_year, inflation_next_12_months (fraction)" + "data": "supply, height, per_block, per_year and inflation_next_12_months (fraction; both assume a block every 60 s), recent_pace{block_seconds, per_year, inflation_next_12_months} (at the past 365 days' actual pace; text output uses this)" }, "richlist [--page N]": { "aliases": ["top"], diff --git a/src/secret.rs b/src/secret.rs new file mode 100644 index 0000000..877f329 --- /dev/null +++ b/src/secret.rs @@ -0,0 +1,93 @@ +//! Refuses input that looks like a private key or recovery phrase, before it +//! leaves the machine. Lookups only ever need public data (block heights and +//! hashes, transaction ids, addresses), so a secret is never sent anywhere. +//! +//! A bare 64-character hex string is not caught here: it is also what a +//! transaction id or block hash looks like. Dogechain.com rejects key-shaped +//! input of that kind itself. + +use crate::error::{CliError, Result}; + +pub const WARNING: &str = "that looks like a private key or recovery phrase, so it wasn't \ + looked up; never send one to any website or API"; + +const BASE58: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +/// Fails with a bad-input error (exit 2) if `input` looks like a secret. +pub fn refuse_secrets(input: &str) -> Result<()> { + if looks_secret(input) { + Err(CliError::BadInput(WARNING.into())) + } else { + Ok(()) + } +} + +pub fn looks_secret(input: &str) -> bool { + let s = input.trim(); + is_wif(s) || is_extended_private_key(s) || is_recovery_phrase(s) +} + +fn is_base58(s: &str) -> bool { + !s.is_empty() && s.chars().all(|c| BASE58.contains(c)) +} + +/// Wallet import format: 51 (uncompressed) or 52 (compressed) base58 +/// characters. Addresses are 33–34, so the lengths never overlap. +/// Prefixes: Dogecoin 6/Q, Bitcoin 5/K/L, testnets 9/c. +fn is_wif(s: &str) -> bool { + matches!(s.len(), 51 | 52) && is_base58(s) && s.starts_with(['6', 'Q', '5', 'K', 'L', '9', 'c']) +} + +/// BIP32 extended private keys (Dogecoin dgpv, Bitcoin xprv/yprv/zprv and +/// testnet tprv/uprv/vprv). +fn is_extended_private_key(s: &str) -> bool { + const PREFIXES: [&str; 7] = ["dgpv", "xprv", "yprv", "zprv", "tprv", "uprv", "vprv"]; + s.len() >= 100 && is_base58(s) && PREFIXES.iter().any(|p| s.starts_with(p)) +} + +/// 12 to 24 short lowercase words, the shape of a BIP39 recovery phrase. +fn is_recovery_phrase(s: &str) -> bool { + let words: Vec<&str> = s.split_whitespace().collect(); + (12..=24).contains(&words.len()) + && words + .iter() + .all(|w| (3..=8).contains(&w.len()) && w.bytes().all(|b| b.is_ascii_lowercase())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catches_keys_and_phrases() { + // Key-shaped sample strings (standard test vectors and made-up + // examples); none controls any funds. + assert!(looks_secret( + "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ" + )); + assert!(looks_secret( + "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn" + )); + assert!(looks_secret( + "6KbBFk8U7sxzfajBnm1JJWqLvdcpd97K9Sf5GPgP2z8A1nR4GZ7" + )); + assert!(looks_secret( + "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi" + )); + assert!(looks_secret( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + )); + } + + #[test] + fn lets_public_data_through() { + assert!(!looks_secret("DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L")); + assert!(!looks_secret("A8x177ySgB3BMr3LXYHWp7dLKNAjxo4hGJ")); + assert!(!looks_secret( + "2c916c8046224e19f865f288ebc9870fc75f93b5144595b7ec270b31e4863e7f" + )); + assert!(!looks_secret("6389673")); + assert!(!looks_secret("latest")); + assert!(!looks_secret("what is the dogecoin supply")); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index de32082..a0de26f 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1028,3 +1028,59 @@ fn shows_dollar_values_at_the_time() { "Price then $0.0939 per DOGE · reward worth about $938.77", )); } + +#[test] +fn never_sends_private_keys_or_recovery_phrases() { + let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let wif = "6KbBFk8U7sxzfajBnm1JJWqLvdcpd97K9Sf5GPgP2z8A1nR4GZ7"; + for args in [ + vec!["find", phrase], + vec!["address", wif], + vec!["labels", ADDR, wif], + ] { + let m = mock(vec![]); + dogechain(&m) + .args(&args) + .assert() + .code(2) + .stderr(predicate::str::contains( + "looks like a private key or recovery phrase", + )); + assert!( + m.paths.lock().unwrap().is_empty(), + "nothing may be sent: {args:?}" + ); + } + let m = mock(vec![]); + let out = dogechain(&m) + .args(["find", wif, "--json"]) + .output() + .unwrap(); + let v: Value = serde_json::from_slice(&out.stderr).unwrap(); + assert_eq!(v["data"]["code"], "BAD_INPUT"); +} + +#[test] +fn supply_uses_the_recent_pace() { + let supply = json!({ + "supply": "156155592865.81425251", "height": 6398309, "per_block": "10000.00000000", + "per_year": "5259600000.00000000", "inflation_next_12_months": 0.0337, + "recent_pace": { "block_seconds": 63.4, "per_year": "4977336801.00000000", + "inflation_next_12_months": 0.031874 } + }); + dogechain(&mock(vec![ok(supply)])) + .arg("supply") + .assert() + .success() + .stdout(predicate::str::contains( + "At the past year's pace (a block every 63.4 seconds), that is about 4,977,336,801 DOGE a year: 3.19% inflation", + )); +} + +#[test] +fn find_kind_none_without_value() { + dogechain(&mock(vec![ok(json!({ "kind": "none" }))])) + .args(["find", "0xabc"]) + .assert() + .code(3); +}