From f35baa3a5d7f6737161588af55235cb2fd265380 Mon Sep 17 00:00:00 2001 From: genericFJS Date: Sun, 12 Oct 2025 22:37:36 +0200 Subject: [PATCH 1/4] Fix bug where organization entry is not added to organization --- src/index.ts | 5 ----- src/keepassWriter.ts | 14 +++++++------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index e816a91..79bbfed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,5 @@ import { executeBackup } from './executeBackup'; -function success() { - console.log('🎉 Backup completed successfully'); - process.exit(0); -} - executeBackup().then( () => { console.log('🎉 Backup completed successfully'); diff --git a/src/keepassWriter.ts b/src/keepassWriter.ts index 8a51a07..eb3f083 100644 --- a/src/keepassWriter.ts +++ b/src/keepassWriter.ts @@ -75,13 +75,13 @@ export class KeePassWriter { // add items for (const item of bitwarden.items) { // get all group and all collections of item - const groupsOfItem = - /* implicitly no folder/collection */ (!item.folderId && item.collectionIds.length === 0) || - /* explicitly no folder */ item.folderId === noFolder?.id - ? [this.db.getDefaultGroup()] - : [item.folderId, ...item.collectionIds] - .filter((v) => v !== undefined) - .map((id) => groups[id]); + const groupsOfItem = [item.folderId, ...item.collectionIds] + .filter((v) => v != null) + .map((id) => groups[id]); + + if (groupsOfItem.length === 0) { + groupsOfItem.push(this.db.getDefaultGroup()); + } // Add entry to each group for (const group of groupsOfItem) { From ce300e8a0e8f4530d3538034eb6c84b2be2fe04c Mon Sep 17 00:00:00 2001 From: genericFJS Date: Thu, 16 Oct 2025 00:18:56 +0200 Subject: [PATCH 2/4] Change how organization entries are stored --- .env.example | 2 + CHANGELOG.md | 35 +++++++ README.md | 30 +++--- docker-compose.yml | 30 +++--- package.json | 2 +- src/executeBackup.ts | 20 +++- src/keepassWriter.ts | 202 ++++++++++++++++++++++++------------ tests/keepassWriter.spec.ts | 76 ++------------ 8 files changed, 232 insertions(+), 165 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.env.example b/.env.example index d2804fc..dd046d1 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,5 @@ KEEPASS_BACKUP_PATH="./backup" KEEPASS_BACKUP_FILE_NAME="BitwardenBackup" # same name every time: overwrite KEEPASS_BACKUP_DATABASE_NAME="BitwardenBackup_%date%" ORGANIZATIONS_GROUP_NAME="Organizations" +ORGANIZATION_FOLDERS_NAME="Folders" +ORGANIZATION_COLLECTIONS_NAME="Collections" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7824c02 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 1.2.0 + +### Changed + +- Organization entries are saved differently in the keepass file. + - Organizations no longer have each collection as direct keepass group children (and no folders; see fix below). + - Organizations now have a keepass group for folders and collections; in these groups the folders and collections are created as keepass groups. + - This resembles the structure how it is presented in vaultwarden. + +### Fixed + +- Organization entries appearing in the folders outside of the organization. + +## 1.1.0 + +### Added + +- Support for keepass key files (thanks to [rahul-kurup](https://github.com/rahul-kurup)). + +## 1.0.0 + +### Added + +- Initial implementation. + +### Fixed + +- Issue where an already valid otpauth://totp/ URI would be incorrectly re-encoded, leading to a corrupted TOTP value (thanks to [DmitriiPetukhov](https://github.com/DmitriiPetukhov)). diff --git a/README.md b/README.md index c8f9dda..7eff1f5 100644 --- a/README.md +++ b/README.md @@ -31,20 +31,22 @@ This is similar to projects like [lazywarden](https://github.com/querylab/lazywa Use the following environment variables to configure the script: -| variable | default | mandatory | notes | -| --------------------------------------- | ------------------------ | --------- | -------------------------------------------------------------------------------------------------------- | -| `URL` | - | x | use the url to your bitwarden/vaultwarden instance | -| `BW_CLIENTID` | - | x | see [personal api key](https://bitwarden.com/help/personal-api-key/) | -| `BW_CLIENTSECRET` | - | x | see [personal api key](https://bitwarden.com/help/personal-api-key/) | -| `BW_PASSWORD` \* | - | x | password to your bitwarden/vaultwarden account (base64-encoded) | -| `KEEPASS_BACKUP_PASSWORD` \* | _[same as BW_PASSWORD]_ | | password for the KeePass database (base64-encoded) | -| `KEEPASS_BACKUP_KEYFILE_PATH` | _undefined_ | | provide the key file’s absolute path to use it instead of a password or as an additional security layer. | -| `ATTACHMENT_TEMP_FOLDER` | ./attachmentBackup | | directory where attachments are temporarily stored (recommendation: use `/tmp` for linux machines) | -| `MAX_ATTACHMENT_BYTES` | 100000 | | maximum size of an attachment that should be backed up in the KeePass database | -| `KEEPASS_BACKUP_PATH` | ./backup | | location where KeePass backup should be saved | -| `KEEPASS_BACKUP_FILE_NAME` | `BitwardenBackup_%date%` | | name of the KeePass database file; use `%date%` anywhere to insert path-friendly date+time string | -| `KEEPASS_BACKUP_DATABASE_NAME` | _[same as filename]_ | | name of the KeePass database (when opened); can use `%date%` as well | -| `ORGANIZATIONS_GROUP_NAME` | Organizations | | name of the KeePass group where organizations and its items should be stored | +| variable | default | mandatory | notes | +| --------------------------------------- | ------------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `URL` | - | x | use the url to your bitwarden/vaultwarden instance | +| `BW_CLIENTID` | - | x | see [personal api key](https://bitwarden.com/help/personal-api-key/) | +| `BW_CLIENTSECRET` | - | x | see [personal api key](https://bitwarden.com/help/personal-api-key/) | +| `BW_PASSWORD` \* | - | x | password to your bitwarden/vaultwarden account (base64-encoded) | +| `KEEPASS_BACKUP_PASSWORD` \* | _[same as BW_PASSWORD]_ | | password for the KeePass database (base64-encoded); if this not set and keyfile is provided it is not set instead of taking the default value | +| `KEEPASS_BACKUP_KEYFILE_PATH` | _undefined_ | | absolute path of a key file to use instead of or as an additional security layer to a password | +| `ATTACHMENT_TEMP_FOLDER` | ./attachmentBackup | | directory where attachments are temporarily stored (recommendation: use `/tmp` for linux machines) | +| `MAX_ATTACHMENT_BYTES` | 100000 | | maximum size of an attachment that should be backed up in the KeePass database | +| `KEEPASS_BACKUP_PATH` | ./backup | | location where KeePass backup should be saved | +| `KEEPASS_BACKUP_FILE_NAME` | `BitwardenBackup_%date%` | | name of the KeePass database file; use `%date%` anywhere to insert path-friendly date+time string | +| `KEEPASS_BACKUP_DATABASE_NAME` | _[same as filename]_ | | name of the KeePass database (when opened); can use `%date%` as well | +| `ORGANIZATIONS_GROUP_NAME` | Organizations | | name of the KeePass group where organizations and its items should be stored | +| `ORGANIZATION_FOLDERS_NAME` | Folders | | name of the KeePass group where folders for an organization should be stored | +| `ORGANIZATION_COLLECTIONS_NAME` | Collections | | name of the KeePass group where collections for an organizations should be stored | \*: In most cases these environment variables are stored in plain text. That means they can easily be read. To make this _somewhat_ more secure and conceal them on first sight, your passwords have to be base64-encoded. To encode your password in base64 use some (online) tool of your choice or just open the developer tools console in any browser (usually via F12) and use the output of `btoa("your_password")`. diff --git a/docker-compose.yml b/docker-compose.yml index b87f0db..123b049 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,22 +4,24 @@ services: build: https://github.com/genericFJS/vaultwarden2keepass.git command: npm run start volumes: - - './backup:/app/backup' + - "./backup:/app/backup" # Cache bitwarden cli config (else every start of the script would trigger a "new device" mail) - - './backup/cli-config:/root/.config/Bitwarden CLI/' + - "./backup/cli-config:/root/.config/Bitwarden CLI/" environment: # Mandatory - - 'URL=https://vault.bitwarden.com/' - - 'BW_CLIENTID=user.aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' - - 'BW_CLIENTSECRET=abcdefghijklmnopqrstuvwxz01234' - - 'BW_PASSWORD=Y29ycmVjdGhvcnNlYmF0dGVyeXN0YXBsZQ==' + - "URL=https://vault.bitwarden.com/" + - "BW_CLIENTID=user.aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + - "BW_CLIENTSECRET=abcdefghijklmnopqrstuvwxz01234" + - "BW_PASSWORD=Y29ycmVjdGhvcnNlYmF0dGVyeXN0YXBsZQ==" # Optional - - 'KEEPASS_BACKUP_PASSWORD=VHIwdWI0ZG9yJjM=' - - 'KEEPASS_BACKUP_KEYFILE_PATH=/path/to/keyfile.keyx' - - 'ATTACHMENT_TEMP_FOLDER=/tmp' - - 'MAX_ATTACHMENT_BYTES=100000' - - 'KEEPASS_BACKUP_PATH=./backup' - - 'KEEPASS_BACKUP_FILE_NAME=BitwardenBackup' - - 'KEEPASS_BACKUP_DATABASE_NAME=BitwardenBackup_%date%' - - 'ORGANIZATIONS_GROUP_NAME=Organizations' + - "KEEPASS_BACKUP_PASSWORD=VHIwdWI0ZG9yJjM=" + - "KEEPASS_BACKUP_KEYFILE_PATH=/path/to/keyfile.keyx" + - "ATTACHMENT_TEMP_FOLDER=/tmp" + - "MAX_ATTACHMENT_BYTES=100000" + - "KEEPASS_BACKUP_PATH=./backup" + - "KEEPASS_BACKUP_FILE_NAME=BitwardenBackup" + - "KEEPASS_BACKUP_DATABASE_NAME=BitwardenBackup_%date%" + - "ORGANIZATIONS_GROUP_NAME=Organizations" + - "ORGANIZATION_FOLDERS_NAME=Folders" + - "ORGANIZATION_COLLECTIONS_NAME=Collections" diff --git a/package.json b/package.json index 2e59684..d3e3896 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vaultwarden2keepass", - "version": "1.1.0", + "version": "1.2.0", "type": "module", "scripts": { "start": "tsx ./src/index.ts", diff --git a/src/executeBackup.ts b/src/executeBackup.ts index 7ef27b5..a5a81dc 100644 --- a/src/executeBackup.ts +++ b/src/executeBackup.ts @@ -8,7 +8,9 @@ const DEFAULTS = { KEEPASS_BACKUP_PATH: './backup', KEEPASS_BACKUP_FILE_NAME: 'BitwardenBackup_%date%', ORGANIZATIONS_GROUP_NAME: 'Organizations', -}; + ORGANIZATION_FOLDERS_NAME: 'Folders', + ORGANIZATION_COLLECTIONS_NAME: 'Collections', +} as const; function ensureParameter(parameter: string, explanation: string) { const value = process.env[parameter]; @@ -58,9 +60,14 @@ export async function executeBackup() { process.env['KEEPASS_BACKUP_FILE_NAME'] || DEFAULTS.KEEPASS_BACKUP_FILE_NAME; const backupDatabaseName = process.env['KEEPASS_BACKUP_DATABASE_NAME'] || backupFileName; - const organizationsFolderName = + const organizationsRootName = process.env['ORGANIZATIONS_GROUP_NAME'] || DEFAULTS.ORGANIZATIONS_GROUP_NAME; + const organizationsFoldersName = + process.env['ORGANIZATION_FOLDERS_NAME'] || DEFAULTS.ORGANIZATION_FOLDERS_NAME; + const organizationsCollectionsName = + process.env['ORGANIZATION_COLLECTIONS_NAME'] || DEFAULTS.ORGANIZATION_COLLECTIONS_NAME; + // ================ DO BACKUP ================ const backupDate = new Date().toISOString().slice(0, -5).replaceAll(':', '-'); const keepassFileName = backupFileName.replaceAll('%date%', backupDate); @@ -68,15 +75,18 @@ export async function executeBackup() { const bitwardenExtractor = new BitwardenExtractor(url, attachmentTempFolder, maxAttachmentBytes); const bitwardenData = await bitwardenExtractor.getBitwardenData(); + bitwardenExtractor.logout(); const keepassWriter = new KeePassWriter({ name: keepassDatabaseName, keyFile: backupKeyFile, password: backupPassword, - organizationsFolderName, + organizationFolderNames: { + organizations: organizationsRootName, + collections: organizationsCollectionsName, + folders: organizationsFoldersName, + }, }); await keepassWriter.fillDatabaseWithBitwardenData(bitwardenData); await keepassWriter.writeDatabase(backupPath, keepassFileName); - - bitwardenExtractor.logout(); } diff --git a/src/keepassWriter.ts b/src/keepassWriter.ts index 82ef97f..b2ff348 100644 --- a/src/keepassWriter.ts +++ b/src/keepassWriter.ts @@ -1,5 +1,8 @@ +import { mkdirSync, writeFileSync } from 'fs'; +import type { KdbxEntry } from 'kdbxweb'; +import { join, resolve } from 'path'; +import type { Item, Organization } from './bitwardenCliTypes'; import type { BitwardenData } from './bitwardenExtractor'; -import type { Collection, Folder, Item } from './bitwardenCliTypes'; import { fieldMappings } from './fieldMappings'; import { getMapping, @@ -9,24 +12,29 @@ import { type MappingRecord, } from './fieldMappingsUtil'; import { Credentials, Kdbx, ProtectedValue, type KdbxGroup } from './kdbxweb'; -import { mkdirSync, writeFileSync } from 'fs'; -import type { KdbxEntry } from 'kdbxweb'; -import { join, resolve } from 'path'; + +type OrganizationFolderNames = { + organizations: string; + folders: string; + collections: string; +}; type KeePassWriterArgs = { name: string; password?: string; keyFile?: Uint8Array; - organizationsFolderName: string; + organizationFolderNames: OrganizationFolderNames; }; export class KeePassWriter { private db: Kdbx; - private organizationsFolderName!: string; + private organizationFolderNames!: OrganizationFolderNames; + private allGroupPaths!: string[]; + private readonly groups: Record = {}; - constructor({ name, password, keyFile, organizationsFolderName }: KeePassWriterArgs) { + constructor({ name, password, keyFile, organizationFolderNames }: KeePassWriterArgs) { const credentials = this.createKbdxCredentials(password, keyFile); - this.organizationsFolderName = organizationsFolderName; + this.organizationFolderNames = organizationFolderNames; this.db = Kdbx.create(credentials, name); this.db.createDefaultGroup(); } @@ -75,52 +83,106 @@ export class KeePassWriter { async fillDatabaseWithBitwardenData(bitwarden: BitwardenData) { console.log('💻 Filling KeePass database with bitwarden data'); // Transform organizations folder name so there are no collisions with existing folders - const allRootFolders = new Set([ - ...bitwarden.folders.map((f) => KeePassWriter.getSubfolders(bitwarden.folders, f.name)[0]), - ...bitwarden.collections.map( - (c) => KeePassWriter.getSubfolders(bitwarden.collections, c.name)[0], + const allRootFolders = new Set( + bitwarden.folders.map( + (f) => + KeePassWriter.getSubfolders( + bitwarden.folders.map((f) => f.name), + f.name, + )[0], ), - ]); - const originalOrganizationsFolder = this.organizationsFolderName; - if (allRootFolders.has(this.organizationsFolderName)) { - this.organizationsFolderName = `Bitwarden${originalOrganizationsFolder}`; + ); + const originalOrganizationsFolder = this.organizationFolderNames.organizations; + if (allRootFolders.has(originalOrganizationsFolder)) { + this.organizationFolderNames.organizations = `Bitwarden${originalOrganizationsFolder}`; } - for (let i = 0; allRootFolders.has(this.organizationsFolderName); i++) { - this.organizationsFolderName = `${originalOrganizationsFolder}_${i}`; + for (let i = 0; allRootFolders.has(this.organizationFolderNames.organizations); i++) { + this.organizationFolderNames.organizations = `${originalOrganizationsFolder}_${i}`; } + // folders, collections and organizations by id for easy access + const pathsById = [...bitwarden.folders, ...bitwarden.collections].reduce< + Record + >((partial, { id, name }) => { + if (id != undefined) partial[id] = name; + return partial; + }, {}); + const noFolderName = bitwarden.folders.filter((f) => !f.id)![0].name; + const organizations = bitwarden.organizations.reduce>( + (partial, o) => ({ ...partial, [o.id]: o }), + {}, + ); + console.log('⏱️ Adding folders and collections as groups'); - // add folders - let groups = this.addGroups(this.db.getDefaultGroup(), bitwarden.folders); - - // add organizations/collections - if (bitwarden.organizations.length > 0) { - const organizationsGroup = this.db.createGroup( - this.db.getDefaultGroup(), - this.organizationsFolderName, - ); - for (const organization of bitwarden.organizations) { - const organizationGroup = this.db.createGroup(organizationsGroup, organization.name); - const collections = bitwarden.collections.filter( - (c) => c.organizationId === organization.id, + // collect all group paths first, so that the group order is dictated by the group name instead of the item name + const groupPaths = new Set(); + for (const item of bitwarden.items) { + if (!item.organizationId) { + if (item.folderId) groupPaths.add(pathsById[item.folderId]); + } else { + groupPaths.add(this.organizationFolderNames.organizations); + groupPaths.add(this.groupPathForOrganization(organizations[item.organizationId])); + groupPaths.add(this.groupPathForOrganization(organizations[item.organizationId], 'folder')); + groupPaths.add( + this.groupPathForOrganization( + organizations[item.organizationId], + 'folder', + item.folderId ? pathsById[item.folderId] : noFolderName, + ), ); - if (collections.length > 0) { - groups = { ...groups, ...this.addGroups(organizationGroup, collections) }; + for (const collectionId of item.collectionIds) { + groupPaths.add( + this.groupPathForOrganization(organizations[item.organizationId], 'collection'), + ); + groupPaths.add( + this.groupPathForOrganization( + organizations[item.organizationId], + 'collection', + pathsById[collectionId], + ), + ); } } } + this.allGroupPaths = [...groupPaths]; + + for (const groupPath of [...groupPaths].toSorted((a, b) => + a === this.organizationFolderNames.organizations ? 1 : a.localeCompare(b), + )) { + this.createGroupRecursive(groupPath); + } + console.log('⏱️ Adding items as entries'); - const noFolder = bitwarden.folders.find((f) => !f.id); // add items for (const item of bitwarden.items) { // get all group and all collections of item - const groupsOfItem = [item.folderId, ...item.collectionIds] - .filter((v) => v != null) - .map((id) => groups[id]); - - if (groupsOfItem.length === 0) { - groupsOfItem.push(this.db.getDefaultGroup()); + const groupsOfItem: KdbxGroup[] = []; + if (!item.organizationId) { + groupsOfItem.push( + item.folderId ? this.groups[pathsById[item.folderId]] : this.db.getDefaultGroup(), + ); + } else { + groupsOfItem.push( + this.groups[ + this.groupPathForOrganization( + organizations[item.organizationId], + 'folder', + item.folderId ? pathsById[item.folderId] : noFolderName, + ) + ], + ); + for (const collectionId of item.collectionIds) { + groupsOfItem.push( + this.groups[ + this.groupPathForOrganization( + organizations[item.organizationId], + 'collection', + pathsById[collectionId], + ) + ], + ); + } } // Add entry to each group @@ -226,10 +288,10 @@ export class KeePassWriter { * @param folderOrPath Folder name to find subfolders of. * @returns Subfolders for folder name. */ - static getSubfolders(folders: (Folder | Collection)[], folderOrPath: string) { + static getSubfolders(folders: string[], folderOrPath: string) { const subfolders: string[] = []; - const allFolders = new Set(folders.map((f) => f.name)); + const allFolders = new Set(folders); const potentialSubfolders = folderOrPath.split('/'); const path: string[] = []; @@ -248,35 +310,43 @@ export class KeePassWriter { } /** - * Adds folders/groups to KeePass database. + * Assembles a path for an organization, its folder or collection. * - * @param root Root KeePass-group to add (sub-)groups to. - * @param folders Bitwarden folders to add as groups. - * @returns Record of bitwarden folder ids with corresponding KeePass groups. + * @param organization The organization the path belongs to. + * @param path The path itself. + * @param type If the path is a folder or a collection. + * @returns Complete path as it should be added as a group. */ - private addGroups(root: KdbxGroup, folders?: (Folder | Collection)[]): Record { - if (!folders) return {}; - return folders.reduce( - (partial, { id, name }) => { - if (id == null) return partial; - const subfolders = KeePassWriter.getSubfolders(folders, name); - - let parent = root; - for (const folder of subfolders) { - const availableParent = [...parent.allGroups()].find((g) => g.name === folder); - if (!availableParent) { - parent = this.db.createGroup(parent, folder); - } else { - parent = availableParent; - } - } - partial[id] = parent; - return partial; - }, - {} as Record, + private groupPathForOrganization( + organization: Organization, + type?: 'folder' | 'collection', + path?: string, + ) { + return ( + `${this.organizationFolderNames.organizations}/${organization.name}` + + (type ? `/${this.organizationFolderNames[`${type}s`]}` + (path ? `/${path}` : '') : '') ); } + /** + * Creates a Keepass group based on a path. If the path contains subfolders, they are created recursively. + * + * @param path Path to create a group for. + * @returns The created group. + */ + private createGroupRecursive(path: string): KdbxGroup { + const subfolders = KeePassWriter.getSubfolders(this.allGroupPaths, path); + const folder = subfolders.at(-1)!; + const parent = + subfolders.length === 1 + ? this.db.getDefaultGroup() + : this.createGroupRecursive(subfolders.slice(0, -1).join('/')); + + if (!(path in this.groups)) this.groups[path] = this.db.createGroup(parent, folder); + + return this.groups[path]; + } + /** * Adds a field to the KeePass database based on the Bitwarden field value and its mapping. * diff --git a/tests/keepassWriter.spec.ts b/tests/keepassWriter.spec.ts index 285e69e..c752c26 100644 --- a/tests/keepassWriter.spec.ts +++ b/tests/keepassWriter.spec.ts @@ -1,88 +1,34 @@ -import type { Folder } from '../src/bitwardenCliTypes'; +import { describe, expect, it } from 'vitest'; import { KeePassWriter } from '../src/keepassWriter'; -import { describe, it, expect } from 'vitest'; describe('should transform bitwarden folder names to subfolders', () => { it('gets folders where first folder contains slash', () => { - const folders: Folder[] = [ - { - object: 'folder', - name: 'a/b', - }, - { - object: 'folder', - name: 'a/b/c', - }, - { - object: 'folder', - name: 'a/b/c/d', - }, - ]; + const folders: string[] = ['a/b', 'a/b/c', 'a/b/c/d']; - expect(KeePassWriter.getSubfolders(folders, folders.at(-1)!.name)).toEqual(['a/b', 'c', 'd']); + expect(KeePassWriter.getSubfolders(folders, folders.at(-1)!)).toEqual(['a/b', 'c', 'd']); }); it('gets folders where middle folder contains slash', () => { - const folders: Folder[] = [ - { - object: 'folder', - name: 'a', - }, - { - object: 'folder', - name: 'a/b/c', - }, - { - object: 'folder', - name: 'a/b/c/d', - }, - ]; + const folders: string[] = ['a', 'a/b/c', 'a/b/c/d']; - expect(KeePassWriter.getSubfolders(folders, folders.at(-1)!.name)).toEqual(['a', 'b/c', 'd']); + expect(KeePassWriter.getSubfolders(folders, folders.at(-1)!)).toEqual(['a', 'b/c', 'd']); }); it('gets folders where last folder contains slash', () => { - const folders: Folder[] = [ - { - object: 'folder', - name: 'a', - }, - { - object: 'folder', - name: 'a/b', - }, - { - object: 'folder', - name: 'a/b/c/d', - }, - ]; + const folders: string[] = ['a', 'a/b', 'a/b/c/d']; - expect(KeePassWriter.getSubfolders(folders, folders.at(-1)!.name)).toEqual(['a', 'b', 'c/d']); + expect(KeePassWriter.getSubfolders(folders, folders.at(-1)!)).toEqual(['a', 'b', 'c/d']); }); it('gets folders where first and last folder contains slash', () => { - const folders: Folder[] = [ - { - object: 'folder', - name: 'a/b', - }, - { - object: 'folder', - name: 'a/b/c/d', - }, - ]; + const folders: string[] = ['a/b', 'a/b/c/d']; - expect(KeePassWriter.getSubfolders(folders, folders.at(-1)!.name)).toEqual(['a/b', 'c/d']); + expect(KeePassWriter.getSubfolders(folders, folders.at(-1)!)).toEqual(['a/b', 'c/d']); }); it('gets folders where entire folder contains slash', () => { - const folders: Folder[] = [ - { - object: 'folder', - name: 'a/b/c/d', - }, - ]; + const folders: string[] = ['a/b/c/d']; - expect(KeePassWriter.getSubfolders(folders, folders.at(-1)!.name)).toEqual(['a/b/c/d']); + expect(KeePassWriter.getSubfolders(folders, folders.at(-1)!)).toEqual(['a/b/c/d']); }); }); From 28d28210d817be077a028668b2a85034dd8455ca Mon Sep 17 00:00:00 2001 From: genericFJS Date: Thu, 16 Oct 2025 00:27:38 +0200 Subject: [PATCH 3/4] Fix spelling --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7824c02..347add6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Organization entries are saved differently in the keepass file. +- Organization items are saved differently in the keepass file. - Organizations no longer have each collection as direct keepass group children (and no folders; see fix below). - Organizations now have a keepass group for folders and collections; in these groups the folders and collections are created as keepass groups. - This resembles the structure how it is presented in vaultwarden. ### Fixed -- Organization entries appearing in the folders outside of the organization. +- Organization items appearing in the folders outside of the organization. ## 1.1.0 From 9bd3f7e0bad5ab45bfd074c3c8d92a3edb4e2979 Mon Sep 17 00:00:00 2001 From: genericFJS Date: Thu, 16 Oct 2025 23:57:53 +0200 Subject: [PATCH 4/4] Add flat/nested mode for organizations --- .env.example | 1 + CHANGELOG.md | 4 +++ README.md | 6 ++-- docker-compose.yml | 1 + src/executeBackup.ts | 6 ++++ src/keepassWriter.ts | 86 ++++++++++++++++++++++++++++++-------------- 6 files changed, 76 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index dd046d1..c415c49 100644 --- a/.env.example +++ b/.env.example @@ -13,5 +13,6 @@ KEEPASS_BACKUP_PATH="./backup" KEEPASS_BACKUP_FILE_NAME="BitwardenBackup" # same name every time: overwrite KEEPASS_BACKUP_DATABASE_NAME="BitwardenBackup_%date%" ORGANIZATIONS_GROUP_NAME="Organizations" +ORGANIZATION_MODE="flat" ORGANIZATION_FOLDERS_NAME="Folders" ORGANIZATION_COLLECTIONS_NAME="Collections" diff --git a/CHANGELOG.md b/CHANGELOG.md index 347add6..49eab83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Organizations now have a keepass group for folders and collections; in these groups the folders and collections are created as keepass groups. - This resembles the structure how it is presented in vaultwarden. +### Added + +- Option to save organization items nested with subgroups for folders/collections or flat without duplicate entries. + ### Fixed - Organization items appearing in the folders outside of the organization. diff --git a/README.md b/README.md index 7eff1f5..238cfac 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,11 @@ Use the following environment variables to configure the script: | `KEEPASS_BACKUP_FILE_NAME` | `BitwardenBackup_%date%` | | name of the KeePass database file; use `%date%` anywhere to insert path-friendly date+time string | | `KEEPASS_BACKUP_DATABASE_NAME` | _[same as filename]_ | | name of the KeePass database (when opened); can use `%date%` as well | | `ORGANIZATIONS_GROUP_NAME` | Organizations | | name of the KeePass group where organizations and its items should be stored | -| `ORGANIZATION_FOLDERS_NAME` | Folders | | name of the KeePass group where folders for an organization should be stored | -| `ORGANIZATION_COLLECTIONS_NAME` | Collections | | name of the KeePass group where collections for an organizations should be stored | +| `ORGANIZATION_MODE` | flat | | how entries are saved in an organization (flat/nested)\*\* | +| `ORGANIZATION_FOLDERS_NAME` | Folders | | name of the KeePass group where folders for an organization should be stored; only relevant when mode is nested | +| `ORGANIZATION_COLLECTIONS_NAME` | Collections | | name of the KeePass group where collections for an organizations should be stored; only relevant when mode is nested | \*: In most cases these environment variables are stored in plain text. That means they can easily be read. To make this _somewhat_ more secure and conceal them on first sight, your passwords have to be base64-encoded. To encode your password in base64 use some (online) tool of your choice or just open the developer tools console in any browser (usually via F12) and use the output of `btoa("your_password")`. +\*\*: In flat-mode, all organization entries are saved in the organization itself without creating keepass groups for folders/collections (the folder/collections information is instead saved to a field). Nested-mode creates keepass groups for folders/collections and creates duplicate entries to sort into these groups. The advantage of flat-mode is, that there are no duplicate entries, so entries are better found by searching. The advantage of nested-mode is, that it more closely resembles the structure of the items in vaultwarden, so entries are better found by browsing. Decide yourself, what's more important for your backup strategy. Depending how you use this script (preferably in your local network), you may access your self-hosted vaultwarden/bitwarden server with a self signed certificate. In this case just set the node environment variable which disables certificate checking: `NODE_TLS_REJECT_UNAUTHORIZED=0`. diff --git a/docker-compose.yml b/docker-compose.yml index 123b049..56f8f5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,5 +23,6 @@ services: - "KEEPASS_BACKUP_FILE_NAME=BitwardenBackup" - "KEEPASS_BACKUP_DATABASE_NAME=BitwardenBackup_%date%" - "ORGANIZATIONS_GROUP_NAME=Organizations" + - "ORGANIZATION_MODE=flat" - "ORGANIZATION_FOLDERS_NAME=Folders" - "ORGANIZATION_COLLECTIONS_NAME=Collections" diff --git a/src/executeBackup.ts b/src/executeBackup.ts index a5a81dc..613ea01 100644 --- a/src/executeBackup.ts +++ b/src/executeBackup.ts @@ -8,6 +8,7 @@ const DEFAULTS = { KEEPASS_BACKUP_PATH: './backup', KEEPASS_BACKUP_FILE_NAME: 'BitwardenBackup_%date%', ORGANIZATIONS_GROUP_NAME: 'Organizations', + ORGANIZATION_MODE: 'flat', ORGANIZATION_FOLDERS_NAME: 'Folders', ORGANIZATION_COLLECTIONS_NAME: 'Collections', } as const; @@ -63,6 +64,10 @@ export async function executeBackup() { const organizationsRootName = process.env['ORGANIZATIONS_GROUP_NAME'] || DEFAULTS.ORGANIZATIONS_GROUP_NAME; + const organizationMode = + (process.env['ORGANIZATION_MODE'] || DEFAULTS.ORGANIZATION_MODE).toLowerCase() === 'nested' + ? 'nested' + : 'flat'; const organizationsFoldersName = process.env['ORGANIZATION_FOLDERS_NAME'] || DEFAULTS.ORGANIZATION_FOLDERS_NAME; const organizationsCollectionsName = @@ -81,6 +86,7 @@ export async function executeBackup() { name: keepassDatabaseName, keyFile: backupKeyFile, password: backupPassword, + organizationMode, organizationFolderNames: { organizations: organizationsRootName, collections: organizationsCollectionsName, diff --git a/src/keepassWriter.ts b/src/keepassWriter.ts index b2ff348..b0ed8ec 100644 --- a/src/keepassWriter.ts +++ b/src/keepassWriter.ts @@ -13,6 +13,8 @@ import { } from './fieldMappingsUtil'; import { Credentials, Kdbx, ProtectedValue, type KdbxGroup } from './kdbxweb'; +type OrganizationMode = 'flat' | 'nested'; + type OrganizationFolderNames = { organizations: string; folders: string; @@ -23,17 +25,26 @@ type KeePassWriterArgs = { name: string; password?: string; keyFile?: Uint8Array; + organizationMode: OrganizationMode; organizationFolderNames: OrganizationFolderNames; }; export class KeePassWriter { private db: Kdbx; - private organizationFolderNames!: OrganizationFolderNames; + private organizationMode: OrganizationMode; + private organizationFolderNames: OrganizationFolderNames; private allGroupPaths!: string[]; private readonly groups: Record = {}; - constructor({ name, password, keyFile, organizationFolderNames }: KeePassWriterArgs) { + constructor({ + name, + password, + keyFile, + organizationMode, + organizationFolderNames, + }: KeePassWriterArgs) { const credentials = this.createKbdxCredentials(password, keyFile); + this.organizationMode = organizationMode; this.organizationFolderNames = organizationFolderNames; this.db = Kdbx.create(credentials, name); this.db.createDefaultGroup(); @@ -122,25 +133,29 @@ export class KeePassWriter { } else { groupPaths.add(this.organizationFolderNames.organizations); groupPaths.add(this.groupPathForOrganization(organizations[item.organizationId])); - groupPaths.add(this.groupPathForOrganization(organizations[item.organizationId], 'folder')); - groupPaths.add( - this.groupPathForOrganization( - organizations[item.organizationId], - 'folder', - item.folderId ? pathsById[item.folderId] : noFolderName, - ), - ); - for (const collectionId of item.collectionIds) { + if (this.organizationMode === 'nested') { groupPaths.add( - this.groupPathForOrganization(organizations[item.organizationId], 'collection'), + this.groupPathForOrganization(organizations[item.organizationId], 'folder'), ); groupPaths.add( this.groupPathForOrganization( organizations[item.organizationId], - 'collection', - pathsById[collectionId], + 'folder', + item.folderId ? pathsById[item.folderId] : noFolderName, ), ); + for (const collectionId of item.collectionIds) { + groupPaths.add( + this.groupPathForOrganization(organizations[item.organizationId], 'collection'), + ); + groupPaths.add( + this.groupPathForOrganization( + organizations[item.organizationId], + 'collection', + pathsById[collectionId], + ), + ); + } } } } @@ -163,25 +178,31 @@ export class KeePassWriter { item.folderId ? this.groups[pathsById[item.folderId]] : this.db.getDefaultGroup(), ); } else { - groupsOfItem.push( - this.groups[ - this.groupPathForOrganization( - organizations[item.organizationId], - 'folder', - item.folderId ? pathsById[item.folderId] : noFolderName, - ) - ], - ); - for (const collectionId of item.collectionIds) { + if (this.organizationMode === 'nested') { groupsOfItem.push( this.groups[ this.groupPathForOrganization( organizations[item.organizationId], - 'collection', - pathsById[collectionId], + 'folder', + item.folderId ? pathsById[item.folderId] : noFolderName, ) ], ); + for (const collectionId of item.collectionIds) { + groupsOfItem.push( + this.groups[ + this.groupPathForOrganization( + organizations[item.organizationId], + 'collection', + pathsById[collectionId], + ) + ], + ); + } + } else { + groupsOfItem.push( + this.groups[this.groupPathForOrganization(organizations[item.organizationId])], + ); } } @@ -240,6 +261,19 @@ export class KeePassWriter { this.addFields(entry, item.secureNote, item, fieldMappings.secureNote); } + + // folder/collection for flat organization mode + if (item.organizationId && this.organizationMode === 'flat') { + if (item.folderId) { + entry.fields.set('Folder', pathsById[item.folderId]); + } + for (const [index, collectionId] of item.collectionIds.entries()) { + entry.fields.set( + 'Collection' + (item.collectionIds.length === 1 ? '' : `_${index}`), + pathsById[collectionId], + ); + } + } } }