Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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')
Expand Down
17 changes: 15 additions & 2 deletions android/app/src/main/java/md/zennotes/CloudFileStream.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -73,15 +74,27 @@ 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) {
throw new IllegalStateException(impossible);
}
}

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++) {
Expand Down
120 changes: 120 additions & 0 deletions android/app/src/main/java/md/zennotes/DirectUploadPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading