parse: init ee
This commit is contained in:
parent
a2ad8b3334
commit
38bf1d3c3b
14
Cargo.toml
14
Cargo.toml
@ -5,8 +5,6 @@ authors = ["occheung"]
|
|||||||
edition = "2018"
|
edition = "2018"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
aes-gcm = "0.7.0"
|
|
||||||
ccm = "0.2.0"
|
|
||||||
hkdf = "0.9.0"
|
hkdf = "0.9.0"
|
||||||
sha2 = { version = "0.9.1", default-features = false }
|
sha2 = { version = "0.9.1", default-features = false }
|
||||||
byteorder = { version = "1.3.4", default-features = false }
|
byteorder = { version = "1.3.4", default-features = false }
|
||||||
@ -15,6 +13,16 @@ log = "0.4.11"
|
|||||||
generic-array = "0.14.4"
|
generic-array = "0.14.4"
|
||||||
heapless = "0.5.6"
|
heapless = "0.5.6"
|
||||||
|
|
||||||
|
[dependencies.aes-gcm]
|
||||||
|
version = "0.8.0"
|
||||||
|
default-features = true
|
||||||
|
features = [ "heapless" ]
|
||||||
|
|
||||||
|
[dependencies.ccm]
|
||||||
|
version = "0.3.0"
|
||||||
|
default-features = true
|
||||||
|
features = [ "heapless" ]
|
||||||
|
|
||||||
[dependencies.smoltcp]
|
[dependencies.smoltcp]
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
default-features = false
|
default-features = false
|
||||||
@ -28,7 +36,7 @@ features = []
|
|||||||
[dependencies.chacha20poly1305]
|
[dependencies.chacha20poly1305]
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
default-features = false
|
default-features = false
|
||||||
features = [ "alloc", "chacha20" ]
|
features = [ "alloc", "chacha20", "heapless" ]
|
||||||
|
|
||||||
[dependencies.p256]
|
[dependencies.p256]
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
@ -86,7 +86,7 @@ impl<'a> TlsBuffer<'a> {
|
|||||||
self.write_u16(tls_repr.version.into())?;
|
self.write_u16(tls_repr.version.into())?;
|
||||||
self.write_u16(tls_repr.length)?;
|
self.write_u16(tls_repr.length)?;
|
||||||
if let Some(app_data) = tls_repr.payload {
|
if let Some(app_data) = tls_repr.payload {
|
||||||
self.write(app_data)?;
|
self.write(app_data.as_slice())?;
|
||||||
} else if let Some(handshake_repr) = tls_repr.handshake {
|
} else if let Some(handshake_repr) = tls_repr.handshake {
|
||||||
// Queue handshake_repr into buffer
|
// Queue handshake_repr into buffer
|
||||||
self.enqueue_handshake_repr(handshake_repr)?;
|
self.enqueue_handshake_repr(handshake_repr)?;
|
||||||
|
2
src/encrypted.rs
Normal file
2
src/encrypted.rs
Normal file
File diff suppressed because one or more lines are too long
@ -16,5 +16,6 @@ pub enum Error {
|
|||||||
PropagatedError(smoltcp::Error),
|
PropagatedError(smoltcp::Error),
|
||||||
ParsingError,
|
ParsingError,
|
||||||
EncryptionError,
|
EncryptionError,
|
||||||
|
DecryptionError,
|
||||||
CapacityError,
|
CapacityError,
|
||||||
}
|
}
|
43
src/main.rs
43
src/main.rs
@ -22,6 +22,9 @@ use hkdf::Hkdf;
|
|||||||
use smoltcp_tls::key::*;
|
use smoltcp_tls::key::*;
|
||||||
use smoltcp_tls::buffer::TlsBuffer;
|
use smoltcp_tls::buffer::TlsBuffer;
|
||||||
|
|
||||||
|
mod encrypted;
|
||||||
|
use encrypted::ENCRYPTED_DATA;
|
||||||
|
|
||||||
struct CountingRng(u64);
|
struct CountingRng(u64);
|
||||||
|
|
||||||
impl RngCore for CountingRng {
|
impl RngCore for CountingRng {
|
||||||
@ -120,7 +123,7 @@ fn main() {
|
|||||||
handshake_secret_hkdf.expand(info, &mut okm).unwrap();
|
handshake_secret_hkdf.expand(info, &mut okm).unwrap();
|
||||||
okm
|
okm
|
||||||
};
|
};
|
||||||
let client_handshake_write_key = {
|
let server_handshake_write_key = {
|
||||||
let hkdf_label = HkdfLabel {
|
let hkdf_label = HkdfLabel {
|
||||||
length: 16,
|
length: 16,
|
||||||
label_length: 9,
|
label_length: 9,
|
||||||
@ -135,15 +138,49 @@ fn main() {
|
|||||||
|
|
||||||
// Define output key material (OKM), dynamically sized by hash
|
// Define output key material (OKM), dynamically sized by hash
|
||||||
let mut okm: GenericArray<u8, U16> = GenericArray::default();
|
let mut okm: GenericArray<u8, U16> = GenericArray::default();
|
||||||
Hkdf::<Sha256>::from_prk(&client_handshake_traffic_secret)
|
Hkdf::<Sha256>::from_prk(&server_handshake_traffic_secret)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expand(info, &mut okm);
|
.expand(info, &mut okm);
|
||||||
okm
|
okm
|
||||||
};
|
};
|
||||||
|
let server_handshake_write_iv = {
|
||||||
|
let hkdf_label = HkdfLabel {
|
||||||
|
length: 12,
|
||||||
|
label_length: 8,
|
||||||
|
label: b"tls13 iv",
|
||||||
|
context_length: 0,
|
||||||
|
context: b"",
|
||||||
|
};
|
||||||
|
let mut array = [0; 100];
|
||||||
|
let mut buffer = TlsBuffer::new(&mut array);
|
||||||
|
buffer.enqueue_hkdf_label(hkdf_label);
|
||||||
|
let info: &[u8] = buffer.into();
|
||||||
|
|
||||||
|
// Define output key material (OKM), dynamically sized by hash
|
||||||
|
let mut okm: GenericArray<u8, U12> = GenericArray::default();
|
||||||
|
Hkdf::<Sha256>::from_prk(&server_handshake_traffic_secret)
|
||||||
|
.unwrap()
|
||||||
|
.expand(info, &mut okm);
|
||||||
|
okm
|
||||||
|
};
|
||||||
|
let cipher: Aes128Gcm = Aes128Gcm::new(&server_handshake_write_key);
|
||||||
|
let decrypted_data = {
|
||||||
|
let mut vec: Vec<u8, U2048> = Vec::from_slice(&ENCRYPTED_DATA).unwrap();
|
||||||
|
cipher.decrypt_in_place(
|
||||||
|
&server_handshake_write_iv,
|
||||||
|
&[
|
||||||
|
0x17, 0x03, 0x03, 0x04, 0x75
|
||||||
|
],
|
||||||
|
&mut vec
|
||||||
|
).unwrap();
|
||||||
|
vec
|
||||||
|
};
|
||||||
|
|
||||||
println!("{:x?}", client_handshake_traffic_secret);
|
println!("{:x?}", client_handshake_traffic_secret);
|
||||||
println!("{:x?}", server_handshake_traffic_secret);
|
println!("{:x?}", server_handshake_traffic_secret);
|
||||||
println!("{:x?}", client_handshake_write_key);
|
println!("{:x?}", server_handshake_write_key);
|
||||||
|
println!("{:x?}", server_handshake_write_iv);
|
||||||
|
println!("{:x?}", decrypted_data);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
29
src/parse.rs
29
src/parse.rs
@ -43,7 +43,9 @@ pub(crate) fn parse_tls_repr(bytes: &[u8]) -> IResult<&[u8], TlsRepr> {
|
|||||||
repr.handshake = Some(handshake);
|
repr.handshake = Some(handshake);
|
||||||
},
|
},
|
||||||
ChangeCipherSpec | ApplicationData => {
|
ChangeCipherSpec | ApplicationData => {
|
||||||
repr.payload = Some(bytes);
|
let mut vec: Vec<u8> = Vec::new();
|
||||||
|
vec.extend_from_slice(bytes);
|
||||||
|
repr.payload = Some(vec);
|
||||||
},
|
},
|
||||||
_ => todo!()
|
_ => todo!()
|
||||||
}
|
}
|
||||||
@ -124,6 +126,30 @@ fn parse_server_hello(bytes: &[u8]) -> IResult<&[u8], HandshakeData> {
|
|||||||
Ok((rest, HandshakeData::ServerHello(server_hello)))
|
Ok((rest, HandshakeData::ServerHello(server_hello)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_encrypted_extensions(bytes: &[u8]) -> IResult<&[u8], EncryptedExtensions> {
|
||||||
|
let (mut rest, extension_length) = take(2_usize)(bytes)?;
|
||||||
|
let mut extension_length: i32 = NetworkEndian::read_u16(extension_length).into();
|
||||||
|
let mut extension_vec: Vec<Extension> = Vec::new();
|
||||||
|
while extension_length > 0 {
|
||||||
|
let (rem, extension) = parse_extension(rest, HandshakeType::EncryptedExtensions)?;
|
||||||
|
rest = rem;
|
||||||
|
extension_length -= i32::try_from(extension.get_length()).unwrap();
|
||||||
|
|
||||||
|
// Todo:: Proper error
|
||||||
|
if extension_length < 0 {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension_vec.push(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
let encrypted_extensions = EncryptedExtensions {
|
||||||
|
length: u16::try_from(extension_length).unwrap(),
|
||||||
|
extensions: extension_vec
|
||||||
|
};
|
||||||
|
Ok((rest, encrypted_extensions))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_extension(bytes: &[u8], handshake_type: HandshakeType) -> IResult<&[u8], Extension> {
|
fn parse_extension(bytes: &[u8], handshake_type: HandshakeType) -> IResult<&[u8], Extension> {
|
||||||
let extension_type = take(2_usize);
|
let extension_type = take(2_usize);
|
||||||
let length = take(2_usize);
|
let length = take(2_usize);
|
||||||
@ -139,6 +165,7 @@ fn parse_extension(bytes: &[u8], handshake_type: HandshakeType) -> IResult<&[u8]
|
|||||||
// Process extension data according to extension_type
|
// Process extension data according to extension_type
|
||||||
// TODO: Deal with HelloRetryRequest
|
// TODO: Deal with HelloRetryRequest
|
||||||
let (rest, extension_data) = {
|
let (rest, extension_data) = {
|
||||||
|
// TODO: Handle all mandatory extension types
|
||||||
use ExtensionType::*;
|
use ExtensionType::*;
|
||||||
match extension_type {
|
match extension_type {
|
||||||
SupportedVersions => {
|
SupportedVersions => {
|
||||||
|
@ -27,6 +27,7 @@ pub(crate) struct Session {
|
|||||||
changed_cipher_spec: bool,
|
changed_cipher_spec: bool,
|
||||||
// Handshake secret, Master secret
|
// Handshake secret, Master secret
|
||||||
// Early secret is computed right before HS
|
// Early secret is computed right before HS
|
||||||
|
// TLS standard: Secrets should not be stored unnecessarily
|
||||||
latest_secret: Option<Vec<u8, U64>>,
|
latest_secret: Option<Vec<u8, U64>>,
|
||||||
// Hash functions needed
|
// Hash functions needed
|
||||||
hash: Hash,
|
hash: Hash,
|
||||||
@ -412,6 +413,10 @@ impl Session {
|
|||||||
self.state = TlsState::WAIT_EE;
|
self.state = TlsState::WAIT_EE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn client_update_for_ee(&mut self) {
|
||||||
|
self.state = TlsState::WAIT_CERT_CR;
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn verify_session_id_echo(&self, session_id_echo: &[u8]) -> bool {
|
pub(crate) fn verify_session_id_echo(&self, session_id_echo: &[u8]) -> bool {
|
||||||
if let Some(session_id_inner) = self.session_id {
|
if let Some(session_id_inner) = self.session_id {
|
||||||
session_id_inner == session_id_echo
|
session_id_inner == session_id_echo
|
||||||
@ -431,6 +436,50 @@ impl Session {
|
|||||||
pub(crate) fn receive_change_cipher_spec(&mut self) {
|
pub(crate) fn receive_change_cipher_spec(&mut self) {
|
||||||
self.changed_cipher_spec = true;
|
self.changed_cipher_spec = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn encrypt_in_place(
|
||||||
|
&self,
|
||||||
|
associated_data: &[u8],
|
||||||
|
buffer: &mut dyn Buffer
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let (nonce, cipher): (&Vec<u8, U12>, &Cipher) = match self.role {
|
||||||
|
TlsRole::Client => {(
|
||||||
|
self.client_nonce.as_ref().unwrap(),
|
||||||
|
self.client_cipher.as_ref().unwrap()
|
||||||
|
)},
|
||||||
|
TlsRole::Server => {(
|
||||||
|
self.server_nonce.as_ref().unwrap(),
|
||||||
|
self.server_cipher.as_ref().unwrap()
|
||||||
|
)},
|
||||||
|
};
|
||||||
|
cipher.encrypt_in_place(
|
||||||
|
&GenericArray::from_slice(nonce),
|
||||||
|
associated_data,
|
||||||
|
buffer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decrypt_in_place(
|
||||||
|
&self,
|
||||||
|
associated_data: &[u8],
|
||||||
|
buffer: &mut dyn Buffer
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let (nonce, cipher): (&Vec<u8, U12>, &Cipher) = match self.role {
|
||||||
|
TlsRole::Client => {(
|
||||||
|
self.client_nonce.as_ref().unwrap(),
|
||||||
|
self.client_cipher.as_ref().unwrap()
|
||||||
|
)},
|
||||||
|
TlsRole::Server => {(
|
||||||
|
self.server_nonce.as_ref().unwrap(),
|
||||||
|
self.server_cipher.as_ref().unwrap()
|
||||||
|
)},
|
||||||
|
};
|
||||||
|
cipher.decrypt_in_place(
|
||||||
|
&GenericArray::from_slice(nonce),
|
||||||
|
associated_data,
|
||||||
|
buffer
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
@ -525,7 +574,7 @@ pub(crate) enum Cipher {
|
|||||||
|
|
||||||
impl Cipher {
|
impl Cipher {
|
||||||
pub(crate) fn encrypt_in_place(
|
pub(crate) fn encrypt_in_place(
|
||||||
&mut self,
|
&self,
|
||||||
nonce: &GenericArray<u8, U12>,
|
nonce: &GenericArray<u8, U12>,
|
||||||
associated_data: &[u8],
|
associated_data: &[u8],
|
||||||
buffer: &mut dyn Buffer
|
buffer: &mut dyn Buffer
|
||||||
@ -545,4 +594,26 @@ impl Cipher {
|
|||||||
}
|
}
|
||||||
}.map_err(|_| Error::EncryptionError)
|
}.map_err(|_| Error::EncryptionError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decrypt_in_place(
|
||||||
|
&self,
|
||||||
|
nonce: &GenericArray<u8, U12>,
|
||||||
|
associated_data: &[u8],
|
||||||
|
buffer: &mut dyn Buffer
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
match self {
|
||||||
|
Cipher::Aes128Gcm { aes128gcm } => {
|
||||||
|
aes128gcm.decrypt_in_place(nonce, associated_data, buffer)
|
||||||
|
},
|
||||||
|
Cipher::Aes256Gcm { aes256gcm } => {
|
||||||
|
aes256gcm.decrypt_in_place(nonce, associated_data, buffer)
|
||||||
|
},
|
||||||
|
Cipher::Chacha20poly1305 { chacha20poly1305 } => {
|
||||||
|
chacha20poly1305.decrypt_in_place(nonce, associated_data, buffer)
|
||||||
|
},
|
||||||
|
Cipher::Ccm { ccm } => {
|
||||||
|
ccm.decrypt_in_place(nonce, associated_data, buffer)
|
||||||
|
}
|
||||||
|
}.map_err(|_| Error::DecryptionError)
|
||||||
|
}
|
||||||
}
|
}
|
40
src/tls.rs
40
src/tls.rs
@ -34,7 +34,7 @@ use alloc::vec::{ self, Vec };
|
|||||||
|
|
||||||
use crate::Error as TlsError;
|
use crate::Error as TlsError;
|
||||||
use crate::tls_packet::*;
|
use crate::tls_packet::*;
|
||||||
use crate::parse::parse_tls_repr;
|
use crate::parse::{ parse_tls_repr, parse_encrypted_extensions };
|
||||||
use crate::buffer::TlsBuffer;
|
use crate::buffer::TlsBuffer;
|
||||||
use crate::session::{Session, TlsRole};
|
use crate::session::{Session, TlsRole};
|
||||||
|
|
||||||
@ -179,7 +179,7 @@ impl<R: RngCore + CryptoRng> TlsSocket<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process TLS ingress during handshake
|
// Process TLS ingress during handshake
|
||||||
fn process(&self, repr: TlsRepr) -> Result<()> {
|
fn process(&self, mut repr: TlsRepr) -> Result<()> {
|
||||||
// Change_cipher_spec check:
|
// Change_cipher_spec check:
|
||||||
// Must receive CCS before recv peer's FINISH message
|
// Must receive CCS before recv peer's FINISH message
|
||||||
// i.e. Must happen after START and before CONNECTED
|
// i.e. Must happen after START and before CONNECTED
|
||||||
@ -316,8 +316,42 @@ impl<R: RngCore + CryptoRng> TlsSocket<R> {
|
|||||||
|
|
||||||
// Expect encrypted extensions after receiving SH
|
// Expect encrypted extensions after receiving SH
|
||||||
TlsState::WAIT_EE => {
|
TlsState::WAIT_EE => {
|
||||||
|
// Check that the packet is classified as application data
|
||||||
|
if !repr.is_application_data() {
|
||||||
|
// Abort communication, this affect IV calculation
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
// ExcepytedExtensions are disguised as ApplicationData
|
// ExcepytedExtensions are disguised as ApplicationData
|
||||||
// Pull out the `payload` from TlsRepr, decrypt as EE
|
// Pull out the `payload` from TlsRepr, decrypt as EE
|
||||||
|
let mut payload = repr.payload.take().unwrap();
|
||||||
|
let mut array: [u8; 5] = [0; 5];
|
||||||
|
let mut buffer = TlsBuffer::new(&mut array);
|
||||||
|
buffer.write_u8(repr.content_type.into())?;
|
||||||
|
buffer.write_u16(repr.version.into())?;
|
||||||
|
buffer.write_u16(repr.length)?;
|
||||||
|
let associated_data: &[u8] = buffer.into();
|
||||||
|
self.session.borrow().encrypt_in_place(
|
||||||
|
associated_data,
|
||||||
|
&mut payload
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Parse payload of EE
|
||||||
|
let (_, encrypted_extensions) =
|
||||||
|
parse_encrypted_extensions(&payload)
|
||||||
|
.map_err(|_| Error::Unrecognized)?;
|
||||||
|
|
||||||
|
// TODO: Process payload
|
||||||
|
// Practically, nothing will be done about cookies/server name
|
||||||
|
// Extension processing is therefore skipped
|
||||||
|
|
||||||
|
self.session.borrow_mut().client_update_for_ee();
|
||||||
|
},
|
||||||
|
|
||||||
|
// In this stage, wait for a certificate from server
|
||||||
|
// Parse the certificate and check its content
|
||||||
|
TlsState::WAIT_CERT_CR => {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_ => {},
|
_ => {},
|
||||||
@ -370,10 +404,8 @@ impl<R: RngCore + CryptoRng> TlsSocket<R> {
|
|||||||
if !tcp_socket.can_recv() {
|
if !tcp_socket.can_recv() {
|
||||||
return Ok((Vec::new()));
|
return Ok((Vec::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let array_size = tcp_socket.recv_slice(byte_array)?;
|
let array_size = tcp_socket.recv_slice(byte_array)?;
|
||||||
let mut vec: Vec<TlsRepr> = Vec::new();
|
let mut vec: Vec<TlsRepr> = Vec::new();
|
||||||
|
|
||||||
let mut bytes: &[u8] = &byte_array[..array_size];
|
let mut bytes: &[u8] = &byte_array[..array_size];
|
||||||
loop {
|
loop {
|
||||||
match parse_tls_repr(bytes) {
|
match parse_tls_repr(bytes) {
|
||||||
|
@ -46,7 +46,7 @@ pub(crate) struct TlsRepr<'a> {
|
|||||||
pub(crate) content_type: TlsContentType,
|
pub(crate) content_type: TlsContentType,
|
||||||
pub(crate) version: TlsVersion,
|
pub(crate) version: TlsVersion,
|
||||||
pub(crate) length: u16,
|
pub(crate) length: u16,
|
||||||
pub(crate) payload: Option<&'a[u8]>,
|
pub(crate) payload: Option<Vec<u8>>,
|
||||||
pub(crate) handshake: Option<HandshakeRepr<'a>>
|
pub(crate) handshake: Option<HandshakeRepr<'a>>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,14 +96,21 @@ impl<'a> TlsRepr<'a> {
|
|||||||
self.handshake.is_none() &&
|
self.handshake.is_none() &&
|
||||||
self.payload.is_some() &&
|
self.payload.is_some() &&
|
||||||
{
|
{
|
||||||
if let Some(data) = self.payload {
|
if let Some(data) = &self.payload {
|
||||||
[0x01] == data
|
data[0] == 0x01 &&
|
||||||
|
data.len() == 1
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_application_data(&self) -> bool {
|
||||||
|
self.content_type == TlsContentType::ApplicationData &&
|
||||||
|
self.handshake.is_none() &&
|
||||||
|
self.payload.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn decrypt_ee(&self, shared_secret: &SharedSecret) -> HandshakeRepr {
|
pub(crate) fn decrypt_ee(&self, shared_secret: &SharedSecret) -> HandshakeRepr {
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
@ -405,8 +412,8 @@ pub(crate) struct ServerHello<'a> {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct EncryptedExtensions {
|
pub(crate) struct EncryptedExtensions {
|
||||||
length: u16,
|
pub(crate) length: u16,
|
||||||
extensions: Vec<Extension>,
|
pub(crate) extensions: Vec<Extension>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Clone, Copy, IntoPrimitive, TryFromPrimitive)]
|
#[derive(Debug, PartialEq, Eq, Clone, Copy, IntoPrimitive, TryFromPrimitive)]
|
||||||
|
Loading…
Reference in New Issue
Block a user