Skip to content

Commit ddcaacf

Browse files
committed
Add Bitwarden Manager
Signed-off-by: Alberto Ferrer Sánchez <[email protected]>
1 parent 2c0d34b commit ddcaacf

File tree

10 files changed

+1042
-143
lines changed

10 files changed

+1042
-143
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")
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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 ([email protected])
19+
#
20+
21+
from .bw_manager import BitwardenManager
22+
from .exceptions import (
23+
CredentialManagerError,
24+
AuthenticationError,
25+
InvalidCredentialsError,
26+
SessionNotFoundError,
27+
ConnectionError,
28+
SecretRetrievalError,
29+
SecretNotFoundError,
30+
CredentialNotFoundError,
31+
InvalidSecretFormatError,
32+
BitwardenCLIError,
33+
)
34+
35+
__all__ = [
36+
"BitwardenManager",
37+
"CredentialManagerError",
38+
"AuthenticationError",
39+
"InvalidCredentialsError",
40+
"SessionNotFoundError",
41+
"ConnectionError",
42+
"SecretRetrievalError",
43+
"SecretNotFoundError",
44+
"CredentialNotFoundError",
45+
"InvalidSecretFormatError",
46+
"BitwardenCLIError",
47+
]
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: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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 ([email protected])
19+
#
20+
21+
import subprocess
22+
import logging
23+
import shutil
24+
from typing import Any
25+
26+
from .exceptions import (
27+
BitwardenCLIError,
28+
InvalidCredentialsError,
29+
CredentialNotFoundError,
30+
)
31+
32+
logger = logging.getLogger(__name__)
33+
34+
35+
class BitwardenManager:
36+
"""Retrieve credentials from Bitwarden.
37+
38+
This class defines functions to log in, retrieve secrets
39+
and log out of Bitwarden using the Bitwarden CLI. The
40+
workflow is:
41+
42+
manager = BitwardenManager(client_id, client_secret)
43+
manager.login()
44+
manager.get_secret("github")
45+
manager.get_secret("elasticsearch")
46+
manager.logout()
47+
48+
The manager logs in using the client_id and client_secret
49+
given as arguments when creating the instance, so the object
50+
is reusable along the program.
51+
52+
The path of Bitwarden CLI (bw) is retrieved using shutil.
53+
"""
54+
55+
def __init__(self, client_id: str, client_secret: str):
56+
"""
57+
Creates BitwardenManager object using API key authentication
58+
"""
59+
# Session key of the bw session
60+
self.session_key = None
61+
62+
# API credentials
63+
self.client_id = client_id
64+
self.client_secret = client_secret
65+
66+
# Get the absolute path to the bw executable
67+
self.bw_path = shutil.which("bw")
68+
if not self.bw_path:
69+
raise BitwardenCLIError("Bitwarden CLI (bw) not found in PATH")
70+
71+
# Set up environment variables for consistent execution context
72+
self.env = {"LANG": "C", "BW_CLIENTID": client_id, "BW_CLIENTSECRET": client_secret}
73+
74+
def login(self) -> str | None:
75+
"""Log into Bitwarden.
76+
77+
Use the API authentication key to log in and unlock the vault. After it,
78+
it will obtain a session key that will be used by to access the vault.
79+
80+
:returns: The session key for the current Bitwarden session.
81+
82+
:raises InvalidCredentialsError: If invalid credentials are provided
83+
:raises BitwardenCLIError: If Bitwarden CLI operations fail
84+
"""
85+
# Log in using API key
86+
login_result = subprocess.run(
87+
[self.bw_path, "login", "--apikey"],
88+
input=f"{self.client_id}\n{self.client_secret}\n",
89+
capture_output=True,
90+
text=True,
91+
env=self.env,
92+
)
93+
94+
if login_result.returncode != 0:
95+
error_msg = login_result.stderr.strip() if login_result.stderr else "Unknown error"
96+
logger.error("Error logging in with API key: %s", error_msg)
97+
raise InvalidCredentialsError("Invalid API credentials provided for Bitwarden")
98+
99+
# After login, we need to unlock the vault to get a session key
100+
self.session_key = self._unlock_vault()
101+
102+
return self.session_key
103+
104+
def _unlock_vault(self) -> str:
105+
"""Unlock the vault after authentication.
106+
107+
Executes the Bitwarden unlock command to obtain a session key
108+
for an already authenticated user but locked vault.
109+
110+
:returns: Session key for the unlocked vault
111+
:raises BitwardenCLIError: If unlock operation fails or returns empty session key
112+
"""
113+
unlock_result = subprocess.run(
114+
[self.bw_path, "unlock", "--raw"],
115+
capture_output=True,
116+
text=True,
117+
env=self.env,
118+
)
119+
120+
if unlock_result.returncode != 0:
121+
error_msg = unlock_result.stderr.strip() if unlock_result.stderr else "Unknown error"
122+
logger.error("Error unlocking vault: %s", error_msg)
123+
raise BitwardenCLIError(f"Failed to unlock vault: {error_msg}")
124+
125+
session_key = unlock_result.stdout.strip() if unlock_result.stdout else ""
126+
if not session_key:
127+
raise BitwardenCLIError("Empty session key received from unlock command")
128+
129+
return session_key
130+
131+
def get_secret(self, item_name: str) -> Any | None:
132+
"""
133+
Retrieves an item by name from the Bitwarden vault. This
134+
retrieves all the fields stored in json format for an item
135+
with the name provided as an argument.
136+
137+
This json can be later parsed to retrieve the values of the
138+
desired fields. (see docs)
139+
140+
:param str item_name: The name of the item to retrieve.
141+
:raises CredentialNotFoundError: If the specific credential is not found
142+
:raises BitwardenCLIError: If Bitwarden CLI operations fail
143+
"""
144+
secret = subprocess.run(
145+
[self.bw_path, "get", "item", item_name],
146+
capture_output=True,
147+
text=True,
148+
env=self.env,
149+
)
150+
151+
if secret.returncode != 0:
152+
raise CredentialNotFoundError(f"Credential not found: '{item_name}'")
153+
154+
return secret
155+
156+
def logout(self) -> None:
157+
"""Log out from Bitwarden and invalidate the session.
158+
159+
This method ends the current session and clears the session key.
160+
"""
161+
logger.info("Logging out from Bitwarden")
162+
163+
# Execute logout command
164+
result = subprocess.run(
165+
[self.bw_path, "logout"],
166+
capture_output=True,
167+
text=True,
168+
env=self.env,
169+
)
170+
171+
if result.returncode != 0:
172+
error_msg = result.stderr.strip() if result.stderr else "Unknown error"
173+
logger.error("Error during logout: %s", error_msg)
174+
175+
# Clear session key for security
176+
self.session_key = None
177+
178+
logger.info("Successfully logged out from Bitwarden")

0 commit comments

Comments
 (0)