use std::time::Duration;
use borsh::BorshSerialize;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_with::{hex::Hex, serde_as};
use crate::Near;
use crate::error::Error;
use crate::types::{AccountId, BlockReference, CryptoHash, PublicKey, Signature};
pub const NEP413_TAG: u32 = (1 << 31) + 413;
pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(5 * 60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignMessageParams {
pub message: String,
pub recipient: String,
pub nonce: [u8; 32],
pub callback_url: Option<String>,
pub state: Option<String>,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthPayload {
pub signed_message: SignedMessage,
#[serde_as(as = "Hex")]
pub nonce: [u8; 32],
pub message: String,
pub recipient: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub callback_url: Option<String>,
}
impl AuthPayload {
pub fn to_params(&self) -> SignMessageParams {
SignMessageParams {
message: self.message.clone(),
recipient: self.recipient.clone(),
nonce: self.nonce,
callback_url: self.callback_url.clone(),
state: self.signed_message.state.clone(),
}
}
pub fn from_signed(signed_message: SignedMessage, params: &SignMessageParams) -> Self {
Self {
signed_message,
nonce: params.nonce,
message: params.message.clone(),
recipient: params.recipient.clone(),
callback_url: params.callback_url.clone(),
}
}
}
#[derive(BorshSerialize)]
struct Nep413Payload {
message: String,
nonce: [u8; 32],
recipient: String,
callback_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignedMessage {
pub account_id: AccountId,
pub public_key: PublicKey,
#[serde(
serialize_with = "serialize_signature_base64",
deserialize_with = "deserialize_signature_flexible"
)]
pub signature: Signature,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
#[derive(Debug, Clone)]
pub struct VerifyOptions {
pub max_age: Duration,
pub require_full_access: bool,
}
impl Default for VerifyOptions {
fn default() -> Self {
Self {
max_age: DEFAULT_MAX_AGE,
require_full_access: true,
}
}
}
pub fn generate_nonce() -> [u8; 32] {
let mut nonce = [0u8; 32];
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("Time went backwards")
.as_millis() as u64;
nonce[..8].copy_from_slice(×tamp.to_be_bytes());
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce[8..]);
nonce
}
pub fn extract_timestamp_from_nonce(nonce: &[u8; 32]) -> u64 {
u64::from_be_bytes(nonce[..8].try_into().unwrap())
}
pub fn serialize_message(params: &SignMessageParams) -> CryptoHash {
let tag_bytes = NEP413_TAG.to_le_bytes();
let payload = Nep413Payload {
message: params.message.clone(),
nonce: params.nonce,
recipient: params.recipient.clone(),
callback_url: params.callback_url.clone(),
};
let payload_bytes = borsh::to_vec(&payload).expect("Borsh serialization should not fail");
let mut combined = Vec::with_capacity(tag_bytes.len() + payload_bytes.len());
combined.extend_from_slice(&tag_bytes);
combined.extend_from_slice(&payload_bytes);
CryptoHash::hash(&combined)
}
pub fn verify_signature(
signed: &SignedMessage,
params: &SignMessageParams,
max_age: Duration,
) -> bool {
if max_age != Duration::MAX {
let timestamp_ms = extract_timestamp_from_nonce(¶ms.nonce);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("Time went backwards")
.as_millis() as u64;
let age_ms = now_ms.saturating_sub(timestamp_ms);
if age_ms > max_age.as_millis() as u64 || timestamp_ms > now_ms {
return false;
}
}
let hash = serialize_message(params);
signed.signature.verify(hash.as_bytes(), &signed.public_key)
}
pub async fn verify(
signed: &SignedMessage,
params: &SignMessageParams,
near: &Near,
options: VerifyOptions,
) -> Result<bool, Error> {
if !verify_signature(signed, params, options.max_age) {
return Ok(false);
}
if options.require_full_access {
let access_key_result = near
.rpc()
.view_access_key(
&signed.account_id,
&signed.public_key,
BlockReference::optimistic(),
)
.await;
match access_key_result {
Ok(access_key) => {
if !matches!(
access_key.permission,
crate::types::AccessKeyPermissionView::FullAccess
) {
return Ok(false);
}
}
Err(_) => {
return Ok(false);
}
}
}
Ok(true)
}
fn serialize_signature_base64<S>(signature: &Signature, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
use base64::prelude::*;
let base64_str = BASE64_STANDARD.encode(signature.as_bytes());
serializer.serialize_str(&base64_str)
}
fn deserialize_signature_flexible<'de, D>(deserializer: D) -> Result<Signature, D::Error>
where
D: Deserializer<'de>,
{
use base64::prelude::*;
use serde::de::Error;
let s: String = String::deserialize(deserializer)?;
if let Ok(bytes) = BASE64_STANDARD.decode(&s) {
if bytes.len() == 64 {
return Ok(Signature::ed25519_from_bytes(
bytes
.try_into()
.map_err(|_| D::Error::custom("Invalid signature length"))?,
));
}
}
if let Some(data) = s.strip_prefix("ed25519:") {
let bytes = bs58::decode(data)
.into_vec()
.map_err(|e| D::Error::custom(format!("Invalid base58: {}", e)))?;
if bytes.len() == 64 {
return Ok(Signature::ed25519_from_bytes(
bytes
.try_into()
.map_err(|_| D::Error::custom("Invalid signature length"))?,
));
}
}
if let Ok(bytes) = bs58::decode(&s).into_vec() {
if bytes.len() == 64 {
return Ok(Signature::ed25519_from_bytes(
bytes
.try_into()
.map_err(|_| D::Error::custom("Invalid signature length"))?,
));
}
}
Err(D::Error::custom(
"Invalid signature format. Expected base64, ed25519:base58, or plain base58",
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_nonce() {
let nonce1 = generate_nonce();
let nonce2 = generate_nonce();
assert_eq!(nonce1.len(), 32);
assert_eq!(nonce2.len(), 32);
assert_ne!(nonce1, nonce2);
let ts1 = extract_timestamp_from_nonce(&nonce1);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
assert!(now - ts1 < 1000);
}
#[test]
fn test_serialize_message() {
let params = SignMessageParams {
message: "Hello NEAR!".to_string(),
recipient: "example.near".to_string(),
nonce: [0u8; 32],
callback_url: None,
state: None,
};
let hash = serialize_message(¶ms);
assert_eq!(hash.as_bytes().len(), 32);
let hash2 = serialize_message(¶ms);
assert_eq!(hash, hash2);
let params2 = SignMessageParams {
message: "Hello NEAR!".to_string(),
recipient: "other.near".to_string(),
nonce: [0u8; 32],
callback_url: None,
state: None,
};
let hash3 = serialize_message(¶ms2);
assert_ne!(hash, hash3);
}
#[test]
fn test_signed_message_json_roundtrip() {
use crate::types::SecretKey;
let secret = SecretKey::generate_ed25519();
let params = SignMessageParams {
message: "Test".to_string(),
recipient: "app.near".to_string(),
nonce: generate_nonce(),
callback_url: None,
state: Some("csrf_token".to_string()),
};
let hash = serialize_message(¶ms);
let signature = secret.sign(hash.as_bytes());
let signed = SignedMessage {
account_id: "alice.near".parse().unwrap(),
public_key: secret.public_key(),
signature,
state: params.state.clone(),
};
let json = serde_json::to_string(&signed).unwrap();
let json_value: serde_json::Value = serde_json::from_str(&json).unwrap();
let sig_str = json_value["signature"].as_str().unwrap();
assert!(
!sig_str.contains(':'),
"Signature should be base64, not prefixed format: {}",
sig_str
);
let deserialized: SignedMessage = serde_json::from_str(&json).unwrap();
assert_eq!(signed.account_id, deserialized.account_id);
assert_eq!(signed.public_key, deserialized.public_key);
assert_eq!(
signed.signature.as_bytes(),
deserialized.signature.as_bytes()
);
assert_eq!(signed.state, deserialized.state);
}
#[test]
fn test_verify_signature_basic() {
use crate::types::SecretKey;
let secret = SecretKey::generate_ed25519();
let params = SignMessageParams {
message: "Test message".to_string(),
recipient: "myapp.com".to_string(),
nonce: generate_nonce(),
callback_url: None,
state: None,
};
let hash = serialize_message(¶ms);
let signature = secret.sign(hash.as_bytes());
let signed = SignedMessage {
account_id: "alice.near".parse().unwrap(),
public_key: secret.public_key(),
signature,
state: None,
};
assert!(verify_signature(&signed, ¶ms, DEFAULT_MAX_AGE));
let wrong_params = SignMessageParams {
message: "Wrong message".to_string(),
..params.clone()
};
assert!(!verify_signature(&signed, &wrong_params, DEFAULT_MAX_AGE));
let other_secret = SecretKey::generate_ed25519();
let wrong_signed = SignedMessage {
public_key: other_secret.public_key(),
..signed.clone()
};
assert!(!verify_signature(&wrong_signed, ¶ms, DEFAULT_MAX_AGE));
}
#[test]
fn test_verify_signature_expiration() {
use crate::types::SecretKey;
let secret = SecretKey::generate_ed25519();
let mut old_nonce = [0u8; 32];
let old_timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64
- (10 * 60 * 1000); old_nonce[..8].copy_from_slice(&old_timestamp.to_be_bytes());
let params = SignMessageParams {
message: "Test".to_string(),
recipient: "app.com".to_string(),
nonce: old_nonce,
callback_url: None,
state: None,
};
let hash = serialize_message(¶ms);
let signature = secret.sign(hash.as_bytes());
let signed = SignedMessage {
account_id: "alice.near".parse().unwrap(),
public_key: secret.public_key(),
signature,
state: None,
};
assert!(!verify_signature(&signed, ¶ms, DEFAULT_MAX_AGE));
assert!(verify_signature(
&signed,
¶ms,
Duration::from_secs(15 * 60)
));
assert!(verify_signature(&signed, ¶ms, Duration::MAX));
}
#[test]
fn test_typescript_interoperability() {
use base64::prelude::*;
let nonce_base64 = "KNV0cOpvJ50D5vfF9pqWom8wo2sliQ4W+Wa7uZ3Uk6Y=";
let nonce_bytes = BASE64_STANDARD.decode(nonce_base64).unwrap();
let nonce: [u8; 32] = nonce_bytes.try_into().unwrap();
let params_no_callback = SignMessageParams {
message: "Hello NEAR!".to_string(),
recipient: "example.near".to_string(),
nonce,
callback_url: None,
state: None,
};
let expected_sig_no_callback = "NnJgPU1Ql7ccRTITIoOVsIfElmvH1RV7QAT4a9Vh6ShCOnjIzRwxqX54JzoQ/nK02p7VBMI2vJn48rpImIJwAw==";
let hash = serialize_message(¶ms_no_callback);
let public_key: crate::types::PublicKey =
"ed25519:2RM3EotCzEiVobm6aMjaup43k8cFffR4KHFtrqbZ79Qy"
.parse()
.unwrap();
let sig_bytes = BASE64_STANDARD.decode(expected_sig_no_callback).unwrap();
let signature = crate::types::Signature::ed25519_from_bytes(
sig_bytes.try_into().expect("signature should be 64 bytes"),
);
assert!(
signature.verify(hash.as_bytes(), &public_key),
"Signature verification failed - serialization mismatch with TypeScript"
);
let params_with_callback = SignMessageParams {
message: "Hello NEAR!".to_string(),
recipient: "example.near".to_string(),
nonce,
callback_url: Some("http://localhost:3000".to_string()),
state: None,
};
let expected_sig_with_callback = "zzZQ/GwAjrZVrTIFlvmmQbDQHllfzrr8urVWHaRt5cPfcXaCSZo35c5LDpPpTKivR6BxLyb3lcPM0FfCW5lcBQ==";
let hash = serialize_message(¶ms_with_callback);
let sig_bytes = BASE64_STANDARD.decode(expected_sig_with_callback).unwrap();
let signature = crate::types::Signature::ed25519_from_bytes(
sig_bytes.try_into().expect("signature should be 64 bytes"),
);
assert!(
signature.verify(hash.as_bytes(), &public_key),
"Signature verification with callback_url failed - serialization mismatch"
);
}
#[test]
fn test_deserialize_typescript_signed_message() {
let ts_json = r#"{
"accountId": "alice.testnet",
"publicKey": "ed25519:2RM3EotCzEiVobm6aMjaup43k8cFffR4KHFtrqbZ79Qy",
"signature": "NnJgPU1Ql7ccRTITIoOVsIfElmvH1RV7QAT4a9Vh6ShCOnjIzRwxqX54JzoQ/nK02p7VBMI2vJn48rpImIJwAw=="
}"#;
let signed: SignedMessage = serde_json::from_str(ts_json).unwrap();
assert_eq!(signed.account_id.as_str(), "alice.testnet");
assert_eq!(
signed.public_key.to_string(),
"ed25519:2RM3EotCzEiVobm6aMjaup43k8cFffR4KHFtrqbZ79Qy"
);
assert_eq!(signed.signature.as_bytes().len(), 64);
assert!(signed.state.is_none());
}
#[test]
fn test_deserialize_legacy_base58_signature() {
let legacy_json = r#"{
"accountId": "alice.testnet",
"publicKey": "ed25519:HeEp8gQPzs6rMPRN1hijJ7dXFmZLu3FPNKeLDpmLfFBT",
"signature": "ed25519:2DzVcjvceXbR6n9ot4C9xA8gVPrZRq8NqJj4b3DaLBmVk1TqXwK8yHcL6M6ezQD4HxXHhZQPbgjdNW7Tx8sjxSFe"
}"#;
let signed: SignedMessage = serde_json::from_str(legacy_json).unwrap();
assert_eq!(signed.account_id.as_str(), "alice.testnet");
assert_eq!(signed.signature.as_bytes().len(), 64);
}
#[test]
fn test_rust_to_typescript_roundtrip() {
use crate::types::SecretKey;
let secret = SecretKey::generate_ed25519();
let params = SignMessageParams {
message: "Cross-platform test".to_string(),
recipient: "myapp.com".to_string(),
nonce: generate_nonce(),
callback_url: None,
state: Some("session123".to_string()),
};
let hash = serialize_message(¶ms);
let signature = secret.sign(hash.as_bytes());
let signed = SignedMessage {
account_id: "alice.near".parse().unwrap(),
public_key: secret.public_key(),
signature,
state: params.state.clone(),
};
let json = serde_json::to_string(&signed).unwrap();
let json_value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(json_value.get("accountId").is_some());
assert!(json_value.get("publicKey").is_some());
assert!(json_value.get("signature").is_some());
let sig_str = json_value["signature"].as_str().unwrap();
assert!(!sig_str.contains(':'));
let roundtrip: SignedMessage = serde_json::from_str(&json).unwrap();
assert_eq!(signed.account_id, roundtrip.account_id);
assert_eq!(signed.public_key, roundtrip.public_key);
assert_eq!(signed.signature.as_bytes(), roundtrip.signature.as_bytes());
assert_eq!(signed.state, roundtrip.state);
assert!(verify_signature(&roundtrip, ¶ms, Duration::MAX));
}
#[test]
fn test_deserialize_http_auth_payload() {
let nonce_hex = "28d57470ea6f279d03e6f7c5f69a96a26f30a36b25890e16f966bbb99dd493a6";
let http_payload = serde_json::json!({
"signedMessage": {
"accountId": "alice.testnet",
"publicKey": "ed25519:2RM3EotCzEiVobm6aMjaup43k8cFffR4KHFtrqbZ79Qy",
"signature": "NnJgPU1Ql7ccRTITIoOVsIfElmvH1RV7QAT4a9Vh6ShCOnjIzRwxqX54JzoQ/nK02p7VBMI2vJn48rpImIJwAw=="
},
"nonce": nonce_hex,
"message": "Hello NEAR!",
"recipient": "example.near"
});
let payload: AuthPayload = serde_json::from_value(http_payload).unwrap();
assert_eq!(payload.signed_message.account_id.as_str(), "alice.testnet");
assert_eq!(payload.message, "Hello NEAR!");
assert_eq!(payload.recipient, "example.near");
assert_eq!(payload.nonce.len(), 32);
let params = payload.to_params();
assert!(verify_signature(
&payload.signed_message,
¶ms,
Duration::MAX
));
}
#[test]
fn test_full_auth_flow_interop() {
let http_body = r#"{
"signedMessage": {
"accountId": "alice.testnet",
"publicKey": "ed25519:2RM3EotCzEiVobm6aMjaup43k8cFffR4KHFtrqbZ79Qy",
"signature": "NnJgPU1Ql7ccRTITIoOVsIfElmvH1RV7QAT4a9Vh6ShCOnjIzRwxqX54JzoQ/nK02p7VBMI2vJn48rpImIJwAw=="
},
"nonce": "28d57470ea6f279d03e6f7c5f69a96a26f30a36b25890e16f966bbb99dd493a6",
"message": "Hello NEAR!",
"recipient": "example.near"
}"#;
let payload: AuthPayload = serde_json::from_str(http_body).unwrap();
let params = payload.to_params();
let is_valid = verify_signature(&payload.signed_message, ¶ms, Duration::MAX);
assert!(is_valid, "Signature should be valid");
assert_eq!(payload.signed_message.account_id.as_str(), "alice.testnet");
}
#[test]
fn test_generate_auth_payload_from_rust() {
use crate::types::SecretKey;
let secret = SecretKey::generate_ed25519();
let params = SignMessageParams {
message: "Sign in to My App".to_string(),
recipient: "myapp.com".to_string(),
nonce: generate_nonce(),
callback_url: None,
state: None,
};
let hash = serialize_message(¶ms);
let signature = secret.sign(hash.as_bytes());
let signed = SignedMessage {
account_id: "alice.near".parse().unwrap(),
public_key: secret.public_key(),
signature,
state: None,
};
let payload = AuthPayload::from_signed(signed.clone(), ¶ms);
let json = serde_json::to_string(&payload).unwrap();
let json_value: serde_json::Value = serde_json::from_str(&json).unwrap();
let nonce_str = json_value["nonce"].as_str().unwrap();
assert!(
nonce_str.len() == 64, "Nonce should be hex encoded, got: {}",
nonce_str
);
let roundtrip: AuthPayload = serde_json::from_str(&json).unwrap();
assert_eq!(payload.nonce, roundtrip.nonce);
assert_eq!(payload.message, roundtrip.message);
let roundtrip_params = roundtrip.to_params();
assert!(verify_signature(
&roundtrip.signed_message,
&roundtrip_params,
Duration::MAX
));
}
}