stealth-message-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stealth_cli/config.py ADDED
@@ -0,0 +1,132 @@
1
+ """Configuration and key persistence (platformdirs-based).
2
+
3
+ Directory layout (example on macOS):
4
+ ~/Library/Application Support/stealth-message/
5
+ ├── config.json ← alias and settings
6
+ └── keys/
7
+ ├── private.asc ← ASCII-armored private key (mode 0600)
8
+ └── public.asc ← ASCII-armored public key (mode 0644)
9
+
10
+ The private key file is stored with permissions 0600 so only the owning
11
+ user can read it. The passphrase is NEVER persisted anywhere on disk.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import stat
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ from platformdirs import user_config_dir
23
+
24
+ APP_NAME = "stealth-message"
25
+
26
+
27
+ # --------------------------------------------------------------------------- #
28
+ # Paths #
29
+ # --------------------------------------------------------------------------- #
30
+
31
+
32
+ def _config_dir() -> Path:
33
+ return Path(user_config_dir(APP_NAME))
34
+
35
+
36
+ def _keys_dir() -> Path:
37
+ return _config_dir() / "keys"
38
+
39
+
40
+ def config_file() -> Path:
41
+ return _config_dir() / "config.json"
42
+
43
+
44
+ def private_key_file() -> Path:
45
+ return _keys_dir() / "private.asc"
46
+
47
+
48
+ def public_key_file() -> Path:
49
+ return _keys_dir() / "public.asc"
50
+
51
+
52
+ # --------------------------------------------------------------------------- #
53
+ # First-use detection #
54
+ # --------------------------------------------------------------------------- #
55
+
56
+
57
+ def is_first_use() -> bool:
58
+ """Return True if no saved keypair exists yet."""
59
+ return not (private_key_file().exists() and public_key_file().exists())
60
+
61
+
62
+ # --------------------------------------------------------------------------- #
63
+ # Persistence #
64
+ # --------------------------------------------------------------------------- #
65
+
66
+
67
+ def save_keypair(
68
+ armored_private: str,
69
+ armored_public: str,
70
+ alias: str,
71
+ ) -> None:
72
+ """Persist the PGP keypair and alias to disk.
73
+
74
+ The private key is written with mode 0600 (owner read/write only).
75
+
76
+ Args:
77
+ armored_private: ASCII-armored PGP private key block.
78
+ armored_public: ASCII-armored PGP public key block.
79
+ alias: Human-readable name stored in config.json.
80
+ """
81
+ _config_dir().mkdir(parents=True, exist_ok=True)
82
+ _keys_dir().mkdir(parents=True, exist_ok=True)
83
+
84
+ # Write private key — restrictive permissions.
85
+ priv_path = private_key_file()
86
+ priv_path.write_text(armored_private, encoding="utf-8")
87
+ priv_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600
88
+
89
+ # Write public key — readable by owner.
90
+ pub_path = public_key_file()
91
+ pub_path.write_text(armored_public, encoding="utf-8")
92
+ pub_path.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) # 0644
93
+
94
+ # Write config.
95
+ cfg = {"alias": alias}
96
+ config_file().write_text(json.dumps(cfg, indent=2), encoding="utf-8")
97
+
98
+
99
+ def load_alias() -> Optional[str]:
100
+ """Load the stored alias from config.json, or None if not found."""
101
+ path = config_file()
102
+ if not path.exists():
103
+ return None
104
+ try:
105
+ data = json.loads(path.read_text(encoding="utf-8"))
106
+ return str(data.get("alias", "")) or None
107
+ except (json.JSONDecodeError, OSError):
108
+ return None
109
+
110
+
111
+ def load_armored_private() -> str:
112
+ """Read the ASCII-armored private key from disk.
113
+
114
+ Raises:
115
+ FileNotFoundError: If the key file does not exist (first use).
116
+ OSError: On permission or I/O errors.
117
+ """
118
+ return private_key_file().read_text(encoding="utf-8")
119
+
120
+
121
+ def load_armored_public() -> str:
122
+ """Read the ASCII-armored public key from disk."""
123
+ return public_key_file().read_text(encoding="utf-8")
124
+
125
+
126
+ def delete_keypair() -> None:
127
+ """Delete the stored keypair and config, reverting to first-use state."""
128
+ for path in (private_key_file(), public_key_file(), config_file()):
129
+ try:
130
+ path.unlink()
131
+ except FileNotFoundError:
132
+ pass
File without changes
@@ -0,0 +1,120 @@
1
+ """PGP key generation, loading and fingerprint utilities.
2
+
3
+ All public functions accept and return ``str`` (ASCII-armored PGP blocks).
4
+ Internal conversion to/from binary is handled here; callers never touch bytes.
5
+
6
+ Security notes:
7
+ - Private keys returned by ``load_private_key`` are locked (protected).
8
+ Callers must use ``key.unlock(passphrase)`` as a context manager to perform
9
+ crypto operations, keeping the passphrase in memory only for the duration.
10
+ - Passphrases are never logged, stored, or included in exceptions.
11
+ """
12
+
13
+ import pgpy
14
+ from pgpy.constants import (
15
+ CompressionAlgorithm,
16
+ HashAlgorithm,
17
+ KeyFlags,
18
+ PubKeyAlgorithm,
19
+ SymmetricKeyAlgorithm,
20
+ )
21
+
22
+
23
+ def generate_keypair(alias: str, passphrase: str) -> tuple[str, str]:
24
+ """Generate a passphrase-protected RSA-4096 keypair.
25
+
26
+ Args:
27
+ alias: Human-readable name embedded in the key UID (max 64 chars).
28
+ passphrase: Passphrase used to protect the private key.
29
+
30
+ Returns:
31
+ A ``(armored_private, armored_public)`` tuple of ASCII-armored strings.
32
+ """
33
+ key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 4096)
34
+ uid = pgpy.PGPUID.new(alias)
35
+ key.add_uid(
36
+ uid,
37
+ usage={
38
+ KeyFlags.Sign,
39
+ KeyFlags.EncryptCommunications,
40
+ KeyFlags.EncryptStorage,
41
+ },
42
+ hashes=[HashAlgorithm.SHA512, HashAlgorithm.SHA384, HashAlgorithm.SHA256],
43
+ ciphers=[SymmetricKeyAlgorithm.AES256],
44
+ compression=[CompressionAlgorithm.ZLIB, CompressionAlgorithm.Uncompressed],
45
+ )
46
+ key.protect(passphrase, SymmetricKeyAlgorithm.AES256, HashAlgorithm.SHA256)
47
+
48
+ armored_private = str(key)
49
+ armored_public = str(key.pubkey)
50
+ return armored_private, armored_public
51
+
52
+
53
+ def load_private_key(armored: str, passphrase: str) -> pgpy.PGPKey:
54
+ """Load and validate a passphrase-protected private key.
55
+
56
+ The returned key is locked. Use ``with key.unlock(passphrase)`` to
57
+ temporarily unlock it for crypto operations.
58
+
59
+ Args:
60
+ armored: ASCII-armored PGP private key block.
61
+ passphrase: Passphrase that protects the key.
62
+
63
+ Returns:
64
+ The loaded ``pgpy.PGPKey`` in locked (protected) state.
65
+
66
+ Raises:
67
+ ValueError: If ``armored`` does not contain a private key.
68
+ pgpy.errors.PGPDecryptionError: If ``passphrase`` is incorrect.
69
+ """
70
+ if "BEGIN PGP PRIVATE KEY BLOCK" not in armored:
71
+ raise ValueError("armored does not contain a private key")
72
+
73
+ key, _ = pgpy.PGPKey.from_blob(armored)
74
+
75
+ # Validate the passphrase immediately; raises PGPDecryptionError if wrong.
76
+ with key.unlock(passphrase):
77
+ pass
78
+
79
+ return key
80
+
81
+
82
+ def load_public_key(armored: str) -> pgpy.PGPKey:
83
+ """Load a PGP public key from an ASCII-armored block.
84
+
85
+ Args:
86
+ armored: ASCII-armored PGP public key block.
87
+
88
+ Returns:
89
+ The loaded ``pgpy.PGPKey`` (public only).
90
+
91
+ Raises:
92
+ ValueError: If ``armored`` contains a private key instead of a public key.
93
+ """
94
+ if "BEGIN PGP PRIVATE KEY BLOCK" in armored:
95
+ raise ValueError(
96
+ "armored contains a private key; use load_private_key instead"
97
+ )
98
+
99
+ key, _ = pgpy.PGPKey.from_blob(armored)
100
+ return key
101
+
102
+
103
+ def get_fingerprint(armored_public: str) -> str:
104
+ """Return the fingerprint of a public key formatted in groups of 4 characters.
105
+
106
+ Example output: ``"A1B2 C3D4 E5F6 7890 ABCD EF12 3456 7890 ABCD EF12"``
107
+
108
+ The grouped format is the standard presentation for manual out-of-band
109
+ verification between users (see docs/protocol.md §6).
110
+
111
+ Args:
112
+ armored_public: ASCII-armored PGP public key block.
113
+
114
+ Returns:
115
+ 40-character hex fingerprint split into 10 groups of 4, separated
116
+ by single spaces (total length: 49 characters).
117
+ """
118
+ key = load_public_key(armored_public)
119
+ raw = str(key.fingerprint).upper()
120
+ return " ".join(raw[i : i + 4] for i in range(0, len(raw), 4))
@@ -0,0 +1,130 @@
1
+ """PGP message encryption and decryption (protocol.md §2.1).
2
+
3
+ All public functions accept and return ``str``. Internal conversion between
4
+ ``str``, ``bytes`` and pgpy objects is handled here; callers never touch bytes.
5
+
6
+ Caller contract:
7
+ The ``pgpy.PGPKey`` arguments must be **already unlocked** via
8
+ ``with key.unlock(passphrase)`` before calling these functions.
9
+ Managing the passphrase and the unlock lifetime is the UI layer's
10
+ responsibility (cli/stealth_cli/ui/), not this module's.
11
+
12
+ Encoding pipeline (encrypt):
13
+ plaintext str
14
+ → pgpy.PGPMessage (literal data packet)
15
+ → sign with sender's private key (inline one-pass signature)
16
+ → encrypt with recipient's public key (PGP encrypted message)
17
+ → ASCII-armored str
18
+ → Base64 URL-safe str ← this is the JSON "payload" field
19
+
20
+ Decoding pipeline (decrypt):
21
+ Base64 URL-safe str (JSON "payload" field)
22
+ → ASCII-armored str
23
+ → pgpy encrypted message
24
+ → decrypt with recipient's private key
25
+ → verify inline signature with sender's public key
26
+ → plaintext str (only if signature is valid)
27
+ """
28
+
29
+ import base64
30
+
31
+ import pgpy
32
+
33
+ from stealth_cli.crypto.keys import load_public_key
34
+ from stealth_cli.exceptions import SignatureError
35
+
36
+
37
+ def encrypt(
38
+ plaintext: str,
39
+ recipient_pubkey: str,
40
+ sender_privkey: pgpy.PGPKey,
41
+ ) -> str:
42
+ """Encrypt and sign a plaintext message.
43
+
44
+ The output is suitable for use as the ``"payload"`` field in a
45
+ protocol.md §2.1 message JSON object.
46
+
47
+ Args:
48
+ plaintext: UTF-8 text to encrypt.
49
+ recipient_pubkey: ASCII-armored PGP public key of the recipient.
50
+ sender_privkey: Sender's private key, **already unlocked** by the
51
+ caller via ``with sender_privkey.unlock(passphrase)``.
52
+
53
+ Returns:
54
+ Base64 URL-safe string containing the ASCII-armored PGP encrypted
55
+ message (sign-then-encrypt).
56
+ """
57
+ pub = load_public_key(recipient_pubkey)
58
+
59
+ msg = pgpy.PGPMessage.new(plaintext)
60
+
61
+ # Sign with sender's private key (key must be unlocked by the caller).
62
+ msg |= sender_privkey.sign(msg)
63
+
64
+ # Encrypt with recipient's public key.
65
+ encrypted = pub.encrypt(msg)
66
+
67
+ # ASCII-armor → UTF-8 bytes → Base64 URL-safe string.
68
+ armored = str(encrypted)
69
+ return base64.urlsafe_b64encode(armored.encode("utf-8")).decode("ascii")
70
+
71
+
72
+ def decrypt(
73
+ payload: str,
74
+ recipient_privkey: pgpy.PGPKey,
75
+ sender_pubkey: str,
76
+ ) -> str:
77
+ """Decrypt a payload and verify the sender's signature.
78
+
79
+ Implements the decryption and verification steps of protocol.md §2.1.
80
+ The message is returned **only** if the signature is valid; otherwise
81
+ ``SignatureError`` is raised so the caller can discard the message.
82
+
83
+ Args:
84
+ payload: Base64 URL-safe string from the ``"payload"`` JSON field.
85
+ recipient_privkey: Recipient's private key, **already unlocked** by
86
+ the caller via ``with recipient_privkey.unlock(passphrase)``.
87
+ sender_pubkey: ASCII-armored PGP public key of the sender.
88
+
89
+ Returns:
90
+ Decrypted plaintext as a UTF-8 string.
91
+
92
+ Raises:
93
+ SignatureError: If the signature is invalid, missing, or cannot be
94
+ verified with ``sender_pubkey``. Callers must never display the
95
+ plaintext when this exception is raised (protocol.md §2.1).
96
+ """
97
+ # Base64 URL-safe decode → ASCII-armored PGP message.
98
+ # Add padding so urlsafe_b64decode never fails on missing '=' chars.
99
+ armored = base64.urlsafe_b64decode(
100
+ payload.encode("ascii") + b"=="
101
+ ).decode("utf-8")
102
+
103
+ # PGPMessage.from_blob returns the message object directly (unlike
104
+ # PGPKey.from_blob which returns a (key, extras) tuple). Unpacking it
105
+ # would iterate over internal packets — use the raw return value.
106
+ result = pgpy.PGPMessage.from_blob(armored)
107
+ encrypted_msg = result[0] if isinstance(result, tuple) else result
108
+
109
+ # Decrypt (recipient_privkey must already be unlocked by the caller).
110
+ decrypted = recipient_privkey.decrypt(encrypted_msg)
111
+
112
+ # Verify the inline signature with the sender's public key.
113
+ # protocol.md §2.1: "If the signature is not valid, discard the message."
114
+ sender_pub = load_public_key(sender_pubkey)
115
+ try:
116
+ verification = sender_pub.verify(decrypted)
117
+ if not verification:
118
+ raise SignatureError(
119
+ "PGP signature is invalid — message discarded (protocol.md §2.1)"
120
+ )
121
+ except pgpy.errors.PGPError as exc:
122
+ raise SignatureError(
123
+ "PGP signature verification failed — message discarded (protocol.md §2.1)"
124
+ ) from exc
125
+
126
+ # Return plaintext as str; pgpy may give bytes for binary literal packets.
127
+ content = decrypted.message
128
+ if isinstance(content, (bytes, bytearray)):
129
+ return content.decode("utf-8")
130
+ return str(content)
@@ -0,0 +1,29 @@
1
+ """Custom exceptions for stealth-message CLI.
2
+
3
+ All exceptions raised by stealth_cli modules are defined here.
4
+ Network and protocol error codes follow docs/protocol.md §4.
5
+ """
6
+
7
+
8
+ class StealthError(Exception):
9
+ """Base exception for all stealth-message errors."""
10
+
11
+
12
+ class SignatureError(StealthError):
13
+ """Raised when a PGP signature is missing, invalid, or cannot be verified.
14
+
15
+ Callers receiving this exception must discard the message and notify the
16
+ user — never display plaintext from an unverified message (protocol.md §2.1).
17
+ """
18
+
19
+
20
+ class ProtocolError(StealthError):
21
+ """Raised when a received WebSocket message violates the protocol spec.
22
+
23
+ Attributes:
24
+ code: Numeric error code defined in protocol.md §4.
25
+ """
26
+
27
+ def __init__(self, message: str, code: int) -> None:
28
+ super().__init__(message)
29
+ self.code = code
File without changes