From 7d119cf5f9b7445bd2ac530175c193d0aca7930b Mon Sep 17 00:00:00 2001 From: clearloop <26088946+clearloop@users.noreply.github.com> Date: Fri, 2 Feb 2024 06:43:19 +0800 Subject: [PATCH] feat(keyring): introduce gring (#3619) --- Cargo.lock | 22 +++ Cargo.toml | 1 + Makefile | 3 +- utils/crates-io/src/lib.rs | 3 +- utils/gring/Cargo.toml | 39 ++++++ utils/gring/README.md | 7 + utils/gring/res/pair.json | 14 ++ utils/gring/src/bin/gring.rs | 55 ++++++++ utils/gring/src/cmd.rs | 245 ++++++++++++++++++++++++++++++++++ utils/gring/src/keyring.rs | 177 ++++++++++++++++++++++++ utils/gring/src/keystore.rs | 229 +++++++++++++++++++++++++++++++ utils/gring/src/lib.rs | 32 +++++ utils/gring/src/pair.rs | 126 +++++++++++++++++ utils/gring/src/scrypt.rs | 116 ++++++++++++++++ utils/gring/src/ss58.rs | 117 ++++++++++++++++ utils/gring/tests/command.rs | 91 +++++++++++++ utils/gring/tests/keystore.rs | 34 +++++ 17 files changed, 1309 insertions(+), 2 deletions(-) create mode 100644 utils/gring/Cargo.toml create mode 100644 utils/gring/README.md create mode 100644 utils/gring/res/pair.json create mode 100644 utils/gring/src/bin/gring.rs create mode 100644 utils/gring/src/cmd.rs create mode 100644 utils/gring/src/keyring.rs create mode 100644 utils/gring/src/keystore.rs create mode 100644 utils/gring/src/lib.rs create mode 100644 utils/gring/src/pair.rs create mode 100644 utils/gring/src/scrypt.rs create mode 100644 utils/gring/src/ss58.rs create mode 100644 utils/gring/tests/command.rs create mode 100644 utils/gring/tests/keystore.rs diff --git a/Cargo.lock b/Cargo.lock index 491f807f6f3..7fafa6ec81a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4583,6 +4583,28 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "gring" +version = "1.1.0" +dependencies = [ + "anyhow", + "base64 0.21.7", + "blake2", + "bs58 0.5.0", + "clap 4.4.18", + "colored", + "dirs", + "hex", + "nacl", + "once_cell", + "rand 0.8.5", + "schnorrkel 0.9.1", + "serde", + "serde_json", + "tracing", + "tracing-subscriber 0.3.18", +] + [[package]] name = "group" version = "0.13.0" diff --git a/Cargo.toml b/Cargo.toml index 14c5a713f05..95707c5ea72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -458,6 +458,7 @@ toml_edit = "0.21.0" # crat scale-decode = "0.9.0" # gsdk directories = "5.0.1" # utils/key-finder num-traits = { version = "0.2", default-features = false } # gear-core +blake2 = "0.10.6" # gring # TODO: remove after wasmer bug is fixed: # `misaligned pointer dereference: address must be a multiple of 0x8 but is...` diff --git a/Makefile b/Makefile index a48a07cf491..f4480f181ee 100644 --- a/Makefile +++ b/Makefile @@ -197,7 +197,8 @@ test-gear: # Crates except gclient, gcli, gsdk are excluded to significantly dec --exclude gear-authorship \ --exclude pallet-gear-staking-rewards \ --exclude gear-wasm-gen \ - --exclude demo-stack-allocations + --exclude demo-stack-allocations \ + --exclude gring .PHONY: test-gear-release test-gear-release: diff --git a/utils/crates-io/src/lib.rs b/utils/crates-io/src/lib.rs index a19b8739530..fbfb9335b3d 100644 --- a/utils/crates-io/src/lib.rs +++ b/utils/crates-io/src/lib.rs @@ -66,7 +66,8 @@ pub const STACKED_DEPENDENCIES: [&str; 13] = [ /// Packages need to be published. /// /// NOTE: DO NOT change the order of this array. -pub const PACKAGES: [&str; 6] = [ +pub const PACKAGES: [&str; 7] = [ + "gring", "gear-wasm-builder", "gstd", "gtest", diff --git a/utils/gring/Cargo.toml b/utils/gring/Cargo.toml new file mode 100644 index 00000000000..30f96b84aee --- /dev/null +++ b/utils/gring/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "gring" +description = "Substrate keystore implementation" +keywords = [ "substrate", "gear", "keystore" ] +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[[bin]] +name = "gring" +path = "src/bin/gring.rs" +required-features = ["cli"] + +[dependencies] +anyhow.workspace = true +blake2.workspace = true +bs58 = { workspace = true, features = ["alloc"] } +base64.workspace = true +nacl.workspace = true +once_cell.workspace = true +rand = { workspace = true, features = ["std", "std_rng"] } +schnorrkel.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +tracing.workspace = true + +# Feature CLI +clap = { workspace = true, features = ["derive"], optional = true } +colored = { workspace = true, optional = true } +dirs = { workspace = true, optional = true } +hex = { workspace = true, features = ["std"], optional = true } +tracing-subscriber = { workspace = true, features = ["env-filter"], optional = true } + +[features] +default = ["cli"] +cli = ["clap", "colored", "dirs", "hex", "tracing-subscriber"] diff --git a/utils/gring/README.md b/utils/gring/README.md new file mode 100644 index 00000000000..58a39fb71da --- /dev/null +++ b/utils/gring/README.md @@ -0,0 +1,7 @@ +# keyring + +This crate implements a sr25519 keyring which is synced from [the keystore implementation in polkadot-js][keystore-format]. + +## Keystore + +[keystore-format]: https://github.com/polkadot-js/common/blob/6971012f4af62f453ba25d83d0ebbfd12eaf5709/packages/util-crypto/src/json/encryptFormat.ts#L9 diff --git a/utils/gring/res/pair.json b/utils/gring/res/pair.json new file mode 100644 index 00000000000..3e2f3b2e4bf --- /dev/null +++ b/utils/gring/res/pair.json @@ -0,0 +1,14 @@ +{ + "encoded": "X/sAaS3pNejnqvbHk0lne8tcXXmTu2gPQgXvtbf3azgAgAAAAQAAAAgAAABxGGfnP+9PCbP7Gp0+7jxxl8twTthzIq4pLfC0m6NvA8hk557A4dkDapszVKhlyDhTvnQQE2WwhqzkfDwvq0XtFl9PDW6ShvVM/lSVLkZTF6QGnTzRZ2dwT7+X5v+gjFIJftI5z3vLFg7NM+NXy7kxU039iooVTxYDqzCnMSjXMBtnY2cqNedlGUcrbDGE0lNdWqu3MWT9J27kmysC", + "encoding": { + "content": ["pkcs8", "sr25519"], + "type": ["scrypt", "xsalsa20-poly1305"], + "version": "3" + }, + "address": "5Hax9tpSjfiX1nYrqhFf8F3sLiaa2ZfPv2VeDQzPBLzKNjRq", + "meta": { + "genesisHash": "", + "name": "GEAR", + "whenCreated": 1659544420591 + } +} diff --git a/utils/gring/src/bin/gring.rs b/utils/gring/src/bin/gring.rs new file mode 100644 index 00000000000..a2f44ea54a4 --- /dev/null +++ b/utils/gring/src/bin/gring.rs @@ -0,0 +1,55 @@ +// This file is part of Gear. +// +// Copyright (C) 2024 Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use anyhow::Result; +use clap::{CommandFactory, Parser}; +use gring::cmd::Command; +use tracing_subscriber::filter::EnvFilter; + +/// Gear keyring. +#[derive(Parser)] +pub struct Opt { + /// The verbosity level. + #[arg(global = true, short, long, action = clap::ArgAction::Count)] + pub verbose: u8, + + /// Sub commands. + #[command(subcommand)] + pub command: Command, +} + +impl Opt { + /// Run the CLI with logger. + pub fn start() -> Result<()> { + let app = Self::parse(); + let name = Self::command().get_name().to_string(); + let env = EnvFilter::try_from_default_env().unwrap_or(EnvFilter::new(match app.verbose { + 0 => format!("{name}=info"), + 1 => format!("{name}=debug"), + 2 => "debug".into(), + _ => "trace".into(), + })); + + tracing_subscriber::fmt().with_env_filter(env).init(); + app.command.run() + } +} + +fn main() -> anyhow::Result<()> { + Opt::start() +} diff --git a/utils/gring/src/cmd.rs b/utils/gring/src/cmd.rs new file mode 100644 index 00000000000..5ac74667027 --- /dev/null +++ b/utils/gring/src/cmd.rs @@ -0,0 +1,245 @@ +// This file is part of Gear. +// +// Copyright (C) 2024 Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! CLI implementation for gring. + +#![cfg(feature = "cli")] + +use crate::{ss58, Keyring, Keystore}; +use anyhow::{anyhow, Result}; +use clap::Parser; +use colored::{ColoredString, Colorize}; +use schnorrkel::{PublicKey, Signature}; +use std::{fs, path::PathBuf}; + +/// gring sub commands. +#[derive(Parser)] +pub enum Command { + /// Generate a new key. + New { + /// The name of the key. + name: String, + /// The passphrase of the key. + #[arg(short, long)] + passphrase: String, + /// If the key should be a vanity key. + #[arg(short, long)] + vanity: Option, + }, + /// List all keys in keystore. + #[clap(visible_alias = "l")] + List { + /// If only list the primary key. + #[arg(short, long)] + primary: bool, + }, + /// Use the provided key as primary key. + Use { + /// Set the key as the primary key. + key: String, + }, + /// Sign a message. + Sign { + /// The singning context. + #[clap(short, long, default_value = "gring.vara")] + ctx: String, + /// The message to sign. + message: String, + /// the passphrase of the primary key. + #[clap(short, long)] + passphrase: String, + }, + /// Verify a message. + Verify { + /// The singning context. + #[clap(short, long, default_value = "gring.vara")] + ctx: String, + /// The signed to message. + message: String, + /// The signature to verify. + signature: String, + /// The address used in the verification, supports hex + /// public key bytes and VARA ss58 address. + /// + /// NOTE: if not provided, the address of the primary + /// key will be used. + #[arg(short, long)] + address: Option, + }, +} + +impl Command { + /// The path of the keyring store. + /// + /// NOTE: This is currently not configurable. + pub fn store() -> Result { + let app = env!("CARGO_PKG_NAME"); + let store = dirs::data_dir() + .ok_or_else(|| anyhow!("Failed to locate app directory."))? + .join(app); + + fs::create_dir_all(&store).map_err(|e| { + tracing::error!("Failed to create keyring store at {store:?}"); + e + })?; + + tracing::info!( + "keyring store: {}", + store.display().to_string().underline().dimmed() + ); + Ok(store) + } + + /// Run the command. + pub fn run(self) -> Result<()> { + let mut keyring = Keyring::load(Command::store()?)?; + match self { + Command::New { + mut name, + vanity, + passphrase, + } => { + if name.len() > 16 { + return Err(anyhow!("Name must be less than 16 characters.")); + } + + let raw_name = name.clone(); + let path = { + let mut path = keyring.store.join(&name).with_extension("json"); + let mut count = 0; + while path.exists() { + name = format!("{}-{}", &raw_name, count); + path = keyring.store.join(&name).with_extension("json"); + count += 1; + } + + path + }; + + if name != raw_name { + tracing::info!( + "Key {} exists, auto switching to {}", + raw_name.underline(), + name.underline().cyan() + ); + } + + let (keystore, keypair) = + keyring.create(&name, vanity.as_deref(), Some(passphrase.as_ref()))?; + + println!("{:<16}{}", "Name:", name.bold()); + println!("{:<16}{}", "VARA Address: ", keystore.address); + println!("{:<16}0x{}", "Public Key:", hex::encode(keypair.public)); + println!( + "Drag {} to the polkadot.js extension to import it.", + path.display().to_string().underline() + ); + } + Command::List { primary } => { + let key = keyring.primary()?; + if primary { + Self::print_key(&key); + return Ok(()); + } + + println!("| {:<16} | {:<49} |", "Name".bold(), "Address".bold()); + println!("| {} | {} |", "-".repeat(16), "-".repeat(49)); + + for key in keyring.list() { + let mut name: ColoredString = key.meta.name.clone().into(); + let mut address: ColoredString = key.address.clone().into(); + if key.meta.name == keyring.primary { + name = name.cyan(); + address = address.cyan(); + }; + + println!("| {name:<16} | {address} |"); + } + } + Command::Use { key } => { + let key = keyring.set_primary(key)?; + println!("The primary key has been updated to:"); + Self::print_key(&key); + } + Command::Sign { + ctx, + message, + passphrase, + } => { + let key = keyring.primary()?; + let pair = key.decrypt_scrypt(passphrase.as_ref()).map_err(|e| { + anyhow!("Incorrect passphrase, failed to decrypt keystore, {e}") + })?; + let sig = pair + .sign(schnorrkel::signing_context(ctx.as_bytes()).bytes(message.as_bytes())); + println!("{:<16}{}", "Key:", key.meta.name.green().bold()); + println!("{:<16}{}", "SS58 Address:", key.address); + println!("{:<16}{ctx}", "Context:"); + println!("{:<16}{message}", "Message:"); + println!("{:<16}0x{}", "Signature:", hex::encode(sig.to_bytes())); + } + Command::Verify { + ctx, + message, + signature, + address, + } => { + let pk_bytes = if let Some(address) = address { + if let Some(encoded) = address.strip_prefix("0x") { + hex::decode(encoded).map_err(Into::into) + } else { + ss58::decode(address.as_bytes(), 32) + } + } else { + let key = keyring.primary()?; + ss58::decode(key.address.as_bytes(), 32) + }?; + + let pk = PublicKey::from_bytes(&pk_bytes) + .map_err(|e| anyhow!("Failed to decode public key, {e}"))?; + + let result = if pk + .verify( + schnorrkel::signing_context(ctx.as_bytes()).bytes(message.as_bytes()), + &Signature::from_bytes(&hex::decode(signature.trim_start_matches("0x"))?) + .map_err(|e| anyhow!("Failed to decode signature, {e}"))?, + ) + .is_ok() + { + "Verified".green().bold() + } else { + "Not Verified".red().bold() + }; + + println!("{:<16}{result}", "Result:"); + println!("{:<16}{ctx}", "Context:"); + println!("{:<16}{message}", "Message:"); + println!("{:<16}0x{signature}", "Signature:"); + println!("{:<16}0x{}", "Public Key:", hex::encode(&pk_bytes)); + println!("{:<16}{}", "SS58 Address:", ss58::encode(&pk_bytes)); + } + } + Ok(()) + } + + /// Print a single key. + fn print_key(key: &Keystore) { + println!("Name: {}", key.meta.name.to_string().bold()); + println!("VARA Address: {}", key.address.to_string().underline()); + } +} diff --git a/utils/gring/src/keyring.rs b/utils/gring/src/keyring.rs new file mode 100644 index 00000000000..cbc7da1e4ad --- /dev/null +++ b/utils/gring/src/keyring.rs @@ -0,0 +1,177 @@ +// This file is part of Gear. +// +// Copyright (C) 2024 Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Keyring implementation based on the polkadot-js keystore. + +use crate::{ + ss58::{self, VARA_SS58_PREFIX}, + Keystore, +}; +use anyhow::Result; +use colored::Colorize; +use schnorrkel::Keypair; +use serde::{Deserialize, Serialize}; +use std::{fs, path::PathBuf}; + +const CONFIG: &str = "keyring.json"; + +/// Gear keyring. +#[derive(Default, Serialize, Deserialize)] +pub struct Keyring { + /// Path to the store. + #[serde(skip)] + pub store: PathBuf, + /// A set of keystore instances. + #[serde(skip)] + ring: Vec, + /// The primary key. + pub primary: String, + /// The SS58 prefix. + #[serde(default = "ss58::default_ss58_version")] + pub ss58_version: u16, +} + +impl Keyring { + /// Loads the keyring from the store. + /// + /// NOTE: For the store path, see [`STORE`]. + pub fn load(store: PathBuf) -> Result { + let ring = fs::read_dir(&store)? + .filter_map(|entry| { + let path = entry.ok()?.path(); + let content = fs::read(&path).ok()?; + if path.ends_with(CONFIG) { + return None; + } + + serde_json::from_slice(&content) + .map_err(|err| { + tracing::warn!("Failed to load keystore at {path:?}: {err}"); + err + }) + .ok() + }) + .collect::>(); + + let config = store.join(CONFIG); + let mut this = if config.exists() { + serde_json::from_slice(&fs::read(&config)?)? + } else { + Self::default() + }; + + if this.ss58_version != VARA_SS58_PREFIX { + ss58::set_default_ss58_version(this.ss58_version); + } + + this.ring = ring; + this.store = store; + + Ok(this) + } + + /// Update and get the primary key. + pub fn primary(&mut self) -> Result { + if self.ring.is_empty() { + return Err(anyhow::anyhow!( + "No keys in keyring, run {} to create a new one.", + "`gring generate -p `" + .underline() + .cyan() + .bold() + )); + } + + if let Some(key) = self + .ring + .iter() + .find(|k| k.meta.name == self.primary) + .cloned() + { + Ok(key) + } else { + self.primary = self.ring[0].meta.name.clone(); + fs::write(self.store.join(CONFIG), serde_json::to_vec_pretty(&self)?)?; + Ok(self.ring[0].clone()) + } + } + + /// Set the primary key. + pub fn set_primary(&mut self, name: String) -> Result { + let key = self + .ring + .iter() + .find(|k| k.meta.name == name) + .cloned() + .ok_or_else(|| { + anyhow::anyhow!( + "Key with name {} not found, run {} to see all keys in keyring.", + name.underline().bold(), + "`gring list`".underline().cyan().bold() + ) + })?; + + self.primary = name; + fs::write(self.store.join(CONFIG), serde_json::to_vec_pretty(&self)?)?; + Ok(key) + } + + /// Set the SS58 version. + pub fn set_ss58_version(&mut self, version: u16) -> Result<()> { + self.ss58_version = version; + fs::write(self.store.join(CONFIG), serde_json::to_vec_pretty(&self)?)?; + Ok(()) + } + + /// create a new key in keyring. + pub fn create( + &mut self, + name: &str, + vanity: Option<&str>, + passphrase: Option<&str>, + ) -> Result<(Keystore, Keypair)> { + let keypair = if let Some(vanity) = vanity { + tracing::info!("Generating vanity key with prefix {vanity}..."); + let mut keypair = Keypair::generate(); + + while !ss58::encode(&keypair.public.to_bytes()).starts_with(vanity) { + keypair = Keypair::generate(); + } + + keypair + } else { + Keypair::generate() + }; + + let mut keystore = Keystore::encrypt(keypair.clone(), passphrase.map(|p| p.as_bytes()))?; + keystore.meta.name = name.into(); + + fs::write( + self.store.join(&keystore.meta.name).with_extension("json"), + serde_json::to_vec_pretty(&keystore)?, + )?; + + self.ring.push(keystore.clone()); + Ok((keystore, keypair)) + } + + /// List all keystores. + pub fn list(&self) -> &[Keystore] { + self.ring.as_ref() + } +} diff --git a/utils/gring/src/keystore.rs b/utils/gring/src/keystore.rs new file mode 100644 index 00000000000..b027d944a6f --- /dev/null +++ b/utils/gring/src/keystore.rs @@ -0,0 +1,229 @@ +// This file is part of Gear. +// +// Copyright (C) 2024 Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use crate::{ss58, KeypairInfo, Scrypt}; +use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use rand::RngCore; +use schnorrkel::Keypair; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// JSON keystore for storing sr25519 key pair. +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct Keystore { + /// The encoded keypair in base64. + pub encoded: String, + /// Encoding format. + #[serde(default)] + pub encoding: Encoding, + /// The address of the keypair. + pub address: String, + /// The meta data of the keypair. + #[serde(default)] + pub meta: Meta, +} + +impl Keystore { + /// The length of nonce. + const NONCE_LENGTH: usize = 24; + + /// Encrypt the provided keypair with the given password. + pub fn encrypt(keypair: Keypair, passphrase: Option<&[u8]>) -> Result { + let info = KeypairInfo::from(keypair); + if let Some(passphrase) = passphrase { + Self::encrypt_scrypt(info, passphrase) + } else { + Ok(Self::encrypt_none(info)) + } + } + + /// Encrypt keypair info with scrypt. + pub fn encrypt_scrypt(info: KeypairInfo, passphrase: &[u8]) -> Result { + let mut encoded = Vec::new(); + + // 1. Get passwd from scrypt + let scrypt = Scrypt::default(); + let passwd = scrypt.passwd(passphrase)?; + encoded.extend_from_slice(&scrypt.encode()); + + // 2. Generate random nonce + let mut nonce = [0; Self::NONCE_LENGTH]; + rand::thread_rng().fill_bytes(&mut nonce); + encoded.extend_from_slice(&nonce); + + // 3. Pack secret box + let encrypted = nacl::secret_box::pack(&info.encode(), &nonce, &passwd[..32]) + .map_err(|e| anyhow!("{e:?}"))?; + encoded.extend_from_slice(&encrypted); + + Ok(Self { + encoded: STANDARD.encode(&encoded), + address: ss58::encode(&info.public), + encoding: Encoding::scrypt(), + ..Default::default() + }) + } + + /// Encrypt keypair without encryption. + pub fn encrypt_none(info: KeypairInfo) -> Self { + Self { + encoded: STANDARD.encode(info.encode()), + address: ss58::encode(&info.public), + ..Default::default() + } + } + + /// Decrypt keypair from encrypted data. + pub fn decrypt(&self, passphrase: Option<&[u8]>) -> Result { + if let Some(passphrase) = passphrase { + if !self.encoding.is_scrypt() { + return Err(anyhow!( + "unsupported key deriven function {}.", + self.encoding.ty[0] + )); + } + + self.decrypt_scrypt(passphrase) + } else { + if self.encoding.is_xsalsa20_poly1305() { + return Err(anyhow!("password required to decode encrypted data.")); + } + + self.decrypt_none() + } + } + + /// Decrypt keypair from encrypted data with scrypt. + pub fn decrypt_scrypt(&self, passphrase: &[u8]) -> Result { + let decoded = self.decoded()?; + + // 1. Get passwd from scrypt + let mut encoded_scrypt = [0; Scrypt::ENCODED_LENGTH]; + encoded_scrypt.copy_from_slice(&decoded[..Scrypt::ENCODED_LENGTH]); + let passwd = Scrypt::decode(encoded_scrypt).passwd(passphrase)?; + + // 2. Decrypt the secret key with xsalsa20-poly1305 + let encrypted = &decoded[Scrypt::ENCODED_LENGTH..]; + let secret = nacl::secret_box::open( + &encrypted[Self::NONCE_LENGTH..], + &encrypted[..Self::NONCE_LENGTH], + &passwd[..32], + ) + .map_err(|e| anyhow!("{e:?}"))?; + + // 3. Decode the secret key to keypair + KeypairInfo::decode(&secret[..KeypairInfo::ENCODED_LENGTH])?.into_keypair() + } + + /// Decrypt keypair from data without encryption. + pub fn decrypt_none(&self) -> Result { + KeypairInfo::decode(&self.decoded()?)?.into_keypair() + } + + /// Returns self with the given name in meta. + pub fn with_name(mut self, name: &str) -> Self { + self.meta.name = name.to_owned(); + self + } + + /// Decode the encoded keypair info with base64. + fn decoded(&self) -> Result> { + STANDARD.decode(&self.encoded).map_err(Into::into) + } +} + +/// Encoding format for the keypair. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Encoding { + /// The content of the keystore. + /// + /// - The first element is the standard. + /// - The second element is the key algorithm. + pub content: (String, String), + + /// The type of the keystore. + /// + /// - The first element is the key deriven function of the keystore. + /// - if the first element is `none`, there will be no cipher following. + /// - The second element is the encryption cipher of the keystore. + #[serde(rename = "type")] + pub ty: Vec, + + /// The version of the keystore. + pub version: String, +} + +impl Encoding { + /// None encoding format. + pub fn none() -> Self { + Self { + content: ("pkcs8".into(), "sr25519".into()), + ty: vec!["none".into()], + version: "3".to_string(), + } + } + + /// Recommend encoding format. + pub fn scrypt() -> Self { + Self { + content: ("pkcs8".into(), "sr25519".into()), + ty: vec!["scrypt".into(), "xsalsa20-poly1305".into()], + ..Default::default() + } + } + + /// Check if is encoding with scrypt. + pub fn is_scrypt(&self) -> bool { + self.ty.get(0) == Some(&"scrypt".into()) + } + + /// Check if the cipher is xsalsa20-poly1305. + pub fn is_xsalsa20_poly1305(&self) -> bool { + self.ty.get(1) == Some(&"xsalsa20-poly1305".into()) + } +} + +impl Default for Encoding { + fn default() -> Self { + Self::none() + } +} + +/// The metadata of the key pair. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Meta { + /// The name of the key pair. + pub name: String, + + /// The timestamp when the key pair is created in milliseconds. + #[serde(rename = "whenCreated")] + pub when_created: u128, +} + +impl Default for Meta { + fn default() -> Self { + Self { + name: "".into(), + when_created: SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_millis(), + } + } +} diff --git a/utils/gring/src/lib.rs b/utils/gring/src/lib.rs new file mode 100644 index 00000000000..32f52f6f656 --- /dev/null +++ b/utils/gring/src/lib.rs @@ -0,0 +1,32 @@ +// This file is part of Gear. +// +// Copyright (C) 2024 Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +mod keyring; +mod keystore; +mod pair; +mod scrypt; + +pub mod cmd; +pub mod ss58; + +pub use self::{ + keyring::Keyring, + keystore::{Encoding, Keystore}, + pair::KeypairInfo, + scrypt::Scrypt, +}; diff --git a/utils/gring/src/pair.rs b/utils/gring/src/pair.rs new file mode 100644 index 00000000000..abe3aa3b28c --- /dev/null +++ b/utils/gring/src/pair.rs @@ -0,0 +1,126 @@ +// This file is part of Gear. +// +// Copyright (C) 2024 Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use anyhow::{anyhow, Result}; +use schnorrkel::{Keypair, KEYPAIR_LENGTH, PUBLIC_KEY_LENGTH, SECRET_KEY_LENGTH}; + +/// Key info wrapped in pkcs8 format. +/// +/// NOTE: the meaning of these bytes is ambiguous for now, see +/// +/// +/// For the encoded data format of this implementation: +/// +/// ENCODED(117) = HEADER(16) + SECRET_KEY_LENGTH(64) + DIVIDER(5) + PUBLIC_KEY_LENGTH(32) +pub struct KeypairInfo { + /// Schnorrkel secret key. + pub secret: [u8; SECRET_KEY_LENGTH], + /// Schnorrkel public key. + pub public: [u8; PUBLIC_KEY_LENGTH], +} + +impl KeypairInfo { + /// The length of the pkcs8 key info. + /// + /// NOTE: LENGTH(117) = HEADER(16) + SECRET_KEY_LENGTH + DIVIDER(5) + PUBLIC_KEY_LENGTH + pub const ENCODED_LENGTH: usize = 117; + + /// The length of pkcs8 header in polkadot-js. + const PKCS8_HEADER_LENGTH: usize = 16; + + /// The pkcs8 header used in polkadot-js. + const PKCS8_HEADER: [u8; Self::PKCS8_HEADER_LENGTH] = + [48, 83, 2, 1, 1, 48, 5, 6, 3, 43, 101, 112, 4, 34, 4, 32]; + + /// The length of pkcs8 divider in polkadot-js. + const PKCS8_DIVIDER_LENGTH: usize = 5; + + /// The pkcs8 divider used in polkadot-js. + const PKCS8_DIVIDER: [u8; Self::PKCS8_DIVIDER_LENGTH] = [161, 35, 3, 33, 0]; + + /// The offset of secret key in pkcs8 key info. + const SECRET_KEY_OFFSET: usize = Self::PKCS8_HEADER_LENGTH; + + /// The offset of divider in pkcs8 key info. + const PKCS8_DIVIDER_OFFSET: usize = Self::PKCS8_HEADER_LENGTH + SECRET_KEY_LENGTH; + + /// The offset of public key in pkcs8 key info. + const PUBLIC_KEY_OFFSET: usize = + Self::SECRET_KEY_OFFSET + SECRET_KEY_LENGTH + Self::PKCS8_DIVIDER_LENGTH; + + /// Decode key info from fixed bytes. + pub fn decode(data: &[u8]) -> Result { + if data[..Self::PKCS8_HEADER_LENGTH] != Self::PKCS8_HEADER { + return Err(anyhow!("invalid pkcs8 header")); + } + + if data[Self::PKCS8_DIVIDER_OFFSET..Self::PKCS8_DIVIDER_OFFSET + Self::PKCS8_DIVIDER_LENGTH] + != Self::PKCS8_DIVIDER + { + return Err(anyhow!("invalid pkcs8 divider")); + } + + let mut encoded = [0; Self::ENCODED_LENGTH]; + encoded.copy_from_slice(data); + + let mut secret = [0u8; SECRET_KEY_LENGTH]; + let mut public = [0u8; PUBLIC_KEY_LENGTH]; + + secret.copy_from_slice( + &encoded[Self::SECRET_KEY_OFFSET..Self::SECRET_KEY_OFFSET + SECRET_KEY_LENGTH], + ); + public.copy_from_slice( + &encoded[Self::PUBLIC_KEY_OFFSET..Self::PUBLIC_KEY_OFFSET + PUBLIC_KEY_LENGTH], + ); + + Ok(Self { secret, public }) + } + + /// Encode self to fixed bytes. + pub fn encode(&self) -> [u8; Self::ENCODED_LENGTH] { + let mut encoded = [0; Self::ENCODED_LENGTH]; + + encoded[..Self::PKCS8_HEADER_LENGTH].copy_from_slice(&Self::PKCS8_HEADER); + encoded[Self::SECRET_KEY_OFFSET..Self::SECRET_KEY_OFFSET + SECRET_KEY_LENGTH] + .copy_from_slice(&self.secret); + encoded + [Self::PKCS8_DIVIDER_OFFSET..Self::PKCS8_DIVIDER_OFFSET + Self::PKCS8_DIVIDER_LENGTH] + .copy_from_slice(&Self::PKCS8_DIVIDER); + encoded[Self::PUBLIC_KEY_OFFSET..Self::PUBLIC_KEY_OFFSET + PUBLIC_KEY_LENGTH] + .copy_from_slice(&self.public); + + encoded + } + + /// Convert self to schnorrkel keypair. + pub fn into_keypair(self) -> Result { + let mut bytes = [0u8; KEYPAIR_LENGTH]; + bytes[..SECRET_KEY_LENGTH].copy_from_slice(&self.secret); + bytes[SECRET_KEY_LENGTH..].copy_from_slice(&self.public); + Keypair::from_half_ed25519_bytes(&bytes) + .map_err(|e| anyhow!("Failed to create pair: {e:?}")) + } +} + +impl From for KeypairInfo { + fn from(keypair: Keypair) -> Self { + let secret = keypair.secret.to_ed25519_bytes(); + let public = keypair.public.to_bytes(); + Self { secret, public } + } +} diff --git a/utils/gring/src/scrypt.rs b/utils/gring/src/scrypt.rs new file mode 100644 index 00000000000..d443b356017 --- /dev/null +++ b/utils/gring/src/scrypt.rs @@ -0,0 +1,116 @@ +// This file is part of Gear. +// +// Copyright (C) 2024 Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! codec for keys. + +use anyhow::{anyhow, Result}; +use rand::RngCore; +use schnorrkel::PUBLIC_KEY_LENGTH; + +/// Parameters of scrypt +pub struct Scrypt { + /// Salt used for scrypt. + pub salt: [u8; Self::SALT_LENGTH], + /// CPU/memory cost parameter, must be power of 2 (e.g. 1024). + pub n: u32, + /// Block size parameter, which fine-tunes sequential memory + /// read size and performance ( 8 is commonly used ). + pub r: u32, + /// Parallelization parameter ( 1 .. 2^32 -1 * hLen/MFlen ). + pub p: u32, +} + +impl Scrypt { + /// The length of encoded scrypt params. + /// + /// NOTE: SALT(32) + N(4) + R(4) + P(4) + pub const ENCODED_LENGTH: usize = 44; + + /// The length of salt used for scrypt. + const SALT_LENGTH: usize = 32; + + /// Read from encoded data. + pub fn decode(encoded: [u8; Self::ENCODED_LENGTH]) -> Self { + let mut salt = [0; Self::SALT_LENGTH]; + salt.copy_from_slice(&encoded[..Self::SALT_LENGTH]); + + let params = encoded[Self::SALT_LENGTH..] + .chunks(4) + .map(|bytes| { + let mut buf = [0; 4]; + buf.copy_from_slice(bytes); + u32::from_le_bytes(buf) + }) + .collect::>(); + + Self { + salt, + n: params[0].ilog2(), + r: params[2], + p: params[1], + } + } + + /// Encode self to bytes. + pub fn encode(&self) -> [u8; Self::ENCODED_LENGTH] { + let mut buf = [0; Self::ENCODED_LENGTH]; + let n = 1 << self.n; + buf[..Self::SALT_LENGTH].copy_from_slice(&self.salt); + buf[Self::SALT_LENGTH..].copy_from_slice( + [n, self.p, self.r] + .iter() + .flat_map(|n| n.to_le_bytes()) + .collect::>() + .as_slice(), + ); + + buf + } + + /// Get passwd from passphrase. + pub fn passwd(&self, passphrase: &[u8]) -> Result<[u8; 32]> { + let mut passwd = [0; 32]; + let output = nacl::scrypt( + passphrase, + &self.salt, + self.n as u8, + self.r as usize, + self.p as usize, + PUBLIC_KEY_LENGTH, + &|_: u32| {}, + ) + .map_err(|e| anyhow!("{e:?}"))?; + passwd.copy_from_slice(&output[..32]); + + Ok(passwd) + } +} + +impl Default for Scrypt { + fn default() -> Self { + let mut salt = [0; Self::SALT_LENGTH]; + rand::thread_rng().fill_bytes(&mut salt); + + Self { + salt, + n: 15, + r: 8, + p: 1, + } + } +} diff --git a/utils/gring/src/ss58.rs b/utils/gring/src/ss58.rs new file mode 100644 index 00000000000..3b9f3766eca --- /dev/null +++ b/utils/gring/src/ss58.rs @@ -0,0 +1,117 @@ +// This file is part of Gear. +// +// Copyright (C) 2024 Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! SS58 encoding implementation +//! +//! This library is extracted from [ss58 codec][ss58-codec] in `sp-core`, not +//! importing `sp-core` because it is super big (~300 dependencies). +//! +//! [ss58-codec]: https://paritytech.github.io/polkadot-sdk/master/sp_core/crypto/trait.Ss58Codec.html + +use anyhow::{anyhow, Result}; +use blake2::{Blake2b512, Digest}; +use core::sync::atomic::{AtomicU16, Ordering}; + +/// The SS58 prefix of vara network. +pub const VARA_SS58_PREFIX: u16 = 137; + +/// The default ss58 version. +pub static DEFAULT_SS58_VERSION: AtomicU16 = AtomicU16::new(VARA_SS58_PREFIX); + +/// SS58 prefix +const SS58_PREFIX: &[u8] = b"SS58PRE"; + +/// The checksum length used in ss58 encoding +const CHECKSUM_LENGTH: usize = 2; + +/// Encode data to SS58 format. +pub fn encode(data: &[u8]) -> String { + let ident: u16 = default_ss58_version() & 0b0011_1111_1111_1111; + let mut v = match ident { + 0..=63 => vec![ident as u8], + 64..=16_383 => { + // upper six bits of the lower byte(!) + let first = ((ident & 0b0000_0000_1111_1100) as u8) >> 2; + // lower two bits of the lower byte in the high pos, + // lower bits of the upper byte in the low pos + let second = ((ident >> 8) as u8) | ((ident & 0b0000_0000_0000_0011) as u8) << 6; + vec![first | 0b01000000, second] + } + _ => unreachable!("masked out the upper two bits; qed"), + }; + + v.extend_from_slice(data); + let r = blake2b_512(&v); + v.extend(&r[0..CHECKSUM_LENGTH]); + bs58::encode(v).into_string() +} + +/// Decode data from SS58 format. +pub fn decode(encoded: &[u8], body_len: usize) -> Result> { + let data = bs58::decode(encoded) + .into_vec() + .map_err(|e| anyhow!("Invalid ss58 data: {}", e))?; + if data.len() < CHECKSUM_LENGTH { + return Err(anyhow!("Invalid length of encoded ss58 data.")); + } + + let (prefix_len, _) = match data[0] { + 0..=63 => (1, data[0] as u16), + 64..=127 => { + // weird bit manipulation owing to the combination of LE encoding and missing two + // bits from the left. + // d[0] d[1] are: 01aaaaaa bbcccccc + // they make the LE-encoded 16-bit value: aaaaaabb 00cccccc + // so the lower byte is formed of aaaaaabb and the higher byte is 00cccccc + let lower = (data[0] << 2) | (data[1] >> 6); + let upper = data[1] & 0b00111111; + (2, (lower as u16) | ((upper as u16) << 8)) + } + _ => return Err(anyhow!("Invalid prefix of encoded ss58 data.")), + }; + + if data.len() != prefix_len + body_len + CHECKSUM_LENGTH { + return Err(anyhow!("Invalid length of encoded ss58 data.")); + } + + let hash = blake2b_512(&data[..prefix_len + body_len]); + let checksum = &hash[0..CHECKSUM_LENGTH]; + if data[body_len + prefix_len..body_len + prefix_len + CHECKSUM_LENGTH] != *checksum { + return Err(anyhow!("Invalid checksum of encoded ss58 data.")); + } + + Ok(data[prefix_len..body_len + prefix_len].to_vec()) +} + +/// Get the default ss58 version. +pub fn default_ss58_version() -> u16 { + DEFAULT_SS58_VERSION.load(Ordering::Relaxed) +} + +/// Set the default ss58 version. +pub fn set_default_ss58_version(version: u16) { + DEFAULT_SS58_VERSION.store(version, Ordering::Relaxed); +} + +/// blake2b_512 hash +fn blake2b_512(data: &[u8]) -> Vec { + let mut ctx = Blake2b512::new(); + ctx.update(SS58_PREFIX); + ctx.update(data); + ctx.finalize().to_vec() +} diff --git a/utils/gring/tests/command.rs b/utils/gring/tests/command.rs new file mode 100644 index 00000000000..0ca422c56ea --- /dev/null +++ b/utils/gring/tests/command.rs @@ -0,0 +1,91 @@ +// This file is part of Gear. +// +// Copyright (C) 2021-2024 Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +#![cfg(feature = "cli")] + +use anyhow::{anyhow, Result}; +use gring::{cmd::Command, Keystore}; +use std::{path::PathBuf, process}; + +fn bin() -> PathBuf { + let target = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target"); + let profile = if cfg!(debug_assertions) { + "debug" + } else { + "release" + }; + + target.join(profile).join("gring") +} + +fn gring(bin: &PathBuf, args: &[&str]) -> Result { + let output = process::Command::new(bin).args(args).output()?; + if output.stdout.is_empty() { + return Err(anyhow::anyhow!( + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + let stdout = output.stdout; + Ok(String::from_utf8_lossy(&stdout).to_string()) +} + +#[test] +fn new() -> Result<()> { + let key = "_gring_test_new"; + let passphrase = "test"; + Command::New { + name: key.to_string(), + passphrase: passphrase.to_string(), + vanity: None, + } + .run()?; + + let json = Command::store()?.join(format!("{key}.json")); + assert!(json.exists()); + + let keystore = serde_json::from_slice::(&std::fs::read(json)?)?; + assert!(keystore.decrypt_scrypt(passphrase.as_bytes()).is_ok()); + Ok(()) +} + +#[test] +fn sign_and_verify() -> Result<()> { + let key = "_gring_sig"; + let key2 = "_gring_sig_2"; + let message = "vara"; + let bin = bin(); + + gring(&bin, &["new", key, "-p", "test"])?; + gring(&bin, &["use", key])?; + let sign = gring(&bin, &["sign", message, "-p", "test"])?; + let signature = sign + .lines() + .find(|line| line.contains("Signature")) + .ok_or_else(|| anyhow!("Signature not found in output: {}", sign))? + .split("Signature:") + .collect::>()[1] + .trim(); + assert!(gring(&bin, &["verify", message, signature])?.contains("Verified")); + + // `key2` can not verify this signature bcz it is signed by `key`. + gring(&bin, &["new", key2, "-p", "test"])?; + gring(&bin, &["use", key2])?; + assert!(gring(&bin, &["verify", message, signature])?.contains("Not Verified")); + Ok(()) +} diff --git a/utils/gring/tests/keystore.rs b/utils/gring/tests/keystore.rs new file mode 100644 index 00000000000..eaa7ef6bafe --- /dev/null +++ b/utils/gring/tests/keystore.rs @@ -0,0 +1,34 @@ +use anyhow::Result; +use gring::Keystore; +use schnorrkel::Keypair; + +const POLKADOT_JS_PAIR: &[u8] = include_bytes!("../res/pair.json"); + +#[test] +fn polkadot_js() -> Result<()> { + let store = serde_json::from_slice::(POLKADOT_JS_PAIR)?; + + assert!(store.decrypt(None).is_err()); + assert!(store.decrypt(Some(b"42")).is_err()); + assert!(store.decrypt(Some(b"000000")).is_ok()); + Ok(()) +} + +#[test] +fn scrypt() -> Result<()> { + let passphrase = b"42"; + let pair = Keypair::generate(); + let store = Keystore::encrypt_scrypt(pair.clone().into(), passphrase)?; + + assert_eq!(pair.secret, store.decrypt_scrypt(b"42")?.secret); + Ok(()) +} + +#[test] +fn nopasswd() -> Result<()> { + let pair = Keypair::generate(); + let store = Keystore::encrypt_none(pair.clone().into()); + + assert_eq!(pair.secret, store.decrypt_none()?.secret); + Ok(()) +}