feat(mobile): decode page-bundle download windows natively (#31817)

* feat(mobile): decode page-bundle download windows natively

Each 384 KiB window of the page-bundle download cost about 200-300 ms of
Hermes JS time (tweetnacl secretbox open, fflate gunzip, a base64 re-encode
check and the TextDecoder polyfill), which kept the JS thread busy for the
whole download.

The phone now opens v2 frames with react-native-libsodium (secretbox open and
strict base64) and decodes ranges with a local Expo module,
orca-bundle-window-codec (bounded gunzip via system zlib on iOS and
java.util.zip.Inflater on Android, plus strict UTF-8). The shared v2 session
takes these as optional primitives; the desktop keeps tweetnacl.

If any native piece is missing or fails a known-answer probe, the phone keeps
today's JS path, with the base64 re-encode replaced by an O(1) canonical check.

* refactor(mobile): name the native decode for every inbound frame, not just bundle windows

The native secretbox, base64 and UTF-8 primitives decode every inbound v2
frame on the phone, so the files and the Expo module are renamed for what
they cover (paired-e2ee-native-*, orca-gzip-utf8-codec). A throw from the
native UTF-8 decode now falls back to TextDecoder, since the frame is
already open and its counter advanced.

* test(mobile): pin the TextDecoder fallback when native UTF-8 throws

* fix(mobile): hand the native decode to the transport from the app entry

The transport modules are reached by shared, desktop and e2e code. Importing
the native bindings there pulled expo-modules-core into the e2e typecheck,
whose global types make NODE_ENV required and broke ~20 e2e files. The app
entry now provides the bindings; without them the transport runs the JS path.

* fix(mobile): a native-codec lookup before the app provides bindings does not pin the JS path
This commit is contained in:
Jinwoo Hong
2026-10-22 20:55:22 -04:00
committed by GitHub
parent ca1e870dbb
commit 5a097e7739
32 changed files with 1205 additions and 10 deletions
+5
View File
@@ -1,3 +1,8 @@
// Headless notification launches do not mount the router layout.
import './src/notifications/push-background-dismissal'
import { loadPairedE2EENativeBindings } from './src/transport/paired-e2ee-native-bindings'
import { providePairedE2EENativeBindings } from './src/transport/paired-e2ee-native-codec'
import 'expo-router/entry'
// Why here: only the app entry may load native modules; tests and desktop code reach the transport.
providePairedE2EENativeBindings(loadPairedE2EENativeBindings)
@@ -0,0 +1 @@
/build/
@@ -0,0 +1,21 @@
apply plugin: 'com.android.library'
group = 'expo.modules.orcagziputf8codec'
version = '0.0.1'
def expoModulesCorePlugin = new File(project(':expo-modules-core').projectDir.absolutePath, 'ExpoModulesCorePlugin.gradle')
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
useDefaultAndroidSdkVersions()
android {
namespace 'expo.modules.orcagziputf8codec'
}
dependencies {
testImplementation 'junit:junit:4.13.2'
// The android.jar used by JVM unit tests stubs org.json; the shared vectors are JSON.
testImplementation 'org.json:json:20240303'
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,111 @@
package expo.modules.orcagziputf8codec
import java.util.zip.CRC32
import java.util.zip.DataFormatException
import java.util.zip.Inflater
class BoundedGunzipException(message: String) : Exception(message)
private const val FLAG_HCRC = 0x02
private const val FLAG_EXTRA = 0x04
private const val FLAG_NAME = 0x08
private const val FLAG_COMMENT = 0x10
private const val FLAG_RESERVED = 0xe0
private const val TRAILER_LENGTH = 8
/**
* Inflates exactly one gzip member, as zlib's gzip mode does on iOS: the header, CRC32 and ISIZE
* are checked, and any byte after the member (trailing data or a second member) is refused.
*
* Never produces more than [outputLimit] bytes. A body that reaches the limit comes back at the
* limit with the rest of the stream unread: the caller sets the limit one past anything it accepts.
*/
fun gunzipBounded(input: ByteArray, outputLimit: Int): ByteArray {
if (outputLimit < 0) {
throw BoundedGunzipException("negative output limit")
}
val bodyStart = gzipBodyStart(input)
val output = ByteArray(outputLimit)
val inflater = Inflater(true)
try {
inflater.setInput(input, bodyStart, input.size - bodyStart)
var produced = 0
while (!inflater.finished()) {
if (produced == outputLimit) {
return output
}
val count = try {
inflater.inflate(output, produced, outputLimit - produced)
} catch (error: DataFormatException) {
throw BoundedGunzipException(error.message ?: "invalid deflate data")
}
if (count == 0 && inflater.needsInput()) {
throw BoundedGunzipException("truncated deflate data")
}
produced += count
}
val trailerStart = input.size - inflater.remaining
if (inflater.remaining < TRAILER_LENGTH) {
throw BoundedGunzipException("truncated gzip trailer")
}
if (inflater.remaining > TRAILER_LENGTH) {
throw BoundedGunzipException("data after the gzip member")
}
val crc = CRC32().apply { update(output, 0, produced) }.value
if (readUint32(input, trailerStart) != crc) {
throw BoundedGunzipException("gzip CRC mismatch")
}
if (readUint32(input, trailerStart + 4) != produced.toLong()) {
throw BoundedGunzipException("gzip length mismatch")
}
return if (produced == outputLimit) output else output.copyOf(produced)
} finally {
inflater.end()
}
}
/** RFC 1952 header, checked as strictly as zlib checks it: reserved flags and FHCRC included. */
private fun gzipBodyStart(input: ByteArray): Int {
fun byteAt(index: Int): Int {
if (index >= input.size) {
throw BoundedGunzipException("truncated gzip header")
}
return input[index].toInt() and 0xff
}
if (byteAt(0) != 0x1f || byteAt(1) != 0x8b) {
throw BoundedGunzipException("not gzip")
}
if (byteAt(2) != 8) {
throw BoundedGunzipException("unknown gzip compression method")
}
val flags = byteAt(3)
if (flags and FLAG_RESERVED != 0) {
throw BoundedGunzipException("reserved gzip flags set")
}
// MTIME, XFL and OS are not read, but a header without them is truncated.
byteAt(9)
var cursor = 10
if (flags and FLAG_EXTRA != 0) {
cursor += 2 + (byteAt(cursor) or (byteAt(cursor + 1) shl 8))
}
if (flags and FLAG_NAME != 0) {
while (byteAt(cursor++) != 0) {}
}
if (flags and FLAG_COMMENT != 0) {
while (byteAt(cursor++) != 0) {}
}
if (flags and FLAG_HCRC != 0) {
val expected = byteAt(cursor) or (byteAt(cursor + 1) shl 8)
val crc = CRC32().apply { update(input, 0, cursor) }.value and 0xffff
if (crc != expected.toLong()) {
throw BoundedGunzipException("gzip header CRC mismatch")
}
cursor += 2
}
return cursor
}
private fun readUint32(input: ByteArray, offset: Int): Long =
(0 until 4).fold(0L) { value, index ->
value or ((input[offset + index].toLong() and 0xff) shl (8 * index))
}
@@ -0,0 +1,20 @@
package expo.modules.orcagziputf8codec
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
// Synchronous on purpose: each call is a few ms per window, and only a sync call reads its
// Uint8Array argument without a thread hop.
class OrcaGzipUtf8CodecModule : Module() {
override fun definition() = ModuleDefinition {
Name("OrcaGzipUtf8Codec")
Function("gunzip") { input: ByteArray, outputLimit: Int ->
gunzipBounded(input, outputLimit)
}
Function("decodeUtf8") { input: ByteArray ->
decodeStrictUtf8(input)
}
}
}
@@ -0,0 +1,30 @@
package expo.modules.orcagziputf8codec
import java.nio.ByteBuffer
import java.nio.charset.CharacterCodingException
import java.nio.charset.CodingErrorAction
import java.nio.charset.StandardCharsets
/**
* Well-formed UTF-8 with no leading BOM, decoded; null otherwise. Those are the only inputs where
* every decoder agrees, so the caller hands anything else to the JS TextDecoder for its
* replacement and BOM rules.
*/
fun decodeStrictUtf8(input: ByteArray): String? {
if (input.size >= 3 &&
input[0] == 0xef.toByte() &&
input[1] == 0xbb.toByte() &&
input[2] == 0xbf.toByte()
) {
return null
}
return try {
StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(input))
.toString()
} catch (_: CharacterCodingException) {
null
}
}
@@ -0,0 +1,30 @@
package expo.modules.orcagziputf8codec
import java.io.File
import org.json.JSONArray
import org.junit.Assert.assertEquals
import org.junit.Test
class BoundedGunzipTest {
// Shared with the Swift checks and the JS tests, which pin fflate's answer to the same inputs.
private val vectors = JSONArray(File("../tests/gunzip-vectors.json").readText())
@Test
fun `answers every shared vector as zlib's gzip mode does`() {
for (index in 0 until vectors.length()) {
val vector = vectors.getJSONObject(index)
val expected = if (vector.isNull("native")) null else vector.getString("native")
val actual = try {
gunzipBounded(hexBytes(vector.getString("gzipHex")), vector.getInt("outputLimit")).toHex()
} catch (_: BoundedGunzipException) {
null
}
assertEquals(vector.getString("name"), expected, actual)
}
}
private fun hexBytes(hex: String) =
ByteArray(hex.length / 2) { index -> hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() }
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
}
@@ -0,0 +1,29 @@
package expo.modules.orcagziputf8codec
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class StrictUtf8Test {
@Test
fun `decodes well-formed UTF-8 of every sequence length`() {
val text = "{\"a\":\"xé✓🙂\"}"
assertEquals(text, decodeStrictUtf8(text.toByteArray(Charsets.UTF_8)))
assertEquals("", decodeStrictUtf8(ByteArray(0)))
}
@Test
fun `hands back to JS what TextDecoder would repair or strip`() {
val refused = listOf(
byteArrayOf(0xef.toByte(), 0xbb.toByte(), 0xbf.toByte(), 0x41),
byteArrayOf(0xc0.toByte(), 0x80.toByte()),
byteArrayOf(0xed.toByte(), 0xa0.toByte(), 0x80.toByte()),
byteArrayOf(0x41, 0xe2.toByte(), 0x82.toByte()),
byteArrayOf(0x80.toByte()),
byteArrayOf(0xf5.toByte(), 0x80.toByte(), 0x80.toByte(), 0x80.toByte())
)
for (bytes in refused) {
assertNull(decodeStrictUtf8(bytes))
}
}
}
@@ -0,0 +1,9 @@
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["OrcaGzipUtf8CodecModule"]
},
"android": {
"modules": ["expo.modules.orcagziputf8codec.OrcaGzipUtf8CodecModule"]
}
}
@@ -0,0 +1,57 @@
import Foundation
import zlib
struct BoundedGunzipError: Error, CustomStringConvertible {
let description: String
}
/// Inflates exactly one gzip member with zlib's gzip mode, which checks the header, CRC32 and ISIZE.
/// Any byte after the member (trailing data or a second member) is refused.
///
/// Never produces more than `outputLimit` bytes. A body that reaches the limit comes back at the
/// limit with the rest of the stream unread: the caller sets the limit one past anything it accepts.
func gunzipBounded(_ input: Data, outputLimit: Int) throws -> Data {
guard outputLimit >= 0 else {
throw BoundedGunzipError(description: "negative output limit")
}
var output = Data(count: outputLimit)
if outputLimit == 0 {
return output
}
var stream = z_stream()
// 16 + MAX_WBITS: a gzip wrapper only, never zlib or raw deflate.
guard inflateInit2_(&stream, 16 + MAX_WBITS, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size)) == Z_OK
else {
throw BoundedGunzipError(description: "inflateInit2 failed")
}
defer { inflateEnd(&stream) }
let produced = try input.withUnsafeBytes { (inBytes: UnsafeRawBufferPointer) -> Int in
try output.withUnsafeMutableBytes { (outBytes: UnsafeMutableRawBufferPointer) -> Int in
stream.next_in = UnsafeMutablePointer(
mutating: inBytes.bindMemory(to: Bytef.self).baseAddress)
stream.avail_in = uInt(inBytes.count)
stream.next_out = outBytes.bindMemory(to: Bytef.self).baseAddress
stream.avail_out = uInt(outBytes.count)
while true {
let status = inflate(&stream, Z_NO_FLUSH)
if status == Z_STREAM_END {
if stream.avail_in != 0 {
throw BoundedGunzipError(description: "data after the gzip member")
}
return outputLimit - Int(stream.avail_out)
}
if stream.avail_out == 0 && (status == Z_OK || status == Z_BUF_ERROR) {
return outputLimit
}
if status == Z_BUF_ERROR {
throw BoundedGunzipError(description: "truncated gzip stream")
}
if status != Z_OK {
let message = stream.msg.map { String(cString: $0) } ?? "inflate failed (\(status))"
throw BoundedGunzipError(description: message)
}
}
}
}
return produced == outputLimit ? output : output.prefix(produced)
}
@@ -0,0 +1,16 @@
Pod::Spec.new do |s|
s.name = 'OrcaGzipUtf8Codec'
s.version = '0.0.1'
s.summary = 'Bounded gunzip and strict UTF-8 decode'
s.description = s.summary
s.license = { :type => 'MIT' }
s.author = 'Orca'
s.homepage = 'https://onorca.dev'
s.source = { :git => 'https://github.com/stablyai/orca.git' }
s.platforms = { :ios => '15.1' }
s.swift_version = '5.9'
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.libraries = 'z'
s.source_files = '**/*.swift'
end
@@ -0,0 +1,17 @@
import ExpoModulesCore
// Synchronous on purpose: each call is a few ms per window, and only a sync call reads its
// Uint8Array argument without a thread hop.
public class OrcaGzipUtf8CodecModule: Module {
public func definition() -> ModuleDefinition {
Name("OrcaGzipUtf8Codec")
Function("gunzip") { (input: Data, outputLimit: Int) throws -> Data in
try gunzipBounded(input, outputLimit: outputLimit)
}
Function("decodeUtf8") { (input: Data) -> String? in
decodeStrictUtf8(input)
}
}
}
@@ -0,0 +1,11 @@
import Foundation
/// Well-formed UTF-8 with no leading BOM, decoded; nil otherwise. Those are the only inputs where
/// every decoder agrees, so the caller hands anything else to the JS TextDecoder for its
/// replacement and BOM rules.
func decodeStrictUtf8(_ input: Data) -> String? {
if input.starts(with: [0xef, 0xbb, 0xbf]) {
return nil
}
return String(data: input, encoding: .utf8)
}
@@ -0,0 +1,5 @@
{
"name": "orca-gzip-utf8-codec",
"version": "0.0.1",
"private": true
}
@@ -0,0 +1,16 @@
import { requireOptionalNativeModule } from 'expo-modules-core'
export type OrcaGzipUtf8CodecModule = {
/**
* Inflates exactly one gzip member, checking its header, CRC32 and length, and refusing any byte
* after it. Never produces more than `outputLimit` bytes: a body that reaches the limit comes
* back at the limit with the rest unread. Throws on anything else.
*/
gunzip(input: Uint8Array, outputLimit: number): Uint8Array
/** Well-formed UTF-8 with no leading BOM, decoded; null otherwise. */
decodeUtf8(input: Uint8Array): string | null
}
/** Null in a binary built before the module existed. */
export const OrcaGzipUtf8Codec =
requireOptionalNativeModule<OrcaGzipUtf8CodecModule>('OrcaGzipUtf8Codec')
@@ -0,0 +1,54 @@
import Foundation
// The codec's decisions, compiled and run without a device, from the module directory:
//
// swiftc -O -o /tmp/gzip-utf8-codec-checks ios/BoundedGunzip.swift \
// ios/StrictUtf8.swift tests/GzipUtf8CodecChecks.swift && /tmp/gzip-utf8-codec-checks
@main struct GzipUtf8CodecChecks {
static var failures = 0
static func check(_ condition: Bool, _ name: String) {
if !condition {
failures += 1
print("FAIL: \(name)")
}
}
static func bytes(hex: String) -> Data {
var data = Data(capacity: hex.count / 2)
var index = hex.startIndex
while index < hex.endIndex {
let next = hex.index(index, offsetBy: 2)
data.append(UInt8(hex[index..<next], radix: 16)!)
index = next
}
return data
}
static func main() throws {
// Shared with the Kotlin tests and the JS tests, which pin fflate's answer to the same inputs.
let url = URL(fileURLWithPath: "tests/gunzip-vectors.json")
let vectors = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as! [[String: Any]]
for vector in vectors {
let name = vector["name"] as! String
let expected = (vector["native"] as? String).map { bytes(hex: $0) }
let actual = try? gunzipBounded(
bytes(hex: vector["gzipHex"] as! String), outputLimit: vector["outputLimit"] as! Int)
check(actual == expected, "gunzip: \(name)")
}
let text = "{\"a\":\"x\u{e9}\u{2713}\u{1F642}\"}"
check(decodeStrictUtf8(Data(text.utf8)) == text, "utf8: every sequence length")
check(decodeStrictUtf8(Data()) == "", "utf8: empty")
let refused: [[UInt8]] = [
[0xef, 0xbb, 0xbf, 0x41], [0xc0, 0x80], [0xed, 0xa0, 0x80], [0x41, 0xe2, 0x82], [0x80],
[0xf5, 0x80, 0x80, 0x80],
]
for input in refused {
check(decodeStrictUtf8(Data(input)) == nil, "utf8: refuses \(input)")
}
print(failures == 0 ? "all checks passed" : "\(failures) check(s) failed")
exit(failures == 0 ? 0 : 1)
}
}
@@ -0,0 +1,121 @@
[
{
"name": "one member",
"gzipHex": "1f8b08000000000000132bcfcc4bc92f5730b052a8562a482cc950b2524a2c2e4e2d29d64fd4cb2a56d251cacf56b22a292a4dd5513ab70728f968cee40ff3673629d5729543741a92add3886c9dc664eb34215ba729e93a01177932565c010000",
"outputLimit": 349,
"native": "77696e646f7720303a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720313a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720323a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720333a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720343a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720353a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a",
"js": "77696e646f7720303a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720313a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720323a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720333a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720343a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720353a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a"
},
{
"name": "empty member",
"gzipHex": "1f8b080000000000001303000000000000000000",
"outputLimit": 1,
"native": "",
"js": ""
},
{
"name": "reaches the limit",
"gzipHex": "1f8b08000000000000132bcfcc4bc92f5730b052a8562a482cc950b2524a2c2e4e2d29d64fd4cb2a56d251cacf56b22a292a4dd5513ab70728f968cee40ff3673629d5729543741a92add3886c9dc664eb34215ba729e93a01177932565c010000",
"outputLimit": 298,
"native": "77696e646f7720303a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720313a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720323a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720333a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720343a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f772035",
"js": "77696e646f7720303a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720313a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720323a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720333a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720343a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f772035"
},
{
"name": "bomb",
"gzipHex": "1f8b0800000000000013edc13101000000c2a0f54f6d085fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003e031cea38a700001000",
"outputLimit": 1025,
"native": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"js": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"name": "name, comment and header CRC",
"gzipHex": "1f8b081a000000000003612e6a73006e6f74650071bc2bcfcc4bc92f5730b052a8562a482cc950b2524a2c2e4e2d29d64fd4cb2a56d251cacf56b22a292a4dd5513ab70728f968cee40ff3673629d5729543741a92add3886c9dc664eb34215ba729e93a01177932565c010000",
"outputLimit": 349,
"native": "77696e646f7720303a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720313a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720323a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720333a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720343a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720353a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a",
"js": "77696e646f7720303a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720313a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720323a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720333a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720343a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720353a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a"
},
{
"name": "bad header CRC",
"gzipHex": "1f8b081a000000000003612e6a73006e6f74650071bd2bcfcc4bc92f5730b052a8562a482cc950b2524a2c2e4e2d29d64fd4cb2a56d251cacf56b22a292a4dd5513ab70728f968cee40ff3673629d5729543741a92add3886c9dc664eb34215ba729e93a01177932565c010000",
"outputLimit": 349,
"native": null,
"js": "77696e646f7720303a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720313a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720323a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720333a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720343a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720353a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a"
},
{
"name": "reserved flag",
"gzipHex": "1f8b08200000000000032bcfcc4bc92f5730b052a8562a482cc950b2524a2c2e4e2d29d64fd4cb2a56d251cacf56b22a292a4dd5513ab70728f968cee40ff3673629d5729543741a92add3886c9dc664eb34215ba729e93a01177932565c010000",
"outputLimit": 349,
"native": null,
"js": "77696e646f7720303a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720313a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720323a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720333a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720343a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720353a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a"
},
{
"name": "bad CRC",
"gzipHex": "1f8b08000000000000132bcfcc4bc92f5730b052a8562a482cc950b2524a2c2e4e2d29d64fd4cb2a56d251cacf56b22a292a4dd5513ab70728f968cee40ff3673629d5729543741a92add3886c9dc664eb34215ba729e93a01167932565c010000",
"outputLimit": 349,
"native": null,
"js": "77696e646f7720303a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720313a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720323a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720333a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720343a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720353a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a"
},
{
"name": "bad length",
"gzipHex": "1f8b08000000000000132bcfcc4bc92f5730b052a8562a482cc950b2524a2c2e4e2d29d64fd4cb2a56d251cacf56b22a292a4dd5513ab70728f968cee40ff3673629d5729543741a92add3886c9dc664eb34215ba729e93a01177932565c010001",
"outputLimit": 349,
"native": null,
"js": "77696e646f7720303a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720313a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720323a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720333a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720343a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720353a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a"
},
{
"name": "trailing data",
"gzipHex": "1f8b08000000000000132bcfcc4bc92f5730b052a8562a482cc950b2524a2c2e4e2d29d64fd4cb2a56d251cacf56b22a292a4dd5513ab70728f968cee40ff3673629d5729543741a92add3886c9dc664eb34215ba729e93a01177932565c010000000102",
"outputLimit": 349,
"native": null,
"js": "77696e646f7720303a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720313a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720323a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720333a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720343a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720353a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a"
},
{
"name": "second member",
"gzipHex": "1f8b08000000000000132bcfcc4bc92f5730b052a8562a482cc950b2524a2c2e4e2d29d64fd4cb2a56d251cacf56b22a292a4dd5513ab70728f968cee40ff3673629d5729543741a92add3886c9dc664eb34215ba729e93a01177932565c0100001f8b08000000000000132bcfcc4bc92f5730b052a8562a482cc950b2524a2c2e4e2d29d64fd4cb2a56d251cacf56b22a292a4dd5513ab70728f968cee40ff3673629d5729543741a92add3886c9dc664eb34215ba729e93a01177932565c010000",
"outputLimit": 697,
"native": null,
"js": "77696e646f7720303a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720313a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720323a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720333a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720343a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a77696e646f7720353a207b2270617468223a226173736574732f612e6a73222c226f6b223a747275652c22cebc223a22e29c93f09f9982227d0a"
},
{
"name": "truncated body",
"gzipHex": "1f8b08000000000000132bcfcc4bc92f5730b052a8562a482cc950b2524a",
"outputLimit": 349,
"native": null,
"js": null
},
{
"name": "truncated trailer",
"gzipHex": "1f8b08000000000000132bcfcc4bc92f5730b052a8562a482cc950b2524a2c2e4e2d29d64fd4cb2a56d251cacf56b22a292a4dd5513ab70728f968cee40ff3673629d5729543741a92add3886c9dc664eb34215ba729e93a0117793256",
"outputLimit": 349,
"native": null,
"js": null
},
{
"name": "not gzip",
"gzipHex": "6e6f7420677a697020617420616c6c",
"outputLimit": 349,
"native": null,
"js": null
},
{
"name": "unknown method",
"gzipHex": "1f8b09000000000000132bcfcc4bc92f5730b052a8562a482cc950b2524a2c2e4e2d29d64fd4cb2a56d251cacf56b22a292a4dd5513ab70728f968cee40ff3673629d5729543741a92add3886c9dc664eb34215ba729e93a01177932565c010000",
"outputLimit": 349,
"native": null,
"js": null
},
{
"name": "header only",
"gzipHex": "1f8b0800000000000013",
"outputLimit": 349,
"native": null,
"js": null
},
{
"name": "empty input",
"gzipHex": "",
"outputLimit": 349,
"native": null,
"js": null
}
]
+1
View File
@@ -60,6 +60,7 @@
"react-dom": "19.2.8",
"react-native": "^0.83.10",
"react-native-gesture-handler": "^2.31.2",
"react-native-libsodium": "1.7.0",
"react-native-reanimated": "4.3.4",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "^4.24.0",
+42
View File
@@ -297,6 +297,9 @@ importers:
react-native-gesture-handler:
specifier: ^2.31.2
version: 2.31.2(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8)
react-native-libsodium:
specifier: 1.7.0
version: 1.7.0(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8)
react-native-reanimated:
specifier: 4.3.4
version: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8)(supports-color@8.1.1))(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8)
@@ -5315,6 +5318,18 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
libsodium-sumo@0.8.4:
resolution: {integrity: sha512-TMtHShQfVVsaxDygyapvUC3o7YsPgXa/hRWeIgzyFz6w5k/1hirGptCxp1U7XwW3rCskaTTYKgV10v86UiGgNw==}
libsodium-wrappers-sumo@0.8.4:
resolution: {integrity: sha512-ql7hcgulKZ3ekfa2DGAogcCKsWU0diA/0nArz1CFzh93WQdb46/Kj18ka/Hifq6uA3Ush34Pc6vU/6HXeRwUkg==}
libsodium-wrappers@0.8.4:
resolution: {integrity: sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==}
libsodium@0.8.4:
resolution: {integrity: sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==}
lighthouse-logger@1.4.2:
resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==}
@@ -6181,6 +6196,13 @@ packages:
react: '*'
react-native: '*'
react-native-libsodium@1.7.0:
resolution: {integrity: sha512-0I2FcCeyR2HUiUOIvoukteZFMY2WIhPCQ6OOOYy33nQiyGFpwW6I4ky/f18SdSN84n/GLvgC9DOF0fJaKTYYJg==}
engines: {node: '>= 20.20.0'}
peerDependencies:
react: '*'
react-native: '*'
react-native-reanimated@4.3.4:
resolution: {integrity: sha512-kQJMiDROed3eO+asdXfodDxgsDDIGM/ifnHFAiPsKWxmrcPu+Bz4vO0fg79eyuEmKCeQuDczmn4tkTb4RbSVYA==}
peerDependencies:
@@ -13338,6 +13360,18 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
libsodium-sumo@0.8.4: {}
libsodium-wrappers-sumo@0.8.4:
dependencies:
libsodium-sumo: 0.8.4
libsodium-wrappers@0.8.4:
dependencies:
libsodium: 0.8.4
libsodium@0.8.4: {}
lighthouse-logger@1.4.2(supports-color@8.1.1):
dependencies:
debug: 2.6.9(supports-color@8.1.1)
@@ -14373,6 +14407,14 @@ snapshots:
react: 19.2.8
react-native: 0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1)
react-native-libsodium@1.7.0(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8):
dependencies:
'@noble/hashes': 1.8.0
libsodium-wrappers: 0.8.4
libsodium-wrappers-sumo: 0.8.4
react: 19.2.8
react-native: 0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1)
react-native-reanimated@4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8)(supports-color@8.1.1))(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8):
dependencies:
react: 19.2.8
+2
View File
@@ -1,5 +1,7 @@
allowBuilds:
esbuild: true
# Its postinstall unpacks the prebuilt libsodium the native builds link.
react-native-libsodium: true
minimumReleaseAge: 4320
# Latest requested releases are still within the three-day delay; exempt only these versions.
+29
View File
@@ -28,3 +28,32 @@ export function decodeBase64Bytes(value: string): Uint8Array {
}
return bytes
}
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
/**
* Null for anything `btoa` would not have produced, which `atob` forgives: missing padding,
* whitespace and stray bits in the last character. Checked in O(1) after the decode instead of
* re-encoding, which costs ~20 ms per 200 KB frame on Hermes.
*/
export function decodeCanonicalBase64Bytes(value: string): Uint8Array | null {
let bytes: Uint8Array
try {
bytes = decodeBase64Bytes(value)
} catch {
return null
}
if (value.length % 4 !== 0) {
return null
}
const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0
// Why: the length equation rules out whitespace, which atob strips.
if (bytes.length !== (value.length / 4) * 3 - padding) {
return null
}
if (padding === 0) {
return bytes
}
const lastSextet = BASE64_ALPHABET.indexOf(value[value.length - padding - 1]!)
return (lastSextet & (padding === 2 ? 0x0f : 0x03)) === 0 ? bytes : null
}
@@ -1,6 +1,12 @@
import nacl from 'tweetnacl'
import type { PairedE2EEPrimitives } from '../../../src/shared/paired-e2ee-primitives'
import { derivePairedE2EEV2KeySchedule } from '../../../src/shared/paired-e2ee-v2-key-schedule'
import { decodeBase64Bytes, encodeBase64Bytes } from './base64-byte-codec'
import {
decodeBase64Bytes,
decodeCanonicalBase64Bytes,
encodeBase64Bytes
} from './base64-byte-codec'
import { pairedE2EENativeCodec } from './paired-e2ee-native-codec'
import * as e2ee from './e2ee'
// Why arrows: resolved per call, so transport tests that mock ./e2ee partially still load.
@@ -17,5 +23,15 @@ export const MOBILE_PAIRED_E2EE_PRIMITIVES: PairedE2EEPrimitives = {
openLegacyText: (frame, sharedKey) => e2ee.decrypt(frame, sharedKey),
sealLegacyBinary: (plaintext, sharedKey) => e2ee.encryptBytes(plaintext, sharedKey),
openLegacyBinary: (frame, sharedKey) => e2ee.decryptBytes(frame, sharedKey),
openSecretbox: (box, nonce, key) => {
const native = pairedE2EENativeCodec()
return native ? native.openSecretbox(box, nonce, key) : nacl.secretbox.open(box, nonce, key)
},
decodeCanonicalBase64: (value) => {
const native = pairedE2EENativeCodec()
return native ? native.decodeBase64(value) : decodeCanonicalBase64Bytes(value)
},
decodeUtf8: (bytes) =>
pairedE2EENativeCodec()?.decodeUtf8(bytes) ?? new TextDecoder().decode(bytes),
deriveV2KeySchedule: derivePairedE2EEV2KeySchedule
}
@@ -1,5 +1,6 @@
import { gzipSync } from 'fflate'
import { describe, expect, it } from 'vitest'
import gunzipVectors from '../../modules/orca-gzip-utf8-codec/tests/gunzip-vectors.json'
import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal'
import { decodeMobileWebBundleRange } from './mobile-web-bundle-range-decode'
@@ -62,4 +63,25 @@ describe('decoding a bundle range', () => {
RAW.byteLength
)
})
// The vectors the native codec's Kotlin and Swift tests run. Where their `native` answer is a
// refusal and `js` is not (bad CRC or length, trailing bytes), this pins fflate's leniency on
// the fallback path.
it('answers the shared gunzip vectors as fflate does', () => {
for (const vector of gunzipVectors) {
let actual: string | null
try {
actual = Buffer.from(
decodeMobileWebBundleRange(
RANGE,
Buffer.from(vector.gzipHex, 'hex'),
vector.outputLimit - 1
)
).toString('hex')
} catch {
actual = null
}
expect(actual, vector.name).toBe(vector.js)
}
})
})
@@ -1,4 +1,5 @@
import { gunzipSync } from 'fflate'
import { pairedE2EENativeCodec } from './paired-e2ee-native-codec'
import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal'
/**
@@ -7,6 +8,9 @@ import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal'
* Inflated into a buffer one byte past the window: fflate fills a supplied `out` and never grows it,
* so a gzip bomb is bounded in memory, not in the time spent inflating. A wrong length is left to the
* fetch's slot checks, where a filled spare byte reads as overlong.
*
* The native codec is stricter than fflate: it also refuses a bad CRC or length, and bytes after
* the member. No host sends those, since every host gzips with node:zlib.
*/
export function decodeMobileWebBundleRange(
range: { readonly path: string; readonly offset: number; readonly encoding: string },
@@ -23,7 +27,10 @@ export function decodeMobileWebBundleRange(
)
}
try {
return gunzipSync(wire, { out: new Uint8Array(expectedLength + 1) })
const native = pairedE2EENativeCodec()
return native
? native.gunzip(wire, expectedLength + 1)
: gunzipSync(wire, { out: new Uint8Array(expectedLength + 1) })
} catch (error) {
throw new MobileWebBundleFetchError(
'range-undecodable',
@@ -1,3 +1,5 @@
import { decodeBase64Bytes } from './base64-byte-codec'
import { pairedE2EENativeCodec } from './paired-e2ee-native-codec'
import { mobileWebBundleChunkRead, mobileWebBundleRangeRead } from './mobile-web-bundle-operations'
import { decodeMobileWebBundleRange } from './mobile-web-bundle-range-decode'
import type { MobileWebBundleManifestReply } from './mobile-web-bundle-reply-schemas'
@@ -78,12 +80,16 @@ export function mobileWebBundleWindowReader(
}
}
/** Metro ships no Buffer; `atob` is the decoder the pairing and E2EE paths already run on Hermes. */
/** Every host encodes with `Buffer#toString('base64')`, so the strict native decoder refuses nothing a
* host sends; `atob` is the fallback the pairing and E2EE paths already run on Hermes. */
function decodeBase64(value: string): Uint8Array {
const binary = atob(value)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
const native = pairedE2EENativeCodec()
if (!native) {
return decodeBase64Bytes(value)
}
const bytes = native.decodeBase64(value)
if (!bytes) {
throw new Error('bundle window is not canonical base64')
}
return bytes
}
@@ -0,0 +1,46 @@
import { NativeModules } from 'react-native'
import { OrcaGzipUtf8Codec } from '../../modules/orca-gzip-utf8-codec/src'
import type { LibsodiumBindings, PairedE2EENativeBindings } from './paired-e2ee-native-codec'
declare global {
// Installed by react-native-libsodium's native install; unknown until checked.
var jsi_from_base64_to_arraybuffer: unknown
var jsi_crypto_secretbox_open_easy: unknown
}
/**
* Null when either native piece is missing from this binary. Only the app entry imports this file:
* shared, desktop and e2e code reach the transport modules and must not load native-module types.
*/
export function loadPairedE2EENativeBindings(): PairedE2EENativeBindings | null {
const sodium = installLibsodium()
return sodium && OrcaGzipUtf8Codec ? { codec: OrcaGzipUtf8Codec, sodium } : null
}
/**
* react-native-libsodium's JS entry installs its JSI functions on import, logs as it does, and
* wraps every call; only its native install and two of the functions are needed here.
*/
function installLibsodium(): LibsodiumBindings | null {
const libsodium: { install?: unknown } | undefined = NativeModules.Libsodium
if (typeof libsodium?.install !== 'function') {
return null
}
libsodium.install()
const fromBase64 = globalThis.jsi_from_base64_to_arraybuffer
const secretboxOpen = globalThis.jsi_crypto_secretbox_open_easy
if (typeof fromBase64 !== 'function' || typeof secretboxOpen !== 'function') {
return null
}
return {
fromBase64: (value, variant) => arrayBufferOf(fromBase64(value, variant)),
secretboxOpen: (box, nonce, key) => arrayBufferOf(secretboxOpen(box, nonce, key))
}
}
function arrayBufferOf(value: unknown): ArrayBuffer {
if (!(value instanceof ArrayBuffer)) {
throw new TypeError('libsodium returned something other than an ArrayBuffer')
}
return value
}
@@ -0,0 +1,302 @@
import { gunzipSync } from 'node:zlib'
import sodium from 'react-native-libsodium'
import nacl from 'tweetnacl'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import {
encodeMobileE2EEV2Transcript,
validateMobileE2EEV2Handshake,
type MobileE2EEV2Ready
} from '../../../src/shared/mobile-e2ee-v2-contract'
import { sealMobileE2EEV2Frame } from '../../../src/shared/mobile-e2ee-v2-framing'
import type { PairedE2EEPrimitives } from '../../../src/shared/paired-e2ee-primitives'
import { PairedE2EEV2ClientSession } from '../../../src/shared/paired-e2ee-v2-client-session'
import { derivePairedE2EEV2KeySchedule } from '../../../src/shared/paired-e2ee-v2-key-schedule'
import type { PairedE2EENativeBindings } from './paired-e2ee-native-codec'
// Under Node, react-native-libsodium resolves to its web build: the same libsodium C, as wasm.
// The codec module's half is a stand-in here; its own Kotlin and Swift tests pin it.
const secretboxOpenCalls = vi.fn()
let bindings: PairedE2EENativeBindings | null = null
function libsodiumBindings(): PairedE2EENativeBindings {
return {
sodium: {
fromBase64: (value, variant) => sodium.from_base64(value, variant).slice().buffer,
secretboxOpen: (box, nonce, key) => {
secretboxOpenCalls(box.byteLength)
return sodium
.crypto_secretbox_open_easy(
new Uint8Array(box),
new Uint8Array(nonce),
new Uint8Array(key)
)
.slice().buffer
}
},
codec: {
gunzip: (input, outputLimit) => new Uint8Array(gunzipSync(input)).slice(0, outputLimit),
decodeUtf8: (input) => {
if (input[0] === 0xef && input[1] === 0xbb && input[2] === 0xbf) {
return null
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(input)
} catch {
return null
}
}
}
}
}
/** A fresh module graph, so the once-only detection runs again with or without native. */
async function load(withNative: boolean) {
bindings = withNative ? libsodiumBindings() : null
vi.resetModules()
const codec = await import('./paired-e2ee-native-codec')
if (withNative) {
codec.providePairedE2EENativeBindings(() => bindings)
}
const primitives = await import('./mobile-paired-e2ee-primitives')
return {
primitives: primitives.MOBILE_PAIRED_E2EE_PRIMITIVES,
native: codec.pairedE2EENativeCodec()
}
}
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
const b64 = (bytes: Uint8Array) => Buffer.from(bytes).toString('base64')
function pairedSession(primitives: PairedE2EEPrimitives) {
const session = PairedE2EEV2ClientSession.create({
primitives,
desktopPublicKeyB64: b64(desktop.publicKey),
transport: 'relay',
relayHostId: 'AbCdEf0123_-xyZ9',
clientNonce: new Uint8Array(32).fill(3),
clientKeyPair: client
})
const ready: MobileE2EEV2Ready = {
type: 'e2ee_ready',
v: 2,
desktopPublicKeyB64: b64(desktop.publicKey),
clientNonceB64: session.hello.clientNonceB64,
desktopNonceB64: b64(new Uint8Array(32).fill(4)),
selection: { framing: 2, payloadKinds: ['text', 'binary'] },
context: session.hello.context
}
expect(session.acceptReady(ready)).toBe(true)
const handshake = validateMobileE2EEV2Handshake(session.hello, ready)!
const schedule = derivePairedE2EEV2KeySchedule({
sharedSecret: nacl.box.before(client.publicKey, desktop.secretKey),
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
return { session, schedule }
}
let seed = 7
function random(below: number): number {
seed = (seed * 1103515245 + 12345) % 2 ** 31
// Why the shift: an LCG's low bits cycle with short periods.
return Math.floor(seed / 256) % below
}
function randomBytes(length: number): Uint8Array {
return Uint8Array.from({ length }, () => random(256))
}
const PAYLOADS = [
'{"id":1,"result":{"ok":true}}',
'',
'\uFEFF{"bom":"stripped by TextDecoder"}',
'é✓🙂 μ',
JSON.stringify({ dataBase64: b64(randomBytes(150_000)) })
]
/** Every desktop frame for counters 0..n, each preceded by the ways a frame can be wrong. */
function inboundWire(schedule: ReturnType<typeof derivePairedE2EEV2KeySchedule>) {
const wire: { frame: string; plaintext: string | null }[] = []
const seal = (payload: string, counter: bigint, key = schedule.desktopToMobileKey) =>
sealMobileE2EEV2Frame({
payload: new TextEncoder().encode(payload),
key,
sessionId: schedule.sessionId,
direction: 'desktop-to-mobile',
payloadKind: 'text',
counter
})
const flipped = (frame: Uint8Array, index: number) => {
const copy = frame.slice()
copy[index]! ^= 1 << random(8)
return copy
}
PAYLOADS.forEach((payload, index) => {
const counter = BigInt(index)
const good = seal(payload, counter)
const goodB64 = b64(good)
const bad = [
flipped(good, random(24)),
flipped(good, 24 + random(16)),
flipped(good, 40 + random(good.length - 40)),
good.subarray(0, -1),
good.subarray(0, 81),
good.subarray(0, 30),
seal(payload, counter, randomBytes(32)),
seal(payload, counter + 1n)
].map(b64)
const nonCanonical = [
goodB64.replace(/=+$/, ''),
`${goodB64.slice(0, 8)}\n${goodB64.slice(8)}`,
`${goodB64} `,
goodB64.replaceAll('+', '-').replaceAll('/', '_'),
`${goodB64}=`
].filter((variant) => variant !== goodB64)
for (const frame of [...bad, ...nonCanonical]) {
wire.push({ frame, plaintext: null })
}
wire.push({
frame: goodB64,
plaintext: new TextDecoder().decode(new TextEncoder().encode(payload))
})
})
return wire
}
describe('the native paired E2EE codec', () => {
beforeAll(async () => {
await sodium.ready
})
it('opens and refuses exactly the frames the JS paths do, never advancing on a refusal', async () => {
const { primitives: nativePrimitives, native } = await load(true)
const { primitives: fallbackPrimitives, native: absent } = await load(false)
expect(native).not.toBeNull()
expect(absent).toBeNull()
// What the desktop's own client session runs: tweetnacl, re-encode, TextDecoder.
const originalPrimitives: PairedE2EEPrimitives = {
...fallbackPrimitives,
openSecretbox: undefined,
decodeCanonicalBase64: undefined,
decodeUtf8: undefined
}
const sessions = [nativePrimitives, fallbackPrimitives, originalPrimitives].map((primitives) =>
pairedSession(primitives)
)
const wire = inboundWire(sessions[0]!.schedule)
expect(wire.filter((entry) => entry.plaintext === null).length).toBeGreaterThan(50)
for (const { session } of sessions) {
expect(wire.map(({ frame }) => session.openText(frame))).toEqual(
wire.map(({ plaintext }) => plaintext)
)
}
})
it('uses native once provided, even after a lookup with no provider', async () => {
const { native: before } = await load(false)
expect(before).toBeNull()
const codec = await import('./paired-e2ee-native-codec')
bindings = libsodiumBindings()
codec.providePairedE2EENativeBindings(() => bindings)
expect(codec.pairedE2EENativeCodec()).not.toBeNull()
})
it('agrees with tweetnacl on boxes either library sealed', async () => {
const { native } = await load(true)
for (let index = 0; index < 300; index++) {
const key = randomBytes(32)
const nonce = randomBytes(24)
const message = randomBytes(random(600))
const boxes = [
sodium.crypto_secretbox_easy(message, nonce, key),
nacl.secretbox(message, nonce, key)
]
expect(boxes[0]).toEqual(boxes[1])
for (const box of boxes) {
expect(native!.openSecretbox(box, nonce, key)).toEqual(message)
const tampered = box.slice()
tampered[random(box.length)]! ^= 1 << random(8)
expect(native!.openSecretbox(tampered, nonce, key)).toBeNull()
expect(nacl.secretbox.open(tampered, nonce, key)).toBeNull()
}
}
})
// The frame is open and the counter advanced before UTF-8, so a native throw must not drop it.
it('falls back to TextDecoder when native UTF-8 throws', async () => {
const { primitives } = await load(true)
bindings!.codec.decodeUtf8 = () => {
throw new Error('native UTF-8 failed')
}
const { session, schedule } = pairedSession(primitives)
const frames = ['{"a":1}', 'é✓🙂'].map((payload, counter) =>
b64(
sealMobileE2EEV2Frame({
payload: new TextEncoder().encode(payload),
key: schedule.desktopToMobileKey,
sessionId: schedule.sessionId,
direction: 'desktop-to-mobile',
payloadKind: 'text',
counter: BigInt(counter)
})
)
)
expect(frames.map((frame) => session.openText(frame))).toEqual(['{"a":1}', 'é✓🙂'])
})
// libsodium sizes its output as the box length minus the MAC, which underflows and crashes.
it('never hands libsodium a box shorter than its MAC', async () => {
const { native } = await load(true)
secretboxOpenCalls.mockClear()
for (let length = 0; length < 16; length++) {
expect(
native!.openSecretbox(new Uint8Array(length), randomBytes(24), randomBytes(32))
).toBeNull()
}
expect(secretboxOpenCalls).not.toHaveBeenCalled()
})
it('accepts exactly the base64 that re-encodes to itself, on both paths', async () => {
const { native, primitives } = await load(true)
const { primitives: fallback } = await load(false)
const reencodes = (value: string) => {
try {
const bytes = Uint8Array.from(atob(value), (char) => char.charCodeAt(0))
return b64(bytes) === value ? bytes : null
} catch {
return null
}
}
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
const noise = `${alphabet}=-_ \n\t.`
const cases = [
'',
'QR==',
'QQ==',
'QQ',
'QQ=',
'Q===',
'====',
'QUJD',
'QUJ D',
'Q=Q=',
'-_8=',
'AB+/'
]
for (let index = 0; index < 5_000; index++) {
let value = b64(randomBytes(random(10)))
if (random(2) === 0) {
const at = random(value.length + 1)
value = value.slice(0, at) + noise[random(noise.length)] + value.slice(at + 1)
}
cases.push(value)
}
for (const value of cases) {
const expected = reencodes(value)
expect(native!.decodeBase64(value), value).toEqual(expected)
expect(primitives.decodeCanonicalBase64!(value), value).toEqual(expected)
expect(fallback.decodeCanonicalBase64!(value), value).toEqual(expected)
}
})
})
@@ -0,0 +1,141 @@
/**
* Inbound decode steps done natively instead of in Hermes JS: every v2 text frame the phone opens
* (base64, secretbox, UTF-8), plus the gunzip of page-bundle ranges.
*/
export type PairedE2EENativeCodec = {
/** Canonical padded base64 only; null for anything else, including what `atob` forgives. */
decodeBase64(value: string): Uint8Array | null
/** XSalsa20-Poly1305 secretbox open; null for a box that fails authentication. */
openSecretbox(box: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array | null
/** Throws on a body that is not one well-formed gzip member. See orca-gzip-utf8-codec. */
gunzip(input: Uint8Array, outputLimit: number): Uint8Array
/** Null where only TextDecoder's replacement or BOM rules give the answer, or native failed. */
decodeUtf8(input: Uint8Array): string | null
}
/** The two react-native-libsodium JSI functions the phone needs; both throw on failure. */
export type LibsodiumBindings = {
fromBase64: (value: string, variant: number) => ArrayBuffer
secretboxOpen: (box: ArrayBuffer, nonce: ArrayBuffer, key: ArrayBuffer) => ArrayBuffer
}
/** What the app entry hands over: libsodium plus the orca-gzip-utf8-codec module. */
export type PairedE2EENativeBindings = {
codec: {
gunzip(input: Uint8Array, outputLimit: number): Uint8Array
decodeUtf8(input: Uint8Array): string | null
}
sodium: LibsodiumBindings
}
// libsodium's sodium_base64_VARIANT_ORIGINAL: the standard alphabet, padded.
const SODIUM_BASE64_ORIGINAL = 1
const SECRETBOX_MAC_BYTES = 16
// A box sealed by tweetnacl under key 0..31 and nonce 0..23, and a gzip member, both of "ok".
const PROBE_BOX_BASE64 = 'sqQBX9c2fQaZ4F6j8gHDpjGU'
const PROBE_GZIP_BASE64 = 'H4sIAAAAAAAAE8vPBgBH3dx5AgAAAA=='
let loadBindings: (() => PairedE2EENativeBindings | null) | null = null
let detected: PairedE2EENativeCodec | null | undefined
/**
* Called once by the app entry. Injected rather than imported so that this module, which shared,
* desktop and e2e code reach, never loads the native modules or their global types.
*/
export function providePairedE2EENativeBindings(load: () => PairedE2EENativeBindings | null): void {
loadBindings = load
// Why: a lookup that ran before this must not pin the JS path for the whole run.
detected = undefined
}
/**
* The native codec, or null when none was provided or any part of it is missing or answers the
* probe wrongly; the caller then runs the JS path. Detected once, on first use.
*/
export function pairedE2EENativeCodec(): PairedE2EENativeCodec | null {
if (detected === undefined) {
detected = detectPairedE2EENativeCodec()
}
return detected
}
function detectPairedE2EENativeCodec(): PairedE2EENativeCodec | null {
try {
const bindings = loadBindings?.() ?? null
if (!bindings) {
return null
}
const { codec: module, sodium } = bindings
const codec: PairedE2EENativeCodec = {
decodeBase64: (value) => {
// Why: libsodium sizes its output from the input, and an empty one has nothing to size.
if (value.length === 0) {
return new Uint8Array(0)
}
try {
return new Uint8Array(sodium.fromBase64(value, SODIUM_BASE64_ORIGINAL))
} catch {
return null
}
},
openSecretbox: (box, nonce, key) => {
// Why: libsodium's output length underflows below the MAC length and crashes the app.
if (box.length < SECRETBOX_MAC_BYTES) {
return null
}
try {
return new Uint8Array(
sodium.secretboxOpen(wholeBuffer(box), wholeBuffer(nonce), wholeBuffer(key))
)
} catch {
// libsodium throws where tweetnacl returns null: a forged or corrupted box.
return null
}
},
gunzip: (input, outputLimit) => module.gunzip(input, outputLimit),
decodeUtf8: (input) => {
// Why: the frame is already open and the counter advanced, so a throw here must not drop it.
try {
return module.decodeUtf8(input)
} catch {
return null
}
}
}
return answersProbe(codec) ? codec : null
} catch {
return null
}
}
function answersProbe(codec: PairedE2EENativeCodec): boolean {
const box = codec.decodeBase64(PROBE_BOX_BASE64)
const gzip = codec.decodeBase64(PROBE_GZIP_BASE64)
if (!box || !gzip) {
return false
}
const key = Uint8Array.from({ length: 32 }, (_, index) => index)
const nonce = Uint8Array.from({ length: 24 }, (_, index) => index)
const opened = codec.openSecretbox(box, nonce, key)
return (
opened !== null &&
codec.decodeUtf8(opened) === 'ok' &&
codec.decodeUtf8(codec.gunzip(gzip, 3)) === 'ok' &&
codec.decodeBase64('b2s=') !== null &&
codec.decodeBase64('b2t=') === null
)
}
/** libsodium reads whole ArrayBuffers, so a view into a larger one is copied out first. */
function wholeBuffer(view: Uint8Array): ArrayBuffer {
const { buffer } = view
if (
buffer instanceof ArrayBuffer &&
view.byteOffset === 0 &&
view.byteLength === buffer.byteLength
) {
return buffer
}
return view.slice().buffer
}
+11 -1
View File
@@ -3,6 +3,13 @@ import type { MobileE2EEPayloadKind } from './mobile-e2ee-v2-contract'
export type MobileE2EEDirection = 'mobile-to-desktop' | 'desktop-to-mobile'
/** NaCl secretbox open: null for a box that fails authentication. */
export type SecretboxOpen = (
box: Uint8Array,
nonce: Uint8Array,
key: Uint8Array
) => Uint8Array | null
const NONCE_LENGTH = 24
const SESSION_ID_LENGTH = 32
const HEADER_LENGTH = SESSION_ID_LENGTH + 1 + 1 + 8
@@ -32,6 +39,7 @@ export function openMobileE2EEV2Frame(args: {
direction: MobileE2EEDirection
payloadKind: MobileE2EEPayloadKind
expectedCounter: bigint
openSecretbox?: SecretboxOpen
}): Uint8Array | null {
validateFrameInputs(args.key, args.sessionId, args.expectedCounter)
if (args.frame.length < NONCE_LENGTH + nacl.secretbox.overheadLength + HEADER_LENGTH) {
@@ -48,7 +56,9 @@ export function openMobileE2EEV2Frame(args: {
return null
}
const plaintext = nacl.secretbox.open(args.frame.subarray(NONCE_LENGTH), nonce, args.key)
// The length check above must stay ahead of this: native libsodium crashes on a box shorter than its MAC.
const openSecretbox = args.openSecretbox ?? nacl.secretbox.open
const plaintext = openSecretbox(args.frame.subarray(NONCE_LENGTH), nonce, args.key)
if (!plaintext) {
return null
}
+8
View File
@@ -1,3 +1,5 @@
import type { SecretboxOpen } from './mobile-e2ee-v2-framing'
// Why: desktop/CLI (Node), web and mobile each keep their own NaCl box and
// base64 adapters (size limits, Hermes typed-array quirks, no Buffer in a
// browser), so the shared handshake core takes them as a parameter.
@@ -23,6 +25,12 @@ export type PairedE2EEPrimitives = {
openLegacyText: (frame: string, sharedKey: Uint8Array) => string | null
sealLegacyBinary: (plaintext: Uint8Array, sharedKey: Uint8Array) => Uint8Array<ArrayBuffer>
openLegacyBinary: (frame: Uint8Array, sharedKey: Uint8Array) => Uint8Array | null
// Why optional: only the phone swaps these, for native code on every inbound v2 frame, since
// Hermes JS is ~100x slower than V8. Absent, the session uses tweetnacl, re-encode and TextDecoder.
openSecretbox?: SecretboxOpen
// Null for anything but canonical padded base64.
decodeCanonicalBase64?: (value: string) => Uint8Array | null
decodeUtf8?: (bytes: Uint8Array) => string
// Why injected: Node uses node:crypto, so the CLI's unbundled runtime needs no extra package.
deriveV2KeySchedule: (args: {
sharedSecret: Uint8Array
+11 -2
View File
@@ -99,7 +99,12 @@ export class PairedE2EEV2ClientSession {
return null
}
const plaintext = this.open(frame, 'text')
return plaintext ? new TextDecoder().decode(plaintext) : null
if (!plaintext) {
return null
}
return this.primitives.decodeUtf8
? this.primitives.decodeUtf8(plaintext)
: new TextDecoder().decode(plaintext)
}
openBinary(frame: Uint8Array): Uint8Array | null {
@@ -124,7 +129,8 @@ export class PairedE2EEV2ClientSession {
sessionId: this.schedule.sessionId,
direction: 'desktop-to-mobile',
payloadKind,
expectedCounter: this.inboundCounter
expectedCounter: this.inboundCounter,
openSecretbox: this.primitives.openSecretbox
})
if (plaintext) {
this.inboundCounter++
@@ -149,6 +155,9 @@ export class PairedE2EEV2ClientSession {
}
private decodeCanonicalBase64(value: string): Uint8Array | null {
if (this.primitives.decodeCanonicalBase64) {
return this.primitives.decodeCanonicalBase64(value)
}
try {
const bytes = this.primitives.decodeBase64(value)
return this.primitives.encodeBase64(bytes) === value ? bytes : null