CLI caches the E2E key (0600)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-12 10:59:34 +01:00
parent f7e24bbb1a
commit 19f930a93a
3 changed files with 42 additions and 1 deletions

View File

@ -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<List<int>?> _readCachedKey() async {
final file = _keyFile;
if (!file.existsSync()) return null;
try {
return base64.decode((await file.readAsString()).trim());
} on FormatException {
return null;
}
}
Future<void> _writeCachedKey(List<int> 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';

View File

@ -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

View File

@ -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'));