release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic: audit-cycle complete, opt-in surface fully exposed, threat model refreshed for every new surface. Highlights: - All 24 @shade/* packages bumped to 4.0.0 in lockstep. - CHANGELOG 4.0.0 section is the canonical manifest of what landed. - THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12 Web-Worker boundary) + residual-risks table refreshed. - OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox, bridge, observer, /metrics, /healthz, /ready. - MIGRATION 0.3.x → 4.0 documented + smoke-tested against shade migrate-storage on a real SQLite DB. - docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer. - scripts/soak.ts harness for the GA-stable 2-week soak window. - All V*.md plans archived under docs/archive/ with Status: Done. - Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen non-realtime stack. Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green. Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports version 4.0.0 on /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package no.zyon.shade.backup
|
||||
|
||||
import no.zyon.shade.crypto.CryptoProvider
|
||||
|
||||
/**
|
||||
* Backup format v1 — passphrase-derived AES-256-GCM blob.
|
||||
* Mirror @shade/sdk/backup.ts.
|
||||
*
|
||||
* backupKey = HKDF(passphrase_utf8, salt_random_32, info="ShadeBackupKey", 32)
|
||||
* blob = AES-256-GCM(backupKey, plaintext, no AAD)
|
||||
*
|
||||
* The stored on-disk form is `{ version, salt(b64), nonce(b64), ciphertext(b64) }`.
|
||||
* This file ships only the cryptographic primitives — payload schema and JSON
|
||||
* serialization live alongside the high-level SDK and don't need a Kotlin port
|
||||
* for vector parity (each platform builds the BackupBlob in its native idiom).
|
||||
*
|
||||
* NOTE: HKDF is NOT a proper password KDF. The TS SDK acknowledges this and
|
||||
* warns users to choose a high-entropy passphrase. When `argon2id` lands in
|
||||
* `CryptoProvider`, both ports swap together. Until then, byte-parity for the
|
||||
* HKDF + AEAD layer is what V3.5 §sjekkpunkt 8 gates.
|
||||
*/
|
||||
|
||||
private val BACKUP_INFO: ByteArray = "ShadeBackupKey".toByteArray(Charsets.UTF_8)
|
||||
const val BACKUP_KEY_BYTES = 32
|
||||
const val BACKUP_VERSION = 1
|
||||
|
||||
fun deriveBackupKey(crypto: CryptoProvider, passphrase: String, salt: ByteArray): ByteArray {
|
||||
require(passphrase.length >= 12) { "Passphrase must be at least 12 characters" }
|
||||
require(salt.size >= 16) { "salt must be at least 16 bytes" }
|
||||
return crypto.hkdf(
|
||||
passphrase.toByteArray(Charsets.UTF_8),
|
||||
salt,
|
||||
BACKUP_INFO,
|
||||
BACKUP_KEY_BYTES,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package no.zyon.shade.group
|
||||
|
||||
import no.zyon.shade.crypto.CryptoProvider
|
||||
import no.zyon.shade.protocol.kdfChainKey
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Group sender-keys (Sesame). Mirror @shade/core/sender-keys.ts.
|
||||
*
|
||||
* Each sender maintains a chain key that ratchets forward with `kdfChainKey`
|
||||
* — same primitive the Double Ratchet uses for its symmetric chain. Per-message
|
||||
* AEAD AAD binds (groupId, senderAddress, iteration) so a captured ciphertext
|
||||
* cannot be replayed under a different sender or group:
|
||||
*
|
||||
* aad = u16_be(groupIdLen) || groupId || u16_be(senderAddrLen) || senderAddr || u32_be(iteration)
|
||||
*
|
||||
* Each ciphertext is signed by the sender's Ed25519 key over `aad || ciphertext`,
|
||||
* which is what receivers verify before advancing their chain.
|
||||
*/
|
||||
|
||||
data class SenderKeyMessage(
|
||||
val senderAddress: String,
|
||||
val iteration: Int,
|
||||
val ciphertext: ByteArray,
|
||||
val nonce: ByteArray,
|
||||
val signature: ByteArray,
|
||||
)
|
||||
|
||||
fun encodeSenderHeader(groupId: String, senderAddress: String, iteration: Int): ByteArray {
|
||||
val gBytes = groupId.toByteArray(Charsets.UTF_8)
|
||||
val sBytes = senderAddress.toByteArray(Charsets.UTF_8)
|
||||
require(gBytes.size <= 0xFFFF) { "groupId too long (>65535 UTF-8 bytes)" }
|
||||
require(sBytes.size <= 0xFFFF) { "senderAddress too long (>65535 UTF-8 bytes)" }
|
||||
|
||||
val out = ByteArray(2 + gBytes.size + 2 + sBytes.size + 4)
|
||||
val buf = ByteBuffer.wrap(out)
|
||||
buf.putShort(gBytes.size.toShort())
|
||||
buf.put(gBytes)
|
||||
buf.putShort(sBytes.size.toShort())
|
||||
buf.put(sBytes)
|
||||
buf.putInt(iteration)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute (newChainKey, messageKey, aad) for the next group message.
|
||||
* Pure function; caller is responsible for state advancement and the AEAD/sign
|
||||
* steps (which need access to the signing private key not exposed here).
|
||||
*/
|
||||
data class SenderStepResult(
|
||||
val newChainKey: ByteArray,
|
||||
val messageKey: ByteArray,
|
||||
val aad: ByteArray,
|
||||
)
|
||||
|
||||
fun senderKeyStep(
|
||||
crypto: CryptoProvider,
|
||||
chainKey: ByteArray,
|
||||
groupId: String,
|
||||
senderAddress: String,
|
||||
iteration: Int,
|
||||
): SenderStepResult {
|
||||
val r = kdfChainKey(crypto, chainKey)
|
||||
val aad = encodeSenderHeader(groupId, senderAddress, iteration)
|
||||
return SenderStepResult(newChainKey = r.newChainKey, messageKey = r.messageKey, aad = aad)
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate `aad || ciphertext` — the byte string the sender signs and the
|
||||
* receiver verifies. Exposed as a helper so vector parity can pin both sides.
|
||||
*/
|
||||
fun senderSignedBytes(aad: ByteArray, ciphertext: ByteArray): ByteArray {
|
||||
val out = ByteArray(aad.size + ciphertext.size)
|
||||
aad.copyInto(out, 0)
|
||||
ciphertext.copyInto(out, aad.size)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package no.zyon.shade.serialization
|
||||
|
||||
import no.zyon.shade.streams.StreamConstants
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Wire-decoded stream-chunk envelope (type 0x11).
|
||||
*
|
||||
* Mirror @shade/proto/wire.ts `StreamChunkWire`. The nonce is deterministic
|
||||
* (derived from `(laneId, seq)` on both sides) but is also serialized over
|
||||
* the wire for self-description and validated by the receiver.
|
||||
*
|
||||
* `seq` is unsigned-u64 on the wire; on the JVM we keep it as Long. The
|
||||
* encode/decode helpers operate on the raw 8-byte big-endian representation,
|
||||
* so values past Long.MAX_VALUE roundtrip via `Long.toULong()`.
|
||||
*/
|
||||
data class StreamChunkWire(
|
||||
val streamId: ByteArray,
|
||||
val laneId: Long,
|
||||
val seq: Long,
|
||||
val isLast: Boolean,
|
||||
val nonce: ByteArray,
|
||||
val aad: ByteArray,
|
||||
val ciphertext: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is StreamChunkWire) return false
|
||||
return streamId.contentEquals(other.streamId) &&
|
||||
laneId == other.laneId &&
|
||||
seq == other.seq &&
|
||||
isLast == other.isLast &&
|
||||
nonce.contentEquals(other.nonce) &&
|
||||
aad.contentEquals(other.aad) &&
|
||||
ciphertext.contentEquals(other.ciphertext)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = streamId.contentHashCode()
|
||||
result = 31 * result + laneId.hashCode()
|
||||
result = 31 * result + seq.hashCode()
|
||||
result = 31 * result + isLast.hashCode()
|
||||
result = 31 * result + nonce.contentHashCode()
|
||||
result = 31 * result + aad.contentHashCode()
|
||||
result = 31 * result + ciphertext.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Stream-chunk wire codec. Mirror @shade/proto/wire.ts `encodeStreamChunk`/`decodeStreamChunk`. */
|
||||
object StreamChunkWireFormat {
|
||||
private const val VERSION: Byte = 0x02
|
||||
const val TYPE_STREAM_CHUNK: Byte = 0x11
|
||||
|
||||
fun encodeStreamChunk(c: StreamChunkWire): ByteArray {
|
||||
require(c.streamId.size == StreamConstants.STREAM_ID_BYTES) {
|
||||
"streamId must be ${StreamConstants.STREAM_ID_BYTES} bytes"
|
||||
}
|
||||
require(c.nonce.size == StreamConstants.STREAM_NONCE_BYTES) {
|
||||
"nonce must be ${StreamConstants.STREAM_NONCE_BYTES} bytes"
|
||||
}
|
||||
require(c.laneId in 0L..0xFFFFFFFFL) { "laneId out of u32 range: ${c.laneId}" }
|
||||
// c.seq is unsigned-u64; negative signed longs encode as the high half
|
||||
// of the u64 range. ByteBuffer.putLong writes the raw 8-byte pattern.
|
||||
|
||||
val headerSize =
|
||||
1 + 1 +
|
||||
StreamConstants.STREAM_ID_BYTES +
|
||||
4 + 8 + 1 +
|
||||
StreamConstants.STREAM_NONCE_BYTES +
|
||||
4 + c.aad.size +
|
||||
4
|
||||
val out = ByteArray(headerSize + c.ciphertext.size)
|
||||
val buf = ByteBuffer.wrap(out)
|
||||
|
||||
buf.put(VERSION)
|
||||
buf.put(TYPE_STREAM_CHUNK)
|
||||
buf.put(c.streamId)
|
||||
buf.putInt(c.laneId.toInt())
|
||||
buf.putLong(c.seq)
|
||||
buf.put(if (c.isLast) 0x01.toByte() else 0x00.toByte())
|
||||
buf.put(c.nonce)
|
||||
buf.putInt(c.aad.size)
|
||||
buf.put(c.aad)
|
||||
buf.putInt(c.ciphertext.size)
|
||||
buf.put(c.ciphertext)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
fun decodeStreamChunk(data: ByteArray): StreamChunkWire {
|
||||
val minHeaderSize = 2 +
|
||||
StreamConstants.STREAM_ID_BYTES +
|
||||
4 + 8 + 1 +
|
||||
StreamConstants.STREAM_NONCE_BYTES +
|
||||
4 + 4
|
||||
require(data.size >= minHeaderSize) {
|
||||
"stream-chunk too short: ${data.size} < $minHeaderSize"
|
||||
}
|
||||
require(data[0] == VERSION) { "Unknown version: ${data[0]}" }
|
||||
require(data[1] == TYPE_STREAM_CHUNK) { "Not a stream-chunk: type=${data[1]}" }
|
||||
|
||||
val buf = ByteBuffer.wrap(data)
|
||||
buf.position(2)
|
||||
|
||||
val streamId = ByteArray(StreamConstants.STREAM_ID_BYTES)
|
||||
buf.get(streamId)
|
||||
|
||||
val laneId = buf.int.toLong() and 0xFFFFFFFFL
|
||||
val seq = buf.long
|
||||
val isLast = buf.get() == 0x01.toByte()
|
||||
|
||||
val nonce = ByteArray(StreamConstants.STREAM_NONCE_BYTES)
|
||||
buf.get(nonce)
|
||||
|
||||
val aadLen = buf.int
|
||||
require(buf.position() + aadLen + 4 <= data.size) {
|
||||
"stream-chunk truncated in aad/ctLen"
|
||||
}
|
||||
val aad = ByteArray(aadLen)
|
||||
buf.get(aad)
|
||||
|
||||
val ctLen = buf.int
|
||||
require(buf.position() + ctLen == data.size) {
|
||||
"stream-chunk length mismatch: declared ${buf.position() + ctLen}, actual ${data.size}"
|
||||
}
|
||||
val ciphertext = ByteArray(ctLen)
|
||||
buf.get(ciphertext)
|
||||
|
||||
return StreamChunkWire(streamId, laneId, seq, isLast, nonce, aad, ciphertext)
|
||||
}
|
||||
|
||||
/** Inspect the type tag without full parsing. Mirror @shade/proto/wire.ts. */
|
||||
enum class EnvelopeKind { PREKEY, RATCHET, STREAM_CHUNK, UNKNOWN }
|
||||
|
||||
fun inspectEnvelopeType(data: ByteArray): EnvelopeKind {
|
||||
if (data.size < 2 || data[0] != VERSION) return EnvelopeKind.UNKNOWN
|
||||
return when (data[1]) {
|
||||
0x01.toByte() -> EnvelopeKind.PREKEY
|
||||
0x02.toByte() -> EnvelopeKind.RATCHET
|
||||
TYPE_STREAM_CHUNK -> EnvelopeKind.STREAM_CHUNK
|
||||
else -> EnvelopeKind.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package no.zyon.shade.streams
|
||||
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* AES-256-GCM with caller-supplied nonce. Mirror @shade/streams/aead.ts.
|
||||
*
|
||||
* Unlike `CryptoProvider.aesGcmEncrypt` (which generates a random nonce
|
||||
* internally), streams require deterministic nonces derived from
|
||||
* `(laneId, seq)`. Returns the ciphertext concatenated with the 16-byte
|
||||
* authentication tag — same layout SubtleCrypto produces.
|
||||
*/
|
||||
|
||||
const val AEAD_TAG_BYTES = 16
|
||||
|
||||
fun aesGcmEncryptWithNonce(
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
plaintext: ByteArray,
|
||||
aad: ByteArray,
|
||||
): ByteArray {
|
||||
require(nonce.size == StreamConstants.STREAM_NONCE_BYTES) {
|
||||
"AES-GCM nonce must be ${StreamConstants.STREAM_NONCE_BYTES} bytes"
|
||||
}
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val spec = GCMParameterSpec(128, nonce)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), spec)
|
||||
cipher.updateAAD(aad)
|
||||
return cipher.doFinal(plaintext)
|
||||
}
|
||||
|
||||
fun aesGcmDecryptWithNonce(
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
ciphertext: ByteArray,
|
||||
aad: ByteArray,
|
||||
): ByteArray {
|
||||
require(nonce.size == StreamConstants.STREAM_NONCE_BYTES) {
|
||||
"AES-GCM nonce must be ${StreamConstants.STREAM_NONCE_BYTES} bytes"
|
||||
}
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val spec = GCMParameterSpec(128, nonce)
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), spec)
|
||||
cipher.updateAAD(aad)
|
||||
return cipher.doFinal(ciphertext)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package no.zyon.shade.streams
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Deterministic AEAD nonce + AAD construction for stream chunks.
|
||||
* Mirror @shade/streams/nonce.ts.
|
||||
*
|
||||
* nonce[0..4] = u32_be(laneId)
|
||||
* nonce[4..12] = u64_be(seq)
|
||||
*
|
||||
* aad = streamId(16) || u32_be(laneId) || u64_be(seq) || u8(isLast)
|
||||
*
|
||||
* `seq` is unsigned-u64 on the wire. Kotlin's `Long` is signed; we accept it
|
||||
* for the bit pattern (same as TS `BigInt` would write), so values past
|
||||
* `Long.MAX_VALUE` arrive here as negative signed longs. `ByteBuffer.putLong`
|
||||
* writes the raw 8 bytes regardless of sign — that's what we want.
|
||||
*
|
||||
* Use `java.lang.Long.parseUnsignedLong("…")` to decode JSON strings
|
||||
* representing u64 values larger than 2^63 - 1.
|
||||
*/
|
||||
|
||||
fun buildChunkNonce(laneId: Long, seq: Long): ByteArray {
|
||||
require(laneId in 0L..0xFFFFFFFFL) { "laneId must fit in u32: $laneId" }
|
||||
val out = ByteArray(StreamConstants.STREAM_NONCE_BYTES)
|
||||
val buf = ByteBuffer.wrap(out)
|
||||
buf.putInt(laneId.toInt())
|
||||
buf.putLong(seq)
|
||||
return out
|
||||
}
|
||||
|
||||
fun buildChunkAad(
|
||||
streamId: ByteArray,
|
||||
laneId: Long,
|
||||
seq: Long,
|
||||
isLast: Boolean,
|
||||
): ByteArray {
|
||||
require(streamId.size == StreamConstants.STREAM_ID_BYTES) {
|
||||
"streamId must be ${StreamConstants.STREAM_ID_BYTES} bytes"
|
||||
}
|
||||
require(laneId in 0L..0xFFFFFFFFL) { "laneId must fit in u32: $laneId" }
|
||||
|
||||
val out = ByteArray(StreamConstants.STREAM_ID_BYTES + 4 + 8 + 1)
|
||||
streamId.copyInto(out, 0)
|
||||
val buf = ByteBuffer.wrap(out, StreamConstants.STREAM_ID_BYTES, 4 + 8 + 1)
|
||||
buf.putInt(laneId.toInt())
|
||||
buf.putLong(seq)
|
||||
out[out.size - 1] = if (isLast) 0x01 else 0x00
|
||||
return out
|
||||
}
|
||||
Binary file not shown.
@@ -1,18 +1,36 @@
|
||||
package no.zyon.shade
|
||||
|
||||
import no.zyon.shade.backup.deriveBackupKey
|
||||
import no.zyon.shade.crypto.TinkProvider
|
||||
import no.zyon.shade.fingerprint.computeFingerprint
|
||||
import no.zyon.shade.group.encodeSenderHeader
|
||||
import no.zyon.shade.group.senderKeyStep
|
||||
import no.zyon.shade.group.senderSignedBytes
|
||||
import no.zyon.shade.protocol.deriveInitialRootKey
|
||||
import no.zyon.shade.protocol.kdfChainKey
|
||||
import no.zyon.shade.protocol.kdfRootKey
|
||||
import no.zyon.shade.serialization.StreamChunkWire
|
||||
import no.zyon.shade.serialization.StreamChunkWireFormat
|
||||
import no.zyon.shade.serialization.WireFormat
|
||||
import no.zyon.shade.streams.aesGcmDecryptWithNonce
|
||||
import no.zyon.shade.streams.aesGcmEncryptWithNonce
|
||||
import no.zyon.shade.streams.buildChunkAad
|
||||
import no.zyon.shade.streams.buildChunkNonce
|
||||
import no.zyon.shade.streams.deriveLaneKey
|
||||
import no.zyon.shade.streams.deriveStreamKey
|
||||
import no.zyon.shade.types.PreKeyMessage
|
||||
import no.zyon.shade.types.RatchetMessage
|
||||
import no.zyon.shade.types.ShadeEnvelope
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONArray
|
||||
import java.nio.ByteBuffer
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Cross-platform test vectors. MUST match the TypeScript implementation
|
||||
@@ -25,6 +43,7 @@ class CrossPlatformVectorTest {
|
||||
|
||||
private val crypto = TinkProvider()
|
||||
private val vectorsDir = File("../../test-vectors")
|
||||
private val expectedVersion = 2
|
||||
|
||||
private fun fromHex(str: String): ByteArray {
|
||||
val bytes = ByteArray(str.length / 2)
|
||||
@@ -39,10 +58,40 @@ class CrossPlatformVectorTest {
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private data class VectorFile(val version: Int, val vectors: JSONArray)
|
||||
|
||||
private fun loadVectors(name: String): JSONArray {
|
||||
val file = File(vectorsDir, name)
|
||||
val content = file.readText()
|
||||
return JSONObject(content).getJSONArray("vectors")
|
||||
val obj = JSONObject(content)
|
||||
val version = obj.getInt("version")
|
||||
assertEquals("Unexpected vector schema version in $name", expectedVersion, version)
|
||||
return obj.getJSONArray("vectors")
|
||||
}
|
||||
|
||||
private fun encodeRatchetHeader(
|
||||
dhPublicKey: ByteArray,
|
||||
previousCounter: Int,
|
||||
counter: Int,
|
||||
): ByteArray {
|
||||
val buf = ByteBuffer.allocate(40)
|
||||
buf.put(dhPublicKey)
|
||||
buf.putInt(previousCounter)
|
||||
buf.putInt(counter)
|
||||
return buf.array()
|
||||
}
|
||||
|
||||
private fun aesGcmEncryptDeterministic(
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
plaintext: ByteArray,
|
||||
aad: ByteArray,
|
||||
): ByteArray {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val spec = GCMParameterSpec(128, nonce)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), spec)
|
||||
cipher.updateAAD(aad)
|
||||
return cipher.doFinal(plaintext)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -106,31 +155,347 @@ class CrossPlatformVectorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wireFormatVectorsMatch() {
|
||||
fun wireFormatRatchetVectorsMatch() {
|
||||
val vectors = loadVectors("wire-format.json")
|
||||
val v = vectors.getJSONObject(0)
|
||||
val m = v.getJSONObject("message")
|
||||
var found = false
|
||||
for (i in 0 until vectors.length()) {
|
||||
val v = vectors.getJSONObject(i)
|
||||
if (v.optString("kind") != "ratchet") continue
|
||||
found = true
|
||||
val m = v.getJSONObject("message")
|
||||
|
||||
val msg = RatchetMessage(
|
||||
dhPublicKey = fromHex(m.getString("dhPublicKey")),
|
||||
previousCounter = m.getInt("previousCounter"),
|
||||
counter = m.getInt("counter"),
|
||||
ciphertext = fromHex(m.getString("ciphertext")),
|
||||
nonce = fromHex(m.getString("nonce")),
|
||||
)
|
||||
val envelope = ShadeEnvelope(
|
||||
type = ShadeEnvelope.EnvelopeType.RATCHET,
|
||||
content = msg,
|
||||
timestamp = 0,
|
||||
senderAddress = "",
|
||||
)
|
||||
val encoded = WireFormat.encodeEnvelope(envelope)
|
||||
assertEquals(v.getString("encoded"), hex(encoded))
|
||||
val msg = RatchetMessage(
|
||||
dhPublicKey = fromHex(m.getString("dhPublicKey")),
|
||||
previousCounter = m.getInt("previousCounter"),
|
||||
counter = m.getInt("counter"),
|
||||
ciphertext = fromHex(m.getString("ciphertext")),
|
||||
nonce = fromHex(m.getString("nonce")),
|
||||
)
|
||||
val envelope = ShadeEnvelope(
|
||||
type = ShadeEnvelope.EnvelopeType.RATCHET,
|
||||
content = msg,
|
||||
timestamp = 0,
|
||||
senderAddress = "",
|
||||
)
|
||||
val encoded = WireFormat.encodeEnvelope(envelope)
|
||||
assertEquals(v.getString("encoded"), hex(encoded))
|
||||
|
||||
// Roundtrip decode
|
||||
val decoded = WireFormat.decodeEnvelope(encoded)
|
||||
assertEquals(ShadeEnvelope.EnvelopeType.RATCHET, decoded.type)
|
||||
val rm = decoded.content as RatchetMessage
|
||||
assertEquals(msg.counter, rm.counter)
|
||||
val decoded = WireFormat.decodeEnvelope(encoded)
|
||||
assertEquals(ShadeEnvelope.EnvelopeType.RATCHET, decoded.type)
|
||||
val rm = decoded.content as RatchetMessage
|
||||
assertEquals(msg.counter, rm.counter)
|
||||
assertEquals(hex(msg.ciphertext), hex(rm.ciphertext))
|
||||
}
|
||||
assertTrue("No ratchet wire vectors found", found)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wireFormatPreKeyVectorsMatch() {
|
||||
val vectors = loadVectors("wire-format.json")
|
||||
var matched = 0
|
||||
for (i in 0 until vectors.length()) {
|
||||
val v = vectors.getJSONObject(i)
|
||||
if (v.optString("kind") != "prekey") continue
|
||||
matched++
|
||||
val m = v.getJSONObject("message")
|
||||
val inner = m.getJSONObject("inner")
|
||||
|
||||
val innerMsg = RatchetMessage(
|
||||
dhPublicKey = fromHex(inner.getString("dhPublicKey")),
|
||||
previousCounter = inner.getInt("previousCounter"),
|
||||
counter = inner.getInt("counter"),
|
||||
ciphertext = fromHex(inner.getString("ciphertext")),
|
||||
nonce = fromHex(inner.getString("nonce")),
|
||||
)
|
||||
val preKeyId: Int? = if (m.isNull("preKeyId")) null else m.getInt("preKeyId")
|
||||
val pre = PreKeyMessage(
|
||||
registrationId = m.getInt("registrationId"),
|
||||
preKeyId = preKeyId,
|
||||
signedPreKeyId = m.getInt("signedPreKeyId"),
|
||||
ephemeralKey = fromHex(m.getString("ephemeralKey")),
|
||||
identityDHKey = fromHex(m.getString("identityDHKey")),
|
||||
message = innerMsg,
|
||||
)
|
||||
val envelope = ShadeEnvelope(
|
||||
type = ShadeEnvelope.EnvelopeType.PREKEY,
|
||||
content = pre,
|
||||
timestamp = 0,
|
||||
senderAddress = "",
|
||||
)
|
||||
val encoded = WireFormat.encodeEnvelope(envelope)
|
||||
assertEquals(v.getString("encoded"), hex(encoded))
|
||||
|
||||
val decoded = WireFormat.decodeEnvelope(encoded)
|
||||
assertEquals(ShadeEnvelope.EnvelopeType.PREKEY, decoded.type)
|
||||
val dm = decoded.content as PreKeyMessage
|
||||
assertEquals(pre.registrationId, dm.registrationId)
|
||||
assertEquals(pre.preKeyId, dm.preKeyId)
|
||||
assertEquals(pre.signedPreKeyId, dm.signedPreKeyId)
|
||||
assertEquals(hex(pre.ephemeralKey), hex(dm.ephemeralKey))
|
||||
assertEquals(hex(innerMsg.ciphertext), hex(dm.message.ciphertext))
|
||||
}
|
||||
assertTrue("Expected at least 2 prekey vectors", matched >= 2)
|
||||
}
|
||||
|
||||
private fun findVector(arr: JSONArray, prefix: String): JSONObject {
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
if (o.getString("description").startsWith(prefix)) return o
|
||||
}
|
||||
throw AssertionError("Vector with description prefix '$prefix' not found")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamsVectorsMatch() {
|
||||
val vectors = loadVectors("streams.json")
|
||||
|
||||
// 1. deriveStreamKey
|
||||
val sk = findVector(vectors, "deriveStreamKey")
|
||||
val streamSecret = fromHex(sk.getString("streamSecret"))
|
||||
val streamId = fromHex(sk.getString("streamId"))
|
||||
val streamKey = deriveStreamKey(crypto, streamSecret, streamId)
|
||||
assertEquals(sk.getString("streamKey"), hex(streamKey))
|
||||
|
||||
// 2. deriveLaneKey
|
||||
val lk = findVector(vectors, "deriveLaneKey")
|
||||
val lkStreamKey = fromHex(lk.getString("streamKey"))
|
||||
val lkStreamId = fromHex(lk.getString("streamId"))
|
||||
val lanes = lk.getJSONArray("lanes")
|
||||
for (i in 0 until lanes.length()) {
|
||||
val lane = lanes.getJSONObject(i)
|
||||
val laneId = lane.getLong("laneId")
|
||||
val k = deriveLaneKey(crypto, lkStreamKey, lkStreamId, laneId)
|
||||
assertEquals(lane.getString("laneKey"), hex(k))
|
||||
}
|
||||
|
||||
// 3. buildChunkNonce
|
||||
val nv = findVector(vectors, "buildChunkNonce")
|
||||
val nonces = nv.getJSONArray("nonces")
|
||||
for (i in 0 until nonces.length()) {
|
||||
val n = nonces.getJSONObject(i)
|
||||
val laneId = n.getLong("laneId")
|
||||
val seq = java.lang.Long.parseUnsignedLong(n.getString("seq"))
|
||||
val out = buildChunkNonce(laneId, seq)
|
||||
assertEquals(n.getString("nonce"), hex(out))
|
||||
}
|
||||
|
||||
// 4. buildChunkAad
|
||||
val av = findVector(vectors, "buildChunkAad")
|
||||
val avStreamId = fromHex(av.getString("streamId"))
|
||||
val cases = av.getJSONArray("cases")
|
||||
for (i in 0 until cases.length()) {
|
||||
val c = cases.getJSONObject(i)
|
||||
val laneId = c.getLong("laneId")
|
||||
val seq = java.lang.Long.parseUnsignedLong(c.getString("seq"))
|
||||
val isLast = c.getBoolean("isLast")
|
||||
val out = buildChunkAad(avStreamId, laneId, seq, isLast)
|
||||
assertEquals(c.getString("aad"), hex(out))
|
||||
}
|
||||
|
||||
// 5. End-to-end chunk encrypt + decrypt
|
||||
val ev = findVector(vectors, "End-to-end chunk encrypt")
|
||||
val laneKey = fromHex(ev.getString("laneKey"))
|
||||
val nonce = fromHex(ev.getString("nonce"))
|
||||
val aad = fromHex(ev.getString("aad"))
|
||||
val plaintext = fromHex(ev.getString("plaintext"))
|
||||
val ct = aesGcmEncryptWithNonce(laneKey, nonce, plaintext, aad)
|
||||
assertEquals(ev.getString("ciphertext"), hex(ct))
|
||||
val pt = aesGcmDecryptWithNonce(laneKey, nonce, fromHex(ev.getString("ciphertext")), aad)
|
||||
assertEquals(ev.getString("plaintext"), hex(pt))
|
||||
|
||||
// 6. Wire 0x11 envelope encode/decode
|
||||
val wv = findVector(vectors, "Wire 0x11")
|
||||
val wire = StreamChunkWire(
|
||||
streamId = fromHex(wv.getString("streamId")),
|
||||
laneId = wv.getLong("laneId"),
|
||||
seq = java.lang.Long.parseUnsignedLong(wv.getString("seq")),
|
||||
isLast = wv.getBoolean("isLast"),
|
||||
nonce = fromHex(wv.getString("nonce")),
|
||||
aad = fromHex(wv.getString("extraAad")),
|
||||
ciphertext = fromHex(wv.getString("ciphertext")),
|
||||
)
|
||||
val encoded = StreamChunkWireFormat.encodeStreamChunk(wire)
|
||||
assertEquals(wv.getString("encoded"), hex(encoded))
|
||||
|
||||
val decoded = StreamChunkWireFormat.decodeStreamChunk(encoded)
|
||||
assertEquals(hex(wire.streamId), hex(decoded.streamId))
|
||||
assertEquals(wire.laneId, decoded.laneId)
|
||||
assertEquals(wire.seq, decoded.seq)
|
||||
assertEquals(wire.isLast, decoded.isLast)
|
||||
assertEquals(hex(wire.nonce), hex(decoded.nonce))
|
||||
assertEquals(hex(wire.ciphertext), hex(decoded.ciphertext))
|
||||
|
||||
// 7. Envelope-type inspector
|
||||
assertEquals(
|
||||
StreamChunkWireFormat.EnvelopeKind.STREAM_CHUNK,
|
||||
StreamChunkWireFormat.inspectEnvelopeType(encoded),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backupVectorsMatch() {
|
||||
val vectors = loadVectors("backup.json")
|
||||
|
||||
val kv = findVector(vectors, "Backup v1: HKDF")
|
||||
val backupKey = deriveBackupKey(crypto, kv.getString("passphrase"), fromHex(kv.getString("salt")))
|
||||
assertEquals(kv.getString("backupKey"), hex(backupKey))
|
||||
|
||||
val ev = findVector(vectors, "Backup v1: AES-256-GCM")
|
||||
val ct = aesGcmEncryptDeterministic(
|
||||
fromHex(ev.getString("backupKey")),
|
||||
fromHex(ev.getString("nonce")),
|
||||
fromHex(ev.getString("plaintext")),
|
||||
ByteArray(0),
|
||||
)
|
||||
assertEquals(ev.getString("ciphertext"), hex(ct))
|
||||
|
||||
val pt = crypto.aesGcmDecrypt(
|
||||
fromHex(ev.getString("backupKey")),
|
||||
fromHex(ev.getString("ciphertext")),
|
||||
fromHex(ev.getString("nonce")),
|
||||
null,
|
||||
)
|
||||
assertEquals(ev.getString("plaintext"), hex(pt))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupSenderKeyVectorsMatch() {
|
||||
val vectors = loadVectors("group.json")
|
||||
|
||||
// 1. Header AAD
|
||||
val hv = findVector(vectors, "Sender header AAD")
|
||||
val aad = encodeSenderHeader(
|
||||
hv.getString("groupId"),
|
||||
hv.getString("senderAddress"),
|
||||
hv.getInt("iteration"),
|
||||
)
|
||||
assertEquals(hv.getString("aad"), hex(aad))
|
||||
|
||||
// 2. Sender-key step
|
||||
val sv = findVector(vectors, "Sender-key step")
|
||||
val step = senderKeyStep(
|
||||
crypto,
|
||||
fromHex(sv.getString("chainKey")),
|
||||
sv.getString("groupId"),
|
||||
sv.getString("senderAddress"),
|
||||
sv.getInt("iteration"),
|
||||
)
|
||||
assertEquals(sv.getString("newChainKey"), hex(step.newChainKey))
|
||||
assertEquals(sv.getString("messageKey"), hex(step.messageKey))
|
||||
assertEquals(sv.getString("aad"), hex(step.aad))
|
||||
|
||||
val ct = aesGcmEncryptDeterministic(
|
||||
step.messageKey,
|
||||
fromHex(sv.getString("nonce")),
|
||||
fromHex(sv.getString("plaintext")),
|
||||
step.aad,
|
||||
)
|
||||
assertEquals(sv.getString("ciphertext"), hex(ct))
|
||||
|
||||
// 3. Ed25519 verify on the recorded signature
|
||||
val signed = senderSignedBytes(step.aad, ct)
|
||||
val ok = crypto.verify(
|
||||
fromHex(sv.getString("signingPublicKey")),
|
||||
signed,
|
||||
fromHex(sv.getString("signature")),
|
||||
)
|
||||
assertTrue("Sender-key signature verification failed", ok)
|
||||
|
||||
// 4. Decrypt roundtrip
|
||||
val pt = crypto.aesGcmDecrypt(
|
||||
step.messageKey,
|
||||
fromHex(sv.getString("ciphertext")),
|
||||
fromHex(sv.getString("nonce")),
|
||||
step.aad,
|
||||
)
|
||||
assertEquals(sv.getString("plaintext"), hex(pt))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storageHkdfVectorsMatch() {
|
||||
val vectors = loadVectors("storage-hkdf.json")
|
||||
|
||||
val sv = findVector(vectors, "Storage HKDF: storageKey")
|
||||
val storageKey = crypto.hkdf(
|
||||
fromHex(sv.getString("masterKey")),
|
||||
ByteArray(0),
|
||||
"shade-storage-v1".toByteArray(Charsets.UTF_8),
|
||||
32,
|
||||
)
|
||||
assertEquals(sv.getString("storageKey"), hex(storageKey))
|
||||
|
||||
val fv = findVector(vectors, "Storage HKDF: fieldKey")
|
||||
val fStorageKey = fromHex(fv.getString("storageKey"))
|
||||
val fields = fv.getJSONArray("fields")
|
||||
for (i in 0 until fields.length()) {
|
||||
val f = fields.getJSONObject(i)
|
||||
val info = "shade-field-v1:${f.getString("table")}:${f.getString("column")}"
|
||||
.toByteArray(Charsets.UTF_8)
|
||||
val k = crypto.hkdf(fStorageKey, ByteArray(0), info, 32)
|
||||
assertEquals(f.getString("fieldKey"), hex(k))
|
||||
}
|
||||
|
||||
val nv = findVector(vectors, "Storage HKDF: rowNonce")
|
||||
val rowKey = fromHex(nv.getString("rowKey"))
|
||||
val nonces = nv.getJSONArray("nonces")
|
||||
for (i in 0 until nonces.length()) {
|
||||
val n = nonces.getJSONObject(i)
|
||||
val info = "shade-row-nonce-v1:${n.getString("table")}:${n.getString("pk")}"
|
||||
.toByteArray(Charsets.UTF_8)
|
||||
val out = crypto.hkdf(rowKey, ByteArray(0), info, 12)
|
||||
assertEquals(n.getString("nonce"), hex(out))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ratchetStepRoundtripMatches() {
|
||||
val vectors = loadVectors("ratchet-step.json")
|
||||
assertTrue("ratchet-step vectors expected", vectors.length() > 0)
|
||||
|
||||
for (i in 0 until vectors.length()) {
|
||||
val v = vectors.getJSONObject(i)
|
||||
val inputs = v.getJSONObject("inputs")
|
||||
val derived = v.getJSONObject("derived")
|
||||
|
||||
val rootKey = fromHex(inputs.getString("rootKey"))
|
||||
val dhSendPriv = fromHex(inputs.getString("dhSendPrivateKey"))
|
||||
val dhSendPub = fromHex(inputs.getString("dhSendPublicKey"))
|
||||
val dhRemotePub = fromHex(inputs.getString("dhRemotePublicKey"))
|
||||
val plaintext = fromHex(inputs.getString("plaintext"))
|
||||
val nonce = fromHex(inputs.getString("nonce"))
|
||||
val previousCounter = inputs.getInt("previousCounter")
|
||||
val counter = inputs.getInt("counter")
|
||||
|
||||
// 1. DH
|
||||
val dhOutput = crypto.x25519(dhSendPriv, dhRemotePub)
|
||||
assertEquals(derived.getString("dhOutput"), hex(dhOutput))
|
||||
|
||||
// 2. kdfRootKey
|
||||
val root = kdfRootKey(crypto, rootKey, dhOutput)
|
||||
assertEquals(derived.getString("newRootKey"), hex(root.newRootKey))
|
||||
assertEquals(derived.getString("chainKey"), hex(root.chainKey))
|
||||
|
||||
// 3. kdfChainKey
|
||||
val chain = kdfChainKey(crypto, root.chainKey)
|
||||
assertEquals(derived.getString("newChainKey"), hex(chain.newChainKey))
|
||||
assertEquals(derived.getString("messageKey"), hex(chain.messageKey))
|
||||
|
||||
// 4. Header AAD
|
||||
val aad = encodeRatchetHeader(dhSendPub, previousCounter, counter)
|
||||
assertEquals(derived.getString("aad"), hex(aad))
|
||||
|
||||
// 5. AES-GCM encrypt with fixed nonce
|
||||
val ciphertext = aesGcmEncryptDeterministic(chain.messageKey, nonce, plaintext, aad)
|
||||
assertEquals(v.getString("ciphertext"), hex(ciphertext))
|
||||
|
||||
// 6. Roundtrip decrypt
|
||||
val recovered = crypto.aesGcmDecrypt(
|
||||
chain.messageKey,
|
||||
fromHex(v.getString("ciphertext")),
|
||||
nonce,
|
||||
aad,
|
||||
)
|
||||
assertEquals(inputs.getString("plaintext"), hex(recovered))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user