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:
parent
0a46ff64eb
commit
95a920197e
16
bun.lock
16
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=="],
|
||||
|
|
|
|||
207
desktop/Cargo.lock
generated
207
desktop/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
322
desktop/src/credentials.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
44
src/app/settings/credentials/app.ts
Normal file
44
src/app/settings/credentials/app.ts
Normal 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)
|
||||
}
|
||||
141
src/app/settings/credentials/browser.ts
Normal file
141
src/app/settings/credentials/browser.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
34
src/app/settings/credentials/index.ts
Normal file
34
src/app/settings/credentials/index.ts
Normal 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'
|
||||
32
src/app/settings/credentials/memory.ts
Normal file
32
src/app/settings/credentials/memory.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
73
src/app/settings/credentials/migration.ts
Normal file
73
src/app/settings/credentials/migration.ts
Normal 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)
|
||||
}
|
||||
51
src/app/settings/credentials/native.ts
Normal file
51
src/app/settings/credentials/native.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/app/settings/credentials/reference.ts
Normal file
29
src/app/settings/credentials/reference.ts
Normal 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
|
||||
}
|
||||
27
src/app/settings/credentials/services.ts
Normal file
27
src/app/settings/credentials/services.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/app/settings/credentials/storage.ts
Normal file
31
src/app/settings/credentials/storage.ts
Normal 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)
|
||||
}
|
||||
87
src/app/settings/credentials/switchable.ts
Normal file
87
src/app/settings/credentials/switchable.ts
Normal 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
|
||||
}
|
||||
}
|
||||
46
src/app/settings/credentials/types.ts
Normal file
46
src/app/settings/credentials/types.ts
Normal 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>
|
||||
}
|
||||
68
tests/engine/app/settings/credential-migration.test.ts
Normal file
68
tests/engine/app/settings/credential-migration.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
147
tests/engine/app/settings/credentials.test.ts
Normal file
147
tests/engine/app/settings/credentials.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue