Environment: Foundation SDK 1.0.0, KeyOS 1.4.0-beta1, third-party
(publisher-signed, sideloaded) app, publisher trusted via Settings → Apps →
Allowed Publishers.
Summary
A sideloaded app exported an OpenPGP secret key to the Airlock, the user
moved it to the USB/SD volume, the device's own file UI listed it — and on a
computer the file is 849 bytes of garbage, not a key. A second entry on the
same volume is corrupt at the directory level: readdir returns it, and every
operation on it fails with EINVAL.
The app did nothing unusual: open_file(CREATE) → overwrite(bytes) → close.
It has no way to flush, because os/fs FlushFs carries no permissionGroup
and so resolves to Foundation-signed-only.
This is worse than losing the file. The export reports success, the file
appears with the correct size, and the contents are wrong — for key material
the user carries away, that is discovered only when the key is needed.
Evidence, from the card as it sits
Three files written minutes apart on the same volume:
| file |
written by |
size |
content |
5CC2C119E6940DB7-public.asc |
device (02:53) |
624 |
✅ -----BEGIN PGP PUBLIC KEY BLOCK----- |
5CC2C119E6940DB7-secret.asc |
device (02:55) |
849 |
❌ binary garbage |
5CC2C119E6940DB7-secret-1.asc |
macOS GPG Keychain (03:09) |
849 |
✅ -----BEGIN PGP PRIVATE KEY BLOCK----- |
The garbage file begins:
00000000: 5776 18a8 042b 55aa 0059 89de a2f1 1113 Wv...+U..Y......
00000010: ee35 5074 397f a4a6 29dd cfd8 8960 580b .5Pt9...)....`X.
file(1) reports data. The size is exactly right for the armored key, so the
directory entry reached the medium while the data clusters (or the FAT chain
pointing at them) did not — reads return whatever those clusters held before.
And a fourth entry, 5CC2C119E6940DB7.asc, is unusable:
find → ./5CC2C119E6940DB7.asc (readdir yields it)
ls -1 → not listed
stat() → Invalid argument (errno 22)
Its name bytes are clean ASCII (b'5CC2C119E6940DB7.asc'), so this is the
entry itself, not a name-encoding problem. It cannot be opened, renamed or
deleted through the filesystem; the user hit it when Finder refused to rename
another file onto that name.
Mechanism
| layer |
call |
third-party? |
| file → fatfs |
Flush(handle) → file.flush() |
✅ grouped file-system.write-and-mutate |
| fatfs → disk cache |
same call; the block may sit in Disk's LRU cache as dirty |
✅ |
| disk cache → block device |
FlushFs → flush_disk() → Disk::flush() |
❌ ungrouped → Foundation-only |
Disk::flush() (os/fs/src/disk/mod.rs) is the only code that walks the block
cache and writes dirty entries to the device. The only other trigger is
Disk::drop, i.e. unmount — and the Airlock unmount (MountAirlock(false)) is
ungrouped too. So an app has no way to force its own writes out, and no way
to know whether they landed.
Some writes do land regardless: Disk::write has a fast path where a full,
aligned block goes straight to write_blocks, and LRU eviction drains the rest
over time. That is what makes this so hard to attribute — data and metadata for
the same file take different paths, so a file can end up with a valid entry and
stale contents, which is exactly what the card shows.
What we would like
- Give
FlushFs a permissionGroup — file-system.write-and-mutate
would be consistent: an app permitted to write should be permitted to make
its writes durable. A narrower "flush the volume this handle lives on" would
serve equally well.
- Failing that, flush on
CloseFile for third-party writers, making close
a durability boundary. Slower, but safe by default.
- Independently: whatever the app is allowed to do, the filesystem should
not be left in a state where a directory entry survives without its data,
or where an entry exists that stat rejects. If the volume can be pulled at
any moment (it is removable, and the device gives an app no way to sync),
ordering metadata after data would keep the failure to "file missing" rather
than "file present and wrong".
Notes on reproducing
We could not reproduce this in the hosted simulator: a file written and
saved through an app appears in disk.dat — data and directory entry — with no
FlushFs call. That looks like a limitation of the simulator rather than
evidence the device is fine: its block device is a host file, so even an
evicted dirty block lands in the host's page cache and looks written.
The device evidence above is from a real card, still in the state described.
Happy to run any diagnostic that would help.
Secondary: the Airlock volume messages, and denials that abort the app
MountAirlock / FormatAirlock are ungrouped too, while the file-access pair
is grouped:
GetAirlockReadAccess = { id = 104, permissionGroup = "file-system.airlock-files", approval = "grantOnFirstUse" }
GetAirlockWriteAccess = { id = 105, permissionGroup = "file-system.airlock-files", approval = "grantOnFirstUse" }
MountAirlock = { id = 28, approval = "autoAllow" } # no permissionGroup
FormatAirlock = { id = 29, approval = "autoAllow" } # no permissionGroup
So an app can be granted access to Airlock files it has no way to reach when
the volume is not already mounted. We would rather rely on a documented
guarantee than on mounting ourselves: if the system guarantees the Airlock
volume is mounted whenever USB does not own it, please say so — we will drop
our mount calls and use GetAirlockWriteAccess alone. Nothing in the shipped
SDK states this, which is why the calls are there.
Worth noting where those mount calls came from: the hosted simulator mounts
nothing, so the pattern ("mount lazily, format after a failed mount") was
written to make Airlock testable in the sim and then shipped to hardware.
Having the simulator mount its Airlock image the way the device does would stop
the next person writing the same code.
Finally, a denied message aborts the app rather than returning an error.
mount_airlock() sends via send_blocking_scalar, which is
try_send_blocking_scalar(..).unwrap(), so AccessDenied panics the process —
no xous_names warning, no chance for the app's own fallback. Our export code
already reads if mount.is_ok() { Airlock } else { Internal }; the panic is
what stops that fallback from running. FileSystem::flush() uses the
non-unwrapping try_ form and returns the denial as an fs::Error, which is
the behaviour we would want throughout. Routing the Airlock mount/format sends
the same way would let apps degrade instead of dying.
Environment: Foundation SDK 1.0.0, KeyOS 1.4.0-beta1, third-party
(publisher-signed, sideloaded) app, publisher trusted via Settings → Apps →
Allowed Publishers.
Summary
A sideloaded app exported an OpenPGP secret key to the Airlock, the user
moved it to the USB/SD volume, the device's own file UI listed it — and on a
computer the file is 849 bytes of garbage, not a key. A second entry on the
same volume is corrupt at the directory level:
readdirreturns it, and everyoperation on it fails with
EINVAL.The app did nothing unusual:
open_file(CREATE)→overwrite(bytes)→ close.It has no way to flush, because
os/fsFlushFscarries nopermissionGroupand so resolves to Foundation-signed-only.
This is worse than losing the file. The export reports success, the file
appears with the correct size, and the contents are wrong — for key material
the user carries away, that is discovered only when the key is needed.
Evidence, from the card as it sits
Three files written minutes apart on the same volume:
5CC2C119E6940DB7-public.asc-----BEGIN PGP PUBLIC KEY BLOCK-----5CC2C119E6940DB7-secret.asc5CC2C119E6940DB7-secret-1.asc-----BEGIN PGP PRIVATE KEY BLOCK-----The garbage file begins:
file(1)reportsdata. The size is exactly right for the armored key, so thedirectory entry reached the medium while the data clusters (or the FAT chain
pointing at them) did not — reads return whatever those clusters held before.
And a fourth entry,
5CC2C119E6940DB7.asc, is unusable:Its name bytes are clean ASCII (
b'5CC2C119E6940DB7.asc'), so this is theentry itself, not a name-encoding problem. It cannot be opened, renamed or
deleted through the filesystem; the user hit it when Finder refused to rename
another file onto that name.
Mechanism
Flush(handle)→file.flush()file-system.write-and-mutateDisk's LRU cache as dirtyFlushFs→flush_disk()→Disk::flush()Disk::flush()(os/fs/src/disk/mod.rs) is the only code that walks the blockcache and writes dirty entries to the device. The only other trigger is
Disk::drop, i.e. unmount — and the Airlock unmount (MountAirlock(false)) isungrouped too. So an app has no way to force its own writes out, and no way
to know whether they landed.
Some writes do land regardless:
Disk::writehas a fast path where a full,aligned block goes straight to
write_blocks, and LRU eviction drains the restover time. That is what makes this so hard to attribute — data and metadata for
the same file take different paths, so a file can end up with a valid entry and
stale contents, which is exactly what the card shows.
What we would like
FlushFsapermissionGroup—file-system.write-and-mutatewould be consistent: an app permitted to write should be permitted to make
its writes durable. A narrower "flush the volume this handle lives on" would
serve equally well.
CloseFilefor third-party writers, making closea durability boundary. Slower, but safe by default.
not be left in a state where a directory entry survives without its data,
or where an entry exists that
statrejects. If the volume can be pulled atany moment (it is removable, and the device gives an app no way to sync),
ordering metadata after data would keep the failure to "file missing" rather
than "file present and wrong".
Notes on reproducing
We could not reproduce this in the hosted simulator: a file written and
saved through an app appears in
disk.dat— data and directory entry — with noFlushFscall. That looks like a limitation of the simulator rather thanevidence the device is fine: its block device is a host file, so even an
evicted dirty block lands in the host's page cache and looks written.
The device evidence above is from a real card, still in the state described.
Happy to run any diagnostic that would help.
Secondary: the Airlock volume messages, and denials that abort the app
MountAirlock/FormatAirlockare ungrouped too, while the file-access pairis grouped:
So an app can be granted access to Airlock files it has no way to reach when
the volume is not already mounted. We would rather rely on a documented
guarantee than on mounting ourselves: if the system guarantees the Airlock
volume is mounted whenever USB does not own it, please say so — we will drop
our mount calls and use
GetAirlockWriteAccessalone. Nothing in the shippedSDK states this, which is why the calls are there.
Worth noting where those mount calls came from: the hosted simulator mounts
nothing, so the pattern ("mount lazily, format after a failed mount") was
written to make Airlock testable in the sim and then shipped to hardware.
Having the simulator mount its Airlock image the way the device does would stop
the next person writing the same code.
Finally, a denied message aborts the app rather than returning an error.
mount_airlock()sends viasend_blocking_scalar, which istry_send_blocking_scalar(..).unwrap(), soAccessDeniedpanics the process —no
xous_nameswarning, no chance for the app's own fallback. Our export codealready reads
if mount.is_ok() { Airlock } else { Internal }; the panic iswhat stops that fallback from running.
FileSystem::flush()uses thenon-unwrapping
try_form and returns the denial as anfs::Error, which isthe behaviour we would want throughout. Routing the Airlock mount/format sends
the same way would let apps degrade instead of dying.