diff --git a/bun.lock b/bun.lock index fd6f0501e..fc58f487b 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "dedent": "^1.7.1", "fflate": "^0.8.2", "fzstd": "^0.1.1", + "idb": "^8.0.3", "jspdf": "^4.2.1", "lib0": "^0.2.117", "motion-v": "^2.0.0", @@ -86,6 +87,7 @@ "@typescript/native-preview": "^7.0.0-dev.20260228.1", "@vitejs/plugin-vue": "^6.0.5", "esbuild": "^0.27.3", + "fake-indexeddb": "^6.2.5", "hono": "^4.12.5", "jscpd": "^4.0.8", "knip": "6.20.0", @@ -1816,6 +1818,8 @@ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -1976,7 +1980,7 @@ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="], + "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -3240,6 +3244,12 @@ "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@firebase/app/idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="], + + "@firebase/installations/idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="], + + "@firebase/messaging/idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="], + "@libp2p/crypto/@libp2p/interface": ["@libp2p/interface@3.1.0", "", { "dependencies": { "@multiformats/dns": "^1.0.6", "@multiformats/multiaddr": "^13.0.1", "main-event": "^1.0.1", "multiformats": "^13.4.0", "progress-events": "^1.0.1", "uint8arraylist": "^2.4.8" } }, "sha512-RE7/XyvC47fQBe1cHxhMvepYKa5bFCUyFrrpj8PuM0E7JtzxU7F+Du5j4VXbg2yLDcToe0+j8mB7jvwE2AThYw=="], "@libp2p/crypto/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], @@ -3550,12 +3560,16 @@ "with/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "workbox-background-sync/idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="], + "workbox-build/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "workbox-build/pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="], "workbox-build/source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], + "workbox-expiration/idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="], + "@ai-sdk/deepseek/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], "@libp2p/crypto/@libp2p/interface/@multiformats/multiaddr": ["@multiformats/multiaddr@13.0.1", "", { "dependencies": { "@chainsafe/is-ip": "^2.0.1", "multiformats": "^13.0.0", "uint8-varint": "^2.0.1", "uint8arrays": "^5.0.0" } }, "sha512-XToN915cnfr6Lr9EdGWakGJbPT0ghpg/850HvdC+zFX8XvpLZElwa8synCiwa8TuvKNnny6m8j8NVBNCxhIO3g=="], diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 9a11d5d88..5da560134 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -47,6 +58,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -279,6 +301,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -422,6 +453,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -485,6 +525,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -778,6 +828,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1685,6 +1736,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -2019,6 +2088,16 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2158,6 +2237,27 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0298a59b384c540e408a600c8b375a09b49c3f97debc080e2c30675d79a6368a" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -2495,12 +2595,75 @@ dependencies = [ "serde", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2780,6 +2943,7 @@ version = "0.13.2" dependencies = [ "fix-path-env", "font-kit", + "keyring", "reqwest 0.12.28", "serde", "serde_json", @@ -3861,6 +4025,25 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5853,6 +6036,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -6459,6 +6655,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + [[package]] name = "zbus_macros" version = "5.14.0" diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 7ee5031e7..429bf9a1f 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -34,6 +34,7 @@ fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs", branch = tauri-plugin-updater = "2" tauri-plugin-process = "2" tauri-plugin-clipboard-manager = "2" +keyring = "4.1.5" [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/desktop/src/credentials.rs b/desktop/src/credentials.rs new file mode 100644 index 000000000..59563c00c --- /dev/null +++ b/desktop/src/credentials.rs @@ -0,0 +1,322 @@ +use keyring::{Entry, Error as KeyringError}; +use serde::{Deserialize, Serialize}; + +const CREDENTIAL_SERVICE: &str = "net.dannote.open-pencil.credentials"; +const AVAILABILITY_ACCOUNT: &str = "v1:system:default:availability"; +const MAX_SEGMENT_LENGTH: usize = 64; +const MAX_CREDENTIAL_LENGTH: usize = 16 * 1024; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CredentialRef { + integration_id: String, + profile_id: String, + field: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CredentialError { + code: CredentialErrorCode, + message: &'static str, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +enum CredentialErrorCode { + InvalidReference, + InvalidValue, + Locked, + Unavailable, + Failed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum CredentialStatus { + Configured, + Missing, + Locked, + Unavailable, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum CredentialStoreAvailability { + Available, + Locked, + Unavailable, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum BackendError { + Locked, + Unavailable, + Failed, +} + +trait CredentialBackend { + fn read(&self, account: &str) -> Result, BackendError>; + fn write(&self, account: &str, value: &str) -> Result<(), BackendError>; + fn remove(&self, account: &str) -> Result<(), BackendError>; +} + +struct NativeCredentialBackend; + +impl NativeCredentialBackend { + fn entry(account: &str) -> Result { + Entry::new(CREDENTIAL_SERVICE, account).map_err(map_keyring_error) + } +} + +impl CredentialBackend for NativeCredentialBackend { + fn read(&self, account: &str) -> Result, BackendError> { + match Self::entry(account)?.get_password() { + Ok(value) => Ok(Some(value)), + Err(KeyringError::NoEntry) => Ok(None), + Err(error) => Err(map_keyring_error(error)), + } + } + + fn write(&self, account: &str, value: &str) -> Result<(), BackendError> { + Self::entry(account)? + .set_password(value) + .map_err(map_keyring_error) + } + + fn remove(&self, account: &str) -> Result<(), BackendError> { + match Self::entry(account)?.delete_credential() { + Ok(()) | Err(KeyringError::NoEntry) => Ok(()), + Err(error) => Err(map_keyring_error(error)), + } + } +} + +fn map_keyring_error(error: KeyringError) -> BackendError { + match error { + KeyringError::NoStorageAccess(_) => BackendError::Locked, + KeyringError::NoDefaultStore + | KeyringError::NotSupportedByStore(_) + | KeyringError::PlatformFailure(_) => BackendError::Unavailable, + _ => BackendError::Failed, + } +} + +fn validate_segment(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_SEGMENT_LENGTH + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +fn account_for(reference: &CredentialRef) -> Result { + if !validate_segment(&reference.integration_id) + || !validate_segment(&reference.profile_id) + || !validate_segment(&reference.field) + { + return Err(CredentialError { + code: CredentialErrorCode::InvalidReference, + message: "Credential reference is invalid", + }); + } + + Ok(format!( + "v1:{}:{}:{}", + reference.integration_id, reference.profile_id, reference.field + )) +} + +fn public_error(error: BackendError) -> CredentialError { + match error { + BackendError::Locked => CredentialError { + code: CredentialErrorCode::Locked, + message: "The system credential store is locked", + }, + BackendError::Unavailable => CredentialError { + code: CredentialErrorCode::Unavailable, + message: "The system credential store is unavailable", + }, + BackendError::Failed => CredentialError { + code: CredentialErrorCode::Failed, + message: "The credential operation failed", + }, + } +} + +fn read_with( + backend: &impl CredentialBackend, + reference: &CredentialRef, +) -> Result, CredentialError> { + let account = account_for(reference)?; + backend.read(&account).map_err(public_error) +} + +fn write_with( + backend: &impl CredentialBackend, + reference: &CredentialRef, + value: &str, +) -> Result<(), CredentialError> { + if value.is_empty() || value.len() > MAX_CREDENTIAL_LENGTH { + return Err(CredentialError { + code: CredentialErrorCode::InvalidValue, + message: "Credential value is invalid", + }); + } + let account = account_for(reference)?; + backend.write(&account, value).map_err(public_error) +} + +fn remove_with( + backend: &impl CredentialBackend, + reference: &CredentialRef, +) -> Result<(), CredentialError> { + let account = account_for(reference)?; + backend.remove(&account).map_err(public_error) +} + +#[tauri::command] +pub async fn credential_store_availability() -> Result +{ + tauri::async_runtime::spawn_blocking(|| { + match NativeCredentialBackend.read(AVAILABILITY_ACCOUNT) { + Ok(_) => Ok(CredentialStoreAvailability::Available), + Err(BackendError::Locked) => Ok(CredentialStoreAvailability::Locked), + Err(BackendError::Unavailable) => Ok(CredentialStoreAvailability::Unavailable), + Err(error) => Err(public_error(error)), + } + }) + .await + .map_err(|_| public_error(BackendError::Failed))? +} + +#[tauri::command] +pub async fn credential_status( + reference: CredentialRef, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + match read_with(&NativeCredentialBackend, &reference) { + Ok(Some(_)) => Ok(CredentialStatus::Configured), + Ok(None) => Ok(CredentialStatus::Missing), + Err(CredentialError { + code: CredentialErrorCode::Locked, + .. + }) => Ok(CredentialStatus::Locked), + Err(CredentialError { + code: CredentialErrorCode::Unavailable, + .. + }) => Ok(CredentialStatus::Unavailable), + Err(error) => Err(error), + } + }) + .await + .map_err(|_| public_error(BackendError::Failed))? +} + +#[tauri::command] +pub async fn credential_read(reference: CredentialRef) -> Result, CredentialError> { + tauri::async_runtime::spawn_blocking(move || read_with(&NativeCredentialBackend, &reference)) + .await + .map_err(|_| public_error(BackendError::Failed))? +} + +#[tauri::command] +pub async fn credential_write( + reference: CredentialRef, + value: String, +) -> Result<(), CredentialError> { + tauri::async_runtime::spawn_blocking(move || { + write_with(&NativeCredentialBackend, &reference, &value) + }) + .await + .map_err(|_| public_error(BackendError::Failed))? +} + +#[tauri::command] +pub async fn credential_remove(reference: CredentialRef) -> Result<(), CredentialError> { + tauri::async_runtime::spawn_blocking(move || remove_with(&NativeCredentialBackend, &reference)) + .await + .map_err(|_| public_error(BackendError::Failed))? +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{collections::HashMap, sync::Mutex}; + + #[derive(Default)] + struct MemoryBackend(Mutex>); + + impl CredentialBackend for MemoryBackend { + fn read(&self, account: &str) -> Result, BackendError> { + Ok(self + .0 + .lock() + .ok() + .and_then(|values| values.get(account).cloned())) + } + + fn write(&self, account: &str, value: &str) -> Result<(), BackendError> { + self.0 + .lock() + .map_err(|_| BackendError::Failed)? + .insert(account.to_owned(), value.to_owned()); + Ok(()) + } + + fn remove(&self, account: &str) -> Result<(), BackendError> { + self.0 + .lock() + .map_err(|_| BackendError::Failed)? + .remove(account); + Ok(()) + } + } + + fn reference() -> CredentialRef { + CredentialRef { + integration_id: "openai-compatible".to_owned(), + profile_id: "default".to_owned(), + field: "api-key".to_owned(), + } + } + + #[test] + fn creates_stable_versioned_account_names() { + assert_eq!( + account_for(&reference()).expect("valid reference"), + "v1:openai-compatible:default:api-key" + ); + } + + #[test] + fn rejects_untrusted_account_segments() { + let invalid = CredentialRef { + integration_id: "../../other-app".to_owned(), + ..reference() + }; + + assert!(account_for(&invalid).is_err()); + } + + #[test] + fn rejects_empty_credential_values() { + let backend = MemoryBackend::default(); + assert!(write_with(&backend, &reference(), "").is_err()); + } + + #[test] + fn reads_writes_and_removes_through_backend_contract() { + let backend = MemoryBackend::default(); + let reference = reference(); + + assert_eq!(read_with(&backend, &reference).expect("initial read"), None); + write_with(&backend, &reference, "secret").expect("write"); + assert_eq!( + read_with(&backend, &reference).expect("configured read"), + Some("secret".to_owned()) + ); + remove_with(&backend, &reference).expect("remove"); + assert_eq!(read_with(&backend, &reference).expect("removed read"), None); + } +} diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index fe05ef1b6..e643c501f 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -1,3 +1,4 @@ +mod credentials; mod fig_container; mod fonts; mod http; @@ -6,6 +7,10 @@ mod menu_events; #[cfg(target_os = "macos")] mod window; +use credentials::{ + credential_read, credential_remove, credential_status, credential_store_availability, + credential_write, +}; use fig_container::build_fig_file; use fonts::{list_system_fonts, load_system_font}; use http::proxy_http_request; @@ -118,6 +123,11 @@ pub fn run() { .manage(PendingOpen(Mutex::new(Vec::new()))) .invoke_handler(tauri::generate_handler![ build_fig_file, + credential_read, + credential_remove, + credential_status, + credential_store_availability, + credential_write, list_system_fonts, load_system_font, proxy_http_request, diff --git a/lint/plugin.js b/lint/plugin.js index 583f5028d..f3f46aed7 100644 --- a/lint/plugin.js +++ b/lint/plugin.js @@ -1186,6 +1186,7 @@ const noDirectStorageAccess = { const allowedFiles = [ '/src/app/ai/chat/storage.ts', '/src/app/cache/index.ts', + '/src/app/settings/credentials/storage.ts', '/src/app/shell/layout-storage.ts', '/packages/vue/src/i18n/locale.ts' ] diff --git a/package.json b/package.json index 1196c589f..2256b6a4a 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "dedent": "^1.7.1", "fflate": "^0.8.2", "fzstd": "^0.1.1", + "idb": "^8.0.3", "jspdf": "^4.2.1", "lib0": "^0.2.117", "motion-v": "^2.0.0", @@ -139,6 +140,7 @@ "@typescript/native-preview": "^7.0.0-dev.20260228.1", "@vitejs/plugin-vue": "^6.0.5", "esbuild": "^0.27.3", + "fake-indexeddb": "^6.2.5", "hono": "^4.12.5", "jscpd": "^4.0.8", "knip": "6.20.0", diff --git a/src/app/settings/credentials/app.ts b/src/app/settings/credentials/app.ts new file mode 100644 index 000000000..f083a0255 --- /dev/null +++ b/src/app/settings/credentials/app.ts @@ -0,0 +1,44 @@ +import { ref } from 'vue' + +import { IS_TAURI } from '@open-pencil/core/constants' + +import { BrowserCredentialStore } from '@/app/settings/credentials/browser' +import { MemoryCredentialStore } from '@/app/settings/credentials/memory' +import { NativeCredentialStore } from '@/app/settings/credentials/native' +import { createCredentialServices } from '@/app/settings/credentials/services' +import { + browserRemembersCredentials, + hasLegacyCredentialStorage, + setBrowserRemembersCredentials +} from '@/app/settings/credentials/storage' +import { SwitchableCredentialStore } from '@/app/settings/credentials/switchable' +import type { CredentialRef, CredentialStore } from '@/app/settings/credentials/types' + +const legacyCredentialsPresent = hasLegacyCredentialStorage() +if (legacyCredentialsPresent) setBrowserRemembersCredentials(true) + +export const browserCredentialsRemembered = ref( + !IS_TAURI && (browserRemembersCredentials() || legacyCredentialsPresent) +) + +function initialCredentialStore(): CredentialStore { + if (IS_TAURI) return new NativeCredentialStore() + if (browserCredentialsRemembered.value) return new BrowserCredentialStore() + return new MemoryCredentialStore() +} + +export const appCredentialStore = new SwitchableCredentialStore(initialCredentialStore()) +export const appCredentialServices = createCredentialServices(appCredentialStore) + +export async function setBrowserCredentialPersistence( + remembered: boolean, + references: CredentialRef[] +): Promise { + if (IS_TAURI) return + + const next = remembered ? new BrowserCredentialStore() : new MemoryCredentialStore() + await appCredentialStore.switchTo(next, references, { clearPrevious: !remembered }) + + browserCredentialsRemembered.value = remembered + setBrowserRemembersCredentials(remembered) +} diff --git a/src/app/settings/credentials/browser.ts b/src/app/settings/credentials/browser.ts new file mode 100644 index 000000000..c6ccd3968 --- /dev/null +++ b/src/app/settings/credentials/browser.ts @@ -0,0 +1,141 @@ +import { openDB, type DBSchema, type IDBPDatabase } from 'idb' + +import { credentialKey, validateCredentialValue } from '@/app/settings/credentials/reference' +import type { + CredentialRef, + CredentialStatus, + CredentialStore, + CredentialStoreAvailability +} from '@/app/settings/credentials/types' +import { CredentialStoreError } from '@/app/settings/credentials/types' + +const DATABASE_NAME = 'open-pencil-credentials' +const DATABASE_VERSION = 1 +const MASTER_KEY_ID = 'master-key' +const IV_LENGTH = 12 + +interface EncryptedCredential { + iv: ArrayBuffer + ciphertext: ArrayBuffer +} + +function ownedBuffer(bytes: Uint8Array): ArrayBuffer { + const owned = new Uint8Array(bytes.byteLength) + owned.set(bytes) + return owned.buffer +} + +interface CredentialDatabase extends DBSchema { + credentials: { + key: string + value: EncryptedCredential + } + metadata: { + key: string + value: CryptoKey + } +} + +export class BrowserCredentialStore implements CredentialStore { + readonly backend = 'browser' as const + readonly #database: Promise> + #masterKeyPromise?: Promise + + constructor() { + this.#database = openDB(DATABASE_NAME, DATABASE_VERSION, { + upgrade(database) { + if (!database.objectStoreNames.contains('credentials')) { + database.createObjectStore('credentials') + } + if (!database.objectStoreNames.contains('metadata')) { + database.createObjectStore('metadata') + } + } + }) + } + + async availability(): Promise { + try { + await this.#database + return 'available' + } catch { + return 'unavailable' + } + } + + async status(reference: CredentialRef): Promise { + return (await this.read(reference)) === null ? 'missing' : 'configured' + } + + async read(reference: CredentialRef): Promise { + const key = credentialKey(reference) + try { + const database = await this.#database + const record = await database.get('credentials', key) + if (!record) return null + + const masterKey = await this.#masterKey(database) + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: record.iv, additionalData: this.#additionalData(key) }, + masterKey, + record.ciphertext + ) + return new TextDecoder().decode(plaintext) + } catch (error) { + throw this.#normalizeError(error) + } + } + + async write(reference: CredentialRef, value: string): Promise { + validateCredentialValue(value) + const key = credentialKey(reference) + try { + const database = await this.#database + const masterKey = await this.#masterKey(database) + const iv = ownedBuffer(crypto.getRandomValues(new Uint8Array(IV_LENGTH))) + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv, additionalData: this.#additionalData(key) }, + masterKey, + ownedBuffer(new TextEncoder().encode(value)) + ) + await database.put('credentials', { iv, ciphertext }, key) + } catch (error) { + throw this.#normalizeError(error) + } + } + + async remove(reference: CredentialRef): Promise { + try { + const database = await this.#database + await database.delete('credentials', credentialKey(reference)) + } catch (error) { + throw this.#normalizeError(error) + } + } + + #masterKey(database: IDBPDatabase): Promise { + this.#masterKeyPromise ??= this.#loadOrCreateMasterKey(database) + return this.#masterKeyPromise + } + + async #loadOrCreateMasterKey(database: IDBPDatabase): Promise { + const existing = await database.get('metadata', MASTER_KEY_ID) + if (existing) return existing + + const generated = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, [ + 'encrypt', + 'decrypt' + ]) + await database.put('metadata', generated, MASTER_KEY_ID) + return generated + } + + #additionalData(key: string): ArrayBuffer { + return ownedBuffer(new TextEncoder().encode(key)) + } + + #normalizeError(error: unknown): CredentialStoreError { + if (error instanceof CredentialStoreError) return error + return new CredentialStoreError('failed', 'Browser credential operation failed') + } +} diff --git a/src/app/settings/credentials/index.ts b/src/app/settings/credentials/index.ts new file mode 100644 index 000000000..53c721bfd --- /dev/null +++ b/src/app/settings/credentials/index.ts @@ -0,0 +1,34 @@ +import { IS_TAURI } from '@open-pencil/core/constants' + +import { BrowserCredentialStore } from '@/app/settings/credentials/browser' +import { MemoryCredentialStore } from '@/app/settings/credentials/memory' +import { NativeCredentialStore } from '@/app/settings/credentials/native' +import { createCredentialServices } from '@/app/settings/credentials/services' +import type { CredentialStore } from '@/app/settings/credentials/types' + +export type BrowserCredentialPersistence = 'session' | 'remembered' + +export function createCredentialStore( + browserPersistence: BrowserCredentialPersistence = 'session' +): CredentialStore { + if (IS_TAURI) return new NativeCredentialStore() + return browserPersistence === 'remembered' + ? new BrowserCredentialStore() + : new MemoryCredentialStore() +} + +export const sessionCredentialServices = createCredentialServices(createCredentialStore()) + +export { credentialRef } from '@/app/settings/credentials/reference' +export { createCredentialServices } from '@/app/settings/credentials/services' +export type { + CredentialBackend, + CredentialErrorCode, + CredentialManager, + CredentialRef, + CredentialResolver, + CredentialStatus, + CredentialStore, + CredentialStoreAvailability +} from '@/app/settings/credentials/types' +export { CredentialStoreError } from '@/app/settings/credentials/types' diff --git a/src/app/settings/credentials/memory.ts b/src/app/settings/credentials/memory.ts new file mode 100644 index 000000000..b3a06bfe9 --- /dev/null +++ b/src/app/settings/credentials/memory.ts @@ -0,0 +1,32 @@ +import { credentialKey, validateCredentialValue } from '@/app/settings/credentials/reference' +import type { + CredentialRef, + CredentialStatus, + CredentialStore +} from '@/app/settings/credentials/types' + +export class MemoryCredentialStore implements CredentialStore { + readonly backend = 'memory' as const + readonly #values = new Map() + + async availability() { + return 'available' as const + } + + async status(reference: CredentialRef): Promise { + return this.#values.has(credentialKey(reference)) ? 'configured' : 'missing' + } + + async read(reference: CredentialRef): Promise { + return this.#values.get(credentialKey(reference)) ?? null + } + + async write(reference: CredentialRef, value: string): Promise { + validateCredentialValue(value) + this.#values.set(credentialKey(reference), value) + } + + async remove(reference: CredentialRef): Promise { + this.#values.delete(credentialKey(reference)) + } +} diff --git a/src/app/settings/credentials/migration.ts b/src/app/settings/credentials/migration.ts new file mode 100644 index 000000000..48a458604 --- /dev/null +++ b/src/app/settings/credentials/migration.ts @@ -0,0 +1,73 @@ +import { AI_PROVIDERS, type AIProviderID } from '@open-pencil/core/constants' + +import { appCredentialStore } from '@/app/settings/credentials/app' +import { credentialRef } from '@/app/settings/credentials/reference' +import { browserCredentialStorage } from '@/app/settings/credentials/storage' +import type { CredentialRef, CredentialStore } from '@/app/settings/credentials/types' + +const STORAGE_PREFIX = 'open-pencil:' +const MIGRATION_VERSION_KEY = `${STORAGE_PREFIX}credential-migration` +const MIGRATION_VERSION = '1' +const LEGACY_OPENROUTER_KEY = `${STORAGE_PREFIX}openrouter-api-key` + +export function providerCredentialRef(providerId: AIProviderID): CredentialRef { + if (providerId.startsWith('acp:')) { + throw new Error('ACP agents do not use API-key credentials') + } + return credentialRef(providerId, 'api-key') +} + +export const PEXELS_CREDENTIAL = credentialRef('pexels', 'api-key') +export const UNSPLASH_CREDENTIAL = credentialRef('unsplash', 'access-key') + +type LegacyCredential = { + storageKey: string + reference: CredentialRef +} + +function legacyCredentials(storage: Storage): LegacyCredential[] { + const credentials: LegacyCredential[] = [ + { storageKey: LEGACY_OPENROUTER_KEY, reference: providerCredentialRef('openrouter') }, + { storageKey: `${STORAGE_PREFIX}pexels-api-key`, reference: PEXELS_CREDENTIAL }, + { storageKey: `${STORAGE_PREFIX}unsplash-access-key`, reference: UNSPLASH_CREDENTIAL } + ] + + for (let index = 0; index < storage.length; index++) { + const storageKey = storage.key(index) + if (!storageKey?.startsWith(`${STORAGE_PREFIX}ai-key:`)) continue + const legacyProviderId = storageKey.slice(`${STORAGE_PREFIX}ai-key:`.length) + const providerId = AI_PROVIDERS.find((provider) => provider.id === legacyProviderId)?.id + if (!providerId) continue + credentials.push({ storageKey, reference: providerCredentialRef(providerId) }) + } + + return credentials +} + +export async function migrateLegacyCredentials( + storage: Storage, + store: CredentialStore +): Promise { + if (storage.getItem(MIGRATION_VERSION_KEY) === MIGRATION_VERSION) return true + if ((await store.availability()) !== 'available') return false + + const credentials = legacyCredentials(storage) + for (const { storageKey, reference } of credentials) { + const legacyValue = storage.getItem(storageKey)?.trim() + if (!legacyValue) continue + + const existing = await store.read(reference) + if (existing === null) await store.write(reference, legacyValue) + if ((await store.read(reference)) === null) return false + storage.removeItem(storageKey) + } + + storage.setItem(MIGRATION_VERSION_KEY, MIGRATION_VERSION) + return true +} + +export async function initializeCredentialMigration(): Promise { + const storage = browserCredentialStorage() + if (!storage) return true + return migrateLegacyCredentials(storage, appCredentialStore) +} diff --git a/src/app/settings/credentials/native.ts b/src/app/settings/credentials/native.ts new file mode 100644 index 000000000..e3ea832da --- /dev/null +++ b/src/app/settings/credentials/native.ts @@ -0,0 +1,51 @@ +import { invoke } from '@tauri-apps/api/core' + +import { + CredentialStoreError, + type CredentialErrorCode, + type CredentialRef, + type CredentialStatus, + type CredentialStore, + type CredentialStoreAvailability +} from '@/app/settings/credentials/types' + +type NativeCredentialError = { + code?: CredentialErrorCode + message?: string +} + +export class NativeCredentialStore implements CredentialStore { + readonly backend = 'native' as const + + async availability(): Promise { + return this.#invoke('credential_store_availability') + } + + async status(reference: CredentialRef): Promise { + return this.#invoke('credential_status', { reference }) + } + + async read(reference: CredentialRef): Promise { + return this.#invoke('credential_read', { reference }) + } + + async write(reference: CredentialRef, value: string): Promise { + await this.#invoke('credential_write', { reference, value }) + } + + async remove(reference: CredentialRef): Promise { + await this.#invoke('credential_remove', { reference }) + } + + async #invoke(command: string, args?: Record): Promise { + try { + return await invoke(command, args) + } catch (error) { + const nativeError = error as NativeCredentialError + throw new CredentialStoreError( + nativeError.code ?? 'failed', + nativeError.message ?? 'System credential operation failed' + ) + } + } +} diff --git a/src/app/settings/credentials/reference.ts b/src/app/settings/credentials/reference.ts new file mode 100644 index 000000000..42407b60d --- /dev/null +++ b/src/app/settings/credentials/reference.ts @@ -0,0 +1,29 @@ +import type { CredentialRef } from '@/app/settings/credentials/types' +import { CredentialStoreError } from '@/app/settings/credentials/types' + +const CREDENTIAL_SEGMENT_PATTERN = /^[a-z0-9._-]{1,64}$/ +const MAX_CREDENTIAL_LENGTH = 16 * 1024 + +export function credentialKey(reference: CredentialRef): string { + const segments = [reference.integrationId, reference.profileId, reference.field] + if (!segments.every((segment) => CREDENTIAL_SEGMENT_PATTERN.test(segment))) { + throw new CredentialStoreError('invalid-reference', 'Credential reference is invalid') + } + return `v1:${segments.join(':')}` +} + +export function validateCredentialValue(value: string): void { + if (!value || new TextEncoder().encode(value).byteLength > MAX_CREDENTIAL_LENGTH) { + throw new CredentialStoreError('invalid-value', 'Credential value is invalid') + } +} + +export function credentialRef( + integrationId: string, + field: string, + profileId = 'default' +): CredentialRef { + const reference = { integrationId, profileId, field } + credentialKey(reference) + return reference +} diff --git a/src/app/settings/credentials/services.ts b/src/app/settings/credentials/services.ts new file mode 100644 index 000000000..1c63620ed --- /dev/null +++ b/src/app/settings/credentials/services.ts @@ -0,0 +1,27 @@ +import type { + CredentialManager, + CredentialResolver, + CredentialStore +} from '@/app/settings/credentials/types' + +export type CredentialServices = { + manager: CredentialManager + resolver: CredentialResolver +} + +export function createCredentialServices(store: CredentialStore): CredentialServices { + return { + manager: { + get backend() { + return store.backend + }, + availability: () => store.availability(), + status: (reference) => store.status(reference), + set: (reference, value) => store.write(reference, value), + clear: (reference) => store.remove(reference) + }, + resolver: { + resolve: (reference) => store.read(reference) + } + } +} diff --git a/src/app/settings/credentials/storage.ts b/src/app/settings/credentials/storage.ts new file mode 100644 index 000000000..7785e8302 --- /dev/null +++ b/src/app/settings/credentials/storage.ts @@ -0,0 +1,31 @@ +const BROWSER_PERSISTENCE_KEY = 'open-pencil:credential-persistence' +const LEGACY_CREDENTIAL_KEYS = [ + 'open-pencil:openrouter-api-key', + 'open-pencil:pexels-api-key', + 'open-pencil:unsplash-access-key' +] + +export function browserCredentialStorage(): Storage | null { + return 'window' in globalThis ? window.localStorage : null +} + +export function hasLegacyCredentialStorage(): boolean { + const storage = browserCredentialStorage() + if (!storage) return false + if (LEGACY_CREDENTIAL_KEYS.some((key) => Boolean(storage.getItem(key)))) return true + for (let index = 0; index < storage.length; index++) { + if (storage.key(index)?.startsWith('open-pencil:ai-key:')) return true + } + return false +} + +export function browserRemembersCredentials(): boolean { + return browserCredentialStorage()?.getItem(BROWSER_PERSISTENCE_KEY) === 'remembered' +} + +export function setBrowserRemembersCredentials(remembered: boolean): void { + const storage = browserCredentialStorage() + if (!storage) return + if (remembered) storage.setItem(BROWSER_PERSISTENCE_KEY, 'remembered') + else storage.removeItem(BROWSER_PERSISTENCE_KEY) +} diff --git a/src/app/settings/credentials/switchable.ts b/src/app/settings/credentials/switchable.ts new file mode 100644 index 000000000..1bd5ae57d --- /dev/null +++ b/src/app/settings/credentials/switchable.ts @@ -0,0 +1,87 @@ +import { + CredentialStoreError, + type CredentialRef, + type CredentialStore +} from '@/app/settings/credentials/types' + +export type CredentialStoreSwitchOptions = { + clearPrevious?: boolean +} + +export class SwitchableCredentialStore implements CredentialStore { + #delegate: CredentialStore + + constructor(delegate: CredentialStore) { + this.#delegate = delegate + } + + get backend() { + return this.#delegate.backend + } + + availability() { + return this.#delegate.availability() + } + + status(reference: CredentialRef) { + return this.#delegate.status(reference) + } + + read(reference: CredentialRef) { + return this.#delegate.read(reference) + } + + write(reference: CredentialRef, value: string) { + return this.#delegate.write(reference, value) + } + + remove(reference: CredentialRef) { + return this.#delegate.remove(reference) + } + + async switchTo( + next: CredentialStore, + references: CredentialRef[], + options: CredentialStoreSwitchOptions = {} + ): Promise { + const previous = this.#delegate + if (next.backend === previous.backend) return + + const values = await Promise.all(references.map((reference) => previous.read(reference))) + try { + for (const [index, reference] of references.entries()) { + const value = values[index] + if (value === null) continue + await next.write(reference, value) + if ((await next.read(reference)) !== value) { + throw new CredentialStoreError('failed', 'Credential copy verification failed') + } + } + } catch (error) { + await Promise.allSettled(references.map((reference) => next.remove(reference))) + throw error + } + + if (options.clearPrevious) { + try { + await Promise.all(references.map((reference) => previous.remove(reference))) + } catch (error) { + const restoration = await Promise.allSettled( + references.map(async (reference, index) => { + const value = values[index] + if (value !== null) await previous.write(reference, value) + }) + ) + const restored = restoration.every((result) => result.status === 'fulfilled') + if (restored) { + await Promise.allSettled(references.map((reference) => next.remove(reference))) + } else { + this.#delegate = next + } + throw error + } + } + + this.#delegate = next + } +} diff --git a/src/app/settings/credentials/types.ts b/src/app/settings/credentials/types.ts new file mode 100644 index 000000000..5443c6279 --- /dev/null +++ b/src/app/settings/credentials/types.ts @@ -0,0 +1,46 @@ +export type CredentialRef = { + integrationId: string + profileId: string + field: string +} + +export type CredentialStoreAvailability = 'available' | 'locked' | 'unavailable' +export type CredentialStatus = 'configured' | 'missing' | 'locked' | 'unavailable' +export type CredentialBackend = 'native' | 'browser' | 'memory' +export type CredentialErrorCode = + | 'invalid-reference' + | 'invalid-value' + | 'locked' + | 'unavailable' + | 'failed' + +export class CredentialStoreError extends Error { + constructor( + readonly code: CredentialErrorCode, + message: string + ) { + super(message) + this.name = 'CredentialStoreError' + } +} + +export interface CredentialStore { + readonly backend: CredentialBackend + availability(): Promise + status(reference: CredentialRef): Promise + read(reference: CredentialRef): Promise + write(reference: CredentialRef, value: string): Promise + remove(reference: CredentialRef): Promise +} + +export interface CredentialManager { + readonly backend: CredentialBackend + availability(): Promise + status(reference: CredentialRef): Promise + set(reference: CredentialRef, value: string): Promise + clear(reference: CredentialRef): Promise +} + +export interface CredentialResolver { + resolve(reference: CredentialRef): Promise +} diff --git a/tests/engine/app/settings/credential-migration.test.ts b/tests/engine/app/settings/credential-migration.test.ts new file mode 100644 index 000000000..8deafe20e --- /dev/null +++ b/tests/engine/app/settings/credential-migration.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test' + +import { MemoryCredentialStore } from '@/app/settings/credentials/memory' +import { + migrateLegacyCredentials, + PEXELS_CREDENTIAL, + providerCredentialRef +} from '@/app/settings/credentials/migration' + +class TestStorage implements Storage { + readonly #values = new Map() + + get length(): number { + return this.#values.size + } + + clear(): void { + this.#values.clear() + } + + getItem(key: string): string | null { + return this.#values.get(key) ?? null + } + + key(index: number): string | null { + return [...this.#values.keys()][index] ?? null + } + + removeItem(key: string): void { + this.#values.delete(key) + } + + setItem(key: string, value: string): void { + this.#values.set(key, value) + } +} + +class UnavailableCredentialStore extends MemoryCredentialStore { + override async availability() { + return 'unavailable' as const + } +} + +describe('legacy credential migration', () => { + test('verifies encrypted destinations before removing plaintext keys', async () => { + const storage = new TestStorage() + const store = new MemoryCredentialStore() + storage.setItem('open-pencil:openrouter-api-key', 'old-openrouter-key') + storage.setItem('open-pencil:ai-key:anthropic', 'anthropic-key') + storage.setItem('open-pencil:pexels-api-key', 'pexels-key') + + expect(await migrateLegacyCredentials(storage, store)).toBeTrue() + expect(await store.read(providerCredentialRef('openrouter'))).toBe('old-openrouter-key') + expect(await store.read(providerCredentialRef('anthropic'))).toBe('anthropic-key') + expect(await store.read(PEXELS_CREDENTIAL)).toBe('pexels-key') + expect(storage.getItem('open-pencil:openrouter-api-key')).toBeNull() + expect(storage.getItem('open-pencil:ai-key:anthropic')).toBeNull() + expect(storage.getItem('open-pencil:pexels-api-key')).toBeNull() + }) + + test('retains plaintext when the destination is unavailable', async () => { + const storage = new TestStorage() + storage.setItem('open-pencil:ai-key:openrouter', 'keep-me') + + expect(await migrateLegacyCredentials(storage, new UnavailableCredentialStore())).toBeFalse() + expect(storage.getItem('open-pencil:ai-key:openrouter')).toBe('keep-me') + }) +}) diff --git a/tests/engine/app/settings/credentials.test.ts b/tests/engine/app/settings/credentials.test.ts new file mode 100644 index 000000000..f7e743e8d --- /dev/null +++ b/tests/engine/app/settings/credentials.test.ts @@ -0,0 +1,147 @@ +import 'fake-indexeddb/auto' +import { describe, expect, test } from 'bun:test' + +import { openDB } from 'idb' + +import { BrowserCredentialStore } from '@/app/settings/credentials/browser' +import { MemoryCredentialStore } from '@/app/settings/credentials/memory' +import { credentialKey, credentialRef } from '@/app/settings/credentials/reference' +import { createCredentialServices } from '@/app/settings/credentials/services' +import { SwitchableCredentialStore } from '@/app/settings/credentials/switchable' +import type { + CredentialBackend, + CredentialRef, + CredentialStatus, + CredentialStore, + CredentialStoreAvailability +} from '@/app/settings/credentials/types' + +const API_KEY = credentialRef('openai-compatible', 'api-key') +const MEDIA_KEY = credentialRef('pexels', 'api-key') + +class TestCredentialStore implements CredentialStore { + readonly #values = new Map() + failWriteFor?: string + failRemoveFor?: string + + constructor(readonly backend: CredentialBackend) {} + + availability(): Promise { + return Promise.resolve('available') + } + + status(reference: CredentialRef): Promise { + return Promise.resolve(this.#values.has(credentialKey(reference)) ? 'configured' : 'missing') + } + + read(reference: CredentialRef): Promise { + return Promise.resolve(this.#values.get(credentialKey(reference)) ?? null) + } + + write(reference: CredentialRef, value: string): Promise { + const key = credentialKey(reference) + if (key === this.failWriteFor) return Promise.reject(new Error('write failed')) + this.#values.set(key, value) + return Promise.resolve() + } + + remove(reference: CredentialRef): Promise { + const key = credentialKey(reference) + if (key === this.failRemoveFor) return Promise.reject(new Error('remove failed')) + this.#values.delete(key) + return Promise.resolve() + } +} + +describe('credential references', () => { + test('creates stable versioned keys', () => { + expect(credentialKey(API_KEY)).toBe('v1:openai-compatible:default:api-key') + }) + + test('rejects account-path injection', () => { + expect(() => credentialRef('../../other-app', 'api-key')).toThrow( + 'Credential reference is invalid' + ) + }) +}) + +describe('credential service roles', () => { + test('settings can manage status without receiving read access', async () => { + const { manager, resolver } = createCredentialServices(new MemoryCredentialStore()) + + expect('resolve' in manager).toBeFalse() + expect(await manager.status(API_KEY)).toBe('missing') + await expect(manager.set(API_KEY, '')).rejects.toThrow('Credential value is invalid') + await manager.set(API_KEY, 'secret') + expect(await manager.status(API_KEY)).toBe('configured') + expect(await resolver.resolve(API_KEY)).toBe('secret') + await manager.clear(API_KEY) + expect(await resolver.resolve(API_KEY)).toBeNull() + }) + + test('switches persistence without changing service consumers', async () => { + const store = new SwitchableCredentialStore(new MemoryCredentialStore()) + const { manager, resolver } = createCredentialServices(store) + await manager.set(API_KEY, 'move-me') + + await store.switchTo(new BrowserCredentialStore(), [API_KEY]) + + expect(manager.backend).toBe('browser') + expect(await resolver.resolve(API_KEY)).toBe('move-me') + await manager.clear(API_KEY) + }) + + test('keeps the previous backend active when copying fails', async () => { + const previous = new TestCredentialStore('memory') + const next = new TestCredentialStore('browser') + next.failWriteFor = credentialKey(MEDIA_KEY) + await previous.write(API_KEY, 'ai-secret') + await previous.write(MEDIA_KEY, 'media-secret') + const store = new SwitchableCredentialStore(previous) + + await expect(store.switchTo(next, [API_KEY, MEDIA_KEY])).rejects.toThrow('write failed') + + expect(store.backend).toBe('memory') + expect(await store.read(API_KEY)).toBe('ai-secret') + expect(await store.read(MEDIA_KEY)).toBe('media-secret') + expect(await next.read(API_KEY)).toBeNull() + }) + + test('restores the previous backend when persistent cleanup fails', async () => { + const previous = new TestCredentialStore('browser') + const next = new TestCredentialStore('memory') + previous.failRemoveFor = credentialKey(MEDIA_KEY) + await previous.write(API_KEY, 'ai-secret') + await previous.write(MEDIA_KEY, 'media-secret') + const store = new SwitchableCredentialStore(previous) + + await expect( + store.switchTo(next, [API_KEY, MEDIA_KEY], { clearPrevious: true }) + ).rejects.toThrow('remove failed') + + expect(store.backend).toBe('browser') + expect(await store.read(API_KEY)).toBe('ai-secret') + expect(await store.read(MEDIA_KEY)).toBe('media-secret') + expect(await next.read(API_KEY)).toBeNull() + expect(await next.read(MEDIA_KEY)).toBeNull() + }) +}) + +describe('browser credential store', () => { + test('encrypts remembered credentials in IndexedDB', async () => { + const store = new BrowserCredentialStore() + + expect(await store.availability()).toBe('available') + await store.write(API_KEY, 'browser-secret') + expect(await store.read(API_KEY)).toBe('browser-secret') + + const database = await openDB('open-pencil-credentials', 1) + const record = await database.get('credentials', credentialKey(API_KEY)) + expect(record).toBeDefined() + expect(JSON.stringify(record)).not.toContain('browser-secret') + database.close() + + await store.remove(API_KEY) + expect(await store.status(API_KEY)).toBe('missing') + }) +})