From de1facb9af75333bde7160b751fc1c545b695f30 Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Sat, 25 Apr 2026 03:12:38 +0700 Subject: [PATCH 1/9] feat(nano): implement NOMS for sign-message (ORIS-001) --- ows/crates/ows-signer/src/chains/nano.rs | 56 ++++++++++++++++++------ 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/ows/crates/ows-signer/src/chains/nano.rs b/ows/crates/ows-signer/src/chains/nano.rs index d3d33fd8..3c7725b2 100644 --- a/ows/crates/ows-signer/src/chains/nano.rs +++ b/ows/crates/ows-signer/src/chains/nano.rs @@ -221,6 +221,9 @@ pub fn build_state_block( // NanoSigner // ───────────────────────────────────────────────────────────────────────────── +/// NOMS (Nano Off-chain Message Standard) magic header. +const NOMS_MAGIC_HEADER: &[u8; 25] = b"\x18Nano Off-chain Message:\n"; + /// Nano chain signer (Ed25519 with blake2b-512). pub struct NanoSigner; @@ -290,14 +293,23 @@ impl ChainSigner for NanoSigner { fn sign_message( &self, - _private_key: &[u8], - _message: &[u8], + private_key: &[u8], + message: &[u8], ) -> Result { - Err(SignerError::SigningFailed( - "Nano off-chain message signing is not supported: no canonical standard exists. \ - Define an ecosystem convention before enabling this." - .into(), - )) + let msg_len = u32::try_from(message.len()) + .map_err(|_| SignerError::InvalidMessage("message too large for NOMS".into()))?; + + let mut payload = Vec::with_capacity(NOMS_MAGIC_HEADER.len() + 4 + message.len()); + payload.extend_from_slice(NOMS_MAGIC_HEADER); + payload.extend_from_slice(&msg_len.to_be_bytes()); + payload.extend_from_slice(message); + + let mut hasher = Blake2b::::new(); + hasher.update(&payload); + let mut message_hash = [0u8; 32]; + message_hash.copy_from_slice(&hasher.finalize()); + + self.sign(private_key, &message_hash) } fn encode_signed_transaction( @@ -485,16 +497,32 @@ mod tests { } #[test] - fn test_sign_message_unsupported() { + fn test_sign_message_noms() { let key = derive_key(MNEMONIC_12, "", "m/44'/165'/0'"); let signer = NanoSigner; let message = b"hello nano"; - let result = signer.sign_message(&key, message); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Nano off-chain message signing is not supported")); + let result = signer.sign_message(&key, message).unwrap(); + + // Assert output format + assert_eq!(result.signature.len(), 64); + assert!(result.recovery_id.is_none()); + assert!(result.public_key.is_some()); + + let vk = NanoSigner::verifying_key(&key).unwrap(); + + // Verify that the signed hash matches NOMS spec manually + let mut expected_payload = Vec::new(); + expected_payload.extend_from_slice(b"\x18Nano Off-chain Message:\n"); + expected_payload.extend_from_slice(&(message.len() as u32).to_be_bytes()); + expected_payload.extend_from_slice(message); + + let mut hasher = Blake2b::::new(); + hasher.update(&expected_payload); + let mut expected_hash = [0u8; 32]; + expected_hash.copy_from_slice(&hasher.finalize()); + + let sig = ed25519_dalek::Signature::from_bytes(&result.signature.try_into().unwrap()); + raw_verify::(&vk, &expected_hash, &sig).expect("should verify against NOMS payload hash"); } #[test] From 9ab939e938f8e80f37f8802872d6622d4d69c7a1 Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Sat, 25 Apr 2026 12:23:20 +0700 Subject: [PATCH 2/9] test(nano): add known vector interoperability test for NOMS --- ows/crates/ows-signer/src/chains/nano.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ows/crates/ows-signer/src/chains/nano.rs b/ows/crates/ows-signer/src/chains/nano.rs index 3c7725b2..a18896e6 100644 --- a/ows/crates/ows-signer/src/chains/nano.rs +++ b/ows/crates/ows-signer/src/chains/nano.rs @@ -525,6 +525,20 @@ mod tests { raw_verify::(&vk, &expected_hash, &sig).expect("should verify against NOMS payload hash"); } + #[test] + fn test_sign_message_noms_known_vector() { + let signer = NanoSigner; + let private_key = hex::decode("681FD5ED71A9F81E9D29E3450F6CD8AACB87346FD21A26003389290B9D0CB173").unwrap(); + let expected_pubkey = hex::decode("D2B3C9D00FFB55E84E7979D67308A515FB07CA79E40A77EB1AAFE62881781783").unwrap(); + let message = b"Hej!"; + let expected_signature = hex::decode("e347e2d2bc3fba0932bcf533997bdd4c4c6a217e5f5ee128470e0ced8a2450c182189322fd2eeeb354da50f14d2010e8bc9814824c490145013754cdff944806").unwrap(); + + let result = signer.sign_message(&private_key, message).unwrap(); + + assert_eq!(result.public_key.unwrap(), expected_pubkey); + assert_eq!(result.signature, expected_signature); + } + #[test] fn test_public_key_in_sign_output() { let key = derive_key(MNEMONIC_24, PASSPHRASE_24, "m/44'/165'/0'"); From 8d70b4eed2a803396cb60caeaf3ccee3e2eea588 Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Sat, 25 Apr 2026 12:27:55 +0700 Subject: [PATCH 3/9] test(nano): assert derived explicit address in noms vector test --- ows/crates/ows-signer/src/chains/nano.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/ows/crates/ows-signer/src/chains/nano.rs b/ows/crates/ows-signer/src/chains/nano.rs index a18896e6..fd903f04 100644 --- a/ows/crates/ows-signer/src/chains/nano.rs +++ b/ows/crates/ows-signer/src/chains/nano.rs @@ -537,6 +537,7 @@ mod tests { assert_eq!(result.public_key.unwrap(), expected_pubkey); assert_eq!(result.signature, expected_signature); + assert_eq!(signer.derive_address(&private_key).unwrap(), "nano_3noms9a1zytox399kygpge6cc7hu1z79ms1cgzojodz8741qi7w5u3nzb8mn"); } #[test] From 72f30f789cc25b281e75ff5e5b053a4ecd512b10 Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Sat, 25 Apr 2026 12:59:11 +0700 Subject: [PATCH 4/9] test(nano): regenerate known vector test with UTF-8 message including emojis --- ows/crates/ows-signer/src/chains/nano.rs | 25 +++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/ows/crates/ows-signer/src/chains/nano.rs b/ows/crates/ows-signer/src/chains/nano.rs index fd903f04..7666f540 100644 --- a/ows/crates/ows-signer/src/chains/nano.rs +++ b/ows/crates/ows-signer/src/chains/nano.rs @@ -528,15 +528,34 @@ mod tests { #[test] fn test_sign_message_noms_known_vector() { let signer = NanoSigner; + + // Note: This is the final 32-byte Ed25519-blake2b private key. + // It is NOT a Nano Wallet "Seed". + // Implementations treating this input as a Seed may erroneously perform a + // derivation hash (e.g. `blake2b_256(seed || index)`) before expansion, + // producing an entirely different keypair (like the 'nano_3iq5r...' address). let private_key = hex::decode("681FD5ED71A9F81E9D29E3450F6CD8AACB87346FD21A26003389290B9D0CB173").unwrap(); + + // The exact corresponding 32-byte public key. let expected_pubkey = hex::decode("D2B3C9D00FFB55E84E7979D67308A515FB07CA79E40A77EB1AAFE62881781783").unwrap(); - let message = b"Hej!"; - let expected_signature = hex::decode("e347e2d2bc3fba0932bcf533997bdd4c4c6a217e5f5ee128470e0ced8a2450c182189322fd2eeeb354da50f14d2010e8bc9814824c490145013754cdff944806").unwrap(); - + + // The UTF-8 string with an emoji which gets domain-separated and hashed via NOMS before being signed. + let message = "Hej Nano! 🥦".as_bytes(); + + // Expected ED25519-blake2b signature over the Blake2b-256 NOMS payload digest. + let expected_signature = hex::decode("be9e691f3bab829b440126c3492eee518c47a6555de4c00f1017874e7324bdfc1a2451cef59c8a011e03215df92fbc12b9d25c15d2808ca4665616a8c5350506").unwrap(); + + // The sign_message call expands the 32-byte key via Blake2b-512 and signs the hashed payload. let result = signer.sign_message(&private_key, message).unwrap(); + // Verify the directly extracted public key matches our expected bytes. assert_eq!(result.public_key.unwrap(), expected_pubkey); + + // Verify the signature bytes match exactly. assert_eq!(result.signature, expected_signature); + + // Verify that encoding the 'nano_' address directly from this raw private key + // correctly resolves to the expected address ('nano_3noms...'). assert_eq!(signer.derive_address(&private_key).unwrap(), "nano_3noms9a1zytox399kygpge6cc7hu1z79ms1cgzojodz8741qi7w5u3nzb8mn"); } From 37c33459e6d6c1c8a963fb5b7acf7fd06b4803e6 Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Sat, 25 Apr 2026 13:28:15 +0700 Subject: [PATCH 5/9] test(nano): sync known vector test signature formatting with WIP implementations --- ows/crates/ows-signer/src/chains/nano.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ows/crates/ows-signer/src/chains/nano.rs b/ows/crates/ows-signer/src/chains/nano.rs index 7666f540..945a256b 100644 --- a/ows/crates/ows-signer/src/chains/nano.rs +++ b/ows/crates/ows-signer/src/chains/nano.rs @@ -540,10 +540,10 @@ mod tests { let expected_pubkey = hex::decode("D2B3C9D00FFB55E84E7979D67308A515FB07CA79E40A77EB1AAFE62881781783").unwrap(); // The UTF-8 string with an emoji which gets domain-separated and hashed via NOMS before being signed. - let message = "Hej Nano! 🥦".as_bytes(); + let message = "Hej Nano!🥦".as_bytes(); // Expected ED25519-blake2b signature over the Blake2b-256 NOMS payload digest. - let expected_signature = hex::decode("be9e691f3bab829b440126c3492eee518c47a6555de4c00f1017874e7324bdfc1a2451cef59c8a011e03215df92fbc12b9d25c15d2808ca4665616a8c5350506").unwrap(); + let expected_signature = hex::decode("535c745819d0f40056f3c46402b4fae4356b3a8897bde99c955d411920e740d781e6dddcbde228e8b86c4383a1003f9f315519ff73bd356f561d19865dc90f09").unwrap(); // The sign_message call expands the 32-byte key via Blake2b-512 and signs the hashed payload. let result = signer.sign_message(&private_key, message).unwrap(); From 4e14379566478b542a3abf4f8546b5e3407f809e Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Sat, 25 Apr 2026 14:05:36 +0700 Subject: [PATCH 6/9] test(nano): address review feedback regarding magic header literal and u32 casting --- ows/crates/ows-signer/src/chains/nano.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ows/crates/ows-signer/src/chains/nano.rs b/ows/crates/ows-signer/src/chains/nano.rs index 945a256b..e21d6cbb 100644 --- a/ows/crates/ows-signer/src/chains/nano.rs +++ b/ows/crates/ows-signer/src/chains/nano.rs @@ -512,8 +512,8 @@ mod tests { // Verify that the signed hash matches NOMS spec manually let mut expected_payload = Vec::new(); - expected_payload.extend_from_slice(b"\x18Nano Off-chain Message:\n"); - expected_payload.extend_from_slice(&(message.len() as u32).to_be_bytes()); + expected_payload.extend_from_slice(super::NOMS_MAGIC_HEADER); + expected_payload.extend_from_slice(&(u32::try_from(message.len()).unwrap()).to_be_bytes()); expected_payload.extend_from_slice(message); let mut hasher = Blake2b::::new(); From 34dc7b8f028515e2be843213700fd35309ac694e Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Sat, 25 Apr 2026 14:10:23 +0700 Subject: [PATCH 7/9] docs(nano): add ORIS-001 NOMS specification link to header and sign_message impl --- ows/crates/ows-signer/src/chains/nano.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ows/crates/ows-signer/src/chains/nano.rs b/ows/crates/ows-signer/src/chains/nano.rs index e21d6cbb..596069cc 100644 --- a/ows/crates/ows-signer/src/chains/nano.rs +++ b/ows/crates/ows-signer/src/chains/nano.rs @@ -222,6 +222,7 @@ pub fn build_state_block( // ───────────────────────────────────────────────────────────────────────────── /// NOMS (Nano Off-chain Message Standard) magic header. +/// See: https://github.com/OpenRai/Standards/blob/main/rfcs/ORIS-001.md const NOMS_MAGIC_HEADER: &[u8; 25] = b"\x18Nano Off-chain Message:\n"; /// Nano chain signer (Ed25519 with blake2b-512). @@ -291,6 +292,8 @@ impl ChainSigner for NanoSigner { self.sign(private_key, &block_hash) } + // Implements Nano Off-chain Message Signing (NOMS). + // See: https://github.com/OpenRai/Standards/blob/main/rfcs/ORIS-001.md fn sign_message( &self, private_key: &[u8], From 86f7c7e3c54eb5bbcdfbd29a34f801ac070a96e6 Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Sat, 25 Apr 2026 14:56:44 +0700 Subject: [PATCH 8/9] docs(nano): explicitly document intentional double-hash semantics in sign_message --- ows/crates/ows-signer/src/chains/nano.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ows/crates/ows-signer/src/chains/nano.rs b/ows/crates/ows-signer/src/chains/nano.rs index 596069cc..ad17180c 100644 --- a/ows/crates/ows-signer/src/chains/nano.rs +++ b/ows/crates/ows-signer/src/chains/nano.rs @@ -312,6 +312,13 @@ impl ChainSigner for NanoSigner { let mut message_hash = [0u8; 32]; message_hash.copy_from_slice(&hasher.finalize()); + // Note on double-hashing: + // The NOMS spec dictates creating a 32-byte Blake2b-256 digest of the payload. + // We then pass this 32-byte digest to `self.sign()`, which internally applies + // Nano's Ed25519-blake2b signature scheme. That scheme computes a Blake2b-512 + // hash over the scalar and the message (which in this case is the 32-byte digest). + // This effective `Blake2b-512(scalar || Blake2b-256(payload))` double-hash is + // intentional and precisely matches the ORIS-001 specification. self.sign(private_key, &message_hash) } From ab4c61ca946ff51451c941b99591db9e6205dd9f Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Sat, 25 Apr 2026 15:32:01 +0700 Subject: [PATCH 9/9] test(nano): add empty string edge case test for NOMS payload --- ows/crates/ows-signer/src/chains/nano.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ows/crates/ows-signer/src/chains/nano.rs b/ows/crates/ows-signer/src/chains/nano.rs index ad17180c..21230d2a 100644 --- a/ows/crates/ows-signer/src/chains/nano.rs +++ b/ows/crates/ows-signer/src/chains/nano.rs @@ -569,6 +569,21 @@ mod tests { assert_eq!(signer.derive_address(&private_key).unwrap(), "nano_3noms9a1zytox399kygpge6cc7hu1z79ms1cgzojodz8741qi7w5u3nzb8mn"); } + #[test] + fn test_sign_message_noms_empty_string() { + let key = derive_key(MNEMONIC_12, "", "m/44'/165'/0'"); + let signer = NanoSigner; + + // Empty string should not panic on `u32::try_from` or `payload` construction. + let message = b""; + let result = signer.sign_message(&key, message); + assert!(result.is_ok()); + + // Validate signature output length manually + let sig_output = result.unwrap(); + assert_eq!(sig_output.signature.len(), 64); + } + #[test] fn test_public_key_in_sign_output() { let key = derive_key(MNEMONIC_24, PASSPHRASE_24, "m/44'/165'/0'");