460 lines
16 KiB
Python
460 lines
16 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
MONTANA CLI
|
|||
|
|
Время — единственная реальная валюта
|
|||
|
|
|
|||
|
|
Post-Quantum Security: ML-DSA-65 + ML-KEM-768
|
|||
|
|
|
|||
|
|
Usage:
|
|||
|
|
montana init # Создать кошелёк
|
|||
|
|
montana restore # Восстановить из когнитивного ключа
|
|||
|
|
montana balance # Показать баланс
|
|||
|
|
montana send <addr> <amount> # Перевод
|
|||
|
|
montana status # Статус сервиса
|
|||
|
|
montana presence # Запустить сервис присутствия
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import click
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import json
|
|||
|
|
import hashlib
|
|||
|
|
import getpass
|
|||
|
|
import time
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
# ML-DSA-65
|
|||
|
|
try:
|
|||
|
|
from dilithium_py.ml_dsa import ML_DSA_65
|
|||
|
|
except ImportError:
|
|||
|
|
print("Установи dilithium-py: pip install dilithium-py")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
# ML-KEM-768 (постквантовое шифрование ключей)
|
|||
|
|
try:
|
|||
|
|
from mlkem768 import encrypt_private_key, decrypt_private_key, check_dependencies as check_mlkem
|
|||
|
|
HAS_MLKEM = check_mlkem()
|
|||
|
|
except ImportError:
|
|||
|
|
HAS_MLKEM = False
|
|||
|
|
print("Предупреждение: ML-KEM-768 недоступен. Ключи будут храниться без шифрования.")
|
|||
|
|
print("Установи: pip install kyber-py cryptography")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
import requests
|
|||
|
|
except ImportError:
|
|||
|
|
print("Установи requests: pip install requests")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
# Конфигурация
|
|||
|
|
MONTANA_DIR = Path.home() / ".montana"
|
|||
|
|
KEYS_DIR = MONTANA_DIR / "keys"
|
|||
|
|
CONFIG_FILE = MONTANA_DIR / "config.json"
|
|||
|
|
API_URL = "https://efir.org"
|
|||
|
|
|
|||
|
|
VERSION = "1.5.0" # ML-KEM-768 integration
|
|||
|
|
|
|||
|
|
|
|||
|
|
@click.group()
|
|||
|
|
@click.version_option(version=VERSION, prog_name="montana")
|
|||
|
|
def cli():
|
|||
|
|
"""Montana Protocol — Время это деньги"""
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
@cli.command()
|
|||
|
|
def init():
|
|||
|
|
"""Создать новый кошелёк"""
|
|||
|
|
click.echo("Montana Protocol")
|
|||
|
|
click.echo(" Время — единственная реальная валюта\n")
|
|||
|
|
|
|||
|
|
# Проверяем существующий кошелёк
|
|||
|
|
if (KEYS_DIR / "private.key.enc").exists() or (KEYS_DIR / "private.key").exists():
|
|||
|
|
click.echo("Кошелёк уже существует!")
|
|||
|
|
config = load_config()
|
|||
|
|
if config:
|
|||
|
|
click.echo(f" Адрес: {config['address']}")
|
|||
|
|
click.echo("\n Используй 'montana restore' для восстановления на другом устройстве")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
click.echo("Придумай когнитивный ключ — уникальную фразу минимум из 24 слов.")
|
|||
|
|
click.echo("Это твоя история. Только ты её знаешь.\n")
|
|||
|
|
|
|||
|
|
# Ввод когнитивного ключа (скрытый)
|
|||
|
|
cognitive_key = getpass.getpass("Когнитивный ключ: ")
|
|||
|
|
cognitive_key_confirm = getpass.getpass("Подтверди ключ: ")
|
|||
|
|
|
|||
|
|
if cognitive_key != cognitive_key_confirm:
|
|||
|
|
click.echo("Ключи не совпадают")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# Валидация
|
|||
|
|
words = cognitive_key.split()
|
|||
|
|
if len(words) < 24 and len(cognitive_key) < 150:
|
|||
|
|
click.echo(f"Минимум 24 слова или 150 символов (сейчас: {len(words)} слов, {len(cognitive_key)} символов)")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
click.echo("\nГенерирую ключи ML-DSA-65...")
|
|||
|
|
|
|||
|
|
# Генерация ключей из когнитивного ключа
|
|||
|
|
address = generate_keys_from_cognitive(cognitive_key)
|
|||
|
|
|
|||
|
|
# Регистрируем на сервере
|
|||
|
|
try:
|
|||
|
|
register_on_server(address, cognitive_key)
|
|||
|
|
except Exception as e:
|
|||
|
|
click.echo(f"Предупреждение: не удалось зарегистрировать на сервере: {e}")
|
|||
|
|
|
|||
|
|
click.echo(f"\nКошелёк создан!")
|
|||
|
|
click.echo(f" Адрес: {address}")
|
|||
|
|
if HAS_MLKEM:
|
|||
|
|
click.echo(f" Защита: ML-KEM-768 + AES-256-GCM")
|
|||
|
|
click.echo(f"\nВАЖНО: Запомни свой когнитивный ключ!")
|
|||
|
|
click.echo(" Это единственный способ восстановить доступ.")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@cli.command()
|
|||
|
|
def restore():
|
|||
|
|
"""Восстановить кошелёк из когнитивного ключа"""
|
|||
|
|
click.echo("Восстановление кошелька Montana\n")
|
|||
|
|
|
|||
|
|
cognitive_key = getpass.getpass("Введи когнитивный ключ: ")
|
|||
|
|
|
|||
|
|
click.echo("\nВосстанавливаю ключи...")
|
|||
|
|
address = generate_keys_from_cognitive(cognitive_key)
|
|||
|
|
|
|||
|
|
# Проверяем на сервере
|
|||
|
|
try:
|
|||
|
|
resp = requests.get(f"{API_URL}/api/balance/{address}", timeout=10)
|
|||
|
|
if resp.status_code == 200:
|
|||
|
|
data = resp.json()
|
|||
|
|
click.echo(f"\nКошелёк восстановлен!")
|
|||
|
|
click.echo(f" Адрес: {address}")
|
|||
|
|
click.echo(f" Баланс: {data.get('balance', 0)}")
|
|||
|
|
else:
|
|||
|
|
click.echo(f"\nПрофиль не найден на сервере. Создан новый кошелёк.")
|
|||
|
|
click.echo(f" Адрес: {address}")
|
|||
|
|
except Exception as e:
|
|||
|
|
click.echo(f"\nКошелёк создан локально (сервер недоступен: {e})")
|
|||
|
|
click.echo(f" Адрес: {address}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@cli.command()
|
|||
|
|
def balance():
|
|||
|
|
"""Показать баланс"""
|
|||
|
|
config = load_config()
|
|||
|
|
if not config:
|
|||
|
|
click.echo("Сначала выполни: montana init")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
address = config["address"]
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
resp = requests.get(f"{API_URL}/api/balance/{address}", timeout=10)
|
|||
|
|
if resp.status_code == 200:
|
|||
|
|
data = resp.json()
|
|||
|
|
bal = data.get('balance', 0)
|
|||
|
|
click.echo(f"{bal}")
|
|||
|
|
click.echo(f"Адрес: {address}")
|
|||
|
|
else:
|
|||
|
|
click.echo(f"Ошибка: {resp.status_code}")
|
|||
|
|
except Exception as e:
|
|||
|
|
click.echo(f"Ошибка подключения: {e}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@cli.command()
|
|||
|
|
def address():
|
|||
|
|
"""Показать адрес кошелька"""
|
|||
|
|
config = load_config()
|
|||
|
|
if not config:
|
|||
|
|
click.echo("Сначала выполни: montana init")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
click.echo(config["address"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
@cli.command()
|
|||
|
|
@click.argument("to_address")
|
|||
|
|
@click.argument("amount", type=int)
|
|||
|
|
def send(to_address, amount):
|
|||
|
|
"""Отправить на адрес"""
|
|||
|
|
config = load_config()
|
|||
|
|
if not config:
|
|||
|
|
click.echo("Сначала выполни: montana init")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
from_address = config["address"]
|
|||
|
|
|
|||
|
|
click.echo(f"Отправить {amount} на {to_address}?")
|
|||
|
|
if not click.confirm("Подтвердить?"):
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# Загружаем приватный ключ (требует когнитивный ключ если зашифрован)
|
|||
|
|
private_key = load_private_key()
|
|||
|
|
if not private_key:
|
|||
|
|
click.echo("Не удалось загрузить приватный ключ")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
timestamp = int(time.time())
|
|||
|
|
message = f"TRANSFER:{from_address}:{to_address}:{amount}:{timestamp}"
|
|||
|
|
signature = ML_DSA_65.sign(private_key, message.encode())
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
resp = requests.post(f"{API_URL}/api/transfer", json={
|
|||
|
|
"from": from_address,
|
|||
|
|
"to": to_address,
|
|||
|
|
"amount": amount,
|
|||
|
|
"timestamp": timestamp,
|
|||
|
|
"signature": signature.hex()
|
|||
|
|
}, timeout=30)
|
|||
|
|
|
|||
|
|
if resp.status_code == 200:
|
|||
|
|
data = resp.json()
|
|||
|
|
if data.get("success"):
|
|||
|
|
click.echo(f"Отправлено {amount}")
|
|||
|
|
if "new_balance" in data:
|
|||
|
|
click.echo(f"Новый баланс: {data['new_balance']}")
|
|||
|
|
else:
|
|||
|
|
click.echo(f"Ошибка: {data.get('error', 'Unknown')}")
|
|||
|
|
else:
|
|||
|
|
click.echo(f"Ошибка сервера: {resp.status_code}")
|
|||
|
|
except Exception as e:
|
|||
|
|
click.echo(f"Ошибка: {e}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@cli.command()
|
|||
|
|
def status():
|
|||
|
|
"""Статус сервиса присутствия"""
|
|||
|
|
import subprocess
|
|||
|
|
|
|||
|
|
config = load_config()
|
|||
|
|
if config:
|
|||
|
|
click.echo(f"Адрес: {config['address']}")
|
|||
|
|
if config.get("encrypted"):
|
|||
|
|
click.echo("Защита: ML-KEM-768 + AES-256-GCM")
|
|||
|
|
|
|||
|
|
if sys.platform == "darwin":
|
|||
|
|
result = subprocess.run(
|
|||
|
|
["launchctl", "list", "network.montana.presence"],
|
|||
|
|
capture_output=True, text=True
|
|||
|
|
)
|
|||
|
|
if result.returncode == 0:
|
|||
|
|
click.echo("Сервис присутствия: активен")
|
|||
|
|
else:
|
|||
|
|
click.echo("Сервис присутствия: остановлен")
|
|||
|
|
else:
|
|||
|
|
result = subprocess.run(
|
|||
|
|
["systemctl", "is-active", "montana-presence"],
|
|||
|
|
capture_output=True, text=True
|
|||
|
|
)
|
|||
|
|
if "active" in result.stdout:
|
|||
|
|
click.echo("Сервис присутствия: активен")
|
|||
|
|
else:
|
|||
|
|
click.echo("Сервис присутствия: остановлен")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@cli.command()
|
|||
|
|
@click.option("--daemon", is_flag=True, help="Запуск как daemon (без вывода)")
|
|||
|
|
def presence(daemon):
|
|||
|
|
"""Сервис присутствия (Proof of Presence)"""
|
|||
|
|
config = load_config()
|
|||
|
|
if not config:
|
|||
|
|
click.echo("Сначала выполни: montana init")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
address = config["address"]
|
|||
|
|
|
|||
|
|
if not daemon:
|
|||
|
|
click.echo(f"Сервис присутствия запущен")
|
|||
|
|
click.echo(f" Адрес: {address}")
|
|||
|
|
click.echo(" Ctrl+C для остановки\n")
|
|||
|
|
|
|||
|
|
last_sync = 0
|
|||
|
|
presence_seconds = 0
|
|||
|
|
|
|||
|
|
while True:
|
|||
|
|
try:
|
|||
|
|
time.sleep(1)
|
|||
|
|
presence_seconds += 1
|
|||
|
|
|
|||
|
|
# Синхронизация каждые 30 секунд
|
|||
|
|
if time.time() - last_sync >= 30:
|
|||
|
|
try:
|
|||
|
|
resp = requests.post(
|
|||
|
|
f"{API_URL}/api/presence",
|
|||
|
|
headers={"X-Device-ID": address},
|
|||
|
|
json={"seconds": presence_seconds},
|
|||
|
|
timeout=10
|
|||
|
|
)
|
|||
|
|
if resp.status_code == 200:
|
|||
|
|
data = resp.json()
|
|||
|
|
presence_seconds = 0
|
|||
|
|
last_sync = time.time()
|
|||
|
|
if not daemon:
|
|||
|
|
bal = data.get("balance", "?")
|
|||
|
|
click.echo(f" Синхронизировано | Баланс: {bal}")
|
|||
|
|
except Exception as e:
|
|||
|
|
if not daemon:
|
|||
|
|
click.echo(f" Ошибка синхронизации: {e}")
|
|||
|
|
|
|||
|
|
except KeyboardInterrupt:
|
|||
|
|
if not daemon:
|
|||
|
|
click.echo("\nОстановлено")
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
|
|||
|
|
@cli.command()
|
|||
|
|
def stop():
|
|||
|
|
"""Остановить сервис присутствия"""
|
|||
|
|
import subprocess
|
|||
|
|
|
|||
|
|
if sys.platform == "darwin":
|
|||
|
|
subprocess.run(["sudo", "launchctl", "unload",
|
|||
|
|
"/Library/LaunchDaemons/network.montana.presence.plist"],
|
|||
|
|
capture_output=True)
|
|||
|
|
click.echo("Сервис остановлен")
|
|||
|
|
else:
|
|||
|
|
subprocess.run(["sudo", "systemctl", "stop", "montana-presence"],
|
|||
|
|
capture_output=True)
|
|||
|
|
click.echo("Сервис остановлен")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@cli.command()
|
|||
|
|
def start():
|
|||
|
|
"""Запустить сервис присутствия"""
|
|||
|
|
import subprocess
|
|||
|
|
|
|||
|
|
if sys.platform == "darwin":
|
|||
|
|
subprocess.run(["sudo", "launchctl", "load",
|
|||
|
|
"/Library/LaunchDaemons/network.montana.presence.plist"],
|
|||
|
|
capture_output=True)
|
|||
|
|
click.echo("Сервис запущен")
|
|||
|
|
else:
|
|||
|
|
subprocess.run(["sudo", "systemctl", "start", "montana-presence"],
|
|||
|
|
capture_output=True)
|
|||
|
|
click.echo("Сервис запущен")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# === Вспомогательные функции ===
|
|||
|
|
|
|||
|
|
def generate_keys_from_cognitive(cognitive_key: str) -> str:
|
|||
|
|
"""Генерация ML-DSA-65 ключей из когнитивного ключа"""
|
|||
|
|
|
|||
|
|
# Нормализация
|
|||
|
|
normalized = " ".join(cognitive_key.lower().split())
|
|||
|
|
|
|||
|
|
# PBKDF2 (600K итераций) — защита от brute-force
|
|||
|
|
salt = b"MONTANA_COGNITIVE_KEY_V1"
|
|||
|
|
seed = hashlib.pbkdf2_hmac("sha256", normalized.encode(), salt, 600_000, dklen=32)
|
|||
|
|
|
|||
|
|
# Детерминированная генерация ML-DSA-65
|
|||
|
|
import random
|
|||
|
|
random.seed(int.from_bytes(seed, 'big'))
|
|||
|
|
|
|||
|
|
public_key, private_key = ML_DSA_65.keygen()
|
|||
|
|
|
|||
|
|
# Адрес = mt + SHA256(pubkey)[:20].hex()
|
|||
|
|
address = "mt" + hashlib.sha256(public_key).hexdigest()[:40]
|
|||
|
|
|
|||
|
|
# Создаём директории
|
|||
|
|
MONTANA_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
KEYS_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
# Сохраняем ключи
|
|||
|
|
if HAS_MLKEM:
|
|||
|
|
# Шифруем приватный ключ с ML-KEM-768
|
|||
|
|
encrypted = encrypt_private_key(private_key, cognitive_key)
|
|||
|
|
if encrypted:
|
|||
|
|
(KEYS_DIR / "private.key.enc").write_bytes(encrypted)
|
|||
|
|
os.chmod(KEYS_DIR / "private.key.enc", 0o600)
|
|||
|
|
# Удаляем незашифрованный если был
|
|||
|
|
if (KEYS_DIR / "private.key").exists():
|
|||
|
|
(KEYS_DIR / "private.key").unlink()
|
|||
|
|
else:
|
|||
|
|
# Fallback на незашифрованное хранение
|
|||
|
|
(KEYS_DIR / "private.key").write_bytes(private_key)
|
|||
|
|
os.chmod(KEYS_DIR / "private.key", 0o600)
|
|||
|
|
else:
|
|||
|
|
# Без ML-KEM — сохраняем открыто
|
|||
|
|
(KEYS_DIR / "private.key").write_bytes(private_key)
|
|||
|
|
os.chmod(KEYS_DIR / "private.key", 0o600)
|
|||
|
|
|
|||
|
|
(KEYS_DIR / "public.key").write_bytes(public_key)
|
|||
|
|
|
|||
|
|
# Сохраняем конфиг
|
|||
|
|
config = {
|
|||
|
|
"address": address,
|
|||
|
|
"public_key": public_key.hex(),
|
|||
|
|
"version": VERSION,
|
|||
|
|
"encrypted": HAS_MLKEM and (KEYS_DIR / "private.key.enc").exists()
|
|||
|
|
}
|
|||
|
|
CONFIG_FILE.write_text(json.dumps(config, indent=2))
|
|||
|
|
|
|||
|
|
return address
|
|||
|
|
|
|||
|
|
|
|||
|
|
def register_on_server(address: str, cognitive_key: str):
|
|||
|
|
"""Регистрация адреса на сервере"""
|
|||
|
|
public_key = (KEYS_DIR / "public.key").read_bytes()
|
|||
|
|
private_key = load_private_key_internal(cognitive_key)
|
|||
|
|
if not private_key:
|
|||
|
|
raise Exception("Не удалось загрузить приватный ключ")
|
|||
|
|
|
|||
|
|
# Подписываем регистрацию
|
|||
|
|
message = f"MONTANA_REGISTER:{address}"
|
|||
|
|
signature = ML_DSA_65.sign(private_key, message.encode())
|
|||
|
|
|
|||
|
|
resp = requests.post(f"{API_URL}/api/auth/register", json={
|
|||
|
|
"address": address,
|
|||
|
|
"public_key": public_key.hex(),
|
|||
|
|
"signature": signature.hex()
|
|||
|
|
}, timeout=30)
|
|||
|
|
|
|||
|
|
return resp.status_code == 200
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_config():
|
|||
|
|
"""Загрузить конфигурацию"""
|
|||
|
|
if CONFIG_FILE.exists():
|
|||
|
|
return json.loads(CONFIG_FILE.read_text())
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_private_key_internal(cognitive_key: str) -> bytes:
|
|||
|
|
"""Загрузить приватный ключ с когнитивным ключом"""
|
|||
|
|
# Сначала пробуем зашифрованный
|
|||
|
|
enc_path = KEYS_DIR / "private.key.enc"
|
|||
|
|
if enc_path.exists() and HAS_MLKEM:
|
|||
|
|
encrypted = enc_path.read_bytes()
|
|||
|
|
decrypted = decrypt_private_key(encrypted, cognitive_key)
|
|||
|
|
if decrypted:
|
|||
|
|
return decrypted
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# Fallback на незашифрованный
|
|||
|
|
plain_path = KEYS_DIR / "private.key"
|
|||
|
|
if plain_path.exists():
|
|||
|
|
return plain_path.read_bytes()
|
|||
|
|
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_private_key():
|
|||
|
|
"""Загрузить приватный ключ (запрашивает когнитивный ключ если нужно)"""
|
|||
|
|
config = load_config()
|
|||
|
|
|
|||
|
|
# Если ключ зашифрован — запрашиваем когнитивный ключ
|
|||
|
|
if config and config.get("encrypted"):
|
|||
|
|
cognitive_key = getpass.getpass("Когнитивный ключ: ")
|
|||
|
|
return load_private_key_internal(cognitive_key)
|
|||
|
|
|
|||
|
|
# Незашифрованный
|
|||
|
|
plain_path = KEYS_DIR / "private.key"
|
|||
|
|
if plain_path.exists():
|
|||
|
|
return plain_path.read_bytes()
|
|||
|
|
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
cli()
|