fix(app): a saved secret is 0600 on Apple's Arm too, and macOS saves at all

The short reading's H1: open(2) takes its mode among variable arguments,
which Apple's Arm passes on the stack, so bound with three fixed ones it
read the mode from whatever the stack held: a phrase or recovery codes
saved on an Apple silicon Mac could be readable by everyone. It is bound
as variadic now (VarArgs), and every file written is made 0600 through its
descriptor (fchmod), checked.

M1: macOS's sandbox lets an app reach the file the person chose and
nothing beside it, so the file made beside it was refused and nothing
could be saved; Learn's sandbox allowed reading chosen files alone, so its
exports never saved on macOS either. There, writePrivately now writes the
chosen file itself (decided by the developer on 21 Oct): opened without
following a link or waiting on a pipe and without truncating, refused with
nothing written unless fstat finds a file of the own user's, then made
0600 and emptied. Both apps' entitlements grant reading and writing the
files the person chooses. Linux keeps the new file renamed over it.

The reading's nits: the buffer is wiped whatever happens, an interrupted
call is made again (EINTR), the file made beside a chosen one is named
apart from it so a name as long as a folder takes still saves, the folder
is flushed after the rename, and Linux reads a file's type and owner with
statx, alike on every architecture, where __lxstat's version differed
(riscv64's was wrong). The test of another user's file is skipped as root.
The in-place path is tested on Linux; none of it has run on a Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 17:57:44 +02:00
commit 290f584329
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -32,6 +32,10 @@ void main() {
}, skip: Platform.isWindows ? 'no modes' : false);
final hasModes = Platform.isLinux || Platform.isMacOS;
// Root may write anyone's files: the refusals of another user's can't be
// seen as root (the short reading, L6).
final isRoot =
hasModes && Process.runSync('id', ['-u']).stdout.toString().trim() == '0';
test('a saved file replaces the one there without writing into it: '
'whoever held the old file open, or a link to it, never reads what is '
@ -43,7 +47,11 @@ void main() {
final elsewhere = p.join(folder.path, 'held-open');
await Process.run('ln', [path, elsewhere]);
await writePrivately(path, Uint8List.fromList(utf8.encode('secret')));
await writePrivately(
path,
Uint8List.fromList(utf8.encode('secret')),
inPlace: false,
);
expect(await File(path).readAsString(), 'secret');
expect(File(path).statSync().mode & 0x1ff, 0x180, reason: '0600');
@ -96,9 +104,90 @@ void main() {
},
skip: !hasModes || !File('/etc/hostname').existsSync()
? 'no such file'
: isRoot
? "root writes anyone's files"
: false,
);
test('a name as long as a folder takes is saved: the file made beside it '
'is named apart from it (the short reading)', () async {
final path = p.join(folder.path, '${'a' * 245}.txt');
await writePrivately(path, Uint8List.fromList([7]), inPlace: false);
expect(await File(path).readAsBytes(), [7]);
expect(folder.listSync(), hasLength(1));
}, skip: hasModes ? false : 'no modes');
group("in place, as macOS's sandbox allows only the file chosen (the short "
'reading, M1)', () {
test('the chosen file is made 0600 before anything is written, and '
'what it held goes whole', () async {
final path = p.join(folder.path, 'halkyon-recovery-codes.txt');
await File(path).writeAsString('old, and longer than what is saved');
await Process.run('chmod', ['644', path]);
await writePrivately(path, Uint8List.fromList([1, 2, 3]), inPlace: true);
expect(await File(path).readAsBytes(), [1, 2, 3]);
expect(File(path).statSync().mode & 0x1ff, 0x180, reason: '0600');
expect(folder.listSync(), hasLength(1));
});
test('a new file is made 0600', () async {
final path = p.join(folder.path, 'new.txt');
await writePrivately(path, Uint8List.fromList([4, 5]), inPlace: true);
expect(await File(path).readAsBytes(), [4, 5]);
expect(File(path).statSync().mode & 0x1ff, 0x180, reason: '0600');
});
test('never follows a symbolic link, to a file or to none', () async {
final target = p.join(folder.path, 'someone-elses.txt');
await File(target).writeAsString('theirs');
final link = p.join(folder.path, 'halkyon-recovery-phrase.txt');
await Link(link).create(target);
final dangling = p.join(folder.path, 'dangling.txt');
await Link(dangling).create(p.join(folder.path, 'nowhere.txt'));
for (final path in [link, dangling]) {
await expectLater(
writePrivately(path, Uint8List.fromList([1]), inPlace: true),
throwsA(isA<FileSystemException>()),
reason: path,
);
}
expect(await File(target).readAsString(), 'theirs');
expect(File(p.join(folder.path, 'nowhere.txt')).existsSync(), isFalse);
});
test('refuses what is no file, never waiting on a pipe', () async {
final pipe = p.join(folder.path, 'pipe');
await Process.run('mkfifo', [pipe]);
for (final path in [pipe, folder.path]) {
await expectLater(
writePrivately(path, Uint8List.fromList([1]), inPlace: true),
throwsA(isA<FileSystemException>()),
reason: path,
);
}
});
test("refuses another user's file, writing nothing", () async {
const rootsFile = '/etc/hostname';
final before = File(rootsFile).readAsStringSync();
await expectLater(
writePrivately(rootsFile, Uint8List.fromList([1]), inPlace: true),
throwsA(isA<FileSystemException>()),
);
expect(File(rootsFile).readAsStringSync(), before);
}, skip: isRoot || !File('/etc/hostname').existsSync() ? 'root' : false);
}, skip: hasModes ? false : 'no modes');
test('a new file is made 0600 from the start, with nothing left beside '
'it', () async {
final path = p.join(folder.path, 'new.txt');