Security

How Link protects the people who use it, what it doesn't protect against, and where each part's details are.

Report a vulnerability privately (Report a Vulnerability): GitHub's "Report a vulnerability" button, or dev@local.link. Never in a public issue.

Each part's guarantees and gaps:

Who is involved, and what they can do

visitor ──HTTPS──▶ Caddy ──HTTP──▶ link-relay ◀──WSS── lnk (user's machine) ──HTTP──▶ localhost:<port>
  (anyone)          └──────── relay server ────────┘      (outbound only)

Telegram, Slack, Discord, WhatsApp, Signal sender ──▶ Telegram, Slack, Discord, WhatsApp, Signal ──▶ harness (sandboxed, on the user's machine) ──▶ model provider, the internet
                                  lnk bucket (user's machine) ──HTTPS──▶ the user's clouds
                         lnk cloud, lnk box (user's machine) ──HTTPS, SSH──▶ the user's clouds, their boxes
PartyTrusted withMust not be able to
Operator, who runs the relayTunnel traffic, users' tokens and accountsReach the user's machine beyond the exposed port, push code, see agents, buckets, clouds or boxes
User, who runs lnkTheir own tunnels, agent, clouds and boxesTouch another user's tunnels, or exhaust the relay
Visitor, anyone on the internetWhat the user chose to exposeGet past a tunnel's sign-in, reach other ports, stall others, exhaust the relay
The harness, maybe steered by a prompt injectionIts home, the files folder, the model key, the bot, its permissionsRead other files or Link's settings, write elsewhere, reach other servers, outlive the sandbox
A chat sender on Telegram, Slack, Discord, WhatsApp or SignalNothing, unless they're the paired userTalk to the harness
A plugin from outside Link: a harness's adapter, from its own repository, signed by its own keyRunning as the user, like any program they installInstall an update not signed by the key the user trusted, or take one of Link's plugins' names
Vendor installers: a harness's own, rclone's releasesInstalling their programWrite outside the harness's home, or change rclone
Cloud providers, and anyone with the account or bucketThe user's files and boxesRead --encrypt files, make clean lose a file, reach the user's machine
Release pipeline: GitHub Actions, the public woodpav/link-releases repoPublishing buildsGet unsigned code onto servers or users' machines

In more detail:

  • Operator. TLS ends at Caddy, so the relay sees tunnel traffic in plaintext. The operator can't reach anything on a user's machine but the port that user exposed, and can't push code to users or servers without the release signing key. A user's agent, buckets, cloud accounts and boxes never touch the relay.
  • User. A user can't answer for another user's tunnels, read their traffic, or set cookies for them.
  • Visitor. A visitor can't get past a tunnel's password or GitHub sign-in.
  • The harness may be steered by a web page, a message or a file it reads. It can't read the user's other files, SSH keys, browser data or Link's settings. It can't write outside its home and the files folder, reach other servers on the machine, or outlive the sandbox through a background service of its own.
  • A chat sender is refused unless they are the user or member id paired with the bot or app.
  • Vendor installers run sandboxed. rclone is installed only if its checksum matches the one pinned in Local Link's source.
  • Cloud providers can read files not pushed with --encrypt. They run the user's boxes, and so everything on one, its own lnk login included. They can't make clean delete a file here that has no good copy, short of deliberately forging MD5 checksums as the provider; on S3, clean reads back any copy whose MD5 its uploader set (what clean guarantees). A box holds no key to the user's own machine, and is never handed the user's SSH agent.
  • Release pipeline. Nothing reaches servers or users' machines unless it's signed with the release key. The key itself comes from Local Link's source, never from a release: built into lnk, embedded in install.sh, and on servers installed by bootstrap.sh, which the operator runs from their own checkout (what a server installs).

The design addresses:

  • anonymous internet traffic;
  • a hostile visitor aimed at someone's tunnel;
  • a hostile logged-in user running a modified agent;
  • a hostile or compromised relay, as seen from a user's machine;
  • a compromised releases repo or its token;
  • a harness turned against its user by what it reads;
  • a stranger messaging the user's bot;
  • a compromised harness installer;
  • a cloud that loses or corrupts copies.

TLS protects every connection except loopback against a network attacker between the parties. Other local users on the same machine are kept out of Link's files (mode 600 and 700) but can see command lines, so secrets are asked for or read from variables, never taken as flags where that can be avoided.

Secrets in the Keychain

On a Mac, Link keeps its secrets in the login Keychain instead of its settings files: the relay token, each agent's model key and its channels' secrets, cloud accounts' secret keys, and the cloud keys and encryption passwords lnk bucket writes. So they aren't in the plain files that backups, dotfile syncs and copies of ~/.config carry.

  • Through Apple's security tool. Items are generic passwords of the service link, one account each: relay-token, agent/<name>/model-key, agent/<name>/<channel>-<field> (each field a channel's plugin says is secret; a Telegram bot's token is agent/<name>/bot-token), cloud/<account>/<variable>, bucket/<section>/<key>. security makes them, so each item trusts it and reading one never asks. Link's programs aren't signed by Apple, so an item they made would ask again after every upgrade. In exchange, any program of the user's can read an item through security, as it could read the file: the Keychain doesn't keep one of the user's programs from another.
  • No secret on a command line. Secrets reach security on its stdin, hex-encoded, and each is read back after it's saved. If the Keychain refuses one, it stays in the file, with a warning.
  • Moving in. A secret still in a file from before moves on first use. Settings files record which secrets are in the Keychain: accounts.toml's keychain, and rclone-keychain.toml.
  • Read only when used. An agent's settings file keeps its Telegram bot's id, the token's public first part, so lnk agent list and lnk agent connect name every agent's bots without reading their keys.
  • Moving out. An agent's items go when it leaves the machine, an account's when it's disconnected, and a bucket's with its section. lnk uninstall removes only those of the parts you pick. It lists Link's items by name with security dump-keychain, never their secrets.
  • Not everywhere. Linux and boxes have no Keychain: secrets stay in files (600). LNK_KEYCHAIN=off keeps them in files on a Mac too, and a config file named with LNK_CONFIG keeps its own token.

Privacy and logs

  • The relay's logs hold agent connections and disconnections (with how many requests the relay gave up on while connected, and, once its token is checked, the lnk version, at most 32 characters of letters, digits, ., - and +), and logins with the username. Never anything per public request: no bodies, headers, paths or visitor addresses, and never tokens or passwords. Caddy keeps no access log. The conventions make this a rule, and the terms page promises it.
  • In the relay's memory only: connected tunnels (name, the agent's address, lnk version, request count); tunnel passwords and GitHub lists; visitor addresses for rate limits and password-guess limits, up to a day; sign-ins waiting on GitHub (the tunnel, the page, the visitor's address), up to 10 minutes; web logins waiting for their approval (the starting address and device's name), up to 10 minutes, and then for a machine signed in to confirm them, up to 10 more; confirmations given ahead (lnk box start), up to 10 minutes; and share links' wrong-password counters. A visitor's GitHub username goes into their sign-in cookie, not onto the relay's disk or into its logs.
  • On the relay's disk, in its state folder and its backups: accounts (GitHub username and id, device name, the token's hash), blocks, and share links (who made each, until when, its files' names, sizes and signed URLs, its password's hash). A share is dropped once it expires. A backup keeps it until the backup is deleted, and its URLs expire with it.
  • The local app sees visitors' addresses in X-Forwarded-For.
  • The website's waitlist (/waitlist) embeds a form from a form service, Tally, which keeps what people enter: an email and answers. Nothing about it reaches the relay or the website's own server; the terms page says so.
  • On the user's machine and nowhere else: the harness's log (what it prints), the harness's own state in its home, the bucket manifests (every file's name, size and which cloud holds it), and the cloud accounts and boxes. lnk sends none of them to the relay or to Local Link. The harness talks to its model provider and to Telegram itself, and lnk bucket and lnk box talk to the user's clouds. lnk model serve logs nothing per request. The sandboxes' proxies log where each call went, never what it carried (lnk sandbox log).

Known limitations

Each gap is followed by what limits it.

  • The operator can read all tunnel traffic, since TLS ends at Caddy. It's needed to route by hostname and check passwords at the relay.
  • The release key is a GitHub Actions secret, so repository admins, whoever can merge to main past its required reviewer, or a compromised first-party action, could sign. The jobs that sign run only from main, in the release environment, whose secrets they are. The pinned third-party rust-cache action runs in the build jobs, so a malicious pinned commit could alter what then gets signed, and so could a crate's build script, which runs in them. Limited by: actions are pinned and crates locked; signing runs in its own job with only actions/*, and signs only the build jobs' artifacts, after checking they hold exactly the expected files; the harnesses' installers, unpinned, run only on pull requests, never in a release's run.
  • local.link isn't on the Public Suffix List (the draft entry). Tunnels on one relay share a registrable domain for cookies. Limited by: host-only cookies cover the most direct abuse.
  • Many addresses can use up a --auth tunnel's shared wrong-password allowance, so new visitors wait. Limited by: visitors who already logged in are unaffected, and --github tunnels have no such allowance.
  • --github tunnels let in browsers only: a program needs a browser's sign-in cookie. Programs use --auth.
  • Some secrets are in files. On Linux and boxes, all of them (mode 600). On a Mac: the harness's .env (its channels' tokens, but for Telegram's when it's on the wire, and its model key unless it's on the wire), a wired key or bot token for the proxy, Link's SSH key for boxes, and sign-ins rclone saves itself. Keychain items are readable by any program of the user's through security. Limited by: they're readable only by the user, and secrets reach adapters on stdin and cloud CLIs in their environment, never on a command line.
  • A plugin from outside Link runs as you. lnk plugin add <url> trusts the key its repository offers the first time, after showing it and asking, and the adapter's own answers run outside the sandbox. Limited by: only a harness adapter is added, every later release must be signed by the pinned key, and the harness it installs and runs is in the sandbox like any other (Plugins Security).
  • A harness's installer may fetch some things unpinned, such as a package's dependencies resolved at install time. Limited by: its adapter pins and checks the installer and the harness's own code, and everything runs over HTTPS, inside the sandbox, able to write only the harness's home, reaching only the hosts its adapter lists (a registry, GitHub) through its proxy.
  • One connected cloud key reaches both buckets, your files' and your agent's. Limited by: the buckets are separate, and the agent's is always encrypted.
  • Link's SSH key for boxes has no passphrase and reaches every box. Limited by: it's in a 700 folder, readable only by the user, and a removed box's host key is forgotten.
  • A box's SSH port is open to the whole internet. Limited by: it's key-only (Ubuntu's cloud images refuse passwords), and nothing else is open.
  • A new box's host key may be trusted on first contact (accept-new) when its console doesn't show the host keys within 5 minutes, or its adapter can't read it. That contact then trusts the network between you and the cloud. Limited by: usually the keys are read from the console and pinned first; otherwise the key is pinned after, in Link's own known_hosts.
  • With lan, a harness reaches more than it should. On macOS it reaches every localhost port through ::ffff:127.0.0.1, and a server listening on every address through the Mac's own address. On Linux it reaches every localhost port. See the harness section. Limited by: lan is off by default, and an agent without it has no network but its filtering proxy, which closes this. lnk agent <harness> check reports it as open.
  • A harness's own ports are on a Mac's loopback, where every user and program of the Mac can reach them, and a channel daemon there that asks for no credential would let them send as the agent. Limited by: each adapter keeps its harness's daemons on a unix socket in its home or behind a relay that asks for a secret, where its harness lets it, and says on its security page what it can't; on Linux behind its proxy, those ports stay in the sandbox's own network.
  • A program can take an agent's port between the start's check that it's free and the harness's bind, or while the harness's service restarts. Limited by: a harness whose adapter says proof proves it holds its endpoint's key before Link's channels send it the key or a message (Agents Security); an older adapter's endpoint is sent them unchecked.
  • --auth is HTTP Basic only, while OpenAI SDKs send Authorization: Bearer <key>. SDK users set the Basic header themselves; curl -u works.
  • Login phishing, before a first machine. Someone can start lnk auth login and trick a person into approving it. Once the account has a machine signed in, that gets them nothing: a new login also waits for one of those machines to confirm it (lnk auth approve), shown the device and the address that started it, with a warning when that's another network (Tunnels: accounts and tokens). What's left: an account's first login, before it has a machine to ask, and a person tricked twice, in the browser and on their own machine. With the client secret, the relay's approval page warns when the login came from another network; without it (device flow), only the invite list limits a first login, which is why * needs the secret.
  • One relay, holding tunnel state in memory. Limited by: servers are disposable and rebuild in minutes.
  • Ending a share link (lnk bucket unshare) doesn't end the signed URLs already handed out. Limited by: they expire with the share, in at most 7 days, and the cloud's own controls (deleting the object, or the key that signed it) end them sooner.
  • Share links come only from S3 storage (aws, s3). Other clouds' files are refused, never shared with a link that doesn't expire.
  • A question asked on a terminal can be faked. With no desktop, the sandboxed program shares the terminal and can write over the question. Limited by: the code typed back allows only the call that asked; with a desktop it's a dialog, and lnk sandbox asks shows a waiting question as it is.