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 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; } }