mirror of
https://code.briarproject.org/briar/briar.git
synced 2026-02-18 05:39:53 +01:00
Duplicate current secrets may be derived from successive dead secrets.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
package net.sf.briar.transport;
|
package net.sf.briar.transport;
|
||||||
|
|
||||||
|
import static java.util.logging.Level.INFO;
|
||||||
import static java.util.logging.Level.WARNING;
|
import static java.util.logging.Level.WARNING;
|
||||||
import static net.sf.briar.api.transport.TransportConstants.MAX_CLOCK_DIFFERENCE;
|
import static net.sf.briar.api.transport.TransportConstants.MAX_CLOCK_DIFFERENCE;
|
||||||
|
|
||||||
@@ -114,6 +115,7 @@ class KeyManagerImpl extends TimerTask implements KeyManager, DatabaseListener {
|
|||||||
// Discard the secret if the transport has been removed
|
// Discard the secret if the transport has been removed
|
||||||
Long maxLatency = maxLatencies.get(s.getTransportId());
|
Long maxLatency = maxLatencies.get(s.getTransportId());
|
||||||
if(maxLatency == null) {
|
if(maxLatency == null) {
|
||||||
|
if(LOG.isLoggable(INFO)) LOG.info("Discarding obsolete secret");
|
||||||
ByteUtils.erase(s.getSecret());
|
ByteUtils.erase(s.getSecret());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -165,8 +167,8 @@ class KeyManagerImpl extends TimerTask implements KeyManager, DatabaseListener {
|
|||||||
TemporarySecret s1 = new TemporarySecret(s, currentPeriod - 1, b1);
|
TemporarySecret s1 = new TemporarySecret(s, currentPeriod - 1, b1);
|
||||||
TemporarySecret s2 = new TemporarySecret(s, currentPeriod, b2);
|
TemporarySecret s2 = new TemporarySecret(s, currentPeriod, b2);
|
||||||
TemporarySecret s3 = new TemporarySecret(s, currentPeriod + 1, b3);
|
TemporarySecret s3 = new TemporarySecret(s, currentPeriod + 1, b3);
|
||||||
// Add the secrets to their respective maps - the old and current
|
// Add the secrets to their respective maps - copies may already
|
||||||
// secrets may already exist, in which case erase the duplicates
|
// exist, in which case erase the duplicates
|
||||||
EndpointKey k = new EndpointKey(s);
|
EndpointKey k = new EndpointKey(s);
|
||||||
TemporarySecret exists = oldSecrets.put(k, s1);
|
TemporarySecret exists = oldSecrets.put(k, s1);
|
||||||
if(exists == null) created.add(s1);
|
if(exists == null) created.add(s1);
|
||||||
@@ -174,8 +176,9 @@ class KeyManagerImpl extends TimerTask implements KeyManager, DatabaseListener {
|
|||||||
exists = currentSecrets.put(k, s2);
|
exists = currentSecrets.put(k, s2);
|
||||||
if(exists == null) created.add(s2);
|
if(exists == null) created.add(s2);
|
||||||
else ByteUtils.erase(exists.getSecret());
|
else ByteUtils.erase(exists.getSecret());
|
||||||
newSecrets.put(k, s3);
|
exists = newSecrets.put(k, s3);
|
||||||
created.add(s3);
|
if(exists == null) created.add(s3);
|
||||||
|
else ByteUtils.erase(exists.getSecret());
|
||||||
}
|
}
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
@@ -199,10 +202,17 @@ class KeyManagerImpl extends TimerTask implements KeyManager, DatabaseListener {
|
|||||||
public synchronized ConnectionContext getConnectionContext(ContactId c,
|
public synchronized ConnectionContext getConnectionContext(ContactId c,
|
||||||
TransportId t) {
|
TransportId t) {
|
||||||
TemporarySecret s = currentSecrets.get(new EndpointKey(c, t));
|
TemporarySecret s = currentSecrets.get(new EndpointKey(c, t));
|
||||||
if(s == null) return null;
|
if(s == null) {
|
||||||
|
if(LOG.isLoggable(INFO)) LOG.info("No secret for endpoint");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
long connection;
|
long connection;
|
||||||
try {
|
try {
|
||||||
connection = db.incrementConnectionCounter(c, t, s.getPeriod());
|
connection = db.incrementConnectionCounter(c, t, s.getPeriod());
|
||||||
|
if(connection == -1) {
|
||||||
|
if(LOG.isLoggable(INFO)) LOG.info("No counter for period");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
} catch(DbException e) {
|
} catch(DbException e) {
|
||||||
if(LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
|
if(LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
|
||||||
return null;
|
return null;
|
||||||
@@ -214,7 +224,8 @@ class KeyManagerImpl extends TimerTask implements KeyManager, DatabaseListener {
|
|||||||
public synchronized void endpointAdded(Endpoint ep, byte[] initialSecret) {
|
public synchronized void endpointAdded(Endpoint ep, byte[] initialSecret) {
|
||||||
Long maxLatency = maxLatencies.get(ep.getTransportId());
|
Long maxLatency = maxLatencies.get(ep.getTransportId());
|
||||||
if(maxLatency == null) {
|
if(maxLatency == null) {
|
||||||
if(LOG.isLoggable(WARNING)) LOG.warning("No such transport");
|
if(LOG.isLoggable(INFO))
|
||||||
|
LOG.info("No such transport, ignoring endpoint");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Work out which rotation period we're in
|
// Work out which rotation period we're in
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
package net.sf.briar.transport;
|
package net.sf.briar.transport;
|
||||||
|
|
||||||
import static net.sf.briar.api.transport.TransportConstants.MAX_CLOCK_DIFFERENCE;
|
import static net.sf.briar.api.transport.TransportConstants.MAX_CLOCK_DIFFERENCE;
|
||||||
import static org.junit.Assert.assertArrayEquals;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Random;
|
|
||||||
import java.util.TimerTask;
|
import java.util.TimerTask;
|
||||||
|
|
||||||
import net.sf.briar.BriarTestCase;
|
import net.sf.briar.BriarTestCase;
|
||||||
@@ -23,36 +21,32 @@ import net.sf.briar.api.transport.TemporarySecret;
|
|||||||
|
|
||||||
import org.jmock.Expectations;
|
import org.jmock.Expectations;
|
||||||
import org.jmock.Mockery;
|
import org.jmock.Mockery;
|
||||||
import org.junit.Before;
|
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
public class KeyManagerImplTest extends BriarTestCase {
|
public class KeyManagerImplTest extends BriarTestCase {
|
||||||
|
|
||||||
private final Random random = new Random();
|
private static final long EPOCH = 1000L * 1000L * 1000L * 1000L;
|
||||||
|
private static final long MAX_LATENCY = 2 * 60 * 1000; // 2 minutes
|
||||||
|
private static final long ROTATION_PERIOD_LENGTH =
|
||||||
|
MAX_LATENCY + MAX_CLOCK_DIFFERENCE;
|
||||||
|
|
||||||
private final ContactId contactId;
|
private final ContactId contactId;
|
||||||
private final TransportId transportId;
|
private final TransportId transportId;
|
||||||
private final long maxLatency;
|
private final byte[] secret0, secret1, secret2, secret3, secret4;
|
||||||
private final long rotationPeriodLength;
|
|
||||||
private final byte[] secret0, secret1, secret2, secret3;
|
|
||||||
private final long epoch = 1000L * 1000L * 1000L * 1000L;
|
|
||||||
|
|
||||||
public KeyManagerImplTest() {
|
public KeyManagerImplTest() {
|
||||||
contactId = new ContactId(234);
|
contactId = new ContactId(234);
|
||||||
transportId = new TransportId(TestUtils.getRandomId());
|
transportId = new TransportId(TestUtils.getRandomId());
|
||||||
maxLatency = 2 * 60 * 1000; // 2 minutes
|
|
||||||
rotationPeriodLength = maxLatency + MAX_CLOCK_DIFFERENCE;
|
|
||||||
secret0 = new byte[32];
|
secret0 = new byte[32];
|
||||||
secret1 = new byte[32];
|
secret1 = new byte[32];
|
||||||
secret2 = new byte[32];
|
secret2 = new byte[32];
|
||||||
secret3 = new byte[32];
|
secret3 = new byte[32];
|
||||||
}
|
secret4 = new byte[32];
|
||||||
|
for(int i = 0; i < secret0.length; i++) secret0[i] = 1;
|
||||||
@Before
|
for(int i = 0; i < secret1.length; i++) secret1[i] = 2;
|
||||||
public void setUp() {
|
for(int i = 0; i < secret2.length; i++) secret2[i] = 3;
|
||||||
random.nextBytes(secret0);
|
for(int i = 0; i < secret3.length; i++) secret3[i] = 4;
|
||||||
random.nextBytes(secret1);
|
for(int i = 0; i < secret4.length; i++) secret4[i] = 5;
|
||||||
random.nextBytes(secret2);
|
|
||||||
random.nextBytes(secret3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -64,8 +58,10 @@ public class KeyManagerImplTest extends BriarTestCase {
|
|||||||
context.mock(ConnectionRecogniser.class);
|
context.mock(ConnectionRecogniser.class);
|
||||||
final Clock clock = context.mock(Clock.class);
|
final Clock clock = context.mock(Clock.class);
|
||||||
final Timer timer = context.mock(Timer.class);
|
final Timer timer = context.mock(Timer.class);
|
||||||
|
|
||||||
final KeyManagerImpl keyManager = new KeyManagerImpl(crypto, db,
|
final KeyManagerImpl keyManager = new KeyManagerImpl(crypto, db,
|
||||||
connectionRecogniser, clock, timer);
|
connectionRecogniser, clock, timer);
|
||||||
|
|
||||||
context.checking(new Expectations() {{
|
context.checking(new Expectations() {{
|
||||||
// start()
|
// start()
|
||||||
oneOf(db).addListener(with(any(DatabaseListener.class)));
|
oneOf(db).addListener(with(any(DatabaseListener.class)));
|
||||||
@@ -74,7 +70,7 @@ public class KeyManagerImplTest extends BriarTestCase {
|
|||||||
oneOf(db).getTransportLatencies();
|
oneOf(db).getTransportLatencies();
|
||||||
will(returnValue(Collections.emptyMap()));
|
will(returnValue(Collections.emptyMap()));
|
||||||
oneOf(clock).currentTimeMillis();
|
oneOf(clock).currentTimeMillis();
|
||||||
will(returnValue(epoch));
|
will(returnValue(EPOCH));
|
||||||
oneOf(timer).scheduleAtFixedRate(with(any(TimerTask.class)),
|
oneOf(timer).scheduleAtFixedRate(with(any(TimerTask.class)),
|
||||||
with(any(long.class)), with(any(long.class)));
|
with(any(long.class)), with(any(long.class)));
|
||||||
// stop()
|
// stop()
|
||||||
@@ -98,13 +94,16 @@ public class KeyManagerImplTest extends BriarTestCase {
|
|||||||
context.mock(ConnectionRecogniser.class);
|
context.mock(ConnectionRecogniser.class);
|
||||||
final Clock clock = context.mock(Clock.class);
|
final Clock clock = context.mock(Clock.class);
|
||||||
final Timer timer = context.mock(Timer.class);
|
final Timer timer = context.mock(Timer.class);
|
||||||
|
|
||||||
final KeyManagerImpl keyManager = new KeyManagerImpl(crypto, db,
|
final KeyManagerImpl keyManager = new KeyManagerImpl(crypto, db,
|
||||||
connectionRecogniser, clock, timer);
|
connectionRecogniser, clock, timer);
|
||||||
|
|
||||||
// The DB contains secrets for periods 0 - 2
|
// The DB contains secrets for periods 0 - 2
|
||||||
Endpoint ep = new Endpoint(contactId, transportId, epoch, true);
|
Endpoint ep = new Endpoint(contactId, transportId, EPOCH, true);
|
||||||
final TemporarySecret s0 = new TemporarySecret(ep, 0, secret0);
|
final TemporarySecret s0 = new TemporarySecret(ep, 0, secret0.clone());
|
||||||
final TemporarySecret s1 = new TemporarySecret(ep, 1, secret1);
|
final TemporarySecret s1 = new TemporarySecret(ep, 1, secret1.clone());
|
||||||
final TemporarySecret s2 = new TemporarySecret(ep, 2, secret2);
|
final TemporarySecret s2 = new TemporarySecret(ep, 2, secret2.clone());
|
||||||
|
|
||||||
context.checking(new Expectations() {{
|
context.checking(new Expectations() {{
|
||||||
// start()
|
// start()
|
||||||
oneOf(db).addListener(with(any(DatabaseListener.class)));
|
oneOf(db).addListener(with(any(DatabaseListener.class)));
|
||||||
@@ -112,10 +111,10 @@ public class KeyManagerImplTest extends BriarTestCase {
|
|||||||
will(returnValue(Arrays.asList(s0, s1, s2)));
|
will(returnValue(Arrays.asList(s0, s1, s2)));
|
||||||
oneOf(db).getTransportLatencies();
|
oneOf(db).getTransportLatencies();
|
||||||
will(returnValue(Collections.singletonMap(transportId,
|
will(returnValue(Collections.singletonMap(transportId,
|
||||||
maxLatency)));
|
MAX_LATENCY)));
|
||||||
// The current time is the second secret's activation time
|
// The current time is the epoch, the start of period 1
|
||||||
oneOf(clock).currentTimeMillis();
|
oneOf(clock).currentTimeMillis();
|
||||||
will(returnValue(epoch));
|
will(returnValue(EPOCH));
|
||||||
// The secrets for periods 0 - 2 should be added to the recogniser
|
// The secrets for periods 0 - 2 should be added to the recogniser
|
||||||
oneOf(connectionRecogniser).addSecret(s0);
|
oneOf(connectionRecogniser).addSecret(s0);
|
||||||
oneOf(connectionRecogniser).addSecret(s1);
|
oneOf(connectionRecogniser).addSecret(s1);
|
||||||
@@ -135,7 +134,7 @@ public class KeyManagerImplTest extends BriarTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testLoadSecretsAtNewActivationTime() throws Exception {
|
public void testLoadSecretsAtStartOfPeriod2() throws Exception {
|
||||||
Mockery context = new Mockery();
|
Mockery context = new Mockery();
|
||||||
final CryptoComponent crypto = context.mock(CryptoComponent.class);
|
final CryptoComponent crypto = context.mock(CryptoComponent.class);
|
||||||
final DatabaseComponent db = context.mock(DatabaseComponent.class);
|
final DatabaseComponent db = context.mock(DatabaseComponent.class);
|
||||||
@@ -143,15 +142,18 @@ public class KeyManagerImplTest extends BriarTestCase {
|
|||||||
context.mock(ConnectionRecogniser.class);
|
context.mock(ConnectionRecogniser.class);
|
||||||
final Clock clock = context.mock(Clock.class);
|
final Clock clock = context.mock(Clock.class);
|
||||||
final Timer timer = context.mock(Timer.class);
|
final Timer timer = context.mock(Timer.class);
|
||||||
|
|
||||||
final KeyManagerImpl keyManager = new KeyManagerImpl(crypto, db,
|
final KeyManagerImpl keyManager = new KeyManagerImpl(crypto, db,
|
||||||
connectionRecogniser, clock, timer);
|
connectionRecogniser, clock, timer);
|
||||||
|
|
||||||
// The DB contains secrets for periods 0 - 2
|
// The DB contains secrets for periods 0 - 2
|
||||||
Endpoint ep = new Endpoint(contactId, transportId, epoch, true);
|
Endpoint ep = new Endpoint(contactId, transportId, EPOCH, true);
|
||||||
final TemporarySecret s0 = new TemporarySecret(ep, 0, secret0);
|
final TemporarySecret s0 = new TemporarySecret(ep, 0, secret0.clone());
|
||||||
final TemporarySecret s1 = new TemporarySecret(ep, 1, secret1);
|
final TemporarySecret s1 = new TemporarySecret(ep, 1, secret1.clone());
|
||||||
final TemporarySecret s2 = new TemporarySecret(ep, 2, secret2);
|
final TemporarySecret s2 = new TemporarySecret(ep, 2, secret2.clone());
|
||||||
// A fourth secret should be derived and stored
|
// The secret for period 3 should be derived and stored
|
||||||
final TemporarySecret s3 = new TemporarySecret(ep, 3, secret3);
|
final TemporarySecret s3 = new TemporarySecret(ep, 3, secret3.clone());
|
||||||
|
|
||||||
context.checking(new Expectations() {{
|
context.checking(new Expectations() {{
|
||||||
// start()
|
// start()
|
||||||
oneOf(db).addListener(with(any(DatabaseListener.class)));
|
oneOf(db).addListener(with(any(DatabaseListener.class)));
|
||||||
@@ -159,17 +161,17 @@ public class KeyManagerImplTest extends BriarTestCase {
|
|||||||
will(returnValue(Arrays.asList(s0, s1, s2)));
|
will(returnValue(Arrays.asList(s0, s1, s2)));
|
||||||
oneOf(db).getTransportLatencies();
|
oneOf(db).getTransportLatencies();
|
||||||
will(returnValue(Collections.singletonMap(transportId,
|
will(returnValue(Collections.singletonMap(transportId,
|
||||||
maxLatency)));
|
MAX_LATENCY)));
|
||||||
// The current time is the third secret's activation time
|
// The current time is the start of period 2
|
||||||
oneOf(clock).currentTimeMillis();
|
oneOf(clock).currentTimeMillis();
|
||||||
will(returnValue(epoch + rotationPeriodLength));
|
will(returnValue(EPOCH + ROTATION_PERIOD_LENGTH));
|
||||||
// A fourth secret should be derived and stored
|
// The secret for period 3 should be derived and stored
|
||||||
oneOf(crypto).deriveNextSecret(secret0, 1);
|
oneOf(crypto).deriveNextSecret(secret0, 1);
|
||||||
will(returnValue(secret1.clone()));
|
will(returnValue(secret1.clone()));
|
||||||
oneOf(crypto).deriveNextSecret(secret1, 2);
|
oneOf(crypto).deriveNextSecret(secret1, 2);
|
||||||
will(returnValue(secret2.clone()));
|
will(returnValue(secret2.clone()));
|
||||||
oneOf(crypto).deriveNextSecret(secret2, 3);
|
oneOf(crypto).deriveNextSecret(secret2, 3);
|
||||||
will(returnValue(secret3));
|
will(returnValue(secret3.clone()));
|
||||||
oneOf(db).addSecrets(Arrays.asList(s3));
|
oneOf(db).addSecrets(Arrays.asList(s3));
|
||||||
// The secrets for periods 1 - 3 should be added to the recogniser
|
// The secrets for periods 1 - 3 should be added to the recogniser
|
||||||
oneOf(connectionRecogniser).addSecret(s1);
|
oneOf(connectionRecogniser).addSecret(s1);
|
||||||
@@ -184,8 +186,75 @@ public class KeyManagerImplTest extends BriarTestCase {
|
|||||||
}});
|
}});
|
||||||
|
|
||||||
assertTrue(keyManager.start());
|
assertTrue(keyManager.start());
|
||||||
// The dead secret should have been erased
|
keyManager.stop();
|
||||||
assertArrayEquals(new byte[32], secret0);
|
|
||||||
|
context.assertIsSatisfied();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testLoadSecretsAtStartOfPeriod3() throws Exception {
|
||||||
|
Mockery context = new Mockery();
|
||||||
|
final CryptoComponent crypto = context.mock(CryptoComponent.class);
|
||||||
|
final DatabaseComponent db = context.mock(DatabaseComponent.class);
|
||||||
|
final ConnectionRecogniser connectionRecogniser =
|
||||||
|
context.mock(ConnectionRecogniser.class);
|
||||||
|
final Clock clock = context.mock(Clock.class);
|
||||||
|
final Timer timer = context.mock(Timer.class);
|
||||||
|
|
||||||
|
final KeyManagerImpl keyManager = new KeyManagerImpl(crypto, db,
|
||||||
|
connectionRecogniser, clock, timer);
|
||||||
|
|
||||||
|
// The DB contains secrets for periods 0 - 2
|
||||||
|
Endpoint ep = new Endpoint(contactId, transportId, EPOCH, true);
|
||||||
|
final TemporarySecret s0 = new TemporarySecret(ep, 0, secret0.clone());
|
||||||
|
final TemporarySecret s1 = new TemporarySecret(ep, 1, secret1.clone());
|
||||||
|
final TemporarySecret s2 = new TemporarySecret(ep, 2, secret2.clone());
|
||||||
|
// The secrets for periods 3 and 4 should be derived and stored
|
||||||
|
final TemporarySecret s3 = new TemporarySecret(ep, 3, secret3.clone());
|
||||||
|
final TemporarySecret s4 = new TemporarySecret(ep, 4, secret4.clone());
|
||||||
|
|
||||||
|
context.checking(new Expectations() {{
|
||||||
|
// start()
|
||||||
|
oneOf(db).addListener(with(any(DatabaseListener.class)));
|
||||||
|
oneOf(db).getSecrets();
|
||||||
|
will(returnValue(Arrays.asList(s0, s1, s2)));
|
||||||
|
oneOf(db).getTransportLatencies();
|
||||||
|
will(returnValue(Collections.singletonMap(transportId,
|
||||||
|
MAX_LATENCY)));
|
||||||
|
// The current time is the start of period 3
|
||||||
|
oneOf(clock).currentTimeMillis();
|
||||||
|
will(returnValue(EPOCH + 2 * ROTATION_PERIOD_LENGTH));
|
||||||
|
// The secrets for periods 3 and 4 should be derived from secret 0
|
||||||
|
oneOf(crypto).deriveNextSecret(secret0, 1);
|
||||||
|
will(returnValue(secret1.clone()));
|
||||||
|
oneOf(crypto).deriveNextSecret(secret1, 2);
|
||||||
|
will(returnValue(secret2.clone()));
|
||||||
|
oneOf(crypto).deriveNextSecret(secret2, 3);
|
||||||
|
will(returnValue(secret3.clone()));
|
||||||
|
oneOf(crypto).deriveNextSecret(secret3, 4);
|
||||||
|
will(returnValue(secret4.clone()));
|
||||||
|
// The secrets for periods 3 and 4 should be derived from secret 1
|
||||||
|
oneOf(crypto).deriveNextSecret(secret1, 2);
|
||||||
|
will(returnValue(secret2.clone()));
|
||||||
|
oneOf(crypto).deriveNextSecret(secret2, 3);
|
||||||
|
will(returnValue(secret3.clone()));
|
||||||
|
oneOf(crypto).deriveNextSecret(secret3, 4);
|
||||||
|
will(returnValue(secret4.clone()));
|
||||||
|
// One copy of each of the new secrets should be stored
|
||||||
|
oneOf(db).addSecrets(Arrays.asList(s3, s4));
|
||||||
|
// The secrets for periods 2 - 3 should be added to the recogniser
|
||||||
|
oneOf(connectionRecogniser).addSecret(s2);
|
||||||
|
oneOf(connectionRecogniser).addSecret(s3);
|
||||||
|
oneOf(connectionRecogniser).addSecret(s4);
|
||||||
|
oneOf(timer).scheduleAtFixedRate(with(any(TimerTask.class)),
|
||||||
|
with(any(long.class)), with(any(long.class)));
|
||||||
|
// stop()
|
||||||
|
oneOf(db).removeListener(with(any(DatabaseListener.class)));
|
||||||
|
oneOf(timer).cancel();
|
||||||
|
oneOf(connectionRecogniser).removeSecrets();
|
||||||
|
}});
|
||||||
|
|
||||||
|
assertTrue(keyManager.start());
|
||||||
keyManager.stop();
|
keyManager.stop();
|
||||||
|
|
||||||
context.assertIsSatisfied();
|
context.assertIsSatisfied();
|
||||||
|
|||||||
Reference in New Issue
Block a user