diff --git a/pom.xml b/pom.xml index eb61671..89a5094 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.entangled entangled-api-java - 0.3.0 + 0.4.0 jar entangled-api-java diff --git a/src/main/java/org/entangled/crypto/TorV3Address.java b/src/main/java/org/entangled/crypto/TorV3Address.java index 6bc6186..e912450 100644 --- a/src/main/java/org/entangled/crypto/TorV3Address.java +++ b/src/main/java/org/entangled/crypto/TorV3Address.java @@ -17,7 +17,9 @@ *

Decoding validates the structure, the version byte, and the checksum, and * returns the embedded public key so the caller can compare it to * {@code origin.origin_pubkey} (origin binding, section 06; failure maps to - * {@code E_BIND_ORIGIN}). + * {@code E_BIND_ORIGIN}). The inverse direction, {@link #encodePublicKey}, + * derives the canonical address from an origin public key, which a publisher + * needs when building a manifest origin. */ public final class TorV3Address { @@ -68,6 +70,27 @@ public static byte[] decodePublicKey(String address) { return pubkey; } + /** + * Derive the canonical Tor v3 onion address from a 32-byte Ed25519 origin + * public key. The exact inverse of {@link #decodePublicKey}: compute + * {@code CHECKSUM = SHA3-256(".onion checksum" || PUBKEY || 0x03)[:2]}, + * base32-encode {@code PUBKEY || CHECKSUM || VERSION} in the lowercase RFC + * 4648 alphabet, and append {@code .onion}. A publisher building a manifest + * origin derives the address this way from its origin key. + */ + public static String encodePublicKey(byte[] pubkey) { + if (pubkey.length != 32) { + throw new InvalidOnionAddress("origin public key must be 32 bytes, got " + pubkey.length); + } + byte[] checksum = computeChecksum(pubkey, VERSION); + byte[] body = new byte[35]; + System.arraycopy(pubkey, 0, body, 0, 32); + body[32] = checksum[0]; + body[33] = checksum[1]; + body[34] = VERSION; + return base32Encode(body) + SUFFIX; + } + private static byte[] computeChecksum(byte[] pubkey, byte version) { byte[] input = new byte[CHECKSUM_PREFIX.length + pubkey.length + 1]; int pos = 0; @@ -110,4 +133,23 @@ private static byte[] base32Decode(String s) { } return out; } + + /** Lowercase RFC 4648 base32 encode (no padding); 35 bytes -> 56 chars. */ + private static String base32Encode(byte[] data) { + StringBuilder out = new StringBuilder(data.length * 8 / 5); + int buffer = 0; + int bits = 0; + for (byte b : data) { + buffer = (buffer << 8) | (b & 0xFF); + bits += 8; + while (bits >= 5) { + bits -= 5; + out.append(BASE32_ALPHABET.charAt((buffer >> bits) & 0x1F)); + } + } + if (bits > 0) { + out.append(BASE32_ALPHABET.charAt((buffer << (5 - bits)) & 0x1F)); + } + return out.toString(); + } } diff --git a/src/test/java/org/entangled/TorV3AddressTest.java b/src/test/java/org/entangled/TorV3AddressTest.java new file mode 100644 index 0000000..5ad655a --- /dev/null +++ b/src/test/java/org/entangled/TorV3AddressTest.java @@ -0,0 +1,46 @@ +package org.entangled; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.entangled.crypto.Base64Url; +import org.entangled.crypto.TorV3Address; +import org.junit.jupiter.api.Test; + +/** Tor v3 onion address encode/decode exercises. */ +class TorV3AddressTest { + + @Test + void encodeDecodeRoundTrip() { + byte[] pubkey = new byte[32]; + for (int i = 0; i < 32; i++) { + pubkey[i] = (byte) (i * 7 + 1); + } + String address = TorV3Address.encodePublicKey(pubkey); + assertEquals(56 + ".onion".length(), address.length()); + assertArrayEquals(pubkey, TorV3Address.decodePublicKey(address)); + } + + /** + * Canonical fixture: the corpus origin key + * {@code Gp8y4JM7Qlkn8JXkJAOW8s3MSkkQNGHGC1c7-AK6Wpo} derives to + * {@code dkptfyethnbfsj7qsxscia4w6lg4yssjca2gdrqlk457qav2lkna4xqd.onion}. + * Matching this confirms the encoder is byte-identical to the corpus + * generator and to the Rust reference. + */ + @Test + void corpusFixture() { + byte[] pubkey = Base64Url.decode("Gp8y4JM7Qlkn8JXkJAOW8s3MSkkQNGHGC1c7-AK6Wpo", 32); + assertEquals( + "dkptfyethnbfsj7qsxscia4w6lg4yssjca2gdrqlk457qav2lkna4xqd.onion", + TorV3Address.encodePublicKey(pubkey)); + } + + @Test + void rejectsWrongPubkeyLength() { + assertThrows( + TorV3Address.InvalidOnionAddress.class, + () -> TorV3Address.encodePublicKey(new byte[31])); + } +}