diff --git a/Cargo.lock b/Cargo.lock index 39691e4229..2479ef9b79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6256,6 +6256,7 @@ dependencies = [ "memmap2", "num-derive", "num-traits", + "num_enum", "pbkdf2 0.11.0", "qstring", "rand 0.7.3", diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 22fbfd2c2f..8a2600a981 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -25,6 +25,7 @@ use { decode_error::DecodeError, hash::Hash, instruction::InstructionError, + offchain_message::OffchainMessage, pubkey::Pubkey, signature::{Signature, Signer, SignerError}, stake::{instruction::LockupArgs, state::Lockup}, @@ -440,6 +441,14 @@ pub enum CliCommand { }, // Address lookup table commands AddressLookupTable(AddressLookupTableCliCommand), + SignOffchainMessage { + message: OffchainMessage, + }, + VerifyOffchainSignature { + signer_pubkey: Option, + signature: Signature, + message: OffchainMessage, + }, } #[derive(Debug, PartialEq)] @@ -470,6 +479,8 @@ pub enum CliError { RpcRequestError(String), #[error("Keypair file not found: {0}")] KeypairFileNotFound(String), + #[error("Invalid signature")] + InvalidSignature, } impl From> for CliError { @@ -821,6 +832,12 @@ pub fn parse_command( }) } ("transfer", Some(matches)) => parse_transfer(matches, default_signer, wallet_manager), + ("sign-offchain-message", Some(matches)) => { + parse_sign_offchain_message(matches, default_signer, wallet_manager) + } + ("verify-offchain-signature", Some(matches)) => { + parse_verify_offchain_signature(matches, default_signer, wallet_manager) + } // ("", None) => { eprintln!("{}", matches.usage()); @@ -1634,11 +1651,18 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { derived_address_program_id.as_ref(), compute_unit_price.as_ref(), ), - // Address Lookup Table Commands CliCommand::AddressLookupTable(subcommand) => { process_address_lookup_table_subcommand(rpc_client, config, subcommand) } + CliCommand::SignOffchainMessage { message } => { + process_sign_offchain_message(config, message) + } + CliCommand::VerifyOffchainSignature { + signer_pubkey, + signature, + message, + } => process_verify_offchain_signature(config, signer_pubkey, signature, message), } } @@ -2013,7 +2037,7 @@ mod tests { assert_eq!( parse_command(&test_resolve_signer, &default_signer, &mut None).unwrap(), CliCommandInfo { - command: CliCommand::ResolveSigner(Some(keypair_file)), + command: CliCommand::ResolveSigner(Some(keypair_file.clone())), signers: vec![], } ); @@ -2029,6 +2053,43 @@ mod tests { signers: vec![], } ); + + // Test SignOffchainMessage + let test_sign_offchain = test_commands.clone().get_matches_from(vec![ + "test", + "sign-offchain-message", + "Test Message", + ]); + let message = OffchainMessage::new(0, b"Test Message").unwrap(); + assert_eq!( + parse_command(&test_sign_offchain, &default_signer, &mut None).unwrap(), + CliCommandInfo { + command: CliCommand::SignOffchainMessage { + message: message.clone() + }, + signers: vec![read_keypair_file(&keypair_file).unwrap().into()], + } + ); + + // Test VerifyOffchainSignature + let signature = keypair.sign_message(&message.serialize().unwrap()); + let test_verify_offchain = test_commands.clone().get_matches_from(vec![ + "test", + "verify-offchain-signature", + "Test Message", + &signature.to_string(), + ]); + assert_eq!( + parse_command(&test_verify_offchain, &default_signer, &mut None).unwrap(), + CliCommandInfo { + command: CliCommand::VerifyOffchainSignature { + signer_pubkey: None, + signature, + message + }, + signers: vec![read_keypair_file(&keypair_file).unwrap().into()], + } + ); } #[test] @@ -2375,6 +2436,23 @@ mod tests { config.command = CliCommand::GetTransactionCount; assert!(process_command(&config).is_err()); + + let message = OffchainMessage::new(0, b"Test Message").unwrap(); + config.command = CliCommand::SignOffchainMessage { + message: message.clone(), + }; + config.signers = vec![&keypair]; + let result = process_command(&config); + assert!(result.is_ok()); + + config.command = CliCommand::VerifyOffchainSignature { + signer_pubkey: None, + signature: result.unwrap().parse().unwrap(), + message, + }; + config.signers = vec![&keypair]; + let result = process_command(&config); + assert!(result.is_ok()); } #[test] diff --git a/cli/src/wallet.rs b/cli/src/wallet.rs index f8600c6667..96a4fcf73b 100644 --- a/cli/src/wallet.rs +++ b/cli/src/wallet.rs @@ -33,6 +33,7 @@ use { solana_sdk::{ commitment_config::CommitmentConfig, message::Message, + offchain_message::OffchainMessage, pubkey::Pubkey, signature::Signature, stake, @@ -274,6 +275,71 @@ impl WalletSubCommands for App<'_, '_> { .arg(fee_payer_arg()) .arg(compute_unit_price_arg()), ) + .subcommand( + SubCommand::with_name("sign-offchain-message") + .about("Sign off-chain message") + .arg( + Arg::with_name("message") + .index(1) + .takes_value(true) + .value_name("STRING") + .required(true) + .help("The message text to be signed") + ) + .arg( + Arg::with_name("version") + .long("version") + .takes_value(true) + .value_name("VERSION") + .required(false) + .default_value("0") + .validator(|p| match p.parse::() { + Err(_) => Err(String::from("Must be unsigned integer")), + Ok(_) => { Ok(()) } + }) + .help("The off-chain message version") + ) + ) + .subcommand( + SubCommand::with_name("verify-offchain-signature") + .about("Verify off-chain message signature") + .arg( + Arg::with_name("message") + .index(1) + .takes_value(true) + .value_name("STRING") + .required(true) + .help("The text of the original message") + ) + .arg( + Arg::with_name("signature") + .index(2) + .value_name("SIGNATURE") + .takes_value(true) + .required(true) + .help("The message signature to verify") + ) + .arg( + Arg::with_name("version") + .long("version") + .takes_value(true) + .value_name("VERSION") + .required(false) + .default_value("0") + .validator(|p| match p.parse::() { + Err(_) => Err(String::from("Must be unsigned integer")), + Ok(_) => { Ok(()) } + }) + .help("The off-chain message version") + ) + .arg( + pubkey!(Arg::with_name("signer") + .long("signer") + .value_name("PUBKEY") + .required(false), + "The pubkey of the message signer (if different from config default)") + ) + ) } } @@ -447,6 +513,54 @@ pub fn parse_transfer( }) } +pub fn parse_sign_offchain_message( + matches: &ArgMatches<'_>, + default_signer: &DefaultSigner, + wallet_manager: &mut Option>, +) -> Result { + let version: u8 = value_of(matches, "version").unwrap(); + let message_text: String = value_of(matches, "message") + .ok_or_else(|| CliError::BadParameter("MESSAGE".to_string()))?; + let message = OffchainMessage::new(version, message_text.as_bytes()) + .map_err(|_| CliError::BadParameter("VERSION or MESSAGE".to_string()))?; + + Ok(CliCommandInfo { + command: CliCommand::SignOffchainMessage { message }, + signers: vec![default_signer.signer_from_path(matches, wallet_manager)?], + }) +} + +pub fn parse_verify_offchain_signature( + matches: &ArgMatches<'_>, + default_signer: &DefaultSigner, + wallet_manager: &mut Option>, +) -> Result { + let version: u8 = value_of(matches, "version").unwrap(); + let message_text: String = value_of(matches, "message") + .ok_or_else(|| CliError::BadParameter("MESSAGE".to_string()))?; + let message = OffchainMessage::new(version, message_text.as_bytes()) + .map_err(|_| CliError::BadParameter("VERSION or MESSAGE".to_string()))?; + + let signer_pubkey = pubkey_of_signer(matches, "signer", wallet_manager)?; + let signers = if signer_pubkey.is_some() { + vec![] + } else { + vec![default_signer.signer_from_path(matches, wallet_manager)?] + }; + + let signature = value_of(matches, "signature") + .ok_or_else(|| CliError::BadParameter("SIGNATURE".to_string()))?; + + Ok(CliCommandInfo { + command: CliCommand::VerifyOffchainSignature { + signer_pubkey, + signature, + message, + }, + signers, + }) +} + pub fn process_show_account( rpc_client: &RpcClient, config: &CliConfig, @@ -779,3 +893,29 @@ pub fn process_transfer( log_instruction_custom_error::(result, config) } } + +pub fn process_sign_offchain_message( + config: &CliConfig, + message: &OffchainMessage, +) -> ProcessResult { + Ok(message.sign(config.signers[0])?.to_string()) +} + +pub fn process_verify_offchain_signature( + config: &CliConfig, + signer_pubkey: &Option, + signature: &Signature, + message: &OffchainMessage, +) -> ProcessResult { + let signer = if let Some(pubkey) = signer_pubkey { + *pubkey + } else { + config.signers[0].pubkey() + }; + + if message.verify(&signer, signature)? { + Ok("Signature is valid".to_string()) + } else { + Err(CliError::InvalidSignature.into()) + } +} diff --git a/docs/sidebars.js b/docs/sidebars.js index 0fa270088b..7ef24a0f74 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -339,6 +339,7 @@ module.exports = { "offline-signing", "offline-signing/durable-nonce", "cli/usage", + "cli/sign-offchain-message", ], architectureSidebar: [ { diff --git a/docs/src/cli/sign-offchain-message.md b/docs/src/cli/sign-offchain-message.md new file mode 100644 index 0000000000..cc25628172 --- /dev/null +++ b/docs/src/cli/sign-offchain-message.md @@ -0,0 +1,98 @@ +--- +title: Off-Chain Message Signing +--- + +Off-chain message signing is a method of signing non-transaction messages with +a Solana wallet. This feature can be used to authenticate users or provide +proof of wallet ownership. + +## Sign Off-Chain Message + +To sign an arbitrary off-chain message, run the following command: + +```bash +solana sign-offchain-message +``` + +The message will be encoded and signed with CLI's default private key and +signature printed to the output. If you want to sign it with another key, just +use the `-k/--keypair` option: + +```bash +solana sign-offchain-message -k +``` + +By default, the messages constructed are version 0, the only version currently +supported. When other versions become available, you can override the default +value with the `--version` option: + +```bash +solana sign-offchain-message -k --version +``` + +The message format is determined automatically based on the version and text +of the message. + +Version `0` headers specify three message formats allowing for trade-offs +between compatibility and composition of messages: + +| ID | Encoding | Maximum Length | Hardware Wallet Support | +| :-: | :-----------------: | :------------: | :---------------------: | +| 0 | Restricted ASCII \* | 1212 | Yes | +| 1 | UTF-8 | 1212 | Blind sign only | +| 2 | UTF-8 | 65515 | No | + +\* Those characters for which [`isprint(3)`](https://linux.die.net/man/3/isprint) +returns true. That is, `0x20..=0x7e`. + +Formats `0` and `1` are motivated by hardware wallet support where both RAM to +store the payload and font character support are limited. + +To sign an off-chain message with Ledger, ensure your Ledger is running latest +firmware and Solana Ledger App version 1.3.0 or later. After Ledger is +unlocked and Solana Ledger App is open, run: + +```bash +solana sign-offchain-message -k usb://ledger +``` + +For more information on how to setup and work with the ledger device see this +[link](../wallet-guide/hardware-wallets/ledger.md). + +Please note that UTF-8 encoded messages require `Allow blind sign` option +enabled in Solana Ledger App. Also, due to the lack of UTF-8 support in Ledger +devices, only the hash of the message will be displayed in such cases. + +If `Display mode` is set to `Expert`, Ledger will display technical +information about the message to be signed. + +## Verify Off-Chain Message Signature + +To verify the off-chain message signature, run the following command: + +```bash +solana verify-offchain-signature +``` + +The public key of the default CLI signer will be used. You can specify another +key with the `--signer` option: + +```bash +solana verify-offchain-signature --signer +``` + +If the signed message has a version different from the default, you need to +specify the matching version explicitly: + +```bash +solana verify-offchain-signature --version +``` + +## Protocol Specification + +To ensure that off-chain messages are not valid transactions, they are encoded +with a fixed prefix: `\xffsolana offchain`, where first byte is chosen such +that it is implicitly illegal as the first byte in a transaction +`MessageHeader` today. More details about the payload format and other +considerations are available in the +[proposal](https://github.com/solana-labs/solana/blob/e80f67dd58b7fa3901168055211f346164efa43a/docs/src/proposals/off-chain-message-signing.md). diff --git a/docs/src/wallet-guide/hardware-wallets/ledger.md b/docs/src/wallet-guide/hardware-wallets/ledger.md index 6994561cd0..272dd6ce54 100644 --- a/docs/src/wallet-guide/hardware-wallets/ledger.md +++ b/docs/src/wallet-guide/hardware-wallets/ledger.md @@ -2,7 +2,7 @@ title: Ledger Nano --- -This page describes how to use a Ledger Nano S or Nano X to interact with Solana +This page describes how to use a Ledger Nano S, Nano S Plus, or Nano X to interact with Solana using the command line tools. ## Before You Begin diff --git a/programs/sbf/Cargo.lock b/programs/sbf/Cargo.lock index 31842058e7..e7f2fe86c6 100644 --- a/programs/sbf/Cargo.lock +++ b/programs/sbf/Cargo.lock @@ -5567,6 +5567,7 @@ dependencies = [ "memmap2", "num-derive", "num-traits", + "num_enum", "pbkdf2 0.11.0", "qstring", "rand 0.7.3", diff --git a/remote-wallet/src/ledger.rs b/remote-wallet/src/ledger.rs index ec929257ac..b9cc80bc2b 100644 --- a/remote-wallet/src/ledger.rs +++ b/remote-wallet/src/ledger.rs @@ -67,6 +67,7 @@ mod commands { pub const GET_APP_CONFIGURATION: u8 = 0x04; pub const GET_PUBKEY: u8 = 0x05; pub const SIGN_MESSAGE: u8 = 0x06; + pub const SIGN_OFFCHAIN_MESSAGE: u8 = 0x07; } enum ConfigurationVersion { @@ -430,6 +431,13 @@ impl RemoteWallet for LedgerWallet { derivation_path: &DerivationPath, data: &[u8], ) -> Result { + // If the first byte of the data is 0xff then it is an off-chain message + // because it starts with the Domain Specifier b"\xffsolana offchain". + // On-chain messages, in contrast, start with either 0x80 (MESSAGE_VERSION_PREFIX) + // or the number of signatures (0x00 - 0x13). + if !data.is_empty() && data[0] == 0xff { + return self.sign_offchain_message(derivation_path, data); + } let mut payload = if self.outdated_app() { extend_and_serialize(derivation_path) } else { @@ -498,7 +506,16 @@ impl RemoteWallet for LedgerWallet { chunks.last_mut().unwrap().0 &= !P2_MORE; for (p2, payload) in chunks { - result = self.send_apdu(commands::SIGN_MESSAGE, p1, p2, &payload)?; + result = self.send_apdu( + if self.outdated_app() { + commands::DEPRECATED_SIGN_MESSAGE + } else { + commands::SIGN_MESSAGE + }, + p1, + p2, + &payload, + )?; } } @@ -509,6 +526,42 @@ impl RemoteWallet for LedgerWallet { } Ok(Signature::new(&result)) } + + fn sign_offchain_message( + &self, + derivation_path: &DerivationPath, + message: &[u8], + ) -> Result { + if message.len() + > solana_sdk::offchain_message::v0::OffchainMessage::MAX_LEN_LEDGER + + solana_sdk::offchain_message::v0::OffchainMessage::HEADER_LEN + { + return Err(RemoteWalletError::InvalidInput( + "Off-chain message to sign is too long".to_string(), + )); + } + + let mut data = extend_and_serialize_multiple(&[derivation_path]); + data.extend_from_slice(message); + + let p1 = P1_CONFIRM; + let mut p2 = 0; + let mut payload = data.as_slice(); + while payload.len() > MAX_CHUNK_SIZE { + let chunk = &payload[..MAX_CHUNK_SIZE]; + self.send_apdu(commands::SIGN_OFFCHAIN_MESSAGE, p1, p2 | P2_MORE, chunk)?; + payload = &payload[MAX_CHUNK_SIZE..]; + p2 |= P2_EXTEND; + } + + let result = self.send_apdu(commands::SIGN_OFFCHAIN_MESSAGE, p1, p2, payload)?; + if result.len() != 64 { + return Err(RemoteWalletError::Protocol( + "Signature packet size mismatch", + )); + } + Ok(Signature::new(&result)) + } } /// Check if the detected device is a valid `Ledger device` by checking both the product ID and the vendor ID diff --git a/remote-wallet/src/ledger_error.rs b/remote-wallet/src/ledger_error.rs index b6dfff5d37..8075a1fac1 100644 --- a/remote-wallet/src/ledger_error.rs +++ b/remote-wallet/src/ledger_error.rs @@ -71,6 +71,15 @@ pub enum LedgerError { #[error("Ledger received invalid Solana message")] SolanaInvalidMessage = 0x6a80, + #[error("Ledger received message with invalid header")] + SolanaInvalidMessageHeader = 0x6a81, + + #[error("Ledger received message in invalid format")] + SolanaInvalidMessageFormat = 0x6a82, + + #[error("Ledger received message with invalid size")] + SolanaInvalidMessageSize = 0x6a83, + #[error("Solana summary finalization failed on Ledger device")] SolanaSummaryFinalizeFailed = 0x6f00, diff --git a/remote-wallet/src/remote_wallet.rs b/remote-wallet/src/remote_wallet.rs index 78fb969d13..e1da0c7ba8 100644 --- a/remote-wallet/src/remote_wallet.rs +++ b/remote-wallet/src/remote_wallet.rs @@ -236,6 +236,15 @@ pub trait RemoteWallet { ) -> Result { unimplemented!(); } + + /// Sign off-chain message with wallet managing pubkey at derivation path m/44'/501'/'/'. + fn sign_offchain_message( + &self, + derivation_path: &DerivationPath, + message: &[u8], + ) -> Result { + unimplemented!(); + } } /// `RemoteWallet` device diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index 7abcfcc6b1..660b46a6f8 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -60,6 +60,7 @@ log = "0.4.17" memmap2 = { version = "0.5.3", optional = true } num-derive = "0.3" num-traits = "0.2" +num_enum = "0.5.7" pbkdf2 = { version = "0.11.0", default-features = false } qstring = "0.7.2" rand = { version = "0.7.0", optional = true } diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index c1a2967ab5..624d5c9995 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -47,6 +47,7 @@ pub mod inflation; pub mod log; pub mod native_loader; pub mod nonce_account; +pub mod offchain_message; pub mod packet; pub mod poh_config; pub mod precompiles; diff --git a/sdk/src/offchain_message.rs b/sdk/src/offchain_message.rs new file mode 100644 index 0000000000..344367edcf --- /dev/null +++ b/sdk/src/offchain_message.rs @@ -0,0 +1,302 @@ +//! Off-Chain Message container for storing non-transaction messages. + +#![cfg(feature = "full")] + +use { + crate::{ + hash::Hash, + pubkey::Pubkey, + sanitize::SanitizeError, + signature::{Signature, Signer}, + }, + num_enum::{IntoPrimitive, TryFromPrimitive}, +}; + +#[cfg(test)] +static_assertions::const_assert_eq!(OffchainMessage::HEADER_LEN, 17); +#[cfg(test)] +static_assertions::const_assert_eq!(v0::OffchainMessage::MAX_LEN, 65515); +#[cfg(test)] +static_assertions::const_assert_eq!(v0::OffchainMessage::MAX_LEN_LEDGER, 1212); + +/// Check if given bytes contain only printable ASCII characters +pub fn is_printable_ascii(data: &[u8]) -> bool { + for &char in data { + if !(0x20..=0x7e).contains(&char) { + return false; + } + } + true +} + +/// Check if given bytes contain valid UTF8 string +pub fn is_utf8(data: &[u8]) -> bool { + std::str::from_utf8(data).is_ok() +} + +#[repr(u8)] +#[derive(Debug, PartialEq, Eq, Copy, Clone, TryFromPrimitive, IntoPrimitive)] +pub enum MessageFormat { + RestrictedAscii, + LimitedUtf8, + ExtendedUtf8, +} + +#[allow(clippy::integer_arithmetic)] +pub mod v0 { + use { + super::{is_printable_ascii, is_utf8, MessageFormat, OffchainMessage as Base}, + crate::{ + hash::{Hash, Hasher}, + packet::PACKET_DATA_SIZE, + sanitize::SanitizeError, + }, + }; + + /// OffchainMessage Version 0. + /// Struct always contains a non-empty valid message. + #[derive(Debug, PartialEq, Eq, Clone)] + pub struct OffchainMessage { + format: MessageFormat, + message: Vec, + } + + impl OffchainMessage { + // Header Length = Message Format (1) + Message Length (2) + pub const HEADER_LEN: usize = 3; + // Max length of the OffchainMessage + pub const MAX_LEN: usize = u16::MAX as usize - Base::HEADER_LEN - Self::HEADER_LEN; + // Max Length of the OffchainMessage supported by the Ledger + pub const MAX_LEN_LEDGER: usize = PACKET_DATA_SIZE - Base::HEADER_LEN - Self::HEADER_LEN; + + /// Construct a new OffchainMessage object from the given message + pub fn new(message: &[u8]) -> Result { + let format = if message.is_empty() { + return Err(SanitizeError::InvalidValue); + } else if message.len() <= OffchainMessage::MAX_LEN_LEDGER { + if is_printable_ascii(message) { + MessageFormat::RestrictedAscii + } else if is_utf8(message) { + MessageFormat::LimitedUtf8 + } else { + return Err(SanitizeError::InvalidValue); + } + } else if message.len() <= OffchainMessage::MAX_LEN { + if is_utf8(message) { + MessageFormat::ExtendedUtf8 + } else { + return Err(SanitizeError::InvalidValue); + } + } else { + return Err(SanitizeError::ValueOutOfBounds); + }; + Ok(Self { + format, + message: message.to_vec(), + }) + } + + /// Serialize the message to bytes, including the full header + pub fn serialize(&self, data: &mut Vec) -> Result<(), SanitizeError> { + // invalid messages shouldn't be possible, but a quick sanity check never hurts + assert!(!self.message.is_empty() && self.message.len() <= Self::MAX_LEN); + data.reserve(Self::HEADER_LEN.saturating_add(self.message.len())); + // format + data.push(self.format.into()); + // message length + data.extend_from_slice(&(self.message.len() as u16).to_le_bytes()); + // message + data.extend_from_slice(&self.message); + Ok(()) + } + + /// Deserialize the message from bytes that include a full header + pub fn deserialize(data: &[u8]) -> Result { + // validate data length + if data.len() <= Self::HEADER_LEN || data.len() > Self::HEADER_LEN + Self::MAX_LEN { + return Err(SanitizeError::ValueOutOfBounds); + } + // decode header + let format = + MessageFormat::try_from(data[0]).map_err(|_| SanitizeError::InvalidValue)?; + let message_len = u16::from_le_bytes([data[1], data[2]]) as usize; + // check header + if Self::HEADER_LEN.saturating_add(message_len) != data.len() { + return Err(SanitizeError::InvalidValue); + } + let message = &data[Self::HEADER_LEN..]; + // check format + let is_valid = match format { + MessageFormat::RestrictedAscii => { + (message.len() <= Self::MAX_LEN_LEDGER) && is_printable_ascii(message) + } + MessageFormat::LimitedUtf8 => { + (message.len() <= Self::MAX_LEN_LEDGER) && is_utf8(message) + } + MessageFormat::ExtendedUtf8 => (message.len() <= Self::MAX_LEN) && is_utf8(message), + }; + + if is_valid { + Ok(Self { + format, + message: message.to_vec(), + }) + } else { + Err(SanitizeError::InvalidValue) + } + } + + /// Compute the SHA256 hash of the serialized off-chain message + pub fn hash(serialized_message: &[u8]) -> Result { + let mut hasher = Hasher::default(); + hasher.hash(serialized_message); + Ok(hasher.result()) + } + + pub fn get_format(&self) -> MessageFormat { + self.format + } + + pub fn get_message(&self) -> &Vec { + &self.message + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum OffchainMessage { + V0(v0::OffchainMessage), +} + +impl OffchainMessage { + pub const SIGNING_DOMAIN: &'static [u8] = b"\xffsolana offchain"; + // Header Length = Signing Domain (16) + Header Version (1) + pub const HEADER_LEN: usize = Self::SIGNING_DOMAIN.len() + 1; + + /// Construct a new OffchainMessage object from the given version and message + pub fn new(version: u8, message: &[u8]) -> Result { + match version { + 0 => Ok(Self::V0(v0::OffchainMessage::new(message)?)), + _ => Err(SanitizeError::ValueOutOfBounds), + } + } + + /// Serialize the off-chain message to bytes including full header + pub fn serialize(&self) -> Result, SanitizeError> { + // serialize signing domain + let mut data = Self::SIGNING_DOMAIN.to_vec(); + + // serialize version and call version specific serializer + match self { + Self::V0(msg) => { + data.push(0); + msg.serialize(&mut data)?; + } + } + Ok(data) + } + + /// Deserialize the off-chain message from bytes that include full header + pub fn deserialize(data: &[u8]) -> Result { + if data.len() <= Self::HEADER_LEN { + return Err(SanitizeError::ValueOutOfBounds); + } + let version = data[Self::SIGNING_DOMAIN.len()]; + let data = &data[Self::SIGNING_DOMAIN.len().saturating_add(1)..]; + match version { + 0 => Ok(Self::V0(v0::OffchainMessage::deserialize(data)?)), + _ => Err(SanitizeError::ValueOutOfBounds), + } + } + + /// Compute the hash of the off-chain message + pub fn hash(&self) -> Result { + match self { + Self::V0(_) => v0::OffchainMessage::hash(&self.serialize()?), + } + } + + pub fn get_version(&self) -> u8 { + match self { + Self::V0(_) => 0, + } + } + + pub fn get_format(&self) -> MessageFormat { + match self { + Self::V0(msg) => msg.get_format(), + } + } + + pub fn get_message(&self) -> &Vec { + match self { + Self::V0(msg) => msg.get_message(), + } + } + + /// Sign the message with provided keypair + pub fn sign(&self, signer: &dyn Signer) -> Result { + Ok(signer.sign_message(&self.serialize()?)) + } + + /// Verify that the message signature is valid for the given public key + pub fn verify(&self, signer: &Pubkey, signature: &Signature) -> Result { + Ok(signature.verify(signer.as_ref(), &self.serialize()?)) + } +} + +#[cfg(test)] +mod tests { + use {super::*, crate::signature::Keypair, std::str::FromStr}; + + #[test] + fn test_offchain_message_ascii() { + let message = OffchainMessage::new(0, b"Test Message").unwrap(); + assert_eq!(message.get_version(), 0); + assert_eq!(message.get_format(), MessageFormat::RestrictedAscii); + assert_eq!(message.get_message().as_slice(), b"Test Message"); + assert!( + matches!(message, OffchainMessage::V0(ref msg) if msg.get_format() == MessageFormat::RestrictedAscii) + ); + let serialized = [ + 255, 115, 111, 108, 97, 110, 97, 32, 111, 102, 102, 99, 104, 97, 105, 110, 0, 0, 12, 0, + 84, 101, 115, 116, 32, 77, 101, 115, 115, 97, 103, 101, + ]; + let hash = Hash::from_str("HG5JydBGjtjTfD3sSn21ys5NTWPpXzmqifiGC2BVUjkD").unwrap(); + assert_eq!(message.serialize().unwrap(), serialized); + assert_eq!(message.hash().unwrap(), hash); + assert_eq!(message, OffchainMessage::deserialize(&serialized).unwrap()); + } + + #[test] + fn test_offchain_message_utf8() { + let message = OffchainMessage::new(0, "Тестовое сообщение".as_bytes()).unwrap(); + assert_eq!(message.get_version(), 0); + assert_eq!(message.get_format(), MessageFormat::LimitedUtf8); + assert_eq!( + message.get_message().as_slice(), + "Тестовое сообщение".as_bytes() + ); + assert!( + matches!(message, OffchainMessage::V0(ref msg) if msg.get_format() == MessageFormat::LimitedUtf8) + ); + let serialized = [ + 255, 115, 111, 108, 97, 110, 97, 32, 111, 102, 102, 99, 104, 97, 105, 110, 0, 1, 35, 0, + 208, 162, 208, 181, 209, 129, 209, 130, 208, 190, 208, 178, 208, 190, 208, 181, 32, + 209, 129, 208, 190, 208, 190, 208, 177, 209, 137, 208, 181, 208, 189, 208, 184, 208, + 181, + ]; + let hash = Hash::from_str("6GXTveatZQLexkX4WeTpJ3E7uk1UojRXpKp43c4ArSun").unwrap(); + assert_eq!(message.serialize().unwrap(), serialized); + assert_eq!(message.hash().unwrap(), hash); + assert_eq!(message, OffchainMessage::deserialize(&serialized).unwrap()); + } + + #[test] + fn test_offchain_message_sign_and_verify() { + let message = OffchainMessage::new(0, b"Test Message").unwrap(); + let keypair = Keypair::new(); + let signature = message.sign(&keypair).unwrap(); + assert!(message.verify(&keypair.pubkey(), &signature).unwrap()); + } +}