aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-common')
-rw-r--r--packages/meshbay-common/src/meshbay_common/keyderive.py130
-rw-r--r--packages/meshbay-common/src/meshbay_common/paths.py8
-rw-r--r--packages/meshbay-common/tests/test_keyderive.py57
-rw-r--r--packages/meshbay-common/tests/test_paths.py7
-rw-r--r--packages/meshbay-common/tests/test_portable_name_parity.py1
5 files changed, 14 insertions, 189 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/keyderive.py b/packages/meshbay-common/src/meshbay_common/keyderive.py
deleted file mode 100644
index 4b90af3..0000000
--- a/packages/meshbay-common/src/meshbay_common/keyderive.py
+++ /dev/null
@@ -1,130 +0,0 @@
-"""
-MeshBay — Key derivation from username + password.
-
-Allows Ed25519 + X25519 keypairs to be derived deterministically
-from credentials. Same inputs → same keys on any device.
-
-Algorithm: Argon2id (Python CLI / native clients)
- salt = SHA-256("meshbay:v1:" + username)
- seed = Argon2id(password, salt, length=64, ...)
- sk_ed = Ed25519PrivateKey.from_private_bytes(seed[:32])
- sk_x25519 = X25519PrivateKey.from_private_bytes(seed[32:])
-
-Browser alternative (keyderive.js): uses PBKDF2-SHA512 because
-WebCrypto does not support Argon2. The two algorithms produce
-DIFFERENT keys from the same password — a user registered via Python
-CLI and via web browser will have different keypairs.
-
-Resolution: the web client generates RANDOM keypairs on first login
-(WebCrypto, stored encrypted in hub), and uses derive_keys_from_password
-only to encrypt/decrypt the stored keypair bundle. This avoids the
-algorithm mismatch problem entirely.
-
-See keyderive.js for the browser-side implementation.
-"""
-
-import hashlib
-
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
-from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
-
-# Argon2id parameters — same as keystore (see crypto.py)
-_ITERATIONS = 3
-_MEMORY_COST = 65536 # 64 MB — increase to 262144 for production
-_LANES = 4
-_SEED_LENGTH = 64 # 32 bytes Ed25519 + 32 bytes X25519
-
-
-def _derive_salt(username: str) -> bytes:
- """Deterministic salt: SHA-256 of 'meshbay:v1:<username>'."""
- return hashlib.sha256(f"meshbay:v1:{username}".encode()).digest()
-
-
-def derive_keys_from_password(
- username: str,
- password: str,
-) -> tuple[Ed25519PrivateKey, X25519PrivateKey]:
- """
- Derive Ed25519 + X25519 keypairs deterministically from username + password.
-
- Properties:
- - Same credentials always produce the same keypairs
- - Different usernames produce different keys (even with same password)
- - Password cannot be recovered from the public keys
- - Changing the password invalidates all GEK bundles stored on the hub
-
- Use for:
- - CLI / native node registration (Argon2id available)
- - Recovery of lost keypairs from credentials
-
- Do NOT use for:
- - Web browser registration (use random keypairs + encrypted bundle instead)
- """
- salt = _derive_salt(username)
- kdf = Argon2id(
- salt=salt, length=_SEED_LENGTH,
- iterations=_ITERATIONS, lanes=_LANES, memory_cost=_MEMORY_COST,
- )
- seed = kdf.derive(password.encode())
- return (
- Ed25519PrivateKey.from_private_bytes(seed[:32]),
- X25519PrivateKey.from_private_bytes(seed[32:]),
- )
-
-
-def encrypt_keypair_bundle(
- sk_ed: Ed25519PrivateKey,
- sk_x: X25519PrivateKey,
- password: str,
- username: str,
-) -> bytes:
- """
- Encrypt a keypair bundle with a password-derived key (for hub storage).
- Used by web clients: random keypairs encrypted with password, stored on hub.
- Returns: AES-256-GCM ciphertext (nonce prepended).
- """
- import os
-
- import msgpack
- from cryptography.hazmat.primitives.ciphers.aead import AESGCM
-
- from meshbay_common.crypto import sk_to_raw
-
- # Derive an AES key from the password (different info string from key derivation)
- salt = hashlib.sha256(f"meshbay:bundle:v1:{username}".encode()).digest()
- kdf = Argon2id(salt=salt, length=32, iterations=_ITERATIONS,
- lanes=_LANES, memory_cost=_MEMORY_COST)
- aes_key = kdf.derive(password.encode())
-
- payload = msgpack.packb({
- "sk_ed": sk_to_raw(sk_ed),
- "sk_x": sk_to_raw(sk_x),
- }, use_bin_type=True)
-
- nonce = os.urandom(12)
- ct = AESGCM(aes_key).encrypt(nonce, payload, None)
- return nonce + ct
-
-
-def decrypt_keypair_bundle(
- bundle: bytes,
- password: str,
- username: str,
-) -> tuple[Ed25519PrivateKey, X25519PrivateKey]:
- """Decrypt a keypair bundle. Raises on wrong password."""
- import msgpack
- from cryptography.hazmat.primitives.ciphers.aead import AESGCM
-
- salt = hashlib.sha256(f"meshbay:bundle:v1:{username}".encode()).digest()
- kdf = Argon2id(salt=salt, length=32, iterations=_ITERATIONS,
- lanes=_LANES, memory_cost=_MEMORY_COST)
- aes_key = kdf.derive(password.encode())
-
- nonce, ct = bundle[:12], bundle[12:]
- payload = AESGCM(aes_key).decrypt(nonce, ct, None)
- data = msgpack.unpackb(payload, raw=False)
- return (
- Ed25519PrivateKey.from_private_bytes(data["sk_ed"]),
- X25519PrivateKey.from_private_bytes(data["sk_x"]),
- )
diff --git a/packages/meshbay-common/src/meshbay_common/paths.py b/packages/meshbay-common/src/meshbay_common/paths.py
index b673649..8be4680 100644
--- a/packages/meshbay-common/src/meshbay_common/paths.py
+++ b/packages/meshbay-common/src/meshbay_common/paths.py
@@ -30,8 +30,12 @@ WINDOWS_RESERVED = frozenset({
*(f"LPT{i}" for i in range(1, 10)),
})
-# Reserved on Windows; `/` is reserved everywhere. Control characters go too.
-_RESERVED_CHARS = set('<>:"/\\|?*') | {chr(c) for c in range(32)}
+# Reserved on Windows; `/` is reserved everywhere. Control characters go too,
+# and so do the bidirectional controls: "invoice\u202efdp.exe" displays as
+# "invoiceexe.pdf", and a saved name must say what the file is.
+BIDI_CONTROLS = frozenset("\u061c\u200e\u200f\u202a\u202b\u202c\u202d\u202e"
+ "\u2066\u2067\u2068\u2069")
+_RESERVED_CHARS = set('<>:"/\\|?*') | {chr(c) for c in range(32)} | BIDI_CONTROLS
# Windows without long-path support. A deep media library reaches this.
MAX_PATH_WINDOWS = 260
diff --git a/packages/meshbay-common/tests/test_keyderive.py b/packages/meshbay-common/tests/test_keyderive.py
deleted file mode 100644
index 40b3c71..0000000
--- a/packages/meshbay-common/tests/test_keyderive.py
+++ /dev/null
@@ -1,57 +0,0 @@
-"""Tests for password-based key derivation."""
-
-import pytest
-from meshbay_common.crypto import pk_to_b64
-from meshbay_common.keyderive import (
- decrypt_keypair_bundle,
- derive_keys_from_password,
- encrypt_keypair_bundle,
-)
-
-
-def test_deterministic():
- """Same credentials → same keys."""
- sk_ed1, sk_x1 = derive_keys_from_password("alice", "correct-horse")
- sk_ed2, sk_x2 = derive_keys_from_password("alice", "correct-horse")
- assert pk_to_b64(sk_ed1.public_key()) == pk_to_b64(sk_ed2.public_key())
- assert pk_to_b64(sk_x1.public_key()) == pk_to_b64(sk_x2.public_key())
-
-
-def test_different_users_different_keys():
- sk_ed_a, _ = derive_keys_from_password("alice", "samepassword")
- sk_ed_b, _ = derive_keys_from_password("bob", "samepassword")
- assert pk_to_b64(sk_ed_a.public_key()) != pk_to_b64(sk_ed_b.public_key())
-
-
-def test_different_passwords_different_keys():
- sk_ed1, _ = derive_keys_from_password("alice", "password1")
- sk_ed2, _ = derive_keys_from_password("alice", "password2")
- assert pk_to_b64(sk_ed1.public_key()) != pk_to_b64(sk_ed2.public_key())
-
-
-def test_ed_and_x_keys_independent():
- sk_ed, sk_x = derive_keys_from_password("user", "pass12345")
- from meshbay_common.crypto import sk_to_raw
- assert sk_to_raw(sk_ed) != sk_to_raw(sk_x)
-
-
-def test_bundle_encrypt_decrypt():
- sk_ed, sk_x = derive_keys_from_password("alice", "strongpass!")
- bundle = encrypt_keypair_bundle(sk_ed, sk_x, "password123", "alice")
- sk_ed2, sk_x2 = decrypt_keypair_bundle(bundle, "password123", "alice")
- assert pk_to_b64(sk_ed.public_key()) == pk_to_b64(sk_ed2.public_key())
- assert pk_to_b64(sk_x.public_key()) == pk_to_b64(sk_x2.public_key())
-
-
-def test_bundle_wrong_password_rejected():
- sk_ed, sk_x = derive_keys_from_password("alice", "correctpass")
- bundle = encrypt_keypair_bundle(sk_ed, sk_x, "correctpass", "alice")
- with pytest.raises(Exception):
- decrypt_keypair_bundle(bundle, "wrongpass", "alice")
-
-
-def test_bundle_wrong_username_rejected():
- sk_ed, sk_x = derive_keys_from_password("alice", "pass12345")
- bundle = encrypt_keypair_bundle(sk_ed, sk_x, "pass12345", "alice")
- with pytest.raises(Exception):
- decrypt_keypair_bundle(bundle, "pass12345", "bob") # wrong username salt
diff --git a/packages/meshbay-common/tests/test_paths.py b/packages/meshbay-common/tests/test_paths.py
index 223a2a6..012a32e 100644
--- a/packages/meshbay-common/tests/test_paths.py
+++ b/packages/meshbay-common/tests/test_paths.py
@@ -119,3 +119,10 @@ def test_sanitizing_produces_something_writable():
def test_sanitizing_never_returns_nothing():
assert sanitize_for_download("...") not in ("", None)
assert sanitize_for_download("???") not in ("", None)
+
+
+def test_a_bidi_override_cannot_hide_an_extension():
+ from meshbay_common.paths import portable_name_problem, sanitize_for_download
+ disguised = "invoice‮fdp.exe" # displays as "invoiceexe.pdf"
+ assert sanitize_for_download(disguised) == "invoice_fdp.exe"
+ assert portable_name_problem(disguised)
diff --git a/packages/meshbay-common/tests/test_portable_name_parity.py b/packages/meshbay-common/tests/test_portable_name_parity.py
index 3a491a7..d7df710 100644
--- a/packages/meshbay-common/tests/test_portable_name_parity.py
+++ b/packages/meshbay-common/tests/test_portable_name_parity.py
@@ -26,6 +26,7 @@ pytestmark = pytest.mark.skipif(
)
NAMES = [
+ "invoice\u202efdp.exe", "a\u2066b\u2069.txt", "mark\u200f.txt",
"plain.txt", "Réunion 12:30.pdf", 'a<b>c:d"e/f\\g|h?i*j.txt', "tab\tnew\nline",
"ends with dot.", "ends with space ", "trailing . . ", "CON", "con.txt", "aux.tar.gz",
"COM1", "com10.txt", "LPT9.log", "nul.", ".", "..", "", " ", ".bashrc", "...",