package ganarchy.friendcode.sam;
import com.google.common.collect.ImmutableMap;
import java.io.IOException;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetSocketAddress;
import java.net.Socket;
public class I2PSamControl extends I2PSamStateMachine {
private final boolean zeroHop;
/**
* The session's private key.
*
* The threat model assumes the local machine (including RAM) to be trusted.
*/
private String privateKey;
/**
* The session's public key.
*/
private String publicKey;
private String id;
/**
* Creates a new SAM control socket.
*
* @param zeroHop Whether to use zero-hop tunnels.
* @param privateKey The (optional) identity private key, for persistent friend codes.
*/
public I2PSamControl(boolean zeroHop, String privateKey) {
this.zeroHop = zeroHop;
this.privateKey = privateKey;
}
@Override
public boolean connect() {
try {
// try IPv6 first
// there's no Inet6Address.getLoopbackAddress() which is unfortunate.
final Socket samSocket = new Socket();
samSocket.connect(new InetSocketAddress(Inet6Address.getByAddress("localhost", new byte[] {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1}, 0), 7656), 3000);
return this.connect(samSocket);
} catch (IOException e) {
try {
final Socket samSocket = new Socket();
samSocket.connect(new InetSocketAddress(Inet4Address.getByAddress("localhost", new byte[] {127,0,0,1}), 7656), 3000);
return this.connect(samSocket);
} catch (IOException ex) {
return false;
}
}
}
@Override
public boolean start() {
if (!super.start()) {
return false;
}
try {
// try to enable auth
this.enableAuth();
if (this.privateKey == null) {
// generate our keys
this.sendCommand(new I2PSamCommand(
"DEST", "GENERATE",
ImmutableMap.of("SIGNATURE_TYPE", "EdDSA_SHA512_Ed25519")
));
var dest = this.getCommand("DEST", "REPLY");
this.publicKey = dest.parameters().get("PUB");
this.privateKey = dest.parameters().get("PRIV");
}
// setup our session
int i = 0;
String status;
do {
i++;
// we want a session with the given privkey and zero hops for maximum performance
// this isn't anonymous but then it's not meant to be - we're using I2P as a free STUN/TURN service
// the connecting clients handle the need for TURN by setting their hop count to 1 as needed
this.id = "minecraft_friendcode_" + i;
final ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
this.sendCommand(new I2PSamCommand(
"SESSION", "CREATE",
builder.put(
"STYLE", "STREAM"
).put(
"ID", this.id
).put(
"DESTINATION", this.privateKey
).put(
"inbound.length", this.zeroHop ? "0" : "1"
).put(
"outbound.length", this.zeroHop ? "0" : "1"
).put(
"inbound.allowZeroHop", "true"
).put(
"outbound.allowZeroHop", "true"
).put(
"shouldBundleReplyInfo", "true"
).put(
"i2cp.dontPublishLeaseSet", "false"
).put(
"streaming.maxWindowSize", "1024"
).build()
));
} while ("DUPLICATED_ID".equals(status = this.getCommand("SESSION", "STATUS").parameters().get("RESULT")));
if ("OK".equals(status)) {
// find our public key (if we're using persistent keys)
if (this.publicKey == null) {
this.sendCommand(new I2PSamCommand("NAMING", "LOOKUP", ImmutableMap.of("NAME", "ME")));
final var lookup = this.getCommand("NAMING", "REPLY");
if (!"OK".equals(lookup.parameters().get("RESULT"))) {
return false;
}
this.publicKey = lookup.parameters().get("VALUE");
}
return true;
}
return false;
} catch (IOException e) {
return false;
}
}
/**
* Creates a stream forwarder.
* @return A stream forwarder.
*/
public I2PSamStreamForwarder forwardStream(String port) {
return new I2PSamStreamForwarder(this.getSamBridgeAddress(), id, port);
}
/**
* Creates a stream forwarder.
* @return A stream connector.
*/
public I2PSamStreamConnector connectStream(String b32) {
return new I2PSamStreamConnector(this.getSamBridgeAddress(), id, b32);
}
/**
* Returns the session public key.
*/
public String publicKey() {
return this.publicKey;
}
/**
* Returs the session private key.
*/
public String privateKey() {
return this.privateKey;
}
/**
* Set up and enable auth.
*
* @throws IOException If an I/O error occurs.
*/
private void enableAuth() throws IOException {
// enable auth (if it hasn't been explicitly disabled by the user), for
// the user's sake
// (ugh i2p you really fucked up by not making this the default)
// btw tor does this with a filesystem path to an auth cookie (which is
// much more secure) but we digress.
// check if we're already using strong auth.
if (I2PSamAuthUtil.isStrongAuth()) {
return;
}
// attempt to delete old user - versions 1.0.0 and 1.0.1 of the mod are
// deprecated, unsupported, and should never be used.
this.sendCommand(new I2PSamCommand(
"AUTH", "REMOVE",
ImmutableMap.of("USER", "minecraft_friendcode")
));
// check if removed.
var removed = "OK".equals(
this.getCommand("AUTH", "STATUS").parameters().get("RESULT")
);
// generate secure auth pair
var auth = I2PSamAuthUtil.upgradeAuthPair();
// add new user
this.sendCommand(new I2PSamCommand(
"AUTH", "ADD",
ImmutableMap.of("USER", auth.user(), "PASSWORD", auth.password())
));
if ("OK".equals(
this.getCommand("AUTH", "STATUS").parameters().get("RESULT")
)) {
// old user existed - don't mess with auth enable.
if (removed) {
return;
}
this.sendCommand(new I2PSamCommand("AUTH", "ENABLE"));
// ignore the response on this one, just get it off the queue
this.getCommand("AUTH", "STATUS");
}
}
public NameStatus checkName(String target) {
try {
this.sendCommand(new I2PSamCommand("NAMING", "LOOKUP", ImmutableMap.of("NAME", target)));
I2PSamCommand result = this.getCommand("NAMING", "REPLY");
if (result.parameters().get("RESULT") == null) {
return NameStatus.FAILED;
}
switch (result.parameters().get("RESULT")) {
case "OK" -> {
return NameStatus.OK;
}
case "INVALID_KEY" -> {
return NameStatus.INVALID;
}
case "KEY_NOT_FOUND" -> {
return NameStatus.UNKNOWN;
}
default -> {
return NameStatus.FAILED;
}
}
} catch (IOException e) {
return NameStatus.FAILED;
}
}
public enum NameStatus {
OK,
INVALID,
UNKNOWN,
FAILED;
}
}