diff --git a/README.md b/README.md index 0ddbabb..5163fc0 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under `vendor/zennotes/` with their source identity and checksums (`manifest.json`), and `package-lock.json` pins the complete install. No source checkout is used. The vendored set is the core release -[core-2.66.0-core.hf27b6fbd42368c71](https://github.com/ZenNotes/zennotes/releases/tag/core-2.66.0-core.hf27b6fbd42368c71) -(desktop commit `461fe76d`, clean tree). Run `npm run +[core-2.67.0-core.hb828dd60259bc376](https://github.com/ZenNotes/zennotes/releases/tag/core-2.67.0-core.hb828dd60259bc376) +(desktop commit `58ba24ac`, clean tree). Run `npm run boundaries:check` to verify archives, installed versions, singleton editor/React peers, and imports; it refuses an archive built from a dirty upstream tree unless `ZEN_ALLOW_DIRTY_CORE=1` is set for a local try-out. diff --git a/android/app/build.gradle b/android/app/build.gradle index f38c5a1..c2794b4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId "md.zennotes" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 39 - versionName "1.1.36" + versionCode 40 + versionName "1.1.37" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -58,6 +58,9 @@ dependencies { implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation project(':capacitor-android') testImplementation "junit:junit:$junitVersion" + // Local unit tests only see android.jar's stubbed org.json; the shared + // Cloud encryption vectors are read with the real library. + testImplementation "org.json:json:20250517" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" implementation project(':capacitor-cordova-android-plugins') diff --git a/android/app/src/main/java/md/zennotes/CloudFileStream.java b/android/app/src/main/java/md/zennotes/CloudFileStream.java index 1191613..2382535 100644 --- a/android/app/src/main/java/md/zennotes/CloudFileStream.java +++ b/android/app/src/main/java/md/zennotes/CloudFileStream.java @@ -13,6 +13,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Arrays; /** Bounded file inspection and copying, including UTF-8 sequences split across reads. */ final class CloudFileStream { @@ -73,7 +74,19 @@ static void copyVerified(InputStream input, OutputStream output, long expectedBy output.flush(); } - private static MessageDigest digest() { + /** Up to `limit` bytes from the start of a stream, never reading past them. */ + static byte[] readPrefix(InputStream input, int limit) throws IOException { + byte[] buffer = new byte[limit]; + int filled = 0; + while (filled < limit) { + int count = input.read(buffer, filled, limit - filled); + if (count == -1) break; + filled += count; + } + return filled == limit ? buffer : Arrays.copyOf(buffer, filled); + } + + static MessageDigest digest() { try { return MessageDigest.getInstance("SHA-256"); } catch (NoSuchAlgorithmException impossible) { @@ -81,7 +94,7 @@ private static MessageDigest digest() { } } - private static String hex(byte[] bytes) { + static String hex(byte[] bytes) { char[] alphabet = "0123456789abcdef".toCharArray(); char[] out = new char[bytes.length * 2]; for (int i = 0; i < bytes.length; i++) { diff --git a/android/app/src/main/java/md/zennotes/DirectUploadPlugin.java b/android/app/src/main/java/md/zennotes/DirectUploadPlugin.java index ee7440e..6e98da6 100644 --- a/android/app/src/main/java/md/zennotes/DirectUploadPlugin.java +++ b/android/app/src/main/java/md/zennotes/DirectUploadPlugin.java @@ -6,6 +6,7 @@ import android.content.pm.PackageManager; import android.net.Uri; import android.os.Process; +import android.provider.DocumentsContract; import com.getcapacitor.JSObject; import com.getcapacitor.Plugin; @@ -39,6 +40,7 @@ public class DirectUploadPlugin extends Plugin { private static final int CONNECT_TIMEOUT_MS = 30_000; private static final int READ_TIMEOUT_MS = 300_000; private static final int BUFFER_SIZE = 64 * 1024; + private static final int HEADER_READ_LIMIT = 64 * 1024; @PluginMethod public void inspect(PluginCall call) { @@ -145,6 +147,124 @@ public void download(PluginCall call) { }); } + /** + * Read the first bytes of a vault file, so JavaScript can check a staged + * ZNE1 object's header and key commitment before decryptFile opens its + * chunks. At most one chunk, so this never becomes a way to carry a whole + * file across the bridge. + */ + @PluginMethod + public void readHeader(PluginCall call) { + execute(() -> { + Integer length = call.getInt("length"); + if (length == null || length < 1 || length > HEADER_READ_LIMIT) { + call.reject("Invalid Cloud file header request.", "INVALID_HEADER_REQUEST"); + return; + } + try (InputStream input = openVaultFile(call.getString("source"))) { + JSObject result = new JSObject(); + result.put("base64", Base64.encodeToString(CloudFileStream.readPrefix(input, length), Base64.NO_WRAP)); + call.resolve(result); + } catch (Exception error) { + call.reject("Cloud file header read failed.", "CLOUD_FILE_READ_FAILED", error); + } + }); + } + + /** + * Seal a vault file into a ZNE1 object for an end-to-end encrypted Cloud + * vault. JavaScript derives the object key, builds the header and computes + * the AAD, so no parent key crosses the bridge; the file moves in 64 KiB + * chunks and its bytes never enter the WebView. Resolves the ciphertext's + * length and SHA-256, which the upload declares, and the plaintext's, + * which the item metadata records. + */ + @PluginMethod + public void encryptFile(PluginCall call) { + execute(() -> transformZne1(call, true)); + } + + /** + * Open a staged ZNE1 object into a vault staging file. JavaScript has + * already checked the header's key commitment; this confirms the file + * still begins with that header and writes only chunks that authenticate. + * Any failure deletes the partial output, so a truncated or forged object + * never lands as plaintext. + */ + @PluginMethod + public void decryptFile(PluginCall call) { + execute(() -> transformZne1(call, false)); + } + + private void transformZne1(PluginCall call, boolean encrypt) { + String operation = encrypt ? "encryption" : "decryption"; + Zne1FileCipher cipher; + try { + cipher = new Zne1FileCipher(decodeBase64(call, "header"), decodeBase64(call, "objectKey"), + decodeBase64(call, "aad")); + } catch (IllegalArgumentException error) { + call.reject("Invalid Cloud " + operation + " request.", + encrypt ? "INVALID_ENCRYPT_REQUEST" : "INVALID_DECRYPT_REQUEST"); + return; + } + Uri written = null; + try { + Uri target = vaultFileUri(call.getString("destination"), Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + Uri source = vaultFileUri(call.getString("source"), Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (source.equals(target)) throw new IOException("Cloud " + operation + " source and destination must differ."); + JSObject result = new JSObject(); + try (InputStream input = openVaultFile(source.toString())) { + written = target; + try (OutputStream output = "content".equals(target.getScheme()) + ? getContext().getContentResolver().openOutputStream(target, "wt") + : new FileOutputStream(target.getPath())) { + if (output == null) throw new IOException("Cloud " + operation + " destination is unavailable."); + if (encrypt) { + Zne1FileCipher.Sealed sealed = cipher.encrypt(input, output); + result.put("byteLength", sealed.byteLength); + result.put("sha256", sealed.sha256); + result.put("plaintextByteLength", sealed.plaintextByteLength); + result.put("plaintextSha256", sealed.plaintextSha256); + } else { + Zne1FileCipher.Opened opened = cipher.decrypt(input, output); + result.put("byteLength", opened.byteLength); + result.put("sha256", opened.sha256); + } + } + } + call.resolve(result); + } catch (Exception error) { + discard(written); + if (error instanceof Zne1FileCipher.Zne1Exception) { + call.reject(error.getMessage(), ((Zne1FileCipher.Zne1Exception) error).code, error); + } else { + call.reject("Cloud file " + operation + " failed.", + encrypt ? "CLOUD_FILE_ENCRYPT_FAILED" : "CLOUD_FILE_DECRYPT_FAILED", error); + } + } + } + + private static byte[] decodeBase64(PluginCall call, String name) { + String value = call.getString(name); + if (value == null) throw new IllegalArgumentException("Missing " + name + "."); + return Base64.decode(value, Base64.DEFAULT); + } + + /** Remove a failed write's partial output, so part of a file can never pass for all of it. */ + private void discard(Uri target) { + if (target == null) return; + if ("file".equals(target.getScheme())) { + new File(target.getPath()).delete(); + return; + } + try { + DocumentsContract.deleteDocument(getContext().getContentResolver(), target); + } catch (Exception ignored) { + // The bridge deletes its staging document too; the rejection + // already marks this output as incomplete. + } + } + @PluginMethod public void put(PluginCall call) { execute(() -> upload(call)); diff --git a/android/app/src/main/java/md/zennotes/Zne1FileCipher.java b/android/app/src/main/java/md/zennotes/Zne1FileCipher.java new file mode 100644 index 0000000..da86e5c --- /dev/null +++ b/android/app/src/main/java/md/zennotes/Zne1FileCipher.java @@ -0,0 +1,226 @@ +package md.zennotes; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Arrays; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * The ZNE1 chunk loop for end-to-end encrypted Cloud vaults. It writes and + * accepts exactly the bytes of encryptZne1Stream and decryptZne1Stream in the + * shared cloud-e2ee module, and refuses truncated, reordered or forged chunks + * with the same codes; docs/specs/cloud-e2ee.md in the desktop repo is + * normative, and the shared vectors in the unit-test resources pin the bytes. + * + * JavaScript derives the object key, builds the 74-byte header, checks the key + * commitment and computes the AAD. This class receives only those three, never + * a parent key, and holds two chunks at a time, so a 200 MB attachment never + * crosses the WebView bridge or sits in memory whole. + */ +final class Zne1FileCipher { + static final int HEADER_BYTES = 74; + static final int CHUNK_BYTES = 65_536; + static final int TAG_BYTES = 16; + static final int SEALED_CHUNK_BYTES = CHUNK_BYTES + TAG_BYTES; + static final String MALFORMED = "E2EE_MALFORMED"; + static final String TAMPERED = "E2EE_TAMPERED"; + + private static final byte[] MAGIC = { 0x5a, 0x4e, 0x45, 0x31 }; + private static final byte ALGORITHM_AES_256_GCM = 0x01; + private static final int KEY_BYTES = 32; + private static final int AAD_BYTES = 32; + + /** A refusal the bridge reports by its code: malformed data, or data that failed authentication. */ + static final class Zne1Exception extends IOException { + final String code; + + Zne1Exception(String code, String message) { + super(message); + this.code = code; + } + } + + /** The ciphertext the upload declares, and the plaintext the item metadata records. */ + static final class Sealed { + final long byteLength; + final String sha256; + final long plaintextByteLength; + final String plaintextSha256; + + Sealed(long byteLength, String sha256, long plaintextByteLength, String plaintextSha256) { + this.byteLength = byteLength; + this.sha256 = sha256; + this.plaintextByteLength = plaintextByteLength; + this.plaintextSha256 = plaintextSha256; + } + } + + static final class Opened { + final long byteLength; + final String sha256; + + Opened(long byteLength, String sha256) { + this.byteLength = byteLength; + this.sha256 = sha256; + } + } + + private final byte[] header; + private final SecretKeySpec key; + private final byte[] aad; + + Zne1FileCipher(byte[] header, byte[] objectKey, byte[] aad) { + // Another algorithm byte is another format with its own chunking, which + // this loop must never run. + if (header == null || header.length != HEADER_BYTES + || !Arrays.equals(Arrays.copyOf(header, MAGIC.length), MAGIC) || header[4] != ALGORITHM_AES_256_GCM) { + throw new IllegalArgumentException("Expected a 74-byte ZNE1 header for algorithm 1."); + } + if (objectKey == null || objectKey.length != KEY_BYTES) { + throw new IllegalArgumentException("ZNE1 object keys are 32 bytes."); + } + if (aad == null || aad.length != AAD_BYTES) throw new IllegalArgumentException("ZNE1 AAD is 32 bytes."); + this.header = header.clone(); + this.key = new SecretKeySpec(objectKey, "AES"); + this.aad = aad.clone(); + } + + /** Writes the header, then one sealed chunk per 65536 plaintext bytes; an empty input seals one empty chunk. */ + Sealed encrypt(InputStream plaintext, OutputStream ciphertext) throws IOException { + Cipher cipher = newCipher(); + MessageDigest plainDigest = CloudFileStream.digest(); + MessageDigest sealedDigest = CloudFileStream.digest(); + byte[] chunk = new byte[CHUNK_BYTES]; + byte[] following = new byte[CHUNK_BYTES]; + byte[] sealed = new byte[SEALED_CHUNK_BYTES]; + ciphertext.write(header); + sealedDigest.update(header); + long plainLength = 0; + long sealedLength = HEADER_BYTES; + int size = fill(plaintext, chunk); + for (long index = 0; ; index++) { + if (Thread.currentThread().isInterrupted()) throw new IOException("Cloud encryption cancelled."); + // A full chunk is the last one only when nothing follows it, so the + // next one is read before this one is sealed. + int next = size == CHUNK_BYTES ? fill(plaintext, following) : 0; + boolean last = next == 0; + int count = crypt(cipher, Cipher.ENCRYPT_MODE, chunk, size, sealed, index, last); + plainDigest.update(chunk, 0, size); + sealedDigest.update(sealed, 0, count); + ciphertext.write(sealed, 0, count); + plainLength += size; + sealedLength += count; + if (last) break; + byte[] done = chunk; + chunk = following; + following = done; + size = next; + } + ciphertext.flush(); + return new Sealed(sealedLength, CloudFileStream.hex(sealedDigest.digest()), + plainLength, CloudFileStream.hex(plainDigest.digest())); + } + + /** + * Writes the plaintext of each chunk once it authenticates, so the output + * only ever holds a verified prefix. A failure says nothing about how much + * was written; the caller discards the output. + */ + Opened decrypt(InputStream ciphertext, OutputStream plaintext) throws IOException { + byte[] found = new byte[HEADER_BYTES]; + if (fill(ciphertext, found) < HEADER_BYTES) { + throw new Zne1Exception(MALFORMED, "The encrypted data is shorter than a ZNE1 header."); + } + if (!Arrays.equals(found, header)) { + // JavaScript checked this header's commitment; any other object is + // not the one it unlocked. + throw new Zne1Exception(TAMPERED, "The encrypted data does not begin with the header that was checked."); + } + Cipher cipher = newCipher(); + MessageDigest digest = CloudFileStream.digest(); + byte[] chunk = new byte[SEALED_CHUNK_BYTES]; + byte[] following = new byte[SEALED_CHUNK_BYTES]; + byte[] opened = new byte[SEALED_CHUNK_BYTES]; + long length = 0; + int size = fill(ciphertext, chunk); + for (long index = 0; ; index++) { + if (Thread.currentThread().isInterrupted()) throw new IOException("Cloud decryption cancelled."); + // The chunk that ends the input is the last one, so each full chunk + // waits for the next read before it is opened. + int next = size == SEALED_CHUNK_BYTES ? fill(ciphertext, following) : 0; + boolean last = next == 0; + if (last && size < TAG_BYTES) { + throw new Zne1Exception(TAMPERED, "The encrypted data was changed or is incomplete."); + } + if (last && size == TAG_BYTES && index > 0) { + // Authentication alone would accept an empty last chunk sealed + // after a full one; the format never writes one, so its length + // is refused here. + throw new Zne1Exception(MALFORMED, "A sealed ZNE1 chunk of " + size + " bytes is not allowed here."); + } + int count = crypt(cipher, Cipher.DECRYPT_MODE, chunk, size, opened, index, last); + digest.update(opened, 0, count); + plaintext.write(opened, 0, count); + length += count; + if (last) break; + byte[] done = chunk; + chunk = following; + following = done; + size = next; + } + plaintext.flush(); + return new Opened(length, CloudFileStream.hex(digest.digest())); + } + + /** One chunk under a fresh init: every chunk has its own nonce, and all share the AAD. */ + private int crypt(Cipher cipher, int mode, byte[] input, int length, byte[] output, long index, boolean last) + throws IOException { + try { + cipher.init(mode, key, new GCMParameterSpec(TAG_BYTES * 8, nonce(index, last))); + cipher.updateAAD(aad); + return cipher.doFinal(input, 0, length, output, 0); + } catch (BadPaddingException error) { + // A failed tag is an AEADBadTagException; catching its parent class + // covers providers that throw the parent directly. + throw new Zne1Exception(TAMPERED, "The encrypted data was changed or is incomplete."); + } catch (GeneralSecurityException error) { + throw new IOException("AES-GCM failed.", error); + } + } + + /** The chunk index as an 11-byte big-endian integer, then 0x01 for the last chunk or 0x00. */ + private static byte[] nonce(long index, boolean last) { + byte[] nonce = new byte[12]; + for (int position = 10; position >= 0; position--) { + nonce[position] = (byte) index; + index >>>= 8; + } + nonce[11] = last ? (byte) 1 : (byte) 0; + return nonce; + } + + private static Cipher newCipher() throws IOException { + try { + return Cipher.getInstance("AES/GCM/NoPadding"); + } catch (GeneralSecurityException error) { + throw new IOException("AES-GCM is unavailable.", error); + } + } + + /** Reads until the buffer is full or the input ends; a stream may return any amount per read. */ + private static int fill(InputStream input, byte[] buffer) throws IOException { + int filled = 0; + while (filled < buffer.length) { + int count = input.read(buffer, filled, buffer.length - filled); + if (count == -1) break; + filled += count; + } + return filled; + } +} diff --git a/android/app/src/test/java/md/zennotes/CloudFileStreamTest.java b/android/app/src/test/java/md/zennotes/CloudFileStreamTest.java index d07c1e4..c5bcfb1 100644 --- a/android/app/src/test/java/md/zennotes/CloudFileStreamTest.java +++ b/android/app/src/test/java/md/zennotes/CloudFileStreamTest.java @@ -62,6 +62,21 @@ public void streamsAnExactUploadAndRejectsChangedContentOrLength() throws Except new ByteArrayInputStream(bytes), OutputStream.nullOutputStream(), bytes.length, hash(new byte[] { 1 }))); } + @Test + public void readsOnlyTheRequestedPrefixAcrossShortReads() throws Exception { + byte[] bytes = new byte[200]; + for (int i = 0; i < bytes.length; i++) bytes[i] = (byte) i; + InputStream input = new ByteArrayInputStream(bytes) { + @Override public synchronized int read(byte[] target, int offset, int length) { + return super.read(target, offset, Math.min(length, 7)); + } + }; + assertArrayEquals(Arrays.copyOf(bytes, 74), CloudFileStream.readPrefix(input, 74)); + assertEquals("Nothing past the prefix is consumed", 126, input.available()); + assertArrayEquals(new byte[] { 1, 2 }, CloudFileStream.readPrefix(new ByteArrayInputStream(new byte[] { 1, 2 }), 74)); + assertArrayEquals(new byte[0], CloudFileStream.readPrefix(new ByteArrayInputStream(new byte[0]), 74)); + } + private static String hash(byte[] bytes) throws Exception { StringBuilder hex = new StringBuilder(); for (byte value : MessageDigest.getInstance("SHA-256").digest(bytes)) hex.append(String.format("%02x", value)); diff --git a/android/app/src/test/java/md/zennotes/Zne1FileCipherTest.java b/android/app/src/test/java/md/zennotes/Zne1FileCipherTest.java new file mode 100644 index 0000000..6bd9276 --- /dev/null +++ b/android/app/src/test/java/md/zennotes/Zne1FileCipherTest.java @@ -0,0 +1,262 @@ +package md.zennotes; + +import static org.junit.Assert.*; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; + +/** Runs the shared ZNE1 vectors (copied from the desktop repo's docs/specs) through the native chunk loop. */ +public class Zne1FileCipherTest { + private static final int HEADER = Zne1FileCipher.HEADER_BYTES; + private static final int CHUNK = Zne1FileCipher.CHUNK_BYTES; + private static final int SEALED = Zne1FileCipher.SEALED_CHUNK_BYTES; + private static final String MALFORMED = Zne1FileCipher.MALFORMED; + private static final String TAMPERED = Zne1FileCipher.TAMPERED; + + @Test + public void sealsEveryVectorToItsExactBytesAndOpensItAgain() throws Exception { + Set lengths = new HashSet<>(); + for (JSONObject vector : vectors()) { + int length = vector.getInt("plaintext_length"); + lengths.add(length); + String label = "purpose " + vector.getInt("purpose") + ", " + length + " bytes"; + byte[] plaintext = pattern(length); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Zne1FileCipher.Sealed sealed = cipher(vector).encrypt(trickle(plaintext, CHUNK), output); + byte[] ciphertext = output.toByteArray(); + assertEquals(label, vector.getLong("ciphertext_length"), ciphertext.length); + assertEquals(label, vector.getString("ciphertext_sha256"), hash(ciphertext)); + if (vector.has("ciphertext_hex")) assertEquals(label, vector.getString("ciphertext_hex"), hex(ciphertext)); + assertEquals(label, ciphertext.length, sealed.byteLength); + assertEquals(label, hash(ciphertext), sealed.sha256); + assertEquals(label, length, sealed.plaintextByteLength); + assertEquals(label, hash(plaintext), sealed.plaintextSha256); + + output = new ByteArrayOutputStream(); + Zne1FileCipher.Opened opened = cipher(vector).decrypt(trickle(ciphertext, SEALED), output); + assertArrayEquals(label, plaintext, output.toByteArray()); + assertEquals(label, length, opened.byteLength); + assertEquals(label, hash(plaintext), opened.sha256); + } + assertTrue("The vectors cover the sizes the spec names", + lengths.containsAll(Arrays.asList(0, 1, 65_535, 65_536, 65_537, 200_000, 256))); + } + + @Test + public void refusesAChangedByteInTheHeaderAnyChunkOrAnyTag() throws Exception { + JSONObject vector = vector(200_000); + byte[] sealed = seal(vector); + for (int offset = 0; offset < HEADER; offset++) assertRefused(TAMPERED, vector, flip(sealed, offset), 0); + for (int chunk = 0; HEADER + chunk * SEALED < sealed.length; chunk++) { + int start = HEADER + chunk * SEALED; + int end = Math.min(start + SEALED, sealed.length); + assertRefused(TAMPERED, vector, flip(sealed, start), chunk * CHUNK); + assertRefused(TAMPERED, vector, flip(sealed, end - 1), chunk * CHUNK); + } + } + + @Test + public void refusesInputThatEndsEarlyOrIsShorterThanAHeader() throws Exception { + JSONObject vector = vector(200_000); + byte[] sealed = seal(vector); + for (int chunks = 1; chunks <= 3; chunks++) { + // Ends on a chunk boundary: the chunk that now ends the input was not sealed as the last one. + assertRefused(TAMPERED, vector, Arrays.copyOf(sealed, HEADER + chunks * SEALED), (chunks - 1) * CHUNK); + } + assertRefused(TAMPERED, vector, Arrays.copyOf(sealed, sealed.length - 5), 3 * CHUNK); + assertRefused(TAMPERED, vector, Arrays.copyOf(sealed, HEADER + SEALED + 3), CHUNK); + assertRefused(TAMPERED, vector, Arrays.copyOf(sealed, HEADER), 0); + assertRefused(MALFORMED, vector, Arrays.copyOf(sealed, 40), 0); + } + + @Test + public void refusesSwappedAndAppendedChunks() throws Exception { + JSONObject vector = vector(200_000); + byte[] sealed = seal(vector); + byte[] swapped = sealed.clone(); + System.arraycopy(sealed, HEADER + SEALED, swapped, HEADER, SEALED); + System.arraycopy(sealed, HEADER, swapped, HEADER + SEALED, SEALED); + assertRefused(TAMPERED, vector, swapped, 0); + assertRefused(TAMPERED, vector, concat(sealed, new byte[1]), 3 * CHUNK); + assertRefused(TAMPERED, vector, concat(sealed, Arrays.copyOfRange(sealed, HEADER, HEADER + SEALED)), 3 * CHUNK); + + // One full chunk that is also the last: anything after it means it was not. + JSONObject single = vector(CHUNK); + byte[] whole = seal(single); + assertRefused(TAMPERED, single, concat(whole, new byte[Zne1FileCipher.TAG_BYTES]), 0); + assertRefused(TAMPERED, single, concat(whole, Arrays.copyOfRange(whole, HEADER, whole.length)), 0); + } + + @Test + public void refusesAnEmptyLastChunkAfterAFullOneByItsLength() throws Exception { + // The format seals 65536 bytes as one final chunk. Sealed instead as a + // full chunk plus an empty final chunk, every chunk still + // authenticates, so only the length rule refuses the object. + JSONObject vector = vector(CHUNK); + byte[] header = unhex(vector.getString("header_hex")); + byte[] key = unhex(vector.getString("object_key_hex")); + byte[] aad = unhex(vector.getString("aad_hex")); + byte[] plaintext = pattern(CHUNK); + assertArrayEquals("The crafted chunks follow the spec", seal(vector), concat(header, gcm(key, aad, 0, true, plaintext))); + byte[] notLast = gcm(key, aad, 0, false, plaintext); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + cipher(vector).decrypt(new ByteArrayInputStream(concat(header, notLast, gcm(key, aad, 1, true, new byte[] { 7 }))), output); + assertEquals(CHUNK + 1, output.size()); + + assertRefused(MALFORMED, vector, concat(header, notLast, gcm(key, aad, 1, true, new byte[0])), CHUNK); + } + + @Test + public void refusesAnotherObjectsHeaderAndTheWrongKeyOrContext() throws Exception { + JSONObject content = vector(1); + JSONObject metadata = vectorsFile().getJSONObject("metadata_object"); + byte[] sealed = seal(content); + byte[] header = unhex(content.getString("header_hex")); + byte[] key = unhex(content.getString("object_key_hex")); + byte[] aad = unhex(content.getString("aad_hex")); + assertRefused(TAMPERED, new Zne1FileCipher(unhex(metadata.getString("header_hex")), key, aad), sealed, 0); + assertRefused(TAMPERED, new Zne1FileCipher(header, flip(key, 0), aad), sealed, 0); + assertRefused(TAMPERED, new Zne1FileCipher(header, key, unhex(metadata.getString("aad_hex"))), sealed, 0); + assertRefused(TAMPERED, new Zne1FileCipher(header, key, flip(aad, 31)), sealed, 0); + } + + @Test + public void refusesParametersThatDoNotDescribeAZne1Object() throws Exception { + JSONObject vector = vector(0); + byte[] header = unhex(vector.getString("header_hex")); + byte[] key = unhex(vector.getString("object_key_hex")); + byte[] aad = unhex(vector.getString("aad_hex")); + byte[] nextAlgorithm = header.clone(); + nextAlgorithm[4] = 0x02; + assertThrows(IllegalArgumentException.class, () -> new Zne1FileCipher(null, key, aad)); + assertThrows(IllegalArgumentException.class, () -> new Zne1FileCipher(Arrays.copyOf(header, HEADER - 1), key, aad)); + assertThrows(IllegalArgumentException.class, () -> new Zne1FileCipher(flip(header, 0), key, aad)); + assertThrows(IllegalArgumentException.class, () -> new Zne1FileCipher(nextAlgorithm, key, aad)); + assertThrows(IllegalArgumentException.class, () -> new Zne1FileCipher(header, Arrays.copyOf(key, 16), aad)); + assertThrows(IllegalArgumentException.class, () -> new Zne1FileCipher(header, key, Arrays.copyOf(aad, 31))); + } + + /** Checks the refusal's code, and that only the chunks before the failure reached the output. */ + private static void assertRefused(String code, Zne1FileCipher cipher, byte[] data, int written) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Zne1FileCipher.Zne1Exception error = assertThrows(Zne1FileCipher.Zne1Exception.class, + () -> cipher.decrypt(new ByteArrayInputStream(data), output)); + assertEquals(error.getMessage(), code, error.code); + assertArrayEquals(pattern(written), output.toByteArray()); + } + + private static void assertRefused(String code, JSONObject vector, byte[] data, int written) throws Exception { + assertRefused(code, cipher(vector), data, written); + } + + /** + * Serves at most 7919 bytes per read, so chunks straddle reads the way a + * document provider delivers them, and fails any read larger than allowed. + */ + private static InputStream trickle(byte[] bytes, int largestRead) { + return new ByteArrayInputStream(bytes) { + @Override public synchronized int read(byte[] target, int offset, int length) { + assertTrue("Native reads must stay bounded", length <= largestRead); + return super.read(target, offset, Math.min(length, 7919)); + } + }; + } + + private static JSONObject vectorsFile() throws Exception { + try (InputStream input = Zne1FileCipherTest.class.getClassLoader().getResourceAsStream("cloud-e2ee-vectors.json")) { + assertNotNull("The shared vectors belong in src/test/resources", input); + return new JSONObject(new String(input.readAllBytes(), StandardCharsets.UTF_8)); + } + } + + /** Every content object, then the item-metadata object. */ + private static List vectors() throws Exception { + JSONObject file = vectorsFile(); + JSONArray objects = file.getJSONArray("objects"); + List vectors = new ArrayList<>(); + for (int index = 0; index < objects.length(); index++) vectors.add(objects.getJSONObject(index)); + vectors.add(file.getJSONObject("metadata_object")); + return vectors; + } + + private static JSONObject vector(int plaintextLength) throws Exception { + for (JSONObject vector : vectors()) { + if (vector.getInt("purpose") == 1 && vector.getInt("plaintext_length") == plaintextLength) return vector; + } + throw new AssertionError("No content vector of " + plaintextLength + " bytes"); + } + + private static Zne1FileCipher cipher(JSONObject vector) throws Exception { + return new Zne1FileCipher(unhex(vector.getString("header_hex")), unhex(vector.getString("object_key_hex")), + unhex(vector.getString("aad_hex"))); + } + + private static byte[] seal(JSONObject vector) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + cipher(vector).encrypt(new ByteArrayInputStream(pattern(vector.getInt("plaintext_length"))), output); + return output.toByteArray(); + } + + /** Plaintext bytes for the vectors: byte i is i mod 251. */ + private static byte[] pattern(int length) { + byte[] bytes = new byte[length]; + for (int index = 0; index < length; index++) bytes[index] = (byte) (index % 251); + return bytes; + } + + /** One chunk sealed by javax.crypto directly, with a nonce built here from the spec. */ + private static byte[] gcm(byte[] key, byte[] aad, int index, boolean last, byte[] chunk) throws Exception { + byte[] nonce = new byte[12]; + for (int position = 10; position >= 7; position--) nonce[position] = (byte) (index >>> (8 * (10 - position))); + nonce[11] = (byte) (last ? 1 : 0); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce)); + cipher.updateAAD(aad); + return cipher.doFinal(chunk); + } + + private static byte[] flip(byte[] bytes, int offset) { + byte[] copy = bytes.clone(); + copy[offset] ^= 0x01; + return copy; + } + + private static byte[] concat(byte[]... parts) { + ByteArrayOutputStream joined = new ByteArrayOutputStream(); + for (byte[] part : parts) joined.write(part, 0, part.length); + return joined.toByteArray(); + } + + private static byte[] unhex(String hex) { + byte[] bytes = new byte[hex.length() / 2]; + for (int index = 0; index < bytes.length; index++) { + bytes[index] = (byte) Integer.parseInt(hex.substring(index * 2, index * 2 + 2), 16); + } + return bytes; + } + + private static String hex(byte[] bytes) { + StringBuilder hex = new StringBuilder(); + for (byte value : bytes) hex.append(String.format("%02x", value)); + return hex.toString(); + } + + private static String hash(byte[] bytes) throws Exception { + return hex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } +} diff --git a/android/app/src/test/resources/cloud-e2ee-vectors.json b/android/app/src/test/resources/cloud-e2ee-vectors.json new file mode 100644 index 0000000..80dc921 --- /dev/null +++ b/android/app/src/test/resources/cloud-e2ee-vectors.json @@ -0,0 +1,217 @@ +{ + "format": "ZNE1", + "spec": "docs/specs/cloud-e2ee.md", + "notes": "Generated by packages/shared-domain/src/cloud-e2ee.test.ts with UPDATE_E2EE_VECTORS=1, and checked there against an independent node:crypto implementation and OpenSSL 3.6 Argon2id. Plaintext for objects is byte i = i mod 251.", + "argon2id": [ + { + "passphrase": "password", + "password_hex": "70617373776f7264", + "salt_hex": "736f6d6573616c74", + "memory_kib": 65536, + "iterations": 2, + "parallelism": 1, + "output_hex": "09316115d5cf24ed5a15a31a3ba326e5cf32edc24702987c02b6566f61913cf7" + }, + { + "passphrase": "correct horse battery staple", + "password_hex": "636f727265637420686f727365206261747465727920737461706c65", + "salt_hex": "000102030405060708090a0b0c0d0e0f", + "memory_kib": 65536, + "iterations": 3, + "parallelism": 4, + "output_hex": "853b272a44db1421c02962669a55eb0994f3cab385ed1c4c79253eee19bab49e" + }, + { + "passphrase": "Ångström ☕ file", + "password_hex": "c3856e67737472c3b66d20e298952066696c65", + "salt_hex": "000102030405060708090a0b0c0d0e0f", + "memory_kib": 65536, + "iterations": 3, + "parallelism": 4, + "output_hex": "557b069f56e082ae2aecd39bbb2513634b549ab4215fdbf02974607c65e5462d" + } + ], + "objects": [ + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 0, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 90, + "ciphertext_sha256": "406d8d367c9f4584b8947bc9bcfbe61654e601b60450b343a437cdc54ec802b7", + "ciphertext_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f58dd6859d5d96c76debf161861795b2b8" + }, + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 1, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 91, + "ciphertext_sha256": "7a077f57ff7045dbdded6c9132488da107b6e3ccbe9a7640b16367dbecfc1cb1", + "ciphertext_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5a98d5b8c638f404c1965f233907b8e2329" + }, + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 65535, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 65625, + "ciphertext_sha256": "58730a31a5351d8fa5282c8d868a6258b6eb54dadc26f7a6f94113402149cb02" + }, + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 65536, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 65626, + "ciphertext_sha256": "a8dd2a58944e98bbb8ded713ad9c399037e484ed49a27516eccb33d0795382b2" + }, + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 65537, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 65643, + "ciphertext_sha256": "51e01f7545c6b91251d8161beaf97f6e5584f9f0f0743edb7c03000343ab598e" + }, + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 131072, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 131178, + "ciphertext_sha256": "b4abfea6809249a8cef96cc2c046a3567f4dc4c834e77e7dfc4ecd8eeb37ba6e" + }, + { + "purpose": 1, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "", + "plaintext_length": 200000, + "object_key_hex": "6f8af0826a25068000e30dda372bf04bf313f10a2ae5d6b8de07f142aa91c95d", + "aad_hex": "54dd60d0bdc522e759214e6e4386f625bc0cc17dadcd8f1ec76592a8537fd4d6", + "header_hex": "5a4e4531010100000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f076aa4bb4461d7cc5c300560ccbb47f7e16106567e3641fa32b7b155b01f22f5", + "ciphertext_length": 200138, + "ciphertext_sha256": "fc64aa074ad12237fb34aa10c5c7233f9e0e966b257c859b39eab73f9f13ae9d" + } + ], + "metadata_object": { + "purpose": 2, + "key_version": 1, + "parent_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "context_hex": "66626437653635653338373931663063366334353938643130386233653534323532383039333033326635396163613833613934323633376265633831366434", + "plaintext_length": 256, + "object_key_hex": "a48338e6ed50b692c9d5941f6ae47852edfcccaa7d21772cef19d613403ed381", + "aad_hex": "147fdebf075db58a5a92923c85aba132a7f85c1dc8dda1c4b9393b9ae7bc8d88", + "header_hex": "5a4e4531010200000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f737941db973616cfcc7b549c02f34cf3312d1a8c994e9addfa96f388fe26404a", + "ciphertext_length": 346, + "ciphertext_sha256": "b589793da30135a4c1c868d00e1cc6cffbf7e57c74d09725e8b18a0e063594ca", + "ciphertext_hex": "5a4e4531010200000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f737941db973616cfcc7b549c02f34cf3312d1a8c994e9addfa96f388fe26404aa9795acaed813a74477b937f9f33e27256bd5eee12dec43d814a28a512f4077921a65ed6447278213d6ed03c82b17343f00bc5154a2dba50c2e91ce4857653e582d4afbf6df08a17718949d0a7a4ad9388011cba476084aac55a83b2da8f98c5cac509b526eb7fef73836700adc7ec991f720d225a6d4668fc6ee14f589a45ce9e0f8ca59659e95a9a740ab9d426ea453f682c50d1ad74b5d6f062341ce60fef29a0a88ad78844ee56ad077d3ff835bd3f4c8618b0d30951012132c6d6bd15df2ff6242d25e1b1fc7225472dd24472cf0ea01617d48958b7e518d0e4419f502d8528e27223fee930504f69116f02e41b835d7fd7c1ca1df2e896e2be16a31e132dc1e2b5dd1c358fe3d7549f33a25661" + }, + "path_index": { + "vault_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "path_index_key_hex": "98cc5742f6f9b05a6d1e8267e9389e3dccc872309f555e2869e0510fc21c89d6", + "paths": [ + { + "path": "Notes/Ideas.md", + "canonical": "notes/ideas.md", + "path_id": "fbd7e65e38791f0c6c4598d108b3e542528093032f59aca83a942637bec816d4" + }, + { + "path": "notes\\IDEAS.md", + "canonical": "notes/ideas.md", + "path_id": "fbd7e65e38791f0c6c4598d108b3e542528093032f59aca83a942637bec816d4" + }, + { + "path": "Journal/2026/Café.md", + "canonical": "journal/2026/café.md", + "path_id": "b07d484e7d0367ecb5be82d7b82047024443d3a875a3c1d39ed02f4321cb9069" + }, + { + "path": "Journal/2026/Café.md", + "canonical": "journal/2026/café.md", + "path_id": "b07d484e7d0367ecb5be82d7b82047024443d3a875a3c1d39ed02f4321cb9069" + }, + { + "path": ".zennotes/vault.json", + "canonical": ".zennotes/vault.json", + "path_id": "efd4756ab4f4f2d063f6bc863b2a7aa8929ffae30550071e4f6fc0469cccde20" + } + ] + }, + "item_meta": { + "vault_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "vault_key_version": 1, + "path_id": "fbd7e65e38791f0c6c4598d108b3e542528093032f59aca83a942637bec816d4", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "meta": { + "path": "Notes/Ideas.md", + "kind": "text", + "encoding": "utf8", + "media_type": "text/markdown", + "sha256": "25930e05a7c60415fd345391c29a9c525e7489f597e57c2e8ab5383adac4f7b3", + "byte_length": 8 + }, + "envelope_base64": "Wk5FMQECAAAAAUBBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fc3lB25c2Fs/Me1ScAvNM8zEtGoyZTprd+pbziP4mQErSWi7r07UQUT8T7RyxBM4zKdgpjimCtk/4IBzTass1RGruEpFCbXxycD+ONYK+OAKjVZNPEH+uXdilUrmBaUH4r/CJlUjquClJpSGhyZ2GpKx/I4hhXraS6m37xaShrvuYkV30eKwrvSLZPQ7xn+PBDDVPZR8tVnu3I64HHdZYgyfvN0Un7lq4Jcm4Cz6eXf3KzImgd13aQyxcy5ezGvUR6jVsHhEewGXcZtSi9gr3d+GaQMMmXIebmbioXUogi0DPF8bOwQRXG5rMrcY+qZwg/lHk5CB8rkAd4SofvWKu0kXpILHnOy/3mIaj2qPPKtRTjK0EFR/LJTBPOJ43gT03MDpqG1UWNS3/F2+PpYmDbQ==" + }, + "vault_name": { + "vault_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "name": "Journal 2026", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "envelope_base64": "Wk5FMQEDAAAAAUBBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fLHiyx6Si/QG8HgqhrnakhROrzQGoW0bmmR3pOy7KE8cFzF7OIzXEj9s0CPedczuK4sRBmrMwYxo0Mp1vEpX8yIUsff/YbKqAWpeRSdY96fMXRrKak6NE6b9q128plpb5Ghofauz76o1iNcbuwBoILA==" + }, + "envelopes": { + "account_key_hex": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "passphrase_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "recovery_key_hex": "9e885d952ad362caeb4efe34a8e91bd2a3cbd94c7bd5b1e8f3cc1e3d7b3c6bc1", + "salt_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "by_passphrase_base64": "Wk5FMQEFAAAAAEBBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fTcKaw5mYUi6jvInv0VeQbb2aczLuYJiqSNcUhBl+wfqP8ncFd1B2KNFfUTdsQW+2pYCCzYUWvXEtJ/2VQ3j2DrdpDzlXCB//A2VjvdgnWIM=", + "by_recovery_base64": "Wk5FMQEFAAAAAEBBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fDvUpUsGPBPmL32+3YbtBfdC9rcqIN8cQkb74Q2nShJlbfYZ2yZF8eS3/XGjfwGODNdj3h5CPBK+9HBtMjGYb2QRNiVOdlOQ3M9b2zvNmw/w=", + "vault_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "vault_key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "account_key_version": 1, + "vault_key_base64": "Wk5FMQEEAAAAAUBBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fU3gAU5SrZkJv67iKTtagJMnXKlTwBMiAHNA1wWIfj7L60vcJFbd5oBHBkIOhTWmSya44pyg5D1oIJEGkIYgD0KcXYKkOitPn65lhTorwT30=" + }, + "recovery_words": [ + { + "entropy_hex": "0000000000000000000000000000000000000000000000000000000000000000", + "words": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art" + }, + { + "entropy_hex": "9e885d952ad362caeb4efe34a8e91bd2a3cbd94c7bd5b1e8f3cc1e3d7b3c6bc1", + "words": "ozone drill grab fiber curtain grace pudding thank cruise elder eight pipe device uncover cradle kingdom rapid elegant vessel detect stumble owner pyramid cupboard" + } + ] +} diff --git a/package-lock.json b/package-lock.json index 6e25468..19b30c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-android", - "version": "1.1.36", + "version": "1.1.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-android", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.1", "@capacitor/android": "^8.5.2", @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.66.0-core.hf27b6fbd42368c71.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.66.0-boundaries.h3df85330eec3e7fb.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.66.0-boundaries.h3df85330eec3e7fb.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.67.0-core.hb828dd60259bc376.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.67.0-boundaries.h53f4d533587f4290.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.67.0-boundaries.h53f4d533587f4290.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.16", "function-plot": "^1.25.3", @@ -1737,6 +1737,18 @@ } } }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3125,6 +3137,18 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@scure/bip39": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.4.0.tgz", + "integrity": "sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -3618,9 +3642,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.66.0-core.hf27b6fbd42368c71", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.66.0-core.hf27b6fbd42368c71.tgz", - "integrity": "sha512-fpJrao/0LPlTradE9E6EDk+yBvKbSkRVODh5WVeLmH5cwn9PO8AbtUCUBb1JXQvz9qtyOWvxvdsu07cXpbwxow==", + "version": "2.67.0-core.hb828dd60259bc376", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.67.0-core.hb828dd60259bc376.tgz", + "integrity": "sha512-k94Z88Sebd6NHAe8/1ba5K0K46NuJ7gwhMmxzAG97Y5VKu+6r+TKsXD/R8Axkh6UIJ4AtoBVBi0lca6nOO2JUQ==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3648,8 +3672,8 @@ "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.66.0-boundaries.h3df85330eec3e7fb", - "@zennotes/shared-domain": "2.66.0-boundaries.h3df85330eec3e7fb", + "@zennotes/bridge-contract": "2.67.0-boundaries.h53f4d533587f4290", + "@zennotes/shared-domain": "2.67.0-boundaries.h53f4d533587f4290", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3693,18 +3717,20 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.66.0-boundaries.h3df85330eec3e7fb", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.66.0-boundaries.h3df85330eec3e7fb.tgz", - "integrity": "sha512-62g3+UuVtdvnx5+QJLf6SpcfAJa1lhIe3ipkTptJ1uvF4KI8mxaD+3BUUeflQwEuXlIEO5lb89jvTPuj+h2VrQ==", + "version": "2.67.0-boundaries.h53f4d533587f4290", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.67.0-boundaries.h53f4d533587f4290.tgz", + "integrity": "sha512-3qnOkEVjq4pKqAOO/jw9oRy+NzK+h9G6yKGywzcfPQSFVS/ugN+6JKBKkRvObdDz64vPkIhR5m0cu8H3t7uFyw==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.66.0-boundaries.h3df85330eec3e7fb", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.66.0-boundaries.h3df85330eec3e7fb.tgz", - "integrity": "sha512-gy0rYVYvlnUCKrkBSbWX2qTeZF8CG48qJOyHKJJukENAkWvuVJ1vXWoJE8CUaN2i314IPT4z9u1CuEXRyXS6lA==", + "version": "2.67.0-boundaries.h53f4d533587f4290", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.67.0-boundaries.h53f4d533587f4290.tgz", + "integrity": "sha512-5oooEqUyGJJ32/ZKA7KzReaCNmXOF0AQHHumfGsUESxTAE1Tf6u2sY9ubbNgFZCErZuDDmF0aF2ZiPxtMyJBnQ==", "license": "MIT", "dependencies": { - "@zennotes/bridge-contract": "2.66.0-boundaries.h3df85330eec3e7fb", + "@scure/bip39": "2.4.0", + "@zennotes/bridge-contract": "2.67.0-boundaries.h53f4d533587f4290", + "hash-wasm": "4.12.0", "lz-string": "^1.5.0" } }, @@ -5381,6 +5407,12 @@ "fflate": "^0.8.2" } }, + "node_modules/hash-wasm": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==", + "license": "MIT" + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", diff --git a/package.json b/package.json index 7bdc4b9..fd8a7bd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-android", "private": true, - "version": "1.1.36", + "version": "1.1.37", "type": "module", "description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core", "homepage": "https://zennotes.org", @@ -43,9 +43,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.66.0-core.hf27b6fbd42368c71.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.66.0-boundaries.h3df85330eec3e7fb.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.66.0-boundaries.h3df85330eec3e7fb.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.67.0-core.hb828dd60259bc376.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.67.0-boundaries.h53f4d533587f4290.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.67.0-boundaries.h53f4d533587f4290.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.16", "function-plot": "^1.25.3", diff --git a/src/bootstrap.ts b/src/bootstrap.ts index ec7912a..ece1f11 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,3 +1,4 @@ +import './random-uuid-polyfill' import { Preferences } from '@capacitor/preferences' import { GESTURES_KEY, HIDE_STATUS_BAR_KEY, LAYOUT_MODE_KEY, START_SCREEN_KEY } from './viewport' diff --git a/src/bridge/cloud-sync-client.test.ts b/src/bridge/cloud-sync-client.test.ts index 42bd7d0..e9e2f44 100644 --- a/src/bridge/cloud-sync-client.test.ts +++ b/src/bridge/cloud-sync-client.test.ts @@ -141,3 +141,104 @@ it('preserves the requested change page size and metadata-only content budget', assert.equal(request.searchParams.get('max_inline_bytes'), '0') assert.equal(request.searchParams.get('max_response_bytes'), '1048576') }) + +function memoryKeyStore(value: { format: 1; key: string; version: number } | null) { + return { load: async () => value, save: async () => assert.fail('nothing saves here'), clear: async () => {} } +} + +async function encryptedVaultFixture(name = 'Journal 2026') { + const { generateCloudE2eeKey, toBase64 } = await import('@zennotes/shared-domain/cloud-e2ee') + const { CloudVaultCipher } = await import('@zennotes/shared-domain/cloud-e2ee-vault') + const accountKey = generateCloudE2eeKey() + const vault = { + id: 'vault-1', name: 'Encrypted vault', cursor: 0, created_at: '2026-10-12T00:00:00Z', updated_at: '2026-10-12T00:00:00Z', + encryption: await CloudVaultCipher.prepareVault(name, accountKey, 1) + } + return { vault, unlocked: { format: 1 as const, key: toBase64(accountKey), version: 1 } } +} + +it('announces end-to-end encryption only for a client given a keyring, and forwards request headers', async () => { + const { CloudE2eeKeyring } = await import('@zennotes/shared-domain/cloud-e2ee-account') + const requests: { headers: Record }[] = [] + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + registerPlugin: () => ({}), + CapacitorHttp: { request: async ({ headers }: { headers: Record }) => { + requests.push({ headers }) + return { status: 200, data: { data: [], cursor: 0, next_page: null } } + } } + } + }) + const readable = { id: 'vault-1', name: 'Notes', cursor: 0, created_at: '', updated_at: '', encryption: null } + const keyring = new CloudE2eeKeyring({ listVaults: async () => [readable], store: memoryKeyStore(null) }) + await createCloudSyncClient('https://example.test', 'test-only', { accountId: 'account' }).manifest('vault-1', { includeContent: false }) + await createCloudSyncClient('https://example.test', 'test-only', { accountId: 'account', keyring }).manifest('vault-1', { includeContent: false }) + assert.equal(requests[0]!.headers['X-ZenNotes-Encryption'], undefined) + assert.equal(requests[1]!.headers['X-ZenNotes-Encryption'], 'zne1') + assert.ok(requests.every(({ headers }) => headers.Authorization === 'Bearer test-only')) +}) + +it('lists an encrypted vault under its real name when this device is unlocked, and the placeholder when locked', async () => { + const { CloudE2eeKeyring } = await import('@zennotes/shared-domain/cloud-e2ee-account') + const { vault, unlocked } = await encryptedVaultFixture() + const readable = { id: 'vault-2', name: 'Notes', cursor: 0, created_at: '', updated_at: '', encryption: null } + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + registerPlugin: () => ({}), + CapacitorHttp: { request: async () => ({ status: 200, data: { data: [vault, readable] } }) } + } + }) + const names = async (stored: typeof unlocked | null) => (await createCloudSyncClient('https://example.test', 'test-only', { + accountId: 'account', + keyring: new CloudE2eeKeyring({ listVaults: async () => [vault, readable], store: memoryKeyStore(stored) }) + }).listVaults()).data.map((listed: { name: string }) => listed.name) + assert.deepEqual(await names(unlocked), ['Journal 2026', 'Notes']) + assert.deepEqual(await names(null), ['Encrypted vault', 'Notes']) +}) + +it('sends an encrypted vault\'s file direct once its ciphertext passes the inline cap, sealed and never readable', async () => { + const { CloudE2eeKeyring } = await import('@zennotes/shared-domain/cloud-e2ee-account') + const { zne1CiphertextLength } = await import('@zennotes/shared-domain/cloud-e2ee') + const { vault, unlocked } = await encryptedVaultFixture() + const requests: { method: string; path: string; headers: Record; data: any }[] = [] + const puts: any[] = [] + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + registerPlugin: () => ({ put: async (request: unknown) => { puts.push(request); return { status: 200 } } }), + CapacitorHttp: { request: async ({ method, url, headers, data }: any) => { + const path = new URL(url).pathname + requests.push({ method, path, headers, data }) + if (path.endsWith('/uploads')) { + return { status: 201, data: { data: { + id: 'upload-1', operation_id: data.operation_id, status: 'uploading', expected_bytes: data.content.byte_length, + expires_at: '2099-01-01T00:00:00Z', upload: { method: 'PUT', url: 'https://objects.example.test/put', headers: {} } + } } } + } + assert.ok(path.endsWith('/uploads/upload-1/complete'), `unexpected ${method} ${path}`) + return { status: 200, data: { data: { id: 'upload-1', operation_id: 'operation-1', status: 'completed', + result: { acknowledged: [{ operation_id: 'operation-1', item_id: 'item-1', revision: 1, sequence: 1 }], conflicts: [], cursor: 1 } } } } + } } + } + }) + const keyring = new CloudE2eeKeyring({ listVaults: async () => [vault], store: memoryKeyStore(unlocked) }) + const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: 'account', keyring }) + const bytes = Buffer.alloc(5 * 1024 * 1024 - 100, 4) + const result = await client.mutate('vault-1', { mutations: [{ + type: 'upsert', operation_id: 'operation-1', item_id: 'item-1', base_revision: null, path: 'Scans/receipt.pdf', kind: 'binary', + content: { encoding: 'base64', data: bytes.toString('base64'), sha256: createHash('sha256').update(bytes).digest('hex'), + byte_length: bytes.length, media_type: 'application/pdf' } + }] }) + + assert.equal(result.acknowledged.length, 1) + assert.deepEqual(requests.map(({ method, path }) => `${method} ${path}`), [ + 'POST /api/v1/vaults/vault-1/uploads', 'POST /api/v1/vaults/vault-1/uploads/upload-1/complete' + ]) + assert.ok(requests.every(({ headers }) => headers['X-ZenNotes-Encryption'] === 'zne1')) + const initiated = requests[0]!.data + assert.match(initiated.path, /^[0-9a-f]{64}$/) + assert.equal(JSON.stringify(initiated).includes('receipt'), false) + assert.deepEqual([initiated.kind, initiated.content.encoding, initiated.content.byte_length], ['binary', 'aes-gcm', zne1CiphertextLength(bytes.length)]) + const sealed = Buffer.from(puts[0].base64, 'base64') + assert.equal(puts[0].sha256, createHash('sha256').update(sealed).digest('hex')) + assert.equal(sealed.subarray(0, 4).toString('latin1'), 'ZNE1') +}) diff --git a/src/bridge/cloud-sync-client.ts b/src/bridge/cloud-sync-client.ts index d70a895..12c7a2c 100644 --- a/src/bridge/cloud-sync-client.ts +++ b/src/bridge/cloud-sync-client.ts @@ -8,8 +8,11 @@ import { } from '@zennotes/shared-domain/cloud-sync-api' import type { CloudSyncMutationRequest, - CloudSyncMutationResponse + CloudSyncMutationResponse, + CloudSyncVaultCollection } from '@zennotes/bridge-contract/cloud-sync' +import type { CloudE2eeKeyring } from '@zennotes/shared-domain/cloud-e2ee-account' +import { ENCRYPTED_VAULT_PLACEHOLDER_NAME } from '@zennotes/shared-domain/cloud-e2ee-vault' import { MobileDirectUploadError, mutateWithMobileDirectUploads, @@ -51,7 +54,18 @@ export class CloudServiceRequestError extends Error { } } -export function createCloudSyncClient(baseUrl: string, token: string, options: { accountId: string; signal?: AbortSignal }): CloudSyncApiClient { +export interface MobileCloudSyncClientOptions { + accountId: string + signal?: AbortSignal + /** + * Turns on end-to-end encryption: the client tells the service it may serve + * encrypted vaults and opens them with this keyring's ciphers. Only a host + * that seals and opens every path an encrypted vault's bytes take may pass it. + */ + keyring?: CloudE2eeKeyring +} + +export function createCloudSyncClient(baseUrl: string, token: string, options: MobileCloudSyncClientOptions): CloudSyncApiClient { const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, '') const lifetime = options.signal ?? requestLifetime.signal const transport: CloudSyncHttpTransport = { @@ -61,6 +75,7 @@ export function createCloudSyncClient(baseUrl: string, token: string, options: { method: request.method, url: `${normalizedBaseUrl}${request.path}`, headers: { + ...request.headers, Accept: 'application/json', Authorization: `Bearer ${token}`, ...(multipart @@ -125,19 +140,42 @@ export function createCloudSyncClient(baseUrl: string, token: string, options: { accountScope: { baseUrl: normalizedBaseUrl, accountId: options.accountId }, signal: lifetime, bootstrapContentPageBytes: 1024 * 1024, - allowInsecureLoopbackDownloads: loopback - }) + allowInsecureLoopbackDownloads: loopback, + ...(options.keyring ? { endToEndEncryption: true, vaultCiphers: options.keyring } : {}) + }, options.keyring) } class MobileCloudSyncApiClient extends CloudSyncApiClient { constructor( http: CloudSyncHttpTransport, private readonly uploadObject: MobileObjectUpload, - options: ConstructorParameters[1] + options: ConstructorParameters[1], + private readonly keyring?: CloudE2eeKeyring ) { super(http, options) } + /** + * An encrypted vault's real name is sealed: show it when this device is + * unlocked, and the placeholder the service stores otherwise. + */ + override async listVaults(): Promise { + const response = await super.listVaults() + const keyring = this.keyring + if (!keyring) return response + return { + ...response, + data: await Promise.all(response.data.map(async (vault) => { + if (!vault.encryption) return vault + try { + return { ...vault, name: await keyring.vaultName(vault) } + } catch { + return { ...vault, name: ENCRYPTED_VAULT_PLACEHOLDER_NAME } + } + })) + } + } + override async mutate( vaultId: string, body: CloudSyncMutationRequest @@ -147,7 +185,8 @@ class MobileCloudSyncApiClient extends CloudSyncApiClient { mutate: (nextVaultId, nextBody) => super.mutate(nextVaultId, nextBody), initiateUpload: (nextVaultId, nextBody) => super.initiateUpload(nextVaultId, nextBody), completeUpload: (nextVaultId, uploadId) => super.completeUpload(nextVaultId, uploadId), - abortUpload: (nextVaultId, uploadId) => super.abortUpload(nextVaultId, uploadId) + abortUpload: (nextVaultId, uploadId) => super.abortUpload(nextVaultId, uploadId), + vaultCipher: (nextVaultId) => this.vaultCipher(nextVaultId) }, vaultId, body, diff --git a/src/bridge/cloud-sync-repository.test.ts b/src/bridge/cloud-sync-repository.test.ts index a343387..343d0d8 100644 --- a/src/bridge/cloud-sync-repository.test.ts +++ b/src/bridge/cloud-sync-repository.test.ts @@ -8,6 +8,7 @@ import type { import type { CloudSyncRepository } from '@zennotes/shared-domain/cloud-sync-coordinator' import { loadMobileModule } from '../../tooling/load-mobile-module.ts' +import { withoutRandomUUID } from '../../tooling/without-random-uuid.ts' const { CachedCloudSyncRepository, mutateWithMobileDirectUploads } = await loadMobileModule([ './src/bridge/cloud-sync-repository', './src/bridge/mobile-direct-upload' @@ -584,6 +585,19 @@ describe('cached scan with the pinned conflict coordinator', () => { assert.equal(h.files.get('note.md')?.bytes.toString(), 'Keep me') }) + it('writes a Cloud change on a WebView without crypto.randomUUID', async () => { + await withoutRandomUUID(async () => { + const h = harness({ 'note.md': 'Old' }) + const [local] = await h.repository.scan() + await h.repository.apply({ sequence: 2, item_id: 'item', revision: 2, type: 'upsert', path: 'note.md', + previous_path: null, content: content('New') }, { item_id: 'item', path: local.path, kind: local.kind, + revision: 1, sha256: local.content.sha256, byte_length: local.content.byte_length, media_type: local.content.media_type }) + assert.equal(h.files.get('note.md')?.bytes.toString(), 'New') + assert.ok(h.writes.some((path) => /^\.zennotes\/sync\/write-[0-9a-f-]{36}\.md$/.test(path))) + assert.ok(h.writes.some((path) => /^\.zennotes\/sync\/rollback-[0-9a-f-]{36}\.md$/.test(path))) + }) + }) + it('uses streaming reads for inherited apply and preserves an unsynced large edit', async () => { const bytes = Buffer.alloc(6_000_000, 197) const h = harness({ 'asset.bin': bytes }) diff --git a/src/bridge/cloud-sync-repository.ts b/src/bridge/cloud-sync-repository.ts index 981aa92..dc95ee1 100644 --- a/src/bridge/cloud-sync-repository.ts +++ b/src/bridge/cloud-sync-repository.ts @@ -39,6 +39,7 @@ import type { NativeFs } from './native-fs' import { cloudSyncWorkBudget, decodeCloudSyncBase64 } from './cloud-sync-work' import { CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES, rememberMobileUploadSource } from './mobile-direct-upload' import { isNotFoundError } from './fs-errors' +import { randomUUID } from './uuid' import { NativeCloudStaging, type CloudStagingNative } from './cloud-sync-staging' export interface ScanCacheEntry { @@ -52,7 +53,8 @@ export interface ScanCacheEntry { export type ScanCache = Record -type CloudSyncNativeFiles = Pick & Partial +type CloudSyncNativeFiles = Pick & + Partial & Partial> export interface ScanCacheStore { loadTracked(): Promise @@ -209,10 +211,13 @@ class NativeCloudSyncContent { await this.assertReady() const file = await this.native.readForSync(path, TEXT_EXTENSIONS.has(extension(path)), uri) if (file.byteLength > CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES) { + // An encrypted vault seals this file natively from the same URI, into + // this vault's own private sync folder. + const sealForUpload = this.native.sealForUpload?.bind(this.native) const content = rememberMobileUploadSource({ encoding: file.utf8 ? 'utf8' : 'base64', data: '', sha256: file.sha256, byte_length: file.byteLength, media_type: mediaType(path, file.utf8) - }, file.uri) + }, file.uri, sealForUpload) this.sources.set(content, { path, hash: file.sha256, bytes: file.byteLength }) return { path, @@ -262,8 +267,8 @@ class NativeCloudSyncContent { const kind = await this.fs.stat(path) if (kind === 'directory') throw new Error('The destination is a directory.') const before = kind === 'file' ? await this.read(path) : null - const temporary = `.zennotes/sync/write-${crypto.randomUUID()}${extension(path)}` - const backup = `.zennotes/sync/rollback-${crypto.randomUUID()}${extension(path)}` + const temporary = `.zennotes/sync/write-${randomUUID()}${extension(path)}` + const backup = `.zennotes/sync/rollback-${randomUUID()}${extension(path)}` let publishing = false let completed = false try { diff --git a/src/bridge/cloud-sync-staging.test.ts b/src/bridge/cloud-sync-staging.test.ts index 6054bce..3370203 100644 --- a/src/bridge/cloud-sync-staging.test.ts +++ b/src/bridge/cloud-sync-staging.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict' -import { createHash } from 'node:crypto' -import { describe, it } from 'node:test' +import { createDecipheriv, createHash } from 'node:crypto' +import { afterEach, beforeEach, describe, it } from 'node:test' +import { generateCloudE2eeKey } from '@zennotes/shared-domain/cloud-e2ee' +import { CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault' import { loadMobileModule } from '../../tooling/load-mobile-module.ts' // One bundle: the staging token registry is a module-private WeakMap, so the @@ -245,3 +247,197 @@ describe('native Cloud staging', () => { assert.equal(h.files.get('note.md')!.toString(), 'changed again') }) }) + +const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' + +describe('native Cloud staging without crypto.randomUUID (iOS 15.0 to 15.3)', () => { + beforeEach(() => { + Object.defineProperty(crypto, 'randomUUID', { value: undefined, configurable: true }) + }) + afterEach(() => { + Reflect.deleteProperty(crypto, 'randomUUID') + }) + + it('names its staged download and rollback copy from getRandomValues', async () => { + const original = Buffer.from('original') + const h = harness({ 'note.md': original }) + const cloud = Buffer.from('cloud version') + const ref = h.reference('item', 2, cloud, 'utf8') + h.objects.set('https://objects.example.test/item/2', cloud) + const file = await h.staging.stage(h.source(ref, 'https://objects.example.test/item/2')) + const tracked = { item_id: 'item', path: 'note.md', kind: 'text', revision: 1, sha256: hash(original), byte_length: original.length, media_type: 'text/markdown' } + const conflict = await h.staging.apply( + { sequence: 6, item_id: 'item', type: 'upsert', path: 'note.md', previous_path: null, revision: 2, content_ref: ref }, + tracked, file + ) + assert.equal(conflict, undefined) + assert.equal(h.files.get('note.md')!.toString(), 'cloud version') + assert.ok(h.log.some((entry) => new RegExp(`^rename note\\.md -> \\.zennotes/sync/rollback-${UUID}$`).test(entry))) + assert.ok(h.log.some((entry) => new RegExp(`^rename \\.zennotes/sync/downloads/${UUID} -> note\\.md$`).test(entry))) + }) +}) + +const TAMPERED = 'The encrypted Cloud file was changed or is incomplete.' + +/** Stands in for the CryptoKit chunk loop: opens exactly what the header, key + * and AAD JavaScript hands over describe, or fails as the plugin does. */ +function openZne1(sealed: Buffer, keys: { header: string; objectKey: string; aad: string }): Buffer { + if (!sealed.subarray(0, 74).equals(Buffer.from(keys.header, 'base64'))) { + throw Object.assign(new Error(TAMPERED), { code: 'E2EE_TAMPERED' }) + } + const key = Buffer.from(keys.objectKey, 'base64') + const aad = Buffer.from(keys.aad, 'base64') + const body = sealed.subarray(74) + const parts: Buffer[] = [] + for (let index = 0, offset = 0; ; index++) { + const end = Math.min(offset + 65_552, body.length) + const last = end === body.length + const nonce = Buffer.alloc(12) + nonce.writeUInt32BE(index, 7) + nonce[11] = last ? 1 : 0 + const decipher = createDecipheriv('aes-256-gcm', key, nonce) + decipher.setAAD(aad) + decipher.setAuthTag(body.subarray(end - 16, end)) + try { + parts.push(decipher.update(body.subarray(offset, end - 16)), decipher.final()) + } catch { + throw Object.assign(new Error(TAMPERED), { code: 'E2EE_TAMPERED' }) + } + offset = end + if (last) return Buffer.concat(parts) + } +} + +async function vaultCipher() { + const accountKey = generateCloudE2eeKey() + const encryption = await CloudVaultCipher.prepareVault('Test vault', accountKey, 1) + return CloudVaultCipher.open({ id: 'vault-1', encryption }, accountKey, 1) +} + +/** The staging harness with the native header read and chunk loop, and a real vault cipher. */ +async function encryptedHarness(initial: Record = {}) { + const h = harness(initial) + const opened: string[] = [] + const downloads: string[] = [] + const download = h.native.download + Object.assign(h.native, { + async download(options: { url: string; to: string; byteLength: number; sha256: string }) { + downloads.push(options.to) + await download(options) + }, + async readHeader(path: string, length: number) { + return h.files.get(path)!.subarray(0, length).toString('base64') + }, + async decryptFile(options: { from: string; to: string; header: string; objectKey: string; aad: string }) { + const plaintext = openZne1(h.files.get(options.from)!, options) + h.files.set(options.to, plaintext) + opened.push(`${options.from} -> ${options.to}`) + return { byteLength: plaintext.length, sha256: hash(plaintext) } + } + }) + const cipher = await vaultCipher() + const seal = async (plaintext: Buffer) => { + const pieces: Buffer[] = [] + for await (const piece of cipher.encryptStream([plaintext])) pieces.push(Buffer.from(piece)) + return Buffer.concat(pieces) + } + /** As the vault cipher hands a reference over: plaintext metadata, and the stored ciphertext in `encrypted`. */ + const reference = (itemId: string, revision: number, plaintext: Buffer, sealed: Buffer) => ({ + item_id: itemId, revision, encoding: 'base64', sha256: hash(plaintext), byte_length: plaintext.length, + media_type: 'application/octet-stream', encrypted: { sha256: hash(sealed), byte_length: sealed.length } + }) + const source = (ref: any, url: string, decryptor: unknown = cipher) => ({ + reference: ref, previewLimitBytes: 262_144, allowInsecureLoopback: false, + ...(decryptor ? { decryptor } : {}), + getInstruction: async () => ({ item_id: ref.item_id, revision: ref.revision, + content: { encoding: 'aes-gcm', sha256: ref.encrypted.sha256, byte_length: ref.encrypted.byte_length, media_type: 'application/octet-stream' }, + download: { url, method: 'GET', headers: {}, expires_at: new Date(Date.now() + 300_000).toISOString() } }) + }) + const leftovers = () => [...h.files.keys()].filter((path) => path.startsWith('.zennotes/sync/')) + return { ...h, cipher, seal, reference, source, opened, downloads, leftovers } +} + +describe('native Cloud staging for end-to-end encrypted vaults', () => { + const url = 'https://objects.example.test/item/7' + + it('downloads the ciphertext, opens it natively, and publishes only the checked plaintext', async () => { + const h = await encryptedHarness() + const plaintext = Buffer.alloc(200_000, 3) + const sealed = await h.seal(plaintext) + const ref = h.reference('item', 7, plaintext, sealed) + h.objects.set(url, sealed) + const file = await h.staging.stage(h.source(ref, url)) + const [staged] = h.leftovers() + assert.match(staged!, new RegExp(`^\\.zennotes/sync/downloads/${UUID}$`)) + assert.deepEqual(h.downloads, [`${staged}.sealed`], 'the ciphertext downloads beside the staged file') + assert.deepEqual(h.opened, [`${staged}.sealed -> ${staged}`]) + assert.deepEqual(h.files.get(staged!), plaintext) + assert.equal([...h.files.values()].some((bytes) => bytes.equals(sealed)), false, 'no ciphertext is left anywhere') + const conflict = await h.staging.apply( + { sequence: 8, item_id: 'item', type: 'upsert', path: 'attachements/photo.bin', previous_path: null, revision: 7, content_ref: ref }, + undefined, file + ) + assert.equal(conflict, undefined) + assert.deepEqual(h.files.get('attachements/photo.bin'), plaintext) + assert.deepEqual(h.leftovers(), []) + }) + + it('refuses an encrypted reference without its key before downloading anything', async () => { + const h = await encryptedHarness() + const sealed = await h.seal(Buffer.from('secret')) + h.objects.set(url, sealed) + await assert.rejects(h.staging.stage(h.source(h.reference('item', 7, Buffer.from('secret'), sealed), url, null)), /no key to open it/) + assert.deepEqual(h.downloads, []) + assert.deepEqual(h.leftovers(), []) + }) + + it('refuses an encrypted reference on a device without the native chunk loop', async () => { + const h = harness() + const cipher = await vaultCipher() + const encrypted = { sha256: 'a'.repeat(64), byte_length: 96 } + await assert.rejects(h.staging.stage({ + reference: { ...h.reference('item', 7, Buffer.from('secret')), encrypted }, decryptor: cipher, + previewLimitBytes: 262_144, getInstruction: async () => assert.fail('nothing may download') + }), /cannot open end-to-end encrypted/) + assert.deepEqual(h.log, []) + }) + + it('rejects ciphertext the service changed even when its hashes agree, and leaves nothing behind', async () => { + const h = await encryptedHarness() + const plaintext = Buffer.alloc(70_000, 5) + const sealed = await h.seal(plaintext) + sealed[74 + 65_552 + 3]! ^= 0x01 + h.objects.set(url, sealed) + await assert.rejects(h.staging.stage(h.source(h.reference('item', 7, plaintext, sealed), url)), /changed or is incomplete/) + assert.deepEqual(h.leftovers(), []) + }) + + it('rejects a key that does not open the object before decrypting anything', async () => { + const h = await encryptedHarness() + const plaintext = Buffer.from('another vault') + const sealed = await h.seal(plaintext) + h.objects.set(url, sealed) + await assert.rejects(h.staging.stage(h.source(h.reference('item', 7, plaintext, sealed), url, await vaultCipher())), /key does not open/) + assert.deepEqual(h.opened, []) + assert.deepEqual(h.leftovers(), []) + }) + + it('rejects plaintext that is not the file its metadata names', async () => { + const h = await encryptedHarness() + const sealed = await h.seal(Buffer.from('served under the wrong name')) + h.objects.set(url, sealed) + const claimed = Buffer.from('the file the metadata names') + await assert.rejects(h.staging.stage(h.source(h.reference('item', 7, claimed, sealed), url)), /not the file its metadata names/) + assert.deepEqual(h.leftovers(), []) + }) + + it('rejects a download that is not the ciphertext its reference describes', async () => { + const h = await encryptedHarness() + const plaintext = Buffer.from('expected') + const sealed = await h.seal(plaintext) + h.objects.set(url, await h.seal(plaintext)) + await assert.rejects(h.staging.stage(h.source(h.reference('item', 7, plaintext, sealed), url)), /verification failed/) + assert.deepEqual(h.opened, []) + assert.deepEqual(h.leftovers(), []) + }) +}) diff --git a/src/bridge/cloud-sync-staging.ts b/src/bridge/cloud-sync-staging.ts index 57e64bd..d8b8430 100644 --- a/src/bridge/cloud-sync-staging.ts +++ b/src/bridge/cloud-sync-staging.ts @@ -1,11 +1,13 @@ import type { CloudSyncChange, CloudSyncContentReference } from '@zennotes/bridge-contract/cloud-sync' import { cloudSyncPathKey, normalizeCloudSyncPath, shouldSyncVaultPath } from '@zennotes/shared-domain/cloud-sync' +import { ZNE1_HEADER_BYTES, fromBase64, toBase64 } from '@zennotes/shared-domain/cloud-e2ee' import { cloudSyncStagedHandle, registerCloudSyncStagedFile, throwIfCloudSyncCancelled, validateCloudSyncContentReference, validateCloudSyncDownloadInstruction, + type CloudContentDecryptor, type CloudSyncDownloadSource, type CloudSyncStagedConflict, type CloudSyncStagedFile @@ -13,6 +15,7 @@ import { import type { CloudSyncRepositoryConflict } from '@zennotes/shared-domain/cloud-sync-coordinator' import type { CloudSyncLocalItem, CloudSyncTrackedItem } from '@zennotes/shared-domain/cloud-sync-engine' import type { CloudFileFingerprint } from './native-fs' +import { randomUUID } from './uuid' const ROLLBACK_DIRECTORY = '.zennotes/sync' const STAGING_DIRECTORY = `${ROLLBACK_DIRECTORY}/downloads` @@ -27,8 +30,14 @@ export interface CloudStagingNative { deleteFile(path: string): Promise mkdir(path: string): Promise readText(path: string): Promise + /** End-to-end encrypted vaults: base64 of up to `length` leading bytes of a file. */ + readHeader?(path: string, length: number): Promise + /** End-to-end encrypted vaults: opens a ZNE1 file into `to` with the native chunk loop. */ + decryptFile?(options: { from: string; to: string; header: string; objectKey: string; aad: string }): Promise<{ byteLength: number; sha256: string }> } +type NativeDecryption = Required> + interface StagedHandle { owner: object path: string @@ -46,10 +55,16 @@ export class NativeCloudStaging { async stage(source: CloudSyncDownloadSource): Promise { const reference = validateCloudSyncContentReference(source.reference) + // An encrypted vault's download is ciphertext. It is verified against + // `encrypted`, opened by the native chunk loop into the staged file and + // deleted, so only plaintext checked against the reference can publish. + const open = reference.encrypted ? this.opener(source.decryptor) : null throwIfCloudSyncCancelled(source.signal) await this.native.mkdir(STAGING_DIRECTORY) - const path = `${STAGING_DIRECTORY}/${crypto.randomUUID()}` + const path = `${STAGING_DIRECTORY}/${randomUUID()}` + const download = open ? `${path}.sealed` : path const discard = async () => { await this.native.deleteFile(path).catch(() => {}) } + const discardDownload = async () => { await this.native.deleteFile(download).catch(() => {}) } try { for (let attempt = 0; attempt < 2; attempt++) { throwIfCloudSyncCancelled(source.signal) @@ -60,19 +75,28 @@ export class NativeCloudStaging { if (attempt === 0) continue throw new Error('Cloud returned an expired download URL.') } + const expected = reference.encrypted ?? reference try { await this.native.download({ - url: instruction.download.url, headers: instruction.download.headers, to: path, - byteLength: reference.byte_length, sha256: reference.sha256 + url: instruction.download.url, headers: instruction.download.headers, to: download, + byteLength: expected.byte_length, sha256: expected.sha256 }) } catch (error) { if (attempt === 0 && isExpiredUrlError(error)) { - await discard() + await discardDownload() continue } throw error } throwIfCloudSyncCancelled(source.signal) + if (open) { + try { + await open(download, path, reference) + } finally { + await discardDownload() + } + throwIfCloudSyncCancelled(source.signal) + } const staged = await this.native.readForSync(path, false) if (staged.sha256 !== reference.sha256 || staged.byteLength !== reference.byte_length) { throw new Error('Cloud download failed length or SHA-256 verification.') @@ -90,10 +114,27 @@ export class NativeCloudStaging { throw new Error('Cloud download could not be refreshed.') } catch (error) { await discard() + if (download !== path) await discardDownload() throw error } } + /** Checked before anything downloads: an encrypted reference needs its key and this device's native chunk loop. */ + private opener(decryptor: CloudContentDecryptor | undefined) { + if (!decryptor) throw new Error('This encrypted download has no key to open it.') + const native = this.native + if (!hasNativeDecryption(native)) throw new Error('This device cannot open end-to-end encrypted Cloud files.') + return async (from: string, to: string, reference: CloudSyncContentReference): Promise => { + const keys = await decryptor.openContentHeader(fromBase64(await native.readHeader(from, ZNE1_HEADER_BYTES))) + const opened = await native.decryptFile({ + from, to, header: toBase64(keys.header), objectKey: toBase64(keys.objectKey), aad: toBase64(keys.aad) + }) + if (opened.sha256 !== reference.sha256 || opened.byteLength !== reference.byte_length) { + throw new Error('Decrypted Cloud file is not the file its metadata names.') + } + } + } + async matches(path: string, reference: CloudSyncContentReference): Promise { if (await this.native.statVerified(path) !== 'file') return false const local = await this.native.readForSync(path, false) @@ -197,7 +238,7 @@ export class NativeCloudStaging { // fail-closed recovery check (which lists that directory) sees it if the // process dies mid-publish. A stranded original must never read as a // local deletion on the next scan. - const backup = `${ROLLBACK_DIRECTORY}/rollback-${crypto.randomUUID()}` + const backup = `${ROLLBACK_DIRECTORY}/rollback-${randomUUID()}` let publishing = false try { throwIfCloudSyncCancelled(handle.signal) @@ -255,6 +296,10 @@ export class NativeCloudStaging { } } +function hasNativeDecryption(native: CloudStagingNative): native is CloudStagingNative & NativeDecryption { + return typeof native.readHeader === 'function' && typeof native.decryptFile === 'function' +} + function isExpiredUrlError(error: unknown): boolean { // Capacitor plugin rejections carry the HTTP status under `data`. const candidate = error as { status?: unknown; data?: { status?: unknown } } diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 97f32bb..a21d88a 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -84,6 +84,7 @@ import { resolveExternalVault } from './folder-picker' import { emitVaultChange, onVaultChange, onOpenNoteRequested, requestOpenNote } from './events' +import { randomUUID } from './uuid' import { renderTikzOnDevice } from './tikz' import { fetchLinkMetadataOnDevice } from './link-metadata' import { @@ -99,6 +100,16 @@ import { updateMobileCloudPublishedNote } from './mobile-cloud-auth' import { uploadObject } from './cloud-sync-client' +import { + changeMobileCloudEncryptionPassphrase, + finishMobileCloudEncryptionSetup, + finishNewMobileCloudRecoveryKey, + getMobileCloudEncryptionStatus, + lockMobileCloudEncryption, + prepareMobileCloudEncryptionSetup, + prepareNewMobileCloudRecoveryKey, + unlockMobileCloudEncryption +} from './mobile-cloud-encryption' import { mobilePublishAssetPlatform } from './mobile-publish-assets' import { createAndLinkMobileCloudVault, @@ -143,7 +154,7 @@ import { import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core' import { isPhoneViewport } from '../viewport' -let appVersion = '1.1.36' +let appVersion = '1.1.37' export async function loadNativeAppVersion(): Promise { try { @@ -807,7 +818,7 @@ const droppedFiles = new Map() function getPathForFile(file: File): string | null { if (!file) return null - const token = `mobile-drop://${crypto.randomUUID()}/${encodeURIComponent(file.name)}` + const token = `mobile-drop://${randomUUID()}/${encodeURIComponent(file.name)}` droppedFiles.set(token, file) return token } @@ -932,8 +943,20 @@ export const mobileBridge: ZenBridge = { listCloudVaults: listMobileCloudVaults, getCloudVaultLink: () => getMobileCloudVaultLink(activeMobileVault()), linkCloudVault: (vaultId) => linkMobileCloudVault(activeMobileVault(), vaultId), - createAndLinkCloudVault: (name) => - createAndLinkMobileCloudVault(activeMobileVault(), name), + createAndLinkCloudVault: (name, options) => + createAndLinkMobileCloudVault(activeMobileVault(), name, options), + // End-to-end encryption for the account. Settings shows it only while the + // service offers it and this build opted in (MOBILE_CLOUD_END_TO_END_ENCRYPTION). + // Moving an existing vault into an encrypted one (encryptCloudVault) stays + // desktop-only. + getCloudEncryptionStatus: getMobileCloudEncryptionStatus, + prepareCloudEncryptionSetup: prepareMobileCloudEncryptionSetup, + finishCloudEncryptionSetup: finishMobileCloudEncryptionSetup, + unlockCloudEncryption: unlockMobileCloudEncryption, + changeCloudEncryptionPassphrase: changeMobileCloudEncryptionPassphrase, + prepareNewCloudRecoveryKey: prepareNewMobileCloudRecoveryKey, + finishNewCloudRecoveryKey: finishNewMobileCloudRecoveryKey, + lockCloudEncryption: lockMobileCloudEncryption, unlinkCloudVault: () => unlinkMobileCloudVault(activeMobileVault()), deleteCloudVault: () => deleteMobileCloudVault(activeMobileVault()), syncCloudVault: () => syncMobileCloudVault(activeMobileVault()), diff --git a/src/bridge/mobile-cloud-auth.test.ts b/src/bridge/mobile-cloud-auth.test.ts index 4371250..9efce50 100644 --- a/src/bridge/mobile-cloud-auth.test.ts +++ b/src/bridge/mobile-cloud-auth.test.ts @@ -11,8 +11,10 @@ const account = { } const credential = JSON.stringify({ base_url: account.base_url, token: 'test-only', account }) -async function coldLaunch(saved: Map) { +async function coldLaunch(saved: Map, http: Record = {}) { const lifecycle: string[] = [] + // Entries a restored install can no longer decrypt: its Keystore key did not come back. + const unreadable = new Set() const listeners = new Map void>() let lifetime = new AbortController() let delayRead: { entered(): void; wait: Promise } | null = null @@ -27,6 +29,7 @@ async function coldLaunch(saved: Map) { async setSynchronize(_value: boolean) {} async setDefaultKeychainAccess(_value: unknown) {} async getItem(key: string) { + if (unreadable.has(this.prefix + key)) throw Object.assign(new Error('Could not decrypt the item'), { code: 'osError' }) const value = saved.get(this.prefix + key) ?? null if (key === 'credential' && delayRead) { const delayed = delayRead @@ -38,11 +41,14 @@ async function coldLaunch(saved: Map) { } async setItem(key: string, value: string) { saved.set(this.prefix + key, value) } async removeItem(key: string) { saved.delete(this.prefix + key) } + async keys() { + return [...saved.keys()].filter((key) => key.startsWith(this.prefix)).map((key) => key.slice(this.prefix.length)) + } }() } }) const api = await loadMobileModule('./src/bridge/mobile-cloud-auth.ts', { - '@capacitor/core': { Capacitor: { isNativePlatform: () => true }, CapacitorHttp: {} }, + '@capacitor/core': { Capacitor: { isNativePlatform: () => true }, CapacitorHttp: http }, '@capacitor/app': { App: { addListener: async (name: string, listener: (event: any) => void) => { listeners.set(name, listener) return {} @@ -61,6 +67,8 @@ async function coldLaunch(saved: Map) { await api.configureMobileCloudAuth('test-version') api.lifecycle = lifecycle api.appState = (isActive: boolean) => listeners.get('appStateChange')!({ isActive }) + api.openUrl = (url: string) => listeners.get('appUrlOpen')!({ url }) + api.unreadable = unreadable api.pauseNextCredentialRead = () => { let entered!: () => void let release!: () => void @@ -137,3 +145,78 @@ it('recovers pending sign-in state across cold launches', async () => { assert.deepEqual([...saved.entries()], [['zennotes.cloud.pending-auth', pending]]) } }) + +const unlockedKey = { format: 1, key: Buffer.alloc(32, 7).toString('base64'), version: 1 } + +it('keeps one encryption key per account, and reads a missing, damaged or unreadable one as locked', async () => { + const saved = new Map([['zennotes.cloud.credential', credential]]) + const api = await coldLaunch(saved) + const store = api.mobileCloudAccountKeyStore(account.base_url, 'Test@Example.test') + assert.equal(await store.load(), null) + await store.save(unlockedKey) + assert.deepEqual(await api.mobileCloudAccountKeyStore(`${account.base_url}/`, account.user.email).load(), unlockedKey) + assert.equal(await api.mobileCloudAccountKeyStore('https://other.example.test', account.user.email).load(), null) + const [entry] = [...saved.keys()].filter((key) => key.includes('e2ee-account-key.')) + assert.match(entry!, /^zennotes\.cloud\.e2ee-account-key\.[0-9a-f]{64}$/) + + saved.set(entry!, '{"format":1') + assert.equal(await store.load(), null) + saved.set(entry!, JSON.stringify({ ...unlockedKey, key: 'c2hvcnQ=' })) + assert.equal(await store.load(), null) + saved.set(entry!, JSON.stringify(unlockedKey)) + api.unreadable.add(entry!) + assert.equal(await store.load(), null) + api.unreadable.clear() + await store.clear() + assert.equal(saved.has(entry!), false) +}) + +it('revokes this device and forgets every encryption key when signing out', async () => { + for (const status of [204, 401, 500]) { + const saved = new Map([['zennotes.cloud.credential', credential]]) + const requests: unknown[] = [] + const api = await coldLaunch(saved, { request: async (request: unknown) => { requests.push(request); return { status, data: {} } } }) + await api.mobileCloudAccountKeyStore(account.base_url, account.user.email).save(unlockedKey) + saved.set(`zennotes.cloud.e2ee-account-key.${'f'.repeat(64)}`, JSON.stringify(unlockedKey)) + + assert.deepEqual(await api.logoutMobileCloudAccount(), { state: 'disconnected', account: null }) + assert.deepEqual(requests, [{ + method: 'DELETE', url: 'https://zennotes.org/api/v1/devices/current', + headers: { Accept: 'application/json', Authorization: 'Bearer test-only' }, + connectTimeout: 10_000, readTimeout: 10_000, disableRedirects: true + }]) + assert.equal(saved.size, 0, `answer ${status}`) + } +}) + +it('keeps the encryption key across a new sign-in to the same account, and drops it on a switch', async () => { + const saved = new Map([['zennotes.cloud.credential', credential]]) + let signedInAs = account.user.email + const api = await coldLaunch(saved, { + post: async () => ({ status: 200, data: { + token: 'next-token', user: { name: 'Test', email: signedInAs }, + device: { id: 'test-device', name: 'Test phone', platform: 'android' } + } }) + }) + const signIn = async (email: string) => { + signedInAs = email + saved.set('zennotes.cloud.pending-auth', JSON.stringify({ + base_url: account.base_url, state: 'test-state', code_verifier: 'a'.repeat(43), expires_at: '2099-01-01T00:00:00Z' + })) + const changed = new Promise((resolve) => { const stop = api.onMobileCloudAccountChange((value: unknown) => { stop(); resolve(value) }) }) + api.openUrl('zennotes://auth?code=abc123&state=test-state') + let timer: ReturnType | undefined + await Promise.race([changed, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Sign-in did not finish')), 5_000) })]) + clearTimeout(timer) + } + const store = api.mobileCloudAccountKeyStore(account.base_url, account.user.email) + await store.save(unlockedKey) + + await signIn(account.user.email.toUpperCase()) + assert.deepEqual(await store.load(), unlockedKey) + assert.equal((await api.authenticatedCredential()).token, 'next-token') + + await signIn('someone-else@example.test') + assert.equal(await store.load(), null) + assert.deepEqual([...saved.keys()].filter((key) => key.includes('e2ee-account-key.')), []) +}) diff --git a/src/bridge/mobile-cloud-auth.ts b/src/bridge/mobile-cloud-auth.ts index 4ba9bed..38f2c12 100644 --- a/src/bridge/mobile-cloud-auth.ts +++ b/src/bridge/mobile-cloud-auth.ts @@ -21,15 +21,33 @@ import { type CloudAuthPending, type CloudAuthStorage } from '@zennotes/shared-domain/cloud-auth-flow' +import { + CloudE2eeKeyring, + type CloudAccountKeyStore, + type StoredCloudAccountKey +} from '@zennotes/shared-domain/cloud-e2ee-account' import { createCloudSyncClient, stopMobileCloudRequests, resumeMobileCloudRequests, mobileCloudRequestSignal } from './cloud-sync-client' import { publishWithStagedUploads, type CloudPublishAssetPlatform } from '@zennotes/shared-domain/cloud-publish-uploads' +/** + * Whether this build lets ZenNotes Cloud serve it end-to-end encrypted vaults + * (docs/specs/cloud-e2ee.md in the desktop repo). It may be on only while every + * path an encrypted vault's bytes can take is sealed and opened on this + * device: the core seals inline content, mobile-direct-upload seals streamed + * uploads natively, and the shared download staging opens references + * natively. Off, the client never sends the encryption header, so the service + * refuses encrypted vaults to this app, and Settings never offers encryption. + */ +export const MOBILE_CLOUD_END_TO_END_ENCRYPTION = true + const DEVELOPMENT_CLOUD_BASE_URL = import.meta.env.VITE_ZENNOTES_CLOUD_DEV_URL?.trim() const PRODUCTION_CLOUD_BASE_URL = 'https://zennotes.org' const LEGACY_CLOUD_BASE_URL = 'https://zennotes.laravel.cloud' const DEFAULT_CLOUD_BASE_URL = DEVELOPMENT_CLOUD_BASE_URL || PRODUCTION_CLOUD_BASE_URL const PENDING_AUTH_KEY = 'pending-auth' const CREDENTIAL_KEY = 'credential' +/** One entry per account: a fingerprint of the base URL and the lowercased email follows. */ +const ACCOUNT_KEY_PREFIX = 'e2ee-account-key.' const accountListeners = new Set<(status: CloudAccountStatus) => void>() let authFlow: CloudAuthFlow | null = null @@ -116,20 +134,120 @@ const storage: CloudAuthStorage = { }, async saveCredential(credential: CloudAuthCredential): Promise { stopMobileCloudRequests() + forgetKeyring() assertNativeCloudAuth() await secureStorageReady() - const canonicalCredential = migrateLegacyCloudCredential(credential).value + const canonicalCredential = migrateLegacyCloudCredential(credential).value as CloudAuthCredential + // Another account's encryption key does not outlive a switch to this one; + // signing in again to the same account keeps this device unlocked. Each + // entry belongs to one account and can never unlock another, so a failed + // sweep must not block signing in; signing out sweeps again and must work. + await clearAccountKeys( + await accountKeyName(canonicalCredential.base_url, canonicalCredential.account.user.email) + ).catch(() => {}) await SecureStorage.setItem(CREDENTIAL_KEY, JSON.stringify(canonicalCredential)) if (appActive) resumeMobileCloudRequests() }, async deleteCredential(): Promise { stopMobileCloudRequests() + forgetKeyring() if (!Capacitor.isNativePlatform()) return await secureStorageReady() + // Signing out also forgets this device's encryption keys; the passphrase + // or the recovery key unlocks it again. + await clearAccountKeys() await SecureStorage.removeItem(CREDENTIAL_KEY) } } +async function clearAccountKeys(keep: string | null = null): Promise { + for (const key of await SecureStorage.keys()) { + if (key.startsWith(ACCOUNT_KEY_PREFIX) && key !== keep) await SecureStorage.removeItem(key) + } +} + +async function accountKeyName(baseUrl: string, email: string): Promise { + const scope = `${baseUrl.trim().replace(/\/+$/, '')}\n${email.trim().toLowerCase()}` + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(scope)) + return ACCOUNT_KEY_PREFIX + [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('') +} + +/** + * This device's unlocked end-to-end encryption key for one account, kept in + * the Keystore-backed storage that holds the credential. A missing or + * unreadable entry reads as locked: with allowBackup, a restored install gets + * the stored value back but not the Keystore key that decrypts it, and that + * must ask for the passphrase again rather than fail on every read. + */ +export function mobileCloudAccountKeyStore(baseUrl: string, email: string): CloudAccountKeyStore { + return { + async load(): Promise { + if (!Capacitor.isNativePlatform()) return null + try { + await secureStorageReady() + return parseStoredAccountKey(await SecureStorage.getItem(await accountKeyName(baseUrl, email))) + } catch { + return null + } + }, + async save(value: StoredCloudAccountKey): Promise { + assertNativeCloudAuth() + await secureStorageReady() + await SecureStorage.setItem(await accountKeyName(baseUrl, email), JSON.stringify(value)) + }, + async clear(): Promise { + if (!Capacitor.isNativePlatform()) return + await secureStorageReady() + await SecureStorage.removeItem(await accountKeyName(baseUrl, email)) + } + } +} + +function parseStoredAccountKey(raw: string | null): StoredCloudAccountKey | null { + if (raw === null) return null + try { + const value = JSON.parse(raw) as Partial | null + // A 32-byte key is 44 base64 characters; anything else could never open a vault. + return value?.format === 1 && typeof value.key === 'string' && /^[A-Za-z0-9+/]{43}=$/.test(value.key) && + typeof value.version === 'number' && Number.isInteger(value.version) && value.version >= 0 + ? { format: 1, key: value.key, version: value.version } + : null + } catch { + return null + } +} + +let keyringCache: { scope: string; keyring: CloudE2eeKeyring } | null = null + +/** Which signed-in credential this is: the account, and the token that signs in as it. */ +export function mobileCloudCredentialScope(credential: CloudAuthCredential): string { + return JSON.stringify([credential.base_url, credential.account.user.email.toLowerCase(), credential.token]) +} + +/** + * One keyring per signed-in credential, so the "is this vault encrypted" + * answers and opened ciphers last across calls but never across accounts. + */ +export function mobileCloudKeyring(credential: CloudAuthCredential): CloudE2eeKeyring { + const scope = mobileCloudCredentialScope(credential) + if (keyringCache?.scope === scope) return keyringCache.keyring + forgetKeyring() + const keyring = new CloudE2eeKeyring({ + listVaults: async () => (await createCloudSyncClient(credential.base_url, credential.token, { + accountId: credential.account.user.email, + signal: mobileCloudRequestSignal() + }).listVaults()).data, + store: mobileCloudAccountKeyStore(credential.base_url, credential.account.user.email) + }) + keyringCache = { scope, keyring } + return keyring +} + +function forgetKeyring(): void { + keyringCache?.keyring.forget() + keyringCache = null +} + export async function configureMobileCloudAuth(appVersion: string): Promise { if (authFlow) return @@ -140,6 +258,7 @@ export async function configureMobileCloudAuth(appVersion: string): Promise { @@ -281,6 +404,26 @@ async function openExternalBrowser(url: string): Promise { window.open(url, '_blank', 'noopener,noreferrer') } +/** + * Ask the service to end this device's access while the token still exists + * to ask with, as Connected apps on the website does. CloudAuthFlow signs out + * whatever the answer, and gives up waiting after a few seconds. + */ +async function revokeMobileCloudDevice(baseUrl: string, token: string): Promise { + const response = await CapacitorHttp.request({ + method: 'DELETE', + url: `${baseUrl}/api/v1/devices/current`, + headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, + connectTimeout: 10_000, + readTimeout: 10_000, + disableRedirects: true + }) + // 401 means the token is already gone, which is what was asked for. + if ((response.status < 200 || response.status >= 300) && response.status !== 401) { + throw new Error(`ZenNotes Cloud did not disconnect this device (${response.status}).`) + } +} + async function exchangeCloudAuthCode( baseUrl: string, request: CloudAuthExchangeRequest diff --git a/src/bridge/mobile-cloud-encryption.test.ts b/src/bridge/mobile-cloud-encryption.test.ts new file mode 100644 index 0000000..4f3f279 --- /dev/null +++ b/src/bridge/mobile-cloud-encryption.test.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import type { CloudEncryptionKeyset, CloudSyncVault } from '@zennotes/bridge-contract/cloud-sync' +import { generateCloudE2eeKey } from '@zennotes/shared-domain/cloud-e2ee' +import { CloudE2eeKeyring, type StoredCloudAccountKey } from '@zennotes/shared-domain/cloud-e2ee-account' +import { CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +const { createMobileCloudEncryption } = await loadMobileModule('./src/bridge/mobile-cloud-encryption.ts', { + './mobile-cloud-auth': { MOBILE_CLOUD_END_TO_END_ENCRYPTION: false } +}) + +const PASSPHRASE = 'correct horse battery staple' +const NEXT_PASSPHRASE = 'a brand new passphrase here' + +/** An account on a service that stores the keyset, and this device's key store and keyring. */ +function account() { + let keyset: CloudEncryptionKeyset | null = null + let stored: StoredCloudAccountKey | null = null + const vaults: CloudSyncVault[] = [] + const store = { + load: async () => stored, + save: async (value: StoredCloudAccountKey) => { stored = value }, + clear: async () => { stored = null } + } + const client = { + account: async () => ({ data: { capabilities: { end_to_end_encryption: true }, encryption: { keyset: keyset !== null } } }), + keysetApi: () => ({ + getKeyset: async () => keyset, + createKeyset: async (body: Omit) => { + assert.equal(keyset, null) + keyset = { ...body, revision: 1, updated_at: '2026-10-12T00:00:00Z' } + return keyset + }, + updateKeyset: async ({ expected_revision: revision, ...changes }: { expected_revision: number }) => { + assert.equal(revision, keyset?.revision) + keyset = { ...keyset!, ...changes, revision: revision + 1 } + return keyset + } + }), + createEncryptedVault: async (name: string, accountKey: Uint8Array, version: number) => { + const vault = { + id: `vault-${vaults.length + 1}`, name: 'Encrypted vault', cursor: 0, created_at: '', updated_at: '', + encryption: await CloudVaultCipher.prepareVault(name, accountKey, version) + } + vaults.push(vault) + return { data: vault } + }, + listVaults: async () => ({ data: vaults }) + } + const keyring = new CloudE2eeKeyring({ listVaults: async () => vaults, store }) + return { client, store, keyring, vaults, get keyset() { return keyset } } +} + +it('stays out of Settings and refuses encrypted vaults until this build opts in', async () => { + const h = account() + const elsewhere = generateCloudE2eeKey() + h.vaults.push( + { id: 'readable', name: 'Notes', cursor: 0, created_at: '', updated_at: '', encryption: null }, + { id: 'sealed', name: 'Encrypted vault', cursor: 0, created_at: '', updated_at: '', + encryption: await CloudVaultCipher.prepareVault('Journal', elsewhere, 1) } + ) + let accountRequests = 0 + const encryption = createMobileCloudEncryption({ + enabled: false, + client: async () => ({ ...h.client, account: async () => { accountRequests++; return h.client.account() } }), + keys: async () => assert.fail('a build without encryption never reads keys') + }) + assert.deepEqual(await encryption.status(), { available: false, keyset: false, unlocked: false }) + assert.equal(accountRequests, 0) + await assert.rejects(encryption.prepareSetup(PASSPHRASE), /not available in this version/) + await assert.rejects(encryption.createEncryptedVault('Journal'), /not available in this version/) + assert.equal(await encryption.isEncrypted('sealed'), false) + await encryption.assertCanLink('readable') + await assert.rejects(encryption.assertCanLink('sealed'), /cannot open/) +}) + +it('reports nothing available while signed out', async () => { + const encryption = createMobileCloudEncryption({ + enabled: true, client: async () => null, keys: async () => assert.fail('no keys while signed out') + }) + assert.deepEqual(await encryption.status(), { available: false, keyset: false, unlocked: false }) +}) + +it('sets up, locks, unlocks, changes the passphrase and replaces the recovery key with the core\'s key work', async () => { + const h = account() + const encryption = createMobileCloudEncryption({ + enabled: true, + client: async () => h.client, + keys: async () => ({ store: h.store, keyring: h.keyring, scope: 'account-a' }) + }) + assert.deepEqual(await encryption.status(), { available: true, keyset: false, unlocked: false }) + + const setup = await encryption.prepareSetup(PASSPHRASE) + assert.equal(setup.recoveryWords.length, 24) + assert.equal(h.keyset, null, 'nothing reaches the service before the words are confirmed') + await encryption.finishSetup(setup.setupId) + assert.deepEqual(await encryption.status(), { available: true, keyset: true, unlocked: true }) + await assert.rejects(encryption.finishSetup(setup.setupId), /no longer waiting/) + + const vaultId = await encryption.createEncryptedVault(' Journal ') + assert.equal(await h.keyring.vaultName(h.vaults[0]!), 'Journal') + assert.equal(await encryption.isEncrypted(vaultId), true) + await encryption.assertCanLink(vaultId) + + await encryption.lock() + assert.equal((await encryption.status()).unlocked, false) + await assert.rejects(encryption.assertCanLink(vaultId), { code: 'E2EE_LOCKED' }) + await assert.rejects(encryption.createEncryptedVault('Another'), /Unlock end-to-end encryption/) + await encryption.unlock({ recoveryWords: setup.recoveryWords.join(' ') }) + await encryption.assertCanLink(vaultId) + + await encryption.changePassphrase({ passphrase: PASSPHRASE }, NEXT_PASSPHRASE) + await encryption.lock() + await assert.rejects(encryption.unlock({ passphrase: PASSPHRASE }), /does not unlock/) + await encryption.unlock({ passphrase: NEXT_PASSPHRASE }) + + const replaced = await encryption.prepareNewRecoveryKey({ passphrase: NEXT_PASSPHRASE }) + await encryption.finishNewRecoveryKey(replaced.setupId) + await encryption.lock() + await assert.rejects(encryption.unlock({ recoveryWords: setup.recoveryWords.join(' ') }), /does not unlock/) + await encryption.unlock({ recoveryWords: replaced.recoveryWords.join(' ') }) + assert.equal((await encryption.status()).unlocked, true) +}) + +it('finishes a pending setup only under the credential that began it', async () => { + const h = account() + let scope = 'account-a' + const encryption = createMobileCloudEncryption({ + enabled: true, + client: async () => h.client, + keys: async () => ({ store: h.store, keyring: h.keyring, scope }) + }) + const setup = await encryption.prepareSetup(PASSPHRASE) + scope = 'account-b' + await assert.rejects(encryption.finishSetup(setup.setupId), /no longer waiting/) + assert.equal(h.keyset, null) + assert.equal(await h.store.load(), null) +}) diff --git a/src/bridge/mobile-cloud-encryption.ts b/src/bridge/mobile-cloud-encryption.ts new file mode 100644 index 0000000..99e560d --- /dev/null +++ b/src/bridge/mobile-cloud-encryption.ts @@ -0,0 +1,208 @@ +/** + * End-to-end encryption for the signed-in Cloud account, as the shared + * Settings panel (app-core's CloudEncryptionPanel) asks for it: set up, unlock, + * change the passphrase, replace the recovery key, lock, and create encrypted + * vaults. The key work is the core's (cloud-e2ee-account); this module supplies + * the account's keyset API, this device's key store and its keyring. + */ +import type { + CloudEncryptionRecoveryPrompt, + CloudEncryptionSecretInput, + CloudEncryptionStatus, + CloudSyncVault +} from '@zennotes/bridge-contract/cloud-sync' +import type { CloudSyncApiClient } from '@zennotes/shared-domain/cloud-sync-api' +import { fromBase64 } from '@zennotes/shared-domain/cloud-e2ee' +import { + changeCloudEncryptionPassphrase, + prepareCloudEncryptionSetup, + prepareNewCloudRecoveryKey, + unlockCloudEncryption, + type CloudAccountKeyStore, + type CloudE2eeKeyring, + type PendingCloudEncryptionSetup +} from '@zennotes/shared-domain/cloud-e2ee-account' +import { + MOBILE_CLOUD_END_TO_END_ENCRYPTION, + authenticatedClient, + authenticatedCredential, + getMobileCloudAccountStatus, + mobileCloudAccountKeyStore, + mobileCloudCredentialScope, + mobileCloudKeyring +} from './mobile-cloud-auth' +import { randomUUID } from './uuid' + +export const ENCRYPTED_VAULT_BACKUP_DOWNLOAD_MESSAGE = + 'Download backups of encrypted vaults from the ZenNotes desktop app.' + +export type MobileCloudEncryptionClient = Pick< + CloudSyncApiClient, + 'account' | 'keysetApi' | 'createEncryptedVault' | 'listVaults' +> + +/** This device's side of the signed-in account's encryption. */ +export interface MobileCloudEncryptionKeys { + store: CloudAccountKeyStore + keyring: Pick + /** Which signed-in credential this is; a pending setup finishes only under the one that began it. */ + scope: string +} + +export interface MobileCloudEncryptionDependencies { + /** The host opted in: every path an encrypted vault's bytes take is sealed and opened here. */ + enabled: boolean + /** The signed-in account's client, or null while no account is signed in. */ + client(): Promise + keys(): Promise +} + +const NOT_AVAILABLE: CloudEncryptionStatus = { available: false, keyset: false, unlocked: false } + +export function createMobileCloudEncryption(deps: MobileCloudEncryptionDependencies) { + /** Setups and recovery-key changes waiting for the person to confirm their words. */ + const pending = new Map() + + async function connection(): Promise<{ client: MobileCloudEncryptionClient } & MobileCloudEncryptionKeys> { + if (!deps.enabled) throw new Error('End-to-end encryption is not available in this version of ZenNotes.') + const client = await deps.client() + if (!client) throw new Error('Connect ZenNotes Cloud before setting up encryption.') + return { client, ...(await deps.keys()) } + } + + function remember(scope: string, setup: PendingCloudEncryptionSetup): CloudEncryptionRecoveryPrompt { + const setupId = randomUUID() + pending.set(setupId, { scope, setup }) + return { setupId, recoveryWords: setup.recoveryWords } + } + + async function finish(setupId: string): Promise { + const waiting = pending.get(setupId) + const current = await connection() + if (!waiting || waiting.scope !== current.scope) { + pending.delete(setupId) + throw new Error('That recovery key is no longer waiting to be confirmed. Start again.') + } + await waiting.setup.finish() + pending.delete(setupId) + } + + async function vault(vaultId: string): Promise { + const client = await deps.client() + if (!client) return null + return (await client.listVaults()).data.find((candidate) => candidate.id === vaultId) ?? null + } + + return { + async status(): Promise { + if (!deps.enabled) return NOT_AVAILABLE + const client = await deps.client() + if (!client) return NOT_AVAILABLE + const account = (await client.account()).data + const { store } = await deps.keys() + return { + available: account.capabilities?.end_to_end_encryption === true, + keyset: account.encryption?.keyset === true, + unlocked: (await store.load()) !== null + } + }, + + /** Derives the keys and returns the recovery words; nothing reaches ZenNotes Cloud until finishSetup. */ + async prepareSetup(passphrase: string): Promise { + const { client, store, scope } = await connection() + return remember(scope, await prepareCloudEncryptionSetup(client.keysetApi(), store, passphrase)) + }, + + finishSetup: finish, + + async unlock(secret: CloudEncryptionSecretInput): Promise { + const { client, store, keyring } = await connection() + await unlockCloudEncryption(client.keysetApi(), store, secret) + keyring.forget() + }, + + async changePassphrase(current: CloudEncryptionSecretInput, next: string): Promise { + const { client, store } = await connection() + await changeCloudEncryptionPassphrase(client.keysetApi(), store, current, next) + }, + + async prepareNewRecoveryKey(current: CloudEncryptionSecretInput): Promise { + const { client, store, scope } = await connection() + return remember(scope, await prepareNewCloudRecoveryKey(client.keysetApi(), store, current)) + }, + + finishNewRecoveryKey: finish, + + /** Forgets this device's account key; encrypted vaults stop syncing until it unlocks again. */ + async lock(): Promise { + if (!(await deps.client())) return + const { store, keyring } = await deps.keys() + await store.clear() + keyring.forget() + }, + + /** Creates an encrypted vault and returns its id; its real name is sealed. */ + async createEncryptedVault(name: string): Promise { + const normalized = name.trim() + if (!normalized) throw new Error('Cloud vault name is required.') + const { client, store, keyring } = await connection() + const stored = await store.load() + if (!stored) throw new Error('Unlock end-to-end encryption on this device first.') + const created = (await client.createEncryptedVault(normalized, fromBase64(stored.key), stored.version)).data + keyring.remember(created) + return created.id + }, + + /** + * Linking an encrypted vault needs its key, so a device that cannot open + * it stops here instead of at the first sync. + */ + async assertCanLink(vaultId: string): Promise { + const listed = await vault(vaultId) + if (!listed?.encryption) return + if (!deps.enabled) { + throw new Error('This vault is end-to-end encrypted, which this version of ZenNotes cannot open.') + } + const { keyring } = await connection() + keyring.remember(listed) + await keyring.forVault(listed.id) + }, + + async isEncrypted(vaultId: string): Promise { + // A build that cannot open encrypted vaults refuses to link one. + if (!deps.enabled) return false + return Boolean((await vault(vaultId))?.encryption) + } + } +} + +const encryption = createMobileCloudEncryption({ + enabled: MOBILE_CLOUD_END_TO_END_ENCRYPTION, + async client() { + if ((await getMobileCloudAccountStatus()).state !== 'connected') return null + return authenticatedClient() + }, + async keys() { + const credential = await authenticatedCredential() + return { + store: mobileCloudAccountKeyStore(credential.base_url, credential.account.user.email), + keyring: mobileCloudKeyring(credential), + scope: mobileCloudCredentialScope(credential) + } + } +}) + +export const getMobileCloudEncryptionStatus = (): Promise => encryption.status() +export const prepareMobileCloudEncryptionSetup = (passphrase: string): Promise => + encryption.prepareSetup(passphrase) +export const finishMobileCloudEncryptionSetup = (setupId: string): Promise => encryption.finishSetup(setupId) +export const unlockMobileCloudEncryption = (secret: CloudEncryptionSecretInput): Promise => encryption.unlock(secret) +export const changeMobileCloudEncryptionPassphrase = (current: CloudEncryptionSecretInput, next: string): Promise => + encryption.changePassphrase(current, next) +export const prepareNewMobileCloudRecoveryKey = (current: CloudEncryptionSecretInput): Promise => + encryption.prepareNewRecoveryKey(current) +export const finishNewMobileCloudRecoveryKey = (setupId: string): Promise => encryption.finishNewRecoveryKey(setupId) +export const lockMobileCloudEncryption = (): Promise => encryption.lock() +export const createMobileEncryptedCloudVault = (name: string): Promise => encryption.createEncryptedVault(name) +export const assertMobileCloudVaultCanLink = (vaultId: string): Promise => encryption.assertCanLink(vaultId) +export const isMobileCloudVaultEncrypted = (vaultId: string): Promise => encryption.isEncrypted(vaultId) diff --git a/src/bridge/mobile-cloud-sync.integration.test.ts b/src/bridge/mobile-cloud-sync.integration.test.ts index 902d21c..966edff 100644 --- a/src/bridge/mobile-cloud-sync.integration.test.ts +++ b/src/bridge/mobile-cloud-sync.integration.test.ts @@ -6,6 +6,7 @@ import type { } from '@zennotes/bridge-contract/cloud-sync' import type { CloudSyncState } from '@zennotes/shared-domain/cloud-sync-engine' import { loadMobileModule } from '../../tooling/load-mobile-module.ts' +import { withoutRandomUUID } from '../../tooling/without-random-uuid.ts' function textContent(text: string): CloudSyncContent { return { @@ -16,7 +17,10 @@ function textContent(text: string): CloudSyncContent { /** Real mobile adapter -> host service -> coordinator -> cached repository. * Only Capacitor storage/auth/layout and the server boundary are fakes. */ -async function fixture(initial: Record = { 'note.md': 'Original' }) { +async function fixture( + initial: Record = { 'note.md': 'Original' }, + mocks: Record> = {} +) { const persisted = new Map() const files = new Map(Object.entries(initial).map(([path, data]) => [ path, { bytes: Buffer.from(data), mtime: 1000 } @@ -182,7 +186,8 @@ async function fixture(initial: Record = { 'note.md': 'Original' authenticatedCredential: async () => ({ base_url: 'https://sync.example.test', token: 'test-only' }), authenticatedClient: async () => remote, getMobileCloudAccountStatus: async () => accountStatus - } + }, + ...mocks }) await api.linkMobileCloudVault(vault, 'vault-1') const stateKey = () => [...persisted.keys()].find((path) => path.includes('/states/')) @@ -398,6 +403,18 @@ describe('deleted Cloud vault recovery (#791)', () => { assert.equal(h.persisted.get(otherStateKey), otherState) }) + it('archives a missing vault\'s state on a WebView without crypto.randomUUID', async () => { + const h = await fixture({}) + await h.sync() + h.remote.changes = async () => { throw missing() } + h.remote.manifest = async () => { throw missing() } + + await withoutRandomUUID(() => assert.rejects(h.sync())) + + assert.equal(await h.api.getMobileCloudVaultLink(h.vault), null) + assert.ok([...h.persisted.keys()].some((path) => /\/retired-states\/.+\.[0-9a-f-]{36}\.json$/.test(path))) + }) + it('preserves complete conflict drafts in unique inactive state archives when a Cloud vault disappears', async () => { const h = await fixture({ 'note.md': 'local version' }) h.remoteText('note.md', 'cloud version') @@ -508,3 +525,38 @@ describe('deleted Cloud vault recovery (#791)', () => { assert.deepEqual(await h.api.getMobileCloudVaultLink(h.vault), replacement) }) }) + +describe('end-to-end encrypted vaults', () => { + const encryptedVaults = (encrypted: Set, created: string[] = []) => ({ + './mobile-cloud-encryption': { + ENCRYPTED_VAULT_BACKUP_DOWNLOAD_MESSAGE: 'Download backups of encrypted vaults from the ZenNotes desktop app.', + assertMobileCloudVaultCanLink: async () => {}, + createMobileEncryptedCloudVault: async (name: string) => { created.push(name); return 'vault-2' }, + isMobileCloudVaultEncrypted: async (vaultId: string) => encrypted.has(vaultId) + } + }) + + it('never saves an encrypted vault\'s backup archive and keeps its backups unlabeled', async () => { + const encrypted = new Set(['vault-1']) + const h = await fixture({}, encryptedVaults(encrypted)) + const labels: (string | undefined)[] = [] + Object.assign(h.remote, { + createBackup: async (_vaultId: string, label?: string) => { labels.push(label); return { data: { id: 'backup-2' } } } + }) + await assert.rejects(h.api.downloadMobileCloudBackup(h.vault, 'backup-1'), + /^Error: Download backups of encrypted vaults from the ZenNotes desktop app\.$/) + await h.api.createMobileCloudBackup(h.vault, 'Before the trip') + encrypted.clear() + await h.api.createMobileCloudBackup(h.vault, 'Before the trip') + assert.deepEqual(labels, [undefined, 'Before the trip']) + }) + + it('creates an encrypted vault through the encryption layer and links this vault to it', async () => { + const created: string[] = [] + const h = await fixture({}, encryptedVaults(new Set(), created)) + const link = await h.api.createAndLinkMobileCloudVault(h.vault, 'Journal', { encrypted: true }) + assert.deepEqual(created, ['Journal']) + assert.equal(link.vault_id, 'vault-2') + assert.deepEqual(await h.api.getMobileCloudVaultLink(h.vault), link) + }) +}) diff --git a/src/bridge/mobile-cloud-sync.ts b/src/bridge/mobile-cloud-sync.ts index 33e661c..b7bb7ec 100644 --- a/src/bridge/mobile-cloud-sync.ts +++ b/src/bridge/mobile-cloud-sync.ts @@ -41,6 +41,13 @@ import { import { CachedCloudSyncRepository, type ScanCache } from './cloud-sync-repository' import { trackCloudSyncChanges } from './cloud-sync-refresh' import { isNotFoundError } from './fs-errors' +import { randomUUID } from './uuid' +import { + ENCRYPTED_VAULT_BACKUP_DOWNLOAD_MESSAGE, + assertMobileCloudVaultCanLink, + createMobileEncryptedCloudVault, + isMobileCloudVaultEncrypted +} from './mobile-cloud-encryption' const STORAGE_ROOT = 'zennotes-cloud-sync' const refreshStates = new WeakMap() @@ -73,7 +80,7 @@ const persistence: CloudSyncHostPersistence = { } // Preserve the complete state, including unsent conflict drafts, before unlinking. await Filesystem.writeFile({ - path: path.replace('/states/', '/retired-states/').replace(/\.json$/, `.${crypto.randomUUID()}.json`), + path: path.replace('/states/', '/retired-states/').replace(/\.json$/, `.${randomUUID()}.json`), directory: Directory.Data, encoding: Encoding.UTF8, data, recursive: true }) await deleteDataFile(path) @@ -94,6 +101,7 @@ export async function linkMobileCloudVault( vault: MobileVault, vaultId: string ): Promise { + await assertMobileCloudVaultCanLink(vaultId) const link = await service.link(hostVault(vault), vaultId) // Joining an established cloud vault: align this vault's physical layout // with the cloud tree before the first sync merges them (#17). Best-effort — @@ -108,9 +116,14 @@ export async function linkMobileCloudVault( export async function createAndLinkMobileCloudVault( vault: MobileVault, - name: string + name: string, + options: { encrypted?: boolean } = {} ): Promise { - return service.createAndLink(hostVault(vault), name) + if (!options.encrypted) return service.createAndLink(hostVault(vault), name) + // The service stores a placeholder name for an encrypted vault. Linking + // through the vault list, which opens names on an unlocked device, keeps + // the real one in the link, and the link never leaves this device. + return service.link(hostVault(vault), await createMobileEncryptedCloudVault(name)) } export async function unlinkMobileCloudVault(vault: MobileVault): Promise { @@ -260,7 +273,11 @@ export async function createMobileCloudBackup( vault: MobileVault, label?: string ): Promise { - return service.createBackup(hostVault(vault), label) + // A typed label would reach ZenNotes Cloud readable, so encrypted vaults + // keep their backups unlabeled. + const link = label === undefined ? null : await service.linkedVault(hostVault(vault)) + const encrypted = link ? await isMobileCloudVaultEncrypted(link.vault_id) : false + return service.createBackup(hostVault(vault), encrypted ? undefined : label) } export async function deleteMobileCloudBackup( @@ -280,10 +297,13 @@ export async function downloadMobileCloudBackup( if (link.base_url !== credential.base_url) { throw new Error('This vault is linked to a different ZenNotes Cloud account.') } + // An encrypted vault's archive holds ciphertext under path ids. The desktop + // app decrypts it item by item; this app does not, so it never saves one. + if (await isMobileCloudVaultEncrypted(link.vault_id)) throw new Error(ENCRYPTED_VAULT_BACKUP_DOWNLOAD_MESSAGE) const safeBackupId = backupId.replace(/[^a-zA-Z0-9-]/g, '') if (!safeBackupId) throw new Error('That backup identifier is invalid.') - const path = `zennotes-cloud-backups/${crypto.randomUUID()}/zennotes-backup-${safeBackupId}.json.gz` + const path = `zennotes-cloud-backups/${randomUUID()}/zennotes-backup-${safeBackupId}.json.gz` const url = `${credential.base_url}/api/v1/vaults/${encodeURIComponent(link.vault_id)}/backups/${encodeURIComponent(backupId)}/download` await Filesystem.downloadFile({ url, diff --git a/src/bridge/mobile-direct-upload.test.ts b/src/bridge/mobile-direct-upload.test.ts index 604a8e7..65ec515 100644 --- a/src/bridge/mobile-direct-upload.test.ts +++ b/src/bridge/mobile-direct-upload.test.ts @@ -1,17 +1,33 @@ import assert from 'node:assert/strict' +import { createCipheriv, createHash } from 'node:crypto' import { describe, it } from 'node:test' import type { CloudSyncMutationRequest, CloudSyncMutationResponse, + CloudSyncUploadRequest, CloudSyncUpsertMutation } from '@zennotes/bridge-contract/cloud-sync' +import { + CloudPathIndex, + Zne1Purpose, + decryptItemMeta, + decryptZne1, + generateCloudE2eeKey, + unwrapVaultKey, + zne1CiphertextLength, + type Zne1ChunkCipherParams +} from '@zennotes/shared-domain/cloud-e2ee' +import { CloudE2eeLockedError, CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault' import { CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES, MobileDirectUploadError, mobileObjectUploadOptions, mutateWithMobileDirectUploads, + rememberMobileUploadSource, type MobileDirectUploadApi, - type MobileObjectUpload + type MobileObjectUpload, + type MobileObjectUploadRequest, + type MobileUploadSealer } from './mobile-direct-upload.ts' describe('mutateWithMobileDirectUploads', () => { @@ -181,6 +197,215 @@ describe('mutateWithMobileDirectUploads', () => { }) }) +describe('encrypted vaults', () => { + const SIGNED_URL = 'https://objects.example.test/upload?signature=signed' + + it('seals a large file natively, sends only ciphertext under a path id, and discards the sealed copy', async () => { + const { cipher, vaultKey } = await encryptedVault() + const plaintext = Buffer.alloc(CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 70_000, 7) + const files = new Map([['file:///vault/assets/photo.png', plaintext]]) + const log: string[] = [] + const mutation = fileBackedMutation('assets/photo.png', plaintext) + rememberMobileUploadSource(mutation.content, 'file:///vault/assets/photo.png', nativeSealer(files, log)) + const sealedLength = zne1CiphertextLength(plaintext.length) + let initiated: CloudSyncUploadRequest | undefined + let sent: Buffer | undefined + const uploads: MobileObjectUploadRequest[] = [] + + const result = await mutateWithMobileDirectUploads(fakeApi({ + vaultCipher: async () => cipher, + mutate: async () => assert.fail('a large encrypted file never goes inline'), + initiateUpload: async (_vaultId, body) => { + initiated = body + return uploadInstruction(mutation, SIGNED_URL, sealedLength) + }, + completeUpload: async () => completion(mutation) + }), 'vault-1', { mutations: [mutation] }, async (upload) => { + uploads.push(upload) + sent = files.get(upload.uri!) + }) + + assert.deepEqual(result.acknowledged.map((ack) => ack.revision), [3]) + assert.ok(initiated && sent) + assert.equal(initiated.path, await cipher.pathId('assets/photo.png')) + assert.equal(initiated.kind, 'binary') + assert.deepEqual(initiated.content, { + encoding: 'aes-gcm', sha256: sha256(sent), byte_length: sealedLength, media_type: 'application/octet-stream' + }) + const meta = await decryptItemMeta(initiated.meta!, { + vaultKey, vaultKeyVersion: 1, pathId: initiated.path, pathIndex: await CloudPathIndex.forVaultKey(vaultKey) + }) + assert.deepEqual([meta.path, meta.kind, meta.sha256, meta.byte_length], ['assets/photo.png', 'binary', sha256(plaintext), plaintext.length]) + assert.equal(uploads.length, 1) + assert.equal(uploads[0]!.base64, undefined) + assert.deepEqual([uploads[0]!.sha256, uploads[0]!.byteLength], [sha256(sent), sealedLength]) + const opened = await decryptZne1(sent, { parentKey: vaultKey, purpose: Zne1Purpose.content, keyVersion: 1 }) + assert.ok(Buffer.from(opened).equals(plaintext)) + assert.deepEqual(log, ['seal file:///vault/assets/photo.png', 'discard file:///vault/.zennotes/sync/uploads/0.zne1']) + }) + + it('decides inline or direct by the size an encrypted vault actually sends', async () => { + const { cipher, vaultKey } = await encryptedVault() + const plaintext = Buffer.alloc(CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES - 100, 3) + assert.ok(zne1CiphertextLength(plaintext.length) > CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES) + const mutation = { ...fileBackedMutation('assets/scan.pdf', plaintext), content: inlineContent(plaintext) } + + const inline: CloudSyncMutationRequest[] = [] + await mutateWithMobileDirectUploads(fakeApi({ + vaultCipher: async () => null, + mutate: async (_vaultId, body) => { inline.push(body); return emptyResponse() } + }), 'vault-1', { mutations: [mutation] }, async () => assert.fail('a readable vault sends this file inline')) + assert.equal(inline.length, 1) + + let initiated: CloudSyncUploadRequest | undefined + const uploads: MobileObjectUploadRequest[] = [] + await mutateWithMobileDirectUploads(fakeApi({ + vaultCipher: async () => cipher, + mutate: async () => assert.fail('its ciphertext is over the inline cap'), + initiateUpload: async (_vaultId, body) => { + initiated = body + return uploadInstruction(mutation, SIGNED_URL, zne1CiphertextLength(plaintext.length)) + }, + completeUpload: async () => completion(mutation) + }), 'vault-1', { mutations: [mutation] }, async (upload) => { uploads.push(upload) }) + + const sealed = Buffer.from(uploads[0]!.base64!, 'base64') + assert.equal(sealed.length, zne1CiphertextLength(plaintext.length)) + assert.deepEqual([uploads[0]!.sha256, uploads[0]!.byteLength], [sha256(sealed), sealed.length]) + assert.equal(initiated?.content.sha256, sha256(sealed)) + const opened = await decryptZne1(sealed, { parentKey: vaultKey, purpose: Zne1Purpose.content, keyVersion: 1 }) + assert.ok(Buffer.from(opened).equals(plaintext)) + }) + + it('refuses a file that changed after sync hashed it, before anything is sent', async () => { + const { cipher } = await encryptedVault() + const plaintext = Buffer.alloc(CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1, 9) + const files = new Map([['file:///vault/big.bin', plaintext]]) + const log: string[] = [] + const mutation = fileBackedMutation('big.bin', plaintext) + const edited = (bytes: Buffer) => Buffer.concat([bytes.subarray(1), Buffer.from([1])]) + rememberMobileUploadSource(mutation.content, 'file:///vault/big.bin', nativeSealer(files, log, edited)) + + await assert.rejects( + mutateWithMobileDirectUploads(fakeApi({ vaultCipher: async () => cipher }), 'vault-1', { mutations: [mutation] }, + async () => assert.fail('nothing is uploaded')), + (error: unknown) => error instanceof MobileDirectUploadError && error.code === 'DIRECT_UPLOAD_SIZE_MISMATCH' + ) + assert.deepEqual(log, ['seal file:///vault/big.bin', 'discard file:///vault/.zennotes/sync/uploads/0.zne1']) + }) + + it('discards the sealed copy when the upload fails, and opens a conflict back to its real path', async () => { + const { cipher } = await encryptedVault() + const plaintext = Buffer.alloc(CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1, 5) + const files = new Map([['file:///vault/big.bin', plaintext]]) + const log: string[] = [] + const aborted: string[] = [] + const mutation = fileBackedMutation('big.bin', plaintext) + rememberMobileUploadSource(mutation.content, 'file:///vault/big.bin', nativeSealer(files, log)) + + await assert.rejects(mutateWithMobileDirectUploads(fakeApi({ + vaultCipher: async () => cipher, + initiateUpload: async () => uploadInstruction(mutation, SIGNED_URL, zne1CiphertextLength(plaintext.length)), + abortUpload: async (_vaultId, uploadId) => { aborted.push(uploadId) } + }), 'vault-1', { mutations: [mutation] }, async () => { + throw new MobileDirectUploadError('Object upload failed.', 503, 'DIRECT_UPLOAD_FAILED') + }), /Object upload failed/) + assert.deepEqual(aborted, ['upload-1']) + assert.deepEqual(log.at(-1), 'discard file:///vault/.zennotes/sync/uploads/0.zne1') + + const other = await cipher.sealMeta('Other.png', 'binary', mutation.content) + const result = await mutateWithMobileDirectUploads(fakeApi({ + vaultCipher: async () => cipher, + initiateUpload: async () => { + throw new MobileDirectUploadError('Path taken.', 409, 'PATH_CONFLICT', { + current_revision: 4, current_path: other.pathId, current_meta: other.meta + }) + } + }), 'vault-1', { mutations: [mutation] }, async () => assert.fail('a conflict uploads nothing')) + assert.deepEqual(result.conflicts.map((conflict) => [conflict.code, conflict.current_path]), [['PATH_CONFLICT', 'Other.png']]) + assert.deepEqual(log.at(-1), 'discard file:///vault/.zennotes/sync/uploads/1.zne1') + }) + + it('stops a locked encrypted vault before anything is sent', async () => { + const bytes = Buffer.from('a note') + await assert.rejects( + mutateWithMobileDirectUploads(fakeApi({ + vaultCipher: async () => { throw new CloudE2eeLockedError() }, + mutate: async () => assert.fail('nothing is sent while locked') + }), 'vault-1', { mutations: [{ ...fileBackedMutation('note.md', bytes), content: inlineContent(bytes) }] }, + async () => assert.fail('nothing is uploaded while locked')), + { code: 'E2EE_LOCKED' } + ) + }) +}) + +async function encryptedVault() { + const accountKey = generateCloudE2eeKey() + const encryption = await CloudVaultCipher.prepareVault('Notes', accountKey, 1) + return { + cipher: await CloudVaultCipher.open({ id: 'vault-1', encryption }, accountKey, 1), + vaultKey: await unwrapVaultKey(encryption.vault_key_envelope, accountKey, 1, encryption.key_id) + } +} + +/** What ZenDirectUpload.encryptFile does on the device, from the same three inputs. */ +function nativeSeal(plaintext: Buffer, params: Zne1ChunkCipherParams): Buffer { + const parts = [Buffer.from(params.header)] + const chunks = Math.max(1, Math.ceil(plaintext.length / 65_536)) + for (let index = 0; index < chunks; index++) { + const nonce = Buffer.alloc(12) + nonce.writeUInt32BE(index, 7) + nonce[11] = index === chunks - 1 ? 1 : 0 + const gcm = createCipheriv('aes-256-gcm', params.objectKey, nonce) + gcm.setAAD(params.aad) + parts.push(gcm.update(plaintext.subarray(index * 65_536, (index + 1) * 65_536)), gcm.final(), gcm.getAuthTag()) + } + return Buffer.concat(parts) +} + +function nativeSealer(files: Map, log: string[], read = (bytes: Buffer) => bytes): MobileUploadSealer { + let count = 0 + return async (uri, params) => { + const plaintext = read(files.get(uri)!) + const sealed = nativeSeal(plaintext, params) + const sealedUri = `file:///vault/.zennotes/sync/uploads/${count++}.zne1` + files.set(sealedUri, sealed) + log.push(`seal ${uri}`) + return { + uri: sealedUri, byteLength: sealed.length, sha256: sha256(sealed), + plaintextByteLength: plaintext.length, plaintextSha256: sha256(plaintext), + discard: async () => { + log.push(`discard ${sealedUri}`) + files.delete(sealedUri) + } + } + } +} + +function fileBackedMutation(path: string, bytes: Buffer): CloudSyncUpsertMutation { + return { + type: 'upsert', operation_id: 'operation-1', item_id: 'item-1', base_revision: null, path, kind: 'binary', + content: { encoding: 'base64', data: '', sha256: sha256(bytes), byte_length: bytes.length, media_type: 'image/png' } + } +} + +function inlineContent(bytes: Buffer): CloudSyncUpsertMutation['content'] { + return { encoding: 'base64', data: bytes.toString('base64'), sha256: sha256(bytes), byte_length: bytes.length, media_type: 'application/pdf' } +} + +function completion(mutation: CloudSyncUpsertMutation) { + return { + data: { + id: 'upload-1', operation_id: mutation.operation_id, status: 'completed' as const, + result: { acknowledged: [{ operation_id: mutation.operation_id, item_id: mutation.item_id, revision: 3, sequence: 9 }], conflicts: [], cursor: 9 } + } + } +} + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex') +} + function fakeApi(overrides: Partial): MobileDirectUploadApi { return { mutate: async () => emptyResponse(), @@ -217,13 +442,13 @@ function upsertMutation(byteLength: number, data: string): CloudSyncUpsertMutati } } -function uploadInstruction(mutation: CloudSyncUpsertMutation, url: string) { +function uploadInstruction(mutation: CloudSyncUpsertMutation, url: string, expectedBytes = mutation.content.byte_length) { return { data: { id: 'upload-1', operation_id: mutation.operation_id, status: 'uploading' as const, - expected_bytes: mutation.content.byte_length, + expected_bytes: expectedBytes, expires_at: '2026-08-26T18:30:00.000Z', upload: { method: 'PUT' as const, diff --git a/src/bridge/mobile-direct-upload.ts b/src/bridge/mobile-direct-upload.ts index 6e4dcba..eea609f 100644 --- a/src/bridge/mobile-direct-upload.ts +++ b/src/bridge/mobile-direct-upload.ts @@ -3,6 +3,8 @@ import type { CloudSyncConflict, CloudSyncConflictCode, CloudSyncContent, + CloudSyncContentMetadata, + CloudSyncItemKind, CloudSyncMutation, CloudSyncMutationRequest, CloudSyncMutationResponse, @@ -11,13 +13,46 @@ import type { CloudSyncUploadInitiationResponse, CloudSyncUploadRequest } from '@zennotes/bridge-contract/cloud-sync' +import { zne1CiphertextLength, type Zne1ChunkCipherParams } from '@zennotes/shared-domain/cloud-e2ee' export const CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 -const uploadSources = new WeakMap() +/** A sealed copy of a local file, ready to PUT. */ +export interface MobileSealedUploadFile { + uri: string + /** The whole ciphertext file, header included. */ + byteLength: number + sha256: string + plaintextByteLength: number + plaintextSha256: string + discard(): Promise +} + +/** Seals a local file natively for an encrypted vault's streamed upload. */ +export type MobileUploadSealer = (sourceUri: string, params: Zne1ChunkCipherParams) => Promise + +/** What a streamed upload needs from an encrypted vault's cipher; CloudVaultCipher is one. */ +export interface MobileUploadCipher { + beginContentEncryption(): Promise + sealContent(content: CloudSyncContent): Promise + directUploadFields( + path: string, + kind: CloudSyncItemKind, + plaintext: CloudSyncContentMetadata, + ciphertext: { sha256: string; byte_length: number } + ): Promise<{ path: string; kind: CloudSyncItemKind; meta: string; content: CloudSyncContentMetadata }> + openConflict(conflict: CloudSyncConflict): Promise +} -export function rememberMobileUploadSource(content: CloudSyncContent, uri: string): CloudSyncContent { - uploadSources.set(content, uri) +const uploadSources = new WeakMap() + +/** A large file's bytes stay on disk; the upload reads (or seals) them from `uri`. */ +export function rememberMobileUploadSource( + content: CloudSyncContent, + uri: string, + seal?: MobileUploadSealer +): CloudSyncContent { + uploadSources.set(content, seal ? { uri, seal } : { uri }) return content } @@ -39,6 +74,12 @@ export interface MobileDirectUploadApi { ): Promise completeUpload(vaultId: string, uploadId: string): Promise abortUpload(vaultId: string, uploadId: string): Promise + /** + * The cipher of an end-to-end encrypted vault, or null for a readable one. + * Throws, before anything is sent, when the vault is encrypted and this + * device is locked. + */ + vaultCipher?(vaultId: string): Promise } export type MobileObjectUploadRequest = { @@ -107,7 +148,12 @@ export async function mutateWithMobileDirectUploads( body: CloudSyncMutationRequest, uploadObject: MobileObjectUpload ): Promise { - if (!body.mutations.some(usesDirectUpload)) return api.mutate(vaultId, body) + // Encryption adds a header and a tag per chunk, and the service caps inline + // bodies, so an encrypted vault decides by the size it will actually send. + const cipher = (await api.vaultCipher?.(vaultId)) ?? null + const direct = (mutation: CloudSyncMutation): mutation is CloudSyncUpsertMutation => + usesDirectUpload(mutation, cipher !== null) + if (!body.mutations.some(direct)) return api.mutate(vaultId, body) const responses: CloudSyncMutationResponse[] = [] let inlineMutations: CloudSyncMutation[] = [] @@ -118,9 +164,9 @@ export async function mutateWithMobileDirectUploads( } for (const mutation of body.mutations) { - if (usesDirectUpload(mutation)) { + if (direct(mutation)) { await flushInline() - responses.push(await directUpload(api, vaultId, mutation, uploadObject)) + responses.push(await directUpload(api, vaultId, mutation, uploadObject, cipher)) } else { inlineMutations.push(mutation) } @@ -134,32 +180,139 @@ export async function mutateWithMobileDirectUploads( } } -function usesDirectUpload(mutation: CloudSyncMutation): mutation is CloudSyncUpsertMutation { - return mutation.type === 'upsert' && - mutation.content.byte_length > CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES +function usesDirectUpload(mutation: CloudSyncMutation, encrypted: boolean): mutation is CloudSyncUpsertMutation { + if (mutation.type !== 'upsert') return false + const bytes = encrypted ? zne1CiphertextLength(mutation.content.byte_length) : mutation.content.byte_length + return bytes > CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES +} + +interface PreparedDirectUpload { + request: CloudSyncUploadRequest + /** The exact bytes the PUT sends: the file, or for an encrypted vault its ciphertext. */ + byteLength: number + source: { uri: string; sha256: string } | { base64: string; sha256: string } + discard(): Promise } async function directUpload( api: MobileDirectUploadApi, vaultId: string, mutation: CloudSyncUpsertMutation, - uploadObject: MobileObjectUpload + uploadObject: MobileObjectUpload, + cipher: MobileUploadCipher | null +): Promise { + const prepared = cipher ? await prepareEncryptedUpload(mutation, cipher) : preparePlainUpload(mutation) + try { + return await sendDirectUpload(api, vaultId, mutation, uploadObject, prepared, cipher) + } finally { + await prepared.discard() + } +} + +function preparePlainUpload(mutation: CloudSyncUpsertMutation): PreparedDirectUpload { + const uri = uploadSources.get(mutation.content)?.uri + return { + request: uploadRequest(mutation), + byteLength: mutation.content.byte_length, + source: uri === undefined + ? { base64: uploadBase64(mutation), sha256: mutation.content.sha256 } + : { uri, sha256: mutation.content.sha256 }, + discard: async () => {} + } +} + +/** + * Seals the file before the upload starts, because the service must be told + * the ciphertext's length and hash first. A large file is sealed natively into + * a private file under .zennotes/sync, so its plaintext never crosses the + * bridge; content sync already holds in memory is sealed here. Either way the + * plaintext must still be the bytes sync hashed, so a file that changed since + * is never sealed under the old hash. + */ +async function prepareEncryptedUpload( + mutation: CloudSyncUpsertMutation, + cipher: MobileUploadCipher +): Promise { + const content = mutation.content + if (content.data === '' && content.byte_length > 0) { + const source = uploadSources.get(content) + if (!source?.seal) throw directUploadSizeMismatch() + const sealed = await source.seal(source.uri, await cipher.beginContentEncryption()) + try { + if (sealed.plaintextByteLength !== content.byte_length || sealed.plaintextSha256 !== content.sha256) { + throw directUploadSizeMismatch() + } + const fields = await cipher.directUploadFields(mutation.path, mutation.kind, content, { + sha256: sealed.sha256, + byte_length: sealed.byteLength + }) + return { + request: encryptedUploadRequest(mutation, fields), + byteLength: sealed.byteLength, + source: { uri: sealed.uri, sha256: sealed.sha256 }, + discard: () => sealed.discard() + } + } catch (error) { + await sealed.discard() + throw error + } + } + + let sealed: CloudSyncContent + try { + sealed = await cipher.sealContent(content) + } catch (error) { + if ((error as { code?: unknown } | null)?.code === 'tampered') throw directUploadSizeMismatch() + throw error + } + const fields = await cipher.directUploadFields(mutation.path, mutation.kind, content, { + sha256: sealed.sha256, + byte_length: sealed.byte_length + }) + return { + request: encryptedUploadRequest(mutation, fields), + byteLength: sealed.byte_length, + source: { base64: sealed.data, sha256: sealed.sha256 }, + discard: async () => {} + } +} + +function encryptedUploadRequest( + mutation: CloudSyncUpsertMutation, + fields: Awaited> +): CloudSyncUploadRequest { + return { + operation_id: mutation.operation_id, + item_id: mutation.item_id, + base_revision: mutation.base_revision, + path: fields.path, + kind: fields.kind, + content: fields.content, + meta: fields.meta + } +} + +async function sendDirectUpload( + api: MobileDirectUploadApi, + vaultId: string, + mutation: CloudSyncUpsertMutation, + uploadObject: MobileObjectUpload, + prepared: PreparedDirectUpload, + cipher: MobileUploadCipher | null ): Promise { - const uri = uploadSources.get(mutation.content) - const source = uri === undefined ? { base64: uploadBase64(mutation) } : { uri } let initiation: CloudSyncUploadInitiationResponse try { - initiation = await api.initiateUpload(vaultId, uploadRequest(mutation)) + initiation = await api.initiateUpload(vaultId, prepared.request) } catch (error) { const conflict = directUploadConflict(error, mutation) - if (conflict) return { acknowledged: [], conflicts: [conflict], cursor: 0 } + if (conflict) return { acknowledged: [], conflicts: [await openConflict(conflict, cipher)], cursor: 0 } throw error } let instruction: CloudSyncUploadInitiationResponse['data'] try { - instruction = directUploadInstruction(initiation, mutation) + instruction = directUploadInstruction(initiation, mutation, prepared.byteLength) } catch (error) { const uploadId = uploadSessionId(initiation) if (uploadId) await abortQuietly(api, vaultId, uploadId) @@ -171,30 +324,35 @@ async function directUpload( url: secureDirectUploadUrl(instruction.upload.url), method: instruction.upload.method, headers: instruction.upload.headers, - ...source, - sha256: mutation.content.sha256, - byteLength: mutation.content.byte_length + ...prepared.source, + byteLength: prepared.byteLength }) } catch (error) { await abortQuietly(api, vaultId, instruction.id) throw error } - return completeDirectUpload(api, vaultId, instruction.id, mutation) + return completeDirectUpload(api, vaultId, instruction.id, mutation, cipher) +} + +/** An encrypted vault's conflict names a path id; this device turns it back into the path. */ +function openConflict(conflict: CloudSyncConflict, cipher: MobileUploadCipher | null): Promise { + return cipher ? cipher.openConflict(conflict) : Promise.resolve(conflict) } async function completeDirectUpload( api: MobileDirectUploadApi, vaultId: string, uploadId: string, - mutation: CloudSyncUpsertMutation + mutation: CloudSyncUpsertMutation, + cipher: MobileUploadCipher | null ): Promise { for (let attempt = 1; attempt <= DIRECT_UPLOAD_COMPLETION_ATTEMPTS; attempt++) { try { return (await api.completeUpload(vaultId, uploadId)).data.result } catch (error) { const conflict = directUploadConflict(error, mutation) - if (conflict) return { acknowledged: [], conflicts: [conflict], cursor: 0 } + if (conflict) return { acknowledged: [], conflicts: [await openConflict(conflict, cipher)], cursor: 0 } if (attempt === DIRECT_UPLOAD_COMPLETION_ATTEMPTS || !retryableCompletionError(error)) { throw error } @@ -283,7 +441,8 @@ function decodedBase64Length(value: string): number { function directUploadInstruction( initiation: CloudSyncUploadInitiationResponse, - mutation: CloudSyncUpsertMutation + mutation: CloudSyncUpsertMutation, + expectedBytes: number ): CloudSyncUploadInitiationResponse['data'] { const candidate = initiation as unknown as { data?: unknown } if (!isRecord(candidate.data)) throw invalidDirectUploadResponse() @@ -292,7 +451,7 @@ function directUploadInstruction( const upload = data.upload if ( data.operation_id !== mutation.operation_id || - data.expected_bytes !== mutation.content.byte_length || + data.expected_bytes !== expectedBytes || typeof data.id !== 'string' || data.id === '' || upload.method !== 'PUT' || @@ -347,6 +506,7 @@ function directUploadConflict( typeof error.details?.current_revision === 'number' ? error.details.current_revision : null, current_path: typeof error.details?.current_path === 'string' ? error.details.current_path : null, + ...(typeof error.details?.current_meta === 'string' ? { current_meta: error.details.current_meta } : {}), ...(capacity ? { capacity } : {}) } } diff --git a/src/bridge/native-fs-cloud-e2ee.test.ts b/src/bridge/native-fs-cloud-e2ee.test.ts new file mode 100644 index 0000000..68ce536 --- /dev/null +++ b/src/bridge/native-fs-cloud-e2ee.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict' +import { createDecipheriv, createHash } from 'node:crypto' +import { it } from 'node:test' +import { generateCloudE2eeKey } from '@zennotes/shared-domain/cloud-e2ee' +import { CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +const params = { + header: new Uint8Array(74).fill(1), + objectKey: new Uint8Array(32).fill(2), + aad: new Uint8Array(32).fill(3) +} +const base64 = (bytes: Uint8Array) => Buffer.from(bytes).toString('base64') + +/** A local-tier NativeFs whose ZenDirectUpload calls go to `plugin`. */ +async function nativeFs(plugin: Record) { + const files = new Map() + const uri = (path: string) => `file:///data/${path}` + const missing = () => Object.assign(new Error('Not found'), { code: 'OS-PLUG-FILE-0008' }) + const storage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: () => null } }) + try { + const { NativeFs } = await loadMobileModule('./src/bridge/native-fs', { + './icloud': { ensureDownloaded: async () => {} }, + '@capacitor/core': { Capacitor: {}, registerPlugin: (name: string) => name === 'SafFs' ? {} : plugin }, + '@capacitor/filesystem': { + Directory: { Data: 'DATA', External: 'EXTERNAL' }, Encoding: { UTF8: 'utf8' }, + Filesystem: { + stat: async ({ path }: { path: string }) => { + const file = files.get(path) + if (!file) throw missing() + return { type: 'file', uri: uri(path), mtime: 1, size: file.length } + }, + writeFile: async ({ path, data }: { path: string; data: string }) => { files.set(path, Buffer.from(data, 'base64')) }, + deleteFile: async ({ path }: { path: string }) => { files.delete(path) }, + mkdir: async () => {}, + getUri: async ({ path }: { path: string }) => ({ uri: uri(path) }), + readFile: () => assert.fail('File bytes must not cross the bridge') + } + } + }) + return { fs: new NativeFs('Test'), files, uri } + } finally { + if (storage) Object.defineProperty(globalThis, 'localStorage', storage) + else Reflect.deleteProperty(globalThis, 'localStorage') + } +} + +it('seals into a fresh private file under .zennotes/sync/uploads, and removes it when sealing fails', async () => { + const requests: unknown[] = [] + let refuse = false + const h = await nativeFs({ + encryptFile: async (request: unknown) => { + requests.push(request) + if (refuse) throw new Error('Cloud file encryption failed.') + return { byteLength: 106, sha256: 'b'.repeat(64), plaintextByteLength: 16, plaintextSha256: 'c'.repeat(64) } + } + }) + const sealed = await h.fs.sealForUpload('file:///data/ZenNotes/Test/big.bin', params) + const [path] = [...h.files.keys()] + assert.match(path!, /^ZenNotes\/Test\/\.zennotes\/sync\/uploads\/[0-9a-f-]{36}\.zne1$/) + assert.deepEqual(requests, [{ + source: 'file:///data/ZenNotes/Test/big.bin', destination: h.uri(path!), + header: base64(params.header), objectKey: base64(params.objectKey), aad: base64(params.aad) + }]) + assert.deepEqual([sealed.uri, sealed.byteLength, sealed.plaintextByteLength], [h.uri(path!), 106, 16]) + await sealed.discard() + assert.equal(h.files.size, 0) + + refuse = true + await assert.rejects(h.fs.sealForUpload('file:///data/ZenNotes/Test/big.bin', params), /encryption failed/) + assert.equal(h.files.size, 0) +}) + +it('reads a staged file\'s header without reading the rest', async () => { + const h = await nativeFs({ + readHeader: async ({ source, length }: { source: string; length: number }) => { + assert.equal(source, h.uri('ZenNotes/Test/.zennotes/sync/downloads/sealed')) + assert.equal(length, 74) + return { base64: Buffer.alloc(74, 9).toString('base64') } + } + }) + h.files.set('ZenNotes/Test/.zennotes/sync/downloads/sealed', Buffer.alloc(1_000)) + assert.equal(await h.fs.readHeader('.zennotes/sync/downloads/sealed', 74), Buffer.alloc(74, 9).toString('base64')) + await assert.rejects(h.fs.readHeader('.zennotes/sync/downloads/missing', 74), /unavailable/) +}) + +it('opens a staged file into a fresh file, and leaves nothing behind when the data is refused', async () => { + const requests: unknown[] = [] + let refuse = false + const h = await nativeFs({ + decryptFile: async (request: unknown) => { + requests.push(request) + if (refuse) throw Object.assign(new Error('The encrypted data was changed or is incomplete.'), { code: 'E2EE_TAMPERED' }) + return { byteLength: 5, sha256: 'd'.repeat(64) } + } + }) + const keys = { header: base64(params.header), objectKey: base64(params.objectKey), aad: base64(params.aad) } + h.files.set('ZenNotes/Test/.zennotes/sync/downloads/sealed', Buffer.alloc(95)) + const open = (to: string) => h.fs.decryptFile({ from: '.zennotes/sync/downloads/sealed', to, ...keys }) + assert.deepEqual(await open('.zennotes/sync/downloads/plain'), { byteLength: 5, sha256: 'd'.repeat(64) }) + assert.deepEqual(requests, [{ + source: h.uri('ZenNotes/Test/.zennotes/sync/downloads/sealed'), + destination: h.uri('ZenNotes/Test/.zennotes/sync/downloads/plain'), ...keys + }]) + await assert.rejects(open('.zennotes/sync/downloads/plain'), /already exists/) + refuse = true + await assert.rejects(open('.zennotes/sync/downloads/again'), { code: 'E2EE_TAMPERED' }) + assert.equal(h.files.has('ZenNotes/Test/.zennotes/sync/downloads/again'), false) +}) + +it('opens an encrypted download through the shared staging with this NativeFs', async () => { + const accountKey = generateCloudE2eeKey() + const cipher = await CloudVaultCipher.open( + { id: 'vault-1', encryption: await CloudVaultCipher.prepareVault('Notes', accountKey, 1) }, accountKey, 1 + ) + const plaintext = Buffer.alloc(200_000, 6) + const pieces: Buffer[] = [] + for await (const piece of cipher.encryptStream([plaintext])) pieces.push(Buffer.from(piece)) + const sealed = Buffer.concat(pieces) + const path = (fileUri: string) => fileUri.replace('file:///data/', '') + const calls: string[] = [] + const h = await nativeFs({ + download: async ({ to }: { to: string }) => { calls.push('download'); h.files.set(path(to), sealed) }, + readHeader: async ({ source, length }: { source: string; length: number }) => { + calls.push(`readHeader ${length}`) + return { base64: h.files.get(path(source))!.subarray(0, length).toString('base64') } + }, + decryptFile: async (request: { source: string; destination: string; header: string; objectKey: string; aad: string }) => { + calls.push('decryptFile') + const opened = openNatively(h.files.get(path(request.source))!, request) + h.files.set(path(request.destination), opened) + return { byteLength: opened.length, sha256: sha256(opened) } + }, + inspect: async ({ uri: fileUri }: { uri: string }) => { + const bytes = h.files.get(path(fileUri))! + return { uri: fileUri, sha256: sha256(bytes), byteLength: bytes.length, utf8: false } + } + }) + const { NativeCloudStaging } = await loadMobileModule('./src/bridge/cloud-sync-staging.ts') + const staging = new NativeCloudStaging(h.fs, () => false, async () => assert.fail('no local reads')) + const reference = { + item_id: 'item', revision: 3, encoding: 'base64', sha256: sha256(plaintext), byte_length: plaintext.length, + media_type: 'application/octet-stream', encrypted: { sha256: sha256(sealed), byte_length: sealed.length } + } + await staging.stage({ + reference, previewLimitBytes: 262_144, decryptor: cipher, + getInstruction: async () => ({ + item_id: 'item', revision: 3, + content: { encoding: 'aes-gcm', sha256: sha256(sealed), byte_length: sealed.length, media_type: 'application/octet-stream' }, + download: { url: 'https://objects.example.test/item/3', method: 'GET', headers: {}, expires_at: new Date(Date.now() + 300_000).toISOString() } + }) + }) + + assert.deepEqual(calls, ['download', 'readHeader 74', 'decryptFile']) + const staged = [...h.files.keys()] + assert.equal(staged.length, 1, 'only the opened file remains; the ciphertext is gone') + assert.match(staged[0]!, /^ZenNotes\/Test\/\.zennotes\/sync\/downloads\/[0-9a-f-]{36}$/) + assert.ok(h.files.get(staged[0]!)!.equals(plaintext)) +}) + +/** What DirectUploadPlugin.decryptFile does on the device, from the same inputs. */ +function openNatively(sealed: Buffer, keys: { header: string; objectKey: string; aad: string }): Buffer { + assert.ok(sealed.subarray(0, 74).equals(Buffer.from(keys.header, 'base64'))) + const body = sealed.subarray(74) + const parts: Buffer[] = [] + for (let index = 0, offset = 0; offset < body.length; index++) { + const end = Math.min(offset + 65_552, body.length) + const nonce = Buffer.alloc(12) + nonce.writeUInt32BE(index, 7) + nonce[11] = end === body.length ? 1 : 0 + const gcm = createDecipheriv('aes-256-gcm', Buffer.from(keys.objectKey, 'base64'), nonce) + gcm.setAAD(Buffer.from(keys.aad, 'base64')) + gcm.setAuthTag(body.subarray(end - 16, end)) + parts.push(gcm.update(body.subarray(offset, end - 16)), gcm.final()) + offset = end + } + return Buffer.concat(parts) +} + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex') +} diff --git a/src/bridge/native-fs.ts b/src/bridge/native-fs.ts index 507e4ef..5f8c71d 100644 --- a/src/bridge/native-fs.ts +++ b/src/bridge/native-fs.ts @@ -19,8 +19,11 @@ */ import { Capacitor, registerPlugin } from '@capacitor/core' import { Directory, Encoding, Filesystem, type FileInfo } from '@capacitor/filesystem' +import { toBase64, type Zne1ChunkCipherParams } from '@zennotes/shared-domain/cloud-e2ee' import { ensureDownloaded } from './icloud' import { isNotFoundError } from './fs-errors' +import type { MobileSealedUploadFile } from './mobile-direct-upload' +import { randomUUID } from './uuid' export const VAULTS_DIR = 'ZenNotes' @@ -103,12 +106,60 @@ export interface CloudFileFingerprint { inlineBase64?: string } +/** + * One ZNE1 object for the native chunk loop (cloud-e2ee.md). JavaScript + * derives the object key, builds the header, checks the key commitment and + * computes the AAD; native code only seals or opens the 64 KiB chunks and + * hashes them, so no parent key ever crosses the bridge. `source` and + * `destination` are vault file URIs, as for `copy`, and must differ. + */ +export interface Zne1FileRequest { + source: string + destination: string + /** Base64 of the object's 74-byte header. */ + header: string + /** Base64 of the 32-byte per-object AES-256-GCM key. */ + objectKey: string + /** Base64 of SHA-256 over the header followed by the context. */ + aad: string +} + +/** The ciphertext the upload declares, and the plaintext the item metadata records. */ +export interface Zne1FileEncryption { + byteLength: number + sha256: string + plaintextByteLength: number + plaintextSha256: string +} + const CloudFiles = registerPlugin<{ inspect(options: { uri: string; textCandidate: boolean }): Promise copy(options: { from: string; to: string; byteLength: number; sha256: string }): Promise download(options: { url: string; headers: Record; to: string; byteLength: number; sha256: string }): Promise + /** Writes the header, then the sealed chunks. Rejects with INVALID_ENCRYPT_REQUEST or CLOUD_FILE_ENCRYPT_FAILED. */ + encryptFile(options: Zne1FileRequest): Promise + /** + * Writes the plaintext and resolves its length and SHA-256. Rejects with + * E2EE_TAMPERED (the file does not begin with `header`, a chunk fails + * authentication, or the input stops early), E2EE_MALFORMED (shorter than a + * header, or an empty last chunk after a full one), INVALID_DECRYPT_REQUEST + * or CLOUD_FILE_DECRYPT_FAILED. Every rejection deletes the partial output. + */ + decryptFile(options: Zne1FileRequest): Promise<{ byteLength: number; sha256: string }> + /** + * Base64 of up to `length` bytes (at most 65536) from the start of a file; + * a ZNE1 header is 74. Rejects with INVALID_HEADER_REQUEST or + * CLOUD_FILE_READ_FAILED. + */ + readHeader(options: { source: string; length: number }): Promise<{ base64: string }> }>('ZenDirectUpload') +/** Base64 of what one ZNE1 object's native chunk loop needs; never a parent key. */ +export type Zne1NativeKeys = Pick + +/** Where encrypted uploads are sealed: inside the vault's private sync folder, which sync never uploads from. */ +const UPLOAD_SEALING_DIRECTORY = '.zennotes/sync/uploads' + export function isSafRoot(uri: string | null | undefined): boolean { return typeof uri === 'string' && uri.startsWith('content://') } @@ -427,6 +478,63 @@ export class NativeFs { } } + /** Seal a file into a fresh private ZNE1 file for an encrypted vault's + * streamed upload. JavaScript hands over only the object key, header and AAD + * the vault cipher made for this one file, so the plaintext never crosses the + * bridge. The caller discards the sealed file when the upload ends, however + * it ends. */ + async sealForUpload(sourceUri: string, params: Zne1ChunkCipherParams): Promise { + const path = `${UPLOAD_SEALING_DIRECTORY}/${randomUUID()}.zne1` + await this.mkdir(UPLOAD_SEALING_DIRECTORY) + await this.writeBase64(path, '') + const discard = () => this.deleteFile(path).catch(() => {}) + try { + const target = await this.statOrNull(path) + if (!target) throw new Error('Encrypted upload staging file is unavailable.') + const sealed = await CloudFiles.encryptFile({ + source: sourceUri, destination: target.uri, + header: toBase64(params.header), objectKey: toBase64(params.objectKey), aad: toBase64(params.aad) + }) + return { ...sealed, uri: target.uri, discard } + } catch (error) { + await discard() + throw error + } + } + + /** Base64 of up to `length` leading bytes of a vault file, without reading + * the rest: the header JavaScript checks before native code opens a staged + * ZNE1 object. */ + async readHeader(relPath: string, length: number): Promise { + const file = await this.statOrNull(relPath) + if (file?.type !== 'file') throw new Error('Cloud staging file is unavailable.') + return (await CloudFiles.readHeader({ source: file.uri, length })).base64 + } + + /** Open a staged ZNE1 file into a fresh file with the native chunk loop. + * Native code checks that the file still begins with the header JavaScript + * checked and writes only chunks that authenticate. The destination is + * created first because a document provider can only open what exists, and + * a failure removes it again. */ + async decryptFile(options: { from: string; to: string } & Zne1NativeKeys): Promise<{ byteLength: number; sha256: string }> { + const { from, to, header, objectKey, aad } = options + if (from === to) throw new Error('Invalid Cloud file decryption.') + if (await this.statVerified(to) !== null) throw new Error('Cloud decryption destination already exists.') + const source = await this.statOrNull(from) + if (source?.type !== 'file') throw new Error('Cloud staging file is unavailable.') + const parent = to.includes('/') ? to.slice(0, to.lastIndexOf('/')) : '' + if (parent) await this.mkdir(parent) + await this.writeBase64(to, '') + try { + const target = await this.statOrNull(to) + if (!target) throw new Error('Cloud decryption staging file is unavailable.') + return await CloudFiles.decryptFile({ source: source.uri, destination: target.uri, header, objectKey, aad }) + } catch (error) { + await this.deleteFile(to).catch(() => {}) + throw error + } + } + async deleteFile(relPath: string): Promise { if (this.saf) { await SafFs.delete({ root: this.cloudRootUri!, path: relPath }) diff --git a/src/bridge/remote-workspace-profile.test.ts b/src/bridge/remote-workspace-profile.test.ts new file mode 100644 index 0000000..047140d --- /dev/null +++ b/src/bridge/remote-workspace-profile.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' +import { withoutRandomUUID } from '../../tooling/without-random-uuid.ts' + +it('saves a new server profile on a WebView without crypto.randomUUID', async () => { + const stored = new Map() + const { saveProfile, listProfiles } = await loadMobileModule('./src/bridge/remote-workspace.ts', { + '@capacitor/preferences': { + Preferences: { + get: async ({ key }: { key: string }) => ({ value: stored.get(key) ?? null }), + set: async ({ key, value }: { key: string; value: string }) => { stored.set(key, value) } + } + } + }) + const saved = await withoutRandomUUID(() => saveProfile({ baseUrl: 'https://notes.example.test', authToken: 'test-only' })) + assert.match(saved.id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + assert.deepEqual((await listProfiles()).map((profile: { id: string }) => profile.id), [saved.id]) +}) diff --git a/src/bridge/remote-workspace.ts b/src/bridge/remote-workspace.ts index a2dcc36..32ffed2 100644 --- a/src/bridge/remote-workspace.ts +++ b/src/bridge/remote-workspace.ts @@ -17,6 +17,7 @@ import type { VaultInfo } from '@zennotes/shared-domain/ipc' import { RemoteClient, normalizeBaseUrl } from './remote-client' +import { randomUUID } from './uuid' import { RemoteVault } from './remote-vault' const PROFILES_KEY = 'zn-remote-profiles' @@ -188,7 +189,7 @@ export async function saveProfile( const baseUrl = normalizeBaseUrl(input.baseUrl) const existing = input.id ? profiles.find((p) => p.id === input.id) : undefined const next: StoredRemoteProfile = { - id: existing?.id ?? crypto.randomUUID(), + id: existing?.id ?? randomUUID(), name: input.name?.trim() || existing?.name || new URL(baseUrl).host, baseUrl, authToken: input.clearAuthToken diff --git a/src/bridge/uuid.test.ts b/src/bridge/uuid.test.ts new file mode 100644 index 0000000..e775d74 --- /dev/null +++ b/src/bridge/uuid.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it } from 'node:test' +import { installRandomUUIDPolyfill, randomUUID } from './uuid.ts' + +const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +describe('random UUIDs on iOS 15.0 to 15.3', () => { + // Those WebViews have getRandomValues but no randomUUID. + beforeEach(() => { + Object.defineProperty(crypto, 'randomUUID', { value: undefined, configurable: true }) + }) + afterEach(() => { + Reflect.deleteProperty(crypto, 'randomUUID') + }) + + it('makes version 4 UUIDs from getRandomValues', () => { + const ids = new Set(Array.from({ length: 64 }, () => randomUUID())) + assert.equal(ids.size, 64) + for (const id of ids) assert.match(id, V4) + }) + + it('installs crypto.randomUUID for code that calls it directly', () => { + installRandomUUIDPolyfill() + assert.equal(typeof crypto.randomUUID, 'function') + assert.match(crypto.randomUUID(), V4) + assert.notEqual(crypto.randomUUID(), crypto.randomUUID()) + }) +}) + +describe('a WebView with its own crypto.randomUUID', () => { + it('keeps it', () => { + const own = () => '00000000-0000-4000-8000-000000000000' as const + const target = { randomUUID: own, getRandomValues: crypto.getRandomValues.bind(crypto) } as unknown as Crypto + installRandomUUIDPolyfill(target) + assert.equal(target.randomUUID, own) + }) +}) diff --git a/src/bridge/uuid.ts b/src/bridge/uuid.ts new file mode 100644 index 0000000..fcb84a0 --- /dev/null +++ b/src/bridge/uuid.ts @@ -0,0 +1,28 @@ +/** crypto.randomUUID needs WebKit 15.4; the deployment floor is iOS 15.0, + * so 15.0 to 15.3 devices take the getRandomValues path. */ +export function randomUUID(): string { + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID() + return uuidFromRandomBytes(crypto) +} + +/** + * Gives a WebView without crypto.randomUUID one built from getRandomValues, + * for code outside this app that calls it directly. A WebView that has its + * own keeps it. + */ +export function installRandomUUIDPolyfill(target: Crypto = globalThis.crypto): void { + if (typeof target.randomUUID === 'function') return + Object.defineProperty(target, 'randomUUID', { + value: () => uuidFromRandomBytes(target), + configurable: true, + writable: true + }) +} + +function uuidFromRandomBytes(source: Crypto): `${string}-${string}-${string}-${string}-${string}` { + const bytes = source.getRandomValues(new Uint8Array(16)) + bytes[6] = (bytes[6]! & 0x0f) | 0x40 + bytes[8] = (bytes[8]! & 0x3f) | 0x80 + const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} diff --git a/src/bridge/vault-fs.ts b/src/bridge/vault-fs.ts index d73a7c4..c93c912 100644 --- a/src/bridge/vault-fs.ts +++ b/src/bridge/vault-fs.ts @@ -36,6 +36,7 @@ import { type RenameNoteRef } from '@zennotes/shared-domain/wikilink-rename' import { NativeFs } from './native-fs' +import { randomUUID } from './uuid' import { ensureDownloaded } from './icloud' import { emitVaultChange } from './events' import { @@ -103,7 +104,7 @@ interface SearchCandidate { } function uuid(): string { - return crypto.randomUUID() + return randomUUID() } /** diff --git a/src/random-uuid-polyfill.test.ts b/src/random-uuid-polyfill.test.ts new file mode 100644 index 0000000..9495544 --- /dev/null +++ b/src/random-uuid-polyfill.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { it } from 'node:test' +import { loadMobileModule } from '../tooling/load-mobile-module.ts' +import { withoutRandomUUID } from '../tooling/without-random-uuid.ts' + +// The helper itself is covered by the shared src/bridge/uuid.test.ts; this is +// the Android boot wiring around it. + +it('installs crypto.randomUUID at boot on a WebView that lacks it', async () => { + await withoutRandomUUID(async () => { + await loadMobileModule('./src/random-uuid-polyfill.ts') + assert.match(crypto.randomUUID(), /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + }) +}) + +it('boots the polyfill before anything else, and the core loads only after it', () => { + const bootstrap = readFileSync(new URL('./bootstrap.ts', import.meta.url), 'utf8') + assert.equal(bootstrap.match(/^import\s.*$/m)?.[0], "import './random-uuid-polyfill'") + assert.doesNotMatch(bootstrap, /^import .*@zennotes\//m) +}) diff --git a/src/random-uuid-polyfill.ts b/src/random-uuid-polyfill.ts new file mode 100644 index 0000000..5d40f11 --- /dev/null +++ b/src/random-uuid-polyfill.ts @@ -0,0 +1,12 @@ +/** + * bootstrap.ts imports this first, so crypto.randomUUID exists before main.tsx + * and the core it pulls in are even loaded: bootstrap reaches them through a + * dynamic import. Android's oldest supported WebView (Chrome 92, which the + * bundle's syntax needs) already has it, so this is a guard, as on the iPhone + * for iOS 15.0 to 15.3: the core and this app's bridge route their own calls + * through the getRandomValues fallback in bridge/uuid.ts, and this covers code + * that calls crypto.randomUUID directly. + */ +import { installRandomUUIDPolyfill } from './bridge/uuid' + +installRandomUUIDPolyfill() diff --git a/tooling/without-random-uuid.ts b/tooling/without-random-uuid.ts new file mode 100644 index 0000000..3f19d62 --- /dev/null +++ b/tooling/without-random-uuid.ts @@ -0,0 +1,14 @@ +/** + * Runs `work` the way a WebView without crypto.randomUUID would (iOS 15.0 to + * 15.3): crypto keeps getRandomValues but has no randomUUID. The real method + * comes back after. + */ +export async function withoutRandomUUID(work: () => Promise | T): Promise { + const webCrypto = globalThis.crypto + Object.defineProperty(webCrypto, 'randomUUID', { value: undefined, configurable: true, writable: true }) + try { + return await work() + } finally { + Reflect.deleteProperty(webCrypto, 'randomUUID') + } +} diff --git a/vendor/zennotes/manifest.json b/vendor/zennotes/manifest.json index 5b20212..151b847 100644 --- a/vendor/zennotes/manifest.json +++ b/vendor/zennotes/manifest.json @@ -1,12 +1,12 @@ { "name": "@zennotes/app-core", - "version": "2.66.0-core.hf27b6fbd42368c71", - "file": "zennotes-app-core-2.66.0-core.hf27b6fbd42368c71.tgz", - "sha256": "a29e0c2f2de69ead14a0659a7e03f25331de2561623bf7638f053a04676bfcb2", - "integrity": "sha512-fpJrao/0LPlTradE9E6EDk+yBvKbSkRVODh5WVeLmH5cwn9PO8AbtUCUBb1JXQvz9qtyOWvxvdsu07cXpbwxow==", - "sourceCommit": "461fe76dff0522e6d90fb6591b8b21b255eb4cbd", + "version": "2.67.0-core.hb828dd60259bc376", + "file": "zennotes-app-core-2.67.0-core.hb828dd60259bc376.tgz", + "sha256": "02a2982b92c6d48ddc3c0b0730ff9462a5227f1ce7d599e5c856aebd35e07bc7", + "integrity": "sha512-k94Z88Sebd6NHAe8/1ba5K0K46NuJ7gwhMmxzAG97Y5VKu+6r+TKsXD/R8Axkh6UIJ4AtoBVBi0lca6nOO2JUQ==", + "sourceCommit": "58ba24acaf78422b1622418cdbf80dfc2647abfa", "workingTreeDirty": false, - "sourceLockSha256": "68754de2a05cc6a9bd71b248a1265a47fe3d63e036f7fde98c6043bd259052b7", + "sourceLockSha256": "784aee794c2047a319d16b3e248c22ca2445fcf18cad03e2ce6a92f5c6456f92", "toolchain": { "node": "v22.23.3", "typescript": "5.9.3", @@ -15,20 +15,20 @@ "dependencies": [ { "name": "@zennotes/bridge-contract", - "version": "2.66.0-boundaries.h3df85330eec3e7fb", - "file": "zennotes-bridge-contract-2.66.0-boundaries.h3df85330eec3e7fb.tgz", - "sha256": "42f41e2f6c6c99effb645bb613134ac58d0876e6e6bdc923cc8e56f21e69c444", - "integrity": "sha512-62g3+UuVtdvnx5+QJLf6SpcfAJa1lhIe3ipkTptJ1uvF4KI8mxaD+3BUUeflQwEuXlIEO5lb89jvTPuj+h2VrQ==", - "sourceCommit": "461fe76dff0522e6d90fb6591b8b21b255eb4cbd", + "version": "2.67.0-boundaries.h53f4d533587f4290", + "file": "zennotes-bridge-contract-2.67.0-boundaries.h53f4d533587f4290.tgz", + "sha256": "d489790a5659ec148746e1ea0baeba8d0795dab1729517b93861c0d65717fcaa", + "integrity": "sha512-3qnOkEVjq4pKqAOO/jw9oRy+NzK+h9G6yKGywzcfPQSFVS/ugN+6JKBKkRvObdDz64vPkIhR5m0cu8H3t7uFyw==", + "sourceCommit": "58ba24acaf78422b1622418cdbf80dfc2647abfa", "workingTreeDirty": false }, { "name": "@zennotes/shared-domain", - "version": "2.66.0-boundaries.h3df85330eec3e7fb", - "file": "zennotes-shared-domain-2.66.0-boundaries.h3df85330eec3e7fb.tgz", - "sha256": "e2c5511e34ed9b012d0cc1fa2407821cfd8cf86dd0e19a4f483dc14aa98374f2", - "integrity": "sha512-gy0rYVYvlnUCKrkBSbWX2qTeZF8CG48qJOyHKJJukENAkWvuVJ1vXWoJE8CUaN2i314IPT4z9u1CuEXRyXS6lA==", - "sourceCommit": "461fe76dff0522e6d90fb6591b8b21b255eb4cbd", + "version": "2.67.0-boundaries.h53f4d533587f4290", + "file": "zennotes-shared-domain-2.67.0-boundaries.h53f4d533587f4290.tgz", + "sha256": "c6df54b576bb34202697c034f5f90a5a69aaa6d64b194301d38b4eaae52e2158", + "integrity": "sha512-5oooEqUyGJJ32/ZKA7KzReaCNmXOF0AQHHumfGsUESxTAE1Tf6u2sY9ubbNgFZCErZuDDmF0aF2ZiPxtMyJBnQ==", + "sourceCommit": "58ba24acaf78422b1622418cdbf80dfc2647abfa", "workingTreeDirty": false } ] diff --git a/vendor/zennotes/zennotes-app-core-2.66.0-core.hf27b6fbd42368c71.tgz b/vendor/zennotes/zennotes-app-core-2.66.0-core.hf27b6fbd42368c71.tgz deleted file mode 100644 index 7c76327..0000000 Binary files a/vendor/zennotes/zennotes-app-core-2.66.0-core.hf27b6fbd42368c71.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-app-core-2.67.0-core.hb828dd60259bc376.tgz b/vendor/zennotes/zennotes-app-core-2.67.0-core.hb828dd60259bc376.tgz new file mode 100644 index 0000000..1ed8ce6 Binary files /dev/null and b/vendor/zennotes/zennotes-app-core-2.67.0-core.hb828dd60259bc376.tgz differ diff --git a/vendor/zennotes/zennotes-bridge-contract-2.66.0-boundaries.h3df85330eec3e7fb.tgz b/vendor/zennotes/zennotes-bridge-contract-2.66.0-boundaries.h3df85330eec3e7fb.tgz deleted file mode 100644 index adcbdf1..0000000 Binary files a/vendor/zennotes/zennotes-bridge-contract-2.66.0-boundaries.h3df85330eec3e7fb.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-bridge-contract-2.67.0-boundaries.h53f4d533587f4290.tgz b/vendor/zennotes/zennotes-bridge-contract-2.67.0-boundaries.h53f4d533587f4290.tgz new file mode 100644 index 0000000..84f91a0 Binary files /dev/null and b/vendor/zennotes/zennotes-bridge-contract-2.67.0-boundaries.h53f4d533587f4290.tgz differ diff --git a/vendor/zennotes/zennotes-shared-domain-2.66.0-boundaries.h3df85330eec3e7fb.tgz b/vendor/zennotes/zennotes-shared-domain-2.66.0-boundaries.h3df85330eec3e7fb.tgz deleted file mode 100644 index 6e3c703..0000000 Binary files a/vendor/zennotes/zennotes-shared-domain-2.66.0-boundaries.h3df85330eec3e7fb.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-shared-domain-2.67.0-boundaries.h53f4d533587f4290.tgz b/vendor/zennotes/zennotes-shared-domain-2.67.0-boundaries.h53f4d533587f4290.tgz new file mode 100644 index 0000000..45ea3c8 Binary files /dev/null and b/vendor/zennotes/zennotes-shared-domain-2.67.0-boundaries.h53f4d533587f4290.tgz differ