Threat model
The full document: what is protected, from whom, by what means and where the protection ends. Where something is not verified or not guaranteed, we say so.
Reference document for version 0.1.0 (pre-release). It has not been verified by third parties.
To report a security issue, write to security@shelleasy.app.
This document states who ShellEasy protects your data from, by what means, and where the protection ends. Every statement has been checked against the code; where something is not verified or not guaranteed, the document says so.
Last checked: version 0.1.0 (pre-release). If the cryptography, the trust boundaries or the file format change, this document must be updated in the same change.
1. What is protected
| Asset | Where it lives | Value |
|---|---|---|
| SSH private keys, passphrases, server passwords | identities.* columns of the database | The highest: they give access to servers |
| List of hosts, usernames, folders, tags, snippets, tunnel rules | database | High: they reveal the infrastructure and habits |
| Master Password | only in the user's head (optionally in the keychain, behind biometrics) | It is the only key to everything |
Server keys already accepted (known_hosts) | database | They prevent the man-in-the-middle attack |
.shelleasy export files | wherever the user saves them | They contain everything, secrets included |
| Content of sessions and transferred files | memory, network, destination disk | Outside the vault perimeter (see §7) |
2. Who the adversary is
Considered:
- Someone who obtains the app's files without the Master Password — a stolen or lost computer, a copied backup (Time Machine, cloud, external drive), another user of the same computer, malware that reads files but does not yet have the password. This is the main scenario the vault exists for.
- Someone who can read and modify the files while the app is closed — the same category, but active: they can alter or replace files in the data folder.
- A hostile or compromised SSH/SFTP server that the user connects to: it can send malicious file and folder names, terminal output with special sequences, changed keys.
- A crafted
.shelleasyfile or~/.ssh/configthat the user is induced to import. - Another machine on the network trying to use a tunnel or a SOCKS proxy left listening.
- Someone looking over the shoulder, or taking an unlocked computer left unattended for a few minutes — mitigated only by auto-lock (§5.4).
Not considered (outside the perimeter):
- An attacker with the computer unlocked and the app open, or with code running as the same user while the vault is unlocked: they can read the process memory, log the keyboard, ask the app to export. No local vault resists this.
- A compromised operating system or firmware.
- Side-channel analysis (timing, power consumption, cache) on a computer shared with someone who can observe them.
- An attacker who guesses a weak Master Password with an expensive but not impossible offline attack (§5.1): the strength of the protection depends on the chosen password.
- The security of the servers you connect to and of Go's SSH library (
golang.org/x/crypto/ssh), which is trusted.
3. How the encryption works (in brief)
Master Password --Argon2id(salt, t=3, 64 MiB, p=4)--> KEK
KEK --AES-256-GCM, AAD "easyshell/vault/dek/v1"--> wrapped DEK (in vault.json, OUTSIDE the database)
DEK --HKDF-SHA256, info "easyshell/db/v1"--> database key
database key --Adiantum, 4 KiB blocks--> entire SQLite file
DEK --AES-256-GCM, AAD "table|column|id"--> individual secrets (second layer, inside the database)
vault.jsoncontains only the version, the salt, the Argon2id parameters and the wrapped DEK. It sits outside the database because it is needed to open it. Written atomically, permissions 0600.- Whole database encrypted with Adiantum (
ncruces/go-sqlite3library): host names, usernames, folders, tags, table structure, indexes, SQLite journal files. Temporary files use random keys andtemp_store=memoryavoids writing them. - Second layer for secrets: every private key, passphrase and password is encrypted with AES-256-GCM and bound to its own record by the AAD
table|column|id. A blob copied into another record does not open (verified in the tests). Every place in the code that encrypts or decrypts a secret goes throughvault.FieldAADwith the row'sid(checked:data.go,sessions.go,sync.go). - The frontend never receives secrets. Identities reach the interface with indicators only (
hasKey,hasPassphrase,hasPassword); the private key is referenced by a path chosen in the native dialog and does not pass through the webview. Checked ininternal/core/data.goand in the bindings ofapp.go. .shelleasyfile: header (magic, version, Argon2id parameters, salt, nonce) authenticated as AAD, then AES-256-GCM over the compressed JSON. The plaintext after decompression is limited to 64 MiB and the file read to 128 MiB.
4. What stays in the clear (metadata)
Whoever has the files but not the password does not read the data, but can see:
- That a ShellEasy data folder exists, the names of the files (
vault.json, the database,backups/) and their sizes and modification dates. The database size gives a rough idea of how much data it holds; the dates show when the app was used and when changes were saved. - The vault parameters: salt, Argon2id costs, format version (
vault.json). They are not secrets, but they show how much a password-guessing attempt costs. - The approximate length of the secrets inside the database is hidden (they are encrypted in 4 KiB blocks), but the overall size is not.
- Of the
.shelleasyfiles: the size (≈ amount of data after compression) and the header parameters. - Outside the vault, depending on the system: the most recently used folders, the operating system's recent items, the local shell history, the temporary files of «Remote edit» while they are open (user's temporary folder 0700, files 0600, removed on close).
- The interface preferences saved in the webview's
localStorage(keyses.*): they contain no server data.
The system keychain, if fingerprint unlock (Touch ID) is turned on, contains the Master Password (§5.5).
5. Limits and choices, one by one
5.1 The Master Password is the only barrier
The attacker of §2 (1) can try passwords offline. Each attempt costs one Argon2id run with 64 MiB, 3 passes, 4 threads: this makes the attack slow, but a weak password stays weak. The minimum required length is 8 characters; a phrase of 4-5 words is much better than a word with symbols. There is no recovery: if the password is lost, the data is lost (a design choice, «zero-knowledge»). This is why a .shelleasy export is recommended.
The parameters cannot go below the minimum (3 passes, 64 MiB, 4 threads) nor above a ceiling (16 passes, 1 GiB, 16 threads): see §6.1.
5.2 Database encryption: what Adiantum gives and what it does not
Adiantum is a disk encryption scheme (the file is divided into 4 KiB blocks, each encrypted as a whole, with the same plaintext in the same position giving the same ciphertext). Its threat model is: the adversary sees several copies of the disk and learns only which blocks have changed. Three limits follow, and they apply to ShellEasy as well:
- It is deterministic. Whoever has two copies of the database (for example two backups from different days) sees which 4 KiB blocks changed, which did not, and which went back to an earlier value, but not what is inside them. A
VACUUMrebuilds the file and erases much of this information; ShellEasy does not run it on its own initiative. - It does not authenticate the pages. A modified block decrypts to random data (the whole block, not a single bit), which SQLite normally detects as corruption, but this is not a guarantee: Adiantum does not claim tamper protection. An attacker who modifies the file can therefore, in the worst case, corrupt or cause the loss of data, or replay genuine blocks or whole files that existed before (see point 3); without the key they cannot construct content of their own choosing.
- It does not protect against restoring an old copy. Whoever replaces the database with an earlier version of it (same key) gets a valid but old database: hosts and identities deleted or changed after that date reappear as they were. The app does not notice.
What still protects the integrity of the secrets. The credentials do not depend on Adiantum for authenticity: they are AES-GCM blobs with AAD bound to the record. A tampered block or a moved blob makes opening the secret fail (an error, not wrong data). An old but genuine blob of the same record (rollback of a single secret or of the whole file) does open, instead: this is the limit of point 3.
Practical consequences for the user:
- A copy of the database is not a safe backup to keep where others can write to it: a backup is for recovering, not for trusting. The app's own backups (
backups/, the last 10 copies made before each import) are encrypted copies with permissions 0700/0600. - The database is never rebuilt or replaced automatically from an unverified copy.
- The most solid recovery medium is the
.shelleasyexport (AES-GCM, authenticated as a whole).
5.3 vault.json: MAC and version counter — evaluated and not adopted
An integrity check on the file was evaluated, for example a MAC on vault.json plus a version counter. Conclusion:
- A MAC on
vault.jsonadds nothing. The file is already authenticated for what matters: the wrapped DEK is an AES-GCM blob with AAD; any change to the salt, the parameters or the blob changes the derived key or breaks the authentication, and unlocking fails (ErrWrongPassword), without ever producing a wrong key. The attacker of §2 (2) can prevent unlocking (delete or corrupt the file), not hijack it. - A version counter does not hold without an anchor outside the adversary's reach. If the counter is in the same folder, whoever can restore an old file restores the counter too. An anchor in the system keychain would be needed (which exists only where the keychain is available and tied to biometrics, today only macOS) and it would remain valid only against someone who cannot modify that as well. It is not adopted now; it remains as an idea recorded in the follow-up issue (§9).
- Restoring an old
vault.jsondoes, however, have a real effect: the DEK does not change when the Master Password is changed (it is only re-wrapped, a design choice to avoid re-encrypting everything). So whoever knows the old password and has an old copy ofvault.json(for example in a backup) and a copy of the database can still open it, even after the user has changed the password because of a suspicion that it was compromised. If you think the password has been compromised, changing it is not enough: create a new vault with a new password and import a.shelleasyexport. A DEK rotation on password change is recorded in the follow-up (§9).
5.4 Lock, open sessions and memory
- Auto-lock after 15 minutes of interface inactivity (fixed value, not yet configurable) and on request. Locking closes the database and zeroes the DEK. SSH sessions that are already authenticated and tunnels stay active after locking (decision D3, so as not to interrupt work in progress); opening new ones requires unlocking. Whoever wants to close everything must close the sessions or the app.
- Zeroization is not guaranteed. The app keeps secrets as
[]byteand zeroes them after use (vault.Zero; locking zeroes the DEK), but Go does not ensure that the garbage collector or stack growth leave no copies, and not all data passes through[]byte:- the Master Password arrives from the interface as a string (the bridge between the webview and Go carries it that way) and copies remain in the memory of the webview and of the bridge buffers, which the app does not control;
- the database key is passed to the SQLite driver as a hexadecimal string (
PRAGMA hexkey, immutable in Go) and lives in the driver's memory until the process ends; closing the database is not a verified erasure; - the private keys read to authenticate a session remain in the SSH client's memory for as long as the session exists;
- memory is not locked (
mlock) and may end up in swap or in a crash/hibernation file. So whoever can read the process memory or a disk/RAM dump may find secrets even after locking. This is a declared limit, not an isolated defect: see also §2.
- Secrets do not appear in logs or error messages (checked by searching the code: no application logging; the only
log.Fatalfis inmain.goand prints the Wails startup error).
5.5 Fingerprint unlock (Touch ID)
The Master Password is held by the system behind biometrics and is never written to disk by the app. On macOS, with a signed app and a provisioning profile, the secret sits in the keychain with kSecAccessControlBiometryCurrentSet and WhenUnlockedThisDeviceOnly: the system asks for the fingerprint on read and the secret is invalidated if the enrolled fingerprints change. An unsigned build, or one with an ad-hoc signature, uses a weaker level (app): the secret is still in the keychain and another program cannot read it without the system's consent, but the fingerprint prompt is a check made by the app and not by the system; the interface indicates the level in use. Unlocking uses the same Unlock: there is no alternative path to the key. Not verified on Windows (Windows Hello) and Linux: they are not implemented yet.
5.6 Server keys (TOFU)
On the first connection the app shows the SHA256 fingerprint and asks for confirmation; the key is saved on the device. If the server later presents a different key (or one of a different type from those known), the connection is blocked with an explicit error, with no possibility of accepting on the fly (HostKeyChangedError). There is no InsecureIgnoreHostKey anywhere in the code (checked). Intrinsic limit of TOFU: if the first connection is already intercepted, the attack is not visible; verifying the fingerprint through a different channel is left to the user. Known keys are not synchronized between devices: trust is per device.
5.7 A hostile SFTP server
The server chooses the names of the files it lists. As long as the names are only used for display, the risk is low (the interface does not use v-html/innerHTML: checked). The real risk is in transfers: when downloading a folder, a name such as ..\..\Startup\x.exe on Windows, where \ separates folders, escaped from the folder chosen by the user. Fixed in this revision (§6.2): names read from a remote side are checked before building any local path; dangerous ones are skipped and appear in the transfer notes. The SFTP client already discards . and .. and uses path.Base for names. Remote symbolic links are never followed in transfers.
In addition, incoming files are now created with permissions 0600 and take the original's permissions only once the copy is finished, so a restricted file is not readable by other users while it is partial.
5.8 A hostile .shelleasy file
The header is authenticated, but its numeric parts are read before the password is verified. A file built to make the app allocate a lot of memory or compute for hours had to be rejected up front: fixed with the ceiling on the parameters (§6.1). After opening: maximum size of the file read (128 MiB), of the decompressed envelope (64 MiB, against decompression «bombs»), maximum number of items (200,000), validation of every field, no content executed. The import shows a preview, makes a safety copy of the database and is a single transaction. A hostile file supplied by someone who knows the password can still insert hosts or snippets with content of their choosing: snippets reject control characters (ESC, C1, DEL), which in the terminal would serve to hide or alter what is pasted; the user still sees the final command before inserting it, if it has parameters.
5.9 Import from ~/.ssh/config
The file is read and interpreted (including nested Include with a depth limit and cycle detection) but nothing is executed: ProxyCommand is not supported, the host is reported as not importable and the app never launches that command.
5.10 Tunnels and proxies
By default, listening is only on 127.0.0.1. If an address reachable from the network is chosen, the app shows a warning in the dialog and in the list (with SOCKS5 it would be a proxy open to everyone, without authentication). A local tunnel listening on 127.0.0.1 is nevertheless reachable by any program on the same computer (other users included, on multi-user systems): this is the behavior of ssh -L and it is documented, not mitigated. Locking the vault does not stop tunnels.
5.11 The webview and the frontend
The frontend is a local page served from the app package, with no remote content and no v-html/innerHTML/eval (checked), with the Nuxt development tools disabled (and the webview inspector, which Wails enables only in debug builds). Terminal output goes through xterm.js as text. There is no Content-Security-Policy: today it would not be trivial to set one because the generated page contains inline scripts (import map and initial data); making it strict requires hashes or nonces. It is recorded as an improvement (§9): it would be defense in depth, not the fix for a known hole.
5.12 Updates and supply chain
No automatic updates and no telemetry: the frontend loads no remote resources and the only outgoing connections are those to the SSH hosts the user opens and, only if the user turns it on, to the AI assistant's server (§5.13). The app's only HTTP client is internal/ai. Go and npm dependencies are pinned by lock files; appimagetool is pinned by version and checksum. Release builds are not yet signed (macOS/Windows): until they are, the integrity of the downloaded package can only be verified with SHA256SUMS. There is not yet an automatic scan for vulnerable dependencies: to be added (§9).
5.13 Local AI assistant
The assistant (explain, from description to command, summarize, snippet help) is off by default and opens a new channel: it carries terminal text to a model server chosen by the user. No model is included and no request is sent without the user having seen and submitted it.
Where the text can go.
- By default only to the user's own computer: only
localhostand loopback addresses written out in full (127.x,::1) are «local». Any name is considered remote even if today it points to the local computer, because name resolution can change (DNS rebinding). - A non-local server can be used only after an explicit confirmation, tied to that name and that port: changing server cancels the confirmation. With a non-local server and a key,
https://is required. - At connection time, for local servers, the actual address must be a loopback address, otherwise the connection is refused. Redirects are not followed and proxy environment variables are ignored.
- The server's key, if there is one, is encrypted in the database (AES-GCM with a dedicated AAD), never leaves toward the interface and is not sent to a server other than the configured one. The configuration is not synchronized.
What is sent. Every request goes through a preview built by the backend: the text shown is the text that is sent, byte for byte (tested), to the destination shown (host, port, model); if the settings change after the preview, sending is refused. Before the preview, the text and the request go through a filter that masks private keys, credentials in addresses, authorization headers, known tokens (GitHub, AWS, Slack, JWT…), values of variables with names such as password, secret, token, api_key, command-line options (--password, sshpass -p, mysql -p…, curl -u) and the passwords and passphrases saved in the vault if they appear in the text. The preview says what was masked. At most the last 12,000 characters of the terminal are sent.
Limits of the filter (also declared in the preview). It recognizes secrets by shape and by known value: it cannot recognize a password written in plain text without a name that gives it away, a secret that is not in the vault, nor sensitive information that is not a secret (host names, IP addresses, paths, file contents, personal data in the output). With a non-local server, all of this leaves the computer. This is why the preview must be read.
Programs the app starts (server detection). The Start button makes the app run an external program, so it is a point to be kept tight: only ollama serve and lms server start|stop are run, looked up by fixed name in the PATH and in known locations; no command, argument or path comes from the user, the interface or the model. Ollama is started with OLLAMA_HOST=127.0.0.1:port enforced, so it cannot listen on the network; processes started by the app are closed with the app and Stop concerns only those. Residual risk: whoever can already place an executable named ollama in the user's PATH gets execution when the user presses Start (the same level as someone who can already modify the user's environment, §2). The Download button opens in the browser a page chosen from a fixed table in the backend (HTTPS, three official addresses): the app downloads and installs nothing and makes no connections to the Internet; what is downloaded and installed is decided by the user in the browser. The lms commands have not yet been tried on a real LM Studio.
The model is an untrusted entity.
- Hostile terminal output (prompt injection): a server or a file can make text print that «gives orders» to the model. Mitigations: the text is enclosed between delimiters that it cannot close (they are neutralized) and the instructions say to treat it as data; the model has no tools and no access to the system, so it can only produce text. It remains possible that the explanation is misleading or that the proposed command is harmful.
- Proposed command: it is never executed. It is inserted into the prompt as a paste (without Enter) and the user launches it; the interface shows the command in a box, warns if it has more than one line (the shell might not support bracketed paste) and strips every control character from it, ESC included, so that a hostile response cannot close the bracketed paste and get something else executed. The residual risk is that the user presses Enter without reading.
- Excessive or slow response: cap of 256 KB per response, interruption after 120 s without data, at most 3 requests at the same time.
Vault lock. Requests in progress are interrupted and previews not yet sent are forgotten.
Not verified: compatibility with real servers (Ollama, LM Studio, llama.cpp) is tested with a fake server that imitates the OpenAI API, not with the real programs; streaming formats may have unforeseen variants. The filter has not been evaluated on large real outputs: false positives (text masked for no reason) are possible and preferable to false negatives.
6. Fixes made in this revision
| # | Problem | Fix | Evidence |
|---|---|---|---|
| 6.1 | The Argon2id parameters of vault.json and of .shelleasy envelopes had only a minimum: a hostile header could request gigabytes or hours of computation before the password check | Ceiling: 16 passes, 1 GiB, 16 threads (vault.MaxTime/MaxMemoryKiB/MaxThreads, error ErrHugeParams), applied on creation, unlock, export and import | TestParamsCeiling, TestHugeParamsRejectedOnOpen: rejection happens in under a second, without deriving keys |
| 6.2 | File names read from an SFTP server used without checking to build local paths (escape from the destination folder on Windows; reserved names; NTFS alternate streams) | unsafeName / safeReadDir / checkTopName: discarded "", ., .., /, NUL and, on Windows, \ : * ? " < > |, control characters, names ending in a dot/space and device names (CON, NUL, COM1…); discarded ones end up in the transfer notes | TestUnsafeName, TestSafeReadDirSkipsHostileNames |
| 6.3 | Local files created with permissions 0644 during download | Created with 0600; the original's permissions are applied once the copy is finished | TestLocalCreateIsPrivate |
The full Go suite also passes with -race. Not verified: the behavior of 6.2 on real Windows (the Windows rules are tested with a table, not on a Windows file system).
7. Outside the vault: what the app does not protect
- Data passing through sessions (terminal output, transferred files, what is pasted): not encrypted at rest and dependent on the computer and the server. If the AI assistant is used, the text you decide to send leaves the vault for the model server (§5.13). The terminal's scrollback and the local shell use the operating system's memory and files.
- Downloaded files stay on the destination disk with that disk's protections.
- The system clipboard: a copied secret stays in the clipboard until something replaces it.
- The
.shelleasyexports are protected by their own password for their whole lifetime: store them the way you would store keys.
8. External review
Recommendation: yes, before version 1.0 and before recommending the app for production credentials, limited to a small, clear perimeter:
internal/vault,internal/envelope,internal/storeand the unlock point (internal/core/core.go): about 1,000 lines, a review of a few days.- The handling of server keys (
internal/sshconn/hostkey.go) and of transfers (§5.7). - The biometric integration on macOS (
internal/biometric).
There is no planned budget: zero-cost alternatives are a peer review by someone who knows Go and cryptography, publishing the code together with this document to attract reports, and enabling GitHub's security alerts. Until a review is done, the honest wording is: cryptography with standard primitives and documented choices, not verified by third parties.
9. Open follow-ups
- DEK rotation on password change (optional): re-encrypt the database and the secrets with a new DEK, so that an old copy of
vault.jsonplus the old password no longer open anything. It is a long and delicate operation (atomic copy, rollback on error). - Detect the restoring of old copies with an anchor in the system keychain (§5.3).
- Content-Security-Policy for the webview (§5.11).
- Automatic dependency scanning (
govulncheck,pnpm audit) in the checks (§5.12). - Package signing and verification of the AppImage/Windows builds.
- Windows Hello and biometric unlock on Linux.