镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

core signatures: install checks and the Security Warning - #20085

Draft
Davey-Hughes wants to merge 27 commits into
libretro:masterfrom
Davey-Hughes:feat/core-signing-install
Draft

Davey-Hughes wants to merge 27 commits into
libretro:masterfrom
Davey-Hughes:feat/core-signing-install

Conversation

@Davey-Hughes

@Davey-Hughes Davey-Hughes commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Description

Install warning in Ozone on an AYN Thor (Install or Restore a Core with an unsigned core, a game running behind):

Security Warning in Ozone over a running game
XMB (Thor) MaterialUI (desktop)
Security Warning in XMB Security Warning in MaterialUI
RGUI (desktop) Load warning, Ozone (built with the load check)
Security Warning in RGUI Load warning in Ozone

Important

This PR turns nothing on. core_signing_keys.h still 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 .sig and 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_SIGNATURES defaults 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 .sig yet 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:

  • Ozone, XMB and MaterialUI place a confirm's buttons from the box's bottom; master's overrun it on long confirms.
  • A confirm that would leave the screen moves up; RGUI draws its buttons in one row.
  • A confirm that fails to open is disarmed.

Known limits:

  • Small screens: a box drops its last paragraphs, a wide row stacks, and XMB at scale 2.75+ fits no warning text. As on master, Ozone shows no confirm under ~420 px wide and MaterialUI's spills in portrait.
  • Other message boxes shown during a confirm draw its buttons too.
  • A warning queued behind the Check Core Signatures Off confirm still asks after Off.
  • Fails closed: no update for a symlinked core once keys are in (even with checks off), a core with an undeletable file in staging, or (with phase 3's fs-verity) an Android verity core.
  • The .sig download has no size cap.

TL;DR: cores install only if their .sig checks 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) and core_warning: plain, ASan/UBSan, TSan, mingw-w64 under Wine.
  • Button taps: RGUI in core_warning; Ozone, XMB and MaterialUI via xdotool on Xvfb.
  • Default, no-signatures, load-check, test-key and no-threads builds; C89_BUILD=1 on the touched objects; CI: 181 pass, 3 skip.
  • Desk check on Linux: test-key build against a local buildbot.

Related Issues

None

Related Pull Requests

#20041 (phase 1; this PR is stacked on it)

Reviewers

@cscd98

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
Davey-Hughes force-pushed the feat/core-signing-install branch 2 times, most recently from ddaa8c9 to 183cd86 Compare November 14, 2026 16:21
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
Davey-Hughes force-pushed the feat/core-signing-install branch from 691bdfd to 6551a42 Compare November 15, 2026 06:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant