Skip to main content

near_kit/types/
nep413.rs

1//! NEP-413: Off-chain message signing for authentication.
2//!
3//! NEP-413 enables users to sign messages for authentication and ownership verification
4//! without gas fees or blockchain transactions.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! use near_kit::{Near, InMemorySigner, nep413};
10//!
11//! # async fn example() -> Result<(), near_kit::Error> {
12//! let signer = InMemorySigner::new(
13//!     "alice.testnet",
14//!     "ed25519:..."
15//! )?;
16//!
17//! let near = Near::testnet()
18//!     .signer(signer)
19//!     .build();
20//!
21//! // Sign a message
22//! let params = nep413::SignMessageParams {
23//!     message: "Login to MyApp".to_string(),
24//!     recipient: "myapp.com".to_string(),
25//!     nonce: nep413::generate_nonce(),
26//!     callback_url: None,
27//!     state: None,
28//! };
29//!
30//! let signed = near.sign_message(params.clone()).await?;
31//!
32//! // Verify the signature
33//! let is_valid = nep413::verify(&signed, &params, &near, Default::default()).await?;
34//! # Ok(())
35//! # }
36//! ```
37//!
38//! @see <https://github.com/near/NEPs/blob/master/neps/nep-0413.md>
39
40use std::time::Duration;
41
42use borsh::BorshSerialize;
43use serde::{Deserialize, Deserializer, Serialize, Serializer};
44use serde_with::{hex::Hex, serde_as};
45
46use crate::Near;
47use crate::error::Error;
48use crate::types::{AccountId, BlockReference, CryptoHash, PublicKey, Signature};
49
50/// NEP-413 tag prefix: 2^31 + 413 = 2147484061
51///
52/// This prefix ensures that signed messages cannot be confused with valid transactions.
53/// The tag makes the message too long to be a valid signer account ID.
54pub const NEP413_TAG: u32 = (1 << 31) + 413;
55
56/// Default maximum age for signature validity (5 minutes).
57pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(5 * 60);
58
59// ============================================================================
60// Types
61// ============================================================================
62
63/// Parameters for signing a NEP-413 message.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct SignMessageParams {
66    /// The message to sign.
67    pub message: String,
68
69    /// The recipient identifier (e.g., "alice.near" or "myapp.com").
70    pub recipient: String,
71
72    /// A 32-byte nonce for replay protection.
73    /// Use [`generate_nonce()`] to create one with an embedded timestamp.
74    pub nonce: [u8; 32],
75
76    /// Optional callback URL for web wallets.
77    pub callback_url: Option<String>,
78
79    /// Optional state parameter for CSRF protection.
80    pub state: Option<String>,
81}
82
83/// HTTP request payload for NEP-413 authentication.
84///
85/// This is the typical JSON structure sent from a frontend to a backend
86/// for authentication. Use this to deserialize the HTTP request body.
87///
88/// # Example
89///
90/// ```rust,no_run
91/// use near_kit::nep413::{AuthPayload, verify_signature, DEFAULT_MAX_AGE};
92///
93/// // Parse JSON from HTTP request body (in a real app, from req.body)
94/// fn handle_login(body: &str) -> bool {
95///     let payload: AuthPayload = serde_json::from_str(body).unwrap();
96///     let params = payload.to_params();
97///     verify_signature(&payload.signed_message, &params, DEFAULT_MAX_AGE)
98/// }
99/// ```
100#[serde_as]
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct AuthPayload {
104    /// The signed message from the client.
105    pub signed_message: SignedMessage,
106
107    /// The nonce as a hex-encoded string (64 characters for 32 bytes).
108    #[serde_as(as = "Hex")]
109    pub nonce: [u8; 32],
110
111    /// The message that was signed (must match what the client signed).
112    pub message: String,
113
114    /// The recipient identifier (must match what the client signed).
115    pub recipient: String,
116
117    /// Optional callback URL (must match what the client signed, if any).
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub callback_url: Option<String>,
120}
121
122impl AuthPayload {
123    /// Convert to [`SignMessageParams`] for verification.
124    pub fn to_params(&self) -> SignMessageParams {
125        SignMessageParams {
126            message: self.message.clone(),
127            recipient: self.recipient.clone(),
128            nonce: self.nonce,
129            callback_url: self.callback_url.clone(),
130            state: self.signed_message.state.clone(),
131        }
132    }
133
134    /// Create an `AuthPayload` from a signed message and the original parameters.
135    ///
136    /// This is useful when you want to generate the same JSON format that a
137    /// TypeScript client would produce, for example when testing or when a
138    /// Rust client needs to authenticate to a service.
139    ///
140    /// # Example
141    ///
142    /// ```rust,no_run
143    /// use near_kit::{Near, nep413};
144    ///
145    /// # async fn example() -> Result<(), near_kit::Error> {
146    /// let near = Near::testnet()
147    ///     .credentials("ed25519:...", "alice.testnet")?
148    ///     .build();
149    ///
150    /// let params = nep413::SignMessageParams {
151    ///     message: "Sign in to My App".to_string(),
152    ///     recipient: "myapp.com".to_string(),
153    ///     nonce: nep413::generate_nonce(),
154    ///     callback_url: None,
155    ///     state: None,
156    /// };
157    ///
158    /// let signed = near.sign_message(params.clone()).await?;
159    /// let payload = nep413::AuthPayload::from_signed(signed, &params);
160    ///
161    /// // Serialize to JSON for HTTP request
162    /// let json = serde_json::to_string(&payload)?;
163    /// println!("POST body: {}", json);
164    /// # Ok(())
165    /// # }
166    /// ```
167    pub fn from_signed(signed_message: SignedMessage, params: &SignMessageParams) -> Self {
168        Self {
169            signed_message,
170            nonce: params.nonce,
171            message: params.message.clone(),
172            recipient: params.recipient.clone(),
173            callback_url: params.callback_url.clone(),
174        }
175    }
176}
177
178/// Internal Borsh-serializable payload matching NEP-413 spec.
179#[derive(BorshSerialize)]
180struct Nep413Payload {
181    message: String,
182    nonce: [u8; 32],
183    recipient: String,
184    callback_url: Option<String>,
185}
186
187/// A signed NEP-413 message.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct SignedMessage {
191    /// The account that signed the message.
192    pub account_id: AccountId,
193
194    /// The public key used to sign.
195    pub public_key: PublicKey,
196
197    /// The signature (base64 encoded in JSON).
198    #[serde(
199        serialize_with = "serialize_signature_base64",
200        deserialize_with = "deserialize_signature_flexible"
201    )]
202    pub signature: Signature,
203
204    /// Optional state parameter for CSRF protection.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub state: Option<String>,
207}
208
209/// Options for signature verification.
210#[derive(Debug, Clone)]
211pub struct VerifyOptions {
212    /// Maximum age for the signature to be considered valid.
213    /// Set to `Duration::MAX` to disable expiration checking.
214    /// Default: 5 minutes.
215    pub max_age: Duration,
216
217    /// Whether to verify that the public key belongs to the account
218    /// and has full access permission via RPC.
219    /// Default: true.
220    pub require_full_access: bool,
221}
222
223impl Default for VerifyOptions {
224    fn default() -> Self {
225        Self {
226            max_age: DEFAULT_MAX_AGE,
227            require_full_access: true,
228        }
229    }
230}
231
232// ============================================================================
233// Core Functions
234// ============================================================================
235
236/// Generate a 32-byte nonce with an embedded timestamp for expiration checking.
237///
238/// The nonce structure:
239/// - First 8 bytes: timestamp (milliseconds since epoch, big-endian)
240/// - Remaining 24 bytes: cryptographically random data
241///
242/// # Example
243///
244/// ```rust
245/// use near_kit::nep413;
246///
247/// let nonce = nep413::generate_nonce();
248/// assert_eq!(nonce.len(), 32);
249/// ```
250pub fn generate_nonce() -> [u8; 32] {
251    let mut nonce = [0u8; 32];
252
253    // First 8 bytes: timestamp (ms since epoch) as big-endian u64
254    let timestamp = std::time::SystemTime::now()
255        .duration_since(std::time::UNIX_EPOCH)
256        .expect("Time went backwards")
257        .as_millis() as u64;
258    nonce[..8].copy_from_slice(&timestamp.to_be_bytes());
259
260    // Remaining 24 bytes: random data
261    rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce[8..]);
262
263    nonce
264}
265
266/// Extract the timestamp from a nonce (first 8 bytes as big-endian u64 milliseconds).
267pub fn extract_timestamp_from_nonce(nonce: &[u8; 32]) -> u64 {
268    u64::from_be_bytes(nonce[..8].try_into().unwrap())
269}
270
271/// Serialize and hash a NEP-413 message, ready for signing.
272///
273/// Steps:
274/// 1. Serialize the tag (2147484061) as u32 little-endian
275/// 2. Serialize the payload with Borsh
276/// 3. Concatenate: tag_bytes + payload_bytes
277/// 4. Hash with SHA256
278///
279/// # Example
280///
281/// ```rust
282/// use near_kit::nep413::{self, SignMessageParams};
283///
284/// let params = SignMessageParams {
285///     message: "Hello".to_string(),
286///     recipient: "myapp.com".to_string(),
287///     nonce: nep413::generate_nonce(),
288///     callback_url: None,
289///     state: None,
290/// };
291///
292/// let hash = nep413::serialize_message(&params);
293/// ```
294pub fn serialize_message(params: &SignMessageParams) -> CryptoHash {
295    // Serialize tag as u32 little-endian (Borsh uses little-endian)
296    let tag_bytes = NEP413_TAG.to_le_bytes();
297
298    // Serialize payload
299    let payload = Nep413Payload {
300        message: params.message.clone(),
301        nonce: params.nonce,
302        recipient: params.recipient.clone(),
303        callback_url: params.callback_url.clone(),
304    };
305    let payload_bytes = borsh::to_vec(&payload).expect("Borsh serialization should not fail");
306
307    // Concatenate tag + payload
308    let mut combined = Vec::with_capacity(tag_bytes.len() + payload_bytes.len());
309    combined.extend_from_slice(&tag_bytes);
310    combined.extend_from_slice(&payload_bytes);
311
312    // Hash with SHA256
313    CryptoHash::hash(&combined)
314}
315
316/// Verify a NEP-413 signature without RPC (cryptographic verification only).
317///
318/// This checks:
319/// - The signature is valid for the message
320/// - The signature is not expired (based on nonce timestamp)
321///
322/// Does NOT check:
323/// - Whether the public key belongs to the claimed account
324/// - Whether the key has full access permission
325///
326/// Use [`verify()`] for full verification including RPC checks.
327pub fn verify_signature(
328    signed: &SignedMessage,
329    params: &SignMessageParams,
330    max_age: Duration,
331) -> bool {
332    // Check timestamp expiration if max_age is not infinite
333    if max_age != Duration::MAX {
334        let timestamp_ms = extract_timestamp_from_nonce(&params.nonce);
335        let now_ms = std::time::SystemTime::now()
336            .duration_since(std::time::UNIX_EPOCH)
337            .expect("Time went backwards")
338            .as_millis() as u64;
339
340        let age_ms = now_ms.saturating_sub(timestamp_ms);
341
342        // Check if expired or timestamp is in the future (clock skew/tampering)
343        if age_ms > max_age.as_millis() as u64 || timestamp_ms > now_ms {
344            return false;
345        }
346    }
347
348    // Reconstruct the hash
349    let hash = serialize_message(params);
350
351    // Verify the signature
352    signed.signature.verify(hash.as_bytes(), &signed.public_key)
353}
354
355/// Verify a NEP-413 signed message with full verification.
356///
357/// This checks:
358/// - The signature is valid for the message
359/// - The signature is not expired (based on nonce timestamp)
360/// - The public key belongs to the claimed account (via RPC)
361/// - The key has full access permission (not a function call key)
362///
363/// # Arguments
364///
365/// * `signed` - The signed message to verify
366/// * `params` - Original message parameters (must match what was signed)
367/// * `near` - Near client for RPC verification
368/// * `options` - Verification options
369///
370/// # Example
371///
372/// ```rust,no_run
373/// use near_kit::{Near, nep413};
374///
375/// # async fn example() -> Result<(), near_kit::Error> {
376/// let near = Near::testnet().build();
377///
378/// # let signed = todo!();
379/// # let params = todo!();
380/// let is_valid = nep413::verify(&signed, &params, &near, Default::default()).await?;
381/// # Ok(())
382/// # }
383/// ```
384pub async fn verify(
385    signed: &SignedMessage,
386    params: &SignMessageParams,
387    near: &Near,
388    options: VerifyOptions,
389) -> Result<bool, Error> {
390    // First, do cryptographic verification
391    if !verify_signature(signed, params, options.max_age) {
392        return Ok(false);
393    }
394
395    // If RPC verification is requested, check key ownership
396    if options.require_full_access {
397        // Query the access key
398        let access_key_result = near
399            .rpc()
400            .view_access_key(
401                &signed.account_id,
402                &signed.public_key,
403                BlockReference::optimistic(),
404            )
405            .await;
406
407        match access_key_result {
408            Ok(access_key) => {
409                // Check if it's a full access key
410                if !matches!(
411                    access_key.permission,
412                    crate::types::AccessKeyPermissionView::FullAccess
413                ) {
414                    return Ok(false);
415                }
416            }
417            Err(_) => {
418                // Key not found or RPC error - verification fails
419                return Ok(false);
420            }
421        }
422    }
423
424    Ok(true)
425}
426
427// ============================================================================
428// Serde Helpers
429// ============================================================================
430
431/// Serialize a Signature as base64 (NEP-413 spec requirement).
432fn serialize_signature_base64<S>(signature: &Signature, serializer: S) -> Result<S::Ok, S::Error>
433where
434    S: Serializer,
435{
436    use base64::prelude::*;
437    let base64_str = BASE64_STANDARD.encode(signature.as_bytes());
438    serializer.serialize_str(&base64_str)
439}
440
441/// Deserialize a Signature from multiple formats for backwards compatibility:
442/// - Base64 (NEP-413 spec)
443/// - Prefixed base58 (ed25519:... or secp256k1:...)
444/// - Plain base58
445fn deserialize_signature_flexible<'de, D>(deserializer: D) -> Result<Signature, D::Error>
446where
447    D: Deserializer<'de>,
448{
449    use base64::prelude::*;
450    use serde::de::Error;
451
452    let s: String = String::deserialize(deserializer)?;
453
454    // Try base64 first (NEP-413 spec)
455    if let Ok(bytes) = BASE64_STANDARD.decode(&s) {
456        if bytes.len() == 64 {
457            return Ok(Signature::ed25519_from_bytes(
458                bytes
459                    .try_into()
460                    .map_err(|_| D::Error::custom("Invalid signature length"))?,
461            ));
462        }
463    }
464
465    // Try prefixed format (ed25519:base58 or secp256k1:base58)
466    if let Some(data) = s.strip_prefix("ed25519:") {
467        let bytes = bs58::decode(data)
468            .into_vec()
469            .map_err(|e| D::Error::custom(format!("Invalid base58: {}", e)))?;
470        if bytes.len() == 64 {
471            return Ok(Signature::ed25519_from_bytes(
472                bytes
473                    .try_into()
474                    .map_err(|_| D::Error::custom("Invalid signature length"))?,
475            ));
476        }
477    }
478
479    // Try plain base58
480    if let Ok(bytes) = bs58::decode(&s).into_vec() {
481        if bytes.len() == 64 {
482            return Ok(Signature::ed25519_from_bytes(
483                bytes
484                    .try_into()
485                    .map_err(|_| D::Error::custom("Invalid signature length"))?,
486            ));
487        }
488    }
489
490    Err(D::Error::custom(
491        "Invalid signature format. Expected base64, ed25519:base58, or plain base58",
492    ))
493}
494
495// ============================================================================
496// Tests
497// ============================================================================
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn test_generate_nonce() {
505        let nonce1 = generate_nonce();
506        let nonce2 = generate_nonce();
507
508        assert_eq!(nonce1.len(), 32);
509        assert_eq!(nonce2.len(), 32);
510
511        // Nonces should be different (random part)
512        assert_ne!(nonce1, nonce2);
513
514        // Timestamps should be recent (within 1 second)
515        let ts1 = extract_timestamp_from_nonce(&nonce1);
516        let now = std::time::SystemTime::now()
517            .duration_since(std::time::UNIX_EPOCH)
518            .unwrap()
519            .as_millis() as u64;
520        assert!(now - ts1 < 1000);
521    }
522
523    #[test]
524    fn test_serialize_message() {
525        let params = SignMessageParams {
526            message: "Hello NEAR!".to_string(),
527            recipient: "example.near".to_string(),
528            nonce: [0u8; 32],
529            callback_url: None,
530            state: None,
531        };
532
533        let hash = serialize_message(&params);
534
535        // Should produce a valid 32-byte hash
536        assert_eq!(hash.as_bytes().len(), 32);
537
538        // Same input should produce same hash
539        let hash2 = serialize_message(&params);
540        assert_eq!(hash, hash2);
541
542        // Different input should produce different hash
543        let params2 = SignMessageParams {
544            message: "Hello NEAR!".to_string(),
545            recipient: "other.near".to_string(),
546            nonce: [0u8; 32],
547            callback_url: None,
548            state: None,
549        };
550        let hash3 = serialize_message(&params2);
551        assert_ne!(hash, hash3);
552    }
553
554    #[test]
555    fn test_signed_message_json_roundtrip() {
556        use crate::types::SecretKey;
557
558        let secret = SecretKey::generate_ed25519();
559        let params = SignMessageParams {
560            message: "Test".to_string(),
561            recipient: "app.near".to_string(),
562            nonce: generate_nonce(),
563            callback_url: None,
564            state: Some("csrf_token".to_string()),
565        };
566
567        let hash = serialize_message(&params);
568        let signature = secret.sign(hash.as_bytes());
569
570        let signed = SignedMessage {
571            account_id: "alice.near".parse().unwrap(),
572            public_key: secret.public_key(),
573            signature,
574            state: params.state.clone(),
575        };
576
577        // Serialize to JSON
578        let json = serde_json::to_string(&signed).unwrap();
579
580        // The signature should be base64, not ed25519:base58 format
581        // Note: public_key still uses ed25519: prefix (that's correct per NEAR format)
582        // We check that the signature field specifically is base64 by parsing
583        let json_value: serde_json::Value = serde_json::from_str(&json).unwrap();
584        let sig_str = json_value["signature"].as_str().unwrap();
585        // Base64 signatures should not have a colon (ed25519: prefix would have one)
586        assert!(
587            !sig_str.contains(':'),
588            "Signature should be base64, not prefixed format: {}",
589            sig_str
590        );
591
592        // Deserialize back
593        let deserialized: SignedMessage = serde_json::from_str(&json).unwrap();
594        assert_eq!(signed.account_id, deserialized.account_id);
595        assert_eq!(signed.public_key, deserialized.public_key);
596        assert_eq!(
597            signed.signature.as_bytes(),
598            deserialized.signature.as_bytes()
599        );
600        assert_eq!(signed.state, deserialized.state);
601    }
602
603    #[test]
604    fn test_verify_signature_basic() {
605        use crate::types::SecretKey;
606
607        let secret = SecretKey::generate_ed25519();
608        let params = SignMessageParams {
609            message: "Test message".to_string(),
610            recipient: "myapp.com".to_string(),
611            nonce: generate_nonce(),
612            callback_url: None,
613            state: None,
614        };
615
616        let hash = serialize_message(&params);
617        let signature = secret.sign(hash.as_bytes());
618
619        let signed = SignedMessage {
620            account_id: "alice.near".parse().unwrap(),
621            public_key: secret.public_key(),
622            signature,
623            state: None,
624        };
625
626        // Should verify successfully
627        assert!(verify_signature(&signed, &params, DEFAULT_MAX_AGE));
628
629        // Should fail with wrong message
630        let wrong_params = SignMessageParams {
631            message: "Wrong message".to_string(),
632            ..params.clone()
633        };
634        assert!(!verify_signature(&signed, &wrong_params, DEFAULT_MAX_AGE));
635
636        // Should fail with wrong public key
637        let other_secret = SecretKey::generate_ed25519();
638        let wrong_signed = SignedMessage {
639            public_key: other_secret.public_key(),
640            ..signed.clone()
641        };
642        assert!(!verify_signature(&wrong_signed, &params, DEFAULT_MAX_AGE));
643    }
644
645    #[test]
646    fn test_verify_signature_expiration() {
647        use crate::types::SecretKey;
648
649        let secret = SecretKey::generate_ed25519();
650
651        // Create a nonce with an old timestamp (10 minutes ago)
652        let mut old_nonce = [0u8; 32];
653        let old_timestamp = std::time::SystemTime::now()
654            .duration_since(std::time::UNIX_EPOCH)
655            .unwrap()
656            .as_millis() as u64
657            - (10 * 60 * 1000); // 10 minutes ago
658        old_nonce[..8].copy_from_slice(&old_timestamp.to_be_bytes());
659
660        let params = SignMessageParams {
661            message: "Test".to_string(),
662            recipient: "app.com".to_string(),
663            nonce: old_nonce,
664            callback_url: None,
665            state: None,
666        };
667
668        let hash = serialize_message(&params);
669        let signature = secret.sign(hash.as_bytes());
670
671        let signed = SignedMessage {
672            account_id: "alice.near".parse().unwrap(),
673            public_key: secret.public_key(),
674            signature,
675            state: None,
676        };
677
678        // Should fail with default max age (5 minutes)
679        assert!(!verify_signature(&signed, &params, DEFAULT_MAX_AGE));
680
681        // Should pass with longer max age
682        assert!(verify_signature(
683            &signed,
684            &params,
685            Duration::from_secs(15 * 60)
686        ));
687
688        // Should pass with infinite max age
689        assert!(verify_signature(&signed, &params, Duration::MAX));
690    }
691
692    /// Test interoperability with TypeScript near-kit implementation.
693    /// These test vectors were verified against real wallet implementations
694    /// (Meteor Wallet, MyNearWallet) in near-api-rs.
695    #[test]
696    fn test_typescript_interoperability() {
697        use base64::prelude::*;
698
699        // Test vector from near-api-rs, verified against Meteor wallet
700        // Seed phrase: "fatal edge jacket cash hard pass gallery fabric whisper size rain biology"
701        // HD path: m/44'/397'/0'
702        // This produces public key: ed25519:2RM3EotCzEiVobm6aMjaup43k8cFffR4KHFtrqbZ79Qy
703
704        let nonce_base64 = "KNV0cOpvJ50D5vfF9pqWom8wo2sliQ4W+Wa7uZ3Uk6Y=";
705        let nonce_bytes = BASE64_STANDARD.decode(nonce_base64).unwrap();
706        let nonce: [u8; 32] = nonce_bytes.try_into().unwrap();
707
708        // Test WITHOUT callback_url (Meteor wallet style)
709        let params_no_callback = SignMessageParams {
710            message: "Hello NEAR!".to_string(),
711            recipient: "example.near".to_string(),
712            nonce,
713            callback_url: None,
714            state: None,
715        };
716
717        let expected_sig_no_callback = "NnJgPU1Ql7ccRTITIoOVsIfElmvH1RV7QAT4a9Vh6ShCOnjIzRwxqX54JzoQ/nK02p7VBMI2vJn48rpImIJwAw==";
718
719        // Verify our serialization produces the same hash that the TS impl would sign
720        let hash = serialize_message(&params_no_callback);
721
722        // The public key from the seed phrase (derived with m/44'/397'/0')
723        let public_key: crate::types::PublicKey =
724            "ed25519:2RM3EotCzEiVobm6aMjaup43k8cFffR4KHFtrqbZ79Qy"
725                .parse()
726                .unwrap();
727
728        // Decode the expected signature
729        let sig_bytes = BASE64_STANDARD.decode(expected_sig_no_callback).unwrap();
730        let signature = crate::types::Signature::ed25519_from_bytes(
731            sig_bytes.try_into().expect("signature should be 64 bytes"),
732        );
733
734        // Verify the signature is valid for our computed hash
735        assert!(
736            signature.verify(hash.as_bytes(), &public_key),
737            "Signature verification failed - serialization mismatch with TypeScript"
738        );
739
740        // Test WITH callback_url (MyNearWallet style)
741        let params_with_callback = SignMessageParams {
742            message: "Hello NEAR!".to_string(),
743            recipient: "example.near".to_string(),
744            nonce,
745            callback_url: Some("http://localhost:3000".to_string()),
746            state: None,
747        };
748
749        let expected_sig_with_callback = "zzZQ/GwAjrZVrTIFlvmmQbDQHllfzrr8urVWHaRt5cPfcXaCSZo35c5LDpPpTKivR6BxLyb3lcPM0FfCW5lcBQ==";
750
751        let hash = serialize_message(&params_with_callback);
752        let sig_bytes = BASE64_STANDARD.decode(expected_sig_with_callback).unwrap();
753        let signature = crate::types::Signature::ed25519_from_bytes(
754            sig_bytes.try_into().expect("signature should be 64 bytes"),
755        );
756
757        assert!(
758            signature.verify(hash.as_bytes(), &public_key),
759            "Signature verification with callback_url failed - serialization mismatch"
760        );
761    }
762
763    /// Test that we can deserialize a SignedMessage JSON from TypeScript.
764    #[test]
765    fn test_deserialize_typescript_signed_message() {
766        // Simulated JSON from TypeScript near-kit
767        // Using the correct public key derived from seed phrase
768        let ts_json = r#"{
769            "accountId": "alice.testnet",
770            "publicKey": "ed25519:2RM3EotCzEiVobm6aMjaup43k8cFffR4KHFtrqbZ79Qy",
771            "signature": "NnJgPU1Ql7ccRTITIoOVsIfElmvH1RV7QAT4a9Vh6ShCOnjIzRwxqX54JzoQ/nK02p7VBMI2vJn48rpImIJwAw=="
772        }"#;
773
774        // Should deserialize successfully
775        let signed: SignedMessage = serde_json::from_str(ts_json).unwrap();
776
777        assert_eq!(signed.account_id.as_str(), "alice.testnet");
778        assert_eq!(
779            signed.public_key.to_string(),
780            "ed25519:2RM3EotCzEiVobm6aMjaup43k8cFffR4KHFtrqbZ79Qy"
781        );
782        assert_eq!(signed.signature.as_bytes().len(), 64);
783        assert!(signed.state.is_none());
784    }
785
786    /// Test that we can deserialize legacy ed25519:base58 signature format.
787    #[test]
788    fn test_deserialize_legacy_base58_signature() {
789        // Some older implementations might send signatures in ed25519:base58 format
790        let legacy_json = r#"{
791            "accountId": "alice.testnet",
792            "publicKey": "ed25519:HeEp8gQPzs6rMPRN1hijJ7dXFmZLu3FPNKeLDpmLfFBT",
793            "signature": "ed25519:2DzVcjvceXbR6n9ot4C9xA8gVPrZRq8NqJj4b3DaLBmVk1TqXwK8yHcL6M6ezQD4HxXHhZQPbgjdNW7Tx8sjxSFe"
794        }"#;
795
796        // Should deserialize successfully (backwards compatibility)
797        let signed: SignedMessage = serde_json::from_str(legacy_json).unwrap();
798
799        assert_eq!(signed.account_id.as_str(), "alice.testnet");
800        assert_eq!(signed.signature.as_bytes().len(), 64);
801    }
802
803    /// Test roundtrip: Rust -> JSON -> TypeScript-compatible -> JSON -> Rust
804    #[test]
805    fn test_rust_to_typescript_roundtrip() {
806        use crate::types::SecretKey;
807
808        let secret = SecretKey::generate_ed25519();
809        let params = SignMessageParams {
810            message: "Cross-platform test".to_string(),
811            recipient: "myapp.com".to_string(),
812            nonce: generate_nonce(),
813            callback_url: None,
814            state: Some("session123".to_string()),
815        };
816
817        let hash = serialize_message(&params);
818        let signature = secret.sign(hash.as_bytes());
819
820        let signed = SignedMessage {
821            account_id: "alice.near".parse().unwrap(),
822            public_key: secret.public_key(),
823            signature,
824            state: params.state.clone(),
825        };
826
827        // Serialize to JSON (as if sending to TypeScript)
828        let json = serde_json::to_string(&signed).unwrap();
829
830        // Parse as generic JSON to inspect the format
831        let json_value: serde_json::Value = serde_json::from_str(&json).unwrap();
832
833        // Verify field names are camelCase (TypeScript compatible)
834        assert!(json_value.get("accountId").is_some());
835        assert!(json_value.get("publicKey").is_some());
836        assert!(json_value.get("signature").is_some());
837
838        // Verify signature is base64 (no colon = not prefixed format)
839        let sig_str = json_value["signature"].as_str().unwrap();
840        assert!(!sig_str.contains(':'));
841
842        // Deserialize back (as if receiving from TypeScript)
843        let roundtrip: SignedMessage = serde_json::from_str(&json).unwrap();
844        assert_eq!(signed.account_id, roundtrip.account_id);
845        assert_eq!(signed.public_key, roundtrip.public_key);
846        assert_eq!(signed.signature.as_bytes(), roundtrip.signature.as_bytes());
847        assert_eq!(signed.state, roundtrip.state);
848
849        // Verify signature still works after roundtrip
850        assert!(verify_signature(&roundtrip, &params, Duration::MAX));
851    }
852
853    /// Test deserializing the full HTTP authentication payload from TypeScript.
854    #[test]
855    fn test_deserialize_http_auth_payload() {
856        // This is what a TypeScript frontend would send to the backend
857        // nonce is sent as hex (64 chars for 32 bytes)
858        let nonce_hex = "28d57470ea6f279d03e6f7c5f69a96a26f30a36b25890e16f966bbb99dd493a6";
859
860        // Build the JSON as the TS client would
861        let http_payload = serde_json::json!({
862            "signedMessage": {
863                "accountId": "alice.testnet",
864                "publicKey": "ed25519:2RM3EotCzEiVobm6aMjaup43k8cFffR4KHFtrqbZ79Qy",
865                "signature": "NnJgPU1Ql7ccRTITIoOVsIfElmvH1RV7QAT4a9Vh6ShCOnjIzRwxqX54JzoQ/nK02p7VBMI2vJn48rpImIJwAw=="
866            },
867            "nonce": nonce_hex,
868            "message": "Hello NEAR!",
869            "recipient": "example.near"
870        });
871
872        // Deserialize the HTTP payload
873        let payload: AuthPayload = serde_json::from_value(http_payload).unwrap();
874
875        // Verify we got the right data
876        assert_eq!(payload.signed_message.account_id.as_str(), "alice.testnet");
877        assert_eq!(payload.message, "Hello NEAR!");
878        assert_eq!(payload.recipient, "example.near");
879        assert_eq!(payload.nonce.len(), 32);
880
881        // Convert to params and verify the signature
882        let params = payload.to_params();
883        assert!(verify_signature(
884            &payload.signed_message,
885            &params,
886            Duration::MAX
887        ));
888    }
889
890    /// Test the complete authentication flow: TS client -> JSON -> Rust server
891    #[test]
892    fn test_full_auth_flow_interop() {
893        // Simulate what a real HTTP request body would look like from near-kit TS
894        // Nonce is hex encoded (64 chars for 32 bytes)
895        let http_body = r#"{
896            "signedMessage": {
897                "accountId": "alice.testnet",
898                "publicKey": "ed25519:2RM3EotCzEiVobm6aMjaup43k8cFffR4KHFtrqbZ79Qy",
899                "signature": "NnJgPU1Ql7ccRTITIoOVsIfElmvH1RV7QAT4a9Vh6ShCOnjIzRwxqX54JzoQ/nK02p7VBMI2vJn48rpImIJwAw=="
900            },
901            "nonce": "28d57470ea6f279d03e6f7c5f69a96a26f30a36b25890e16f966bbb99dd493a6",
902            "message": "Hello NEAR!",
903            "recipient": "example.near"
904        }"#;
905
906        // Parse as AuthPayload
907        let payload: AuthPayload = serde_json::from_str(http_body).unwrap();
908
909        // Verify the signature
910        let params = payload.to_params();
911        let is_valid = verify_signature(&payload.signed_message, &params, Duration::MAX);
912
913        assert!(is_valid, "Signature should be valid");
914        assert_eq!(payload.signed_message.account_id.as_str(), "alice.testnet");
915    }
916
917    /// Test generating AuthPayload from Rust and serializing to JSON
918    #[test]
919    fn test_generate_auth_payload_from_rust() {
920        use crate::types::SecretKey;
921
922        let secret = SecretKey::generate_ed25519();
923        let params = SignMessageParams {
924            message: "Sign in to My App".to_string(),
925            recipient: "myapp.com".to_string(),
926            nonce: generate_nonce(),
927            callback_url: None,
928            state: None,
929        };
930
931        let hash = serialize_message(&params);
932        let signature = secret.sign(hash.as_bytes());
933
934        let signed = SignedMessage {
935            account_id: "alice.near".parse().unwrap(),
936            public_key: secret.public_key(),
937            signature,
938            state: None,
939        };
940
941        // Create AuthPayload (what would be sent over HTTP)
942        let payload = AuthPayload::from_signed(signed.clone(), &params);
943
944        // Serialize to JSON
945        let json = serde_json::to_string(&payload).unwrap();
946
947        // Verify nonce is hex (not an array)
948        let json_value: serde_json::Value = serde_json::from_str(&json).unwrap();
949        let nonce_str = json_value["nonce"].as_str().unwrap();
950        assert!(
951            nonce_str.len() == 64, // Hex of 32 bytes = 64 chars
952            "Nonce should be hex encoded, got: {}",
953            nonce_str
954        );
955
956        // Deserialize back and verify
957        let roundtrip: AuthPayload = serde_json::from_str(&json).unwrap();
958        assert_eq!(payload.nonce, roundtrip.nonce);
959        assert_eq!(payload.message, roundtrip.message);
960
961        // Verify signature still works
962        let roundtrip_params = roundtrip.to_params();
963        assert!(verify_signature(
964            &roundtrip.signed_message,
965            &roundtrip_params,
966            Duration::MAX
967        ));
968    }
969}