Skip to content
Open
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ commands, `--json` output and exit codes. Each release's notes start with its se

### Added

- An opt-in plain-text token store, for machines that have no system keychain (an SSH or jump host,
WSL without a desktop). Set `HODEISHIELD_TOKEN_STORE=plaintext-file`, or the global setting
`hodeishield config set token_store plaintext-file`, and `login --device` keeps the session in a
file only your user can read, one per profile, in the platform's state directory
(`$XDG_STATE_HOME/hodeishield/sessions/` on Linux). The keychain stays the default, nothing ever
falls back from one store to the other, and the file store is not available on Windows yet. Every
read and write refuses a file or directory that is a symbolic link, belongs to another user or is
open to group or others (exit code 3 for commands that need a credential, 1 for `login` and
`logout`); files are replaced atomically with mode 600. `logout` revokes the tokens
and deletes the file. The README has a new section, "Servers, containers and remote sessions",
with the trade-off and the advice for containers and CI (a tenant API key); ADR 0001 records the
decision. A configuration file that contains `token_store` is refused by older CLIs.
([#39](https://github.com/Hodeitek/hodeishield-cli/issues/39))
- Each release now includes a Windows on Arm build, `hodeishield-<version>-aarch64-pc-windows-msvc.zip`.
It is built natively on a Windows on Arm runner, its `hodeishield.exe` is signed with Authenticode
like the x64 one and checked to need no Visual C++ runtime, and the archive is listed in
Expand Down
9 changes: 5 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ Thanks for helping. Bug reports, fixes and documentation improvements are welcom

- **The CLI only reads.** Changes that write to the API will not be accepted; the `/v1` API is
read-only anyway.
- **Credentials never leave the keychain or the environment.** No change may write a token or key to
a file, print it, or log it. Tests assert this; keep them passing.
- **Credentials stay in the keychain, the environment or the opt-in token file.** No change may write
a token or key anywhere else, print it, or log it, and nothing may fall back from one store to the
other. Tests assert this; keep them passing.
- **Do not include real data** in issues, tests or examples: no real API keys, tokens, tenant names,
hostnames other than the public `*.hodeishield.com`, or customer data. Use `example.com` /
`example.test` and made-up values.
Expand Down Expand Up @@ -105,8 +106,8 @@ implementation plugged into one. **Depth** is the behaviour available per unit o
- **The interface is the test surface.** Test through the interface. A test that reaches past it
pins the implementation and breaks on every refactor.
- **One adapter is a hypothetical seam; two make it real.** Add a trait only when a second
implementation exists today, a test double included. `TokenStore` qualifies: the OS keychain and
the in-memory store used by tests both implement it.
implementation exists today, a test double included. `TokenStore` qualifies: the OS keychain, the
opt-in token file and the in-memory store used by tests all implement it.
- **Locality.** Table, JSON and CSV rendering, including the sanitising of terminal control
characters, lives in `output.rs`, so a change to it touches one module, not every command.

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

88 changes: 79 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ endpoint agents from a terminal or a script.

- **Read-only.** Every command is a `GET` against the public `/v1` API. Nothing here can change data in
your organisation.
- **Safe with credentials.** Sign-in tokens live in the system keychain, never in a file; nothing is
ever printed or logged; credentials only travel over HTTPS and are never replayed to a redirect.
- **Safe with credentials.** Sign-in tokens live in the system keychain unless you opt in to a
plain-text file for machines that have none; nothing is ever printed or logged; credentials only travel over HTTPS and are never replayed to a redirect.
- **Built for scripts.** `--json` prints the API's own JSON, exit codes say what went wrong, and
`HODEISHIELD_API_KEY` works without any interactive step.
- **Verifiable.** Every release is signed with Sigstore (keyless, from GitHub Actions) and carries
Expand Down Expand Up @@ -169,7 +169,8 @@ docker run --rm -e HODEISHIELD_API_KEY ghcr.io/hodeitek/hodeishield-cli:$v vendo

`HODEISHIELD_API_KEY` is passed through from your environment (`-e NAME` without a value) so the key
never appears on the command line. `hodeishield login` does not work in a container (there is no
browser and no system keychain): use a tenant API key, see [Authenticate](#authenticate).
browser and no system keychain): use a tenant API key, see
[Servers, containers and remote sessions](#servers-containers-and-remote-sessions).

The image is signed by digest with cosign (keyless) and carries the release's CycloneDX SBOM as a
signed attestation. Verify both before you rely on the image:
Expand Down Expand Up @@ -297,8 +298,9 @@ hodeishield login --profile client-b

A profile that has not been defined is refused (`Profile ... is not defined`).

`login` first checks that the system keychain is available and stops before opening the browser or
showing a code if it is not; use a tenant API key on such a machine.
`login` first checks that the selected token store is available and stops before opening the browser
or showing a code if it is not. On a machine without a keychain, see
[Servers, containers and remote sessions](#servers-containers-and-remote-sessions).

With `--device`, open the address the CLI prints on any device and **type the code by hand**: the app
does not accept a link with the code filled in, on purpose.
Expand All @@ -309,8 +311,8 @@ framework the app offers; evidence; endpoints) and for a refresh token. To ask f

The token is kept in the system keychain (macOS Keychain, Windows Credential Manager, or the Secret
Service — GNOME Keyring, KWallet — on Linux) and refreshed automatically, also when the app ends an
access token early. Without a keychain, signing in is refused rather than falling back to a file: use
an API key there.
access token early. Without a keychain, signing in is refused rather than falling back to a file;
the file store below is an explicit choice, never a fallback.

The keychain can also be out of reach in a session that has one on the machine, such as an SSH or
other remote logon on Windows. With no API key set, commands then stop with exit code 3 and say that
Expand All @@ -320,6 +322,70 @@ automated sessions. With the key set, the keychain is not read at all.
To cut the CLI's access from elsewhere, revoke it in [the app](https://app.hodeishield.com) under
**Account → Application access**. Signing out of the web app does not cut it.

### Servers, containers and remote sessions

Where there is no desktop session (an SSH server, a container, WSL without a desktop, a CI runner)
there is usually no keychain either, and `hodeishield login` stops before it opens a browser or shows
a code. You have two options, in this order of preference:

1. **A tenant API key** (see [With a tenant API key](#with-a-tenant-api-key-scripts-ci-servers)).
It works everywhere, needs no storage on the machine, can be limited to the read scopes you need
and is the right choice for containers and CI. Do not sign in during `docker build`, and never
commit or publish a container in which someone has signed in.
2. **A plain-text token file, if you want to sign in as yourself** on a machine you control, such as
a jump host. You opt in explicitly, and the trade-off is that the tokens are stored
**unencrypted** in a file that only your user can read. Anyone who can read your files, such as
`root`, a backup, a snapshot or a copy of your home directory, can use the refresh token:
- **How long.** Today, up to 30 days after the CLI last refreshed the session (every refresh
starts a new 30 days), unless you end it earlier with `hodeishield logout` or by revoking the
access in the app under **Account › Applications**. The service is adding an absolute limit of
90 days from the sign-in, and will also end these sessions when you change your password; this
section will say so once that is in place.
- **Reuse is detected.** Each refresh replaces the refresh token. If someone refreshes with a
copy of your file, the next refresh by your own CLI is seen as a reuse of an old token and
ends the session for both. An unexpected "sign in again" is a reason to revoke the access in
the app and check who could read the file.

```sh
export HODEISHIELD_TOKEN_STORE=plaintext-file # or: hodeishield config set token_store plaintext-file
hodeishield login --device
hodeishield whoami
hodeishield logout # revokes the tokens, then deletes the file
```

- **Where.** One file per profile in the sessions directory of your state directory:
`$XDG_STATE_HOME/hodeishield/sessions/` (by default `~/.local/state/hodeishield/sessions/`) on
Linux, and `~/Library/Application Support/com.Hodeitek.HodeiShield.sessions/` on macOS (next to,
not inside, the folder that holds the configuration file). The configuration file never holds
a token, and the file names are a hash of the profile name, not the name itself (a hash of a
guessable name can be reversed, so do not treat it as hiding the profile). The parents of that
directory, such as your state directory, must not be writable by other users.
- **Checks.** The directory is created with mode 700 and the files with mode 600, written by
replacing the file atomically. On every read and write the CLI refuses, and does not repair, a
file or directory that is a symbolic link, belongs to another user, or is open to group or
others. Commands that need a credential then stop with exit code 3 (`login` and `logout`: 1) and
tell you what is wrong; fix it yourself (`chmod`), and revoke the tokens in the app if someone
else could have read them. Temporary files left by a write that was interrupted are removed
the next time that profile is saved or signed out.
- **Do not run it as `root` or with `sudo` for your user.** A run as `root` that uses your home
directory can create root-owned directories there, which your own runs then refuse (and
`root` refuses yours, since they belong to another user). Sign in with the user that will run
the commands.
- **No fallback.** The setting must be the same on every command, and the CLI never switches
between the keychain and the file on its own. Tokens are not moved when you change it: sign out,
change the setting, sign in again. The values are `plaintext-file` and `keychain` (the default);
anything else is an error.
- **Network home directories.** Do not keep the state directory on NFS or another shared file
system: the file would be readable by the server's administrators and by `root` on other
machines that mount it, and its permissions are not enforced the same way. On Linux the CLI
warns on `login` when the directory is on NFS or SMB; it does not stop you. Point
`XDG_STATE_HOME` at a local directory instead.
- **Deleting is not revoking.** `logout` revokes the tokens at the app and then deletes the file,
but removing the file by hand, or restoring it from a backup, does not end the session. Only
revoking does.
- **Not on Windows yet.** Windows has no permission bits to check, so selecting the file store
there is refused with exit code 1, before anything is asked of the app.

## Use

```sh
Expand Down Expand Up @@ -380,7 +446,7 @@ hodeishield vendors list --all --csv > vendors.csv
| 0 | Success |
| 1 | Error (invalid input, configuration, unexpected answer) |
| 2 | Invalid command line |
| 3 | Not authenticated: no credential (also when the system keychain cannot be reached in this session), or the API rejected it |
| 3 | Not authenticated: no credential (also when the system keychain cannot be reached in this session, or the opt-in token file is refused as unsafe; `login` and `logout` exit 1 for that), or the API rejected it |
| 4 | The credential lacks the scope the command needs |
| 5 | Not found (or not in your organisation) |
| 6 | Rate limit exhausted (the CLI already retried short waits) |
Expand Down Expand Up @@ -426,6 +492,7 @@ hodeishield config path # where the file is
hodeishield --profile staging config set api_url https://api.example.test
hodeishield config use staging # make it the default profile
hodeishield config set update_check false # no new-version notice (global, not per profile)
hodeishield config set token_store plaintext-file # opt-in token file, see the section on servers
hodeishield --profile default vendors list # or pick one per command
```

Expand All @@ -436,9 +503,12 @@ hodeishield --profile default vendors list # or pick one per command
| `HODEISHIELD_API_URL` | API base URL |
| `HODEISHIELD_APP_URL` | App base URL (sign-in) |
| `HODEISHIELD_CONFIG` | Path of the configuration file |
| `HODEISHIELD_TOKEN_STORE` | Where sign-in tokens are kept: `keychain` (the default) or `plaintext-file`, an [opt-in](#servers-containers-and-remote-sessions); takes precedence over `token_store` in the configuration file |
| `HODEISHIELD_NO_UPDATE_CHECK` | Any non-empty value turns off the [new-version notice](#new-version-notice) |

The configuration file holds URLs and sign-in settings only, never a credential. URLs must use HTTPS;
The configuration file holds URLs and sign-in settings only, never a credential. `update_check` and
`token_store` are global settings, not per profile. An older CLI refuses a configuration file that
contains `token_store`; use the environment variable there. URLs must use HTTPS;
plain HTTP is accepted only for `localhost`.

## New-version notice
Expand Down
3 changes: 2 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ Only the latest release receives fixes.
## What is in scope

- Anything that could expose a credential: the tenant API key in `HODEISHIELD_API_KEY`, or the
sign-in tokens the CLI keeps in the system keychain.
sign-in tokens the CLI keeps in the system keychain or, if the user opted in, in its token file
(including the checks that refuse an unsafe file or directory).
- Sending a credential anywhere other than the configured HodeiShield hosts (redirects, plain HTTP,
crafted URLs or identifiers).
- Output from the API reaching your terminal in a way that can run or hide commands.
Expand Down
3 changes: 3 additions & 0 deletions crates/hodeishield-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ webbrowser = "1"
[target.'cfg(target_os = "macos")'.dependencies]
apple-native-keyring-store = { version = "1.0.2", features = ["keychain"] }

[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1.5", default-features = false, features = ["std", "process", "fs"] }

[target.'cfg(windows)'.dependencies]
windows-native-keyring-store = { version = "1.1.0", default-features = false }

Expand Down
Loading
Loading