Skip to content

Commit f7eed67

Browse files
committed
Add Bitwarden Manager
Signed-off-by: Alberto Ferrer Sánchez <alberefe@gmail.com>
1 parent ea20833 commit f7eed67

File tree

9 files changed

+835
-99
lines changed

9 files changed

+835
-99
lines changed

README.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,121 @@ To spaw a new shell within the virtual environment use:
5959
$ poetry shell
6060
```
6161

62+
## Credential Manager Library
63+
64+
This is a module made to retrieve credentials from different secrets management systems like Bitwarden.
65+
It accesses the secrets management service, looks for the desired credential and returns it in String form.
66+
67+
To use the module in your python code
68+
69+
### Bitwarden
70+
71+
```
72+
from grimoirelab_toolkit.credential_manager import BitwardenManager
73+
74+
75+
# Instantiate the Bitwarden manager using the api credentials for login
76+
bw_manager = BitwardenManager("your_client_id", "your_client_secret", "your_master_password")
77+
78+
# Login
79+
bw_manager.login()
80+
81+
# Retrieve a secret from Bitwarden
82+
username = bw_manager.get_secret("github")
83+
password = bw_manager.get_secret("elasticsearch")
84+
85+
# Logout
86+
bw_manager.logout()
87+
```
88+
89+
90+
#### Response format
91+
92+
When calling `get_secret(item_name)`, the method returns a JSON object with the following structure:
93+
94+
_NOTE: the parameter "item_name" corresponds with the field "name" of the json. That's the name of the item._
95+
(in this case, GitHub)
96+
97+
98+
##### Example Response
99+
100+
```json
101+
{
102+
"passwordHistory": [
103+
{
104+
"lastUsedDate": "2024-11-05T10:27:18.411Z",
105+
"password": "previous_password_value_1"
106+
},
107+
{
108+
"lastUsedDate": "2024-11-05T09:20:06.512Z",
109+
"password": "previous_password_value_2"
110+
}
111+
],
112+
"revisionDate": "2025-05-11T14:40:19.456Z",
113+
"creationDate": "2024-10-30T18:56:41.023Z",
114+
"object": "item",
115+
"id": "91300380-620f-4707-8de1-b21901383315",
116+
"organizationId": null,
117+
"folderId": null,
118+
"type": 1,
119+
"reprompt": 0,
120+
"name": "GitHub",
121+
"notes": null,
122+
"favorite": false,
123+
"fields": [
124+
{
125+
"name": "api-token",
126+
"value": "TOKEN"
127+
"type": 0,
128+
"linkedId": null
129+
},
130+
{
131+
"name": "api_key",
132+
"value": "APIKEY",
133+
"type": 0,
134+
"linkedId": null
135+
}
136+
],
137+
"login": {
138+
"uris": [],
139+
"username": "your_username",
140+
"password": "your_password",
141+
"totp": null,
142+
"passwordRevisionDate": "2024-11-05T10:27:18.411Z"
143+
},
144+
"collectionIds": [],
145+
"attachments": []
146+
}
147+
```
148+
149+
Field Descriptions
150+
151+
- passwordHistory: Array of previously used passwords with timestamps
152+
- revisionDate: Last modification timestamp (ISO 8601 format)
153+
- creationDate: Item creation timestamp (ISO 8601 format)
154+
- object: Always "item" for credential items
155+
- id: Unique identifier for this item
156+
- organizationId: Organization ID if shared, null for personal items
157+
- folderId: Folder ID if organized, null otherwise
158+
- type: Item type (1 = login, 2 = secure note, 3 = card, 4 = identity)
159+
- name: Display name of the credential item (name used as argument in get_secret())
160+
- notes: Optional notes field
161+
- favorite: Boolean indicating if item is favorited
162+
- fields: Array of custom fields with name-value pairs
163+
- name: Field name
164+
- value: Field value (can contain secrets)
165+
- type: Field type (0 = text, 1 = hidden, 2 = boolean)
166+
- login: Login credentials object
167+
- username: Login username
168+
- password: Login password
169+
- totp: TOTP secret for 2FA (if configured)
170+
- uris: Array of associated URIs/URLs
171+
- passwordRevisionDate: Last password change timestamp
172+
- collectionIds: Array of collection IDs this item belongs to
173+
- attachments: Array of file attachments
174+
175+
The module uses the [Bitwarden CLI](https://bitwarden.com/help/cli/) to interact with Bitwarden.
176+
62177
## License
63178

64179
Licensed under GNU General Public License (GPL), version 3 or later.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) Grimoirelab Contributors
4+
#
5+
# This program is free software; you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation; either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU General Public License
16+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
#
18+
# Author:
19+
# Alberto Ferrer Sánchez (alberefe@gmail.com)
20+
#
21+
22+
from .bw_manager import BitwardenManager
23+
from .exceptions import (
24+
CredentialManagerError,
25+
InvalidCredentialsError,
26+
CredentialNotFoundError,
27+
BitwardenCLIError,
28+
)
29+
30+
__all__ = [
31+
"BitwardenManager",
32+
"CredentialManagerError",
33+
"InvalidCredentialsError",
34+
"CredentialNotFoundError",
35+
"BitwardenCLIError",
36+
]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .credential_manager import main
2+
3+
if __name__ == "__main__":
4+
main()
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
#
2+
#
3+
#
4+
# This program is free software; you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation; either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
#
17+
# Author:
18+
# Alberto Ferrer Sánchez (alberefe@gmail.com)
19+
#
20+
import json
21+
import subprocess
22+
import logging
23+
import shutil
24+
25+
from .exceptions import (
26+
BitwardenCLIError,
27+
InvalidCredentialsError,
28+
CredentialNotFoundError,
29+
)
30+
31+
logger = logging.getLogger(__name__)
32+
33+
34+
class BitwardenManager:
35+
"""Retrieve credentials from Bitwarden.
36+
37+
This class defines functions to log in, retrieve secrets
38+
and log out of Bitwarden using the Bitwarden CLI. The
39+
workflow is:
40+
41+
manager = BitwardenManager(client_id, client_secret, master_password)
42+
manager.login()
43+
manager.get_secret("github")
44+
manager.get_secret("elasticsearch")
45+
manager.logout()
46+
47+
The manager logs in using the client_id, client_secret, and
48+
master_password given as arguments when creating the instance,
49+
so the object is reusable along the program.
50+
51+
The path of Bitwarden CLI (bw) is retrieved using shutil.
52+
"""
53+
54+
def __init__(self, client_id: str, client_secret: str, master_password: str):
55+
"""
56+
Creates BitwardenManager object using API key authentication
57+
58+
:param str client_id: Bitwarden API client ID
59+
:param str client_secret: Bitwarden API client secret
60+
:param str master_password: Master password for unlocking the vault
61+
"""
62+
# Session key of the bw session
63+
self.session_key = None
64+
65+
# API credentials
66+
self.client_id = client_id
67+
self.client_secret = client_secret
68+
self.master_password = master_password
69+
70+
# Get the absolute path to the bw executable
71+
self.bw_path = shutil.which("bw")
72+
if not self.bw_path:
73+
raise BitwardenCLIError("Bitwarden CLI (bw) not found in PATH")
74+
75+
# Set up environment variables for consistent execution context
76+
self.env = {
77+
"LANG": "C",
78+
"BW_CLIENTID": client_id,
79+
"BW_CLIENTSECRET": client_secret,
80+
}
81+
82+
def login(self) -> str | None:
83+
"""Log into Bitwarden.
84+
85+
Use the API authentication key to log in and unlock the vault. After it,
86+
it will obtain a session key that will be used by to access the vault.
87+
88+
:returns: The session key for the current Bitwarden session.
89+
90+
:raises InvalidCredentialsError: If invalid credentials are provided
91+
:raises BitwardenCLIError: If Bitwarden CLI operations fail
92+
"""
93+
# Log in using API key
94+
login_result = subprocess.run(
95+
[self.bw_path, "login", "--apikey"],
96+
input=f"{self.client_id}\n{self.client_secret}\n",
97+
capture_output=True,
98+
text=True,
99+
env=self.env,
100+
)
101+
102+
if login_result.returncode != 0:
103+
error_msg = (
104+
login_result.stderr.strip() if login_result.stderr else "Unknown error"
105+
)
106+
logger.error("Error logging in with API key: %s", error_msg)
107+
raise InvalidCredentialsError(
108+
"Invalid API credentials provided for Bitwarden"
109+
)
110+
111+
# After login, we need to unlock the vault to get a session key
112+
self.session_key = self._unlock_vault()
113+
114+
return self.session_key
115+
116+
def _unlock_vault(self) -> str:
117+
"""Unlock the vault after authentication.
118+
119+
Executes the Bitwarden unlock command to obtain a session key
120+
for an already authenticated user but locked vault.
121+
122+
:returns: Session key for the unlocked vault
123+
:raises BitwardenCLIError: If unlock operation fails or returns empty session key
124+
"""
125+
# this uses the master password to unlock the vault
126+
unlock_result = subprocess.run(
127+
[self.bw_path, "unlock", "--raw"],
128+
input=f"{self.master_password}\n",
129+
capture_output=True,
130+
text=True,
131+
env=self.env,
132+
)
133+
134+
if unlock_result.returncode != 0:
135+
error_msg = (
136+
unlock_result.stderr.strip()
137+
if unlock_result.stderr
138+
else "Unknown error"
139+
)
140+
logger.error("Error unlocking vault: %s", error_msg)
141+
raise BitwardenCLIError(f"Failed to unlock vault: {error_msg}")
142+
143+
# the session key is used when retrieving the secrets with get_secret
144+
session_key = unlock_result.stdout.strip()
145+
if not session_key:
146+
raise BitwardenCLIError("Empty session key received from unlock command")
147+
148+
return session_key
149+
150+
def get_secret(self, item_name: str) -> dict:
151+
"""Retrieve an item from the Bitwarden vault.
152+
153+
Retrieves all the fields stored for an item with the name
154+
provided as an argument and returns them as a dictionary.
155+
156+
The returned dictionary includes fields such as:
157+
- login: username, password, URIs, TOTP
158+
- fields: custom fields
159+
- notes: secure notes
160+
- name, id, and other metadata
161+
162+
:param str item_name: The name of the item to retrieve
163+
164+
:returns: Dictionary containing the item data
165+
:rtype: dict
166+
167+
:raises CredentialNotFoundError: If the specific credential is not found
168+
:raises BitwardenCLIError: If Bitwarden CLI operations fail
169+
"""
170+
# Pass session key via command line parameter
171+
result = subprocess.run(
172+
[self.bw_path, "get", "item", item_name, "--session", self.session_key],
173+
capture_output=True,
174+
text=True,
175+
env=self.env,
176+
)
177+
178+
if result.returncode != 0:
179+
raise CredentialNotFoundError(f"Credential not found: '{item_name}'")
180+
181+
# Parse the JSON response returned in stdout
182+
try:
183+
item = json.loads(result.stdout)
184+
except json.JSONDecodeError as e:
185+
logger.error("Failed to parse Bitwarden response: %s", str(e))
186+
raise BitwardenCLIError(f"Invalid JSON response from Bitwarden: {e}")
187+
188+
return item
189+
190+
def logout(self) -> None:
191+
"""Log out from Bitwarden and invalidate the session.
192+
193+
This method ends the current session and clears the session key.
194+
"""
195+
logger.info("Logging out from Bitwarden")
196+
197+
# Execute logout command
198+
result = subprocess.run(
199+
[self.bw_path, "logout"],
200+
capture_output=True,
201+
text=True,
202+
env=self.env,
203+
)
204+
205+
if result.returncode != 0:
206+
error_msg = result.stderr.strip() if result.stderr else "Unknown error"
207+
logger.error("Error during logout: %s", error_msg)
208+
209+
# Clear session key for security
210+
self.session_key = None
211+
212+
logger.info("Successfully logged out from Bitwarden")

0 commit comments

Comments
 (0)