Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions src/renderer/components/GlobalEffects.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('renderer/components/GlobalEffects.tsx', () => {
expect(useShortcutRegistrationStore.getState().shortcutRegistrationError).toBeNull();
});

describe('migrateAccountTokens (startup)', () => {
describe('persistRotatedAccountTokens (startup)', () => {
const refreshAccountSpy = vi
.spyOn(authUtils, 'refreshAccount')
.mockImplementation(async (account) => account);
Expand Down Expand Up @@ -94,18 +94,17 @@ describe('renderer/components/GlobalEffects.tsx', () => {
expect(useAccountsStore.getState().accounts[0]?.token).toBe(mockGitHubCloudAccount.token);
});

it('re-encrypts plaintext token (legacy migration) when decrypt throws', async () => {
it('does not re-encrypt or persist when decrypt throws', async () => {
decryptValueSpy.mockRejectedValue(new Error('not encrypted'));
encryptValueSpy.mockResolvedValue('newly-encrypted');

await act(async () => {
renderWithProviders(<GlobalEffects />, {
accounts: [{ ...mockGitHubCloudAccount }],
});
});

expect(encryptValueSpy).toHaveBeenCalledWith(mockGitHubCloudAccount.token);
expect(useAccountsStore.getState().accounts[0]?.token).toBe('newly-encrypted');
expect(encryptValueSpy).not.toHaveBeenCalled();
expect(useAccountsStore.getState().accounts[0]?.token).toBe(mockGitHubCloudAccount.token);
});
});
});
13 changes: 6 additions & 7 deletions src/renderer/hooks/useAccounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,21 @@ interface AccountsState {
* Hook that keeps account details fresh.
*
* Refreshes all accounts on startup and on an hourly interval via TanStack
* Query. Token migration runs once, before the first refresh, so legacy or
* rotated tokens are re-encrypted ahead of any API calls.
* Query. Any keychain-rotated token ciphertext is persisted once, before the
* first refresh, so rotated tokens are stored ahead of any API calls.
*/
export const useAccounts = (): AccountsState => {
const accounts = useAccountsStore((state) => state.accounts);

const hasMigratedTokensRef = useRef(false);
const hasPersistedRotatedTokensRef = useRef(false);

const { refetch } = useQuery<boolean, Error>({
queryKey: accountsKeys.refresh(accounts.map(getAccountUUID)),

queryFn: async () => {
// TODO - Remove token migration logic in future release
if (!hasMigratedTokensRef.current) {
await useAccountsStore.getState().migrateAccountTokens();
hasMigratedTokensRef.current = true;
if (!hasPersistedRotatedTokensRef.current) {
await useAccountsStore.getState().persistRotatedAccountTokens();
hasPersistedRotatedTokensRef.current = true;
}

const refreshAccount = useAccountsStore.getState().refreshAccount;
Expand Down
8 changes: 4 additions & 4 deletions src/renderer/stores/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@ export interface AccountsActions {
removeAccount: (account: Account) => void;

/**
* Re-encrypts persisted account tokens when the OS keychain rotated keys,
* and encrypts any tokens persisted in plaintext by legacy versions.
* Persists account tokens re-encrypted after the OS keychain rotated keys.
*
* TODO - Remove migration logic in future release
* Tokens are expected to already be encrypted at rest; plaintext tokens from
* pre-encryption releases are no longer migrated and require re-authentication.
*/
migrateAccountTokens: () => Promise<void>;
persistRotatedAccountTokens: () => Promise<void>;

/**
* Checks if the user is logged in (has at least one account).
Expand Down
31 changes: 14 additions & 17 deletions src/renderer/stores/useAccountsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
} from '../utils/auth/utils';
import { rendererLogWarn } from '../utils/core/logger';
import { getAdapter, isKnownForge } from '../utils/forges/registry';
import { decryptValue, encryptValue } from '../utils/system/comms';
import { decryptValue } from '../utils/system/comms';
import { DEFAULT_ACCOUNTS_STATE } from './defaults';

/**
Expand Down Expand Up @@ -98,38 +98,35 @@ const useAccountsStore = create<AccountsStore>()(
set({ accounts: updated.accounts });
},

migrateAccountTokens: async () => {
persistRotatedAccountTokens: async () => {
const accounts = get().accounts;

const migratedAccounts = await Promise.all(
const rotatedAccounts = await Promise.all(
accounts.map(async (account) => {
try {
const { reEncryptedToken } = await decryptValue(account.token);
if (reEncryptedToken) {
return { ...account, token: reEncryptedToken as Token };
}
return account;
} catch {
// Tokens are expected to already be encrypted at rest; plaintext
// tokens from pre-encryption releases are no longer migrated.
// Leave the account unchanged (the user must re-authenticate).
rendererLogWarn(
'migrateAccountTokens',
`token for account ${account.user?.login ?? account.hostname} could not be decrypted, re-encrypting`,
'persistRotatedAccountTokens',
`token for account ${account.user?.login ?? account.hostname} could not be decrypted; the account may need to be re-authenticated`,
);
const encryptedToken = (await encryptValue(account.token)) as Token;
return { ...account, token: encryptedToken };
}
return account;
}),
);

const tokensMigrated = migratedAccounts.some((migratedAccount) => {
const originalAccount = accounts.find(
(account) => getAccountUUID(account) === getAccountUUID(migratedAccount),
);

return !originalAccount || migratedAccount.token !== originalAccount.token;
});
const tokensRotated = rotatedAccounts.some(
(account, index) => account.token !== accounts[index]?.token,
);

if (tokensMigrated) {
set({ accounts: migratedAccounts });
if (tokensRotated) {
set({ accounts: rotatedAccounts });
}
},

Expand Down