# Security

What Ovenlight protects, how, and where its limits are.

## Threat model

Ovenlight is built so that:

- **A guest gets one app, not your computer.** A guest's phone joins your tailnet, but
  can reach only the nodes of the apps it was invited to, on port 443, and sees none of
  your devices unless one of your own rules lets a device reach guest devices.
- **Nobody else in your tailnet gets in.** The connector admits the owner and guests who
  claimed an invite to that app, and answers 403 to everyone else, including people you
  share a node with through Tailscale.
- **Websites can't act through an admitted device.** Identity comes from the device, not
  a cookie, so any page open on an admitted device could otherwise act as that device.
  The connector refuses such requests (see [The proxy](#the-proxy)), except top-level
  link navigations, which is why an app's GETs must
  [change nothing](app-contract.md#make-get-change-nothing).
- **The app can trust who is calling**, as long as it listens only on `127.0.0.1`, reads
  each request body in full, and guards requests that reach it straight: it checks their
  `Host`, sends no CORS headers, and gives a request without identity headers the owner's
  rights only from its own pages
  ([app-contract.md](app-contract.md#guard-requests-that-come-straight-to-the-port)).
- **The connector changes your tailnet policy only on your yes.** It edits the policy
  only when you make an app shareable, unpublish a shareable one or run `setup-sharing`,
  and only after it shows the exact change and you type `yes` in a terminal
  ([The policy change](#the-policy-change)).
- **Only a signed release updates the connector.** It installs only a release that an
  index signed with one of its built-in release keys names and checksums, so the download
  server can hold updates back but can't choose what runs. Whoever can sign a release,
  or get code onto master and through the Mac that builds it, can still run code on your
  computer ([The release keys](#the-release-keys)). `ovenlight update off` opts out
  ([Update](connector.md#update) has what that means).

Out of scope: anything running as you on the computer, a coding agent with a shell
included, can do what you can. On Windows, that includes administrator rights
([On the computer](#on-the-computer)). A tailnet admin can change the policy, and an app
that listens on a LAN address can be reached around the connector. The computer's other
accounts can reach an app on `127.0.0.1` directly too, so they must belong to people you
trust. And every device signed in to Tailscale as you, such as a server you keep on or a
NAS, reaches your apps as you, along with everything on it: its other accounts and its
containers included. Their other accounts must belong to people you trust too, and run
only containers you trust on those devices.

The connector's nodes send logs to Tailscale and may open a port mapping on your router:
[What goes to Tailscale](#what-goes-to-tailscale) has the details.

## Two locks

Every guest request passes two independent checks:

1. **The tailnet policy.** Each shareable app has its own pair of tags. A guest device
   carries `tag:ovenlight-guest-<slug>` for each app it holds, and the policy lets that
   tag reach only `tag:ovenlight-app-<slug>` on port 443. So a guest's phone reaches only
   the apps it was invited to, whatever the connector does.
2. **The connector's WhoIs check.** For every request, the connector asks the tailnet who
   the calling device is. The owner is an untagged device of the owner's login: the one
   recorded by `publish --shareable`, or else the user who owns the app's node. A guest
   is a device tagged as a guest of this app, with an active claim on it. Anything else,
   unknown included, fails closed.

The admin API on port 8443 is owner only by the same WhoIs check, and the policy closes
it to guests as well.

## The policy change

`ovenlight publish --shareable` edits your tailnet policy file. It first names the local
port guests will reach. While no owner is recorded, it then asks one line:

```
Owner: you@example.com, guests see you as "You". Type yes to record:
```

The owner is the login behind your app nodes, taken from the running connector;
`--owner` and `--owner-label` set it instead, and `--owner` is needed when the connector
isn't running. The owner is recorded only once the policy change is applied.

It then reads your policy file and devices, prints the exact diff and a safety report,
and changes nothing until you type `yes`. It asks in a terminal only; there is no
`--yes`. The safety report names every rule that lets one of your devices reach guest
devices. After your `yes` it reads the device list again, and changes nothing if the
plan would now be different.

Only one connector per tailnet can share apps. Each keeps its own guest records, while
tags and a guest's phone reach across the tailnet, so two would mix up each other's
guests. `publish --shareable` refuses while a device in the tailnet carries an app tag
and isn't one of this connector's own app nodes, which it knows by the node ID in its
saved state. That includes another computer's node for an app with the same slug, and a
node this computer left behind when its saved state was lost. The refusal names the
device and says what to do: delete it in the admin console (Machines) if nothing runs it
any more, or make apps shareable on the connector it belongs to. The list of unclaimed
phones shows only guest devices of this connector's apps.

### What it adds

- `tagOwners` for `tag:ovenlight-app-<slug>` (the app's node) and
  `tag:ovenlight-guest-<slug>` (guests invited to it), owned by `autogroup:admin`.
- One rule letting `tag:ovenlight-guest-<slug>` reach only `tag:ovenlight-app-<slug>` on
  443.
- The app tag in your own rule letting you reach the app nodes on 443 and 8443 (a tagged
  node no longer counts as your own device). The first shareable app adds that rule.

Ovenlight writes new rules only to `grants`, next to your `acls` if your file uses those
(Tailscale applies both). It never deletes a guest rule you added a key to (such as
`srcPosture`).

Tag names are compared without regard to case, as Tailscale reads them in lowercase, so
a tag spelled in other case counts as the same tag.

### What it narrows

On a tailnet that still allows everything (`"*"` to `"*"`), it narrows that rule to
`autogroup:member` reaching `autogroup:member`, so guests neither inherit it nor see your
devices (a device you can reach shows up as a peer to it). It narrows no other rule on
its own.

### What it refuses

It stops, and prints the edit to make by hand, when:

- narrowing the allow-all rule would cut off something you have: tagged devices, rules
  with tag sources, subnet routes or exit nodes;
- any other rule has a `"*"` source;
- a rule would let guest devices reach more than their apps;
- a source can't be classified (IP sets, unknown autogroups);
- `nodeAttrs` could give guest devices more than client-side settings (NextDNS, App
  Connector domains and the like);
- `autoApprovers` could approve a guest device, or App Connectors could run on one;
- a guest device could take on other tags;
- anyone but admins may apply an Ovenlight tag;
- a key in the file isn't plain ASCII, since a JSON reader may take it for another key.

A destination it can't classify counts as reaching guests in the report.

### How it writes

It edits the file in place, so comments and formatting stay, and running it again
changes nothing. It validates the new policy with Tailscale, writes with `If-Match` (a
concurrent edit makes it stop), keeps the old policy as a backup in the state directory
(`policy-backups/`), reads the policy back,
checks that your app nodes still see your devices, and rolls back to the backup if any of
that fails. Without the connector running, only the read-back is checked. A write whose
answer is lost counts as applied only if the policy reads back as written. The rollback
is conditional on its own write, so if someone edited the policy in between, it stops and
leaves their edit alone.

### Later changes

- `setup-sharing --rollback` restores the last backup, unless the backup would let guest
  devices reach more than their apps while a guest device or an open invite exists.
- `setup-sharing --owner <login>` changes the recorded owner. The new owner gets a rule
  of their own letting them reach the app nodes, and the earlier owner's rule stays as
  it is, with a note in the plan; remove it in the admin console when they no longer
  need it. The connector's identity check turns away anyone but the recorded owner and
  guests, whatever such a rule allows.
- `setup-sharing --remove <slug>` takes an unshared app's tags and rule out of the policy.
  Unpublishing a shareable app offers (after `yes`) to do the same, unless the connector
  didn't confirm the unpublish in time; it then says to run `--remove` later. That policy
  change is refused while a rule Ovenlight doesn't manage, such as an earlier owner's or a
  guest rule you added a key to, still uses the app's tags.
- Whenever the connector loads its config or syncs, it removes the guests of any app the
  config no longer lists (giving "the app was unpublished" as the reason) and cancels its
  open invites. An empty config removes them all, while a missing `config.json` serves no
  apps and keeps every guest until a config exists. So an app unpublished while the
  connector was down loses its guests when the connector next starts. `unpublish` says
  whether its guests are gone already or go later.
- Until the connector has removed an unpublished app's guests and open invites,
  publishing the same slug again is refused, from the terminal and the MCP server alike,
  and `publish --shareable` refuses before it asks anything: start the connector (or wait
  for it) and run the command again.
- Unpublishing a shareable app also deletes its node from the tailnet with the stored
  API credential, and its node state on the computer, even when the connector didn't confirm
  the unpublish. Without a working credential the node stays, offline, and `unpublish`
  says to delete it in the admin console (Machines): until then, no other app can be made
  shareable.

### Tagging the app's node

After the policy change, `publish --shareable` turns the app's node into a
`tag:ovenlight-app-<slug>` node: it mints a short-lived tagged key, deletes the old node
from the tailnet (freeing its name) and logs a new one in under the same name. A new app
published with `--shareable` starts tagged. There is no command to undo it.

Until the node carries the tag, `share` refuses the app; if tagging failed, run
`publish --slug <slug> --shareable` again. The same goes for a shareable app whose node
loses its login: `status`, `doctor` and the log say to run it, rather than showing a login
link, which would sign the node in untagged.

## The API credential

`ovenlight auth set` reads the secret from standard input, never the command line. It
takes a Tailscale API access token, or an OAuth client (`--oauth-client-id <id>`, then
paste its secret) with the scopes `policy_file`, `devices:core` and `auth_keys`. A
client that also has `feature_settings:read`, which Ovenlight no longer uses, works the
same. An OAuth client can only apply the tags it was given, and every shareable app adds
two, so a token is simpler.

The credential is checked against the API, and refused if its tailnet has none of the
running connector's shareable apps (with none shareable, none of its apps), then stored
in `credentials.json` beside the app list,
readable only by you ([On the computer](#on-the-computer)); the connector
refuses to use one anyone else can read. `auth status` shows its type (with the
client ID for an OAuth client), a fingerprint, the tailnet and the API URL, never the
secret; `auth status --check` also tries it against the API. `auth remove` deletes it.

With an API access token whose user is the owner (not an OAuth client), a new app that
isn't shareable logs in as you with a single-use, 10-minute key instead of the browser
(the first app on a fresh install still signs in through the browser). Its key expiry is
then turned off; a failed attempt is logged and tried again every hour until it works, so
a new credential takes effect without a restart. A node of yours that signed in through
the browser gets the same once it runs, while a credential is stored. `doctor` warns when
a node's key expires within 30 days.

## Invites

- `share` mints a single-use key tagged `tag:ovenlight-guest-<slug>` that expires in 7
  days. The key is preauthorized, whatever the tailnet's device approval setting, so a
  guest's phone never waits for you.
- A review invite, for an app store's review (`share --review`, terminal only;
  [development.md](development.md#app-review-invites)), has a reusable key instead, valid
  for 90 days, and up to 10 devices can claim it. Its key is deleted once the tenth has,
  so a leaked review link admits up to 10 devices to that app until it expires or you
  cancel it. While the key works, any number of devices can join your tailnet with it,
  but the connector refuses one that hasn't claimed, and `guests` lists it for you to
  remove. Removing one of its devices doesn't keep that phone out while places remain:
  it can join again and claim. To end it, revoke the person or the invite's ID.
- The connector records the invite with the key's SHA-256, never the key.
- The link carries the key in its fragment, so it never reaches the website that hosts
  the join page ([protocol.md](protocol.md#invite-links)).
- A device belongs to one person. A device already admitted to any of your apps can only
  claim that person's invites; any other invite stays unused. The exception is an invite
  for someone who has never had a device, opened on a phone that holds another of your
  apps: the invite becomes the phone's person's, and the phone gets the app. Ovenlight
  claims it through the other app's node when the phone can't reach this one yet. That
  covers inviting someone you already share with under a new name, and two people
  sharing one phone. Only that invite moves, and each key still opens only its own app,
  so holding one invite never grants another's app. A phone that already has the app
  gains nothing, so the invite stays open for whoever else it was meant for. Review
  invites never move.
- Making an invite with `share` takes a terminal. The MCP server can't share at all.
- `share --cancel <id>` withdraws an unused invite, or a review invite with places left
  (devices that joined with it keep the app), and deletes its key. If the delete
  fails (an expired API token, say), the connector refuses the invite at once, retries
  the delete on each sync, and `--cancel` says the key still works until then.
- When the person already has a phone in your tailnet for another of your apps
  (`--existing` or `--person`, or chosen in Ovenlight), Ovenlight reuses it rather than
  joining again: `share` adds the new app's guest tag to that device, as does a claim
  from a phone that joins after the invite was made. Canceling the invite, or letting it
  expire, takes the tag back.

Ovenlight never asks you to approve a guest's phone. Guests join under random device
names, so you couldn't tell whose phone was waiting, and approving it would be a rubber
stamp. What protects you is the invite: it works once, for 7 days (a review invite, on
up to 10 devices for 90 days), and only for one app, since the policy lets its guest tag
reach that app alone. You can remove anyone at any time. If a stranger uses a forwarded
link first, a single-use invite doesn't work for the guest you invited, and your guest
list shows who has it, so you can remove them.

## Revocation

`ovenlight revoke` stops serving the guest at once, open requests and streams included.
It then takes this app's tag off the device if it still holds another of your apps, and
otherwise deletes it from the tailnet, even when an open invite to that person for
another app lists it. That invite still works: the phone joins again to claim it.

Every 3 minutes, and on `guests --sync`, the connector compares its guests with the
tailnet's devices:

- a device deleted in the admin console removes that guest;
- the guests of an app no longer published are removed, and its open invites canceled;
- unused invites expire;
- a guest device whose tags don't match the apps it holds is put right.

A guest device that never claimed an invite is listed as unclaimed (`unclaimed` in
`GET /v1/guests`, and in `guests --json`, which prints the same view). The connector
refuses it; remove it in the admin console if you don't expect it. Keys of used,
canceled and expired invites are deleted, retried until the tailnet confirms it. When a
sync fails, `status` and `guests` say why, and suggest `ovenlight auth set` when the API
turned the credential down (401). `status --json` has the reason as `sharing.syncError`,
and `sharing.stateError` when the guest records can't be read.

A guest who leaves in Ovenlight stays in your guest list until you remove them
([development.md](development.md#guests)).

## The proxy

- HTTPS on port 443 with the node's real `ts.net` certificate, to `127.0.0.1:<port>`
  only.
- **Host.** The request must carry the node's own MagicDNS name; any other `Host` gets
  421. This blocks DNS rebinding.
- **Cross-site requests** get 403, except top-level link navigations
  ([app-contract.md](app-contract.md#cross-site-requests) has the exact rule).
- **Headers.** Every incoming `Tailscale-*`, `Ovenlight-*`, `X-Ovenlight-*`,
  `X-Forwarded-*`, `X-Real-IP` and `Forwarded` header is dropped, underscore spellings
  too, because some frameworks map both to the same variable. The connector then sets
  identity and forwarding headers itself.
- **Upgrades.** Only WebSocket upgrades pass; any other gets 400, since a protocol such as
  h2c would carry requests past these checks.
- **Bodies.** A request with a body goes to the app with `Connection: close`, so an app
  that answers without reading it can't take it for a request of its own, as long as the
  app honors that header ([app-contract.md](app-contract.md#read-each-request-body)).
- `/__ovenlight/` never reaches the app.
- **Admin API.** Owner only, and requests that change something need
  `X-Ovenlight-Request: 1` ([protocol.md](protocol.md#port-8443-the-admin-api)).

## Feedback

Only Ovenlight itself posts feedback: the connector refuses a post with `Origin` or
`Sec-Fetch-Site`, which a web page's requests carry, so an app's own page can't post in
its viewer's name. Owner and guests can post to `/__ovenlight/feedback`:

- a note of up to 4000 characters, with control characters other than newlines and
  tabs stripped;
- a whole PNG screenshot up to 5 MB, at most 8192 pixels a side and as many pixels as
  4096 by 4096;
- 30 an hour and one at a time per person. An upload that fails or is rejected still
  counts toward the 30; one refused because the person's last upload is still running
  doesn't.

When the inbox is full (1000 items or 500 MB), a new item replaces its sender's oldest,
or the oldest overall if the sender has none there. `ovenlight feedback` lists it with
the screenshot paths, and `status` shows the count and who sent the latest three.

## On the computer

The connector keeps everything as you, in the places connector.md's
[table](connector.md#what-it-installs) lists for each system: the app list with the
API credential beside it, and the state directory, with each node's keys and
certificates, the guest and invite records, the feedback inbox, the policy backups and
the control socket. Only you can read them:

- **macOS and Linux:** the directories are mode 700 and the files 600, and the connector
  runs with umask 077.
- **Windows:** the state folder is in `%LOCALAPPDATA%`, which only you, SYSTEM and the
  Administrators group can open, and what the connector writes there inherits that. A
  state folder the connector makes itself grants only you, and each file it saves
  atomically (the app list, the credential, the guest records) gets its own. Windows
  ignores Unix modes, so the check before using the credential reads its access list
  instead, and refuses one that grants reading to anyone but you, SYSTEM or the
  Administrators group, the counterparts of root.
- **The control socket**, `ovenlight.sock` in the state directory, takes the commands
  `publish`, `share`, `revoke` and the rest. It is a Unix socket on every system,
  Windows included, and only you can use it. On macOS and Linux its mode is 600. Windows
  can't be relied on to check a socket file's access list when a client connects (in
  testing it didn't), so there the connector asks Windows which process is calling, and
  refuses any that doesn't run as you.

On Windows the connector runs as a scheduled task, as you, with logon type S4U: it runs
with nobody signed in, Windows stores no password for it, and it gets no network
credentials of yours (it needs none). Registering such a task takes an administrator,
so `install.ps1` must run elevated.

An S4U task gets the account's full administrator rights whatever its run level says, so
the connector has them. Your apps don't. The connector starts them with a standard token,
as a PowerShell window not run as administrator has: the administrator groups in it can
only deny access, and it has medium integrity and no administrator privileges. If Windows
won't make that token, the connector starts no app. This keeps an app from using
administrator rights by mistake, not one written to take them: anything running as you
can replace the connector's binary in `%LOCALAPPDATA%` without administrator rights, and
the task's next start runs the replacement with them.

On a work or school account (Microsoft Entra ID) or a domain account, the task runs in
your session from sign-in instead, so the connector runs only while you're signed in. It
has the same rights there, and your sign-in's network credentials, which your apps can
use too.

## Logging

- The connector's log is readable only by you. At 20 MB it moves to `ovenlight.log.1`,
  replacing the one before, so there are two files at most. A crash lands in
  `ovenlight.stderr.log` beside it on macOS and Windows, and in the user journal on
  Linux. The [table](connector.md#what-it-installs) in connector.md has where the log is.
- It keeps each request's path, cut at 200 bytes, and never its query, which can carry
  OAuth codes and tokens.
- With the guest records, it keeps the day each guest's device last opened each app: a
  request from the app's own pages in Ovenlight, on screen or kept open in the background,
  which send Fetch Metadata headers. Ovenlight's own checks and icon fetches send none and
  don't count. `ovenlight guests` and People & Sharing in Ovenlight show it to you.
- Each node's backend log is `nodes/<slug>/backend.log` in the state directory. At 10 MB
  it moves to `backend.log.1`, replacing the one before.
- An app the connector runs (`publish --run`) writes its output to `<slug>.log`, readable
  only by you, in `~/Library/Logs/ovenlight/` on macOS and in the state directory's
  `logs/` elsewhere. It moves to `<slug>.log.1` at 5 MB. What it holds is up to the app,
  and often includes what requests sent it, such as their paths. On macOS and Linux the
  app runs in your login shell's environment, so what your shell profile exports, API keys
  included, reaches it, and lands in this log if the app prints it. To read that
  environment the connector runs your login shell, and so your profile, each time it
  starts an app's command. When a command restarts on its own, it runs it only while no
  read has worked, at most once every five minutes. Each read ends whatever the profile
  left running in the shell's process group once it is done, and a profile that takes
  over 10 seconds is ended then. `publish` runs it too, to check a new command's programs
  are found. On Windows there is no login shell, and the app gets the connector's own
  environment, with your account's environment variables. Beside the log, `<slug>.pid`
  names the app's running process group, so a connector restarted after a crash can end
  what the last one left running.
- The apps' commands are kept in `commands.json` beside the config, readable only by you.
  They run as the connector's children. On macOS they have whatever access to a folder
  macOS protects (Desktop, Documents, iCloud Drive) you allowed the connector, wherever
  they run.
- The logs name guests, devices and your login. `uninstall.sh --purge` (or
  `uninstall.ps1 --purge`) deletes them,
  with node state, the config, the stored API credential, guest records, the feedback
  inbox and the policy backups.

## What goes to Tailscale

- **The connector's nodes** name the app as `ovenlight` in the device information they
  send, and otherwise keep tsnet's defaults. They send backend logs to Tailscale's log
  service, as the Tailscale app does. tsnet may open a NAT-PMP or UPnP port mapping on
  your router for direct connections, and its peer and UDP listeners bind to all
  interfaces.
- **Ovenlight on the phone** doesn't upload logs: its embedded Tailscale library is built
  with log upload turned off, for the owner's node and every guest's, on iPhone
  ([development.md](development.md#tailscalekit)) and on Android
  ([development.md](development.md#the-android-tailscale-library)).
- `ovenlight run` clears the environment variables that would set tsnet's login, control
  server or log target, unless `OVENLIGHT_DEV=1`. The apps it runs never get them from
  your login shell.
- **App addresses** are publicly listed. Each app node's HTTPS certificate is recorded in
  public Certificate Transparency logs, so anyone can look up its
  `<slug>.<tailnet>.ts.net` name. Every invite link carries that name too. Choose app
  names and slugs you don't mind people seeing.

## Updates

The installed connector updates itself ([connector.md](connector.md#update) has when, and
how to turn it off). Only a release build started by its service (`run --service`), from
the connector's bin folder, does. A build from source, a pre-release and an `ovenlight run`
in a terminal never update themselves.

### What it trusts

One thing: a signature by one of the release keys built into the binary
(`connector/release_keys`). Not TLS, and not the download server. Whoever took over
downloads.ovenlight.app could hold updates and pulls back, but could install only a release
we signed as the latest, and couldn't skip the two-day wait or bring a
pulled release back to a computer that saw the pull. An index doesn't expire, so while
the server holds one back, `status` and `doctor` still show recent checks. The
[verified steps](connector.md#check-a-download-first) for installing by hand check the
same signed index, so they too take only a release once signed as the latest, though
not that it still is: an older index, with its release, passes them too. So `status`
shows the serial of the index your connector holds, `doctor` names it when it sends you to
them, and the steps take no index with a lower one. They trust this
repository's `allowed_signers`, so a change pushed to master changes what they accept:
the connector itself trusts only the keys built into it.

The one-line installers, [install.sh](https://ovenlight.app/install.sh) and
[install.ps1](https://ovenlight.app/install.ps1), take those steps for you, with the keys
in `allowed_signers` written into them (a test keeps them the same). They compare no
serial, so on a computer the connector already runs on, they also take an older index and
its release. The verified steps, with the serial `status` shows, rule that out. Where
`ssh-keygen` can't read the release keys, they say so and check the download against an
index whose signature went unchecked. There `install.sh` trusts the HTTPS download, as the
steps by hand do without the check. `install.ps1` trusts our build computer's code
signature in place of the release keys, so it takes any release from 1.1.0 on that Snowy
Ghost LLC signed, even a build never released. Releases signed within the same few days
share a certificate, so it may also take one's binary with another's scripts. It checks
that the release's `ovenlight.exe`, `install.ps1` and `uninstall.ps1` carry valid code
signatures under one certificate (one signing run leaves them that way) and that the
certificate carries the EKU naming our Microsoft Artifact Signing profile. It also checks
that the release holds nothing else but its notices, and that the binary names 1.1.0 or
later, the first release that updates itself. And a script you pipe into a shell does
whatever the site serving it says: whoever took over ovenlight.app could change it. To
check before you run anything, save a copy, read it and run that copy
([connector.md](connector.md#read-the-script-first) shows how), or use the [verified
steps](connector.md#check-a-download-first).

Once a day the connector fetches the signed index,
`https://downloads.ovenlight.app/connector/latest.json`, and its signature,
`latest.json.sig`. The index names the latest release, the oldest without a known
security problem (`secure`), the oldest that may update by binary alone
(`reinstallBelow`), when it was signed (`serial`), and the SHA-256 of each of the latest
release's archives. The connector accepts it only when it is signed in the namespace
`ovenlight-latest` by a built-in key, and it is either the index the connector already
holds or one with a higher serial that lowers neither `secure` nor `reinstallBelow`.
[allowed_signers](https://github.com/snowy-ghost/ovenlight/blob/master/docs/allowed_signers) has the command to check it yourself.

It installs only the release the index names, never an older one. That release is due two
days after this computer first saw it named (or after the index was signed, if later), or
at once when the running release is below `secure`. Below `reinstallBelow` it installs
nothing, and `ovenlight status` and `ovenlight doctor` say to install that release with
its install script, since an update replaces only the binary: never the install scripts,
the service, the config or the state.

### How it installs

1. Right after a check that works, it downloads this system's archive from
   `connector/<version>/`, within 30 minutes and 128 MB, and opens it only once its
   SHA-256 matches the index.
2. It takes out one file, `ovenlight-connector-<version>/ovenlight` (`ovenlight.exe` on
   Windows), by its exact name, and nothing else.
3. When the update's moment comes, it fetches the index again, and goes on only if it still
   names that release. So a computer that gets the pull at any point in the two days
   doesn't install the pulled release.
4. It runs the new binary's `version` and requires the version the index names.
5. It records a trial, keeps the running binary as `ovenlight.prev` (`ovenlight.prev.exe`
   on Windows), puts the new one in its place and exits. The service starts the new one.
6. The new release has 10 minutes of time awake to serve every app that was serving before,
   with its node logged in, to verify the signed index that brought it with its own
   built-in keys, and to have its own updater running. If it crashes, or misses any of
   these in that time, the previous binary goes back and the update is tried again at the
   start of the next night, busy or not. One that hangs so that its trial can't end goes
   back after 15 minutes awake. A restart starts the 10 minutes again, though on Windows
   some stops count as a crash ([connector.md](connector.md#update)).

A failure before the swap leaves the running connector as it was. `ovenlight status`
shows a failed update of the release it installs next, and `doctor` warns once one
release has failed three times in a row.

`ovenlight update now --index <file>` installs the release a canary index names, at once.
That is how we try a release on our own computer before publishing it
([development.md](development.md#releases)). A canary index is signed in its own
namespace, `ovenlight-canary`, by a built-in key, and can't lower `secure` or
`reinstallBelow` either. The connector never accepts one from `latest.json`, takes one only
over its control socket, and applies it once without keeping it as the index it holds.

### The release keys

Two keys sign releases: one in a Mac's Secure Enclave, which signs only with Touch ID, and
a backup printed on paper, kept in two places and off every computer until it signs the
release that replaces it. Their public halves are built into the connector and listed in
[allowed_signers](https://github.com/snowy-ghost/ovenlight/blob/master/docs/allowed_signers), and the connector trusts no other key: not our Apple
Developer ID, and nothing fetched at update time.

A release is built on one Mac from a commit on this repository's master branch, and signed
on another. Touch ID approves a signature but doesn't show what is signed, so the signing
script prints the commit, the index and the checksums first. It signs only a commit whose
`connector/release_keys` lists exactly the keys that Mac keeps plus the key signing it, so
the keys a release trusts never come from the commit alone. It keeps its own record of
what it signed and which index went live, and checks the live index against it before
every signature, so the files it shares with the Mac that builds can't change what it
signs. Even so, a signature vouches for what is on master and for the Mac that built it,
and no more. Whoever can sign a release, or get code onto master and through that Mac, can
run code on your computer within a day (an index that raises `secure` skips the two-day
wait), as you, beside your Tailscale API credential, and on Windows with administrator
rights. `ovenlight update off` opts out.

When one key is lost, the next release, signed with the other, replaces it. The signing
script then waits six weeks, and two weeks after the last change to the index, before
signing with only the new key. A computer that stays off, or has updates off, for four
weeks after that release comes out can't verify the releases signed with only the new key:
its checks fail, `doctor` warns after seven days, and it needs one install by hand. If both
keys were lost, every computer would be in that position once we publish a release signed
with a new key.

### The files it keeps

In the state directory (connector.md's [table](connector.md#what-it-installs) has it for
each system):

- `update.json`: the index accepted last, the minute of the daily check, when this
  computer first saw its latest release named, the last check, the last update and the
  last failure. One that can't be read is set aside as `update.json.bad`, and the updater
  starts afresh.
- `updates-off`: there while updates are off.
- `update-trial.json`: an update's trial, while the new release proves itself.

In its `bin` folder, beside the connector's binary:

- `ovenlight.download`: an archive while it downloads, deleted once the binary is out.
- `ovenlight.next` (`ovenlight.next.exe` on Windows): the new binary, waiting for its
  moment.
- `ovenlight.prev` (`ovenlight.prev.exe`): the binary before the last update, for a
  rollback.
- On Windows, `ovenlight.old-<number>.exe`: older binaries moved aside, deleted at the
  next update or install.

Only you can read them, as with the rest of the state directory. The install scripts
remove a trial and `ovenlight.prev` before they install, so the connector they install is
never rolled back. The uninstall scripts remove all but `update.json`, `update.json.bad`
and `updates-off`, which `--purge` deletes too.

### What it sends

Only the installed service of a release checks: a build from source never contacts
downloads.ovenlight.app. The daily check is two GETs with no cookies, query or
identifiers. It comes at the same minute each day, picked at random when the updater first
runs. A failed check is tried again within the hour, and a new release, or one a failed update
went back to, checks as soon as it starts. The check runs with updates off too, so the connector still knows when its release
has a security problem. An update fetches the archive, whose name and folder show this
computer's system (on Linux and Windows, its processor type too) and the version it
installs. It fetches the index again just before the download, unless it has just checked,
and once more just before it installs. Like any request, each
shows downloads.ovenlight.app the computer's IP address. Nothing else in the connector
contacts ovenlight.app or its subdomains, and `ovenlight doctor` fetches nothing.

## The MCP server

`ovenlight mcp` serves `guide`, `status`, `doctor`, `check_app`, `publish`, `unpublish`,
`feedback_list`, `logs` and `restart_app` on stdio for coding agents. It can't run
commands, share an app or grant anyone access. A command runs as you, with your login
shell's environment, so one set through
MCP would hand a client with no shell of its own, such as a desktop chat app, everything
you can do, sharing included. So `publish` keeps the command the connector runs for an
app and can't set one: you set it in a terminal,
`ovenlight publish --slug <slug> --run '<command>'`, or an agent does through its shell,
where you approve the command. Every tool refuses an argument it doesn't take, such as a
`run` or `command` passed to `publish`, rather than ignoring it. Invites are made in a
terminal or in Ovenlight.
Unpublishing a shareable app, moving it to another port, or changing its command or folder needs a
terminal too, since what listens on its port is what guests reach. These terminal-only
steps are what keep the MCP server from sharing. An agent with a shell can do what you
can, as the [threat model](#threat-model) says. `restart_app` works for a shareable app
too, since it restarts the command you set and changes neither it nor the port. When the
connector doesn't confirm an `unpublish`, the tool still succeeds, since the app is out
of the config, and adds a `note` that the connector stops serving it once it confirms,
or when it next starts. `check_app` (like `check`, `doctor` and `status`) asks the app
with its tailnet name as `Host` and your identity headers, as the connector does for
you, or with none while your login isn't known. `doctor` and `status` ask only `GET /`.
`restart` and `restart_app`, once the app answers, also send the files and CORS requests
below, to `127.0.0.1` without identity headers.
`check_app` and `check` ask only `GET` and `OPTIONS`, to `/`, the pages, stylesheets, web
manifest and icon the app itself names, a few fixed paths (`/api/ovenlight-check`,
`/ovenlight-check-missing`, `/console`), one random path under `/ovenlight-check-`, which
tells a route that answers every path from a served file, and the app's own files, at
most 40 requests (and one more each for the files and the debug pages when the app
refuses its tailnet name, which they then ask without):
the files at the top of the folder the connector runs the app in and the database files
(such as `*.db` and `*.sqlite`) a folder down, by their paths there under `/`, under the
folders of the assets the home page links, and under a few common prefixes such as
`/static/`. To tell a file served from a page, they read each file's first 16 bytes, and
count a shorter file only when the answer is all of it; a file of blank space is left out.
They read nothing in a folder that holds more than an app's, such as the home folder, or
one macOS protects, and for an app without a folder they ask only `/.env` and
`/package.json`. The CORS and `/console` requests carry no identity headers, going to
`127.0.0.1` as a page in this computer's browser would. They read `/` a second time, and
the web manifest, from `127.0.0.1` without identity headers too, as the connector reads
them for the app's icon and color. The agent chooses none of them. What guests wrote in
feedback, and every feedback item's page address, which the page chose, reach the agent
fenced as untrusted data, over MCP and in the CLI alike. The MCP `logs` tool and
`ovenlight logs` fence the output of the apps the connector runs too, since it holds what
requests sent the app, which a guest, or any website open in this computer's browser, can
choose. A screenshot can't be fenced, so the
agent is told that it is an image its sender chose, and any text in it is data.

## On the phone

- The whole app locks behind the phone's own unlock (Face ID or the passcode on iPhone, a
  fingerprint, face or the screen lock on Android) once it keeps anything: an app, an
  invite or a node. It locks again after a set time in the background (a minute by
  default), or never once it's turned off in Settings, which asks for the unlock first.
  The unlock is asked for again before creating an invite and removing a guest. On a
  phone with no passcode or screen lock there is nothing to ask for: Ovenlight stays
  open, says so in Settings, and those confirmations pass.
- Add App takes only an address on the owner's own tailnet, and none before the owner has
  signed in, so a new app comes from the owner's own computers or from an invite. An app
  of your own goes through the owner's node only when its host is on the owner's own
  tailnet (its MagicDNS suffix, remembered from the node's status). Any other address,
  such as an app from a tailnet the owner was signed in to before, is refused: it shows
  Can't Reach and sends nothing, not even a probe, and Ovenlight never looks its name up
  in public DNS or loads it outside the tailnet. Only the iPhone's debug builds load an
  app at `localhost` directly, for screenshots of a local demo. The owner's node only ever
  carries names on that known tailnet. Until the tailnet is known, a `*.ts.net` app is
  routed to the owner's node and waits behind Sign In, loading nothing. The route is
  checked before every probe and load, and a view whose route changed is made again
  through the right node, or refused. A change of the remembered tailnet closes every view
  whose route it changed. Sign Out and Stop Using My Own Computers forget it, close every
  view that went through the owner's node, kept in the background or not, and remove the
  apps found on the owner's computers with their data. A Sign Out cut short by a quit
  finishes at the next launch. A closed view can't connect again, and neither can a
  refused app's (on iPhone, its data store is set to a proxy that refuses every
  connection, and on Android, its route is cleared). The closed view's app loads again
  through its route, at once if it's on screen. A shared app always goes through its
  owner's guest node.
- Through Ovenlight's proxy, pages reach only a node's peers and the internet. A name in a
  tailnet resolves only among that node's peers, never in public DNS. Any other address
  must be on the internet: the proxy refuses the phone itself, its local networks, the
  network's public address, other tailnets and the ranges that aren't globally reachable,
  NAT64 and IPv4-mapped forms included (on iPhone and on Android 10, NAT64 under the
  well-known prefix only, since neither gives Ovenlight the network's own), unless the
  address is one of the node's peers. So no page reaches the phone's own network, or the
  router in front of it, through Ovenlight. On iPhone, WebKit sends a load to one of the
  phone's own addresses straight out, past the proxy. Content rules block loopback,
  private and link-local IP literals (outside Tailscale's fd7a:115c:a1e0::/48), but not
  the phone's other addresses, so a page can still reach the phone itself at one of those,
  such as a public address, a carrier's in 100.64.0.0/10 or another VPN's.
- Ovenlight takes WebRTC away from pages. Its traffic wouldn't go through Ovenlight's
  proxy: through a TURN relay a page could reach any address it names, the phone's own
  network included. A script at the start of every document, in every frame, removes it.
  On iPhone, WebKit runs it in a frame's first `about:blank` document too, as the frame is
  made. On Android it doesn't run there: it strips a new `about:blank` frame as it's made,
  but a page that sets out to can still find WebRTC in a frame's first, empty document,
  before the frame's own page loads.
- From iOS 27.1, pages on iPhone can use WebTransport. The content rules don't cover it,
  but the data store's SOCKS5 proxy does, and every release route sets that proxy (its
  node's, or one that refuses every connection). A route without one would need
  WebTransport removed, as WebRTC is.
- While Ovenlight is locked, no page gets the microphone or camera, and an app that an
  `ovenlight://open` link asks for waits for the unlock. Locking also stops any
  microphone or camera stream already running in an open app, on screen or kept in the
  background. Nor does a page kept in the background get them: closing an app to the
  launcher stops its streams, and it can't start one until it's on screen again.
- An app's downloads come only from its own host (or a blob or data URL its pages made),
  none while Ovenlight is locked, and leave Ovenlight only through the share sheet (on
  Android, Ovenlight's Save, Share and Open). The files stay in Ovenlight's temporary
  folder until the next launch, since a share may still be sending. Files not yet offered
  go at once with their app or its data.
- The owner's admin calls go through the owner's own node to each connector's port 8443.
  The phone holds no API credentials. It asks only the owner's own devices: peers of
  the owner's own user, or tagged `tag:ovenlight-app-*`, on the owner's own tailnet (not
  devices shared in), and trusts a device's `/v1/apps` answer only for hosts that are
  themselves such peers.
- Each network the phone joins gets its own node, state and random device name
  (`ovenlight-` and six random characters), so no name or key ties its guest devices
  together. Two owners comparing notes could still match them: each sees when Ovenlight
  is open on the phone (its guest node comes online and checks in with their apps), and
  its public IP address and operating system (on iPhone, its iOS version too).
- Ovenlight builds Tailscale without its peer API server. Through it, a device of the same
  user, which an invite's sender can arrange, could ask the phone to look up names on the
  phone's own networks: its router, Private DNS or a company's DNS.
- Node state and the membership list are excluded from backups, so node keys never come
  back on another phone. On Android, nothing of Ovenlight's is backed up or moved to a new
  phone.

### On Android

- **One proxy.** Android has one proxy setting for every web view in an app, so the
  embedded Tailscale library runs one HTTP CONNECT proxy on `127.0.0.1`, and every
  request of every app's pages, and of Ovenlight itself, goes through it, with no
  fallback to a direct connection. Other apps on the phone can reach its port, so it
  answers only a CONNECT, and only with the secret Ovenlight makes at each launch. A
  page's web view answers the proxy's challenge with its app's own route key, which
  names the node that carries it, and a key with no route is refused. The page itself
  never sees the secret. If the proxy ever stops, Ovenlight stops too, so no other app can
  take the port.
- **Each app apart.** Each app has its own WebView profile, with its own cookies,
  storage and cache. Data that ends (Leave, Remove, a revoke) is wiped at once, and its
  profile deleted by the next launch. Pages run in WebView's sandboxed renderer process,
  apart from Ovenlight's own, and a renderer that dies doesn't take Ovenlight with it:
  its pages are made again. WebView hands Ovenlight every message a page writes to its
  console, whole, so a page can run Ovenlight out of memory: a denial of service, nothing
  more. Safe Browsing is off in app views. Their pages come only from apps on tailnets
  Ovenlight knows, but a page can still embed another site's frames, scripts and images,
  which load unchecked. A page that navigates to another site opens it in the browser,
  which keeps its own Safe Browsing.
- **The lock** is a window of its own above everything Ovenlight shows, pickers and
  menus a page opened included, and under it no page is in the window at all. Its clock
  runs from the moment Ovenlight's screen goes, even for a file picker or Tailscale's
  sign-in tab, unlike on iPhone, since Android can't tell someone still in a picker from
  someone who left. A file picked meanwhile reaches the page only after the unlock.
  Recents shows no picture of Ovenlight.
- **Microphone and camera.** WebView can't stop a stream it handed out. On lock and when
  an app closes to the launcher, Ovenlight's page script stops the tracks it saw, and if
  the phone still records or holds a camera two seconds later, Ovenlight closes any
  page granted either since it last loaded.
- **Downloads.** Android doesn't say which frame started a download, so one from an
  `http` or `https` address is checked by that address alone: a frame from another site
  can start a download from the app's own host. Ovenlight fetches it again through the
  proxy with the app's cookies, following no redirect, and it ends up in that app's own
  Save, Share and Open sheet. Open hands another app only PDFs, images, audio, video and
  plain text, never HTML, SVG, XML or an app package.
- **Home screen shortcuts.** Add to Home screen, in an app's touch-and-hold menu, pins a
  shortcut that carries only the app's ID and opens it through the lock. Removing the app
  disables its shortcut, which keeps its name and icon on the home screen until the person
  removes it, since Android gives apps no way to unpin one. Ovenlight publishes no dynamic
  shortcuts, which would list apps on its icon outside the lock.
- **Links.** On Android 12 and later, an invite link opens Ovenlight only when the site's
  `assetlinks.json` lists the key that signed it. On Android 10 and 11, an app that isn't
  listed can still be offered in the system's chooser. The join page's Open in Ovenlight
  names Ovenlight's package, since another app can claim the `ovenlight://` scheme. On
  iPhone the page never puts the key in a link another app can claim: the guest copies the
  invite and pastes it into Ovenlight. A page
  can't hand Ovenlight an invite directly: an invite link opens the join page in the
  browser, where the person decides, and an `ovenlight://` link is refused. Nor can a page
  open `android-app:`, `file:` or `content:` links, and an `intent:` link is stripped of
  whatever could aim it elsewhere and, like any link to another app, asked about first.

## If something goes wrong

- **An invite link leaked.** If it's still open, `ovenlight share --cancel <id>`
  withdraws it (`ovenlight guests` lists open invites). If someone else used it,
  `ovenlight guests` lists who has the app now, which may be someone you already share
  with: `ovenlight revoke <device ID> --app <slug>` takes back only that app. Then send a
  new invite. A review link keeps admitting devices after one is removed
  ([Invites](#invites)), so for a leaked one, open or not, run
  `ovenlight revoke <invite ID>` (`ovenlight guests` lists the ID while the invite is
  open, and `--json` shows it on each device that claimed it). That removes every device
  that claimed it and cancels it if it's still open. Delete any device that joined
  without claiming, which `ovenlight guests --sync` lists, in the admin console
  (Machines), then make a new review invite.
- **An app's login link leaked.** Whoever opens a pending login link first and signs in
  gets that app's node, in their own tailnet. If it's still pending, sign in with it
  yourself at once. If someone beat you to it, `ovenlight status` shows the app at an
  address in another tailnet, or with another owner: run `ovenlight unpublish <slug>`,
  delete `nodes/<slug>` in the [state directory](connector.md#what-it-installs),
  delete the app's old node in the admin console (Machines) if it's still there, and
  publish the app again, which starts a new node. The node they got never comes back
  online, since its keys are gone.
- **An API token or OAuth client secret leaked.** Revoke it in the Tailscale admin
  console (Settings, Keys; for an OAuth client, Trust credentials) and store a new one
  with `ovenlight auth set`. Then check your tailnet policy, devices and keys for changes
  you didn't make.
- **A lost or stolen phone.** Your own: delete its node, an untagged `ovenlight-` device
  of yours, in the admin console (Machines), then look in `ovenlight guests` for invites
  and guests you didn't make. A guest's: `ovenlight revoke <device ID>`, then invite them
  again on their new phone.
- **A stolen computer.** Revoke the API credential and delete the computer's app nodes in
  the admin console (Machines), then set up again on another computer, as for a move
  [without a backup](connector.md#move-to-a-new-computer). FileVault keeps the node keys
  and the credential unreadable on a Mac that's off, but the automatic login that
  [connector.md](connector.md#what-the-computer-needs) suggests for a Mac without a
  display needs FileVault off.
- **An app that may be compromised.** `ovenlight unpublish <slug>`, which also stops the
  command the connector runs for it. It ran as you, with your login shell's environment
  (or your terminal's, if you started it): rotate the secrets there, and the API
  credential, which it could read too.

## Reporting a vulnerability

See [SECURITY.md](https://github.com/snowy-ghost/ovenlight/blob/master/SECURITY.md).
