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

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:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -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,
)
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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))
}
}
}