feat(app): add cross-platform credential storage

- Store desktop secrets in native OS credential services through a narrow Tauri bridge

- Provide session-only and encrypted remembered browser stores with explicit switching

- Migrate legacy plaintext keys only after verified writes and cover failure recovery
This commit is contained in:
Danila Poyarkov 2026-07-26 14:07:26 +03:00
parent 0a46ff64eb
commit 95a920197e
20 changed files with 1368 additions and 1 deletions

View file

@ -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=="],

207
desktop/Cargo.lock generated
View file

@ -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"

View file

@ -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"

322
desktop/src/credentials.rs Normal file
View file

@ -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<Option<String>, 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, BackendError> {
Entry::new(CREDENTIAL_SERVICE, account).map_err(map_keyring_error)
}
}
impl CredentialBackend for NativeCredentialBackend {
fn read(&self, account: &str) -> Result<Option<String>, 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<String, CredentialError> {
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<Option<String>, 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<CredentialStoreAvailability, CredentialError>
{
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<CredentialStatus, CredentialError> {
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<Option<String>, 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<HashMap<String, String>>);
impl CredentialBackend for MemoryBackend {
fn read(&self, account: &str) -> Result<Option<String>, 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);
}
}

View file

@ -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,

View file

@ -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'
]

View file

@ -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",

View file

@ -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<void> {
if (IS_TAURI) return
const next = remembered ? new BrowserCredentialStore() : new MemoryCredentialStore()
await appCredentialStore.switchTo(next, references, { clearPrevious: !remembered })
browserCredentialsRemembered.value = remembered
setBrowserRemembersCredentials(remembered)
}

View file

@ -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<IDBPDatabase<CredentialDatabase>>
#masterKeyPromise?: Promise<CryptoKey>
constructor() {
this.#database = openDB<CredentialDatabase>(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<CredentialStoreAvailability> {
try {
await this.#database
return 'available'
} catch {
return 'unavailable'
}
}
async status(reference: CredentialRef): Promise<CredentialStatus> {
return (await this.read(reference)) === null ? 'missing' : 'configured'
}
async read(reference: CredentialRef): Promise<string | null> {
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<void> {
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<void> {
try {
const database = await this.#database
await database.delete('credentials', credentialKey(reference))
} catch (error) {
throw this.#normalizeError(error)
}
}
#masterKey(database: IDBPDatabase<CredentialDatabase>): Promise<CryptoKey> {
this.#masterKeyPromise ??= this.#loadOrCreateMasterKey(database)
return this.#masterKeyPromise
}
async #loadOrCreateMasterKey(database: IDBPDatabase<CredentialDatabase>): Promise<CryptoKey> {
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')
}
}

View file

@ -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'

View file

@ -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<string, string>()
async availability() {
return 'available' as const
}
async status(reference: CredentialRef): Promise<CredentialStatus> {
return this.#values.has(credentialKey(reference)) ? 'configured' : 'missing'
}
async read(reference: CredentialRef): Promise<string | null> {
return this.#values.get(credentialKey(reference)) ?? null
}
async write(reference: CredentialRef, value: string): Promise<void> {
validateCredentialValue(value)
this.#values.set(credentialKey(reference), value)
}
async remove(reference: CredentialRef): Promise<void> {
this.#values.delete(credentialKey(reference))
}
}

View file

@ -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<boolean> {
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<boolean> {
const storage = browserCredentialStorage()
if (!storage) return true
return migrateLegacyCredentials(storage, appCredentialStore)
}

View file

@ -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<CredentialStoreAvailability> {
return this.#invoke('credential_store_availability')
}
async status(reference: CredentialRef): Promise<CredentialStatus> {
return this.#invoke('credential_status', { reference })
}
async read(reference: CredentialRef): Promise<string | null> {
return this.#invoke('credential_read', { reference })
}
async write(reference: CredentialRef, value: string): Promise<void> {
await this.#invoke('credential_write', { reference, value })
}
async remove(reference: CredentialRef): Promise<void> {
await this.#invoke('credential_remove', { reference })
}
async #invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
try {
return await invoke<T>(command, args)
} catch (error) {
const nativeError = error as NativeCredentialError
throw new CredentialStoreError(
nativeError.code ?? 'failed',
nativeError.message ?? 'System credential operation failed'
)
}
}
}

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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)
}

View file

@ -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<void> {
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
}
}

View file

@ -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<CredentialStoreAvailability>
status(reference: CredentialRef): Promise<CredentialStatus>
read(reference: CredentialRef): Promise<string | null>
write(reference: CredentialRef, value: string): Promise<void>
remove(reference: CredentialRef): Promise<void>
}
export interface CredentialManager {
readonly backend: CredentialBackend
availability(): Promise<CredentialStoreAvailability>
status(reference: CredentialRef): Promise<CredentialStatus>
set(reference: CredentialRef, value: string): Promise<void>
clear(reference: CredentialRef): Promise<void>
}
export interface CredentialResolver {
resolve(reference: CredentialRef): Promise<string | null>
}

View file

@ -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<string, string>()
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')
})
})

View file

@ -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<string, string>()
failWriteFor?: string
failRemoveFor?: string
constructor(readonly backend: CredentialBackend) {}
availability(): Promise<CredentialStoreAvailability> {
return Promise.resolve('available')
}
status(reference: CredentialRef): Promise<CredentialStatus> {
return Promise.resolve(this.#values.has(credentialKey(reference)) ? 'configured' : 'missing')
}
read(reference: CredentialRef): Promise<string | null> {
return Promise.resolve(this.#values.get(credentialKey(reference)) ?? null)
}
write(reference: CredentialRef, value: string): Promise<void> {
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<void> {
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')
})
})