Merge branch 'feat/files-engine' into 'main'

The Files vault crypto in Kotlin, pinned to the browser's vectors

See merge request paulhitt/flamenet-messenger-android!50
This commit was merged in pull request #77.
This commit is contained in:
Paul Hitt
2026-09-04 20:49:52 +00:00
10 changed files with 1612 additions and 0 deletions
+22
View File
@@ -336,6 +336,8 @@ the messenger rather than staying separate apps.
| Feature | Android |
|---|:--:|
| Files engine: names, key wrapping, chunked content | ✅ pinned to the **browser's** published vectors, not to the Swift engine |
| Files engine: KDF and identity record | ✅ an identity written by the browser unlocks here |
| Local vault: add, organise, delete | ⚪ |
| Bell–LaPadula labels and clearance | 🟡 lattice, encoding and clearance in Kotlin, pinned to independently-computed vectors; the ★-property is unenforced on **both** clients — android#15 |
| Share a file into a chat or group | ⚪ |
@@ -348,6 +350,26 @@ the messenger rather than staying separate apps.
⚠️ Nothing above the engine exists on Android yet. This is the largest
remaining gap and it is a build, not a port of a screen.
🔴 **The Files crypto is held to `vectors/files-v1.json`, which the JavaScript
engine in the browser produced — a third implementation, not the Swift one.**
Checking Kotlin against Swift would only establish that the two agree, and two
implementations agreeing on the wrong answer is how somebody's files stop
opening when they change phones. The file is copied byte for byte from the iOS
repository rather than regenerated here, because a vector file regenerated by
the implementation under test proves nothing at all.
Two of those vectors were additionally recomputed from scratch in Python before
any Kotlin ran against them — `struct.pack('>hQ')` for the label encoding,
`hmac.new(...).digest()` for the name digests. The `café` case matches under NFC
and **not** under NFD, which is what makes the normalisation form pinned rather
than assumed: a platform normalising differently does not error, it just fails
to find a file somebody filed on another device.
⚠️ The tampering tests (truncation, reordering) each prove the surviving bytes
are still *authentic* under their original index and end-flag. Without that
second half, "it threw" would be all they established — and any bug at all makes
a mangled blob throw.
🔴 **The wallet primitives are now in Kotlin and pinned to the STANDARDS, not to
the Swift engine.** `Bip39`, `Base58` and `HdKey` are held to BIP-39's own
vectors, SLIP-0010's own ed25519 vectors and the published word-list SHA-256 —
@@ -0,0 +1,134 @@
package io.flamenet.e2e
/**
* File contents in the Files vault. Mirrors §7 of the published specification.
*
* 🔴 **Framed into independently authenticated chunks, and that framing is the
* security.** A single AES-GCM blob cannot be verified until all of it has been
* buffered, which is untenable for a large file on a phone — so a naive
* implementation streams it out unverified, and the person reads attacker-chosen
* bytes before the tag ever fails.
*
* Independent chunks would otherwise open three doors, each closed by what goes
* into the authenticated data:
*
* - **Truncation** — drop the tail and the file just looks shorter. Closed by
* the final-chunk flag: the last chunk says it is last, so a truncated file
* ends on a chunk that claims it is not.
* - **Reordering** — closed by the index.
* - **Splicing between files** — closed by the label, plus a per-file content
* key.
*
* ⚠️ Named `FileContent` because the messenger's own `Content` is a different
* thing entirely — a message payload. They are not interchangeable and the
* shorter name was already taken.
*/
object FileContent {
private val MAGIC = "ENCL".toByteArray(Charsets.UTF_8)
const val VERSION: Byte = 1
const val TAG_LEN = 16
const val HEADER_LEN = 17
const val DEFAULT_CHUNK_SIZE = 64 * 1024
/** Big-endian, four bytes. */
internal fun u32be(value: Int): ByteArray = byteArrayOf(
(value ushr 24).toByte(), (value ushr 16).toByte(),
(value ushr 8).toByte(), value.toByte(),
)
internal fun readU32be(data: ByteArray, at: Int): Long =
((data[at].toLong() and 0xFF) shl 24) or ((data[at + 1].toLong() and 0xFF) shl 16) or
((data[at + 2].toLong() and 0xFF) shl 8) or (data[at + 3].toLong() and 0xFF)
/**
* `baseNonce(8) || index(4)`.
*
* 🔴 Derived, not stored, and therefore never repeated within one file. A
* per-chunk random nonce would cost four extra bytes a chunk and buy
* nothing; a per-chunk *counter reset* would repeat a nonce under one key,
* which does not degrade GCM gracefully — it hands over the authentication
* subkey and with it the ability to forge.
*/
internal fun nonce(base: ByteArray, index: Int): ByteArray = base + u32be(index)
internal fun aad(label: Label, index: Int, isLast: Boolean): ByteArray =
label.bytes() + u32be(index) + byteArrayOf(if (isLast) 1 else 0)
fun encrypt(
plaintext: ByteArray,
contentKey: ByteArray,
label: Label,
chunkSize: Int = DEFAULT_CHUNK_SIZE,
): ByteArray {
if (chunkSize <= 0) throw FilesEngineException.Malformed("chunk size out of range")
val baseNonce = Random.bytes(8)
// 🔴 An empty file is still ONE chunk. Writing zero chunks would leave a
// file with no authenticated end marker at all, indistinguishable from
// one truncated to nothing — the exact attack the flag exists to stop.
val count = maxOf(1, (plaintext.size + chunkSize - 1) / chunkSize)
val out = java.io.ByteArrayOutputStream(HEADER_LEN + plaintext.size + count * TAG_LEN)
out.write(MAGIC); out.write(byteArrayOf(VERSION))
out.write(u32be(chunkSize)); out.write(baseNonce)
for (index in 0 until count) {
val start = index * chunkSize
val end = minOf(start + chunkSize, plaintext.size)
val box = Aead.seal(
key = contentKey,
nonce = nonce(baseNonce, index),
plaintext = plaintext.copyOfRange(start, end),
aad = aad(label, index, index == count - 1),
)
// The nonce is derived on both sides, so it is not carried.
out.write(box, Aead.NONCE_LEN, box.size - Aead.NONCE_LEN)
}
return out.toByteArray()
}
fun decrypt(blob: ByteArray, contentKey: ByteArray, label: Label): ByteArray {
if (blob.size < HEADER_LEN) throw FilesEngineException.Malformed("truncated header")
if (!blob.copyOfRange(0, 4).contentEquals(MAGIC)) {
throw FilesEngineException.Malformed("not an Enclave content blob")
}
if (blob[4] != VERSION) throw FilesEngineException.UnsupportedVersion(blob[4])
// ⚠️ Read as a Long. The header field is unsigned 32-bit, and a hostile
// or corrupt blob claiming 0xFFFFFFFF becomes -1 as an Int — which
// passes a `> 0` check written the obvious way and then allocates
// nonsense.
val declared = readU32be(blob, 5)
if (declared <= 0 || declared > Int.MAX_VALUE) {
throw FilesEngineException.Malformed("invalid chunk size")
}
val chunkSize = declared.toInt()
val baseNonce = blob.copyOfRange(9, HEADER_LEN)
val body = blob.copyOfRange(HEADER_LEN, blob.size)
val stored = chunkSize + TAG_LEN
val remainder = body.size % stored
// A remainder of exactly the tag length is a legitimate empty final
// chunk, not damage.
if (remainder != 0 && remainder < TAG_LEN) {
throw FilesEngineException.Malformed("malformed final chunk")
}
val count = body.size / stored + (if (remainder == 0) 0 else 1)
if (count <= 0) throw FilesEngineException.Malformed("no chunks")
val out = java.io.ByteArrayOutputStream(body.size)
for (index in 0 until count) {
val start = index * stored
val end = minOf(start + stored, body.size)
// Authentication fails here on truncation, reordering, relabelling
// or a spliced chunk from another file.
out.write(
Aead.open(
key = contentKey,
box = nonce(baseNonce, index) + blob.copyOfRange(HEADER_LEN + start, HEADER_LEN + end),
aad = aad(label, index, index == count - 1),
)
)
}
return out.toByteArray()
}
}
@@ -0,0 +1,96 @@
package io.flamenet.e2e
import java.text.Normalizer
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Filenames in the Files vault. Mirrors §6 of the published specification.
*
* A name is encrypted, so no two encryptions of one name are equal and no
* index could ever match on the ciphertext. Lookup runs on a keyed digest
* instead — keyed **per folder**, so nothing holding the rows can invert a name
* or even tell that two folders contain one of the same name.
*
* 🔴 **Normalisation is part of the digest, and therefore permanent.** "café"
* typed on a Mac and "café" typed on a phone are different byte sequences for
* the same word — one carries a combining accent, the other a precomposed
* character. Without NFC they produce different digests, and a file filed on
* one device is simply not found by the other. Changing the form later would
* invalidate every digest ever written, so it is pinned to NFC here and tested.
*
* ⚠️ **Case is significant, deliberately.** Adding case folding would be the
* same one-way door: every existing digest would stop matching.
*/
object FileNames {
const val VERSION: Byte = 1
/**
* NFC, matching CryptoKit's `precomposedStringWithCanonicalMapping`.
*
* ⚠️ NFC and not NFKC. The compatibility forms fold things that are not the
* same name — fi into fi, full-width Latin into ASCII — so two files a
* person can tell apart on screen would collide in the index.
*/
fun normalize(name: String): ByteArray =
Normalizer.normalize(name, Normalizer.Form.NFC).toByteArray(Charsets.UTF_8)
fun digest(folderKey: ByteArray, name: String): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(folderKey, "HmacSHA256"))
return mac.doFinal(normalize(name))
}
/** The name ciphertext, and the digest it is bound to. */
data class Sealed(val digest: ByteArray, val ciphertext: ByteArray) {
override fun equals(other: Any?): Boolean =
other is Sealed && digest.contentEquals(other.digest) &&
ciphertext.contentEquals(other.ciphertext)
override fun hashCode(): Int = digest.contentHashCode() * 31 + ciphertext.contentHashCode()
}
/**
* 🔴 **The label AND the digest are authenticated.** Without both, a name
* ciphertext could be lifted onto another row — which is how somebody
* learns which plaintext belongs to a file they cannot open, and how a row
* gets quietly reclassified by editing a column.
*/
fun encrypt(folderKey: ByteArray, name: String, label: Label): Sealed {
val nameDigest = digest(folderKey, name)
val box = Aead.seal(
key = folderKey,
nonce = Random.bytes(Aead.NONCE_LEN),
plaintext = normalize(name),
aad = label.bytes() + nameDigest,
)
return Sealed(nameDigest, byteArrayOf(VERSION) + box)
}
fun decrypt(folderKey: ByteArray, blob: ByteArray, digest: ByteArray, label: Label): String {
// > 13, not >= : one version byte plus a nonce and a tag is the floor,
// and a box of exactly that length holds no ciphertext at all.
if (blob.size <= 1 + Aead.NONCE_LEN) throw FilesEngineException.Malformed("name too short")
if (blob[0] != VERSION) throw FilesEngineException.UnsupportedVersion(blob[0])
val plaintext = Aead.open(
key = folderKey,
box = blob.copyOfRange(1, blob.size),
aad = label.bytes() + digest,
)
// 🔴 Strictly, and not `String(bytes, UTF_8)`. That constructor
// substitutes U+FFFD for anything invalid, so a name that is not UTF-8
// arrives on screen as a row of replacement characters instead of being
// refused — and iOS, which does refuse, would disagree about whether the
// same vault row exists at all.
return try {
Charsets.UTF_8.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
.decode(java.nio.ByteBuffer.wrap(plaintext))
.toString()
} catch (e: java.nio.charset.CharacterCodingException) {
throw FilesEngineException.Malformed("name is not valid UTF-8")
}
}
}
@@ -0,0 +1,115 @@
package io.flamenet.e2e
/**
* Key wrapping for the Files vault. Mirrors §4 and §5 of the published
* specification.
*
* 🔴 **Every wrap takes the canonical label encoding as authenticated data.**
* That single decision is what stops a row being reclassified by editing a
* column: change the stored classification and the key no longer unwraps, so a
* relabelled file fails to open rather than quietly changing sensitivity. It is
* also why [Label.bytes] is pinned to a vector — a label that encodes
* differently on two platforms produces no warning, only a decryption failure
* on the other phone.
*/
object FileWrap {
/** `version(1) || ephemeral public key(32) || nonce(12) || ciphertext || tag` */
const val ASYMMETRIC_VERSION: Byte = 1
/** `version(17) || nonce(12) || ciphertext || tag` */
const val SYMMETRIC_VERSION: Byte = 17
private val INFO = "enclave/wrap/v1".toByteArray(Charsets.UTF_8)
/**
* 🔴 **Both public keys go in the salt, and the order is fixed.** They bind
* the derived key to this exact pair, so a wrap made for one recipient
* cannot be replayed at another. Swapping the two halves is the mistake
* that reads as working — sender and recipient each derive a key, the two
* differ, and the only symptom is a file that will not open.
*/
private fun kek(
privateKey: java.security.PrivateKey,
peerRaw: ByteArray,
ephemeralPublic: ByteArray,
recipientPublic: ByteArray,
): ByteArray = Hkdf.derive(
ikm = X25519.agree(privateKey, peerRaw),
salt = ephemeralPublic + recipientPublic,
info = INFO,
length = 32,
)
fun toPublicKey(recipientRaw: ByteArray, key: ByteArray, aad: ByteArray): ByteArray {
val ephemeral = X25519.generate()
val ephemeralPublic = X25519.rawFromPublicKey(ephemeral.public)
val derived = kek(ephemeral.private, recipientRaw, ephemeralPublic, recipientRaw)
val box = Aead.seal(derived, Random.bytes(Aead.NONCE_LEN), key, aad)
return byteArrayOf(ASYMMETRIC_VERSION) + ephemeralPublic + box
}
fun fromPrivateKey(
privateKey: java.security.PrivateKey,
ownPublicRaw: ByteArray,
blob: ByteArray,
aad: ByteArray,
): ByteArray {
if (blob.size <= 1 + X25519.KEY_LEN + Aead.NONCE_LEN) {
throw FilesEngineException.Malformed("wrap too short")
}
if (blob[0] != ASYMMETRIC_VERSION) throw FilesEngineException.UnsupportedVersion(blob[0])
val ephemeralPublic = blob.copyOfRange(1, 1 + X25519.KEY_LEN)
// 🔴 The salt is rebuilt from the SAME two values the sender used —
// their ephemeral key and OUR public key — never from whichever key
// happens to be to hand. `ownPublicRaw` is passed in rather than
// recovered from the private key because the JCA round-trip for that is
// one more place to hand over the wrong 32 bytes silently.
val derived = kek(privateKey, ephemeralPublic, ephemeralPublic, ownPublicRaw)
return Aead.open(derived, blob.copyOfRange(1 + X25519.KEY_LEN, blob.size), aad)
}
fun withKey(kek: ByteArray, key: ByteArray, aad: ByteArray): ByteArray =
byteArrayOf(SYMMETRIC_VERSION) + Aead.seal(kek, Random.bytes(Aead.NONCE_LEN), key, aad)
fun openWithKey(kek: ByteArray, blob: ByteArray, aad: ByteArray): ByteArray {
if (blob.size <= 1 + Aead.NONCE_LEN) throw FilesEngineException.Malformed("wrap too short")
if (blob[0] != SYMMETRIC_VERSION) throw FilesEngineException.UnsupportedVersion(blob[0])
return Aead.open(kek, blob.copyOfRange(1, blob.size), aad)
}
}
/**
* The key hierarchy.
*
* 🔴 **A node key is wrapped to its OWN label key, never to the folder above
* it.** The tree deliberately allows a Restricted file to sit inside a
* Confidential folder, so a folder-derived key would hand every holder of that
* folder's key content above their clearance. When the rows lived in Postgres
* an RLS predicate would still have refused to return them; there is no
* database in the local vault, and nothing else would catch it.
*/
object FileKeys {
fun grantLabelKey(labelKey: ByteArray, label: Label, toRecipientRaw: ByteArray): ByteArray =
FileWrap.toPublicKey(toRecipientRaw, labelKey, label.bytes())
fun acceptLabelKey(
blob: ByteArray,
label: Label,
privateKey: java.security.PrivateKey,
ownPublicRaw: ByteArray,
): ByteArray = FileWrap.fromPrivateKey(privateKey, ownPublicRaw, blob, label.bytes())
fun wrapNodeKey(nodeKey: ByteArray, labelKey: ByteArray, label: Label): ByteArray =
FileWrap.withKey(labelKey, nodeKey, label.bytes())
fun unwrapNodeKey(blob: ByteArray, labelKey: ByteArray, label: Label): ByteArray =
FileWrap.openWithKey(labelKey, blob, label.bytes())
fun wrapContentKey(contentKey: ByteArray, nodeKey: ByteArray, label: Label): ByteArray =
FileWrap.withKey(nodeKey, contentKey, label.bytes())
fun unwrapContentKey(blob: ByteArray, nodeKey: ByteArray, label: Label): ByteArray =
FileWrap.openWithKey(nodeKey, blob, label.bytes())
}
@@ -0,0 +1,39 @@
package io.flamenet.e2e
import java.security.SecureRandom
/**
* What the Files half of the engine throws, and where its randomness comes
* from.
*
* ⚠️ **Each case carries a sentence, not a code.** The iOS engine learned this
* the hard way: an enum without `errorDescription` reaches a person as "The
* operation couldn't be completed. (error 1.)", and the actual reason — already
* written at the throw site — is discarded on the way to the screen. Kotlin
* makes the same mistake available by default, so the message is required here.
*/
sealed class FilesEngineException(message: String) : Exception(message) {
class UnsupportedVersion(val version: Byte) : FilesEngineException(
"This was written in a newer format (version ${version.toInt() and 0xFF}) " +
"than this app understands."
)
class Malformed(why: String) : FilesEngineException(why)
class NotCleared : FilesEngineException("You aren't cleared for this.")
}
/**
* Random bytes, from the system CSPRNG.
*
* 🔴 `SecureRandom()` and never `kotlin.random.Random`. The latter is a
* deterministic PRNG seeded from the clock — fine for shuffling a list, and a
* total break for a nonce or a content key. The two are one import apart and
* the wrong one compiles.
*/
object Random {
private val source = SecureRandom()
fun bytes(count: Int): ByteArray = ByteArray(count).also { source.nextBytes(it) }
}
@@ -0,0 +1,163 @@
package io.flamenet.e2e
import org.json.JSONObject
/**
* The Files identity keypair, and the record that protects it. Mirrors §3 of
* the published specification.
*
* 🔴 **The record is written by every FlameNet client and must open in all of
* them.** The browser writes it, iOS writes it, and this reads what either
* produced. That is why the awkward details below are pinned rather than
* tidied: each one is a place where two implementations could disagree and only
* find out when somebody's files will not open on their new phone.
*/
object FilesIdentity {
/**
* ⚠️ **The stored private key is PKCS#8, not the raw 32 bytes**, because
* WebCrypto exports X25519 private keys that way and CryptoKit does not.
* For X25519 the wrapper is a fixed 16-byte prefix, so the conversion is
* exact in both directions — and on the JVM it is not even a conversion:
* the JCA wants PKCS#8 anyway, which is the one place this platform has it
* easier than the other two.
*/
private val PKCS8_PREFIX = byteArrayOf(
0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2B, 0x65, 0x6E, 0x04, 0x22, 0x04, 0x20,
)
/**
* The X25519 base point, `u = 9`.
*
* 🔴 **This is how the public key is recovered from the private one.** RFC
* 7748 defines the public key as X25519(scalar, 9), and a key agreement
* against the base point computes exactly that — so the JCA gives it up
* without needing a scalar multiplication written here. The JCA otherwise
* has no way to derive a public key from an XDH private key, and the
* alternative is storing the public half alongside, which is one more thing
* that can be stored wrong and one more thing to disagree about.
*
* ⚠️ Pinned to the vector file's `identityPublicKey`, because "it returned
* 32 bytes" is not evidence that they are the right 32 bytes.
*/
private val BASE_POINT = ByteArray(32).also { it[0] = 9 }
fun rawFromPkcs8(pkcs8: ByteArray): ByteArray {
if (pkcs8.size != 48 || !pkcs8.copyOfRange(0, 16).contentEquals(PKCS8_PREFIX)) {
throw FilesEngineException.Malformed("not an X25519 PKCS#8 private key")
}
return pkcs8.copyOfRange(16, 48)
}
fun pkcs8FromRaw(raw: ByteArray): ByteArray {
if (raw.size != 32) {
throw FilesEngineException.Malformed("X25519 private key must be 32 bytes")
}
return PKCS8_PREFIX + raw
}
/** The public half, as the wire and every wrap salt carry it. */
fun publicKeyFromRaw(rawPrivate: ByteArray): ByteArray =
X25519.agree(X25519.privateKeyFromPkcs8(pkcs8FromRaw(rawPrivate)), BASE_POINT)
/** An unlocked identity: both halves, and the JCA handle for agreement. */
class Unlocked(val rawPrivate: ByteArray) {
val pkcs8: ByteArray = pkcs8FromRaw(rawPrivate)
val privateKey: java.security.PrivateKey = X25519.privateKeyFromPkcs8(pkcs8)
val publicKeyRaw: ByteArray = publicKeyFromRaw(rawPrivate)
}
/** The stored record, as written by any FlameNet client. */
data class Record(
val v: Int,
val kdf: Int,
val iterations: Int,
val context: String,
val salt: String,
val nonce: String,
val privateKey: String,
) {
fun json(): String = JSONObject()
.put("v", v).put("kdf", kdf).put("iterations", iterations)
.put("context", context).put("salt", salt).put("nonce", nonce)
.put("privateKey", privateKey)
.toString()
companion object {
fun parse(text: String): Record = try {
val o = JSONObject(text)
Record(
v = o.getInt("v"), kdf = o.getInt("kdf"),
iterations = o.getInt("iterations"), context = o.getString("context"),
salt = o.getString("salt"), nonce = o.getString("nonce"),
privateKey = o.getString("privateKey"),
)
} catch (e: org.json.JSONException) {
throw FilesEngineException.Malformed("identity record is not valid JSON")
}
}
}
/**
* Encrypt a private key for storage.
*
* 🔴 **The iteration count is written into the record, not looked up.**
* Raising the default must not strand accounts whose records were made
* under the old one, so the record has to say what actually produced its
* key. A registry lookup would make every past record undecryptable the day
* the number changed.
*
* ⚠️ The context is the authenticated data as well as the KDF separator, so
* a record made for one product will not open as another's even with the
* right passphrase.
*/
fun protect(
rawPrivate: ByteArray,
passphrase: String,
params: FilesKdf.Params = FilesKdf.Params.V1,
context: String = FilesKdf.Context.FILES_IDENTITY,
): Record {
val salt = Random.bytes(16)
val kek = FilesKdf.derive(passphrase, salt, context, params)
val nonce = Random.bytes(Aead.NONCE_LEN)
val box = Aead.seal(kek, nonce, pkcs8FromRaw(rawPrivate), context.toByteArray(Charsets.UTF_8))
return Record(
v = 1, kdf = params.id, iterations = params.iterations, context = context,
salt = Bytes.b64(salt),
nonce = Bytes.b64(nonce),
// ⚠️ Ciphertext and tag joined, WITHOUT the nonce — that is what
// WebCrypto's AES-GCM emits and what the record has always held.
// Storing the combined box here instead would produce a record no
// other client can read, and this one would read its own back
// perfectly.
privateKey = Bytes.b64(box.copyOfRange(Aead.NONCE_LEN, box.size)),
)
}
/** Unlock a record produced by this engine, by iOS, or by the browser. */
fun unlock(record: Record, passphrase: String): Unlocked {
if (record.v != 1) throw FilesEngineException.UnsupportedVersion(record.v.toByte())
val salt = decode(record.salt, "salt")
val nonce = decode(record.nonce, "nonce")
val sealed = decode(record.privateKey, "private key")
val kek = FilesKdf.derive(
passphrase, salt, record.context,
FilesKdf.Params(id = record.kdf, iterations = record.iterations),
)
val pkcs8 = try {
// The record keeps the nonce apart; [Aead] wants it in front.
Aead.open(kek, nonce + sealed, record.context.toByteArray(Charsets.UTF_8))
} catch (e: Aead.DecryptionFailed) {
throw FilesEngineException.Malformed("That passphrase didn't open this identity.")
}
return Unlocked(rawFromPkcs8(pkcs8))
}
private fun decode(text: String, what: String): ByteArray = try {
Bytes.unb64(text)
} catch (e: IllegalArgumentException) {
throw FilesEngineException.Malformed("identity $what is not valid base64")
}
}
@@ -0,0 +1,132 @@
package io.flamenet.e2e
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Key derivation for the Files vault. Mirrors §2 of the published
* specification.
*
* Two stages on purpose: one expensive stretch, then a cheap per-product split.
* That is what lets a single FlameNet credential back several products without
* any of them sharing key material — a compromise of the Messenger identity key
* says nothing about the Files one.
*
* ⚠️ Not [AccountKdf]. Same shape, different context strings and a different
* salt, and the two must not be collapsed: the whole point of the split is that
* one product's derived secret is useless in another.
*/
object FilesKdf {
data class Params(val id: Int, val iterations: Int) {
companion object {
val V1 = Params(id = 1, iterations = 650_000)
}
}
/**
* Per-product context strings.
*
* 🔴 **These are wire format.** Changing one strands every account that
* used it — the record does not open, and there is nothing to fall back to.
*/
object Context {
const val FILES_IDENTITY = "flamenet/files/identity/v1"
const val FILES_AUTH = "flamenet/files/auth/v1"
const val FILES_RECOVERY = "flamenet/files/recovery/v1"
const val MESSENGER_IDENTITY = "flamenet/messenger/identity/v1"
/** The key a vault's label keys are derived under, on this device. */
const val LOCAL_LABEL = "flamenet/files/local-label/v1"
}
/**
* PBKDF2-HMAC-SHA256.
*
* 🔴 **Hand-written on top of [Mac] rather than through
* `SecretKeyFactory("PBKDF2WithHmacSHA256")`, for the same reason
* [Bip39.pbkdf2HmacSha512] is.** The JCA path takes a `CharArray` and lets
* the provider decide how to encode it. The reference implementation here
* is WebCrypto, which takes *bytes* — and CommonCrypto on iOS takes bytes
* too. Handing the JCA characters and hoping puts a provider between us and
* a byte sequence that three implementations already agree on.
*
* ⚠️ The failure it prevents is silent: a passphrase containing anything
* outside ASCII would stretch differently under a different provider, and
* the identity record simply would not open — on that phone, for that
* person, with no way to tell why.
*/
internal fun pbkdf2HmacSha256(
password: ByteArray,
salt: ByteArray,
iterations: Int,
length: Int,
): ByteArray {
require(iterations > 0) { "PBKDF2 needs at least one iteration" }
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(password, "HmacSHA256"))
val blockSize = mac.macLength
val out = ByteArray(length)
var written = 0
var block = 1
while (written < length) {
mac.reset()
mac.update(salt)
mac.update(
byteArrayOf(
(block ushr 24).toByte(), (block ushr 16).toByte(),
(block ushr 8).toByte(), block.toByte(),
)
)
var u = mac.doFinal()
val accumulated = u.copyOf()
for (round in 2..iterations) {
mac.reset()
u = mac.doFinal(u)
for (i in accumulated.indices) {
accumulated[i] = (accumulated[i].toInt() xor u[i].toInt()).toByte()
}
}
val take = minOf(blockSize, length - written)
System.arraycopy(accumulated, 0, out, written, take)
written += take
block++
}
return out
}
/** Stage one. Shared across products, never used directly. */
fun master(passphrase: String, salt: ByteArray, params: Params = Params.V1): ByteArray =
pbkdf2HmacSha256(passphrase.toByteArray(Charsets.UTF_8), salt, params.iterations, 32)
/**
* Stage two: raw bytes for one context.
*
* ⚠️ The HKDF salt is empty, deliberately. The per-account salt has already
* done its work in the stretch above, and the context string is what
* separates one derived secret from another. RFC 5869 defines an empty salt
* as HashLen zeros, which is what [Hkdf] does — not "skip the extract".
*/
fun derive(
passphrase: String,
salt: ByteArray,
context: String,
params: Params = Params.V1,
length: Int = 32,
): ByteArray = Hkdf.derive(
ikm = master(passphrase, salt, params),
salt = ByteArray(0),
info = context.toByteArray(Charsets.UTF_8),
length = length,
)
/**
* The secret sent to the account service.
*
* 🔴 Its own context, so a compromised account service holds nothing that
* decrypts a file. It has the proof you knew the passphrase and not one
* byte of anything that opens a vault.
*/
fun authSecret(passphrase: String, salt: ByteArray, params: Params = Params.V1): ByteArray =
derive(passphrase, salt, Context.FILES_AUTH, params)
}
@@ -0,0 +1,417 @@
package io.flamenet.e2e
import java.util.Base64
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* Interop for the Files vault, against the published reference vectors.
*
* 🔴 **These vectors were produced by the JavaScript engine that runs in the
* browser — a third implementation, not the Swift one.** Nothing here checks
* that this code is self-consistent. It checks that a vault written by the
* software people already use opens on an Android phone, which is the only
* question that matters: a framing, authenticated-data or key-hierarchy mistake
* fails here, and nowhere else until somebody's files will not open.
*
* The file is `vectors/files-v1.json`, copied byte for byte from
* `Packages/FlamenetFiles/Tests/FilesEngineTests/vectors/v1.json` in the iOS
* repository. Copied rather than regenerated, deliberately: a vector file
* regenerated by the implementation under test proves only that it agrees with
* itself.
*
* ⚠️ Two of these vectors were additionally recomputed from scratch in Python —
* `struct.pack('>hQ')` for the label encoding, `hmac.new(...).digest()` for the
* name digests — before any Kotlin was run against them. The `café` case
* matches under NFC and NOT under NFD, which is what makes the normalisation
* form pinned rather than assumed.
*/
class FilesInteropTest {
private val vectors: JSONObject = JSONObject(
requireNotNull(javaClass.getResourceAsStream("/vectors/files-v1.json")) {
"the interop vectors are missing from the test resources"
}.reader().readText()
)
private val cases: JSONObject get() = vectors.getJSONObject("cases")
private val hierarchy: JSONObject get() = cases.getJSONObject("hierarchy")
private fun b64(s: String): ByteArray = Base64.getDecoder().decode(s)
private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }
private fun labelOf(o: JSONObject): Label {
val bits = o.getJSONArray("compartments")
return Label(
classification = o.getInt("classification").toShort(),
compartments = (0 until bits.length()).map { bits.getInt(it) }.toSet(),
)
}
private val hierarchyLabel: Label get() = labelOf(hierarchy.getJSONObject("label"))
/** The passphrase the vector identity was written under. */
private val passphrase: String get() = cases.getJSONObject("kdf").getString("passphrase")
private fun identityRecord(): FilesIdentity.Record =
FilesIdentity.Record.parse(hierarchy.getJSONObject("identityRecord").toString())
// ---- §1 The label encoding -------------------------------------------
@Test
fun `the label encoding matches the reference implementation`() {
val list = cases.getJSONArray("labelBytes")
assertTrue(list.length() >= 4, "the vector file lost its label cases")
for (i in 0 until list.length()) {
val entry = list.getJSONObject(i)
val label = labelOf(entry)
assertEquals(
hex(b64(entry.getString("expected"))),
hex(label.bytes()),
"classification ${label.classification} compartments ${label.compartments}",
)
}
}
// ---- §2 Key derivation -----------------------------------------------
/**
* 🔴 The whole PBKDF2-then-HKDF chain, against a value the browser
* published. A wrong iteration count, a wrong salt position, a wrong
* context string or a password encoded as UTF-16 all land here.
*/
@Test
fun `the auth secret derives to the reference value`() {
val kdf = cases.getJSONObject("kdf")
val derived = FilesKdf.authSecret(
passphrase = kdf.getString("passphrase"),
salt = b64(kdf.getString("salt")),
params = FilesKdf.Params(id = 1, iterations = kdf.getInt("iterations")),
)
assertEquals(hex(b64(kdf.getString("authSecret"))), hex(derived))
}
// ---- §3 The identity record ------------------------------------------
/**
* 🔴 **An identity written by the browser, opened here.** This is the one
* that decides whether somebody can move to an Android phone at all.
*/
@Test
fun `an identity record written by the browser unlocks`() {
val opened = FilesIdentity.unlock(identityRecord(), passphrase)
assertEquals(
hex(b64(hierarchy.getString("identityPublicKey"))),
hex(opened.publicKeyRaw),
"the public half does not match what the browser published for this record",
)
}
/**
* ⚠️ The public key above is recovered by agreeing against the X25519 base
* point, because the JCA will not derive one from an XDH private key. That
* it returns 32 bytes proves nothing; that they are *these* 32 bytes is the
* proof, and this states it separately so the reason survives a refactor.
*/
@Test
fun `the base-point trick recovers the right public key`() {
val raw = FilesIdentity.rawFromPkcs8(FilesIdentity.unlock(identityRecord(), passphrase).pkcs8)
assertEquals(
hex(b64(hierarchy.getString("identityPublicKey"))),
hex(FilesIdentity.publicKeyFromRaw(raw)),
)
}
@Test
fun `the wrong passphrase does not open the record`() {
assertFailsWith<FilesEngineException.Malformed> {
FilesIdentity.unlock(identityRecord(), "not the passphrase")
}
}
/**
* 🔴 The context is authenticated data as well as a KDF separator, so a
* record made for Files must not open as a Messenger one even with the
* right passphrase. Without this, one product's compromise would be all of
* them.
*/
@Test
fun `a record does not open under another product's context`() {
val record = identityRecord()
assertFailsWith<FilesEngineException.Malformed> {
FilesIdentity.unlock(
record.copy(context = FilesKdf.Context.MESSENGER_IDENTITY),
passphrase,
)
}
}
// ---- §4 The asymmetric grant -----------------------------------------
/**
* 🔴 **A label key granted by the browser, accepted here.** This is how an
* area is shared between two people, and the salt is both public keys in a
* fixed order — the mistake that reads as working is swapping them, which
* produces a key that is simply different, with no error until the file
* fails to open.
*/
@Test
fun `a label key granted by the browser is accepted`() {
val opened = FilesIdentity.unlock(identityRecord(), passphrase)
val labelKey = FileKeys.acceptLabelKey(
blob = b64(hierarchy.getString("labelKeyGrant")),
label = hierarchyLabel,
privateKey = opened.privateKey,
ownPublicRaw = opened.publicKeyRaw,
)
assertEquals(hex(b64(hierarchy.getString("labelKey"))), hex(labelKey))
}
/** A grant is bound to its label: relabel the row and it stops opening. */
@Test
fun `a grant does not open under a different label`() {
val opened = FilesIdentity.unlock(identityRecord(), passphrase)
assertFailsWith<Aead.DecryptionFailed> {
FileKeys.acceptLabelKey(
blob = b64(hierarchy.getString("labelKeyGrant")),
label = Label(hierarchyLabel.classification),
privateKey = opened.privateKey,
ownPublicRaw = opened.publicKeyRaw,
)
}
}
// ---- §5 The key hierarchy --------------------------------------------
@Test
fun `the key hierarchy unwraps as the browser wrapped it`() {
val labelKey = b64(hierarchy.getString("labelKey"))
val nodeKey = FileKeys.unwrapNodeKey(
b64(hierarchy.getString("wrappedNodeKey")), labelKey, hierarchyLabel)
assertEquals(hex(b64(hierarchy.getString("nodeKey"))), hex(nodeKey))
val contentKey = FileKeys.unwrapContentKey(
b64(hierarchy.getString("wrappedContentKey")), nodeKey, hierarchyLabel)
assertEquals(hex(b64(hierarchy.getString("contentKey"))), hex(contentKey))
}
/**
* 🔴 **Reclassification must break the key, not succeed quietly.** Edit the
* stored classification and the node key no longer unwraps. Without the
* label in the authenticated data, anything that could write to the index
* could move a file down a level and it would still open.
*/
@Test
fun `a relabelled row does not unwrap`() {
assertFailsWith<Aead.DecryptionFailed> {
FileKeys.unwrapNodeKey(
b64(hierarchy.getString("wrappedNodeKey")),
b64(hierarchy.getString("labelKey")),
Label(hierarchyLabel.classification, hierarchyLabel.compartments + setOf(7)),
)
}
}
// ---- §6 Names ---------------------------------------------------------
/**
* 🔴 The `café` case is the one that matters: it matches under NFC and not
* under NFD, so a platform normalising differently fails here rather than
* quietly failing to find a file somebody filed on another device.
*/
@Test
fun `name digests match the reference implementation`() {
val list = cases.getJSONArray("nameDigest")
assertTrue(list.length() >= 3, "the vector file lost its digest cases")
var sawNonAscii = false
for (i in 0 until list.length()) {
val entry = list.getJSONObject(i)
val name = entry.getString("name")
if (name.any { it.code > 127 }) sawNonAscii = true
assertEquals(
hex(b64(entry.getString("expected"))),
hex(FileNames.digest(b64(entry.getString("folderKey")), name)),
name,
)
}
assertTrue(sawNonAscii, "the normalisation case has gone from the vectors")
}
/**
* The composed and decomposed spellings of one name are one file.
*
* 🔴 Written as escapes, not as two literals that look alike. Two source
* literals both typed as "cafe-with-an-accent" are the SAME bytes in this
* file, so the test would pass with normalisation removed entirely - which
* is precisely the failure it exists to catch.
*/
@Test
fun `a decomposed name digests the same as a composed one`() {
val composed = "caf\u00e9" // e-acute, one code point
val decomposed = "cafe\u0301" // e, then a combining acute
assertTrue(
composed.toByteArray().size != decomposed.toByteArray().size,
"these two spellings are meant to differ before normalisation",
)
val key = b64(hierarchy.getString("nodeKey"))
assertEquals(
hex(FileNames.digest(key, composed)),
hex(FileNames.digest(key, decomposed)),
"NFC is not applied - a name filed on one device would not be found on another",
)
// And it is the COMPOSED spelling both land on, which is what the
// reference vector pins.
assertEquals(
hex(b64(cases.getJSONArray("nameDigest").getJSONObject(1).getString("expected"))),
hex(FileNames.digest(key, decomposed)),
)
}
@Test
fun `a filename written by the browser decrypts`() {
val name = FileNames.decrypt(
folderKey = b64(hierarchy.getString("nodeKey")),
blob = b64(hierarchy.getString("nameCiphertext")),
digest = b64(hierarchy.getString("nameDigest")),
label = hierarchyLabel,
)
assertEquals(hierarchy.getString("expectedName"), name)
}
/**
* ⚠️ The digest is authenticated alongside the label, so a name ciphertext
* cannot be lifted onto another row — which is how somebody would learn
* which plaintext belongs to a file they cannot open.
*/
@Test
fun `a name does not decrypt against another row's digest`() {
assertFailsWith<Aead.DecryptionFailed> {
FileNames.decrypt(
folderKey = b64(hierarchy.getString("nodeKey")),
blob = b64(hierarchy.getString("nameCiphertext")),
digest = ByteArray(32),
label = hierarchyLabel,
)
}
}
// ---- §7 Content -------------------------------------------------------
/**
* 🔴 **A file written by the browser, in more than one chunk.** The vector
* uses a 16-byte chunk size precisely so the multi-chunk path is the one
* being tested — a single-chunk file would pass with the index and the
* final-chunk flag both wrong.
*/
@Test
fun `content written by the browser decrypts, across chunks`() {
val blob = b64(hierarchy.getString("content"))
val chunkSize = hierarchy.getInt("contentChunkSize")
assertEquals(chunkSize.toLong(), FileContent.readU32be(blob, 5),
"the blob's own header disagrees with the vector's chunk size")
val plaintext = b64(hierarchy.getString("expectedContent"))
assertTrue(plaintext.size > chunkSize, "this vector is not exercising the multi-chunk path")
assertEquals(
hex(plaintext),
hex(FileContent.decrypt(blob, b64(hierarchy.getString("contentKey")), hierarchyLabel)),
)
}
/** The label is authenticated in every chunk, so a relabelled file fails. */
@Test
fun `content does not open under a different label`() {
assertFailsWith<Aead.DecryptionFailed> {
FileContent.decrypt(
b64(hierarchy.getString("content")),
b64(hierarchy.getString("contentKey")),
Label(hierarchyLabel.classification),
)
}
}
/**
* 🔴 **Truncation, on a real blob.** Drop the final chunk and the file
* would simply look shorter - except that the chunk before it says it is
* not last.
*
* ⚠️ **The second half of this test is the important half.** Rejecting a
* truncated file proves nothing on its own: a corrupted length, a wrong
* offset, any old bug would also reject it. What makes the final-chunk flag
* the thing doing the work is that the SAME bytes still open perfectly
* under their original `isLast = false` - so the ciphertext is authentic,
* intact and in the right place, and the only thing refusing it is the
* claim about where the file ends.
*/
@Test
fun `a truncated file is refused rather than silently shortened`() {
val blob = b64(hierarchy.getString("content"))
val key = b64(hierarchy.getString("contentKey"))
val stored = hierarchy.getInt("contentChunkSize") + FileContent.TAG_LEN
val bodyLength = blob.size - FileContent.HEADER_LEN
val truncated = blob.copyOfRange(0, blob.size - bodyLength % stored)
assertTrue(truncated.size < blob.size, "the vector has only one chunk to drop")
assertFailsWith<Aead.DecryptionFailed> { FileContent.decrypt(truncated, key, hierarchyLabel) }
// The same surviving bytes, opened as what they actually are: chunk 0
// of a longer file. If this fails, the test above proved nothing.
val baseNonce = blob.copyOfRange(9, FileContent.HEADER_LEN)
val chunkZero = truncated.copyOfRange(FileContent.HEADER_LEN, truncated.size)
val plaintext = Aead.open(
key = key,
box = FileContent.nonce(baseNonce, 0) + chunkZero,
aad = FileContent.aad(hierarchyLabel, index = 0, isLast = false),
)
assertEquals(
hex(b64(hierarchy.getString("expectedContent")).copyOfRange(0, plaintext.size)),
hex(plaintext),
"the surviving bytes are not intact, so the refusal above was not about truncation",
)
}
/**
* 🔴 **Reordering.** Swap two chunks and every byte is still authentic -
* for a different index.
*
* ⚠️ Same discipline as truncation: the moved chunk is shown to open under
* the index it was written at and to fail under the one it was moved to.
* Otherwise "it threw" is all this would establish.
*/
@Test
fun `swapped chunks are refused`() {
val blob = b64(hierarchy.getString("content"))
val key = b64(hierarchy.getString("contentKey"))
val stored = hierarchy.getInt("contentChunkSize") + FileContent.TAG_LEN
val body = blob.copyOfRange(FileContent.HEADER_LEN, blob.size)
assertTrue(body.size > stored, "need at least two chunks to swap")
val header = blob.copyOfRange(0, FileContent.HEADER_LEN)
val first = body.copyOfRange(0, stored)
val last = body.copyOfRange(stored, body.size)
assertFailsWith<Aead.DecryptionFailed> {
FileContent.decrypt(header + last + first, key, hierarchyLabel)
}
// The moved chunk is intact: it opens at the index it was written at.
val baseNonce = blob.copyOfRange(9, FileContent.HEADER_LEN)
val opened = Aead.open(
key = key,
box = FileContent.nonce(baseNonce, 1) + last,
aad = FileContent.aad(hierarchyLabel, index = 1, isLast = true),
)
assertTrue(opened.isNotEmpty(), "chunk 1 should hold the tail of the file")
// ...and does not open at the index it was moved to.
assertFailsWith<Aead.DecryptionFailed> {
Aead.open(
key = key,
box = FileContent.nonce(baseNonce, 0) + last,
aad = FileContent.aad(hierarchyLabel, index = 0, isLast = true),
)
}
}
}
@@ -0,0 +1,358 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* The Files crypto, on the cases the reference vectors do not reach.
*
* The vectors in `FilesInteropTest` pin agreement with the browser, which is
* the question that matters most — but a vector file only covers the shapes
* somebody thought to write down. The boundaries below are where an
* implementation that matches every vector still breaks: an empty file, a file
* exactly one chunk long, a chunk size that overflows an `Int`.
*/
class FilesVaultCryptoTest {
private val label = Label(3, setOf(0, 1))
private val key = ByteArray(32) { (it * 7 + 1).toByte() }
private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }
// ---- Content boundaries ----------------------------------------------
/**
* 🔴 **An empty file is one chunk, not zero.** Zero chunks would leave a
* file with no authenticated end marker at all — indistinguishable from one
* truncated to nothing, which is precisely the attack the flag exists to
* stop. The round trip alone would pass either way, so the chunk count is
* asserted directly.
*/
@Test
fun `an empty file still carries an authenticated end marker`() {
val blob = FileContent.encrypt(ByteArray(0), key, label, chunkSize = 16)
assertEquals(
FileContent.HEADER_LEN + FileContent.TAG_LEN, blob.size,
"an empty file should be a header and exactly one empty, tagged chunk",
)
assertEquals(0, FileContent.decrypt(blob, key, label).size)
}
/**
* ⚠️ The off-by-one that a round trip hides: a file whose length is an
* exact multiple of the chunk size. Written as N chunks, it must not be
* read back as N+1 — nor as N-1 with the tail treated as padding.
*/
@Test
fun `a file exactly filling its chunks round-trips`() {
for (chunks in 1..3) {
val plaintext = ByteArray(16 * chunks) { (it % 251).toByte() }
val blob = FileContent.encrypt(plaintext, key, label, chunkSize = 16)
assertEquals(
FileContent.HEADER_LEN + chunks * (16 + FileContent.TAG_LEN), blob.size,
"$chunks-chunk file has the wrong framed length",
)
assertEquals(hex(plaintext), hex(FileContent.decrypt(blob, key, label)))
}
}
@Test
fun `a file one byte over a chunk boundary round-trips`() {
val plaintext = ByteArray(17) { it.toByte() }
assertEquals(hex(plaintext),
hex(FileContent.decrypt(FileContent.encrypt(plaintext, key, label, 16), key, label)))
}
/** Larger than one default chunk, so the real path is exercised too. */
@Test
fun `a file larger than the default chunk size round-trips`() {
val plaintext = ByteArray(FileContent.DEFAULT_CHUNK_SIZE + 1_000) { (it % 256).toByte() }
assertEquals(hex(plaintext),
hex(FileContent.decrypt(FileContent.encrypt(plaintext, key, label), key, label)))
}
/**
* 🔴 **A hostile chunk size in the header.** `0xFFFFFFFF` read as a signed
* `Int` is -1, which passes a `> 0` check written the obvious way and then
* allocates nonsense. Read as a `Long`, it is refused.
*/
@Test
fun `a chunk size that overflows an Int is refused`() {
val blob = FileContent.encrypt(byteArrayOf(1, 2, 3), key, label, chunkSize = 16)
for (bytes in listOf(
byteArrayOf(-1, -1, -1, -1), // 0xFFFFFFFF
byteArrayOf(-128, 0, 0, 0), // 0x80000000
byteArrayOf(0, 0, 0, 0), // zero
)) {
val hostile = blob.copyOf()
bytes.copyInto(hostile, 5)
assertFailsWith<FilesEngineException.Malformed>("chunk size ${hex(bytes)} was accepted") {
FileContent.decrypt(hostile, key, label)
}
}
}
@Test
fun `a blob that is not ours is refused by its magic, not by a tag failure`() {
val blob = FileContent.encrypt(byteArrayOf(1), key, label)
val foreign = blob.copyOf().also { "XXXX".toByteArray().copyInto(it, 0) }
assertFailsWith<FilesEngineException.Malformed> { FileContent.decrypt(foreign, key, label) }
}
@Test
fun `a newer format version is named, not treated as damage`() {
val blob = FileContent.encrypt(byteArrayOf(1), key, label)
val newer = blob.copyOf().also { it[4] = 9 }
val thrown = assertFailsWith<FilesEngineException.UnsupportedVersion> {
FileContent.decrypt(newer, key, label)
}
assertTrue(
thrown.message!!.contains("9"),
"the version somebody needs in order to act on this was thrown away",
)
}
/**
* ⚠️ Every file gets a fresh base nonce. Reusing one across two files under
* one content key would repeat a nonce, and GCM does not degrade there — it
* hands over the authentication subkey.
*/
@Test
fun `two encryptions of the same bytes use different nonces`() {
val a = FileContent.encrypt(byteArrayOf(1, 2, 3), key, label)
val b = FileContent.encrypt(byteArrayOf(1, 2, 3), key, label)
assertTrue(
!a.copyOfRange(9, FileContent.HEADER_LEN).contentEquals(
b.copyOfRange(9, FileContent.HEADER_LEN)),
"the base nonce is being reused across files",
)
}
// ---- Names -----------------------------------------------------------
@Test
fun `a name round-trips through encryption`() {
val sealed = FileNames.encrypt(key, "Q3 Forecast.txt", label)
assertEquals("Q3 Forecast.txt",
FileNames.decrypt(key, sealed.ciphertext, sealed.digest, label))
}
/**
* 🔴 Two encryptions of one name differ, and that is required, not
* incidental: identical ciphertexts would let anything holding the rows see
* that two files share a name.
*/
@Test
fun `two encryptions of one name are not equal, but both open`() {
val a = FileNames.encrypt(key, "Payroll", label)
val b = FileNames.encrypt(key, "Payroll", label)
assertTrue(!a.ciphertext.contentEquals(b.ciphertext), "name encryption is deterministic")
// ...while the digest, which is what lookup runs on, IS the same.
assertEquals(hex(a.digest), hex(b.digest))
assertEquals("Payroll", FileNames.decrypt(key, b.ciphertext, b.digest, label))
}
/** Case is significant: folding it later would invalidate every digest. */
@Test
fun `case is significant in the digest`() {
assertTrue(
!FileNames.digest(key, "Payroll").contentEquals(FileNames.digest(key, "payroll")),
"names are being case-folded, which is a one-way door",
)
}
/**
* ⚠️ Per-folder keying is what stops anything holding the rows noticing
* that two folders contain a file of the same name.
*/
@Test
fun `the same name under two folder keys digests differently`() {
val other = ByteArray(32) { (it * 13 + 5).toByte() }
assertTrue(!FileNames.digest(key, "Payroll").contentEquals(FileNames.digest(other, "Payroll")))
}
@Test
fun `a name does not open under a different label`() {
val sealed = FileNames.encrypt(key, "Payroll", label)
assertFailsWith<Aead.DecryptionFailed> {
FileNames.decrypt(key, sealed.ciphertext, sealed.digest, Label(label.classification))
}
}
@Test
fun `a truncated name blob is refused before it reaches the cipher`() {
assertFailsWith<FilesEngineException.Malformed> {
FileNames.decrypt(key, byteArrayOf(FileNames.VERSION) + ByteArray(12), ByteArray(32), label)
}
}
// ---- Wrapping ---------------------------------------------------------
@Test
fun `a symmetric wrap round-trips and is bound to its label`() {
val wrapped = FileWrap.withKey(key, ByteArray(32) { 9 }, label.bytes())
assertEquals(FileWrap.SYMMETRIC_VERSION, wrapped[0])
assertEquals(hex(ByteArray(32) { 9 }), hex(FileWrap.openWithKey(key, wrapped, label.bytes())))
assertFailsWith<Aead.DecryptionFailed> {
FileWrap.openWithKey(key, wrapped, Label(label.classification).bytes())
}
}
/**
* 🔴 The two wrap forms carry different version bytes, and neither will
* open the other's. Without that, a symmetric blob handed to the asymmetric
* opener would be parsed as a public key plus a box and fail somewhere far
* less obvious.
*/
@Test
fun `the two wrap forms do not open each other`() {
val symmetric = FileWrap.withKey(key, ByteArray(32), label.bytes())
val pair = X25519.generate()
val recipient = X25519.rawFromPublicKey(pair.public)
val asymmetric = FileWrap.toPublicKey(recipient, ByteArray(32), label.bytes())
assertEquals(FileWrap.ASYMMETRIC_VERSION, asymmetric[0])
assertFailsWith<FilesEngineException.UnsupportedVersion> {
FileWrap.openWithKey(key, asymmetric, label.bytes())
}
assertFailsWith<FilesEngineException.UnsupportedVersion> {
FileWrap.fromPrivateKey(pair.private, recipient, symmetric, label.bytes())
}
}
@Test
fun `an asymmetric grant round-trips to its recipient`() {
val pair = X25519.generate()
val recipient = X25519.rawFromPublicKey(pair.public)
val labelKey = ByteArray(32) { (it + 3).toByte() }
val grant = FileKeys.grantLabelKey(labelKey, label, recipient)
assertEquals(
hex(labelKey),
hex(FileKeys.acceptLabelKey(grant, label, pair.private, recipient)),
)
}
/**
* 🔴 **A grant made for one person does not open for another**, even though
* the ciphertext is well-formed and the label is right. Both public keys go
* into the salt for exactly this.
*/
@Test
fun `a grant addressed to somebody else does not open`() {
val intended = X25519.generate()
val other = X25519.generate()
val grant = FileKeys.grantLabelKey(
ByteArray(32), label, X25519.rawFromPublicKey(intended.public))
assertFailsWith<Aead.DecryptionFailed> {
FileKeys.acceptLabelKey(
grant, label, other.private, X25519.rawFromPublicKey(other.public))
}
}
// ---- The identity record ---------------------------------------------
/** A fast parameter set: this asserts the format, not the work factor. */
private val fast = FilesKdf.Params(id = 1, iterations = 1_000)
@Test
fun `an identity record round-trips`() {
val raw = ByteArray(32) { (it * 5 + 2).toByte() }
val record = FilesIdentity.protect(raw, "correct horse", fast)
assertEquals(hex(raw), hex(FilesIdentity.unlock(record, "correct horse").rawPrivate))
}
@Test
fun `a record survives a round trip through its JSON form`() {
val raw = ByteArray(32) { (it * 5 + 2).toByte() }
val record = FilesIdentity.protect(raw, "correct horse", fast)
val reparsed = FilesIdentity.Record.parse(record.json())
assertEquals(hex(raw), hex(FilesIdentity.unlock(reparsed, "correct horse").rawPrivate))
}
/**
* 🔴 The record holds `ciphertext || tag` WITHOUT the nonce, which is what
* WebCrypto emits and what every other client expects. Storing the combined
* box would produce a record this engine reads back perfectly and no other
* client can open at all — so the length is asserted rather than trusted.
*/
@Test
fun `the stored private key is the box without its nonce`() {
val record = FilesIdentity.protect(ByteArray(32), "x", fast)
assertEquals(
48 + Aead.TAG_LEN,
Bytes.unb64(record.privateKey).size,
"the nonce is being stored twice, or not at all",
)
assertEquals(Aead.NONCE_LEN, Bytes.unb64(record.nonce).size)
}
@Test
fun `the wrong passphrase is refused with a sentence`() {
val record = FilesIdentity.protect(ByteArray(32), "one", fast)
val thrown = assertFailsWith<FilesEngineException.Malformed> {
FilesIdentity.unlock(record, "two")
}
assertTrue(
thrown.message!!.length > 20 && thrown.message!!.contains("passphrase"),
"a person reading this cannot tell what went wrong: ${thrown.message}",
)
}
/**
* ⚠️ PKCS#8 in, raw 32 out, and a wrapper that is not ours is refused
* rather than silently sliced. A 48-byte value whose prefix belongs to some
* other curve would otherwise yield 32 bytes of something else entirely.
*/
@Test
fun `a foreign PKCS8 wrapper is refused`() {
val good = FilesIdentity.pkcs8FromRaw(ByteArray(32) { 4 })
assertEquals(48, good.size)
assertEquals(hex(ByteArray(32) { 4 }), hex(FilesIdentity.rawFromPkcs8(good)))
assertFailsWith<FilesEngineException.Malformed> {
FilesIdentity.rawFromPkcs8(good.copyOf().also { it[9] = 0x2C }) // a different OID
}
assertFailsWith<FilesEngineException.Malformed> {
FilesIdentity.rawFromPkcs8(good.copyOfRange(0, 47))
}
}
// ---- Derivation -------------------------------------------------------
/**
* 🔴 One passphrase, one salt, two contexts, two unrelated secrets. This is
* the whole reason a Files compromise is not a Messenger compromise.
*/
@Test
fun `two contexts derive unrelated secrets from one passphrase`() {
val salt = ByteArray(16) { 7 }
val files = FilesKdf.derive("correct horse", salt, FilesKdf.Context.FILES_IDENTITY, fast)
val auth = FilesKdf.derive("correct horse", salt, FilesKdf.Context.FILES_AUTH, fast)
val messenger = FilesKdf.derive("correct horse", salt, FilesKdf.Context.MESSENGER_IDENTITY, fast)
assertEquals(3, setOf(hex(files), hex(auth), hex(messenger)).size)
}
/**
* ⚠️ The passphrase reaches PBKDF2 as UTF-8 bytes, and this is the case
* that would differ if a provider were deciding: an astral character is one
* code point, two Kotlin chars, and four UTF-8 bytes.
*/
@Test
fun `a passphrase outside the basic plane derives from its UTF-8 bytes`() {
val salt = ByteArray(16) { 7 }
val emoji = "correct horse 🐎"
val direct = Hkdf.derive(
ikm = FilesKdf.pbkdf2HmacSha256(
emoji.toByteArray(Charsets.UTF_8), salt, fast.iterations, 32),
salt = ByteArray(0),
info = FilesKdf.Context.FILES_IDENTITY.toByteArray(Charsets.UTF_8),
length = 32,
)
assertEquals(
hex(direct),
hex(FilesKdf.derive(emoji, salt, FilesKdf.Context.FILES_IDENTITY, fast)),
)
}
}
@@ -0,0 +1,136 @@
{
"version": 1,
"note": "Decrypt these. See docs/SPEC.md.",
"cases": {
"labelBytes": [
{
"classification": 3,
"compartments": [
0
],
"expected": "AAMAAAAAAAAAAQ=="
},
{
"classification": 1,
"compartments": [],
"expected": "AAEAAAAAAAAAAA=="
},
{
"classification": 0,
"compartments": [
63
],
"expected": "AACAAAAAAAAAAA=="
},
{
"classification": 2,
"compartments": [
0,
1,
63
],
"expected": "AAKAAAAAAAAAAw=="
}
],
"kdf": {
"passphrase": "correct horse battery staple",
"salt": "BwcHBwcHBwcHBwcHBwcHBw==",
"iterations": 1000,
"contexts": {
"filesIdentity": "flamenet/files/identity/v1",
"filesAuth": "flamenet/files/auth/v1",
"messengerIdentity": "flamenet/messenger/identity/v1"
},
"authSecret": "67XBl7YK3eWLxlwCsQfvGZKiokbcY8mEqCw1E8QDxaE="
},
"hierarchy": {
"label": {
"classification": 3,
"compartments": [
0,
1
]
},
"identityPublicKey": "rx6FBwu0ekejPG/KuVDGUjDGgpk1KeuneLAjxSQ4Ul0=",
"identityRecord": {
"v": 1,
"kdf": 1,
"iterations": 1000,
"context": "flamenet/files/identity/v1",
"salt": "3nW9N41kTjy+XV4MTrZpBw==",
"nonce": "/RfcIYNdqzoJjFJ0",
"privateKey": "Cq0gk03SqDC0QrYMdhyz/KwdwRxAaG95Cow0sE2+nilEjAnbgeDZPDSkKoZKSVla5Jv2Tf0AsDwYmGS01EWVGQ=="
},
"labelKey": "mdghL5vAIw5DeSsY9/BwRARhADaDBQnCxZDqeRHxl20=",
"nodeKey": "Xlb5FpmFRr06pADUaHGW9+uXIqtbhcnEVy2Ht8om3lM=",
"contentKey": "WP2IXXEHgqN9B4LosOtkeQgbfNgaqmLPSm9t02XQkc0=",
"labelKeyGrant": "Acacc9qfWCLxyXcc6rRTEyUDJxwdc0wxqy4UW8NSHwlhv/rvokK6kYMRfcTyUfXoE8H68Sk6nvIuC3sBAYMIcGkX/El9tUms+c90ffr+EbRZqyR16dREwtPowST+",
"wrappedNodeKey": "EUITJmLQVJgoL8z+lcfoKXIxHsGDWGVXDsxqdQnoPghuMvbD3eyhCCVT3lkDOQJ2sOKrfm8aAgEpzUSGMw==",
"wrappedContentKey": "EUQ1b+IbuxUblsm1gKGp1GWk8Tw3IsNNCGvTVwfQBA8OHLWmiHgpDburxl3GweXMzo9rFbhPazZkYKBsmw==",
"nameDigest": "zEu1hnRTyD+91Vke88XDSSVa/jfVERPIbMLB2kul4hY=",
"nameCiphertext": "AZlKWc9giJcswo4Fva4Ez7BhdljC5MfIsUpeSYz4LxwlAl9bobFz6Te/jCQ=",
"expectedName": "Q3 Forecast.txt",
"content": "RU5DTAEAAAAQfPLZFLMzaHtthhTgPXIbF6Q5iDW4RM+x97t9Ggd4lEJ3V1xMmyKvg8ISLjEUixPNrNGpzhx58+qfkj0pXqqzZijC",
"expectedContent": "cmV2ZW51ZTogNDEKaGVhZGNvdW50OiAxMgo=",
"contentChunkSize": 16
},
"nameDigest": [
{
"folderKey": "Xlb5FpmFRr06pADUaHGW9+uXIqtbhcnEVy2Ht8om3lM=",
"name": "Q3 Forecast.txt",
"expected": "zEu1hnRTyD+91Vke88XDSSVa/jfVERPIbMLB2kul4hY="
},
{
"folderKey": "Xlb5FpmFRr06pADUaHGW9+uXIqtbhcnEVy2Ht8om3lM=",
"name": "café",
"expected": "yiuuJ6pdJeJX7gQ3YJFGWbtu10Qz4VvUwE2cdWhMLB4="
},
{
"folderKey": "Xlb5FpmFRr06pADUaHGW9+uXIqtbhcnEVy2Ht8om3lM=",
"name": "Payroll",
"expected": "NN1zYjaALbTzp1MAf8dUNbfd/DVQVQ8jN3cN0DiEZt8="
}
],
"recovery": {
"code": "BKKR-SJXB-E8M8-1A6B-CN47-WNM7-6RQN",
"entropy": "XOeMy6tyKICoy2VIflaHNg=="
}
},
"recovery": [
{
"entropy": "AEeO1RxjqvE4f8YNVJviKQ==",
"code": "013R-XN8W-CENF-2E3Z-RR6N-96Z2-57K9",
"kdfSecret": "00478ed51c63aaf1387fc60d549be229"
},
{
"entropy": "JWyz+kGIzxZdpOsyecAHTg==",
"code": "4NPB-7YJ1-H37H-CQD4-XCS7-KG07-9RBA",
"kdfSecret": "256cb3fa4188cf165da4eb3279c0074e"
},
{
"entropy": "SpHYH2at9DuCyRBXnuUscw==",
"code": "9A8X-G7V6-NQT3-Q0P9-21BS-XS9C-EE7C",
"kdfSecret": "4a91d81f66adf43b82c910579ee52c73"
},
{
"entropy": "b7b9RIvSGWCn7jV8wwpRmA==",
"code": "DYVF-TH4B-T8CP-19ZE-6NYC-62JH-K3JD",
"kdfSecret": "6fb6fd448bd21960a7ee357cc30a5198"
},
{
"entropy": "lNsiabD3PoXME1qh6C92vQ==",
"code": "JKDJ-4TDG-YWZ8-BK0K-BAGY-GBVP-QN2Y",
"kdfSecret": "94db2269b0f73e85cc135aa1e82f76bd"
},
{
"entropy": "uQBHjtUcY6rxOH/GDVSb4g==",
"code": "Q404-F3PN-3HHT-NW9R-FZ30-TN4V-W87R",
"kdfSecret": "b900478ed51c63aaf1387fc60d549be2"
}
],
"recoveryWithQ": {
"entropy": "AB06V3SRrsvoBSI/XHmWsw==",
"code": "00EK-MNVM-J6QC-QT05-48ZN-RYCP-PDSA",
"kdfSecret": "001d3a577491aecbe805223f5c7996b3"
}
}