mirror of
https://github.com/ZeroTier/ZeroTierOne
synced 2025-08-22 22:33:58 -07:00
Noise XK work in progress.
This commit is contained in:
parent
8eedf70a1f
commit
dfc9d5c4e0
7 changed files with 371 additions and 35 deletions
|
@ -7,6 +7,8 @@ use std::ptr::null;
|
||||||
|
|
||||||
pub const SHA512_HASH_SIZE: usize = 64;
|
pub const SHA512_HASH_SIZE: usize = 64;
|
||||||
pub const SHA384_HASH_SIZE: usize = 48;
|
pub const SHA384_HASH_SIZE: usize = 48;
|
||||||
|
pub const HMAC_SHA512_SIZE: usize = 64;
|
||||||
|
pub const HMAC_SHA384_SIZE: usize = 48;
|
||||||
|
|
||||||
pub struct SHA512(Option<openssl::sha::Sha512>);
|
pub struct SHA512(Option<openssl::sha::Sha512>);
|
||||||
|
|
||||||
|
|
|
@ -62,6 +62,9 @@ pub trait ApplicationLayer: Sized {
|
||||||
/// Rate limit and check an attempted new session (called before accept_new_session).
|
/// Rate limit and check an attempted new session (called before accept_new_session).
|
||||||
fn check_new_session(&self, rc: &ReceiveContext<Self>, remote_address: &Self::RemoteAddress) -> bool;
|
fn check_new_session(&self, rc: &ReceiveContext<Self>, remote_address: &Self::RemoteAddress) -> bool;
|
||||||
|
|
||||||
|
/// Get a new locally unique session ID.
|
||||||
|
fn new_session(&self, remote_address: &Self::RemoteAddress) -> Option<(SessionId, Self::Data)>;
|
||||||
|
|
||||||
/// Check whether a new session should be accepted.
|
/// Check whether a new session should be accepted.
|
||||||
///
|
///
|
||||||
/// On success a tuple of local session ID, static secret, and associated object is returned. The
|
/// On success a tuple of local session ID, static secret, and associated object is returned. The
|
||||||
|
|
|
@ -7,19 +7,6 @@ pub const MIN_TRANSPORT_MTU: usize = 64;
|
||||||
/// Minimum recommended interval between calls to service() on each session, in milliseconds.
|
/// Minimum recommended interval between calls to service() on each session, in milliseconds.
|
||||||
pub const SERVICE_INTERVAL: u64 = 10000;
|
pub const SERVICE_INTERVAL: u64 = 10000;
|
||||||
|
|
||||||
/// Setting this to true enables kyber1024 post-quantum forward secrecy.
|
|
||||||
///
|
|
||||||
/// Kyber1024 is used for data forward secrecy but not authentication. Authentication would
|
|
||||||
/// require Kyber1024 in identities, which would make them huge, and isn't needed for our
|
|
||||||
/// threat model which is data warehousing today to decrypt tomorrow. Breaking authentication
|
|
||||||
/// is only relevant today, not in some mid to far future where a QC that can break 384-bit ECC
|
|
||||||
/// exists.
|
|
||||||
///
|
|
||||||
/// This is normally enabled but could be disabled at build time for e.g. very small devices.
|
|
||||||
/// It might not even be necessary there to disable it since it's not that big and is usually
|
|
||||||
/// faster than NIST P-384 ECDH.
|
|
||||||
pub(crate) const JEDI: bool = true;
|
|
||||||
|
|
||||||
/// Maximum number of fragments for data packets.
|
/// Maximum number of fragments for data packets.
|
||||||
pub(crate) const MAX_FRAGMENTS: usize = 48; // hard protocol max: 63
|
pub(crate) const MAX_FRAGMENTS: usize = 48; // hard protocol max: 63
|
||||||
|
|
||||||
|
@ -61,14 +48,10 @@ pub(crate) const HEADER_CHECK_ENCRYPT_START: usize = 6;
|
||||||
/// End of single block AES encryption of a portion of the header (and some data).
|
/// End of single block AES encryption of a portion of the header (and some data).
|
||||||
pub(crate) const HEADER_CHECK_ENCRYPT_END: usize = 22;
|
pub(crate) const HEADER_CHECK_ENCRYPT_END: usize = 22;
|
||||||
|
|
||||||
/// Size of AES-GCM keys (256 bits)
|
|
||||||
pub(crate) const AES_KEY_SIZE: usize = 32;
|
pub(crate) const AES_KEY_SIZE: usize = 32;
|
||||||
|
pub(crate) const AES_HEADER_CHECK_KEY_SIZE: usize = 16;
|
||||||
/// Size of AES-GCM MAC tags
|
|
||||||
pub(crate) const AES_GCM_TAG_SIZE: usize = 16;
|
pub(crate) const AES_GCM_TAG_SIZE: usize = 16;
|
||||||
|
pub(crate) const AES_GCM_NONCE_SIZE: usize = 12;
|
||||||
/// Size of HMAC-SHA384 MAC tags
|
|
||||||
pub(crate) const HMAC_SIZE: usize = 48;
|
|
||||||
|
|
||||||
/// Size of a session ID, which behaves a bit like a TCP port number.
|
/// Size of a session ID, which behaves a bit like a TCP port number.
|
||||||
///
|
///
|
||||||
|
@ -84,17 +67,11 @@ pub(crate) const COUNTER_WINDOW_MAX_OUT_OF_ORDER: usize = 16;
|
||||||
/// the counter yields an invalid value.
|
/// the counter yields an invalid value.
|
||||||
pub(crate) const COUNTER_WINDOW_MAX_SKIP_AHEAD: u64 = 16777216;
|
pub(crate) const COUNTER_WINDOW_MAX_SKIP_AHEAD: u64 = 16777216;
|
||||||
|
|
||||||
// Packet types can range from 0 to 15 (4 bits) -- 0-3 are defined and 4-15 are reserved for future use
|
|
||||||
pub(crate) const PACKET_TYPE_DATA: u8 = 0;
|
|
||||||
pub(crate) const PACKET_TYPE_INITIAL_KEY_OFFER: u8 = 1; // "alice"
|
|
||||||
pub(crate) const PACKET_TYPE_KEY_COUNTER_OFFER: u8 = 2; // "bob"
|
|
||||||
|
|
||||||
// Key usage labels for sub-key derivation using NIST-style KBKDF (basically just HMAC KDF).
|
// Key usage labels for sub-key derivation using NIST-style KBKDF (basically just HMAC KDF).
|
||||||
pub(crate) const KBKDF_KEY_USAGE_LABEL_HMAC: u8 = b'M'; // HMAC-SHA384 authentication for key exchanges
|
pub(crate) const KBKDF_KEY_USAGE_LABEL_KEX_PAYLOAD_ENCRYPTION: u8 = b'M'; // intermediate keys used in key exchanges
|
||||||
pub(crate) const KBKDF_KEY_USAGE_LABEL_HEADER_CHECK: u8 = b'H'; // AES-based header check code generation
|
pub(crate) const KBKDF_KEY_USAGE_LABEL_HEADER_CHECK: u8 = b'H'; // AES-based header check code generation
|
||||||
pub(crate) const KBKDF_KEY_USAGE_LABEL_AES_GCM_ALICE_TO_BOB: u8 = b'A'; // AES-GCM in A->B direction
|
pub(crate) const KBKDF_KEY_USAGE_LABEL_AES_GCM_ALICE_TO_BOB: u8 = b'A'; // AES-GCM in A->B direction
|
||||||
pub(crate) const KBKDF_KEY_USAGE_LABEL_AES_GCM_BOB_TO_ALICE: u8 = b'B'; // AES-GCM in B->A direction
|
pub(crate) const KBKDF_KEY_USAGE_LABEL_AES_GCM_BOB_TO_ALICE: u8 = b'B'; // AES-GCM in B->A direction
|
||||||
pub(crate) const KBKDF_KEY_USAGE_LABEL_RATCHETING: u8 = b'R'; // Key input for next ephemeral ratcheting
|
|
||||||
|
|
||||||
// AES key size for header check code generation
|
// AES key size for header check code generation
|
||||||
pub(crate) const HEADER_CHECK_AES_KEY_SIZE: usize = 16;
|
pub(crate) const HEADER_CHECK_AES_KEY_SIZE: usize = 16;
|
||||||
|
@ -106,8 +83,8 @@ pub(crate) const HEADER_CHECK_AES_KEY_SIZE: usize = 16;
|
||||||
/// the primary algorithm from NIST P-384 or the transport cipher from AES-GCM.
|
/// the primary algorithm from NIST P-384 or the transport cipher from AES-GCM.
|
||||||
pub(crate) const INITIAL_KEY: [u8; 64] = [
|
pub(crate) const INITIAL_KEY: [u8; 64] = [
|
||||||
// macOS command line to generate:
|
// macOS command line to generate:
|
||||||
// echo -n 'ZSSP_Noise_IKpsk2_NISTP384_?KYBER1024_AESGCM_SHA512' | shasum -a 512 | cut -d ' ' -f 1 | xxd -r -p | xxd -i
|
// echo -n 'ZSSP_Noise_XKpsk2_NISTP384_?KYBER1024_AESGCM_SHA512' | shasum -a 512 | cut -d ' ' -f 1 | xxd -r -p | xxd -i
|
||||||
0x35, 0x6a, 0x75, 0xc0, 0xbf, 0xbe, 0xc3, 0x59, 0x70, 0x94, 0x50, 0x69, 0x4c, 0xa2, 0x08, 0x40, 0xc7, 0xdf, 0x67, 0xa8, 0x68, 0x52,
|
0xc7, 0xde, 0xa3, 0xbe, 0x84, 0xe5, 0x91, 0x25, 0x30, 0x59, 0xc1, 0xc9, 0x5d, 0x22, 0xf5, 0x5a, 0xd0, 0x67, 0x9e, 0xf9, 0xf6, 0xbb,
|
||||||
0x6e, 0xd5, 0xdd, 0x77, 0xec, 0x59, 0x6f, 0x8e, 0xa1, 0x99, 0xb4, 0x32, 0x85, 0xaf, 0x7f, 0x0d, 0xa9, 0x6c, 0x01, 0xfb, 0x72, 0x46,
|
0xc0, 0x2a, 0x7f, 0xd0, 0x12, 0xb2, 0x0f, 0xed, 0x64, 0x7a, 0x86, 0x9f, 0x82, 0x19, 0xca, 0x84, 0xad, 0xf6, 0x61, 0xda, 0x59, 0xcc,
|
||||||
0xc0, 0x09, 0x58, 0xb8, 0xe0, 0xa8, 0xcf, 0xb1, 0x58, 0x04, 0x6e, 0x32, 0xba, 0xa8, 0xb8, 0xf9, 0x0a, 0xa4, 0xbf, 0x36,
|
0x40, 0xcf, 0x57, 0x68, 0x3e, 0xe4, 0xd6, 0xe7, 0xd1, 0xad, 0xe9, 0x56, 0x50, 0xf2, 0x38, 0x22, 0x88, 0xa3, 0x5c, 0x7f,
|
||||||
];
|
];
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
mod applicationlayer;
|
mod applicationlayer;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod proto;
|
||||||
mod sessionid;
|
mod sessionid;
|
||||||
mod tests;
|
mod tests;
|
||||||
mod zssp;
|
mod zssp;
|
||||||
|
|
154
zssp/src/proto.rs
Normal file
154
zssp/src/proto.rs
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
use std::mem::size_of;
|
||||||
|
|
||||||
|
use pqc_kyber::{KYBER_CIPHERTEXTBYTES, KYBER_PUBLICKEYBYTES};
|
||||||
|
use zerotier_crypto::p384::P384_PUBLIC_KEY_SIZE;
|
||||||
|
|
||||||
|
use crate::applicationlayer::ApplicationLayer;
|
||||||
|
use crate::constants::{AES_GCM_TAG_SIZE, HEADER_SIZE, MIN_PACKET_SIZE, SESSION_ID_SIZE};
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
/// Maximum packet size for handshake packets
|
||||||
|
///
|
||||||
|
/// Packed structs are padded to this size so they can be recast to byte arrays of this size.
|
||||||
|
pub(crate) const NOISE_MAX_HANDSHAKE_PACKET_SIZE: usize = 2048;
|
||||||
|
|
||||||
|
pub(crate) const PACKET_TYPE_DATA: u8 = 0;
|
||||||
|
pub(crate) const PACKET_TYPE_ALICE_EPHEMERAL_OFFER: u8 = 1;
|
||||||
|
pub(crate) const PACKET_TYPE_BOB_EPHEMERAL_COUNTER_OFFER: u8 = 2;
|
||||||
|
pub(crate) const PACKET_TYPE_ALICE_STATIC_ACK: u8 = 3;
|
||||||
|
pub(crate) const PACKET_TYPE_ALICE_REKEY_INIT: u8 = 4;
|
||||||
|
pub(crate) const PACKET_TYPE_BOB_REKEY_ACK: u8 = 5;
|
||||||
|
|
||||||
|
pub(crate) const NOISE_XK_ALICE_EPHEMERAL_OFFER_ENCRYPTED_SECTION_START: usize = HEADER_SIZE + 1 + P384_PUBLIC_KEY_SIZE;
|
||||||
|
pub(crate) const NOISE_XK_ALICE_EPHEMERAL_OFFER_ENCRYPTED_SECTION_END: usize =
|
||||||
|
NOISE_XK_ALICE_EPHEMERAL_OFFER_ENCRYPTED_SECTION_START + SESSION_ID_SIZE + KYBER_PUBLICKEYBYTES + 8;
|
||||||
|
pub(crate) const NOISE_XK_ALICE_EPHEMERAL_OFFER_SIZE: usize = NOISE_XK_ALICE_EPHEMERAL_OFFER_ENCRYPTED_SECTION_END + AES_GCM_TAG_SIZE;
|
||||||
|
|
||||||
|
pub(crate) trait ProtocolFlatBuffer {}
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
#[repr(C, packed)]
|
||||||
|
pub(crate) struct NoiseXKAliceEphemeralOffer {
|
||||||
|
pub header: [u8; HEADER_SIZE],
|
||||||
|
pub session_protocol_version: u8,
|
||||||
|
pub alice_noise_e: [u8; P384_PUBLIC_KEY_SIZE],
|
||||||
|
// -- start AES-GCM(es) encrypted section (IV is first 12 bytes of SHA384(alice_noise_e))
|
||||||
|
pub alice_session_id: [u8; SESSION_ID_SIZE],
|
||||||
|
pub alice_hk_public: [u8; KYBER_PUBLICKEYBYTES],
|
||||||
|
pub salt: [u8; 8],
|
||||||
|
// -- end encrypted section
|
||||||
|
pub gcm_mac: [u8; 16],
|
||||||
|
_padding: [u8; NOISE_MAX_HANDSHAKE_PACKET_SIZE - NOISE_XK_ALICE_EPHEMERAL_OFFER_SIZE],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const NOISE_XK_BOB_EPHEMERAL_COUNTER_OFFER_ENCRYPTED_SECTION_START: usize = HEADER_SIZE + 1 + P384_PUBLIC_KEY_SIZE;
|
||||||
|
pub(crate) const NOISE_XK_BOB_EPHEMERAL_COUNTER_OFFER_ENCRYPTED_SECTION_END: usize =
|
||||||
|
NOISE_XK_BOB_EPHEMERAL_COUNTER_OFFER_ENCRYPTED_SECTION_START + SESSION_ID_SIZE + KYBER_CIPHERTEXTBYTES;
|
||||||
|
pub(crate) const NOISE_XK_BOB_EPHEMERAL_COUNTER_OFFER_SIZE: usize =
|
||||||
|
NOISE_XK_BOB_EPHEMERAL_COUNTER_OFFER_ENCRYPTED_SECTION_END + AES_GCM_TAG_SIZE;
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
#[repr(C, packed)]
|
||||||
|
pub(crate) struct NoiseXKBobEphemeralCounterOffer {
|
||||||
|
pub header: [u8; HEADER_SIZE],
|
||||||
|
pub session_protocol_version: u8,
|
||||||
|
pub bob_noise_e: [u8; P384_PUBLIC_KEY_SIZE],
|
||||||
|
// -- start AES-GCM(es_ee) encrypted section (IV is first 12 bytes of SHA384(bob_noise_e))
|
||||||
|
pub bob_session_id: [u8; SESSION_ID_SIZE],
|
||||||
|
pub bob_hk_ciphertext: [u8; KYBER_CIPHERTEXTBYTES],
|
||||||
|
// -- end encrypted sectiion
|
||||||
|
pub gcm_mac: [u8; 16],
|
||||||
|
_padding: [u8; NOISE_MAX_HANDSHAKE_PACKET_SIZE - NOISE_XK_BOB_EPHEMERAL_COUNTER_OFFER_SIZE],
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
pub(crate) const NOISE_XK_ALICE_STATIC_ACK_FIXED_FIELDS_SIZE: usize = HEADER_SIZE + 1;
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
#[repr(C, packed)]
|
||||||
|
pub(crate) struct NoiseXKAliceStaticAck {
|
||||||
|
pub header: [u8; HEADER_SIZE],
|
||||||
|
pub session_protocol_version: u8,
|
||||||
|
// -- start AES-GCM(es_ee) encrypted section (IV is first 12 bytes of SHA384(hk))
|
||||||
|
_var_length_fields_and_padding: [u8; NOISE_MAX_HANDSHAKE_PACKET_SIZE - NOISE_XK_ALICE_STATIC_ACK_FIXED_FIELDS_SIZE],
|
||||||
|
// alice_static_blob_length: u16,
|
||||||
|
// alice_static_blob: [u8; ???],
|
||||||
|
// alice_metadata_length: u16,
|
||||||
|
// alice_metadata: [u8; ???],
|
||||||
|
// hmac_es_ee_se_hk_psk: [u8; HMAC_SHA384_SIZE],
|
||||||
|
// -- end encrypted section
|
||||||
|
// pub gcm_mac: [u8; 16],
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
#[repr(C, packed)]
|
||||||
|
pub(crate) struct AliceRekeyInit {
|
||||||
|
pub header: [u8; HEADER_SIZE],
|
||||||
|
// -- start AES-GCM encrypted portion (using current key)
|
||||||
|
pub alice_e: [u8; P384_PUBLIC_KEY_SIZE],
|
||||||
|
pub alice_hk_public: [u8; KYBER_PUBLICKEYBYTES],
|
||||||
|
// -- end AES-GCM encrypted portion
|
||||||
|
pub gcm_mac: [u8; 16],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
#[repr(C, packed)]
|
||||||
|
pub(crate) struct BobRekeyAck {
|
||||||
|
pub header: [u8; HEADER_SIZE],
|
||||||
|
// -- start AES-GCM encrypted portion (using current key)
|
||||||
|
pub bob_e: [u8; P384_PUBLIC_KEY_SIZE],
|
||||||
|
pub bob_hk_ciphertext: [u8; KYBER_CIPHERTEXTBYTES],
|
||||||
|
// -- end AES-GCM encrypted portion
|
||||||
|
pub gcm_mac: [u8; 16],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annotate only these structs as being compatible with packet_buffer_as_bytes(). These structs
|
||||||
|
// are packed flat buffers containing only byte or byte array fields, making them safe to treat
|
||||||
|
// this way even on architectures that require type size aligned access.
|
||||||
|
impl ProtocolFlatBuffer for NoiseXKAliceEphemeralOffer {}
|
||||||
|
impl ProtocolFlatBuffer for NoiseXKBobEphemeralCounterOffer {}
|
||||||
|
//impl ProtocolFlatBuffer for NoiseXKAliceStaticAck {}
|
||||||
|
impl ProtocolFlatBuffer for AliceRekeyInit {}
|
||||||
|
impl ProtocolFlatBuffer for BobRekeyAck {}
|
||||||
|
|
||||||
|
/// Assemble a series of fragments into a buffer and return the length of the assembled packet in bytes.
|
||||||
|
pub(crate) fn assemble_fragments_into<A: ApplicationLayer>(fragments: &[A::IncomingPacketBuffer], d: &mut [u8]) -> Result<usize, Error> {
|
||||||
|
let mut l = 0;
|
||||||
|
for i in 0..fragments.len() {
|
||||||
|
let mut ff = fragments[i].as_ref();
|
||||||
|
if ff.len() <= MIN_PACKET_SIZE {
|
||||||
|
return Err(Error::InvalidPacket);
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
ff = &ff[HEADER_SIZE..];
|
||||||
|
}
|
||||||
|
let j = l + ff.len();
|
||||||
|
if j > d.len() {
|
||||||
|
return Err(Error::InvalidPacket);
|
||||||
|
}
|
||||||
|
d[l..j].copy_from_slice(ff);
|
||||||
|
l = j;
|
||||||
|
}
|
||||||
|
return Ok(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down here is where the only unsafe code here lives. It's instrumented with assertions wherever
|
||||||
|
// possible and just helps us efficiently cast to/from flat buffers.
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
pub(crate) fn new_packet_buffer<B: ProtocolFlatBuffer>() -> B {
|
||||||
|
unsafe { std::mem::zeroed() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
pub(crate) fn packet_buffer_as_bytes<B: ProtocolFlatBuffer>(b: &B) -> &[u8; NOISE_MAX_HANDSHAKE_PACKET_SIZE] {
|
||||||
|
assert_eq!(size_of::<B>(), NOISE_MAX_HANDSHAKE_PACKET_SIZE);
|
||||||
|
unsafe { &*(b as *const B).cast() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
pub(crate) fn packet_buffer_as_bytes_mut<B: ProtocolFlatBuffer>(b: &mut B) -> &mut [u8; NOISE_MAX_HANDSHAKE_PACKET_SIZE] {
|
||||||
|
assert_eq!(size_of::<B>(), NOISE_MAX_HANDSHAKE_PACKET_SIZE);
|
||||||
|
unsafe { &mut *(b as *mut B).cast() }
|
||||||
|
}
|
|
@ -25,6 +25,13 @@ impl SessionId {
|
||||||
Self(NonZeroU64::new(((random::xorshift64_random() % (Self::MAX - 1)) + 1).to_le()).unwrap())
|
Self(NonZeroU64::new(((random::xorshift64_random() % (Self::MAX - 1)) + 1).to_le()).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new_from_bytes(b: &[u8; SESSION_ID_SIZE]) -> Option<SessionId> {
|
||||||
|
let mut tmp = [0u8; 8];
|
||||||
|
tmp[..SESSION_ID_SIZE].copy_from_slice(b);
|
||||||
|
Self::new_from_u64_le(u64::from_ne_bytes(tmp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create from a u64 that is already in little-endian byte order.
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
pub(crate) fn new_from_u64_le(i: u64) -> Option<SessionId> {
|
pub(crate) fn new_from_u64_le(i: u64) -> Option<SessionId> {
|
||||||
NonZeroU64::new(i & Self::MAX.to_le()).map(|i| Self(i))
|
NonZeroU64::new(i & Self::MAX.to_le()).map(|i| Self(i))
|
||||||
|
|
202
zssp/src/zssp.rs
202
zssp/src/zssp.rs
|
@ -3,9 +3,12 @@
|
||||||
// ZSSP: ZeroTier Secure Session Protocol
|
// ZSSP: ZeroTier Secure Session Protocol
|
||||||
// FIPS compliant Noise_IK with Jedi powers and built-in attack-resistant large payload (fragmentation) support.
|
// FIPS compliant Noise_IK with Jedi powers and built-in attack-resistant large payload (fragmentation) support.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{Mutex, RwLock};
|
use std::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
|
use pqc_kyber::KYBER_SECRETKEYBYTES;
|
||||||
|
|
||||||
use zerotier_crypto::aes::{Aes, AesGcm};
|
use zerotier_crypto::aes::{Aes, AesGcm};
|
||||||
use zerotier_crypto::hash::{hmac_sha512, HMACSHA384, SHA384};
|
use zerotier_crypto::hash::{hmac_sha512, HMACSHA384, SHA384};
|
||||||
use zerotier_crypto::p384::{P384KeyPair, P384PublicKey, P384_PUBLIC_KEY_SIZE};
|
use zerotier_crypto::p384::{P384KeyPair, P384PublicKey, P384_PUBLIC_KEY_SIZE};
|
||||||
|
@ -22,6 +25,7 @@ use zerotier_utils::varint;
|
||||||
use crate::applicationlayer::ApplicationLayer;
|
use crate::applicationlayer::ApplicationLayer;
|
||||||
use crate::constants::*;
|
use crate::constants::*;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
|
use crate::proto::*;
|
||||||
use crate::sessionid::SessionId;
|
use crate::sessionid::SessionId;
|
||||||
|
|
||||||
/// Result generated by the packet receive function, with possible payloads.
|
/// Result generated by the packet receive function, with possible payloads.
|
||||||
|
@ -63,16 +67,31 @@ pub struct Session<Application: ApplicationLayer> {
|
||||||
|
|
||||||
send_counter: AtomicU64, // Outgoing packet counter and nonce state
|
send_counter: AtomicU64, // Outgoing packet counter and nonce state
|
||||||
receive_window: [AtomicU64; COUNTER_WINDOW_MAX_OUT_OF_ORDER], // Receive window for anti-replay and deduplication
|
receive_window: [AtomicU64; COUNTER_WINDOW_MAX_OUT_OF_ORDER], // Receive window for anti-replay and deduplication
|
||||||
psk: Secret<64>, // Arbitrary PSK provided by external code
|
|
||||||
noise_ss: Secret<48>, // Static raw shared ECDH NIST P-384 key
|
|
||||||
header_check_cipher: Aes, // Cipher used for header check codes (not Noise related)
|
header_check_cipher: Aes, // Cipher used for header check codes (not Noise related)
|
||||||
state: RwLock<SessionMutableState>, // Mutable parts of state (other than defrag buffers)
|
offer: Mutex<EphemeralOffer>, // Most recently sent ephemeral offer
|
||||||
remote_s_public_blob_hash: [u8; 48], // SHA384(remote static public key blob)
|
state: RwLock<State>, // Miscellaneous mutable state
|
||||||
remote_s_public_p384_bytes: [u8; P384_PUBLIC_KEY_SIZE], // Remote NIST P-384 static public key
|
|
||||||
|
|
||||||
|
//psk: Secret<64>, // Arbitrary PSK provided by external code
|
||||||
|
//noise_ss: Secret<48>, // Static raw shared ECDH NIST P-384 key
|
||||||
|
//state: RwLock<SessionMutableState>, // Mutable parts of state (other than defrag buffers)
|
||||||
|
//remote_s_public_blob_hash: [u8; 48], // SHA384(remote static public key blob)
|
||||||
|
//remote_s_public_p384_bytes: [u8; P384_PUBLIC_KEY_SIZE], // Remote NIST P-384 static public key
|
||||||
defrag: Mutex<RingBufferMap<u64, GatherArray<Application::IncomingPacketBuffer, MAX_FRAGMENTS>, 8, 8>>,
|
defrag: Mutex<RingBufferMap<u64, GatherArray<Application::IncomingPacketBuffer, MAX_FRAGMENTS>, 8, 8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum EphemeralOffer {
|
||||||
|
None,
|
||||||
|
Alice(Secret<64>, P384KeyPair, [u8; KYBER_SECRETKEYBYTES]),
|
||||||
|
Bob(Secret<64>, P384KeyPair),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
keys: [Option<SessionKey>; 2],
|
||||||
|
current_key: usize,
|
||||||
|
psk: Secret<64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
struct SessionMutableState {
|
struct SessionMutableState {
|
||||||
remote_session_id: Option<SessionId>, // The other side's 48-bit session ID
|
remote_session_id: Option<SessionId>, // The other side's 48-bit session ID
|
||||||
session_keys: [Option<SessionKey>; 2], // Buffers to store last and latest key by 1-bit key index
|
session_keys: [Option<SessionKey>; 2], // Buffers to store last and latest key by 1-bit key index
|
||||||
|
@ -80,6 +99,7 @@ struct SessionMutableState {
|
||||||
offer: Option<EphemeralOffer>, // Most recent ephemeral offer sent to remote
|
offer: Option<EphemeralOffer>, // Most recent ephemeral offer sent to remote
|
||||||
last_remote_offer: i64, // Time of most recent ephemeral offer (ms)
|
last_remote_offer: i64, // Time of most recent ephemeral offer (ms)
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
/// A shared symmetric session key.
|
/// A shared symmetric session key.
|
||||||
struct SessionKey {
|
struct SessionKey {
|
||||||
|
@ -98,6 +118,7 @@ struct SessionKey {
|
||||||
jedi: bool, // True if Kyber1024 was used (both sides enabled)
|
jedi: bool, // True if Kyber1024 was used (both sides enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
/// Alice's KEY_OFFER, remembered so Noise agreement process can resume on KEY_COUNTER_OFFER.
|
/// Alice's KEY_OFFER, remembered so Noise agreement process can resume on KEY_COUNTER_OFFER.
|
||||||
struct EphemeralOffer {
|
struct EphemeralOffer {
|
||||||
id: [u8; 16], // Arbitrary random offer ID
|
id: [u8; 16], // Arbitrary random offer ID
|
||||||
|
@ -108,6 +129,7 @@ struct EphemeralOffer {
|
||||||
alice_e_keypair: P384KeyPair, // NIST P-384 key pair (Noise ephemeral key for Alice)
|
alice_e_keypair: P384KeyPair, // NIST P-384 key pair (Noise ephemeral key for Alice)
|
||||||
alice_hk_keypair: Option<pqc_kyber::Keypair>, // Kyber1024 key pair (PQ hybrid ephemeral key for Alice)
|
alice_hk_keypair: Option<pqc_kyber::Keypair>, // Kyber1024 key pair (PQ hybrid ephemeral key for Alice)
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
/// Was this side the one who sent the first offer (Alice) or countered (Bob).
|
/// Was this side the one who sent the first offer (Alice) or countered (Bob).
|
||||||
///
|
///
|
||||||
|
@ -130,6 +152,7 @@ impl<Application: ApplicationLayer> Session<Application> {
|
||||||
/// * `application_data` - Arbitrary object to put into session
|
/// * `application_data` - Arbitrary object to put into session
|
||||||
/// * `mtu` - Physical wire maximum transmission unit (current value, can change through the course of a session)
|
/// * `mtu` - Physical wire maximum transmission unit (current value, can change through the course of a session)
|
||||||
/// * `current_time` - Current monotonic time in milliseconds since an arbitrary time in the past
|
/// * `current_time` - Current monotonic time in milliseconds since an arbitrary time in the past
|
||||||
|
/*
|
||||||
pub fn start_new<SendFunction: FnMut(&mut [u8])>(
|
pub fn start_new<SendFunction: FnMut(&mut [u8])>(
|
||||||
app: &Application,
|
app: &Application,
|
||||||
mut send: SendFunction,
|
mut send: SendFunction,
|
||||||
|
@ -190,12 +213,14 @@ impl<Application: ApplicationLayer> Session<Application> {
|
||||||
}
|
}
|
||||||
return Err(Error::InvalidParameter);
|
return Err(Error::InvalidParameter);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
/// Send data over the session.
|
/// Send data over the session.
|
||||||
///
|
///
|
||||||
/// * `send` - Function to call to send physical packet(s)
|
/// * `send` - Function to call to send physical packet(s)
|
||||||
/// * `mtu_sized_buffer` - A writable work buffer whose size also specifies the physical MTU
|
/// * `mtu_sized_buffer` - A writable work buffer whose size also specifies the physical MTU
|
||||||
/// * `data` - Data to send
|
/// * `data` - Data to send
|
||||||
|
/*
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn send<SendFunction: FnMut(&mut [u8])>(
|
pub fn send<SendFunction: FnMut(&mut [u8])>(
|
||||||
&self,
|
&self,
|
||||||
|
@ -253,7 +278,9 @@ impl<Application: ApplicationLayer> Session<Application> {
|
||||||
}
|
}
|
||||||
return Err(Error::SessionNotEstablished);
|
return Err(Error::SessionNotEstablished);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
/// Check whether this session is established.
|
/// Check whether this session is established.
|
||||||
pub fn established(&self) -> bool {
|
pub fn established(&self) -> bool {
|
||||||
let state = self.state.read().unwrap();
|
let state = self.state.read().unwrap();
|
||||||
|
@ -267,6 +294,7 @@ impl<Application: ApplicationLayer> Session<Application> {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|k| (k.secret_fingerprint, k.ratchet_count, k.role, k.jedi))
|
.map(|k| (k.secret_fingerprint, k.ratchet_count, k.role, k.jedi))
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
/// This function needs to be called on each session at least every SERVICE_INTERVAL milliseconds.
|
/// This function needs to be called on each session at least every SERVICE_INTERVAL milliseconds.
|
||||||
///
|
///
|
||||||
|
@ -285,6 +313,7 @@ impl<Application: ApplicationLayer> Session<Application> {
|
||||||
current_time: i64,
|
current_time: i64,
|
||||||
force_expire: bool,
|
force_expire: bool,
|
||||||
) {
|
) {
|
||||||
|
/*
|
||||||
let state = self.state.read().unwrap();
|
let state = self.state.read().unwrap();
|
||||||
if state.session_keys[state.cur_session_key_idx].as_ref().map_or(true, |k| {
|
if state.session_keys[state.cur_session_key_idx].as_ref().map_or(true, |k| {
|
||||||
matches!(k.role, Role::Bob)
|
matches!(k.role, Role::Bob)
|
||||||
|
@ -323,6 +352,7 @@ impl<Application: ApplicationLayer> Session<Application> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check the receive window without mutating state.
|
/// Check the receive window without mutating state.
|
||||||
|
@ -509,6 +539,7 @@ impl<Application: ApplicationLayer> ReceiveContext<Application> {
|
||||||
|
|
||||||
let message_nonce = create_message_nonce(packet_type, counter);
|
let message_nonce = create_message_nonce(packet_type, counter);
|
||||||
if packet_type == PACKET_TYPE_DATA {
|
if packet_type == PACKET_TYPE_DATA {
|
||||||
|
/*
|
||||||
if let Some(session) = session {
|
if let Some(session) = session {
|
||||||
let state = session.state.read().unwrap();
|
let state = session.state.read().unwrap();
|
||||||
if let Some(session_key) = state.session_keys[key_index].as_ref() {
|
if let Some(session_key) = state.session_keys[key_index].as_ref() {
|
||||||
|
@ -589,9 +620,169 @@ impl<Application: ApplicationLayer> ReceiveContext<Application> {
|
||||||
unlikely_branch();
|
unlikely_branch();
|
||||||
return Err(Error::SessionNotEstablished);
|
return Err(Error::SessionNotEstablished);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
todo!()
|
||||||
} else {
|
} else {
|
||||||
unlikely_branch();
|
unlikely_branch();
|
||||||
|
|
||||||
|
match packet_type {
|
||||||
|
PACKET_TYPE_ALICE_EPHEMERAL_OFFER => {
|
||||||
|
// Alice (remote) --> Bob (local)
|
||||||
|
|
||||||
|
let mut pkt: NoiseXKAliceEphemeralOffer = new_packet_buffer();
|
||||||
|
if assemble_fragments_into::<Application>(fragments, packet_buffer_as_bytes_mut(&mut pkt))?
|
||||||
|
!= NOISE_XK_ALICE_EPHEMERAL_OFFER_SIZE
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidPacket);
|
||||||
|
}
|
||||||
|
if pkt.session_protocol_version != SESSION_PROTOCOL_VERSION {
|
||||||
|
return Err(Error::UnknownProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alice_noise_e = P384PublicKey::from_bytes(&pkt.alice_noise_e).ok_or(Error::FailedAuthentication)?;
|
||||||
|
let noise_es = app.get_local_s_keypair().agree(&alice_noise_e).ok_or(Error::FailedAuthentication)?;
|
||||||
|
|
||||||
|
let mut gcm = AesGcm::new(
|
||||||
|
&kbkdf512(noise_es.as_bytes(), KBKDF_KEY_USAGE_LABEL_KEX_PAYLOAD_ENCRYPTION).as_bytes()[..AES_KEY_SIZE],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
gcm.reset_init_gcm(&SHA384::hash(&pkt.alice_noise_e)[..AES_GCM_NONCE_SIZE]);
|
||||||
|
gcm.crypt_in_place(
|
||||||
|
&mut packet_buffer_as_bytes_mut(&mut pkt)
|
||||||
|
[NOISE_XK_ALICE_EPHEMERAL_OFFER_ENCRYPTED_SECTION_START..NOISE_XK_ALICE_EPHEMERAL_OFFER_ENCRYPTED_SECTION_END],
|
||||||
|
);
|
||||||
|
if !gcm.finish_decrypt(&pkt.gcm_mac) {
|
||||||
|
return Err(Error::FailedAuthentication);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alice_session_id = SessionId::new_from_bytes(&pkt.alice_session_id).ok_or(Error::InvalidPacket)?;
|
||||||
|
let (bob_hk_ciphertext, hk) = pqc_kyber::encapsulate(&pkt.alice_hk_public, &mut random::SecureRandom::default())
|
||||||
|
.map_err(|_| Error::FailedAuthentication)?;
|
||||||
|
|
||||||
|
let (bob_session_id, application_data) = app.new_session(remote_address).ok_or(Error::NewSessionRejected)?;
|
||||||
|
let bob_e_secret = P384KeyPair::generate();
|
||||||
|
|
||||||
|
let noise_es_ee = Secret(hmac_sha512(
|
||||||
|
noise_es.as_bytes(),
|
||||||
|
bob_e_secret.agree(&alice_noise_e).ok_or(Error::FailedAuthentication)?.as_bytes(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut reply: NoiseXKBobEphemeralCounterOffer = new_packet_buffer();
|
||||||
|
reply.session_protocol_version = SESSION_PROTOCOL_VERSION;
|
||||||
|
reply.bob_noise_e = bob_e_secret.public_key_bytes().clone();
|
||||||
|
reply.bob_session_id = bob_session_id.as_bytes().clone();
|
||||||
|
reply.bob_hk_ciphertext = bob_hk_ciphertext;
|
||||||
|
|
||||||
|
let mut gcm = AesGcm::new(
|
||||||
|
&kbkdf512(noise_es_ee.as_bytes(), KBKDF_KEY_USAGE_LABEL_KEX_PAYLOAD_ENCRYPTION).as_bytes()[..AES_KEY_SIZE],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
gcm.reset_init_gcm(&SHA384::hash(bob_e_secret.public_key_bytes())[..AES_GCM_NONCE_SIZE]);
|
||||||
|
gcm.crypt_in_place(
|
||||||
|
&mut packet_buffer_as_bytes_mut(&mut reply)[NOISE_XK_BOB_EPHEMERAL_COUNTER_OFFER_ENCRYPTED_SECTION_START
|
||||||
|
..NOISE_XK_BOB_EPHEMERAL_COUNTER_OFFER_ENCRYPTED_SECTION_END],
|
||||||
|
);
|
||||||
|
reply.gcm_mac = gcm.finish_encrypt();
|
||||||
|
|
||||||
|
let header_check_cipher = Aes::new(
|
||||||
|
&kbkdf512(noise_es.as_bytes(), KBKDF_KEY_USAGE_LABEL_HEADER_CHECK).as_bytes()[..AES_HEADER_CHECK_KEY_SIZE],
|
||||||
|
);
|
||||||
|
|
||||||
|
send_with_fragmentation(
|
||||||
|
send,
|
||||||
|
&mut packet_buffer_as_bytes_mut(&mut reply)[..NOISE_XK_BOB_EPHEMERAL_COUNTER_OFFER_SIZE],
|
||||||
|
mtu,
|
||||||
|
PACKET_TYPE_BOB_EPHEMERAL_COUNTER_OFFER,
|
||||||
|
u64::from(alice_session_id),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
&header_check_cipher,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
return Ok(ReceiveResult::OkNewSession(Session {
|
||||||
|
id: bob_session_id,
|
||||||
|
application_data,
|
||||||
|
send_counter: AtomicU64::new(2), // 1 was used in reply
|
||||||
|
receive_window: std::array::from_fn(|_| AtomicU64::new(0)),
|
||||||
|
header_check_cipher,
|
||||||
|
offer: Mutex::new(EphemeralOffer::Bob(noise_es_ee, bob_e_secret)),
|
||||||
|
state: RwLock::new(State { keys: [None, None], current_key: 0, psk: Secret::default() }),
|
||||||
|
defrag: Mutex::new(RingBufferMap::new(random::xorshift64_random() as u32)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
PACKET_TYPE_BOB_EPHEMERAL_COUNTER_OFFER => {
|
||||||
|
// Bob (remote) --> Alice (local)
|
||||||
|
|
||||||
|
if let Some(session) = session {
|
||||||
|
let mut pkt: NoiseXKBobEphemeralCounterOffer = new_packet_buffer();
|
||||||
|
if assemble_fragments_into::<Application>(fragments, packet_buffer_as_bytes_mut(&mut pkt))?
|
||||||
|
!= NOISE_XK_BOB_EPHEMERAL_COUNTER_OFFER_SIZE
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidPacket);
|
||||||
|
}
|
||||||
|
if pkt.session_protocol_version != SESSION_PROTOCOL_VERSION {
|
||||||
|
return Err(Error::UnknownProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut offer = session.offer.lock().unwrap();
|
||||||
|
match &*offer {
|
||||||
|
EphemeralOffer::Alice(noise_es, alice_e_secret, alice_hk_secret) => {
|
||||||
|
let bob_noise_e = P384PublicKey::from_bytes(&pkt.bob_noise_e).ok_or(Error::FailedAuthentication)?;
|
||||||
|
let noise_es_ee = Secret(hmac_sha512(
|
||||||
|
noise_es.as_bytes(),
|
||||||
|
alice_e_secret.agree(&bob_noise_e).ok_or(Error::FailedAuthentication)?.as_bytes(),
|
||||||
|
));
|
||||||
|
let noise_es_ee_se = app.get_local_s_keypair().agree(&bob_noise_e).ok_or(Error::FailedAuthentication)?;
|
||||||
|
|
||||||
|
let mut gcm = AesGcm::new(
|
||||||
|
&kbkdf512(noise_es_ee.as_bytes(), KBKDF_KEY_USAGE_LABEL_KEX_PAYLOAD_ENCRYPTION).as_bytes()
|
||||||
|
[..AES_KEY_SIZE],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
gcm.reset_init_gcm(&SHA384::hash(&pkt.bob_noise_e)[..AES_GCM_NONCE_SIZE]);
|
||||||
|
gcm.crypt_in_place(
|
||||||
|
&mut packet_buffer_as_bytes_mut(&mut pkt)[NOISE_XK_BOB_EPHEMERAL_COUNTER_OFFER_ENCRYPTED_SECTION_START
|
||||||
|
..NOISE_XK_BOB_EPHEMERAL_COUNTER_OFFER_ENCRYPTED_SECTION_END],
|
||||||
|
);
|
||||||
|
if !gcm.finish_decrypt(&pkt.gcm_mac) {
|
||||||
|
return Err(Error::FailedAuthentication);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bob_session_id = SessionId::new_from_bytes(&pkt.bob_session_id).ok_or(Error::InvalidPacket)?;
|
||||||
|
let hk = pqc_kyber::decapsulate(&pkt.bob_hk_ciphertext, alice_hk_secret)
|
||||||
|
.map_err(|_| Error::FailedAuthentication)?;
|
||||||
|
|
||||||
|
let alice_s_public_blob = app.get_local_s_public_blob();
|
||||||
|
|
||||||
|
let mut reply = [0u8; NOISE_MAX_HANDSHAKE_PACKET_SIZE];
|
||||||
|
let mut reply_w = &mut reply[..];
|
||||||
|
assert!(alice_s_public_blob.len() <= (u16::MAX as usize));
|
||||||
|
let _ = reply_w.write_all(&(alice_s_public_blob.len() as u16).to_le_bytes());
|
||||||
|
let _ = reply_w.write_all(alice_s_public_blob);
|
||||||
|
let _ = reply_w.write_all(&[0u8, 0u8]); // zero size meta-data, to be implemented later
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Ok(ReceiveResult::Ignored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(Error::SessionNotEstablished);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PACKET_TYPE_ALICE_STATIC_ACK => {}
|
||||||
|
|
||||||
|
PACKET_TYPE_ALICE_REKEY_INIT => {}
|
||||||
|
|
||||||
|
PACKET_TYPE_BOB_REKEY_ACK => {}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
return Err(Error::InvalidPacket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
todo!()
|
||||||
|
/*
|
||||||
// To greatly simplify logic handling key exchange packets, assemble these first.
|
// To greatly simplify logic handling key exchange packets, assemble these first.
|
||||||
// Handling KEX packets isn't the fast path so the extra copying isn't significant.
|
// Handling KEX packets isn't the fast path so the extra copying isn't significant.
|
||||||
const KEX_BUF_LEN: usize = 4096;
|
const KEX_BUF_LEN: usize = 4096;
|
||||||
|
@ -1081,6 +1272,7 @@ impl<Application: ApplicationLayer> ReceiveContext<Application> {
|
||||||
|
|
||||||
_ => return Err(Error::InvalidPacket),
|
_ => return Err(Error::InvalidPacket),
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue