add ui for idle timeout

This commit is contained in:
Joseph Montanaro 2024-02-06 20:27:51 -08:00
parent 141334f7e2
commit 87617a0726
9 changed files with 155 additions and 32 deletions

View File

@ -9,7 +9,7 @@ The following is a list of security features that I hope to add eventually, in a
* To defend against the possibility that an attacker could replace, say, the `aws` executable with a malicious one that snarfs your credentials and then passes the command on to the real one, maybe track the path (and maybe even the hash) of the executable, and raise a warning if this is the first time we've seen that one? Using the hash would be safer, but would also introduce a lot of false positives, since every time the application gets updated it would trigger. On the other hand, users should presumably know when they've updated things, so maybe it would be ok. On the _other_ other hand, if somebody doesn't use `aws` very often then it might be weeks or months in between updating it and actually using the updated executable, in which case they probably won't remember that this is the first time they've used it since updating. * To defend against the possibility that an attacker could replace, say, the `aws` executable with a malicious one that snarfs your credentials and then passes the command on to the real one, maybe track the path (and maybe even the hash) of the executable, and raise a warning if this is the first time we've seen that one? Using the hash would be safer, but would also introduce a lot of false positives, since every time the application gets updated it would trigger. On the other hand, users should presumably know when they've updated things, so maybe it would be ok. On the _other_ other hand, if somebody doesn't use `aws` very often then it might be weeks or months in between updating it and actually using the updated executable, in which case they probably won't remember that this is the first time they've used it since updating.
Another possible approach is to _watch_ the files in question, and alert the user whenever any of them changes. Presumably the user will know whether this change is expected or not. Another possible approach is to _watch_ the files in question, and alert the user whenever any of them changes. Presumably the user will know whether this change is expected or not.
* Downgrade privileges after launching. In particular, if possible, disallow any kind of outgoing network access (obviously we have to bind the listening socket, but maybe we can filter that down to _just_ the ability to bind that particular address/port) and filesystem access outside of state db. I think this is doable on Linux, although it may involve high levels of `seccomp` grossness. No idea whether it's possible on Windows. Probably possible on MacOS although it may require lengths to which I am currently unwilling to go (e.g. pay for a certificate from Apple or something.) * Downgrade privileges after launching. In particular, if possible, disallow any kind of outgoing network access (obviously we have to bind the listening socket, but maybe we can filter that down to _just_ the ability to bind that particular address/port) and filesystem access outside of state db. I think this is doable on Linux, although it may involve high levels of `seccomp` grossness. No idea whether it's possible on Windows. Probably possible on MacOS although it may require lengths to which I am currently unwilling to go (e.g. pay for a certificate from Apple or something.)
* "Panic button" - if a potential attack is detected (e.g. the user denies a request but Creddy discovers the request has already succeeded somehow), offer a one-click option to lock out the current IAM user. (Sadly, you can't revoke session tokens, so this is the only way to limit a potential compromise). Not sure how feasible this is, session credentials may be limited with regard to what kind of IAM operations they can carry out.) * "Panic button" - if a potential attack is detected (e.g. the user denies a request but Creddy discovers the request has already succeeded somehow), offer a one-click option to lock out the current IAM user. Sadly, you can't revoke session tokens, so this is the only way to limit a potential compromise. Obviously this would require the current user having the ability to revoke their own IAM permissions.)
* Some kind of Yubikey or other HST integration. (Optional, since not everyone will have a HST.) This comes in two flavors: * Some kind of Yubikey or other HST integration. (Optional, since not everyone will have a HST.) This comes in two flavors:
1. (Probably doable) Store the encryption key for the passphrase on the HST, and ask the HST to decrypt the passphrase instead of asking the user to enter it. This has the advantage of being a) lower-friction, since the user doesn't have to type in the passphrase, and b) more secure, since the application code never sees the encryption key. 1. (Probably doable) Store the encryption key for the passphrase on the HST, and ask the HST to decrypt the passphrase instead of asking the user to enter it. This has the advantage of being a) lower-friction, since the user doesn't have to type in the passphrase, and b) more secure, since the application code never sees the encryption key.
2. (Less doable) Store the actual AWS secret key on the HST, and then ask the HST to just sign the whole `GetSessionToken` request. This requires that the HST support the exact signing algorithm required by AWS, which a) it probably doesn't, and b) is subject to change anyway. So this is probably not doable, but it's worth at least double-checking, since it would provide the maximum theoretical level of security. (That is, after initial setup, the application would never again see the long-lived AWS secret key.) 2. (Less doable) Store the actual AWS secret key on the HST, and then ask the HST to just sign the whole `GetSessionToken` request. This requires that the HST support the exact signing algorithm required by AWS, which a) it probably doesn't, and b) is subject to change anyway. So this is probably not doable, but it's worth at least double-checking, since it would provide the maximum theoretical level of security. (That is, after initial setup, the application would never again see the long-lived AWS secret key.)
@ -19,9 +19,9 @@ Another possible approach is to _watch_ the files in question, and alert the use
Who exactly are we defending against and why? Who exactly are we defending against and why?
The basic idea behind Creddy is that it provides "gap coverage" between two wildly different security boundaries: 1) the older, user-based model, where all code executing as a given user is assumed to have the same level of trust, and 2) the newer, application-based model (most clearly seen on mobile devices) where that bondary instead exists around each _application_. The basic idea behind Creddy is that it provides "gap coverage" between two wildly different security models: 1) the older, user-based model, where all code executing as a given user is assumed to have the same level of trust, and 2) the newer, application-based model (most clearly seen on mobile devices) where that bondary instead exists around each _application_.
The unfortunate reality is that desktop environments are unlikely to adopt the latter model any time soon, if ever. This is primarily due to friction: Per-application security is a nightmare to manage. The only reason it works at all on mobile devices is because most mobile apps eschew the local device in favor of cloud-backed services where they can, e.g. for file storage. Arguably, the higher-friction trust model of mobile environments is in part _why_ mobile apps tend to be cloud-first. The unfortunate reality is that desktop environments are unlikely to adopt the latter model any time soon, if ever. This is primarily due to friction: Per-application security is a nightmare to manage. The only reason it works at all on mobile devices is because most mobile apps eschew the local device in favor of cloud-backed services where they can, e.g. for file storage. Arguably, the higher-friction trust model of mobile environments (along with the frequently-replaced nature of mobile devices) is in part _why_ mobile apps tend to be cloud-first.
Regardless, we live in a world where it's difficult to run untrusted code without giving it an inordinate level of access to the machine on which it runs. Creddy attempts to prevent that access from including your AWS credentials. The threat model is thus "untrusted code running under your user". This is especially likely to occur in the form of a supply-chain attack, where the compromised code is not your own but rather a dependency, or a dependency of a dependency, etc. Regardless, we live in a world where it's difficult to run untrusted code without giving it an inordinate level of access to the machine on which it runs. Creddy attempts to prevent that access from including your AWS credentials. The threat model is thus "untrusted code running under your user". This is especially likely to occur in the form of a supply-chain attack, where the compromised code is not your own but rather a dependency, or a dependency of a dependency, etc.
@ -31,13 +31,13 @@ There are lots of ways that I can imagine someone might try to circumvent Creddy
### Tricking Creddy into allowing a request that it shouldn't ### Tricking Creddy into allowing a request that it shouldn't
If an attacker is able to compromise Creddy's frontend, e.g. via a JS library that Creddy relies on, they could forge "request accepted" responses and cause the backend to hand out credentials to an unauthorized client. Most likely, the user would immediately be alerted to the fact that Something Is Up because as soon as the request came in, Creddy would pop up requesting permission. When the user (presumably) denied the request, Creddy would discover that the request had already been approved - we could make this a high-alert situation because it would be unlikely to happen unless something fishy were going on. Additionally, the request and (hopefully) what executable made it would be logged. If an attacker is able to compromise Creddy's frontend, e.g. via a JS library that Creddy relies on, they could forge "request accepted" responses and cause the backend to hand out credentials to an unauthorized client. Most likely, the user would immediately be alerted to the fact that Something Is Up because Creddy would pop up requesting then permission, and then immediately disappear again because the request had been approved. Additionally, the request and (hopefully) what executable made it would be logged.
### Tricking the user into allowing a request they didn't intend to ### Tricking the user into allowing a request they didn't intend to
If an attacker can edit the user's .bashrc or similar, they could theoretically insert a function or pre-command hook that wraps, say, the `aws` command, and dump the credentials before continuing on with the user's command. This would most likely alert the user because either a) the attacker is hijacking the original `aws` command and thus it doesn't do what the user told it to, or b) the user's original `aws` command proceeds as normal after the malicious one, and the user is alerted by the second request where there should only have been one. If an attacker can edit the user's .bashrc or similar, they could theoretically insert a function or pre-command hook that wraps, say, the `aws` command, and dump the credentials before continuing on with the user's command. The attacker could inject the credentials into the environment before running the original command, so as to avoid alerting the user by issuing a second credentials request.
A similar but more-difficult-to-detect attack would be replacing the `aws` executable, or any other executable that is always expected to ask for AWS credentials, with a malicious wrapper that snarfs the credentials before passing them through to the original command. Creddy could defend against this to a certain extent by storing the hash of the executable, as discussed above. Another attack along the same lines would be replacing the `aws` executable, or any other executable that is always expected to ask for AWS credentials, with a malicious wrapper that snarfs the credentials before passing them through to the original command. Creddy could defend against this to a certain extent by storing the hash of the executable, as discussed above.
### Pretending to be the user ### Pretending to be the user
@ -46,3 +46,5 @@ Most desktop environments don't prevent applications from simulating user-input
### Twiddling with Creddy's persistent state ### Twiddling with Creddy's persistent state
The solutions to or mitigations for a lot of these attacks rely on Creddy being able to assume that its local database hasn't been tampered with. Unfortunately, given that our threat model is "other code running as the same user", this isn't a safe assumption. The solutions to or mitigations for a lot of these attacks rely on Creddy being able to assume that its local database hasn't been tampered with. Unfortunately, given that our threat model is "other code running as the same user", this isn't a safe assumption.
The solution to this problem is probably just to encrypt the entire database. This introduces a bit of complexity since certain settings, like `start_on_login` and `start_minimized`, will need to be accessible before the app is unlocked,but these settings can probably just be stashed alongside the database and kept in sync on every config save.

View File

@ -1,6 +1,6 @@
{ {
"name": "creddy", "name": "creddy",
"version": "0.4.5", "version": "0.4.6",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",

View File

@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { getVersion } from '@tauri-apps/api/app';
import { appState, acceptRequest, cleanupRequest } from './lib/state.js'; import { appState, acceptRequest, cleanupRequest } from './lib/state.js';
import { views, currentView, navigate } from './lib/routing.js'; import { views, currentView, navigate } from './lib/routing.js';
@ -11,6 +12,8 @@ $views = import.meta.glob('./views/*.svelte', {eager: true});
navigate('Home'); navigate('Home');
invoke('get_config').then(config => $appState.config = config); invoke('get_config').then(config => $appState.config = config);
invoke('get_session_status').then(status => $appState.credentialStatus = status);
getVersion().then(version => $appState.appVersion = version);
listen('credentials-request', (tauriEvent) => { listen('credentials-request', (tauriEvent) => {
$appState.pendingRequests.put(tauriEvent.payload); $appState.pendingRequests.put(tauriEvent.payload);
@ -40,6 +43,10 @@ listen('launch-terminal-request', async (tauriEvent) => {
} }
}); });
listen('locked', () => {
$appState.credentialStatus = 'locked';
});
invoke('get_setup_errors') invoke('get_setup_errors')
.then(errs => { .then(errs => {
$appState.setupErrors = errs.map(e => ({msg: e, show: true})); $appState.setupErrors = errs.map(e => ({msg: e, show: true}));
@ -49,4 +56,9 @@ acceptRequest();
</script> </script>
<svelte:window
on:click={() => invoke('signal_activity')}
on:keydown={() => invoke('signal_activity')}
/>
<svelte:component this="{$currentView}" /> <svelte:component this="{$currentView}" />

View File

@ -9,6 +9,7 @@ export let appState = writable({
pendingRequests: queue(), pendingRequests: queue(),
credentialStatus: 'locked', credentialStatus: 'locked',
setupErrors: [], setupErrors: [],
appVersion: '',
}); });

View File

@ -1,6 +1,6 @@
<script> <script>
export let keys; export let keys;
let classes; let classes = '';
export {classes as class}; export {classes as class};
</script> </script>

View File

@ -0,0 +1,92 @@
<script>
import Setting from './Setting.svelte';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let title;
// seconds are required
export let seconds;
export let min = 0;
export let max = null;
// best unit is the unit that results in the smallest non-fractional number
let unit = null;
const UNITS = {
Seconds: 1,
Minutes: 60,
Hours: 3600,
Days: 86400,
};
if (unit === null) {
let min = Infinity;
let bestUnit = null;
for (const [u, multiplier] of Object.entries(UNITS)) {
const v = seconds / multiplier;
if (v < min && v >= 1) {
min = v;
bestUnit = u;
}
}
unit = bestUnit;
}
// local value is only one-way synced to value so that we can better handle changes
$: localValue = (seconds / UNITS[unit]).toString();
let error = null;
function updateValue() {
localValue = localValue.replace(/[^0-9.]/g, '');
// Don't update the value, but also don't error, if it's empty,
// or if it could be the start of a float
if (localValue === '' || localValue === '.') {
error = null;
return;
}
const num = parseFloat(localValue);
if (num < 0) {
error = `${num} is not a valid duration`
}
else if (min !== null && num < min) {
error = `Too low (minimum ${min * UNITS[unit]}`;
}
else if (max !== null & num > max) {
error = `Too high (maximum ${max * UNITS[unit]}`;
}
else {
error = null;
seconds = Math.round(num * UNITS[unit]);
dispatch('update', {seconds});
}
}
</script>
<Setting {title}>
<div slot="input">
<select class="select select-bordered select-sm mr-2" bind:value={unit}>
{#each Object.keys(UNITS) as u}
<option selected={u === unit || null}>{u}</option>
{/each}
</select>
<div class="tooltip tooltip-error" class:tooltip-open={error !== null} data-tip={error}>
<input
type="text"
class="input input-sm input-bordered text-right"
size={Math.max(5, localValue.length)}
class:input-error={error}
bind:value={localValue}
on:input={updateValue}
>
</div>
</div>
<slot name="description" slot="description"></slot>
</Setting>

View File

@ -3,3 +3,4 @@ export { default as ToggleSetting } from './ToggleSetting.svelte';
export { default as NumericSetting } from './NumericSetting.svelte'; export { default as NumericSetting } from './NumericSetting.svelte';
export { default as FileSetting } from './FileSetting.svelte'; export { default as FileSetting } from './FileSetting.svelte';
export { default as TextSetting } from './TextSetting.svelte'; export { default as TextSetting } from './TextSetting.svelte';
export { default as TimeSetting } from './TimeSetting.svelte';

View File

@ -25,31 +25,29 @@
<div class="flex flex-col h-screen items-center justify-center p-4 space-y-4"> <div class="flex flex-col h-screen items-center justify-center p-4 space-y-4">
<div class="flex flex-col items-center space-y-4"> <div class="flex flex-col items-center space-y-4">
{@html vaultDoorSvg} {@html vaultDoorSvg}
{#await invoke('get_session_status') then status} {#if $appState.credentialStatus === 'locked'}
{#if status === 'locked'}
<h2 class="text-2xl font-bold">Creddy is locked</h2> <h2 class="text-2xl font-bold">Creddy is locked</h2>
<Link target="Unlock" hotkey="Enter" class="w-64"> <Link target="Unlock" hotkey="Enter" class="w-64">
<button class="btn btn-primary w-full">Unlock</button> <button class="btn btn-primary w-full">Unlock</button>
</Link> </Link>
{:else if status === 'unlocked'} {:else if $appState.credentialStatus === 'unlocked'}
<h2 class="text-2xl font-bold">Waiting for requests</h2> <h2 class="text-2xl font-bold">Waiting for requests</h2>
<button class="btn btn-primary w-full" on:click={launchTerminal}> <button class="btn btn-primary w-full" on:click={launchTerminal}>
Launch Terminal Launch Terminal
</button> </button>
<label class="label cursor-pointer flex items-center space-x-2"> <label class="label cursor-pointer flex items-center space-x-2">
<span class="label-text">Launch with long-lived credentials</span> <span class="label-text">Launch with long-lived credentials</span>
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={launchBase}> <input type="checkbox" class="checkbox checkbox-sm" bind:checked={launchBase}>
</label> </label>
{:else if status === 'empty'} {:else if $appState.credentialStatus === 'empty'}
<h2 class="text-2xl font-bold">No credentials found</h2> <h2 class="text-2xl font-bold">No credentials found</h2>
<Link target="EnterCredentials" hotkey="Enter" class="w-64"> <Link target="EnterCredentials" hotkey="Enter" class="w-64">
<button class="btn btn-primary w-full">Enter Credentials</button> <button class="btn btn-primary w-full">Enter Credentials</button>
</Link> </Link>
{/if} {/if}
{/await}
</div> </div>
</div> </div>

View File

@ -8,7 +8,7 @@
import ErrorAlert from '../ui/ErrorAlert.svelte'; import ErrorAlert from '../ui/ErrorAlert.svelte';
import SettingsGroup from '../ui/settings/SettingsGroup.svelte'; import SettingsGroup from '../ui/settings/SettingsGroup.svelte';
import Keybind from '../ui/settings/Keybind.svelte'; import Keybind from '../ui/settings/Keybind.svelte';
import { Setting, ToggleSetting, NumericSetting, FileSetting, TextSetting } from '../ui/settings'; import { Setting, ToggleSetting, NumericSetting, FileSetting, TextSetting, TimeSetting } from '../ui/settings';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { backInOut } from 'svelte/easing'; import { backInOut } from 'svelte/easing';
@ -38,7 +38,7 @@
<h1 slot="title" class="text-2xl font-bold">Settings</h1> <h1 slot="title" class="text-2xl font-bold">Settings</h1>
</Nav> </Nav>
<div class="max-w-lg mx-auto mt-1.5 mb-24 p-4 space-y-16"> <div class="max-w-lg mx-auto my-1.5 p-4 space-y-16">
<SettingsGroup name="General"> <SettingsGroup name="General">
<ToggleSetting title="Start on login" bind:value={config.start_on_login}> <ToggleSetting title="Start on login" bind:value={config.start_on_login}>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
@ -60,6 +60,20 @@
</svelte:fragment> </svelte:fragment>
</NumericSetting> </NumericSetting>
<ToggleSetting title="Lock when idle" bind:value={config.auto_lock}>
<svelte:fragment slot="description">
Automatically lock Creddy after a period of inactivity.
</svelte:fragment>
</ToggleSetting>
{#if config.auto_lock}
<TimeSetting title="Idle timeout" bind:seconds={config.lock_after.secs}>
<svelte:fragment slot="description">
How long to wait before automatically locking.
</svelte:fragment>
</TimeSetting>
{/if}
<Setting title="Update credentials"> <Setting title="Update credentials">
<Link slot="input" target="EnterCredentials"> <Link slot="input" target="EnterCredentials">
<button class="btn btn-sm btn-primary">Update</button> <button class="btn btn-sm btn-primary">Update</button>
@ -91,6 +105,9 @@
</div> </div>
</SettingsGroup> </SettingsGroup>
<p class="text-sm text-right">
Creddy {$appState.appVersion}
</p>
</div> </div>
{#if error} {#if error}