Security

What's protected: what runs as lnk and Link's plugins is what Link released, a plugin from outside Link is what its maker released under the key you trusted, and relay servers run only signed builds.

Which program runs

lnk <group> runs lnk-<plugin> from lnk's own folder first, then from the PATH, the way git does. So whoever can write to a folder on the user's PATH can already replace what lnk tunnel open runs. Empty and relative PATH entries, such as the current folder, are skipped: lnk drops them from the PATH it runs with and hands its plugins, so neither runs a tar or systemctl from the folder it was started in.

What installs plugins

  • lnk up, lnk plugin add, a missing plugin's install offer and lnk upgrade install Link's plugins only from a release whose SHA256SUMS is signed by the keys built into lnk, and check each archive against it.
  • lnk plugin add takes the release of lnk's own version, so plugins and core match.
  • The install offer asks first, and only in a terminal.
  • install.sh installs lnk alone.
  • Link's plugins come only from Link's own releases. Prebuilt programs exist for macOS (Apple Silicon, Intel) and Linux (x86_64, arm64) only; other platforms build from source.
  • lnk plugin add <url> takes a GitHub repository's latest release (https://github.com/<owner>/<repo> only), its SHA256SUMS signed with that repository's own key in the link-release namespace, and each archive checked against it. Link's keys never vouch for it.
  • The first add trusts the key the release itself offers (release-key.pub), after showing its fingerprint and asking, or --yes. Whoever controls the repository on that first day is trusted: compare the fingerprint with one its maker publishes elsewhere.
  • lnk pins the repository and the key in ~/.config/lnk/plugins.toml (600). Every later add and lnk upgrade of it verifies against the pinned key alone: a release signed by another key is refused, and a key is only replaced by remove, then add again.
  • It must answer the harness contract's version before it's installed, so only a harness adapter is added this way, and its name can't be one of Link's plugins'.
  • An outside plugin runs as you, outside any sandbox, like any program you install: the harness plugin asks it what to run, and lnk asks its version. Link's sandbox confines the harness it installs and runs, not the adapter's own answers.
  • On a box, lnk agent move adds it with --key, the key this computer pinned, so the box trusts nothing the release offers.
  • lnk upgrade, and add again (as a box does on every move), take its latest release and install it only when it is newer than the one installed; one whose version, or the installed one's, can't be compared is refused. A release whose signed VERSION file says it is no newer is neither downloaded nor run.
  • A SHA256SUMS line naming anything but letters, digits, ., _ and - is dropped, so the names a release not yet trusted lists reach the terminal only as plain text.

Uninstall

  • lnk uninstall deletes only inside ~/.config/lnk and ~/Link. A path a plugin names outside them, with .. or relative, is left out. A path whose folder leads out of them through a link is left out too.
  • Before asking, it lists every path each picked part deletes.
  • Before asking, it runs only Link's own plugins. Another program named lnk-* in lnk's folder is listed, then told to uninstall and removed once you say yes. It's never asked to delete a part.
  • lnk plugin remove keeps a plugin that fails to stop what it runs, and shows what the plugin said, so its services never outlive the program that stops them.

Releases and updates

Signing

Every release's SHA256SUMS is signed with the release key (ssh-keygen -Y sign, namespace link-release). The public key is in src/shared/release-signers. It is built into lnk and the relay, embedded in install.sh and the relay's bootstrap.sh, which installs it on servers at /etc/link/release-signers; a test checks the copies match. No release carries the key.

Who checks

The relay updater, the relay's bootstrap.sh, lnk upgrade and install.sh install nothing without a valid signature, so someone with the releases repo or its token can't push code.

  • lnk upgrade uses its own SSHSIG verifier, link_plugin::release.
  • lnk upgrade --check, which lnk up and lnk agent status start in the background at most once a day, reads the latest release's VERSION only through its signed SHA256SUMS, so a notice can't name a version Link didn't release. It asks the releases host for three small files and sends nothing but lnk's version, as its user agent; LNK_UPDATE_NOTICE=off turns it off.
  • install.sh uses ssh-keygen, and refuses to run without it unless LNK_INSECURE=1.

The installer

curl https://local.link/install.sh | sh gets the script from the relay's own signed binary, not from the releases repo, so the releases repo can't swap it.

Downgrades

  • lnk upgrade refuses a release older than itself unless that version is requested, and refuses a version it can't parse.
  • lnk upgrade <version> installs only that version: the lnk it downloads must report it, so an older signed release moved under the tag is refused, with or without a VERSION file.
  • Releases carry a VERSION file inside the signed SHA256SUMS. lnk upgrade checks it against the version asked for and the lnk it holds; lnk plugin add checks it against its own version.
  • install.sh checks the VERSION file against LNK_VERSION, and the lnk it installs must report that version. It installs latest only from a release with a VERSION file, and, when the relay served the script, never one older than the relay (the relay fills in its own version). Right after a release, the relay can be up before its release is: the script then says to try again in a few minutes.
  • lnk plugin add also runs each plugin's --version, which must match, and must answer when a release has no VERSION file.
  • The relay updater, link-relay-update, refuses a relay whose --version (inside the signed binary) is older than the installed one's, or that it can't read, and remembers it so later runs skip it. It also refuses one older than the floor bootstrap keeps in /etc/link/min-relay-version, the operator's checkout's version, whether or not a relay is installed yet. LINK_ALLOW_DOWNGRADE=1 is the operator's way to roll back on purpose.
  • The relay updater still installs a same-version build, since relay-latest is rebuilt on every merge to main without a version bump. So an earlier build of the same version can still be put back; nothing older than the installed relay or the floor can. A server bootstrapped without --min-version and with no relay yet has no floor, and takes any signed release.

Download limits

Downloads give up after 15 s connecting or 60 s without data. They stop past 64 KiB for SHA256SUMS, its signature and VERSION, and past 200 MiB for an archive.

CI

  • Workflow actions are pinned to commit hashes.
  • Jobs that run third-party actions get no secrets.
  • Signing and publishing run in separate jobs that use only actions/*.
  • Default permissions are read-only.
  • A publish fails if the signature doesn't verify against src/shared/release-signers.

Key rotation

How to rotate it: Rotate the release signing key.

Gaps

  • The first lnk plugin add <url> trusts whatever key the repository serves that day; nothing in lnk vouches for an outside plugin's key.

  • An outside plugin can go back to an older release signed by the same key only if its maker publishes it as the latest; lnk upgrade refuses a version older than the installed one.

  • An outside plugin's release without a VERSION file, or whose VERSION says newer than its program does, has its program run (contract, then --version) before it's refused as older. It is signed by the pinned key, so this runs nothing its maker couldn't run already.

  • The release key is a GitHub Actions secret; see Known limitations.

  • A copy of install.sh the relay didn't serve, such as the one in each release or in a checkout, has no relay version to compare with. So latest pointed at an older signed release that has a VERSION file is installed.