diff --git a/Cargo.lock b/Cargo.lock index a4af8dcac6..748612f839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -541,9 +541,9 @@ checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] name = "base64" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" @@ -853,6 +853,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -1174,6 +1175,21 @@ dependencies = [ "subtle", ] +[[package]] +name = "crypto_secretbox" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" +dependencies = [ + "aead", + "cipher", + "generic-array", + "poly1305", + "salsa20", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek" version = "3.2.0" @@ -2470,7 +2486,7 @@ version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08163edd8bcc466c33d79e10f695cdc98c00d1e6ddfb95cec41b6b0279dd5432" dependencies = [ - "base64 0.22.0", + "base64 0.22.1", "futures-channel", "futures-util", "gloo-net", @@ -2523,7 +2539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d90064e04fb9d7282b1c71044ea94d0bbc6eff5621c66f1a0bce9e9de7cf3ac" dependencies = [ "async-trait", - "base64 0.22.0", + "base64 0.22.1", "http-body", "hyper", "hyper-rustls", @@ -3060,6 +3076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", + "hmac 0.12.1", "password-hash", ] @@ -3689,7 +3706,7 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" dependencies = [ - "base64 0.22.0", + "base64 0.22.1", "rustls-pki-types", ] @@ -3772,6 +3789,15 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +[[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" @@ -3981,6 +4007,18 @@ 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 = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2 0.10.8", +] + [[package]] name = "sec1" version = "0.7.3" @@ -4377,7 +4415,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37468c595637c10857701c990f93a40ce0e357cedb0953d1c26c8d8027f9bb53" dependencies = [ - "base64 0.22.0", + "base64 0.22.1", "bytes", "futures", "httparse", @@ -5019,9 +5057,11 @@ dependencies = [ name = "subxt-signer" version = "0.37.0" dependencies = [ + "base64 0.22.1", "bip32", "bip39", "cfg-if", + "crypto_secretbox", "getrandom", "hex", "hex-literal", @@ -5032,8 +5072,11 @@ dependencies = [ "proptest", "regex", "schnorrkel", + "scrypt", "secp256k1", "secrecy", + "serde", + "serde_json", "sha2 0.10.8", "sp-core", "sp-crypto-hashing", diff --git a/Cargo.toml b/Cargo.toml index 39ddb19f9c..61688b900c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -162,6 +162,9 @@ keccak-hash = { version = "0.10.0", default-features = false } secrecy = "0.8.0" sha2 = { version = "0.10.8", default-features = false } zeroize = { version = "1", default-features = false } +base64 = { version = "0.22.1", default-features = false } +scrypt = { version = "0.11.0", default-features = false } +crypto_secretbox = { version = "0.1.1", default-features = false } [profile.dev.package.smoldot-light] opt-level = 2 diff --git a/signer/Cargo.toml b/signer/Cargo.toml index 17356a9722..6018483970 100644 --- a/signer/Cargo.toml +++ b/signer/Cargo.toml @@ -23,8 +23,13 @@ std = [ "sha2/std", "hmac/std", "bip39/std", - "schnorrkel/std", - "secp256k1/std", + "schnorrkel?/std", + "secp256k1?/std", + "serde?/std", + "serde_json?/std", + "base64?/std", + "scrypt?/std", + "crypto_secretbox?/std", ] # Pick the signer implementation(s) you need by enabling the @@ -35,6 +40,9 @@ sr25519 = ["schnorrkel"] ecdsa = ["secp256k1"] unstable-eth = ["keccak-hash", "ecdsa", "secp256k1", "bip32"] +# Enable support for loading key pairs from polkadot-js json. +polkadot-js-compat = ["std", "subxt", "sr25519", "base64", "scrypt", "crypto_secretbox", "serde", "serde_json"] + # Make the keypair algorithms here compatible with Subxt's Signer trait, # so that they can be used to sign transactions for compatible chains. subxt = ["dep:subxt-core"] @@ -66,6 +74,13 @@ secp256k1 = { workspace = true, optional = true, features = [ ] } keccak-hash = { workspace = true, optional = true } +# These are used if the polkadot-js-compat feature is enabled +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +base64 = { workspace = true, optional = true, features = ["alloc"] } +scrypt = { workspace = true, default-features = false, optional = true } +crypto_secretbox = { workspace = true, optional = true, features = ["alloc", "salsa20"] } + # We only pull this in to enable the JS flag for schnorrkel to use. getrandom = { workspace = true, optional = true } @@ -86,4 +101,4 @@ rustdoc-args = ["--cfg", "docsrs"] defalt-features = true [lints] -workspace = true \ No newline at end of file +workspace = true diff --git a/signer/src/lib.rs b/signer/src/lib.rs index 23865bf81c..7b1fdc3174 100644 --- a/signer/src/lib.rs +++ b/signer/src/lib.rs @@ -37,6 +37,11 @@ pub mod ecdsa; #[cfg_attr(docsrs, doc(cfg(feature = "unstable-eth")))] pub mod eth; +/// A polkadot-js account json loader. +#[cfg(feature = "polkadot-js-compat")] +#[cfg_attr(docsrs, doc(cfg(feature = "polkadot-js-compat")))] +pub mod polkadot_js_compat; + // Re-export useful bits and pieces for generating a Pair from a phrase, // namely the Mnemonic struct. pub use bip39; diff --git a/signer/src/polkadot_js_compat.rs b/signer/src/polkadot_js_compat.rs new file mode 100644 index 0000000000..b90f87efa6 --- /dev/null +++ b/signer/src/polkadot_js_compat.rs @@ -0,0 +1,213 @@ +// Copyright 2019-2024 Parity Technologies (UK) Ltd. +// This file is dual-licensed as Apache-2.0 or GPL-3.0. +// see LICENSE for license details. + +//! A Polkadot-JS account loader. + +use base64::Engine; +use core::fmt::Display; +use crypto_secretbox::{ + aead::{Aead, KeyInit}, + Key, Nonce, XSalsa20Poly1305, +}; +use serde::Deserialize; +use subxt_core::utils::AccountId32; + +use crate::sr25519; + +/// Given a JSON keypair as exported from Polkadot-JS, this returns an [`sr25519::Keypair`] +pub fn decrypt_json(json: &str, password: &str) -> Result { + let pair_json: KeyringPairJson = serde_json::from_str(json)?; + Ok(pair_json.decrypt(password)?) +} + +/// Error +#[derive(Debug)] +pub enum Error { + /// Error decoding JSON. + Json(serde_json::Error), + /// The keypair has an unsupported encoding. + UnsupportedEncoding, + /// Base64 decoding error. + Base64(base64::DecodeError), + /// Wrong Scrypt parameters + UnsupportedScryptParameters { + /// N + n: u32, + /// p + p: u32, + /// r + r: u32, + }, + /// Decryption error. + Secretbox(crypto_secretbox::Error), + /// sr25519 keypair error. + Sr25519(sr25519::Error), + /// The decrypted keys are not valid. + InvalidKeys, +} + +impl_from!(serde_json::Error => Error::Json); +impl_from!(base64::DecodeError => Error::Base64); +impl_from!(crypto_secretbox::Error => Error::Secretbox); +impl_from!(sr25519::Error => Error::Sr25519); + +impl Display for Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Error::Json(e) => write!(f, "Invalid JSON: {e}"), + Error::UnsupportedEncoding => write!(f, "Unsupported encoding."), + Error::Base64(e) => write!(f, "Base64 decoding error: {e}"), + Error::UnsupportedScryptParameters { n, p, r } => { + write!(f, "Unsupported Scrypt parameters: N: {n}, p: {p}, r: {r}") + } + Error::Secretbox(e) => write!(f, "Decryption error: {e}"), + Error::Sr25519(e) => write!(f, "{e}"), + Error::InvalidKeys => write!(f, "The decrypted keys are not valid."), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +#[derive(Deserialize)] +struct EncryptionMetadata { + /// Descriptor for the content + content: Vec, + /// The encoding (in current/latest versions this is always an array) + r#type: Vec, + /// The version of encoding applied + version: String, +} + +/// https://github.com/polkadot-js/common/blob/37fa211fdb141d4f6eb32e8f377a4651ed2d9068/packages/keyring/src/types.ts#L67 +#[derive(Deserialize)] +struct KeyringPairJson { + /// The encoded string + encoded: String, + /// The encoding used + encoding: EncryptionMetadata, + /// The ss58 encoded address or the hex-encoded version (the latter is for ETH-compat chains) + address: AccountId32, +} + +// This can be removed once split_array is stabilized. +fn slice_to_u32(slice: &[u8]) -> u32 { + u32::from_le_bytes(slice.try_into().expect("Slice should be 4 bytes.")) +} + +impl KeyringPairJson { + /// Decrypt JSON keypair. + fn decrypt(self, password: &str) -> Result { + // Check encoding. + // https://github.com/polkadot-js/common/blob/37fa211fdb141d4f6eb32e8f377a4651ed2d9068/packages/keyring/src/keyring.ts#L166 + if self.encoding.version != "3" + || !self.encoding.content.contains(&"pkcs8".to_owned()) + || !self.encoding.content.contains(&"sr25519".to_owned()) + || !self.encoding.r#type.contains(&"scrypt".to_owned()) + || !self + .encoding + .r#type + .contains(&"xsalsa20-poly1305".to_owned()) + { + return Err(Error::UnsupportedEncoding); + } + + // Decode from Base64. + let decoded = base64::engine::general_purpose::STANDARD.decode(self.encoded)?; + let params: [u8; 68] = decoded[..68] + .try_into() + .map_err(|_| Error::UnsupportedEncoding)?; + + // Extract scrypt parameters. + // https://github.com/polkadot-js/common/blob/master/packages/util-crypto/src/scrypt/fromU8a.ts + let salt = ¶ms[0..32]; + let n = slice_to_u32(¶ms[32..36]); + let p = slice_to_u32(¶ms[36..40]); + let r = slice_to_u32(¶ms[40..44]); + + // FIXME At this moment we assume these to be fixed params, this is not a great idea + // since we lose flexibility and updates for greater security. However we need some + // protection against carefully-crafted params that can eat up CPU since these are user + // inputs. So we need to get very clever here, but atm we only allow the defaults + // and if no match, bail out. + if n != 32768 || p != 1 || r != 8 { + return Err(Error::UnsupportedScryptParameters { n, p, r }); + } + + // Hash password. + let scrypt_params = + scrypt::Params::new(15, 8, 1, 32).expect("Provided parameters should be valid."); + let mut key = Key::default(); + scrypt::scrypt(password.as_bytes(), salt, &scrypt_params, &mut key) + .expect("Key should be 32 bytes."); + + // Decrypt keys. + // https://github.com/polkadot-js/common/blob/master/packages/util-crypto/src/json/decryptData.ts + let cipher = XSalsa20Poly1305::new(&key); + let nonce = Nonce::from_slice(¶ms[44..68]); + let ciphertext = &decoded[68..]; + let plaintext = cipher.decrypt(nonce, ciphertext)?; + + // https://github.com/polkadot-js/common/blob/master/packages/keyring/src/pair/decode.ts + if plaintext.len() != 117 { + return Err(Error::InvalidKeys); + } + + let header = &plaintext[0..16]; + let secret_key = &plaintext[16..80]; + let div = &plaintext[80..85]; + let public_key = &plaintext[85..117]; + + if header != [48, 83, 2, 1, 1, 48, 5, 6, 3, 43, 101, 112, 4, 34, 4, 32] + || div != [161, 35, 3, 33, 0] + { + return Err(Error::InvalidKeys); + } + + // Generate keypair. + let keypair = sr25519::Keypair::from_ed25519_bytes(secret_key)?; + + // Ensure keys are correct. + if keypair.public_key().0 != public_key + || keypair.public_key().to_account_id() != self.address + { + return Err(Error::InvalidKeys); + } + + Ok(keypair) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_get_keypair_sr25519() { + let json = r#" + { + "encoded": "DumgApKCTqoCty1OZW/8WS+sgo6RdpHhCwAkA2IoDBMAgAAAAQAAAAgAAAB6IG/q24EeVf0JqWqcBd5m2tKq5BlyY84IQ8oamLn9DZe9Ouhgunr7i36J1XxUnTI801axqL/ym1gil0U8440Qvj0lFVKwGuxq38zuifgoj0B3Yru0CI6QKEvQPU5xxj4MpyxdSxP+2PnTzYao0HDH0fulaGvlAYXfqtU89xrx2/z9z7IjSwS3oDFPXRQ9kAdDebtyCVreZ9Otw9v3", + "encoding": { + "content": [ + "pkcs8", + "sr25519" + ], + "type": [ + "scrypt", + "xsalsa20-poly1305" + ], + "version": "3" + }, + "address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "meta": { + "genesisHash": "", + "name": "Alice", + "whenCreated": 1718265838755 + } + } + "#; + decrypt_json(json, "whoisalice").unwrap(); + } +} diff --git a/signer/src/sr25519.rs b/signer/src/sr25519.rs index 2a9eb84ecd..77ee6a113b 100644 --- a/signer/src/sr25519.rs +++ b/signer/src/sr25519.rs @@ -122,6 +122,18 @@ impl Keypair { Ok(Keypair(keypair)) } + /// Construct a keypair from a slice of bytes, corresponding to + /// an Ed25519 expanded secret key. + #[cfg(feature = "polkadot-js-compat")] + pub(crate) fn from_ed25519_bytes(bytes: &[u8]) -> Result { + let secret_key = schnorrkel::SecretKey::from_ed25519_bytes(bytes)?; + + Ok(Keypair(schnorrkel::Keypair { + public: secret_key.to_public(), + secret: secret_key, + })) + } + /// Derive a child key from this one given a series of junctions. /// /// # Example @@ -199,10 +211,13 @@ pub enum Error { Phrase(bip39::Error), /// Invalid hex. Hex(hex::FromHexError), + /// Signature error. + Signature(schnorrkel::SignatureError), } impl_from!(bip39::Error => Error::Phrase); impl_from!(hex::FromHexError => Error::Hex); +impl_from!(schnorrkel::SignatureError => Error::Signature); impl Display for Error { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { @@ -210,6 +225,7 @@ impl Display for Error { Error::InvalidSeed => write!(f, "Invalid seed (was it the wrong length?)"), Error::Phrase(e) => write!(f, "Cannot parse phrase: {e}"), Error::Hex(e) => write!(f, "Cannot parse hex string: {e}"), + Error::Signature(e) => write!(f, "Signature error: {e}"), } } }