fix(app): a saved file is the own user's from the start, and replaces the one chosen whole
The reading of the full reading's fixes found that writePrivately made
the file, then set it to 0600: another local user could open it in that
window and keep reading what was written after; and that where the path
was a link or another user's file, chmod failed and the secret was
written into it anyway.
On Linux and macOS it now makes a new file beside the one chosen, 0600
as it is created (O_EXCL, O_NOFOLLOW), writes through the descriptor that
made it, and renames it over the chosen path, so the old file is never
written into; a symbolic link or another user's file is refused, with
nothing written (lstat through FFI, by each system's layout). Windows
writes in place, with its folder's rights.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 16:16:00 +02:00
|
|
|
import 'dart:convert';
|
2026-10-21 14:44:10 +02:00
|
|
|
import 'dart:io';
|
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
import 'package:halkyon_app/halkyon_app.dart';
|
|
|
|
|
import 'package:path/path.dart' as p;
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
late Directory folder;
|
|
|
|
|
|
|
|
|
|
setUp(() async {
|
|
|
|
|
folder = await Directory.systemTemp.createTemp('file_saver_test');
|
|
|
|
|
});
|
|
|
|
|
tearDown(() => folder.delete(recursive: true));
|
|
|
|
|
|
|
|
|
|
test("a saved file is the own user's alone where the platform has modes, "
|
|
|
|
|
'set before anything is written', () async {
|
|
|
|
|
final path = p.join(folder.path, 'halkyon-recovery-codes.txt');
|
|
|
|
|
// One there before, readable by everyone.
|
|
|
|
|
await File(path).writeAsString('old');
|
|
|
|
|
if (!Platform.isWindows) {
|
|
|
|
|
await Process.run('chmod', ['644', path]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await writePrivately(path, Uint8List.fromList([1, 2, 3]));
|
|
|
|
|
|
|
|
|
|
expect(await File(path).readAsBytes(), [1, 2, 3]);
|
|
|
|
|
if (Platform.isLinux || Platform.isMacOS) {
|
|
|
|
|
expect(File(path).statSync().mode & 0x1ff, 0x180, reason: '0600');
|
|
|
|
|
}
|
|
|
|
|
}, skip: Platform.isWindows ? 'no modes' : false);
|
|
|
|
|
|
fix(app): a saved file is the own user's from the start, and replaces the one chosen whole
The reading of the full reading's fixes found that writePrivately made
the file, then set it to 0600: another local user could open it in that
window and keep reading what was written after; and that where the path
was a link or another user's file, chmod failed and the secret was
written into it anyway.
On Linux and macOS it now makes a new file beside the one chosen, 0600
as it is created (O_EXCL, O_NOFOLLOW), writes through the descriptor that
made it, and renames it over the chosen path, so the old file is never
written into; a symbolic link or another user's file is refused, with
nothing written (lstat through FFI, by each system's layout). Windows
writes in place, with its folder's rights.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 16:16:00 +02:00
|
|
|
final hasModes = Platform.isLinux || Platform.isMacOS;
|
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>
2026-10-21 17:57:44 +02:00
|
|
|
// 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';
|
fix(app): a saved file is the own user's from the start, and replaces the one chosen whole
The reading of the full reading's fixes found that writePrivately made
the file, then set it to 0600: another local user could open it in that
window and keep reading what was written after; and that where the path
was a link or another user's file, chmod failed and the secret was
written into it anyway.
On Linux and macOS it now makes a new file beside the one chosen, 0600
as it is created (O_EXCL, O_NOFOLLOW), writes through the descriptor that
made it, and renames it over the chosen path, so the old file is never
written into; a symbolic link or another user's file is refused, with
nothing written (lstat through FFI, by each system's layout). Windows
writes in place, with its folder's rights.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 16:16:00 +02:00
|
|
|
|
|
|
|
|
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 '
|
|
|
|
|
'saved (the reading of the fixes)', () async {
|
|
|
|
|
final path = p.join(folder.path, 'halkyon-recovery-codes.txt');
|
|
|
|
|
await File(path).writeAsString('old');
|
|
|
|
|
await Process.run('chmod', ['644', path]);
|
|
|
|
|
// Another name for the same file, as a descriptor opened earlier is.
|
|
|
|
|
final elsewhere = p.join(folder.path, 'held-open');
|
|
|
|
|
await Process.run('ln', [path, elsewhere]);
|
|
|
|
|
|
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>
2026-10-21 17:57:44 +02:00
|
|
|
await writePrivately(
|
|
|
|
|
path,
|
|
|
|
|
Uint8List.fromList(utf8.encode('secret')),
|
|
|
|
|
inPlace: false,
|
|
|
|
|
);
|
fix(app): a saved file is the own user's from the start, and replaces the one chosen whole
The reading of the full reading's fixes found that writePrivately made
the file, then set it to 0600: another local user could open it in that
window and keep reading what was written after; and that where the path
was a link or another user's file, chmod failed and the secret was
written into it anyway.
On Linux and macOS it now makes a new file beside the one chosen, 0600
as it is created (O_EXCL, O_NOFOLLOW), writes through the descriptor that
made it, and renames it over the chosen path, so the old file is never
written into; a symbolic link or another user's file is refused, with
nothing written (lstat through FFI, by each system's layout). Windows
writes in place, with its folder's rights.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 16:16:00 +02:00
|
|
|
|
|
|
|
|
expect(await File(path).readAsString(), 'secret');
|
|
|
|
|
expect(File(path).statSync().mode & 0x1ff, 0x180, reason: '0600');
|
|
|
|
|
expect(await File(elsewhere).readAsString(), 'old');
|
|
|
|
|
// Nothing left beside it.
|
|
|
|
|
expect(
|
|
|
|
|
folder.listSync().map((entry) => p.basename(entry.path)).toSet(),
|
|
|
|
|
{'halkyon-recovery-codes.txt', 'held-open'},
|
|
|
|
|
);
|
|
|
|
|
}, skip: hasModes ? false : 'no modes');
|
|
|
|
|
|
|
|
|
|
test('never follows a symbolic link where the file is to be saved: '
|
|
|
|
|
'refused, the file it points to untouched', () async {
|
|
|
|
|
final target = p.join(folder.path, 'someone-elses.txt');
|
|
|
|
|
await File(target).writeAsString('theirs');
|
|
|
|
|
final path = p.join(folder.path, 'halkyon-recovery-phrase.txt');
|
|
|
|
|
await Link(path).create(target);
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
writePrivately(path, Uint8List.fromList(utf8.encode('secret'))),
|
|
|
|
|
throwsA(isA<FileSystemException>()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(await File(target).readAsString(), 'theirs');
|
|
|
|
|
expect(FileSystemEntity.isLinkSync(path), isTrue);
|
|
|
|
|
expect(
|
|
|
|
|
folder.listSync().map((entry) => p.basename(entry.path)).toSet(),
|
|
|
|
|
{'someone-elses.txt', 'halkyon-recovery-phrase.txt'},
|
|
|
|
|
);
|
|
|
|
|
}, skip: hasModes ? false : 'no modes');
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
"refuses a file another user owns, writing nothing: a root's file "
|
|
|
|
|
'stands in for one in a shared folder',
|
|
|
|
|
() async {
|
|
|
|
|
const rootsFile = '/etc/hostname';
|
|
|
|
|
final before = File(rootsFile).readAsStringSync();
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
writePrivately(rootsFile, Uint8List.fromList([1])),
|
|
|
|
|
throwsA(
|
|
|
|
|
isA<FileSystemException>().having(
|
|
|
|
|
(error) => error.message,
|
|
|
|
|
'message',
|
|
|
|
|
"Another user's file",
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(File(rootsFile).readAsStringSync(), before);
|
|
|
|
|
},
|
|
|
|
|
skip: !hasModes || !File('/etc/hostname').existsSync()
|
|
|
|
|
? 'no such file'
|
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>
2026-10-21 17:57:44 +02:00
|
|
|
: isRoot
|
|
|
|
|
? "root writes anyone's files"
|
fix(app): a saved file is the own user's from the start, and replaces the one chosen whole
The reading of the full reading's fixes found that writePrivately made
the file, then set it to 0600: another local user could open it in that
window and keep reading what was written after; and that where the path
was a link or another user's file, chmod failed and the secret was
written into it anyway.
On Linux and macOS it now makes a new file beside the one chosen, 0600
as it is created (O_EXCL, O_NOFOLLOW), writes through the descriptor that
made it, and renames it over the chosen path, so the old file is never
written into; a symbolic link or another user's file is refused, with
nothing written (lstat through FFI, by each system's layout). Windows
writes in place, with its folder's rights.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 16:16:00 +02:00
|
|
|
: false,
|
|
|
|
|
);
|
|
|
|
|
|
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>
2026-10-21 17:57:44 +02:00
|
|
|
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');
|
|
|
|
|
|
fix(app): a saved file is the own user's from the start, and replaces the one chosen whole
The reading of the full reading's fixes found that writePrivately made
the file, then set it to 0600: another local user could open it in that
window and keep reading what was written after; and that where the path
was a link or another user's file, chmod failed and the secret was
written into it anyway.
On Linux and macOS it now makes a new file beside the one chosen, 0600
as it is created (O_EXCL, O_NOFOLLOW), writes through the descriptor that
made it, and renames it over the chosen path, so the old file is never
written into; a symbolic link or another user's file is refused, with
nothing written (lstat through FFI, by each system's layout). Windows
writes in place, with its folder's rights.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 16:16:00 +02:00
|
|
|
test('a new file is made 0600 from the start, with nothing left beside '
|
|
|
|
|
'it', () async {
|
|
|
|
|
final path = p.join(folder.path, 'new.txt');
|
|
|
|
|
|
|
|
|
|
await writePrivately(path, Uint8List.fromList([4, 5, 6]));
|
|
|
|
|
|
|
|
|
|
expect(await File(path).readAsBytes(), [4, 5, 6]);
|
|
|
|
|
expect(File(path).statSync().mode & 0x1ff, 0x180, reason: '0600');
|
|
|
|
|
expect(folder.listSync(), hasLength(1));
|
|
|
|
|
}, skip: hasModes ? false : 'no modes');
|
|
|
|
|
|
2026-10-21 14:44:10 +02:00
|
|
|
test("the system picker's copy in the temporary folder is removed, "
|
|
|
|
|
'others left', () async {
|
|
|
|
|
final copy = File(p.join(folder.path, 'halkyon-recovery-phrase.txt'));
|
|
|
|
|
final other = File(p.join(folder.path, 'something-else.txt'));
|
|
|
|
|
await copy.writeAsString('the phrase');
|
|
|
|
|
await other.writeAsString('other');
|
|
|
|
|
|
|
|
|
|
await forgetPickerCopy('halkyon-recovery-phrase.txt', temporary: folder);
|
|
|
|
|
await forgetPickerCopy('not-there.txt', temporary: folder);
|
|
|
|
|
|
|
|
|
|
expect(copy.existsSync(), isFalse);
|
|
|
|
|
expect(other.existsSync(), isTrue);
|
|
|
|
|
});
|
|
|
|
|
}
|