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:
parent
a9bbda7017
commit
290f584329
1 changed files with 413 additions and 110 deletions
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue