From 19f930a93aecea70581b7838da374fa8a7c5db7d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Thu, 12 Nov 2026 10:59:34 +0100 Subject: [PATCH] CLI caches the E2E key (0600) Argon2id ran on every CLI/MCP call; the derived key is now cached owner-only in the data dir. Todo: fda71ed1 Co-Authored-By: Claude Opus 5.5 --- lib/cli/cli_session.dart | 26 ++++++++++++++++++++++++++ pubspec.yaml | 2 +- test/cli/cli_session_test.dart | 15 +++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/lib/cli/cli_session.dart b/lib/cli/cli_session.dart index 997c1f5..60ee67c 100644 --- a/lib/cli/cli_session.dart +++ b/lib/cli/cli_session.dart @@ -7,6 +7,7 @@ /// records hand-written into RTDB. library; +import 'dart:convert'; import 'dart:io'; import 'package:crdt_sync/crdt_sync.dart'; @@ -149,6 +150,8 @@ class CliSession { store: _remote(push: push), oldPrefix: plainService.notesDir, readPassphrase: _readPassphrase, + // Argon2id once per install, not on every CLI/MCP call. + keyCache: E2eKeyCache(read: _readCachedKey, write: _writeCachedKey), ); if (!push) { // Read-only during the migration: old builds still write the plaintext @@ -181,6 +184,29 @@ class CliSession { RemoteStore _remote({required bool push}) => push ? _firebase : PullOnlyStore(_firebase); + /// Owner-only file in the data dir caching the derived E2E key. + static const e2eKeyFileName = 'e2e_key'; + + File get _keyFile => File(p.join(dataDir, e2eKeyFileName)); + + Future?> _readCachedKey() async { + final file = _keyFile; + if (!file.existsSync()) return null; + try { + return base64.decode((await file.readAsString()).trim()); + } on FormatException { + return null; + } + } + + Future _writeCachedKey(List bytes) async { + final file = _keyFile; + // Restrict before the key is written, never after. + await file.writeAsString(''); + await Process.run('chmod', ['600', file.path]); + await file.writeAsString(base64.encode(bytes), flush: true); + } + /// Environment variable holding the shared crdt-sync passphrase. static const e2ePassphraseEnv = 'TODO_E2E_PASSPHRASE'; diff --git a/pubspec.yaml b/pubspec.yaml index 70098f1..69e4bd6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.86+1 +version: 1.0.87+1 environment: sdk: ^3.12.2 diff --git a/test/cli/cli_session_test.dart b/test/cli/cli_session_test.dart index 8753627..7384521 100644 --- a/test/cli/cli_session_test.dart +++ b/test/cli/cli_session_test.dart @@ -129,6 +129,21 @@ void main() { await session.close(); }); + test('the derived key is cached owner-only for the next run', () async { + final first = await open('cache'); + await first.repository.upsert(note('n1')); + await first.sync(push: true); + await first.close(); + final cached = File('${tmp.path}/cache/${CliSession.e2eKeyFileName}'); + expect(cached.existsSync(), isTrue); + expect(cached.statSync().mode & 0x1ff, 384); // 0600 + // Without any passphrase the next run still unlocks from the cache. + File('${tmp.path}/e2e_passphrase').deleteSync(); + final second = await open('cache'); + expect((await second.sync(push: true)).pushed, isFalse); + await second.close(); + }); + test('a second node sees the first node note', () async { final a = await open('a'); await a.repository.upsert(note('shared'));