Repository navigation
core signatures: install checks and the Security Warning - #20085
Draft
Davey-Hughes wants to merge 27 commits into
Draft
Davey-Hughes wants to merge 27 commits into
Davey-Hughes wants to merge 27 commits into
Conversation
Writes <core>.sig for the core signature check: key id, build time, the core's SHA-256 and its file name, signed with ECDSA P-256 as raw r || s. Signing goes through OpenSSL 3.2 or later; without it the tool exits with status 3. A PEM key signs with RFC 6979 nonces, checked against RFC 6979's own vector; a PKCS#11 URI's token makes its own. A build time of 0 is refused: RetroArch's rollback check reads it as no build time at all.
A core's <core>.sig names its key, build time, SHA-256 and file name, signed with ECDSA P-256. Parsing takes only an exact-length file and refuses a build time of 0, which the rollback check would read as none; the digest verified is our own SHA-256 of the signed bytes, and success is p256_ecdsa_verify() == 0. libretro's keys go in core_signing_keys.h, empty for now. Nothing calls this yet, and only the harness builds it until the check is wired in. The harness in samples/tasks/core_signature checks against fixtures that gen_fixtures.py makes with tools/core_sign.py. The samples-tasks job runs it under ASan and UBSan, and mingw-w64 with Wine. The CI local runner job runs core_sign.py's self-test, with its RFC 6979 vector where OpenSSL is 3.2 or newer, and checks the fixtures on any OpenSSL: each signed header is rebuilt and each signature verified with openssl, and on 3.2 or newer the fixtures are also signed again and compared.
A core is opened once and both identified and hashed through that handle, so what is hashed is the file that was identified: fstat's device, inode, size and, where the libc has them, nanosecond times on POSIX; volume serial, 128-bit file id, size, write and change time on Windows, where GetFileInformationByHandleEx is looked up at run time and the file is opened without write or delete sharing. A FIFO is refused rather than waited on. Hashing goes a 64 KiB chunk per step so a task can pace it. The identity also says whether the file's filesystem keeps ctime itself, from fstatfs on the same handle: ext2/3/4, xfs, btrfs, f2fs, tmpfs, zfs and bcachefs on Linux and Android; apfs, hfs, ufs, ffs, zfs and tmpfs on macOS, FreeBSD, OpenBSD and DragonFly. Only there does the kernel keep ctime against every writer that goes through it, so only there may an identity record stand in for a hash. Stacked and network filesystems, FAT, exFAT and NTFS are not on the list, and Windows never is: any writer can set ChangeTime.
Identity records, trusted hashes, the newest build time seen per core name in each cores folder, and a few flags, serialised big-endian with a SHA-256 trailer and parsed strictly. Only the main thread changes the store; each change publishes a new reference-counted set and never touches one already handed out, so a task reads its own set with no lock. Identity records are a bounded cache and trusted hashes refuse when full. The newest list holds 4096 entries; when it is full a new one replaces the oldest build, ties going to the lower folder key and then the lower name, so the list's order never decides. A record is not kept for a build older than the newest seen in its folder, unless it comes from Always Trust or a restore, and Always Trust refuses a check that ended in CORE_CHECK_ERROR. A record is made only of a file whose filesystem keeps ctime and that is on the store's own filesystem or on RetroArch's, as core_trust_set_device gives them: whoever can write either raw can rewrite the store or RetroArch itself anyway. A core anywhere else is hashed on every check, and a trusted hash still makes it OK. struct core_check carries a check's outcome to the main thread to record; the check that fills it follows. From here the samples-tasks job also runs the harness under TSan, with a worker reading a set while the main thread changes the store.
The store is read whole, with a size cap. A file that exists but can't be looked at or read, isn't a regular file, or was written by a newer format version is left alone, and the store stays read-only for the session, so nothing it holds is lost. A file that reads but is damaged is renamed to core_trust.bad, replacing an older one, and the store starts empty with every flag clear: CORE_TRUST_SEEDED too, so the one-time trust of the cores already installed runs again. If it can't be renamed it is treated as unreadable. Running out of memory is never damage. Each load also takes the device of the store's folder: identity records are kept only for files on that filesystem (the next commit adds RetroArch's own), and none when it can't be read or on Windows. Another instance can share the file, so each save reads it again and puts this store's changes since it was last loaded or saved over what is there now: the newer build date wins and trusted hashes are the union. Only newest entries this instance raised are merged, so a save at the list's cap never brings back what another instance's save dropped. A hash the file has no room for stays a change for a later save. One write runs at a time, through a temporary of this instance's own and a rename; a change while one is out writes again after. The file lives in Android's internalDataPath and elsewhere in the platform's default config folder.
The check, in order: off; an identity record of the file, for a build no older than the newest seen in its folder; or, on POSIX, a core this user can't have put there (the exemption below) answers without hashing. Otherwise the core is hashed and a trusted hash, then a .sig signed by a known key, decides. A signed build older than the newest seen for that name in the same cores folder is refused, unless it is a restore of RetroArch's own backup (CORE_CHECK_ALLOW_OLDER). The folder is keyed by its canonical path, so ., .., repeated separators and symlinks (POSIX) or case (Windows) don't change it. On Android every folder has the same key, so the newest date is per core name whatever folder holds the cores. The outcome goes back to the main thread to record. An identity record answers only for a file whose filesystem keeps ctime and that is on the store's filesystem or on RetroArch's own, which loading the store finds through core_self_find. A core that can't be opened, read or identified, or whose folder can't be keyed or whose size changed while it was hashed, is CORE_CHECK_ERROR: never trusted, and nothing is recorded. The .sig is read until its end or until a buffer longer than any valid one is full. The unsigned exemption needs the core on RetroArch's own filesystem, and the file and every folder up to / not writable by this user, owned by root or by the owner of RetroArch's binary, never by the overflow uid a user namespace shows for accounts it doesn't map, and without group write unless the folder is sticky or, on Linux and Android, the group is root's; on macOS and the BSDs gid 0 is wheel, the admin users. Read-only mounts skip the owner and group rules. When the overflow uid can't be read only root's files qualify. Windows has no exemption. A .sig that is a FIFO is refused without waiting for a writer.
The install check and the load check must read and verify a .sig the same way.
HAVE_CORE_SIGNING_TEST_KEY appends RFC 6979 A.2.5's P-256 key, the harness's test key 1, to the built-in keys. Its private half is public, so such a build runs cores anyone can sign: it exists to test real RetroArch before libretro has keys and is never on by default.
HAVE_CORE_SIGNATURES builds the signature check and its trust store, HAVE_CORE_LOAD_CHECK the check at every core load, and HAVE_CORE_SIGNING_TEST_KEY the public test key. The first defaults on where dynamic loading and the crypto library are, a line to turn off for one target; the load check and the test key default off. configure has --disable-core_signatures, --enable-core_load_check and --enable-core_signing_test_key. griffin stops with an #error when HAVE_CORE_SIGNATURES is set without HAVE_CRYPTO, as it does for HAVE_S3, rather than failing on crypto/pk.c's missing symbols.
Davey-Hughes
force-pushed
the
feat/core-signing-install
branch
2 times, most recently
from
November 14, 2026 16:21
ddaa8c9 to
183cd86
Compare
core_trust_runtime owns the one store: loaded at startup before any core is opened, saved by a task after each change with one write out at a time, and written once more at quit, within the exit drain's limit, if a change didn't go through. A store that can't be read is left alone and the session's changes stay in memory. With no keys compiled in, nothing is loaded and the feature acts as if it weren't built. A test-key build says so at every launch. Test-key builds keep their store in core_trust.testkey, so what they record never reaches a release build's store on the same account. On macOS the store always lives in ~/Library/Application Support/RetroArch, whatever portable.txt, RAPortableInstall or a Steam build say about the app's folder.
With the load check built in, the first run with a store that was never seeded hashes every core in the platform's built-in cores folder, a read or a folder entry at a time within the per-frame I/O window, and trusts each one, up to the store's 256 trusted hashes. Only what lstat() calls a regular file is seeded, so a symlink in the folder can't bring in a file that lives elsewhere; a hardlink's bytes are the folder's own. On Android the seed walks <ApplicationInfo.dataDir>/cores, which no intent extra moves, rather than the default core folder, which follows the DATADIR extra; when ApplicationInfo can't be read the seed doesn't run that launch. The store is marked seeded once the walk finishes or the folder doesn't exist. A seed cut short at quit, or a folder that is there but can't be opened, is seeded at a later start. If trust was recorded for at least one core, a notice says once that cores are now checked.
core_warning_push() holds one request, a copy of the check that failed, and opens a screen built like Load Archive / Open Archive: the reason and the full-access warning as rows above the choices, the line about turning the checks off below, the cursor on Cancel. An install offers Install and Trust, a load Load Once and Always Trust This Core, a core that couldn't be read Cancel only. The text rows are wrapped short because no menu driver wraps a label. The core updater's two refusal messages are added with the rest. The caller's callback gets the choice, or Cancel on Back, a newer request or quit. Each request has a serial that its screen's rows carry, so OK on rows built for an older request refreshes the screen rather than answering the newer one. OK and Back take the screen off the stack before the callback runs, so a callback that changes the menu stack (a load starting content) isn't followed by a pop of the wrong screen. A newer push runs the replaced request's callback with Cancel inside itself, with the newer request already in the slot; a choice made from there is ignored. A request still pending when a stack flush (a tab switch, a menu reinit) takes its screen away gets it back on the next frame; one answered before its screen came up leaves none. Info on the screen does nothing. core_warning_push() refuses during deinit's cancel and when the menu driver shows no screens (null), so the caller keeps its request instead of one nobody can answer. samples/menu/core_warning drives the screen through the shipping handlers, with core_signature.c rebuilt around the test key and a trust store of its own.
The confirm dialogs (quit, reset, close content) arm their command before the dialog is pushed. When the push fails, the command is cleared, so a later OK elsewhere can't run it.
The value lives in the trust store, not retroarch.cfg, so no config file can turn the checks off. Turning them off always asks first with the OK/Back confirm, even with confirmations off; turning them on asks nothing. The row is hidden when the feature is inactive, and in a session whose store couldn't be read it shows its value but says it can't be changed. The row is a switch like Core Lock's: Left and Right leave it alone when it already sits on that side, and Start resets it to On.
While a Security Warning is pending or the confirm for turning the checks off is up, button states from the command interfaces and the network pad are dropped where they join the menu's input, and a MENU request is answered with an error instead of being run. Keyboard, touch, overlays and controllers work as before, and so does synthetic input once neither is up. The core_warning harness drives both through a command interface stand-in and the network pad's state.
The load check is off in the default build, so one job builds with --enable-core_load_check and another with --enable-core_signing_test_key. The test-key job only builds: samples-tasks already runs the core_trust_runtime harness with the key under ASan/UBSan, TSan and mingw-w64, and the core_signature harness with it under ASan/UBSan and mingw-w64.
core_check_hashed()'s <core>.sig path and its read, bounded so that an over-long .sig stays invalid, become core_sig_path() and core_sig_read(), so other checks read a .sig the same way.
Adds the check the install paths will use: a core is staged under the name it is installed as, checked there and only then moved into place. The check hashes the staged file even when a record or the folder rule would pass the file in place: those vouch for the file, not the download. A check reads as failed until it ends, so a cancel is a failure. The old .sig is set aside as <core>.sig.old while the core is renamed in, then deleted, or renamed back when the rename fails; the new .sig is placed and the identity read again after the rename. A crash in between leaves a core without a .sig, never a new core beside the old one's .sig, and a stray .sig.old is never put back. A core that passed by its trusted hash keeps its staged .sig only if that .sig verifies on its own.
A backup gets a copy of the core's .sig beside it, whether it is written or moved into the backups before an update, so a restore can check it. Pruning and Delete Backup remove it with the backup. When an update's install move puts the new core in, the old .sig is set aside as in core_install_commit(), and the backup's copy of it goes if the move rolls back.
Restoring a backup or using Install or Restore a Core now unpacks the core under its own name beside the one it replaces and checks it with the .sig kept beside what was picked. A restore from RetroArch's own backup list may be an older build. Anything that doesn't pass goes to the Security Warning; Install and Trust runs the restore again and installs only when the core is still the one the warning showed. On a Play Store build a core installed through Play Feature Delivery is a link into the APK's read-only lib folder; a restore stages beside the link and deletes it only once the core passes. Install and Trust that can't run the restore again says so.
The updater fetches <core>.sig into the core's staging folder before the core itself; with checks on, a missing one refuses the update there. Every download is then staged and checked under its install name, and only a core that passes replaces the installed one, its .sig beside it, its record made on the main thread. A refused core is named in a message and the old core stays; Update Installed Cores carries on with the rest. Builds without keys update as before. Only a 404 or 410 for the .sig means the core is unsigned; any other failure ends as a failed install, never as a bad signature. A checked download that holds anything but the core and its .sig is refused. The core's staging folder is cleared before a download and at every end, a capped number of entries a tick, so a hostile archive can't stall a frame. An update cancelled after the backup task moved the new core in still places its .sig and records it.
Update Installed Cores skips a core whose CRC matches the buildbot's, so such a core never got a .sig. With checks on, the run now fetches the .sig of an up-to-date core that has none and checks it against the core in place on its own, never through the core's trusted hash. One that checks out is placed and recorded; one that doesn't is only logged, and the next run asks again. A .sig-only download that was never hashed places nothing. Fetching a .sig changes no core file, so it forces no core info rescan and no contentless cores reload.
The warning now opens the menu's confirm box with named buttons instead of a list screen: its text in the box, then Cancel, Install and Trust, or Cancel, Load Once and Always Trust This Core. Cancel starts highlighted, Left/Up and Right/Down move between buttons, OK presses the highlighted one, Back and Info cancel, and the answer is logged. The box goes up for the pending request, waits while a Cancel/OK confirm is open, and a press on a box an older request put up chooses nothing. The list screen goes with its text rows and their 40-character wrap, the row labels and the handlers behind them; the strings the dialog shows stay. A classic confirm reads as Cancel and OK through the same getter, so the drivers can draw one button row for every confirm. For the named buttons it also says which pad icon each shows: Back on Cancel, OK on the others, dimmed unless highlighted. A tap whose press began on a box since replaced chooses nothing, a hover measured on a replaced box is dropped, and a confirm set over another box starts with nothing hovered.
Every confirm's buttons come from one getter: Cancel and OK for a classic confirm, the named buttons of the Security Warning with the highlighted one lit. RGUI draws them as one row. When three don't fit its narrowest layouts they stack one per line, the box widening to the widest button up to the framebuffer's width, and the box stays on screen. A button wider than the screen is lit where it shows. Ozone, XMB and MaterialUI lay the row out from the box's bottom edge, so it no longer drifts onto or under that edge as the text grows, as the first-run downloads prompt showed. XMB and MaterialUI placed it from the text's top, mirrored, about half a line further down per line of text. In XMB and MaterialUI a row wider than the screen no longer widens the box past it, and MaterialUI's row stops one margin inside each side of the screen; Ozone stops the box at the screen's width and packs from the left. The Security Warning's highlighted button is drawn with the cursor in Ozone and on the selection colour in XMB and MaterialUI. Its buttons carry their pad icons, and in XMB and MaterialUI Cancel and OK keep theirs. In Ozone every button keeps room for an icon, so nothing moves with the highlight, and icon and label are centred on the ink, in the cursor as drawn. The Security Warning harness now taps each button through RGUI under a scripted mouse, including a click that wakes the screensaver and a tap begun on a box since replaced.
The box is centred on its text, so the buttons' room only grows it downward: at scale 1.50 the Security Warning ran off the bottom in XMB and MaterialUI, and in Ozone on a short screen. A box whose bottom would leave the screen now moves up, never above the top. If it is still too tall, XMB first wraps it wider by the font's measured width; then the box is drawn as tall as the screen with the buttons at its bottom and the text cut back to the last whole paragraph that fits, or to a line ending in "..." when the first paragraph alone doesn't. A named row too wide for the box drops its icons; one too wide even then stacks its buttons one under another, each as wide as the text column, and a stack whose labels still don't fit drops its icons too. Text, buttons and hit boxes move together; boxes that fit are drawn as before.
The box can come up while a key is still down, mid-scroll or from the press that started the load. Raising it holds back what is down, as a menu open does, so a held direction's repeat can't walk the highlight to Always Trust; a press made with the box up acts at once.
Builds that trust the test signing key get a Test Signing Key toggle in Notification Visibility. Off hides the on-screen reminder; the log line saying the build trusts a test key stays on every launch. Release builds have no such setting.
Davey-Hughes
force-pushed
the
feat/core-signing-install
branch
from
November 15, 2026 06:08
691bdfd to
6551a42
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Install warning in Ozone on an AYN Thor (Install or Restore a Core with an unsigned core, a game running behind):
Important
This PR turns nothing on.
core_signing_keys.hstill ships with no keys, so no core is checked on any platform until a separate keys PR. libretro's buildbot signs only Android cores today, so that PR turns the checks on for Android builds first.Stacked on #20041: its 6 commits come first; review starts at commit 7.
Phase 2 of three checks cores at install. The core updater fetches each core's
.sigand installs only a download that passes. Backups keep their.sig; restores and Install or Restore a Core are checked too.A restore or file install that fails opens the Security Warning: the menu's confirm box with Cancel (highlighted first, Back icon) and Install and Trust (OK icon, dimmed unless highlighted). Only the user can answer it. Settings > Core > Check Core Signatures turns checks off after a confirm and is kept in the trust store, not
retroarch.cfg.HAVE_CORE_SIGNATURESdefaults on where cores load dynamically, the load check and test key off. Test-key builds (--enable-core_signing_test_key) remind on every launch for about 3 s; Settings > User Interface > On-Screen Notifications > Notification Visibility > Test Signing Key Notifications hides the reminder, and the log line stays.On every Android ABI the buildbot has no
.sigyet for daphne, fbalpha, ffmpeg, nicaiemu, pokketstation, qemu, race, test, vice_xcbm2 and vice_xcbm5x0 (some ABIs also miss amiberry, dolphin, mamearcade, mesens or play); once keys are in, the core updater refuses those.Also in this PR:
Known limits:
.sigdownload has no size cap.TL;DR: cores install only if their
.sigchecks out or the user trusts them; inactive until keys ship.Validation
core_signature,core_trust_runtime,core_install_check,core_backup(Makefile.sig),core_updater(Makefile.signed_install,staged_install,update_installed) andcore_warning: plain, ASan/UBSan, TSan, mingw-w64 under Wine.core_warning; Ozone, XMB and MaterialUI via xdotool on Xvfb.C89_BUILD=1on the touched objects; CI: 181 pass, 3 skip.Related Issues
None
Related Pull Requests
#20041 (phase 1; this PR is stacked on it)
Reviewers
@cscd98