ROS 2 EXPKEYSIG F42ED6FBAB17C654 apt update fix: ros2-apt-source

한국어로 읽기

What happened when I ran it

You run sudo apt update on a robot PC and the ROS 2 repository line ends with The following signatures were invalid: EXPKEYSIG F42ED6FBAB17C654 Open Robotics. The common answer is a one-liner that downloads the key file again. The current ROS 2 install guide does something else: it installs a small package called ros2-apt-source. I wanted to know what that package actually changes, what happens if you install it on a machine that was set up the old way, and how a machine that misses a key update behaves.

So I reproduced the apt side of the problem in the official ros:jazzy-ros-base container, offline, five times. Three results matter most:

  • The official image already ships ros2-apt-source. Its /etc/apt/sources.list.d/ros2.sources is a symlink, and grep -r walks right past it.
  • When the old ros2.list stays next to the new ros2.sources, apt stops with E: Conflicting values set for option Signed-By and exit code 100. This still happens if you edit the old line to point at the new keyring file.
  • A machine that missed a key extension but still had old index files printed EXPKEYSIG as a warning, and apt-get update exited with 0. A machine with no index files exited with 100 and is not signed.

Every result matched the predictions in the plan, which was written before the runs, and all five runs gave the same output.

The short answer

F42ED6FBAB17C654 is not a new key. Open Robotics has signed the ROS apt repository with the same key since 2019 and has only moved its expiry date. The error means the copy of that key on your machine is past the expiry date it carries. Today’s key expires on 2030-06-01 12:39:34 UTC.

ros2-apt-source doesn’t change the key either. It changes how the key and the repository config get onto your machine: apt manages them as a package, so a future key extension can arrive as a normal package upgrade. The package does not delete the files from the old manual setup, so you have to remove them yourself.

To fix an Ubuntu 24.04 (Noble) machine that fails right now:

# 1. Remove the old manual setup (this is what causes the Signed-By conflict later)
sudo rm /etc/apt/sources.list.d/ros2.list
sudo rm /usr/share/keyrings/ros-archive-keyring.gpg

# 2. Install ros2-apt-source from GitHub over HTTPS (1.3.0 was the latest release on 2026-10-18)
sudo apt update && sudo apt install curl -y
curl -L -o /tmp/ros2-apt-source.deb https://github.com/ros-infrastructure/ros-apt-source/releases/download/1.3.0/ros2-apt-source_1.3.0.noble_all.deb
sudo dpkg -i /tmp/ros2-apt-source.deb

# 3. Check that apt is clean again
sudo apt update

On Ubuntu 22.04 use ros2-apt-source_1.3.0.jammy_all.deb instead. The official install step looks up the latest release tag and your Ubuntu codename automatically; the commands above pin them so you can see exactly what gets downloaded. Check the releases page for anything newer.

If you only need the machine working again in the next five minutes, the old one-liner still works and nothing forbids it:

sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key -o /usr/share/keyrings/ros-archive-keyring.gpg
sudo apt update

That gives you the copy that expires in 2030, and on that date you’ll need to run it again on every machine.

Measured results

Environment. Image ros:jazzy-ros-base (ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca), ROS 2 Jazzy, Python 3.12.3, rclpy 7.1.12-1noble.20260902.053513, RMW rmw_fastrtps_cpp (default; not used, since this test exercises apt only), apt 2.8.3 and gpg 2.4.4 from the image, no network, limits 1 CPU and 1 GiB. The script experiment.py (sha256 9530eb822dcf7e6b278b456a81d53cab95f7f3fdeef3c3b73b571a36e8aa3398) ran 5 times. All 5 runs exited with code 0 and took 18.91 s to 19.43 s. All 5 runs produced the same output except for timestamps and the IDs of the test keys, which are generated fresh in every run. Predictions come from plan.md, written before the runs.

Part 1: what the official image contains

Check Predicted (plan.md) Measured, all 5 runs
dpkg-query -W -f '${Version}' ros2-apt-source not predicted 1.2.0~noble
/etc/apt/sources.list.d/ros2.sources symlink to /usr/share/ros-apt-source/ros2.sources, owned by ros2-apt-source symlink to /usr/share/ros-apt-source/ros2.sources, dpkg -S owner ros2-apt-source
Other files in sources.list.d — ubuntu.sources (regular file, no dpkg owner)
/usr/share/keyrings/ros2-archive-keyring.gpg fingerprint C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654, expires 2030-06-01 12:39:34 UTC, 1,167 bytes fingerprint C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654 (long ID F42ED6FBAB17C654), RSA 4096, created 2019-05-30T00:40:54Z, expires 2030-06-01T12:39:34Z, 1167 bytes, sha256 4a91c49af0d6f0016108b93698782b596c27ccd836937e18e0e36c3347dc602f
Key embedded in Signed-By: of ros2.sources — same fingerprint, identical to the keyring
Old ros2.list / ros-archive-keyring.gpg absent both absent
grep -rl packages.ros.org /etc/apt/sources.list.d/ 0 files (skips symlinks) exit code 1, 0 files
grep -Rl packages.ros.org /etc/apt/sources.list.d/ 1 file exit code 0, 1 file: /etc/apt/sources.list.d/ros2.sources

Part 2: old and new files together. An isolated apt setup (APT_CONFIG pointing sources, trusted keys, lists and status at empty directories under /tmp) ran apt-cache policy on five combinations. The new file is the image’s real /usr/share/ros-apt-source/ros2.sources.

Case Predicted (plan.md) Exit code First message
new ros2.sources only OK (0) 0 none
old ros2.list only (signed-by= key file path) OK (0) 0 none
old ros2.list + new ros2.sources Conflicting values set for option Signed-By 100 E: Conflicting values set for option Signed-By regarding source […] noble: /tmp/lab/conflict/ros-archive-keyring.gpg != -----BE
old line pointing at the package keyring + new ros2.sources conflict (path vs embedded key block) 100 E: Conflicting values set for option Signed-By regarding source […] noble: /usr/share/keyrings/ros2-archive-keyring.gpg != ----
the old line twice, same key file configured multiple times warning, not a conflict 0 W: Target Packages (main/binary-amd64/Packages) is configured multiple times in /tmp/lab/conflict/old_line_twice_same_key/sources.list.d/ros-copy.list:1 and /tm

Messages are cut off where the stored output cuts them. […] stands for the ROS 2 repository URL on packages.ros.org, which I left out here.

Part 3: seven machines, two key extensions. A throwaway EdDSA (ed25519) signing key with a 6-second lifetime signs a local file: repository. The repository also carries lab-apt-source, a .deb that contains the keyring, like ros2-apt-source does. Seven machines, each with its own isolated apt config: A1–A3 (manual key file), B1–B3 (package), and B_off (package, switched off during event 1). All seven start from the old manual setup, and that setup isn’t counted. Migrating a B machine counts as one human touch. In each event the operator extends the same key (same fingerprint) before the old copy expires, to twice the original lifetime, and publishes a new package version. A machines get the key file overwritten by hand, one touch each. B machines that are on run apt-get update, download the new version and unpack it with no human. After the old expiry time passes, all seven run apt-get update. A human repairs any machine that fails or shows EXPKEYSIG. The code counts every human action.

Measurement (n = 3 per group, k = 2 events) Predicted (plan.md) Measured, all 5 runs
Human touches, A group (manual) n·k = 6 6 (2 per machine)
Human touches, B group (package, always on) n·1 = 3 3 (1 per machine)
Human touches, B_off migration 1 + repair 1 = 2 2
Event 1: package upgrades finished before old expiry — yes; B1, B2, B3 upgraded, B_off was off
Event 1: apt-get update exit codes after expiry not predicted 0 on all 7 machines
Event 1: machines showing EXPKEYSIG B_off B_off only, using previous index files; exit code 0 after repair
Event 2: machines showing EXPKEYSIG — none; B1, B2, B3 and B_off all upgraded by apt, all 7 exit codes 0
Key copies on A1, B1, B_off after each event same key same fingerprint as the original key, same new expiry as the repository key

Stale key copy, with and without old indexes. A second test key (3-second lifetime) signed another repository. One machine ran apt-get update before expiry (exit code 0) so it had index files. A second, fresh machine had none. After expiry, the operator extended the key to 60 seconds and re-signed, but both machines still held the old copy.

Predicted (plan.md) Machine with previous index Machine without index
apt-get update exit code not predicted 0 100
EXPKEYSIG in output yes yes yes
“previous index files will be used” not predicted yes no
is not signed yes, on the new machine no yes
files in lists/ — 2 0
package candidate visible — yes no

Raw lines from run 1. B_off after event 1 (exit code 0):

W: An error occurred during the signature verification. The repository is not updated and the previous index files will be used. GPG error: file:/tmp/lab/lab/re
W: Failed to fetch file:/tmp/lab/lab/repo/dists/lab/InRelease  The following signatures were invalid: EXPKEYSIG C125E13F35D4DF59 HuntLab Lab Archive <lab@exampl
W: Some index files failed to download. They have been ignored, or old ones used instead.

The machine without an index (exit code 100):

W: GPG error: file:/tmp/lab/lab-stale/repo lab InRelease: The following signatures were invalid: EXPKEYSIG 2FF74373D4933CCB HuntLab Lab Archive <[email protected]
E: The repository 'file:/tmp/lab/lab-stale/repo lab InRelease' is not signed.

The image check:

{"part": "image", "grep": "grep -rl packages.ros.org /etc/apt/sources.list.d/", "exit_code": 1, "files_found": 0, "files": [], "t": 0.09}
{"part": "image", "grep": "grep -Rl packages.ros.org /etc/apt/sources.list.d/", "exit_code": 0, "files_found": 1, "files": ["/etc/apt/sources.list.d/ros2.sources"], "t": 0.09}

What EXPKEYSIG F42ED6FBAB17C654 actually says

Before apt downloads package lists from a repository, it checks the repository’s InRelease file. That file lists the index files with their hashes, and Open Robotics signs it with its private key. Your machine checks the signature with its copy of the matching public key.

  • F42ED6FBAB17C654 is the long key ID: the last 16 hex digits of the key’s 40-digit fingerprint, C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654.
  • EXPKEYSIG means the signature was made with that key, but the copy you have says the key has expired.
  • NO_PUBKEY F42ED6FBAB17C654 is a different problem: your machine has no copy of the key at all.

Expiry here is just a date written into the public key. When the maintainers want the key to keep working, they extend the date and publish the public key again. Everyone who still holds the copy with the old date gets EXPKEYSIG until they fetch the new one. That’s why this error comes back every few years, as you can see in the dates below.

Same key, three expiry dates

The public key lives in ros.key in the rosdistro repository. I downloaded it from three commits and read each one with gpg --show-keys:

ros.key commit Fingerprint Created Expires (UTC)
89b6383 (2019) C1CF…C654 2019-05-30 2021-05-29 00:40:54
8baf090 (2021) same same 2025-06-01 05:59:45
eb71c28 (2025) same same 2030-06-01 12:39:34

The first expiry caught everyone by surprise. Open Robotics explained in the 2021 incident post that the key had been issued with a two-year lifetime after a 2019 security incident and that the reminder to extend it was missed. Extending it meant publishing a new public key that every user had to download. That post said the extended key would last five years, but the key file from that commit expires on 2025-06-01, about four years later. The second expiry was announced ahead of time in the 2025 signing key migration guide, together with the new package.

The keyring shipped in the official Jazzy image matches the 2025 file exactly: same fingerprint, same 2030 expiry, and the same SHA-256 as the current ros.key.

What ros2-apt-source installs, and what it leaves alone

Old manual setup ros2-apt-source
Key file /usr/share/keyrings/ros-archive-keyring.gpg, written by you with curl /usr/share/keyrings/ros2-archive-keyring.gpg, installed and owned by dpkg. Note ros vs ros2 in the name.
Repository file /etc/apt/sources.list.d/ros2.list, one line with signed-by=/usr/share/keyrings/ros-archive-keyring.gpg /etc/apt/sources.list.d/ros2.sources, a symlink to /usr/share/ros-apt-source/ros2.sources. deb822 format, with the key block embedded in Signed-By: (22.04 and later).
How the key gets updated You download it again on every machine A new package version in the ROS repository, picked up by apt upgrade
How to remove it sudo rm the two files sudo apt remove ros2-apt-source, then apt update, apt autoremove, apt upgrade (docs)

The file list comes from the package’s install and links files and its rules. I also unpacked the eight Jammy and Noble .deb files from 1.0.0 to 1.3.0. Two things stood out:

  • No maintainer scripts. The control archive holds only control and md5sums. Nothing runs at install time, so nothing deletes your old ros2.list or ros-archive-keyring.gpg.
  • The key has never changed inside the package. All eight packages contain byte-identical keyrings, already the 2030 copy. So far, no key has ever been updated through a package upgrade. The automatic path exists by design and is documented, but it will first be used for real when the 2030 expiry comes around. Don’t assume it has been tested in production.

The README explains why a separate keyring file is kept even though the key is embedded in the sources file: so you can inspect the key without extracting it. That makes the expiry check a single command (see the checklist below).

Why the old ros2.list has to go

The migration guide says it plainly: remove the old repository files, or apt breaks with a key conflict. The measured table above shows what that looks like. apt treats Signed-By as a per-repository option, and when two entries for the same URI and suite give it different values, apt 2.8 refuses to continue (debmetaindex.cc L809–L824). It compares the values as normalized strings (L60–L94), not by which key they resolve to.

That explains the fourth case in the table. A tempting “fix” is to keep ros2.list and change its signed-by= to the new ros2-archive-keyring.gpg. The key really is the same, but a file path is not the same string as an embedded key block, so apt still reports a conflict and exits with 100. Delete the old line rather than editing it.

The conflict is also why the order of the fix matters. If you install the package first and leave ros2.list in place, the next apt update fails with a different error from the one you started with.

The quiet failure: EXPKEYSIG with exit code 0

This is the result I’d act on first. A machine that already has package lists from earlier keeps using them when the signature check fails. In this test, apt printed EXPKEYSIG and “previous index files will be used” as W: warnings and apt-get update still returned 0. The machine kept seeing packages, but from frozen lists.

What that means in practice (my reading, not something I tested on a fleet):

  • A script, Ansible task or CI step that only checks the exit code of apt-get update won’t notice. The robot keeps installing from an index that stopped updating on the expiry date, and it never gets newer ROS packages.
  • The first command of the official install step is sudo apt update && sudo apt install curl -y. On a machine with old lists, the && doesn’t stop anything.
  • A fresh machine or a fresh container build with no lists fails hard (exit 100, is not signed). That’s the version of the error most people search for.

If you monitor machines, grep the output of apt-get update for EXPKEYSIG as well as checking the exit code.

Is migrating worth it? Count the hands-on work

Take one “touch” to mean a person doing repository setup work on one machine. Say you have n machines that are already set up the old way, and k future key or repository changes.

  • Manual: every change needs every machine, so A = n × k.
  • Package: one migration per machine, then nothing, as long as each machine upgrades before its old copy expires. If m machines miss an update, each needs a repair: B = n + m.

With no missed machines, the saving is n × (k − 1):

  • 6 machines, 2 future changes: 12 touches vs 6.
  • 6 machines, 1 future change: 6 vs 6. Same count; the only gain is that nobody has to remember the command in 2030.
  • 6 machines, 0 future changes: migration costs 6 touches for nothing. This only applies to machines that already work. A new machine needs one setup either way, so for new installs the package costs nothing extra.
  • 6 machines, 1 change, and one machine was powered off during it: 6 + 1 = 7, which is worse than manual.

The lab run is the same formula with n = 3 and k = 2: 6 touches for the manual group and 3 for the package group, and the machine that was off during the first extension needed 2. The arithmetic doesn’t decide this for you. k is a guess about the future: so far there have been extensions in 2021 and 2025, with the next due in 2030. m depends on how often your robots are actually powered on and allowed to upgrade. A robot that sits in a cupboard for six months is exactly the B_off case.

One safety note that comes from general practice, not from this test. On a robot PC, apt upgrade can also upgrade ROS packages, drivers included. Don’t run it while the robot is moving. Stop the robot (or put it in e-stop) first, and bring your nodes back up afterwards to check them.

Checking a machine after migration

These are read-only checks. The test only ran some of these exact commands, and I’m not showing expected output for the others.

# Old and new files side by side? You want ros2.sources -> /usr/share/ros-apt-source/ros2.sources and no ros2.list
ls -l /etc/apt/sources.list.d/

# Every file that points at the ROS repo. Use -R: plain -r skips the ros2.sources symlink
grep -Rl packages.ros.org /etc/apt/sources.list /etc/apt/sources.list.d/

# Is the file owned by the package, and which version is installed?
dpkg -S /etc/apt/sources.list.d/ros2.sources
dpkg-query -W ros2-apt-source

# Fingerprint should end in F42ED6FBAB17C654, expiry 2030-06-01
gpg --show-keys /usr/share/keyrings/ros2-archive-keyring.gpg

# Look for EXPKEYSIG, "Conflicting values set for option Signed-By" or "configured multiple times"
sudo apt update

The grep line deserves the warning. The GNU grep manual says -r skips symlinks it meets while recursing and -R follows them all. On the official image, grep -rl found no ROS repository file at all, even though one was configured. If you audit machines with grep -r, migrated machines look as if they have no ROS repository.

Docker images

Two points for container builds:

  • The official ros:jazzy-ros-base image I tested already contains ros2-apt-source 1.2.0 and the 2030 key. If your Dockerfile also adds the old ros2.list line from an older tutorial, you’ve set up exactly the old-and-new case from Part 2. I didn’t build such a Dockerfile; this follows from the image contents plus the conflict test.
  • The accepted answer on the original Robotics Stack Exchange question warns that Docker’s layer cache can keep an old key: the command that downloads the key hasn’t changed, so the layer isn’t rebuilt. Rebuild without the cache after a key extension.

Limits of this test

  • It is an offline reproduction of apt behavior in a container. It never contacted packages.ros.org and never used the real RSA key for signing. The repository is a local file: repo signed with a throwaway ed25519 key. HTTP transport, proxies and mirrors weren’t tested.
  • The package path is simulated. The B machines used apt-get download and dpkg-deb -x to unpack the new keyring, not apt upgrade or unattended-upgrades. dpkg registration and package ownership of the lab .sources file weren’t reproduced. When a real upgrade actually arrives on a real machine wasn’t measured.
  • Key lifetimes were squeezed into seconds. The test counts human touches, not how long each takes or how often people make mistakes.
  • The conflict test runs apt-cache policy, which parses the config. It doesn’t download anything.
  • Only Noble (Jazzy image) was run. The Jammy packages were inspected but not run. The Focal (20.04) package uses a key path instead of an embedded key, per its build rules, so the conflict case may differ there; I didn’t test it. Debian and RHEL are out of scope.
  • No robot, simulator or hardware was involved.

Sources and reproduction