diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea721b..d670178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Overhauled PEM auth. PEM files are now password-protected by default, and must be used instead of seed files. Passwords can be provided interactively or with `--password-file`. Keys can be generated unencrypted with `quill generate --storage-mode plaintext`, and encrypted keys can be converted to plaintext with `quill decrypt-pem`. - Overhauled output format. All commands besides `quill sns` should have human-readable output instead of candid IDL. Candid IDL format can be forced with `--raw`. ## [0.4.4] - 2024-03-21 diff --git a/Cargo.lock b/Cargo.lock index 120493c..21e2a9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.7.8" @@ -309,12 +320,6 @@ dependencies = [ "rustc-demangle", ] -[[package]] -name = "base16ct" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" - [[package]] name = "base16ct" version = "0.2.0" @@ -413,15 +418,15 @@ dependencies = [ [[package]] name = "bip32" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b30ed1d6f8437a487a266c8293aeb95b61a23261273e3e02912cdb8b68bf798b" +checksum = "7e141fb0f8be1c7b45887af94c88b182472b57c96b56773250ae00cd6a14a164" dependencies = [ - "bs58", + "bs58 0.5.1", "hmac", - "k256 0.11.6", + "k256", "once_cell", - "pbkdf2", + "pbkdf2 0.12.2", "rand_core", "ripemd", "sha2 0.10.8", @@ -486,6 +491,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bls12_381" version = "0.7.1" @@ -529,8 +543,14 @@ name = "bs58" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "771fe0050b883fcc3ea2359b1a96bcfbc090b7116eae7c3c512c7a083fdf23d3" + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ - "sha2 0.9.9", + "sha2 0.10.8", ] [[package]] @@ -758,6 +778,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.0.98" @@ -818,6 +847,16 @@ dependencies = [ "half 2.4.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "3.2.25" @@ -1032,18 +1071,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" -[[package]] -name = "crypto-bigint" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" -dependencies = [ - "generic-array", - "rand_core", - "subtle", - "zeroize", -] - [[package]] name = "crypto-bigint" version = "0.5.5" @@ -1231,17 +1258,6 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" -[[package]] -name = "der" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" -dependencies = [ - "const-oid", - "pem-rfc7468 0.6.0", - "zeroize", -] - [[package]] name = "der" version = "0.7.9" @@ -1249,7 +1265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" dependencies = [ "const-oid", - "pem-rfc7468 0.7.0", + "pem-rfc7468", "zeroize", ] @@ -1343,6 +1359,19 @@ dependencies = [ "prost", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + [[package]] name = "diff" version = "0.1.13" @@ -1426,30 +1455,18 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" -[[package]] -name = "ecdsa" -version = "0.14.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" -dependencies = [ - "der 0.6.1", - "elliptic-curve 0.12.3", - "rfc6979 0.3.1", - "signature 1.6.4", -] - [[package]] name = "ecdsa" version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der 0.7.9", + "der", "digest 0.10.7", - "elliptic-curve 0.13.8", - "rfc6979 0.4.0", - "signature 2.2.0", - "spki 0.7.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", ] [[package]] @@ -1458,8 +1475,8 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8 0.10.2", - "signature 2.2.0", + "pkcs8", + "signature", ] [[package]] @@ -1489,7 +1506,7 @@ dependencies = [ "rand_core", "serde", "sha2 0.10.8", - "signature 2.2.0", + "signature", "subtle", "zeroize", ] @@ -1500,43 +1517,22 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" -[[package]] -name = "elliptic-curve" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" -dependencies = [ - "base16ct 0.1.1", - "crypto-bigint 0.4.9", - "der 0.6.1", - "digest 0.10.7", - "ff 0.12.1", - "generic-array", - "group 0.12.1", - "pem-rfc7468 0.6.0", - "pkcs8 0.9.0", - "rand_core", - "sec1 0.3.0", - "subtle", - "zeroize", -] - [[package]] name = "elliptic-curve" version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct 0.2.0", - "crypto-bigint 0.5.5", + "base16ct", + "crypto-bigint", "digest 0.10.7", "ff 0.13.0", "generic-array", "group 0.13.0", - "pem-rfc7468 0.7.0", - "pkcs8 0.10.2", + "pem-rfc7468", + "pkcs8", "rand_core", - "sec1 0.7.3", + "sec1", "subtle", "zeroize", ] @@ -2125,17 +2121,17 @@ dependencies = [ "ic-certification", "ic-transport-types", "ic-verify-bls-signature", - "k256 0.13.3", + "k256", "leb128", "p256", "pem 2.0.1", - "pkcs8 0.10.2", + "pkcs8", "rand", "rangemap", "reqwest", "ring", "rustls-webpki", - "sec1 0.7.3", + "sec1", "serde", "serde_bytes", "serde_cbor", @@ -2332,7 +2328,7 @@ source = "git+https://github.com/dfinity/ic?rev=479fc39a7ee082a62ec070efeed22478 dependencies = [ "async-trait", "bech32", - "bs58", + "bs58 0.4.0", "candid", "ciborium", "hex", @@ -2376,7 +2372,7 @@ name = "ic-crypto-ecdsa-secp256k1" version = "0.9.0" source = "git+https://github.com/dfinity/ic?rev=479fc39a7ee082a62ec070efeed224784a83eb1b#479fc39a7ee082a62ec070efeed224784a83eb1b" dependencies = [ - "k256 0.13.3", + "k256", "lazy_static", "num-bigint 0.4.5", "pem 1.1.1", @@ -2555,7 +2551,7 @@ dependencies = [ "ic-crypto-secrets-containers", "ic-crypto-sha2", "ic-types", - "k256 0.13.3", + "k256", "lazy_static", "p256", "paste", @@ -3736,6 +3732,16 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -3829,19 +3835,6 @@ dependencies = [ "serde", ] -[[package]] -name = "k256" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72c1e0b51e7ec0a97369623508396067a486bd0cbed95a2659a4b863d28cfc8b" -dependencies = [ - "cfg-if", - "ecdsa 0.14.8", - "elliptic-curve 0.12.3", - "sha2 0.10.8", - "sha3", -] - [[package]] name = "k256" version = "0.13.3" @@ -3849,11 +3842,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "956ff9b67e26e1a6a866cb758f12c6f8746208489e3e4a4b5580802f2f0a587b" dependencies = [ "cfg-if", - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", + "ecdsa", + "elliptic-curve", "once_cell", "sha2 0.10.8", - "signature 2.2.0", + "signature", ] [[package]] @@ -4406,8 +4399,8 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", + "ecdsa", + "elliptic-curve", "primeorder", "sha2 0.10.8", ] @@ -4459,6 +4452,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + [[package]] name = "pem" version = "1.1.1" @@ -4478,15 +4481,6 @@ dependencies = [ "serde", ] -[[package]] -name = "pem-rfc7468" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d159833a9105500e0398934e205e0773f0b27529557134ecfc51c27646adac" -dependencies = [ - "base64ct", -] - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -4605,13 +4599,18 @@ dependencies = [ ] [[package]] -name = "pkcs8" -version = "0.9.0" +name = "pkcs5" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" dependencies = [ - "der 0.6.1", - "spki 0.6.0", + "aes", + "cbc", + "der", + "pbkdf2 0.12.2", + "scrypt", + "sha2 0.10.8", + "spki", ] [[package]] @@ -4620,8 +4619,10 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.9", - "spki 0.7.3", + "der", + "pkcs5", + "rand_core", + "spki", ] [[package]] @@ -4727,7 +4728,7 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve 0.13.8", + "elliptic-curve", ] [[package]] @@ -4890,6 +4891,7 @@ dependencies = [ "clap 4.5.4", "crc32fast", "data-encoding", + "dialoguer", "flate2", "hex", "hidapi", @@ -4909,17 +4911,20 @@ dependencies = [ "icrc-ledger-types", "indicatif", "itertools 0.10.5", - "k256 0.11.6", + "k256", "ledger-apdu", "ledger-canister", "ledger-transport-hid", "num-bigint 0.4.5", "once_cell", - "pem 1.1.1", + "pem 2.0.1", + "pkcs8", "qrcodegen", "rand", + "ring", "rpassword", "scopeguard", + "sec1", "serde", "serde_bytes", "serde_cbor", @@ -4928,7 +4933,6 @@ dependencies = [ "sha2 0.10.8", "sha3", "shellwords", - "simple_asn1", "tempfile", "tiny-bip39", "tokio", @@ -5134,17 +5138,6 @@ dependencies = [ "winreg", ] -[[package]] -name = "rfc6979" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" -dependencies = [ - "crypto-bigint 0.4.9", - "hmac", - "zeroize", -] - [[package]] name = "rfc6979" version = "0.4.0" @@ -5338,6 +5331,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -5359,6 +5361,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2 0.12.2", + "salsa20", + "sha2 0.10.8", +] + [[package]] name = "sct" version = "0.7.1" @@ -5375,30 +5388,16 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" -[[package]] -name = "sec1" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" -dependencies = [ - "base16ct 0.1.1", - "der 0.6.1", - "generic-array", - "pkcs8 0.9.0", - "subtle", - "zeroize", -] - [[package]] name = "sec1" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct 0.2.0", - "der 0.7.9", + "base16ct", + "der", "generic-array", - "pkcs8 0.10.2", + "pkcs8", "subtle", "zeroize", ] @@ -5590,6 +5589,12 @@ dependencies = [ "keccak", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shellwords" version = "1.1.0" @@ -5609,16 +5614,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "1.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" -dependencies = [ - "digest 0.10.7", - "rand_core", -] - [[package]] name = "signature" version = "2.2.0" @@ -5730,16 +5725,6 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -[[package]] -name = "spki" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" -dependencies = [ - "base64ct", - "der 0.6.1", -] - [[package]] name = "spki" version = "0.7.3" @@ -5747,7 +5732,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.9", + "der", ] [[package]] @@ -6017,7 +6002,7 @@ dependencies = [ "anyhow", "hmac", "once_cell", - "pbkdf2", + "pbkdf2 0.11.0", "rand", "rustc-hash", "sha2 0.10.8", diff --git a/Cargo.toml b/Cargo.toml index 4a395c2..e25bed4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,27 +31,30 @@ ic-identity-hsm = { git = "https://github.com/dfinity/agent-rs", rev = "8c39e262 anyhow = "1.0.34" base64 = "0.13.0" bigdecimal = "0.4" -bip32 = "0.4.0" +bip32 = "0.5.0" chrono = "0.4" clap = { version = "4.5.4", features = ["derive", "cargo", "color"] } crc32fast = "1.3.2" data-encoding = "2.3.3" +dialoguer = "0.11.0" flate2 = "1.0.22" hex = { version = "0.4.2", features = ["serde"] } hidapi = { version = "1.4", default-features = false, optional = true } indicatif = "0.17" itertools = "0.10.5" -k256 = { version = "0.11.4", features = ["pem"] } +k256 = { version = "0.13.0", features = ["pem", "pkcs8"] } ledger-apdu = { version = "0.10", optional = true } ledger-transport-hid = { version = "0.10", optional = true } num-bigint = "0.4.3" once_cell = "1.17.1" -pem = "1.0.1" +pem = "2.0.1" +pkcs8 = { version = "0.10.0", features = ["encryption"] } qrcodegen = "1.8" rand = { version = "0.8.4", features = ["getrandom"] } +ring = "0.17.7" rpassword = "6.0.0" -simple_asn1 = "0.6.1" scopeguard = "1" +sec1 = { version = "0.7.0", features = ["std"] } serde = { version = "1.0.130", features = ["derive"] } serde_bytes = "0.11.2" serde_cbor = "0.11.2" @@ -74,6 +77,12 @@ default = ["hsm", "ledger"] [profile.release] opt-level = 2 +[profile.dev.package.aes] +opt-level = 2 + +[profile.dev.package.scrypt] +opt-level = 2 + [package.metadata.binstall] pkg-fmt = "bin" bin-dir = "" diff --git a/docs/cli-reference/index.mdx b/docs/cli-reference/index.mdx index b6172aa..f965140 100644 --- a/docs/cli-reference/index.mdx +++ b/docs/cli-reference/index.mdx @@ -28,6 +28,7 @@ When you have quill installed, you can use the following commands to specify the - [quill ckbtc transfer](./ckbtc/quill-ckbtc-transfer.mdx) - [quill ckbtc update-balance](./ckbtc/quill-ckbtc-update-balance.mdx) - [quill ckbtc withdrawal-address](./ckbtc/quill-ckbtc-withdrawal-address.mdx) +- [quill decrypt-pem](./quill-decrypt-pem.mdx) - [quill generate](./quill-generate.mdx) - [quill get-neuron-info](./quill-get-neuron-info.mdx) - [quill get-proposal-info](./quill-get-proposal-info.mdx) diff --git a/docs/cli-reference/quill-decrypt-pem.mdx b/docs/cli-reference/quill-decrypt-pem.mdx new file mode 100644 index 0000000..9b8fc67 --- /dev/null +++ b/docs/cli-reference/quill-decrypt-pem.mdx @@ -0,0 +1,48 @@ +import { MarkdownChipRow } from "/src/components/Chip/MarkdownChipRow"; + +# quill decrypt-pem + + + +Decrypts an encrypted PEM file for use with other tools. + +## Basic usage + +The basic syntax for running `quill decrypt-pem` commands is: + +``` bash +quill decrypt-pem [option] +``` + +## Arguments + +| Argument | Description | +|-----------------|-----------------------------------------------------------| +| `` | The path to write the decrypted PEM to, or "-" for STDOUT | + +## Flags + +| Flag | Description | +|----------------|-----------------------------| +| `-h`, `--help` | Displays usage information. | + +## Options + +| Option | Description | +|-----------------------------------|------------------------------------------------------| +| `--pem-file ` | Path to your PEM file (use "-" for STDIN) | +| `--password-file ` | Read the password from this file (use "-" for STDIN) | + +## Examples + +The `quill decrypt-pem` command is used to convert a password-protected PEM file into a plaintext one. + +```sh +quill decrypt-pem --pem-file identity.pem decrypted.pem +``` + +This will interactively prompt for the password. To use it in a script, pass a password file: + +```sh +quill decrypt-pem --pem-file identity.pem --password-file password.txt decrypted.pem +``` diff --git a/docs/cli-reference/quill-generate.mdx b/docs/cli-reference/quill-generate.mdx index 90471bf..b06ce6b 100644 --- a/docs/cli-reference/quill-generate.mdx +++ b/docs/cli-reference/quill-generate.mdx @@ -24,12 +24,14 @@ quill generate [option] ## Options -| Option | Description | -|---------------------------|----------------------------------------------------------| -| `--pem-file ` | File to write the PEM to. | -| `--phrase ` | A seed phrase in quotes to use to generate the PEM file. | -| `--seed-file ` | File to write the seed phrase to [default: seed.txt]. | -| `--words ` | Number of words: 12 or 24 [default: 12]. | +| Option | Description | +|----------------------------------|---------------------------------------------------------------| +| `--pem-file ` | File to write the PEM to. [default: identity.pem] | +| `--phrase ` | A seed phrase in quotes to use to generate the PEM file. | +| `--password-file | Read the encryption password from this file. | +| `--seed-file ` | File to write the seed phrase to. | +| `--words ` | Number of words: 12 or 24 [default: 12]. | +| `--storage-mode ` | Change how PEM files are stored [default: password-protected] | ## Examples @@ -38,17 +40,35 @@ The `quill generate` command has two primary uses - generating a new key, or rec To generate a new key, and output it to a PEM file: ```sh -quill generate --pem-file identity.pem +quill generate --pem-file identity.pem --seed-file seed.txt ``` -This will generate a new key that you can use to sign IC transactions with quill, or any other IC tool that supports secp256k1, like `dfx`. It will also output a `seed.txt` file containing a seed phrase which can be used to recover this key - write it down in a safe place! +This will generate a new key that you can use to sign IC transactions with quill, or any other IC tool that supports secp256k1, like `dfx`. It will also output a `seed.txt` file containing a seed phrase which can be used to recover this key - write it down in a safe place! If you do not specify a file, it will be printed to the terminal. -To recover a key from a seed phrase stored in `phrase.txt`: +Keys are password-protected by default. This command will prompt for a password interactively. To use it in a script, use `--password-file`: ```sh -quill generate --phrase "$(< phrase.txt)" --pem-file identity.pem +quill generate --pem-file identity.pem --password-file password.txt +``` + +Or to disable password protection: + +```sh +quill generate --pem-file identity.pem --storage-mode plaintext +``` + +To recover a key from a seed phrase stored in `seed.txt`: + +```sh +quill generate --phrase "$(< seed.txt)" --pem-file identity.pem ``` ## Remarks -Most `quill` commands take `--pem-file` and `--seed-file` parameters, for the key used to sign the messages. Only one of these parameters is needed at a time. +Most `quill` commands take a `--pem-file` parameter, for the key used to sign the messages. If the key is password-protected, it will prompt you for the password, or you can use a `--password-file` parameter. + +If a password-protected key needs to be exported for use with another tool such as DFX, use [`quill decrypt-pem`]. + +Technical notes: Passwords are run through `scrypt(r=8,p=1,n=131072,len=32)`, and then the file is encrypted with AES-256-CBC. + +[`quill decrypt-pem`]: quill-decrypt-pem.mdx diff --git a/docs/cli-reference/quill-parent.mdx b/docs/cli-reference/quill-parent.mdx index a5d5b4c..d480e61 100644 --- a/docs/cli-reference/quill-parent.mdx +++ b/docs/cli-reference/quill-parent.mdx @@ -22,26 +22,26 @@ To see the available subcommands, please refer to the [index page](index.mdx) of You can use the following optional flags with the `quill` parent command or with any of the `quill` subcommands. -| Flag | Description | -|-----------------------------|-------------------------------------------------| -| `-h`, `--help` | Displays usage information. | -| `--hsm` | Enables HSM functionality. | -| `--insecure-local-dev-mode` | Enter local testing mode. | -| `--qr` | Output the result(s) as UTF-8 QR codes. | -| `-V`, `--version` | Displays version information. | -| `--ledger` | Authenticate using a Ledger hardware wallet | +| Flag | Description | +|-----------------------------|---------------------------------------------| +| `-h`, `--help` | Displays usage information. | +| `--hsm` | Enables HSM functionality. | +| `--insecure-local-dev-mode` | Enter local testing mode. | +| `--qr` | Output the result(s) as UTF-8 QR codes. | +| `-V`, `--version` | Displays version information. | +| `--ledger` | Authenticate using a Ledger hardware wallet | ## Options You can use the following options with the `quill` command. -| Option | Description | -|-------------------------------|---------------------------------------------| -| `--hsm-id ` | Specifies the HSM key identifier. | -| `--hsm-libpath ` | Specifies the path to the HSM library. | -| `--hsm-slot ` | Specifies the HSM slot to use. | -| `--pem-file ` | Path to your PEM file (use "-" for STDIN). | -| `--seed-file ` | Path to your seed file (use "-" for STDIN). | +| Option | Description | +|-----------------------------------|------------------------------------------------------------------------------------| +| `--hsm-id ` | Specifies the HSM key identifier. | +| `--hsm-libpath ` | Specifies the path to the HSM library. | +| `--hsm-slot ` | Specifies the HSM slot to use. | +| `--pem-file ` | Path to your PEM file (use "-" for STDIN). | +| `--password-file ` | If the PEM file is encrypted, read the password from this file (use "-" for STDIN) | ## Examples @@ -53,22 +53,29 @@ See [`quill generate`] to generate a new key file, though Quill should be compat quill list-neurons --pem-file identity.pem ``` -Some commands that do not require your key will still be more useful with it; for example, `quill account-balance` doesn't require authentication, but providing your key prevents you from having to provide your principal or account ID: +This file can be specified to come from stdin: ```sh -quill account-balance --pem-file identity.pem +cat identity.pem | quill list-neurons --pem-file - ``` -Quill can also be used with a seed phrase directly, though using [`quill generate`] to convert it into a private key should be preferred instead. To authenticate using a `seed.txt` file containing your seed phrase: +If the PEM file is password-protected, this will prompt for the password interactively. To use it in a script, specify a password file: ```sh -quill list-neurons --seed-file seed.txt +quill list-neurons --pem-file identity.pem --password-file password.txt ``` -Both of these files can be specified to come from stdin: +Some commands that do not require your key will still be more useful with it; for example, `quill account-balance` doesn't require authentication, but providing your key prevents you from having to provide your principal or account ID: ```sh -cat identity.pem | quill list-neurons --pem-file - +quill account-balance --pem-file identity.pem +``` + +Previous versions of Quill could also be used with a seed phrase directly; it must now be converted to a PEM file. To authenticate using a `seed.txt` file containing your seed phrase: + +```sh +quill generate --pem-file identity.pem --phrase "$(cat seed.txt)" +quill list-neurons --pem-file identity.pem ``` Quill can also sign transactions using a hardware key (HSM) such as Nitrokey or Yubikey. It will need to have been configured beforehand with a secp256r1 (aka P-256) key, and you will need OpenSC or an equivalent installed. Assuming the HSM is in slot 0 (`pkcs11-tool --list-slots`), and you are signing with the first key it holds, such a signing command might look like: diff --git a/src/commands/account_balance.rs b/src/commands/account_balance.rs index ab87936..65806e9 100644 --- a/src/commands/account_balance.rs +++ b/src/commands/account_balance.rs @@ -4,6 +4,7 @@ use crate::{ get_account_id, ledger_canister_id, AnyhowResult, AuthInfo, ParsedNnsAccount, ROLE_ICRC1_LEDGER, ROLE_NNS_LEDGER, }, + AUTH_FLAGS, }; use candid::{CandidType, Encode}; use clap::Parser; @@ -19,7 +20,7 @@ pub struct AccountBalanceArgs { #[derive(Parser)] pub struct AccountBalanceOpts { /// The id of the account to query. Optional if a key is used. - #[arg(required_unless_present = "auth")] + #[arg(required_unless_present_any = AUTH_FLAGS)] account_id: Option, #[command(flatten)] diff --git a/src/commands/ckbtc/balance.rs b/src/commands/ckbtc/balance.rs index 63efbd8..da5bdc2 100644 --- a/src/commands/ckbtc/balance.rs +++ b/src/commands/ckbtc/balance.rs @@ -7,6 +7,7 @@ use crate::{ ckbtc_canister_id, AnyhowResult, AuthInfo, ParsedAccount, ParsedSubaccount, ROLE_ICRC1_LEDGER, }, + AUTH_FLAGS, }; /// Sends a message to check the provided user's ckBTC balance. @@ -15,7 +16,7 @@ use crate::{ #[derive(Parser)] pub struct BalanceOpts { /// The account to check. Optional if a key is used. - #[arg(long, required_unless_present = "auth")] + #[arg(long, required_unless_present_any = AUTH_FLAGS)] of: Option, /// The subaccount of the account to check. diff --git a/src/commands/ckbtc/update_balance.rs b/src/commands/ckbtc/update_balance.rs index c864e07..34b22e9 100644 --- a/src/commands/ckbtc/update_balance.rs +++ b/src/commands/ckbtc/update_balance.rs @@ -9,13 +9,14 @@ use crate::{ signing::{sign_ingress_with_request_status_query, IngressWithRequestId}, AnyhowResult, AuthInfo, ParsedAccount, ParsedSubaccount, ROLE_CKBTC_MINTER, }, + AUTH_FLAGS, }; /// Signs a message to mint ckBTC from previously deposited BTC. #[derive(Parser)] pub struct UpdateBalanceOpts { /// The account to mint ckBTC to. - #[arg(long, required_unless_present = "auth")] + #[arg(long, required_unless_present_any = AUTH_FLAGS)] sender: Option, /// The subaccount to mint ckBTC to. #[arg(long)] diff --git a/src/commands/ckbtc/withdrawal_address.rs b/src/commands/ckbtc/withdrawal_address.rs index 8a7532f..152f102 100644 --- a/src/commands/ckbtc/withdrawal_address.rs +++ b/src/commands/ckbtc/withdrawal_address.rs @@ -4,6 +4,7 @@ use clap::Parser; use crate::{ commands::get_principal, lib::{AnyhowResult, AuthInfo, ParsedAccount}, + AUTH_FLAGS, }; use super::ckbtc_withdrawal_address; @@ -17,7 +18,7 @@ use super::ckbtc_withdrawal_address; #[derive(Parser)] pub struct GetWithdrawalAddressOpts { /// The principal to get the withdrawal address for. Optional if a key is used. - #[arg(long, required_unless_present = "auth")] + #[arg(long, required_unless_present_any = AUTH_FLAGS)] of: Option, /// Uses ckTESTBTC instead of ckBTC. diff --git a/src/commands/claim_neurons.rs b/src/commands/claim_neurons.rs index 3253ff8..b836c47 100644 --- a/src/commands/claim_neurons.rs +++ b/src/commands/claim_neurons.rs @@ -5,25 +5,18 @@ use crate::lib::{ signing::{sign_ingress_with_request_status_query, IngressWithRequestId}, AnyhowResult, AuthInfo, ROLE_NNS_GTC, }; -use anyhow::{anyhow, Context}; +use anyhow::anyhow; use candid::Encode; use clap::Parser; -use k256::{elliptic_curve::sec1::ToEncodedPoint, SecretKey}; +use k256::elliptic_curve::sec1::ToEncodedPoint; /// Claim seed neurons from the Genesis Token Canister. #[derive(Parser)] pub struct ClaimNeuronOpts; pub fn exec(auth: &AuthInfo) -> AnyhowResult> { - if let AuthInfo::PemFile(pem) = auth { - let keyinfo = pem::parse_many(pem)? - .into_iter() - .find(|p| p.tag == "EC PRIVATE KEY") - .context("Pem file did not contain sec1 key")?; - let point = SecretKey::from_sec1_der(&keyinfo.contents) - .map_err(|e| anyhow!("could not load pem file: {e}"))? - .public_key() - .to_encoded_point(false); + if let AuthInfo::K256Key(pk) = auth { + let point = pk.public_key().to_encoded_point(false); let sig = Encode!(&hex::encode(point.as_bytes()))?; Ok(vec![sign_ingress_with_request_status_query( diff --git a/src/commands/decrypt_pem.rs b/src/commands/decrypt_pem.rs new file mode 100644 index 0000000..c156523 --- /dev/null +++ b/src/commands/decrypt_pem.rs @@ -0,0 +1,32 @@ +use std::path::PathBuf; + +use anyhow::bail; +use clap::Parser; +use sec1::LineEnding; + +use crate::{ + lib::{AnyhowResult, AuthInfo}, + write_file, +}; + +/// Decrypts an encrypted PEM file for use with other tools. +#[derive(Parser)] +pub struct DecryptPemOpts { + /// The path to write the decrypted PEM to, or "-" for STDOUT + output_path: PathBuf, +} + +pub fn exec(auth: &AuthInfo, opts: DecryptPemOpts) -> AnyhowResult<()> { + let AuthInfo::K256Key(pk) = auth else { + bail!("--pem-file was not set to an encrypted PEM file") + }; + // technically this permits an unencrypted secp256k1 key, which will just be re-encoded directly + // but who cares + write_file( + &opts.output_path, + "PEM", + pk.to_sec1_pem(LineEnding::default())?.as_bytes(), + )?; + eprintln!("Wrote PEM file to {}", opts.output_path.display()); + Ok(()) +} diff --git a/src/commands/generate.rs b/src/commands/generate.rs index 1612672..20a09e7 100644 --- a/src/commands/generate.rs +++ b/src/commands/generate.rs @@ -1,9 +1,19 @@ -use crate::lib::{get_account_id, mnemonic_to_pem, AnyhowResult, AuthInfo}; -use anyhow::{anyhow, Context}; +use crate::{ + lib::{get_account_id, mnemonic_to_key, AnyhowResult}, + read_file, +}; +use anyhow::{anyhow, bail, Context}; use bip39::{Language, Mnemonic}; -use clap::Parser; -use rand::{rngs::OsRng, RngCore}; -use std::path::PathBuf; +use clap::{Parser, ValueEnum}; +use dialoguer::Password; +use ic_agent::{identity::Secp256k1Identity, Identity}; +use pkcs8::EncodePrivateKey; +use rand::{rngs::OsRng, thread_rng, RngCore}; +use sec1::LineEnding; +use std::{ + io::{stdin, IsTerminal}, + path::PathBuf, +}; /// Generate a mnemonic seed phrase and generate or recover PEM. #[derive(Parser, Debug)] @@ -13,13 +23,13 @@ pub struct GenerateOpts { #[arg(long, default_value = "12")] words: u32, - /// File to write the seed phrase to. - #[arg(long, default_value = "seed.txt")] - seed_file: PathBuf, + /// File to write the seed phrase to. If unspecified, it will be printed to the terminal. + #[arg(long)] + seed_file: Option, /// File to write the PEM to. - #[arg(long)] - pem_file: Option, + #[arg(long, default_value = "identity.pem")] + pem_file: PathBuf, /// A seed phrase in quotes to use to generate the PEM file. #[arg(long)] @@ -32,18 +42,44 @@ pub struct GenerateOpts { /// Overwrite any existing PEM file. #[arg(long)] overwrite_pem_file: bool, + + /// Change how PEM files are stored. + #[arg(long, value_enum, default_value_t = StorageMode::PasswordProtected)] + storage_mode: StorageMode, + + /// Read the encryption password from this file. Use "-" for STDIN. Required if STDIN is being piped. + #[arg(long)] + password_file: Option, +} + +#[derive(Debug, ValueEnum, Clone, Copy, PartialEq, Eq)] +enum StorageMode { + PasswordProtected, + Plaintext, } /// Generate or recover mnemonic seed phrase and/or PEM file. pub fn exec(opts: GenerateOpts) -> AnyhowResult { - if opts.seed_file.exists() && !opts.overwrite_seed_file { - return Err(anyhow!("Seed file exists and overwrite is not set.")); - } - if let Some(path) = &opts.pem_file { - if path.exists() && !opts.overwrite_pem_file { - return Err(anyhow!("PEM file exists and overwrite is not set.")); + if let Some(seed_file) = &opts.seed_file { + if !opts.overwrite_seed_file && seed_file.exists() { + bail!( + "Seed file {} exists and overwrite is not set.", + seed_file.display() + ); } } + if !opts.overwrite_pem_file && opts.pem_file.exists() { + bail!( + "PEM file {} exists and overwrite is not set.", + opts.pem_file.display() + ); + } + if opts.storage_mode == StorageMode::PasswordProtected + && opts.password_file.is_none() + && !stdin().is_terminal() + { + bail!("Must use --password-file if using --storage-mode=password-protected and stdin cannot receive terminal input."); + } let bytes = match opts.words { 12 => 16, 24 => 32, @@ -59,15 +95,43 @@ pub fn exec(opts: GenerateOpts) -> AnyhowResult { Mnemonic::from_entropy(&key, Language::English).unwrap() } }; - let pem = mnemonic_to_pem(&mnemonic).context("Failed to convert mnemonic to PEM")?; - let mut phrase = mnemonic.into_phrase(); - phrase.push('\n'); - std::fs::write(opts.seed_file, phrase)?; - if let Some(path) = opts.pem_file { - std::fs::write(path, &pem)?; + let key = mnemonic_to_key(&mnemonic).context("Failed to convert mnemonic to PEM")?; + let phrase = mnemonic.into_phrase(); + if let Some(seed_file) = opts.seed_file { + std::fs::write(&seed_file, phrase)?; + println!("Written seed file to {}.", seed_file.display()); + println!("Copy the contents of this file to external media or a piece of paper and store it in a safe place."); + if opts.storage_mode != StorageMode::Plaintext { + println!("Be sure to delete the file afterwards, as it is not password-protected like the PEM file is.") + } + } else { + println!( + "\ +Seed phrase: {phrase} +Copy this onto a piece of paper or external media and store it in a safe place." + ); } - let principal_id = crate::lib::get_principal(&AuthInfo::PemFile(pem))?; + let pem = match opts.storage_mode { + StorageMode::Plaintext => key.to_sec1_pem(LineEnding::default())?, + StorageMode::PasswordProtected => { + let password = if let Some(password_file) = opts.password_file { + read_file(password_file, "password")? + } else { + Password::new() + .with_prompt("PEM encryption password") + .with_confirmation("Re-enter password", "Passwords did not match") + .interact()? + }; + key.to_pkcs8_encrypted_pem(thread_rng(), password, LineEnding::default())? + } + }; + std::fs::write(&opts.pem_file, &pem)?; + println!("Written PEM file to {}.", opts.pem_file.display()); + let principal_id = Secp256k1Identity::from_private_key(key) + .sender() + .map_err(|s| anyhow!(s))?; let account_id = get_account_id(principal_id, None)?; + println!("Principal id: {principal_id}"); println!("Legacy account id: {account_id}"); Ok(()) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a5c2128..3d25cdc 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,6 +9,7 @@ use std::io::{self, Write}; mod account_balance; mod ckbtc; mod claim_neurons; +mod decrypt_pem; mod generate; mod get_neuron_info; mod get_proposal_info; @@ -44,6 +45,7 @@ pub enum Command { Ckbtc(ckbtc::CkbtcCommand), Sns(sns::SnsOpts), Generate(generate::GenerateOpts), + DecryptPem(decrypt_pem::DecryptPemOpts), /// Print QR Scanner dapp QR code: scan to start dapp to submit QR results. ScannerQRCode, QRCode(qrcode::QRCodeOpts), @@ -95,6 +97,7 @@ pub fn dispatch(auth: &AuthInfo, cmd: Command, fetch_root_key: bool, qr: bool) - send::exec(opts, fetch_root_key)?; } Command::Generate(opts) => generate::exec(opts)?, + Command::DecryptPem(opts) => decrypt_pem::exec(auth, opts)?, Command::Ckbtc(subcmd) => ckbtc::dispatch(auth, subcmd, qr, fetch_root_key)?, Command::Sns(opts) => sns::dispatch(auth, opts, qr, fetch_root_key)?, // QR code for URL: https://p5deo-6aaaa-aaaab-aaaxq-cai.raw.ic0.app/ diff --git a/src/commands/public.rs b/src/commands/public.rs index 7987dbc..ef9b82d 100644 --- a/src/commands/public.rs +++ b/src/commands/public.rs @@ -3,7 +3,7 @@ use crate::lib::ledger::LedgerIdentity; use crate::lib::{ get_account_id, get_principal, AnyhowResult, AuthInfo, ParsedAccount, ParsedSubaccount, }; -use anyhow::{anyhow, bail, Context}; +use anyhow::{anyhow, bail}; use candid::Principal; use clap::Parser; use icp_ledger::AccountIdentifier; @@ -22,7 +22,7 @@ pub struct PublicOpts { genesis_dfn: bool, /// If authenticating with a Ledger device, display the public IDs on the device. #[cfg_attr(not(feature = "ledger"), arg(hide = true))] - #[arg(long, requires = "ledgerhq")] + #[arg(long, requires = "ledger")] display_on_ledger: bool, /// Print IDs for the provided subaccount. #[arg(long)] @@ -44,10 +44,10 @@ pub fn exec(auth: &AuthInfo, opts: PublicOpts) -> AnyhowResult { ); } if opts.genesis_dfn { - let AuthInfo::PemFile(pem) = auth else { - bail!("Must supply a pem or seed file for the DFN address"); + let AuthInfo::K256Key(pk) = auth else { + bail!("Must supply a pem file for the DFN address"); }; - println!("DFN address: {}", get_dfn(pem)?); + println!("DFN address: {}", get_dfn(pk.clone())?); } if opts.display_on_ledger { #[cfg(feature = "ledger")] @@ -91,8 +91,7 @@ fn get_public_ids( } } -fn get_dfn(pem: &str) -> AnyhowResult { - let pk = SecretKey::from_sec1_pem(pem).context("DFN addresses need a secp256k1 key")?; +fn get_dfn(pk: SecretKey) -> AnyhowResult { let pubk = pk.public_key(); let uncompressed = pubk.to_encoded_point(false); let hash = Keccak256::digest(&uncompressed.as_bytes()[1..]); diff --git a/src/commands/qrcode.rs b/src/commands/qrcode.rs index b296380..0098972 100644 --- a/src/commands/qrcode.rs +++ b/src/commands/qrcode.rs @@ -12,7 +12,7 @@ pub struct QRCodeOpts { #[arg(long)] file: Option, - // String to be output as a QRCode. + /// String to be output as a QRCode. #[arg(long)] string: Option, } diff --git a/src/commands/sns/balance.rs b/src/commands/sns/balance.rs index 7d8a829..4b379eb 100644 --- a/src/commands/sns/balance.rs +++ b/src/commands/sns/balance.rs @@ -1,7 +1,7 @@ use crate::{ commands::{get_account, send::submit_unsigned_ingress, SendingOpts}, lib::{AuthInfo, ParsedAccount, ParsedSubaccount, ROLE_ICRC1_LEDGER}, - AnyhowResult, + AnyhowResult, AUTH_FLAGS, }; use candid::Encode; use clap::Parser; @@ -14,7 +14,7 @@ use super::SnsCanisterIds; #[derive(Parser)] pub struct BalanceOpts { /// The account to query. Optional if a key is used. - #[arg(long, required_unless_present = "auth")] + #[arg(long, required_unless_present_any = AUTH_FLAGS)] of: Option, /// The subaccount of the account to query. diff --git a/src/commands/sns/disburse.rs b/src/commands/sns/disburse.rs index 56cc8d7..cf2d656 100644 --- a/src/commands/sns/disburse.rs +++ b/src/commands/sns/disburse.rs @@ -12,6 +12,7 @@ use crate::{ signing::{sign_ingress_with_request_status_query, IngressWithRequestId}, AnyhowResult, AuthInfo, ParsedAccount, ParsedSubaccount, ROLE_SNS_GOVERNANCE, }, + AUTH_FLAGS, }; use super::{governance_account, ParsedSnsNeuron, SnsCanisterIds}; @@ -22,7 +23,7 @@ pub struct DisburseOpts { /// The neuron to disburse. neuron_id: ParsedSnsNeuron, /// The account to transfer the SNS utility tokens to. If unset, defaults to the caller. - #[arg(long, required_unless_present = "auth")] + #[arg(long, required_unless_present_any = AUTH_FLAGS)] to: Option, /// The subaccount to transfer the SNS utility tokens to. #[arg(long)] diff --git a/src/commands/sns/disburse_maturity.rs b/src/commands/sns/disburse_maturity.rs index f861c66..3f23bd1 100644 --- a/src/commands/sns/disburse_maturity.rs +++ b/src/commands/sns/disburse_maturity.rs @@ -11,6 +11,7 @@ use crate::{ signing::{sign_ingress_with_request_status_query, IngressWithRequestId}, AnyhowResult, AuthInfo, ParsedAccount, ParsedSubaccount, ROLE_SNS_GOVERNANCE, }, + AUTH_FLAGS, }; use super::{governance_account, ParsedSnsNeuron, SnsCanisterIds}; @@ -21,7 +22,7 @@ pub struct DisburseMaturityOpts { /// The neuron ID to disburse maturity from. neuron_id: ParsedSnsNeuron, /// The account to transfer the SNS utility tokens to. If not provided, defaults to the caller. - #[arg(long, required_unless_present = "auth")] + #[arg(long, required_unless_present_any = AUTH_FLAGS)] to: Option, /// The subaccount to transfer the SNS utility tokens to. #[arg(long)] diff --git a/src/commands/sns/get_sale_participation.rs b/src/commands/sns/get_sale_participation.rs index 55a11b1..aee2f9f 100644 --- a/src/commands/sns/get_sale_participation.rs +++ b/src/commands/sns/get_sale_participation.rs @@ -5,6 +5,7 @@ use ic_sns_swap::pb::v1::GetBuyerStateRequest; use crate::{ commands::{get_principal, send::submit_unsigned_ingress, SendingOpts}, lib::{AnyhowResult, AuthInfo, ROLE_SNS_SWAP}, + AUTH_FLAGS, }; use super::SnsCanisterIds; @@ -13,7 +14,7 @@ use super::SnsCanisterIds; #[derive(Parser)] pub struct GetSaleParticipationOpts { /// The principal to query. If unspecified, the caller will be used. - #[arg(long, required_unless_present = "auth")] + #[arg(long, required_unless_present_any = AUTH_FLAGS)] principal: Option, #[command(flatten)] diff --git a/src/commands/sns/neuron_id.rs b/src/commands/sns/neuron_id.rs index f8c325c..c0aafc5 100644 --- a/src/commands/sns/neuron_id.rs +++ b/src/commands/sns/neuron_id.rs @@ -1,5 +1,6 @@ use crate::commands::get_principal; use crate::lib::{AnyhowResult, AuthInfo}; +use crate::AUTH_FLAGS; use candid::Principal; use clap::Parser; use ic_base_types::PrincipalId; @@ -9,7 +10,7 @@ use ic_sns_governance::pb::v1::NeuronId; #[derive(Parser)] pub struct NeuronIdOpts { /// Principal used when calculating the SNS Neuron Id. - #[arg(long, required_unless_present = "auth")] + #[arg(long, required_unless_present_any = AUTH_FLAGS)] principal_id: Option, /// Memo used when calculating the SNS Neuron Id. diff --git a/src/lib/ledger.rs b/src/lib/ledger.rs index 4c6679d..ab77390 100644 --- a/src/lib/ledger.rs +++ b/src/lib/ledger.rs @@ -11,12 +11,11 @@ use candid::Principal; use hidapi::HidApi; use ic_agent::{agent::EnvelopeContent, Identity, Signature}; use indicatif::ProgressBar; -use k256::{ - elliptic_curve::sec1::FromEncodedPoint, pkcs8::EncodePublicKey, EncodedPoint, PublicKey, -}; +use k256::{elliptic_curve::sec1::FromEncodedPoint, EncodedPoint, PublicKey}; use ledger_apdu::{APDUAnswer, APDUCommand, APDUErrorCode}; use ledger_transport_hid::TransportNativeHID; use once_cell::sync::Lazy; +use pkcs8::EncodePublicKey; use serde::Serialize; use serde_cbor::Serializer; diff --git a/src/lib/mod.rs b/src/lib/mod.rs index ba88c29..6d8daf2 100644 --- a/src/lib/mod.rs +++ b/src/lib/mod.rs @@ -21,13 +21,10 @@ use ic_nns_constants::{ }; use icp_ledger::{AccountIdentifier, Subaccount}; use icrc_ledger_types::icrc1::account::Account; -use k256::{elliptic_curve::sec1::ToEncodedPoint, SecretKey}; -use pem::{encode, Pem}; +use k256::SecretKey; +use ring::signature::Ed25519KeyPair; use serde_cbor::Value; -use simple_asn1::ASN1Block::{ - BitString, Explicit, Integer, ObjectIdentifier, OctetString, Sequence, -}; -use simple_asn1::{oid, to_der, ASN1Class, BigInt, BigUint}; + #[cfg(feature = "hsm")] use std::{cell::RefCell, path::PathBuf}; use std::{ @@ -97,11 +94,17 @@ impl HSMInfo { #[derive(Debug)] pub enum AuthInfo { - NoAuth, // No authentication details were provided; - // only unsigned queries are allowed. - PemFile(String), // --private-pem file specified + /// No authentication details were provided; + /// only unsigned queries are allowed. + NoAuth, + /// Secp256k1 key provided via --pem-file. + K256Key(SecretKey), + /// Ed25519 key provided via --pem-file. Stored in PKCS#8 because Ed25519KeyPair cannot be cloned or unpacked. + Ed25519Key(Vec), + /// PKCS#11 security module, usually Nitrokey #[cfg(feature = "hsm")] Pkcs11Hsm(HSMInfo), + /// Ledger Nano with the Internet Computer app installed #[cfg(feature = "ledger")] Ledger, } @@ -354,13 +357,10 @@ fn read_pkcs11_pin_env_var() -> Result, String> { pub fn get_identity(auth: &AuthInfo) -> AnyhowResult> { match auth { AuthInfo::NoAuth => Ok(Box::new(AnonymousIdentity) as _), - AuthInfo::PemFile(pem) => match Secp256k1Identity::from_pem(pem.as_bytes()) { - Ok(id) => Ok(Box::new(id) as _), - Err(_) => match BasicIdentity::from_pem(pem.as_bytes()) { - Ok(id) => Ok(Box::new(id) as _), - Err(e) => Err(e).context("couldn't load identity from PEM file"), - }, - }, + AuthInfo::K256Key(pk) => Ok(Box::new(Secp256k1Identity::from_private_key(pk.clone()))), + AuthInfo::Ed25519Key(kp) => Ok(Box::new(BasicIdentity::from_key_pair( + Ed25519KeyPair::from_pkcs8(kp).expect("Ed25519 key previously validated"), + ))), #[cfg(feature = "hsm")] AuthInfo::Pkcs11Hsm(info) => { let pin_fn = || { @@ -434,48 +434,14 @@ pub fn get_account_id( } /// Converts menmonic to PEM format -pub fn mnemonic_to_pem(mnemonic: &Mnemonic) -> AnyhowResult { - fn der_encode_secret_key(public_key: Vec, secret: Vec) -> AnyhowResult> { - let secp256k1_id = ObjectIdentifier(0, oid!(1, 3, 132, 0, 10)); - let data = Sequence( - 0, - vec![ - Integer(0, BigInt::from(1)), - OctetString(32, secret), - Explicit( - ASN1Class::ContextSpecific, - 0, - BigUint::from(0u32), - Box::new(secp256k1_id), - ), - Explicit( - ASN1Class::ContextSpecific, - 0, - BigUint::from(1u32), - Box::new(BitString(0, public_key.len() * 8, public_key)), - ), - ], - ); - to_der(&data).context("Failed to encode secp256k1 secret key to DER") - } - +pub fn mnemonic_to_key(mnemonic: &Mnemonic) -> AnyhowResult { let seed = Seed::new(mnemonic, ""); let ext = bip32::XPrv::derive_from_path(seed, &derivation_path()) .map_err(|err| anyhow!("{:?}", err)) .context("Failed to derive BIP32 extended private key")?; let secret = ext.private_key(); let secret_key = SecretKey::from(secret); - let public_key = secret_key.public_key(); - let der = der_encode_secret_key( - public_key.to_encoded_point(false).to_bytes().into(), - secret_key.to_be_bytes().to_vec(), - )?; - let pem = Pem { - tag: String::from("EC PRIVATE KEY"), - contents: der, - }; - let key_pem = encode(&pem); - Ok(key_pem.replace('\r', "").replace("\n\n", "\n")) + Ok(secret_key) } const DERIVATION_PATH: &str = "m/44'/223'/0'/0/0"; diff --git a/src/main.rs b/src/main.rs index 6017935..e8e4802 100755 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,24 @@ #![warn(unused_extern_crates)] #![allow(special_module_name)] +use std::io::{stdin, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use crate::lib::AnyhowResult; -use anyhow::Context; -use bip39::{Language, Mnemonic}; -use clap::{crate_version, ArgGroup, Args, Parser}; +use anyhow::{bail, Context}; +use clap::{crate_version, Args, Parser}; +use dialoguer::Password; +use k256::SecretKey; use lib::AuthInfo; +use pkcs8::DecodePrivateKey; +use ring::signature::Ed25519KeyPair; +use sec1::pem::PemLabel; mod commands; mod lib; /// Ledger & Governance ToolKit for cold wallets. #[derive(Parser)] -#[command(name("quill"), version = crate_version!())] +#[command(name("quill"), version = crate_version!(), help_expected = true)] pub struct CliOpts { #[command(flatten, next_help_heading = "COMMON")] global_opts: GlobalOpts, @@ -21,21 +26,29 @@ pub struct CliOpts { command: commands::Command, } +const AUTH_FLAGS: &[&str] = &[ + "pem_file", + "password_file", + "hsm", + "hsm_libpath", + "hsm_slot", + "hsm_id", + "ledger", +]; + #[derive(Args)] -#[command( - group(ArgGroup::new("pkcs11").multiple(true).conflicts_with_all(&["seeded", "ledgerhq"])), - group(ArgGroup::new("seeded").multiple(true).conflicts_with_all(&["pkcs11", "ledgerhq"])), - group(ArgGroup::new("ledgerhq").multiple(true).conflicts_with_all(&["seeded", "pkcs11"])), - group(ArgGroup::new("auth").multiple(true)), -)] struct GlobalOpts { /// Path to your PEM file (use "-" for STDIN) - #[arg(long, groups = &["seeded", "auth"], global = true)] + #[arg(long, global = true)] pem_file: Option, + /// If the PEM file is encrypted, read the password from this file (use "-" for STDIN) + #[arg(long, global = true, requires = "pem_file")] + password_file: Option, + /// Use a hardware key to sign messages. #[cfg_attr(not(feature = "hsm"), arg(hide = true))] - #[arg(long, groups = &["pkcs11", "auth"], global = true)] + #[arg(long, global = true)] hsm: bool, /// Path to the PKCS#11 module to use. @@ -52,26 +65,26 @@ struct GlobalOpts { target_os = "linux", doc = "Defaults to /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so" )] - #[arg(long, global = true, groups = &["pkcs11", "auth"])] + #[arg(long, global = true)] hsm_libpath: Option, /// The slot that the hardware key is in. If OpenSC is installed, `pkcs11-tool --list-slots` #[cfg_attr(not(feature = "hsm"), arg(hide = true))] - #[arg(long, global = true, groups = &["pkcs11", "auth"])] + #[arg(long, global = true)] hsm_slot: Option, /// The ID of the key to use. Consult your hardware key's documentation. #[cfg_attr(not(feature = "hsm"), arg(hide = true))] - #[arg(long, global = true, groups = &["pkcs11", "auth"])] + #[arg(long, global = true)] hsm_id: Option, - /// Path to your seed file (use "-" for STDIN) - #[arg(long, global = true, groups = &["seeded", "auth"])] + /// No longer supported, included for compatibility + #[arg(long, hide = true)] seed_file: Option, /// Authenticate using a Ledger hardware wallet. #[cfg_attr(not(feature = "ledger"), arg(hide = true))] - #[arg(long, global = true, groups = &["ledgerhq", "auth"])] + #[arg(long, global = true)] ledger: bool, /// Output the result(s) as UTF-8 QR codes. @@ -102,10 +115,20 @@ fn main() -> AnyhowResult { } fn get_auth(opts: GlobalOpts) -> AnyhowResult { - // Get PEM from the file if provided, or try to convert from the seed file - if opts.hsm || opts.hsm_libpath.is_some() || opts.hsm_slot.is_some() || opts.hsm_id.is_some() { + if opts.seed_file.is_some() { + bail!("Seed phrases are not accepted by commands directly anymore. Use `quill generate --phrase`."); + } else if opts.hsm + || opts.hsm_libpath.is_some() + || opts.hsm_slot.is_some() + || opts.hsm_id.is_some() + { #[cfg(feature = "hsm")] { + anyhow::ensure!(!opts.ledger, "Ledger flags cannot be used with HSM flags"); + anyhow::ensure!( + opts.pem_file.is_none() && opts.password_file.is_none(), + "PEM file flags cannot be used with HSM flags" + ); let mut hsm = lib::HSMInfo::new()?; if let Some(path) = opts.hsm_libpath { hsm.libpath = path; @@ -125,47 +148,71 @@ fn get_auth(opts: GlobalOpts) -> AnyhowResult { } else if opts.ledger { #[cfg(feature = "ledger")] { + anyhow::ensure!( + opts.pem_file.is_none() && opts.password_file.is_none(), + "PEM file flags cannot be used with Ledger flags" + ); Ok(AuthInfo::Ledger) } #[cfg(not(feature = "ledger"))] { anyhow::bail!("This build of quill does not support Ledger functionality.") } - } else { + } else if opts.pem_file.is_some() { pem_auth(opts) - } -} - -fn pem_auth(opts: GlobalOpts) -> AnyhowResult { - let pem = read_pem(opts.pem_file.as_deref(), opts.seed_file.as_deref())?; - if let Some(pem) = pem { - Ok(AuthInfo::PemFile(pem)) } else { Ok(AuthInfo::NoAuth) } } -// Get PEM from the file if provided, or try to convert from the seed file -fn read_pem(pem_file: Option<&Path>, seed_file: Option<&Path>) -> AnyhowResult> { - match (pem_file, seed_file) { - (Some(pem_file), _) => read_file(pem_file, "PEM").map(Some), - (_, Some(seed_file)) => { - let seed = read_file(seed_file, "seed")?; - let mnemonic = parse_mnemonic(&seed)?; - let mnemonic = lib::mnemonic_to_pem(&mnemonic)?; - Ok(Some(mnemonic)) +fn pem_auth(opts: GlobalOpts) -> AnyhowResult { + let file = opts + .pem_file + .as_ref() + .expect("pem_file needed for pem_auth"); + let pem = pem::parse_many(read_file(file, "PEM")?)?; + for document in pem { + match document.tag() { + sec1::EcPrivateKey::PEM_LABEL => { + return Ok(AuthInfo::K256Key( + SecretKey::from_sec1_der(document.contents()).with_context(|| { + format!("File {} was not a valid secp256k1 key", file.display()) + })?, + )) + } + "PRIVATE KEY" => { + Ed25519KeyPair::from_pkcs8_maybe_unchecked(document.contents()).with_context( + || format!("File {} was not a valid Ed25519 key", file.display()), + )?; + return Ok(AuthInfo::Ed25519Key(document.into_contents())); + } + pkcs8::EncryptedPrivateKeyInfo::PEM_LABEL => { + let password = if let Some(password_file) = &opts.password_file { + read_file(password_file, "password")? + } else if stdin().is_terminal() { + Password::new() + .with_prompt("PEM decryption password") + .interact()? + } else { + bail!("Must use --password-file if PEM file is encrypted and stdin cannot receive terminal input."); + }; + return Ok(AuthInfo::K256Key( + SecretKey::from_pkcs8_encrypted_der(document.contents(), password) + .with_context(|| { + format!("Could not read file {} as a secp256k1 key", file.display()) + })?, + )); + } + _ => {} } - _ => Ok(None), } -} - -fn parse_mnemonic(phrase: &str) -> AnyhowResult { - Mnemonic::from_phrase(phrase, Language::English) - .context("Couldn't parse the seed phrase as a valid mnemonic. {:?}") + bail!( + "File {} contained no recognized key formats", + file.display() + ); } fn read_file(path: impl AsRef, name: &str) -> AnyhowResult { - use std::io::Read; let path = path.as_ref(); if path == Path::new("-") { // read from STDIN @@ -179,67 +226,26 @@ fn read_file(path: impl AsRef, name: &str) -> AnyhowResult { } } +fn write_file(path: impl AsRef, name: &str, content: &[u8]) -> AnyhowResult { + let path = path.as_ref(); + if path == Path::new("-") { + // write to STDOUT + std::io::stdout() + .lock() + .write_all(content) + .context("Couldn't write {name} to STDOUT") + } else { + std::fs::write(path, content).with_context(|| format!("Couldn't write {name} file")) + } +} + #[cfg(test)] mod tests { - use crate::{read_pem, CliOpts}; - use bip39::{Language, Mnemonic}; + use crate::CliOpts; use clap::CommandFactory; #[test] fn check_cli() { CliOpts::command().debug_assert() } - - #[test] - fn test_read_pem_none_none() { - let res = read_pem(None, None); - assert_eq!(None, res.expect("read_pem(None, None) failed")); - } - - #[test] - fn test_read_pem_from_pem_file() { - use std::io::Write; - - let mut pem_file = tempfile::NamedTempFile::new().expect("Cannot create temp file"); - - let content = "pem".to_string(); - pem_file - .write_all(content.as_bytes()) - .expect("Cannot write to temp file"); - - let res = read_pem(Some(pem_file.path()), None); - - assert_eq!(Some(content), res.expect("read_pem from pem file")); - } - - #[test] - fn test_read_pem_from_seed_file() { - use std::io::Write; - - let mut seed_file = tempfile::NamedTempFile::new().expect("Cannot create temp file"); - - let phrase = "ozone drill grab fiber curtain grace pudding thank cruise elder eight about"; - seed_file - .write_all(phrase.as_bytes()) - .expect("Cannot write to temp file"); - let mnemonic = - crate::lib::mnemonic_to_pem(&Mnemonic::from_phrase(phrase, Language::English).unwrap()) - .unwrap(); - - let pem = read_pem(None, Some(seed_file.path())) - .expect("Unable to read seed_file") - .expect("None returned instead of Some"); - - assert_eq!(mnemonic, pem); - } - - #[test] - fn test_read_pem_from_non_existing_file() { - let dir = tempfile::tempdir().expect("Cannot create temp dir"); - let non_existing_file = dir.path().join("non_existing_pem_file"); - - read_pem(Some(&non_existing_file), None).unwrap_err(); - - read_pem(None, Some(&non_existing_file)).unwrap_err(); - } } diff --git a/tests/output/default/base_command/need_password.txt b/tests/output/default/base_command/need_password.txt new file mode 100644 index 0000000..84eddf2 --- /dev/null +++ b/tests/output/default/base_command/need_password.txt @@ -0,0 +1 @@ +Error: Must use --password-file if PEM file is encrypted and stdin cannot receive terminal input. diff --git a/tests/output/default/generate/no_password.txt b/tests/output/default/generate/no_password.txt new file mode 100644 index 0000000..57082af --- /dev/null +++ b/tests/output/default/generate/no_password.txt @@ -0,0 +1 @@ +Error: Must use --password-file if using --storage-mode=password-protected and stdin cannot receive terminal input. diff --git a/tests/output/root.rs b/tests/output/root.rs index 44f59b1..7a34cfd 100644 --- a/tests/output/root.rs +++ b/tests/output/root.rs @@ -71,7 +71,7 @@ fn generate() { let seed = NamedTempFile::new().unwrap(); quill(&format!( r#"generate --phrase "tornado allow zero warm have deer wool finish tiger ski dynamic strong" - --seed-file {seed} --overwrite-seed-file --pem-file {pem} --overwrite-pem-file"#, + --seed-file {seed} --overwrite-seed-file --pem-file {pem} --overwrite-pem-file --storage-mode plaintext"#, seed = escape_p(&seed), pem = escape_p(&pem), )).assert_success(); @@ -79,7 +79,36 @@ fn generate() { b"\ Principal id: beckf-r6bg7-t6ju6-s7k45-b5jtj-mcm57-zjaie-svgrr-7ekzs-55v75-sae Legacy account id: ffc463646a2c92dce58d1179d26c64d4ccbaf1079a6edc5628cedc0d4b3b1866", - ) + ); + let mut password = NamedTempFile::new().unwrap(); + password.write_all(b"pass").unwrap(); + quill(&format!( + "generate --pem-file {pem} --overwrite-pem-file", + pem = escape_p(&pem) + )) + .diff_err("generate/no_password.txt"); + quill(&format!( + "generate --pem-file {pem} --overwrite-pem-file --password-file {password}", + pem = escape_p(&pem), + password = escape_p(&password) + )) + .assert_success(); + quill(&format!( + "public-ids --pem-file {pem}", + pem = escape_p(&pem) + )) + .diff_err("base_command/need_password.txt"); + quill(&format!( + "public-ids --pem-file {pem} --password-file {pem}", + pem = escape_p(&pem), + )) + .assert_err(); + quill(&format!( + "public-ids --pem-file {pem} --password-file {pass}", + pem = escape_p(&pem), + pass = escape_p(&password) + )) + .assert_success(); } #[test] @@ -95,17 +124,24 @@ Principal id: 44mwt-bq3um-tqicz-bwhad-iipx4-6wzex-olvaj-z63bj-wkelv-xoua3-rqe Legacy account id: fe09de27b0fc2f9541f6e24ae41d0652aab116212dec7f75f0d502417539e6d4", ); let mut seed = NamedTempFile::new().unwrap(); - seed.write_all(b"fee tube anger harsh pipe pull since path erase hire ordinary display") - .unwrap(); + seed.write_all( + b"\ +-----BEGIN EC PRIVATE KEY----- +MHQCAQEEIIb7zeOCb1+GCE3/zikHvfpNsLEyD/lD7dEDEdkV6Rs9oAcGBSuBBAAK +oUQDQgAEKmH3zLC+gmKTJr9MRsGpNWzXIAyHf/YUPoFymekUh/3KEEmj3Ut+RbrO +RJt7Z+jd+BKc8GEzTfxhNW2KtBkLdA== +-----END EC PRIVATE KEY-----", + ) + .unwrap(); quill(&format!( - "public-ids --genesis-dfn --seed-file {}", + "public-ids --genesis-dfn --pem-file {}", escape_p(&seed) )) .diff_s( b"\ -Principal id: ed6vu-jnldn-5wync-3xnlm-jzlg2-5kjds-iqbcj-5pjgi-jhbw3-qawnx-eae -Legacy account id: 2adf562a6232efe3a3934880edb092ae481651fc961a61d845797d762f437fbd -DFN address: bfcc18caabb2b3ca17c50c0d3834e368c4e4b88f", +Principal id: 5lfph-rgykk-rizf2-kw7bi-52qsm-j44vi-2c7fu-kmpym-ohxe2-upuuw-oae +Legacy account id: e4f68df16f65a47e15acdd6089ca9f2a357b3c0f3b0a53f7bc060af350cb560f +DFN address: f452d283ff8381b9ca3cfa3cbc32e45177ea3ee6", ); }