The Kotlin engine
Nothing here is an app. This is the protocol implemented a third time, and it is deliberately first: a third implementation is only worth writing if it is written against the specification and the published vectors rather than against a reading of the Swift and JavaScript that already agree. So far: canonical bytes, safety numbers, and the RFC 6962 key log with its inclusion and consistency proofs. Nine tests, all against the vectors the other two engines are held to. X25519, Ed25519, X3DH, the Double Ratchet, sealed sender and the post-quantum halves are not written yet. The vectors are read from the protocol repository rather than copied here, and the suite fails rather than skips when it cannot find them -- an engine that quietly stops checking itself is worth less than no third engine.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
build/
|
||||
.gradle/
|
||||
.DS_Store
|
||||
local.properties
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Extant 2000 LLC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Flamenet Messenger — Android
|
||||
|
||||
Nothing here is an app yet. This is the engine, and it is deliberately first.
|
||||
|
||||
An Android client needs the protocol implemented a third time. That third
|
||||
implementation is the one that finds the places where the specification says
|
||||
less than the two existing engines quietly agreed on — but only if it is
|
||||
written against the spec and the published vectors rather than against a
|
||||
reading of our own Swift and JavaScript. So the engine comes first, it is a
|
||||
plain Kotlin/JVM library with no Android in it at all, and it is held to
|
||||
`protocol-v1.json` — the same file the other two engines are checked against,
|
||||
read rather than copied, so it cannot quietly diverge.
|
||||
|
||||
## Running the tests
|
||||
|
||||
The vectors live in the protocol repository, so clone it beside this one:
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.com/paulhitt/flamenet-e2e
|
||||
git clone https://gitlab.com/paulhitt/flamenet-messenger-android
|
||||
cd flamenet-messenger-android && ./gradlew test
|
||||
```
|
||||
|
||||
Or point `FLAMENET_VECTORS` at a `protocol-v1.json` anywhere. The suite **fails**
|
||||
rather than skips when it cannot find one: the entire value of a third engine is
|
||||
that it is checked against something other than a reading of our own code, and
|
||||
one that quietly stops checking is worth less than no third engine at all.
|
||||
|
||||
## The rest of the project
|
||||
|
||||
| Repo | What it is |
|
||||
|---|---|
|
||||
| [flamenet-e2e](https://gitlab.com/paulhitt/flamenet-e2e) | The protocol: JavaScript engine, spec, vectors |
|
||||
| [flamenet-relay](https://gitlab.com/paulhitt/flamenet-relay) | The server |
|
||||
| [flamenet-messenger-ios](https://gitlab.com/paulhitt/flamenet-messenger-ios) | The iOS app, and the source of truth for this engine |
|
||||
|
||||
```
|
||||
./gradlew :engine:test
|
||||
```
|
||||
|
||||
No Android SDK, no emulator, no device. It runs anywhere a JVM does, which is
|
||||
also what lets it run in CI on Linux.
|
||||
|
||||
## What the engine covers today
|
||||
|
||||
| Area | Spec | State |
|
||||
|---|---|---|
|
||||
| Safety numbers | §7 | **Done** — fingerprints and conversation ordering match the published vectors |
|
||||
| Key transparency verification | §19 | **Done** — RFC 6962 leaf/node hashing, unbalanced split, bottom-up audit paths |
|
||||
| Byte helpers | — | Unsigned comparison, constant-time equality |
|
||||
|
||||
## What it does not cover
|
||||
|
||||
Listed rather than left to be discovered, because a table of green ticks with
|
||||
the hard parts quietly missing is worse than no table:
|
||||
|
||||
| Area | Spec | State |
|
||||
|---|---|---|
|
||||
| X25519 / Ed25519 | §3 | Not started — needs a provider that works on both JVM and Android |
|
||||
| X3DH key agreement | §4 | Not started |
|
||||
| Double Ratchet | §6 | Not started |
|
||||
| Sealed sender | §12 | Not started |
|
||||
| ML-KEM-768 hybrid | §11 | Not started |
|
||||
| ML-DSA-65 identities | §20 | Not started |
|
||||
| Relay client | §8 | Not started |
|
||||
| The app itself | — | Not started |
|
||||
|
||||
Until those exist, Android is honestly marked "not started" on the website, and
|
||||
a conversation gets the protocol's guarantees only when both ends are iOS.
|
||||
|
||||
## Why the vectors matter more than the code
|
||||
|
||||
Two implementations that agree may both be wrong in the same way, especially
|
||||
when one was written while reading the other. The audit-path ordering in §19 is
|
||||
the worked example: a top-down reading of a bottom-up path produces the correct
|
||||
root for every left-spine index, so it passes any test that only checks index
|
||||
0. The vector file pins a right-descending index for that reason, and the
|
||||
Kotlin tests assert both that the published path verifies and that the same
|
||||
path reversed does not.
|
||||
|
||||
If this engine ever disagrees with the file, the interesting question is which
|
||||
of the three is wrong — not this one by default.
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
kotlin("jvm") version "2.4.0" apply false
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
repositories { mavenCentral() }
|
||||
|
||||
dependencies {
|
||||
testImplementation(kotlin("test"))
|
||||
// Only to read the published vector file. The engine itself has no
|
||||
// dependencies at all so far, which is worth keeping for as long as it
|
||||
// lasts: every jar here is one more thing between the protocol and someone
|
||||
// auditing it.
|
||||
testImplementation("org.json:json:20240303")
|
||||
}
|
||||
|
||||
// Bytecode level 17, which is what Android's toolchain consumes, produced by
|
||||
// whatever JDK happens to be installed. A `jvmToolchain(17)` here would demand
|
||||
// a JDK 17 on every machine that builds this and fail on anything newer, which
|
||||
// is a poor trade for a library with no JDK 17-specific behaviour.
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
testLogging {
|
||||
events("passed", "failed", "skipped")
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Byte helpers shared by the rest of the engine.
|
||||
*
|
||||
* Kotlin's `ByteArray` is signed, which is a recurring source of quiet bugs
|
||||
* when porting from JavaScript's `Uint8Array`: `byte` values above 127 read as
|
||||
* negative, so an unguarded comparison or shift silently disagrees with the
|
||||
* reference implementation. Everything here converts through `and 0xFF`.
|
||||
*/
|
||||
internal object Bytes {
|
||||
|
||||
fun concat(vararg parts: ByteArray): ByteArray {
|
||||
val out = ByteArray(parts.sumOf { it.size })
|
||||
var at = 0
|
||||
for (p in parts) { p.copyInto(out, at); at += p.size }
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsigned lexicographic comparison.
|
||||
*
|
||||
* The signed default would order 0x80 before 0x00, which for safety numbers
|
||||
* means two devices pick opposite orderings and read out different numbers
|
||||
* for the same conversation — a false mismatch, and the worst possible
|
||||
* failure for a check whose entire purpose is detecting a real one.
|
||||
*/
|
||||
fun compareUnsigned(a: ByteArray, b: ByteArray): Int {
|
||||
val len = minOf(a.size, b.size)
|
||||
for (i in 0 until len) {
|
||||
val d = (a[i].toInt() and 0xFF) - (b[i].toInt() and 0xFF)
|
||||
if (d != 0) return d
|
||||
}
|
||||
return a.size - b.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparison that does not stop early.
|
||||
*
|
||||
* Used wherever a mismatch is attacker-visible; `contentEquals` returns as
|
||||
* soon as it can, which leaks how much of a value was right.
|
||||
*/
|
||||
fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean {
|
||||
if (a.size != b.size) return false
|
||||
var diff = 0
|
||||
for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt())
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
fun sha256(data: ByteArray): ByteArray =
|
||||
MessageDigest.getInstance("SHA-256").digest(data)
|
||||
|
||||
fun sha512(data: ByteArray): ByteArray =
|
||||
MessageDigest.getInstance("SHA-512").digest(data)
|
||||
|
||||
fun b64(data: ByteArray): String = java.util.Base64.getEncoder().encodeToString(data)
|
||||
|
||||
fun unb64(text: String): ByteArray = java.util.Base64.getDecoder().decode(text)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
/**
|
||||
* Key transparency verification (spec §19), client side.
|
||||
*
|
||||
* Verification only, deliberately. There is nothing here that can *build* a
|
||||
* tree, because a client able to construct a proof is a client that can be
|
||||
* talked into believing one it constructed itself.
|
||||
*
|
||||
* A transparency log without a client that refuses unproven keys is just a
|
||||
* log: it faithfully records the substitution, and nobody reads it.
|
||||
*/
|
||||
object KeyLog {
|
||||
|
||||
/** RFC 6962 domain separation: leaves under 0x00, interior nodes under 0x01. */
|
||||
fun leafHash(data: ByteArray): ByteArray = Bytes.sha256(Bytes.concat(byteArrayOf(0x00), data))
|
||||
|
||||
fun nodeHash(left: ByteArray, right: ByteArray): ByteArray =
|
||||
Bytes.sha256(Bytes.concat(byteArrayOf(0x01), left, right))
|
||||
|
||||
/**
|
||||
* The largest power of two strictly below [n].
|
||||
*
|
||||
* Not n/2. The unbalanced split is what makes the tree's shape depend only
|
||||
* on its size, which is what lets two verifiers agree without exchanging
|
||||
* anything about its structure.
|
||||
*/
|
||||
fun split(n: Int): Int {
|
||||
var k = 1
|
||||
while (k shl 1 < n) k = k shl 1
|
||||
return k
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact bytes the relay commits to for one key publication (§19.2).
|
||||
*
|
||||
* This binding is the part that matters. Proving that *some* leaf sits in
|
||||
* the log proves nothing at all; what has to hold is that the leaf at the
|
||||
* claimed index is this device's published identity and no other.
|
||||
*/
|
||||
fun canonicalLeaf(
|
||||
kind: String,
|
||||
userId: Long,
|
||||
deviceId: String,
|
||||
ikSigB64: String,
|
||||
ikDhB64: String,
|
||||
): ByteArray = "v1|$kind|$userId|$deviceId|$ikSigB64|$ikDhB64".toByteArray(Charsets.UTF_8)
|
||||
|
||||
/**
|
||||
* Recompute the root from a leaf and its audit path.
|
||||
*
|
||||
* The path is consumed **bottom-up** — the leaf's own sibling first, the
|
||||
* highest sibling last. That is the order RFC 6962 specifies and the order
|
||||
* the relay emits, and getting it backwards is not a loud failure: a
|
||||
* top-down reading produces the correct root for any left-spine index, so
|
||||
* it passes any test that only ever checks index 0. The vector suite pins a
|
||||
* right-descending index precisely to catch that.
|
||||
*
|
||||
* Returns null rather than throwing: a bad proof is an expected answer from
|
||||
* a hostile server, not an exceptional condition.
|
||||
*/
|
||||
fun rootFromInclusion(
|
||||
leaf: ByteArray,
|
||||
index: Int,
|
||||
size: Int,
|
||||
proof: List<ByteArray>,
|
||||
): ByteArray? {
|
||||
if (index < 0 || index >= size) return null
|
||||
var fn = index
|
||||
var sn = size - 1
|
||||
var r = leaf
|
||||
var i = 0
|
||||
while (sn > 0) {
|
||||
if (i >= proof.size) return null
|
||||
if ((fn and 1) != 0 || fn == sn) {
|
||||
r = nodeHash(proof[i++], r)
|
||||
while (fn != 0 && (fn and 1) == 0) { fn = fn shr 1; sn = sn shr 1 }
|
||||
} else {
|
||||
r = nodeHash(r, proof[i++])
|
||||
}
|
||||
fn = fn shr 1
|
||||
sn = sn shr 1
|
||||
}
|
||||
// Leftover path elements mean the proof does not describe this tree.
|
||||
return if (i == proof.size) r else null
|
||||
}
|
||||
|
||||
fun verifyInclusion(
|
||||
leaf: ByteArray,
|
||||
index: Int,
|
||||
size: Int,
|
||||
proof: List<ByteArray>,
|
||||
root: ByteArray,
|
||||
): Boolean {
|
||||
val got = rootFromInclusion(leaf, index, size, proof) ?: return false
|
||||
return Bytes.constantTimeEquals(got, root)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Safety numbers (spec §7).
|
||||
*
|
||||
* The one check in the whole system that does not depend on trusting the
|
||||
* server: two people read the same sixty digits aloud, and if they match there
|
||||
* is nobody in the middle. Everything else here can be verified by machines;
|
||||
* this is verified by humans, which is why the output is grouped for reading
|
||||
* rather than rendered as hex.
|
||||
*/
|
||||
object SafetyNumber {
|
||||
|
||||
/** 5200 iterations, matching the reference engine. */
|
||||
private const val ITERATIONS = 5200
|
||||
|
||||
/**
|
||||
* Thirty digits for one identity key.
|
||||
*
|
||||
* The iteration count is the point: it makes searching for a key whose
|
||||
* fingerprint collides in the digits a person actually compares expensive
|
||||
* rather than instant.
|
||||
*/
|
||||
fun fingerprint(publicKey: ByteArray): String {
|
||||
var h = publicKey
|
||||
repeat(ITERATIONS) {
|
||||
h = Bytes.sha512(Bytes.concat(h, publicKey)).copyOfRange(0, 32)
|
||||
}
|
||||
val bytes = h.copyOfRange(0, 30)
|
||||
val out = StringBuilder(30)
|
||||
var c = 0
|
||||
while (c < 30) {
|
||||
var v = BigInteger.ZERO
|
||||
for (i in 0 until 5) {
|
||||
v = v.shiftLeft(8).or(BigInteger.valueOf((bytes[c + i].toInt() and 0xFF).toLong()))
|
||||
}
|
||||
out.append(v.mod(BigInteger.valueOf(100_000L)).toString().padStart(5, '0'))
|
||||
c += 5
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The sixty digits for a conversation.
|
||||
*
|
||||
* Ordered by the keys themselves rather than by who is asking, so both ends
|
||||
* read out the same string. Unsigned comparison — see [Bytes.compareUnsigned].
|
||||
*/
|
||||
fun compute(localIdentity: ByteArray, remoteIdentity: ByteArray): String {
|
||||
val local = fingerprint(localIdentity)
|
||||
val remote = fingerprint(remoteIdentity)
|
||||
return if (Bytes.compareUnsigned(localIdentity, remoteIdentity) < 0) local + remote
|
||||
else remote + local
|
||||
}
|
||||
|
||||
/** Grouped into fives, which is how it is meant to be read aloud. */
|
||||
fun format(number: String): String =
|
||||
number.chunked(5).joinToString(" ")
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The Kotlin engine against the published vectors.
|
||||
*
|
||||
* These are the same bytes the JavaScript and Swift engines are held to, read
|
||||
* from the one file rather than copied here — a vector transcribed into the
|
||||
* test that checks it is a vector that agrees with itself.
|
||||
*
|
||||
* This is the whole point of starting Android with the engine. A third
|
||||
* implementation is only worth anything if it can be checked against something
|
||||
* other than a reading of our own code.
|
||||
*/
|
||||
class PublishedVectorsTest {
|
||||
|
||||
private val vectors: JSONObject by lazy {
|
||||
// Where the vectors are depends on which checkout this is: android/ as a
|
||||
// subtree of the app repo, or the extracted Android repo with
|
||||
// flamenet-e2e cloned beside it. Resolved rather than assumed, because a
|
||||
// hardcoded depth is how the harness stops testing anything.
|
||||
//
|
||||
// This FAILS rather than skips when it finds nothing, deliberately: the
|
||||
// whole reason a third engine is worth writing is that it is checked
|
||||
// against the published vectors, and one that quietly stops checking is
|
||||
// worth less than no third engine at all.
|
||||
val candidates = listOfNotNull(
|
||||
System.getenv("FLAMENET_VECTORS"),
|
||||
"../../engine/test/vectors/protocol-v1.json", // app repo subtree
|
||||
"../../../flamenet-e2e/test/vectors/protocol-v1.json", // beside the engine repo
|
||||
"../../flamenet-e2e/test/vectors/protocol-v1.json",
|
||||
).map { File(it) }
|
||||
val file = candidates.firstOrNull { it.exists() }
|
||||
assertTrue(
|
||||
file != null,
|
||||
"published vectors not found. Looked in: " +
|
||||
candidates.joinToString(", ") { it.absolutePath } +
|
||||
". Clone https://gitlab.com/paulhitt/flamenet-e2e beside this repo, " +
|
||||
"or set FLAMENET_VECTORS to protocol-v1.json.",
|
||||
)
|
||||
JSONObject(file!!.readText())
|
||||
}
|
||||
|
||||
private fun JSONArray.bytesAt(i: Int) = Bytes.unb64(getString(i))
|
||||
|
||||
@Test
|
||||
fun `safety number fingerprints match the published vectors`() {
|
||||
val v = vectors.getJSONObject("safetyNumber")
|
||||
val a = Bytes.unb64(v.getString("identityA"))
|
||||
val b = Bytes.unb64(v.getString("identityB"))
|
||||
|
||||
assertEquals(v.getString("fingerprintA"), SafetyNumber.fingerprint(a))
|
||||
assertEquals(v.getString("fingerprintB"), SafetyNumber.fingerprint(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `safety number ordering does not depend on who is asking`() {
|
||||
val v = vectors.getJSONObject("safetyNumber")
|
||||
val a = Bytes.unb64(v.getString("identityA"))
|
||||
val b = Bytes.unb64(v.getString("identityB"))
|
||||
val expected = v.getString("combined")
|
||||
|
||||
// Both ends have to read out the same string, or the check reports a
|
||||
// mismatch that isn't one — which is the worst failure available to a
|
||||
// control whose only job is spotting a real mismatch.
|
||||
assertEquals(expected, SafetyNumber.compute(a, b))
|
||||
assertEquals(expected, SafetyNumber.compute(b, a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `safety number formats into readable groups`() {
|
||||
val n = vectors.getJSONObject("safetyNumber").getString("combined")
|
||||
val formatted = SafetyNumber.format(n)
|
||||
assertEquals(n, formatted.replace(" ", ""))
|
||||
assertEquals(12, formatted.split(" ").size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical leaf encodes and hashes exactly as published`() {
|
||||
val leaf = vectors.getJSONObject("keyLog").getJSONObject("canonicalLeaf")
|
||||
val input = leaf.getJSONObject("input")
|
||||
|
||||
val encoded = KeyLog.canonicalLeaf(
|
||||
kind = input.getString("kind"),
|
||||
userId = input.getLong("userId"),
|
||||
deviceId = input.getString("deviceId"),
|
||||
ikSigB64 = input.getString("ikSigB64"),
|
||||
ikDhB64 = input.getString("ikDhB64"),
|
||||
)
|
||||
assertEquals(leaf.getString("encoded"), String(encoded, Charsets.UTF_8))
|
||||
assertEquals(leaf.getString("leafHash"), Bytes.b64(KeyLog.leafHash(encoded)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merkle roots match at every prefix size`() {
|
||||
val keyLog = vectors.getJSONObject("keyLog")
|
||||
val leaves = keyLog.getJSONArray("leafHashes")
|
||||
val roots = keyLog.getJSONArray("rootsBySize")
|
||||
|
||||
// Every size, not just the last: the unbalanced split means each size
|
||||
// has a different tree shape, and an implementation that only ever
|
||||
// builds a power-of-two tree agrees with this one at sizes 1, 2 and 4
|
||||
// while disagreeing at 3 and 5.
|
||||
for (size in 1..leaves.length()) {
|
||||
val prefix = (0 until size).map { leaves.bytesAt(it) }
|
||||
assertEquals(
|
||||
roots.getString(size - 1),
|
||||
Bytes.b64(rootOf(prefix)),
|
||||
"root disagrees at size $size",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inclusion proof verifies at a right-descending index`() {
|
||||
val inc = vectors.getJSONObject("keyLog").getJSONObject("inclusion")
|
||||
val leaf = Bytes.unb64(inc.getString("leaf"))
|
||||
val proof = inc.getJSONArray("proof").let { a -> (0 until a.length()).map { a.bytesAt(it) } }
|
||||
val root = Bytes.unb64(inc.getString("root"))
|
||||
|
||||
val got = KeyLog.rootFromInclusion(leaf, inc.getInt("index"), inc.getInt("size"), proof)
|
||||
assertNotNull(got, "the published proof did not verify")
|
||||
assertEquals(inc.getString("root"), Bytes.b64(got))
|
||||
assertTrue(KeyLog.verifyInclusion(leaf, inc.getInt("index"), inc.getInt("size"), proof, root))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same proof read the other way round is rejected`() {
|
||||
val inc = vectors.getJSONObject("keyLog").getJSONObject("inclusion")
|
||||
val leaf = Bytes.unb64(inc.getString("leaf"))
|
||||
val proof = inc.getJSONArray("proof").let { a -> (0 until a.length()).map { a.bytesAt(it) } }
|
||||
val root = Bytes.unb64(inc.getString("root"))
|
||||
|
||||
// The audit path is bottom-up. Reading it top-down still produces the
|
||||
// right root for any left-spine index, so this assertion — at a
|
||||
// right-descending index, with the path reversed — is the one that
|
||||
// actually catches the mistake.
|
||||
assertFalse(
|
||||
KeyLog.verifyInclusion(leaf, inc.getInt("index"), inc.getInt("size"), proof.reversed(), root),
|
||||
"a reversed audit path verified, so the path order is not being honoured",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a proof for the wrong index does not verify`() {
|
||||
val inc = vectors.getJSONObject("keyLog").getJSONObject("inclusion")
|
||||
val leaf = Bytes.unb64(inc.getString("leaf"))
|
||||
val proof = inc.getJSONArray("proof").let { a -> (0 until a.length()).map { a.bytesAt(it) } }
|
||||
val root = Bytes.unb64(inc.getString("root"))
|
||||
val size = inc.getInt("size")
|
||||
val index = inc.getInt("index")
|
||||
|
||||
for (other in 0 until size) {
|
||||
if (other == index) continue
|
||||
assertFalse(
|
||||
KeyLog.verifyInclusion(leaf, other, size, proof, root),
|
||||
"the proof verified at index $other as well as $index",
|
||||
)
|
||||
}
|
||||
assertNull(KeyLog.rootFromInclusion(leaf, size, size, proof), "index == size was accepted")
|
||||
assertNull(KeyLog.rootFromInclusion(leaf, -1, size, proof), "a negative index was accepted")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `split picks the largest power of two strictly below n`() {
|
||||
assertEquals(1, KeyLog.split(2))
|
||||
assertEquals(2, KeyLog.split(3))
|
||||
assertEquals(2, KeyLog.split(4))
|
||||
assertEquals(4, KeyLog.split(5))
|
||||
assertEquals(4, KeyLog.split(8))
|
||||
assertEquals(8, KeyLog.split(9))
|
||||
}
|
||||
|
||||
/**
|
||||
* The reference tree, built here rather than in the engine.
|
||||
*
|
||||
* [KeyLog] verifies and cannot construct, on purpose. The test needs a
|
||||
* builder to check the published roots, so it carries its own — which also
|
||||
* means the roots are checked against an independent construction rather
|
||||
* than against the same code that produced them.
|
||||
*/
|
||||
private fun rootOf(leaves: List<ByteArray>): ByteArray {
|
||||
require(leaves.isNotEmpty())
|
||||
if (leaves.size == 1) return leaves[0]
|
||||
val k = KeyLog.split(leaves.size)
|
||||
return KeyLog.nodeHash(rootOf(leaves.subList(0, k)), rootOf(leaves.subList(k, leaves.size)))
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
+9
@@ -0,0 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Refuse to publish anything that looks like a credential.
|
||||
#
|
||||
# This subtree is mirrored to a PUBLIC repository, and a public git history is
|
||||
# not something you can take back — a leaked key is leaked the moment the push
|
||||
# lands, regardless of what the next commit does. So this runs as a gate before
|
||||
# the mirror, not as a report afterwards.
|
||||
#
|
||||
# scripts/secret-sweep.sh [dir] # defaults to the package root
|
||||
#
|
||||
# Exits non-zero on the first credible hit. False positives are expected and are
|
||||
# the correct trade here: the cost of one is a person reading a line of output,
|
||||
# and the cost of a miss is a rotation and an incident.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
cd "$ROOT"
|
||||
|
||||
# Build products and vendored upstream are excluded: .build is compiler output,
|
||||
# and vendor/ is checked separately by the supply-chain digest test. This script
|
||||
# excludes ITSELF by filename rather than by one hardcoded path -- it is the file
|
||||
# with every pattern in it, so a copy living anywhere else reports itself as a
|
||||
# find, and a repo with a `Scripts/` rather than a `scripts/` did exactly that.
|
||||
# `git ls-files .` and not plain `git ls-files`: this subtree may be untracked
|
||||
# in the repo that contains it, and an unscoped call there returns the WHOLE
|
||||
# repository. An empty result means untracked, not empty — fall back to find,
|
||||
# or the sweep silently passes by looking at nothing, which is worse than not
|
||||
# running it at all.
|
||||
FILES=$(git ls-files . 2>/dev/null || true)
|
||||
if [ -z "$FILES" ]; then
|
||||
FILES=$(find . -type f -not -path '*/.build/*' -not -path '*/node_modules/*' -not -path '*/.git/*')
|
||||
fi
|
||||
|
||||
hits=0
|
||||
probe() {
|
||||
local label="$1" pattern="$2"
|
||||
local found
|
||||
found=$(printf '%s\n' "$FILES" \
|
||||
| grep -vE '(^|/)vendor/|\.build/|secret-sweep\.sh$' \
|
||||
| xargs -I{} grep -HnE "$pattern" {} 2>/dev/null || true)
|
||||
if [ -n "$found" ]; then
|
||||
echo "── $label"
|
||||
printf '%s\n' "$found" | head -8
|
||||
hits=$((hits + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Shapes, not names: a variable called API_KEY holding "" is fine, and a
|
||||
# variable called `x` holding a live token is not.
|
||||
probe "Stripe live key" 'sk_live_[0-9A-Za-z]{16,}'
|
||||
probe "GitLab token" 'glpat-[0-9A-Za-z_-]{20,}'
|
||||
probe "AWS access key id" 'AKIA[0-9A-Z]{16}'
|
||||
probe "Anthropic key" 'sk-ant-[0-9A-Za-z_-]{20,}'
|
||||
probe "Google API key" 'AIza[0-9A-Za-z_-]{35}'
|
||||
probe "Slack token" 'xox[baprs]-[0-9A-Za-z-]{10,}'
|
||||
probe "private key block" '-----BEGIN (RSA |EC |OPENSSH |PGP )?PRIVATE KEY'
|
||||
probe "JWT" 'eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.'
|
||||
probe "Supabase service role" 'service_role.{0,40}eyJ'
|
||||
probe "assigned password" '(password|passwd|secret|token)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"'{}$][^"'"'"']{7,}["'"'"']'
|
||||
|
||||
if [ "$hits" -gt 0 ]; then
|
||||
echo
|
||||
echo "secret sweep FAILED: $hits pattern(s) matched. Nothing was published."
|
||||
exit 1
|
||||
fi
|
||||
echo "secret sweep clean ($(printf '%s\n' "$FILES" | wc -l | tr -d ' ') files)"
|
||||
@@ -0,0 +1,9 @@
|
||||
// Flamenet Messenger — Android.
|
||||
//
|
||||
// `engine` is deliberately a plain Kotlin/JVM library, not an Android library:
|
||||
// the protocol has nothing to do with Android, and keeping it off the platform
|
||||
// means its tests run on any machine and in CI on Linux, with no emulator and
|
||||
// no SDK. The app module, when it exists, will depend on this.
|
||||
rootProject.name = "flamenet-android"
|
||||
|
||||
include(":engine")
|
||||
Reference in New Issue
Block a user