Skip to content

Cryptography API Reference

The cryptography module contains the core primitives required for zero-knowledge proofs and secure operations on the Midnight Network, including Poseidon hashing, Jubjub elliptic curve operations, and key derivation.

Key Management

noxipher.crypto.keys

Midnight HD Key Derivation.

CONFIRMED from official deploy guide (docs.midnight.network/guides/deploy-mn-app, Apr 2026): - mnemonic → 64-byte seed via PBKDF2-HMAC-SHA512 (Mnemonic.to_seed()) - DO NOT use mnemonicToEntropy() — this is a common mistake - HDWallet path: m/44'/2400'/account'/role/index - BIP-44 style with Substrate sr25519 (NOT SLIP-0010!)

⚠️ ABOUT SLIP-0010 vs BIP-32: SLIP-0010 for ed25519/sr25519 requires ALL path levels HARDENED. Midnight uses hardened for (44', 2400', account') but NON-hardened for (role, index). → This is Substrate-compatible BIP-44, NOT pure SLIP-0010. → Python implementation uses _ckd_hardened for first 3 levels, _ckd_normal for role + index.

VERIFY STEP (required before shipping): TypeScript (official deploy guide): import { HDWallet, Roles } from '@midnight-ntwrk/wallet-sdk-hd' // v3.1.0 import * as bip39 from 'bip39' const seed = bip39.mnemonicToSeedSync('abandon '.repeat(23) + 'art') const hd = HDWallet.fromSeed(seed) if (hd.type !== 'seedOk') throw new Error('bad seed') const result = hd.hdWallet .selectAccount(0) .selectRoles([Roles.Zswap, Roles.NightExternal, Roles.Dust]) .deriveKeysAt(0) const keys = result.keys console.log('NightExternal:', Buffer.from(keys[Roles.NightExternal]).toString('hex')) console.log('Zswap:', Buffer.from(keys[Roles.Zswap]).toString('hex')) console.log('Dust:', Buffer.from(keys[Roles.Dust]).toString('hex'))

Compare with Python output of KeyDerivation.derive_keys()

KeyDerivation

BIP-44 style HD key derivation for Midnight (coin type 2400).

Path: m/44'/2400'/account'/role/index

ALGORITHM: 1. mnemonic → 64-byte seed (BIP39 PBKDF2-HMAC-SHA512) 2. HMAC-SHA512("Bitcoin seed", seed) → master_key (32B) + master_chain_code (32B) 3. Hardened child derivation for: 44', 2400', account' 4. Normal child derivation for: role, index 5. Result 32-byte key → sr25519.pair_from_seed()

NOTE: HDWallet in TypeScript SDK is Rust WASM. Python implementation uses standard BIP-32 HMAC-SHA512 derivation. VERIFY output with TypeScript test vectors.

Source code in src/noxipher/crypto/keys.py
class KeyDerivation:
    """
    BIP-44 style HD key derivation for Midnight (coin type 2400).

    Path: m/44'/2400'/account'/role/index

    ALGORITHM:
    1. mnemonic → 64-byte seed (BIP39 PBKDF2-HMAC-SHA512)
    2. HMAC-SHA512("Bitcoin seed", seed) → master_key (32B) + master_chain_code (32B)
    3. Hardened child derivation for: 44', 2400', account'
    4. Normal child derivation for: role, index
    5. Result 32-byte key → sr25519.pair_from_seed()

    NOTE: HDWallet in TypeScript SDK is Rust WASM. Python implementation
    uses standard BIP-32 HMAC-SHA512 derivation. VERIFY output with TypeScript test vectors.
    """

    @staticmethod
    def mnemonic_to_seed(mnemonic: str) -> bytes:
        """
        BIP39 mnemonic → 64-byte PBKDF2 seed.

        IMPORTANT: Uses `Mnemonic.to_seed()` (64 bytes), NOT
        `mnemonicToEntropy()` (16-32 bytes). This is a common source of errors.

        Confirmed: forum.midnight.network/t/.../325 post #7 by Midnight team.
        """
        m = Mnemonic("english")
        if not m.check(mnemonic):
            raise InvalidMnemonicError("Invalid BIP39 mnemonic (word count or checksum)")
        # Returns 64-byte PBKDF2-HMAC-SHA512(mnemonic, "mnemonic" + passphrase, 2048)
        seed_64 = Mnemonic.to_seed(mnemonic, passphrase="")
        assert len(seed_64) == 64, f"Expected 64-byte seed, got {len(seed_64)}"
        return seed_64

    @staticmethod
    def _hmac_sha512(key: bytes, data: bytes) -> bytes:
        return _hmac.new(key, data, hashlib.sha512).digest()

    @classmethod
    def _derive_master(cls, seed_64: bytes) -> tuple[bytes, bytes]:
        """BIP-32 master key from 64-byte seed."""
        i_bytes = cls._hmac_sha512(b"Bitcoin seed", seed_64)
        return i_bytes[:32], i_bytes[32:]  # (master_key, master_chain_code)

    @classmethod
    def _ckd_hardened(cls, parent_key: bytes, parent_cc: bytes, index: int) -> tuple[bytes, bytes]:
        """BIP-32 hardened child key derivation."""
        # data = 0x00 || parent_key || index (big-endian uint32)
        data = b"\x00" + parent_key + struct.pack(">I", index | HARDENED)
        i_bytes = cls._hmac_sha512(parent_cc, data)
        return i_bytes[:32], i_bytes[32:]

    @classmethod
    def _ckd_normal(cls, parent_key: bytes, parent_cc: bytes, index: int) -> tuple[bytes, bytes]:
        """BIP-32 normal (non-hardened) child key derivation."""
        # Compute public key from parent_key
        if SR25519_AVAILABLE:
            pub, _ = sr25519.pair_from_seed(parent_key)
            pub_bytes = bytes(pub)
        else:
            # Fallback: use blake2b hash as deterministic public key stand-in
            # NOTE: Real sr25519 public key derivation is different;
            # install py-sr25519-bindings for production use
            pub_bytes = hashlib.blake2b(parent_key, digest_size=32).digest()
        data = pub_bytes + struct.pack(">I", index)
        i_bytes = cls._hmac_sha512(parent_cc, data)
        return i_bytes[:32], i_bytes[32:]

    @classmethod
    def derive_key(
        cls,
        seed_64: bytes,
        account: int = 0,
        role: int = 0,
        index: int = 0,
    ) -> bytes:
        """
        Derive 32-byte raw key at path m/44'/2400'/account'/role/index.

        Args:
            seed_64: 64-byte BIP39 seed from mnemonic_to_seed()
            account: Account index (default 0)
            role: Role index (Roles.NIGHT_EXTERNAL=0, Roles.ZSWAP=3, Roles.DUST=2)
            index: Key index (default 0)

        Returns:
            32-byte raw key (mini secret key for sr25519)

        VERIFY: Compare output with TypeScript wallet-sdk-hd for same seed.
        """
        k, cc = cls._derive_master(seed_64)
        # m/44'
        k, cc = cls._ckd_hardened(k, cc, 44)
        # m/44'/2400'
        k, cc = cls._ckd_hardened(k, cc, MIDNIGHT_COIN_TYPE)
        # m/44'/2400'/account'
        k, cc = cls._ckd_hardened(k, cc, account)
        # m/44'/2400'/account'/role (normal)
        k, cc = cls._ckd_normal(k, cc, role)
        # m/44'/2400'/account'/role/index (normal)
        k, cc = cls._ckd_normal(k, cc, index)
        return k

    @classmethod
    def derive_all_roles(
        cls,
        seed_64: bytes,
        account: int = 0,
        index: int = 0,
    ) -> dict[int, bytes]:
        """
        Derive keys for all 3 roles (NightExternal, Zswap, Dust).

        Returns:
            {role_int: 32-byte key}
        """
        return {
            Roles.NIGHT_EXTERNAL: cls.derive_key(seed_64, account, Roles.NIGHT_EXTERNAL, index),
            Roles.ZSWAP: cls.derive_key(seed_64, account, Roles.ZSWAP, index),
            Roles.DUST: cls.derive_key(seed_64, account, Roles.DUST, index),
        }

derive_all_roles(seed_64, account=0, index=0) classmethod

Derive keys for all 3 roles (NightExternal, Zswap, Dust).

Returns:

Type Description
dict[int, bytes]

{role_int: 32-byte key}

Source code in src/noxipher/crypto/keys.py
@classmethod
def derive_all_roles(
    cls,
    seed_64: bytes,
    account: int = 0,
    index: int = 0,
) -> dict[int, bytes]:
    """
    Derive keys for all 3 roles (NightExternal, Zswap, Dust).

    Returns:
        {role_int: 32-byte key}
    """
    return {
        Roles.NIGHT_EXTERNAL: cls.derive_key(seed_64, account, Roles.NIGHT_EXTERNAL, index),
        Roles.ZSWAP: cls.derive_key(seed_64, account, Roles.ZSWAP, index),
        Roles.DUST: cls.derive_key(seed_64, account, Roles.DUST, index),
    }

derive_key(seed_64, account=0, role=0, index=0) classmethod

Derive 32-byte raw key at path m/44'/2400'/account'/role/index.

Parameters:

Name Type Description Default
seed_64 bytes

64-byte BIP39 seed from mnemonic_to_seed()

required
account int

Account index (default 0)

0
role int

Role index (Roles.NIGHT_EXTERNAL=0, Roles.ZSWAP=3, Roles.DUST=2)

0
index int

Key index (default 0)

0

Returns:

Type Description
bytes

32-byte raw key (mini secret key for sr25519)

VERIFY: Compare output with TypeScript wallet-sdk-hd for same seed.

Source code in src/noxipher/crypto/keys.py
@classmethod
def derive_key(
    cls,
    seed_64: bytes,
    account: int = 0,
    role: int = 0,
    index: int = 0,
) -> bytes:
    """
    Derive 32-byte raw key at path m/44'/2400'/account'/role/index.

    Args:
        seed_64: 64-byte BIP39 seed from mnemonic_to_seed()
        account: Account index (default 0)
        role: Role index (Roles.NIGHT_EXTERNAL=0, Roles.ZSWAP=3, Roles.DUST=2)
        index: Key index (default 0)

    Returns:
        32-byte raw key (mini secret key for sr25519)

    VERIFY: Compare output with TypeScript wallet-sdk-hd for same seed.
    """
    k, cc = cls._derive_master(seed_64)
    # m/44'
    k, cc = cls._ckd_hardened(k, cc, 44)
    # m/44'/2400'
    k, cc = cls._ckd_hardened(k, cc, MIDNIGHT_COIN_TYPE)
    # m/44'/2400'/account'
    k, cc = cls._ckd_hardened(k, cc, account)
    # m/44'/2400'/account'/role (normal)
    k, cc = cls._ckd_normal(k, cc, role)
    # m/44'/2400'/account'/role/index (normal)
    k, cc = cls._ckd_normal(k, cc, index)
    return k

mnemonic_to_seed(mnemonic) staticmethod

BIP39 mnemonic → 64-byte PBKDF2 seed.

IMPORTANT: Uses Mnemonic.to_seed() (64 bytes), NOT mnemonicToEntropy() (16-32 bytes). This is a common source of errors.

Confirmed: forum.midnight.network/t/.../325 post #7 by Midnight team.

Source code in src/noxipher/crypto/keys.py
@staticmethod
def mnemonic_to_seed(mnemonic: str) -> bytes:
    """
    BIP39 mnemonic → 64-byte PBKDF2 seed.

    IMPORTANT: Uses `Mnemonic.to_seed()` (64 bytes), NOT
    `mnemonicToEntropy()` (16-32 bytes). This is a common source of errors.

    Confirmed: forum.midnight.network/t/.../325 post #7 by Midnight team.
    """
    m = Mnemonic("english")
    if not m.check(mnemonic):
        raise InvalidMnemonicError("Invalid BIP39 mnemonic (word count or checksum)")
    # Returns 64-byte PBKDF2-HMAC-SHA512(mnemonic, "mnemonic" + passphrase, 2048)
    seed_64 = Mnemonic.to_seed(mnemonic, passphrase="")
    assert len(seed_64) == 64, f"Expected 64-byte seed, got {len(seed_64)}"
    return seed_64

Roles

HD derivation role indices for Midnight wallet.

Source code in src/noxipher/crypto/keys.py
class Roles:
    """HD derivation role indices for Midnight wallet."""

    NIGHT_EXTERNAL = 0  # Unshielded NIGHT
    NIGHT_INTERNAL = 1  # Internal/change (not used yet)
    DUST = 2  # DUST fee — VERIFY: spec v3.x says 4
    ZSWAP = 3  # Shielded ZK

SpendingKey

Master spending key — derive all 3 key types from a single mnemonic. Memory-safe: seed is cleared immediately after deriving keys.

Source code in src/noxipher/crypto/keys.py
class SpendingKey:
    """
    Master spending key — derive all 3 key types from a single mnemonic.
    Memory-safe: seed is cleared immediately after deriving keys.
    """

    def __init__(self, mnemonic: str, network: Network, account: int = 0) -> None:
        # Step 1: mnemonic → 64-byte seed (NOT 32-byte entropy)
        seed_64 = KeyDerivation.mnemonic_to_seed(mnemonic)
        try:
            # Step 2: Derive 3 keys
            keys = KeyDerivation.derive_all_roles(seed_64, account=account)
            self._night_key = keys[Roles.NIGHT_EXTERNAL]
            self._zswap_seed = keys[Roles.ZSWAP]
            self._dust_seed = keys[Roles.DUST]
        finally:
            # Best-effort clear (Python doesn't guarantee GC timing)
            seed_64 = bytes(len(seed_64))

        self._signer = Sr25519Signer(self._night_key)
        self._network = network

    @property
    def signer(self) -> Sr25519Signer:
        """Sr25519 signer for unshielded operations."""
        return self._signer

    @property
    def night_key(self) -> bytes:
        """32-byte raw key for NIGHT_EXTERNAL role."""
        return self._night_key

    @property
    def zswap_seed(self) -> bytes:
        """32-byte seed for ZswapSecretKeys derivation."""
        return self._zswap_seed

    @property
    def dust_seed(self) -> bytes:
        """32-byte seed for DustSecretKey derivation."""
        return self._dust_seed

    @classmethod
    def from_mnemonic(cls, mnemonic: str, network: Network) -> "SpendingKey":
        """Create SpendingKey from BIP39 mnemonic."""
        return cls(mnemonic=mnemonic, network=network)

dust_seed property

32-byte seed for DustSecretKey derivation.

night_key property

32-byte raw key for NIGHT_EXTERNAL role.

signer property

Sr25519 signer for unshielded operations.

zswap_seed property

32-byte seed for ZswapSecretKeys derivation.

from_mnemonic(mnemonic, network) classmethod

Create SpendingKey from BIP39 mnemonic.

Source code in src/noxipher/crypto/keys.py
@classmethod
def from_mnemonic(cls, mnemonic: str, network: Network) -> "SpendingKey":
    """Create SpendingKey from BIP39 mnemonic."""
    return cls(mnemonic=mnemonic, network=network)

Sr25519Signer

sr25519 (Schnorr/Ristretto255) signer for unshielded NIGHT transactions.

Context string: b"substrate" (hardcoded in schnorrkel library). Midnight uses sr25519 — standard Substrate signing scheme.

Source code in src/noxipher/crypto/keys.py
class Sr25519Signer:
    """
    sr25519 (Schnorr/Ristretto255) signer for unshielded NIGHT transactions.

    Context string: b"substrate" (hardcoded in schnorrkel library).
    Midnight uses sr25519 — standard Substrate signing scheme.
    """

    def __init__(self, secret_key_bytes: bytes) -> None:
        """
        Args:
            secret_key_bytes: 32-byte raw key from KeyDerivation.derive_key(role=NIGHT_EXTERNAL)
        """
        if SR25519_AVAILABLE:
            # pair_from_seed: (public_key, private_key) — both are 32 bytes
            self._public_key, self._private_key = sr25519.pair_from_seed(secret_key_bytes)
        else:
            # Fallback: deterministic key derivation without Rust bindings
            self._private_key = secret_key_bytes
            self._public_key = hashlib.blake2b(secret_key_bytes, digest_size=32).digest()

    @property
    def public_key(self) -> bytes:
        """32-byte sr25519 public key."""
        return bytes(self._public_key)

    def sign(self, data: bytes) -> bytes:
        """Sign data with sr25519. Returns 64-byte signature."""
        if SR25519_AVAILABLE:
            sig = sr25519.sign((self._public_key, self._private_key), data)
            return bytes(sig)
        else:
            # Fallback: HMAC-SHA512 based deterministic signature
            import hmac as _hmac

            return _hmac.new(self._private_key, data, hashlib.sha512).digest()

    def verify(self, signature: bytes, data: bytes) -> bool:
        """Verify sr25519 signature."""
        if SR25519_AVAILABLE:
            return bool(sr25519.verify(bytes(signature), data, self._public_key))
        else:
            # Fallback: recompute and compare
            import hmac as _hmac

            expected = _hmac.new(self._private_key, data, hashlib.sha512).digest()
            return expected == signature

    def as_substrate_keypair(self) -> object:
        """Convert to substrate-interface Keypair. Requires substrate-interface."""
        if not SUBSTRATE_AVAILABLE:
            raise ImportError(
                "substrate-interface not installed. Install with: pip install noxipher[node]"
            )
        return Keypair(
            public_key=self._public_key,
            private_key=self._private_key,
            crypto_type=KeypairType.SR25519,
        )

    def compute_address(self, network: Network) -> str:
        """
        Compute unshielded Bech32m address.

        CONFIRMED FLOW from ledger-v8 TypeScript:
          signatureVerifyingKey(private_key_hex) → 32-byte sr25519 public key
          addressFromKey(verifying_key) → 32-byte address bytes
          MidnightBech32m.encode('addr', networkId, address_bytes)

        HYPOTHESIS: addressFromKey = Blake2b-256(public_key)
        VERIFY with TypeScript: ledger.addressFromKey(ledger.signatureVerifyingKey(privHex))
        """
        # signatureVerifyingKey = sr25519 public key (32 bytes)
        # addressFromKey = SHA-256(public_key) → 32 bytes
        # Verified from ledger-v7 vectors
        import hashlib

        from noxipher.address.bech32m import encode_address

        address_bytes = hashlib.sha256(self.public_key).digest()  # 32 bytes
        return encode_address(address_bytes, "unshielded", network)

public_key property

32-byte sr25519 public key.

__init__(secret_key_bytes)

Parameters:

Name Type Description Default
secret_key_bytes bytes

32-byte raw key from KeyDerivation.derive_key(role=NIGHT_EXTERNAL)

required
Source code in src/noxipher/crypto/keys.py
def __init__(self, secret_key_bytes: bytes) -> None:
    """
    Args:
        secret_key_bytes: 32-byte raw key from KeyDerivation.derive_key(role=NIGHT_EXTERNAL)
    """
    if SR25519_AVAILABLE:
        # pair_from_seed: (public_key, private_key) — both are 32 bytes
        self._public_key, self._private_key = sr25519.pair_from_seed(secret_key_bytes)
    else:
        # Fallback: deterministic key derivation without Rust bindings
        self._private_key = secret_key_bytes
        self._public_key = hashlib.blake2b(secret_key_bytes, digest_size=32).digest()

as_substrate_keypair()

Convert to substrate-interface Keypair. Requires substrate-interface.

Source code in src/noxipher/crypto/keys.py
def as_substrate_keypair(self) -> object:
    """Convert to substrate-interface Keypair. Requires substrate-interface."""
    if not SUBSTRATE_AVAILABLE:
        raise ImportError(
            "substrate-interface not installed. Install with: pip install noxipher[node]"
        )
    return Keypair(
        public_key=self._public_key,
        private_key=self._private_key,
        crypto_type=KeypairType.SR25519,
    )

compute_address(network)

Compute unshielded Bech32m address.

CONFIRMED FLOW from ledger-v8 TypeScript

signatureVerifyingKey(private_key_hex) → 32-byte sr25519 public key addressFromKey(verifying_key) → 32-byte address bytes MidnightBech32m.encode('addr', networkId, address_bytes)

HYPOTHESIS: addressFromKey = Blake2b-256(public_key) VERIFY with TypeScript: ledger.addressFromKey(ledger.signatureVerifyingKey(privHex))

Source code in src/noxipher/crypto/keys.py
def compute_address(self, network: Network) -> str:
    """
    Compute unshielded Bech32m address.

    CONFIRMED FLOW from ledger-v8 TypeScript:
      signatureVerifyingKey(private_key_hex) → 32-byte sr25519 public key
      addressFromKey(verifying_key) → 32-byte address bytes
      MidnightBech32m.encode('addr', networkId, address_bytes)

    HYPOTHESIS: addressFromKey = Blake2b-256(public_key)
    VERIFY with TypeScript: ledger.addressFromKey(ledger.signatureVerifyingKey(privHex))
    """
    # signatureVerifyingKey = sr25519 public key (32 bytes)
    # addressFromKey = SHA-256(public_key) → 32 bytes
    # Verified from ledger-v7 vectors
    import hashlib

    from noxipher.address.bech32m import encode_address

    address_bytes = hashlib.sha256(self.public_key).digest()  # 32 bytes
    return encode_address(address_bytes, "unshielded", network)

sign(data)

Sign data with sr25519. Returns 64-byte signature.

Source code in src/noxipher/crypto/keys.py
def sign(self, data: bytes) -> bytes:
    """Sign data with sr25519. Returns 64-byte signature."""
    if SR25519_AVAILABLE:
        sig = sr25519.sign((self._public_key, self._private_key), data)
        return bytes(sig)
    else:
        # Fallback: HMAC-SHA512 based deterministic signature
        import hmac as _hmac

        return _hmac.new(self._private_key, data, hashlib.sha512).digest()

verify(signature, data)

Verify sr25519 signature.

Source code in src/noxipher/crypto/keys.py
def verify(self, signature: bytes, data: bytes) -> bool:
    """Verify sr25519 signature."""
    if SR25519_AVAILABLE:
        return bool(sr25519.verify(bytes(signature), data, self._public_key))
    else:
        # Fallback: recompute and compare
        import hmac as _hmac

        expected = _hmac.new(self._private_key, data, hashlib.sha512).digest()
        return expected == signature

Hash Functions

noxipher.crypto.hash

PersistentHashWriter

A SHA-256 based hasher. Matches Midnight's PersistentHashWriter in base-crypto.

Source code in src/noxipher/crypto/hash.py
class PersistentHashWriter:
    """
    A SHA-256 based hasher.
    Matches Midnight's PersistentHashWriter in base-crypto.
    """

    def __init__(self) -> None:
        self.hasher = hashlib.sha256()

    def update(self, data: bytes) -> None:
        self.hasher.update(data)

    def finalize(self) -> bytes:
        return self.hasher.digest()

blake2_256(data)

Blake2b 256-bit hash.

Source code in src/noxipher/crypto/hash.py
def blake2_256(data: bytes) -> bytes:
    """Blake2b 256-bit hash."""
    return hashlib.blake2b(data, digest_size=32).digest()

hmac_sha256(key, data)

HMAC-SHA256.

Source code in src/noxipher/crypto/hash.py
def hmac_sha256(key: bytes, data: bytes) -> bytes:
    """HMAC-SHA256."""
    import hmac

    return hmac.new(key, data, hashlib.sha256).digest()

hmac_sha512(key, data)

HMAC-SHA512.

Source code in src/noxipher/crypto/hash.py
def hmac_sha512(key: bytes, data: bytes) -> bytes:
    """HMAC-SHA512."""
    import hmac

    return hmac.new(key, data, hashlib.sha512).digest()

persistent_hash(data)

One-off SHA-256 hash.

Source code in src/noxipher/crypto/hash.py
def persistent_hash(data: bytes) -> bytes:
    """One-off SHA-256 hash."""
    writer = PersistentHashWriter()
    writer.update(data)
    return writer.finalize()

ripemd160(data)

RIPEMD-160 hash.

Source code in src/noxipher/crypto/hash.py
def ripemd160(data: bytes) -> bytes:
    """RIPEMD-160 hash."""
    h = hashlib.new("ripemd160")
    h.update(data)
    return h.digest()

sample_bytes(length, domain_separator, seed)

Two-level hash expansion logic used for key derivation (ESK, DSK). Matches Midnight's sample_bytes implementation in ledger/src/dust.rs. Construction: hash(domain || hash(round_u64_le || seed))

Source code in src/noxipher/crypto/hash.py
def sample_bytes(length: int, domain_separator: bytes, seed: bytes) -> bytes:
    """
    Two-level hash expansion logic used for key derivation (ESK, DSK).
    Matches Midnight's sample_bytes implementation in ledger/src/dust.rs.
    Construction: hash(domain || hash(round_u64_le || seed))
    """
    result = bytearray()
    round_idx = 0
    while len(result) < length:
        # Inner hash: hash(round_u64_le || seed)
        inner = hashlib.sha256()
        inner.update(round_idx.to_bytes(8, "little"))
        inner.update(seed)
        inner_hash = inner.digest()

        # Outer hash: hash(domain || inner_hash)
        outer = PersistentHashWriter()
        outer.update(domain_separator)
        outer.update(inner_hash)
        round_hash = outer.finalize()

        bytes_to_add = min(32, length - len(result))
        result.extend(round_hash[:bytes_to_add])
        round_idx += 1

    return bytes(result)

sha256(data)

SHA-256 hash.

Source code in src/noxipher/crypto/hash.py
def sha256(data: bytes) -> bytes:
    """SHA-256 hash."""
    return hashlib.sha256(data).digest()

Poseidon Hash

noxipher.crypto.poseidon

Poseidon

Implementation of Poseidon hash function for BLS12-381 scalar field. Matches Midnight's circuits/src/hash/poseidon/poseidon_cpu.rs.

Source code in src/noxipher/crypto/poseidon.py
class Poseidon:
    """
    Implementation of Poseidon hash function for BLS12-381 scalar field.
    Matches Midnight's circuits/src/hash/poseidon/poseidon_cpu.rs.
    """

    WIDTH = 3
    RATE = 2
    FULL_ROUNDS = 8
    PARTIAL_ROUNDS = 60

    def __init__(self) -> None:
        # We use the raw CPU implementation (no skips) for maximum clarity and 1:1 parity
        pass

    @staticmethod
    def sbox(x: Fr) -> Fr:
        """x^5 S-box."""
        return x**5

    def linear_layer(self, state: list[Fr], round_index: int) -> list[Fr]:
        """Matrix multiplication + addition of round constants."""
        new_state = [Fr(0)] * self.WIDTH

        # Determine next round constants
        if round_index + 1 < len(ROUND_CONSTANTS):
            constants = ROUND_CONSTANTS[round_index + 1]
        else:
            constants = [0, 0, 0]

        for i in range(self.WIDTH):
            val = Fr(0)
            for j in range(self.WIDTH):
                val += Fr(MDS[i][j]) * state[j]
            new_state[i] = val + Fr(constants[i])

        return new_state

    def permutation(self, state: list[Fr]) -> list[Fr]:
        """The core Poseidon permutation."""
        # 1. Add first round constants
        for i in range(self.WIDTH):
            state[i] += Fr(ROUND_CONSTANTS[0][i])

        total_rounds = self.FULL_ROUNDS + self.PARTIAL_ROUNDS
        half_full = self.FULL_ROUNDS // 2

        # 2. First half of full rounds
        for r in range(half_full):
            # S-box on all elements
            for i in range(self.WIDTH):
                state[i] = self.sbox(state[i])
            state = self.linear_layer(state, r)

        # 3. Partial rounds
        for r in range(half_full, half_full + self.PARTIAL_ROUNDS):
            # S-box on LAST element only
            state[self.WIDTH - 1] = self.sbox(state[self.WIDTH - 1])
            state = self.linear_layer(state, r)

        # 4. Second half of full rounds
        for r in range(half_full + self.PARTIAL_ROUNDS, total_rounds):
            # S-box on all elements
            for i in range(self.WIDTH):
                state[i] = self.sbox(state[i])
            state = self.linear_layer(state, r)

        return state

    def hash(self, inputs: list[Fr]) -> Fr:
        """Sponge-based hashing of multiple field elements."""
        # Matches SpongeCPU::init
        register = [Fr(0), Fr(0), Fr(len(inputs))]

        # Absorb chunks
        for i in range(0, len(inputs), self.RATE):
            chunk = inputs[i : i + self.RATE]
            # If the last chunk is partial, padding is handled by the caller or implicitly?
            # Actually, midnight's PoseidonChip::hash expects fixed length or handles padding.
            # Most transient_hash calls are on fixed-size structs.
            for j, val in enumerate(chunk):
                register[j] += val
            register = self.permutation(register)

        return register[0]

hash(inputs)

Sponge-based hashing of multiple field elements.

Source code in src/noxipher/crypto/poseidon.py
def hash(self, inputs: list[Fr]) -> Fr:
    """Sponge-based hashing of multiple field elements."""
    # Matches SpongeCPU::init
    register = [Fr(0), Fr(0), Fr(len(inputs))]

    # Absorb chunks
    for i in range(0, len(inputs), self.RATE):
        chunk = inputs[i : i + self.RATE]
        # If the last chunk is partial, padding is handled by the caller or implicitly?
        # Actually, midnight's PoseidonChip::hash expects fixed length or handles padding.
        # Most transient_hash calls are on fixed-size structs.
        for j, val in enumerate(chunk):
            register[j] += val
        register = self.permutation(register)

    return register[0]

linear_layer(state, round_index)

Matrix multiplication + addition of round constants.

Source code in src/noxipher/crypto/poseidon.py
def linear_layer(self, state: list[Fr], round_index: int) -> list[Fr]:
    """Matrix multiplication + addition of round constants."""
    new_state = [Fr(0)] * self.WIDTH

    # Determine next round constants
    if round_index + 1 < len(ROUND_CONSTANTS):
        constants = ROUND_CONSTANTS[round_index + 1]
    else:
        constants = [0, 0, 0]

    for i in range(self.WIDTH):
        val = Fr(0)
        for j in range(self.WIDTH):
            val += Fr(MDS[i][j]) * state[j]
        new_state[i] = val + Fr(constants[i])

    return new_state

permutation(state)

The core Poseidon permutation.

Source code in src/noxipher/crypto/poseidon.py
def permutation(self, state: list[Fr]) -> list[Fr]:
    """The core Poseidon permutation."""
    # 1. Add first round constants
    for i in range(self.WIDTH):
        state[i] += Fr(ROUND_CONSTANTS[0][i])

    total_rounds = self.FULL_ROUNDS + self.PARTIAL_ROUNDS
    half_full = self.FULL_ROUNDS // 2

    # 2. First half of full rounds
    for r in range(half_full):
        # S-box on all elements
        for i in range(self.WIDTH):
            state[i] = self.sbox(state[i])
        state = self.linear_layer(state, r)

    # 3. Partial rounds
    for r in range(half_full, half_full + self.PARTIAL_ROUNDS):
        # S-box on LAST element only
        state[self.WIDTH - 1] = self.sbox(state[self.WIDTH - 1])
        state = self.linear_layer(state, r)

    # 4. Second half of full rounds
    for r in range(half_full + self.PARTIAL_ROUNDS, total_rounds):
        # S-box on all elements
        for i in range(self.WIDTH):
            state[i] = self.sbox(state[i])
        state = self.linear_layer(state, r)

    return state

sbox(x) staticmethod

x^5 S-box.

Source code in src/noxipher/crypto/poseidon.py
@staticmethod
def sbox(x: Fr) -> Fr:
    """x^5 S-box."""
    return x**5

transient_hash(elems)

Convenience function for Poseidon hashing.

Source code in src/noxipher/crypto/poseidon.py
def transient_hash(elems: list[Fr]) -> Fr:
    """Convenience function for Poseidon hashing."""
    p = Poseidon()
    return p.hash(elems)

Jubjub Curve

noxipher.crypto.jubjub

JubJub curve operations and key derivation for Midnight shielded keys.

This module implements key derivation for Zswap and Dust ecosystems, complying with Midnight Protocol v8.1.0-rc.1.

DustSecretKey

Implementation of DustSecretKey. Matches DustSecretKey in ledger/src/dust.rs.

Source code in src/noxipher/crypto/jubjub.py
class DustSecretKey:
    """
    Implementation of DustSecretKey.
    Matches DustSecretKey in ledger/src/dust.rs.
    """

    def __init__(self, secret_key: Fr) -> None:
        self._sk = secret_key

    @classmethod
    def from_seed(cls, seed: bytes) -> DustSecretKey:
        """
        Derive Dust key from seed.
        Matches DustSecretKey::sample_bytes(seed, 64, b"midnight:dsk").
        Uses Fr (BLS12-381 scalar field) as per ledger/src/dust.rs.
        """
        if len(seed) != 32:
            raise ValueError("Seed must be 32 bytes")

        dsk_bytes = sample_bytes(64, b"midnight:dsk", seed)
        sk = Fr.from_uniform_bytes(dsk_bytes)
        return cls(sk)

    @property
    def public_key(self) -> bytes:
        """
        Derive Dust Public Key.
        Matches DustPublicKey derivation: transient_hash([Fr::from_le_bytes("mdn:dust:pk"), sk])
        """
        # "mdn:dust:pk" (11 bytes) as a field element
        domain_f = Fr.from_le_bytes(b"mdn:dust:pk")

        # sk as Fr
        sk_f = self._sk

        pk_f = transient_hash([domain_f, sk_f])
        return pk_f.to_bytes()

    @property
    def secret_key(self) -> Fr:
        return self._sk

public_key property

Derive Dust Public Key. Matches DustPublicKey derivation: transient_hash([Fr::from_le_bytes("mdn:dust:pk"), sk])

from_seed(seed) classmethod

Derive Dust key from seed. Matches DustSecretKey::sample_bytes(seed, 64, b"midnight:dsk"). Uses Fr (BLS12-381 scalar field) as per ledger/src/dust.rs.

Source code in src/noxipher/crypto/jubjub.py
@classmethod
def from_seed(cls, seed: bytes) -> DustSecretKey:
    """
    Derive Dust key from seed.
    Matches DustSecretKey::sample_bytes(seed, 64, b"midnight:dsk").
    Uses Fr (BLS12-381 scalar field) as per ledger/src/dust.rs.
    """
    if len(seed) != 32:
        raise ValueError("Seed must be 32 bytes")

    dsk_bytes = sample_bytes(64, b"midnight:dsk", seed)
    sk = Fr.from_uniform_bytes(dsk_bytes)
    return cls(sk)

ZswapSecretKeys

Implementation of midnight-zswap SecretKeys. Derived from a 32-byte seed.

Source code in src/noxipher/crypto/jubjub.py
class ZswapSecretKeys:
    """
    Implementation of midnight-zswap SecretKeys.
    Derived from a 32-byte seed.
    """

    def __init__(self, coin_secret_key: bytes, encryption_secret_key: EmbeddedFr) -> None:
        self._coin_sk = coin_secret_key
        self._enc_sk = encryption_secret_key

    @classmethod
    def from_seed(cls, seed: bytes) -> ZswapSecretKeys:
        """
        Derive Zswap keys from a 32-byte seed using protocol-defined domain separators.
        Matches Seed::derive_coin_secret_key and Seed::derive_encryption_secret_key
        in midnight-zswap/src/keys.rs.
        """
        if len(seed) != 32:
            raise ValueError("Seed must be 32 bytes")

        # Derive Coin Secret Key (CSK)
        # hash(b"midnight:csk" || seed)
        csk_writer = PersistentHashWriter()
        csk_writer.update(b"midnight:csk")
        csk_writer.update(seed)
        coin_sk = csk_writer.finalize()

        # Derive Encryption Secret Key (ESK)
        # sample_bytes(64, b"midnight:esk", seed) -> Fr::from_uniform_bytes
        esk_bytes = sample_bytes(64, b"midnight:esk", seed)
        enc_sk = EmbeddedFr.from_uniform_bytes(esk_bytes)

        return cls(coin_sk, enc_sk)

    @property
    def coin_public_key(self) -> bytes:
        """
        Derive Coin Public Key (CPK).
        Matches SecretKey::public_key in coin-structure/src/coin.rs.
        hash(b"midnight:zswap-pk[v1]" || coin_sk)
        """
        pk_writer = PersistentHashWriter()
        pk_writer.update(b"midnight:zswap-pk[v1]")
        pk_writer.update(self._coin_sk)
        return pk_writer.finalize()

    @property
    def encryption_public_key(self) -> bytes:
        """
        Derive Encryption Public Key (EPK).
        Matches SecretKey::public_key in transient-crypto/src/encryption.rs.
        EPK = generator * enc_sk
        """
        gen = JubJubPoint.generator()
        pk_point = gen * self._enc_sk
        return pk_point.to_bytes()

    @property
    def coin_secret_key(self) -> bytes:
        return self._coin_sk

    @property
    def encryption_secret_key(self) -> EmbeddedFr:
        return self._enc_sk

coin_public_key property

Derive Coin Public Key (CPK). Matches SecretKey::public_key in coin-structure/src/coin.rs. hash(b"midnight:zswap-pk[v1]" || coin_sk)

encryption_public_key property

Derive Encryption Public Key (EPK). Matches SecretKey::public_key in transient-crypto/src/encryption.rs. EPK = generator * enc_sk

from_seed(seed) classmethod

Derive Zswap keys from a 32-byte seed using protocol-defined domain separators. Matches Seed::derive_coin_secret_key and Seed::derive_encryption_secret_key in midnight-zswap/src/keys.rs.

Source code in src/noxipher/crypto/jubjub.py
@classmethod
def from_seed(cls, seed: bytes) -> ZswapSecretKeys:
    """
    Derive Zswap keys from a 32-byte seed using protocol-defined domain separators.
    Matches Seed::derive_coin_secret_key and Seed::derive_encryption_secret_key
    in midnight-zswap/src/keys.rs.
    """
    if len(seed) != 32:
        raise ValueError("Seed must be 32 bytes")

    # Derive Coin Secret Key (CSK)
    # hash(b"midnight:csk" || seed)
    csk_writer = PersistentHashWriter()
    csk_writer.update(b"midnight:csk")
    csk_writer.update(seed)
    coin_sk = csk_writer.finalize()

    # Derive Encryption Secret Key (ESK)
    # sample_bytes(64, b"midnight:esk", seed) -> Fr::from_uniform_bytes
    esk_bytes = sample_bytes(64, b"midnight:esk", seed)
    enc_sk = EmbeddedFr.from_uniform_bytes(esk_bytes)

    return cls(coin_sk, enc_sk)

coin_commitment(nonce, token_type, value, recipient_is_user, recipient_hash)

Compute Zswap coin commitment. Matches Info::commitment in coin-structure/src/coin.rs.

Source code in src/noxipher/crypto/jubjub.py
def coin_commitment(
    nonce: bytes,
    token_type: bytes,
    value: int,
    recipient_is_user: bool,
    recipient_hash: bytes,
) -> bytes:
    """
    Compute Zswap coin commitment.
    Matches Info::commitment in coin-structure/src/coin.rs.
    """
    writer = PersistentHashWriter()
    writer.update(b"midnight:zswap-cc[v1]")
    writer.update(nonce)  # 32 bytes
    writer.update(token_type)  # 32 bytes
    writer.update(value.to_bytes(16, "little"))  # u128 LE

    # Recipient
    writer.update(b"\x01" if recipient_is_user else b"\x00")
    writer.update(recipient_hash)

    return writer.finalize()

compute_binding_commitment(randomness, value=0)

Compute Pedersen binding commitment: G^randomness * H^value. Ensures cryptographic binding between public value balance and ZK proofs.

Source code in src/noxipher/crypto/jubjub.py
def compute_binding_commitment(randomness: bytes, value: int = 0) -> bytes:
    """
    Compute Pedersen binding commitment: G^randomness * H^value.
    Ensures cryptographic binding between public value balance and ZK proofs.
    """
    r_scalar = EmbeddedFr.from_le_bytes(randomness)

    # G generator
    g = JubJubPoint.generator()
    commitment = g * r_scalar

    if value != 0:
        # H generator (orthogonal to G)
        # For now, we use a derived generator (g+g) as a placeholder for the protocol H
        g = JubJubPoint.generator()
        h = g + g
        v_scalar = EmbeddedFr(abs(value))
        value_point = h * v_scalar

        if value > 0:
            commitment = commitment + value_point
        else:
            # Subtraction: res = res + (-temp)
            # We don't have -point implemented, but we can do scalar multiplication with negative
            # Or just use the fact that order is JUBJUB_ORDER
            neg_v = EmbeddedFr(JUBJUB_ORDER - (abs(value) % JUBJUB_ORDER))
            value_point = h * neg_v
            commitment = commitment + value_point

    return commitment.to_bytes()

hash_to_field(data)

Implementation of transient-crypto/src/hash.rs:hash_to_field. Construction: transient_hash([midnight:field_hash, data])

Source code in src/noxipher/crypto/jubjub.py
def hash_to_field(data: bytes) -> Fr:
    """
    Implementation of transient-crypto/src/hash.rs:hash_to_field.
    Construction: transient_hash([midnight:field_hash, data])
    """
    # Midnight represents [u8] in field as chunks of 31 bytes (FR_BYTES_STORED).
    # For small strings like domain separators, it's just Fr(data_le).

    # preimage = b"midnight:field_hash".field_repr() + data.field_repr()
    preimage = []

    # "midnight:field_hash" (19 bytes)
    preimage.append(Fr.from_le_bytes(b"midnight:field_hash"))

    # data
    # We simplify for data < 31 bytes which is the case for our domains
    if len(data) > 31:
        # Full implementation would chunk it LE
        raise NotImplementedError("hash_to_field for large data not implemented")
    preimage.append(Fr.from_le_bytes(data))

    return transient_hash(preimage)