Skip to content

Commit

Permalink
mint: add seed decrypt (#403)
Browse files Browse the repository at this point in the history
* mint: add seed decrypt

* add mint seed decryoption and migration tool
  • Loading branch information
callebtc authored Feb 5, 2024
1 parent 30b6e8a commit e02e4bb
Show file tree
Hide file tree
Showing 11 changed files with 465 additions and 35 deletions.
58 changes: 37 additions & 21 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from loguru import logger
from pydantic import BaseModel, Field

from .crypto.aes import AESCipher
from .crypto.keys import (
derive_keys,
derive_keys_sha256,
Expand Down Expand Up @@ -693,54 +694,69 @@ class MintKeyset:
active: bool
unit: Unit
derivation_path: str
seed: str
public_keys: Union[Dict[int, PublicKey], None] = None
valid_from: Union[str, None] = None
valid_to: Union[str, None] = None
first_seen: Union[str, None] = None
version: Union[str, None] = None
seed: Optional[str] = None
encrypted_seed: Optional[str] = None
seed_encryption_method: Optional[str] = None
public_keys: Optional[Dict[int, PublicKey]] = None
valid_from: Optional[str] = None
valid_to: Optional[str] = None
first_seen: Optional[str] = None
version: Optional[str] = None

duplicate_keyset_id: Optional[str] = None # BACKWARDS COMPATIBILITY < 0.15.0

def __init__(
self,
*,
seed: str,
derivation_path: str,
id="",
valid_from=None,
valid_to=None,
first_seen=None,
active=None,
seed: Optional[str] = None,
encrypted_seed: Optional[str] = None,
seed_encryption_method: Optional[str] = None,
valid_from: Optional[str] = None,
valid_to: Optional[str] = None,
first_seen: Optional[str] = None,
active: Optional[bool] = None,
unit: Optional[str] = None,
version: str = "0",
version: Optional[str] = None,
id: str = "",
):
self.derivation_path = derivation_path
self.seed = seed

if encrypted_seed and not settings.mint_seed_decryption_key:
raise Exception("MINT_SEED_DECRYPTION_KEY not set, but seed is encrypted.")
if settings.mint_seed_decryption_key and encrypted_seed:
self.seed = AESCipher(settings.mint_seed_decryption_key).decrypt(
encrypted_seed
)
else:
self.seed = seed

assert self.seed, "seed not set"

self.id = id
self.valid_from = valid_from
self.valid_to = valid_to
self.first_seen = first_seen
self.active = bool(active) if active is not None else False
self.version = version
self.version = version or settings.version

self.version_tuple = tuple(
[int(i) for i in self.version.split(".")] if self.version else []
)

# infer unit from derivation path
if not unit:
logger.warning(
logger.trace(
f"Unit for keyset {self.derivation_path} not set – attempting to parse"
" from derivation path"
)
try:
self.unit = Unit(
int(self.derivation_path.split("/")[2].replace("'", ""))
)
logger.warning(f"Inferred unit: {self.unit.name}")
logger.trace(f"Inferred unit: {self.unit.name}")
except Exception:
logger.warning(
logger.trace(
"Could not infer unit from derivation path"
f" {self.derivation_path} – assuming 'sat'"
)
Expand All @@ -754,7 +770,7 @@ def __init__(

self.generate_keys()

logger.debug(f"Keyset id: {self.id} ({self.unit.name})")
logger.trace(f"Loaded keyset id: {self.id} ({self.unit.name})")

@property
def public_keys_hex(self) -> Dict[int, str]:
Expand All @@ -775,14 +791,14 @@ def generate_keys(self):
self.seed, self.derivation_path
)
self.public_keys = derive_pubkeys(self.private_keys) # type: ignore
logger.warning(
logger.trace(
f"WARNING: Using weak key derivation for keyset {self.id} (backwards"
" compatibility < 0.12)"
)
self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore
elif self.version_tuple < (0, 15):
self.private_keys = derive_keys_sha256(self.seed, self.derivation_path)
logger.warning(
logger.trace(
f"WARNING: Using non-bip32 derivation for keyset {self.id} (backwards"
" compatibility < 0.15)"
)
Expand Down
65 changes: 65 additions & 0 deletions cashu/core/crypto/aes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import base64
from hashlib import sha256

from Cryptodome import Random
from Cryptodome.Cipher import AES

BLOCK_SIZE = 16


class AESCipher:
"""This class is compatible with crypto-js/aes.js
Encrypt and decrypt in Javascript using:
import AES from "crypto-js/aes.js";
import Utf8 from "crypto-js/enc-utf8.js";
AES.encrypt(decrypted, password).toString()
AES.decrypt(encrypted, password).toString(Utf8);
"""

def __init__(self, key: str, description=""):
self.key: str = key
self.description = description + " "

def pad(self, data):
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
return data + (chr(length) * length).encode()

def unpad(self, data):
return data[: -(data[-1] if isinstance(data[-1], int) else ord(data[-1]))]

def bytes_to_key(self, data, salt, output=48):
# extended from https://gist.github.com/gsakkis/4546068
assert len(salt) == 8, len(salt)
data += salt
key = sha256(data).digest()
final_key = key
while len(final_key) < output:
key = sha256(key + data).digest()
final_key += key
return final_key[:output]

def decrypt(self, encrypted: str) -> str: # type: ignore
"""Decrypts a string using AES-256-CBC."""
encrypted = base64.urlsafe_b64decode(encrypted) # type: ignore
assert encrypted[0:8] == b"Salted__"
salt = encrypted[8:16]
key_iv = self.bytes_to_key(self.key.encode(), salt, 32 + 16)
key = key_iv[:32]
iv = key_iv[32:]
aes = AES.new(key, AES.MODE_CBC, iv)
try:
return self.unpad(aes.decrypt(encrypted[16:])).decode() # type: ignore
except UnicodeDecodeError:
raise ValueError("Wrong passphrase")

def encrypt(self, message: bytes) -> str:
salt = Random.new().read(8)
key_iv = self.bytes_to_key(self.key.encode(), salt, 32 + 16)
key = key_iv[:32]
iv = key_iv[32:]
aes = AES.new(key, AES.MODE_CBC, iv)
return base64.urlsafe_b64encode(
b"Salted__" + salt + aes.encrypt(self.pad(message))
).decode()
3 changes: 2 additions & 1 deletion cashu/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def find_env_file():
if not os.path.isfile(env_file):
env_file = os.path.join(str(Path.home()), ".cashu", ".env")
if os.path.isfile(env_file):
env.read_env(env_file)
env.read_env(env_file, recurse=False, override=True)
else:
env_file = ""
return env_file
Expand Down Expand Up @@ -49,6 +49,7 @@ class EnvSettings(CashuSettings):

class MintSettings(CashuSettings):
mint_private_key: str = Field(default=None)
mint_seed_decryption_key: str = Field(default=None)
mint_derivation_path: str = Field(default="m/0'/0'/0'")
mint_derivation_path_list: List[str] = Field(default=[])
mint_listen_host: str = Field(default="127.0.0.1")
Expand Down
6 changes: 4 additions & 2 deletions cashu/mint/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,12 +518,14 @@ async def store_keyset(
await (conn or db).execute( # type: ignore
f"""
INSERT INTO {table_with_schema(db, 'keysets')}
(id, seed, derivation_path, valid_from, valid_to, first_seen, active, version, unit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, seed, encrypted_seed, seed_encryption_method, derivation_path, valid_from, valid_to, first_seen, active, version, unit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
keyset.id,
keyset.seed,
keyset.encrypted_seed,
keyset.seed_encryption_method,
keyset.derivation_path,
keyset.valid_from or timestamp_now(db),
keyset.valid_to or timestamp_now(db),
Expand Down
151 changes: 151 additions & 0 deletions cashu/mint/decrypt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import click

try:
from ..core.crypto.aes import AESCipher
except ImportError:
# for the CLI to work
from cashu.core.crypto.aes import AESCipher
import asyncio
from functools import wraps

from cashu.core.db import Database, table_with_schema
from cashu.core.migrations import migrate_databases
from cashu.core.settings import settings
from cashu.mint import migrations
from cashu.mint.crud import LedgerCrudSqlite
from cashu.mint.ledger import Ledger


# https://github.com/pallets/click/issues/85#issuecomment-503464628
def coro(f):
@wraps(f)
def wrapper(*args, **kwargs):
return asyncio.run(f(*args, **kwargs))

return wrapper


@click.group()
def cli():
"""Ledger Decrypt CLI"""
pass


@cli.command()
@click.option("--message", prompt=True, help="The message to encrypt.")
@click.option(
"--key",
prompt=True,
hide_input=True,
confirmation_prompt=True,
help="The encryption key.",
)
def encrypt(message, key):
"""Encrypt a message."""
aes = AESCipher(key)
encrypted_message = aes.encrypt(message.encode())
click.echo(f"Encrypted message: {encrypted_message}")


@cli.command()
@click.option("--encrypted", prompt=True, help="The encrypted message to decrypt.")
@click.option(
"--key",
prompt=True,
hide_input=True,
help="The decryption key.",
)
def decrypt(encrypted, key):
"""Decrypt a message."""
aes = AESCipher(key)
decrypted_message = aes.decrypt(encrypted)
click.echo(f"Decrypted message: {decrypted_message}")


# command to migrate the database to encrypted seeds
@cli.command()
@coro
@click.option("--no-dry-run", is_flag=True, help="Dry run.", default=False)
async def migrate(no_dry_run):
"""Migrate the database to encrypted seeds."""
ledger = Ledger(
db=Database("mint", settings.mint_database),
seed=settings.mint_private_key,
seed_decryption_key=settings.mint_seed_decryption_key,
derivation_path=settings.mint_derivation_path,
backends={},
crud=LedgerCrudSqlite(),
)
assert settings.mint_seed_decryption_key, "MINT_SEED_DECRYPTION_KEY not set."
assert (
len(settings.mint_seed_decryption_key) > 12
), "MINT_SEED_DECRYPTION_KEY is too short, must be at least 12 characters."
click.echo(
"Decryption key:"
f" {settings.mint_seed_decryption_key[0]}{'*'*10}{settings.mint_seed_decryption_key[-1]}"
)

aes = AESCipher(settings.mint_seed_decryption_key)

click.echo("Making sure that db is migrated to latest version first.")
await migrate_databases(ledger.db, migrations)

# get all keysets
async with ledger.db.connect() as conn:
rows = await conn.fetchall(
f"SELECT * FROM {table_with_schema(ledger.db, 'keysets')} WHERE seed IS NOT"
" NULL"
)
click.echo(f"Found {len(rows)} keysets in database.")
keysets_all = [dict(**row) for row in rows]
keysets_migrate = []
# encrypt the seeds
for keyset_dict in keysets_all:
if keyset_dict["seed"] and not keyset_dict["encrypted_seed"]:
keyset_dict["encrypted_seed"] = aes.encrypt(keyset_dict["seed"].encode())
keyset_dict["seed_encryption_method"] = "aes"
keysets_migrate.append(keyset_dict)
else:
click.echo(f"Skipping keyset {keyset_dict['id']}: already migrated.")

click.echo(f"There are {len(keysets_migrate)} keysets to migrate.")

for keyset_dict in keysets_migrate:
click.echo(f"Keyset {keyset_dict['id']}")
click.echo(f" Encrypted seed: {keyset_dict['encrypted_seed']}")
click.echo(f" Encryption method: {keyset_dict['seed_encryption_method']}")
decryption_success_str = (
"✅"
if aes.decrypt(keyset_dict["encrypted_seed"]) == keyset_dict["seed"]
else "❌"
)
click.echo(f" Seed decryption test: {decryption_success_str}")

if not no_dry_run:
click.echo(
"This was a dry run. Use --no-dry-run to apply the changes to the database."
)
if no_dry_run and keysets_migrate:
click.confirm(
"Are you sure you want to continue? Before you continue, make sure to have"
" a backup of your keysets database table.",
abort=True,
)
click.echo("Updating keysets in the database.")
async with ledger.db.connect() as conn:
for keyset_dict in keysets_migrate:
click.echo(f"Updating keyset {keyset_dict['id']}")
await conn.execute(
f"UPDATE {table_with_schema(ledger.db, 'keysets')} SET seed='',"
" encrypted_seed = ?, seed_encryption_method = ? WHERE id = ?",
(
keyset_dict["encrypted_seed"],
keyset_dict["seed_encryption_method"],
keyset_dict["id"],
),
)
click.echo("✅ Migration complete.")


if __name__ == "__main__":
cli()
Loading

0 comments on commit e02e4bb

Please sign in to comment.