mirror of https://github.com/ctz/rustls
Support stateful resumption in TLS1.3
Prior to this we only supported ticket-style resumption.
This commit is contained in:
parent
1b3e966cf6
commit
e0b9be1970
|
@ -20,6 +20,8 @@ Rustls is currently in development and hence unstable. [Here's what I'm working
|
|||
- Fix a bug in rustls::Stream for non-blocking transports.
|
||||
- Move TLS1.3 support from draft 28 to final RFC8446 version.
|
||||
- Don't offer (eg) TLS1.3 if no TLS1.3 suites are configured.
|
||||
- Support stateful resumption in TLS1.3. Stateless resumption
|
||||
was previously supported, but is not the default configuration.
|
||||
* 0.13.1 (2018-08-17):
|
||||
- Fix a bug in rustls::Stream for non-blocking transports
|
||||
(backport).
|
||||
|
|
|
@ -297,6 +297,8 @@ fn make_server_cfg(opts: &Options) -> Arc<rustls::ServerConfig> {
|
|||
|
||||
if opts.tickets {
|
||||
cfg.ticketer = rustls::Ticketer::new();
|
||||
} else if opts.resumes == 0 {
|
||||
cfg.set_persistence(Arc::new(rustls::NoServerSessionStorage {}));
|
||||
}
|
||||
|
||||
if !opts.protocols.is_empty() {
|
||||
|
|
|
@ -1009,7 +1009,8 @@ impl State for ExpectTLS13EncryptedExtensions {
|
|||
}
|
||||
|
||||
if self.handshake.resuming_session.is_some() {
|
||||
if sess.common.early_traffic {
|
||||
let was_early_traffic = sess.common.early_traffic;
|
||||
if was_early_traffic {
|
||||
if exts.early_data_extension_offered() {
|
||||
sess.early_data.accepted();
|
||||
} else {
|
||||
|
@ -1018,7 +1019,7 @@ impl State for ExpectTLS13EncryptedExtensions {
|
|||
}
|
||||
}
|
||||
|
||||
if !sess.common.early_traffic {
|
||||
if was_early_traffic && !sess.common.early_traffic {
|
||||
// If no early traffic, set the encryption key for handshakes
|
||||
let suite = sess.common.get_suite_assert();
|
||||
let write_key = sess.common.get_key_schedule()
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
use msgs::enums::SignatureScheme;
|
||||
use msgs::handshake::SessionID;
|
||||
use rand;
|
||||
use sign;
|
||||
use key;
|
||||
use webpki;
|
||||
|
@ -14,15 +12,15 @@ use std::sync::{Arc, Mutex};
|
|||
pub struct NoServerSessionStorage {}
|
||||
|
||||
impl server::StoresServerSessions for NoServerSessionStorage {
|
||||
fn generate(&self) -> SessionID {
|
||||
SessionID::empty()
|
||||
}
|
||||
fn put(&self, _id: Vec<u8>, _sec: Vec<u8>) -> bool {
|
||||
false
|
||||
}
|
||||
fn get(&self, _id: &[u8]) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
fn take(&self, _id: &[u8]) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// An implementor of `StoresServerSessions` that stores everything
|
||||
|
@ -54,12 +52,6 @@ impl ServerSessionMemoryCache {
|
|||
}
|
||||
|
||||
impl server::StoresServerSessions for ServerSessionMemoryCache {
|
||||
fn generate(&self) -> SessionID {
|
||||
let mut v = [0u8; 32];
|
||||
rand::fill_random(&mut v);
|
||||
SessionID::new(&v)
|
||||
}
|
||||
|
||||
fn put(&self, key: Vec<u8>, value: Vec<u8>) -> bool {
|
||||
self.cache.lock()
|
||||
.unwrap()
|
||||
|
@ -73,6 +65,12 @@ impl server::StoresServerSessions for ServerSessionMemoryCache {
|
|||
.unwrap()
|
||||
.get(key).cloned()
|
||||
}
|
||||
|
||||
fn take(&self, key: &[u8]) -> Option<Vec<u8>> {
|
||||
self.cache.lock()
|
||||
.unwrap()
|
||||
.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Something which never produces tickets.
|
||||
|
@ -193,14 +191,6 @@ mod test {
|
|||
use super::*;
|
||||
use StoresServerSessions;
|
||||
|
||||
#[test]
|
||||
fn test_noserversessionstorage_yields_no_sessid() {
|
||||
let c = NoServerSessionStorage {};
|
||||
assert_eq!(c.generate(), SessionID::empty());
|
||||
assert_eq!(c.generate().len(), 0);
|
||||
assert!(c.generate().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_noserversessionstorage_drops_put() {
|
||||
let c = NoServerSessionStorage {};
|
||||
|
@ -216,12 +206,6 @@ mod test {
|
|||
assert_eq!(c.get(&[0x02]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serversessionmemorycache_yields_sessid() {
|
||||
let c = ServerSessionMemoryCache::new(4);
|
||||
assert_eq!(c.generate().len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serversessionmemorycache_accepts_put() {
|
||||
let c = ServerSessionMemoryCache::new(4);
|
||||
|
|
|
@ -768,6 +768,22 @@ impl ExpectClientHello {
|
|||
sess.common.send_msg(m, false);
|
||||
}
|
||||
|
||||
fn attempt_tls13_ticket_decryption(&mut self,
|
||||
sess: &mut ServerSessionImpl,
|
||||
ticket: &[u8]) -> Option<persist::ServerSessionValue> {
|
||||
if sess.config.ticketer.enabled() {
|
||||
sess.config
|
||||
.ticketer
|
||||
.decrypt(ticket)
|
||||
.and_then(|plain| persist::ServerSessionValue::read_bytes(&plain))
|
||||
} else {
|
||||
sess.config
|
||||
.session_storage
|
||||
.take(ticket)
|
||||
.and_then(|plain| persist::ServerSessionValue::read_bytes(&plain))
|
||||
}
|
||||
}
|
||||
|
||||
fn start_resumption(mut self,
|
||||
sess: &mut ServerSessionImpl,
|
||||
client_hello: &ClientHelloPayload,
|
||||
|
@ -881,10 +897,7 @@ impl ExpectClientHello {
|
|||
}
|
||||
|
||||
for (i, psk_id) in psk_offer.identities.iter().enumerate() {
|
||||
let maybe_resume = sess.config
|
||||
.ticketer
|
||||
.decrypt(&psk_id.identity.0)
|
||||
.and_then(|plain| persist::ServerSessionValue::read_bytes(&plain));
|
||||
let maybe_resume = self.attempt_tls13_ticket_decryption(sess, &psk_id.identity.0);
|
||||
|
||||
if !can_resume(sess, &self.handshake, &maybe_resume) {
|
||||
continue;
|
||||
|
@ -1129,10 +1142,9 @@ impl State for ExpectClientHello {
|
|||
// If we're not offered a ticket or a potential session ID,
|
||||
// allocate a session ID.
|
||||
if self.handshake.session_id.is_empty() && !ticket_received {
|
||||
let sessid = sess.config
|
||||
.session_storage
|
||||
.generate();
|
||||
self.handshake.session_id = sessid;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::fill_random(&mut bytes);
|
||||
self.handshake.session_id = SessionID::new(&bytes);
|
||||
}
|
||||
|
||||
// Perhaps resume? If we received a ticket, the sessionid
|
||||
|
@ -1672,11 +1684,8 @@ impl ExpectTLS13Finished {
|
|||
})
|
||||
}
|
||||
|
||||
fn emit_ticket_tls13(&mut self, sess: &mut ServerSessionImpl) {
|
||||
if !self.send_ticket {
|
||||
return;
|
||||
}
|
||||
|
||||
fn emit_stateless_ticket_tls13(&mut self, sess: &mut ServerSessionImpl) {
|
||||
debug_assert!(self.send_ticket);
|
||||
let nonce = rand::random_vec(32);
|
||||
let plain = get_server_session_value_tls13(&self.handshake, sess, &nonce)
|
||||
.get_encoding();
|
||||
|
@ -1701,10 +1710,38 @@ impl ExpectTLS13Finished {
|
|||
}),
|
||||
};
|
||||
|
||||
trace!("sending new ticket {:?}", m);
|
||||
trace!("sending new stateless ticket {:?}", m);
|
||||
self.handshake.transcript.add_message(&m);
|
||||
sess.common.send_msg(m, true);
|
||||
}
|
||||
|
||||
fn emit_stateful_ticket_tls13(&mut self, sess: &mut ServerSessionImpl) {
|
||||
debug_assert!(self.send_ticket);
|
||||
let nonce = rand::random_vec(32);
|
||||
let id = rand::random_vec(32);
|
||||
let plain = get_server_session_value_tls13(&self.handshake, sess, &nonce)
|
||||
.get_encoding();
|
||||
|
||||
if sess.config.session_storage.put(id.clone(), plain) {
|
||||
let stateful_lifetime = 24 * 60 * 60; // this is a bit of a punt
|
||||
let age_add = rand::random_u32();
|
||||
let payload = NewSessionTicketPayloadTLS13::new(stateful_lifetime, age_add, nonce, id);
|
||||
let m = Message {
|
||||
typ: ContentType::Handshake,
|
||||
version: ProtocolVersion::TLSv1_3,
|
||||
payload: MessagePayload::Handshake(HandshakeMessagePayload {
|
||||
typ: HandshakeType::NewSessionTicket,
|
||||
payload: HandshakePayload::NewSessionTicketTLS13(payload),
|
||||
}),
|
||||
};
|
||||
|
||||
trace!("sending new stateful ticket {:?}", m);
|
||||
self.handshake.transcript.add_message(&m);
|
||||
sess.common.send_msg(m, true);
|
||||
} else {
|
||||
trace!("resumption not available; not issuing ticket");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State for ExpectTLS13Finished {
|
||||
|
@ -1749,8 +1786,12 @@ impl State for ExpectTLS13Finished {
|
|||
.get_mut_key_schedule()
|
||||
.current_client_traffic_secret = read_key;
|
||||
|
||||
if sess.config.ticketer.enabled() {
|
||||
self.emit_ticket_tls13(sess);
|
||||
if self.send_ticket {
|
||||
if sess.config.ticketer.enabled() {
|
||||
self.emit_stateless_ticket_tls13(sess);
|
||||
} else {
|
||||
self.emit_stateful_ticket_tls13(sess);
|
||||
}
|
||||
}
|
||||
|
||||
sess.common.we_now_encrypting();
|
||||
|
|
|
@ -3,7 +3,7 @@ use keylog::{KeyLog, NoKeyLog};
|
|||
use suites::{SupportedCipherSuite, ALL_CIPHERSUITES};
|
||||
use msgs::enums::{ContentType, SignatureScheme};
|
||||
use msgs::enums::{AlertDescription, HandshakeType, ProtocolVersion};
|
||||
use msgs::handshake::{ServerExtension, SessionID};
|
||||
use msgs::handshake::ServerExtension;
|
||||
use msgs::message::Message;
|
||||
use error::TLSError;
|
||||
use sign;
|
||||
|
@ -21,29 +21,37 @@ mod hs;
|
|||
mod common;
|
||||
pub mod handy;
|
||||
|
||||
/// A trait for the ability to generate Session IDs, and store
|
||||
/// server session data. The keys and values are opaque.
|
||||
/// A trait for the ability to store server session data.
|
||||
///
|
||||
/// The keys and values are opaque.
|
||||
///
|
||||
/// Both the keys and values should be treated as
|
||||
/// **highly sensitive data**, containing enough key material
|
||||
/// to break all security of the corresponding session.
|
||||
/// to break all security of the corresponding sessions.
|
||||
///
|
||||
/// `put` is a mutating operation; this isn't expressed
|
||||
/// Implementations can be lossy (in other words, forgetting
|
||||
/// key/value pairs) without any negative security consequences.
|
||||
///
|
||||
/// However, note that `take` **must** reliably delete a returned
|
||||
/// value. If it does not, there may be security consequences.
|
||||
///
|
||||
/// `put` and `take` are mutating operations; this isn't expressed
|
||||
/// in the type system to allow implementations freedom in
|
||||
/// how to achieve interior mutability. `Mutex` is a common
|
||||
/// choice.
|
||||
pub trait StoresServerSessions : Send + Sync {
|
||||
/// Generate a session ID.
|
||||
fn generate(&self) -> SessionID;
|
||||
|
||||
/// Store session secrets encoded in `value` against key `id`,
|
||||
/// overwrites any existing value against `id`. Returns `true`
|
||||
/// Store session secrets encoded in `value` against `key`,
|
||||
/// overwrites any existing value against `key`. Returns `true`
|
||||
/// if the value was stored.
|
||||
fn put(&self, key: Vec<u8>, value: Vec<u8>) -> bool;
|
||||
|
||||
/// Find a session with the given `id`. Return it, or None
|
||||
/// Find a value with the given `key`. Return it, or None
|
||||
/// if it doesn't exist.
|
||||
fn get(&self, key: &[u8]) -> Option<Vec<u8>>;
|
||||
|
||||
/// Find a value with the given `key`. Return it and delete it;
|
||||
/// or None if it doesn't exist.
|
||||
fn take(&self, key: &[u8]) -> Option<Vec<u8>>;
|
||||
}
|
||||
|
||||
/// A trait for the ability to encrypt and decrypt tickets.
|
||||
|
|
154
tests/api.rs
154
tests/api.rs
|
@ -1,9 +1,10 @@
|
|||
// Assorted public API tests.
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::fs;
|
||||
use std::mem;
|
||||
use std::fmt;
|
||||
use std::io::{self, Write, Read};
|
||||
|
||||
extern crate rustls;
|
||||
|
@ -23,13 +24,15 @@ use rustls::KeyLog;
|
|||
|
||||
extern crate webpki;
|
||||
|
||||
fn transfer(left: &mut Session, right: &mut Session) {
|
||||
fn transfer(left: &mut Session, right: &mut Session) -> usize {
|
||||
let mut buf = [0u8; 262144];
|
||||
let mut total = 0;
|
||||
|
||||
while left.wants_write() {
|
||||
let sz = left.write_tls(&mut buf.as_mut()).unwrap();
|
||||
total += sz;
|
||||
if sz == 0 {
|
||||
return;
|
||||
return total;
|
||||
}
|
||||
|
||||
let mut offs = 0;
|
||||
|
@ -40,6 +43,8 @@ fn transfer(left: &mut Session, right: &mut Session) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
|
@ -139,13 +144,15 @@ fn make_pair_for_arc_configs(client_config: &Arc<ClientConfig>,
|
|||
)
|
||||
}
|
||||
|
||||
fn do_handshake(client: &mut ClientSession, server: &mut ServerSession) {
|
||||
fn do_handshake(client: &mut ClientSession, server: &mut ServerSession) -> (usize, usize) {
|
||||
let (mut to_client, mut to_server) = (0, 0);
|
||||
while server.is_handshaking() || client.is_handshaking() {
|
||||
transfer(client, server);
|
||||
to_server += transfer(client, server);
|
||||
server.process_new_packets().unwrap();
|
||||
transfer(server, client);
|
||||
to_client += transfer(server, client);
|
||||
client.process_new_packets().unwrap();
|
||||
}
|
||||
(to_server, to_client)
|
||||
}
|
||||
|
||||
struct AllClientVersions {
|
||||
|
@ -587,14 +594,14 @@ fn client_checks_server_certificate_with_given_name() {
|
|||
}
|
||||
|
||||
struct ClientCheckCertResolve {
|
||||
query_count: atomic::AtomicUsize,
|
||||
query_count: AtomicUsize,
|
||||
expect_queries: usize
|
||||
}
|
||||
|
||||
impl ClientCheckCertResolve {
|
||||
fn new(expect_queries: usize) -> ClientCheckCertResolve {
|
||||
ClientCheckCertResolve {
|
||||
query_count: atomic::AtomicUsize::new(0),
|
||||
query_count: AtomicUsize::new(0),
|
||||
expect_queries: expect_queries
|
||||
}
|
||||
}
|
||||
|
@ -602,7 +609,7 @@ impl ClientCheckCertResolve {
|
|||
|
||||
impl Drop for ClientCheckCertResolve {
|
||||
fn drop(&mut self) {
|
||||
let count = self.query_count.load(atomic::Ordering::SeqCst);
|
||||
let count = self.query_count.load(Ordering::SeqCst);
|
||||
assert_eq!(count, self.expect_queries);
|
||||
}
|
||||
}
|
||||
|
@ -612,7 +619,7 @@ impl ResolvesClientCert for ClientCheckCertResolve {
|
|||
acceptable_issuers: &[&[u8]],
|
||||
sigschemes: &[SignatureScheme])
|
||||
-> Option<sign::CertifiedKey> {
|
||||
self.query_count.fetch_add(1, atomic::Ordering::SeqCst);
|
||||
self.query_count.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if acceptable_issuers.len() == 0 {
|
||||
panic!("no issuers offered by server");
|
||||
|
@ -1683,8 +1690,8 @@ fn vectored_write_for_server_handshake() {
|
|||
{
|
||||
let mut pipe = OtherSession::new(&mut client);
|
||||
let wrlen = server.writev_tls(&mut pipe).unwrap();
|
||||
assert_eq!(wrlen, 74);
|
||||
assert_eq!(pipe.writevs, vec![vec![42, 32]]);
|
||||
assert_eq!(wrlen, 177);
|
||||
assert_eq!(pipe.writevs, vec![vec![103, 42, 32]]);
|
||||
}
|
||||
|
||||
assert_eq!(server.is_handshaking(), false);
|
||||
|
@ -1746,3 +1753,126 @@ fn vectored_write_with_slow_client() {
|
|||
}
|
||||
check_read(&mut client, b"01234567890123456789");
|
||||
}
|
||||
|
||||
struct ServerStorage {
|
||||
storage: Arc<rustls::StoresServerSessions>,
|
||||
put_count: AtomicUsize,
|
||||
get_count: AtomicUsize,
|
||||
take_count: AtomicUsize,
|
||||
}
|
||||
|
||||
impl ServerStorage {
|
||||
fn new() -> ServerStorage {
|
||||
ServerStorage {
|
||||
storage: rustls::ServerSessionMemoryCache::new(1024),
|
||||
put_count: AtomicUsize::new(0),
|
||||
get_count: AtomicUsize::new(0),
|
||||
take_count: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn puts(&self) -> usize { self.put_count.load(Ordering::SeqCst) }
|
||||
fn gets(&self) -> usize { self.get_count.load(Ordering::SeqCst) }
|
||||
fn takes(&self) -> usize { self.take_count.load(Ordering::SeqCst) }
|
||||
}
|
||||
|
||||
impl fmt::Debug for ServerStorage {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "(put: {:?}, get: {:?}, take: {:?})",
|
||||
self.put_count, self.get_count, self.take_count)
|
||||
}
|
||||
}
|
||||
|
||||
impl rustls::StoresServerSessions for ServerStorage {
|
||||
fn put(&self, key: Vec<u8>, value: Vec<u8>) -> bool {
|
||||
self.put_count.fetch_add(1, Ordering::SeqCst);
|
||||
self.storage.put(key, value)
|
||||
}
|
||||
|
||||
fn get(&self, key: &[u8]) -> Option<Vec<u8>> {
|
||||
self.get_count.fetch_add(1, Ordering::SeqCst);
|
||||
self.storage.get(key)
|
||||
}
|
||||
|
||||
fn take(&self, key: &[u8]) -> Option<Vec<u8>> {
|
||||
self.take_count.fetch_add(1, Ordering::SeqCst);
|
||||
self.storage.take(key)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls13_stateful_resumption() {
|
||||
let kt = KeyType::RSA;
|
||||
let mut client_config = make_client_config(kt);
|
||||
client_config.versions = vec![ ProtocolVersion::TLSv1_3 ];
|
||||
let client_config = Arc::new(client_config);
|
||||
|
||||
let mut server_config = make_server_config(kt);
|
||||
let storage = Arc::new(ServerStorage::new());
|
||||
server_config.session_storage = storage.clone();
|
||||
let server_config = Arc::new(server_config);
|
||||
|
||||
// full handshake
|
||||
let (mut client, mut server) = make_pair_for_arc_configs(&client_config, &server_config);
|
||||
let (full_c2s, full_s2c) = do_handshake(&mut client, &mut server);
|
||||
assert_eq!(storage.puts(), 1);
|
||||
assert_eq!(storage.gets(), 0);
|
||||
assert_eq!(storage.takes(), 0);
|
||||
|
||||
// resumed
|
||||
let (mut client, mut server) = make_pair_for_arc_configs(&client_config, &server_config);
|
||||
let (resume_c2s, resume_s2c) = do_handshake(&mut client, &mut server);
|
||||
assert!(resume_c2s > full_c2s);
|
||||
assert!(resume_s2c < full_s2c);
|
||||
assert_eq!(storage.puts(), 2);
|
||||
assert_eq!(storage.gets(), 0);
|
||||
assert_eq!(storage.takes(), 1);
|
||||
|
||||
// resumed again
|
||||
let (mut client, mut server) = make_pair_for_arc_configs(&client_config, &server_config);
|
||||
let (resume2_c2s, resume2_s2c) = do_handshake(&mut client, &mut server);
|
||||
assert_eq!(resume_s2c, resume2_s2c);
|
||||
assert_eq!(resume_c2s, resume2_c2s);
|
||||
assert_eq!(storage.puts(), 3);
|
||||
assert_eq!(storage.gets(), 0);
|
||||
assert_eq!(storage.takes(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls13_stateless_resumption() {
|
||||
let kt = KeyType::RSA;
|
||||
let mut client_config = make_client_config(kt);
|
||||
client_config.versions = vec![ ProtocolVersion::TLSv1_3 ];
|
||||
let client_config = Arc::new(client_config);
|
||||
|
||||
let mut server_config = make_server_config(kt);
|
||||
server_config.ticketer = rustls::Ticketer::new();
|
||||
let storage = Arc::new(ServerStorage::new());
|
||||
server_config.session_storage = storage.clone();
|
||||
let server_config = Arc::new(server_config);
|
||||
|
||||
// full handshake
|
||||
let (mut client, mut server) = make_pair_for_arc_configs(&client_config, &server_config);
|
||||
let (full_c2s, full_s2c) = do_handshake(&mut client, &mut server);
|
||||
assert_eq!(storage.puts(), 0);
|
||||
assert_eq!(storage.gets(), 0);
|
||||
assert_eq!(storage.takes(), 0);
|
||||
|
||||
// resumed
|
||||
let (mut client, mut server) = make_pair_for_arc_configs(&client_config, &server_config);
|
||||
let (resume_c2s, resume_s2c) = do_handshake(&mut client, &mut server);
|
||||
assert!(resume_c2s > full_c2s);
|
||||
assert!(resume_s2c < full_s2c);
|
||||
assert_eq!(storage.puts(), 0);
|
||||
assert_eq!(storage.gets(), 0);
|
||||
assert_eq!(storage.takes(), 0);
|
||||
|
||||
// resumed again
|
||||
let (mut client, mut server) = make_pair_for_arc_configs(&client_config, &server_config);
|
||||
let (resume2_c2s, resume2_s2c) = do_handshake(&mut client, &mut server);
|
||||
assert_eq!(resume_s2c, resume2_s2c);
|
||||
assert_eq!(resume_c2s, resume2_c2s);
|
||||
assert_eq!(storage.puts(), 0);
|
||||
assert_eq!(storage.gets(), 0);
|
||||
assert_eq!(storage.takes(), 0);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue