Skip to content

Commit cbd8a9a

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 cbd8a9a

20 files changed

+2126
-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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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"
27+
28+
[dev-dependencies]
29+
# Serialization/Deserealization changes order of fields. Preserving order
30+
# makes it easier to construct the unittests.
31+
serde_json = { version = "1.0", features = ["preserve_order"] }

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: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
use {
2+
base64::{prelude::BASE64_STANDARD, DecodeError, Engine},
3+
chacha20poly1305::{
4+
aead::{Aead, KeyInit, OsRng, Payload},
5+
AeadCore, ChaCha20Poly1305, Nonce,
6+
},
7+
std::string::FromUtf8Error,
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 INIT_VEC_LEN: usize = 12;
17+
const PUB_KEY_LENGTH: usize = 32;
18+
const SYM_KEY_LENGTH: usize = 32;
19+
20+
pub type InitVec = [u8; INIT_VEC_LEN];
21+
pub type SymKey = [u8; SYM_KEY_LENGTH];
22+
pub type PubKey = [u8; PUB_KEY_LENGTH];
23+
24+
/// Payload encoding, decoding, encryption and decryption errors.
25+
#[derive(Debug, thiserror::Error)]
26+
pub enum PayloadError {
27+
#[error("Payload is not base64 encoded")]
28+
Base64Decode(#[from] DecodeError),
29+
#[error("Payload decryption failure: {0}")]
30+
Decryption(String),
31+
#[error("Payload encryption failure: {0}")]
32+
Encryption(String),
33+
#[error("Invalid Initialization Vector length={0}")]
34+
InitVecLen(usize),
35+
#[error("Invalid symmetrical key length={0}")]
36+
SymKeyLen(usize),
37+
#[error("Payload does not fit initialization vector (index: {0}..{1})")]
38+
ParseInitVecLen(usize, usize),
39+
#[error("Payload does not fit sender public key (index: {0}..{1})")]
40+
ParseSenderPublicKeyLen(usize, usize),
41+
#[error("Payload is not a valid JSON encoding")]
42+
PayloadJson(#[from] FromUtf8Error),
43+
#[error("Unsupported envelope type={0}")]
44+
UnsupportedEnvelopeType(u8),
45+
#[error("Unexpected envelope type={0}, expected={1}")]
46+
UnexpectedEnvelopeType(u8, u8),
47+
}
48+
49+
#[derive(Clone, Debug, PartialEq, Eq)]
50+
pub enum EnvelopeType<'a> {
51+
Type0,
52+
Type1 { sender_public_key: &'a PubKey },
53+
}
54+
55+
/// Non-owning convenient representation of the decoded payload blob.
56+
#[derive(Clone, Debug, PartialEq, Eq)]
57+
struct EncodingParams<'a> {
58+
/// Encrypted payload.
59+
sealed: &'a [u8],
60+
/// Initialization Vector.
61+
init_vec: &'a InitVec,
62+
envelope_type: EnvelopeType<'a>,
63+
}
64+
65+
impl<'a> EncodingParams<'a> {
66+
fn parse_decoded(data: &'a [u8]) -> Result<Self, PayloadError> {
67+
let envelope_type = data[0];
68+
match envelope_type {
69+
TYPE_0 => {
70+
let init_vec_start_index: usize = TYPE_INDEX + TYPE_LENGTH;
71+
let init_vec_end_index: usize = init_vec_start_index + INIT_VEC_LEN;
72+
let sealed_start_index: usize = init_vec_end_index;
73+
Ok(EncodingParams {
74+
init_vec: data[init_vec_start_index..init_vec_end_index]
75+
.try_into()
76+
.map_err(|_| {
77+
PayloadError::ParseInitVecLen(init_vec_start_index, init_vec_end_index)
78+
})?,
79+
sealed: &data[sealed_start_index..],
80+
envelope_type: EnvelopeType::Type0,
81+
})
82+
}
83+
TYPE_1 => {
84+
let key_start_index: usize = TYPE_INDEX + TYPE_LENGTH;
85+
let key_end_index: usize = key_start_index + PUB_KEY_LENGTH;
86+
let init_vec_start_index: usize = key_end_index;
87+
let init_vec_end_index: usize = init_vec_start_index + INIT_VEC_LEN;
88+
let sealed_start_index: usize = init_vec_end_index;
89+
Ok(EncodingParams {
90+
init_vec: data[init_vec_start_index..init_vec_end_index]
91+
.try_into()
92+
.map_err(|_| {
93+
PayloadError::ParseInitVecLen(init_vec_start_index, init_vec_end_index)
94+
})?,
95+
sealed: &data[sealed_start_index..],
96+
envelope_type: EnvelopeType::Type1 {
97+
sender_public_key: data[key_start_index..key_end_index]
98+
.try_into()
99+
.map_err(|_| {
100+
PayloadError::ParseSenderPublicKeyLen(
101+
init_vec_start_index,
102+
init_vec_end_index,
103+
)
104+
})?,
105+
},
106+
})
107+
}
108+
_ => Err(PayloadError::UnsupportedEnvelopeType(envelope_type)),
109+
}
110+
}
111+
}
112+
113+
/// Encrypts and encodes the plain-text payload.
114+
///
115+
/// TODO: RNG as an input
116+
pub fn encrypt_and_encode<T>(
117+
envelope_type: EnvelopeType,
118+
msg: T,
119+
key: &SymKey,
120+
) -> Result<String, PayloadError>
121+
where
122+
T: AsRef<[u8]>,
123+
{
124+
let payload = Payload {
125+
msg: msg.as_ref(),
126+
aad: &[],
127+
};
128+
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
129+
130+
let sealed = encrypt(&nonce, payload, key)?;
131+
Ok(encode(
132+
envelope_type,
133+
sealed.as_slice(),
134+
nonce
135+
.as_slice()
136+
.try_into()
137+
.map_err(|_| PayloadError::InitVecLen(nonce.len()))?,
138+
))
139+
}
140+
141+
/// Decodes and decrypts the Type0 envelope payload.
142+
pub fn decode_and_decrypt_type0<T>(msg: T, key: &SymKey) -> Result<String, PayloadError>
143+
where
144+
T: AsRef<[u8]>,
145+
{
146+
let data = BASE64_STANDARD.decode(msg)?;
147+
let decoded = EncodingParams::parse_decoded(&data)?;
148+
if let EnvelopeType::Type1 { .. } = decoded.envelope_type {
149+
return Err(PayloadError::UnexpectedEnvelopeType(TYPE_1, TYPE_0));
150+
}
151+
152+
let payload = Payload {
153+
msg: decoded.sealed,
154+
aad: &[],
155+
};
156+
let decrypted = decrypt(
157+
decoded
158+
.init_vec
159+
.try_into()
160+
.map_err(|_| PayloadError::InitVecLen(decoded.init_vec.len()))?,
161+
payload,
162+
key,
163+
)?;
164+
165+
Ok(String::from_utf8(decrypted)?)
166+
}
167+
168+
fn encrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result<Vec<u8>, PayloadError> {
169+
let cipher = ChaCha20Poly1305::new(
170+
key.try_into()
171+
.map_err(|_| PayloadError::SymKeyLen(key.len()))?,
172+
);
173+
let sealed = cipher
174+
.encrypt(nonce, payload)
175+
.map_err(|e| PayloadError::Encryption(e.to_string()))?;
176+
177+
Ok(sealed)
178+
}
179+
180+
fn encode(envelope_type: EnvelopeType, sealed: &[u8], init_vec: &InitVec) -> String {
181+
match envelope_type {
182+
EnvelopeType::Type0 => {
183+
BASE64_STANDARD.encode([&[TYPE_0], init_vec.as_slice(), sealed].concat())
184+
}
185+
EnvelopeType::Type1 { sender_public_key } => BASE64_STANDARD
186+
.encode([&[TYPE_1], sender_public_key.as_slice(), init_vec, sealed].concat()),
187+
}
188+
}
189+
190+
fn decrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result<Vec<u8>, PayloadError> {
191+
let cipher = ChaCha20Poly1305::new(
192+
key.try_into()
193+
.map_err(|_| PayloadError::SymKeyLen(key.len()))?,
194+
);
195+
let unsealed = cipher
196+
.decrypt(nonce, payload)
197+
.map_err(|e| PayloadError::Decryption(e.to_string()))?;
198+
199+
Ok(unsealed)
200+
}
201+
202+
#[cfg(test)]
203+
mod tests {
204+
use anyhow::Result;
205+
use hex_literal::hex;
206+
207+
use super::*;
208+
209+
// https://www.rfc-editor.org/rfc/rfc7539#section-2.8.2
210+
// Below constans are taken from this section of the RFC.
211+
212+
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."#;
213+
const CIPHERTEXT: [u8; 114] = hex!(
214+
"d3 1a 8d 34 64 8e 60 db 7b 86 af bc 53 ef 7e c2
215+
a4 ad ed 51 29 6e 08 fe a9 e2 b5 a7 36 ee 62 d6
216+
3d be a4 5e 8c a9 67 12 82 fa fb 69 da 92 72 8b
217+
1a 71 de 0a 9e 06 0b 29 05 d6 a5 b6 7e cd 3b 36
218+
92 dd bd 7f 2d 77 8b 8c 98 03 ae e3 28 09 1b 58
219+
fa b3 24 e4 fa d6 75 94 55 85 80 8b 48 31 d7 bc
220+
3f f4 de f0 8e 4b 7a 9d e5 76 d2 65 86 ce c6 4b
221+
61 16"
222+
);
223+
const TAG: [u8; 16] = hex!("1a e1 0b 59 4f 09 e2 6a 7e 90 2e cb d0 60 06 91");
224+
const SYMKEY: SymKey = hex!(
225+
"80 81 82 83 84 85 86 87 88 89 8a 8b 8c 8d 8e 8f
226+
90 91 92 93 94 95 96 97 98 99 9a 9b 9c 9d 9e 9f"
227+
);
228+
const AAD: [u8; 12] = hex!("50 51 52 53 c0 c1 c2 c3 c4 c5 c6 c7");
229+
const INIT_VEC: InitVec = hex!("07 00 00 00 40 41 42 43 44 45 46 47");
230+
231+
/// Tests WCv2 encoding and decoding.
232+
#[test]
233+
fn test_decode_encoded() -> Result<()> {
234+
let init_vec: &InitVec = INIT_VEC.as_slice().try_into()?;
235+
let sealed = [CIPHERTEXT.as_slice(), TAG.as_slice()].concat();
236+
237+
let encoded = encode(EnvelopeType::Type0, &sealed, init_vec);
238+
assert_eq!(
239+
encoded,
240+
"AAcAAABAQUJDREVGR9MajTRkjmDbe4avvFPvfsKkre1RKW4I/qnitac27mLWPb6kXoypZxKC+vtp2pJyixpx3gqeBgspBdaltn7NOzaS3b1/LXeLjJgDruMoCRtY+rMk5PrWdZRVhYCLSDHXvD/03vCOS3qd5XbSZYbOxkthFhrhC1lPCeJqfpAuy9BgBpE="
241+
);
242+
243+
let data = BASE64_STANDARD.decode(&encoded)?;
244+
let decoded = EncodingParams::parse_decoded(&data)?;
245+
assert_eq!(decoded.envelope_type, EnvelopeType::Type0);
246+
assert_eq!(decoded.sealed, sealed);
247+
assert_eq!(decoded.init_vec, init_vec);
248+
249+
Ok(())
250+
}
251+
252+
/// Tests ChaCha20-Poly1305 encryption against the RFC test vector.
253+
///
254+
/// https://www.rfc-editor.org/rfc/rfc7539#section-2.8.2
255+
/// Please note that this test vector has an
256+
/// "Additional Authentication Data", in practice, we will likely
257+
/// be using this algorithm without "AAD".
258+
#[test]
259+
fn test_encryption() -> Result<()> {
260+
let payload = Payload {
261+
msg: PLAINTEXT.as_bytes(),
262+
aad: AAD.as_slice(),
263+
};
264+
let init_vec = INIT_VEC.as_slice().try_into()?;
265+
266+
let sealed = encrypt(init_vec, payload, &SYMKEY)?;
267+
assert_eq!(sealed, [CIPHERTEXT.as_slice(), TAG.as_slice()].concat());
268+
269+
Ok(())
270+
}
271+
272+
/// Tests that encrypted message can be decrypted back.
273+
#[test]
274+
fn test_decrypt_encrypted() -> Result<()> {
275+
let init_vec = INIT_VEC.as_slice().try_into()?;
276+
277+
let seal_payload = Payload {
278+
msg: PLAINTEXT.as_bytes(),
279+
aad: AAD.as_slice(),
280+
};
281+
let sealed = encrypt(init_vec, seal_payload, &SYMKEY)?;
282+
283+
let unseal_payload = Payload {
284+
msg: &sealed,
285+
aad: AAD.as_slice(),
286+
};
287+
let unsealed = decrypt(init_vec, unseal_payload, &SYMKEY)?;
288+
289+
assert_eq!(PLAINTEXT.to_string(), String::from_utf8(unsealed)?);
290+
291+
Ok(())
292+
}
293+
294+
/// Tests that plain text can be WCv2 serialized and deserialized back.
295+
#[test]
296+
fn test_encrypt_encode_decode_decrypt() -> Result<()> {
297+
let encoded = encrypt_and_encode(EnvelopeType::Type0, PLAINTEXT, &SYMKEY)?;
298+
let decoded = decode_and_decrypt_type0(&encoded, &SYMKEY)?;
299+
assert_eq!(decoded, PLAINTEXT);
300+
301+
Ok(())
302+
}
303+
}

0 commit comments

Comments
 (0)