Rust implementation

This commit is contained in:
2026-03-20 02:50:17 +09:00
parent 4e0ee51a33
commit 6c9618e54c
27 changed files with 2977 additions and 1022 deletions

3
.gitignore vendored
View File

@@ -1,2 +1 @@
__pycache__/
sep_helper
/target

1740
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

38
Cargo.toml Normal file
View 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

View File

@@ -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
View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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
View File

@@ -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', '')}"

View File

@@ -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
View File

@@ -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
View File

@@ -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)

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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, &params, &mut key).map_err(|e| e.to_string())?;
Ok(key)
}

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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