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:
co-authored by
Claude Opus 5
parent
6a0dce6d4e
commit
2a3187308d
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user