Add vault upload mechanism (#541)

This commit is contained in:
Leendert de Borst
2025-02-03 19:25:38 +01:00
parent c47aa4e182
commit ff0d2cf390
12 changed files with 176 additions and 131 deletions

View File

@@ -1,14 +1,18 @@
import { Vault } from './types/webapi/Vault';
import EncryptionUtility from './utils/EncryptionUtility';
import SqliteClient from './utils/SqliteClient';
import { WebApiService } from './utils/WebApiService';
let vaultState: {
derivedKey: string | null;
publicEmailDomains: string[];
privateEmailDomains: string[];
vaultRevisionNumber: number;
} = {
derivedKey: null,
publicEmailDomains: [],
privateEmailDomains: []
privateEmailDomains: [],
vaultRevisionNumber: 0,
};
// Listen for messages from popup
@@ -19,6 +23,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
vaultState.derivedKey = message.derivedKey;
vaultState.publicEmailDomains = message.publicEmailDomains || [];
vaultState.privateEmailDomains = message.privateEmailDomains || [];
vaultState.vaultRevisionNumber = message.vaultRevisionNumber || 0;
// Re-encrypt vault with session key
(async () : Promise<void> => {
@@ -32,7 +37,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
chrome.storage.session.set({
encryptedVault,
publicEmailDomains: vaultState.publicEmailDomains,
privateEmailDomains: vaultState.privateEmailDomains
privateEmailDomains: vaultState.privateEmailDomains,
vaultRevisionNumber: vaultState.vaultRevisionNumber
}, () => {
if (chrome.runtime.lastError) {
console.error('Failed to store vault:', chrome.runtime.lastError);
@@ -54,7 +60,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return;
}
chrome.storage.session.get(['encryptedVault', 'publicEmailDomains', 'privateEmailDomains'], async (result) => {
chrome.storage.session.get(['encryptedVault', 'publicEmailDomains', 'privateEmailDomains', 'vaultRevisionNumber'], async (result) => {
try {
if (!result.encryptedVault) {
console.error('No encrypted vault found in storage');
@@ -73,7 +79,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
sendResponse({
vault: decryptedVault,
publicEmailDomains: result.publicEmailDomains || [],
privateEmailDomains: result.privateEmailDomains || []
privateEmailDomains: result.privateEmailDomains || [],
vaultRevisionNumber: result.vaultRevisionNumber || 0
});
} catch (parseError) {
console.error('Failed to parse decrypted vault:', parseError);
@@ -90,7 +97,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
vaultState.derivedKey = null;
vaultState.publicEmailDomains = [];
vaultState.privateEmailDomains = [];
chrome.storage.session.remove(['encryptedVault', 'publicEmailDomains', 'privateEmailDomains']);
vaultState.vaultRevisionNumber = 0;
chrome.storage.session.remove(['encryptedVault', 'publicEmailDomains', 'privateEmailDomains', 'vaultRevisionNumber']);
sendResponse({ success: true });
break;
}
@@ -231,6 +239,33 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
privateEmailDomains: vaultState.privateEmailDomains
});
// Upload new vault to server
// TODO: extract all required fields by quering the vault db.
const username = await chrome.storage.local.get('username');
const newVault: Vault = {
blob: encryptedVault,
createdAt: new Date().toISOString(),
credentialsCount: 0, // TODO
currentRevisionNumber: vaultState.vaultRevisionNumber,
emailAddressList: await getEmailAddressesForVault(sqliteClient),
privateEmailDomainList: [], // TODO
publicEmailDomainList: [], // TODO
encryptionPublicKey: '', // TODO
updatedAt: new Date().toISOString(),
username: username.username, // TODO
version: '1.0.0' // TODO
}
console.log('New vault to upload:', newVault);
const webApi = new WebApiService(
() => {}
);
await webApi.initializeBaseUrl();
await webApi.post('Vault', newVault);
console.log('Vault uploaded successfully');
sendResponse({ success: true });
} catch (error) {
console.error('Failed to create identity:', error);
@@ -242,3 +277,26 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
}
return true;
});
/**
* Get all active email addresses from aliases
* @param sqliteClient The SQLite client
* @returns The list of email addresses
*/
async function getEmailAddressesForVault(sqliteClient: SqliteClient): Promise<string[]> {
const credentials = sqliteClient.getAllCredentials();
// Extract unique email addresses from credentials
const emailAddresses = credentials
.filter(cred => cred.Email != null)
.map(cred => cred.Email!)
.filter((email, index, self) => self.indexOf(email) === index); // Get unique values
// Filter to only include domains from the private domains list
const filteredEmailAddresses = emailAddresses.filter(email => {
const domain = email.split('@')[1];
return vaultState.privateEmailDomains.includes(domain);
});
return filteredEmailAddresses;
}

View File

@@ -5,10 +5,7 @@ type AuthContextType = {
isInitialized: boolean;
username: string | null;
login: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
updateTokens: (accessToken: string, refreshToken: string) => Promise<void>;
logout: () => Promise<void>;
getAccessToken: () => string | null;
getRefreshToken: () => string | null;
}
/**
@@ -23,94 +20,49 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [, setAccessToken] = useState<string | null>(null);
const [, setRefreshToken] = useState<string | null>(null);
const accessTokenRef = useRef<string | null>(null);
const refreshTokenRef = useRef<string | null>(null);
/**
* Check for tokens in localStorage on initial load
* Check for tokens in chrome storage on initial load
*/
useEffect(() : void => {
const storedAccessToken = localStorage.getItem('accessToken');
const storedRefreshToken = localStorage.getItem('refreshToken');
const storedUsername = localStorage.getItem('username');
if (storedAccessToken && storedRefreshToken && storedUsername) {
setAccessToken(storedAccessToken);
setRefreshToken(storedRefreshToken);
setUsername(storedUsername);
setIsLoggedIn(true);
}
useEffect(() => {
const initializeAuth = async () => {
const stored = await chrome.storage.local.get(['accessToken', 'refreshToken', 'username']);
if (stored.accessToken && stored.refreshToken && stored.username) {
setUsername(stored.username);
setIsLoggedIn(true);
}
setIsInitialized(true);
};
setIsInitialized(true);
initializeAuth();
}, []);
/**
* Login
*/
const login = async (username: string, accessToken: string, refreshToken: string) : Promise<void> => {
accessTokenRef.current = accessToken; // Immediate update
refreshTokenRef.current = refreshToken; // Immediate update
await Promise.all([
localStorage.setItem('username', username),
localStorage.setItem('accessToken', accessToken),
localStorage.setItem('refreshToken', refreshToken),
]);
const login = async (username: string, accessToken: string, refreshToken: string) => {
await chrome.storage.local.set({
username,
accessToken,
refreshToken
});
setUsername(username);
setAccessToken(accessToken);
setRefreshToken(refreshToken);
setIsLoggedIn(true);
};
/**
* Update tokens
*/
const updateTokens = async (accessToken: string, refreshToken: string) : Promise<void> => {
accessTokenRef.current = accessToken; // Immediate update
refreshTokenRef.current = refreshToken; // Immediate update
await Promise.all([
localStorage.setItem('accessToken', accessToken),
localStorage.setItem('refreshToken', refreshToken),
]);
setAccessToken(accessToken);
setRefreshToken(refreshToken);
};
/**
* Logout
*/
const logout = async () : Promise<void> => {
// Clear vault in background worker.
const logout = async () => {
await chrome.runtime.sendMessage({ type: 'CLEAR_VAULT' });
await Promise.all([
localStorage.removeItem('username'),
localStorage.removeItem('accessToken'),
localStorage.removeItem('refreshToken'),
]);
await Promise.all([
setUsername(null),
setAccessToken(null),
setRefreshToken(null),
setIsLoggedIn(false),
]);
await chrome.storage.local.remove(['username', 'accessToken', 'refreshToken']);
setUsername(null);
setIsLoggedIn(false);
};
/**
* Get access token
*/
const getAccessToken = () : string | null => accessTokenRef.current || localStorage.getItem('accessToken');
/**
* Get refresh token
*/
const getRefreshToken = () : string | null => refreshTokenRef.current || localStorage.getItem('refreshToken');
return (
<AuthContext.Provider value={{ isLoggedIn, isInitialized, username, login, updateTokens, logout, getAccessToken, getRefreshToken }}>
<AuthContext.Provider value={{ isLoggedIn, isInitialized, username, login, logout }}>
{children}
</AuthContext.Provider>
);

View File

@@ -5,7 +5,7 @@ type DbContextType = {
sqliteClient: SqliteClient | null;
dbInitialized: boolean;
dbAvailable: boolean;
initializeDatabase: (derivedKey: string, vault: string, publicEmailDomains: string[], privateEmailDomains: string[]) => Promise<void>;
initializeDatabase: (derivedKey: string, vault: string, publicEmailDomains: string[], privateEmailDomains: string[], vaultRevisionNumber: number) => Promise<void>;
clearDatabase: () => void;
}
@@ -40,7 +40,7 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
*/
const [privateEmailDomains, setPrivateEmailDomains] = useState<string[]>([]);
const initializeDatabase = useCallback(async (derivedKey: string, vault: string, publicEmailDomains: string[], privateEmailDomains: string[]) => {
const initializeDatabase = useCallback(async (derivedKey: string, vault: string, publicEmailDomains: string[], privateEmailDomains: string[], vaultRevisionNumber: number) => {
const client = new SqliteClient();
await client.initializeFromBase64(vault);
setSqliteClient(client);
@@ -49,13 +49,16 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
setPublicEmailDomains(publicEmailDomains);
setPrivateEmailDomains(privateEmailDomains);
// Store in background worker
// Store in background worker.
// TODO: perhaps we can simply pass the full vaultresponse object instead of the individual fields
// in case we need to access more fields in the future.
chrome.runtime.sendMessage({
type: 'STORE_VAULT',
derivedKey: derivedKey,
vault: vault,
publicEmailDomains: publicEmailDomains,
privateEmailDomains: privateEmailDomains
privateEmailDomains: privateEmailDomains,
vaultRevisionNumber: vaultRevisionNumber
});
}, []);

View File

@@ -8,7 +8,7 @@ const WebApiContext = createContext<WebApiService | null>(null);
* WebApiProvider to provide the WebApiService to the app that components can use.
*/
export const WebApiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { getAccessToken, getRefreshToken, updateTokens, logout } = useAuth();
const { logout } = useAuth();
const [webApiService, setWebApiService] = useState<WebApiService | null>(null);
/**
@@ -16,11 +16,6 @@ export const WebApiProvider: React.FC<{ children: React.ReactNode }> = ({ childr
*/
useEffect(() : void => {
const service = new WebApiService(
() => getAccessToken(),
() => getRefreshToken(),
(newAccessToken, newRefreshToken) => {
updateTokens(newAccessToken, newRefreshToken);
},
logout
);
setWebApiService(service);
@@ -40,7 +35,7 @@ export const WebApiProvider: React.FC<{ children: React.ReactNode }> = ({ childr
return () : void => {
chrome.storage.onChanged.removeListener(handleStorageChange);
};
}, [getAccessToken, getRefreshToken, updateTokens, logout]);
}, [logout]);
if (!webApiService) {
return null;

View File

@@ -48,7 +48,7 @@ const CredentialsList: React.FC = () => {
);
// Initialize the SQLite context again with the newly retrieved decrypted blob
await dbContext.initializeDatabase(passwordHashBase64, decryptedBlob);
await dbContext.initializeDatabase(passwordHashBase64, decryptedBlob, vaultResponseJson.vault.publicEmailDomainList, vaultResponseJson.vault.privateEmailDomainList, vaultResponseJson.vault.currentRevisionNumber);
} catch (err) {
console.error('Refresh error:', err);
} finally {

View File

@@ -72,7 +72,7 @@ const Login: React.FC = () => {
const decryptedBlob = await EncryptionUtility.symmetricDecrypt(vaultResponseJson.vault.blob, passwordHashBase64);
// Initialize the SQLite context with decrypted data
await dbContext.initializeDatabase(passwordHashBase64, decryptedBlob, vaultResponseJson.vault.publicEmailDomainList, vaultResponseJson.vault.privateEmailDomainList);
await dbContext.initializeDatabase(passwordHashBase64, decryptedBlob, vaultResponseJson.vault.publicEmailDomainList, vaultResponseJson.vault.privateEmailDomainList, vaultResponseJson.vault.currentRevisionNumber);
// 3. Handle 2FA if required
/*

View File

@@ -53,7 +53,7 @@ const Unlock: React.FC = () => {
);
// Initialize the SQLite context with decrypted data
await dbContext.initializeDatabase(passwordHashBase64, decryptedBlob, vaultResponseJson.vault.publicEmailDomainList, vaultResponseJson.vault.privateEmailDomainList);
await dbContext.initializeDatabase(passwordHashBase64, decryptedBlob, vaultResponseJson.vault.publicEmailDomainList, vaultResponseJson.vault.privateEmailDomainList, vaultResponseJson.vault.currentRevisionNumber);
} catch (err) {
setError('Failed to unlock vault. Please check your password and try again.');
console.error('Unlock error:', err);
@@ -69,10 +69,20 @@ const Unlock: React.FC = () => {
showLoading();
try {
await webApi.logout();
} catch (err) {
console.error('Logout error:', err);
}
try {
await authContext.logout();
} finally {
} catch (err) {
console.error('Logout error:', err);
}
finally {
hideLoading();
}
};
return (

View File

@@ -0,0 +1,13 @@
export type Vault = {
blob: string;
createdAt: string;
credentialsCount: number;
currentRevisionNumber: number;
emailAddressList: string[];
privateEmailDomainList: string[];
publicEmailDomainList: string[];
encryptionPublicKey: string;
updatedAt: string;
username: string;
version: string;
}

View File

@@ -1,18 +1,6 @@
export type Vault = {
blob: string;
createdAt: string;
credentialsCount: number;
currentRevisionNumber: number;
emailAddressList: string[];
privateEmailDomainList: string[];
publicEmailDomainList: string[];
encryptionPublicKey: string;
updatedAt: string;
username: string;
version: string;
}
import { Vault } from "./Vault";
export type VaultResponse = {
status: number;
vault: Vault;
}
status: number;
vault: Vault;
}

View File

@@ -214,21 +214,25 @@ class SqliteClient {
const serviceQuery = `
INSERT INTO Services (Id, Name, Url, Logo, CreatedAt, UpdatedAt)
VALUES (?, ?, ?, ?, ?, ?)`;
const serviceId = crypto.randomUUID();
const serviceId = crypto.randomUUID().toUpperCase();
const currentDateTime = new Date().toISOString()
.replace('T', ' ')
.replace('Z', '')
.substring(0, 23);
const serviceResult = this.executeUpdate(serviceQuery, [
serviceId,
credential.ServiceName,
credential.ServiceUrl ?? null,
logoData,
new Date().toISOString(),
new Date().toISOString()
currentDateTime,
currentDateTime
]);
// 2. Insert Alias
const aliasQuery = `
INSERT INTO Aliases (Id, FirstName, LastName, NickName, BirthDate, Gender, Email, CreatedAt, UpdatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
const aliasId = crypto.randomUUID();
const aliasId = crypto.randomUUID().toUpperCase();
const aliasResult = this.executeUpdate(aliasQuery, [
aliasId,
credential.Alias.FirstName ?? null,
@@ -237,23 +241,23 @@ class SqliteClient {
credential.Alias.BirthDate ?? null,
credential.Alias.Gender ?? null,
credential.Alias.Email ?? null,
new Date().toISOString(),
new Date().toISOString()
currentDateTime,
currentDateTime
]);
// 3. Insert Credential
const credentialQuery = `
INSERT INTO Credentials (Id, Username, Notes, ServiceId, AliasId, CreatedAt, UpdatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`;
const credentialId = crypto.randomUUID();
const credentialId = crypto.randomUUID().toUpperCase();
const credentialResult = this.executeUpdate(credentialQuery, [
credentialId,
credential.Username,
credential.Notes ?? null,
serviceId,
aliasId,
new Date().toISOString(),
new Date().toISOString()
currentDateTime,
currentDateTime
]);
// 4. Insert Password
@@ -261,13 +265,13 @@ class SqliteClient {
const passwordQuery = `
INSERT INTO Passwords (Id, Value, CredentialId, CreatedAt, UpdatedAt)
VALUES (?, ?, ?, ?, ?)`;
const passwordId = crypto.randomUUID();
const passwordId = crypto.randomUUID().toUpperCase();
this.executeUpdate(passwordQuery, [
passwordId,
credential.Password,
credentialId,
new Date().toISOString(),
new Date().toISOString()
currentDateTime,
currentDateTime
]);
}

View File

@@ -17,15 +17,9 @@ export class WebApiService {
/**
* Constructor for the WebApiService class.
*
* @param {Function} getAccessToken - Function to get the access token.
* @param {Function} getRefreshToken - Function to get the refresh token.
* @param {Function} updateTokens - Function to update the access and refresh tokens.
* @param {Function} handleLogout - Function to handle logout.
*/
public constructor(
private getAccessToken: () => string | null,
private getRefreshToken: () => string | null,
private updateTokens: (accessToken: string, refreshToken: string) => void,
private handleLogout: () => void
) {
// Load the API URL from storage when service is initialized
@@ -35,7 +29,7 @@ export class WebApiService {
/**
* Initialize the base URL for the API from settings.
*/
private async initializeBaseUrl() : Promise<void> {
public async initializeBaseUrl() : Promise<void> {
const result = await chrome.storage.local.get(['apiUrl']);
// Trim trailing slash if present
this.baseUrl = (result.apiUrl || 'https://app.aliasvault.net/api').replace(/\/$/, '') + '/v1/';
@@ -53,7 +47,7 @@ export class WebApiService {
const headers = new Headers(options.headers || {});
// Add authorization header if we have an access token
const accessToken = this.getAccessToken();
const accessToken = await this.getAccessToken();
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
@@ -101,7 +95,7 @@ export class WebApiService {
* Refresh the access token.
*/
private async refreshAccessToken(): Promise<string | null> {
const refreshToken = this.getRefreshToken();
const refreshToken = await this.getRefreshToken();
if (!refreshToken) {
return null;
}
@@ -114,7 +108,7 @@ export class WebApiService {
'X-Ignore-Failure': 'true',
},
body: JSON.stringify({
token: this.getAccessToken(),
token: await this.getAccessToken(),
refreshToken: refreshToken,
}),
});
@@ -180,14 +174,42 @@ export class WebApiService {
* Logout and revoke tokens via WebApi.
*/
public async logout(): Promise<void> {
const refreshToken = this.getRefreshToken();
const refreshToken = await this.getRefreshToken();
if (!refreshToken) {
return;
}
await this.post('Auth/revoke', {
token: this.getAccessToken(),
token: await this.getAccessToken(),
refreshToken: refreshToken,
}, false);
}
/**
* Get the current access token from storage.
*/
private async getAccessToken(): Promise<string | null> {
const token = await chrome.storage.local.get('accessToken');
console.log('accessToken get', token);
return token.accessToken || null;
}
/**
* Get the current refresh token from storage.
*/
private async getRefreshToken(): Promise<string | null> {
const token = await chrome.storage.local.get('refreshToken');
console.log('refreshToken get', token);
return token.refreshToken || null;
}
/**
* Update both access and refresh tokens in storage.
*/
private async updateTokens(accessToken: string, refreshToken: string): Promise<void> {
await chrome.storage.local.set({
accessToken,
refreshToken
});
}
}

View File

@@ -137,7 +137,7 @@ else
{
// Error loading alias.
GlobalNotificationService.AddErrorMessage("This credentials entry does not exist (anymore). Please try again.");
NavigationManager.NavigateTo("/", false, true);
NavigationManager.NavigateTo("/credentials", false, true);
return;
}