diff --git a/PARITY.md b/PARITY.md index c113e40..71181b5 100644 --- a/PARITY.md +++ b/PARITY.md @@ -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 β€” diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FileContent.kt b/engine/src/main/kotlin/io/flamenet/e2e/FileContent.kt new file mode 100644 index 0000000..a7f264c --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/FileContent.kt @@ -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() + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FileNames.kt b/engine/src/main/kotlin/io/flamenet/e2e/FileNames.kt new file mode 100644 index 0000000..aed53db --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/FileNames.kt @@ -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") + } + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FileWrap.kt b/engine/src/main/kotlin/io/flamenet/e2e/FileWrap.kt new file mode 100644 index 0000000..639fdbf --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/FileWrap.kt @@ -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()) +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FilesEngine.kt b/engine/src/main/kotlin/io/flamenet/e2e/FilesEngine.kt new file mode 100644 index 0000000..d56c22b --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/FilesEngine.kt @@ -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) } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FilesIdentity.kt b/engine/src/main/kotlin/io/flamenet/e2e/FilesIdentity.kt new file mode 100644 index 0000000..7c4a958 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/FilesIdentity.kt @@ -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") + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FilesKdf.kt b/engine/src/main/kotlin/io/flamenet/e2e/FilesKdf.kt new file mode 100644 index 0000000..41d9f13 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/FilesKdf.kt @@ -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) +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/FilesInteropTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/FilesInteropTest.kt new file mode 100644 index 0000000..b709bb6 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/FilesInteropTest.kt @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { 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 { + 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.open( + key = key, + box = FileContent.nonce(baseNonce, 0) + last, + aad = FileContent.aad(hierarchyLabel, index = 0, isLast = true), + ) + } + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/FilesVaultCryptoTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/FilesVaultCryptoTest.kt new file mode 100644 index 0000000..e245788 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/FilesVaultCryptoTest.kt @@ -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("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 { 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 { + 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 { + FileNames.decrypt(key, sealed.ciphertext, sealed.digest, Label(label.classification)) + } + } + + @Test + fun `a truncated name blob is refused before it reaches the cipher`() { + assertFailsWith { + 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 { + 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 { + FileWrap.openWithKey(key, asymmetric, label.bytes()) + } + assertFailsWith { + 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 { + 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 { + 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 { + FilesIdentity.rawFromPkcs8(good.copyOf().also { it[9] = 0x2C }) // a different OID + } + assertFailsWith { + 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)), + ) + } +} diff --git a/engine/src/test/resources/vectors/files-v1.json b/engine/src/test/resources/vectors/files-v1.json new file mode 100644 index 0000000..87afe28 --- /dev/null +++ b/engine/src/test/resources/vectors/files-v1.json @@ -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" + } +}