Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Can import / export credentials from built-in password manager #2548

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
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
31 changes: 31 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,36 @@ 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) => ({
domain: credential.url,
username: credential.username,
password: credential.password
}))

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
99 changes: 91 additions & 8 deletions js/passwordManager/passwordViewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ 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 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 +54,7 @@ const passwordViewer = {
PasswordManagers.getConfiguredPasswordManager().then(function (manager) {
manager.deleteCredential(credential.domain, credential.username)
container.remove()
passwordViewer._updatePasswordListFooter()
})
}
})
Expand All @@ -77,10 +81,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 +119,85 @@ 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 header = 'url,username,password\n'
const csvData = header + credentials.map(credential => `${credential.domain},${credential.username},${credential.password}`).join('\n')
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 @@ -296,6 +296,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 @@ -295,6 +295,10 @@
"savedPasswordsEmpty": "Geen opgeslagen wachtwoorden.",
"savedPasswordsNeverSavedLabel": "Nooit opgeslagen",
"deletePassword": "Verwijder wachtwoord voor %s?",
"exportCredentials": "Exporteer wachtwoorden",
"importCredentials": "Importeer wachtwoorden",
"exportCredentialsConfirmation": "Wanneer u uw wachtwoorden exporteert, worden ze opgeslagen in een bestand met leesbare tekst. Wanneer u dit bestand klaar bent, raden wij u het 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