Merge main: relay hotfixes and the docs pages

Conflicts were comment wording only, from the WordPress purge. Kept this
branch's prose: main's side carries the rename's artifacts ('the the
previous platform'), and the code either side registers is identical.
Verified after resolving — one api index, no wp-json index, the
federation refusal path, HomeRelays, and FN_ACCOUNTS_URL all present.
This commit is contained in:
Paul Hitt
2026-08-24 17:06:37 -04:00
2 changed files with 89 additions and 0 deletions
@@ -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)
}
@@ -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")