mirror of
https://github.com/morgan9e/bitwarden-desktop-agent
synced 2026-04-14 00:04:06 +09:00
Improvements and fixes
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
|
sep_helper
|
||||||
|
|||||||
100
askpass.py
Normal file
100
askpass.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
Prompter = Callable[[str], str | None]
|
||||||
|
|
||||||
|
|
||||||
|
def _cli() -> Prompter:
|
||||||
|
def prompt(msg: str) -> str | None:
|
||||||
|
try:
|
||||||
|
return getpass.getpass(msg + " ")
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
return None
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
def _osascript() -> Prompter:
|
||||||
|
def prompt(msg: str) -> str | None:
|
||||||
|
script = (
|
||||||
|
f'display dialog "{msg}" with title "Bitwarden" '
|
||||||
|
f'default answer "" with hidden answer buttons {{"Cancel","OK"}} default button "OK"'
|
||||||
|
)
|
||||||
|
r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return None
|
||||||
|
for part in r.stdout.strip().split(","):
|
||||||
|
if "text returned:" in part:
|
||||||
|
return part.split("text returned:")[1].strip()
|
||||||
|
return None
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
def _zenity() -> Prompter:
|
||||||
|
def prompt(msg: str) -> str | None:
|
||||||
|
r = subprocess.run(
|
||||||
|
["zenity", "--entry", "--hide-text", "--title", "",
|
||||||
|
"--text", msg, "--width", "300", "--window-icon", "dialog-password"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
return r.stdout.strip() or None if r.returncode == 0 else None
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
def _kdialog() -> Prompter:
|
||||||
|
def prompt(msg: str) -> str | None:
|
||||||
|
r = subprocess.run(
|
||||||
|
["kdialog", "--password", msg, "--title", "Bitwarden"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
return r.stdout.strip() or None if r.returncode == 0 else None
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_askpass() -> Prompter:
|
||||||
|
binary = os.environ.get("SSH_ASKPASS") or shutil.which("ssh-askpass")
|
||||||
|
if not binary:
|
||||||
|
raise RuntimeError("SSH_ASKPASS not set and ssh-askpass not found")
|
||||||
|
|
||||||
|
def prompt(msg: str) -> str | None:
|
||||||
|
r = subprocess.run([binary, msg], capture_output=True, text=True)
|
||||||
|
return r.stdout.strip() or None if r.returncode == 0 else None
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDERS = {
|
||||||
|
"cli": _cli,
|
||||||
|
"osascript": _osascript,
|
||||||
|
"zenity": _zenity,
|
||||||
|
"kdialog": _kdialog,
|
||||||
|
"ssh-askpass": _ssh_askpass,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompter(name: str | None = None) -> Prompter:
|
||||||
|
if name:
|
||||||
|
if name not in PROVIDERS:
|
||||||
|
raise ValueError(f"unknown provider: {name} (available: {', '.join(PROVIDERS)})")
|
||||||
|
return PROVIDERS[name]()
|
||||||
|
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return _osascript()
|
||||||
|
for gui in ("zenity", "kdialog"):
|
||||||
|
if shutil.which(gui):
|
||||||
|
return PROVIDERS[gui]()
|
||||||
|
return _cli()
|
||||||
|
|
||||||
|
|
||||||
|
def available() -> list[str]:
|
||||||
|
found = ["cli"]
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
found.append("osascript")
|
||||||
|
for name in ("zenity", "kdialog"):
|
||||||
|
if shutil.which(name):
|
||||||
|
found.append(name)
|
||||||
|
if shutil.which("ssh-askpass") or "SSH_ASKPASS" in os.environ:
|
||||||
|
found.append("ssh-askpass")
|
||||||
|
return found
|
||||||
62
auth.py
62
auth.py
@@ -2,16 +2,17 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import sys
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import log
|
import log
|
||||||
|
from askpass import Prompter
|
||||||
from crypto import SymmetricKey, enc_string_decrypt_bytes
|
from crypto import SymmetricKey, enc_string_decrypt_bytes
|
||||||
|
from secmem import wipe
|
||||||
|
|
||||||
|
|
||||||
def login(email: str, password: str, server: str) -> tuple[bytes, str]:
|
def login(email: str, password: str, server: str, prompt: Prompter) -> tuple[bytearray, str]:
|
||||||
base = server.rstrip("/")
|
base = server.rstrip("/")
|
||||||
if "bitwarden.com" in base or "bitwarden.eu" in base:
|
if "bitwarden.com" in base or "bitwarden.eu" in base:
|
||||||
api = base.replace("vault.", "api.")
|
api = base.replace("vault.", "api.")
|
||||||
@@ -21,17 +22,16 @@ def login(email: str, password: str, server: str) -> tuple[bytes, str]:
|
|||||||
|
|
||||||
log.info(f"prelogin {api}/accounts/prelogin")
|
log.info(f"prelogin {api}/accounts/prelogin")
|
||||||
prelogin = _json_post(f"{api}/accounts/prelogin", {"email": email})
|
prelogin = _json_post(f"{api}/accounts/prelogin", {"email": email})
|
||||||
kdf_type = prelogin.get("kdf", prelogin.get("Kdf", 0))
|
kdf_type = _get(prelogin, "kdf", 0)
|
||||||
kdf_iter = prelogin.get("kdfIterations", prelogin.get("KdfIterations", 600000))
|
kdf_iter = _get(prelogin, "kdfIterations", 600000)
|
||||||
kdf_mem = prelogin.get("kdfMemory", prelogin.get("KdfMemory", 64))
|
kdf_mem = _get(prelogin, "kdfMemory", 64)
|
||||||
kdf_par = prelogin.get("kdfParallelism", prelogin.get("KdfParallelism", 4))
|
kdf_par = _get(prelogin, "kdfParallelism", 4)
|
||||||
kdf_name = "pbkdf2" if kdf_type == 0 else "argon2id"
|
log.info(f"kdf: {'pbkdf2' if kdf_type == 0 else 'argon2id'} iterations={kdf_iter}")
|
||||||
log.info(f"kdf: {kdf_name} iterations={kdf_iter}")
|
|
||||||
|
|
||||||
log.info("deriving master key...")
|
log.info("deriving master key...")
|
||||||
master_key = _derive_master_key(password, email, kdf_type, kdf_iter, kdf_mem, kdf_par)
|
master_key = bytearray(_derive_master_key(password, email, kdf_type, kdf_iter, kdf_mem, kdf_par))
|
||||||
pw_hash = base64.b64encode(
|
pw_hash = base64.b64encode(
|
||||||
hashlib.pbkdf2_hmac("sha256", master_key, password.encode(), 1, dklen=32)
|
hashlib.pbkdf2_hmac("sha256", bytes(master_key), password.encode(), 1, dklen=32)
|
||||||
).decode()
|
).decode()
|
||||||
|
|
||||||
form = {
|
form = {
|
||||||
@@ -41,19 +41,25 @@ def login(email: str, password: str, server: str) -> tuple[bytes, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info(f"token {identity}/connect/token")
|
log.info(f"token {identity}/connect/token")
|
||||||
token_resp = _try_login(f"{identity}/connect/token", form)
|
token_resp = _try_login(f"{identity}/connect/token", form, prompt)
|
||||||
|
|
||||||
enc_user_key = _extract_encrypted_user_key(token_resp)
|
enc_user_key = _extract_encrypted_user_key(token_resp)
|
||||||
log.info("decrypting user key...")
|
log.info("decrypting user key...")
|
||||||
stretched = _stretch(master_key)
|
stretched = _stretch(master_key)
|
||||||
user_key_bytes = enc_string_decrypt_bytes(enc_user_key, stretched)
|
wipe(master_key)
|
||||||
|
user_key = enc_string_decrypt_bytes(enc_user_key, stretched)
|
||||||
|
stretched.close()
|
||||||
user_id = _extract_user_id(token_resp.get("access_token", ""))
|
user_id = _extract_user_id(token_resp.get("access_token", ""))
|
||||||
log.info(f"user key decrypted ({len(user_key_bytes)}B)")
|
log.info(f"user key decrypted ({len(user_key)}B)")
|
||||||
|
|
||||||
return user_key_bytes, user_id
|
return user_key, user_id
|
||||||
|
|
||||||
|
|
||||||
def _try_login(url: str, form: dict) -> dict:
|
def _get(d: dict, key: str, default=None):
|
||||||
|
return d.get(key, d.get(key[0].upper() + key[1:], default))
|
||||||
|
|
||||||
|
|
||||||
|
def _try_login(url: str, form: dict, prompt: Prompter) -> dict:
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||||
"Accept": "application/json", "Device-Type": "8",
|
"Accept": "application/json", "Device-Type": "8",
|
||||||
@@ -65,27 +71,29 @@ def _try_login(url: str, form: dict) -> dict:
|
|||||||
log.fatal(f"login failed: {e.body[:200]}")
|
log.fatal(f"login failed: {e.body[:200]}")
|
||||||
|
|
||||||
body = json.loads(e.body)
|
body = json.loads(e.body)
|
||||||
providers = body.get("TwoFactorProviders2", body.get("twoFactorProviders2", {}))
|
providers = _get(body, "twoFactorProviders2", {})
|
||||||
|
|
||||||
if "0" not in providers and 0 not in providers:
|
if "0" not in providers and 0 not in providers:
|
||||||
log.fatal("2FA required but TOTP not available for this account")
|
log.fatal("2FA required but TOTP not available")
|
||||||
|
|
||||||
log.info("TOTP required")
|
log.info("TOTP required")
|
||||||
code = input("TOTP code: ").strip()
|
code = prompt("TOTP code:")
|
||||||
form["twoFactorToken"] = code
|
if code is None:
|
||||||
|
log.fatal("no TOTP code provided")
|
||||||
|
form["twoFactorToken"] = code.strip()
|
||||||
form["twoFactorProvider"] = "0"
|
form["twoFactorProvider"] = "0"
|
||||||
return _form_post(url, form, headers)
|
return _form_post(url, form, headers)
|
||||||
|
|
||||||
|
|
||||||
def _extract_encrypted_user_key(resp: dict) -> str:
|
def _extract_encrypted_user_key(resp: dict) -> str:
|
||||||
udo = resp.get("UserDecryptionOptions", resp.get("userDecryptionOptions"))
|
udo = _get(resp, "userDecryptionOptions")
|
||||||
if udo:
|
if udo:
|
||||||
mpu = udo.get("MasterPasswordUnlock", udo.get("masterPasswordUnlock"))
|
mpu = _get(udo, "masterPasswordUnlock")
|
||||||
if mpu:
|
if mpu:
|
||||||
k = mpu.get("MasterKeyEncryptedUserKey", mpu.get("masterKeyEncryptedUserKey"))
|
k = _get(mpu, "masterKeyEncryptedUserKey")
|
||||||
if k:
|
if k:
|
||||||
return k
|
return k
|
||||||
k = resp.get("Key", resp.get("key"))
|
k = _get(resp, "key")
|
||||||
if k:
|
if k:
|
||||||
return k
|
return k
|
||||||
log.fatal("no encrypted user key in server response")
|
log.fatal("no encrypted user key in server response")
|
||||||
@@ -113,10 +121,10 @@ def _derive_master_key(pw: str, email: str, kdf: int, iters: int, mem: int, par:
|
|||||||
log.fatal(f"unsupported kdf type: {kdf}")
|
log.fatal(f"unsupported kdf type: {kdf}")
|
||||||
|
|
||||||
|
|
||||||
def _stretch(master_key: bytes) -> SymmetricKey:
|
def _stretch(master_key: bytearray) -> SymmetricKey:
|
||||||
enc = hmac.new(master_key, b"enc\x01", hashlib.sha256).digest()
|
enc = hmac.new(bytes(master_key), b"enc\x01", hashlib.sha256).digest()
|
||||||
mac = hmac.new(master_key, b"mac\x01", hashlib.sha256).digest()
|
mac = hmac.new(bytes(master_key), b"mac\x01", hashlib.sha256).digest()
|
||||||
return SymmetricKey(enc + mac)
|
return SymmetricKey(bytearray(enc + mac))
|
||||||
|
|
||||||
|
|
||||||
class _HttpError(Exception):
|
class _HttpError(Exception):
|
||||||
|
|||||||
52
bridge.py
52
bridge.py
@@ -1,12 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import argparse
|
import argparse
|
||||||
import getpass
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
import log
|
import log
|
||||||
|
from askpass import get_prompter, available
|
||||||
from auth import login
|
from auth import login
|
||||||
from ipc import get_socket_path, serve
|
from ipc import get_socket_path, serve
|
||||||
from native_messaging import BiometricBridge
|
from native_messaging import BiometricBridge
|
||||||
|
from secmem import wipe
|
||||||
from storage import get_backend
|
from storage import get_backend
|
||||||
|
|
||||||
|
|
||||||
@@ -20,34 +21,49 @@ def main():
|
|||||||
p.add_argument("--password")
|
p.add_argument("--password")
|
||||||
p.add_argument("--server", default="https://vault.bitwarden.com")
|
p.add_argument("--server", default="https://vault.bitwarden.com")
|
||||||
p.add_argument("--backend", choices=["tpm2", "pin"])
|
p.add_argument("--backend", choices=["tpm2", "pin"])
|
||||||
p.add_argument("--enroll", action="store_true", help="re-login and re-seal key")
|
p.add_argument("--askpass", choices=available())
|
||||||
|
p.add_argument("--enroll", action="store_true")
|
||||||
|
p.add_argument("--remove", action="store_true")
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
uid = user_hash(args.email)
|
uid = user_hash(args.email)
|
||||||
store = get_backend(args.backend)
|
store = get_backend(args.backend)
|
||||||
log.info(f"storage backend: {store.name}")
|
prompt = get_prompter(args.askpass)
|
||||||
|
log.info(f"backend: {store.name}")
|
||||||
|
|
||||||
|
if args.remove:
|
||||||
|
if store.has_key(uid):
|
||||||
|
store.remove(uid)
|
||||||
|
log.info(f"key removed for {args.email}")
|
||||||
|
else:
|
||||||
|
log.info(f"no key found for {args.email}")
|
||||||
|
return
|
||||||
|
|
||||||
if not store.has_key(uid) or args.enroll:
|
if not store.has_key(uid) or args.enroll:
|
||||||
if not store.has_key(uid):
|
log.info("enrolling" if not store.has_key(uid) else "re-enrolling")
|
||||||
log.info(f"no sealed key found, need to enroll")
|
pw = args.password or prompt("master password:")
|
||||||
else:
|
if pw is None:
|
||||||
log.info(f"re-enrolling (--enroll)")
|
log.fatal("no password provided")
|
||||||
|
log.info(f"logging in as {args.email}")
|
||||||
|
key_bytes, server_uid = login(args.email, pw, args.server, prompt)
|
||||||
|
pw = None
|
||||||
|
log.info(f"authenticated, uid={server_uid}")
|
||||||
|
|
||||||
pw = args.password or getpass.getpass("master password: ")
|
auth = prompt(f"choose {store.name} password:")
|
||||||
log.info(f"logging in as {args.email} ...")
|
if auth is None:
|
||||||
key_bytes, server_uid = login(args.email, pw, args.server)
|
log.fatal("no password provided")
|
||||||
log.info(f"authenticated, user_id={server_uid}")
|
auth2 = prompt(f"confirm {store.name} password:")
|
||||||
|
|
||||||
auth = getpass.getpass(f"choose {store.name} password: ")
|
|
||||||
auth2 = getpass.getpass(f"confirm: ")
|
|
||||||
if auth != auth2:
|
if auth != auth2:
|
||||||
log.fatal("passwords don't match")
|
log.fatal("passwords don't match")
|
||||||
store.store(uid, key_bytes, auth)
|
store.store(uid, bytes(key_bytes), auth)
|
||||||
log.info(f"user key sealed via {store.name}")
|
wipe(key_bytes)
|
||||||
|
auth = None
|
||||||
|
auth2 = None
|
||||||
|
log.info(f"key sealed via {store.name}")
|
||||||
else:
|
else:
|
||||||
log.info(f"sealed key ready for {args.email}")
|
log.info(f"key ready for {args.email}")
|
||||||
|
|
||||||
bridge = BiometricBridge(store, uid)
|
bridge = BiometricBridge(store, uid, prompt)
|
||||||
sock = get_socket_path()
|
sock = get_socket_path()
|
||||||
log.info(f"listening on {sock}")
|
log.info(f"listening on {sock}")
|
||||||
serve(sock, bridge)
|
serve(sock, bridge)
|
||||||
|
|||||||
8
bw-agent
Executable file
8
bw-agent
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from bridge import main
|
||||||
|
|
||||||
|
main()
|
||||||
7
com.8bit.bitwarden.json
Normal file
7
com.8bit.bitwarden.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "com.8bit.bitwarden",
|
||||||
|
"description": "Bitwarden desktop <-> browser bridge",
|
||||||
|
"path": "/Users/morgan/Projects/bitwarden-client/desktop-agent/desktop_proxy.py",
|
||||||
|
"type": "stdio",
|
||||||
|
"allowed_extensions": ["{446900e4-71c2-419f-a6a7-df9c091e268b}"]
|
||||||
|
}
|
||||||
104
crypto.py
104
crypto.py
@@ -6,17 +6,34 @@ import os
|
|||||||
from cryptography.hazmat.primitives import padding as sym_padding
|
from cryptography.hazmat.primitives import padding as sym_padding
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
|
from secmem import SecureBuffer, wipe
|
||||||
|
|
||||||
|
|
||||||
class SymmetricKey:
|
class SymmetricKey:
|
||||||
def __init__(self, raw: bytes):
|
def __init__(self, raw: bytes | bytearray):
|
||||||
if len(raw) != 64:
|
if len(raw) != 64:
|
||||||
raise ValueError(f"Expected 64 bytes, got {len(raw)}")
|
raise ValueError(f"expected 64 bytes, got {len(raw)}")
|
||||||
self.raw = raw
|
self._secure = SecureBuffer(raw)
|
||||||
self.enc_key = raw[:32]
|
if isinstance(raw, bytearray):
|
||||||
self.mac_key = raw[32:]
|
wipe(raw)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def raw(self) -> bytearray:
|
||||||
|
return self._secure.raw
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enc_key(self) -> bytearray:
|
||||||
|
return self._secure.raw[:32]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mac_key(self) -> bytearray:
|
||||||
|
return self._secure.raw[32:]
|
||||||
|
|
||||||
def to_b64(self) -> str:
|
def to_b64(self) -> str:
|
||||||
return base64.b64encode(self.raw).decode()
|
return base64.b64encode(bytes(self._secure)).decode()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._secure.close()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_b64(cls, b64: str) -> "SymmetricKey":
|
def from_b64(cls, b64: str) -> "SymmetricKey":
|
||||||
@@ -27,58 +44,61 @@ class SymmetricKey:
|
|||||||
return cls(os.urandom(64))
|
return cls(os.urandom(64))
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_raw(enc_str: str, key: SymmetricKey) -> bytearray:
|
||||||
|
t, rest = enc_str.split(".", 1)
|
||||||
|
parts = rest.split("|")
|
||||||
|
iv = base64.b64decode(parts[0])
|
||||||
|
ct = base64.b64decode(parts[1])
|
||||||
|
if len(parts) > 2:
|
||||||
|
mac_got = base64.b64decode(parts[2])
|
||||||
|
expected = hmac.new(bytes(key.mac_key), iv + ct, hashlib.sha256).digest()
|
||||||
|
if not hmac.compare_digest(mac_got, expected):
|
||||||
|
raise ValueError("MAC mismatch")
|
||||||
|
dec = Cipher(algorithms.AES(bytes(key.enc_key)), modes.CBC(iv)).decryptor()
|
||||||
|
padded = dec.update(ct) + dec.finalize()
|
||||||
|
unpadder = sym_padding.PKCS7(128).unpadder()
|
||||||
|
return bytearray(unpadder.update(padded) + unpadder.finalize())
|
||||||
|
|
||||||
|
|
||||||
def enc_string_encrypt(plaintext: str, key: SymmetricKey) -> str:
|
def enc_string_encrypt(plaintext: str, key: SymmetricKey) -> str:
|
||||||
iv = os.urandom(16)
|
iv = os.urandom(16)
|
||||||
cipher = Cipher(algorithms.AES(key.enc_key), modes.CBC(iv))
|
|
||||||
padder = sym_padding.PKCS7(128).padder()
|
padder = sym_padding.PKCS7(128).padder()
|
||||||
padded = padder.update(plaintext.encode()) + padder.finalize()
|
padded = padder.update(plaintext.encode()) + padder.finalize()
|
||||||
enc = cipher.encryptor()
|
ct = Cipher(algorithms.AES(bytes(key.enc_key)), modes.CBC(iv)).encryptor()
|
||||||
ct = enc.update(padded) + enc.finalize()
|
encrypted = ct.update(padded) + ct.finalize()
|
||||||
mac = hmac.new(key.mac_key, iv + ct, hashlib.sha256).digest()
|
mac = hmac.new(bytes(key.mac_key), iv + encrypted, hashlib.sha256).digest()
|
||||||
return f"2.{base64.b64encode(iv).decode()}|{base64.b64encode(ct).decode()}|{base64.b64encode(mac).decode()}"
|
iv_b64 = base64.b64encode(iv).decode()
|
||||||
|
ct_b64 = base64.b64encode(encrypted).decode()
|
||||||
|
mac_b64 = base64.b64encode(mac).decode()
|
||||||
|
return f"2.{iv_b64}|{ct_b64}|{mac_b64}"
|
||||||
|
|
||||||
|
|
||||||
def enc_string_decrypt(enc_str: str, key: SymmetricKey) -> str:
|
def enc_string_decrypt(enc_str: str, key: SymmetricKey) -> str:
|
||||||
t, rest = enc_str.split(".", 1)
|
raw = _decrypt_raw(enc_str, key)
|
||||||
if t != "2":
|
result = raw.decode()
|
||||||
raise ValueError(f"Unsupported type {t}")
|
wipe(raw)
|
||||||
parts = rest.split("|")
|
return result
|
||||||
iv = base64.b64decode(parts[0])
|
|
||||||
ct = base64.b64decode(parts[1])
|
|
||||||
mac_got = base64.b64decode(parts[2])
|
|
||||||
if not hmac.compare_digest(mac_got, hmac.new(key.mac_key, iv + ct, hashlib.sha256).digest()):
|
|
||||||
raise ValueError("MAC mismatch")
|
|
||||||
dec = Cipher(algorithms.AES(key.enc_key), modes.CBC(iv)).decryptor()
|
|
||||||
padded = dec.update(ct) + dec.finalize()
|
|
||||||
unpadder = sym_padding.PKCS7(128).unpadder()
|
|
||||||
return (unpadder.update(padded) + unpadder.finalize()).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def enc_string_decrypt_bytes(enc_str: str, key: SymmetricKey) -> bytes:
|
def enc_string_decrypt_bytes(enc_str: str, key: SymmetricKey) -> bytearray:
|
||||||
t, rest = enc_str.split(".", 1)
|
return _decrypt_raw(enc_str, key)
|
||||||
parts = rest.split("|")
|
|
||||||
iv = base64.b64decode(parts[0])
|
|
||||||
ct = base64.b64decode(parts[1])
|
|
||||||
mac_got = base64.b64decode(parts[2]) if len(parts) > 2 else None
|
|
||||||
if mac_got and not hmac.compare_digest(mac_got, hmac.new(key.mac_key, iv + ct, hashlib.sha256).digest()):
|
|
||||||
raise ValueError("MAC mismatch")
|
|
||||||
dec = Cipher(algorithms.AES(key.enc_key), modes.CBC(iv)).decryptor()
|
|
||||||
padded = dec.update(ct) + dec.finalize()
|
|
||||||
unpadder = sym_padding.PKCS7(128).unpadder()
|
|
||||||
return unpadder.update(padded) + unpadder.finalize()
|
|
||||||
|
|
||||||
|
|
||||||
def enc_string_to_dict(enc_str: str) -> dict:
|
def enc_string_to_dict(enc_str: str) -> dict:
|
||||||
t, rest = enc_str.split(".", 1)
|
t, rest = enc_str.split(".", 1)
|
||||||
parts = rest.split("|")
|
parts = rest.split("|")
|
||||||
d = {"encryptionType": int(t), "encryptedString": enc_str}
|
d = {"encryptionType": int(t), "encryptedString": enc_str}
|
||||||
if len(parts) >= 1: d["iv"] = parts[0]
|
if len(parts) >= 1:
|
||||||
if len(parts) >= 2: d["data"] = parts[1]
|
d["iv"] = parts[0]
|
||||||
if len(parts) >= 3: d["mac"] = parts[2]
|
if len(parts) >= 2:
|
||||||
|
d["data"] = parts[1]
|
||||||
|
if len(parts) >= 3:
|
||||||
|
d["mac"] = parts[2]
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
def dict_to_enc_string(d: dict) -> str:
|
def dict_to_enc_string(d: dict) -> str:
|
||||||
if d.get("encryptedString"):
|
if s := d.get("encryptedString"):
|
||||||
return d["encryptedString"]
|
return s
|
||||||
return f"{d.get('encryptionType', 2)}.{d.get('iv', '')}|{d.get('data', '')}|{d.get('mac', '')}"
|
t = d.get("encryptionType", 2)
|
||||||
|
return f"{t}.{d.get('iv', '')}|{d.get('data', '')}|{d.get('mac', '')}"
|
||||||
|
|||||||
89
desktop_proxy.py
Normal file → Executable file
89
desktop_proxy.py
Normal file → Executable file
@@ -1,47 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/opt/homebrew/bin/python3
|
||||||
import os
|
|
||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
MAX_MSG = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def ipc_socket_path() -> str:
|
def ipc_socket_path() -> str:
|
||||||
return str(Path.home() / ".librewolf" / "s.bw")
|
return str(Path.home() / ".cache" / "com.bitwarden.desktop" / "s.bw")
|
||||||
|
|
||||||
|
|
||||||
def read_stdin_message() -> bytes | None:
|
|
||||||
header = sys.stdin.buffer.read(4)
|
|
||||||
if len(header) < 4:
|
|
||||||
return None
|
|
||||||
length = struct.unpack("=I", header)[0]
|
|
||||||
if length == 0 or length > 1024 * 1024:
|
|
||||||
return None
|
|
||||||
data = sys.stdin.buffer.read(length)
|
|
||||||
if len(data) < length:
|
|
||||||
return None
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def write_stdout_message(data: bytes):
|
|
||||||
header = struct.pack("=I", len(data))
|
|
||||||
sys.stdout.buffer.write(header + data)
|
|
||||||
sys.stdout.buffer.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def read_ipc_message(sock: socket.socket) -> bytes | None:
|
|
||||||
header = recv_exact(sock, 4)
|
|
||||||
if header is None:
|
|
||||||
return None
|
|
||||||
length = struct.unpack("=I", header)[0]
|
|
||||||
if length == 0 or length > 1024 * 1024:
|
|
||||||
return None
|
|
||||||
return recv_exact(sock, length)
|
|
||||||
|
|
||||||
|
|
||||||
def send_ipc_message(sock: socket.socket, data: bytes):
|
|
||||||
sock.sendall(struct.pack("=I", len(data)) + data)
|
|
||||||
|
|
||||||
|
|
||||||
def recv_exact(sock: socket.socket, n: int) -> bytes | None:
|
def recv_exact(sock: socket.socket, n: int) -> bytes | None:
|
||||||
@@ -54,41 +22,68 @@ def recv_exact(sock: socket.socket, n: int) -> bytes | None:
|
|||||||
return buf
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def read_stdin() -> bytes | None:
|
||||||
|
header = sys.stdin.buffer.read(4)
|
||||||
|
if len(header) < 4:
|
||||||
|
return None
|
||||||
|
length = struct.unpack("=I", header)[0]
|
||||||
|
if length == 0 or length > MAX_MSG:
|
||||||
|
return None
|
||||||
|
data = sys.stdin.buffer.read(length)
|
||||||
|
return data if len(data) == length else None
|
||||||
|
|
||||||
|
|
||||||
|
def write_stdout(data: bytes):
|
||||||
|
sys.stdout.buffer.write(struct.pack("=I", len(data)) + data)
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def read_ipc(sock: socket.socket) -> bytes | None:
|
||||||
|
header = recv_exact(sock, 4)
|
||||||
|
if header is None:
|
||||||
|
return None
|
||||||
|
length = struct.unpack("=I", header)[0]
|
||||||
|
if length == 0 or length > MAX_MSG:
|
||||||
|
return None
|
||||||
|
return recv_exact(sock, length)
|
||||||
|
|
||||||
|
|
||||||
|
def send_ipc(sock: socket.socket, data: bytes):
|
||||||
|
sock.sendall(struct.pack("=I", len(data)) + data)
|
||||||
|
|
||||||
|
|
||||||
def ipc_to_stdout(sock: socket.socket):
|
def ipc_to_stdout(sock: socket.socket):
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
msg = read_ipc_message(sock)
|
msg = read_ipc(sock)
|
||||||
if msg is None:
|
if msg is None:
|
||||||
break
|
break
|
||||||
write_stdout_message(msg)
|
write_stdout(msg)
|
||||||
except (ConnectionResetError, BrokenPipeError, OSError):
|
except (ConnectionResetError, BrokenPipeError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
path = ipc_socket_path()
|
|
||||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
try:
|
try:
|
||||||
sock.connect(path)
|
sock.connect(ipc_socket_path())
|
||||||
except (FileNotFoundError, ConnectionRefusedError):
|
except (FileNotFoundError, ConnectionRefusedError):
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
send_ipc_message(sock, b'{"command":"connected"}')
|
send_ipc(sock, b'{"command":"connected"}')
|
||||||
|
threading.Thread(target=ipc_to_stdout, args=(sock,), daemon=True).start()
|
||||||
reader = threading.Thread(target=ipc_to_stdout, args=(sock,), daemon=True)
|
|
||||||
reader.start()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
msg = read_stdin_message()
|
msg = read_stdin()
|
||||||
if msg is None:
|
if msg is None:
|
||||||
break
|
break
|
||||||
send_ipc_message(sock, msg)
|
send_ipc(sock, msg)
|
||||||
except (BrokenPipeError, OSError):
|
except (BrokenPipeError, OSError):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
send_ipc_message(sock, b'{"command":"disconnected"}')
|
send_ipc(sock, b'{"command":"disconnected"}')
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
sock.close()
|
sock.close()
|
||||||
|
|||||||
37
gui.py
37
gui.py
@@ -1,37 +0,0 @@
|
|||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def ask_password(title: str = "Bitwarden", prompt: str = "Unlock password:") -> str | None:
|
|
||||||
if sys.platform == "darwin":
|
|
||||||
return _osascript(title, prompt)
|
|
||||||
if shutil.which("zenity"):
|
|
||||||
return _zenity(title, prompt)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _osascript(title: str, prompt: str) -> str | None:
|
|
||||||
script = (
|
|
||||||
f'display dialog "{prompt}" with title "{title}" '
|
|
||||||
f'default answer "" with hidden answer buttons {{"Cancel","OK"}} default button "OK"'
|
|
||||||
)
|
|
||||||
r = subprocess.run(
|
|
||||||
["osascript", "-e", script],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
return None
|
|
||||||
for part in r.stdout.strip().split(","):
|
|
||||||
if "text returned:" in part:
|
|
||||||
return part.split("text returned:")[1].strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _zenity(title: str, prompt: str) -> str | None:
|
|
||||||
r = subprocess.run(
|
|
||||||
["zenity", "--entry", "--hide-text", "--title", "", "--text", prompt,
|
|
||||||
"--width", "300", "--window-icon", "dialog-password"],
|
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
return r.stdout.strip() or None if r.returncode == 0 else None
|
|
||||||
4
ipc.py
4
ipc.py
@@ -6,6 +6,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import log
|
import log
|
||||||
|
|
||||||
|
MAX_MSG = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def get_socket_path() -> Path:
|
def get_socket_path() -> Path:
|
||||||
cache = Path.home() / ".cache" / "com.bitwarden.desktop"
|
cache = Path.home() / ".cache" / "com.bitwarden.desktop"
|
||||||
@@ -28,7 +30,7 @@ def read_message(conn: socket.socket) -> dict | None:
|
|||||||
if not header:
|
if not header:
|
||||||
return None
|
return None
|
||||||
length = struct.unpack("=I", header)[0]
|
length = struct.unpack("=I", header)[0]
|
||||||
if length == 0 or length > 1024 * 1024:
|
if length == 0 or length > MAX_MSG:
|
||||||
return None
|
return None
|
||||||
data = recv_exact(conn, length)
|
data = recv_exact(conn, length)
|
||||||
if not data:
|
if not data:
|
||||||
|
|||||||
8
log.py
8
log.py
@@ -3,19 +3,23 @@ import time
|
|||||||
|
|
||||||
_start = time.monotonic()
|
_start = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
def _ts() -> str:
|
def _ts() -> str:
|
||||||
elapsed = time.monotonic() - _start
|
return f"{time.monotonic() - _start:8.3f}"
|
||||||
return f"{elapsed:8.3f}"
|
|
||||||
|
|
||||||
def info(msg: str):
|
def info(msg: str):
|
||||||
print(f"[{_ts()}] {msg}", file=sys.stderr, flush=True)
|
print(f"[{_ts()}] {msg}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
def warn(msg: str):
|
def warn(msg: str):
|
||||||
print(f"[{_ts()}] WARN {msg}", file=sys.stderr, flush=True)
|
print(f"[{_ts()}] WARN {msg}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
def error(msg: str):
|
def error(msg: str):
|
||||||
print(f"[{_ts()}] ERROR {msg}", file=sys.stderr, flush=True)
|
print(f"[{_ts()}] ERROR {msg}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
def fatal(msg: str):
|
def fatal(msg: str):
|
||||||
error(msg)
|
error(msg)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -6,15 +6,17 @@ from cryptography.hazmat.primitives import hashes, serialization
|
|||||||
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
|
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
|
||||||
|
|
||||||
import log
|
import log
|
||||||
|
from askpass import Prompter
|
||||||
from crypto import SymmetricKey, enc_string_encrypt, enc_string_decrypt, enc_string_to_dict, dict_to_enc_string
|
from crypto import SymmetricKey, enc_string_encrypt, enc_string_decrypt, enc_string_to_dict, dict_to_enc_string
|
||||||
from gui import ask_password
|
from secmem import wipe
|
||||||
from storage import KeyStore
|
from storage import KeyStore
|
||||||
|
|
||||||
|
|
||||||
class BiometricBridge:
|
class BiometricBridge:
|
||||||
def __init__(self, store: KeyStore, user_id: str):
|
def __init__(self, store: KeyStore, user_id: str, prompter: Prompter):
|
||||||
self._store = store
|
self._store = store
|
||||||
self._uid = user_id
|
self._uid = user_id
|
||||||
|
self._prompt = prompter
|
||||||
self._sessions: dict[str, SymmetricKey] = {}
|
self._sessions: dict[str, SymmetricKey] = {}
|
||||||
|
|
||||||
def __call__(self, msg: dict) -> dict | None:
|
def __call__(self, msg: dict) -> dict | None:
|
||||||
@@ -42,7 +44,7 @@ class BiometricBridge:
|
|||||||
self._sessions[app_id] = shared
|
self._sessions[app_id] = shared
|
||||||
|
|
||||||
encrypted = pub_key.encrypt(
|
encrypted = pub_key.encrypt(
|
||||||
shared.raw,
|
bytes(shared.raw),
|
||||||
asym_padding.OAEP(
|
asym_padding.OAEP(
|
||||||
mgf=asym_padding.MGF1(algorithm=hashes.SHA1()),
|
mgf=asym_padding.MGF1(algorithm=hashes.SHA1()),
|
||||||
algorithm=hashes.SHA1(), label=None,
|
algorithm=hashes.SHA1(), label=None,
|
||||||
@@ -50,7 +52,6 @@ class BiometricBridge:
|
|||||||
)
|
)
|
||||||
|
|
||||||
log.info(f"handshake complete, app={app_id[:12]}")
|
log.info(f"handshake complete, app={app_id[:12]}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"appId": app_id,
|
"appId": app_id,
|
||||||
"command": "setupEncryption",
|
"command": "setupEncryption",
|
||||||
@@ -83,40 +84,51 @@ class BiometricBridge:
|
|||||||
encrypted = enc_string_encrypt(json.dumps(resp), key)
|
encrypted = enc_string_encrypt(json.dumps(resp), key)
|
||||||
return {"appId": app_id, "messageId": mid, "message": enc_string_to_dict(encrypted)}
|
return {"appId": app_id, "messageId": mid, "message": enc_string_to_dict(encrypted)}
|
||||||
|
|
||||||
|
def _reply(self, cmd: str, mid: int, **kwargs) -> dict:
|
||||||
|
return {"command": cmd, "messageId": mid, "timestamp": int(time.time() * 1000), **kwargs}
|
||||||
|
|
||||||
def _dispatch(self, cmd: str, mid: int) -> dict | None:
|
def _dispatch(self, cmd: str, mid: int) -> dict | None:
|
||||||
ts = int(time.time() * 1000)
|
handlers = {
|
||||||
|
"unlockWithBiometricsForUser": self._handle_unlock,
|
||||||
|
"getBiometricsStatus": self._handle_status,
|
||||||
|
"getBiometricsStatusForUser": self._handle_status,
|
||||||
|
"authenticateWithBiometrics": self._handle_auth,
|
||||||
|
}
|
||||||
|
handler = handlers.get(cmd)
|
||||||
|
if handler is None:
|
||||||
|
return None
|
||||||
|
return handler(cmd, mid)
|
||||||
|
|
||||||
if cmd == "unlockWithBiometricsForUser":
|
def _handle_unlock(self, cmd: str, mid: int) -> dict:
|
||||||
key_b64 = self._unseal_key()
|
key_b64 = self._unseal_key()
|
||||||
if key_b64 is None:
|
if key_b64 is None:
|
||||||
log.warn("unlock denied or failed")
|
log.warn("unlock denied or failed")
|
||||||
return {"command": cmd, "messageId": mid, "timestamp": ts, "response": False}
|
return self._reply(cmd, mid, response=False)
|
||||||
log.info("-> unlock granted, key delivered")
|
log.info("-> unlock granted")
|
||||||
return {"command": cmd, "messageId": mid, "timestamp": ts, "response": True, "userKeyB64": key_b64}
|
resp = self._reply(cmd, mid, response=True, userKeyB64=key_b64)
|
||||||
|
key_b64 = None
|
||||||
|
return resp
|
||||||
|
|
||||||
if cmd in ("getBiometricsStatus", "getBiometricsStatusForUser"):
|
def _handle_status(self, cmd: str, mid: int) -> dict:
|
||||||
log.info(f"-> biometrics available")
|
log.info("-> biometrics available")
|
||||||
return {"command": cmd, "messageId": mid, "timestamp": ts, "response": 0}
|
return self._reply(cmd, mid, response=0)
|
||||||
|
|
||||||
if cmd == "authenticateWithBiometrics":
|
def _handle_auth(self, cmd: str, mid: int) -> dict:
|
||||||
log.info("-> authenticated")
|
log.info("-> authenticated")
|
||||||
return {"command": cmd, "messageId": mid, "timestamp": ts, "response": True}
|
return self._reply(cmd, mid, response=True)
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _unseal_key(self) -> str | None:
|
def _unseal_key(self) -> str | None:
|
||||||
log.info(f"requesting {self._store.name} password via GUI")
|
pw = self._prompt(f"Enter {self._store.name} password:")
|
||||||
pw = ask_password("Bitwarden Unlock", f"Enter {self._store.name} password:")
|
|
||||||
if pw is None:
|
if pw is None:
|
||||||
log.info("user cancelled dialog")
|
log.info("cancelled")
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
raw = self._store.load(self._uid, pw)
|
raw = self._store.load(self._uid, pw)
|
||||||
b64 = base64.b64encode(raw).decode()
|
|
||||||
log.info(f"unsealed {len(raw)}B key from {self._store.name}")
|
|
||||||
raw = None
|
|
||||||
pw = None
|
pw = None
|
||||||
log.info("key material wiped from memory")
|
b64 = base64.b64encode(bytes(raw)).decode()
|
||||||
|
if isinstance(raw, bytearray):
|
||||||
|
wipe(raw)
|
||||||
|
log.info(f"unsealed {len(raw)}B from {self._store.name}")
|
||||||
return b64
|
return b64
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"unseal failed: {e}")
|
log.error(f"unseal failed: {e}")
|
||||||
|
|||||||
61
secmem.py
Normal file
61
secmem.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
_libc = ctypes.CDLL("libSystem.B.dylib")
|
||||||
|
else:
|
||||||
|
_libc = ctypes.CDLL(ctypes.util.find_library("c"))
|
||||||
|
|
||||||
|
_mlock = _libc.mlock
|
||||||
|
_mlock.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
|
||||||
|
_mlock.restype = ctypes.c_int
|
||||||
|
|
||||||
|
_munlock = _libc.munlock
|
||||||
|
_munlock.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
|
||||||
|
_munlock.restype = ctypes.c_int
|
||||||
|
|
||||||
|
|
||||||
|
def _addr(buf: bytearray) -> int:
|
||||||
|
return ctypes.addressof((ctypes.c_char * len(buf)).from_buffer(buf))
|
||||||
|
|
||||||
|
|
||||||
|
def mlock(buf: bytearray):
|
||||||
|
if len(buf) > 0:
|
||||||
|
_mlock(_addr(buf), len(buf))
|
||||||
|
|
||||||
|
|
||||||
|
def munlock(buf: bytearray):
|
||||||
|
if len(buf) > 0:
|
||||||
|
_munlock(_addr(buf), len(buf))
|
||||||
|
|
||||||
|
|
||||||
|
def wipe(buf: bytearray):
|
||||||
|
for i in range(len(buf)):
|
||||||
|
buf[i] = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SecureBuffer:
|
||||||
|
__slots__ = ("_buf",)
|
||||||
|
|
||||||
|
def __init__(self, data: bytes | bytearray):
|
||||||
|
self._buf = bytearray(data)
|
||||||
|
mlock(self._buf)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def raw(self) -> bytearray:
|
||||||
|
return self._buf
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self._buf)
|
||||||
|
|
||||||
|
def __bytes__(self):
|
||||||
|
return bytes(self._buf)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self._buf:
|
||||||
|
wipe(self._buf)
|
||||||
|
munlock(self._buf)
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import base64
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import padding
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
||||||
|
|
||||||
from . import KeyStore
|
from . import KeyStore
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
STORE_DIR = Path.home() / ".cache" / "com.bitwarden.desktop" / "keys"
|
STORE_DIR = Path.home() / ".cache" / "com.bitwarden.desktop" / "keys"
|
||||||
|
|
||||||
|
SCRYPT_N = 2**17
|
||||||
|
SCRYPT_R = 8
|
||||||
|
SCRYPT_P = 1
|
||||||
|
SCRYPT_MAXMEM = 256 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
class PinKeyStore(KeyStore):
|
class PinKeyStore(KeyStore):
|
||||||
def __init__(self, store_dir: Path = STORE_DIR):
|
def __init__(self, store_dir: Path = STORE_DIR):
|
||||||
@@ -33,28 +36,21 @@ class PinKeyStore(KeyStore):
|
|||||||
|
|
||||||
def store(self, uid: str, data: bytes, auth: str):
|
def store(self, uid: str, data: bytes, auth: str):
|
||||||
salt = os.urandom(32)
|
salt = os.urandom(32)
|
||||||
enc_key, mac_key = _derive(auth, salt)
|
key = _derive(auth, salt)
|
||||||
iv = os.urandom(16)
|
nonce = os.urandom(12)
|
||||||
padder = padding.PKCS7(128).padder()
|
ct = AESGCM(key).encrypt(nonce, data, salt)
|
||||||
padded = padder.update(data) + padder.finalize()
|
blob = bytes([VERSION]) + salt + nonce + ct
|
||||||
ct = Cipher(algorithms.AES(enc_key), modes.CBC(iv)).encryptor()
|
|
||||||
encrypted = ct.update(padded) + ct.finalize()
|
|
||||||
mac = hmac.new(mac_key, salt + iv + encrypted, hashlib.sha256).digest()
|
|
||||||
blob = salt + iv + mac + encrypted
|
|
||||||
self._path(uid).write_bytes(blob)
|
self._path(uid).write_bytes(blob)
|
||||||
os.chmod(str(self._path(uid)), 0o600)
|
os.chmod(str(self._path(uid)), 0o600)
|
||||||
|
|
||||||
def load(self, uid: str, auth: str) -> bytes:
|
def load(self, uid: str, auth: str) -> bytes:
|
||||||
blob = self._path(uid).read_bytes()
|
blob = self._path(uid).read_bytes()
|
||||||
salt, iv, mac_stored, ct = blob[:32], blob[32:48], blob[48:80], blob[80:]
|
_ver, salt, nonce, ct = blob[0], blob[1:33], blob[33:45], blob[45:]
|
||||||
enc_key, mac_key = _derive(auth, salt)
|
key = _derive(auth, salt)
|
||||||
mac_check = hmac.new(mac_key, salt + iv + ct, hashlib.sha256).digest()
|
try:
|
||||||
if not hmac.compare_digest(mac_stored, mac_check):
|
return AESGCM(key).decrypt(nonce, ct, salt)
|
||||||
raise ValueError("Wrong PIN or corrupted data")
|
except Exception:
|
||||||
dec = Cipher(algorithms.AES(enc_key), modes.CBC(iv)).decryptor()
|
raise ValueError("wrong password or corrupted data")
|
||||||
padded = dec.update(ct) + dec.finalize()
|
|
||||||
unpadder = padding.PKCS7(128).unpadder()
|
|
||||||
return unpadder.update(padded) + unpadder.finalize()
|
|
||||||
|
|
||||||
def remove(self, uid: str):
|
def remove(self, uid: str):
|
||||||
p = self._path(uid)
|
p = self._path(uid)
|
||||||
@@ -62,6 +58,8 @@ class PinKeyStore(KeyStore):
|
|||||||
p.unlink()
|
p.unlink()
|
||||||
|
|
||||||
|
|
||||||
def _derive(password: str, salt: bytes) -> tuple[bytes, bytes]:
|
def _derive(password: str, salt: bytes) -> bytes:
|
||||||
raw = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600_000, dklen=64)
|
return hashlib.scrypt(
|
||||||
return raw[:32], raw[32:]
|
password.encode(), salt=salt,
|
||||||
|
n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P, dklen=32, maxmem=SCRYPT_MAXMEM,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user