package net.i2p.client.naming; import java.io.BufferedWriter; import java.io.IOException; import java.io.StringWriter; import java.io.Writer; import java.util.Map; import net.i2p.crypto.DSAEngine; import net.i2p.crypto.SigType; import net.i2p.data.Base64; import net.i2p.data.DataFormatException; import net.i2p.data.DataHelper; import net.i2p.data.Destination; import net.i2p.data.Signature; import net.i2p.data.SigningPrivateKey; import net.i2p.data.SigningPublicKey; import net.i2p.util.OrderedProperties; // for testing only import java.io.File; import java.io.OutputStreamWriter; import java.util.Arrays; import net.i2p.data.Base32; import net.i2p.data.PrivateKeyFile; import net.i2p.util.RandomSource; /** * A hostname, b64 destination, and optional properties. * Includes methods to sign and verify the entry. * Used by addressbook to parse subscription data, * and by i2ptunnel to generate signed metadata. * * @since 0.9.26 */ public class HostTxtEntry { private final String name; private final String dest; private final OrderedProperties props; private boolean isValidated; private boolean isValid; public static final char KV_SEPARATOR = '='; public static final String PROPS_SEPARATOR = "#!"; public static final char PROP_SEPARATOR = '#'; public static final String PROP_ACTION = "action"; public static final String PROP_DATE = "date"; public static final String PROP_DEST = "dest"; public static final String PROP_EXPIRES = "expires"; public static final String PROP_NAME = "name"; public static final String PROP_OLDDEST = "olddest"; public static final String PROP_OLDNAME = "oldname"; public static final String PROP_OLDSIG = "oldsig"; public static final String PROP_SIG = "sig"; public static final String ACTION_ADDDEST = "adddest"; public static final String ACTION_ADDNAME = "addname"; public static final String ACTION_ADDSUBDOMAIN = "addsubdomain"; public static final String ACTION_CHANGEDEST = "changedest"; public static final String ACTION_CHANGENAME = "changename"; public static final String ACTION_REMOVE = "remove"; public static final String ACTION_REMOVEALL = "removeall"; public static final String ACTION_UPDATE = "update"; /** * Properties will be null */ public HostTxtEntry(String name, String dest) { this(name, dest, (OrderedProperties) null); } /** * @param sprops line part after the #!, non-null * @throws IllegalArgumentException on dup key in sprops and other errors */ public HostTxtEntry(String name, String dest, String sprops) throws IllegalArgumentException { this(name, dest, parseProps(sprops)); } /** * A 'remove' entry. Name and Dest will be null. * @param sprops line part after the #!, non-null * @throws IllegalArgumentException on dup key in sprops and other errors */ public HostTxtEntry(String sprops) throws IllegalArgumentException { this(null, null, parseProps(sprops)); } /** * @param props may be null */ public HostTxtEntry(String name, String dest, OrderedProperties props) { this.name = name; this.dest = dest; this.props = props; } public String getName() { return name; } public String getDest() { return dest; } public OrderedProperties getProps() { return props; } /** * @param line part after the #! * @throws IllegalArgumentException on dup key and other errors */ private static OrderedProperties parseProps(String line) throws IllegalArgumentException { line = line.trim(); OrderedProperties rv = new OrderedProperties(); String[] entries = DataHelper.split(line, "#"); for (int i = 0; i < entries.length; i++) { String kv = entries[i]; int eq = kv.indexOf('='); if (eq <= 0 || eq == kv.length() - 1) throw new IllegalArgumentException("No value: \"" + kv + '"'); String k = kv.substring(0, eq); String v = kv.substring(eq + 1); Object old = rv.setProperty(k, v); if (old != null) throw new IllegalArgumentException("Dup key: " + k); } return rv; } /** * Write as a standard line name=dest[#!k1=v1#k2=v2...] * Includes newline. */ public void write(BufferedWriter out) throws IOException { write((Writer) out); out.newLine(); } /** * Write as a standard line name=dest[#!k1=v1#k2=v2...] * Does not include newline. */ public void write(Writer out) throws IOException { if (name != null && dest != null) { out.write(name); out.write(KV_SEPARATOR); out.write(dest); } writeProps(out); } /** * Write as a "remove" line #!dest=dest#name=name#k1=v1#sig=sig...] * This works whether constructed with name and dest, or just properties. * Includes newline. * Must have been constructed with non-null properties. */ public void writeRemoveLine(BufferedWriter out) throws IOException { writeRemove(out); out.newLine(); } /** * Write as a "remove" line #!dest=dest#name=name#k1=v1#sig=sig...] * This works whether constructed with name and dest, or just properties. * Does not include newline. * Must have been constructed with non-null properties. */ public void writeRemove(Writer out) throws IOException { if (props == null) throw new IllegalStateException(); if (name != null && dest != null) { props.setProperty(PROP_NAME, name); props.setProperty(PROP_DEST, dest); } writeProps(out); if (name != null && dest != null) { props.remove(PROP_NAME); props.remove(PROP_DEST); } } /** * Write the props part (if any) only, without newline */ public void writeProps(Writer out) throws IOException { writeProps(out, false, false); } /** * Write the props part (if any) only, without newline */ private void writeProps(Writer out, boolean omitSig, boolean omitOldSig) throws IOException { if (props == null) return; boolean started = false; for (Map.Entry<Object, Object> e : props.entrySet()) { String k = (String) e.getKey(); if (omitSig && k.equals(PROP_SIG)) continue; if (omitOldSig && k.equals(PROP_OLDSIG)) continue; if (started) { out.write(PROP_SEPARATOR); } else { started = true; out.write(PROPS_SEPARATOR); } String v = (String) e.getValue(); out.write(k); out.write(KV_SEPARATOR); out.write(v); } } /** * Verify with the dest public key using the "sig" property */ public boolean hasValidSig() { if (props == null || name == null || dest == null) return false; if (!isValidated) { isValidated = true; StringWriter buf = new StringWriter(1024); String sig = props.getProperty(PROP_SIG); if (sig == null) return false; buf.append(name); buf.append(KV_SEPARATOR); buf.append(dest); try { writeProps(buf, true, false); } catch (IOException ioe) { // won't happen return false; } byte[] sdata = Base64.decode(sig); if (sdata == null) return false; Destination d; try { d = new Destination(dest); } catch (DataFormatException dfe) { return false; } SigningPublicKey spk = d.getSigningPublicKey(); SigType type = spk.getType(); if (type == null) return false; Signature s; try { s = new Signature(type, sdata); } catch (IllegalArgumentException iae) { return false; } isValid = DSAEngine.getInstance().verifySignature(s, DataHelper.getUTF8(buf.toString()), spk); } return isValid; } /** * Verify with the "olddest" property's public key using the "oldsig" property */ public boolean hasValidInnerSig() { if (props == null || name == null || dest == null) return false; boolean rv = false; // don't cache result if (true) { StringWriter buf = new StringWriter(1024); String sig = props.getProperty(PROP_OLDSIG); String olddest = props.getProperty(PROP_OLDDEST); if (sig == null || olddest == null) return false; buf.append(name); buf.append(KV_SEPARATOR); buf.append(dest); try { writeProps(buf, true, true); } catch (IOException ioe) { // won't happen return false; } byte[] sdata = Base64.decode(sig); if (sdata == null) return false; Destination d; try { d = new Destination(olddest); } catch (DataFormatException dfe) { return false; } SigningPublicKey spk = d.getSigningPublicKey(); SigType type = spk.getType(); if (type == null) return false; Signature s; try { s = new Signature(type, sdata); } catch (IllegalArgumentException iae) { return false; } rv = DSAEngine.getInstance().verifySignature(s, DataHelper.getUTF8(buf.toString()), spk); } return rv; } /** * Verify with the "dest" property's public key using the "sig" property */ public boolean hasValidRemoveSig() { if (props == null) return false; boolean rv = false; // don't cache result if (true) { StringWriter buf = new StringWriter(1024); String sig = props.getProperty(PROP_SIG); String olddest = props.getProperty(PROP_DEST); if (sig == null || olddest == null) return false; try { writeProps(buf, true, true); } catch (IOException ioe) { // won't happen return false; } byte[] sdata = Base64.decode(sig); if (sdata == null) return false; Destination d; try { d = new Destination(olddest); } catch (DataFormatException dfe) { return false; } SigningPublicKey spk = d.getSigningPublicKey(); SigType type = spk.getType(); if (type == null) return false; Signature s; try { s = new Signature(type, sdata); } catch (IllegalArgumentException iae) { return false; } rv = DSAEngine.getInstance().verifySignature(s, DataHelper.getUTF8(buf.toString()), spk); } return rv; } @Override public int hashCode() { return dest.hashCode(); } /** * Compares Destination only, not properties */ @Override public boolean equals(Object o) { if (o == this) return true; if (!(o instanceof HostTxtEntry)) return false; HostTxtEntry he = (HostTxtEntry) o; return dest.equals(he.getDest()); } /** * Sign and set the "sig" property * Must have been constructed with non-null properties. */ public void sign(SigningPrivateKey spk) { signIt(spk, PROP_SIG); } /** * Sign and set the "oldsig" property * Must have been constructed with non-null properties. */ public void signInner(SigningPrivateKey spk) { signIt(spk, PROP_OLDSIG); } /** * Sign as a "remove" line #!dest=dest#name=name#k1=v1#sig=sig...] * Must have been constructed with non-null properties. */ public void signRemove(SigningPrivateKey spk) { if (props == null) throw new IllegalStateException(); if (props.containsKey(PROP_SIG)) throw new IllegalStateException(); props.setProperty(PROP_NAME, name); props.setProperty(PROP_DEST, dest); if (!props.containsKey(PROP_DATE)) props.setProperty(PROP_DATE, Long.toString(System.currentTimeMillis() / 1000)); StringWriter buf = new StringWriter(1024); try { writeProps(buf); } catch (IOException ioe) { throw new IllegalStateException(ioe); } props.remove(PROP_NAME); props.remove(PROP_DEST); Signature s = DSAEngine.getInstance().sign(DataHelper.getUTF8(buf.toString()), spk); if (s == null) throw new IllegalArgumentException("sig failed"); props.setProperty(PROP_SIG, s.toBase64()); } /** * @param sigprop The signature property to set */ private void signIt(SigningPrivateKey spk, String sigprop) { if (props == null) throw new IllegalStateException(); if (props.containsKey(sigprop)) throw new IllegalStateException(); if (!props.containsKey(PROP_DATE)) props.setProperty(PROP_DATE, Long.toString(System.currentTimeMillis() / 1000)); StringWriter buf = new StringWriter(1024); buf.append(name); buf.append(KV_SEPARATOR); buf.append(dest); try { writeProps(buf); } catch (IOException ioe) { throw new IllegalStateException(ioe); } Signature s = DSAEngine.getInstance().sign(DataHelper.getUTF8(buf.toString()), spk); if (s == null) throw new IllegalArgumentException("sig failed"); props.setProperty(sigprop, s.toBase64()); } /** * Usage: HostTxtEntry [-i] [-x] [hostname.i2p] [key=val]... */ /**** public static void main(String[] args) throws Exception { boolean inner = false; boolean remove = false; if (args.length > 0 && args[0].equals("-i")) { inner = true; args = Arrays.copyOfRange(args, 1, args.length); } if (args.length > 0 && args[0].equals("-x")) { remove = true; args = Arrays.copyOfRange(args, 1, args.length); } String host; if (args.length > 0 && args[0].endsWith(".i2p")) { host = args[0]; args = Arrays.copyOfRange(args, 1, args.length); } else { byte[] rand = new byte[5]; RandomSource.getInstance().nextBytes(rand); host = Base32.encode(rand) + ".i2p"; } OrderedProperties props = new OrderedProperties(); for (int i = 0; i < args.length; i++) { int eq = args[i].indexOf("="); props.setProperty(args[i].substring(0, eq), args[i].substring(eq + 1)); } props.setProperty("zzzz", "zzzzzzzzzzzzzzz"); // outer File f = new File("tmp-eepPriv.dat"); PrivateKeyFile pkf = new PrivateKeyFile(f); pkf.createIfAbsent(SigType.EdDSA_SHA512_Ed25519); //f.delete(); PrivateKeyFile pkf2; if (inner) { // inner File f2 = new File("tmp-eepPriv2.dat"); pkf2 = new PrivateKeyFile(f2); pkf2.createIfAbsent(SigType.DSA_SHA1); //f2.delete(); props.setProperty(PROP_OLDDEST, pkf2.getDestination().toBase64()); } else { pkf2 = null; } HostTxtEntry he = new HostTxtEntry(host, pkf.getDestination().toBase64(), props); BufferedWriter out = new BufferedWriter(new OutputStreamWriter(System.out)); //out.write("Before signing:\n"); //he.write(out); //out.flush(); SigningPrivateKey priv = pkf.getSigningPrivKey(); StringWriter sw = new StringWriter(1024); BufferedWriter buf = new BufferedWriter(sw); if (!remove) { if (inner) { SigningPrivateKey priv2 = pkf2.getSigningPrivKey(); he.signInner(priv2); //out.write("After signing inner:\n"); //he.write(out); } he.sign(priv); //out.write("After signing:\n"); he.write(out); out.flush(); if (inner && !he.hasValidInnerSig()) throw new IllegalStateException("Inner fail 1"); if (!he.hasValidSig()) throw new IllegalStateException("Outer fail 1"); // now create 2nd, read in he.write(buf); buf.flush(); String line = sw.toString(); line = line.substring(line.indexOf(PROPS_SEPARATOR) + 2); HostTxtEntry he2 = new HostTxtEntry(host, pkf.getDestination().toBase64(), line); if (inner && !he2.hasValidInnerSig()) throw new IllegalStateException("Inner fail 2"); if (!he2.hasValidSig()) throw new IllegalStateException("Outer fail 2"); } else { // 'remove' tests (corrupts earlier sigs) he.getProps().remove(PROP_SIG); he.signRemove(priv); //out.write("Remove entry:\n"); sw = new StringWriter(1024); buf = new BufferedWriter(sw); he.writeRemoveLine(buf); buf.flush(); out.write(sw.toString()); out.flush(); String line = sw.toString().substring(2).trim(); HostTxtEntry he3 = new HostTxtEntry(line); if (!he3.hasValidRemoveSig()) throw new IllegalStateException("Remove verify fail"); } //out.write("Test passed\n"); //out.flush(); } ****/ }