Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# ─── Sensitive — NEVER commit ───────────────────────────────────────────────
*.pem
passkey-extension-key.pem
# Encrypted vault backups & exports (real user data — must never be published)
*.pkbak
*.autobak
PassKey_Backup*.pkbak
# Local SQLite vault database (should never live in the repo, but guard anyway)
passkey.db
*.db-wal
*.db-shm

# ─── Build output ────────────────────────────────────────────────────────────
bin/
Expand Down Expand Up @@ -54,6 +62,12 @@ web-ext-artifacts/

# ─── Publish output ──────────────────────────────────────────────────────────
publish/
publish_test*/

# ─── Local build artefacts / scratch (never commit) ──────────────────────────
PassKey-Portable-x64.zip
Installer/*.exe
demo-data/

# ─── claude-code workspace artefacts (per-developer, never commit) ───────────
.claude/
Expand Down
34 changes: 16 additions & 18 deletions extensions/chrome/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,16 +271,16 @@ async function handleGetAllCredentials() {
async function handleCopyCredential(credentialId) {
try {
await ensureSession();
const req = buildRequest('get-credential-password', { id: credentialId });
const req = buildRequest('get-credential-password', { id: credentialId, sessionId });
const resp = parseResponse(await sendNativeMessage(req));
if (!resp.success) return resp;

let password;
if (resp.payload.nonce && resp.payload.nonce.length > 0) {
password = await decryptPassword(sessionKey, resp.payload.nonce, resp.payload.encryptedPassword);
} else {
password = new TextDecoder().decode(base64ToUint8Array(resp.payload.encryptedPassword));
// Desktop always returns an AES-GCM encrypted password tied to the ECDH session.
// A missing nonce means no valid session — never accept a plaintext fallback.
if (!resp.payload?.nonce || resp.payload.nonce.length === 0) {
return { success: false, error: 'no-session' };
}
const password = await decryptPassword(sessionKey, resp.payload.nonce, resp.payload.encryptedPassword);
return { success: true, password };
} catch (err) {
return { success: false, error: err.message || 'copy-failed' };
Expand Down Expand Up @@ -370,25 +370,23 @@ async function handleFillCredential(credentialId, username, tabId) {
await ensureSession();

// Request encrypted password
const req = buildRequest('get-credential-password', { id: credentialId });
const req = buildRequest('get-credential-password', { id: credentialId, sessionId });
const resp = parseResponse(await sendNativeMessage(req));

if (!resp.success) {
return resp;
}

// Decrypt password using session key
let password;
if (resp.payload.nonce && resp.payload.nonce.length > 0) {
password = await decryptPassword(
sessionKey,
resp.payload.nonce,
resp.payload.encryptedPassword
);
} else {
// No session encryption — decode Base64 plaintext (fallback)
password = new TextDecoder().decode(base64ToUint8Array(resp.payload.encryptedPassword));
// Desktop always returns an AES-GCM encrypted password tied to the ECDH session.
// A missing nonce means no valid session — never accept a plaintext fallback.
if (!resp.payload?.nonce || resp.payload.nonce.length === 0) {
return { success: false, error: 'no-session' };
}
const password = await decryptPassword(
sessionKey,
resp.payload.nonce,
resp.payload.encryptedPassword
);

// Send credentials to content script for form filling
await chrome.tabs.sendMessage(tabId, {
Expand Down
2 changes: 1 addition & 1 deletion extensions/chrome/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "PassKey",
"version": "1.0.1",
"version": "1.0.2",
"author": "Giuseppe Imperato",
"homepage_url": "https://github.com/pexatar/PassKey",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjdNrt0WikUv9qMTjwXhpg5jIYvCLS0WYu8lRlUCzcvdB/aEuHOEqaQ4ByEES+eISVoOo4Vmdr4u3Zo1+IyPwE1zGqwX1agNMsZbctr3Ur8esnHp/gMaLkrONFHDrOXhaVdUJy5BDqa5yXuTN5jsZWhk3cmqxpxlfWu70piFWN4bsPPwdKNXuDYPSBQCcdUFlUxUz3GXX8zkm1GsXQONg1vMSYE9E5GHD4ORk5oSWlRDccsTHtnWVlfaNzlBLmuiXTbmrCBRO2zfKU+8T3byMZTWGf/0ygD7+XuNbHvMAxQgFFwdAeP9RX/kXnBej2KwXxlVE65rd/kSwfa5hFLcubQIDAQAB",
Expand Down
34 changes: 16 additions & 18 deletions extensions/firefox/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,16 +274,16 @@ async function handleGetAllCredentials() {
async function handleCopyCredential(credentialId) {
try {
await ensureSession();
const req = buildRequest('get-credential-password', { id: credentialId });
const req = buildRequest('get-credential-password', { id: credentialId, sessionId });
const resp = parseResponse(await sendNativeMessage(req));
if (!resp.success) return resp;

let password;
if (resp.payload.nonce && resp.payload.nonce.length > 0) {
password = await decryptPassword(sessionKey, resp.payload.nonce, resp.payload.encryptedPassword);
} else {
password = new TextDecoder().decode(base64ToUint8Array(resp.payload.encryptedPassword));
// Desktop always returns an AES-GCM encrypted password tied to the ECDH session.
// A missing nonce means no valid session — never accept a plaintext fallback.
if (!resp.payload?.nonce || resp.payload.nonce.length === 0) {
return { success: false, error: 'no-session' };
}
const password = await decryptPassword(sessionKey, resp.payload.nonce, resp.payload.encryptedPassword);
return { success: true, password };
} catch (err) {
return { success: false, error: err.message || 'copy-failed' };
Expand Down Expand Up @@ -373,25 +373,23 @@ async function handleFillCredential(credentialId, username, tabId) {
await ensureSession();

// Request encrypted password
const req = buildRequest('get-credential-password', { id: credentialId });
const req = buildRequest('get-credential-password', { id: credentialId, sessionId });
const resp = parseResponse(await sendNativeMessage(req));

if (!resp.success) {
return resp;
}

// Decrypt password using session key
let password;
if (resp.payload.nonce && resp.payload.nonce.length > 0) {
password = await decryptPassword(
sessionKey,
resp.payload.nonce,
resp.payload.encryptedPassword
);
} else {
// No session encryption — decode Base64 plaintext (fallback)
password = new TextDecoder().decode(base64ToUint8Array(resp.payload.encryptedPassword));
// Desktop always returns an AES-GCM encrypted password tied to the ECDH session.
// A missing nonce means no valid session — never accept a plaintext fallback.
if (!resp.payload?.nonce || resp.payload.nonce.length === 0) {
return { success: false, error: 'no-session' };
}
const password = await decryptPassword(
sessionKey,
resp.payload.nonce,
resp.payload.encryptedPassword
);

// Send credentials to content script for form filling
await browser.tabs.sendMessage(tabId, {
Expand Down
2 changes: 1 addition & 1 deletion extensions/firefox/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "PassKey",
"version": "1.0.1",
"version": "1.0.2",
"author": "Giuseppe Imperato",
"homepage_url": "https://github.com/pexatar/PassKey",
"description": "Local password manager integration for PassKey desktop app. Autofill credentials, credit cards and identities stored securely on your PC — no cloud, no subscription.",
Expand Down
30 changes: 30 additions & 0 deletions src/PassKey.Desktop/Services/BackupFileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,41 @@ public async Task<byte[]> ReadBackupAsync(string filePath)
return await File.ReadAllBytesAsync(filePath);
}

/// <summary>Number of most-recent automatic backups to retain; older ones are pruned.</summary>
private const int MaxAutoBackups = 10;

public async Task WriteAutoBackupAsync(byte[] currentEncryptedBlob)
{
Directory.CreateDirectory(BackupDir);
var timestamp = DateTime.UtcNow.ToString("yyyyMMddTHHmmss");
var autoBackupPath = Path.Combine(BackupDir, $"vault.{timestamp}.autobak");
await File.WriteAllBytesAsync(autoBackupPath, currentEncryptedBlob);

PruneOldAutoBackups();
}

/// <summary>
/// Keeps only the <see cref="MaxAutoBackups"/> most recent <c>vault.*.autobak</c> files,
/// deleting older ones. Prevents the automatic-backup folder from growing without bound.
/// Deletion failures are ignored (a locked/transient file must not break the backup flow).
/// </summary>
private static void PruneOldAutoBackups()
{
try
{
var oldBackups = Directory.GetFiles(BackupDir, "vault.*.autobak")
.OrderByDescending(path => path, StringComparer.Ordinal) // timestamped name sorts chronologically
.Skip(MaxAutoBackups);

foreach (var path in oldBackups)
{
try { File.Delete(path); }
catch { /* ignore individual deletion failures */ }
}
}
catch
{
// Enumeration failure must never break the backup operation itself.
}
}
}
Loading
Loading