diff --git a/.config/forest.dic b/.config/forest.dic index 9783db4b8ba8..acc9fdd30e29 100644 --- a/.config/forest.dic +++ b/.config/forest.dic @@ -1,4 +1,4 @@ -271 +273 Algorand/M API's API/SM @@ -99,6 +99,7 @@ Filecoin/M Filfox Filops FilOz +Filsnap FIP/S FVM GC @@ -121,6 +122,7 @@ implementer/S implementor/SM infeasible interop/S +interoperating ip IPFS IPLD diff --git a/CHANGELOG.md b/CHANGELOG.md index ad859aac4760..074f53d00f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ ### Breaking - [#7073](https://github.com/ChainSafe/forest/pull/7073): Replaced the underlying cache engine across the node. The eviction policy is no longer strict LRU — it is now CLOCK-PRO via [`quick_cache`](https://crates.io/crates/quick_cache), which is scan-resistant and typically gives higher hit rates on chain workloads. The Prometheus metric names `lru_cache_hit_total` and `lru_cache_miss_total` are renamed to `cache_hit_total` and `cache_miss_total`. **Operators must update dashboards, alert rules, and recording rules** that reference the old names. Label set (`kind="..."`) is unchanged. +- [#6442](https://github.com/ChainSafe/forest/issues/6442): `forest-wallet sign` and `forest-wallet verify` now apply the FRC-0102 signing envelope to the message by default. Pass `--raw` on both sides to reproduce the previous raw-bytes behaviour. ### Added diff --git a/src/wallet/subcommands/wallet_cmd.rs b/src/wallet/subcommands/wallet_cmd.rs index 9796573a3c69..394b6dfbd575 100644 --- a/src/wallet/subcommands/wallet_cmd.rs +++ b/src/wallet/subcommands/wallet_cmd.rs @@ -37,7 +37,6 @@ use crate::{ rpc::{self, prelude::*}, }; use anyhow::{Context as _, bail}; -use base64::{Engine, prelude::BASE64_STANDARD}; use clap::Subcommand; use dialoguer::{Password, console::Term, theme::ColorfulTheme}; use directories::ProjectDirs; @@ -169,17 +168,17 @@ impl WalletBackend { } } - async fn wallet_sign(&self, address: Address, message: String) -> anyhow::Result { + async fn wallet_sign(&self, address: Address, message: Vec) -> anyhow::Result { if let Some(keystore) = &self.local { let key = crate::key_management::try_find_key(&address, keystore)?; Ok(crate::key_management::sign( *key.key_info.key_type(), key.key_info.private_key(), - &BASE64_STANDARD.decode(message)?, + &message, )?) } else { - Ok(WalletSign::call(&self.remote, (address, message.into_bytes())).await?) + Ok(WalletSign::call(&self.remote, (address, message)).await?) } } @@ -262,6 +261,11 @@ pub enum WalletCommands { /// The address to be used to sign the message #[arg(short)] address: StrictAddress, + /// Sign the raw message bytes without the FRC-0102 envelope. Use this + /// for interoperating with pre-FRC-0102 tooling, or when the bytes are + /// already an on-chain Filecoin message (which must not be wrapped). + #[arg(long)] + raw: bool, }, /// Validates whether a given string can be decoded as a well-formed address ValidateAddress { @@ -280,6 +284,12 @@ pub enum WalletCommands { /// The signature of the message to verify #[arg(short)] signature: String, + /// Verify against the raw message bytes without applying the + /// FRC-0102 envelope. Use this for signatures produced by + /// pre-FRC-0102 tooling or for on-chain Filecoin messages (which are + /// signed raw, without the envelope). + #[arg(long)] + raw: bool, }, /// Deletes the wallet associated with the given address. Delete { @@ -446,9 +456,13 @@ impl WalletCommands { Ok(()) } Self::SetDefault { key } => backend.wallet_set_default(key.into()).await, - Self::Sign { address, message } => { + Self::Sign { + address, + message, + raw, + } => { let message = hex::decode(message).context("Message has to be a hex string")?; - let message = BASE64_STANDARD.encode(message); + let message = if raw { message } else { wrap_frc0102(&message) }; let signature = backend.wallet_sign(address.into(), message).await?; println!("{}", hex::encode(signature.to_bytes())); @@ -463,10 +477,12 @@ impl WalletCommands { message, address, signature, + raw, } => { let sig_bytes = hex::decode(signature).context("Signature has to be a hex string")?; let msg = hex::decode(message).context("Message has to be a hex string")?; + let msg = if raw { msg } else { wrap_frc0102(&msg) }; let signature = Signature::from_bytes(sig_bytes)?; let is_valid = backend @@ -615,6 +631,15 @@ fn resolve_target_address(target_address: &str) -> anyhow::Result<(Address, bool } } +const FRC_0102_FILECOIN_PREFIX: &[u8] = b"\x19Filecoin Signed Message:\n"; + +/// Wraps `msg` with the FRC-0102 envelope: `0x19 || "Filecoin Signed Message:\n" || ascii(len(msg)) || msg` +// See . +fn wrap_frc0102(msg: &[u8]) -> Vec { + let len = msg.len().to_string(); + [FRC_0102_FILECOIN_PREFIX, len.as_bytes(), msg].concat() +} + fn resolve_method_num(from: &Address, to: &Address, is_0x_recipient: bool) -> u64 { if !is_eth_address(from) && !is_0x_recipient { return METHOD_SEND; @@ -635,7 +660,7 @@ mod tests { use crate::shim::address::{Address, CurrentNetwork, Network}; use crate::shim::message::METHOD_SEND; - use super::{resolve_method_num, resolve_target_address}; + use super::{SignatureType, resolve_method_num, resolve_target_address, wrap_frc0102}; #[test] fn test_resolve_target_address_id() { @@ -753,4 +778,82 @@ mod tests { let method = resolve_method_num(&from, &to, true); assert_eq!(method, EVMMethod::InvokeContract as u64); } + + #[test] + fn test_wrap_frc0102_empty() { + let wrapped = wrap_frc0102(&[]); + assert_eq!(wrapped, b"\x19Filecoin Signed Message:\n0"); + } + + #[test] + fn test_wrap_frc0102_short() { + let wrapped = wrap_frc0102(b"hello"); + assert_eq!(wrapped, b"\x19Filecoin Signed Message:\n5hello"); + } + + #[test] + fn test_wrap_frc0102_longer() { + let msg = b"this is a longer test message"; + let wrapped = wrap_frc0102(msg); + let mut expected = Vec::new(); + expected.extend_from_slice(b"\x19Filecoin Signed Message:\n"); + expected.extend_from_slice(msg.len().to_string().as_bytes()); + expected.extend_from_slice(msg); + assert_eq!(wrapped, expected); + } + + #[test] + fn test_wrap_frc0102_binary_msg() { + // Non-UTF-8 bytes must pass through unchanged. + let msg: &[u8] = &[0x00, 0xFF, 0x10, 0x80]; + let wrapped = wrap_frc0102(msg); + assert_eq!(&wrapped[..26], b"\x19Filecoin Signed Message:\n"); + assert_eq!(&wrapped[26..27], b"4"); + assert_eq!(&wrapped[27..], msg); + } + + #[test] + fn test_wrap_frc0102_length_boundaries() { + // Length is encoded as decimal ASCII; check 1-, 2-, 3- and 4-digit boundaries. + for len in [0, 9, 10, 99, 100, 999, 1000] { + let msg = vec![0xABu8; len]; + let wrapped = wrap_frc0102(&msg); + let digits = len.to_string(); + assert_eq!(&wrapped[..26], b"\x19Filecoin Signed Message:\n"); + assert_eq!(&wrapped[26..26 + digits.len()], digits.as_bytes()); + assert_eq!(&wrapped[26 + digits.len()..], msg.as_slice()); + } + } + + #[test] + fn test_wrap_frc0102_contains_newline() { + // The spec does not require any escaping; embedded newlines pass through. + let msg = b"line1\nline2"; + let wrapped = wrap_frc0102(msg); + assert_eq!(wrapped, b"\x19Filecoin Signed Message:\n11line1\nline2"); + } + + #[test] + fn test_frc0102_roundtrip_sign_verify_secp256k1() { + use crate::key_management::generate_key; + let key = generate_key(SignatureType::Secp256k1).unwrap(); + let raw_msg = b"hello world"; + let wrapped = wrap_frc0102(raw_msg); + + // Sign the wrapped bytes (what `forest-wallet sign` does by default). + let signature = crate::key_management::sign( + *key.key_info.key_type(), + key.key_info.private_key(), + &wrapped, + ) + .unwrap(); + + // `verify` on the wrapped bytes must succeed. + signature.verify(&wrapped, &key.address).unwrap(); + + assert!( + signature.verify(raw_msg, &key.address).is_err(), + "raw-bytes verify should fail when the signature was produced over the FRC-0102 envelope" + ); + } }