Skip to content

Commit 1fb0f78

Browse files
authored
feat: move cacao to Rust sdk (#17)
1 parent 7f08502 commit 1fb0f78

File tree

8 files changed

+396
-0
lines changed

8 files changed

+396
-0
lines changed

relay_rpc/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ chrono = { version = "0.4", default-features = false, features = ["std", "clock"
1717
regex = "1.7"
1818
once_cell = "1.16"
1919
jsonwebtoken = "8.1"
20+
k256 = "0.13.0"
21+
sha3 = "0.10.6"
22+
hex = "0.4.3"

relay_rpc/src/auth.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#[cfg(test)]
22
mod tests;
33

4+
pub mod cacao;
5+
pub mod did;
6+
47
use {
58
crate::domain::{AuthSubject, ClientId, ClientIdDecodingError, DecodedClientId},
69
chrono::{DateTime, Utc},

relay_rpc/src/auth/cacao/header.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
use {
2+
super::CacaoError,
3+
serde::{Deserialize, Serialize},
4+
};
5+
6+
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
7+
pub struct Header {
8+
pub t: String,
9+
}
10+
11+
impl Header {
12+
pub fn is_valid(&self) -> Result<(), CacaoError> {
13+
match self.t.as_str() {
14+
"eip4361" => Ok(()),
15+
_ => Err(CacaoError::Header),
16+
}
17+
}
18+
}

relay_rpc/src/auth/cacao/mod.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
use {
2+
self::{header::Header, payload::Payload, signature::Signature},
3+
core::fmt::Debug,
4+
serde::{Deserialize, Serialize},
5+
std::fmt::{Display, Write as _},
6+
thiserror::Error as ThisError,
7+
};
8+
9+
pub mod header;
10+
pub mod payload;
11+
pub mod signature;
12+
13+
/// Errors that can occur during JWT verification
14+
#[derive(Debug, ThisError)]
15+
pub enum CacaoError {
16+
#[error("Invalid header")]
17+
Header,
18+
19+
#[error("Invalid or missing identity key in payload resources")]
20+
PayloadIdentityKey,
21+
22+
#[error("Invalid payload resources")]
23+
PayloadResources,
24+
25+
#[error("Unsupported signature type")]
26+
UnsupportedSignature,
27+
28+
#[error("Unable to verify")]
29+
Verification,
30+
}
31+
32+
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
33+
pub enum Version {
34+
V1 = 1,
35+
}
36+
37+
impl<'de> Deserialize<'de> for Version {
38+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
39+
where
40+
D: serde::Deserializer<'de>,
41+
{
42+
let version = String::deserialize(deserializer)?;
43+
match version.as_str() {
44+
"1" => Ok(Version::V1),
45+
_ => Err(serde::de::Error::custom("Invalid version")),
46+
}
47+
}
48+
}
49+
50+
impl Serialize for Version {
51+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
52+
where
53+
S: serde::Serializer,
54+
{
55+
serializer.serialize_str(&format!("{}", *self as u8))
56+
}
57+
}
58+
59+
impl Display for Version {
60+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61+
write!(f, "{}", *self as u8)
62+
}
63+
}
64+
65+
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
66+
pub struct Cacao {
67+
pub h: Header,
68+
pub p: Payload,
69+
pub s: Signature,
70+
}
71+
72+
impl Cacao {
73+
const ETHEREUM: &'static str = "Ethereum";
74+
75+
pub fn verify(&self) -> Result<bool, CacaoError> {
76+
self.p.is_valid()?;
77+
self.h.is_valid()?;
78+
self.s.verify(self)
79+
}
80+
81+
pub fn siwe_message(&self) -> Result<String, CacaoError> {
82+
self.caip122_message(Self::ETHEREUM)
83+
}
84+
85+
pub fn caip122_message(&self, chain_name: &str) -> Result<String, CacaoError> {
86+
let mut message = format!(
87+
"{} wants you to sign in with your {} account:\n{}\n",
88+
self.p.domain,
89+
chain_name,
90+
self.p.address()?
91+
);
92+
93+
if let Some(statement) = &self.p.statement {
94+
let _ = write!(message, "\n{}\n", statement);
95+
}
96+
97+
let _ = write!(
98+
message,
99+
"\nURI: {}\nVersion: {}\nChain ID: {}\nNonce: {}\nIssued At: {}",
100+
self.p.aud,
101+
self.p.version,
102+
self.p.chain_id()?,
103+
self.p.nonce,
104+
self.p.iat
105+
);
106+
107+
if let Some(exp) = &self.p.exp {
108+
let _ = write!(message, "\nExpiration Time: {}", exp);
109+
}
110+
111+
if let Some(nbf) = &self.p.nbf {
112+
let _ = write!(message, "\nNot Before: {}", nbf);
113+
}
114+
115+
if let Some(request_id) = &self.p.request_id {
116+
let _ = write!(message, "\nRequest ID: {}", request_id);
117+
}
118+
119+
if let Some(resources) = &self.p.resources {
120+
if !resources.is_empty() {
121+
let _ = write!(message, "\nResources:");
122+
resources.iter().for_each(|resource| {
123+
let _ = write!(message, "\n- {}", resource);
124+
});
125+
}
126+
}
127+
128+
Ok(message)
129+
}
130+
}
131+
132+
#[cfg(test)]
133+
mod tests;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
use {
2+
super::{CacaoError, Version},
3+
crate::auth::did::{extract_did_data, DID_METHOD_KEY},
4+
serde::{Deserialize, Serialize},
5+
};
6+
7+
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
8+
pub struct Payload {
9+
pub domain: String,
10+
pub iss: String,
11+
pub statement: Option<String>,
12+
pub aud: String,
13+
pub version: Version,
14+
pub nonce: String,
15+
pub iat: String,
16+
pub exp: Option<String>,
17+
pub nbf: Option<String>,
18+
pub request_id: Option<String>,
19+
pub resources: Option<Vec<String>>,
20+
}
21+
22+
impl Payload {
23+
const ISS_DELIMITER: &'static str = ":";
24+
const ISS_POSITION_OF_ADDRESS: usize = 4;
25+
const ISS_POSITION_OF_NAMESPACE: usize = 2;
26+
const ISS_POSITION_OF_REFERENCE: usize = 3;
27+
28+
/// TODO: write valdation
29+
pub fn is_valid(&self) -> Result<bool, CacaoError> {
30+
Ok(true)
31+
}
32+
33+
pub fn address(&self) -> Result<String, CacaoError> {
34+
self.iss
35+
.split(Self::ISS_DELIMITER)
36+
.nth(Self::ISS_POSITION_OF_ADDRESS)
37+
.ok_or(CacaoError::PayloadResources)
38+
.map(|s| s.to_string())
39+
}
40+
41+
pub fn namespace(&self) -> Result<String, CacaoError> {
42+
self.iss
43+
.split(Self::ISS_DELIMITER)
44+
.nth(Self::ISS_POSITION_OF_NAMESPACE)
45+
.ok_or(CacaoError::PayloadResources)
46+
.map(|s| s.to_string())
47+
}
48+
49+
pub fn chain_id_reference(&self) -> Result<String, CacaoError> {
50+
Ok(format!(
51+
"{}{}{}",
52+
self.namespace()?,
53+
Self::ISS_DELIMITER,
54+
self.chain_id()?
55+
))
56+
}
57+
58+
pub fn chain_id(&self) -> Result<String, CacaoError> {
59+
self.iss
60+
.split(Self::ISS_DELIMITER)
61+
.nth(Self::ISS_POSITION_OF_REFERENCE)
62+
.ok_or(CacaoError::PayloadResources)
63+
.map(|s| s.to_string())
64+
}
65+
66+
pub fn caip_10_address(&self) -> Result<String, CacaoError> {
67+
Ok(format!(
68+
"{}{}{}",
69+
self.chain_id_reference()?,
70+
Self::ISS_DELIMITER,
71+
self.address()?
72+
)
73+
.to_lowercase())
74+
}
75+
76+
pub fn identity_key(&self) -> Result<String, CacaoError> {
77+
let resources = self
78+
.resources
79+
.as_ref()
80+
.ok_or(CacaoError::PayloadResources)?;
81+
let did_key = resources.first().ok_or(CacaoError::PayloadIdentityKey)?;
82+
83+
extract_did_data(did_key, DID_METHOD_KEY)
84+
.map(|data| data.to_string())
85+
.map_err(|_| CacaoError::PayloadIdentityKey)
86+
}
87+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
use {
2+
super::{Cacao, CacaoError},
3+
serde::{Deserialize, Serialize},
4+
};
5+
6+
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
7+
pub struct Signature {
8+
pub t: String,
9+
pub s: String,
10+
}
11+
12+
impl Signature {
13+
pub fn verify(&self, cacao: &Cacao) -> Result<bool, CacaoError> {
14+
match self.t.as_str() {
15+
"eip191" => Eip191.verify(&cacao.s.s, &cacao.p.address()?, &cacao.siwe_message()?),
16+
// "eip1271" => Eip1271.verify(), TODO: How to accces our RPC?
17+
_ => Err(CacaoError::UnsupportedSignature),
18+
}
19+
}
20+
}
21+
22+
pub struct Eip191;
23+
24+
impl Eip191 {
25+
pub fn eip191_bytes(&self, message: &str) -> Vec<u8> {
26+
format!(
27+
"\u{0019}Ethereum Signed Message:\n{}{}",
28+
message.as_bytes().len(),
29+
message
30+
)
31+
.into()
32+
}
33+
34+
fn verify(&self, signature: &str, address: &str, message: &str) -> Result<bool, CacaoError> {
35+
use {
36+
k256::ecdsa::{RecoveryId, Signature as Sig, VerifyingKey},
37+
sha3::{Digest, Keccak256},
38+
};
39+
40+
let signature_bytes = hex::decode(guarantee_no_hex_prefix(signature))
41+
.map_err(|_| CacaoError::Verification)?;
42+
43+
let sig = Sig::try_from(&signature_bytes[..64]).map_err(|_| CacaoError::Verification)?;
44+
let recovery_id = RecoveryId::try_from(&signature_bytes[64] % 27)
45+
.map_err(|_| CacaoError::Verification)?;
46+
47+
let recovered_key = VerifyingKey::recover_from_digest(
48+
Keccak256::new_with_prefix(&self.eip191_bytes(message)),
49+
&sig,
50+
recovery_id,
51+
)
52+
.map_err(|_| CacaoError::Verification)?;
53+
54+
let add = &Keccak256::default()
55+
.chain_update(&recovered_key.to_encoded_point(false).as_bytes()[1..])
56+
.finalize()[12..];
57+
58+
let address_encoded = hex::encode(add);
59+
60+
if address_encoded.to_lowercase() != guarantee_no_hex_prefix(address).to_lowercase() {
61+
Err(CacaoError::Verification)
62+
} else {
63+
Ok(true)
64+
}
65+
}
66+
}
67+
68+
/// Remove the 0x prefix from a hex string
69+
fn guarantee_no_hex_prefix(s: &str) -> &str {
70+
if let Some(stripped) = s.strip_prefix("0x") {
71+
stripped
72+
} else {
73+
s
74+
}
75+
}

relay_rpc/src/auth/cacao/tests.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use crate::auth::cacao::Cacao;
2+
3+
/// Test that we can verify a Cacao
4+
#[test]
5+
fn cacao_verify_success() {
6+
let cacao_serialized = r#"{
7+
"h": {
8+
"t": "eip4361"
9+
},
10+
"p": {
11+
"iss": "did:pkh:eip155:1:0xf457f233ab23f863cabc383ebb37b29d8929a17a",
12+
"domain": "http://10.0.2.2:8080",
13+
"aud": "http://10.0.2.2:8080",
14+
"version": "1",
15+
"nonce": "[B@c3772c7",
16+
"iat": "2023-01-17T12:15:05+01:00",
17+
"resources": [
18+
"did:key:z6MkkG9nM8ksS37sq5mgeoCn5kihLkWANcm9pza5WTkq3tWZ"
19+
]
20+
},
21+
"s": {
22+
"t": "eip191",
23+
"s": "0x1b39982707c70c95f4676e7386052a07b47ecc073b3e9cf47b64b579687d3f68181d48fa9e926ad591ba6954f1a70c597d0772a800bed5fa906384fcd83bcf4f1b"
24+
}
25+
} "#;
26+
let cacao: Cacao = serde_json::from_str(cacao_serialized).unwrap();
27+
let result = cacao.verify();
28+
assert!(result.is_ok());
29+
assert!(result.map_err(|_| false).unwrap());
30+
31+
let identity_key = cacao.p.identity_key();
32+
assert!(identity_key.is_ok());
33+
}
34+
35+
/// Test that we can verify a Cacao with uppercase address
36+
#[test]
37+
fn cacao_without_lowercase_address_verify_success() {
38+
let cacao_serialized = r#"{"h":{"t":"eip4361"},"p":{"iss":"did:pkh:eip155:1:0xbD4D1935165012e7D29919dB8717A5e670a1a5b1","domain":"https://staging.keys.walletconnect.com","aud":"https://staging.keys.walletconnect.com","version":"1","nonce":"07487c09be5535dcbc341d8e35e5c9b4d3539a802089c42c5b1172dd9ed63c64","iat":"2023-01-25T15:08:36.846Z","statement":"Test","resources":["did:key:451cf9b97c64fcca05fbb0d4c40b886c94133653df5a2b6bd97bd29a0bbcdb37"]},"s":{"t":"eip191","s":"0x8496ad1dd1ddd5cb78ac26b62a6bd1c6cfff703ea3b11a9da29cfca112357ace75cac8ee28d114f9e166a6935ee9ed83151819a9e0ee738a0547116b1d978e351b"}}"#;
39+
let cacao: Cacao = serde_json::from_str(cacao_serialized).unwrap();
40+
let result = cacao.verify();
41+
assert!(result.is_ok());
42+
assert!(result.map_err(|_| false).unwrap());
43+
44+
let identity_key = cacao.p.identity_key();
45+
assert!(identity_key.is_ok());
46+
}

0 commit comments

Comments
 (0)