kp2bw 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kp2bw-2.0.0/PKG-INFO +127 -0
- kp2bw-2.0.0/README.md +105 -0
- kp2bw-2.0.0/pyproject.toml +64 -0
- kp2bw-2.0.0/src/kp2bw/__init__.py +3 -0
- kp2bw-2.0.0/src/kp2bw/__main__.py +3 -0
- kp2bw-2.0.0/src/kp2bw/bitwardenclient.py +247 -0
- kp2bw-2.0.0/src/kp2bw/cli.py +312 -0
- kp2bw-2.0.0/src/kp2bw/convert.py +542 -0
- kp2bw-2.0.0/src/kp2bw/exceptions.py +6 -0
- kp2bw-2.0.0/src/kp2bw/py.typed +0 -0
kp2bw-2.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: kp2bw
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Imports and existing KeePass db with REF fields into Bitwarden
|
|
5
|
+
Keywords: bitwarden,cli,keepass,migration,password-manager
|
|
6
|
+
Author: Kaj Kowalski
|
|
7
|
+
Author-email: Kaj Kowalski <info@kajkowalski.nl>
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Dist: pykeepass>=4.1.1.post1
|
|
17
|
+
Requires-Python: >=3.14
|
|
18
|
+
Project-URL: Changelog, https://github.com/kjanat/kp2bw/blob/master/CHANGELOG.md
|
|
19
|
+
Project-URL: Issues, https://github.com/kjanat/kp2bw/issues
|
|
20
|
+
Project-URL: Repository, https://github.com/kjanat/kp2bw
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# KP2BW - KeePass 2.x to Bitwarden Converter
|
|
24
|
+
|
|
25
|
+
> Fork of [jampe/kp2bw], modernized.
|
|
26
|
+
|
|
27
|
+
Migrates KeePass databases to Bitwarden via the `bw` CLI, with advantages over
|
|
28
|
+
the built-in Bitwarden importer:
|
|
29
|
+
|
|
30
|
+
- **Encrypted in-memory transfer** -- data never hits disk unencrypted (except
|
|
31
|
+
attachments, which are cleaned up after upload)
|
|
32
|
+
- **KeePass REF resolution** -- username/password references are resolved:
|
|
33
|
+
matching credentials merge URLs into one entry; differing ones create new
|
|
34
|
+
entries
|
|
35
|
+
- **Passkey migration** -- KeePassXC FIDO2/passkey credentials
|
|
36
|
+
(`KPEX_PASSKEY_*`) are converted to Bitwarden `fido2Credentials`
|
|
37
|
+
- **Custom properties & attachments** -- imported as Bitwarden custom fields or
|
|
38
|
+
attachments (values > 10k chars auto-upload as files)
|
|
39
|
+
- **Long notes handling** -- notes exceeding 10k chars are uploaded as
|
|
40
|
+
`notes.txt` attachments
|
|
41
|
+
- **Idempotent** -- safe to run multiple times without duplicating entries
|
|
42
|
+
- **Nested folders** -- KeePass folder hierarchy is recreated in Bitwarden
|
|
43
|
+
- **Recycle Bin filtering** -- deleted entries are automatically excluded
|
|
44
|
+
- **Expiry awareness** -- expired entries are marked `[EXPIRED]` in notes;
|
|
45
|
+
optionally skip them entirely with `--skip-expired`
|
|
46
|
+
- **Metadata preservation** -- KeePass tags, expiry dates, and created/modified
|
|
47
|
+
timestamps are stored as Bitwarden custom fields
|
|
48
|
+
- **Tag filtering** -- import only entries matching specific tags
|
|
49
|
+
- **Organization & collection support** -- upload into a Bitwarden organization
|
|
50
|
+
with automatic or manual collection assignment
|
|
51
|
+
- **Full UTF-8 & cross-platform** -- works on Windows, macOS, and Linux
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# install with:
|
|
57
|
+
uv tool install kp2bw
|
|
58
|
+
kp2bw passwords.kdbx
|
|
59
|
+
|
|
60
|
+
# or run directly without installing:
|
|
61
|
+
uvx kp2bw
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
or from a GitHub URL:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# install with:
|
|
68
|
+
uv tool install git+https://github.com/kjanat/kp2bw
|
|
69
|
+
kp2bw passwords.kdbx
|
|
70
|
+
|
|
71
|
+
# run directly without installing:
|
|
72
|
+
uvx --from git+https://github.com/kjanat/kp2bw kp2bw passwords.kdbx
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Prerequisites
|
|
76
|
+
|
|
77
|
+
Install the [Bitwarden CLI] and log in once before using `kp2bw`:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# optional: point to a self-hosted instance
|
|
81
|
+
bw config server https://your-domain.com/
|
|
82
|
+
|
|
83
|
+
# log in (only needed once; kp2bw uses `bw unlock` afterwards)
|
|
84
|
+
bw login <user>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Usage
|
|
88
|
+
|
|
89
|
+
```console
|
|
90
|
+
kp2bw [-h] [-k KEEPASS_PASSWORD] [-K KEEPASS_KEYFILE] [-b BITWARDEN_PASSWORD]
|
|
91
|
+
[-o BITWARDEN_ORG] [-c BITWARDEN_COLLECTION] [-t TAG [TAG ...]]
|
|
92
|
+
[--path-to-name | --no-path-to-name] [--path-to-name-skip N]
|
|
93
|
+
[--skip-expired | --no-skip-expired]
|
|
94
|
+
[--include-recycle-bin | --no-include-recycle-bin]
|
|
95
|
+
[--metadata | --no-metadata] [-y] [-v] [-V | --version] keepass_file
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
| Flag | Description | Env var |
|
|
99
|
+
| -------------------------------------- | -------------------------------------------------------------- | ------------------------------------- |
|
|
100
|
+
| `keepass_file` | Path to your KeePass 2.x database | - |
|
|
101
|
+
| `-k, --keepass-password` | KeePass password (prompted if omitted) | `KP2BW_KEEPASS_PASSWORD` |
|
|
102
|
+
| `-K, --keepass-keyfile` | KeePass key file | `KP2BW_KEEPASS_KEYFILE` |
|
|
103
|
+
| `-b, --bitwarden-password` | Bitwarden password (prompted if omitted) | `KP2BW_BITWARDEN_PASSWORD` |
|
|
104
|
+
| `-o, --bitwarden-org` | Bitwarden Organization ID | `KP2BW_BITWARDEN_ORG` |
|
|
105
|
+
| `-c, --bitwarden-collection` | Collection ID, or `auto` to derive from top-level folder names | `KP2BW_BITWARDEN_COLLECTION` |
|
|
106
|
+
| `-t, --import-tags` | Only import entries with these tags | `KP2BW_IMPORT_TAGS` (comma-separated) |
|
|
107
|
+
| `--path-to-name` / `--no-path-to-name` | Prepend folder path to entry names (default: off) | `KP2BW_PATH_TO_NAME` |
|
|
108
|
+
| `--path-to-name-skip` | Skip first N folders in path prefix (default: 1) | `KP2BW_PATH_TO_NAME_SKIP` |
|
|
109
|
+
| `--skip-expired` | Skip entries that have expired in KeePass | `KP2BW_SKIP_EXPIRED` |
|
|
110
|
+
| `--include-recycle-bin` | Include Recycle Bin entries (excluded by default) | `KP2BW_INCLUDE_RECYCLE_BIN` |
|
|
111
|
+
| `--metadata` / `--no-metadata` | Toggle KeePass metadata as custom fields (default: on) | `KP2BW_MIGRATE_METADATA` |
|
|
112
|
+
| `-y, --yes` | Skip the Bitwarden CLI setup confirmation prompt | `KP2BW_YES` |
|
|
113
|
+
| `-v, --verbose` | Verbose output | `KP2BW_VERBOSE` |
|
|
114
|
+
| `-V, --version` | Print the installed `kp2bw` version and exit | - |
|
|
115
|
+
|
|
116
|
+
Configuration precedence is always: CLI flag > environment variable > built-in default.
|
|
117
|
+
|
|
118
|
+
## Troubleshooting
|
|
119
|
+
|
|
120
|
+
### "Invalid master password" on `bw unlock`
|
|
121
|
+
|
|
122
|
+
If your password contains special shell characters (`?`, `>`, `&`, etc.), wrap
|
|
123
|
+
it in double quotes when prompted. See jampe/kp2bw#10 and
|
|
124
|
+
libkeepass/pykeepass#254 for details.
|
|
125
|
+
|
|
126
|
+
[jampe/kp2bw]: https://github.com/jampe/kp2bw
|
|
127
|
+
[Bitwarden CLI]: https://bitwarden.com/help/cli/
|
kp2bw-2.0.0/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# KP2BW - KeePass 2.x to Bitwarden Converter
|
|
2
|
+
|
|
3
|
+
> Fork of [jampe/kp2bw], modernized.
|
|
4
|
+
|
|
5
|
+
Migrates KeePass databases to Bitwarden via the `bw` CLI, with advantages over
|
|
6
|
+
the built-in Bitwarden importer:
|
|
7
|
+
|
|
8
|
+
- **Encrypted in-memory transfer** -- data never hits disk unencrypted (except
|
|
9
|
+
attachments, which are cleaned up after upload)
|
|
10
|
+
- **KeePass REF resolution** -- username/password references are resolved:
|
|
11
|
+
matching credentials merge URLs into one entry; differing ones create new
|
|
12
|
+
entries
|
|
13
|
+
- **Passkey migration** -- KeePassXC FIDO2/passkey credentials
|
|
14
|
+
(`KPEX_PASSKEY_*`) are converted to Bitwarden `fido2Credentials`
|
|
15
|
+
- **Custom properties & attachments** -- imported as Bitwarden custom fields or
|
|
16
|
+
attachments (values > 10k chars auto-upload as files)
|
|
17
|
+
- **Long notes handling** -- notes exceeding 10k chars are uploaded as
|
|
18
|
+
`notes.txt` attachments
|
|
19
|
+
- **Idempotent** -- safe to run multiple times without duplicating entries
|
|
20
|
+
- **Nested folders** -- KeePass folder hierarchy is recreated in Bitwarden
|
|
21
|
+
- **Recycle Bin filtering** -- deleted entries are automatically excluded
|
|
22
|
+
- **Expiry awareness** -- expired entries are marked `[EXPIRED]` in notes;
|
|
23
|
+
optionally skip them entirely with `--skip-expired`
|
|
24
|
+
- **Metadata preservation** -- KeePass tags, expiry dates, and created/modified
|
|
25
|
+
timestamps are stored as Bitwarden custom fields
|
|
26
|
+
- **Tag filtering** -- import only entries matching specific tags
|
|
27
|
+
- **Organization & collection support** -- upload into a Bitwarden organization
|
|
28
|
+
with automatic or manual collection assignment
|
|
29
|
+
- **Full UTF-8 & cross-platform** -- works on Windows, macOS, and Linux
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# install with:
|
|
35
|
+
uv tool install kp2bw
|
|
36
|
+
kp2bw passwords.kdbx
|
|
37
|
+
|
|
38
|
+
# or run directly without installing:
|
|
39
|
+
uvx kp2bw
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
or from a GitHub URL:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# install with:
|
|
46
|
+
uv tool install git+https://github.com/kjanat/kp2bw
|
|
47
|
+
kp2bw passwords.kdbx
|
|
48
|
+
|
|
49
|
+
# run directly without installing:
|
|
50
|
+
uvx --from git+https://github.com/kjanat/kp2bw kp2bw passwords.kdbx
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Prerequisites
|
|
54
|
+
|
|
55
|
+
Install the [Bitwarden CLI] and log in once before using `kp2bw`:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# optional: point to a self-hosted instance
|
|
59
|
+
bw config server https://your-domain.com/
|
|
60
|
+
|
|
61
|
+
# log in (only needed once; kp2bw uses `bw unlock` afterwards)
|
|
62
|
+
bw login <user>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
```console
|
|
68
|
+
kp2bw [-h] [-k KEEPASS_PASSWORD] [-K KEEPASS_KEYFILE] [-b BITWARDEN_PASSWORD]
|
|
69
|
+
[-o BITWARDEN_ORG] [-c BITWARDEN_COLLECTION] [-t TAG [TAG ...]]
|
|
70
|
+
[--path-to-name | --no-path-to-name] [--path-to-name-skip N]
|
|
71
|
+
[--skip-expired | --no-skip-expired]
|
|
72
|
+
[--include-recycle-bin | --no-include-recycle-bin]
|
|
73
|
+
[--metadata | --no-metadata] [-y] [-v] [-V | --version] keepass_file
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Flag | Description | Env var |
|
|
77
|
+
| -------------------------------------- | -------------------------------------------------------------- | ------------------------------------- |
|
|
78
|
+
| `keepass_file` | Path to your KeePass 2.x database | - |
|
|
79
|
+
| `-k, --keepass-password` | KeePass password (prompted if omitted) | `KP2BW_KEEPASS_PASSWORD` |
|
|
80
|
+
| `-K, --keepass-keyfile` | KeePass key file | `KP2BW_KEEPASS_KEYFILE` |
|
|
81
|
+
| `-b, --bitwarden-password` | Bitwarden password (prompted if omitted) | `KP2BW_BITWARDEN_PASSWORD` |
|
|
82
|
+
| `-o, --bitwarden-org` | Bitwarden Organization ID | `KP2BW_BITWARDEN_ORG` |
|
|
83
|
+
| `-c, --bitwarden-collection` | Collection ID, or `auto` to derive from top-level folder names | `KP2BW_BITWARDEN_COLLECTION` |
|
|
84
|
+
| `-t, --import-tags` | Only import entries with these tags | `KP2BW_IMPORT_TAGS` (comma-separated) |
|
|
85
|
+
| `--path-to-name` / `--no-path-to-name` | Prepend folder path to entry names (default: off) | `KP2BW_PATH_TO_NAME` |
|
|
86
|
+
| `--path-to-name-skip` | Skip first N folders in path prefix (default: 1) | `KP2BW_PATH_TO_NAME_SKIP` |
|
|
87
|
+
| `--skip-expired` | Skip entries that have expired in KeePass | `KP2BW_SKIP_EXPIRED` |
|
|
88
|
+
| `--include-recycle-bin` | Include Recycle Bin entries (excluded by default) | `KP2BW_INCLUDE_RECYCLE_BIN` |
|
|
89
|
+
| `--metadata` / `--no-metadata` | Toggle KeePass metadata as custom fields (default: on) | `KP2BW_MIGRATE_METADATA` |
|
|
90
|
+
| `-y, --yes` | Skip the Bitwarden CLI setup confirmation prompt | `KP2BW_YES` |
|
|
91
|
+
| `-v, --verbose` | Verbose output | `KP2BW_VERBOSE` |
|
|
92
|
+
| `-V, --version` | Print the installed `kp2bw` version and exit | - |
|
|
93
|
+
|
|
94
|
+
Configuration precedence is always: CLI flag > environment variable > built-in default.
|
|
95
|
+
|
|
96
|
+
## Troubleshooting
|
|
97
|
+
|
|
98
|
+
### "Invalid master password" on `bw unlock`
|
|
99
|
+
|
|
100
|
+
If your password contains special shell characters (`?`, `>`, `&`, etc.), wrap
|
|
101
|
+
it in double quotes when prompted. See jampe/kp2bw#10 and
|
|
102
|
+
libkeepass/pykeepass#254 for details.
|
|
103
|
+
|
|
104
|
+
[jampe/kp2bw]: https://github.com/jampe/kp2bw
|
|
105
|
+
[Bitwarden CLI]: https://bitwarden.com/help/cli/
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "kp2bw"
|
|
3
|
+
version = "2.0.0"
|
|
4
|
+
description = "Imports and existing KeePass db with REF fields into Bitwarden"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
keywords = ["bitwarden", "cli", "keepass", "migration", "password-manager"]
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Environment :: Console",
|
|
10
|
+
"Operating System :: OS Independent",
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
13
|
+
"Programming Language :: Python :: 3.14",
|
|
14
|
+
"Topic :: Security",
|
|
15
|
+
"Topic :: Utilities",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
dependencies = ["pykeepass>=4.1.1.post1"]
|
|
19
|
+
|
|
20
|
+
[[project.authors]]
|
|
21
|
+
name = "Kaj Kowalski"
|
|
22
|
+
email = "info@kajkowalski.nl"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Changelog = "https://github.com/kjanat/kp2bw/blob/master/CHANGELOG.md"
|
|
26
|
+
Issues = "https://github.com/kjanat/kp2bw/issues"
|
|
27
|
+
Repository = "https://github.com/kjanat/kp2bw"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
kp2bw = "kp2bw.cli:main"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = [
|
|
34
|
+
{ include-group = "lint" },
|
|
35
|
+
{ include-group = "stubs" },
|
|
36
|
+
]
|
|
37
|
+
lint = [
|
|
38
|
+
"basedpyright>=1.38.1",
|
|
39
|
+
"ruff>=0.15.2",
|
|
40
|
+
"tombi>=0.7.32",
|
|
41
|
+
"ty>=0.0.18",
|
|
42
|
+
]
|
|
43
|
+
stubs = ["pykeepass-stubs"]
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["uv_build>=0.10.4,<0.11.0"]
|
|
47
|
+
build-backend = "uv_build"
|
|
48
|
+
|
|
49
|
+
[tool.pyright]
|
|
50
|
+
venvPath = "."
|
|
51
|
+
venv = ".venv"
|
|
52
|
+
typeCheckingMode = "strict"
|
|
53
|
+
pythonVersion = "3.14"
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
preview = true
|
|
57
|
+
target-version = "py314"
|
|
58
|
+
|
|
59
|
+
[tool.uv.sources]
|
|
60
|
+
pykeepass-stubs = { workspace = true }
|
|
61
|
+
|
|
62
|
+
[tool.uv.workspace]
|
|
63
|
+
members = ["packages/*"]
|
|
64
|
+
exclude = []
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import shutil
|
|
7
|
+
from itertools import groupby
|
|
8
|
+
from subprocess import STDOUT, CalledProcessError, check_output
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pykeepass import Attachment
|
|
12
|
+
|
|
13
|
+
from .exceptions import BitwardenClientError
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BitwardenClient:
|
|
19
|
+
TEMPORARY_ATTACHMENT_FOLDER: str = "attachment-temp"
|
|
20
|
+
|
|
21
|
+
_orgId: str | None
|
|
22
|
+
_key: str
|
|
23
|
+
_folders: dict[str, str]
|
|
24
|
+
_folder_entries: dict[str | None, list[str]]
|
|
25
|
+
_colls: dict[str, str] | None
|
|
26
|
+
|
|
27
|
+
def __init__(self, password: str, org_id: str | None) -> None:
|
|
28
|
+
# check for bw cli installation
|
|
29
|
+
if "bitwarden" not in self._exec("bw"):
|
|
30
|
+
raise BitwardenClientError(
|
|
31
|
+
"Bitwarden Cli not installed! See https://help.bitwarden.com/article/cli/#download--install for help"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# save org
|
|
35
|
+
self._orgId = org_id
|
|
36
|
+
|
|
37
|
+
# login
|
|
38
|
+
self._key = self._exec(f'bw unlock "{password}" --raw')
|
|
39
|
+
if "error" in self._key:
|
|
40
|
+
raise BitwardenClientError(
|
|
41
|
+
"Could not unlock the Bitwarden db. Is the Master Password correct and are bw cli tools set up correctly?"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# make sure data is up to date
|
|
45
|
+
if "Syncing complete." not in self._exec_with_session("bw sync"):
|
|
46
|
+
raise BitwardenClientError(
|
|
47
|
+
"Could not sync the local state to your Bitwarden server"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# get folder list
|
|
51
|
+
self._folders = {
|
|
52
|
+
folder["name"]: folder["id"]
|
|
53
|
+
for folder in json.loads(self._exec_with_session("bw list folders"))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# get existing entries
|
|
57
|
+
self._folder_entries = self._get_existing_folder_entries()
|
|
58
|
+
|
|
59
|
+
# get existing collections
|
|
60
|
+
if org_id:
|
|
61
|
+
self._colls = {
|
|
62
|
+
coll["name"]: coll["id"]
|
|
63
|
+
for coll in json.loads(
|
|
64
|
+
self._exec_with_session(
|
|
65
|
+
f"bw list org-collections --organizationid {org_id}"
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
else:
|
|
70
|
+
self._colls = None
|
|
71
|
+
|
|
72
|
+
def __del__(self) -> None:
|
|
73
|
+
# cleanup temp directory
|
|
74
|
+
self._remove_temporary_attachment_folder()
|
|
75
|
+
|
|
76
|
+
def _create_temporary_attachment_folder(self) -> None:
|
|
77
|
+
if not os.path.isdir(self.TEMPORARY_ATTACHMENT_FOLDER):
|
|
78
|
+
os.mkdir(self.TEMPORARY_ATTACHMENT_FOLDER)
|
|
79
|
+
|
|
80
|
+
def _remove_temporary_attachment_folder(self) -> None:
|
|
81
|
+
if os.path.isdir(self.TEMPORARY_ATTACHMENT_FOLDER):
|
|
82
|
+
shutil.rmtree(self.TEMPORARY_ATTACHMENT_FOLDER)
|
|
83
|
+
|
|
84
|
+
def _exec(self, command: str) -> str:
|
|
85
|
+
output: bytes
|
|
86
|
+
try:
|
|
87
|
+
logger.debug("-- Executing Bitwarden CLI command")
|
|
88
|
+
output = check_output(command, stderr=STDOUT, shell=True)
|
|
89
|
+
except CalledProcessError as e:
|
|
90
|
+
logger.debug(
|
|
91
|
+
f" |- Bitwarden CLI command failed with exit code {e.returncode}"
|
|
92
|
+
)
|
|
93
|
+
if isinstance(e.output, bytes):
|
|
94
|
+
output = e.output
|
|
95
|
+
else:
|
|
96
|
+
output = str(e.output).encode("utf-8", "ignore")
|
|
97
|
+
|
|
98
|
+
logger.debug(f" |- Received {len(output)} bytes from Bitwarden CLI")
|
|
99
|
+
return output.decode("utf-8", "ignore")
|
|
100
|
+
|
|
101
|
+
def _get_existing_folder_entries(self) -> dict[str | None, list[str]]:
|
|
102
|
+
folder_id_lookup_helper: dict[str, str] = {
|
|
103
|
+
folder_id: folder_name for folder_name, folder_id in self._folders.items()
|
|
104
|
+
}
|
|
105
|
+
items: list[dict[str, Any]] = json.loads(
|
|
106
|
+
self._exec_with_session("bw list items")
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# fix None folderIds for entries without folders
|
|
110
|
+
for item in items:
|
|
111
|
+
if not item["folderId"]:
|
|
112
|
+
item["folderId"] = ""
|
|
113
|
+
|
|
114
|
+
items.sort(key=lambda item: item["folderId"])
|
|
115
|
+
return {
|
|
116
|
+
folder_id_lookup_helper.get(folder_id): [entry["name"] for entry in entries]
|
|
117
|
+
for folder_id, entries in groupby(items, key=lambda item: item["folderId"])
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def _exec_with_session(self, command: str) -> str:
|
|
121
|
+
return self._exec(f"{command} --session '{self._key}'")
|
|
122
|
+
|
|
123
|
+
def has_folder(self, folder: str | None) -> bool:
|
|
124
|
+
return folder in self._folders
|
|
125
|
+
|
|
126
|
+
def _get_platform_dependent_echo_str(self, string: str) -> str:
|
|
127
|
+
if platform.system() == "Windows":
|
|
128
|
+
return f"echo {string}"
|
|
129
|
+
else:
|
|
130
|
+
return f"echo '{string}'"
|
|
131
|
+
|
|
132
|
+
def create_folder(self, folder: str | None) -> None:
|
|
133
|
+
if not folder or self.has_folder(folder):
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
data: dict[str, str] = {"name": folder}
|
|
137
|
+
data_b64 = base64.b64encode(json.dumps(data).encode("UTF-8")).decode("UTF-8")
|
|
138
|
+
|
|
139
|
+
output = self._exec_with_session(
|
|
140
|
+
f"{self._get_platform_dependent_echo_str(data_b64)} | bw create folder"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
output_obj: dict[str, str] = json.loads(output)
|
|
144
|
+
|
|
145
|
+
self._folders[output_obj["name"]] = output_obj["id"]
|
|
146
|
+
|
|
147
|
+
def create_entry(self, folder: str | None, entry: dict[str, Any]) -> str:
|
|
148
|
+
# check if already exists
|
|
149
|
+
if (
|
|
150
|
+
folder in self._folder_entries
|
|
151
|
+
and entry["name"] in self._folder_entries[folder]
|
|
152
|
+
):
|
|
153
|
+
logger.info(
|
|
154
|
+
f"-- Entry {entry['name']} already exists in folder {folder}. skipping..."
|
|
155
|
+
)
|
|
156
|
+
return "skip"
|
|
157
|
+
|
|
158
|
+
# create folder if exists
|
|
159
|
+
if folder:
|
|
160
|
+
self.create_folder(folder)
|
|
161
|
+
|
|
162
|
+
# set id
|
|
163
|
+
entry["folderId"] = self._folders[folder]
|
|
164
|
+
|
|
165
|
+
json_str = json.dumps(entry)
|
|
166
|
+
|
|
167
|
+
# convert string to base64
|
|
168
|
+
json_b64 = base64.b64encode(json_str.encode("UTF-8")).decode("UTF-8")
|
|
169
|
+
|
|
170
|
+
return self._exec_with_session(
|
|
171
|
+
f"{self._get_platform_dependent_echo_str(json_b64)} | bw create item"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def create_attachment(
|
|
175
|
+
self, item_id: str, attachment: tuple[str, str] | Attachment
|
|
176
|
+
) -> str:
|
|
177
|
+
# store attachment on disk
|
|
178
|
+
filename: str
|
|
179
|
+
data: bytes
|
|
180
|
+
if not isinstance(attachment, Attachment):
|
|
181
|
+
# long custom property — tuple[str, str]
|
|
182
|
+
filename = attachment[0] + ".txt"
|
|
183
|
+
data = attachment[1].encode("UTF-8")
|
|
184
|
+
else:
|
|
185
|
+
# real kp attachment
|
|
186
|
+
if attachment.filename is None:
|
|
187
|
+
logger.warning("Attachment has no filename, using fallback")
|
|
188
|
+
filename = "attachment"
|
|
189
|
+
else:
|
|
190
|
+
filename = attachment.filename
|
|
191
|
+
data = attachment.data
|
|
192
|
+
|
|
193
|
+
# make sure temporary attachment folder exists
|
|
194
|
+
self._create_temporary_attachment_folder()
|
|
195
|
+
|
|
196
|
+
path_to_file_on_disk = os.path.join(self.TEMPORARY_ATTACHMENT_FOLDER, filename)
|
|
197
|
+
with open(path_to_file_on_disk, "wb") as f:
|
|
198
|
+
_ = f.write(data)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
output = self._exec_with_session(
|
|
202
|
+
f'bw create attachment --file "{path_to_file_on_disk}" --itemid {item_id}'
|
|
203
|
+
)
|
|
204
|
+
finally:
|
|
205
|
+
os.remove(path_to_file_on_disk)
|
|
206
|
+
|
|
207
|
+
return output
|
|
208
|
+
|
|
209
|
+
def create_org_get_collection(self, collectionname: str | None) -> str | None:
|
|
210
|
+
if not collectionname:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
if self._colls is None:
|
|
214
|
+
self._colls = {}
|
|
215
|
+
|
|
216
|
+
# check for existing
|
|
217
|
+
if self._colls.get(collectionname):
|
|
218
|
+
return self._colls.get(collectionname)
|
|
219
|
+
|
|
220
|
+
# get template
|
|
221
|
+
entry: dict[str, Any] = json.loads(
|
|
222
|
+
self._exec_with_session("bw get template org-collection")
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# set org and Name
|
|
226
|
+
entry["name"] = collectionname
|
|
227
|
+
entry["organizationId"] = self._orgId
|
|
228
|
+
|
|
229
|
+
json_str = json.dumps(entry)
|
|
230
|
+
|
|
231
|
+
# convert string to base64
|
|
232
|
+
json_b64 = base64.b64encode(json_str.encode("UTF-8")).decode("UTF-8")
|
|
233
|
+
|
|
234
|
+
output = self._exec_with_session(
|
|
235
|
+
f"{self._get_platform_dependent_echo_str(json_b64)} | bw create org-collection --organizationid {self._orgId}"
|
|
236
|
+
)
|
|
237
|
+
if not output:
|
|
238
|
+
return None
|
|
239
|
+
data: dict[str, Any] = json.loads(output)
|
|
240
|
+
if not data["id"]:
|
|
241
|
+
return None
|
|
242
|
+
new_coll_id: str = data["id"]
|
|
243
|
+
|
|
244
|
+
# store in cache
|
|
245
|
+
self._colls[collectionname] = new_coll_id
|
|
246
|
+
|
|
247
|
+
return new_coll_id
|