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

Names, key wrapping, chunked content, the KDF and the identity record —
everything needed to open a Files vault written by any other FlameNet
client.

🔴 Held to `vectors/files-v1.json`, produced by the JAVASCRIPT engine in
the browser. Not to the Swift engine. Checking Kotlin against Swift would
establish only 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, because a vector file regenerated by the implementation
under test proves nothing.

Two vectors were recomputed from scratch in Python before any Kotlin ran
against them — `struct.pack('>hQ')` for the label encoding and
`hmac.new(...).digest()` for the name digests. The `café` case matches
under NFC and NOT under NFD, which is what pins the normalisation form
rather than assuming it: a platform normalising differently does not
error, it silently fails to find a file filed on another device.

Each guard was proven by breaking the code and watching the right test go
red: normalisation removed → the composed/decomposed digest test; the
wrap salt halves swapped → the browser's label-key grant; the end-flag or
the chunk index dropped from the authenticated data → the browser's file
stops decrypting at all.

⚠️ The truncation and reordering tests each go on to show the surviving
bytes are still authentic under their ORIGINAL index and end-flag. Without
that second half "it threw" is all they would establish, and any bug at
all makes a mangled blob throw.

Two notes on the platform:

- PBKDF2 is hand-written on `Mac`, as `Bip39.pbkdf2HmacSha512` already is.
  The JCA path takes a `CharArray` and lets the provider decide the
  encoding; the reference implementations take bytes. A passphrase outside
  ASCII would otherwise stretch differently under a different provider and
  the record simply would not open, with nothing to say why.
- The public half of an identity 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, so it is pinned to
  the vector file's own `identityPublicKey`.

43 tests. `FileContent` rather than `Content`: the messenger's `Content` is
a message payload and the two are not interchangeable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-04 15:06:30 -04:00
co-authored by Claude Opus 5
parent 6a0dce6d4e
commit 2a3187308d
10 changed files with 1612 additions and 0 deletions
@@ -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)),
)
}
}