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
118 lines
4.3 KiB
TypeScript
118 lines
4.3 KiB
TypeScript
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);
|
|
});
|