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.
Reference in New Issue
Block a user