mirror of
https://github.com/morgan9e/bitwarden-desktop-agent
synced 2026-04-15 00:34:11 +09:00
Rust implementation
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1 @@
|
||||
__pycache__/
|
||||
sep_helper
|
||||
/target
|
||||
|
||||
1740
Cargo.lock
generated
Normal file
1740
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
Cargo.toml
Normal file
38
Cargo.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "desktop-agent"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "bw-agent"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bw-proxy"
|
||||
path = "src/proxy.rs"
|
||||
|
||||
[dependencies]
|
||||
aes = "0.8"
|
||||
argon2 = "0.5"
|
||||
base64 = "0.22"
|
||||
cbc = { version = "0.1", features = ["alloc"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
ctrlc = "3"
|
||||
hex = "0.4"
|
||||
hmac = "0.12"
|
||||
pbkdf2 = { version = "0.12", features = ["sha2"] }
|
||||
rand = "0.8"
|
||||
rsa = { version = "0.9", features = ["sha1"] }
|
||||
scrypt = "0.11"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
sha1 = "0.10"
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
aes-gcm = "0.10"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
100
askpass.py
100
askpass.py
@@ -1,100 +0,0 @@
|
||||
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
|
||||
149
auth.py
149
auth.py
@@ -1,149 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
import log
|
||||
from askpass import Prompter
|
||||
from crypto import SymmetricKey, enc_string_decrypt_bytes
|
||||
from secmem import wipe
|
||||
|
||||
|
||||
def login(email: str, password: str, server: str, prompt: Prompter) -> tuple[bytearray, str]:
|
||||
base = server.rstrip("/")
|
||||
if "bitwarden.com" in base or "bitwarden.eu" in base:
|
||||
api = base.replace("vault.", "api.")
|
||||
identity = base.replace("vault.", "identity.")
|
||||
else:
|
||||
api, identity = base + "/api", base + "/identity"
|
||||
|
||||
log.info(f"prelogin {api}/accounts/prelogin")
|
||||
prelogin = _json_post(f"{api}/accounts/prelogin", {"email": email})
|
||||
kdf_type = _get(prelogin, "kdf", 0)
|
||||
kdf_iter = _get(prelogin, "kdfIterations", 600000)
|
||||
kdf_mem = _get(prelogin, "kdfMemory", 64)
|
||||
kdf_par = _get(prelogin, "kdfParallelism", 4)
|
||||
log.info(f"kdf: {'pbkdf2' if kdf_type == 0 else 'argon2id'} iterations={kdf_iter}")
|
||||
|
||||
log.info("deriving master key...")
|
||||
master_key = bytearray(_derive_master_key(password, email, kdf_type, kdf_iter, kdf_mem, kdf_par))
|
||||
pw_hash = base64.b64encode(
|
||||
hashlib.pbkdf2_hmac("sha256", bytes(master_key), password.encode(), 1, dklen=32)
|
||||
).decode()
|
||||
|
||||
form = {
|
||||
"grant_type": "password", "username": email, "password": pw_hash,
|
||||
"scope": "api offline_access", "client_id": "connector",
|
||||
"deviceType": "8", "deviceIdentifier": str(uuid.uuid4()), "deviceName": "bw-bridge",
|
||||
}
|
||||
|
||||
log.info(f"token {identity}/connect/token")
|
||||
token_resp = _try_login(f"{identity}/connect/token", form, prompt)
|
||||
|
||||
enc_user_key = _extract_encrypted_user_key(token_resp)
|
||||
log.info("decrypting user key...")
|
||||
stretched = _stretch(master_key)
|
||||
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", ""))
|
||||
log.info(f"user key decrypted ({len(user_key)}B)")
|
||||
|
||||
return user_key, user_id
|
||||
|
||||
|
||||
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 = {
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||
"Accept": "application/json", "Device-Type": "8",
|
||||
}
|
||||
try:
|
||||
return _form_post(url, form, headers)
|
||||
except _HttpError as e:
|
||||
if "TwoFactor" not in e.body:
|
||||
log.fatal(f"login failed: {e.body[:200]}")
|
||||
|
||||
body = json.loads(e.body)
|
||||
providers = _get(body, "twoFactorProviders2", {})
|
||||
|
||||
if "0" not in providers and 0 not in providers:
|
||||
log.fatal("2FA required but TOTP not available")
|
||||
|
||||
log.info("TOTP required")
|
||||
code = prompt("TOTP code:")
|
||||
if code is None:
|
||||
log.fatal("no TOTP code provided")
|
||||
form["twoFactorToken"] = code.strip()
|
||||
form["twoFactorProvider"] = "0"
|
||||
return _form_post(url, form, headers)
|
||||
|
||||
|
||||
def _extract_encrypted_user_key(resp: dict) -> str:
|
||||
udo = _get(resp, "userDecryptionOptions")
|
||||
if udo:
|
||||
mpu = _get(udo, "masterPasswordUnlock")
|
||||
if mpu:
|
||||
k = _get(mpu, "masterKeyEncryptedUserKey")
|
||||
if k:
|
||||
return k
|
||||
k = _get(resp, "key")
|
||||
if k:
|
||||
return k
|
||||
log.fatal("no encrypted user key in server response")
|
||||
|
||||
|
||||
def _extract_user_id(token: str) -> str:
|
||||
try:
|
||||
payload = token.split(".")[1]
|
||||
payload += "=" * (4 - len(payload) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(payload)).get("sub", "unknown")
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _derive_master_key(pw: str, email: str, kdf: int, iters: int, mem: int, par: int) -> bytes:
|
||||
salt = email.lower().strip().encode()
|
||||
if kdf == 0:
|
||||
return hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, iters, dklen=32)
|
||||
if kdf == 1:
|
||||
from argon2.low_level import hash_secret_raw, Type
|
||||
return hash_secret_raw(
|
||||
secret=pw.encode(), salt=salt, time_cost=iters,
|
||||
memory_cost=mem * 1024, parallelism=par, hash_len=32, type=Type.ID,
|
||||
)
|
||||
log.fatal(f"unsupported kdf type: {kdf}")
|
||||
|
||||
|
||||
def _stretch(master_key: bytearray) -> SymmetricKey:
|
||||
enc = hmac.new(bytes(master_key), b"enc\x01", hashlib.sha256).digest()
|
||||
mac = hmac.new(bytes(master_key), b"mac\x01", hashlib.sha256).digest()
|
||||
return SymmetricKey(bytearray(enc + mac))
|
||||
|
||||
|
||||
class _HttpError(Exception):
|
||||
def __init__(self, code: int, body: str):
|
||||
self.code, self.body = code, body
|
||||
super().__init__(f"HTTP {code}")
|
||||
|
||||
|
||||
def _json_post(url: str, data: dict) -> dict:
|
||||
req = urllib.request.Request(url, json.dumps(data).encode(),
|
||||
{"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def _form_post(url: str, form: dict, headers: dict) -> dict:
|
||||
req = urllib.request.Request(url, urllib.parse.urlencode(form).encode(), headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
raise _HttpError(e.code, e.read().decode()) from e
|
||||
73
bridge.py
73
bridge.py
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import hashlib
|
||||
|
||||
import log
|
||||
from askpass import get_prompter, available
|
||||
from auth import login
|
||||
from ipc import get_socket_path, serve
|
||||
from native_messaging import BiometricBridge
|
||||
from secmem import wipe
|
||||
from storage import get_backend
|
||||
|
||||
|
||||
def user_hash(email: str) -> str:
|
||||
return hashlib.sha256(email.lower().strip().encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Bitwarden desktop bridge agent")
|
||||
p.add_argument("--email", required=True)
|
||||
p.add_argument("--password")
|
||||
p.add_argument("--server", default="https://vault.bitwarden.com")
|
||||
p.add_argument("--backend", choices=["tpm2", "pin"])
|
||||
p.add_argument("--askpass", choices=available())
|
||||
p.add_argument("--enroll", action="store_true")
|
||||
p.add_argument("--remove", action="store_true")
|
||||
args = p.parse_args()
|
||||
|
||||
uid = user_hash(args.email)
|
||||
store = get_backend(args.backend)
|
||||
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:
|
||||
log.info("enrolling" if not store.has_key(uid) else "re-enrolling")
|
||||
pw = args.password or prompt("master password:")
|
||||
if pw is None:
|
||||
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}")
|
||||
|
||||
auth = prompt(f"choose {store.name} password:")
|
||||
if auth is None:
|
||||
log.fatal("no password provided")
|
||||
auth2 = prompt(f"confirm {store.name} password:")
|
||||
if auth != auth2:
|
||||
log.fatal("passwords don't match")
|
||||
store.store(uid, bytes(key_bytes), auth)
|
||||
wipe(key_bytes)
|
||||
auth = None
|
||||
auth2 = None
|
||||
log.info(f"key sealed via {store.name}")
|
||||
else:
|
||||
log.info(f"key ready for {args.email}")
|
||||
|
||||
bridge = BiometricBridge(store, uid, prompt)
|
||||
sock = get_socket_path()
|
||||
log.info(f"listening on {sock}")
|
||||
serve(sock, bridge)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8
bw-agent
8
bw-agent
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from bridge import main
|
||||
|
||||
main()
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,104 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
|
||||
from cryptography.hazmat.primitives import padding as sym_padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
from secmem import SecureBuffer, wipe
|
||||
|
||||
|
||||
class SymmetricKey:
|
||||
def __init__(self, raw: bytes | bytearray):
|
||||
if len(raw) != 64:
|
||||
raise ValueError(f"expected 64 bytes, got {len(raw)}")
|
||||
self._secure = SecureBuffer(raw)
|
||||
if isinstance(raw, bytearray):
|
||||
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:
|
||||
return base64.b64encode(bytes(self._secure)).decode()
|
||||
|
||||
def close(self):
|
||||
self._secure.close()
|
||||
|
||||
@classmethod
|
||||
def from_b64(cls, b64: str) -> "SymmetricKey":
|
||||
return cls(base64.b64decode(b64))
|
||||
|
||||
@classmethod
|
||||
def generate(cls) -> "SymmetricKey":
|
||||
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:
|
||||
iv = os.urandom(16)
|
||||
padder = sym_padding.PKCS7(128).padder()
|
||||
padded = padder.update(plaintext.encode()) + padder.finalize()
|
||||
ct = Cipher(algorithms.AES(bytes(key.enc_key)), modes.CBC(iv)).encryptor()
|
||||
encrypted = ct.update(padded) + ct.finalize()
|
||||
mac = hmac.new(bytes(key.mac_key), iv + encrypted, hashlib.sha256).digest()
|
||||
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:
|
||||
raw = _decrypt_raw(enc_str, key)
|
||||
result = raw.decode()
|
||||
wipe(raw)
|
||||
return result
|
||||
|
||||
|
||||
def enc_string_decrypt_bytes(enc_str: str, key: SymmetricKey) -> bytearray:
|
||||
return _decrypt_raw(enc_str, key)
|
||||
|
||||
|
||||
def enc_string_to_dict(enc_str: str) -> dict:
|
||||
t, rest = enc_str.split(".", 1)
|
||||
parts = rest.split("|")
|
||||
d = {"encryptionType": int(t), "encryptedString": enc_str}
|
||||
if len(parts) >= 1:
|
||||
d["iv"] = parts[0]
|
||||
if len(parts) >= 2:
|
||||
d["data"] = parts[1]
|
||||
if len(parts) >= 3:
|
||||
d["mac"] = parts[2]
|
||||
return d
|
||||
|
||||
|
||||
def dict_to_enc_string(d: dict) -> str:
|
||||
if s := d.get("encryptedString"):
|
||||
return s
|
||||
t = d.get("encryptionType", 2)
|
||||
return f"{t}.{d.get('iv', '')}|{d.get('data', '')}|{d.get('mac', '')}"
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/opt/homebrew/bin/python3
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
MAX_MSG = 1024 * 1024
|
||||
|
||||
|
||||
def ipc_socket_path() -> str:
|
||||
return str(Path.home() / ".cache" / "com.bitwarden.desktop" / "s.bw")
|
||||
|
||||
|
||||
def recv_exact(sock: socket.socket, n: int) -> bytes | None:
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
return None
|
||||
buf += chunk
|
||||
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):
|
||||
try:
|
||||
while True:
|
||||
msg = read_ipc(sock)
|
||||
if msg is None:
|
||||
break
|
||||
write_stdout(msg)
|
||||
except (ConnectionResetError, BrokenPipeError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.connect(ipc_socket_path())
|
||||
except (FileNotFoundError, ConnectionRefusedError):
|
||||
sys.exit(1)
|
||||
|
||||
send_ipc(sock, b'{"command":"connected"}')
|
||||
threading.Thread(target=ipc_to_stdout, args=(sock,), daemon=True).start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
msg = read_stdin()
|
||||
if msg is None:
|
||||
break
|
||||
send_ipc(sock, msg)
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
send_ipc(sock, b'{"command":"disconnected"}')
|
||||
except OSError:
|
||||
pass
|
||||
sock.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
84
ipc.py
84
ipc.py
@@ -1,84 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import log
|
||||
|
||||
MAX_MSG = 1024 * 1024
|
||||
|
||||
|
||||
def get_socket_path() -> Path:
|
||||
cache = Path.home() / ".cache" / "com.bitwarden.desktop"
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
return cache / "s.bw"
|
||||
|
||||
|
||||
def recv_exact(conn: socket.socket, n: int) -> bytes | None:
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = conn.recv(n - len(buf))
|
||||
if not chunk:
|
||||
return None
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def read_message(conn: socket.socket) -> dict | None:
|
||||
header = recv_exact(conn, 4)
|
||||
if not header:
|
||||
return None
|
||||
length = struct.unpack("=I", header)[0]
|
||||
if length == 0 or length > MAX_MSG:
|
||||
return None
|
||||
data = recv_exact(conn, length)
|
||||
if not data:
|
||||
return None
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def send_message(conn: socket.socket, msg: dict):
|
||||
data = json.dumps(msg).encode()
|
||||
conn.sendall(struct.pack("=I", len(data)) + data)
|
||||
|
||||
|
||||
def serve(sock_path: Path, handler):
|
||||
if sock_path.exists():
|
||||
sock_path.unlink()
|
||||
|
||||
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
srv.bind(str(sock_path))
|
||||
srv.listen(5)
|
||||
os.chmod(str(sock_path), 0o600)
|
||||
|
||||
try:
|
||||
while True:
|
||||
conn, _ = srv.accept()
|
||||
log.info("client connected")
|
||||
_handle_conn(conn, handler)
|
||||
log.info("client disconnected")
|
||||
except KeyboardInterrupt:
|
||||
log.info("shutting down")
|
||||
finally:
|
||||
srv.close()
|
||||
if sock_path.exists():
|
||||
sock_path.unlink()
|
||||
|
||||
|
||||
def _handle_conn(conn: socket.socket, handler):
|
||||
try:
|
||||
while True:
|
||||
msg = read_message(conn)
|
||||
if msg is None:
|
||||
break
|
||||
if "command" in msg and "appId" not in msg:
|
||||
log.info(f"proxy: {msg.get('command')}")
|
||||
continue
|
||||
resp = handler(msg)
|
||||
if resp is not None:
|
||||
send_message(conn, resp)
|
||||
except (ConnectionResetError, BrokenPipeError):
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
25
log.py
25
log.py
@@ -1,25 +0,0 @@
|
||||
import sys
|
||||
import time
|
||||
|
||||
_start = time.monotonic()
|
||||
|
||||
|
||||
def _ts() -> str:
|
||||
return f"{time.monotonic() - _start:8.3f}"
|
||||
|
||||
|
||||
def info(msg: str):
|
||||
print(f"[{_ts()}] {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def warn(msg: str):
|
||||
print(f"[{_ts()}] WARN {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def error(msg: str):
|
||||
print(f"[{_ts()}] ERROR {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def fatal(msg: str):
|
||||
error(msg)
|
||||
sys.exit(1)
|
||||
@@ -1,135 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
|
||||
|
||||
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 secmem import wipe
|
||||
from storage import KeyStore
|
||||
|
||||
|
||||
class BiometricBridge:
|
||||
def __init__(self, store: KeyStore, user_id: str, prompter: Prompter):
|
||||
self._store = store
|
||||
self._uid = user_id
|
||||
self._prompt = prompter
|
||||
self._sessions: dict[str, SymmetricKey] = {}
|
||||
|
||||
def __call__(self, msg: dict) -> dict | None:
|
||||
app_id = msg.get("appId", "")
|
||||
message = msg.get("message")
|
||||
if message is None:
|
||||
return None
|
||||
|
||||
if isinstance(message, dict) and message.get("command") == "setupEncryption":
|
||||
return self._handshake(app_id, message)
|
||||
|
||||
if isinstance(message, dict) and ("encryptedString" in message or "encryptionType" in message):
|
||||
return self._encrypted(app_id, message)
|
||||
|
||||
if isinstance(message, str) and message.startswith("2."):
|
||||
return self._encrypted(app_id, {"encryptedString": message})
|
||||
|
||||
return None
|
||||
|
||||
def _handshake(self, app_id: str, msg: dict) -> dict:
|
||||
pub_bytes = base64.b64decode(msg.get("publicKey", ""))
|
||||
pub_key = serialization.load_der_public_key(pub_bytes)
|
||||
|
||||
shared = SymmetricKey.generate()
|
||||
self._sessions[app_id] = shared
|
||||
|
||||
encrypted = pub_key.encrypt(
|
||||
bytes(shared.raw),
|
||||
asym_padding.OAEP(
|
||||
mgf=asym_padding.MGF1(algorithm=hashes.SHA1()),
|
||||
algorithm=hashes.SHA1(), label=None,
|
||||
),
|
||||
)
|
||||
|
||||
log.info(f"handshake complete, app={app_id[:12]}")
|
||||
return {
|
||||
"appId": app_id,
|
||||
"command": "setupEncryption",
|
||||
"messageId": -1,
|
||||
"sharedSecret": base64.b64encode(encrypted).decode(),
|
||||
}
|
||||
|
||||
def _encrypted(self, app_id: str, enc_msg: dict) -> dict | None:
|
||||
if app_id not in self._sessions:
|
||||
log.warn(f"no session for app={app_id[:12]}")
|
||||
return {"appId": app_id, "command": "invalidateEncryption"}
|
||||
|
||||
key = self._sessions[app_id]
|
||||
try:
|
||||
plaintext = enc_string_decrypt(dict_to_enc_string(enc_msg), key)
|
||||
except Exception:
|
||||
log.error("message decryption failed")
|
||||
return {"appId": app_id, "command": "invalidateEncryption"}
|
||||
|
||||
data = json.loads(plaintext)
|
||||
cmd = data.get("command", "")
|
||||
mid = data.get("messageId", 0)
|
||||
|
||||
log.info(f"<- {cmd} (msg={mid})")
|
||||
resp = self._dispatch(cmd, mid)
|
||||
if resp is None:
|
||||
log.warn(f"unhandled command: {cmd}")
|
||||
return None
|
||||
|
||||
encrypted = enc_string_encrypt(json.dumps(resp), key)
|
||||
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:
|
||||
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)
|
||||
|
||||
def _handle_unlock(self, cmd: str, mid: int) -> dict:
|
||||
key_b64 = self._unseal_key()
|
||||
if key_b64 is None:
|
||||
log.warn("unlock denied or failed")
|
||||
return self._reply(cmd, mid, response=False)
|
||||
log.info("-> unlock granted")
|
||||
resp = self._reply(cmd, mid, response=True, userKeyB64=key_b64)
|
||||
key_b64 = None
|
||||
return resp
|
||||
|
||||
def _handle_status(self, cmd: str, mid: int) -> dict:
|
||||
log.info("-> biometrics available")
|
||||
return self._reply(cmd, mid, response=0)
|
||||
|
||||
def _handle_auth(self, cmd: str, mid: int) -> dict:
|
||||
log.info("-> authenticated")
|
||||
return self._reply(cmd, mid, response=True)
|
||||
|
||||
def _unseal_key(self) -> str | None:
|
||||
pw = self._prompt(f"Enter {self._store.name} password:")
|
||||
if pw is None:
|
||||
log.info("cancelled")
|
||||
return None
|
||||
try:
|
||||
raw = self._store.load(self._uid, pw)
|
||||
pw = None
|
||||
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
|
||||
except Exception as e:
|
||||
log.error(f"unseal failed: {e}")
|
||||
return None
|
||||
61
secmem.py
61
secmem.py
@@ -1,61 +0,0 @@
|
||||
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)
|
||||
131
src/askpass.rs
Normal file
131
src/askpass.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::process::Command;
|
||||
|
||||
pub type Prompter = Box<dyn Fn(&str) -> Option<String>>;
|
||||
|
||||
fn cli() -> Prompter {
|
||||
Box::new(|msg: &str| {
|
||||
eprint!("{msg} ");
|
||||
io::stderr().flush().ok();
|
||||
let mut line = String::new();
|
||||
io::stdin().lock().read_line(&mut line).ok()?;
|
||||
let trimmed = line.trim_end().to_string();
|
||||
if trimmed.is_empty() { None } else { Some(trimmed) }
|
||||
})
|
||||
}
|
||||
|
||||
fn osascript() -> Prompter {
|
||||
Box::new(|msg: &str| {
|
||||
let script = format!(
|
||||
"display dialog \"{}\" with title \"Bitwarden\" \
|
||||
default answer \"\" with hidden answer \
|
||||
buttons {{\"Cancel\",\"OK\"}} default button \"OK\"",
|
||||
msg
|
||||
);
|
||||
let out = Command::new("osascript").args(["-e", &script]).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
for part in stdout.trim().split(',') {
|
||||
if let Some(val) = part.trim().strip_prefix("text returned:") {
|
||||
let v = val.trim().to_string();
|
||||
return if v.is_empty() { None } else { Some(v) };
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn zenity() -> Prompter {
|
||||
Box::new(|msg: &str| {
|
||||
let out = Command::new("zenity")
|
||||
.args(["--entry", "--hide-text", "--title", "", "--text", msg, "--width", "300"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
})
|
||||
}
|
||||
|
||||
fn kdialog() -> Prompter {
|
||||
Box::new(|msg: &str| {
|
||||
let out = Command::new("kdialog")
|
||||
.args(["--password", msg, "--title", "Bitwarden"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
})
|
||||
}
|
||||
|
||||
fn ssh_askpass() -> Option<Prompter> {
|
||||
let binary = std::env::var("SSH_ASKPASS")
|
||||
.ok()
|
||||
.or_else(|| which("ssh-askpass"))?;
|
||||
Some(Box::new(move |msg: &str| {
|
||||
let out = Command::new(&binary).arg(msg).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
}))
|
||||
}
|
||||
|
||||
fn which(name: &str) -> Option<String> {
|
||||
Command::new("which")
|
||||
.arg(name)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
pub fn get_prompter(name: Option<&str>) -> Prompter {
|
||||
match name {
|
||||
Some("cli") => cli(),
|
||||
Some("osascript") => osascript(),
|
||||
Some("zenity") => zenity(),
|
||||
Some("kdialog") => kdialog(),
|
||||
Some("ssh-askpass") => ssh_askpass().unwrap_or_else(|| {
|
||||
crate::log::fatal("SSH_ASKPASS not set and ssh-askpass not found")
|
||||
}),
|
||||
Some(other) => crate::log::fatal(&format!("unknown askpass provider: {other}")),
|
||||
None => {
|
||||
if cfg!(target_os = "macos") {
|
||||
return osascript();
|
||||
}
|
||||
if which("zenity").is_some() {
|
||||
return zenity();
|
||||
}
|
||||
if which("kdialog").is_some() {
|
||||
return kdialog();
|
||||
}
|
||||
cli()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn available() -> Vec<&'static str> {
|
||||
let mut found = vec!["cli"];
|
||||
if cfg!(target_os = "macos") {
|
||||
found.push("osascript");
|
||||
}
|
||||
if which("zenity").is_some() {
|
||||
found.push("zenity");
|
||||
}
|
||||
if which("kdialog").is_some() {
|
||||
found.push("kdialog");
|
||||
}
|
||||
if which("ssh-askpass").is_some() || std::env::var("SSH_ASKPASS").is_ok() {
|
||||
found.push("ssh-askpass");
|
||||
}
|
||||
found
|
||||
}
|
||||
269
src/auth.rs
Normal file
269
src/auth.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::askpass::Prompter;
|
||||
use crate::crypto::{enc_string_decrypt_bytes, SymmetricKey};
|
||||
|
||||
pub fn login(
|
||||
email: &str,
|
||||
password: &str,
|
||||
server: &str,
|
||||
prompt: &Prompter,
|
||||
) -> (Vec<u8>, String) {
|
||||
let base = server.trim_end_matches('/');
|
||||
let (api, identity) = if base.contains("bitwarden.com") || base.contains("bitwarden.eu") {
|
||||
(
|
||||
base.replace("vault.", "api."),
|
||||
base.replace("vault.", "identity."),
|
||||
)
|
||||
} else {
|
||||
(format!("{base}/api"), format!("{base}/identity"))
|
||||
};
|
||||
|
||||
crate::log::info(&format!("prelogin {api}/accounts/prelogin"));
|
||||
let prelogin: serde_json::Value = ureq::post(&format!("{api}/accounts/prelogin"))
|
||||
.set("Content-Type", "application/json")
|
||||
.send_string(&serde_json::json!({"email": email}).to_string())
|
||||
.unwrap_or_else(|e| crate::log::fatal(&format!("prelogin failed: {e}")))
|
||||
.into_json()
|
||||
.unwrap();
|
||||
|
||||
let kdf_type = get_u64(&prelogin, "kdf").unwrap_or(0);
|
||||
let kdf_iter = get_u64(&prelogin, "kdfIterations").unwrap_or(600000) as u32;
|
||||
let kdf_mem = get_u64(&prelogin, "kdfMemory").unwrap_or(64) as u32;
|
||||
let kdf_par = get_u64(&prelogin, "kdfParallelism").unwrap_or(4) as u32;
|
||||
crate::log::info(&format!(
|
||||
"kdf: {} iterations={kdf_iter}",
|
||||
if kdf_type == 0 { "pbkdf2" } else { "argon2id" }
|
||||
));
|
||||
|
||||
crate::log::info("deriving master key...");
|
||||
let master_key = Zeroizing::new(derive_master_key(
|
||||
password, email, kdf_type, kdf_iter, kdf_mem, kdf_par,
|
||||
));
|
||||
|
||||
let pw_hash = {
|
||||
let mut buf = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(master_key.as_slice(), password.as_bytes(), 1, &mut buf);
|
||||
B64.encode(buf)
|
||||
};
|
||||
|
||||
let device_id = uuid::Uuid::new_v4().to_string();
|
||||
let form = [
|
||||
("grant_type", "password"),
|
||||
("username", email),
|
||||
("password", &pw_hash),
|
||||
("scope", "api offline_access"),
|
||||
("client_id", "connector"),
|
||||
("deviceType", "8"),
|
||||
("deviceIdentifier", &device_id),
|
||||
("deviceName", "bw-bridge"),
|
||||
];
|
||||
|
||||
crate::log::info(&format!("token {identity}/connect/token"));
|
||||
let token_resp = try_login(&format!("{identity}/connect/token"), &form, prompt);
|
||||
|
||||
let enc_user_key = extract_encrypted_user_key(&token_resp);
|
||||
crate::log::info("decrypting user key...");
|
||||
|
||||
let stretched = stretch(&master_key);
|
||||
let user_key = enc_string_decrypt_bytes(&enc_user_key, &stretched)
|
||||
.unwrap_or_else(|e| crate::log::fatal(&format!("decrypt user key failed: {e}")));
|
||||
|
||||
let user_id = extract_user_id(
|
||||
token_resp
|
||||
.get("access_token")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or(""),
|
||||
);
|
||||
crate::log::info(&format!("user key decrypted ({}B)", user_key.len()));
|
||||
|
||||
(user_key.to_vec(), user_id)
|
||||
}
|
||||
|
||||
fn get_u64(v: &serde_json::Value, key: &str) -> Option<u64> {
|
||||
v.get(key)
|
||||
.or_else(|| {
|
||||
let pascal = format!("{}{}", key[..1].to_uppercase(), &key[1..]);
|
||||
v.get(&pascal)
|
||||
})
|
||||
.and_then(|v| v.as_u64())
|
||||
}
|
||||
|
||||
fn get_str<'a>(v: &'a serde_json::Value, key: &str) -> Option<&'a str> {
|
||||
v.get(key)
|
||||
.or_else(|| {
|
||||
let pascal = format!("{}{}", key[..1].to_uppercase(), &key[1..]);
|
||||
v.get(&pascal)
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
}
|
||||
|
||||
fn get_obj<'a>(v: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
|
||||
v.get(key).or_else(|| {
|
||||
let pascal = format!("{}{}", key[..1].to_uppercase(), &key[1..]);
|
||||
v.get(&pascal)
|
||||
})
|
||||
}
|
||||
|
||||
fn try_login(
|
||||
url: &str,
|
||||
form: &[(&str, &str)],
|
||||
prompt: &Prompter,
|
||||
) -> serde_json::Value {
|
||||
let body: String = form
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencod(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
match post_form(url, &body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
if !e.contains("TwoFactor") {
|
||||
crate::log::fatal(&format!("login failed: {}", &e[..e.len().min(200)]));
|
||||
}
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(&e).unwrap_or(serde_json::Value::Null);
|
||||
let providers = get_obj(&body, "twoFactorProviders2").unwrap_or(&serde_json::Value::Null);
|
||||
if providers.get("0").is_none() {
|
||||
crate::log::fatal("2FA required but TOTP not available");
|
||||
}
|
||||
|
||||
crate::log::info("TOTP required");
|
||||
let code = prompt("TOTP code:")
|
||||
.unwrap_or_else(|| crate::log::fatal("no TOTP code provided"));
|
||||
|
||||
let mut form2: Vec<(&str, &str)> = form.to_vec();
|
||||
let trimmed = code.trim().to_string();
|
||||
form2.push(("twoFactorToken", &trimmed));
|
||||
form2.push(("twoFactorProvider", "0"));
|
||||
|
||||
let body2: String = form2
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencod(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
post_form(url, &body2)
|
||||
.unwrap_or_else(|e| crate::log::fatal(&format!("login failed: {}", &e[..e.len().min(200)])))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn post_form(url: &str, body: &str) -> Result<serde_json::Value, String> {
|
||||
let resp = ureq::post(url)
|
||||
.set(
|
||||
"Content-Type",
|
||||
"application/x-www-form-urlencoded; charset=utf-8",
|
||||
)
|
||||
.set("Accept", "application/json")
|
||||
.set("Device-Type", "8")
|
||||
.send_string(body);
|
||||
|
||||
match resp {
|
||||
Ok(r) => r.into_json().map_err(|e| e.to_string()),
|
||||
Err(ureq::Error::Status(_code, resp)) => {
|
||||
let body = resp.into_string().unwrap_or_default();
|
||||
Err(body)
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn urlencod(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn extract_encrypted_user_key(resp: &serde_json::Value) -> String {
|
||||
if let Some(udo) = get_obj(resp, "userDecryptionOptions") {
|
||||
if let Some(mpu) = get_obj(udo, "masterPasswordUnlock") {
|
||||
if let Some(k) = get_str(mpu, "masterKeyEncryptedUserKey") {
|
||||
return k.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(k) = get_str(resp, "key") {
|
||||
return k.to_string();
|
||||
}
|
||||
crate::log::fatal("no encrypted user key in server response");
|
||||
}
|
||||
|
||||
fn extract_user_id(token: &str) -> String {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() < 2 {
|
||||
return "unknown".into();
|
||||
}
|
||||
let mut payload = parts[1].to_string();
|
||||
while payload.len() % 4 != 0 {
|
||||
payload.push('=');
|
||||
}
|
||||
let decoded = B64.decode(&payload).or_else(|_| {
|
||||
base64::engine::general_purpose::URL_SAFE.decode(&payload)
|
||||
});
|
||||
match decoded {
|
||||
Ok(bytes) => {
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
|
||||
v.get("sub")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
}
|
||||
Err(_) => "unknown".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_master_key(
|
||||
pw: &str,
|
||||
email: &str,
|
||||
kdf: u64,
|
||||
iters: u32,
|
||||
mem: u32,
|
||||
par: u32,
|
||||
) -> Vec<u8> {
|
||||
let salt = email.to_lowercase().trim().as_bytes().to_vec();
|
||||
match kdf {
|
||||
0 => {
|
||||
let mut key = vec![0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(pw.as_bytes(), &salt, iters, &mut key);
|
||||
key
|
||||
}
|
||||
1 => {
|
||||
let params = argon2::Params::new(mem * 1024, iters, par, Some(32))
|
||||
.unwrap_or_else(|e| crate::log::fatal(&format!("argon2 params: {e}")));
|
||||
let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||
let mut key = vec![0u8; 32];
|
||||
argon
|
||||
.hash_password_into(pw.as_bytes(), &salt, &mut key)
|
||||
.unwrap_or_else(|e| crate::log::fatal(&format!("argon2: {e}")));
|
||||
key
|
||||
}
|
||||
_ => crate::log::fatal(&format!("unsupported kdf type: {kdf}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn stretch(master_key: &[u8]) -> SymmetricKey {
|
||||
let mut enc_hmac = Hmac::<Sha256>::new_from_slice(master_key).unwrap();
|
||||
enc_hmac.update(b"enc\x01");
|
||||
let enc = enc_hmac.finalize().into_bytes();
|
||||
|
||||
let mut mac_hmac = Hmac::<Sha256>::new_from_slice(master_key).unwrap();
|
||||
mac_hmac.update(b"mac\x01");
|
||||
let mac = mac_hmac.finalize().into_bytes();
|
||||
|
||||
let mut combined = Vec::with_capacity(64);
|
||||
combined.extend_from_slice(&enc);
|
||||
combined.extend_from_slice(&mac);
|
||||
SymmetricKey::new(combined)
|
||||
}
|
||||
190
src/bridge.rs
Normal file
190
src/bridge.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||
use rsa::{pkcs1::DecodeRsaPublicKey, Oaep};
|
||||
use serde_json::{json, Value};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::askpass::Prompter;
|
||||
use crate::crypto::{
|
||||
enc_string_decrypt, enc_string_encrypt, enc_string_to_json, json_to_enc_string, SymmetricKey,
|
||||
};
|
||||
use crate::storage::KeyStore;
|
||||
|
||||
pub struct BiometricBridge {
|
||||
store: Box<dyn KeyStore>,
|
||||
uid: String,
|
||||
prompt: Prompter,
|
||||
sessions: HashMap<String, SymmetricKey>,
|
||||
}
|
||||
|
||||
impl BiometricBridge {
|
||||
pub fn new(store: Box<dyn KeyStore>, uid: String, prompt: Prompter) -> Self {
|
||||
Self {
|
||||
store,
|
||||
uid,
|
||||
prompt,
|
||||
sessions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, msg: Value) -> Option<Value> {
|
||||
let app_id = msg.get("appId")?.as_str()?.to_string();
|
||||
let message = msg.get("message")?;
|
||||
|
||||
if let Some(obj) = message.as_object() {
|
||||
if obj.get("command").and_then(|c| c.as_str()) == Some("setupEncryption") {
|
||||
return Some(self.handshake(&app_id, message));
|
||||
}
|
||||
if obj.contains_key("encryptedString") || obj.contains_key("encryptionType") {
|
||||
return self.encrypted(&app_id, message);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(s) = message.as_str() {
|
||||
if s.starts_with("2.") {
|
||||
return self.encrypted(&app_id, &json!({"encryptedString": s}));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn handshake(&mut self, app_id: &str, msg: &Value) -> Value {
|
||||
let pub_b64 = msg
|
||||
.get("publicKey")
|
||||
.and_then(|p| p.as_str())
|
||||
.unwrap_or("");
|
||||
let pub_bytes = B64.decode(pub_b64).unwrap_or_default();
|
||||
let pub_key = rsa::RsaPublicKey::from_pkcs1_der(&pub_bytes)
|
||||
.or_else(|_| {
|
||||
use rsa::pkcs8::DecodePublicKey;
|
||||
rsa::RsaPublicKey::from_public_key_der(&pub_bytes)
|
||||
})
|
||||
.unwrap_or_else(|e| crate::log::fatal(&format!("bad public key: {e}")));
|
||||
|
||||
let shared = SymmetricKey::generate();
|
||||
let encrypted = pub_key
|
||||
.encrypt(
|
||||
&mut rand::thread_rng(),
|
||||
Oaep::new::<sha1::Sha1>(),
|
||||
shared.raw(),
|
||||
)
|
||||
.unwrap_or_else(|e| crate::log::fatal(&format!("RSA encrypt failed: {e}")));
|
||||
|
||||
self.sessions.insert(app_id.to_string(), shared);
|
||||
|
||||
crate::log::info(&format!("handshake complete, app={}", &app_id[..12.min(app_id.len())]));
|
||||
json!({
|
||||
"appId": app_id,
|
||||
"command": "setupEncryption",
|
||||
"messageId": -1,
|
||||
"sharedSecret": B64.encode(encrypted),
|
||||
})
|
||||
}
|
||||
|
||||
fn encrypted(&mut self, app_id: &str, enc_msg: &Value) -> Option<Value> {
|
||||
if !self.sessions.contains_key(app_id) {
|
||||
crate::log::warn(&format!(
|
||||
"no session for app={}",
|
||||
&app_id[..12.min(app_id.len())]
|
||||
));
|
||||
return Some(json!({"appId": app_id, "command": "invalidateEncryption"}));
|
||||
}
|
||||
|
||||
let key = self.sessions.get(app_id).unwrap();
|
||||
let enc_str = json_to_enc_string(enc_msg);
|
||||
let plaintext = match enc_string_decrypt(&enc_str, key) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
crate::log::error("message decryption failed");
|
||||
return Some(json!({"appId": app_id, "command": "invalidateEncryption"}));
|
||||
}
|
||||
};
|
||||
|
||||
let data: Value = serde_json::from_str(&plaintext).ok()?;
|
||||
let cmd = data.get("command")?.as_str()?.to_string();
|
||||
let mid = data.get("messageId").and_then(|m| m.as_i64()).unwrap_or(0);
|
||||
|
||||
crate::log::info(&format!("<- {cmd} (msg={mid})"));
|
||||
let resp = self.dispatch(&cmd, mid)?;
|
||||
|
||||
let key = self.sessions.get(app_id).unwrap();
|
||||
let resp_json = serde_json::to_string(&resp).unwrap();
|
||||
let encrypted = enc_string_encrypt(&resp_json, key);
|
||||
|
||||
Some(json!({
|
||||
"appId": app_id,
|
||||
"messageId": mid,
|
||||
"message": enc_string_to_json(&encrypted),
|
||||
}))
|
||||
}
|
||||
|
||||
fn reply(&self, cmd: &str, mid: i64, extra: Value) -> Value {
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
let mut obj = json!({
|
||||
"command": cmd,
|
||||
"messageId": mid,
|
||||
"timestamp": ts,
|
||||
});
|
||||
if let (Some(base), Some(ext)) = (obj.as_object_mut(), extra.as_object()) {
|
||||
for (k, v) in ext {
|
||||
base.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
obj
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, cmd: &str, mid: i64) -> Option<Value> {
|
||||
match cmd {
|
||||
"unlockWithBiometricsForUser" => Some(self.handle_unlock(cmd, mid)),
|
||||
"getBiometricsStatus" | "getBiometricsStatusForUser" => {
|
||||
crate::log::info("-> biometrics available");
|
||||
Some(self.reply(cmd, mid, json!({"response": 0})))
|
||||
}
|
||||
"authenticateWithBiometrics" => {
|
||||
crate::log::info("-> authenticated");
|
||||
Some(self.reply(cmd, mid, json!({"response": true})))
|
||||
}
|
||||
_ => {
|
||||
crate::log::warn(&format!("unhandled command: {cmd}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_unlock(&mut self, cmd: &str, mid: i64) -> Value {
|
||||
match self.unseal_key() {
|
||||
Some(key_b64) => {
|
||||
crate::log::info("-> unlock granted");
|
||||
self.reply(cmd, mid, json!({"response": true, "userKeyB64": key_b64}))
|
||||
}
|
||||
None => {
|
||||
crate::log::warn("unlock denied or failed");
|
||||
self.reply(cmd, mid, json!({"response": false}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unseal_key(&self) -> Option<String> {
|
||||
let pw = (self.prompt)(&format!("Enter {} password:", self.store.name()))?;
|
||||
match self.store.load(&self.uid, &pw) {
|
||||
Ok(mut raw) => {
|
||||
let len = raw.len();
|
||||
let b64 = B64.encode(&raw);
|
||||
raw.zeroize();
|
||||
crate::log::info(&format!("unsealed {len}B from {}", self.store.name()));
|
||||
crate::log::info("wiped key from memory");
|
||||
Some(b64)
|
||||
}
|
||||
Err(e) => {
|
||||
crate::log::error(&format!("unseal failed: {e}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/crypto.rs
Normal file
122
src/crypto.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
use sha2::Sha256;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
|
||||
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
|
||||
|
||||
pub struct SymmetricKey {
|
||||
raw: Zeroizing<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl SymmetricKey {
|
||||
pub fn new(data: Vec<u8>) -> Self {
|
||||
assert_eq!(data.len(), 64);
|
||||
Self { raw: Zeroizing::new(data) }
|
||||
}
|
||||
|
||||
pub fn generate() -> Self {
|
||||
let mut buf = vec![0u8; 64];
|
||||
rand::thread_rng().fill_bytes(&mut buf);
|
||||
Self::new(buf)
|
||||
}
|
||||
|
||||
pub fn raw(&self) -> &[u8] {
|
||||
&self.raw
|
||||
}
|
||||
|
||||
pub fn enc_key(&self) -> &[u8] {
|
||||
&self.raw[..32]
|
||||
}
|
||||
|
||||
pub fn mac_key(&self) -> &[u8] {
|
||||
&self.raw[32..]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enc_string_encrypt(plaintext: &str, key: &SymmetricKey) -> String {
|
||||
let mut iv = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut iv);
|
||||
|
||||
let encrypted = Aes256CbcEnc::new(key.enc_key().into(), &iv.into())
|
||||
.encrypt_padded_vec_mut::<Pkcs7>(plaintext.as_bytes());
|
||||
|
||||
let mut mac_input = Vec::with_capacity(16 + encrypted.len());
|
||||
mac_input.extend_from_slice(&iv);
|
||||
mac_input.extend_from_slice(&encrypted);
|
||||
|
||||
let mut hmac = Hmac::<Sha256>::new_from_slice(key.mac_key()).unwrap();
|
||||
hmac.update(&mac_input);
|
||||
let mac = hmac.finalize().into_bytes();
|
||||
|
||||
format!(
|
||||
"2.{}|{}|{}",
|
||||
B64.encode(iv),
|
||||
B64.encode(&encrypted),
|
||||
B64.encode(mac)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn enc_string_decrypt(enc_str: &str, key: &SymmetricKey) -> Result<String, &'static str> {
|
||||
let raw = enc_string_decrypt_bytes(enc_str, key)?;
|
||||
String::from_utf8(raw.to_vec()).map_err(|_| "invalid utf8")
|
||||
}
|
||||
|
||||
pub fn enc_string_decrypt_bytes(enc_str: &str, key: &SymmetricKey) -> Result<Zeroizing<Vec<u8>>, &'static str> {
|
||||
let (_t, rest) = enc_str.split_once('.').ok_or("bad format")?;
|
||||
let parts: Vec<&str> = rest.split('|').collect();
|
||||
if parts.len() < 2 {
|
||||
return Err("bad format");
|
||||
}
|
||||
|
||||
let iv = B64.decode(parts[0]).map_err(|_| "bad iv")?;
|
||||
let ct = B64.decode(parts[1]).map_err(|_| "bad ct")?;
|
||||
|
||||
if parts.len() > 2 {
|
||||
let mac_got = B64.decode(parts[2]).map_err(|_| "bad mac")?;
|
||||
let mut hmac = Hmac::<Sha256>::new_from_slice(key.mac_key()).unwrap();
|
||||
let mut mac_input = Vec::with_capacity(iv.len() + ct.len());
|
||||
mac_input.extend_from_slice(&iv);
|
||||
mac_input.extend_from_slice(&ct);
|
||||
hmac.update(&mac_input);
|
||||
hmac.verify_slice(&mac_got).map_err(|_| "MAC mismatch")?;
|
||||
}
|
||||
|
||||
let decrypted = Aes256CbcDec::new(key.enc_key().into(), iv.as_slice().into())
|
||||
.decrypt_padded_vec_mut::<Pkcs7>(&ct)
|
||||
.map_err(|_| "decrypt failed")?;
|
||||
|
||||
Ok(Zeroizing::new(decrypted))
|
||||
}
|
||||
|
||||
pub fn enc_string_to_json(enc_str: &str) -> serde_json::Value {
|
||||
let (t, rest) = enc_str.split_once('.').unwrap_or(("2", enc_str));
|
||||
let parts: Vec<&str> = rest.split('|').collect();
|
||||
let mut m = serde_json::Map::new();
|
||||
m.insert("encryptionType".into(), serde_json::json!(t.parse::<u32>().unwrap_or(2)));
|
||||
m.insert("encryptedString".into(), serde_json::json!(enc_str));
|
||||
if let Some(iv) = parts.first() {
|
||||
m.insert("iv".into(), serde_json::json!(iv));
|
||||
}
|
||||
if let Some(data) = parts.get(1) {
|
||||
m.insert("data".into(), serde_json::json!(data));
|
||||
}
|
||||
if let Some(mac) = parts.get(2) {
|
||||
m.insert("mac".into(), serde_json::json!(mac));
|
||||
}
|
||||
serde_json::Value::Object(m)
|
||||
}
|
||||
|
||||
pub fn json_to_enc_string(v: &serde_json::Value) -> String {
|
||||
if let Some(s) = v.get("encryptedString").and_then(|s| s.as_str()) {
|
||||
return s.to_string();
|
||||
}
|
||||
let t = v.get("encryptionType").and_then(|t| t.as_u64()).unwrap_or(2);
|
||||
let iv = v.get("iv").and_then(|s| s.as_str()).unwrap_or("");
|
||||
let data = v.get("data").and_then(|s| s.as_str()).unwrap_or("");
|
||||
let mac = v.get("mac").and_then(|s| s.as_str()).unwrap_or("");
|
||||
format!("{t}.{iv}|{data}|{mac}")
|
||||
}
|
||||
124
src/ipc.rs
Normal file
124
src/ipc.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MAX_MSG: usize = 1024 * 1024;
|
||||
|
||||
pub fn socket_path() -> PathBuf {
|
||||
let cache = dirs_cache().join("com.bitwarden.desktop");
|
||||
std::fs::create_dir_all(&cache).ok();
|
||||
cache.join("s.bw")
|
||||
}
|
||||
|
||||
fn dirs_cache() -> PathBuf {
|
||||
dirs_home().join(".cache")
|
||||
}
|
||||
|
||||
fn dirs_home() -> PathBuf {
|
||||
std::env::var("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/tmp"))
|
||||
}
|
||||
|
||||
fn recv_exact(stream: &mut UnixStream, n: usize) -> Option<Vec<u8>> {
|
||||
let mut buf = vec![0u8; n];
|
||||
let mut pos = 0;
|
||||
while pos < n {
|
||||
match stream.read(&mut buf[pos..]) {
|
||||
Ok(0) => return None,
|
||||
Ok(k) => pos += k,
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
pub fn read_message(stream: &mut UnixStream) -> Option<serde_json::Value> {
|
||||
let header = recv_exact(stream, 4)?;
|
||||
let length = u32::from_ne_bytes(header[..4].try_into().ok()?) as usize;
|
||||
if length == 0 || length > MAX_MSG {
|
||||
return None;
|
||||
}
|
||||
let data = recv_exact(stream, length)?;
|
||||
serde_json::from_slice(&data).ok()
|
||||
}
|
||||
|
||||
pub fn send_message(stream: &mut UnixStream, msg: &serde_json::Value) {
|
||||
let data = serde_json::to_vec(msg).unwrap();
|
||||
let len_bytes = (data.len() as u32).to_ne_bytes();
|
||||
let _ = stream.write_all(&len_bytes);
|
||||
let _ = stream.write_all(&data);
|
||||
}
|
||||
|
||||
pub fn serve<F>(sock_path: &Path, mut handler: F)
|
||||
where
|
||||
F: FnMut(serde_json::Value) -> Option<serde_json::Value>,
|
||||
{
|
||||
if sock_path.exists() {
|
||||
std::fs::remove_file(sock_path).ok();
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(sock_path).unwrap_or_else(|e| {
|
||||
crate::log::fatal(&format!("bind failed: {e}"));
|
||||
});
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(sock_path, std::fs::Permissions::from_mode(0o600)).ok();
|
||||
}
|
||||
|
||||
let cleanup = || {
|
||||
if sock_path.exists() {
|
||||
std::fs::remove_file(sock_path).ok();
|
||||
}
|
||||
};
|
||||
|
||||
ctrlc_cleanup(sock_path.to_path_buf());
|
||||
|
||||
for stream in listener.incoming() {
|
||||
match stream {
|
||||
Ok(mut conn) => {
|
||||
crate::log::info("client connected");
|
||||
handle_conn(&mut conn, &mut handler);
|
||||
crate::log::info("client disconnected");
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
||||
fn ctrlc_cleanup(path: PathBuf) {
|
||||
ctrlc::set_handler(move || {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
std::process::exit(0);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn handle_conn<F>(conn: &mut UnixStream, handler: &mut F)
|
||||
where
|
||||
F: FnMut(serde_json::Value) -> Option<serde_json::Value>,
|
||||
{
|
||||
loop {
|
||||
let msg = match read_message(conn) {
|
||||
Some(m) => m,
|
||||
None => break,
|
||||
};
|
||||
|
||||
if msg.get("command").is_some() && msg.get("appId").is_none() {
|
||||
if let Some(cmd) = msg.get("command").and_then(|c| c.as_str()) {
|
||||
crate::log::info(&format!("proxy: {cmd}"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(resp) = handler(msg) {
|
||||
send_message(conn, &resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/log.rs
Normal file
26
src/log.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
|
||||
static START: OnceLock<Instant> = OnceLock::new();
|
||||
|
||||
fn ts() -> f64 {
|
||||
let start = START.get_or_init(Instant::now);
|
||||
start.elapsed().as_secs_f64()
|
||||
}
|
||||
|
||||
pub fn info(msg: &str) {
|
||||
eprintln!("[{:8.3}] {msg}", ts());
|
||||
}
|
||||
|
||||
pub fn warn(msg: &str) {
|
||||
eprintln!("[{:8.3}] WARN {msg}", ts());
|
||||
}
|
||||
|
||||
pub fn error(msg: &str) {
|
||||
eprintln!("[{:8.3}] ERROR {msg}", ts());
|
||||
}
|
||||
|
||||
pub fn fatal(msg: &str) -> ! {
|
||||
error(msg);
|
||||
std::process::exit(1);
|
||||
}
|
||||
105
src/main.rs
Normal file
105
src/main.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
mod askpass;
|
||||
mod auth;
|
||||
mod bridge;
|
||||
mod crypto;
|
||||
mod ipc;
|
||||
mod log;
|
||||
mod storage;
|
||||
|
||||
use clap::Parser;
|
||||
use sha2::{Digest, Sha256};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use askpass::get_prompter;
|
||||
use bridge::BiometricBridge;
|
||||
use storage::get_backend;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(about = "Bitwarden desktop bridge agent")]
|
||||
struct Args {
|
||||
#[arg(long)]
|
||||
email: String,
|
||||
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
|
||||
#[arg(long, default_value = "https://vault.bitwarden.com")]
|
||||
server: String,
|
||||
|
||||
#[arg(long)]
|
||||
backend: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
askpass: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
enroll: bool,
|
||||
|
||||
#[arg(long)]
|
||||
remove: bool,
|
||||
}
|
||||
|
||||
fn user_hash(email: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(email.to_lowercase().trim().as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
hex::encode(&hash[..8])
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
let uid = user_hash(&args.email);
|
||||
let store = get_backend(args.backend.as_deref());
|
||||
let prompt = get_prompter(args.askpass.as_deref());
|
||||
log::info(&format!("backend: {}", store.name()));
|
||||
|
||||
if args.remove {
|
||||
if store.has_key(&uid) {
|
||||
store.remove(&uid);
|
||||
log::info(&format!("key removed for {}", args.email));
|
||||
} else {
|
||||
log::info(&format!("no key found for {}", args.email));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if !store.has_key(&uid) || args.enroll {
|
||||
log::info(if !store.has_key(&uid) {
|
||||
"enrolling"
|
||||
} else {
|
||||
"re-enrolling"
|
||||
});
|
||||
|
||||
let pw = args
|
||||
.password
|
||||
.clone()
|
||||
.or_else(|| prompt("master password:"))
|
||||
.unwrap_or_else(|| log::fatal("no password provided"));
|
||||
|
||||
log::info(&format!("logging in as {}", args.email));
|
||||
let (mut key_bytes, server_uid) = auth::login(&args.email, &pw, &args.server, &prompt);
|
||||
log::info(&format!("authenticated, uid={server_uid}"));
|
||||
|
||||
let auth = prompt(&format!("choose {} password:", store.name()))
|
||||
.unwrap_or_else(|| log::fatal("no password provided"));
|
||||
let auth2 = prompt(&format!("confirm {} password:", store.name()))
|
||||
.unwrap_or_else(|| log::fatal("no password provided"));
|
||||
if auth != auth2 {
|
||||
log::fatal("passwords don't match");
|
||||
}
|
||||
|
||||
store
|
||||
.store(&uid, &key_bytes, &auth)
|
||||
.unwrap_or_else(|e| log::fatal(&format!("store failed: {e}")));
|
||||
key_bytes.zeroize();
|
||||
log::info(&format!("key sealed via {}", store.name()));
|
||||
log::info("wiped key from memory");
|
||||
} else {
|
||||
log::info(&format!("key ready for {}", args.email));
|
||||
}
|
||||
|
||||
let mut bridge = BiometricBridge::new(store, uid, prompt);
|
||||
let sock = ipc::socket_path();
|
||||
log::info(&format!("listening on {}", sock.display()));
|
||||
ipc::serve(&sock, |msg| bridge.handle(msg));
|
||||
}
|
||||
90
src/proxy.rs
Normal file
90
src/proxy.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use std::io::{self, Read, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
|
||||
const MAX_MSG: usize = 1024 * 1024;
|
||||
|
||||
fn socket_path() -> String {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
|
||||
PathBuf::from(home)
|
||||
.join(".cache")
|
||||
.join("com.bitwarden.desktop")
|
||||
.join("s.bw")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn read_stdin() -> Option<Vec<u8>> {
|
||||
let mut header = [0u8; 4];
|
||||
io::stdin().read_exact(&mut header).ok()?;
|
||||
let length = u32::from_ne_bytes(header) as usize;
|
||||
if length == 0 || length > MAX_MSG {
|
||||
return None;
|
||||
}
|
||||
let mut data = vec![0u8; length];
|
||||
io::stdin().read_exact(&mut data).ok()?;
|
||||
Some(data)
|
||||
}
|
||||
|
||||
fn write_stdout(data: &[u8]) {
|
||||
let len_bytes = (data.len() as u32).to_ne_bytes();
|
||||
let stdout = io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
let _ = out.write_all(&len_bytes);
|
||||
let _ = out.write_all(data);
|
||||
let _ = out.flush();
|
||||
}
|
||||
|
||||
fn recv_exact(sock: &mut UnixStream, n: usize) -> Option<Vec<u8>> {
|
||||
let mut buf = vec![0u8; n];
|
||||
let mut pos = 0;
|
||||
while pos < n {
|
||||
match sock.read(&mut buf[pos..]) {
|
||||
Ok(0) => return None,
|
||||
Ok(k) => pos += k,
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
fn read_ipc(sock: &mut UnixStream) -> Option<Vec<u8>> {
|
||||
let header = recv_exact(sock, 4)?;
|
||||
let length = u32::from_ne_bytes(header[..4].try_into().ok()?) as usize;
|
||||
if length == 0 || length > MAX_MSG {
|
||||
return None;
|
||||
}
|
||||
recv_exact(sock, length)
|
||||
}
|
||||
|
||||
fn send_ipc(sock: &mut UnixStream, data: &[u8]) {
|
||||
let len_bytes = (data.len() as u32).to_ne_bytes();
|
||||
let _ = sock.write_all(&len_bytes);
|
||||
let _ = sock.write_all(data);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut sock = UnixStream::connect(socket_path()).unwrap_or_else(|_| std::process::exit(1));
|
||||
|
||||
send_ipc(&mut sock, b"{\"command\":\"connected\"}");
|
||||
|
||||
let mut sock2 = sock.try_clone().unwrap();
|
||||
thread::spawn(move || {
|
||||
loop {
|
||||
match read_ipc(&mut sock2) {
|
||||
Some(msg) => write_stdout(&msg),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
match read_stdin() {
|
||||
Some(msg) => send_ipc(&mut sock, &msg),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = send_ipc(&mut sock, b"{\"command\":\"disconnected\"}");
|
||||
}
|
||||
17
src/storage/mod.rs
Normal file
17
src/storage/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
pub mod pin;
|
||||
|
||||
pub trait KeyStore {
|
||||
fn name(&self) -> &str;
|
||||
fn is_available(&self) -> bool;
|
||||
fn has_key(&self, uid: &str) -> bool;
|
||||
fn store(&self, uid: &str, data: &[u8], auth: &str) -> Result<(), String>;
|
||||
fn load(&self, uid: &str, auth: &str) -> Result<Vec<u8>, String>;
|
||||
fn remove(&self, uid: &str);
|
||||
}
|
||||
|
||||
pub fn get_backend(preferred: Option<&str>) -> Box<dyn KeyStore> {
|
||||
match preferred {
|
||||
Some("pin") => Box::new(pin::PinKeyStore::new(None)),
|
||||
_ => Box::new(pin::PinKeyStore::new(None)),
|
||||
}
|
||||
}
|
||||
124
src/storage/pin.rs
Normal file
124
src/storage/pin.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
use rand::RngCore;
|
||||
use scrypt::{scrypt, Params};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::KeyStore;
|
||||
|
||||
const VERSION: u8 = 1;
|
||||
const SCRYPT_LOG_N: u8 = 17;
|
||||
const SCRYPT_R: u32 = 8;
|
||||
const SCRYPT_P: u32 = 1;
|
||||
|
||||
fn store_dir() -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
|
||||
PathBuf::from(home)
|
||||
.join(".cache")
|
||||
.join("com.bitwarden.desktop")
|
||||
.join("keys")
|
||||
}
|
||||
|
||||
pub struct PinKeyStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl PinKeyStore {
|
||||
pub fn new(dir: Option<PathBuf>) -> Self {
|
||||
let dir = dir.unwrap_or_else(store_dir);
|
||||
fs::create_dir_all(&dir).ok();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).ok();
|
||||
}
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
fn path(&self, uid: &str) -> PathBuf {
|
||||
self.dir.join(format!("{uid}.enc"))
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyStore for PinKeyStore {
|
||||
fn name(&self) -> &str {
|
||||
"pin"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn has_key(&self, uid: &str) -> bool {
|
||||
self.path(uid).exists()
|
||||
}
|
||||
|
||||
fn store(&self, uid: &str, data: &[u8], auth: &str) -> Result<(), String> {
|
||||
let mut salt = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut salt);
|
||||
|
||||
let key = derive(auth, &salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ct = cipher
|
||||
.encrypt(nonce, aes_gcm::aead::Payload { msg: data, aad: &salt })
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut blob = Vec::with_capacity(1 + 32 + 12 + ct.len());
|
||||
blob.push(VERSION);
|
||||
blob.extend_from_slice(&salt);
|
||||
blob.extend_from_slice(&nonce_bytes);
|
||||
blob.extend_from_slice(&ct);
|
||||
|
||||
let path = self.path(uid);
|
||||
fs::write(&path, &blob).map_err(|e| e.to_string())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).ok();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load(&self, uid: &str, auth: &str) -> Result<Vec<u8>, String> {
|
||||
let blob = fs::read(self.path(uid)).map_err(|e| e.to_string())?;
|
||||
if blob.len() <= 1 + 32 + 12 + 16 {
|
||||
return Err("file too short".into());
|
||||
}
|
||||
|
||||
let salt = &blob[1..33];
|
||||
let nonce_bytes = &blob[33..45];
|
||||
let ct = &blob[45..];
|
||||
|
||||
let key = derive(auth, salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?;
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
cipher
|
||||
.decrypt(nonce, aes_gcm::aead::Payload { msg: ct, aad: salt })
|
||||
.map_err(|_| "wrong password or corrupted data".into())
|
||||
}
|
||||
|
||||
fn remove(&self, uid: &str) {
|
||||
let p = self.path(uid);
|
||||
if p.exists() {
|
||||
fs::remove_file(p).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn derive(password: &str, salt: &[u8]) -> Result<Zeroizing<Vec<u8>>, String> {
|
||||
let params = Params::new(SCRYPT_LOG_N, SCRYPT_R, SCRYPT_P, 32).map_err(|e| e.to_string())?;
|
||||
let mut key = Zeroizing::new(vec![0u8; 32]);
|
||||
scrypt(password.as_bytes(), salt, ¶ms, &mut key).map_err(|e| e.to_string())?;
|
||||
Ok(key)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class KeyStore(ABC):
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool: ...
|
||||
|
||||
@abstractmethod
|
||||
def has_key(self, user_id: str) -> bool: ...
|
||||
|
||||
@abstractmethod
|
||||
def store(self, user_id: str, data: bytes, auth: str) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def load(self, user_id: str, auth: str) -> bytes: ...
|
||||
|
||||
@abstractmethod
|
||||
def remove(self, user_id: str) -> None: ...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str: ...
|
||||
|
||||
|
||||
def get_backend(preferred: str | None = None) -> KeyStore:
|
||||
from .tpm2 import TPM2KeyStore
|
||||
from .pin import PinKeyStore
|
||||
|
||||
if preferred == "pin":
|
||||
return PinKeyStore()
|
||||
if preferred == "tpm2":
|
||||
store = TPM2KeyStore()
|
||||
if not store.is_available():
|
||||
raise RuntimeError("TPM2 not available")
|
||||
return store
|
||||
|
||||
tpm = TPM2KeyStore()
|
||||
if tpm.is_available():
|
||||
return tpm
|
||||
return PinKeyStore()
|
||||
@@ -1,65 +0,0 @@
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
from . import KeyStore
|
||||
|
||||
VERSION = 1
|
||||
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):
|
||||
def __init__(self, store_dir: Path = STORE_DIR):
|
||||
self._dir = store_dir
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(str(self._dir), 0o700)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "pin"
|
||||
|
||||
def _path(self, uid: str) -> Path:
|
||||
return self._dir / f"{uid}.enc"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
def has_key(self, uid: str) -> bool:
|
||||
return self._path(uid).exists()
|
||||
|
||||
def store(self, uid: str, data: bytes, auth: str):
|
||||
salt = os.urandom(32)
|
||||
key = _derive(auth, salt)
|
||||
nonce = os.urandom(12)
|
||||
ct = AESGCM(key).encrypt(nonce, data, salt)
|
||||
blob = bytes([VERSION]) + salt + nonce + ct
|
||||
self._path(uid).write_bytes(blob)
|
||||
os.chmod(str(self._path(uid)), 0o600)
|
||||
|
||||
def load(self, uid: str, auth: str) -> bytes:
|
||||
blob = self._path(uid).read_bytes()
|
||||
_ver, salt, nonce, ct = blob[0], blob[1:33], blob[33:45], blob[45:]
|
||||
key = _derive(auth, salt)
|
||||
try:
|
||||
return AESGCM(key).decrypt(nonce, ct, salt)
|
||||
except Exception:
|
||||
raise ValueError("wrong password or corrupted data")
|
||||
|
||||
def remove(self, uid: str):
|
||||
p = self._path(uid)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
|
||||
def _derive(password: str, salt: bytes) -> bytes:
|
||||
return hashlib.scrypt(
|
||||
password.encode(), salt=salt,
|
||||
n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P, dklen=32, maxmem=SCRYPT_MAXMEM,
|
||||
)
|
||||
@@ -1,76 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from . import KeyStore
|
||||
|
||||
STORE_DIR = Path.home() / ".cache" / "com.bitwarden.desktop" / "tpm2"
|
||||
|
||||
|
||||
class TPM2KeyStore(KeyStore):
|
||||
def __init__(self, store_dir: Path = STORE_DIR):
|
||||
self._dir = store_dir
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(str(self._dir), 0o700)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "tpm2"
|
||||
|
||||
def _pub(self, uid: str) -> Path:
|
||||
return self._dir / f"{uid}.pub"
|
||||
|
||||
def _priv(self, uid: str) -> Path:
|
||||
return self._dir / f"{uid}.priv"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
try:
|
||||
return subprocess.run(
|
||||
["tpm2_getcap", "properties-fixed"],
|
||||
capture_output=True, timeout=5,
|
||||
).returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
def has_key(self, uid: str) -> bool:
|
||||
return self._pub(uid).exists() and self._priv(uid).exists()
|
||||
|
||||
def store(self, uid: str, data: bytes, auth: str):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
t = Path(tmp)
|
||||
ctx = t / "primary.ctx"
|
||||
dat = t / "data.bin"
|
||||
dat.write_bytes(data)
|
||||
os.chmod(str(dat), 0o600)
|
||||
|
||||
_run(["tpm2_createprimary", "-C", "o", "-g", "sha256", "-G", "aes256cfb", "-c", str(ctx)])
|
||||
_run(["tpm2_create", "-C", str(ctx), "-i", str(dat),
|
||||
"-u", str(self._pub(uid)), "-r", str(self._priv(uid)), "-p", auth])
|
||||
dat.write_bytes(b"\x00" * len(data))
|
||||
|
||||
os.chmod(str(self._pub(uid)), 0o600)
|
||||
os.chmod(str(self._priv(uid)), 0o600)
|
||||
|
||||
def load(self, uid: str, auth: str) -> bytes:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
t = Path(tmp)
|
||||
ctx = t / "primary.ctx"
|
||||
loaded = t / "loaded.ctx"
|
||||
|
||||
_run(["tpm2_createprimary", "-C", "o", "-g", "sha256", "-G", "aes256cfb", "-c", str(ctx)])
|
||||
_run(["tpm2_load", "-C", str(ctx),
|
||||
"-u", str(self._pub(uid)), "-r", str(self._priv(uid)), "-c", str(loaded)])
|
||||
return _run(["tpm2_unseal", "-c", str(loaded), "-p", auth]).stdout
|
||||
|
||||
def remove(self, uid: str):
|
||||
for p in (self._pub(uid), self._priv(uid)):
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
|
||||
def _run(cmd: list[str]) -> subprocess.CompletedProcess:
|
||||
r = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(r.stderr.decode(errors="replace").strip())
|
||||
return r
|
||||
Reference in New Issue
Block a user