forked from I2P_Developers/i2p.i2p
Ratchet: Updates
- Prep for prop. 154 with SingleTagSet - Variable timeout for tagsets - Start cleaner sooner - Make key optional in next key block - HTML debug output improvement - log tweaks and javadocs
This commit is contained in:
@ -772,14 +772,14 @@ public final class ECIESAEADEngine {
|
||||
* - 16 byte MAC
|
||||
* </pre>
|
||||
*
|
||||
* @param target unused, this is AEAD encrypt only using the session key and tag
|
||||
* @param target only used if callback is non-null to register it
|
||||
* @param replyDI non-null to request an ack, or null
|
||||
* @return encrypted data or null on failure
|
||||
*/
|
||||
private byte[] encryptExistingSession(CloveSet cloves, PublicKey target, RatchetEntry re,
|
||||
DeliveryInstructions replyDI, ReplyCallback callback,
|
||||
RatchetSKM keyManager) {
|
||||
//
|
||||
// TODO remove DI, just make it a boolean
|
||||
if (ACKREQ_IN_ES && replyDI == null)
|
||||
replyDI = new DeliveryInstructions();
|
||||
byte rawTag[] = re.tag.getData();
|
||||
@ -794,6 +794,31 @@ public final class ECIESAEADEngine {
|
||||
return encr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Existing Session Message to an anonymous target
|
||||
* using the given session key and tag, for netdb DSM/DSRM replies.
|
||||
* Called from MessageWrapper.
|
||||
*
|
||||
* No datetime, no next key, no acks, no ack requests.
|
||||
* n=0, ad=null.
|
||||
*
|
||||
* <pre>
|
||||
* - 8 byte SessionTag
|
||||
* - payload
|
||||
* - 16 byte MAC
|
||||
* </pre>
|
||||
*
|
||||
* @return encrypted data or null on failure
|
||||
* @since 0.9.46
|
||||
*/
|
||||
public byte[] encrypt(CloveSet cloves, SessionKey key, RatchetSessionTag tag) {
|
||||
byte rawTag[] = tag.getData();
|
||||
byte[] payload = createPayload(cloves, 0, null, null, null);
|
||||
byte encr[] = encryptAEADBlock(rawTag, payload, key, 0);
|
||||
System.arraycopy(rawTag, 0, encr, 0, TAGLEN);
|
||||
return encr;
|
||||
}
|
||||
|
||||
/**
|
||||
* No ad
|
||||
*/
|
||||
|
@ -48,12 +48,12 @@ final class MuxedEngine {
|
||||
try {
|
||||
rv = _context.garlicMessageParser().readCloveSet(dec, 0);
|
||||
} catch (DataFormatException dfe) {
|
||||
if (_log.shouldWarn())
|
||||
_log.warn("ElG decrypt failed, trying ECIES", dfe);
|
||||
if (_log.shouldInfo())
|
||||
_log.info("ElG decrypt failed, trying ECIES", dfe);
|
||||
}
|
||||
} else {
|
||||
if (_log.shouldWarn())
|
||||
_log.warn("ElG decrypt failed, trying ECIES");
|
||||
//if (_log.shouldDebug())
|
||||
// _log.debug("ElG decrypt failed, trying ECIES");
|
||||
}
|
||||
}
|
||||
if (rv == null) {
|
||||
|
@ -12,6 +12,9 @@ class NextSessionKey extends PublicKey {
|
||||
private final int _id;
|
||||
private final boolean _isReverse, _isRequest;
|
||||
|
||||
/**
|
||||
* @param data may be null
|
||||
*/
|
||||
public NextSessionKey(byte[] data, int id, boolean isReverse, boolean isRequest) {
|
||||
super(EncType.ECIES_X25519, data);
|
||||
_id = id;
|
||||
|
@ -136,13 +136,19 @@ class RatchetPayload {
|
||||
|
||||
case BLOCK_NEXTKEY:
|
||||
{
|
||||
if (len != 35)
|
||||
if (len != 3 && len != 35)
|
||||
throw new IOException("Bad length for NEXTKEY: " + len);
|
||||
boolean isReverse = (payload[i] & 0x01) != 0;
|
||||
boolean isRequest = (payload[i] & 0x02) != 0;
|
||||
boolean hasKey = (payload[i] & 0x01) != 0;
|
||||
boolean isReverse = (payload[i] & 0x02) != 0;
|
||||
boolean isRequest = (payload[i] & 0x04) != 0;
|
||||
int id = (int) DataHelper.fromLong(payload, i + 1, 2);
|
||||
byte[] data = new byte[32];
|
||||
System.arraycopy(payload, i + 3, data, 0, 32);
|
||||
byte[] data;
|
||||
if (hasKey) {
|
||||
data = new byte[32];
|
||||
System.arraycopy(payload, i + 3, data, 0, 32);
|
||||
} else {
|
||||
data = null;
|
||||
}
|
||||
NextSessionKey nsk = new NextSessionKey(data, id, isReverse, isRequest);
|
||||
cb.gotNextKey(nsk);
|
||||
}
|
||||
@ -352,17 +358,22 @@ class RatchetPayload {
|
||||
}
|
||||
|
||||
public int getDataLength() {
|
||||
return 35;
|
||||
return next.getData() != null ? 35 : 3;
|
||||
}
|
||||
|
||||
public int writeData(byte[] tgt, int off) {
|
||||
if (next.isReverse())
|
||||
if (next.getData() != null)
|
||||
tgt[off] = 0x01;
|
||||
if (next.isRequest())
|
||||
if (next.isReverse())
|
||||
tgt[off] |= 0x02;
|
||||
if (next.isRequest())
|
||||
tgt[off] |= 0x04;
|
||||
DataHelper.toLong(tgt, off + 1, 2, next.getID());
|
||||
System.arraycopy(next.getData(), 0, tgt, off + 3, 32);
|
||||
return off + 35;
|
||||
if (next.getData() != null) {
|
||||
System.arraycopy(next.getData(), 0, tgt, off + 3, 32);
|
||||
return off + 35;
|
||||
}
|
||||
return off + 3;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -54,7 +54,7 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
* Let outbound session tags sit around for this long before expiring them.
|
||||
* Inbound tag expiration is set by SESSION_LIFETIME_MAX_MS
|
||||
*/
|
||||
private final static long SESSION_TAG_DURATION_MS = 12 * 60 * 1000;
|
||||
final static long SESSION_TAG_DURATION_MS = 12 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Keep unused inbound session tags around for this long (a few minutes longer than
|
||||
@ -63,9 +63,9 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
*
|
||||
* This is also the max idle time for an outbound session.
|
||||
*/
|
||||
private final static long SESSION_LIFETIME_MAX_MS = SESSION_TAG_DURATION_MS + 3 * 60 * 1000;
|
||||
final static long SESSION_LIFETIME_MAX_MS = SESSION_TAG_DURATION_MS + 3 * 60 * 1000;
|
||||
|
||||
private final static long SESSION_PENDING_DURATION_MS = 5 * 60 * 1000;
|
||||
final static long SESSION_PENDING_DURATION_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Time to send more if we are this close to expiration
|
||||
@ -110,8 +110,8 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
|
||||
private class CleanupEvent extends SimpleTimer2.TimedEvent {
|
||||
public CleanupEvent() {
|
||||
// wait until outbound expiration time to start
|
||||
super(_context.simpleTimer2(), SESSION_TAG_DURATION_MS);
|
||||
// wait until first expiration time to start
|
||||
super(_context.simpleTimer2(), SESSION_PENDING_DURATION_MS);
|
||||
}
|
||||
|
||||
public void timeReached() {
|
||||
@ -491,6 +491,14 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* One time session
|
||||
* @param expire time from now
|
||||
*/
|
||||
public void tagsReceived(SessionKey key, RatchetSessionTag tag, long expire) {
|
||||
new SingleTagSet(this, key, tag, _context.clock().now(), expire);
|
||||
}
|
||||
|
||||
/**
|
||||
* remove a bunch of arbitrarily selected tags, then drop all of
|
||||
* the associated tag sets. this is very time consuming - iterating
|
||||
@ -543,13 +551,15 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
if (state == null) {
|
||||
// TODO this should really be after decrypt...
|
||||
PublicKey pk = tagSet.getRemoteKey();
|
||||
OutboundSession sess = getSession(pk);
|
||||
if (sess != null) {
|
||||
sess.firstTagConsumed(tagSet);
|
||||
} else {
|
||||
if (_log.shouldDebug())
|
||||
_log.debug("First tag consumed but session is gone");
|
||||
}
|
||||
if (pk != null) {
|
||||
OutboundSession sess = getSession(pk);
|
||||
if (sess != null) {
|
||||
sess.firstTagConsumed(tagSet);
|
||||
} else {
|
||||
if (_log.shouldDebug())
|
||||
_log.debug("First tag consumed but session is gone");
|
||||
}
|
||||
} // else null for SingleTagSets
|
||||
}
|
||||
}
|
||||
if (_log.shouldDebug()) {
|
||||
@ -611,13 +621,12 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
*/
|
||||
private int aggressiveExpire() {
|
||||
long now = _context.clock().now();
|
||||
long exp = now - SESSION_LIFETIME_MAX_MS;
|
||||
|
||||
// inbound
|
||||
int removed = 0;
|
||||
for (Iterator<RatchetTagSet> iter = _inboundTagSets.values().iterator(); iter.hasNext();) {
|
||||
RatchetTagSet ts = iter.next();
|
||||
if (ts.getDate() < exp) {
|
||||
if (ts.getExpiration() < now) {
|
||||
iter.remove();
|
||||
removed++;
|
||||
}
|
||||
@ -626,7 +635,7 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
// outbound
|
||||
int oremoved = 0;
|
||||
int cremoved = 0;
|
||||
exp = now - (SESSION_LIFETIME_MAX_MS / 2);
|
||||
long exp = now - (SESSION_LIFETIME_MAX_MS / 2);
|
||||
for (Iterator<OutboundSession> iter = _outboundSessions.values().iterator(); iter.hasNext();) {
|
||||
OutboundSession sess = iter.next();
|
||||
oremoved += sess.expireTags(now);
|
||||
@ -753,7 +762,6 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
int total = 0;
|
||||
int totalSets = 0;
|
||||
long now = _context.clock().now();
|
||||
long exp = now - SESSION_LIFETIME_MAX_MS;
|
||||
Set<RatchetTagSet> sets = new TreeSet<RatchetTagSet>(new RatchetTagSetComparator());
|
||||
for (Map.Entry<SessionKey, Set<RatchetTagSet>> e : inboundSets.entrySet()) {
|
||||
SessionKey skey = e.getKey();
|
||||
@ -767,8 +775,9 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
int size = ts.size();
|
||||
total += size;
|
||||
buf.append("<li><b>ID: ").append(ts.getID());
|
||||
buf.append(" created:</b> ").append(DataHelper.formatTime(ts.getCreated()));
|
||||
long expires = ts.getDate() - exp;
|
||||
buf.append(" created:</b> ").append(DataHelper.formatTime(ts.getCreated()))
|
||||
.append(" <b>last use:</b> ").append(DataHelper.formatTime(ts.getDate()));
|
||||
long expires = ts.getExpiration() - now;
|
||||
if (expires > 0)
|
||||
buf.append(" <b>expires in:</b> ").append(DataHelper.formatDuration2(expires)).append(" with ");
|
||||
else
|
||||
@ -789,7 +798,6 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
|
||||
// outbound
|
||||
totalSets = 0;
|
||||
exp = now - SESSION_TAG_DURATION_MS;
|
||||
Set<OutboundSession> outbound = getOutboundSessions();
|
||||
for (OutboundSession sess : outbound) {
|
||||
sets.clear();
|
||||
@ -808,8 +816,9 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
for (RatchetTagSet ts : sets) {
|
||||
int size = ts.remaining();
|
||||
buf.append("<li><b>ID: ").append(ts.getID())
|
||||
.append(" created:</b> ").append(DataHelper.formatTime(ts.getCreated()));
|
||||
long expires = ts.getDate() - exp;
|
||||
.append(" created:</b> ").append(DataHelper.formatTime(ts.getCreated()))
|
||||
.append(" <b>last use:</b> ").append(DataHelper.formatTime(ts.getDate()));
|
||||
long expires = ts.getExpiration() - now;
|
||||
if (expires > 0)
|
||||
buf.append(" <b>expires in:</b> ").append(DataHelper.formatDuration2(expires)).append(" with ");
|
||||
else
|
||||
@ -1130,7 +1139,7 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
synchronized (_tagSets) {
|
||||
for (Iterator<RatchetTagSet> iter = _tagSets.iterator(); iter.hasNext(); ) {
|
||||
RatchetTagSet set = iter.next();
|
||||
if (set.getDate() + SESSION_TAG_DURATION_MS <= now) {
|
||||
if (set.getExpiration() <= now) {
|
||||
iter.remove();
|
||||
removed++;
|
||||
}
|
||||
@ -1139,7 +1148,7 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
if ((now & 0x0f) == 0) {
|
||||
for (Iterator<RatchetTagSet> iter = _unackedTagSets.iterator(); iter.hasNext(); ) {
|
||||
RatchetTagSet set = iter.next();
|
||||
if (set.getDate() + SESSION_TAG_DURATION_MS <= now) {
|
||||
if (set.getExpiration() <= now) {
|
||||
iter.remove();
|
||||
removed++;
|
||||
}
|
||||
@ -1156,7 +1165,7 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
while (!_tagSets.isEmpty()) {
|
||||
RatchetTagSet set = _tagSets.get(0);
|
||||
synchronized(set) {
|
||||
if (set.getDate() + SESSION_TAG_DURATION_MS > now) {
|
||||
if (set.getExpiration() > now) {
|
||||
RatchetSessionTag tag = set.consumeNext();
|
||||
if (tag != null) {
|
||||
set.setDate(now);
|
||||
@ -1186,7 +1195,7 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
RatchetTagSet set = _tagSets.get(i);
|
||||
if (!set.getAcked())
|
||||
continue;
|
||||
if (set.getDate() + SESSION_TAG_DURATION_MS > now) {
|
||||
if (set.getExpiration() > now) {
|
||||
// or just add fixed number?
|
||||
int sz = set.remaining();
|
||||
tags += sz;
|
||||
@ -1205,12 +1214,13 @@ public class RatchetSKM extends SessionKeyManager implements SessionTagListener
|
||||
long last = 0;
|
||||
synchronized (_tagSets) {
|
||||
for (RatchetTagSet set : _tagSets) {
|
||||
if (set.getDate() > last && set.remaining() > 0)
|
||||
last = set.getDate();
|
||||
long exp = set.getExpiration();
|
||||
if (exp > last && set.remaining() > 0)
|
||||
last = exp;
|
||||
}
|
||||
}
|
||||
if (last > 0)
|
||||
return last + SESSION_TAG_DURATION_MS;
|
||||
return last;
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
@ -38,7 +38,7 @@ import net.i2p.util.Log;
|
||||
class RatchetTagSet implements TagSetHandle {
|
||||
private final SessionTagListener _lsnr;
|
||||
private final PublicKey _remoteKey;
|
||||
private final SessionKey _key;
|
||||
protected final SessionKey _key;
|
||||
private final HandshakeState _state;
|
||||
// inbound only, else null
|
||||
// We use object for tags because we must do indexOfValueByValue()
|
||||
@ -48,6 +48,7 @@ class RatchetTagSet implements TagSetHandle {
|
||||
private final SparseArray<byte[]> _sessionKeys;
|
||||
private final HKDF hkdf;
|
||||
private final long _created;
|
||||
private final long _timeout;
|
||||
private long _date;
|
||||
private final int _id;
|
||||
private final int _originalSize;
|
||||
@ -84,7 +85,7 @@ class RatchetTagSet implements TagSetHandle {
|
||||
*/
|
||||
public RatchetTagSet(HKDF hkdf, HandshakeState state, SessionKey rootKey, SessionKey data,
|
||||
long date, int id) {
|
||||
this(hkdf, null, state, null, rootKey, data, date, id, false, 0, 0);
|
||||
this(hkdf, null, state, null, rootKey, data, date, RatchetSKM.SESSION_PENDING_DURATION_MS, id, false, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -94,7 +95,7 @@ class RatchetTagSet implements TagSetHandle {
|
||||
*/
|
||||
public RatchetTagSet(HKDF hkdf, SessionKey rootKey, SessionKey data,
|
||||
long date, int id) {
|
||||
this(hkdf, null, null, null, rootKey, data, date, id, false, 0, 0);
|
||||
this(hkdf, null, null, null, rootKey, data, date, RatchetSKM.SESSION_TAG_DURATION_MS, id, false, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -104,7 +105,7 @@ class RatchetTagSet implements TagSetHandle {
|
||||
*/
|
||||
public RatchetTagSet(HKDF hkdf, SessionTagListener lsnr, HandshakeState state, SessionKey rootKey, SessionKey data,
|
||||
long date, int id, int minSize, int maxSize) {
|
||||
this(hkdf, lsnr, state, null, rootKey, data, date, id, true, minSize, maxSize);
|
||||
this(hkdf, lsnr, state, null, rootKey, data, date, RatchetSKM.SESSION_PENDING_DURATION_MS, id, true, minSize, maxSize);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -115,7 +116,7 @@ class RatchetTagSet implements TagSetHandle {
|
||||
public RatchetTagSet(HKDF hkdf, SessionTagListener lsnr,
|
||||
PublicKey remoteKey, SessionKey rootKey, SessionKey data,
|
||||
long date, int id, int minSize, int maxSize) {
|
||||
this(hkdf, lsnr, null, remoteKey, rootKey, data, date, id, true, minSize, maxSize);
|
||||
this(hkdf, lsnr, null, remoteKey, rootKey, data, date, RatchetSKM.SESSION_LIFETIME_MAX_MS, id, true, minSize, maxSize);
|
||||
}
|
||||
|
||||
|
||||
@ -124,12 +125,13 @@ class RatchetTagSet implements TagSetHandle {
|
||||
*/
|
||||
private RatchetTagSet(HKDF hkdf, SessionTagListener lsnr, HandshakeState state,
|
||||
PublicKey remoteKey, SessionKey rootKey, SessionKey data,
|
||||
long date, int id, boolean isInbound, int minSize, int maxSize) {
|
||||
long date, long timeout, int id, boolean isInbound, int minSize, int maxSize) {
|
||||
_lsnr = lsnr;
|
||||
_state = state;
|
||||
_remoteKey = remoteKey;
|
||||
_key = rootKey;
|
||||
_created = date;
|
||||
_timeout = timeout;
|
||||
_date = date;
|
||||
_id = id;
|
||||
_originalSize = minSize;
|
||||
@ -159,6 +161,31 @@ class RatchetTagSet implements TagSetHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For SingleTagSet
|
||||
* @since 0.9.46
|
||||
*/
|
||||
protected RatchetTagSet(SessionTagListener lsnr, SessionKey rootKey, long date, long timeout) {
|
||||
_lsnr = lsnr;
|
||||
_state = null;
|
||||
_remoteKey = null;
|
||||
_key = rootKey;
|
||||
_created = date;
|
||||
_timeout = timeout;
|
||||
_date = date;
|
||||
_id = 0x10003;
|
||||
_originalSize = 1;
|
||||
_maxSize = 1;
|
||||
_nextRootKey = null;
|
||||
_sesstag_ck = null;
|
||||
_sesstag_constant = null;
|
||||
_symmkey_ck = null;
|
||||
_symmkey_constant = null;
|
||||
hkdf = null;
|
||||
_sessionTags = null;
|
||||
_sessionKeys = null;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
if (_sessionTags != null)
|
||||
_sessionTags.clear();
|
||||
@ -201,6 +228,7 @@ class RatchetTagSet implements TagSetHandle {
|
||||
|
||||
/**
|
||||
* For inbound and outbound: last used time
|
||||
* Expiration is getDate() + getTimeout().
|
||||
*/
|
||||
public long getDate() {
|
||||
return _date;
|
||||
@ -214,12 +242,30 @@ class RatchetTagSet implements TagSetHandle {
|
||||
}
|
||||
|
||||
/**
|
||||
* For inbound and outbound: creation time
|
||||
* For inbound and outbound: creation time, for debugging only
|
||||
*/
|
||||
public long getCreated() {
|
||||
return _created;
|
||||
}
|
||||
|
||||
/**
|
||||
* For inbound and outbound: Idle timeout interval.
|
||||
* Expiration is getDate() + getTimeout().
|
||||
* @since 0.9.46
|
||||
*/
|
||||
public long getTimeout() {
|
||||
return _timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* For inbound and outbound: Expiration.
|
||||
* Expiration is getDate() + getTimeout().
|
||||
* @since 0.9.46
|
||||
*/
|
||||
public synchronized long getExpiration() {
|
||||
return _date + _timeout;
|
||||
}
|
||||
|
||||
/** for debugging */
|
||||
public int getOriginalSize() {
|
||||
return _originalSize;
|
||||
|
@ -0,0 +1,53 @@
|
||||
package net.i2p.router.crypto.ratchet;
|
||||
|
||||
import net.i2p.data.SessionKey;
|
||||
|
||||
/**
|
||||
* Inbound ES tagset with a single tag and key.
|
||||
* Nonce is 0.
|
||||
* For receiving DSM/DSRM replies.
|
||||
*
|
||||
* @since 0.9.46
|
||||
*/
|
||||
class SingleTagSet extends RatchetTagSet {
|
||||
|
||||
private final RatchetSessionTag _tag;
|
||||
private boolean _isUsed;
|
||||
|
||||
/**
|
||||
* For outbound Existing Session
|
||||
*/
|
||||
public SingleTagSet(SessionTagListener lsnr, SessionKey key, RatchetSessionTag tag, long date, long timeout) {
|
||||
super(lsnr, key, date, timeout);
|
||||
_tag = tag;
|
||||
lsnr.addTag(tag, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return _isUsed ? 0 : 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int remaining() {
|
||||
return _isUsed ? 0 : 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SessionKeyAndNonce consume(RatchetSessionTag tag) {
|
||||
if (_isUsed || !tag.equals(_tag))
|
||||
return null;
|
||||
_isUsed = true;
|
||||
return new SessionKeyAndNonce(_key.getData(), 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder buf = new StringBuilder(64);
|
||||
buf.append("[SingleTagSet: 0 ");
|
||||
buf.append(_tag.toBase64());
|
||||
buf.append(' ').append(_key.toBase64());
|
||||
buf.append(']');
|
||||
return buf.toString();
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user