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