Skip to content

Commit

Permalink
Merge pull request #2548 from RubenSmn/feat/export-import-credentials
Browse files Browse the repository at this point in the history
Can import / export credentials from built-in password manager
  • Loading branch information
PalmerAL authored Jan 31, 2025
2 parents 66dfbc9 + 5008cc2 commit 7f89a64
Show file tree
Hide file tree
Showing 8 changed files with 181 additions and 8 deletions.
19 changes: 19 additions & 0 deletions css/passwordViewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,22 @@
overflow: hidden;
text-overflow: ellipsis;
}

#password-viewer-import-container {
margin-top: 0.5em;
display: flex;
justify-content: center;
gap: 1em;
}

#password-viewer-export, #password-viewer-import {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25em;
opacity: 0.7;
}

#password-viewer-export:hover, #password-viewer-import:hover {
opacity: 1;
}
4 changes: 4 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,10 @@ <h2 class="modal-title" data-string="savedPasswordsHeading"></h2>
hidden
></div>
<div id="password-viewer-list"></div>
<div id="password-viewer-import-container">
<button id="password-viewer-import"><span class="i carbon:document-import"></span><span data-string="importCredentials"></span></button>
<button id="password-viewer-export"><span class="i carbon:export"></span><span data-string="exportCredentials"></span></button>
</div>
</div>

<!-- add scripts in Gruntfile.js -->
Expand Down
40 changes: 40 additions & 0 deletions js/passwordManager/keychain.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { ipcRenderer } = require('electron')
const papaparse = require('papaparse')

class Keychain {
constructor () {
Expand Down Expand Up @@ -48,6 +49,45 @@ class Keychain {
ipcRenderer.invoke('credentialStoreDeletePassword', { domain, username })
}

async importCredentials (fileContents) {
try {
const csvData = papaparse.parse(fileContents, {
header: true,
skipEmptyLines: true,
transformHeader (header) {
return header.toLowerCase().trim().replace(/["']/g, '')
}
})
const credentialsToImport = csvData.data.map((credential) => {
try {
const includesProtocol = credential.url.match(/^https?:\/\//g)
const domainWithProtocol = includesProtocol ? credential.url : `https://${credential.url}`

return {
domain: new URL(domainWithProtocol).hostname.replace(/^www\./g, ''),
username: credential.username,
password: credential.password
}
} catch {
return null
}
}).filter(credential => credential !== null)

if (credentialsToImport.length === 0) return []

const currentCredentials = await this.getAllCredentials()
const credentialsWithoutDuplicates = currentCredentials.filter(account => !credentialsToImport.some(a => a.domain === account.domain && a.username === account.username))

const mergedCredentials = credentialsWithoutDuplicates.concat(credentialsToImport)

await ipcRenderer.invoke('credentialStoreSetPasswordBulk', mergedCredentials)
return mergedCredentials
} catch (error) {
console.error('Error importing credentials:', error)
return []
}
}

getAllCredentials () {
return ipcRenderer.invoke('credentialStoreGetCredentials').then(function (results) {
return results.map(function (result) {
Expand Down
106 changes: 98 additions & 8 deletions js/passwordManager/passwordViewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ const webviews = require('webviews.js')
const settings = require('util/settings/settings.js')
const PasswordManagers = require('passwordManager/passwordManager.js')
const modalMode = require('modalMode.js')
const { ipcRenderer } = require('electron')
const papaparse = require('papaparse')

const passwordViewer = {
container: document.getElementById('password-viewer'),
listContainer: document.getElementById('password-viewer-list'),
emptyHeading: document.getElementById('password-viewer-empty'),
closeButton: document.querySelector('#password-viewer .modal-close-button'),
exportButton: document.getElementById('password-viewer-export'),
importButton: document.getElementById('password-viewer-import'),
createCredentialListElement: function (credential) {
var container = document.createElement('div')

Expand Down Expand Up @@ -51,6 +55,7 @@ const passwordViewer = {
PasswordManagers.getConfiguredPasswordManager().then(function (manager) {
manager.deleteCredential(credential.domain, credential.username)
container.remove()
passwordViewer._updatePasswordListFooter()
})
}
})
Expand All @@ -77,10 +82,31 @@ const passwordViewer = {
deleteButton.addEventListener('click', function () {
settings.set('passwordsNeverSaveDomains', settings.get('passwordsNeverSaveDomains').filter(d => d !== domain))
container.remove()
passwordViewer._updatePasswordListFooter()
})

return container
},
_renderPasswordList: function (credentials) {
empty(passwordViewer.listContainer)

credentials.forEach(function (cred) {
passwordViewer.listContainer.appendChild(passwordViewer.createCredentialListElement(cred))
})

const neverSaveDomains = settings.get('passwordsNeverSaveDomains') || []

neverSaveDomains.forEach(function (domain) {
passwordViewer.listContainer.appendChild(passwordViewer.createNeverSaveDomainElement(domain))
})

passwordViewer._updatePasswordListFooter()
},
_updatePasswordListFooter: function () {
const hasCredentials = (passwordViewer.listContainer.children.length !== 0)
passwordViewer.emptyHeading.hidden = hasCredentials
passwordViewer.exportButton.hidden = !hasCredentials
},
show: function () {
PasswordManagers.getConfiguredPasswordManager().then(function (manager) {
if (!manager.getAllCredentials) {
Expand All @@ -94,27 +120,91 @@ const passwordViewer = {
})
passwordViewer.container.hidden = false

credentials.forEach(function (cred) {
passwordViewer.listContainer.appendChild(passwordViewer.createCredentialListElement(cred))
})
passwordViewer._renderPasswordList(credentials)
})
})
},
importCredentials: async function () {
PasswordManagers.getConfiguredPasswordManager().then(async function (manager) {
if (!manager.importCredentials || !manager.getAllCredentials) {
throw new Error('unsupported password manager')
}

const neverSaveDomains = settings.get('passwordsNeverSaveDomains') || []
const credentials = await manager.getAllCredentials()
const shouldShowConsent = credentials.length > 0

neverSaveDomains.forEach(function (domain) {
passwordViewer.listContainer.appendChild(passwordViewer.createNeverSaveDomainElement(domain))
if (shouldShowConsent) {
const securityConsent = ipcRenderer.sendSync('prompt', {
text: l('importCredentialsConfirmation'),
ok: l('dialogConfirmButton'),
cancel: l('dialogCancelButton'),
width: 400,
height: 200
})
if (!securityConsent) return
}

passwordViewer.emptyHeading.hidden = (credentials.length + neverSaveDomains.length !== 0)
const filePaths = await ipcRenderer.invoke('showOpenDialog', {
filters: [
{ name: 'CSV', extensions: ['csv'] },
{ name: 'All Files', extensions: ['*'] }
]
})

if (!filePaths || !filePaths[0]) return

const fileContents = fs.readFileSync(filePaths[0], 'utf8')

manager.importCredentials(fileContents).then(function (credentials) {
if (credentials.length === 0) return
passwordViewer._renderPasswordList(credentials)
})
})
},
exportCredentials: function () {
PasswordManagers.getConfiguredPasswordManager().then(function (manager) {
if (!manager.getAllCredentials) {
throw new Error('unsupported password manager')
}

const securityConsent = ipcRenderer.sendSync('prompt', {
text: l('exportCredentialsConfirmation'),
ok: l('dialogConfirmButton'),
cancel: l('dialogCancelButton'),
width: 400,
height: 200
})
if (!securityConsent) return

manager.getAllCredentials().then(function (credentials) {
if (credentials.length === 0) return

const csvData = papaparse.unparse({
fields: ['url', 'username', 'password'],
data: credentials.map(credential => [
`https://${credential.domain}`,
credential.username,
credential.password
])
})
const blob = new Blob([csvData], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = 'credentials.csv'
anchor.click()
URL.revokeObjectURL(url)
})
})
},
hide: function () {
webviews.hidePlaceholder('passwordViewer')
modalMode.toggle(false)
empty(passwordViewer.listContainer)
passwordViewer.container.hidden = true
},
initialize: function () {
passwordViewer.exportButton.addEventListener('click', passwordViewer.exportCredentials)
passwordViewer.importButton.addEventListener('click', passwordViewer.importCredentials)
passwordViewer.closeButton.addEventListener('click', passwordViewer.hide)
webviews.bindIPC('showCredentialList', function () {
passwordViewer.show()
Expand Down
4 changes: 4 additions & 0 deletions localization/languages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,10 @@
"savedPasswordsEmpty": "No saved passwords.",
"savedPasswordsNeverSavedLabel": "Never saved",
"deletePassword": "Delete password for %s?",
"exportCredentials": "Export credentials",
"importCredentials": "Import credentials",
"exportCredentialsConfirmation": "Your exported credentials will be saved in a file in plain text. When you're done using this file, you should delete it. Are you sure you want to continue?",
"importCredentialsConfirmation": "Importing passwords will overwrite any existing passwords for the same sites. Are you sure you want to continue?",
/* Dialogs */
"loginPromptTitle": "Sign in to %h", //%h is replaced with host, %r with realm (title of protected part of site)
"dialogConfirmButton": "Confirm",
Expand Down
4 changes: 4 additions & 0 deletions localization/languages/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,10 @@
"savedPasswordsEmpty": "Geen opgeslagen wachtwoorden.",
"savedPasswordsNeverSavedLabel": "Nooit opgeslagen",
"deletePassword": "Verwijder wachtwoord voor %s?",
"exportCredentials": "Exporteer wachtwoorden",
"importCredentials": "Importeer wachtwoorden",
"exportCredentialsConfirmation": "Uw geëxporteerde wachtwoorden worden opgeslagen in een bestand in leesbare tekst. Als u dit bestand niet meer gebruikt, is het aan te raden om het bestand te verwijderen. Weet u zeker dat u wilt doorgaan?",
"importCredentialsConfirmation": "Het importeren van wachtwoorden overschrijft alle bestaande wachtwoorden voor dezelfde sites. Weet u zeker dat u wilt doorgaan?",
/* Dialogs */
"loginPromptTitle": "Inloggen bij %h", //%h is replaced with host, %r with realm (title of protected part of site)
"dialogConfirmButton": "Bevestigen",
Expand Down
11 changes: 11 additions & 0 deletions main/keychainService.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ function writeSavedPasswordFile (content) {
fs.writeFileSync(passwordFilePath, safeStorage.encryptString(JSON.stringify(content)))
}

function credentialStoreSetPasswordBulk (accounts) {
const fileContent = readSavedPasswordFile()

fileContent.credentials = accounts
writeSavedPasswordFile(fileContent)
}

function credentialStoreSetPassword (account) {
const fileContent = readSavedPasswordFile()

Expand All @@ -56,6 +63,10 @@ function credentialStoreSetPassword (account) {
writeSavedPasswordFile(fileContent)
}

ipc.handle('credentialStoreSetPasswordBulk', async function (event, accounts) {
return credentialStoreSetPasswordBulk(accounts)
})

ipc.handle('credentialStoreSetPassword', async function (event, account) {
return credentialStoreSetPassword(account)
})
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"electron-squirrel-startup": "^1.0.0",
"expr-eval": "^2.0.2",
"node-abi": "^3.8.0",
"papaparse": "^5.5.1",
"pdfjs-dist": "4.2.67",
"quick-score": "^0.2.0",
"regedit": "^3.0.3",
Expand Down

0 comments on commit 7f89a64

Please sign in to comment.