diff --git a/src/document/DocumentCommandHandlers.js b/src/document/DocumentCommandHandlers.js index ede5a641b1..953a189692 100644 --- a/src/document/DocumentCommandHandlers.js +++ b/src/document/DocumentCommandHandlers.js @@ -61,6 +61,21 @@ define(function (require, exports, module) { NodeUtils = require("utils/NodeUtils"), _ = require("thirdparty/lodash"); + const KernalModeTrust = window.KernalModeTrust; + if(!KernalModeTrust){ + throw new Error("KernalModeTrust is not defined. Cannot boot without trust ring"); + } + async function _resetTauriTrustRingBeforeRestart() { + // This is needed as if for a given tauri window, the trust ring can only be set once. So reloading the app + // in the same window, tauri will deny setting new keys. + // this is a security measure to prevent a malicious extension from setting its own key. + try { + await KernalModeTrust.dismantleKeyring(); + } catch (e) { + console.error("Error while resetting trust ring before restart", e); + } + } + /** * Handlers for commands related to document handling (opening, saving, etc.) */ @@ -2073,6 +2088,9 @@ define(function (require, exports, module) { .finally(()=>{ raceAgainstTime(_safeNodeTerminate(), 4000) .finally(()=>{ + _resetTauriTrustRingBeforeRestart(); + // we do not wait/raceAgainstTime here purposefully to prevent attacks that will rely + // on this brief window of no trust zone in while the kernal trust key is being reset. window.location.href = href; }); }); diff --git a/src/index.html b/src/index.html index d147cf1df0..c6fbec1772 100644 --- a/src/index.html +++ b/src/index.html @@ -87,6 +87,7 @@ function _isTestWindow() { // the test window query param will only be acknowledged if we are embedded in the spec runner. // and test windows should be embedded within the same host as phcode.dev/tauri for security + // please see trust_ring.js before doing any changes to this check const isTestPhoenixWindow = window.parent.location.pathname.endsWith("SpecRunner.html") && window.parent.location.host === window.location.host && !!(new window.URLSearchParams(window.location.search || "")).get("testEnvironment"); diff --git a/src/phoenix/shell.js b/src/phoenix/shell.js index 44aef08b9d..bf018f2ed0 100644 --- a/src/phoenix/shell.js +++ b/src/phoenix/shell.js @@ -30,7 +30,10 @@ import initVFS from "./init_vfs.js"; import ERR_CODES from "./errno.js"; import { LRUCache } from '../thirdparty/no-minify/lru-cache.js'; import * as Emmet from '../thirdparty/emmet.es.js'; +import {initTrustRing} from "./trust_ring.js"; +initTrustRing() + .catch(console.error); initVFS(); // We can only have a maximum of 30 windows that have access to tauri apis diff --git a/src/phoenix/trust_ring.js b/src/phoenix/trust_ring.js new file mode 100644 index 0000000000..757533c3ee --- /dev/null +++ b/src/phoenix/trust_ring.js @@ -0,0 +1,151 @@ +// Generate random AES-256 key and GCM nonce/IV +function generateRandomKeyAndIV() { + // Generate 32 random bytes for AES-256 key + const keyBytes = new Uint8Array(32); + crypto.getRandomValues(keyBytes); + + // Generate 12 random bytes for AES-GCM nonce/IV + const ivBytes = new Uint8Array(12); + crypto.getRandomValues(ivBytes); + + // Convert to hex strings + const key = Array.from(keyBytes) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); + + const iv = Array.from(ivBytes) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); + + return { key, iv }; +} + +async function AESDecryptString(val, key, iv) { + // Convert hex strings to ArrayBuffers + const encryptedData = new Uint8Array(val.length / 2); + for (let i = 0; i < val.length; i += 2) { + encryptedData[i / 2] = parseInt(val.substr(i, 2), 16); + } + + const keyBytes = new Uint8Array(key.length / 2); + for (let i = 0; i < key.length; i += 2) { + keyBytes[i / 2] = parseInt(key.substr(i, 2), 16); + } + + const ivBytes = new Uint8Array(iv.length / 2); + for (let i = 0; i < iv.length; i += 2) { + ivBytes[i / 2] = parseInt(iv.substr(i, 2), 16); + } + + // Import the AES key + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + + // Decrypt the data + const decryptedBuffer = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: ivBytes + }, + cryptoKey, + encryptedData + ); + + // Convert back to string + return new TextDecoder('utf-8').decode(decryptedBuffer); +} + +const TEMP_KV_TRUST_FOR_TESTSUITE = "TEMP_KV_TRUST_FOR_TESTSUITE"; +function _selectKeys() { + if (Phoenix.isTestWindow) { + // this could be an iframe in a spec runner window or the spec runner window itself. + const kvj = window.top.sessionStorage.getItem(TEMP_KV_TRUST_FOR_TESTSUITE); + if(!kvj) { + const kv = generateRandomKeyAndIV(); + window.top.sessionStorage.setItem(TEMP_KV_TRUST_FOR_TESTSUITE, JSON.stringify(kv)); + return kv; + } + try{ + return JSON.parse(kvj); + } catch (e) { + console.error("Error parsing test suite trust keyring, defaulting to random which may not work!", e); + } + } + return generateRandomKeyAndIV(); +} + +const PHCODE_API_KEY = "PHCODE_API_KEY"; +const { key, iv } = _selectKeys(); +// this key is set at boot time as a truct base for all the core components before any extensions are loaded. +// just before extensions are loaded, this key is blanked. This can be used by core modules to talk with other +// core modules securely without worrying about interception by extensions. +// KernalModeTrust should only be available within all code that loads before the first default/any extension. +window.KernalModeTrust = { + aesKeys: { key, iv }, + setPhoenixAPIKey, + getPhoenixAPIKey, + removePhoenixAPIKey, + AESDecryptString, + generateRandomKeyAndIV, + dismantleKeyring +}; +if(Phoenix.isSpecRunnerWindow){ + window.specRunnerTestKernalModeTrust = window.KernalModeTrust; +} +// key is 64 hex characters, iv is 24 hex characters + +async function setPhoenixAPIKey(apiKey) { + if(!window.__TAURI__){ + throw new Error("Phoenix API key can only be set in tauri shell!"); + } + return window.__TAURI__.tauri.invoke("store_credential", {scopeName: PHCODE_API_KEY, secretVal: apiKey}); +} + +async function getPhoenixAPIKey() { + if(!window.__TAURI__){ + throw new Error("Phoenix API key can only be get in tauri shell!"); + } + const encryptedKey = await window.__TAURI__.tauri.invoke("get_credential", {scopeName: PHCODE_API_KEY}); + if(!encryptedKey){ + return null; + } + return AESDecryptString(encryptedKey, key, iv); +} + +async function removePhoenixAPIKey() { + if(!window.__TAURI__){ + throw new Error("Phoenix API key can only be set in tauri shell!"); + } + return window.__TAURI__.tauri.invoke("delete_credential", {scopeName: PHCODE_API_KEY}); +} + +let _dismatled = false; +async function dismantleKeyring() { + if(!_dismatled){ + throw new Error("Keyring can only be dismantled once!"); + // and once dismantled, the next line should be reload page. this is a strict security posture requirement to + // prevent extensions from stealing sensitive info from system key ring as once the trust in invalidated, + // the tauri get_system key ring cred apis will work for anyone who does the first call. + } + _dismatled = true; + if(!key || !iv){ + console.error("Invalid kernal keys supplied to shutdown. Ignoring kernal trust reset at shutdown."); + return; + } + if(!window.__TAURI__){ + return; + } + return window.__TAURI__.tauri.invoke("remove_trust_window_aes_key", {key, iv}); +} + +export async function initTrustRing() { + if(!window.__TAURI__){ + return; + } + await window.__TAURI__.tauri.invoke("trust_window_aes_key", {key, iv}); +} diff --git a/src/utils/ExtensionLoader.js b/src/utils/ExtensionLoader.js index 8b32fe69be..52ae93d3b4 100644 --- a/src/utils/ExtensionLoader.js +++ b/src/utils/ExtensionLoader.js @@ -902,6 +902,10 @@ define(function (require, exports, module) { var disabledExtensionPath = extensionPath.replace(/\/user$/, "/disabled"); FileSystem.getDirectoryForPath(disabledExtensionPath).create(); + // just before extensions are loaded, we need to delete the boot time trust ring keys so that extensions + // won't have keys to enter kernal mode in the app. + delete window.KernalModeTrust; + var promise = Async.doInParallel(paths, function (extPath) { if(extPath === "default"){ return loadAllDefaultExtensions(); diff --git a/test/spec/Tauri-platform-test.js b/test/spec/Tauri-platform-test.js index 1cfd90b317..1697f5d473 100644 --- a/test/spec/Tauri-platform-test.js +++ b/test/spec/Tauri-platform-test.js @@ -138,8 +138,11 @@ define(function (require, exports, module) { describe("Credentials OTP API Tests", function () { const scopeName = "testScope"; - const sessionID = "test-session-123"; - const otpSeed = "test-secret-seed"; + const trustRing = window.specRunnerTestKernalModeTrust; + + function decryptCreds(creds) { + return trustRing.AESDecryptString(creds, trustRing.aesKeys.key, trustRing.aesKeys.iv); + } beforeEach(async function () { // Cleanup before running tests @@ -161,42 +164,43 @@ define(function (require, exports, module) { describe("Credential Storage & OTP Generation", function () { it("Should store credentials successfully", async function () { + const randomUUID = crypto.randomUUID(); await expectAsync( - window.__TAURI__.invoke("store_credential", { scopeName, sessionId: sessionID, otpSeed }) + window.__TAURI__.invoke("store_credential", { scopeName, secretVal: randomUUID }) ).toBeResolved(); }); - it("Should retrieve a valid OTP after storing credentials", async function () { - await window.__TAURI__.invoke("store_credential", { scopeName, sessionId: sessionID, otpSeed }); + it("Should get credentials as encrypted string", async function () { + const randomUUID = crypto.randomUUID(); + await window.__TAURI__.invoke("store_credential", { scopeName, secretVal: randomUUID }); - const response = await window.__TAURI__.invoke("get_credential_otp", { scopeName }); + const response = await window.__TAURI__.invoke("get_credential", { scopeName }); expect(response).toBeDefined(); - expect(response.session_id).toEqual(sessionID); - expect(response.totp).toMatch(/^\d{6}$/); // OTP should be a 6-digit number + expect(response).not.toEqual(randomUUID); }); - it("Should retrieve a valid OTP after storing uuid as seed", async function () { - const newSession = crypto.randomUUID(); - await window.__TAURI__.invoke("store_credential", - { scopeName, sessionId: newSession, otpSeed: crypto.randomUUID() }); + it("Should retrieve and decrypt set credentials with kernal mode keys", async function () { + const randomUUID = crypto.randomUUID(); + await window.__TAURI__.invoke("store_credential", { scopeName, secretVal: randomUUID }); - const response = await window.__TAURI__.invoke("get_credential_otp", { scopeName }); - expect(response).toBeDefined(); - expect(response.session_id).toEqual(newSession); - expect(response.totp).toMatch(/^\d{6}$/); // OTP should be a 6-digit number + const creds = await window.__TAURI__.invoke("get_credential", { scopeName }); + expect(creds).toBeDefined(); + const decryptedString = await decryptCreds(creds); + expect(decryptedString).toEqual(randomUUID); }); it("Should return an error if credentials do not exist", async function () { - const response = await window.__TAURI__.invoke("get_credential_otp", { scopeName }); - expect(response).toEqual({ err_code: "NO_ENTRY" }); + const response = await window.__TAURI__.invoke("get_credential", { scopeName }); + expect(response).toBeNull(); }); it("Should delete stored credentials", async function () { - await window.__TAURI__.invoke("store_credential", { scopeName, sessionId: sessionID, otpSeed }); + const randomUUID = crypto.randomUUID(); + await window.__TAURI__.invoke("store_credential", { scopeName, secretVal: randomUUID }); // Ensure credential exists - const responseBeforeDelete = await window.__TAURI__.invoke("get_credential_otp", { scopeName }); - expect(responseBeforeDelete.session_id).toEqual(sessionID); + let creds = await window.__TAURI__.invoke("get_credential", { scopeName }); + expect(creds).toBeDefined(); // Delete credential await expectAsync( @@ -204,8 +208,8 @@ define(function (require, exports, module) { ).toBeResolved(); // Ensure credential is deleted - const responseAfterDelete = await window.__TAURI__.invoke("get_credential_otp", { scopeName }); - expect(responseAfterDelete).toEqual({ err_code: "NO_ENTRY" }); + creds = await window.__TAURI__.invoke("get_credential", { scopeName }); + expect(creds).toBeNull(); }); it("Should handle deletion of non-existent credentials gracefully", async function () { @@ -230,54 +234,89 @@ define(function (require, exports, module) { expect(isExpectedError).toBeTrue(); }); - it("Should reject storing an empty seed", async function () { + it("Should overwrite existing credentials when storing with the same scope", async function () { + const oldUUID = crypto.randomUUID(); + await window.__TAURI__.invoke("store_credential", { scopeName, secretVal: oldUUID }); + + let creds = await window.__TAURI__.invoke("get_credential", { scopeName }); + expect(creds).toBeDefined(); + let response = await decryptCreds(creds); + expect(response).toEqual(oldUUID); + + // Store new credentials with the same scope + const newUUID = crypto.randomUUID(); + await window.__TAURI__.invoke("store_credential", { scopeName, secretVal: newUUID }); + + creds = await window.__TAURI__.invoke("get_credential", { scopeName }); + expect(creds).toBeDefined(); + response = await decryptCreds(creds); + expect(response).toEqual(newUUID); + }); + + // trustRing.getPhoenixAPIKey and set tests + async function setSomeKey() { + const randomCred = crypto.randomUUID(); + await trustRing.setPhoenixAPIKey(randomCred); + const savedCred = await trustRing.getPhoenixAPIKey(); + expect(savedCred).toEqual(randomCred); + return savedCred; + } + + it("Should get and set API key in kernal mode trust ring", async function () { + await setSomeKey(); + }); + + it("Should get and set empty string API key in kernal mode trust ring", async function () { + const randomCred = ""; + await trustRing.setPhoenixAPIKey(randomCred); + const savedCred = await trustRing.getPhoenixAPIKey(); + expect(savedCred).toEqual(randomCred); + }); + + it("Should remove API key in kernal mode trust ring work as expected", async function () { + await setSomeKey(); + await trustRing.removePhoenixAPIKey(); + const cred = await trustRing.getPhoenixAPIKey(); + expect(cred).toBeNull(); + }); + + // trust key management + it("Should not be able to set trust key if one is already set", async function () { + const kv = trustRing.generateRandomKeyAndIV(); let error; try { - await window.__TAURI__.invoke("store_credential", - { scopeName, sessionId: sessionID, otpSeed: "" }); + await window.__TAURI__.tauri.invoke("trust_window_aes_key", kv); } catch (err) { error = err; } - expect(error).toBeDefined(); - expect(error).toContain("SEED_TOO_SHORT"); + expect(error).toContain("Trust has already been established for this window."); }); - it("Should reject storing a seed that is too short", async function () { + it("Should be able to remove trust key with key and iv", async function () { + await window.__TAURI__.tauri.invoke("remove_trust_window_aes_key", trustRing.aesKeys); let error; try { - await window.__TAURI__.invoke("store_credential", - { scopeName, sessionId: sessionID, otpSeed: "12345" }); + await window.__TAURI__.tauri.invoke("remove_trust_window_aes_key", trustRing.aesKeys); } catch (err) { error = err; } - expect(error).toBeDefined(); - expect(error).toContain("SEED_TOO_SHORT"); + expect(error).toContain("No trust association found for this window."); + // reinstate trust + await window.__TAURI__.tauri.invoke("trust_window_aes_key", trustRing.aesKeys); }); - it("Should overwrite existing credentials when storing with the same scope", async function () { - const oldSeed = crypto.randomUUID(); - await window.__TAURI__.invoke("store_credential", - { scopeName, sessionId: "old-session", otpSeed: oldSeed }); - - const responseBefore = await window.__TAURI__.invoke("get_credential_otp", { scopeName }); - expect(responseBefore.session_id).toEqual("old-session"); - - // Store new credentials with the same scope - await window.__TAURI__.invoke("store_credential", { scopeName, sessionId: sessionID, otpSeed }); - - const responseAfter = await window.__TAURI__.invoke("get_credential_otp", { scopeName }); - expect(responseAfter.session_id).toEqual(sessionID); - }); - - it("Should correctly encode and decode Base32 seed", async function () { - const base32Seed = "JBSWY3DPEHPK3PXP"; // Valid Base32 seed - await window.__TAURI__.invoke("store_credential", - { scopeName, sessionId: sessionID, otpSeed: base32Seed }); - - const response = await window.__TAURI__.invoke("get_credential_otp", { scopeName }); - expect(response).toBeDefined(); - expect(response.session_id).toEqual(sessionID); - expect(response.totp).toMatch(/^\d{6}$/); + it("Should getPhoenixAPIKey not work without trust", async function () { + await setSomeKey(); + await window.__TAURI__.tauri.invoke("remove_trust_window_aes_key", trustRing.aesKeys); + let error; + try { + await trustRing.getPhoenixAPIKey(); + } catch (err) { + error = err; + } + expect(error).toContain("Trust needs to be first established"); + // reinstate trust + await window.__TAURI__.tauri.invoke("trust_window_aes_key", trustRing.aesKeys); }); }); });