feat(desktop): mirror the login tokens into the OS keychain (#105, step 1)
CI / Build & Quality Checks (pull_request) Successful in 1m46s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 8m44s
CI / Build & Quality Checks (pull_request) Successful in 1m46s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 8m44s
Step 1 of moving the desktop app's login out of the webview's plaintext localStorage: keep a verified copy of the tokens in the OS keychain (Windows Credential Manager, via cinny-desktop's new secure_session_* commands). The session is still read from localStorage exactly as before, so nothing about login changes and a keychain problem can't log anyone out. Step 2 (a later release, once this has run on real installs) switches reads to the keychain and drops the tokens from localStorage. - sessions.ts: onSessionPersisted — listeners told about every session write (login, token rotation) and removal (logout); a throwing listener can't break the write. - keychainMirror.ts: desktop only. Mirrors userId/deviceId/accessToken/ refreshToken (not the rest of the session); reads first and writes only when the copy differs, then verifies by reading back; serialized, 5 s timeouts; no session → clear (also covers a logout whose reload beat the clear). Every failure is a status, never an exception. A desktop build without the commands reads as "unsupported", so this can ship before the desktop side. - Settings → General (desktop): "Login in the system keychain" status. Tested: unit tests (fake keychain: store, no rewrite when current, rotation, clear, unsupported, missing commands, denied write, read-back mismatch, timeout); a simulated desktop app with a fake keychain, 14/14 (login mirrors only the secrets, Settings status, reload verifies without rewriting, logout clears, an existing session is mirrored after upgrade, Linux/denied show an honest status and stay logged in); the real Linux desktop binary (commands answer "unsupported", login unaffected, Settings says so). Unit 1295 pass, Playwright 20 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
91f82d60e3
commit
9b9f33b270
@@ -0,0 +1,117 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { KeychainInvoke, sameTokens, syncKeychain, tokensOf } from './keychainMirror';
|
||||
import type { Session } from './sessions';
|
||||
|
||||
const session: Session = {
|
||||
baseUrl: 'https://matrix.example.org',
|
||||
userId: '@alice:example.org',
|
||||
deviceId: 'DEV1',
|
||||
accessToken: 'syt_token',
|
||||
refreshToken: 'mar_refresh',
|
||||
};
|
||||
|
||||
/** An in-memory keychain behind the same commands the desktop app exposes. */
|
||||
const fakeKeychain = (opts: { supported?: boolean; failSet?: boolean; corrupt?: boolean } = {}) => {
|
||||
let stored: unknown = null;
|
||||
const calls: string[] = [];
|
||||
const invoke: KeychainInvoke = async (cmd, args) => {
|
||||
calls.push(cmd);
|
||||
switch (cmd) {
|
||||
case 'secure_session_supported':
|
||||
return opts.supported ?? true;
|
||||
case 'secure_session_get':
|
||||
return stored;
|
||||
case 'secure_session_set':
|
||||
if (opts.failSet) throw new Error('Access is denied.');
|
||||
stored = opts.corrupt ? { ...(args?.tokens as object), accessToken: 'x' } : args?.tokens;
|
||||
return null;
|
||||
case 'secure_session_clear':
|
||||
stored = null;
|
||||
return null;
|
||||
default:
|
||||
throw new Error(`unknown ${cmd}`);
|
||||
}
|
||||
};
|
||||
return { invoke, calls, get: () => stored };
|
||||
};
|
||||
|
||||
test('stores the tokens (not the whole session) and verifies them', async () => {
|
||||
const kc = fakeKeychain();
|
||||
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'ok' });
|
||||
assert.deepEqual(kc.get(), {
|
||||
userId: '@alice:example.org',
|
||||
deviceId: 'DEV1',
|
||||
accessToken: 'syt_token',
|
||||
refreshToken: 'mar_refresh',
|
||||
});
|
||||
assert.deepEqual(kc.calls, [
|
||||
'secure_session_supported',
|
||||
'secure_session_get',
|
||||
'secure_session_set',
|
||||
'secure_session_get',
|
||||
]);
|
||||
});
|
||||
|
||||
test('an up-to-date copy is not rewritten', async () => {
|
||||
const kc = fakeKeychain();
|
||||
await syncKeychain(kc.invoke, session);
|
||||
kc.calls.length = 0;
|
||||
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'ok' });
|
||||
assert.deepEqual(kc.calls, ['secure_session_supported', 'secure_session_get']);
|
||||
});
|
||||
|
||||
test('a token rotation rewrites; no session clears', async () => {
|
||||
const kc = fakeKeychain();
|
||||
await syncKeychain(kc.invoke, session);
|
||||
await syncKeychain(kc.invoke, { ...session, accessToken: 'syt_new', refreshToken: undefined });
|
||||
assert.equal((kc.get() as { accessToken: string }).accessToken, 'syt_new');
|
||||
assert.equal('refreshToken' in (kc.get() as object), false);
|
||||
assert.deepEqual(await syncKeychain(kc.invoke, null), { state: 'cleared' });
|
||||
assert.equal(kc.get(), null);
|
||||
});
|
||||
|
||||
test('unsupported platform: nothing is touched', async () => {
|
||||
const kc = fakeKeychain({ supported: false });
|
||||
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'unsupported' });
|
||||
assert.deepEqual(kc.calls, ['secure_session_supported']);
|
||||
});
|
||||
|
||||
test('failures become a status, never an exception', async () => {
|
||||
assert.deepEqual(await syncKeychain(fakeKeychain({ failSet: true }).invoke, session), {
|
||||
state: 'error',
|
||||
error: 'Access is denied.',
|
||||
});
|
||||
assert.deepEqual(await syncKeychain(fakeKeychain({ corrupt: true }).invoke, session), {
|
||||
state: 'error',
|
||||
error: 'read-back did not match',
|
||||
});
|
||||
// The credential store hangs (the support check itself is instant).
|
||||
const hung: KeychainInvoke = async (cmd) =>
|
||||
cmd === 'secure_session_supported'
|
||||
? true
|
||||
: new Promise(() => {
|
||||
/* never settles */
|
||||
});
|
||||
assert.deepEqual(await syncKeychain(hung, session, 50), {
|
||||
state: 'error',
|
||||
error: 'keychain timed out',
|
||||
});
|
||||
});
|
||||
|
||||
test('a desktop build without the commands reads as unsupported, not an error', async () => {
|
||||
const missingCommand: KeychainInvoke = async (cmd) => {
|
||||
throw new Error(`Command ${cmd} not found`);
|
||||
};
|
||||
assert.deepEqual(await syncKeychain(missingCommand, session), { state: 'unsupported' });
|
||||
});
|
||||
|
||||
test('sameTokens compares only the secrets, treating a missing refresh token as absent', () => {
|
||||
const t = tokensOf({ ...session, refreshToken: undefined });
|
||||
assert.equal(sameTokens({ ...t }, t), true);
|
||||
assert.equal(sameTokens({ ...t, refreshToken: undefined }, t), true);
|
||||
assert.equal(sameTokens({ ...t, accessToken: 'other' }, t), false);
|
||||
assert.equal(sameTokens({ ...t, refreshToken: 'r' }, t), false);
|
||||
assert.equal(sameTokens(null, t), false);
|
||||
assert.equal(sameTokens('string', t), false);
|
||||
});
|
||||
Reference in New Issue
Block a user