Skip to content

Commit 5ca618c

Browse files
feat: add WalletConnect v2 Sign API
This change adds WalletConnect v2 Sign API, as per: https://specs.walletconnect.com/2.0/specs/clients/sign/ Please note that although the specification is fairly thorough, there are some inconsistencies. This implementation is derived from specs, analyzing ws traffic in browsers devtools, as well as the original WalletConnect JavaScript client at: https://github.com/WalletConnect/walletconnect-monorepo Design decisions: Modularity: - RPC and crypto modules are not dependent on each other, so client implementations don't have to use their own crypto implementation. Closes: #47
1 parent 6c2dc10 commit 5ca618c

18 files changed

+1265
-2
lines changed

Cargo.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,21 @@ license = "Apache-2.0"
99
[workspace]
1010
members = [
1111
"relay_client",
12-
"relay_rpc"
12+
"relay_rpc",
13+
"sign_api"
1314
]
1415

1516
[features]
1617
default = ["full"]
17-
full = ["client", "rpc"]
18+
full = ["client", "rpc", "sign_api"]
1819
client = ["dep:relay_client"]
1920
rpc = ["dep:relay_rpc"]
21+
sign_api = ["dep:sign_api"]
2022

2123
[dependencies]
2224
relay_client = { path = "./relay_client", optional = true }
2325
relay_rpc = { path = "./relay_rpc", optional = true }
26+
sign_api = { path = "./sign_api", optional = true }
2427

2528
[dev-dependencies]
2629
anyhow = "1"

sign_api/Cargo.toml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[package]
2+
name = "sign_api"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[dependencies]
7+
anyhow = "1"
8+
base58 = "0.2"
9+
base64 = "0.21"
10+
base64-url = "2.0"
11+
chacha20poly1305 = "0.10"
12+
chrono = "0.4"
13+
hex = "0.4"
14+
hex-literal = "0.4"
15+
hkdf = "0.12"
16+
lazy_static = "1.4"
17+
once_cell = "1.16"
18+
paste = "1.0"
19+
rand = "0.8"
20+
regex = "1.10"
21+
sha2 = "0.10"
22+
serde = { version = "1.0", features = ["derive", "rc"] }
23+
serde_json = "1.0"
24+
thiserror = "1.0"
25+
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
26+
url = "2.4"

sign_api/src/crypto.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub mod payload;
2+
pub mod session;

sign_api/src/crypto/payload.rs

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
use {
2+
anyhow::Result,
3+
base64::{prelude::BASE64_STANDARD, Engine},
4+
chacha20poly1305::{
5+
aead::{Aead, KeyInit, OsRng, Payload},
6+
AeadCore, ChaCha20Poly1305, Nonce,
7+
},
8+
};
9+
10+
// https://specs.walletconnect.com/2.0/specs/clients/core/crypto/
11+
// crypto-envelopes
12+
const TYPE_0: u8 = 0;
13+
const TYPE_1: u8 = 1;
14+
const TYPE_INDEX: usize = 0;
15+
const TYPE_LENGTH: usize = 1;
16+
const IV_LENGTH: usize = 12;
17+
const PUB_KEY_LENGTH: usize = 32;
18+
const SYM_KEY_LENGTH: usize = 32;
19+
20+
pub type Iv = [u8; IV_LENGTH];
21+
pub type SymKey = [u8; SYM_KEY_LENGTH];
22+
pub type PubKey = [u8; PUB_KEY_LENGTH];
23+
24+
#[derive(Clone, Debug, PartialEq, Eq)]
25+
pub enum EnvelopeType<'a> {
26+
Type0,
27+
Type1 { sender_public_key: &'a PubKey },
28+
}
29+
30+
/// Non-owning convenient representation of the decoded payload blob.
31+
#[derive(Clone, Debug, PartialEq, Eq)]
32+
struct EncodingParams<'a> {
33+
/// Encrypted payload.
34+
sealed: &'a [u8],
35+
/// Initialization Vector.
36+
iv: &'a Iv,
37+
envelope_type: EnvelopeType<'a>,
38+
}
39+
40+
impl<'a> EncodingParams<'a> {
41+
fn parse_decoded(data: &'a [u8]) -> Result<Self> {
42+
let envelope_type = data[0];
43+
match envelope_type {
44+
TYPE_0 => {
45+
let iv_index: usize = TYPE_INDEX + TYPE_LENGTH;
46+
let sealed_index: usize = iv_index + IV_LENGTH;
47+
Ok(EncodingParams {
48+
iv: data[iv_index..=IV_LENGTH].try_into()?,
49+
sealed: &data[sealed_index..],
50+
envelope_type: EnvelopeType::Type0,
51+
})
52+
}
53+
TYPE_1 => {
54+
let key_index: usize = TYPE_INDEX + TYPE_LENGTH;
55+
let iv_index: usize = key_index + PUB_KEY_LENGTH;
56+
let sealed_index: usize = iv_index + IV_LENGTH;
57+
Ok(EncodingParams {
58+
iv: data[iv_index..=IV_LENGTH].try_into()?,
59+
sealed: &data[sealed_index..],
60+
envelope_type: EnvelopeType::Type1 {
61+
sender_public_key: data[key_index..=PUB_KEY_LENGTH].try_into()?,
62+
},
63+
})
64+
}
65+
_ => anyhow::bail!("Invalid envelope type: {}", envelope_type),
66+
}
67+
}
68+
}
69+
70+
/// Encrypts and encodes the plain-text payload.
71+
///
72+
/// TODO: RNG as an input
73+
pub fn encrypt_and_encode<T>(envelope_type: EnvelopeType, msg: T, key: &SymKey) -> Result<String>
74+
where
75+
T: AsRef<[u8]>,
76+
{
77+
let payload = Payload {
78+
msg: msg.as_ref(),
79+
aad: &[],
80+
};
81+
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
82+
83+
let sealed = encrypt(&nonce, payload, key)?;
84+
Ok(encode(
85+
envelope_type,
86+
sealed.as_slice(),
87+
nonce.as_slice().try_into()?,
88+
))
89+
}
90+
91+
/// Decodes and decrypts the Type0 envelope payload.
92+
pub fn decode_and_decrypt_type0<T>(msg: T, key: &SymKey) -> Result<String>
93+
where
94+
T: AsRef<[u8]>,
95+
{
96+
let data = BASE64_STANDARD.decode(msg)?;
97+
let decoded = EncodingParams::parse_decoded(&data)?;
98+
if let EnvelopeType::Type1 { .. } = decoded.envelope_type {
99+
anyhow::bail!("Expected envelope type 0");
100+
}
101+
102+
let payload = Payload {
103+
msg: decoded.sealed,
104+
aad: &[],
105+
};
106+
let decrypted = decrypt(decoded.iv.try_into()?, payload, key)?;
107+
108+
Ok(String::from_utf8(decrypted)?)
109+
}
110+
111+
fn encrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result<Vec<u8>> {
112+
let cipher = ChaCha20Poly1305::new(key.try_into()?);
113+
let sealed = cipher
114+
.encrypt(nonce, payload)
115+
.map_err(|e| anyhow::anyhow!("Encryption failed, err: {:?}", e))?;
116+
117+
Ok(sealed)
118+
}
119+
120+
fn encode(envelope_type: EnvelopeType, sealed: &[u8], iv: &Iv) -> String {
121+
match envelope_type {
122+
EnvelopeType::Type0 => BASE64_STANDARD.encode([&[TYPE_0], iv.as_slice(), sealed].concat()),
123+
EnvelopeType::Type1 { sender_public_key } => {
124+
BASE64_STANDARD.encode([&[TYPE_1], sender_public_key.as_slice(), iv, sealed].concat())
125+
}
126+
}
127+
}
128+
129+
fn decrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result<Vec<u8>> {
130+
let cipher = ChaCha20Poly1305::new(key.try_into()?);
131+
let unsealed = cipher
132+
.decrypt(nonce, payload)
133+
.map_err(|e| anyhow::anyhow!("Decryption failed, err: {:?}", e))?;
134+
135+
Ok(unsealed)
136+
}
137+
138+
#[cfg(test)]
139+
mod tests {
140+
use hex_literal::hex;
141+
142+
use super::*;
143+
144+
// https://www.rfc-editor.org/rfc/rfc7539#section-2.8.2
145+
// Below constans are taken from this section of the RFC.
146+
147+
const PLAINTEXT: &str = r#"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."#;
148+
const CIPHERTEXT: [u8; 114] = hex!(
149+
"d3 1a 8d 34 64 8e 60 db 7b 86 af bc 53 ef 7e c2
150+
a4 ad ed 51 29 6e 08 fe a9 e2 b5 a7 36 ee 62 d6
151+
3d be a4 5e 8c a9 67 12 82 fa fb 69 da 92 72 8b
152+
1a 71 de 0a 9e 06 0b 29 05 d6 a5 b6 7e cd 3b 36
153+
92 dd bd 7f 2d 77 8b 8c 98 03 ae e3 28 09 1b 58
154+
fa b3 24 e4 fa d6 75 94 55 85 80 8b 48 31 d7 bc
155+
3f f4 de f0 8e 4b 7a 9d e5 76 d2 65 86 ce c6 4b
156+
61 16"
157+
);
158+
const TAG: [u8; 16] = hex!("1a e1 0b 59 4f 09 e2 6a 7e 90 2e cb d0 60 06 91");
159+
const SYMKEY: SymKey = hex!(
160+
"80 81 82 83 84 85 86 87 88 89 8a 8b 8c 8d 8e 8f
161+
90 91 92 93 94 95 96 97 98 99 9a 9b 9c 9d 9e 9f"
162+
);
163+
const AAD: [u8; 12] = hex!("50 51 52 53 c0 c1 c2 c3 c4 c5 c6 c7");
164+
const IV: Iv = hex!("07 00 00 00 40 41 42 43 44 45 46 47");
165+
166+
/// Tests WCv2 encoding and decoding.
167+
#[test]
168+
fn test_decode_encoded() -> Result<()> {
169+
let iv: &Iv = IV.as_slice().try_into()?;
170+
let sealed = [CIPHERTEXT.as_slice(), TAG.as_slice()].concat();
171+
172+
let encoded = encode(EnvelopeType::Type0, &sealed, iv);
173+
assert_eq!(
174+
encoded,
175+
"AAcAAABAQUJDREVGR9MajTRkjmDbe4avvFPvfsKkre1RKW4I/qnitac27mLWPb6kXoypZxKC+vtp2pJyixpx3gqeBgspBdaltn7NOzaS3b1/LXeLjJgDruMoCRtY+rMk5PrWdZRVhYCLSDHXvD/03vCOS3qd5XbSZYbOxkthFhrhC1lPCeJqfpAuy9BgBpE="
176+
);
177+
178+
let data = BASE64_STANDARD.decode(&encoded)?;
179+
let decoded = EncodingParams::parse_decoded(&data)?;
180+
assert_eq!(decoded.envelope_type, EnvelopeType::Type0);
181+
assert_eq!(decoded.sealed, sealed);
182+
assert_eq!(decoded.iv, iv);
183+
184+
Ok(())
185+
}
186+
187+
/// Tests ChaCha20-Poly1305 encryption against the RFC test vector.
188+
///
189+
/// https://www.rfc-editor.org/rfc/rfc7539#section-2.8.2
190+
/// Please note that this test vector has an
191+
/// "Additional Authentication Data", in practice, we will likely
192+
/// be using this algorithm without "AAD".
193+
#[test]
194+
fn test_encryption() -> Result<()> {
195+
let payload = Payload {
196+
msg: PLAINTEXT.as_bytes(),
197+
aad: AAD.as_slice(),
198+
};
199+
let iv = IV.as_slice().try_into()?;
200+
201+
let sealed = encrypt(iv, payload, &SYMKEY)?;
202+
assert_eq!(sealed, [CIPHERTEXT.as_slice(), TAG.as_slice()].concat());
203+
204+
Ok(())
205+
}
206+
207+
/// Tests that encrypted message can be decrypted back.
208+
#[test]
209+
fn test_decrypt_encrypted() -> Result<()> {
210+
let iv = IV.as_slice().try_into()?;
211+
212+
let seal_payload = Payload {
213+
msg: PLAINTEXT.as_bytes(),
214+
aad: AAD.as_slice(),
215+
};
216+
let sealed = encrypt(iv, seal_payload, &SYMKEY)?;
217+
218+
let unseal_payload = Payload {
219+
msg: &sealed,
220+
aad: AAD.as_slice(),
221+
};
222+
let unsealed = decrypt(iv, unseal_payload, &SYMKEY)?;
223+
224+
assert_eq!(PLAINTEXT.to_string(), String::from_utf8(unsealed)?);
225+
226+
Ok(())
227+
}
228+
229+
/// Tests that plain text can be WCv2 serialized and deserialized back.
230+
#[test]
231+
fn test_encrypt_encode_decode_decrypt() -> Result<()> {
232+
let encoded = encrypt_and_encode(EnvelopeType::Type0, PLAINTEXT, &SYMKEY)?;
233+
let decoded = decode_and_decrypt_type0(&encoded, &SYMKEY)?;
234+
assert_eq!(decoded, PLAINTEXT);
235+
236+
Ok(())
237+
}
238+
}

sign_api/src/crypto/session.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//! https://specs.walletconnect.com/2.0/specs/clients/sign/session-proposal
2+
3+
use {
4+
anyhow::Result,
5+
hkdf::Hkdf,
6+
rand::{rngs::OsRng, CryptoRng, RngCore},
7+
sha2::{Digest, Sha256},
8+
std::fmt::{Debug, Formatter},
9+
x25519_dalek::{EphemeralSecret, PublicKey},
10+
};
11+
12+
#[derive(Clone)]
13+
pub struct SessionKey {
14+
sym_key: [u8; 32],
15+
public_key: PublicKey,
16+
}
17+
18+
impl Debug for SessionKey {
19+
/// Custom debug to hide the symmetrical key.
20+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
21+
f.debug_struct("WalletConnectUrl")
22+
.field("sym_key", &"********")
23+
.field("public_key", &self.public_key)
24+
.finish()
25+
}
26+
}
27+
28+
impl SessionKey {
29+
/// Creates new session key from `osrng`.
30+
///
31+
/// Helper for when `osrng` is good enough.
32+
pub fn from_osrng(sender_public_key: &[u8; 32]) -> Result<Self> {
33+
SessionKey::diffie_hellman(OsRng, sender_public_key)
34+
}
35+
36+
/// Performs Diffie-Hellman symmetric key derivation.
37+
pub fn diffie_hellman<T>(csprng: T, sender_public_key: &[u8; 32]) -> Result<Self>
38+
where
39+
T: RngCore + CryptoRng,
40+
{
41+
let single_use_private_key = EphemeralSecret::random_from_rng(csprng);
42+
let public_key = PublicKey::from(&single_use_private_key);
43+
44+
let ikm = single_use_private_key.diffie_hellman(&PublicKey::from(*sender_public_key));
45+
46+
let mut session_sym_key = Self {
47+
sym_key: [0u8; 32],
48+
public_key,
49+
};
50+
let hk = Hkdf::<Sha256>::new(None, ikm.as_bytes());
51+
hk.expand(&[], &mut session_sym_key.sym_key)
52+
.map_err(|e| anyhow::anyhow!("Failed to generate SymKey: {e}"))?;
53+
54+
Ok(session_sym_key)
55+
}
56+
57+
/// Gets symmetic key reference.
58+
pub fn symmetric_key(&self) -> &[u8; 32] {
59+
&self.sym_key
60+
}
61+
62+
/// Gets "our" public key used in symmetric key derivation.
63+
pub fn diffie_public_key(&self) -> &[u8; 32] {
64+
self.public_key.as_bytes()
65+
}
66+
67+
/// Generates new session topic.
68+
pub fn generate_topic(&self) -> String {
69+
let mut hasher = Sha256::new();
70+
hasher.update(self.sym_key);
71+
hex::encode(hasher.finalize())
72+
}
73+
}

sign_api/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub mod crypto;
2+
pub mod pairing_uri;
3+
pub mod rpc;

0 commit comments

Comments
 (0)