diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt b/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt new file mode 100644 index 0000000..7580a59 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt @@ -0,0 +1,66 @@ +package io.flamenet.e2e + +/** + * Global account identity — the address a person keeps when a relay does not. + * + * A relay's `user_id` is a row number it assigned itself; the account service's + * pseudonym is HMAC'd per product so two services cannot correlate their users. + * Neither survives a trip to a second relay. This does, because it is a public + * key the client derives and no server issues. + * + * Implements `engine/src/fnid.js`, which is normative. + * + * ⚠️ This engine can *encode* an address but cannot yet *derive* one: the + * account key comes from PBKDF2 over the passphrase under + * `flamenet/account/identity/v1`, and this module has no KDF at all — the JS + * and Swift engines each have one and Kotlin does not. Until that exists, an + * Android client can verify and display an address it is given and cannot + * produce its own. + */ +object FNID { + + /** + * Crockford base32: no padding, and no `I`, `L`, `O` or `U`, so the + * alphabet holds no pair a person can confuse reading an address aloud or + * copying it off a screen. Matches the recovery codes Files already issues + * rather than introducing a second encoding. + */ + const val ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" + + /** 32 raw bytes of an Ed25519 public key encode to 52 characters. */ + const val ENCODED_LENGTH = 52 + + fun encode(bytes: ByteArray): String { + val out = StringBuilder() + var buffer = 0 + var bits = 0 + for (b in bytes) { + buffer = (buffer shl 8) or (b.toInt() and 0xFF) + bits += 8 + while (bits >= 5) { + bits -= 5 + out.append(ALPHABET[(buffer shr bits) and 31]) + } + } + if (bits > 0) out.append(ALPHABET[(buffer shl (5 - bits)) and 31]) + return out.toString() + } + + /** + * The address for an account key, prefixed so a loose comparison cannot + * mistake some other 52-character string for one of these. + */ + fun address(accountPublicKey: ByteArray): String = "fnid:" + encode(accountPublicKey) + + /** + * What a client signs to claim an address at a relay. + * + * Binds the audience (so a claim cannot be replayed at another relay), the + * local user id (so it cannot be lifted onto another account there) and the + * key itself (so the signature cannot be transplanted). Concatenated rather + * than encoded, because three implementations must reproduce these bytes + * exactly and an encoder may respace between releases. + */ + fun claimCanonical(audience: String, userId: Long, accountKeyB64: String): ByteArray = + "fnacct-v1|$audience|$userId|$accountKeyB64".toByteArray(Charsets.UTF_8) +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt index bb87599..c11bff2 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt @@ -84,6 +84,29 @@ class PublishedVectorsTest { assertEquals(12, formatted.split(" ").size) } + @Test + fun `account identity encodes exactly as published`() { + val ai = vectors.getJSONObject("accountIdentity") + val key = java.util.Base64.getDecoder().decode(ai.getString("accountKeyB64")) + + assertEquals(ai.getString("encoded"), FNID.encode(key)) + assertEquals(ai.getString("address"), FNID.address(key)) + + val claim = ai.getJSONObject("claim") + val input = claim.getJSONObject("input") + assertEquals( + claim.getString("canonical"), + String( + FNID.claimCanonical( + audience = input.getString("audience"), + userId = input.getLong("userId"), + accountKeyB64 = input.getString("accountKeyB64"), + ), + Charsets.UTF_8, + ), + ) + } + @Test fun `canonical leaf encodes and hashes exactly as published`() { val leaf = vectors.getJSONObject("keyLog").getJSONObject("canonicalLeaf")