7 Commits

Author SHA1 Message Date
8c668e51a6 still in progress 2024-06-25 15:19:29 -04:00
9928996fab get backend running 2024-06-19 05:10:55 -04:00
d0a2532c27 start working on generalizing credential logic 2024-06-16 07:08:10 -04:00
0491cb5790 fix error popup on startup 2024-06-03 01:33:35 -04:00
816bd7db00 upgrade to tauri 2.0 beta 2024-06-02 17:20:37 -04:00
b165965289 update todo 2024-05-08 11:37:07 -04:00
86896d68c2 update Cargo.lock 2024-02-24 19:42:39 -08:00
43 changed files with 8933 additions and 1980 deletions

4
.gitignore vendored
View File

@ -5,7 +5,3 @@ src-tauri/target/
# .env is system-specific
.env
.vscode
# just in case
credentials*
!credentials.rs

View File

@ -2,11 +2,12 @@
* ~~Switch to "process" provider for AWS credentials (much less hacky)~~
* ~~Frontend needs to react when request is cancelled from backend~~
* Session timeout (plain duration, or activity-based?)
* Indicate on approval screen when additional requests are pending
* ~~Session timeout~~
* ~~Fix rehide behavior when new request comes in while old one is still being resolved~~
* Additional hotkey configuration (approve/deny at the very least)
* ~~Switch tray menu item to Hide when window is visible~~
* Clear password input after unlock fails
* Indicate on approval screen when additional requests are pending
* Additional hotkey configuration (approve/deny at the very least)
* Logging
* Icon
* Auto-updates

370
package-lock.json generated
View File

@ -1,19 +1,21 @@
{
"name": "creddy",
"version": "0.4.1",
"version": "0.4.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "creddy",
"version": "0.4.1",
"version": "0.4.7",
"dependencies": {
"@tauri-apps/api": "^1.0.2",
"@tauri-apps/api": "^2.0.0-beta.13",
"@tauri-apps/plugin-dialog": "^2.0.0-beta.5",
"@tauri-apps/plugin-os": "^2.0.0-beta.5",
"daisyui": "^2.51.5"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^1.0.1",
"@tauri-apps/cli": "^1.0.5",
"@tauri-apps/cli": "^2.0.0-beta.20",
"autoprefixer": "^10.4.8",
"postcss": "^8.4.16",
"svelte": "^3.49.0",
@ -81,30 +83,30 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
"integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
"integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
"dependencies": {
"@jridgewell/set-array": "^1.0.1",
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.9"
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
"integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
"integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"engines": {
"node": ">=6.0.0"
}
@ -115,9 +117,9 @@
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.20",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
"integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
@ -186,11 +188,11 @@
}
},
"node_modules/@tauri-apps/api": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-1.5.3.tgz",
"integrity": "sha512-zxnDjHHKjOsrIzZm6nO5Xapb/BxqUq1tc7cGkFXsFkGTsSWgCPH1D8mm0XS9weJY2OaR73I3k3S+b7eSzJDfqA==",
"version": "2.0.0-beta.13",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-beta.13.tgz",
"integrity": "sha512-Np1opKANzRMF3lgJ9gDquBCB9SxlE2lRmNpVx1+L6RyzAmigkuh0ZulT5jMnDA3JLsuSDU135r/s4t/Pmx4atg==",
"engines": {
"node": ">= 14.6.0",
"node": ">= 18",
"npm": ">= 6.6.0",
"yarn": ">= 1.19.1"
},
@ -200,9 +202,9 @@
}
},
"node_modules/@tauri-apps/cli": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-1.5.9.tgz",
"integrity": "sha512-knSt/9AvCTeyfC6wkyeouF9hBW/0Mzuw+5vBKEvzaGPQsfFJo1ZCp5FkdiZpGBBfnm09BhugasGRTGofzatfqQ==",
"version": "2.0.0-beta.20",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.0.0-beta.20.tgz",
"integrity": "sha512-707q9uIc2oNrYHd2dtMvxTrpZXVpart5EIktnRymNOpphkLlB6WUBjHD+ga45WqTU6cNGKbYvkKqTNfshNul9Q==",
"dev": true,
"bin": {
"tauri": "tauri.js"
@ -215,22 +217,22 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "1.5.9",
"@tauri-apps/cli-darwin-x64": "1.5.9",
"@tauri-apps/cli-linux-arm-gnueabihf": "1.5.9",
"@tauri-apps/cli-linux-arm64-gnu": "1.5.9",
"@tauri-apps/cli-linux-arm64-musl": "1.5.9",
"@tauri-apps/cli-linux-x64-gnu": "1.5.9",
"@tauri-apps/cli-linux-x64-musl": "1.5.9",
"@tauri-apps/cli-win32-arm64-msvc": "1.5.9",
"@tauri-apps/cli-win32-ia32-msvc": "1.5.9",
"@tauri-apps/cli-win32-x64-msvc": "1.5.9"
"@tauri-apps/cli-darwin-arm64": "2.0.0-beta.20",
"@tauri-apps/cli-darwin-x64": "2.0.0-beta.20",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.0.0-beta.20",
"@tauri-apps/cli-linux-arm64-gnu": "2.0.0-beta.20",
"@tauri-apps/cli-linux-arm64-musl": "2.0.0-beta.20",
"@tauri-apps/cli-linux-x64-gnu": "2.0.0-beta.20",
"@tauri-apps/cli-linux-x64-musl": "2.0.0-beta.20",
"@tauri-apps/cli-win32-arm64-msvc": "2.0.0-beta.20",
"@tauri-apps/cli-win32-ia32-msvc": "2.0.0-beta.20",
"@tauri-apps/cli-win32-x64-msvc": "2.0.0-beta.20"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-1.5.9.tgz",
"integrity": "sha512-7C2Jf8f0gzv778mLYb7Eszqqv1bm9Wzews81MRTqKrUIcC+eZEtDXLex+JaEkEzFEUrgIafdOvMBVEavF030IA==",
"version": "2.0.0-beta.20",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.0.0-beta.20.tgz",
"integrity": "sha512-oCJOCib7GuYkwkBXx+ekamR8NZZU+2i3MLP+DHpDxK5gS2uhCE+CBkamJkNt6y1x6xdVnwyqZOm5RvN4SRtyIA==",
"cpu": [
"arm64"
],
@ -244,9 +246,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-1.5.9.tgz",
"integrity": "sha512-LHKytpkofPYgH8RShWvwDa3hD1ws131x7g7zNasJPfOiCWLqYVQFUuQVmjEUt8+dpHe/P/err5h4z+YZru2d0A==",
"version": "2.0.0-beta.20",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.0.0-beta.20.tgz",
"integrity": "sha512-lC5QSnRExedYN4Ds6ZlSvC2PxP8qfIYBJQ5ktf+PJI5gQALdNeVtd6YnTG1ODCEklfLq9WKkGwp7JdALTU5wDA==",
"cpu": [
"x64"
],
@ -260,9 +262,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-1.5.9.tgz",
"integrity": "sha512-teGK20IYKx+dVn8wFq/Lg57Q9ce7foq1KHSfyHi464LVt1T0V1rsmULSgZpQPPj/NYPF5BG78PcWYv64yH86jw==",
"version": "2.0.0-beta.20",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.0.0-beta.20.tgz",
"integrity": "sha512-nZCeBMHHye5DLOJV5k2w658hnCS+LYaOZ8y/G9l3ei+g0L/HBjlSy6r4simsAT5TG8+l3oCZzLBngfTMdDS/YA==",
"cpu": [
"arm"
],
@ -276,9 +278,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-1.5.9.tgz",
"integrity": "sha512-onJ/DW5Crw38qVx+wquY4uBbfCxVhzhdJmlCYqnYyXsZZmSiPUfSyhV58y+5TYB0q1hG8eYdB5x8VAwzByhGzw==",
"version": "2.0.0-beta.20",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.0.0-beta.20.tgz",
"integrity": "sha512-B79ISVLPVBgwnCchVqwTKU+vxnFYqxKomcR4rmsvxfs0NVtT5QuNzE1k4NUQnw3966yjwhYR3mnHsSJQSB4Eyw==",
"cpu": [
"arm64"
],
@ -292,9 +294,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.5.9.tgz",
"integrity": "sha512-23AYoLD3acakLp9NtheKQDJl8F66eTOflxoPzdJNRy13hUSxb+W9qpz4rRA+CIzkjICFvO2i3UWjeV9QwDVpsQ==",
"version": "2.0.0-beta.20",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.0.0-beta.20.tgz",
"integrity": "sha512-ojIkv/1uZHhcrgfIN8xgn4BBeo/Xg+bnV0wer6lD78zyxkUMWeEZ+u3mae1ejCJNhhaZOxNaUQ67MvDOiGyr5Q==",
"cpu": [
"arm64"
],
@ -308,9 +310,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-1.5.9.tgz",
"integrity": "sha512-9PQA1rE7gh41W2ylyKd5qOGOds55ymaYPml9KOpM0g+cxmCXa+8Wf9K5NKvACnJldJJ6cekWzIyB4eN6o5T+yQ==",
"version": "2.0.0-beta.20",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.0.0-beta.20.tgz",
"integrity": "sha512-xBy1FNbHKlc7T6pOmFQQPECxJaI5A9QWX7Kb9N64cNVusoOGlvc3xHYkXMS4PTr7xXOT0yiE1Ww2OwDRJ3lYsg==",
"cpu": [
"x64"
],
@ -324,9 +326,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-1.5.9.tgz",
"integrity": "sha512-5hdbNFeDsrJ/pXZ4cSQV4bJwUXPPxXxN3/pAtNUqIph7q+vLcBXOXIMoS64iuyaluJC59lhEwlWZFz+EPv0Hqg==",
"version": "2.0.0-beta.20",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.0.0-beta.20.tgz",
"integrity": "sha512-+O6zq5jmtUxA1FUAAwF2ywPysy4NRo2Y6G+ESZDkY9XosRwdt5OUjqAsYktZA3AxDMZVei8r9buwTqUwi9ny/g==",
"cpu": [
"x64"
],
@ -340,9 +342,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-1.5.9.tgz",
"integrity": "sha512-O18JufjSB3hSJYu5WWByONouGeX7DraLAtXLErsG1r/VS3zHd/zyuzycrVUaObNXk5bfGlIP0Ypt+RvZJILN2w==",
"version": "2.0.0-beta.20",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.0.0-beta.20.tgz",
"integrity": "sha512-RswgMbWyOQcv53CHvIuiuhAh4kKDqaGyZfWD4VlxqX/XhkoF5gsNgr0MxzrY7pmoL+89oVI+fiGVJz4nOQE5vA==",
"cpu": [
"arm64"
],
@ -356,9 +358,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-1.5.9.tgz",
"integrity": "sha512-FQxtxTZu0JVBihfd/lmpxo7jyMOesjWQehfyVUqtgMfm5+Pvvw0Y+ZioeDi1TZkFVrT3QDYy8R4LqDLSZVMQRA==",
"version": "2.0.0-beta.20",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.0.0-beta.20.tgz",
"integrity": "sha512-5lgWmDVXhX3SBGbiv5SduM1yajiRnUEJClWhSdRrEEJeXdsxpCsBEhxYnUnDCEzPKxLLn5fdBv3VrVctJ03csQ==",
"cpu": [
"ia32"
],
@ -372,9 +374,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-1.5.9.tgz",
"integrity": "sha512-EeI1+L518cIBLKw0qUFwnLIySBeSmPQjPLIlNwSukHSro4tAQPHycEVGgKrdToiCWgaZJBA0e5aRSds0Du2TWg==",
"version": "2.0.0-beta.20",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.0.0-beta.20.tgz",
"integrity": "sha512-SuSiiVQTQPSzWlsxQp/NMzWbzDS9TdVDOw7CCfgiG5wnT2GsxzrcIAVN6i7ILsVFLxrjr0bIgPldSJcdcH84Yw==",
"cpu": [
"x64"
],
@ -387,6 +389,22 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.0.0-beta.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.0.0-beta.5.tgz",
"integrity": "sha512-jkaBCsx2v6WB6sB77fTMCeijuvT3FlzwschiHnPlD7aU6CHvQgRlpCv/FttPdTq4ih2t6MIlM4oX85hNYgfs6w==",
"dependencies": {
"@tauri-apps/api": "2.0.0-beta.13"
}
},
"node_modules/@tauri-apps/plugin-os": {
"version": "2.0.0-beta.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.0.0-beta.5.tgz",
"integrity": "sha512-Qfs/clZ9R05J+OVOGkko+9OaYaL+xJaGICeQ1G5CnLFpUdTfMV10D+1nBBauxDdiLU4ay5I0iprJ5aG5GJBunQ==",
"dependencies": {
"@tauri-apps/api": "2.0.0-beta.13"
}
},
"node_modules/ansi-regex": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
@ -432,9 +450,9 @@
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="
},
"node_modules/autoprefixer": {
"version": "10.4.16",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz",
"integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==",
"version": "10.4.19",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
"funding": [
{
"type": "opencollective",
@ -450,9 +468,9 @@
}
],
"dependencies": {
"browserslist": "^4.21.10",
"caniuse-lite": "^1.0.30001538",
"fraction.js": "^4.3.6",
"browserslist": "^4.23.0",
"caniuse-lite": "^1.0.30001599",
"fraction.js": "^4.3.7",
"normalize-range": "^0.1.2",
"picocolors": "^1.0.0",
"postcss-value-parser": "^4.2.0"
@ -473,11 +491,14 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": {
@ -489,20 +510,20 @@
}
},
"node_modules/braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dependencies": {
"fill-range": "^7.0.1"
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz",
"integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==",
"version": "4.23.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz",
"integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==",
"funding": [
{
"type": "opencollective",
@ -518,8 +539,8 @@
}
],
"dependencies": {
"caniuse-lite": "^1.0.30001565",
"electron-to-chromium": "^1.4.601",
"caniuse-lite": "^1.0.30001587",
"electron-to-chromium": "^1.4.668",
"node-releases": "^2.0.14",
"update-browserslist-db": "^1.0.13"
},
@ -539,9 +560,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001576",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001576.tgz",
"integrity": "sha512-ff5BdakGe2P3SQsMsiqmt1Lc8221NR1VzHj5jXN5vBny9A6fpze94HiVV/n7XRosOlsShJcvMv5mdnpjOGCEgg==",
"version": "1.0.30001625",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001625.tgz",
"integrity": "sha512-4KE9N2gcRH+HQhpeiRZXd+1niLB/XNLAhSy4z7fI8EzcbcPoAqjNInxVHTiTwWfTIV4w096XG8OtCOCQQKPv3w==",
"funding": [
{
"type": "opencollective",
@ -558,15 +579,9 @@
]
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@ -579,6 +594,9 @@
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
@ -692,9 +710,9 @@
}
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",
"integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==",
"dev": true,
"dependencies": {
"ms": "2.1.2"
@ -733,9 +751,9 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
},
"node_modules/electron-to-chromium": {
"version": "1.4.628",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.628.tgz",
"integrity": "sha512-2k7t5PHvLsufpP6Zwk0nof62yLOsCf032wZx7/q0mv8gwlXjhcxI3lz6f0jBr0GrnWKcm3burXzI3t5IrcdUxw=="
"version": "1.4.787",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.787.tgz",
"integrity": "sha512-d0EFmtLPjctczO3LogReyM2pbBiiZbnsKnGF+cdZhsYzHm/A0GV7W94kqzLD8SN4O3f3iHlgLUChqghgyznvCQ=="
},
"node_modules/emoji-regex": {
"version": "9.2.2",
@ -1100,9 +1118,9 @@
}
},
"node_modules/escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
"integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
"engines": {
"node": ">=6"
}
@ -1139,17 +1157,17 @@
"integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="
},
"node_modules/fastq": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz",
"integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==",
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
"integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dependencies": {
"to-regex-range": "^5.0.1"
},
@ -1206,21 +1224,21 @@
}
},
"node_modules/glob": {
"version": "10.3.10",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
"integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz",
"integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^2.3.5",
"minimatch": "^9.0.1",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
"path-scurry": "^1.10.1"
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": ">=16 || 14 >=14.17"
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@ -1238,9 +1256,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
"integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": {
"function-bind": "^1.1.2"
},
@ -1316,9 +1334,9 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/jackspeak": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
"integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.1.2.tgz",
"integrity": "sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
@ -1363,9 +1381,9 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
},
"node_modules/lru-cache": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz",
"integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==",
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
"integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
"engines": {
"node": "14 || >=16.14"
}
@ -1391,11 +1409,11 @@
}
},
"node_modules/micromatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
"integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
"dependencies": {
"braces": "^3.0.2",
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
@ -1403,9 +1421,9 @@
}
},
"node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
"integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@ -1417,9 +1435,9 @@
}
},
"node_modules/minipass": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
"integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"engines": {
"node": ">=16 || 14 >=14.17"
}
@ -1508,24 +1526,24 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
"node_modules/path-scurry": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
"integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dependencies": {
"lru-cache": "^9.1.1 || ^10.0.0",
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.17"
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew=="
},
"node_modules/picomatch": {
"version": "2.3.1",
@ -1555,9 +1573,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.33",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz",
"integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==",
"version": "8.4.38",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
"funding": [
{
"type": "opencollective",
@ -1575,7 +1593,7 @@
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
"source-map-js": "^1.2.0"
},
"engines": {
"node": "^10 || ^12 || >=14"
@ -1650,11 +1668,14 @@
}
},
"node_modules/postcss-load-config/node_modules/lilconfig": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz",
"integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz",
"integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/postcss-nested": {
@ -1676,9 +1697,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "6.0.15",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz",
"integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz",
"integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -1831,9 +1852,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"engines": {
"node": ">=0.10.0"
}
@ -1987,9 +2008,9 @@
}
},
"node_modules/tailwindcss": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz",
"integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==",
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz",
"integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@ -1999,7 +2020,7 @@
"fast-glob": "^3.3.0",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.19.1",
"jiti": "^1.21.0",
"lilconfig": "^2.1.0",
"micromatch": "^4.0.5",
"normalize-path": "^3.0.0",
@ -2058,9 +2079,9 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="
},
"node_modules/update-browserslist-db": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
"integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==",
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz",
"integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==",
"funding": [
{
"type": "opencollective",
@ -2076,8 +2097,8 @@
}
],
"dependencies": {
"escalade": "^3.1.1",
"picocolors": "^1.0.0"
"escalade": "^3.1.2",
"picocolors": "^1.0.1"
},
"bin": {
"update-browserslist-db": "cli.js"
@ -2092,9 +2113,9 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/vite": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz",
"integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==",
"version": "3.2.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.2.10.tgz",
"integrity": "sha512-Dx3olBo/ODNiMVk/cA5Yft9Ws+snLOXrhLtrI3F4XLt4syz2Yg8fayZMWScPKoz12v5BUv7VEmQHnsfpY80fYw==",
"dev": true,
"dependencies": {
"esbuild": "^0.15.9",
@ -2253,9 +2274,12 @@
}
},
"node_modules/yaml": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",
"integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==",
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",
"integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}

View File

@ -1,6 +1,6 @@
{
"name": "creddy",
"version": "0.4.7",
"version": "0.4.9",
"scripts": {
"dev": "vite",
"build": "vite build",
@ -9,7 +9,7 @@
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^1.0.1",
"@tauri-apps/cli": "^1.0.5",
"@tauri-apps/cli": "^2.0.0-beta.20",
"autoprefixer": "^10.4.8",
"postcss": "^8.4.16",
"svelte": "^3.49.0",
@ -17,7 +17,9 @@
"vite": "^3.0.7"
},
"dependencies": {
"@tauri-apps/api": "^1.0.2",
"@tauri-apps/api": "^2.0.0-beta.13",
"@tauri-apps/plugin-dialog": "^2.0.0-beta.5",
"@tauri-apps/plugin-os": "^2.0.0-beta.5",
"daisyui": "^2.51.5"
}
}

4183
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "creddy"
version = "0.4.7"
version = "0.4.9"
description = "A friendly AWS credentials manager"
authors = ["Joseph Montanaro"]
license = ""
@ -20,21 +20,19 @@ path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.0.4", features = [] }
tauri-build = { version = "2.0.0-beta", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = [ "app-all", "dialog", "dialog-open", "global-shortcut", "os-all", "system-tray"] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
tauri = { version = "2.0.0-beta", features = ["tray-icon"] }
sodiumoxide = "0.2.7"
tokio = { version = ">=1.19", features = ["full"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] }
sysinfo = "0.26.8"
aws-types = "0.52.0"
aws-sdk-sts = "0.22.0"
aws-smithy-types = "0.52.0"
aws-config = "0.52.0"
aws-config = "1.5.3"
aws-types = "1.3.2"
aws-sdk-sts = "1.33.0"
aws-smithy-types = "1.2.0"
thiserror = "1.0.38"
once_cell = "1.16.0"
strum = "0.24"
@ -48,6 +46,14 @@ chacha20poly1305 = { version = "0.10.1", features = ["std"] }
which = "4.4.0"
windows = { version = "0.51.1", features = ["Win32_Foundation", "Win32_System_Pipes"] }
time = "0.3.31"
tauri-plugin-single-instance = "2.0.0-beta.9"
tauri-plugin-global-shortcut = "2.0.0-beta.6"
rfd = "0.14.1"
ssh-agent-lib = "0.4.0"
ssh-key = { version = "0.6.6", features = ["rsa", "ed25519", "encryption"] }
signature = "2.2.0"
tokio-stream = "0.1.15"
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio", "uuid"] }
[features]
# by default Tauri runs in production mode

View File

@ -0,0 +1,17 @@
{
"identifier": "migrated",
"description": "permissions that were migrated from v1",
"local": true,
"windows": [
"main"
],
"permissions": [
"path:default",
"event:default",
"window:default",
"app:default",
"resources:default",
"menu:default",
"tray:default"
]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
-- key-value store, will be used for various one-off values, serialized to bytes
CREATE TABLE kv (
name TEXT PRIMARY KEY,
value BLOB
);
-- config is currently stored in its own table, as text
INSERT INTO kv (name, value)
SELECT 'config', CAST(data AS BLOB) FROM config;
DROP TABLE config;

View File

@ -0,0 +1,76 @@
-- app structure is changing - instead of passphrase/salt being per credential,
-- we now have a single app-wide key, which is generated by hashing the passphrase
-- with the known salt. To verify the key thus produced, we store a value previously
-- encrypted with that key, and attempt decryption once the key has been re-generated.
-- For migration purposes, we want convert the passphrase for the most recent set of
-- AWS credentials and turn it into the app-wide passphrase. The only value that we
-- have which is encrypted with that passphrase is the secret key for those credentials,
-- so we will just use that as the `verify_blob`. Feels a little weird, but oh well.
WITH latest_creds AS (
SELECT *
FROM credentials
ORDER BY created_at DESC
LIMIT 1
)
INSERT INTO kv (name, value)
SELECT 'salt', salt FROM latest_creds
UNION ALL
SELECT 'verify_nonce', nonce FROM latest_creds
UNION ALL
SELECT 'verify_blob', secret_key_enc FROM latest_creds;
-- Credentials are now going to be stored in a main table
-- plus ancillary tables for type-specific data
-- stash existing AWS creds in temporary table so that we can remake it
CREATE TABLE aws_tmp (id, access_key_id, secret_key_enc, nonce, created_at);
INSERT INTO aws_tmp
SELECT randomblob(16), access_key_id, secret_key_enc, nonce, created_at
FROM credentials
ORDER BY created_at DESC
-- we only ever used one at a time in the past
LIMIT 1;
-- new master credentials table
DROP TABLE credentials;
CREATE TABLE credentials (
-- id is a UUID so we can generate it on the frontend
id BLOB UNIQUE NOT NULL,
name TEXT UNIQUE NOT NULL,
type TEXT NOT NULL,
created_at INTEGER NOT NULL
);
-- populate with basic data from existing AWS credential
INSERT INTO credentials (id, name, type, created_at)
SELECT id, 'default', 'aws', created_at FROM aws_tmp;
-- new AWS-specific table
CREATE TABLE aws_credentials (
id BLOB UNIQUE NOT NULL,
access_key_id TEXT NOT NULL,
secret_key_enc BLOB NOT NULL,
nonce BLOB NOT NULL,
FOREIGN KEY(id) REFERENCES credentials(id) ON DELETE CASCADE
);
-- populate with AWS-specific data from existing credential
INSERT INTO aws_credentials (id, access_key_id, secret_key_enc, nonce)
SELECT id, access_key_id, secret_key_enc, nonce
FROM aws_tmp;
-- done with this now
DROP TABLE aws_tmp;
-- SSH keys are the new hotness
CREATE TABLE ssh_keys (
name TEXT UNIQUE NOT NULL,
public_key BLOB NOT NULL,
private_key_enc BLOB NOT NULL,
nonce BLOB NOT NULL
);

View File

@ -135,9 +135,12 @@ impl LockedCredentials {
}
fn default_credentials_version() -> usize { 1 }
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct BaseCredentials {
#[serde(default = "default_credentials_version")]
pub version: usize,
pub access_key_id: String,
pub secret_access_key: String,
@ -167,6 +170,7 @@ impl BaseCredentials {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct SessionCredentials {
#[serde(default = "default_credentials_version")]
pub version: usize,
pub access_key_id: String,
pub secret_access_key: String,

View File

@ -2,6 +2,10 @@ use std::error::Error;
use std::time::Duration;
use once_cell::sync::OnceCell;
use rfd::{
MessageDialog,
MessageLevel,
};
use sqlx::{
SqlitePool,
sqlite::SqlitePoolOptions,
@ -10,13 +14,16 @@ use sqlx::{
use tauri::{
App,
AppHandle,
Manager,
async_runtime as rt,
Manager,
RunEvent,
WindowEvent,
};
use tauri::menu::MenuItem;
use crate::{
config::{self, AppConfig},
credentials::Session,
credentials::AppSession,
ipc,
server::Server,
errors::*,
@ -33,32 +40,43 @@ pub fn run() -> tauri::Result<()> {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
show_main_window(app)
.error_popup("Failed to show main window");
.error_popup("Failed to show main window")
}))
.system_tray(tray::create())
.on_system_tray_event(tray::handle_event)
.plugin(tauri_plugin_global_shortcut::Builder::default().build())
.invoke_handler(tauri::generate_handler![
ipc::unlock,
ipc::lock,
ipc::set_passphrase,
ipc::respond,
ipc::get_session_status,
ipc::signal_activity,
ipc::save_credentials,
ipc::save_credential,
ipc::delete_credential,
ipc::list_credentials,
ipc::get_config,
ipc::save_config,
ipc::launch_terminal,
ipc::get_setup_errors,
])
.setup(|app| rt::block_on(setup(app)))
.setup(|app| {
let res = rt::block_on(setup(app));
if let Err(ref e) = res {
MessageDialog::new()
.set_level(MessageLevel::Error)
.set_title("Creddy failed to start")
.set_description(format!("{e}"))
.show();
}
res
})
.build(tauri::generate_context!())?
.run(|app, run_event| match run_event {
tauri::RunEvent::WindowEvent { event, .. } => match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
.run(|app, run_event| {
if let RunEvent::WindowEvent { event, .. } = run_event {
if let WindowEvent::CloseRequested { api, .. } = event {
let _ = hide_main_window(app);
api.prevent_close();
}
_ => ()
}
_ => ()
});
Ok(())
@ -77,8 +95,8 @@ pub async fn connect_db() -> Result<SqlitePool, SetupError> {
async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
APP.set(app.handle()).unwrap();
APP.set(app.handle().clone()).unwrap();
tray::setup(app)?;
// get_or_create_db_path doesn't create the actual db file, just the directory
let is_first_launch = !config::get_or_create_db_path()?.exists();
let pool = connect_db().await?;
@ -86,7 +104,7 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
let mut conf = match AppConfig::load(&pool).await {
Ok(c) => c,
Err(SetupError::ConfigParseError(_)) => {
Err(LoadKvError::Invalid(_)) => {
setup_errors.push(
"Could not load configuration from database. Reverting to defaults.".into()
);
@ -95,8 +113,8 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
err => err?,
};
let session = Session::load(&pool).await?;
Server::start(app.handle())?;
let app_session = AppSession::load(&pool).await?;
Server::start(app.handle().clone())?;
config::set_auto_launch(conf.start_on_login)?;
if let Err(_e) = config::set_auto_launch(conf.start_on_login) {
@ -114,16 +132,15 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
.map(|names| names.split(':').any(|n| n == "GNOME"))
.unwrap_or(false);
// if session is empty, this is probably the first launch, so don't autohide
if !conf.start_minimized || is_first_launch {
show_main_window(&app.handle())?;
}
let state = AppState::new(conf, session, pool, setup_errors, desktop_is_gnome);
let state = AppState::new(conf, app_session, pool, setup_errors, desktop_is_gnome);
app.manage(state);
// make sure we do this after managing app state, so that it doesn't panic
start_auto_locker(app.app_handle());
start_auto_locker(app.app_handle().clone());
Ok(())
}
@ -146,27 +163,25 @@ fn start_auto_locker(app: AppHandle) {
pub fn show_main_window(app: &AppHandle) -> Result<(), WindowError> {
let w = app.get_window("main").ok_or(WindowError::NoMainWindow)?;
let w = app.get_webview_window("main").ok_or(WindowError::NoMainWindow)?;
w.show()?;
app.tray_handle()
.get_item("show_hide")
.set_title("Hide")?;
let show_hide = app.state::<MenuItem<tauri::Wry>>();
show_hide.set_text("Hide")?;
Ok(())
}
pub fn hide_main_window(app: &AppHandle) -> Result<(), WindowError> {
let w = app.get_window("main").ok_or(WindowError::NoMainWindow)?;
let w = app.get_webview_window("main").ok_or(WindowError::NoMainWindow)?;
w.hide()?;
app.tray_handle()
.get_item("show_hide")
.set_title("Show")?;
let show_hide = app.state::<MenuItem<tauri::Wry>>();
show_hide.set_text("Show")?;
Ok(())
}
pub fn toggle_main_window(app: &AppHandle) -> Result<(), WindowError> {
let w = app.get_window("main").ok_or(WindowError::NoMainWindow)?;
let w = app.get_webview_window("main").ok_or(WindowError::NoMainWindow)?;
if w.is_visible()? {
hide_main_window(app)
}

View File

@ -0,0 +1,7 @@
use creddy::server::ssh_agent;
#[tokio::main]
async fn main() {
ssh_agent::run().await;
}

13
src-tauri/src/bin/key.rs Normal file
View File

@ -0,0 +1,13 @@
use ssh_key::private::PrivateKey;
fn main() {
// let passphrase = std::env::var("PRIVKEY_PASSPHRASE").unwrap();
let p = AsRef::<std::path::Path>::as_ref("/home/joe/.ssh/test");
let privkey = PrivateKey::read_openssh_file(p)
.unwrap();
// .decrypt(passphrase.as_bytes())
// .unwrap();
dbg!(String::from_utf8_lossy(&privkey.to_bytes().unwrap()));
}

View File

@ -12,7 +12,6 @@ use clap::{
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::credentials::Credentials;
use crate::errors::*;
use crate::server::{Request, Response};
use crate::shortcuts::ShortcutAction;
@ -80,9 +79,10 @@ pub fn parser() -> Command<'static> {
pub fn get(args: &ArgMatches) -> Result<(), CliError> {
let base = args.get_one("base").unwrap_or(&false);
let output = match get_credentials(*base)? {
Credentials::Base(creds) => serde_json::to_string(&creds).unwrap(),
Credentials::Session(creds) => serde_json::to_string(&creds).unwrap(),
let output = match make_request(&Request::GetAwsCredentials { base: *base })? {
Response::AwsBase(creds) => serde_json::to_string(&creds).unwrap(),
Response::AwsSession(creds) => serde_json::to_string(&creds).unwrap(),
r => return Err(RequestError::Unexpected(r).into()),
};
println!("{output}");
Ok(())
@ -98,16 +98,17 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
let mut cmd = ChildCommand::new(cmd_name);
cmd.args(cmd_line);
match get_credentials(base)? {
Credentials::Base(creds) => {
match make_request(&Request::GetAwsCredentials { base })? {
Response::AwsBase(creds) => {
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
},
Credentials::Session(creds) => {
Response::AwsSession(creds) => {
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
cmd.env("AWS_SESSION_TOKEN", creds.session_token);
}
},
r => return Err(RequestError::Unexpected(r).into()),
}
#[cfg(unix)]
@ -157,16 +158,6 @@ pub fn invoke_shortcut(args: &ArgMatches) -> Result<(), CliError> {
}
fn get_credentials(base: bool) -> Result<Credentials, RequestError> {
let req = Request::GetAwsCredentials { base };
match make_request(&req) {
Ok(Response::Aws(creds)) => Ok(creds),
Ok(r) => Err(RequestError::Unexpected(r)),
Err(e) => Err(e),
}
}
#[tokio::main]
async fn make_request(req: &Request) -> Result<Response, RequestError> {
let mut data = serde_json::to_string(req).unwrap();

View File

@ -7,6 +7,7 @@ use serde::{Serialize, Deserialize};
use sqlx::SqlitePool;
use crate::errors::*;
use crate::kv;
#[derive(Clone, Debug, Serialize, Deserialize)]
@ -77,31 +78,16 @@ impl Default for AppConfig {
impl AppConfig {
pub async fn load(pool: &SqlitePool) -> Result<AppConfig, SetupError> {
let res = sqlx::query!("SELECT * from config where name = 'main'")
.fetch_optional(pool)
.await?;
pub async fn load(pool: &SqlitePool) -> Result<AppConfig, LoadKvError> {
let config = kv::load(pool, "config")
.await?
.unwrap_or_else(|| AppConfig::default());
let row = match res {
Some(row) => row,
None => return Ok(AppConfig::default()),
};
Ok(serde_json::from_str(&row.data)?)
Ok(config)
}
pub async fn save(&self, pool: &SqlitePool) -> Result<(), sqlx::error::Error> {
let data = serde_json::to_string(self).unwrap();
sqlx::query(
"INSERT INTO config (name, data) VALUES ('main', ?)
ON CONFLICT (name) DO UPDATE SET data = ?"
)
.bind(&data)
.bind(&data)
.execute(pool)
.await?;
Ok(())
kv::save(pool, "config", self).await
}
}

View File

@ -0,0 +1,345 @@
use std::fmt::{self, Formatter};
use std::time::{SystemTime, UNIX_EPOCH};
use aws_config::BehaviorVersion;
use aws_smithy_types::date_time::{DateTime, Format};
use chacha20poly1305::XNonce;
use serde::{
Serialize,
Deserialize,
Serializer,
Deserializer,
};
use serde::de::{self, Visitor};
use sqlx::{
SqlitePool,
types::Uuid,
};
use sqlx::error::{
Error as SqlxError,
};
use tokio_stream::StreamExt;
use super::{Credential, Crypto, SaveCredential, PersistentCredential};
use crate::errors::*;
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct AwsBaseCredential {
#[serde(default = "default_credentials_version")]
pub version: usize,
pub access_key_id: String,
pub secret_access_key: String,
}
impl AwsBaseCredential {
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
Self {version: 1, access_key_id, secret_access_key}
}
}
impl PersistentCredential for AwsBaseCredential {
async fn save(&self, id: &Uuid, name: &str, crypto: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
let (nonce, ciphertext) = crypto.encrypt(self.secret_access_key.as_bytes())?;
let nonce_bytes = &nonce.as_slice();
let res = sqlx::query!(
"INSERT INTO credentials (id, name, type, created_at)
VALUES (?, ?, 'aws', strftime('%s'))
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
type = excluded.type,
created_at = excluded.created_at;
INSERT OR REPLACE INTO aws_credentials (
id,
access_key_id,
secret_key_enc,
nonce
)
VALUES (?, ?, ?, ?);",
id,
name,
id, // for the second query
self.access_key_id,
ciphertext,
nonce_bytes,
).execute(pool).await;
match res {
Err(SqlxError::Database(e)) if e.code().as_deref() == Some("2067") => Err(SaveCredentialsError::Duplicate),
Err(e) => Err(SaveCredentialsError::DbError(e)),
Ok(_) => Ok(())
}
}
async fn load(name: &str, crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
let row = sqlx::query!(
"SELECT c.name, a.access_key_id, a.secret_key_enc, a.nonce
FROM credentials c JOIN aws_credentials a ON a.id = c.id
WHERE c.name = ?",
name
).fetch_optional(pool)
.await?
.ok_or(LoadCredentialsError::NoCredentials)?;
let nonce = XNonce::clone_from_slice(&row.nonce);
let secret_key_bytes = crypto.decrypt(&nonce, &row.secret_key_enc)?;
let secret_key = String::from_utf8(secret_key_bytes)
.map_err(|_| LoadCredentialsError::InvalidData)?;
Ok(AwsBaseCredential::new(row.access_key_id, secret_key))
}
async fn list(crypto: &Crypto, pool: &SqlitePool) -> Result<Vec<SaveCredential>, LoadCredentialsError> {
let mut rows = sqlx::query!(
"SELECT c.id, c.name, a.access_key_id, a.secret_key_enc, a.nonce
FROM credentials c JOIN aws_credentials a ON a.id = c.id"
).fetch(pool);
let mut creds = Vec::new();
while let Some(row) = rows.try_next().await? {
let nonce = XNonce::clone_from_slice(&row.nonce);
let secret_key_bytes = crypto.decrypt(&nonce, &row.secret_key_enc)?;
let secret_key = String::from_utf8(secret_key_bytes)
.map_err(|_| LoadCredentialsError::InvalidData)?;
let aws = AwsBaseCredential::new(row.access_key_id, secret_key);
let id = Uuid::from_slice(&row.id)
.map_err(|_| LoadCredentialsError::InvalidData)?;
let cred = SaveCredential {
id,
name: row.name,
credential: Credential::AwsBase(aws),
};
creds.push(cred);
}
Ok(creds)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct AwsSessionCredential {
#[serde(default = "default_credentials_version")]
pub version: usize,
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: String,
#[serde(serialize_with = "serialize_expiration")]
#[serde(deserialize_with = "deserialize_expiration")]
pub expiration: DateTime,
}
impl AwsSessionCredential {
pub async fn from_base(base: &AwsBaseCredential) -> Result<Self, GetSessionError> {
let req_creds = aws_sdk_sts::config::Credentials::new(
&base.access_key_id,
&base.secret_access_key,
None, // token
None, //expiration
"Creddy", // "provider name" apparently
);
let config = aws_config::defaults(BehaviorVersion::latest())
.credentials_provider(req_creds)
.load()
.await;
let client = aws_sdk_sts::Client::new(&config);
let resp = client.get_session_token()
.duration_seconds(43_200)
.send()
.await?;
let aws_session = resp.credentials.ok_or(GetSessionError::EmptyResponse)?;
let session_creds = AwsSessionCredential {
version: 1,
access_key_id: aws_session.access_key_id,
secret_access_key: aws_session.secret_access_key,
session_token: aws_session.session_token,
expiration: aws_session.expiration,
};
#[cfg(debug_assertions)]
println!("Got new session:\n{}", serde_json::to_string(&session_creds).unwrap());
Ok(session_creds)
}
pub fn is_expired(&self) -> bool {
let current_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap() // doesn't panic because UNIX_EPOCH won't be later than now()
.as_secs();
let expire_ts = self.expiration.secs();
let remaining = expire_ts - (current_ts as i64);
remaining < 60
}
}
fn default_credentials_version() -> usize { 1 }
struct DateTimeVisitor;
impl<'de> Visitor<'de> for DateTimeVisitor {
type Value = DateTime;
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
write!(formatter, "an RFC 3339 UTC string, e.g. \"2014-01-05T10:17:34Z\"")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<DateTime, E> {
DateTime::from_str(v, Format::DateTime)
.map_err(|_| E::custom(format!("Invalid date/time: {v}")))
}
}
fn deserialize_expiration<'de, D>(deserializer: D) -> Result<DateTime, D::Error>
where D: Deserializer<'de>
{
deserializer.deserialize_str(DateTimeVisitor)
}
fn serialize_expiration<S>(exp: &DateTime, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer
{
// this only fails if the d/t is out of range, which it can't be for this format
let time_str = exp.fmt(Format::DateTime).unwrap();
serializer.serialize_str(&time_str)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_creds() -> AwsBaseCredential {
AwsBaseCredential::new(
"AKIAIOSFODNN7EXAMPLE".into(),
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
)
}
fn test_creds_2() -> AwsBaseCredential {
AwsBaseCredential::new(
"AKIAIOSFODNN7EXAMPL2".into(),
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKE2".into(),
)
}
fn test_uuid() -> Uuid {
Uuid::try_parse("00000000-0000-0000-0000-000000000000").unwrap()
}
fn test_uuid_2() -> Uuid {
Uuid::try_parse("ffffffff-ffff-ffff-ffff-ffffffffffff").unwrap()
}
fn test_uuid_random() -> Uuid {
let bytes = Crypto::salt();
Uuid::from_slice(&bytes[..16]).unwrap()
}
#[sqlx::test]
async fn test_save(pool: SqlitePool) {
let crypt = Crypto::random();
test_creds().save(&test_uuid_random(), "test", &crypt, &pool).await
.expect("Failed to save AWS credentials");
}
#[sqlx::test(fixtures("aws_credentials"))]
async fn test_overwrite(pool: SqlitePool) {
let crypt = Crypto::fixed();
let creds = test_creds_2();
// overwite original creds with different test data
creds.save(&test_uuid(), "test", &crypt, &pool).await
.expect("Failed to update AWS credentials");
// make sure update went through
let loaded = AwsBaseCredential::load("test", &crypt, &pool).await.unwrap();
assert_eq!(creds, loaded);
}
#[sqlx::test(fixtures("aws_credentials"))]
async fn test_duplicate_name(pool: SqlitePool) {
let crypt = Crypto::random();
let id = test_uuid_random();
let resp = test_creds().save(&id, "test", &crypt, &pool).await;
if !matches!(resp, Err(SaveCredentialsError::Duplicate)) {
panic!("Attempt to create duplicate entry returned {resp:?}")
}
}
#[sqlx::test(fixtures("aws_credentials"))]
async fn test_load(pool: SqlitePool) {
let crypt = Crypto::fixed();
let loaded = AwsBaseCredential::load("test", &crypt, &pool).await.unwrap();
assert_eq!(test_creds(), loaded);
}
#[sqlx::test]
async fn test_save_load(pool: SqlitePool) {
let crypt = Crypto::random();
let creds = test_creds();
creds.save(&test_uuid_random(), "test", &crypt, &pool).await.unwrap();
let loaded = AwsBaseCredential::load("test", &crypt, &pool).await.unwrap();
assert_eq!(creds, loaded);
}
#[sqlx::test(fixtures("aws_credentials"))]
async fn test_list(pool: SqlitePool) {
let crypt = Crypto::fixed();
let list = AwsBaseCredential::list(&crypt, &pool).await
.expect("Failed to list AWS credentials");
let first = SaveCredential {
id: test_uuid(),
name: "test".into(),
credential: Credential::AwsBase(test_creds()),
};
assert_eq!(&first, &list[0]);
let second = SaveCredential {
id: test_uuid_2(),
name: "test2".into(),
credential: Credential::AwsBase(test_creds_2()),
};
assert_eq!(&second, &list[1]);
}
#[sqlx::test(fixtures("aws_credentials"))]
async fn test_rekey(pool: SqlitePool) {
let old_crypt = Crypto::fixed();
let orig = AwsBaseCredential::list(&old_crypt, &pool).await.unwrap();
let new_crypt = Crypto::random();
AwsBaseCredential::rekey(&old_crypt, &new_crypt, &pool).await
.expect("Failed to re-key AWS credentials");
let rekeyed = AwsBaseCredential::list(&new_crypt, &pool).await.unwrap();
for (before, after) in orig.iter().zip(rekeyed.iter()) {
assert_eq!(before, after);
}
}
}

View File

@ -0,0 +1,19 @@
INSERT INTO credentials (id, name, type, created_at)
VALUES
(X'00000000000000000000000000000000', 'test', 'aws', strftime('%s')),
(X'ffffffffffffffffffffffffffffffff', 'test2', 'aws', strftime('%s'));
INSERT INTO aws_credentials (id, access_key_id, secret_key_enc, nonce)
VALUES
(
X'00000000000000000000000000000000',
'AKIAIOSFODNN7EXAMPLE',
X'B09ACDADD07E295A3FD9146D8D3672FA5C2518BFB15CF039E68820C42EFD3BC3BE3156ACF438C2C957EC113EF8617DBC71790EAFE39B3DE8',
X'DB777F2C6315DC0E12ADF322256E69D09D7FB586AAE614A6'
),
(
X'ffffffffffffffffffffffffffffffff',
'AKIAIOSFODNN7EXAMPL2',
X'ED6125FF40EF6F61929DF5FFD7141CD2B5A302A51C20152156477F8CC77980C614AB1B212AC06983F3CED35C4F3C54D4EE38964859930FBF',
X'962396B78DAA98DFDCC0AC0C9B7D688EC121F5759EBA790A'
);

View File

@ -0,0 +1,313 @@
use std::fmt::{self, Debug, Formatter};
use argon2::{
Argon2,
Algorithm,
Version,
ParamsBuilder,
password_hash::rand_core::{RngCore, OsRng},
};
use chacha20poly1305::{
XChaCha20Poly1305,
XNonce,
aead::{
Aead,
AeadCore,
KeyInit,
generic_array::GenericArray,
},
};
use serde::{
Serialize,
Deserialize,
Serializer,
Deserializer,
};
use serde::de::{self, Visitor};
use sqlx::SqlitePool;
use sqlx::types::Uuid;
use crate::errors::*;
use crate::kv;
mod aws;
pub use aws::{AwsBaseCredential, AwsSessionCredential};
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum Credential {
AwsBase(AwsBaseCredential),
AwsSession(AwsSessionCredential),
}
// we need a special type for listing structs because
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct SaveCredential {
#[serde(serialize_with = "serialize_uuid")]
#[serde(deserialize_with = "deserialize_uuid")]
id: Uuid, // UUID so it can be generated on the frontend
name: String, // user-facing identifier so it can be changed
credential: Credential,
}
impl SaveCredential {
pub async fn save(&self, crypt: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
let cred = match &self.credential {
Credential::AwsBase(b) => b,
Credential::AwsSession(_) => return Err(SaveCredentialsError::NotPersistent),
};
cred.save(&self.id, &self.name, crypt, pool).await
}
}
fn serialize_uuid<S: Serializer>(u: &Uuid, s: S) -> Result<S::Ok, S::Error> {
let mut buf = Vec::new();
s.serialize_str(u.as_hyphenated().encode_lower(&mut buf))
}
struct UuidVisitor;
impl<'de> Visitor<'de> for UuidVisitor {
type Value = Uuid;
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
write!(formatter, "a hyphenated UUID")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Uuid, E> {
Uuid::try_parse(v)
.map_err(|_| E::custom(format!("Could not interpret string as UUID: {v}")))
}
}
fn deserialize_uuid<'de, D: Deserializer<'de>>(ds: D) -> Result<Uuid, D::Error> {
ds.deserialize_str(UuidVisitor)
}
pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized {
async fn load(name: &str, crypt: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError>;
async fn list(crypt: &Crypto, pool: &SqlitePool) -> Result<Vec<SaveCredential>, LoadCredentialsError>;
async fn save(&self, id: &Uuid, name: &str, crypt: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError>;
async fn rekey(old: &Crypto, new: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
for cred in Self::list(old, pool).await? {
cred.save(new, pool).await?;
}
Ok(())
}
}
#[derive(Clone, Debug)]
pub enum AppSession {
Unlocked {
salt: [u8; 32],
crypto: Crypto,
},
Locked {
salt: [u8; 32],
verify_nonce: XNonce,
verify_blob: Vec<u8>
},
Empty,
}
impl AppSession {
pub fn new(passphrase: &str) -> Result<Self, CryptoError> {
let salt = Crypto::salt();
let crypto = Crypto::new(passphrase, &salt)?;
Ok(Self::Unlocked {salt, crypto})
}
pub fn unlock(&mut self, passphrase: &str) -> Result<(), UnlockError> {
let (salt, nonce, blob) = match self {
Self::Empty => return Err(UnlockError::NoCredentials),
Self::Unlocked {..} => return Err(UnlockError::NotLocked),
Self::Locked {salt, verify_nonce, verify_blob} => (salt, verify_nonce, verify_blob),
};
let crypto = Crypto::new(passphrase, salt)
.map_err(|e| CryptoError::Argon2(e))?;
// if passphrase is incorrect, this will fail
let _verify = crypto.decrypt(&nonce, &blob)?;
*self = Self::Unlocked {crypto, salt: *salt};
Ok(())
}
pub async fn load(pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
match kv::load_bytes_multi!(pool, "salt", "verify_nonce", "verify_blob").await? {
Some((salt, nonce, blob)) => {
Ok(Self::Locked {
salt: salt.try_into().map_err(|_| LoadCredentialsError::InvalidData)?,
// note: replace this with try_from at some point
verify_nonce: XNonce::clone_from_slice(&nonce),
verify_blob: blob,
})
},
None => Ok(Self::Empty),
}
}
pub async fn save(&self, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
match self {
Self::Unlocked {salt, crypto} => {
let (nonce, blob) = crypto.encrypt(b"correct horse battery staple")?;
kv::save_bytes(pool, "salt", salt).await?;
kv::save_bytes(pool, "verify_nonce", &nonce.as_slice()).await?;
kv::save_bytes(pool, "verify_blob", &blob).await?;
},
Self::Locked {salt, verify_nonce, verify_blob} => {
kv::save_bytes(pool, "salt", salt).await?;
kv::save_bytes(pool, "verify_nonce", &verify_nonce.as_slice()).await?;
kv::save_bytes(pool, "verify_blob", verify_blob).await?;
},
// "saving" an empty session just means doing nothing
Self::Empty => (),
};
Ok(())
}
pub fn try_get_crypto(&self) -> Result<&Crypto, GetCredentialsError> {
match self {
Self::Empty => Err(GetCredentialsError::Empty),
Self::Locked {..} => Err(GetCredentialsError::Locked),
Self::Unlocked {crypto, ..} => Ok(crypto),
}
}
pub fn try_encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec<u8>), GetCredentialsError> {
let crypto = match self {
Self::Empty => return Err(GetCredentialsError::Empty),
Self::Locked {..} => return Err(GetCredentialsError::Locked),
Self::Unlocked {crypto, ..} => crypto,
};
let res = crypto.encrypt(data)?;
Ok(res)
}
pub fn try_decrypt(&self, nonce: XNonce, data: &[u8]) -> Result<Vec<u8>, GetCredentialsError> {
let crypto = match self {
Self::Empty => return Err(GetCredentialsError::Empty),
Self::Locked {..} => return Err(GetCredentialsError::Locked),
Self::Unlocked {crypto, ..} => crypto,
};
let res = crypto.decrypt(&nonce, data)?;
Ok(res)
}
}
#[derive(Clone)]
pub struct Crypto {
cipher: XChaCha20Poly1305,
}
impl Crypto {
/// Argon2 params rationale:
///
/// m_cost is measured in KiB, so 128 * 1024 gives us 128MiB.
/// This should roughly double the memory usage of the application
/// while deriving the key.
///
/// p_cost is irrelevant since (at present) there isn't any parallelism
/// implemented, so we leave it at 1.
///
/// With the above m_cost, t_cost = 8 results in about 800ms to derive
/// a key on my (somewhat older) CPU. This is probably overkill, but
/// given that it should only have to happen ~once a day for most
/// usage, it should be acceptable.
#[cfg(not(debug_assertions))]
const MEM_COST: u32 = 128 * 1024;
#[cfg(not(debug_assertions))]
const TIME_COST: u32 = 8;
/// But since this takes a million years without optimizations,
/// we turn it way down in debug builds.
#[cfg(debug_assertions)]
const MEM_COST: u32 = 48 * 1024;
#[cfg(debug_assertions)]
const TIME_COST: u32 = 1;
fn new(passphrase: &str, salt: &[u8]) -> argon2::Result<Crypto> {
let params = ParamsBuilder::new()
.m_cost(Self::MEM_COST)
.p_cost(1)
.t_cost(Self::TIME_COST)
.build()
.unwrap(); // only errors if the given params are invalid
let hasher = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
params,
);
let mut key = [0; 32];
hasher.hash_password_into(passphrase.as_bytes(), &salt, &mut key)?;
let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
Ok(Crypto { cipher })
}
#[cfg(test)]
pub fn random() -> Crypto {
// salt and key are the same length, so we can just use this
let key = Crypto::salt();
let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
Crypto { cipher }
}
#[cfg(test)]
pub fn fixed() -> Crypto {
let key = [
1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
];
let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
Crypto { cipher }
}
fn salt() -> [u8; 32] {
let mut salt = [0; 32];
OsRng.fill_bytes(&mut salt);
salt
}
fn encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec<u8>), CryptoError> {
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = self.cipher.encrypt(&nonce, data)?;
Ok((nonce, ciphertext))
}
fn decrypt(&self, nonce: &XNonce, data: &[u8]) -> Result<Vec<u8>, CryptoError> {
let plaintext = self.cipher.decrypt(nonce, data)?;
Ok(plaintext)
}
}
impl Debug for Crypto {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(f, "Crypto {{ [...] }}")
}
}
// #[cfg(test)]
// mod tests {
// use super::*;
// #[sqlx::test(fixtures("uuid_test"))]
// async fn save_uuid(pool: SqlitePool) {
// let u = Uuid::try_parse("7140b90c-bfbd-4394-9008-01b94f94ecf8").unwrap();
// sqlx::query!("INSERT INTO uuids (uuid) VALUES (?)", u).execute(pool).unwrap();
// panic!("done, go check db");
// }
// }

View File

@ -1,23 +1,25 @@
use std::error::Error;
use std::convert::AsRef;
use std::ffi::OsString;
use std::sync::mpsc;
use std::string::FromUtf8Error;
use strum_macros::AsRefStr;
use thiserror::Error as ThisError;
use aws_sdk_sts::{
types::SdkError as AwsSdkError,
error::GetSessionTokenError,
error::SdkError as AwsSdkError,
operation::get_session_token::GetSessionTokenError,
error::ProvideErrorMetadata,
};
use rfd::{
AsyncMessageDialog,
MessageLevel,
};
use sqlx::{
error::Error as SqlxError,
migrate::MigrateError,
};
use tauri::api::dialog::{
MessageDialogBuilder,
MessageDialogKind,
};
use tauri::async_runtime as rt;
use tauri_plugin_global_shortcut::Error as ShortcutError;
use tokio::sync::oneshot::error::RecvError;
use serde::{
Serialize,
@ -27,30 +29,23 @@ use serde::{
};
pub trait ShowError {
pub trait ShowError<T, E>
{
fn error_popup(self, title: &str);
fn error_popup_nowait(self, title: &str);
fn error_print(self);
fn error_print_prefix(self, prefix: &str);
}
impl<E: std::fmt::Display> ShowError for Result<(), E> {
impl<T, E> ShowError<T, E> for Result<T, E>
where E: std::fmt::Display
{
fn error_popup(self, title: &str) {
if let Err(e) = self {
let (tx, rx) = mpsc::channel();
MessageDialogBuilder::new(title, format!("{e}"))
.kind(MessageDialogKind::Error)
.show(move |_| tx.send(true).unwrap());
rx.recv().unwrap();
}
}
fn error_popup_nowait(self, title: &str) {
if let Err(e) = self {
MessageDialogBuilder::new(title, format!("{e}"))
.kind(MessageDialogKind::Error)
.show(|_| {})
let dialog = AsyncMessageDialog::new()
.set_level(MessageLevel::Error)
.set_title(title)
.set_description(format!("{e}"));
rt::spawn(async move {dialog.show().await});
}
}
@ -133,10 +128,10 @@ pub enum SetupError {
InvalidRecord, // e.g. wrong size blob for nonce or salt
#[error("Error from database: {0}")]
DbError(#[from] SqlxError),
#[error("Error loading data: {0}")]
KvError(#[from] LoadKvError),
#[error("Error running migrations: {0}")]
MigrationError(#[from] MigrateError),
#[error("Error parsing configuration from database")]
ConfigParseError(#[from] serde_json::Error),
#[error("Failed to set up start-on-login: {0}")]
AutoLaunchError(#[from] auto_launch::Error),
#[error("Failed to start listener: {0}")]
@ -144,7 +139,7 @@ pub enum SetupError {
#[error("Failed to resolve data directory: {0}")]
DataDir(#[from] DataDirError),
#[error("Failed to register hotkeys: {0}")]
RegisterHotkeys(#[from] tauri::Error),
RegisterHotkeys(#[from] ShortcutError),
}
@ -214,6 +209,12 @@ pub enum GetCredentialsError {
Locked,
#[error("No credentials are known")]
Empty,
#[error(transparent)]
Crypto(#[from] CryptoError),
#[error(transparent)]
Load(#[from] LoadCredentialsError),
#[error(transparent)]
GetSession(#[from] GetSessionError),
}
@ -251,12 +252,58 @@ pub enum UnlockError {
pub enum LockError {
#[error("App is not unlocked")]
NotUnlocked,
#[error("Database error: {0}")]
DbError(#[from] SqlxError),
#[error(transparent)]
LoadCredentials(#[from] LoadCredentialsError),
#[error(transparent)]
Setup(#[from] SetupError),
#[error(transparent)]
TauriError(#[from] tauri::Error),
#[error(transparent)]
Crypto(#[from] CryptoError),
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum SaveCredentialsError {
#[error("Database error: {0}")]
DbError(#[from] SqlxError),
#[error("Encryption error: {0}")]
Crypto(#[from] CryptoError),
#[error(transparent)]
Session(#[from] GetCredentialsError),
#[error("App is locked")]
Locked,
#[error("Credential is temporary and cannot be saved")]
NotPersistent,
#[error("A credential with that name already exists")]
Duplicate,
// rekeying is fundamentally a save operation,
// but involves loading in order to re-save
#[error(transparent)]
LoadCredentials(#[from] LoadCredentialsError),
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum LoadCredentialsError {
#[error("Database error: {0}")]
DbError(#[from] SqlxError),
#[error("Invalid passphrase")] // pretty sure this is the only way decryption fails
Encryption(#[from] CryptoError),
#[error("Credentials not found")]
NoCredentials,
#[error("Could not decode credential data")]
InvalidData,
#[error(transparent)]
LoadKv(#[from] LoadKvError),
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum LoadKvError {
#[error("Database error: {0}")]
DbError(#[from] SqlxError),
#[error("Could not parse value from database: {0}")]
Invalid(#[from] serde_json::Error),
}
@ -266,6 +313,10 @@ pub enum CryptoError {
Argon2(#[from] argon2::Error),
#[error("Invalid passphrase")] // I think this is the only way decryption fails
Aead(#[from] chacha20poly1305::aead::Error),
#[error("App is currently locked")]
Locked,
#[error("No passphrase has been specified")]
Empty,
}
@ -383,6 +434,8 @@ impl_serialize_basic!(GetCredentialsError);
impl_serialize_basic!(ClientInfoError);
impl_serialize_basic!(WindowError);
impl_serialize_basic!(LockError);
impl_serialize_basic!(SaveCredentialsError);
impl_serialize_basic!(LoadCredentialsError);
impl Serialize for HandlerError {

View File

@ -1,8 +1,12 @@
use serde::{Serialize, Deserialize};
use sqlx::types::Uuid;
use tauri::State;
use crate::config::AppConfig;
use crate::credentials::{Session,BaseCredentials};
use crate::credentials::{
AppSession,
SaveCredential
};
use crate::errors::*;
use crate::clientinfo::Client;
use crate::state::AppState;
@ -17,6 +21,32 @@ pub struct AwsRequestNotification {
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SshRequestNotification {
pub id: u64,
pub client: Client,
pub key_name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum RequestNotification {
Aws(AwsRequestNotification),
Ssh(SshRequestNotification),
}
impl RequestNotification {
pub fn new_aws(id: u64, client: Client, base: bool) -> Self {
Self::Aws(AwsRequestNotification {id, client, base})
}
pub fn new_ssh(id: u64, client: Client, key_name: String) -> Self {
Self::Ssh(SshRequestNotification {id, client, key_name})
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RequestResponse {
pub id: u64,
@ -44,13 +74,25 @@ pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Resul
}
#[tauri::command]
pub async fn lock(app_state: State<'_, AppState>) -> Result<(), LockError> {
app_state.lock().await
}
#[tauri::command]
pub async fn set_passphrase(passphrase: &str, app_state: State<'_, AppState>) -> Result<(), SaveCredentialsError> {
app_state.set_passphrase(passphrase).await
}
#[tauri::command]
pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String, ()> {
let session = app_state.session.read().await;
let session = app_state.app_session.read().await;
let status = match *session {
Session::Locked(_) => "locked".into(),
Session::Unlocked{..} => "unlocked".into(),
Session::Empty => "empty".into()
AppSession::Locked{..} => "locked".into(),
AppSession::Unlocked{..} => "unlocked".into(),
AppSession::Empty => "empty".into(),
};
Ok(status)
}
@ -64,12 +106,25 @@ pub async fn signal_activity(app_state: State<'_, AppState>) -> Result<(), ()> {
#[tauri::command]
pub async fn save_credentials(
credentials: BaseCredentials,
passphrase: String,
pub async fn save_credential(
cred: SaveCredential,
app_state: State<'_, AppState>
) -> Result<(), UnlockError> {
app_state.new_creds(credentials, &passphrase).await
) -> Result<(), SaveCredentialsError> {
app_state.save_credential(cred).await
}
#[tauri::command]
pub async fn delete_credential(id: &str, app_state: State<'_, AppState>) -> Result<(), SaveCredentialsError> {
let id = Uuid::try_parse(id)
.map_err(|_| LoadCredentialsError::NoCredentials)?;
app_state.delete_credential(&id).await
}
#[tauri::command]
pub async fn list_credentials(app_state: State<'_, AppState>) -> Result<Vec<SaveCredential>, GetCredentialsError> {
app_state.list_credentials().await
}

95
src-tauri/src/kv.rs Normal file
View File

@ -0,0 +1,95 @@
use serde::Serialize;
use serde::de::DeserializeOwned;
use sqlx::SqlitePool;
use crate::errors::*;
pub async fn save<T>(pool: &SqlitePool, name: &str, value: &T) -> Result<(), sqlx::Error>
where T: Serialize
{
let bytes = serde_json::to_vec(value).unwrap();
save_bytes(pool, name, &bytes).await
}
pub async fn save_bytes(pool: &SqlitePool, name: &str, bytes: &[u8]) -> Result<(), sqlx::Error> {
sqlx::query!(
"INSERT INTO kv (name, value) VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET value = excluded.value;",
name,
bytes,
).execute(pool).await?;
Ok(())
}
pub async fn load<T>(pool: &SqlitePool, name: &str) -> Result<Option<T>, LoadKvError>
where T: DeserializeOwned
{
let v = load_bytes(pool, name)
.await?
.map(|bytes| serde_json::from_slice(&bytes))
.transpose()?;
Ok(v)
}
pub async fn load_bytes(pool: &SqlitePool, name: &str) -> Result<Option<Vec<u8>>, sqlx::Error> {
sqlx::query!("SELECT name, value FROM kv WHERE name = ?", name)
.map(|row| row.value)
.fetch_optional(pool)
.await
.map(|o| o.flatten())
}
macro_rules! load_bytes_multi {
(
$pool:ident,
$($name:literal),*
) => {
// wrap everything up in an async block for easy short-circuiting...
async {
// ...returning a Result...
Ok::<_, sqlx::Error>(
//containing an Option...
Some(
// containing a tuple...
(
// ...with one item for each repetition of $name
$(
// load_bytes returns Result<Option<_>>, the Result is handled by
// the ? and we match on the Option
match crate::kv::load_bytes($pool, $name).await? {
Some(v) => v,
None => return Ok(None)
},
)*
)
)
)
}
}
}
pub(crate) use load_bytes_multi;
// macro_rules! load_multi {
// (
// $pool:ident,
// $($name:literal),*
// ) => {
// (|| {
// (
// $(
// match load(pool, $name)? {
// Some(v) => v,
// None => return Ok(None)
// },
// )*
// )
// })()
// }
// }

View File

@ -5,8 +5,9 @@ mod credentials;
pub mod errors;
mod clientinfo;
mod ipc;
mod kv;
mod state;
mod server;
pub mod server;
mod shortcuts;
mod terminal;
mod tray;

View File

@ -13,7 +13,7 @@ use creddy::{
fn main() {
let res = match cli::parser().get_matches().subcommand() {
None | Some(("run", _)) => {
app::run().error_popup("Creddy failed to start");
app::run().error_popup("Creddy encountered an error");
Ok(())
},
Some(("get", m)) => cli::get(m),

View File

@ -7,8 +7,11 @@ use tauri::{AppHandle, Manager};
use crate::errors::*;
use crate::clientinfo::{self, Client};
use crate::credentials::Credentials;
use crate::ipc::{Approval, AwsRequestNotification};
use crate::credentials::{
AwsBaseCredential,
AwsSessionCredential,
};
use crate::ipc::{Approval, RequestNotification};
use crate::state::AppState;
use crate::shortcuts::{self, ShortcutAction};
@ -26,6 +29,8 @@ pub use server_unix::Server;
#[cfg(unix)]
use server_unix::Stream;
pub mod ssh_agent;
#[derive(Serialize, Deserialize)]
pub enum Request {
@ -38,7 +43,8 @@ pub enum Request {
#[derive(Debug, Serialize, Deserialize)]
pub enum Response {
Aws(Credentials),
AwsBase(AwsBaseCredential),
AwsSession(AwsSessionCredential),
Empty,
}
@ -125,13 +131,13 @@ async fn get_aws_credentials(
// but ? returns immediately, and we want to unregister the request before returning
// so we bundle it all up in an async block and return a Result so we can handle errors
let proceed = async {
let notification = AwsRequestNotification {id: request_id, client, base};
app_handle.emit_all("credentials-request", &notification)?;
let notification = RequestNotification::new_aws(request_id, client, base);
app_handle.emit("credential-request", &notification)?;
let response = tokio::select! {
r = chan_recv => r?,
_ = waiter.wait_for_close() => {
app_handle.emit_all("request-cancelled", request_id)?;
app_handle.emit("request-cancelled", request_id)?;
return Err(HandlerError::Abandoned);
},
};
@ -139,12 +145,12 @@ async fn get_aws_credentials(
match response.approval {
Approval::Approved => {
if response.base {
let creds = state.base_creds_cloned().await?;
Ok(Response::Aws(Credentials::Base(creds)))
let creds = state.get_aws_base("default").await?;
Ok(Response::AwsBase(creds))
}
else {
let creds = state.session_creds_cloned().await?;
Ok(Response::Aws(Credentials::Session(creds)))
let creds = state.get_aws_session("default").await?;
Ok(Response::AwsSession(creds.clone()))
}
},
Approval::Denied => Err(HandlerError::Denied),

View File

@ -2,7 +2,6 @@ use std::io::ErrorKind;
use tokio::net::{UnixListener, UnixStream};
use tauri::{
AppHandle,
Manager,
async_runtime as rt,
};
@ -41,7 +40,7 @@ impl Server {
async fn try_serve(&self) -> Result<(), HandlerError> {
let (stream, _addr) = self.listener.accept().await?;
let new_handle = self.app_handle.app_handle();
let new_handle = self.app_handle.clone();
let client_pid = get_client_pid(&stream)?;
rt::spawn(async move {
super::handle(stream, new_handle, client_pid)

View File

@ -52,7 +52,7 @@ impl Server {
// create a new pipe instance to listen for the next client, and swap it in
let new_listener = ServerOptions::new().create(r"\\.\pipe\creddy-requests")?;
let stream = std::mem::replace(&mut self.listener, new_listener);
let new_handle = self.app_handle.app_handle();
let new_handle = self.app_handle.clone();
let client_pid = get_client_pid(&stream)?;
rt::spawn(async move {
super::handle(stream, new_handle, client_pid)

View File

@ -0,0 +1,77 @@
use signature::Signer;
use ssh_agent_lib::agent::{Agent, Session};
use ssh_agent_lib::proto::message::Message;
use ssh_key::public::PublicKey;
use ssh_key::private::PrivateKey;
use tokio::net::UnixListener;
struct SshAgent;
impl std::default::Default for SshAgent {
fn default() -> Self {
SshAgent {}
}
}
#[ssh_agent_lib::async_trait]
impl Session for SshAgent {
async fn handle(&mut self, message: Message) -> Result<Message, Box<dyn std::error::Error>> {
println!("Received message");
match message {
Message::RequestIdentities => {
let p = std::path::PathBuf::from("/home/joe/.ssh/id_ed25519.pub");
let pubkey = PublicKey::read_openssh_file(&p).unwrap();
let id = ssh_agent_lib::proto::message::Identity {
pubkey_blob: pubkey.to_bytes().unwrap(),
comment: pubkey.comment().to_owned(),
};
Ok(Message::IdentitiesAnswer(vec![id]))
},
Message::SignRequest(req) => {
println!("Received sign request");
let mut req_bytes = vec![13];
encode_string(&mut req_bytes, &req.pubkey_blob);
encode_string(&mut req_bytes, &req.data);
req_bytes.extend(req.flags.to_be_bytes());
std::fs::File::create("/tmp/signreq").unwrap().write(&req_bytes).unwrap();
let p = std::path::PathBuf::from("/home/joe/.ssh/id_ed25519");
let passphrase = std::env::var("PRIVKEY_PASSPHRASE").unwrap();
let privkey = PrivateKey::read_openssh_file(&p)
.unwrap()
.decrypt(passphrase.as_bytes())
.unwrap();
let sig = Signer::sign(&privkey, &req.data);
use std::io::Write;
std::fs::File::create("/tmp/sig").unwrap().write(sig.as_bytes()).unwrap();
let mut payload = Vec::with_capacity(128);
encode_string(&mut payload, "ssh-ed25519".as_bytes());
encode_string(&mut payload, sig.as_bytes());
println!("Payload length: {}", payload.len());
std::fs::File::create("/tmp/payload").unwrap().write(&payload).unwrap();
Ok(Message::SignResponse(payload))
},
_ => Ok(Message::Failure),
}
}
}
fn encode_string(buf: &mut Vec<u8>, s: &[u8]) {
let len = s.len() as u32;
buf.extend(len.to_be_bytes());
buf.extend(s);
}
pub async fn run() {
let socket = "/tmp/creddy-agent.sock";
let _ = std::fs::remove_file(socket);
let listener = UnixListener::bind(socket).unwrap();
SshAgent.listen(listener).await.unwrap();
}

View File

@ -1,12 +1,13 @@
use serde::{Serialize, Deserialize};
use tauri::{
GlobalShortcutManager,
Manager,
async_runtime as rt,
use tauri::async_runtime as rt;
use tauri_plugin_global_shortcut::{
GlobalShortcutExt,
Error as ShortcutError,
};
use crate::app::APP;
use crate::app::{self, APP};
use crate::config::HotkeysConfig;
use crate::errors::*;
use crate::terminal;
@ -21,38 +22,46 @@ pub enum ShortcutAction {
pub fn exec_shortcut(action: ShortcutAction) {
match action {
ShortcutAction::LaunchTerminal => launch_terminal(),
ShortcutAction::ShowWindow => {
let app = APP.get().unwrap();
app.get_window("main")
.ok_or("Couldn't find application main window")
.map(|w| w.show().error_popup("Failed to show window"))
.error_popup("Failed to show window");
},
ShortcutAction::LaunchTerminal => {
rt::spawn(async {
terminal::launch(false).await.error_popup("Failed to launch terminal");
});
app::show_main_window(app)
.error_popup("Failed to show Creddy");
},
}
}
pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> tauri::Result<()> {
fn launch_terminal() {
rt::spawn(async {
terminal::launch(false)
.await
.error_popup("Failed to launch terminal")
});
}
pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> Result<(), ShortcutError> {
let app = APP.get().unwrap();
let mut manager = app.global_shortcut_manager();
manager.unregister_all()?;
let shortcuts = app.global_shortcut();
shortcuts.unregister_all([
hotkeys.show_window.keys.as_str(),
hotkeys.launch_terminal.keys.as_str(),
])?;
if hotkeys.show_window.enabled {
manager.register(
&hotkeys.show_window.keys,
|| exec_shortcut(ShortcutAction::ShowWindow)
shortcuts.on_shortcut(
hotkeys.show_window.keys.as_str(),
|app, _shortcut, _event| {
app::show_main_window(app).error_popup("Failed to show Creddy")
}
)?;
}
if hotkeys.launch_terminal.enabled {
manager.register(
&hotkeys.launch_terminal.keys,
|| exec_shortcut(ShortcutAction::LaunchTerminal)
shortcuts.on_shortcut(
hotkeys.launch_terminal.keys.as_str(),
|_app, _shortcut, _event| launch_terminal()
)?;
}

View File

@ -3,10 +3,11 @@ use std::time::Duration;
use time::OffsetDateTime;
use tokio::{
sync::RwLock,
sync::{RwLock, RwLockReadGuard},
sync::oneshot::{self, Sender},
};
use sqlx::SqlitePool;
use sqlx::types::Uuid;
use tauri::{
Manager,
async_runtime as rt,
@ -14,12 +15,16 @@ use tauri::{
use crate::app;
use crate::credentials::{
Session,
BaseCredentials,
SessionCredentials,
AppSession,
AwsSessionCredential,
};
use crate::{config, config::AppConfig};
use crate::ipc::{self, Approval, RequestResponse};
use crate::credentials::{
AwsBaseCredential,
SaveCredential,
PersistentCredential
};
use crate::ipc::{self, RequestResponse};
use crate::errors::*;
use crate::shortcuts;
@ -37,7 +42,7 @@ impl Visibility {
fn acquire(&mut self, delay_ms: u64) -> Result<VisibilityLease, WindowError> {
let app = crate::app::APP.get().unwrap();
let window = app.get_window("main")
let window = app.get_webview_window("main")
.ok_or(WindowError::NoMainWindow)?;
self.leases += 1;
@ -63,18 +68,17 @@ impl Visibility {
let lease = VisibilityLease { notify: tx };
let delay = Duration::from_millis(delay_ms);
let handle = app.app_handle();
rt::spawn(async move {
// We don't care if it's an error; lease being dropped should be handled identically
let _ = rx.await;
tokio::time::sleep(delay).await;
// we can't use `self` here because we would have to move it into the async block
let state = handle.state::<AppState>();
let state = app.state::<AppState>();
let mut visibility = state.visibility.write().await;
visibility.leases -= 1;
if visibility.leases == 0 {
if let Some(false) = visibility.original {
app::hide_main_window(&handle).error_print();
app::hide_main_window(app).error_print();
}
visibility.original = None;
}
@ -102,7 +106,8 @@ impl VisibilityLease {
#[derive(Debug)]
pub struct AppState {
pub config: RwLock<AppConfig>,
pub session: RwLock<Session>,
pub app_session: RwLock<AppSession>,
pub aws_session: RwLock<Option<AwsSessionCredential>>,
pub last_activity: RwLock<OffsetDateTime>,
pub request_count: RwLock<u64>,
pub waiting_requests: RwLock<HashMap<u64, Sender<RequestResponse>>>,
@ -117,14 +122,15 @@ pub struct AppState {
impl AppState {
pub fn new(
config: AppConfig,
session: Session,
app_session: AppSession,
pool: SqlitePool,
setup_errors: Vec<String>,
desktop_is_gnome: bool,
) -> AppState {
AppState {
config: RwLock::new(config),
session: RwLock::new(session),
app_session: RwLock::new(app_session),
aws_session: RwLock::new(None),
last_activity: RwLock::new(OffsetDateTime::now_utc()),
request_count: RwLock::new(0),
waiting_requests: RwLock::new(HashMap::new()),
@ -136,12 +142,44 @@ impl AppState {
}
}
pub async fn new_creds(&self, base_creds: BaseCredentials, passphrase: &str) -> Result<(), UnlockError> {
let locked = base_creds.encrypt(passphrase)?;
// do this first so that if it fails we don't save bad credentials
self.new_session(base_creds).await?;
locked.save(&self.pool).await?;
pub async fn save_credential(&self, cred: SaveCredential) -> Result<(), SaveCredentialsError> {
let session = self.app_session.read().await;
let crypto = session.try_get_crypto()?;
cred.save(crypto, &self.pool).await
}
pub async fn delete_credential(&self, id: &Uuid) -> Result<(), SaveCredentialsError> {
sqlx::query!("DELETE FROM credentials WHERE id = ?", id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn list_credentials(&self) -> Result<Vec<SaveCredential>, GetCredentialsError> {
let session = self.app_session.read().await;
let crypto = session.try_get_crypto()?;
let creds = AwsBaseCredential::list(crypto, &self.pool).await?;
// eventual extend this vec with other credential types
Ok(creds)
}
pub async fn set_passphrase(&self, passphrase: &str) -> Result<(), SaveCredentialsError> {
let mut cur_session = self.app_session.write().await;
if let AppSession::Locked {..} = *cur_session {
return Err(SaveCredentialsError::Locked);
}
let new_session = AppSession::new(passphrase)?;
if let AppSession::Unlocked {salt: _, ref crypto} = *cur_session {
AwsBaseCredential::rekey(
crypto,
new_session.try_get_crypto().expect("AppSession::new() should always return Unlocked"),
&self.pool,
).await?;
}
new_session.save(&self.pool).await?;
*cur_session = new_session;
Ok(())
}
@ -188,11 +226,6 @@ impl AppState {
}
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
if let Approval::Approved = response.approval {
let mut session = self.session.write().await;
session.renew_if_expired().await?;
}
let mut waiting_requests = self.waiting_requests.write().await;
waiting_requests
.remove(&response.id)
@ -202,33 +235,49 @@ impl AppState {
}
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
let base_creds = match *self.session.read().await {
Session::Empty => {return Err(UnlockError::NoCredentials);},
Session::Unlocked{..} => {return Err(UnlockError::NotLocked);},
Session::Locked(ref locked) => locked.decrypt(passphrase)?,
};
// Read lock is dropped here, so this doesn't deadlock
self.new_session(base_creds).await?;
Ok(())
let mut session = self.app_session.write().await;
session.unlock(passphrase)
}
pub async fn lock(&self) -> Result<(), LockError> {
let mut session = self.session.write().await;
let mut session = self.app_session.write().await;
match *session {
Session::Empty => Err(LockError::NotUnlocked),
Session::Locked(_) => Err(LockError::NotUnlocked),
Session::Unlocked{..} => {
*session = Session::load(&self.pool).await?;
AppSession::Empty => Err(LockError::NotUnlocked),
AppSession::Locked{..} => Err(LockError::NotUnlocked),
AppSession::Unlocked{..} => {
*session = AppSession::load(&self.pool).await?;
let app_handle = app::APP.get().unwrap();
app_handle.emit_all("locked", None::<usize>)?;
app_handle.emit("locked", None::<usize>)?;
Ok(())
}
}
}
pub async fn get_aws_base(&self, name: &str) -> Result<AwsBaseCredential, GetCredentialsError> {
let app_session = self.app_session.read().await;
let crypto = app_session.try_get_crypto()?;
let creds = AwsBaseCredential::load(name, crypto, &self.pool).await?;
Ok(creds)
}
pub async fn get_aws_session(&self, name: &str) -> Result<RwLockReadGuard<'_, AwsSessionCredential>, GetCredentialsError> {
// yes, this sometimes results in double-fetching base credentials from disk
// I'm done trying to be optimal
{
let mut aws_session = self.aws_session.write().await;
if aws_session.is_none() || aws_session.as_ref().unwrap().is_expired() {
let base_creds = self.get_aws_base(name).await?;
*aws_session = Some(AwsSessionCredential::from_base(&base_creds).await?);
}
}
// we know this is safe, because we juse made sure of it
let s = RwLockReadGuard::map(self.aws_session.read().await, |opt| opt.as_ref().unwrap());
Ok(s)
}
pub async fn signal_activity(&self) {
let mut last_activity = self.last_activity.write().await;
*last_activity = OffsetDateTime::now_utc();
@ -236,7 +285,7 @@ impl AppState {
pub async fn should_auto_lock(&self) -> bool {
let config = self.config.read().await;
if !config.auto_lock || !self.is_unlocked().await {
if !config.auto_lock || self.is_locked().await {
return false;
}
@ -245,28 +294,9 @@ impl AppState {
elapsed >= config.lock_after
}
pub async fn is_unlocked(&self) -> bool {
let session = self.session.read().await;
matches!(*session, Session::Unlocked{..})
}
pub async fn base_creds_cloned(&self) -> Result<BaseCredentials, GetCredentialsError> {
let app_session = self.session.read().await;
let (base, _session) = app_session.try_get()?;
Ok(base.clone())
}
pub async fn session_creds_cloned(&self) -> Result<SessionCredentials, GetCredentialsError> {
let app_session = self.session.read().await;
let (_base, session) = app_session.try_get()?;
Ok(session.clone())
}
async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> {
let session = SessionCredentials::from_base(&base).await?;
let mut app_session = self.session.write().await;
*app_session = Session::Unlocked {base, session};
Ok(())
pub async fn is_locked(&self) -> bool {
let session = self.app_session.read().await;
matches!(*session, AppSession::Locked {..})
}
pub async fn register_terminal_request(&self) -> Result<(), ()> {
@ -286,3 +316,41 @@ impl AppState {
*req = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::Crypto;
use sqlx::types::Uuid;
fn test_state(pool: SqlitePool) -> AppState {
let salt = [0u8; 32];
let crypto = Crypto::fixed();
AppState::new(
AppConfig::default(),
AppSession::Unlocked { salt, crypto },
pool,
vec![],
false,
)
}
#[sqlx::test(fixtures("./credentials/fixtures/aws_credentials.sql"))]
fn test_delete_credential(pool: SqlitePool) {
let state = test_state(pool);
let id = Uuid::try_parse("00000000-0000-0000-0000-000000000000").unwrap();
state.delete_credential(&id).await.unwrap();
// ensure delete-cascade went through correctly
let res = AwsBaseCredential::load(
"test",
&Crypto::fixed(),
&state.pool,
).await;
assert!(matches!(res, Err(LoadCredentialsError::NoCredentials)));
}
}

View File

@ -1,6 +1,8 @@
use std::process::Command;
use std::time::Duration;
use tauri::Manager;
use tokio::time::sleep;
use crate::app::APP;
use crate::errors::*;
@ -23,44 +25,42 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> {
cmd
};
// if session is unlocked or empty, wait for credentials from frontend
if !state.is_unlocked().await {
app.emit_all("launch-terminal-request", ())?;
// if session is locked, wait for credentials from frontend
if state.is_locked().await {
let lease = state.acquire_visibility_lease(0).await
.map_err(|_e| LaunchTerminalError::NoMainWindow)?; // automate conversion eventually?
let (tx, rx) = tokio::sync::oneshot::channel();
app.once_global("credentials-event", move |e| {
let success = match e.payload() {
Some("\"unlocked\"") | Some("\"entered\"") => true,
_ => false,
};
let _ = tx.send(success);
app.once("unlocked", move |_| {
let _ = tx.send(());
});
if !rx.await.unwrap_or(false) {
state.unregister_terminal_request().await;
return Ok(()); // request was canceled by user
let timeout = Duration::from_secs(60);
tokio::select! {
// if the frontend is unlocked within 60 seconds, release visibility lock and proceed
_ = rx => lease.release(),
// otherwise, dump this request, but return Ok so we don't get an error popup
_ = sleep(timeout) => {
state.unregister_terminal_request().await;
eprintln!("WARNING: Request to launch terminal timed out after 60 seconds.");
return Ok(());
},
}
lease.release();
}
// more lock-management
{
let app_session = state.session.read().await;
// session should really be unlocked at this point, but if the frontend misbehaves
// (i.e. lies about unlocking) we could end up here with a locked session
// this will result in an error popup to the user (see main hotkey handler)
let (base_creds, session_creds) = app_session.try_get()?;
if use_base {
cmd.env("AWS_ACCESS_KEY_ID", &base_creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", &base_creds.secret_access_key);
}
else {
cmd.env("AWS_ACCESS_KEY_ID", &session_creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", &session_creds.secret_access_key);
cmd.env("AWS_SESSION_TOKEN", &session_creds.session_token);
}
// session should really be unlocked at this point, but if the frontend misbehaves
// (i.e. lies about unlocking) we could end up here with a locked session
// this will result in an error popup to the user (see main hotkey handler)
if use_base {
let base_creds = state.get_aws_base("default").await?;
cmd.env("AWS_ACCESS_KEY_ID", &base_creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", &base_creds.secret_access_key);
}
else {
let session_creds = state.get_aws_session("default").await?;
cmd.env("AWS_ACCESS_KEY_ID", &session_creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", &session_creds.secret_access_key);
cmd.env("AWS_SESSION_TOKEN", &session_creds.session_token);
}
let res = match cmd.spawn() {

View File

@ -1,45 +1,49 @@
use tauri::{
App,
AppHandle,
CustomMenuItem,
Manager,
SystemTray,
SystemTrayEvent,
SystemTrayMenu,
async_runtime as rt,
};
use tauri::menu::{
MenuBuilder,
MenuEvent,
MenuItemBuilder,
};
use crate::app;
use crate::state::AppState;
pub fn create() -> SystemTray {
let show = CustomMenuItem::new("show_hide".to_string(), "Show");
let quit = CustomMenuItem::new("exit".to_string(), "Exit");
pub fn setup(app: &App) -> tauri::Result<()> {
let show_hide = MenuItemBuilder::with_id("show_hide", "Show").build(app)?;
let exit = MenuItemBuilder::with_id("exit", "Exit").build(app)?;
let menu = SystemTrayMenu::new()
.add_item(show)
.add_item(quit);
let menu = MenuBuilder::new(app)
.items(&[&show_hide, &exit])
.build()?;
SystemTray::new().with_menu(menu)
let tray = app.tray_by_id("main").unwrap();
tray.set_menu(Some(menu))?;
tray.on_menu_event(handle_event);
// stash this so we can find it later to change the text
app.manage(show_hide);
Ok(())
}
pub fn handle_event(app_handle: &AppHandle, event: SystemTrayEvent) {
match event {
SystemTrayEvent::MenuItemClick{ id, .. } => {
match id.as_str() {
"exit" => app_handle.exit(0),
"show_hide" => {
let _ = app::toggle_main_window(app_handle);
let new_handle = app_handle.app_handle();
rt::spawn(async move {
let state = new_handle.state::<AppState>();
state.signal_activity().await;
});
}
_ => (),
}
}
fn handle_event(app_handle: &AppHandle, event: MenuEvent) {
match event.id.0.as_str() {
"exit" => app_handle.exit(0),
"show_hide" => {
let _ = app::toggle_main_window(app_handle);
let new_handle = app_handle.clone();
rt::spawn(async move {
let state = new_handle.state::<AppState>();
state.signal_activity().await;
});
},
_ => (),
}
}

View File

@ -3,69 +3,57 @@
"build": {
"beforeBuildCommand": "npm run build",
"beforeDevCommand": "npm run dev",
"devPath": "http://localhost:5173",
"distDir": "../dist"
"frontendDist": "../dist",
"devUrl": "http://localhost:5173"
},
"package": {
"productName": "creddy",
"version": "0.4.7"
},
"tauri": {
"allowlist": {
"app": {
"all": true,
"show": false,
"hide": false
},
"os": {"all": true},
"dialog": {"open": true}
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"targets": "all",
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "",
"wix": {
"fragmentPaths": [
"conf/cli.wxs"
],
"componentRefs": [
"CliBinary",
"AddToPath"
]
}
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"linux": {
"deb": {
"depends": []
},
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "creddy",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "",
"wix": {
"fragmentPaths": ["conf/cli.wxs"],
"componentRefs": ["CliBinary", "AddToPath"]
}
}
},
"security": {
"csp": {
"default-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"]
}
},
"updater": {
"active": false
},
}
},
"productName": "creddy",
"version": "0.4.9",
"identifier": "creddy",
"plugins": {},
"app": {
"windows": [
{
"fullscreen": false,
@ -77,9 +65,24 @@
"visible": false
}
],
"systemTray": {
"trayIcon": {
"id": "main",
"iconPath": "icons/icon.png",
"iconAsTemplate": true
},
"security": {
"csp": {
"style-src": [
"'self'",
"'unsafe-inline'"
],
"default-src": [
"'self'"
],
"connect-src": [
"ipc: http://ipc.localhost"
]
}
}
}
}

View File

@ -1,7 +1,7 @@
<script>
import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';
import { invoke } from '@tauri-apps/api/core';
import { getVersion } from '@tauri-apps/api/app';
import { appState, acceptRequest, cleanupRequest } from './lib/state.js';

View File

@ -1,6 +1,6 @@
<script>
import { createEventDispatcher } from 'svelte';
import { open } from '@tauri-apps/api/dialog';
import { open } from '@tauri-apps/plugin-dialog';
import Setting from './Setting.svelte';
export let title;

View File

@ -1,6 +1,6 @@
<script>
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri';
import { invoke } from '@tauri-apps/api/core';
import { navigate } from '../lib/routing.js';
import { appState, cleanupRequest } from '../lib/state.js';

View File

@ -1,6 +1,6 @@
<script>
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri';
import { invoke } from '@tauri-apps/api/core';
import { emit } from '@tauri-apps/api/event';
import { getRootCause } from '../lib/errors.js';
@ -33,6 +33,7 @@
saving = true;
await invoke('save_credentials', {credentials, passphrase});
emit('credentials-event', 'entered');
$appState.credentialStatus = 'unlocked';
if ($appState.currentRequest) {
navigate('Approve');
}
@ -41,13 +42,14 @@
}
}
catch (e) {
window.error = e;
const root = getRootCause(e);
if (e.code === 'GetSession' && root.code) {
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
}
else {
errorMsg = e.msg;
// some of the built-in Tauri errors are plain strings,
// so fall back to e if e.msg doesn't exist
errorMsg = e.msg || e;
}
// if the alert already existed, shake it

View File

@ -1,6 +1,6 @@
<script>
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri';
import { invoke } from '@tauri-apps/api/core';
import { appState } from '../lib/state.js';
import { navigate } from '../lib/routing.js';

View File

@ -1,6 +1,6 @@
<script>
import { invoke } from '@tauri-apps/api/tauri';
import { type } from '@tauri-apps/api/os';
import { invoke } from '@tauri-apps/api/core';
import { type } from '@tauri-apps/plugin-os';
import { appState } from '../lib/state.js';
import Nav from '../ui/Nav.svelte';

View File

@ -1,5 +1,5 @@
<script>
import { invoke } from '@tauri-apps/api/tauri';
import { invoke } from '@tauri-apps/api/core';
import { emit } from '@tauri-apps/api/event';
import { onMount } from 'svelte';