pax_global_header00006660000000000000000000000064152104221070014504gustar00rootroot0000000000000052 comment=b398a214b3d8d1d29bd447c26e9008bd9d0dece1 jtsylve-spice-crypt-98ea63c/000077500000000000000000000000001521042210700160705ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/.github/000077500000000000000000000000001521042210700174305ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/.github/FUNDING.yml000066400000000000000000000015251521042210700212500ustar00rootroot00000000000000# These are supported funding model platforms github: [jtsylve] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] jtsylve-spice-crypt-98ea63c/.github/workflows/000077500000000000000000000000001521042210700214655ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/.github/workflows/ci.yml000066400000000000000000000034541521042210700226110ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later name: CI on: push: branches: [main] pull_request: branches: [main] permissions: contents: read jobs: lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v5 - name: Set up Python run: uv python install - name: Run ruff check run: uv run ruff check . - name: Run ruff format check run: uv run ruff format --check . rust: name: Rust lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - run: cargo fmt --check - run: cargo clippy -- -D warnings test: name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.10", "3.14"] steps: - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - name: Install dependencies run: uv sync --no-install-project --group dev --python ${{ matrix.python-version }} - name: Build Rust extension uses: PyO3/maturin-action@v1 with: command: develop args: --release --uv - name: Run tests run: uv run pytest tests/ -v reuse: name: REUSE compliance runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: REUSE compliance check uses: fsfe/reuse-action@v5 jtsylve-spice-crypt-98ea63c/.github/workflows/publish.yml000066400000000000000000000065051521042210700236640ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later name: Publish to PyPI on: release: types: [published] workflow_dispatch: permissions: contents: read jobs: # --------------------------------------------------------------------------- # Build wheels for each platform # --------------------------------------------------------------------------- linux: name: Linux (${{ matrix.target }}) runs-on: ${{ matrix.runner }} strategy: matrix: include: - target: x86_64-unknown-linux-gnu runner: ubuntu-latest - target: aarch64-unknown-linux-gnu runner: ubuntu-latest - target: x86_64-unknown-linux-musl runner: ubuntu-latest - target: aarch64-unknown-linux-musl runner: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: PyO3/maturin-action@v1 with: target: ${{ matrix.target }} args: --release --locked --out dist manylinux: auto - uses: actions/upload-artifact@v7 with: name: wheels-linux-${{ matrix.target }} path: dist/ macos: name: macOS (${{ matrix.target }}) runs-on: ${{ matrix.runner }} strategy: matrix: include: - target: x86_64-apple-darwin runner: macos-15 # cross-compile to Intel - target: aarch64-apple-darwin runner: macos-15 # Apple Silicon runner steps: - uses: actions/checkout@v6 - uses: PyO3/maturin-action@v1 with: target: ${{ matrix.target }} args: --release --locked --out dist - uses: actions/upload-artifact@v7 with: name: wheels-macos-${{ matrix.target }} path: dist/ windows: name: Windows (${{ matrix.target }}) runs-on: windows-latest strategy: matrix: include: - target: x86_64-pc-windows-msvc - target: aarch64-pc-windows-msvc steps: - uses: actions/checkout@v6 - uses: PyO3/maturin-action@v1 with: target: ${{ matrix.target }} args: --release --locked --out dist - uses: actions/upload-artifact@v7 with: name: wheels-windows-${{ matrix.target }} path: dist/ sdist: name: Source distribution runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: PyO3/maturin-action@v1 with: command: sdist args: --out dist - uses: actions/upload-artifact@v7 with: name: wheels-sdist path: dist/ # --------------------------------------------------------------------------- # Publish all wheels + sdist to PyPI # --------------------------------------------------------------------------- publish-pypi: name: Publish to PyPI needs: [linux, macos, windows, sdist] if: github.event_name == 'release' runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/spice-crypt permissions: id-token: write steps: - uses: actions/download-artifact@v8 with: pattern: wheels-* merge-multiple: true path: dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 jtsylve-spice-crypt-98ea63c/.gitignore000066400000000000000000000005441521042210700200630ustar00rootroot00000000000000__pycache__/ *.py[cod] .ruff_cache/ # Distribution / packaging build/ dist/ *.egg-info/ # Unit test / coverage reports .coverage .coverage.* .pytest_cache/ # Environments .venv # IDE specific files .idea/ .vscode/ *.swp *.swo # Rust / Maturin target/ *.so *.dylib *.dll *.pyd # OS specific files .DS_Store # Claude specific files .claude/ CLAUDE.md jtsylve-spice-crypt-98ea63c/.pre-commit-config.yaml000066400000000000000000000023741521042210700223570ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-toml - id: check-added-large-files - id: debug-statements - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.9.10 hooks: - id: ruff args: [--fix] - id: ruff-format - repo: https://github.com/doublify/pre-commit-rust rev: v1.0 hooks: - id: fmt args: [--manifest-path, Cargo.toml, --] - id: clippy args: [--manifest-path, Cargo.toml, --, -D, warnings] - repo: local hooks: - id: check-version name: check dev-status classifier matches version entry: uv run python -B scripts/check_version.py language: system pass_filenames: false files: pyproject\.toml - id: pytest name: pytest entry: uv run python -B -m pytest tests/ language: system pass_filenames: false always_run: true - repo: https://github.com/fsfe/reuse-tool rev: v6.2.0 hooks: - id: reuse jtsylve-spice-crypt-98ea63c/Cargo.lock000066400000000000000000000134161521042210700200020ustar00rootroot00000000000000# This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "cc" version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", ] [[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crossbeam-deque" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ "crossbeam-epoch", "crossbeam-utils", ] [[package]] name = "crossbeam-epoch" version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "proc-macro2" version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "pyo3" version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf85e27e86080aafd5a22eae58a162e133a589551542b3e5cee4beb27e54f8e1" dependencies = [ "libc", "once_cell", "portable-atomic", "pyo3-build-config", "pyo3-ffi", "pyo3-macros", ] [[package]] name = "pyo3-build-config" version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf94ee265674bf76c09fa430b0e99c26e319c945d96ca0d5a8215f31bf81cf7" dependencies = [ "python3-dll-a", "target-lexicon", ] [[package]] name = "pyo3-ffi" version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "491aa5fc66d8059dd44a75f4580a2962c1862a1c2945359db36f6c2818b748dc" dependencies = [ "libc", "pyo3-build-config", ] [[package]] name = "pyo3-macros" version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5d671734e9d7a43449f8480f8b38115df67bef8d21f76837fa75ee7aaa5e52e" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", "syn", ] [[package]] name = "pyo3-macros-backend" version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22faaa1ce6c430a1f71658760497291065e6450d7b5dc2bcf254d49f66ee700a" dependencies = [ "heck", "proc-macro2", "pyo3-build-config", "quote", "syn", ] [[package]] name = "python3-dll-a" version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d381ef313ae70b4da5f95f8a4de773c6aa5cd28f73adec4b4a31df70b66780d8" dependencies = [ "cc", ] [[package]] name = "quote" version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "rayon" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", ] [[package]] name = "rayon-core" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", ] [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "spice-crypt" version = "0.0.0" dependencies = [ "cpufeatures", "pyo3", "rayon", ] [[package]] name = "syn" version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "target-lexicon" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" jtsylve-spice-crypt-98ea63c/Cargo.toml000066400000000000000000000020651521042210700200230ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later [package] name = "spice-crypt" version = "0.0.0" # Version managed in pyproject.toml edition = "2021" [lib] name = "_aes_brute" path = "spice_crypt/pspice/_aes_brute.rs" crate-type = ["cdylib"] [dependencies] pyo3 = { version = "0.28", features = ["abi3-py310"] } rayon = "1" cpufeatures = "0.2" # Windows cross-compile workaround: maturin 1.13 requires pyo3's # `generate-import-lib` feature to build for Windows without a matching-arch # Python interpreter on the host. The feature is a no-op on non-Windows # targets, but scoping it keeps the extra `python3-dll-a` dep out of Linux # and macOS builds. pyo3 main has deprecated this feature in favor of # raw-dylib linking — revisit once that ships in a pyo3 release and maturin # drops the stale interpreter-presence check. [target.'cfg(windows)'.dependencies] pyo3 = { version = "0.28", features = ["generate-import-lib"] } [profile.release] codegen-units = 1 lto = "thin" strip = "symbols" jtsylve-spice-crypt-98ea63c/LICENSE000066400000000000000000000762201521042210700171040ustar00rootroot00000000000000GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. jtsylve-spice-crypt-98ea63c/LICENSES/000077500000000000000000000000001521042210700172755ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/LICENSES/AGPL-3.0-or-later.txt000066400000000000000000000762201521042210700226110ustar00rootroot00000000000000GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. jtsylve-spice-crypt-98ea63c/LICENSES/CC-BY-4.0.txt000066400000000000000000000411771521042210700211440ustar00rootroot00000000000000Creative Commons Attribution 4.0 International Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. Using Creative Commons Public Licenses Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors. Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public. Creative Commons Attribution 4.0 International Public License By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. Section 1 – Definitions. a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. h. Licensor means the individual(s) or entity(ies) granting rights under this Public License. i. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. j. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. k. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. Section 2 – Scope. a. License grant. 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: A. reproduce and Share the Licensed Material, in whole or in part; and B. produce, reproduce, and Share Adapted Material. 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. 3. Term. The term of this Public License is specified in Section 6(a). 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. 5. Downstream recipients. A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. B. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). b. Other rights. 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. 2. Patent and trademark rights are not licensed under this Public License. 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. Section 3 – License Conditions. Your exercise of the Licensed Rights is expressly made subject to the following conditions. a. Attribution. 1. If You Share the Licensed Material (including in modified form), You must: A. retain the following if it is supplied by the Licensor with the Licensed Material: i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); ii. a copyright notice; iii. a notice that refers to this Public License; iv. a notice that refers to the disclaimer of warranties; v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. 4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License. Section 4 – Sui Generis Database Rights. Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. Section 5 – Disclaimer of Warranties and Limitation of Liability. a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You. b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You. c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. Section 6 – Term and Termination. a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 2. upon express reinstatement by the Licensor. c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. Section 7 – Other Terms and Conditions. a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. Section 8 – Interpretation. a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. Creative Commons may be contacted at creativecommons.org. jtsylve-spice-crypt-98ea63c/README.md000066400000000000000000000302571521042210700173560ustar00rootroot00000000000000# SpiceCrypt A Python library and CLI tool for decrypting encrypted SPICE model files. SpiceCrypt supports LTspice®, PSpice®, and QSPICE® encryption formats with automatic format detection, enabling engineers to use lawfully obtained models in any simulator. ## Features - **LTspice text-based format** — Custom DES variant used in encrypted `.CIR`, `.SUB`, `.LIB`, `.ASY`, and other files - **LTspice Binary File format** — Two-layer XOR stream cipher identified by the `` signature - **PSpice Modes 0–5** — Custom DES (modes 0–2) and AES-256 ECB (modes 3–5) with `$CDNENCSTART`/`$CDNENCFINISH` delimited blocks - **PSpice Mode 4 key recovery** — Brute-force recovery of the user-supplied encryption key via a hardware-accelerated Rust extension (AES-NI / ARM Crypto) - **QSPICE protected blocks** — `.prot`/`.unprot` sub-circuit protection: randomized base-16 encoding, a seed-keyed dual stream cipher, DEFLATE compression, and Windows-1252 keyword detokenization - **Automatic format detection** — All formats are detected and handled transparently - **Streaming API** — Memory-efficient processing for large files - **No runtime dependencies** — Pure Python with an optional compiled Rust extension for key recovery ## Installation Install from [PyPI](https://pypi.org/project/spice-crypt/): ```bash pip install spice-crypt ``` Or with [uv](https://docs.astral.sh/uv/): ```bash uv tool install spice-crypt ``` Or add as a dependency to an existing project: ```bash uv add spice-crypt ``` ### Updating ```bash pip install --upgrade spice-crypt # pip uv tool upgrade spice-crypt # uv tool uv lock --upgrade-package spice-crypt # uv project dependency ``` ## Requirements - Python 3.10 or higher - No runtime dependencies for decryption - Rust toolchain required only to build the optional extension for Mode 4 key recovery ## Command Line Usage SpiceCrypt provides the `spice-crypt` command. All encryption formats are auto-detected. ```bash # Run directly without installing uvx spice-crypt path/to/encrypted_file.lib # Decrypt to stdout spice-crypt path/to/encrypted_file.lib # Decrypt to a file spice-crypt -o output.lib path/to/encrypted_file.lib # Force overwrite if output file exists spice-crypt -f -o output.lib path/to/encrypted_file.lib # Verbose output (shows verification values) spice-crypt --verbose path/to/encrypted_file.lib # Suppress all error messages spice-crypt --quiet -o output.lib path/to/encrypted_file.lib # Process raw hex data (bypass LTspice format detection) spice-crypt --raw path/to/hex_file.txt # Show version spice-crypt --version ``` ### PSpice Mode 4 Mode 4 is the only PSpice mode that uses a user-supplied encryption key. SpiceCrypt can recover this key via brute force or decrypt directly if the key is known. ```bash # Brute-force recover the user key (~seconds on modern hardware) spice-crypt --recover-key path/to/encrypted_file.lib # Decrypt with a known user key spice-crypt --user-key KEY path/to/encrypted_file.lib ``` Key recovery exploits a bug in PSpice's key derivation that reduces the effective keyspace from 2^256 to 2^32. See [SPECIFICATIONS/pspice-attack-summary.md](SPECIFICATIONS/pspice-attack-summary.md) for details. ## Python API ### `decrypt_stream(input_file, output_file=None, is_ltspice_file=None, user_key=None)` Stream-decrypt from a file path or file object. Supports all LTspice, PSpice, and QSPICE formats with automatic detection. ```python from spice_crypt import decrypt_stream # Decrypt file to file _, verification = decrypt_stream("encrypted.lib", "decrypted.lib") # Decrypt file to string plaintext, verification = decrypt_stream("encrypted.lib") # Use file objects with open("encrypted.lib") as infile: plaintext, verification = decrypt_stream(infile) # PSpice Mode 4 with a user key plaintext, _ = decrypt_stream("encrypted.lib", user_key=b"mykey") ``` **Parameters:** - `input_file` — File path (str/PathLike) or file object (text or binary mode). - `output_file` (optional) — File path (str) or binary-mode file object. If `None`, returns decrypted content as a string. - `is_ltspice_file` (bool, optional) — Whether the data is in LTspice format. If `True`, skip PSpice detection; if `False`, treat as raw hex. Auto-detected if `None`. - `user_key` (bytes, optional) — User key bytes for PSpice Mode 4 decryption. **Returns:** `(content, (v1, v2))` — `content` is the decrypted string if no output file was given, otherwise `None`. `(v1, v2)` are format-specific verification values: CRC-based checksums for LTspice text format, CRC-32 and rotate-left hash for Binary File format, `(0, 0)` for PSpice format, or `(block_count, 0)` for QSPICE format (the number of protected blocks decrypted). ### `decrypt(data, is_ltspice_file=None)` Decrypt an in-memory string of encrypted data. Supports LTspice text-based format, raw hex, PSpice text-based formats, and QSPICE `.prot` protected blocks (but not Binary File format or PSpice Mode 4 with a user key). ```python from spice_crypt import decrypt with open("encrypted.lib") as f: data = f.read() plaintext, (v1, v2) = decrypt(data) ``` **Parameters:** - `data` (str) — Encrypted data as a string (LTspice format, PSpice format, or raw hex). - `is_ltspice_file` (bool, optional) — Whether the data is in LTspice format. Auto-detected if `None`. **Returns:** `(plaintext, (v1, v2))` — The decrypted text and a tuple of format-specific verification values (see `decrypt_stream` above). ### Lower-level APIs The following classes are exported for direct use: - `LTspiceFileParser` — Text-based DES format parser - `BinaryFileParser` — Binary File format parser - `PSpiceFileParser` — PSpice format parser (modes 0–5) - `QSpiceFileParser` — QSPICE `.prot` protected-block parser - `CryptoState` — LTspice DES key derivation and per-block decryption - `LTspiceDES` — LTspice custom DES variant - `PSpiceDES` — PSpice custom DES variant - `QSpiceCipher` — QSPICE `.prot` decode, decrypt, inflate, and detokenize primitives ## Supported Formats ### LTspice Text-Based DES Encrypted files contain hex-encoded ciphertext delimited by `* Begin:` and `* End ` comment markers. The first 1024 bytes form a crypto table used for key derivation and as an XOR keystream source. All subsequent blocks are decrypted with a custom DES variant that uses non-standard S-boxes and permutation tables, preceded by an XOR stream cipher layer keyed from the same table. ### LTspice Binary File Binary files are identified by a 20-byte signature (`\r\n\r\n\r\n\x1a`). Decrypted with a two-layer XOR stream cipher using two 32-bit keys from the file header and a 2593-byte substitution table with prime-based stepping. ### PSpice Modes 0–5 Encrypted regions are delimited by `$CDNENCSTART` / `$CDNENCFINISH` markers within otherwise plaintext files. Six encryption modes exist: | Mode | Cipher | Key Source | |------|--------|------------| | 0 | Custom DES | Hardcoded | | 1–2 | Custom DES | Hardcoded + version | | 3 | AES-256 ECB | Hardcoded + version | | 4 | AES-256 ECB | Hardcoded XOR user key + version | | 5 | AES-256 ECB | Hardcoded + version | Modes 0–3 and 5 use key material derived entirely from constants in the PSpice binary. Mode 4 incorporates a user-supplied key, but a bug in the key derivation passes only the short key to the AES engine instead of the extended key, leaving just 4 bytes unknown and reducing the effective keyspace to 2^32. This makes the key recoverable by brute force. ### QSPICE Protected Blocks QSPICE protects sub-circuit bodies with a `.prot` … `.unprot` block embedded in otherwise plaintext model files. The payload is a randomized base-16 text encoding in which each plaintext byte becomes two glyphs from a fixed 64-character alphabet. A 32-bit seed, stored in the clear at the start of the block, keys two XOR keystreams — a Mersenne Twister stream and an additive walk over a fixed 9973-byte table — that together decrypt a DEFLATE (zlib) stream. The inflated netlist is stored in the Windows-1252 code page, in which QSPICE's special device prefixes and operators (`Ã`, `Ø`, `¥`, `€`, `£`, `×`, `«`, `»`, `´`, `µ`) are high-bit bytes; SpiceCrypt decodes these to text and rewrites the micro sign `µ` to the ASCII `u` that other tools expect. Decryption replaces each protected block with the recovered plaintext, leaving surrounding lines unchanged. Because the seed is the only key material and is stored alongside the ciphertext, the scheme provides obfuscation rather than cryptographic protection. ## Specifications Detailed technical documentation of the encryption schemes: - [SPECIFICATIONS/ltspice.md](SPECIFICATIONS/ltspice.md) — LTspice encryption: key derivation, DES variant, stream cipher, and Binary File format - [SPECIFICATIONS/pspice.md](SPECIFICATIONS/pspice.md) — PSpice encryption: modes 0–5, custom DES, AES-256 ECB, and key derivation - [SPECIFICATIONS/pspice-attack-summary.md](SPECIFICATIONS/pspice-attack-summary.md) — PSpice Mode 4 key derivation bug and brute-force key recovery - [SPECIFICATIONS/qspice.md](SPECIFICATIONS/qspice.md) — QSPICE `.prot` protected blocks: base-16 encoding, seed-keyed dual stream cipher, DEFLATE compression, and Windows-1252 keyword tokenization ## Purpose and Legal Basis Many third-party component vendors distribute SPICE models exclusively as LTspice- or PSpice-encrypted files. This encryption locks the models to a single simulator, preventing their use in open-source and alternative tools such as [NGSpice](https://ngspice.sourceforge.io/), [Xyce](https://xyce.sandia.gov/), [PySpice](https://github.com/PySpice-org/PySpice), and others. SpiceCrypt exists to restore interoperability by allowing engineers to use lawfully obtained models in the simulator of their choice. This type of reverse engineering for interoperability is specifically permitted by law: - **United States**: [17 U.S.C. § 1201(f)](https://www.law.cornell.edu/uscode/text/17/1201) permits circumvention of technological protection measures for the sole purpose of achieving interoperability between independently created programs. Section 1201(f)(2) explicitly allows distributing the tools developed for this purpose to others seeking interoperability. Additionally, [§ 1201(g)](https://www.law.cornell.edu/uscode/text/17/1201) permits circumvention when conducted in good-faith encryption research — studying the flaws and vulnerabilities of encryption technologies — and allows dissemination of the research findings. - **European Union**: [Article 6 of the Software Directive (2009/24/EC)](https://eur-lex.europa.eu/eli/dir/2009/24/oj) permits decompilation and reverse engineering when it is indispensable to achieve interoperability with independently created programs. Article 6(3) provides that this right cannot be overridden by contract. ### Disclaimer The legal justifications above pertain to the underlying research, technical analysis, and release of SpiceCrypt itself. They are provided to demonstrate that this work was conducted in good faith and to outline its intended purpose. They should not be construed as legal advice. Encrypted SPICE models are often distributed under license agreements or terms of service that end users may have accepted. It is the end user's responsibility to ensure that their use of SpiceCrypt does not violate any such agreements or any applicable laws in their jurisdiction. SpiceCrypt is intended solely for enabling simulator interoperability with lawfully obtained models. Using it to violate intellectual property rights is immoral and is not an acceptable use of the tool. ## Research Contributors - **Joe T. Sylve, Ph.D.** — Reverse engineering and documentation of the LTspice text-based DES encryption format, PSpice encryption modes, and QSPICE `.prot` protected blocks. - **Lucas Gerads** — Reverse engineering and documentation of the LTspice Binary File encryption format. ## Trademarks LTspice® is a registered trademark of Analog Devices, Inc.\ PSpice® is a registered trademark of Cadence Design Systems, Inc.\ QSPICE® is a registered trademark of Qorvo US, Inc. ## License This project is licensed under the [GNU Affero General Public License v3.0 or later](LICENSES/AGPL-3.0-or-later.txt). Copyright (c) 2025–2026 Joe T. Sylve, Ph.D. jtsylve-spice-crypt-98ea63c/REUSE.toml000066400000000000000000000010331521042210700176450ustar00rootroot00000000000000version = 1 [[annotations]] path = ["README.md", "CLAUDE.md", "uv.lock", ".gitignore", ".github/FUNDING.yml", "Cargo.lock", "tests/data/**/*.lib"] SPDX-FileCopyrightText = "© 2026 Joe T. Sylve, Ph.D. " SPDX-License-Identifier = "AGPL-3.0-or-later" [[annotations]] path = ["SPECIFICATIONS/ltspice.md", "SPECIFICATIONS/pspice.md", "SPECIFICATIONS/pspice-attack-summary.md", "SPECIFICATIONS/qspice.md"] SPDX-FileCopyrightText = "© 2026 Joe T. Sylve, Ph.D. " SPDX-License-Identifier = "CC-BY-4.0" jtsylve-spice-crypt-98ea63c/SPECIFICATIONS/000077500000000000000000000000001521042210700201735ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/SPECIFICATIONS/ltspice.md000066400000000000000000001035321521042210700221640ustar00rootroot00000000000000# LTspice® Encryption Specification **Version**: 1.2.0 ([changelog](#changelog))\ **Author**: Joe T. Sylve, Ph.D. \ \ **Repository**: https://github.com/jtsylve/spice-crypt This document describes the two encryption schemes used by LTspice to protect proprietary model and symbol files (`.CIR`, `.SUB`, `.LIB`, `.ASY`, and others). The text-based format ([Chapter 1](#1-text-based-des-format)) uses a modified variant of the Data Encryption Standard (DES), combined with a pre-DES stream cipher layer and a custom key derivation process. The binary format ([Chapter 2](#2-binary-file-format)) uses a two-layer XOR stream cipher and is unrelated to the DES-based scheme. [SpiceCrypt](https://github.com/jtsylve/spice-crypt) is a reference implementation of this specification, available as a command-line tool and Python library under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). ## Purpose Many third-party component vendors distribute SPICE simulation models exclusively as LTspice-encrypted files. This encryption locks the models to a single proprietary simulator, preventing their use in open-source and alternative tools such as NGSpice, Xyce, PySpice, and others. This specification is published in service of two goals: - **Interoperability**: Documenting the encryption schemes allows developers of alternative SPICE simulators to support lawfully obtained encrypted models. The accompanying [SpiceCrypt](../README.md) reference implementation demonstrates working decryption based on this specification. - **Encryption research**: Both schemes rely on security through obscurity — the key material is stored in the clear alongside the ciphertext, and neither scheme provides meaningful cryptographic protection (see Sections [1.8](#18-security-assessment) and [2.5](#25-security-assessment)). Documenting these properties illustrates how proprietary encryption schemes deviate from established standards. Both activities are specifically permitted by law: - **United States**: [17 U.S.C. § 1201(f)](https://www.law.cornell.edu/uscode/text/17/1201) permits circumvention of technological protection measures for the purpose of achieving interoperability between independently created programs, and Section 1201(f)(2) explicitly allows distributing the tools developed for this purpose. [§ 1201(g)](https://www.law.cornell.edu/uscode/text/17/1201) further permits circumvention conducted in good-faith encryption research and allows dissemination of the findings. - **European Union**: [Article 6 of the Software Directive (2009/24/EC)](https://eur-lex.europa.eu/eli/dir/2009/24/oj) permits decompilation and reverse engineering when indispensable to achieve interoperability with independently created programs. Article 6(3) provides that this right cannot be overridden by contract. ## Contributors - **Joe T. Sylve, Ph.D.** — Research and documentation of the text-based DES encryption format ([Chapter 1](#1-text-based-des-format)). - **Lucas Gerads** — Research and documentation of the Binary File encryption format ([Chapter 2](#2-binary-file-format)). ## License Copyright © 2026 Joe T. Sylve, Ph.D. This document is licensed under the [Creative Commons Attribution 4.0 International License](https://creativecommons.org/licenses/by/4.0/) (CC-BY-4.0). You are free to share and adapt this material for any purpose, including commercial use, provided appropriate credit is given. ## 1. Text-Based DES Format This chapter describes the text-based encryption format. Encrypted files in this format contain hex-encoded ciphertext processed through a pre-DES stream cipher and a modified DES block cipher. Each deviation from standard DES is explicitly noted. ### 1.1 Encrypted File Format An LTspice encrypted file in the text-based format is a plain-text file with the following structure: ``` * LTspice Encrypted File * * This encrypted file has been supplied by a 3rd * party vendor that does not wish to publicize * the technology used to implement this library. * * Permission is granted to use this file for * simulations but not to reverse engineer its * contents. * * [Header comments...] * * Begin: A7 CD 92 6F EA 22 42 3D 95 5E D2 59 B6 03 E5 31 67 06 C2 AF 8A BB 32 98 00 15 89 AF C1 15 0C D9 ... more hex data ... * End ``` **Header**: The first 9 lines (from `* LTspice Encrypted File` through `* contents.`) are a fixed header that LTspice validates exactly; files with modified or missing header lines are rejected. The `* Begin:` marker is matched case-insensitively. **Hex payload**: After the `* Begin:` marker, the file contains space-separated hexadecimal byte values. Each pair of hex characters represents one byte. Whitespace (spaces and newlines) separates individual byte values. Lines beginning with `*` within the payload are comments and are skipped. **Payload structure**: The hex payload is consumed as a flat stream of bytes, grouped into 8-byte (64-bit) blocks: | Block range | Count | Purpose | |-------------|-----------|----------------------------------------| | 0–127 | 128 blocks (1024 bytes) | Crypto table (key material) | | 128+ | Variable | Ciphertext blocks | If the final ciphertext block contains fewer than 8 bytes, it is discarded — the partial bytes are included in the ciphertext CRC but are not decrypted. In practice, LTspice always produces payloads that are exact multiples of 8 bytes. **End line**: The `* End` line contains two unsigned 32-bit decimal integers, `v1` and `v2`, which are CRC-32-based verification checksums (see [Section 1.6](#16-integrity-verification)). ### 1.2 Key Derivation The 64-bit DES key and the initial stream cipher state are derived entirely from the 1024-byte crypto table (the first 128 blocks of the payload). #### 1.2.1 Byte Checksums (Stream Cipher Seeds) The table bytes are split by parity of their index position. Two 8-bit checksums are computed: ``` even_byte_sum = (table[0] + table[2] + table[4] + ... + table[1022]) mod 256 odd_byte_sum = (table[1] + table[3] + table[5] + ... + table[1023]) mod 256 ``` These are then XOR'd with fixed constants to produce the initial stream cipher state values: ``` odd_byte_checksum = odd_byte_sum XOR 0x54 even_byte_checksum = even_byte_sum XOR 0xE7 ``` The `even_byte_checksum` acts as a fixed increment and `odd_byte_checksum` acts as a running accumulator in the stream cipher (see [Section 1.3](#13-pre-des-stream-cipher-layer)). #### 1.2.2 DES Key Construction The table is also processed at the 64-bit (qword) level. It is treated as 64 groups of 16 bytes (two little-endian 64-bit words per group). The even-offset and odd-offset qwords are accumulated separately: ``` qword_sum_even = 0 qword_sum_odd = 0 for i in range(0, 1024, 16): qword_sum_even += uint64_le(table[i : i+8]) qword_sum_odd += uint64_le(table[i+8 : i+16]) # All arithmetic is mod 2^64 combined = (qword_sum_even + qword_sum_odd) mod 2^64 ``` Two 16-bit words are extracted from the combined sum: ``` qword_low_word = combined & 0xFFFF # bits [15:0] qword_high_word = (combined >> 32) & 0xFFFF # bits [47:32] ``` These are XOR'd with 32-bit constants and combined into the 64-bit DES key: ``` key_low = qword_low_word XOR 0x66E22120 key_high = qword_high_word XOR 0x20E905C8 key = (key_high << 32) | key_low ``` > **Note on key entropy**: Although the key is 64 bits wide, it is derived from only two independent 16-bit values (extracted from bits [15:0] and [47:32] of the combined qword sum). Bits [31:16] and [63:48] of each key half are entirely determined by the fixed XOR constants. The effective key entropy is therefore at most 32 bits, significantly less than the 56-bit effective key size of standard DES. #### 1.2.3 Unused Intermediate Passes The original LTspice binary contains two additional passes over the crypto table whose results are computed but never used: - **Pass 2 (byte-group sums)**: The table is treated as 256 groups of 4 bytes. Four positional accumulators sum the bytes at each offset within the groups, and the totals are added together. The result is discarded. - **Pass 3 (word-group sums)**: The table is treated as 128 groups of 8 bytes (four little-endian 16-bit words per group). Four positional accumulators sum the words at each offset, and the totals are added together. The result is discarded. Additionally, after all passes complete, a single DES encryption call is made using the combined pass-2 and pass-3 intermediate values as input. The 32-bit result is stored but never read during decryption; it has no effect on the algorithm's output. All of the above may be vestiges of a previous version of the algorithm or deliberate obfuscation. ### 1.3 Pre-DES Stream Cipher Layer Before each 8-byte ciphertext block is passed to the DES decryption function, a stream cipher layer XORs each byte of the block with a byte selected from the crypto table. This layer uses two state variables, `odd_byte_checksum` and `even_byte_checksum`, initialized during key derivation ([Section 1.2.1](#121-byte-checksums-stream-cipher-seeds)). For each byte `i` (0 through 7) within a block: ``` odd_byte_checksum = (odd_byte_checksum + even_byte_checksum) mod 2^32 table_index = (odd_byte_checksum mod 0x3FD) + 1 block[i] ^= crypto_table[table_index] ``` Key observations: - **`even_byte_checksum` is constant** — it is set once during key derivation and never modified. It serves as a fixed additive step. - **`odd_byte_checksum` is a running accumulator** — it advances by `even_byte_checksum` for every byte processed across all blocks. Its arithmetic is mod 2^32 (it is promoted from its initial 8-bit value to a 32-bit accumulator on the first addition). - **Table index range**: The modulus `0x3FD` (1021) combined with the `+1` offset produces indices in the range [1, 1021]. Index 0 is never used. - **State carries across blocks** — the checksum state is not reset between blocks, making this a proper stream cipher where the keystream depends on the block position. After the XOR pass, the modified 8-byte block is interpreted as a little-endian 64-bit integer and passed to the DES decryption function. ### 1.4 DES Variant: Deviations from FIPS 46-3 The core block cipher is a 16-round Feistel network structurally similar to DES (FIPS 46-3), using the same permutation tables (IP, IP^-1, E, P, PC-1, PC-2) and S-boxes. However, the LTspice implementation introduces several deviations from the standard. #### 1.4.1 Pre-Permutation Half-Swap (Input Block) **Standard DES**: The 64-bit plaintext/ciphertext block is passed directly to the Initial Permutation (IP). **LTspice variant**: Before applying IP, the lower 32 bits and upper 32 bits of the input block are swapped: ``` swapped = (input >> 32) | ((input & 0xFFFFFFFF) << 32) permuted = IP(swapped) ``` This means the DES round function operates on bit-transposed data relative to what standard DES would process given the same input. #### 1.4.2 Pre-PC-1 Half-Swap (Key Schedule) **Standard DES**: The 64-bit key is passed directly to Permuted Choice 1 (PC-1) to extract the 56-bit key material. **LTspice variant**: Before applying PC-1, the same lower/upper 32-bit half-swap is applied to the key: ``` swapped_key = (key >> 32) | ((key & 0xFFFFFFFF) << 32) reduced_key = PC1(swapped_key) ``` This effectively remaps which key bits feed into which positions of the 56-bit reduced key. #### 1.4.3 Reversed Key Rotation Direction **Standard DES**: During key schedule generation, the two 28-bit halves of the reduced key (C and D) are **left-rotated** by the amounts specified in the rotation table: `[1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1]`. **LTspice variant**: The two 28-bit halves are **right-rotated** by the same amounts. ``` # Standard DES (left rotate): lower = ((lower << count) | (lower >> (28 - count))) & 0x0FFFFFFF # LTspice variant (right rotate): lower = ((lower >> count) | (lower << (28 - count))) & 0x0FFFFFFF ``` This produces an entirely different set of 16 round subkeys from the same starting key material. #### 1.4.4 Output Truncation **Standard DES**: After the final permutation (IP^-1), the full 64-bit result is returned as the ciphertext/plaintext block. **LTspice variant**: Only the low 32 bits of the IP^-1 output are retained. The upper 32 bits are discarded: ``` result = FP(combined) & 0xFFFFFFFF # only low 32 bits ``` This is a critical difference: each 64-bit ciphertext block decrypts to only a 32-bit (4-byte) plaintext block, halving the output size relative to the input. #### 1.4.5 Summary of Unmodified DES Components The following components are identical to standard DES: | Component | Description | |-----------|-------------| | Initial Permutation (IP) | Standard 64-bit IP table | | Final Permutation (IP^-1) | Standard 64-bit FP table | | Expansion (E) | Standard 32-to-48-bit expansion | | S-boxes (S1–S8) | Standard 8 S-boxes, 6-bit input to 4-bit output | | P-box (P) | Standard 32-bit permutation | | PC-1 | Standard 64-to-56-bit permuted choice | | PC-2 | Standard 56-to-48-bit permuted choice | | Rotation schedule | Same counts: [1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1] | | Feistel structure | Standard 16-round Feistel network | | Decrypt mode | Standard subkey reversal (use subkeys 15..0) | #### 1.4.6 Combined Effect The half-swaps (Sections [1.4.1](#141-pre-permutation-half-swap-input-block) and [1.4.2](#142-pre-pc-1-half-swap-key-schedule)) and the reversed rotation ([Section 1.4.3](#143-reversed-key-rotation-direction)) together mean that even with the same key and plaintext, the LTspice variant produces completely different round computations and outputs compared to standard DES. These modifications are not equivalent to any simple re-keying or re-ordering of the standard algorithm. The output truncation ([Section 1.4.4](#144-output-truncation)) further means the cipher has fundamentally different input/output dimensions: 64 bits in, 32 bits out — making it a one-way compression function rather than a bijective block cipher. ### 1.5 Block Decryption Pipeline For each 8-byte ciphertext block (blocks 128 onward), the full decryption pipeline is: ``` 1. Read 8 bytes of ciphertext from the hex payload. 2. Apply the pre-DES stream cipher: For each byte i = 0..7: a. odd_byte_checksum += even_byte_checksum (mod 2^32) b. table_index = (odd_byte_checksum mod 0x3FD) + 1 c. block[i] ^= crypto_table[table_index] 3. Interpret the 8-byte modified block as a little-endian uint64. 4. Apply the LTspice DES variant in decrypt mode: a. Swap the low and high 32-bit halves of the input. b. Apply the Initial Permutation (IP). c. Split into left (L) and right (R) 32-bit halves. d. For 16 rounds (using subkeys in reverse order 15..0): - new_R = L XOR F(R, subkey[round]) - new_L = R e. Combine: (L << 32) | R f. Apply the Final Permutation (IP^-1). g. Mask to 32 bits (discard upper half). 5. Output the 32-bit result as 4 little-endian bytes of plaintext. ``` The plaintext output is therefore half the size of the ciphertext input (4 bytes out per 8 bytes in, excluding the 1024-byte crypto table). ### 1.6 Integrity Verification Two CRC-32 checksums are maintained incrementally during decryption: - **`plaintext_crc`**: CRC-32 of all decrypted 4-byte output blocks, computed sequentially. - **`ciphertext_crc`**: CRC-32 of all 8-byte ciphertext blocks (after block 127), computed sequentially. After all blocks are processed, two verification values are derived: ``` table_word_44 = uint32_le(crypto_table[0x44 : 0x48]) table_word_94 = uint32_le(crypto_table[0x94 : 0x98]) v1 = plaintext_crc XOR 0x7A6D2C3A XOR table_word_44 v2 = ciphertext_crc XOR 0x4DA77FD3 XOR table_word_94 ``` These values are compared against the two integers on the `* End` line of the file. A mismatch indicates data corruption or an incorrect decryption implementation. > **Validation order**: LTspice performs a two-pass validation: the first pass decrypts the entire file and verifies the CRC checksums against the `* End` line. Only if they match does a second pass produce decrypted output. Files with CRC mismatches are rejected entirely. ### 1.7 S-Box Layout The S-box data is stored in a flat array with 1-based indexing (index 0 is padding). Each S-box maps a 6-bit input to a 4-bit output. The 6-bit input is decomposed as: ``` row = (bit5 << 0) | (bit0 << 1) # 2 bits from MSB and LSB column = (bit1 << 3) | (bit2 << 2) | (bit3 << 1) | bit4 # 4 bits from middle, reversed ``` > Note: Both the row and column bit orderings are reversed compared to standard DES. In standard DES, row = {bit5, bit0} (bit5 as MSB) and column = {bit4, bit3, bit2, bit1} (bit4 as MSB). In this implementation, bit0 is the MSB of row and bit1 is the MSB of column. The interleaved S-box storage and bit-transform together compensate for this, producing the same net substitution values as standard DES S-boxes. The S-box value is looked up at flat-array offset: ``` offset = (36 * column) + (9 * row) + sbox_index + 1 ``` where `sbox_index` ranges from 0 to 7. This interleaved storage format differs from the conventional per-S-box matrix layout in DES literature, but produces the same mapping. After lookup, the 4-bit S-box output is passed through a bit-transform table that reverses the bit order: ``` DES_BIT_TRANSFORM[n]: 0→0, 1→8, 2→4, 3→C, 4→2, 5→A, 6→6, 7→E, 8→1, 9→9, A→5, B→D, C→3, D→B, E→7, F→F ``` This is equivalent to reflecting bits [3:0] to [0:3] (i.e., bit-reversal within the nibble). This transform is folded into the S-box output as part of the implementation, but the net result of S-box + transform produces the same 4-bit substitution values as standard DES S-boxes — the transform compensates for the interleaved storage format. ### 1.8 Security Assessment From a cryptographic perspective: - **Low effective key entropy**: The DES key is derived from only 32 bits of independent data (two 16-bit words extracted from the crypto table checksum). This is well below the 56-bit effective key size of standard DES and far below modern standards. - **Static key material in cleartext**: The entire 1024-byte crypto table from which the key is derived is stored in the clear as the first 128 blocks of the payload. Anyone with knowledge of the key derivation algorithm can compute the key. - **Deterministic stream cipher**: The pre-DES XOR layer's keystream is fully determined by the crypto table, which is public. It adds no additional security beyond the DES layer. - **Obfuscation, not encryption**: The security of this scheme relies entirely on secrecy of the algorithm (security through obscurity) rather than secrecy of the key. Once the algorithm is known, any encrypted file can be decrypted without any secret. ## 2. Binary File Format This chapter describes the "Binary File" encryption format, a two-layer XOR stream cipher unrelated to the DES-based scheme in [Chapter 1](#1-text-based-des-format). The substitution table and key validation table referenced below are fixed constants extracted from the LTspice binary; see [Appendix A](#appendix-a-binary-file-key-validation-table) and [Appendix B](#appendix-b-binary-file-substitution-table) for the full data. ### 2.1 File Structure A Binary File is identified by a 20-byte signature and has the following layout: | Offset | Size | Field | |--------|------|-------| | 0 | 20 | Signature: `\r\n\r\n\r\n\x1a` | | 20 | 4 | `key1` (unsigned 32-bit little-endian integer) | | 24 | 4 | `key2` (unsigned 32-bit little-endian integer) | | 28 | Variable | Encrypted body (byte stream) | The signature in hex is `0D 0A 3C 42 69 6E 61 72 79 20 46 69 6C 65 3E 0D 0A 0D 0A 1A`. The trailing `0x1A` (ASCII SUB / Ctrl-Z) causes legacy programs that treat it as an end-of-file marker to stop reading, preventing the binary body from being displayed as garbled text. ### 2.2 Key Derivation The `key1` and `key2` header fields are combined to derive the two parameters of the keystream generator: a `base` index and a `step` value. #### 2.2.1 Base Index Lookup A check value is computed: ``` check = key1 XOR key2 ``` This check value is looked up in a fixed 100-entry validation table (see [Appendix A](#appendix-a-binary-file-key-validation-table)). Each entry maps a 32-bit check value to a 32-bit base value used as the starting index into the substitution table. If the check value is not found in the table, the file cannot be decrypted (the key pair is unrecognized). #### 2.2.2 Step Value Selection The step value is selected from a 26-entry table indexed by `key2 mod 26`: | Index | Step | Index | Step | Index | Step | |-------|------|-------|------|-------|------| | 0 | 1 | 9 | 23 | 18 | 61 | | 1 | 2 | 10 | 29 | 19 | 67 | | 2 | 3 | 11 | 31 | 20 | 71 | | 3 | 5 | 12 | 37 | 21 | 73 | | 4 | 7 | 13 | 41 | 22 | 79 | | 5 | 11 | 14 | 43 | 23 | 83 | | 6 | 13 | 15 | 47 | 24 | 89 | | 7 | 17 | 16 | 53 | 25 | 97 | | 8 | 19 | 17 | 59 | | | Entry 0 is 1; entries 1 through 25 are the first 25 prime numbers. The substitution table modulus is 2593, which is itself prime. Since every step value in the table is coprime to 2593 (each is either 1 or a prime less than 2593), the linear congruential index sequence `(base + step × N) mod 2593` is guaranteed to have full period — all 2593 table entries are visited exactly once before the sequence repeats. ### 2.3 Decryption Each byte of the encrypted body (from offset 28 onward) is decrypted independently. For body byte at index `N` (0-based): ``` decrypted[N] = encrypted[N] XOR key2_bytes[N mod 4] XOR sbox[(base + step × N) mod 2593] ``` where: - **`key2_bytes`** are the 4 raw little-endian bytes of the `key2` header field, applied cyclically. - **`sbox`** is a fixed 2593-byte substitution table (see [Appendix B](#appendix-b-binary-file-substitution-table)). - **`base`** and **`step`** are derived from the header as described in [Section 2.2](#22-key-derivation). The two XOR layers are: 1. **Cyclic key XOR**: Each byte is XOR'd with one of the 4 bytes of `key2`, cycling every 4 bytes. This is equivalent to repeating the `key2` value as a 4-byte mask across the entire body. 2. **S-box keystream XOR**: Each byte is XOR'd with a value from the 2593-byte substitution table. The index into this table advances linearly: starting at `base`, incrementing by `step` each byte, modulo 2593. The full-period property of this sequence (see [Section 2.2.2](#222-step-value-selection)) means the keystream repeats only after 2593 bytes. Both layers commute (XOR is associative and commutative), so they can be applied in either order or combined into a single pass. > **Note on index arithmetic**: The index expression `base + step × N` is computed using 32-bit unsigned arithmetic. For files exceeding approximately 42 MB (with step=97), the multiplication wraps modulo 2^32, producing a different index sequence than the mathematical formula. Encrypted files in practice are well below this threshold. ### 2.4 Integrity Verification Two verification values are computed over the decrypted output: - **CRC-32**: A standard CRC-32 checksum (ISO 3309 / ITU-T V.42) of the entire decrypted byte stream. - **Rotate-left hash**: An additive hash where each decrypted byte is rotated left within a 32-bit word by a position-dependent shift before being accumulated: ``` rotate_hash = 0 for i, byte in enumerate(decrypted): shift = (i + 1) mod 32 rotated = rotate_left_32(byte, shift) rotate_hash = (rotate_hash + rotated) mod 2^32 ``` where `rotate_left_32(value, shift)` performs a 32-bit left rotation. When `shift` is 0, the byte value is added directly without rotation. Unlike the text-based format ([Section 1.6](#16-integrity-verification)), the Binary File format does not embed expected checksum values in the file. The computed values are available for external validation only. ### 2.5 Security Assessment From a cryptographic perspective: - **XOR-only cipher**: The entire scheme consists of XOR operations with a deterministic keystream. There is no block cipher or nonlinear transformation applied to the plaintext. - **Static, embedded key material**: Both the substitution table and the key validation table are fixed constants embedded in the LTspice binary. The per-file keys (`key1`, `key2`) are stored in the clear in the file header. - **Extremely small keyspace**: With only 100 valid check values in the key table and 26 possible step values, there are at most 2,600 distinct decryption configurations. Exhaustive search is trivial even without knowledge of the algorithm. - **Fully deterministic**: Given the header fields, the entire keystream is determined. No external secret is required for decryption. ## Appendix A: Binary File Key Validation Table The key validation table contains 100 entries. Each entry is an 8-byte record consisting of a 32-bit check value followed by a 32-bit base value, both in little-endian byte order. The check value is matched against `key1 XOR key2` from the file header; the corresponding base value provides the starting index into the substitution table. The table is presented below as raw hexadecimal bytes, 32 bytes (4 entries) per line. Whitespace is for readability only. ``` c0bc4523 64070000 8e725b71 1e060000 96313950 0c060000 fe701206 5c060000 73154e5e 41000000 49199c73 54080000 c9acf402 13030000 63bf893a 84010000 5ec00c30 f9040000 45b8d307 30090000 7c55821c 1e030000 8567a56f be050000 26df2d7f 64070000 2e79d827 36000000 77f6040d a4000000 897b2a22 dd030000 07531c2e b7020000 8faa374c 27070000 f8df310d c9030000 165f0b70 5a070000 f6951b3f 77010000 2a9c0f30 d0080000 d4059268 0b090000 dc2e6733 51060000 26f04935 ee040000 e0803a2b b6030000 34ede626 0e000000 c6688d19 39020000 9b3d0621 6a030000 deb6ee5c 14070000 0dbc3c39 b7080000 4ae7555e e3020000 7f209f12 84020000 b293ae65 da000000 68add77c 27050000 e2f5500b 04030000 286b6139 7a050000 1d86037e e4020000 99edf925 c1030000 2f37923a 210a0000 1b9ca56c 4f060000 6123102d 98060000 75a0ac00 e3040000 a955a139 8b050000 1b6e0308 0d020000 2412be4f 28030000 ef3ea915 28050000 3d399928 da020000 488ba158 d2070000 e55f1948 a7060000 b7bf0164 b4020000 0e7c6c11 3f000000 d3e7ca0e 18030000 dd9b563f 4b040000 25b7da40 15040000 2cb30810 64050000 1c8bb55f ec080000 90dc0c41 7e080000 b562b603 66020000 a3091502 d5010000 c13e3e11 41080000 f9faf94c 03060000 3515e77f c5020000 1fdd2f4f be010000 2501db03 5e030000 2ed9012e 39040000 cc92b36a dd080000 bdeb3f05 fd050000 6857de4e 8c080000 0d50432e 9a090000 a65a7f3e 5d050000 ce61393d 9d040000 c7d9647b 83090000 5511977e d2070000 9770f478 cd070000 4e0dd50a 18080000 bf367f52 51070000 0a2d1a31 1f0a0000 7e3c624d a0030000 72ecee2a 55010000 2e479317 db010000 80fe1939 cd040000 de1a5f18 d3000000 9b54c57b 45040000 d771f002 ba010000 d480f676 98030000 e1a75468 52000000 41b2a45f 0a020000 00217632 f9010000 26bed462 66020000 8edee75e 0b020000 f0409d35 6c070000 bbd37845 41050000 4161cd03 4e090000 24780167 cd010000 dd4d1864 9c070000 5513ad07 0e060000 4d99db00 1d060000 9b368c5b cb040000 79a04907 0c070000 ``` To parse entry `i` (0-based), read 8 bytes at offset `i × 8`: the first 4 bytes are the check value and the next 4 bytes are the base value, both as unsigned 32-bit little-endian integers. ## Appendix B: Binary File Substitution Table The substitution table is a fixed array of 2593 bytes, indexed by `(base + step × N) mod 2593` during decryption (see [Section 2.3](#23-decryption)). The complete table is presented below as raw hexadecimal bytes. Line breaks are for readability only. ``` d55f931826290d5b2961bf26ee61590a58e7b22742b9265466c72f56013d5800 52cff40c0b6f7565e0f4241c1f81fc2a0f6410603af89f5d139320161730ff10 ef101921c1976b254d7b525de8fafb0511071c475fad6c1bd2830d11bde37323 e54e2e66ed2d631152268548e8fe7035f924aa1ca1006572d7869c0acf843d35 c729724d00e85b31bde6963f1f11257542a1820523aec615214e7d7594707712 2e1d3c7b0143a211b3f1733d3e814c5b3b3b426fc784945355b14b6c2a4c5b10 881c0079a22c9e491247571699231c4002da0a65e4ca642756079063e728394b d1f8c738a82d152ccf27aa00cb1d72554a2e7a1ea7ae460b9aa2af0a1158ec6b a796a23c5789464a31691161ea3725427a370d6052b78e567ea89c54a954495b 53fa3068329a1012e7d595368e357357f91ea5653c87e122b881ce67813ba55e dfb37f6ccac8257e1a5fc11ee18d8a51ae938a2570665102c8b6c31c808c525e 1894662e98de6d1d4baac43362c2e04c3f8db428e54c743e741acd38e6235765 3cd6ba08a583de19d05b7c27b60dc868f73a6d704f04197c5f6211444a359e58 819e290e4638a77ad86a11307abdce7383bf881d90ecdf17fbf87352627308 0a5ab50516155835714301935b0849903b85be86730bb8567888d5e2199d52ed 21a396c415d37fa74d0015ce6ee223793eb8cc1b0c742f9b27c947d023f4a2d6 1419b3794199a34c4babb09e7d10eee631e8a765470a13b0415a23850a69468f 55514b573c328e963ae3035e49d40ae059c27a7652defcd11b367ee8631c307c 68f354070d797f7b3f24790c2478138e008437d237ad4eef3d16667b2228ce96 4d80ce960b167b49110af20f0c399bb2178aaae438d339e02f2d3e892ca35d5e 7a6ddd2c7bd8ee272ab34b452c55859242e301d86b0d6fca36bfcb2118344d2f 283ffd6071a2cf7f6108580f020178d74381cc517d3ed6f7651da8532c742159 0ab755732541216050ed34e70a3b8d455dee6f4f0e039b622d635bdc2a6f3ee6 191916ac3e6e4dec36a8d99831a3c090774187cc66d517225e461eef71ae64f9 61ae064a08f969341e04ea8b249108227406d9fe54c3b5ad3cc555511c45d65f 4665852d1ecdad601e464e370ae6517f1b0b84580463f68a365b73d825c2d9cb 29a417eb0648a8bf30fd66110793873a154b43225e61c2ed3102c6202f6459ce 1ccf0fda68aa9fb960071a5f141097a64f7fb7db3e4d384e06bffb9f312dbe25 4746a28224c3e52b56bec6473b4c7b8179869bd912831c99579151e13feb2007 3150caf975d79f184ad272864c5b4e527a3a96a3002de65e721d281e24dead8e 07758e1e231b8f2f2b7135c91cc0d140017c511d5d73fbe94b242b0f1e4b61f7 451d9ba32c2b456e325bf89d159d527f6b787dbc381af43d47ca10a532be1f3f 5dddd9691d89d7ec6d0a9bc056637543300cf485459beca1164f964a615dbe7f 3b728cba602109d12db80cd235ac225e614eef2f20d634f0598ad0ec68c37d4e 43f1c31f05fc05b605834f8f446d153d626f01a051a77a9e62b87634288d9c43 7ed2bf0c15136fd23d2aefc2694a3dc94d2e631005f4ff671c085d082b0b3d7a 227dd7540a12f8c8016fb2bd528acbda4fade46a18be480834e7895a0b1f7125 79df51d9619f962c41cb93835a2d41090275cb1c1b55647043f0be5745668f3c 20516a2649730ee709d3a47902c16bc61a1a89856c8b1bae2a4e080a19ec4892 019f8a806878f7cc0236865b4fcded906d6cf7341f3ee3637ad82a0b10eace89 2950db2c7c47ddc862749a6479fdbf97140526d1165b24bf041c31bd0de477aa 78fabaeb45e7c4406811b9b37a708608613c29b12b01780b40d61545018e93d7 747486f249aababe034fff9d0f8e0f783635d66c2e9d07a8287a580a38d460ed 1615ff742bb0de6507a14e7e0481f6a94aeec1c9017a7989146bc533743e9df6 7dc1565277df5f986d3b5d8e12c77c230e3a845772578e4b20abf4cd06353f43 383e538c08bdad8101a5c54b197b7c3d34be258d417bdb901a0910152933ac7f 0b25964f1e580fb338c1bbf7415b6cbc4cf5165b613c14027a2fcda9630a16d0 0cecf26701d11b28688b0c7a57dbb431034b95b17cf7d1ad4b195228010cec03 74d631463955afb613d368270211b69d2bac3d02347f5df50846f5e063eb908e 3c3c0b770aebba2c7d660dcc70fa30044c6696bd176f1de1192ddd83578c2c0d 36c72c9452ef987b19e798c902bc43ef332bad7d1316667366c659bf4017a0e5 14e7819b4e51663918f254171832174d4b4838e7630ca73f193f03513f1f6a2d 1d6156f62c126c78413020cb480d94f86091c96d4a7615ac2cf824871dcdd4e4 5461d0d8295e32530ec805e920c7669641cd4f3428f5e26c785393a377947cc8 7ae47be8113a2c6d7a50c0b72e0f2966255192e060161a776f27c94b3a38147c 2f6880b007191e63526b2bc97ab0b8976b25c5a26baa2e1a3acf22c508861b99 18bc9a927bff42905194af91794e64004675583c7e8cd418171b39e51ad62815 28eb066c25e33ece3b9e8fab69b856a04dd9213b34f1224f614dd36848bd9d23 462c4fbc5b9d932077cdc6896b7de19c3cb4ad9766f48fd525b5f5186c1c2e48 6e0dae38782021e266cce6df593373db63ca4ffc209c09a562b98e747c87ea8e 1c9b4c35344d3e0676d54e8f6211a57132da121f0df087747de7cd865ac5198b 32d4c64239855d32447d702b00ade87d6d77808125ca4394486a86a133a3cf3d 0168d7b43f374d2b1f20b1da3d1c854c262bdd0045d5a6f32938b39414398b39 3df6c7d510049a746e6cfe1421c017d231a0a31951258d891d4702614e3cf04e 0573cb8f131c51f0304d95c0374ddeae200dd9642e3463471212f83953e19fa7 67bac079568f6865538e8825553141fb7b5aacf91bf80ec708d410397dc283ae 5b305cf227f4c1133bde08fb015b39f36cc968076516bc8f1694c42c2abf30dd 751a56040500c3414b8048af27bbf91d562650cb68c74a1076f7e96c5b991b5b 7ce49b0027447f2d13e6f9091df174655578e27425f8f14370d2140d3d32a3ee 7b875aa943609d321263e4e977e106a35f58acf91a37f52275a38a513b8808ec 422bb7363081934c3de441df2ff51f3e15974fdc5378060c5ab4501b0bb2a5e0 5879c94d253499ca326d9ffe2e9f19190efce3da2864896b0a3835740ae07fdb 4fa808991d1e2f7e27d1f4402520eb0d431621c217a3094e62538efc3e9d7b6b 5b03a78074b672e6367f820e3b5b537a0fee67092c220d6076e45b6652191f40 5ca4a0ac33c89d45020e3f7e713bf0880740a4515cc38f997ced956960b96d9f 01f728642f5a35680f5887b80ff30c3f58bebed31990bc2c1ad38c1a2866c76c 37aeebaa41a4815b4d87b27a7ac40c6d59478ba92fda4077396288d8344a322a 2490b35d70e10ae76fa685a4337e1b671c031847668ae10a06983aa778a7b8f3 19527f5008a679256ae3a87c219223a2646909bf66d03ee6014c914166613223 162b744e11a418fa75543f626ee932222b35d5261028cc7c1650fa8e62e3c0d1 51cc4dd863d7ac095da8cd3e2b14d98113b1ed80160a5617605e0bac3741a1de 06eb ``` ## Changelog ### 1.2.0 - Broadened file type scope from `.CIR`/`.SUB` to include `.LIB`, `.ASY`, and other encrypted file types. - Documented the fixed 9-line header validation (Section 1.1). - Corrected partial final block handling: incomplete blocks are discarded, not zero-padded (Section 1.1). - Documented the unused DES encryption call in key derivation that consumes pass-2/pass-3 intermediate values (Section 1.2.3). - Documented two-pass CRC validation behavior (Section 1.6). - Added note on 32-bit unsigned index arithmetic in the Binary File format (Section 2.3). ### 1.1.0 - Restructured document into two chapters: Chapter 1 (Text-Based DES Format) and Chapter 2 (Binary File Format). - Added Chapter 2: specification of the Binary File encryption format, including file structure, two-layer XOR stream cipher, key derivation via lookup table, and integrity verification. - Added Appendix A (Binary File Key Validation Table) and Appendix B (Binary File Substitution Table) with full constant data for independent reimplementation. - Renumbered all existing sections from 1–8 to 1.1–1.8; subsections renumbered accordingly. ### 1.0.0 - Initial release documenting the text-based DES encryption format. --- LTspice® is a registered trademark of Analog Devices, Inc. jtsylve-spice-crypt-98ea63c/SPECIFICATIONS/pspice-attack-summary.md000066400000000000000000000115301521042210700247400ustar00rootroot00000000000000## PSpice® Mode 4 Encryption Weakness PSpice is a SPICE circuit simulator from Cadence Design Systems that encrypts proprietary semiconductor model files to protect vendor IP and prevent reuse in third-party SPICE simulators. The encryption scheme is proprietary and undocumented. PSpice supports six encryption modes (0–5). Modes 0–3 and 5 derive all key material from constants hardcoded in the binary; once those constants are extracted, files in these modes can be decrypted directly. Mode 4 is the only mode that incorporates user-supplied key material: vendors provide a key string via a CSV file referenced by the `CDN_PSPICE_ENCKEYS` environment variable. This key is XOR'd with the hardcoded base keys during derivation, so decryption requires the same key file. A bug in key derivation reduces the effective keyspace to 2^32, making the user key recoverable by brute force in seconds. ### The Bug Mode 4 uses AES-256 in ECB mode. Key derivation starts from two base strings: - `g_desKey`: a 4-byte "short" base key (`"8gM2"`) - `g_aesKey`: a 27-byte "extended" base key (`"H41Mlwqaspj1nxasyhq8530nh1r"`) When a user provides a key via the `CDN_PSPICE_ENCKEYS` CSV file, user key bytes 0–3 are XOR'd into the short base, and bytes 4–30 are XOR'd into the extended base. A version suffix (e.g., `"1002"`) is then appended to each base key. `PSpiceAESEncoder_setKey` receives only the short key (`g_desKey`), not the extended key (`g_aesKey`). The 32-byte AES-256 key is constructed by zero-padding this null-terminated string: ``` Byte 0–3: XOR("8gM2", user_key[0:4]) -- unknown (4 bytes) Byte 4–7: "1002" -- version suffix (atoi(version_string) + 999) Byte 8: 0x00 (null terminator) -- known Byte 9–31: 0x00 (zero padding) -- known ``` `EncryptionContext_init` calls `initEncryptionKeys` to derive both keys, then passes only `g_desKey` to the cipher engine via a vtable call: ``` lea rdx, g_desKey ; short key loaded as setKey argument ... call qword ptr [rax] ; vtable[0]: setKey(&g_desKey) ``` `PSpiceAESEncoder_setKey` copies this null-terminated string into a zero-filled 32-byte local buffer and calls `AES_keyExpansion(self+8, keyBuf, 256)`. `g_desKey` in mode 4 is 8 characters (4 XOR'd bytes + `"1002"`) followed by a null terminator, so bytes 9–31 of the AES key are always zero. Since 28 of 32 key bytes are known, the effective keyspace shrinks from 2^256 to 2^32. ### Brute-Force Attack The first encrypted block after every `$CDNENCSTART` marker is a metadata header whose plaintext always begins with the fixed prefix `"0001.0000 "` (10 ASCII bytes). This prefix falls entirely within the first 16-byte AES sub-block, providing a known-plaintext crib for validating candidate keys. The attack: 1. Take the first 16 bytes of the header ciphertext block. 2. For each of the 2^32 candidate 4-byte values, construct the full 32-byte key (4 candidate bytes + known suffix + zeros) and decrypt the sub-block. 3. If the first 10 bytes of the decrypted sub-block equal `"0001.0000 "`, the candidate is correct. Exhaustive search takes seconds with AES-NI, or under 1 second on a GPU. ### Full User Key Recovery Once the 4-byte brute-force attack succeeds, the full user key is recoverable. The metadata header's plaintext contains the derived `g_aesKey`: the extended base XOR'd with user key bytes, with the version suffix appended. 1. **Short user key** (bytes 0–3): XOR the recovered 4 bytes with the known base `"8gM2"`. 2. **Extended user key** (bytes 4–30): Decrypt the metadata header with the recovered AES key. The embedded `g_aesKey` equals `XOR("H41Mlwqaspj1nxasyhq8530nh1r", user_key[4:31]) + "1002"`. Strip the version suffix and XOR with the known base to recover the remaining 27 user key bytes. The entire user key string from the CSV file is now known, and all files encrypted with that key are compromised. ### Root Cause The names `g_desKey` and `g_aesKey` are reverse-engineered labels, not original source names. The key sizes suggest the extended key was intended for AES and the short key for DES. The short key is 8 bytes after derivation, matching a DES key size. The extended key is 31 bytes plus a null terminator to fill 32 bytes, which is likely an off-by-one error since AES-256 requires 32 bytes of key material. Passing the short key to the AES engine appears to be a copy-paste error from the DES code path. Had the extended key been used, the effective keyspace would be 2^216, making a brute-force attack infeasible. AES-256 encryption support was introduced in PSpice 16.6 (April 2014), alongside the existing DES-based modes. The bug has presumably been present since that release. Fixing it now would break compatibility with every encrypted model created in the twelve years since its introduction. --- PSpice is a trademark of Cadence Design Systems, Inc. jtsylve-spice-crypt-98ea63c/SPECIFICATIONS/pspice.md000066400000000000000000000721241521042210700220060ustar00rootroot00000000000000# PSpice® Encryption Specification **Version**: 1.1.0 ([changelog](#changelog))\ **Author**: Joe T. Sylve, Ph.D. \ \ **Repository**: https://github.com/jtsylve/spice-crypt This document describes the six encryption modes used by Cadence PSpice to protect proprietary SPICE model and subcircuit definitions within `.LIB` and other netlist files. Modes 0–2 use a custom DES variant; modes 3–5 use AES-256 in ECB mode. All six modes share a common file format ([Chapter 1](#1-file-format)) and a 64-byte block structure ([Chapter 2](#2-block-structure)). [SpiceCrypt](https://github.com/jtsylve/spice-crypt) is a reference implementation of this specification, available as a command-line tool and Python library under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). ## Purpose Many third-party component vendors distribute SPICE simulation models exclusively as PSpice-encrypted files. This encryption locks the models to a single proprietary simulator, preventing their use in open-source and alternative tools such as NGSpice, Xyce, PySpice, and others. This specification is published in service of two goals: - **Interoperability**: Documenting the encryption schemes allows developers of alternative SPICE simulators to support lawfully obtained encrypted models. The accompanying [SpiceCrypt](../README.md) reference implementation demonstrates working decryption based on this specification. - **Encryption research**: Several of the modes rely on security through obscurity — key material is either hardcoded or derivable from public data, and the effective keyspace is far smaller than the nominal algorithm parameters suggest (see [Chapter 7](#7-security-assessment)). Documenting these properties illustrates how proprietary encryption schemes deviate from established standards. Both activities are specifically permitted by law: - **United States**: [17 U.S.C. § 1201(f)](https://www.law.cornell.edu/uscode/text/17/1201) permits circumvention of technological protection measures for the purpose of achieving interoperability between independently created programs, and Section 1201(f)(2) explicitly allows distributing the tools developed for this purpose. [§ 1201(g)](https://www.law.cornell.edu/uscode/text/17/1201) further permits circumvention conducted in good-faith encryption research and allows dissemination of the findings. - **European Union**: [Article 6 of the Software Directive (2009/24/EC)](https://eur-lex.europa.eu/eli/dir/2009/24/oj) permits decompilation and reverse engineering when indispensable to achieve interoperability with independently created programs. Article 6(3) provides that this right cannot be overridden by contract. ## Contributors - **Joe T. Sylve, Ph.D.** — Research and documentation of the PSpice encryption modes 0–5. ## License Copyright © 2026 Joe T. Sylve, Ph.D. This document is licensed under the [Creative Commons Attribution 4.0 International License](https://creativecommons.org/licenses/by/4.0/) (CC-BY-4.0). You are free to share and adapt this material for any purpose, including commercial use, provided appropriate credit is given. ## 1. File Format PSpice encrypted files are otherwise-ordinary SPICE netlists in which selected `.SUBCKT` / `.ENDS` and `.MODEL` blocks have been replaced by hex-encoded ciphertext enclosed between delimiter markers. ### 1.1 Delimiter Markers Each encrypted region begins with a `$CDNENCSTART` variant and ends with the corresponding `$CDNENCFINISH` variant. The specific marker determines the encryption mode: | Marker | Mode | Cipher | |--------|------|--------| | `$CDNENCSTART` | 0 | DES | | `$CDNENCSTART_CENC`*V* | 1 | DES | | `$CDNENCSTART_ADV1` | 2 | DES | | `$CDNENCSTART_ADV2` | 3 | AES-256 ECB | | `$CDNENCSTART_ADV3` | 4 | AES-256 ECB | | `$CDNENCSTART_USER_ADV3` | 4 | AES-256 ECB (user key) | | `$CDNENCSTART_CENC5` | 5 | AES-256 ECB | The finish markers follow the same pattern, substituting `FINISH` for `START` (e.g., `$CDNENCFINISH_ADV2`). The trailing digits in the marker (e.g., `1` in `$CDNENCSTART_ADV1`, `2` in `$CDNENCSTART_ADV2`) constitute the **version string**. This string is used in key derivation ([Chapter 3](#3-key-derivation)). For bare `$CDNENCSTART` (mode 0), the version string is empty. ### 1.2 Encrypted Region Layout Between the start and finish markers, each line contains the hex encoding of one 64-byte encrypted block: ``` $CDNENCSTART_ADV2 a1b2c3d4... (128 hex characters = 64 bytes) e5f6a7b8... (128 hex characters = 64 bytes) ... $CDNENCFINISH_ADV2 ``` Each line is exactly 128 hexadecimal characters (lowercase), representing 64 bytes. Lines that are not exactly 128 hex characters, or that fail hex decoding, are skipped. ### 1.3 Non-Encrypted Pass-Through Lines outside `$CDNENCSTART` / `$CDNENCFINISH` pairs are plaintext SPICE netlist content and are not encrypted. A single file may contain multiple encrypted regions interspersed with plaintext. ## 2. Block Structure All six modes operate on **64-byte blocks**. Each 64-byte block has the following internal structure: | Byte range | Size | Content | |-----------|------|---------| | 0–61 | 62 bytes | Payload (line content and/or padding) | | 62–63 | 2 bytes | Overflow marker, padding tail, or line content/terminator (see [§2.2](#22-content-blocks)–[§2.4](#24-line-continuation)) | The two trailing bytes are **not** unused: their value depends on the role of the block, and decryption must examine them to reconstruct line boundaries ([§2.4](#24-line-continuation)). ### 2.1 Header Block The first encrypted block in each region is a **header block**. It is not part of the plaintext output; it contains metadata formatted as: ``` 0001.0000 0 -1 novendorinformation ``` where `` is the extended key string derived during key derivation ([Section 3.2](#32-extended-key-metadata-only)). If the formatted header exceeds 62 bytes (which occurs for modes with longer extended keys), it is truncated to 62 bytes; otherwise it is padded as described in [Section 2.3](#23-padding). The resulting 64 bytes are encrypted. During decryption, the header block is decrypted and validated: the known prefix `"0001.0000 "` serves as a sentinel confirming that the correct key was used. The header is then discarded and is not part of the plaintext output. The same prefix serves as a known-plaintext crib for key recovery attacks ([Chapter 6](#6-mode-4-key-recovery)). ### 2.2 Content Blocks After the header block, the encoder packs the plaintext **one source line at a time** into successive 64-byte blocks. A line's bytes — its text plus the trailing carriage return inherited from CRLF source files (the line-feed itself is not stored) — are written 62 bytes at a time into bytes 0–61. Each block is then either an *overflow* block, when 62 or more bytes of the line still remain, or a *final* block, which completes the line: - **Overflow block** — bytes 0–61 hold 62 bytes of line content and bytes 62–63 hold the marker `0x24 0x2B` (the ASCII string `$+`). The line continues in the next block. - **Final block** — bytes 0–61 (and, when the line fills the block, bytes 62–63) hold the remaining line content followed by the padding described in [§2.3](#23-padding). Each netlist line therefore maps to one final block, optionally preceded by a single overflow block. Block-level splitting occurs strictly at the 62-byte boundary, **mid-token if necessary** (for example a 70-character identifier is split after its 62nd byte, with the remaining 8 bytes carried into the next block): the encoder copies 62 raw bytes and does not seek a word boundary here. Lines too long for two blocks are first broken into multiple netlist lines at the source level ([§2.4](#24-line-continuation)). ### 2.3 Padding In a final block whose content does not fill the available space, the remainder is padded as follows: 1. The 6-byte sentinel ` $jbs$` (space followed by `$jbs$`) is written immediately after the content. 2. The remaining bytes are filled with pseudo-random ASCII characters. Each fill byte is generated as `(rand() & 0x0F) + base`, where `base` cycles through the values 65 (`A`) through 70 (`F`) in groups of six. The fill extends through byte 62; byte 63 is left null (`0x00`). Because the sentinel is written immediately after the content, it may **straddle the 62-byte payload boundary**: when the content leaves fewer than six bytes before byte 62, only a prefix of the sentinel (` $jbs`, ` $jb`, ` $j`, or ` $`) falls within bytes 0–61 and the remainder spills into bytes 62–63. When the content together with its carriage return fills the block exactly (63 or 64 bytes), there is no room for the sentinel at all and the carriage return itself occupies byte 62 or 63. During decryption, a block terminates the current line if any of the following is found, in order: the full sentinel ` $jbs$` within bytes 0–61; a carriage return (`0x0D`) anywhere in bytes 0–63, with everything after it discarded as padding; or a truncated sentinel prefix at the tail of bytes 0–61 confirmed by the next sentinel byte at byte 62 (for source files with no carriage returns). Otherwise the block is an overflow block — identified by the `$+` marker in bytes 62–63 — and its 62 content bytes are concatenated with the following block. Any trailing null bytes are stripped. ### 2.4 Line Continuation Continuation operates at two independent levels. **Block level.** A netlist line longer than 62 bytes is split across an overflow block and a final block (see [§2.2](#22-content-blocks)). There is no in-band continuation marker beyond the `$+` written into bytes 62–63 of the overflow block; decryption reconstructs the line by concatenating the overflow block's 62 content bytes with the final block. **Source level.** Before block packing, the encoder breaks any netlist line of 125 bytes or more into shorter lines using ordinary SPICE continuation syntax: it scans the first 124 bytes for the last space or comma, truncates the line there, and emits the remainder as a new line prefixed with `+` (ASCII `0x2B`). The process repeats until each line fits. Each resulting line — including the `+` ones — is then packed into one or two blocks as above. Consequently a leading `+` on a decrypted line is **ordinary SPICE syntax**, a netlist-level continuation of the preceding logical line, whether it was present in the original source or inserted by the encoder. It carries no meaning for block-level decoding and is preserved verbatim in the output; it must **not** be stripped or merged into the previous line. ## 3. Key Derivation Each mode derives two key strings from a combination of hardcoded constants, the marker's version string, and (for mode 4) optional user-provided key bytes. ### 3.1 Short Key (Encryption Key) The **short key** is the actual key passed to the cipher engine. It is an ASCII byte string constructed as described in the table below. | Mode | Short key | |------|-----------| | 0 | `0a0vr7jo` (literal, 8 bytes) | | 1 | `1b1w` + *N* | | 2 | `1b1x` + *N* | | 3 | `8gM2` + *N* | | 4 (no user key) | `8gM2` + *N* | | 4 (with user key) | XOR(`8gM2`, *user*[0:4]) + *N* | | 5 | `1yti` + *N* | where *N* is the **version suffix**, a decimal integer computed as: ``` N = atoi(version_string) + 999 ``` For mode 0, the version string is empty and the short key is the literal `0a0vr7jo` with no suffix appended. For example, if the marker is `$CDNENCSTART_ADV2`, the version string is `"2"`, so *N* = `2 + 999 = 1001`, and the short key for mode 3 is `"8gM21001"` (8 bytes). ### 3.2 Extended Key (Metadata Only) The **extended key** is written into the encrypted header block ([Section 2.1](#21-header-block)) but is **not used as encryption key material**. It is constructed as: | Mode | Extended key | |------|-------------| | 0 | `ths0m02ukhy034r6` (literal, 16 bytes) | | 1 | `uit1n13vliz1` + *N* | | 2 | `uit1x13vlka1` + *N* | | 3 | `H41Mlwqaspj1nxasyhq8530nh1r` + *N* | | 4 (no user key) | `H41Mlwqaspj1nxasyhq8530nh1r` + *N* | | 4 (with user key) | XOR(`H41Mlwqaspj1nxasyhq8530nh1r`, *user*[4:31]) + *N* | | 5 | `nhtti50rplx2` + *N* | ### 3.3 Mode 4 User Key XOR Mode 4 optionally incorporates a user-provided key to modify the base key constants. The user key is a 31-byte ASCII string loaded from a CSV file identified by the `CDN_PSPICE_ENCKEYS` environment variable. The CSV file has lines of the form: ``` ; ``` The first entry whose file path does not match the file being decrypted provides the key bytes. When a user key of at least 4 bytes is available: 1. **Short key modification**: The first 4 bytes of the user key are XOR'd byte-by-byte with the 4-byte short key base `8gM2` (ASCII bytes `0x38 0x67 0x4D 0x32`). The version suffix *N* is appended after XOR. 2. **Extended key modification**: User key bytes 4 through 30 (up to 27 bytes) are XOR'd byte-by-byte with the 27-byte extended key base `H41Mlwqaspj1nxasyhq8530nh1r` (ASCII). The version suffix *N* is appended after XOR. When no user key is available, mode 4 uses the unmodified base keys, identical to mode 3. Files encrypted with a user key use the `$CDNENCSTART_USER_ADV3` marker; files without use `$CDNENCSTART_ADV3`. ## 4. DES Variant (Modes 0–2) Modes 0–2 use a custom DES variant that retains the standard 16-round Feistel network structure but differs from FIPS 46-3 in its permutation tables, S-boxes, and key rotation direction. ### 4.1 Key Setup The short key string (up to 8 bytes) is treated as a sequence of raw bytes. If shorter than 8 bytes, it is zero-padded on the right to 8 bytes. The 8-byte value is interpreted as a little-endian 64-bit integer for the DES key schedule. ### 4.2 Block Processing Each 64-byte encrypted block is processed as **8 independent DES-ECB blocks** of 8 bytes each. For each 8-byte sub-block: 1. Read 8 bytes from the block. 2. Interpret as a little-endian 64-bit integer. 3. Decrypt (or encrypt) using the PSpice DES variant. 4. Write the 64-bit result back as 8 little-endian bytes. The full 64-bit DES output is retained. ### 4.3 Deviations from Standard DES (FIPS 46-3) The PSpice DES variant shares the same Feistel structure, Expansion (E), P-box, and rotation count schedule as standard DES. The following components differ: #### 4.3.1 Custom S-Boxes All eight S-boxes differ from the standard DES S-boxes. Each S-box maps a 6-bit input to a 4-bit output using the standard DES decomposition: row = {bit 5, bit 0} (bit 5 is MSB), column = {bit 4, bit 3, bit 2, bit 1} (bit 4 is MSB). The complete S-box data is given in [Appendix A](#appendix-a-des-s-boxes). #### 4.3.2 Custom Permuted Choice 1 (PC-1) The 56-entry PC-1 table, which selects 56 bits from the 64-bit key, differs from the standard DES PC-1. The complete table (0-indexed bit positions) is: ``` 0, 57, 49, 41, 33, 25, 17, 56, 48, 40, 32, 24, 16, 8, 9, 1, 58, 50, 42, 34, 26, 62, 54, 46, 38, 30, 22, 14, 18, 10, 2, 59, 51, 43, 35, 13, 5, 60, 52, 44, 36, 28, 6, 61, 53, 45, 37, 29, 21, 20, 12, 4, 27, 19, 11, 3, ``` #### 4.3.3 Custom Permuted Choice 2 (PC-2) The 48-entry PC-2 table, which selects 48 bits from the 56-bit reduced key for each round, differs from the standard DES PC-2: ``` 13, 16, 10, 23, 0, 4, 22, 18, 11, 3, 25, 7, 2, 27, 14, 5, 20, 9, 15, 6, 26, 19, 12, 1, 29, 39, 50, 44, 32, 47, 40, 51, 30, 36, 46, 54, 45, 48, 38, 55, 33, 52, 41, 49, 35, 43, 28, 31, ``` #### 4.3.4 Custom Initial Permutation (IP) The 64-entry Initial Permutation differs from the standard DES IP by three pair-swaps. The complete table (0-indexed): ``` 57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 13, 3, 61, 53, 45, 37, 29, 21, 11, 5, 55, 63, 47, 39, 31, 23, 15, 7, 48, 56, 40, 32, 24, 16, 8, 0, 58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, 62, 54, 46, 38, 30, 22, 14, 6, ``` The deviations from the standard DES IP (FIPS 46-3) are three pair-swaps of values (0-indexed positions in the IP table): positions 14 and 22 (values 11 and 13 exchanged), positions 24 and 25 (values 63 and 55 exchanged), and positions 32 and 33 (values 56 and 48 exchanged). #### 4.3.5 Custom Final Permutation (FP / IP^-1) The 64-entry Final Permutation is the inverse of the custom IP above: ``` 39, 7, 47, 15, 55, 23, 63, 31, 38, 6, 46, 22, 54, 14, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29, 36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27, 34, 2, 42, 10, 50, 18, 58, 26, 32, 1, 41, 9, 49, 17, 57, 24, 33, 0, 40, 8, 48, 16, 56, 25, ``` #### 4.3.6 Reversed Key Rotation Direction **Standard DES**: During key schedule generation, the two 28-bit halves (C and D) of the reduced key are **left-rotated** by the amounts in the standard rotation schedule: `[1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1]`. **PSpice variant**: The two 28-bit halves are **right-rotated** by the same amounts. ``` # Standard DES (left rotate): half = ((half << count) | (half >> (28 - count))) & 0x0FFFFFFF # PSpice variant (right rotate): half = ((half >> count) | (half << (28 - count))) & 0x0FFFFFFF ``` #### 4.3.7 Unmodified DES Components The following components are identical to standard DES (FIPS 46-3): | Component | Description | |-----------|-------------| | Expansion (E) | Standard 32-to-48-bit expansion | | P-box (P) | Standard 32-bit permutation | | Rotation schedule | Same counts: [1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1] | | Feistel structure | Standard 16-round Feistel network | | Output | Full 64-bit result (no truncation) | | No input half-swap | Input block passed directly to IP (no pre-IP swap) | | No key half-swap | Key passed directly to PC-1 (no pre-PC-1 swap) | | Decrypt mode | Standard subkey reversal (use subkeys 15..0) | ## 5. AES-256 ECB (Modes 3–5) Modes 3–5 use standard AES-256 in Electronic Codebook (ECB) mode, as specified in FIPS 197. There are no algorithmic deviations from the standard; the cipher itself is unmodified. ### 5.1 Key Construction The short key string is copied into a 32-byte buffer, zero-padded on the right. For example, the mode 3 key with version string `"2"` is: ``` Short key string: "8gM21001" (8 ASCII bytes) 32-byte AES key: 38 67 4D 32 31 30 30 31 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ``` The standard AES-256 key expansion (FIPS 197, Section 5.2) is then applied to produce the 15 round keys (60 32-bit words). ### 5.2 Block Processing Each 64-byte encrypted block is processed as **4 independent AES-256-ECB blocks** of 16 bytes each: 1. Divide the 64-byte block into four 16-byte sub-blocks. 2. Decrypt each sub-block independently using AES-256-ECB. 3. Concatenate the four 16-byte results to form the 64-byte decrypted block. ### 5.3 Key Entropy Although AES-256 nominally provides a 256-bit key, the actual key entropy is much lower: - **Modes 3 and 5**: The key is entirely determined by the hardcoded base string and the version suffix. There is zero secret entropy; the key is fully public. - **Mode 4 without user key**: Identical to mode 3. - **Mode 4 with user key**: Only 4 bytes of the user key are XOR'd into the short key (the first 4 bytes). The remaining 24 bytes of the 32-byte AES key are always zero. The effective keyspace is therefore 2^32, a factor of 2^224 smaller than the 2^256 nominal AES-256 keyspace. ## 6. Mode 4 Key Recovery The combination of a small effective keyspace (2^32) and a known plaintext crib makes mode 4 user keys recoverable through brute-force search. For a detailed analysis of the root cause, including the key derivation bug and its implications, see [PSpice Mode 4 Encryption Weakness](pspice-attack-summary.md). ### 6.1 Known Plaintext Crib The header block ([Section 2.1](#21-header-block)) always begins with the fixed prefix `"0001.0000 "` (10 ASCII bytes: `30 30 30 31 2E 30 30 30 30 20`). This prefix falls entirely within the first 16-byte AES sub-block of the 64-byte encrypted block, providing a reliable known-plaintext crib. ### 6.2 Key Structure The 32-byte AES key for mode 4 has the following structure: | Byte positions | Content | |---------------|---------| | 0–3 | Unknown (XOR of `8gM2` with user key bytes 0–3) | | 4–7 | Version suffix digits (ASCII), e.g., `31 30 30 32` for `"1002"` | | 8–31 | Zero bytes | Only bytes 0–3 are unknown. All other bytes are either known constants (the version suffix) or zero. ### 6.3 Search Procedure For each candidate value `C` in `[0, 2^32)`: 1. Set bytes 0–3 of the 32-byte key to `C` (little-endian). 2. Perform the AES-256 key expansion. 3. Decrypt the first 16-byte sub-block of the encrypted header. 4. Compare the first 10 bytes of the decrypted result against `"0001.0000 "`. 5. If they match, the candidate is the correct short key prefix. ### 6.4 User Key Recovery Once the correct short key bytes `S[0:4]` are found: 1. **User key bytes 0–3**: `user[0:4] = XOR(S[0:4], "8gM2")` 2. **User key bytes 4–30**: Decrypt the full header block, extract the extended key string from position 10, strip the version suffix, and XOR with the extended key base `H41Mlwqaspj1nxasyhq8530nh1r` to recover `user[4:31]`. ### 6.5 Key Schedule Optimization The zero-heavy key structure allows the AES-256 key expansion to be partially simplified. In the standard key schedule, words `W[0]` through `W[7]` are derived directly from the key bytes. Since `W[1]` contains only the known version suffix and words `W[2]` through `W[7]` are zero, the first two "epochs" of the key schedule (words `W[8]` through `W[23]`) can be simplified by eliding zero-valued terms. Subsequent epochs use the standard recurrence. ### 6.6 Default Key Detection Before initiating the brute-force search, the header block should be tested against the unmodified mode 4 base keys (identical to mode 3). If the header decrypts successfully with the default keys, no user key was applied, and the file can be decrypted directly without key recovery. ## 7. Security Assessment ### 7.1 Modes 0–2 (DES) - **Fully deterministic keys**: The short key for modes 0–2 is derived entirely from hardcoded constants and the version string, both of which are visible in the file. No secret is required for decryption. - **Weak underlying cipher**: Even if the keys were secret, the custom DES variant operates on 64-bit blocks with at most a 64-bit key, well below modern standards. The effective key entropy is limited to the 56 bits surviving PC-1, minus any entropy reduction from the ASCII key character set (typically 6–7 bits per byte). - **Custom tables do not add security**: The non-standard S-boxes, permutation tables, and reversed key rotation change the cipher's bit mappings but do not increase its resistance to known attacks once the tables are published. ### 7.2 Modes 3 and 5 (AES-256, No User Key) - **Zero-entropy keys**: The AES-256 key is entirely determined by hardcoded constants and the version string. Any encrypted file can be decrypted without any secret. - **Misleading key size**: Despite using AES-256, the actual key material occupies at most 8 bytes (the short key string), with the remaining 24 bytes always zero. ### 7.3 Mode 4 (AES-256, User Key) - **Effective keyspace of 2^32**: Only 4 bytes of the user key affect the encryption key. The remaining 27 bytes of the user key only affect the extended key in the header metadata. - **Known plaintext available**: The fixed header prefix `"0001.0000 "` provides a reliable crib for validating candidate keys. - **Brute-force feasible**: With hardware-accelerated AES (AES-NI or ARM Crypto Extensions) and multi-core parallelism, the 2^32 keyspace can be exhaustively searched in seconds on modern hardware. - **User key fully recoverable**: Once the short key is found, the full 31-byte user key can be reconstructed from the decrypted header. ### 7.4 General Observations - **ECB mode weakness**: Both the DES and AES modes use ECB (each sub-block encrypted independently). Identical plaintext blocks produce identical ciphertext blocks, potentially revealing patterns. - **No integrity protection**: PSpice encrypted files have no cryptographic authentication (MAC, HMAC, or AEAD). The header prefix `"0001.0000 "` ([Section 2.1](#21-header-block)) serves as a decryption sentinel but is a fixed constant, not a data-dependent checksum. - **Obfuscation, not encryption**: For modes 0–3 and 5, the security relies entirely on secrecy of the algorithm and key constants. Once the algorithm is known, any encrypted file in these modes can be decrypted without any secret. ## Appendix A: DES S-Boxes The PSpice DES variant uses eight custom S-boxes. Each S-box maps a 6-bit input to a 4-bit output using the standard DES decomposition: row = {bit 5, bit 0} (bit 5 is MSB), column = {bit 4, bit 3, bit 2, bit 1} (bit 4 is MSB). Each table below has 4 rows (0–3) and 16 columns (0–15). > **Note**: The reference implementation stores S-box data in an equivalent but differently-arranged internal format (reversed row/column bit ordering with a 4-bit output transform). The tables below have been recomputed to use the standard DES decomposition so they can be used directly with any standard DES implementation. **S-box 0** ``` 7, 8, 12, 10, 13, 6, 2, 0, 15, 4, 5, 9, 1, 3, 11, 14, 2, 1, 13, 12, 6, 9, 3, 10, 8, 11, 15, 5, 4, 14, 7, 0, 0, 4, 7, 9, 14, 8, 6, 12, 15, 11, 5, 10, 2, 13, 3, 1, 15, 4, 14, 5, 9, 12, 13, 6, 3, 2, 10, 0, 8, 7, 1, 11, ``` **S-box 1** ``` 11, 13, 12, 0, 5, 14, 2, 7, 1, 6, 15, 10, 8, 3, 4, 9, 15, 9, 6, 3, 1, 4, 12, 10, 8, 14, 13, 0, 7, 11, 2, 5, 12, 3, 15, 6, 2, 8, 1, 13, 11, 0, 4, 9, 14, 5, 7, 10, 0, 10, 5, 9, 14, 3, 11, 4, 7, 1, 2, 12, 13, 6, 8, 15, ``` **S-box 2** ``` 11, 4, 12, 3, 0, 10, 6, 15, 14, 1, 2, 13, 9, 7, 5, 8, 8, 2, 6, 13, 11, 7, 1, 4, 5, 15, 9, 10, 0, 12, 14, 3, 5, 8, 6, 13, 9, 3, 15, 4, 0, 11, 12, 2, 7, 14, 10, 1, 11, 13, 1, 10, 2, 4, 12, 7, 6, 8, 15, 5, 9, 3, 0, 14, ``` **S-box 3** ``` 2, 5, 12, 10, 11, 4, 6, 3, 14, 8, 0, 13, 7, 1, 9, 15, 4, 11, 0, 7, 6, 8, 13, 1, 5, 15, 3, 10, 9, 12, 14, 2, 12, 2, 10, 8, 1, 4, 15, 7, 11, 14, 6, 5, 13, 3, 0, 9, 8, 9, 5, 3, 0, 10, 11, 4, 15, 2, 12, 14, 6, 13, 1, 7, ``` **S-box 4** ``` 5, 1, 2, 11, 4, 12, 14, 7, 13, 10, 8, 0, 3, 15, 6, 9, 2, 11, 15, 6, 8, 3, 13, 0, 4, 14, 9, 12, 1, 10, 5, 7, 7, 14, 0, 12, 8, 15, 3, 1, 13, 11, 4, 9, 10, 5, 2, 6, 5, 8, 13, 6, 10, 4, 3, 0, 2, 7, 1, 15, 12, 11, 14, 9, ``` **S-box 5** ``` 11, 8, 14, 4, 2, 15, 13, 1, 12, 5, 10, 6, 7, 9, 3, 0, 9, 1, 7, 8, 12, 2, 10, 13, 3, 0, 15, 11, 14, 5, 4, 6, 5, 0, 14, 8, 2, 10, 11, 12, 15, 9, 3, 13, 4, 6, 7, 1, 2, 5, 13, 6, 4, 8, 10, 1, 12, 7, 9, 0, 3, 14, 15, 11, ``` **S-box 6** ``` 10, 12, 15, 2, 4, 9, 1, 6, 13, 3, 8, 5, 7, 14, 11, 0, 8, 5, 3, 0, 13, 6, 7, 9, 2, 1, 12, 10, 11, 15, 14, 4, 11, 7, 2, 4, 13, 10, 8, 9, 0, 12, 1, 15, 14, 3, 5, 6, 6, 9, 13, 7, 1, 0, 5, 12, 11, 10, 2, 4, 8, 15, 14, 3, ``` **S-box 7** ``` 12, 5, 6, 10, 1, 11, 13, 4, 3, 9, 15, 0, 2, 7, 8, 14, 14, 0, 9, 15, 2, 5, 7, 10, 13, 6, 3, 12, 8, 11, 4, 1, 8, 3, 5, 1, 11, 6, 14, 9, 15, 10, 12, 7, 0, 13, 2, 4, 4, 15, 2, 12, 7, 9, 1, 6, 8, 3, 13, 10, 14, 0, 11, 5, ``` ## Appendix B: Mode and Marker Summary | Mode | Cipher | Marker suffix | Short key base | Extended key base | User key? | |------|--------|---------------|----------------|-------------------|-----------| | 0 | DES | *(none)* | `0a0vr7jo` | `ths0m02ukhy034r6` | No | | 1 | DES | `_CENC`*V* | `1b1w` | `uit1n13vliz1` | No | | 2 | DES | `_ADV1` | `1b1x` | `uit1x13vlka1` | No | | 3 | AES-256 | `_ADV2` | `8gM2` | `H41Mlwqaspj1nxasyhq8530nh1r` | No | | 4 | AES-256 | `_ADV3` / `_USER_ADV3` | `8gM2` | `H41Mlwqaspj1nxasyhq8530nh1r` | Optional | | 5 | AES-256 | `_CENC5` | `1yti` | `nhtti50rplx2` | No | For modes 1–5, the version suffix *N* = `atoi(version_string) + 999` is appended to both key bases. ## Changelog ### 1.1.0 - Corrected the [block structure](#2-block-structure) chapter, which previously described bytes 62–63 as unused and the line-continuation scheme incorrectly: - Bytes 62–63 are **not** unused; their value depends on the role of the block and decryption must examine them ([§2](#2-block-structure)). - Documented the distinction between *overflow* blocks (62 content bytes followed by the `$+` marker in bytes 62–63) and *final* blocks ([§2.2](#22-content-blocks)). - Clarified that the ` $jbs$` padding sentinel may **straddle the 62-byte payload boundary** or be absent entirely when the content plus its carriage return fills the block, and specified the full set of line-termination conditions ([§2.3](#23-padding)). - Rewrote [line continuation](#24-line-continuation): block-level continuation is signalled by the `$+` overflow marker, not by a `+` prefix. A leading `+` is ordinary SPICE continuation syntax — preserved verbatim, never stripped — and is inserted at the source level only for lines of 125 bytes or more. ### 1.0.0 - Initial release documenting the PSpice encryption file format, block structure, key derivation for all six modes (0–5), DES variant (custom S-boxes, permutation tables, reversed key rotation), AES-256 ECB operation, mode 4 key recovery, and security assessment. --- PSpice is a trademark of Cadence Design Systems, Inc. jtsylve-spice-crypt-98ea63c/SPECIFICATIONS/qspice.md000066400000000000000000000671641521042210700220170ustar00rootroot00000000000000# QSPICE® Encryption Specification **Version**: 1.0.0 ([changelog](#changelog))\ **Author**: Joe T. Sylve, Ph.D. \ \ **Repository**: https://github.com/jtsylve/spice-crypt This document describes the encryption scheme that QSPICE uses to protect proprietary sub-circuit model files (the `.prot` / `.unprot` "protected" block found in `.qsch`, `.lib`, `.sub`, and similar text files). The scheme combines a randomized base-16 text encoding, a dual stream cipher keyed by a per-file random seed stored in the clear, DEFLATE compression, and a Windows-1252 keyword tokenization of the netlist. Each stage is documented so that the protected models can be read by alternative tools. [SpiceCrypt](https://github.com/jtsylve/spice-crypt) is a reference implementation of this specification, available as a command-line tool and Python library under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). ## Purpose Many third-party component vendors distribute SPICE simulation models exclusively as QSPICE-encrypted files. This encryption locks the models to a single proprietary simulator, preventing their use in open-source and alternative tools such as NGSpice, Xyce, PySpice, and others. This specification is published in service of two goals: - **Interoperability**: Documenting the encryption scheme allows developers of alternative SPICE simulators to support lawfully obtained encrypted models. The accompanying [SpiceCrypt](../README.md) reference implementation demonstrates working decryption based on this specification. - **Encryption research**: The scheme relies on security through obscurity — the key material is derived entirely from a seed stored in the clear alongside the ciphertext, so it provides no meaningful cryptographic protection (see [Section 7](#7-security-assessment)). Documenting these properties illustrates how proprietary encryption schemes deviate from established standards. Both activities are specifically permitted by law: - **United States**: [17 U.S.C. § 1201(f)](https://www.law.cornell.edu/uscode/text/17/1201) permits circumvention of technological protection measures for the purpose of achieving interoperability between independently created programs, and Section 1201(f)(2) explicitly allows distributing the tools developed for this purpose. [§ 1201(g)](https://www.law.cornell.edu/uscode/text/17/1201) further permits circumvention conducted in good-faith encryption research and allows dissemination of the findings. - **European Union**: [Article 6 of the Software Directive (2009/24/EC)](https://eur-lex.europa.eu/eli/dir/2009/24/oj) permits decompilation and reverse engineering when indispensable to achieve interoperability with independently created programs. Article 6(3) provides that this right cannot be overridden by contract. ## License Copyright © 2026 Joe T. Sylve, Ph.D. This document is licensed under the [Creative Commons Attribution 4.0 International License](https://creativecommons.org/licenses/by/4.0/) (CC-BY-4.0). You are free to share and adapt this material for any purpose, including commercial use, provided appropriate credit is given. QSPICE is a registered trademark of Qorvo US, Inc. This specification is an independent work of interoperability research and is not affiliated with, endorsed by, or sponsored by Qorvo. ## Notation - Byte values are written in hexadecimal with a `0x` prefix; multi-byte integers are little-endian unless stated otherwise. - `a ^ b` is bitwise XOR, `a >> n` / `a << n` are logical shifts, `a & b` is bitwise AND, and `a % n` is the non-negative remainder. - All 32-bit arithmetic is performed modulo 2³² (`& 0xFFFFFFFF`). ## 1. Encrypted File Format A QSPICE protected model is ordinary SPICE text in which one or more sub-circuit bodies are replaced by a *protected block*. A protected block is delimited by a line containing only `.prot` and a line containing only `.unprot`: ``` * Vendor copyright header * .subckt EXAMPLE A B C .prot l2VOxFvU,vvx,mUYUVYmQnYQlnlUQlmFYvVYwJvlvn2w ...more encoded lines... .unprot .ends EXAMPLE ``` **Delimiters**: The `.prot` and `.unprot` markers are matched against the whitespace-trimmed line, case-insensitively. Everything between them is the encoded payload. Lines outside any protected block — including the enclosing `.subckt` / `.ends` and any comments — are plain text and are passed through unchanged. **Payload**: Between the delimiters, the payload is a stream of glyphs from a fixed 64-character alphabet ([Section 2.1](#21-encoding-alphabet)), wrapped across lines (vendor files use a width of 76 characters). Whitespace between glyphs is not significant and is ignored when decoding. **Decryption replaces** each protected block with the recovered plaintext sub-circuit body, yielding a complete, usable netlist. The `.prot` and `.unprot` markers are not part of the plaintext and are discarded. The remaining sections describe how the glyph stream is turned back into plaintext. The procedure has five stages, applied in order: decode ([Section 2](#2-text-decoding)), generate keystreams ([Section 3](#3-keystream-generation)), decrypt ([Section 4](#4-payload-decryption)), decompress ([Section 5.1](#51-decompression)), and detokenize ([Section 5.2](#52-keyword-tokenization)). ## 2. Text Decoding ### 2.1 Encoding Alphabet The payload is a randomized base-16 encoding: every plaintext byte is written as two glyphs, one per 4-bit nibble (most-significant nibble first). The 64-glyph alphabet is arranged as four rows of sixteen: | Row | Glyphs (nibble value 0x0 … 0xF) | |-----|-----------------------------------------------| | 0 | `,` `v` `U` `x` `F` `K` `n` `m` `J` `w` `V` `O` `l` `2` `Y` `Q` | | 1 | `Z` `R` `r` `D` `P` `H` `8` `f` `0` `u` `e` `d` `I` `T` `N` `7` | | 2 | `z` `q` `M` `c` `y` `i` `h` `3` `W` `p` `E` `o` `j` `A` `1` `k` | | 3 | `5` `6` `L` `t` `s` `&` `b` `9` `X` `g` `G` `a` `S` `4` `C` `B` | As a single string in alphabet order (index `0` … `63`): ``` ,vUxFKnmJwVOl2YQZRrDPH8f0uedITN7zqMcyih3WpEojA1k56Lts&b9XgGaS4CB ``` A glyph encodes the nibble value equal to **its index modulo 16**. Each nibble value therefore has four interchangeable glyphs (one per row); the encoder selects a row pseudo-randomly so that re-encoding the same model produces different-looking output. The row selection carries no information and is ignored when decoding — only `index % 16` matters. ### 2.2 Nibbles to Bytes To decode the payload: 1. Discard any character not in the alphabet (this removes line breaks and inter-glyph whitespace). 2. Take the remaining glyphs in pairs. For a pair `(g_hi, g_lo)`, the decoded byte is `(nibble(g_hi) << 4) | nibble(g_lo)`, where `nibble(g)` is the glyph's index modulo 16. 3. If an odd glyph remains at the end, drop it. Let the full decoded byte string be `D`. ### 2.3 Seed and Payload Split The first four bytes of `D` are a 32-bit **seed**, stored little-endian and **not** encrypted: ``` seed = D[0] | (D[1] << 8) | (D[2] << 16) | (D[3] << 24) ``` The remaining bytes, `P = D[4:]`, are the encrypted payload. The seed is the only key material; both keystreams in [Section 3](#3-keystream-generation) are derived from it. ## 3. Keystream Generation The payload is decrypted by XOR with the byte-wise sum (XOR) of two independent keystreams, both seeded by `seed`. ### 3.1 Mersenne Twister Keystream The first keystream is the low byte of each output word of an MT19937 generator (Matsumoto–Nishimura) that uses **non-standard seeding** but the **standard** recurrence and tempering. **Parameters** (standard MT19937): `n = 624`, `m = 397`, `MATRIX_A = 0x9908B0DF`, `UPPER_MASK = 0x80000000`, `LOWER_MASK = 0x7FFFFFFF`. **Seeding** (non-standard). The 624-word state is a geometric sequence in the seed with multiplier `6069`: ``` state[0] = seed (if seed is 0, use 1) state[k] = (6069 * state[k-1]) & 0xFFFFFFFF for k = 1 … 623 ``` A `seed` of `0` is replaced by `1` for the purpose of seeding `state[0]` only; the table-walk keystream in [Section 3.2](#32-keystream-table-walk) continues to use the raw `seed` (so its first index is `0`). The generator index is initialized to `n` so that a full regeneration ("twist") runs before the first word is produced. **Regeneration** (standard twist): ``` for i in 0 … 623: y = (state[i] & UPPER_MASK) | (state[(i+1) % 624] & LOWER_MASK) state[i] = state[(i+397) % 624] ^ (y >> 1) if y & 1: state[i] ^= MATRIX_A index = 0 ``` **Tempering** (standard). To produce the next word, take `y = state[index]`, advance `index`, regenerating first if `index >= 624`, then: ``` y ^= y >> 11 y ^= (y << 7) & 0x9D2C5680 y ^= (y << 15) & 0xEFC60000 y ^= y >> 18 ``` The MT keystream byte for payload position `i` (0-based) is the low 8 bits (`y & 0xFF`) of the `i`-th tempered word. ### 3.2 Keystream Table Walk The second keystream indexes a fixed 9973-byte table `T` ([Appendix A](#appendix-a-keystream-table)). The walk is a constant-stride sequence modulo the table length (9973 is prime): ``` stride = (seed >> 3) & 0xFF index_0 = seed % 9973 index_{i+1} = (index_i + stride) % 9973 ``` The table keystream byte for payload position `i` is `T[index_i]`. When `stride` is `0` (i.e. `(seed >> 3) & 0xFF == 0`), every position uses `T[seed % 9973]`. ## 4. Payload Decryption Each encrypted payload byte is decrypted by XOR with both keystream bytes for that position: ``` plaintext_compressed[i] = P[i] ^ mt_byte(i) ^ T[index_i] ``` where `mt_byte(i)` is from [Section 3.1](#31-mersenne-twister-keystream) and `T[index_i]` is from [Section 3.2](#32-keystream-table-walk). XOR is its own inverse, so the same operation encrypts and decrypts. ## 5. Decompression and Keyword Tokenization ### 5.1 Decompression The decrypted payload is a single zlib stream (RFC 1950: a two-byte header, a raw DEFLATE body per RFC 1951, and a trailing Adler-32 checksum; vendor files begin with the bytes `0x78 0x9C`). Inflating it yields the QSPICE netlist body — the lines that belong between the enclosing `.subckt` and `.ends`. ### 5.2 Keyword Tokenization QSPICE stores its netlists in the Windows-1252 (CP1252) code page. Standard ASCII SPICE content (component lines, `.model` statements, node names, numeric values) is recovered verbatim, but certain device prefixes and operators are carried as single bytes with the high bit set. These bytes are ordinary CP1252 characters, so decoding the inflated body as Windows-1252 expands every token to the character QSPICE documents for it: | Byte | Char | QSPICE meaning | |------|------|----------------| | `0xC3` | `Ã` | Gm-block device prefix (stored lowercased as `0xE3` `ã`) | | `0xD8` | `Ø` | .DLL device prefix — C++/Verilog modules (stored lowercased as `0xF8` `ø`) | | `0xA5` | `¥` | gate/flip-flop device prefix; also the reserved-/unconnected-pin marker | | `0x80` | `€` | 12-bit DAC device prefix | | `0xA3` | `£` | (de)multiplexer / gate-driver device prefix | | `0xD7` | `×` | saturating-transformer device prefix | | `0xAB` / `0xBB` | `«` / `»` | node-group delimiters (`Ø` and `×` devices) | | `0xB4` | `´` | separator between a device-type prefix and the instance name | | `0xB5` | `µ` | micro (1e-6) SI prefix on numeric values | The device prefixes are written uppercase in QSPICE's documentation but appear lowercased in the payload, because QSPICE lower-cases the netlist before protecting it. Of these characters, only the micro sign has a standard-SPICE ASCII equivalent: `u`. Other SPICE tools (NGSpice, Xyce, PySpice, PSpice) expect `u` and will mis-parse `µ`, so a tool targeting interoperability should rewrite `µ` → `u`. The remaining characters name QSPICE-only behavioral devices and bus syntax with no equivalent in other simulators; they are preserved as the characters QSPICE itself documents. ## 6. Decryption Procedure (Summary) Given the text between `.prot` and `.unprot`: 1. **Decode** the glyphs to bytes `D` ([Section 2.2](#22-nibbles-to-bytes)). 2. **Split** `seed = D[0:4]` (little-endian) and `P = D[4:]` ([Section 2.3](#23-seed-and-payload-split)). 3. **Generate** the MT keystream ([Section 3.1](#31-mersenne-twister-keystream)) and table-walk keystream ([Section 3.2](#32-keystream-table-walk)) from `seed`. 4. **XOR** each byte of `P` with both keystream bytes to recover the compressed payload ([Section 4](#4-payload-decryption)). 5. **Inflate** the result as a zlib stream to recover the netlist body ([Section 5.1](#51-decompression)). 6. **Detokenize** the body by decoding it as Windows-1252, expanding the high-bit device-prefix and operator tokens (and, for interoperability, rewriting `µ` → `u`) ([Section 5.2](#52-keyword-tokenization)). 7. **Substitute** the plaintext body in place of the protected block, discarding the `.prot` / `.unprot` markers. ## 7. Security Assessment The QSPICE protected-block scheme is obfuscation, not encryption, and provides no confidentiality against an informed party: - **No secret key.** All key material is the 32-bit `seed`, which is stored in the clear as the first four bytes of every protected block. Anyone holding the file holds the key. - **Public construction.** The alphabet, the keystream table, the Mersenne Twister parameters, and the seeding rule are fixed constants of the scheme, identical across all files; only the seed varies. Recovering them once (as in this document) is sufficient to decrypt every protected block ever produced. - **Keystream reuse.** The 32-bit seed space is small, and the table walk repeats with period at most 9973. Two blocks sharing a seed share both keystreams, exposing the XOR of their plaintexts. - **Integrity.** The format carries no authentication or signature over the protected block; the only consistency check is that the inner payload must inflate as a valid zlib stream. These properties are inherent to storing the key alongside the ciphertext and are documented here for interoperability and research purposes, consistent with the legal provisions cited under [Purpose](#purpose). ## Appendix A: Keystream Table The 9973-byte keystream table `T` from [Section 3.2](#32-keystream-table-walk), encoded as base64. Decoding yields exactly 9973 bytes; verify with: ``` SHA-256 = bfd3ff9339f28056922c167e92daffbb10668e993aef4ac7ff3dd6d662004df3 ``` ``` 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 ``` ## Changelog - **1.0.0** — Initial specification of the QSPICE `.prot` / `.unprot` protected-block scheme: randomized base-16 encoding, seed-derived Mersenne Twister and table-walk keystreams, zlib decompression, and Windows-1252 keyword tokenization. --- QSPICE is a registered trademark of Qorvo US, Inc. jtsylve-spice-crypt-98ea63c/pyproject.toml000066400000000000000000000041661521042210700210130ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later [project] name = "spice-crypt" version = "3.0.1" description = "A library for decrypting LTspice®, PSpice®, and QSPICE® encrypted files" readme = "README.md" requires-python = ">=3.10" license = "AGPL-3.0-or-later" authors = [ { name = "Joe T. Sylve, Ph.D.", email = "joe.sylve@gmail.com" }, ] keywords = ["ltspice", "pspice", "qspice", "spice", "decryption", "eda", "circuit-simulation"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Science/Research", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Operating System :: OS Independent", "Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)", ] [project.urls] Homepage = "https://github.com/jtsylve/spice-crypt" Repository = "https://github.com/jtsylve/spice-crypt" Issues = "https://github.com/jtsylve/spice-crypt/issues" [project.scripts] spice-crypt = "spice_crypt.cli:main" [dependency-groups] dev = [ "pre-commit>=4.5.0", "pytest>=8.0.0", "ruff>=0.15.0", ] [build-system] requires = ["maturin>=1,<2"] build-backend = "maturin" [tool.maturin] module-name = "spice_crypt.pspice._aes_brute" python-source = "." [tool.pytest.ini_options] addopts = "-p no:cacheprovider" [tool.ruff] line-length = 100 [tool.ruff.lint] select = ["E", "W", "F", "I", "UP", "B", "SIM", "TCH", "RUF"] ignore = ["RUF012"] [tool.ruff.lint.per-file-ignores] # The QSPICE detokenizer deliberately embeds QSPICE's Windows-1252 device-prefix # and operator characters (Ã, Ø, ¥, ×, «, », ´, µ). The ambiguous-Unicode lints # (RUF001/002/003) flag exactly the characters this code must recognize. "spice_crypt/qspice/cipher.py" = ["RUF001", "RUF002", "RUF003"] "scripts/gen_qspice_testdata.py" = ["RUF001", "RUF002", "RUF003"] "tests/test_qspice_decrypt.py" = ["RUF001", "RUF002", "RUF003"] jtsylve-spice-crypt-98ea63c/scripts/000077500000000000000000000000001521042210700175575ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/scripts/check_version.py000066400000000000000000000042061521042210700227550ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """Pre-commit hook: verify Development Status classifier matches version stability. - 0.x (initial development) → "Development Status :: 3 - Alpha" - Pre-release (rc/alpha/beta/dev) → "Development Status :: 4 - Beta" - Stable release (≥1.0) → "Development Status :: 5 - Production/Stable" """ from __future__ import annotations import re import sys from pathlib import Path import tomllib ROOT = Path(__file__).resolve().parent.parent PYPROJECT = ROOT / "pyproject.toml" PRE_RELEASE_RE = re.compile(r"(a|alpha|b|beta|rc|dev)\d*", re.IGNORECASE) CLASSIFIER_ALPHA = "Development Status :: 3 - Alpha" CLASSIFIER_BETA = "Development Status :: 4 - Beta" CLASSIFIER_STABLE = "Development Status :: 5 - Production/Stable" _DEV_STATUS_PREFIX = "Development Status ::" def main() -> int: with PYPROJECT.open("rb") as f: pyproject = tomllib.load(f) project = pyproject.get("project", {}) pyproject_ver: str | None = project.get("version") if pyproject_ver is None: sys.exit(f"error: could not find [project].version in {PYPROJECT}") classifiers: list[str] = project.get("classifiers", []) actual_classifier: str | None = next( (c for c in classifiers if c.startswith(_DEV_STATUS_PREFIX)), None ) major = int(pyproject_ver.split(".")[0]) is_prerelease = bool(PRE_RELEASE_RE.search(pyproject_ver)) if major < 1: expected_classifier = CLASSIFIER_ALPHA elif is_prerelease: expected_classifier = CLASSIFIER_BETA else: expected_classifier = CLASSIFIER_STABLE if actual_classifier is None: print("error: No 'Development Status' classifier found in pyproject.toml", file=sys.stderr) return 1 if actual_classifier != expected_classifier: print( f"error: Classifier mismatch for version {pyproject_ver!r}: " f"expected {expected_classifier!r}, found {actual_classifier!r}", file=sys.stderr, ) return 1 return 0 if __name__ == "__main__": raise SystemExit(main()) jtsylve-spice-crypt-98ea63c/scripts/gen_qspice_testdata.py000066400000000000000000000135131521042210700241420ustar00rootroot00000000000000#!/usr/bin/env python3 # # SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ Generate a synthetic QSPICE ``.prot`` test fixture. This is a deliberately independent encoder for the QSPICE protection scheme described in SPECIFICATIONS/qspice.md. It wraps a known plaintext body in a ``.subckt`` … ``.prot`` … ``.unprot`` … ``.ends`` structure so the decryptor can be regression-tested without redistributing third-party vendor models. The Mersenne Twister, the keystream walk, and the glyph encoding are re-implemented here from the specification rather than imported from the library, so the fixture cross-checks the library implementation. Only the shared keystream table constant is reused. Usage: python scripts/gen_qspice_testdata.py """ from __future__ import annotations import textwrap import zlib from pathlib import Path from spice_crypt.qspice._keystream import KEYSTREAM_TABLE, KEYSTREAM_TABLE_LEN ALPHABET = ",vUxFKnmJwVOl2YQZRrDPH8f0uedITN7zqMcyih3WpEojA1k56Lts&b9XgGaS4CB" _MASK32 = 0xFFFFFFFF def _mt_bytes(seed: int, count: int) -> list[int]: """MT19937 with geometric seeding (state[k] = seed * 6069**k); low bytes.""" state = [0] * 624 # QSPICE substitutes 1 for a zero seed before seeding the MT. state[0] = (seed & _MASK32) or 1 for k in range(1, 624): state[k] = (6069 * state[k - 1]) & _MASK32 index = 624 def twist() -> None: for i in range(624): y = (state[i] & 0x80000000) | (state[(i + 1) % 624] & 0x7FFFFFFF) val = state[(i + 397) % 624] ^ (y >> 1) if y & 1: val ^= 0x9908B0DF state[i] = val out = [] for _ in range(count): if index >= 624: twist() index = 0 y = state[index] index += 1 y ^= y >> 11 y ^= (y << 7) & 0x9D2C5680 y ^= (y << 15) & 0xEFC60000 y ^= y >> 18 out.append(y & 0xFF) return out def _encrypt(plaintext: bytes, seed: int) -> bytes: """zlib-compress then XOR with the two seed-derived keystreams.""" comp = zlib.compress(plaintext, 6) mt = _mt_bytes(seed, len(comp)) stride = (seed >> 3) & 0xFF idx = seed % KEYSTREAM_TABLE_LEN body = bytearray(len(comp)) for i, byte in enumerate(comp): body[i] = byte ^ mt[i] ^ KEYSTREAM_TABLE[idx] idx = (idx + stride) % KEYSTREAM_TABLE_LEN return bytes(body) def _encode(data: bytes) -> str: """Encode bytes to glyphs (always row 0; the decoder ignores the row).""" glyphs = [] for byte in data: glyphs.append(ALPHABET[byte >> 4]) glyphs.append(ALPHABET[byte & 0x0F]) return "\n".join(textwrap.wrap("".join(glyphs), 76)) def make_prot_file(header: str, protected_body: bytes, footer: str, seed: int) -> str: """Assemble a full ``.subckt`` file with one ``.prot`` block.""" data = seed.to_bytes(4, "little") + _encrypt(protected_body, seed) return f"{header}\n.prot\n{_encode(data)}\n.unprot\n{footer}\n" if __name__ == "__main__": out_dir = Path(__file__).resolve().parent.parent / "tests" / "data" / "qspice" out_dir.mkdir(parents=True, exist_ok=True) # Decrypted output (passthrough header + protected body + passthrough footer) # equals tests.conftest.PLAINTEXT_BODY. fixture = make_prot_file( header=".subckt TEST_RES 1 2", protected_body=b"R1 1 2 1k\n", footer=".ends TEST_RES", seed=0x1234ABCD, ) (out_dir / "basic.lib").write_text(fixture) # A fixture with leading comment lines and trailing content after the block. multi = "* synthetic QSPICE test model\n*\n" + make_prot_file( header=".subckt TEST_RES 1 2", protected_body=b"R1 1 2 1k\n", footer=".ends TEST_RES", seed=0x00000007, # exercises a small seed / zero stride edge case ) (out_dir / "comments.lib").write_text(multi) # A fixture that exercises QSPICE keyword tokenization. The protected body # carries high-bit Windows-1252 tokens -- the à (gm-block) and Ø (.DLL) # device prefixes, the ¥ reserved-pin marker, the « » bus-group delimiters, # the ´ separator, and the µ micro sign -- which must survive decryption as # their documented characters (with µ normalized to ASCII "u"). token_body = ( "ã1 vdd vss out in- in+ ¥ ¥ ¥ ¥ ¥ ¥ ¥ ¥ ¥ ¥ multgmamp gm=650µ ref=.5\n" "ø´x1 «in´d out´d» «com» mymodule cout=100p\n" "c1 out com 1µ\n" ).encode("cp1252") tokens = make_prot_file( header=".subckt TOKENS vdd vss", protected_body=token_body, footer=".ends TOKENS", seed=0x0BADF00D, ) (out_dir / "tokens.lib").write_text(tokens) # A fixture whose *passthrough* (non-encrypted) lines carry a high-bit # Windows-1252 character -- the © sign -- so decryption is exercised on a # file that is not pure ASCII outside the protected block. Written as # CP1252, matching how QSPICE stores model files on disk. passthrough = "* © 2026 Example Corp. All rights reserved.\n" + make_prot_file( header=".subckt CP1252 1 2", protected_body=b"R1 1 2 1k\n", footer=".ends CP1252", seed=0x00C0FFEE, ) (out_dir / "passthrough.lib").write_text(passthrough, encoding="cp1252") # A fixture with two protected blocks (distinct seeds) in a single file, to # exercise multi-block decryption and the block counter. multi = make_prot_file( header=".subckt FIRST 1 2", protected_body=b"R1 1 2 1k\n", footer=".ends FIRST", seed=0x11112222, ) + make_prot_file( header=".subckt SECOND 3 4", protected_body=b"C1 3 4 1n\n", footer=".ends SECOND", seed=0x33334444, ) (out_dir / "multi.lib").write_text(multi) print(f"Wrote fixtures to {out_dir}") jtsylve-spice-crypt-98ea63c/spice_crypt/000077500000000000000000000000001521042210700204145ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/spice_crypt/__init__.py000066400000000000000000000017661521042210700225370ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ SpiceCrypt - A library for decrypting LTspice®, PSpice®, and QSPICE® encrypted files """ from importlib.metadata import version from spice_crypt.decrypt import decrypt, decrypt_stream from spice_crypt.ltspice.binary_file import BinaryFileParser from spice_crypt.ltspice.crypto_state import CryptoState from spice_crypt.ltspice.decrypt import LTspiceFileParser from spice_crypt.ltspice.des import LTspiceDES from spice_crypt.pspice.decrypt import PSpiceFileParser from spice_crypt.pspice.des import PSpiceDES from spice_crypt.qspice.cipher import QSpiceCipher from spice_crypt.qspice.decrypt import QSpiceFileParser __version__ = version("spice-crypt") __all__ = [ "BinaryFileParser", "CryptoState", "LTspiceDES", "LTspiceFileParser", "PSpiceDES", "PSpiceFileParser", "QSpiceCipher", "QSpiceFileParser", "decrypt", "decrypt_stream", ] jtsylve-spice-crypt-98ea63c/spice_crypt/_aes.py000066400000000000000000000260641521042210700217050ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ AES-256 ECB decryption. Provides :class:`AES256ECB` for decrypting 16-byte AES blocks using a 256-bit key in ECB mode. When the ``cryptography`` package is installed it is used automatically for performance; otherwise a pure-Python T-table implementation is used. """ from __future__ import annotations import struct # --------------------------------------------------------------------------- # Try the fast C path first # --------------------------------------------------------------------------- try: from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes class AES256ECB: """AES-256 ECB decryption using the ``cryptography`` library.""" def __init__(self, key: bytes): if len(key) != 32: raise ValueError("AES-256 key must be 32 bytes") cipher = Cipher(algorithms.AES(key), modes.ECB()) self._decryptor = cipher.decryptor() def decrypt_block(self, block: bytes) -> bytes: """Decrypt a single 16-byte AES block.""" return self._decryptor.update(block) except ImportError: # ------------------------------------------------------------------ # Pure-Python AES-256 ECB fallback # ------------------------------------------------------------------ # fmt: off # Standard AES S-box _SBOX = ( 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, ) # Standard AES inverse S-box _INV_SBOX = ( 0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb, 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb, 0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e, 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25, 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92, 0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84, 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06, 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b, 0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73, 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e, 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b, 0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4, 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f, 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef, 0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61, 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d, ) # AES round constants _RCON = ( 0x01000000, 0x02000000, 0x04000000, 0x08000000, 0x10000000, 0x20000000, 0x40000000, 0x80000000, 0x1b000000, 0x36000000, ) # fmt: on # --- GF(2^8) helpers ------------------------------------------------- def _xtime(a): """Multiply *a* by x in GF(2^8) with the AES polynomial.""" return ((a << 1) ^ 0x1B) & 0xFF if a & 0x80 else (a << 1) & 0xFF def _gf_mul(a, b): """Multiply *a* and *b* in GF(2^8).""" result = 0 t = a for _ in range(8): if b & 1: result ^= t t = _xtime(t) b >>= 1 return result # --- Build decryption T-tables (Td0 -- Td3) ------------------------- # Each table maps a byte through InvSubBytes then InvMixColumns. # Td1/Td2/Td3 are byte-rotations of Td0. def _build_td_tables(): Td0 = [0] * 256 Td1 = [0] * 256 Td2 = [0] * 256 Td3 = [0] * 256 for i in range(256): s = _INV_SBOX[i] e = _gf_mul(0x0E, s) b = _gf_mul(0x0B, s) d = _gf_mul(0x0D, s) n = _gf_mul(0x09, s) Td0[i] = (e << 24) | (n << 16) | (d << 8) | b Td1[i] = (b << 24) | (e << 16) | (n << 8) | d Td2[i] = (d << 24) | (b << 16) | (e << 8) | n Td3[i] = (n << 24) | (d << 16) | (b << 8) | e return Td0, Td1, Td2, Td3 _Td0, _Td1, _Td2, _Td3 = _build_td_tables() # --- Key schedule helpers -------------------------------------------- def _sub_word(w): """Apply S-box to each byte of a 32-bit word.""" return ( (_SBOX[(w >> 24) & 0xFF] << 24) | (_SBOX[(w >> 16) & 0xFF] << 16) | (_SBOX[(w >> 8) & 0xFF] << 8) | _SBOX[w & 0xFF] ) def _rot_word(w): """Rotate a 32-bit word left by 8 bits.""" return ((w << 8) | (w >> 24)) & 0xFFFFFFFF def _key_expansion_256(key: bytes) -> list[int]: """AES-256 key expansion returning 60 uint32 round-key words.""" nk = 8 # key length in 32-bit words nr = 14 # number of rounds total_words = 4 * (nr + 1) # 60 w = list(struct.unpack(">8I", key)) for i in range(nk, total_words): temp = w[i - 1] if i % nk == 0: temp = _sub_word(_rot_word(temp)) ^ _RCON[i // nk - 1] elif i % nk == 4: temp = _sub_word(temp) w.append(w[i - nk] ^ temp) return w def _invert_key_schedule(w: list[int]) -> list[int]: """Apply InvMixColumns to round keys 1--13 for equivalent inverse cipher.""" inv = list(w) for rnd in range(1, 14): base = rnd * 4 for j in range(4): v = inv[base + j] b0, b1, b2, b3 = (v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF inv[base + j] = ( _Td0[_SBOX[b0]] ^ _Td1[_SBOX[b1]] ^ _Td2[_SBOX[b2]] ^ _Td3[_SBOX[b3]] ) return inv # --- Block decryption ------------------------------------------------ def _aes_decrypt_block(block: bytes, rk: list[int]) -> bytes: """Decrypt one 16-byte block using the equivalent inverse cipher.""" s0, s1, s2, s3 = struct.unpack(">4I", block) mask = 0xFFFFFFFF # Initial AddRoundKey (round 14) s0 ^= rk[56] s1 ^= rk[57] s2 ^= rk[58] s3 ^= rk[59] # Rounds 13 down to 1 (T-table rounds) for rnd in range(13, 0, -1): base = rnd * 4 t0 = ( _Td0[(s0 >> 24) & 0xFF] ^ _Td1[(s3 >> 16) & 0xFF] ^ _Td2[(s2 >> 8) & 0xFF] ^ _Td3[s1 & 0xFF] ^ rk[base] ) & mask t1 = ( _Td0[(s1 >> 24) & 0xFF] ^ _Td1[(s0 >> 16) & 0xFF] ^ _Td2[(s3 >> 8) & 0xFF] ^ _Td3[s2 & 0xFF] ^ rk[base + 1] ) & mask t2 = ( _Td0[(s2 >> 24) & 0xFF] ^ _Td1[(s1 >> 16) & 0xFF] ^ _Td2[(s0 >> 8) & 0xFF] ^ _Td3[s3 & 0xFF] ^ rk[base + 2] ) & mask t3 = ( _Td0[(s3 >> 24) & 0xFF] ^ _Td1[(s2 >> 16) & 0xFF] ^ _Td2[(s1 >> 8) & 0xFF] ^ _Td3[s0 & 0xFF] ^ rk[base + 3] ) & mask s0, s1, s2, s3 = t0, t1, t2, t3 # Final round (no MixColumns -- use inverse S-box directly) t0 = ( (_INV_SBOX[(s0 >> 24) & 0xFF] << 24) | (_INV_SBOX[(s3 >> 16) & 0xFF] << 16) | (_INV_SBOX[(s2 >> 8) & 0xFF] << 8) | _INV_SBOX[s1 & 0xFF] ) ^ rk[0] t1 = ( (_INV_SBOX[(s1 >> 24) & 0xFF] << 24) | (_INV_SBOX[(s0 >> 16) & 0xFF] << 16) | (_INV_SBOX[(s3 >> 8) & 0xFF] << 8) | _INV_SBOX[s2 & 0xFF] ) ^ rk[1] t2 = ( (_INV_SBOX[(s2 >> 24) & 0xFF] << 24) | (_INV_SBOX[(s1 >> 16) & 0xFF] << 16) | (_INV_SBOX[(s0 >> 8) & 0xFF] << 8) | _INV_SBOX[s3 & 0xFF] ) ^ rk[2] t3 = ( (_INV_SBOX[(s3 >> 24) & 0xFF] << 24) | (_INV_SBOX[(s2 >> 16) & 0xFF] << 16) | (_INV_SBOX[(s1 >> 8) & 0xFF] << 8) | _INV_SBOX[s0 & 0xFF] ) ^ rk[3] return struct.pack(">4I", t0 & mask, t1 & mask, t2 & mask, t3 & mask) # --- Public class ---------------------------------------------------- class AES256ECB: """AES-256 ECB decryption (pure-Python fallback).""" def __init__(self, key: bytes): if len(key) != 32: raise ValueError("AES-256 key must be 32 bytes") rk = _key_expansion_256(key) self._rk = _invert_key_schedule(rk) def decrypt_block(self, block: bytes) -> bytes: """Decrypt a single 16-byte AES block.""" return _aes_decrypt_block(block, self._rk) jtsylve-spice-crypt-98ea63c/spice_crypt/_constants.py000066400000000000000000000004521521042210700231420ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """Shared constants used across encryption modules.""" # Masks for wrapping arithmetic to fixed-width unsigned integers MASK32 = 0xFFFFFFFF MASK64 = 0xFFFFFFFFFFFFFFFF jtsylve-spice-crypt-98ea63c/spice_crypt/_des_base.py000066400000000000000000000251301521042210700226730ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ Base DES engine shared by LTspice® and PSpice® DES variants. This module provides :class:`DESBase`, a parameterized DES implementation whose permutation tables, S-boxes, and behavioral flags are set by subclasses. The generic LUT-building machinery and Feistel network logic live here; each variant only needs to supply its table constants and override a few flags. """ from spice_crypt._constants import MASK32, MASK64 # --------------------------------------------------------------------------- # Permutation LUT builders (module-level, reusable) # --------------------------------------------------------------------------- def _build_permutation_lut(table): """ Precompute a byte-chunked lookup table for a bit permutation. Given a permutation table of length N, this builds a list of (byte_count) 256-entry sub-tables. To apply the permutation, split the input into 8-bit chunks and OR together the looked-up contributions:: result = 0 for byte_idx, sub in enumerate(lut): result |= sub[(value >> (byte_idx * 8)) & 0xFF] This replaces the per-bit loop with a per-byte lookup, giving roughly an 8x reduction in Python-level iterations for the hot path. """ max_input_bit = max(table) if table else 0 input_byte_count = (max_input_bit // 8) + 1 lut = [] for byte_idx in range(input_byte_count): sub = [0] * 256 bit_base = byte_idx * 8 for byte_val in range(256): contribution = 0 for out_bit, in_bit in enumerate(table): if bit_base <= in_bit < bit_base + 8 and (byte_val >> (in_bit - bit_base)) & 1: contribution |= 1 << out_bit sub[byte_val] = contribution lut.append(sub) return lut def _apply_permutation(value, lut): """ Apply a precomputed byte-chunked permutation LUT to an integer value. Each entry in *lut* is a 256-element list covering one input byte. """ result = 0 for byte_idx, sub in enumerate(lut): result |= sub[(value >> (byte_idx * 8)) & 0xFF] return result def _build_sbox_direct_lut(sboxes, bit_transform): """ Precompute 8 direct S-box lookup tables (64 entries each). *sboxes* is a list of 8 sub-lists, each containing 64 values indexed as ``sbox[i][row * 16 + col]`` where row and column are derived from the standard DES 6-bit input decomposition. For each S-box *i* and each raw 6-bit input value (0-63), the table stores the 4-bit output with *bit_transform* already applied. """ tables = [] for i in range(8): t = [0] * 64 for six_bits in range(64): # Row index from bits 5 and 0 row = ((six_bits & 0x20) >> 5) | ((six_bits & 0x01) << 1) # Column index from bits 4, 3, 2, 1 col = ( (six_bits & 0x02) << 2 | (six_bits & 0x04) | (six_bits & 0x08) >> 2 | (six_bits & 0x10) >> 4 ) t[six_bits] = bit_transform[sboxes[i][row * 16 + col]] tables.append(t) return tables # --------------------------------------------------------------------------- # Base DES engine # --------------------------------------------------------------------------- class DESBase: """Parameterized DES implementation. Subclasses **must** override the table class attributes (``DES_SBOXES``, ``DES_PC1_TABLE``, etc.) and may override the behavioral flags (``_SWAP_INPUT``, ``_SWAP_KEY``, ``_ROTATE_RIGHT``, ``_OUTPUT_MASK``). Precomputed lookup tables are built automatically when a subclass is defined (via ``__init_subclass__``). """ # fmt: off # --- Table class attributes (subclasses MUST override) ---------------- # S-boxes: list of 8 lists (64 ints each), indexed [row * 16 + col] DES_SBOXES = None DES_PC1_TABLE = None # 56-entry list, 0-indexed bit positions DES_PC2_TABLE = None # 48-entry list DES_INITIAL_PERM = None # 64-entry list DES_FINAL_PERM = None # 64-entry list # Standard DES Expansion (E) table — shared default for all variants. DES_EXPANSION_TABLE = [ 0x1F, 0x00, 0x01, 0x02, 0x03, 0x04, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x00, ] # Standard DES P-box permutation — shared default for all variants. DES_PBOX_TABLE = [ 0x0F, 0x06, 0x13, 0x14, 0x1C, 0x0B, 0x1B, 0x10, 0x00, 0x0E, 0x16, 0x19, 0x04, 0x11, 0x1E, 0x09, 0x01, 0x07, 0x17, 0x0D, 0x1F, 0x1A, 0x02, 0x08, 0x12, 0x0C, 0x1D, 0x05, 0x15, 0x0A, 0x03, 0x18, ] # Standard DES rotation schedule for key schedule. ROTATION_TABLE = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1] # Bit transform for S-box output (maps 4-bit value → permuted nibble). # Standard across all DES variants using this engine. DES_BIT_TRANSFORM = [ 0x00, 0x08, 0x04, 0x0C, 0x02, 0x0A, 0x06, 0x0E, 0x01, 0x09, 0x05, 0x0D, 0x03, 0x0B, 0x07, 0x0F, ] # fmt: on # --- Behavioral flags (subclasses override as needed) ----------------- _SWAP_INPUT: bool = False # Swap 32-bit halves before IP _SWAP_KEY: bool = False # Swap 32-bit halves before PC-1 _ROTATE_RIGHT: bool = False # Rotate key halves right (True) or left (False) _OUTPUT_MASK: int = MASK64 # Mask applied to final output (MASK32 truncates) # --- Precomputed LUTs (auto-built by __init_subclass__) --------------- _PC1_LUT = None _PC2_LUT = None _EXPANSION_LUT = None _PBOX_LUT = None _INITIAL_PERM_LUT = None _FINAL_PERM_LUT = None _SBOX_DIRECT = None def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) # Build LUTs from subclass table overrides if cls.DES_PC1_TABLE is not None: cls._PC1_LUT = _build_permutation_lut(cls.DES_PC1_TABLE) if cls.DES_PC2_TABLE is not None: cls._PC2_LUT = _build_permutation_lut(cls.DES_PC2_TABLE) if cls.DES_EXPANSION_TABLE is not None: cls._EXPANSION_LUT = _build_permutation_lut(cls.DES_EXPANSION_TABLE) if cls.DES_PBOX_TABLE is not None: cls._PBOX_LUT = _build_permutation_lut(cls.DES_PBOX_TABLE) if cls.DES_INITIAL_PERM is not None: cls._INITIAL_PERM_LUT = _build_permutation_lut(cls.DES_INITIAL_PERM) if cls.DES_FINAL_PERM is not None: cls._FINAL_PERM_LUT = _build_permutation_lut(cls.DES_FINAL_PERM) if cls.DES_SBOXES is not None: cls._SBOX_DIRECT = _build_sbox_direct_lut(cls.DES_SBOXES, cls.DES_BIT_TRANSFORM) def __init__(self): """Initialize the DES cipher.""" self.subkeys = None self.initialized_key = None # --- Key schedule ----------------------------------------------------- @staticmethod def _swap_halves(value): """Swap the lower and upper 32 bits of a 64-bit value.""" return (value >> 32) | ((value & MASK32) << 32) @staticmethod def _rotate_halves_right(value, count): """Rotate the two 28-bit halves of the key material right.""" lower = value & 0xFFFFFFF upper = (value >> 28) & 0xFFFFFFF complement = 28 - count lower = ((lower >> count) | (lower << complement)) & 0xFFFFFFF upper = ((upper >> count) | (upper << complement)) & 0xFFFFFFF return lower | (upper << 28) @staticmethod def _rotate_halves_left(value, count): """Rotate the two 28-bit halves of the key material left.""" lower = value & 0xFFFFFFF upper = (value >> 28) & 0xFFFFFFF complement = 28 - count lower = ((lower << count) | (lower >> complement)) & 0xFFFFFFF upper = ((upper << count) | (upper >> complement)) & 0xFFFFFFF return lower | (upper << 28) def generate_key_schedule(self, key): """Generate round keys (key schedule) for the algorithm.""" k = self._swap_halves(key) if self._SWAP_KEY else key reduced_key = _apply_permutation(k, self._PC1_LUT) rotate = self._rotate_halves_right if self._ROTATE_RIGHT else self._rotate_halves_left subkeys = [] for round_num in range(16): reduced_key = rotate(reduced_key, self.ROTATION_TABLE[round_num]) subkeys.append(_apply_permutation(reduced_key, self._PC2_LUT)) self.subkeys = subkeys # --- Feistel round function ------------------------------------------- def feistel_function(self, right_half, round_key): """ Apply the Feistel (F) function for a single round. 1. Expansion of the 32-bit right half to 48 bits 2. XOR with the round key 3. S-box substitution (48 bits to 32 bits) 4. P-box permutation """ xor_val = _apply_permutation(right_half, self._EXPANSION_LUT) ^ round_key sbox_direct = self._SBOX_DIRECT sbox_output = 0 for i in range(7, -1, -1): sbox_output = sbox_direct[i][(xor_val >> (i * 6)) & 0x3F] | (sbox_output << 4) return _apply_permutation(sbox_output, self._PBOX_LUT) # --- Block encrypt / decrypt ------------------------------------------ def crypt(self, input_block, key, decrypt_mode=False): """ Perform DES encryption or decryption on a 64-bit block. Args: input_block: 64-bit input block to encrypt/decrypt key: 64-bit key (56 bits used, 8 bits parity) decrypt_mode: Flag to indicate operation mode Returns: Integer result, masked by ``_OUTPUT_MASK``. """ if self.initialized_key != key: self.generate_key_schedule(key) self.initialized_key = key block = self._swap_halves(input_block) if self._SWAP_INPUT else input_block permuted = _apply_permutation(block, self._INITIAL_PERM_LUT) left_half = permuted & MASK32 right_half = (permuted >> 32) & MASK32 subkeys = self.subkeys for round_num in range(16): key_idx = 15 - round_num if decrypt_mode else round_num f_result = self.feistel_function(right_half, subkeys[key_idx]) left_half, right_half = right_half, f_result ^ left_half combined = (left_half << 32) | right_half return _apply_permutation(combined, self._FINAL_PERM_LUT) & self._OUTPUT_MASK jtsylve-spice-crypt-98ea63c/spice_crypt/cli.py000066400000000000000000000110661521042210700215410ustar00rootroot00000000000000#!/usr/bin/env python3 # # SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ Command-line interface for SpiceCrypt """ import argparse import sys from pathlib import Path from spice_crypt import __version__ from spice_crypt.decrypt import decrypt_stream def _recover_key(args): """Run Mode 4 brute-force key recovery.""" from spice_crypt.pspice.attack import recover_mode4_key try: if args.verbose: print("PSpice® Mode 4 key recovery (Rust/AES-NI)", file=sys.stderr) result = recover_mode4_key(args.input_file) except FileNotFoundError: if not args.quiet: sys.stderr.write(f"Error: File not found: {args.input_file}\n") return 1 except (ValueError, RuntimeError) as e: if not args.quiet: sys.stderr.write(f"Error: {e}\n") return 1 print(f"User key: {result.user_key_full.decode('ascii', errors='replace')}") if args.verbose: print(f"Short key bytes (hex): {result.short_key_bytes.hex()}") print(f"User key short: {result.user_key_short.decode('ascii', errors='replace')}") print(f"User key extended: {result.user_key_extended.decode('ascii', errors='replace')}") return 0 def main(): """Main entry point for the CLI.""" parser = argparse.ArgumentParser( description=( "SpiceCrypt - A tool for decrypting LTspice®, PSpice®, and QSPICE® encrypted files" ) ) parser.add_argument( "input_file", help=( "Path to the encrypted file to decrypt (LTspice, PSpice, or QSPICE format, or raw hex)" ), ) parser.add_argument("-o", "--output", help="Output file path (default: print to stdout)") parser.add_argument( "-f", "--force", action="store_true", help="Overwrite output file if it exists" ) parser.add_argument( "-r", "--raw", action="store_true", help="Treat input as raw hex data instead of LTspice® format", ) parser.add_argument( "--version", action="version", version=f"SpiceCrypt {__version__}", ) parser.add_argument( "--user-key", help="User key string for PSpice Mode 4 decryption (31-byte key from CSV file)", ) parser.add_argument( "--recover-key", action="store_true", help="Recover the PSpice Mode 4 user encryption key via brute-force attack", ) verbosity = parser.add_mutually_exclusive_group() verbosity.add_argument("--verbose", action="store_true", help="Display additional information") verbosity.add_argument("--quiet", action="store_true", help="Suppress all error messages") args = parser.parse_args() # Key recovery mode if args.recover_key: return _recover_key(args) # Check if output file exists and handle accordingly if args.output and Path(args.output).exists() and not args.force: if not args.quiet: sys.stderr.write( f"Error: Output file '{args.output}' already exists. Use --force to overwrite.\n" ) return 1 try: # Process with streaming API if args.verbose: if args.raw: print(f"Processing as raw hex data: {args.input_file}", file=sys.stderr) else: print(f"Processing file: {args.input_file}", file=sys.stderr) # Stream processing - much more memory efficient output_dest = ( args.output if args.output else (sys.stdout.buffer if hasattr(sys.stdout, "buffer") else sys.stdout) ) is_ltspice = False if args.raw else None user_key = args.user_key.encode("ascii") if args.user_key else None _, verification = decrypt_stream( args.input_file, output_dest, is_ltspice_file=is_ltspice, user_key=user_key ) if args.verbose: if args.output: print(f"Decrypted content written to '{args.output}'", file=sys.stderr) print(f"Verification values: {verification}", file=sys.stderr) except FileNotFoundError: if not args.quiet: sys.stderr.write(f"Error: File not found: {args.input_file}\n") return 1 except ValueError as e: if not args.quiet: sys.stderr.write(f"Error: {e}\n") return 1 except Exception as e: if not args.quiet: sys.stderr.write(f"Error during decryption: {e}\n") return 1 return 0 if __name__ == "__main__": sys.exit(main()) jtsylve-spice-crypt-98ea63c/spice_crypt/decrypt.py000066400000000000000000000207431521042210700224460ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ Top-level decryption dispatch. This module provides the public :func:`decrypt` and :func:`decrypt_stream` convenience functions which auto-detect the encryption format (LTspice text-based, LTspice® Binary File, PSpice®, or QSPICE® ``.prot`` protected blocks) and delegate to the appropriate parser. """ import contextlib import io import os from spice_crypt.ltspice.binary_file import BinaryFileParser from spice_crypt.ltspice.decrypt import LTspiceFileParser, _detect_ltspice_format def _try_binary_file(stream): """Peek at *stream* and return a :class:`BinaryFileParser` if it matches. Reads up to 20 bytes from the current position, checks for the Binary File signature, and seeks back. Returns ``None`` when the signature does not match. """ pos = stream.tell() header = stream.read(20) stream.seek(pos) if BinaryFileParser.check_signature(header): return BinaryFileParser(stream) return None def _try_pspice_format(file_obj, user_key=None): """Peek at *file_obj* and return a :class:`PSpiceFileParser` if it matches. Scans up to 50 lines for PSpice markers, then resets the stream position. Returns ``None`` when no PSpice markers are found. """ pos = file_obj.tell() try: for i, line in enumerate(file_obj): if i >= 50: break stripped = ( line.strip() if isinstance(line, str) else line.decode("utf-8", "replace").strip() ) if stripped.startswith("$CDNENCSTART") or stripped.startswith("**$ENCRYPTED_LIB"): file_obj.seek(pos) from spice_crypt.pspice.decrypt import PSpiceFileParser return PSpiceFileParser(file_obj, user_key_bytes=user_key) except (OSError, UnicodeDecodeError): pass file_obj.seek(pos) return None def _try_qspice_format(file_obj): """Peek at *file_obj* and return a :class:`QSpiceFileParser` if it matches. Scans for a ``.prot`` marker line, then resets the stream position. Returns ``None`` when no QSPICE protected block is found. QSPICE model files use the Windows-1252 code page (see qspice.md Section 5.2), unlike the LTspice and PSpice formats, which the shared reader decodes as UTF-8. When a ``.prot`` block is detected, the reader is switched to CP1252 so that high-bit characters in plaintext (passthrough) lines decode correctly; the protected-block payload itself is ASCII glyphs either way. """ from spice_crypt.qspice.decrypt import QSpiceFileParser, _detect_qspice_format if _detect_qspice_format(file_obj): reconfigure = getattr(file_obj, "reconfigure", None) if reconfigure is not None: with contextlib.suppress(ValueError, io.UnsupportedOperation): reconfigure(encoding="cp1252", errors="replace") return QSpiceFileParser(file_obj) return None def _run_decrypt_generator(gen, output_file, stack): """Drive a parser's ``decrypt_stream`` generator, writing output. Returns ``(content, verification)`` in the same form as :func:`decrypt_stream`. """ return_string = output_file is None if return_string: buffer = stack.enter_context(io.StringIO()) elif isinstance(output_file, str): output_file = stack.enter_context(open(output_file, "wb")) # noqa: SIM115 try: while True: chunk = next(gen) if return_string: buffer.write(chunk.decode("utf-8", errors="replace")) else: output_file.write(chunk) except StopIteration as e: verification = e.value or (0, 0) return (buffer.getvalue() if return_string else None), verification def decrypt_stream( input_file, output_file=None, is_ltspice_file=None, user_key=None ) -> tuple[str | None, tuple[int, int]]: """ Stream decrypt data from input_file to output_file. Supports the text-based hex/DES format, the Binary File format (both LTspice), PSpice encrypted formats, and QSPICE ``.prot`` protected blocks. When *is_ltspice_file* is ``None`` (the default), the format is auto-detected. Args: input_file: File object or path to read from output_file: File object or path to write to (if None, returns result as string) is_ltspice_file: Boolean indicating if file is in LTspice format If None, auto-detect based on content user_key: Optional user key bytes for PSpice Mode 4 decryption Returns: tuple: (content, verification) - content: Decrypted text as string (if output_file is None) or None - verification: Tuple of verification values """ with contextlib.ExitStack() as stack: # Handle path input -- open the file once and detect format from # the initial bytes, avoiding a separate open-read-close probe. if isinstance(input_file, str | os.PathLike): if is_ltspice_file is not False: raw = stack.enter_context(open(input_file, "rb")) parser = _try_binary_file(raw) if parser is not None: return _run_decrypt_generator(parser.decrypt_stream(), output_file, stack) # Not a Binary File -- wrap the already-open handle as text # (_try_binary_file already restored the stream position) input_file = stack.enter_context( io.TextIOWrapper(raw, encoding="utf-8", errors="replace") ) else: input_file = stack.enter_context(open(input_file)) # When given a seekable binary file object (not a path), check # for Binary File format before falling through to text-based # detection. This ensures callers who pass an already-open # binary handle get correct auto-detection. elif ( is_ltspice_file is not False and isinstance(input_file, io.RawIOBase | io.BufferedIOBase) and input_file.seekable() ): parser = _try_binary_file(input_file) if parser is not None: return _run_decrypt_generator(parser.decrypt_stream(), output_file, stack) # Not a Binary File -- wrap the binary handle as text so the # text-based detection and LTspiceFileParser receive strings. input_file = stack.enter_context( io.TextIOWrapper(input_file, encoding="utf-8", errors="replace") ) # Try PSpice format detection (text-mode, seekable) if is_ltspice_file is None and hasattr(input_file, "seek"): parser = _try_pspice_format(input_file, user_key=user_key) if parser is not None: return _run_decrypt_generator(parser.decrypt_stream(), output_file, stack) # Try QSPICE (.prot) format detection (text-mode, seekable) if is_ltspice_file is None and hasattr(input_file, "seek"): parser = _try_qspice_format(input_file) if parser is not None: return _run_decrypt_generator(parser.decrypt_stream(), output_file, stack) # Auto-detect if file is in LTspice format if not specified if is_ltspice_file is None: is_ltspice_file = _detect_ltspice_format(input_file) # Create parser parser = LTspiceFileParser(input_file, raw_mode=not is_ltspice_file) return _run_decrypt_generator(parser.decrypt_stream(), output_file, stack) def decrypt(data, is_ltspice_file=None): """ Decrypts encrypted data. Supports the LTspice text-based/raw-hex and PSpice text formats and QSPICE ``.prot`` protected blocks (the in-memory path does not cover the LTspice Binary File format or PSpice Mode 4 with a user key). Args: data: String containing encrypted data (LTspice text/raw hex, PSpice, or QSPICE ``.prot``) is_ltspice_file: Boolean indicating if the data is in LTspice file format. If None, auto-detect based on content. Returns: tuple: (plaintext, verification) - plaintext: Decrypted text as string - verification: Tuple of verification values """ # Delegate to decrypt_stream which handles auto-detection via # _detect_ltspice_format, avoiding duplicated detection logic. with io.StringIO(data) as input_file: return decrypt_stream(input_file, is_ltspice_file=is_ltspice_file) jtsylve-spice-crypt-98ea63c/spice_crypt/ltspice/000077500000000000000000000000001521042210700220575ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/spice_crypt/ltspice/__init__.py000066400000000000000000000007571521042210700242010ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """LTspice encryption format support.""" from spice_crypt.ltspice.binary_file import BinaryFileParser from spice_crypt.ltspice.crypto_state import CryptoState from spice_crypt.ltspice.decrypt import LTspiceFileParser from spice_crypt.ltspice.des import LTspiceDES __all__ = [ "BinaryFileParser", "CryptoState", "LTspiceDES", "LTspiceFileParser", ] jtsylve-spice-crypt-98ea63c/spice_crypt/ltspice/binary_file.py000066400000000000000000000325201521042210700247160ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ Decryption support for LTspice® Binary File format. This module handles encrypted model files that use the Binary File format -- a binary encoding with a two-layer XOR stream cipher, distinct from the text-based hex/DES format handled by the other modules. The file structure is: Offset Size Field ------ ---- ----- 0 20 Signature: ``\\r\\n\\r\\n\\r\\n\\x1a`` 20 4 key1 (uint32 LE) 24 4 key2 (uint32 LE) 28 ... Encrypted body (byte stream) Decryption of each body byte at index *N* (0-based from offset 28): decrypted[N] = (encrypted[N] ^ key2_bytes[N & 3]) ^ sbox[(base + step * N) % 2593] where *base* and *step* are derived from the header key fields via a lookup table, and *sbox* is a fixed 2593-byte substitution table. """ import binascii import struct from collections.abc import Generator from spice_crypt._constants import MASK32 # 20-byte file signature SIGNATURE = b"\r\n\r\n\r\n\x1a" # fmt: off # Step values indexed by key2 % 26. # Entry 0 is 1; entries 1-25 are the first 25 primes. _STEP_TABLE = ( 1, 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, ) _SBOX_MODULUS = 2593 # 2593-byte substitution table, indexed by (base + step * N) % 2593. _SBOX = bytes.fromhex( "d55f931826290d5b2961bf26ee61590a58e7b22742b9265466c72f56013d5800" "52cff40c0b6f7565e0f4241c1f81fc2a0f6410603af89f5d139320161730ff10" "ef101921c1976b254d7b525de8fafb0511071c475fad6c1bd2830d11bde37323" "e54e2e66ed2d631152268548e8fe7035f924aa1ca1006572d7869c0acf843d35" "c729724d00e85b31bde6963f1f11257542a1820523aec615214e7d7594707712" "2e1d3c7b0143a211b3f1733d3e814c5b3b3b426fc784945355b14b6c2a4c5b10" "881c0079a22c9e491247571699231c4002da0a65e4ca642756079063e728394b" "d1f8c738a82d152ccf27aa00cb1d72554a2e7a1ea7ae460b9aa2af0a1158ec6b" "a796a23c5789464a31691161ea3725427a370d6052b78e567ea89c54a954495b" "53fa3068329a1012e7d595368e357357f91ea5653c87e122b881ce67813ba55e" "dfb37f6ccac8257e1a5fc11ee18d8a51ae938a2570665102c8b6c31c808c525e" "1894662e98de6d1d4baac43362c2e04c3f8db428e54c743e741acd38e6235765" "3cd6ba08a583de19d05b7c27b60dc868f73a6d704f04197c5f6211444a359e58" "819e290e4638a77ad86a11307abdce7383bf881d90ecdf17fbf87352627308" "0a5ab50516155835714301935b0849903b85be86730bb8567888d5e2199d52ed" "21a396c415d37fa74d0015ce6ee223793eb8cc1b0c742f9b27c947d023f4a2d6" "1419b3794199a34c4babb09e7d10eee631e8a765470a13b0415a23850a69468f" "55514b573c328e963ae3035e49d40ae059c27a7652defcd11b367ee8631c307c" "68f354070d797f7b3f24790c2478138e008437d237ad4eef3d16667b2228ce96" "4d80ce960b167b49110af20f0c399bb2178aaae438d339e02f2d3e892ca35d5e" "7a6ddd2c7bd8ee272ab34b452c55859242e301d86b0d6fca36bfcb2118344d2f" "283ffd6071a2cf7f6108580f020178d74381cc517d3ed6f7651da8532c742159" "0ab755732541216050ed34e70a3b8d455dee6f4f0e039b622d635bdc2a6f3ee6" "191916ac3e6e4dec36a8d99831a3c090774187cc66d517225e461eef71ae64f9" "61ae064a08f969341e04ea8b249108227406d9fe54c3b5ad3cc555511c45d65f" "4665852d1ecdad601e464e370ae6517f1b0b84580463f68a365b73d825c2d9cb" "29a417eb0648a8bf30fd66110793873a154b43225e61c2ed3102c6202f6459ce" "1ccf0fda68aa9fb960071a5f141097a64f7fb7db3e4d384e06bffb9f312dbe25" "4746a28224c3e52b56bec6473b4c7b8179869bd912831c99579151e13feb2007" "3150caf975d79f184ad272864c5b4e527a3a96a3002de65e721d281e24dead8e" "07758e1e231b8f2f2b7135c91cc0d140017c511d5d73fbe94b242b0f1e4b61f7" "451d9ba32c2b456e325bf89d159d527f6b787dbc381af43d47ca10a532be1f3f" "5dddd9691d89d7ec6d0a9bc056637543300cf485459beca1164f964a615dbe7f" "3b728cba602109d12db80cd235ac225e614eef2f20d634f0598ad0ec68c37d4e" "43f1c31f05fc05b605834f8f446d153d626f01a051a77a9e62b87634288d9c43" "7ed2bf0c15136fd23d2aefc2694a3dc94d2e631005f4ff671c085d082b0b3d7a" "227dd7540a12f8c8016fb2bd528acbda4fade46a18be480834e7895a0b1f7125" "79df51d9619f962c41cb93835a2d41090275cb1c1b55647043f0be5745668f3c" "20516a2649730ee709d3a47902c16bc61a1a89856c8b1bae2a4e080a19ec4892" "019f8a806878f7cc0236865b4fcded906d6cf7341f3ee3637ad82a0b10eace89" "2950db2c7c47ddc862749a6479fdbf97140526d1165b24bf041c31bd0de477aa" "78fabaeb45e7c4406811b9b37a708608613c29b12b01780b40d61545018e93d7" "747486f249aababe034fff9d0f8e0f783635d66c2e9d07a8287a580a38d460ed" "1615ff742bb0de6507a14e7e0481f6a94aeec1c9017a7989146bc533743e9df6" "7dc1565277df5f986d3b5d8e12c77c230e3a845772578e4b20abf4cd06353f43" "383e538c08bdad8101a5c54b197b7c3d34be258d417bdb901a0910152933ac7f" "0b25964f1e580fb338c1bbf7415b6cbc4cf5165b613c14027a2fcda9630a16d0" "0cecf26701d11b28688b0c7a57dbb431034b95b17cf7d1ad4b195228010cec03" "74d631463955afb613d368270211b69d2bac3d02347f5df50846f5e063eb908e" "3c3c0b770aebba2c7d660dcc70fa30044c6696bd176f1de1192ddd83578c2c0d" "36c72c9452ef987b19e798c902bc43ef332bad7d1316667366c659bf4017a0e5" "14e7819b4e51663918f254171832174d4b4838e7630ca73f193f03513f1f6a2d" "1d6156f62c126c78413020cb480d94f86091c96d4a7615ac2cf824871dcdd4e4" "5461d0d8295e32530ec805e920c7669641cd4f3428f5e26c785393a377947cc8" "7ae47be8113a2c6d7a50c0b72e0f2966255192e060161a776f27c94b3a38147c" "2f6880b007191e63526b2bc97ab0b8976b25c5a26baa2e1a3acf22c508861b99" "18bc9a927bff42905194af91794e64004675583c7e8cd418171b39e51ad62815" "28eb066c25e33ece3b9e8fab69b856a04dd9213b34f1224f614dd36848bd9d23" "462c4fbc5b9d932077cdc6896b7de19c3cb4ad9766f48fd525b5f5186c1c2e48" "6e0dae38782021e266cce6df593373db63ca4ffc209c09a562b98e747c87ea8e" "1c9b4c35344d3e0676d54e8f6211a57132da121f0df087747de7cd865ac5198b" "32d4c64239855d32447d702b00ade87d6d77808125ca4394486a86a133a3cf3d" "0168d7b43f374d2b1f20b1da3d1c854c262bdd0045d5a6f32938b39414398b39" "3df6c7d510049a746e6cfe1421c017d231a0a31951258d891d4702614e3cf04e" "0573cb8f131c51f0304d95c0374ddeae200dd9642e3463471212f83953e19fa7" "67bac079568f6865538e8825553141fb7b5aacf91bf80ec708d410397dc283ae" "5b305cf227f4c1133bde08fb015b39f36cc968076516bc8f1694c42c2abf30dd" "751a56040500c3414b8048af27bbf91d562650cb68c74a1076f7e96c5b991b5b" "7ce49b0027447f2d13e6f9091df174655578e27425f8f14370d2140d3d32a3ee" "7b875aa943609d321263e4e977e106a35f58acf91a37f52275a38a513b8808ec" "422bb7363081934c3de441df2ff51f3e15974fdc5378060c5ab4501b0bb2a5e0" "5879c94d253499ca326d9ffe2e9f19190efce3da2864896b0a3835740ae07fdb" "4fa808991d1e2f7e27d1f4402520eb0d431621c217a3094e62538efc3e9d7b6b" "5b03a78074b672e6367f820e3b5b537a0fee67092c220d6076e45b6652191f40" "5ca4a0ac33c89d45020e3f7e713bf0880740a4515cc38f997ced956960b96d9f" "01f728642f5a35680f5887b80ff30c3f58bebed31990bc2c1ad38c1a2866c76c" "37aeebaa41a4815b4d87b27a7ac40c6d59478ba92fda4077396288d8344a322a" "2490b35d70e10ae76fa685a4337e1b671c031847668ae10a06983aa778a7b8f3" "19527f5008a679256ae3a87c219223a2646909bf66d03ee6014c91416661322316" "2b744e11a418fa75543f626ee932222b35d5261028cc7c1650fa8e62e3c0d151" "cc4dd863d7ac095da8cd3e2b14d98113b1ed80160a5617605e0bac3741a1de06" "eb" ) # Key validation table: 100 entries of (check_value, base_value) as # packed little-endian uint32 pairs (800 bytes total). _KEY_TABLE_RAW = bytes.fromhex( "c0bc4523640700008e725b711e060000963139500c060000fe7012065c060000" "73154e5e4100000049199c7354080000c9acf4021303000063bf893a84010000" "5ec00c30f904000045b8d307300900007c55821c1e0300008567a56fbe050000" "26df2d7f640700002e79d8273600000077f6040da4000000897b2a22dd030000" "07531c2eb70200008faa374c27070000f8df310dc9030000165f0b705a070000" "f6951b3f770100002a9c0f30d0080000d40592680b090000dc2e673351060000" "26f04935ee040000e0803a2bb603000034ede6260e000000c6688d1939020000" "9b3d06216a030000deb6ee5c140700000dbc3c39b70800004ae7555ee3020000" "7f209f1284020000b293ae65da00000068add77c27050000e2f5500b04030000" "286b61397a0500001d86037ee402000099edf925c10300002f37923a210a0000" "1b9ca56c4f0600006123102d9806000075a0ac00e3040000a955a1398b050000" "1b6e03080d0200002412be4f28030000ef3ea915280500003d399928da020000" "488ba158d2070000e55f1948a7060000b7bf0164b40200000e7c6c113f000000" "d3e7ca0e18030000dd9b563f4b04000025b7da40150400002cb3081064050000" "1c8bb55fec08000090dc0c417e080000b562b60366020000a3091502d5010000" "c13e3e1141080000f9faf94c030600003515e77fc50200001fdd2f4fbe010000" "2501db035e0300002ed9012e39040000cc92b36add080000bdeb3f05fd050000" "6857de4e8c0800000d50432e9a090000a65a7f3e5d050000ce61393d9d040000" "c7d9647b830900005511977ed20700009770f478cd0700004e0dd50a18080000" "bf367f52510700000a2d1a311f0a00007e3c624da003000072ecee2a55010000" "2e479317db01000080fe1939cd040000de1a5f18d30000009b54c57b45040000" "d771f002ba010000d480f67698030000e1a754685200000041b2a45f0a020000" "00217632f901000026bed462660200008edee75e0b020000f0409d356c070000" "bbd37845410500004161cd034e09000024780167cd010000dd4d18649c070000" "5513ad070e0600004d99db001d0600009b368c5bcb04000079a049070c070000" ) # fmt: on _KEY_TABLE_COUNT = 100 # Pre-built dict mapping check_value -> base_value for O(1) lookup. _KEY_TABLE = { struct.unpack_from("= 20 and data[:20] == SIGNATURE def decrypt_stream(self) -> Generator[bytes, None, tuple[int, int]]: """ Stream-decrypt the file, yielding decrypted chunks. Returns: Generator that yields decrypted byte chunks. The return value (via ``StopIteration``) is a ``(crc32, rotate_hash)`` verification tuple. """ # -- Header parsing ------------------------------------------------ header = self.file_obj.read(28) if len(header) < 28: raise ValueError("File too short for Binary File header") if header[:20] != SIGNATURE: raise ValueError("Invalid Binary File signature") key1, key2 = struct.unpack_from(" ~42 MB (step=97). # Pre-computing one cycle and tiling avoids this — step*i stays # well within 32 bits for i in [0, 2592]. sbox = _SBOX modulus = _SBOX_MODULUS one_cycle = bytes(sbox[(base + step * i) % modulus] for i in range(modulus)) full, remainder = divmod(n, modulus) sbox_stream = one_cycle * full + one_cycle[:remainder] # Apply both XOR layers in bulk using integer arithmetic. body_int = int.from_bytes(body, "big") key2_int = int.from_bytes(key2_mask[:n], "big") sbox_int = int.from_bytes(sbox_stream, "big") decrypted = (body_int ^ key2_int ^ sbox_int).to_bytes(n, "big") # -- Verification checksums ---------------------------------------- crc = binascii.crc32(decrypted) # Compute a rotate-left hash over every byte. Inlining _rol32 # and grouping by rotation amount (which cycles every 32 bytes) # avoids per-byte function-call overhead. rotate_hash = 0 mask = MASK32 for i, byte_val in enumerate(decrypted): shift = (i + 1) & 31 if shift: rotated = ((byte_val << shift) | (byte_val >> (32 - shift))) & mask rotate_hash = (rotate_hash + rotated) & mask else: rotate_hash = (rotate_hash + byte_val) & mask yield decrypted return (crc, rotate_hash) jtsylve-spice-crypt-98ea63c/spice_crypt/ltspice/crypto_state.py000066400000000000000000000140331521042210700251520ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ Cryptographic state management for LTspice® text-based DES decryption. This module implements :class:`CryptoState`, which derives the DES key and stream cipher parameters from a 1024-byte crypto table and provides per-block decryption combining the pre-DES XOR layer with the DES variant. """ from spice_crypt._constants import MASK32, MASK64 from spice_crypt.ltspice.des import LTspiceDES class CryptoState: """Manages key derivation and per-block decryption for the text-based DES format. The 1024-byte crypto table (the first 128 blocks of the hex payload) is processed to derive three pieces of state: - Two stream cipher parameters (``odd_byte_checksum`` and ``even_byte_checksum``) used by the pre-DES XOR layer. - A 64-bit DES key used by the modified DES block cipher. See SPECIFICATIONS/ltspice.md Sections 1.2 and 1.3 for the full derivation. """ def __init__(self, table: bytes): """ Initialize the crypto state from a 1024-byte crypto table. Args: table: The 1024-byte crypto table extracted from the file payload. Raises: ValueError: If *table* is not exactly 1024 bytes. """ if len(table) != 1024: raise ValueError("crypto table must be exactly 1024 bytes") self.crypto_table = table self.DES = LTspiceDES() self.reset() def reset(self): """ Derives the cryptographic state from the 1024-byte crypto table. """ table = self.crypto_table # Pass 1: Compute checksums over even-indexed and odd-indexed bytes. # Only the low 8 bits of each sum are kept. even_byte_sum = 0 odd_byte_sum = 0 for i in range(0, 1024, 2): even_byte_sum += table[i] odd_byte_sum += table[i + 1] even_byte_sum &= 0xFF odd_byte_sum &= 0xFF # Pass 2: Sum bytes by their position in 4-byte chunks. # The table is treated as 256 groups of 4 bytes. Each of the 4 # positional accumulators receives bytes at the same offset within # every group, and the totals are then summed together. # DO NOT REMOVE — documents behaviour present in the original binary # even though the results are unused. See SPECIFICATIONS/ltspice.md. # byte_group_sums = [0] * 4 # for i in range(0, 1024, 4): # for j in range(4): # byte_group_sums[j] = (byte_group_sums[j] + table[i + j]) & MASK32 # byte_sum_result = sum(byte_group_sums) & MASK32 # Pass 3: Sum 16-bit little-endian words by their position in # 4-word (8-byte) chunks. Same idea as Pass 2 but operating on # 16-bit units instead of bytes. # DO NOT REMOVE — see Pass 2 comment above. # word_group_sums = [0] * 4 # for i in range(0, 1024, 8): # for j in range(4): # word_group_sums[j] = ( # word_group_sums[j] # + int.from_bytes(table[i + j * 2 : i + j * 2 + 2], "little") # ) & MASK32 # word_sum_result = sum(word_group_sums) & MASK32 # Pass 4: Sum 64-bit little-endian qwords in 2-qword (16-byte) # chunks. The table is treated as 64 groups of two qwords. # Even-offset (0) and odd-offset (8) qwords are accumulated # separately, then added together. qword_sum_even = 0 # accumulator for qwords at offset 0 in each group qword_sum_odd = 0 # accumulator for qwords at offset 8 in each group for i in range(0, 1024, 16): qword_sum_even += int.from_bytes(table[i : i + 8], "little") qword_sum_odd += int.from_bytes(table[i + 8 : i + 16], "little") qword_sum_even &= MASK64 qword_sum_odd &= MASK64 # Combine the two qword accumulators and extract the 16-bit words # that will feed into the DES key: bits [15:0] and bits [47:32]. combined_qword = (qword_sum_even + qword_sum_odd) & MASK64 qword_low_word = combined_qword & 0xFFFF qword_high_word = (combined_qword >> 32) & 0xFFFF # Final XOR transformation to produce the crypto state. # The checksums are XOR'd with fixed constants and the two 16-bit # qword-derived words are XOR'd with 32-bit constants to form the # 64-bit DES key. self.odd_byte_checksum = odd_byte_sum ^ 0x54 self.even_byte_checksum = even_byte_sum ^ 0xE7 key_low = (qword_low_word ^ 0x66E22120) & MASK32 key_high = (qword_high_word ^ 0x20E905C8) & MASK32 self.key_value = (key_high << 32) | key_low def decrypt_block(self, data: bytes): """ Decrypts a block of data using the cryptographic state and table. Args: data: 8-byte block to decrypt Returns: 32-bit decrypted result """ if len(data) != 8: raise ValueError("Data block must be 8 bytes") crypto_table = self.crypto_table data_copy = bytearray(data) # For each byte in the block, advance the checksum state and XOR # the ciphertext byte with a table byte selected by the running # checksum. This acts as a pre-DES stream-cipher layer. for i in range(8): # Advance the odd checksum by adding the even checksum (mod 2^32) self.odd_byte_checksum = (self.odd_byte_checksum + self.even_byte_checksum) & MASK32 # Use the checksum to pick an index into the crypto table # (range 1..0x3fd, i.e. avoiding the first byte) table_index = (self.odd_byte_checksum % 0x3FD) + 1 # XOR the ciphertext byte with the selected table byte data_copy[i] ^= crypto_table[table_index] # Decrypt the XOR'd block with the DES variant (little-endian # 64-bit input, returns the low 32-bit result) return self.DES.crypt(int.from_bytes(data_copy, "little"), self.key_value, True) jtsylve-spice-crypt-98ea63c/spice_crypt/ltspice/decrypt.py000066400000000000000000000141511521042210700241050ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ Decryption support for LTspice encrypted text-based (hex/DES) files. This module provides :class:`LTspiceFileParser` for streaming decryption of the text-based hex/DES format. """ import binascii import re import warnings from collections.abc import Generator from spice_crypt.ltspice.crypto_state import CryptoState _END_CHECKSUM_RE = re.compile(r"\*\s*End\s+(\d+)\s+(\d+)", re.IGNORECASE) class LTspiceFileParser: """Parser for LTspice® encrypted files with efficient streaming support.""" def __init__(self, file_obj, raw_mode=False): """ Initialize the parser with a file object. Args: file_obj: File-like object (text mode) that supports iteration raw_mode: Whether to treat the input as raw hex data Raises: TypeError: If *file_obj* is not iterable. """ if not hasattr(file_obj, "__iter__"): raise TypeError("file_obj must be an iterable file-like object") self.file_obj = file_obj self.raw_mode = raw_mode self.checksums = None self._crypto_table = None self._crypto_state = None def _read_until_begin(self): """Read the file until the 'Begin:' marker is found.""" if self.raw_mode: return for line in self.file_obj: line = line.strip() if line.lower().startswith("* begin:"): return def _extract_checksums(self, line): """Extract checksums from an End line.""" end_match = _END_CHECKSUM_RE.search(line) if end_match: self.checksums = (int(end_match.group(1)), int(end_match.group(2))) @staticmethod def _convert_hex_block(hex_values): """Convert a list of hex strings to a bytes block. If fewer than 8 values are provided the block is zero-padded on the right to 8 bytes. """ if len(hex_values) < 8: hex_values = hex_values + ["00"] * (8 - len(hex_values)) try: return bytes.fromhex("".join(hex_values)) except ValueError as e: raise ValueError(f"Invalid hex data: {' '.join(hex_values)}") from e def _process_hex_chunks(self): """Process hex data in chunks and yield 8-byte blocks to decrypt.""" hex_data = [] pos = 0 # read cursor; avoids O(n) front-deletion on the list for line in self.file_obj: line = line.strip() # Check if we've reached the end marker if not self.raw_mode and line.lower().startswith("* end"): self._extract_checksums(line) break # Skip comments in LTspice format if not self.raw_mode and line.startswith("*"): continue hex_data.extend(line.split()) # Yield complete 8-value blocks as soon as they are available while len(hex_data) - pos >= 8: yield self._convert_hex_block(hex_data[pos : pos + 8]) pos += 8 # Reclaim memory periodically to avoid unbounded list growth if pos >= 1024: del hex_data[:pos] pos = 0 # Yield any remaining partial block (zero-padded) remaining = hex_data[pos:] if remaining: yield self._convert_hex_block(remaining) def decrypt_stream(self) -> Generator[bytes, None, tuple[int, int]]: """ Stream decrypt the file, yielding decrypted chunks. Returns: Generator that yields decrypted chunks The final value is the verification tuple (v1, v2) """ # Start processing the file self._read_until_begin() block_count = 0 table_bytes = bytearray(1024) plaintext_crc = 0 ciphertext_crc = 0 # Process the hex data in chunks for byte_block in self._process_hex_chunks(): # First 1024 bytes (128 blocks) are the crypto table if block_count < 128: table_bytes[block_count * 8 : (block_count + 1) * 8] = byte_block block_count += 1 # Once we have the complete table, initialize the crypto state if block_count == 128: self._crypto_table = bytes(table_bytes) self._crypto_state = CryptoState(self._crypto_table) else: # Update ciphertext CRC incrementally ciphertext_crc = binascii.crc32(byte_block, ciphertext_crc) # Decrypt the block result = self._crypto_state.decrypt_block(byte_block) # Convert result to bytes result_bytes = result.to_bytes(4, "little") # Update plaintext CRC plaintext_crc = binascii.crc32(result_bytes, plaintext_crc) # Yield the decrypted chunk yield result_bytes # Calculate verification values if self._crypto_table: table_word_44 = int.from_bytes(self._crypto_table[0x44:0x48], byteorder="little") table_word_94 = int.from_bytes(self._crypto_table[0x94:0x98], byteorder="little") v1 = plaintext_crc ^ 0x7A6D2C3A ^ table_word_44 v2 = ciphertext_crc ^ 0x4DA77FD3 ^ table_word_94 # Check against file checksums if available if self.checksums and (v1, v2) != self.checksums: warnings.warn( f"Checksum mismatch! File: {self.checksums}, Calculated: ({v1}, {v2})", stacklevel=2, ) return (v1, v2) return (0, 0) def _detect_ltspice_format(file_obj) -> bool: """ Auto-detect whether a seekable file object contains LTspice-format data. Reads the first line, checks for known markers, then resets the stream position. Returns True if the file appears to be in LTspice format. """ first_line = file_obj.readline() file_obj.seek(0) return "* LTspice Encrypted File" in first_line or "* Begin:" in first_line jtsylve-spice-crypt-98ea63c/spice_crypt/ltspice/des.py000066400000000000000000000166651521042210700232220ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2025-2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ LTspice DES variant implementation. This module implements the modified DES block cipher used by LTspice for its text-based encryption format. See SPECIFICATIONS/ltspice.md Section 1.4 for a detailed description of the deviations from standard DES (FIPS 46-3). """ from spice_crypt._constants import MASK32 from spice_crypt._des_base import DESBase def _flat_sbox_to_normalized(flat_table): """Convert the LTspice interleaved flat S-box table to normalized format. The flat table stores all 8 S-boxes interleaved at stride 9 (with a padding byte at index 0 for 1-based indexing). The normalized format is a list of 8 sub-lists, each 64 entries indexed as ``sbox[row * 16 + col]``. """ sboxes = [] for i in range(8): box = [0] * 64 for row in range(4): for col in range(16): box[row * 16 + col] = flat_table[(36 * col) + (9 * row) + i + 1] sboxes.append(box) return sboxes # fmt: off # Flat interleaved S-box table (LTspice-specific layout, 1-based indexing) _SBOX_FLAT = [ # Padding for 1-based indexing # # S1 S2 S3 S4 S5 S6 S7 S8 # 0x00, 0x0E, 0x0F, 0x0A, 0x07, 0x02, 0x0C, 0x04, 0x0D, 0x00, 0x00, 0x03, 0x0D, 0x0D, 0x0E, 0x0A, 0x0D, 0x01, 0x00, 0x04, 0x00, 0x0D, 0x0A, 0x04, 0x09, 0x01, 0x07, 0x00, 0x0F, 0x0D, 0x01, 0x03, 0x0B, 0x04, 0x06, 0x02, 0x00, 0x04, 0x01, 0x00, 0x0D, 0x0C, 0x01, 0x0B, 0x02, 0x00, 0x0F, 0x0D, 0x07, 0x08, 0x0B, 0x0F, 0x00, 0x0F, 0x00, 0x01, 0x0E, 0x06, 0x06, 0x02, 0x0E, 0x04, 0x0B, 0x00, 0x0C, 0x08, 0x0A, 0x0F, 0x08, 0x03, 0x0B, 0x01, 0x00, 0x0D, 0x08, 0x09, 0x0E, 0x04, 0x0A, 0x02, 0x08, 0x00, 0x07, 0x04, 0x00, 0x0B, 0x02, 0x04, 0x0B, 0x0D, 0x00, 0x0E, 0x07, 0x04, 0x09, 0x01, 0x0F, 0x0B, 0x04, 0x00, 0x08, 0x0A, 0x0D, 0x00, 0x0C, 0x02, 0x0D, 0x0E, 0x00, 0x01, 0x0E, 0x0E, 0x03, 0x01, 0x0F, 0x0E, 0x04, 0x00, 0x04, 0x07, 0x09, 0x05, 0x0C, 0x02, 0x07, 0x08, 0x00, 0x08, 0x0B, 0x09, 0x00, 0x0B, 0x05, 0x0D, 0x01, 0x00, 0x02, 0x01, 0x00, 0x06, 0x07, 0x0C, 0x08, 0x07, 0x00, 0x02, 0x06, 0x06, 0x00, 0x07, 0x09, 0x0F, 0x06, 0x00, 0x0E, 0x0F, 0x03, 0x06, 0x04, 0x07, 0x04, 0x0A, 0x00, 0x0D, 0x0A, 0x08, 0x0C, 0x0A, 0x02, 0x0C, 0x09, 0x00, 0x04, 0x03, 0x06, 0x0A, 0x01, 0x09, 0x01, 0x04, 0x00, 0x0F, 0x0B, 0x03, 0x06, 0x0A, 0x02, 0x00, 0x0F, 0x00, 0x02, 0x02, 0x04, 0x0F, 0x07, 0x0C, 0x09, 0x03, 0x00, 0x06, 0x04, 0x0F, 0x0B, 0x0D, 0x08, 0x03, 0x0C, 0x00, 0x09, 0x0F, 0x09, 0x01, 0x0E, 0x05, 0x04, 0x0A, 0x00, 0x0B, 0x03, 0x0F, 0x09, 0x0B, 0x06, 0x08, 0x0B, 0x00, 0x0D, 0x08, 0x06, 0x00, 0x0D, 0x09, 0x01, 0x07, 0x00, 0x02, 0x0D, 0x03, 0x07, 0x07, 0x0C, 0x07, 0x0E, 0x00, 0x01, 0x04, 0x08, 0x0D, 0x02, 0x0F, 0x0A, 0x08, 0x00, 0x08, 0x04, 0x05, 0x0A, 0x06, 0x08, 0x0D, 0x01, 0x00, 0x01, 0x0E, 0x0A, 0x03, 0x01, 0x05, 0x0A, 0x04, 0x00, 0x0B, 0x01, 0x00, 0x0D, 0x08, 0x03, 0x0E, 0x02, 0x00, 0x07, 0x02, 0x07, 0x08, 0x0D, 0x0A, 0x07, 0x0D, 0x00, 0x03, 0x09, 0x01, 0x01, 0x08, 0x00, 0x03, 0x0A, 0x00, 0x0A, 0x0C, 0x02, 0x04, 0x05, 0x06, 0x0E, 0x0C, 0x00, 0x0F, 0x05, 0x0B, 0x0F, 0x0F, 0x07, 0x0A, 0x00, 0x00, 0x05, 0x0B, 0x04, 0x09, 0x06, 0x0B, 0x09, 0x0F, 0x00, 0x0A, 0x07, 0x0D, 0x02, 0x05, 0x0D, 0x0C, 0x09, 0x00, 0x06, 0x00, 0x08, 0x07, 0x00, 0x01, 0x03, 0x05, 0x00, 0x0C, 0x08, 0x01, 0x01, 0x09, 0x00, 0x0F, 0x06, 0x00, 0x0B, 0x06, 0x0F, 0x04, 0x0F, 0x0E, 0x05, 0x0C, 0x00, 0x06, 0x02, 0x0C, 0x08, 0x03, 0x03, 0x09, 0x03, 0x00, 0x0C, 0x01, 0x05, 0x02, 0x0F, 0x0D, 0x05, 0x06, 0x00, 0x09, 0x0C, 0x02, 0x03, 0x0C, 0x04, 0x06, 0x0A, 0x00, 0x03, 0x07, 0x0E, 0x05, 0x00, 0x01, 0x00, 0x09, 0x00, 0x0C, 0x0D, 0x07, 0x05, 0x0F, 0x04, 0x07, 0x0E, 0x00, 0x0B, 0x0A, 0x0E, 0x0C, 0x0A, 0x0E, 0x0C, 0x0B, 0x00, 0x07, 0x06, 0x0C, 0x0E, 0x05, 0x0A, 0x08, 0x0D, 0x00, 0x0E, 0x0C, 0x03, 0x0B, 0x09, 0x07, 0x0F, 0x00, 0x00, 0x05, 0x0C, 0x0B, 0x0B, 0x0D, 0x0E, 0x05, 0x05, 0x00, 0x09, 0x06, 0x0C, 0x01, 0x03, 0x00, 0x02, 0x00, 0x00, 0x03, 0x09, 0x05, 0x05, 0x06, 0x01, 0x00, 0x0F, 0x00, 0x0A, 0x00, 0x0B, 0x0C, 0x0A, 0x06, 0x0E, 0x03, 0x00, 0x09, 0x00, 0x04, 0x0C, 0x00, 0x07, 0x0A, 0x00, 0x00, 0x05, 0x09, 0x0B, 0x0A, 0x09, 0x0B, 0x0F, 0x0E, 0x00, 0x0A, 0x03, 0x0A, 0x02, 0x03, 0x0D, 0x05, 0x03, 0x00, 0x00, 0x05, 0x05, 0x07, 0x04, 0x00, 0x02, 0x05, 0x00, 0x00, 0x05, 0x02, 0x04, 0x0E, 0x05, 0x06, 0x0C, 0x00, 0x03, 0x0B, 0x0F, 0x0E, 0x08, 0x03, 0x08, 0x09, 0x00, 0x05, 0x02, 0x0E, 0x08, 0x00, 0x0B, 0x09, 0x05, 0x00, 0x06, 0x0E, 0x02, 0x02, 0x05, 0x08, 0x03, 0x06, 0x00, 0x07, 0x0A, 0x08, 0x0F, 0x09, 0x0B, 0x01, 0x07, 0x00, 0x08, 0x05, 0x01, 0x09, 0x06, 0x08, 0x06, 0x02, 0x00, 0x00, 0x0F, 0x07, 0x04, 0x0E, 0x06, 0x02, 0x08, 0x00, 0x0D, 0x09, 0x0C, 0x0E, 0x03, 0x0D, 0x0C, 0x0B, ] # fmt: on class LTspiceDES(DESBase): """ LTspice-DES Variant Implementation A Python port of the encryption algorithm used in LTspice. This is a variant of DES with several differences from the standard algorithm. """ # --- Behavioral flags (LTspice-specific deviations) --- _SWAP_INPUT = True # Swap 32-bit halves before IP _SWAP_KEY = True # Swap 32-bit halves before PC-1 _ROTATE_RIGHT = True # Right-rotation in key schedule _OUTPUT_MASK = MASK32 # Return only low 32 bits # fmt: off # S-boxes in normalized format (8 x 64, indexed by row*16+col) DES_SBOXES = _flat_sbox_to_normalized(_SBOX_FLAT) # Permuted Choice 1 (PC-1) table DES_PC1_TABLE = [ 0x38, 0x30, 0x28, 0x20, 0x18, 0x10, 0x08, 0x00, 0x39, 0x31, 0x29, 0x21, 0x19, 0x11, 0x09, 0x01, 0x3A, 0x32, 0x2A, 0x22, 0x1A, 0x12, 0x0A, 0x02, 0x3B, 0x33, 0x2B, 0x23, 0x3E, 0x36, 0x2E, 0x26, 0x1E, 0x16, 0x0E, 0x06, 0x3D, 0x35, 0x2D, 0x25, 0x1D, 0x15, 0x0D, 0x05, 0x3C, 0x34, 0x2C, 0x24, 0x1C, 0x14, 0x0C, 0x04, 0x1B, 0x13, 0x0B, 0x03, ] # Permuted Choice 2 (PC-2) table DES_PC2_TABLE = [ 0x0D, 0x10, 0x0A, 0x17, 0x00, 0x04, 0x02, 0x1B, 0x0E, 0x05, 0x14, 0x09, 0x16, 0x12, 0x0B, 0x03, 0x19, 0x07, 0x0F, 0x06, 0x1A, 0x13, 0x0C, 0x01, 0x28, 0x33, 0x1E, 0x24, 0x2E, 0x36, 0x1D, 0x27, 0x32, 0x2C, 0x20, 0x2F, 0x2B, 0x30, 0x26, 0x37, 0x21, 0x34, 0x2D, 0x29, 0x31, 0x23, 0x1C, 0x1F, ] # Initial permutation table (IP) DES_INITIAL_PERM = [ 0x39, 0x31, 0x29, 0x21, 0x19, 0x11, 0x09, 0x01, 0x3B, 0x33, 0x2B, 0x23, 0x1B, 0x13, 0x0B, 0x03, 0x3D, 0x35, 0x2D, 0x25, 0x1D, 0x15, 0x0D, 0x05, 0x3F, 0x37, 0x2F, 0x27, 0x1F, 0x17, 0x0F, 0x07, 0x38, 0x30, 0x28, 0x20, 0x18, 0x10, 0x08, 0x00, 0x3A, 0x32, 0x2A, 0x22, 0x1A, 0x12, 0x0A, 0x02, 0x3C, 0x34, 0x2C, 0x24, 0x1C, 0x14, 0x0C, 0x04, 0x3E, 0x36, 0x2E, 0x26, 0x1E, 0x16, 0x0E, 0x06, ] # Final permutation table (IP^-1) DES_FINAL_PERM = [ 0x27, 0x07, 0x2F, 0x0F, 0x37, 0x17, 0x3F, 0x1F, 0x26, 0x06, 0x2E, 0x0E, 0x36, 0x16, 0x3E, 0x1E, 0x25, 0x05, 0x2D, 0x0D, 0x35, 0x15, 0x3D, 0x1D, 0x24, 0x04, 0x2C, 0x0C, 0x34, 0x14, 0x3C, 0x1C, 0x23, 0x03, 0x2B, 0x0B, 0x33, 0x13, 0x3B, 0x1B, 0x22, 0x02, 0x2A, 0x0A, 0x32, 0x12, 0x3A, 0x1A, 0x21, 0x01, 0x29, 0x09, 0x31, 0x11, 0x39, 0x19, 0x20, 0x00, 0x28, 0x08, 0x30, 0x10, 0x38, 0x18, ] # fmt: on jtsylve-spice-crypt-98ea63c/spice_crypt/pspice/000077500000000000000000000000001521042210700216775ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/spice_crypt/pspice/__init__.py000066400000000000000000000015121521042210700240070ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """PSpice® encryption format support.""" from spice_crypt.pspice.decrypt import PSpiceFileParser from spice_crypt.pspice.des import PSpiceDES __all__ = [ "PSpiceDES", "PSpiceFileParser", "RecoveredKey", "recover_mode4_key", ] def __getattr__(name: str): """Lazy-load attack module symbols to avoid requiring the Rust extension at import time.""" if name in ("RecoveredKey", "recover_mode4_key"): from spice_crypt.pspice.attack import RecoveredKey, recover_mode4_key globals()["RecoveredKey"] = RecoveredKey globals()["recover_mode4_key"] = recover_mode4_key return globals()[name] raise AttributeError(f"module {__name__!r} has no attribute {name!r}") jtsylve-spice-crypt-98ea63c/spice_crypt/pspice/_aes_brute.rs000066400000000000000000000436721521042210700243710ustar00rootroot00000000000000// SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. // // SPDX-License-Identifier: AGPL-3.0-or-later //! AES-256 ECB brute-force key search for PSpice® Mode 4. //! //! Exploits the fact that only 4 of 32 AES key bytes are unknown (2^32 //! keyspace). A custom unrolled key schedule uses software S-box //! lookups and elides zero-valued key words, while decrypt rounds use //! hardware intrinsics where available. Rayon parallelises across all //! CPU cores; `cpufeatures` provides runtime detection on platforms //! where hardware AES may or may not be present. //! //! # Backend selection //! //! | Platform | Compile-time feature | Backend | //! |---|---|---| //! | aarch64-apple-darwin | `target_feature="aes"` (default) | ARM Crypto — inlined | //! | aarch64-unknown-linux-gnu | runtime `cpufeatures` check | ARM Crypto — not inlined | //! | x86_64 with `-C target-feature=+aes` | `target_feature="aes"` | AES-NI — inlined | //! | x86_64 default | runtime `cpufeatures` check | AES-NI — not inlined | //! | Everything else | — | Software fallback | use pyo3::prelude::*; use rayon::prelude::*; // ----------------------------------------------------------------------- // AES constants // ----------------------------------------------------------------------- /// Standard AES forward S-box (FIPS 197). #[rustfmt::skip] static SBOX: [u8; 256] = [ 0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76, 0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0, 0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15, 0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75, 0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84, 0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf, 0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8, 0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2, 0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73, 0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb, 0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79, 0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08, 0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a, 0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e, 0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf, 0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16, ]; /// Standard AES inverse S-box (FIPS 197). #[rustfmt::skip] static INV_SBOX: [u8; 256] = [ 0x52,0x09,0x6a,0xd5,0x30,0x36,0xa5,0x38,0xbf,0x40,0xa3,0x9e,0x81,0xf3,0xd7,0xfb, 0x7c,0xe3,0x39,0x82,0x9b,0x2f,0xff,0x87,0x34,0x8e,0x43,0x44,0xc4,0xde,0xe9,0xcb, 0x54,0x7b,0x94,0x32,0xa6,0xc2,0x23,0x3d,0xee,0x4c,0x95,0x0b,0x42,0xfa,0xc3,0x4e, 0x08,0x2e,0xa1,0x66,0x28,0xd9,0x24,0xb2,0x76,0x5b,0xa2,0x49,0x6d,0x8b,0xd1,0x25, 0x72,0xf8,0xf6,0x64,0x86,0x68,0x98,0x16,0xd4,0xa4,0x5c,0xcc,0x5d,0x65,0xb6,0x92, 0x6c,0x70,0x48,0x50,0xfd,0xed,0xb9,0xda,0x5e,0x15,0x46,0x57,0xa7,0x8d,0x9d,0x84, 0x90,0xd8,0xab,0x00,0x8c,0xbc,0xd3,0x0a,0xf7,0xe4,0x58,0x05,0xb8,0xb3,0x45,0x06, 0xd0,0x2c,0x1e,0x8f,0xca,0x3f,0x0f,0x02,0xc1,0xaf,0xbd,0x03,0x01,0x13,0x8a,0x6b, 0x3a,0x91,0x11,0x41,0x4f,0x67,0xdc,0xea,0x97,0xf2,0xcf,0xce,0xf0,0xb4,0xe6,0x73, 0x96,0xac,0x74,0x22,0xe7,0xad,0x35,0x85,0xe2,0xf9,0x37,0xe8,0x1c,0x75,0xdf,0x6e, 0x47,0xf1,0x1a,0x71,0x1d,0x29,0xc5,0x89,0x6f,0xb7,0x62,0x0e,0xaa,0x18,0xbe,0x1b, 0xfc,0x56,0x3e,0x4b,0xc6,0xd2,0x79,0x20,0x9a,0xdb,0xc0,0xfe,0x78,0xcd,0x5a,0xf4, 0x1f,0xdd,0xa8,0x33,0x88,0x07,0xc7,0x31,0xb1,0x12,0x10,0x59,0x27,0x80,0xec,0x5f, 0x60,0x51,0x7f,0xa9,0x19,0xb5,0x4a,0x0d,0x2d,0xe5,0x7a,0x9f,0x93,0xc9,0x9c,0xef, 0xa0,0xe0,0x3b,0x4d,0xae,0x2a,0xf5,0xb0,0xc8,0xeb,0xbb,0x3c,0x83,0x53,0x99,0x61, 0x17,0x2b,0x04,0x7e,0xba,0x77,0xd6,0x26,0xe1,0x69,0x14,0x63,0x55,0x21,0x0c,0x7d, ]; static RCON: [u32; 7] = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40]; // ----------------------------------------------------------------------- // Key schedule (shared across all backends) // ----------------------------------------------------------------------- /// Apply the AES S-box to each byte of a 32-bit word. #[inline(always)] fn sub_word(w: u32) -> u32 { let b = w.to_le_bytes(); u32::from_le_bytes([ SBOX[b[0] as usize], SBOX[b[1] as usize], SBOX[b[2] as usize], SBOX[b[3] as usize], ]) } /// RotWord then SubWord — the combined operation at every 8th key word. #[inline(always)] fn sub_rot_word(w: u32) -> u32 { sub_word(w.rotate_right(8)) } /// AES-256 key expansion optimised for keys where W2-W7 are all zero. /// /// Only W0 (the candidate) and W1 (the version suffix) are non-zero. /// The first two epochs are fully unrolled with zero elisions; the /// remaining epochs use the standard recurrence. #[inline(always)] fn key_schedule(candidate: u32, w1: u32, c8: u32) -> [u32; 60] { let mut w = [0u32; 60]; w[0] = candidate; w[1] = w1; // Epoch 0 (W8-W15): W2-W7 = 0 ⇒ W10 = W11 = W9, W12-W15 = S1 let w8 = candidate ^ c8; let w9 = w1 ^ w8; w[8] = w8; w[9] = w9; w[10] = w9; w[11] = w9; let s1 = sub_word(w9); w[12] = s1; w[13] = s1; w[14] = s1; w[15] = s1; // Epoch 1 (W16-W23): still benefits from W2-W7 = 0 simplifications let t = sub_rot_word(s1) ^ RCON[1]; w[16] = w8 ^ t; w[17] = w9 ^ w[16]; w[18] = w9 ^ w[17]; w[19] = w9 ^ w[18]; let s2 = sub_word(w[19]); w[20] = s1 ^ s2; w[21] = s1 ^ w[20]; w[22] = s1 ^ w[21]; w[23] = s1 ^ w[22]; // Epochs 2-4 (W24-W55): standard AES-256 recurrence let mut i = 24; let mut rc = 2; while i < 56 { let t = sub_rot_word(w[i - 1]) ^ RCON[rc]; w[i] = w[i - 8] ^ t; w[i + 1] = w[i - 7] ^ w[i]; w[i + 2] = w[i - 6] ^ w[i + 1]; w[i + 3] = w[i - 5] ^ w[i + 2]; let s = sub_word(w[i + 3]); w[i + 4] = w[i - 4] ^ s; w[i + 5] = w[i - 3] ^ w[i + 4]; w[i + 6] = w[i - 2] ^ w[i + 5]; w[i + 7] = w[i - 1] ^ w[i + 6]; i += 8; rc += 1; } // Final half-epoch (W56-W59) let t = sub_rot_word(w[55]) ^ RCON[rc]; w[56] = w[48] ^ t; w[57] = w[49] ^ w[56]; w[58] = w[50] ^ w[57]; w[59] = w[51] ^ w[58]; w } // ----------------------------------------------------------------------- // Runtime hardware-AES detection (same pattern as RustCrypto `aes` crate) // ----------------------------------------------------------------------- #[cfg(target_arch = "aarch64")] cpufeatures::new!(hw_aes_available, "aes"); #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] cpufeatures::new!(hw_aes_available, "aes"); // ----------------------------------------------------------------------- // Backend: ARM Crypto Extensions (AESD / AESIMC) // ----------------------------------------------------------------------- #[cfg(target_arch = "aarch64")] mod armv8 { use core::arch::aarch64::*; /// Pack four round-key words into a NEON register. #[inline(always)] unsafe fn pack(a: u32, b: u32, c: u32, d: u32) -> uint8x16_t { let mut v = vdupq_n_u32(0); v = vsetq_lane_u32(a, v, 0); v = vsetq_lane_u32(b, v, 1); v = vsetq_lane_u32(c, v, 2); v = vsetq_lane_u32(d, v, 3); vreinterpretq_u8_u32(v) } /// Compile-time feature path — fully inlinable. #[cfg(target_feature = "aes")] #[inline(always)] pub fn decrypt(w: &[u32; 60], ct: &[u8; 16]) -> [u8; 16] { unsafe { decrypt_impl(w, ct) } } /// Runtime-detected path — not inlinable across the feature boundary. #[cfg(not(target_feature = "aes"))] #[target_feature(enable = "aes,neon")] pub unsafe fn decrypt(w: &[u32; 60], ct: &[u8; 16]) -> [u8; 16] { decrypt_impl(w, ct) } /// 14-round AES-256 decrypt. /// /// ARM `AESD` = AddRoundKey ⊕ InvSubBytes ⊕ InvShiftRows (no /// InvMixColumns), so each full round is `AESIMC(AESD(block, key))`. /// Middle round keys have `AESIMC` pre-applied (equivalent inverse /// cipher). The last round omits the outer `AESIMC`. #[inline(always)] unsafe fn decrypt_impl(w: &[u32; 60], ct: &[u8; 16]) -> [u8; 16] { let rk = |i: usize| pack(w[i], w[i + 1], w[i + 2], w[i + 3]); let mut blk = vld1q_u8(ct.as_ptr()); // Round 1 (RK14, no pre-AESIMC) blk = vaesimcq_u8(vaesdq_u8(blk, rk(56))); // Rounds 2-13 (RK13..RK2, with pre-AESIMC) blk = vaesimcq_u8(vaesdq_u8(blk, vaesimcq_u8(rk(52)))); blk = vaesimcq_u8(vaesdq_u8(blk, vaesimcq_u8(rk(48)))); blk = vaesimcq_u8(vaesdq_u8(blk, vaesimcq_u8(rk(44)))); blk = vaesimcq_u8(vaesdq_u8(blk, vaesimcq_u8(rk(40)))); blk = vaesimcq_u8(vaesdq_u8(blk, vaesimcq_u8(rk(36)))); blk = vaesimcq_u8(vaesdq_u8(blk, vaesimcq_u8(rk(32)))); blk = vaesimcq_u8(vaesdq_u8(blk, vaesimcq_u8(rk(28)))); blk = vaesimcq_u8(vaesdq_u8(blk, vaesimcq_u8(rk(24)))); blk = vaesimcq_u8(vaesdq_u8(blk, vaesimcq_u8(rk(20)))); blk = vaesimcq_u8(vaesdq_u8(blk, vaesimcq_u8(rk(16)))); blk = vaesimcq_u8(vaesdq_u8(blk, vaesimcq_u8(rk(12)))); blk = vaesimcq_u8(vaesdq_u8(blk, vaesimcq_u8(rk(8)))); // Round 14 (RK1, pre-AESIMC, no outer AESIMC) blk = vaesdq_u8(blk, vaesimcq_u8(rk(4))); // Final AddRoundKey (RK0) blk = veorq_u8(blk, rk(0)); let mut out = [0u8; 16]; vst1q_u8(out.as_mut_ptr(), blk); out } } // ----------------------------------------------------------------------- // Backend: x86 / x86_64 AES-NI (AESDEC / AESDECLAST) // ----------------------------------------------------------------------- #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] mod x86ni { #[cfg(target_arch = "x86")] use core::arch::x86::*; #[cfg(target_arch = "x86_64")] use core::arch::x86_64::*; #[inline(always)] unsafe fn pack(a: u32, b: u32, c: u32, d: u32) -> __m128i { _mm_set_epi32(d as i32, c as i32, b as i32, a as i32) } #[cfg(target_feature = "aes")] #[inline(always)] pub fn decrypt(w: &[u32; 60], ct: &[u8; 16]) -> [u8; 16] { unsafe { decrypt_impl(w, ct) } } #[cfg(not(target_feature = "aes"))] #[target_feature(enable = "aes")] pub unsafe fn decrypt(w: &[u32; 60], ct: &[u8; 16]) -> [u8; 16] { decrypt_impl(w, ct) } /// 14-round AES-256 decrypt. /// /// x86 `AESDEC` = AddRoundKey ⊕ InvSubBytes ⊕ InvShiftRows ⊕ /// InvMixColumns (all four steps). `AESDECLAST` omits /// InvMixColumns for the final round. Middle round keys have /// `AESIMC` pre-applied. #[inline(always)] unsafe fn decrypt_impl(w: &[u32; 60], ct: &[u8; 16]) -> [u8; 16] { let rk = |i: usize| pack(w[i], w[i + 1], w[i + 2], w[i + 3]); let mut blk = _mm_loadu_si128(ct.as_ptr().cast()); // Initial AddRoundKey blk = _mm_xor_si128(blk, rk(56)); // Rounds 1-13 (AESDEC includes InvMixColumns) blk = _mm_aesdec_si128(blk, _mm_aesimc_si128(rk(52))); blk = _mm_aesdec_si128(blk, _mm_aesimc_si128(rk(48))); blk = _mm_aesdec_si128(blk, _mm_aesimc_si128(rk(44))); blk = _mm_aesdec_si128(blk, _mm_aesimc_si128(rk(40))); blk = _mm_aesdec_si128(blk, _mm_aesimc_si128(rk(36))); blk = _mm_aesdec_si128(blk, _mm_aesimc_si128(rk(32))); blk = _mm_aesdec_si128(blk, _mm_aesimc_si128(rk(28))); blk = _mm_aesdec_si128(blk, _mm_aesimc_si128(rk(24))); blk = _mm_aesdec_si128(blk, _mm_aesimc_si128(rk(20))); blk = _mm_aesdec_si128(blk, _mm_aesimc_si128(rk(16))); blk = _mm_aesdec_si128(blk, _mm_aesimc_si128(rk(12))); blk = _mm_aesdec_si128(blk, _mm_aesimc_si128(rk(8))); blk = _mm_aesdec_si128(blk, _mm_aesimc_si128(rk(4))); // Round 14 (AESDECLAST omits InvMixColumns) blk = _mm_aesdeclast_si128(blk, rk(0)); let mut out = [0u8; 16]; _mm_storeu_si128(out.as_mut_ptr().cast(), blk); out } } // ----------------------------------------------------------------------- // Backend: portable software AES-256 decrypt // ----------------------------------------------------------------------- mod soft { use super::INV_SBOX; #[inline(always)] fn xtime(a: u8) -> u8 { ((a as u16) << 1 ^ if a & 0x80 != 0 { 0x1b } else { 0 }) as u8 } #[inline(always)] fn gf_mul(mut a: u8, mut b: u8) -> u8 { let mut r = 0u8; for _ in 0..8 { if b & 1 != 0 { r ^= a; } a = xtime(a); b >>= 1; } r } pub fn decrypt(w: &[u32; 60], ct: &[u8; 16]) -> [u8; 16] { let mut s = *ct; add_round_key(&mut s, w, 56); for round in (1..14).rev() { inv_shift_rows(&mut s); inv_sub_bytes(&mut s); add_round_key(&mut s, w, round * 4); inv_mix_columns(&mut s); } inv_shift_rows(&mut s); inv_sub_bytes(&mut s); add_round_key(&mut s, w, 0); s } #[inline(always)] fn add_round_key(state: &mut [u8; 16], w: &[u32; 60], offset: usize) { for col in 0..4 { let k = w[offset + col].to_le_bytes(); for row in 0..4 { state[col * 4 + row] ^= k[row]; } } } #[inline(always)] fn inv_sub_bytes(s: &mut [u8; 16]) { for b in s.iter_mut() { *b = INV_SBOX[*b as usize]; } } #[inline(always)] fn inv_shift_rows(s: &mut [u8; 16]) { // Row 1: right-rotate by 1 let t = s[13]; s[13] = s[9]; s[9] = s[5]; s[5] = s[1]; s[1] = t; // Row 2: right-rotate by 2 let (t0, t1) = (s[2], s[6]); s[2] = s[10]; s[6] = s[14]; s[10] = t0; s[14] = t1; // Row 3: right-rotate by 3 let t = s[3]; s[3] = s[7]; s[7] = s[11]; s[11] = s[15]; s[15] = t; } #[inline(always)] fn inv_mix_columns(s: &mut [u8; 16]) { for col in 0..4 { let i = col * 4; let (a, b, c, d) = (s[i], s[i + 1], s[i + 2], s[i + 3]); s[i] = gf_mul(0x0e, a) ^ gf_mul(0x0b, b) ^ gf_mul(0x0d, c) ^ gf_mul(0x09, d); s[i + 1] = gf_mul(0x09, a) ^ gf_mul(0x0e, b) ^ gf_mul(0x0b, c) ^ gf_mul(0x0d, d); s[i + 2] = gf_mul(0x0d, a) ^ gf_mul(0x09, b) ^ gf_mul(0x0e, c) ^ gf_mul(0x0b, d); s[i + 3] = gf_mul(0x0b, a) ^ gf_mul(0x0d, b) ^ gf_mul(0x09, c) ^ gf_mul(0x0e, d); } } } // ----------------------------------------------------------------------- // Dispatch: key schedule + best available decrypt backend // ----------------------------------------------------------------------- /// Precomputed constants from the fixed key bytes (W1 and W7=0). #[derive(Clone)] struct FixedState { w1: u32, c8: u32, ct: [u8; 16], } impl FixedState { fn new(key_tpl: &[u8; 32], ct: &[u8; 16]) -> Self { Self { w1: u32::from_le_bytes(key_tpl[4..8].try_into().unwrap()), c8: sub_word(0) ^ RCON[0], ct: *ct, } } } /// Expand the key schedule for `candidate` and decrypt `state.ct`. #[inline(always)] fn decrypt_candidate(state: &FixedState, candidate: u32) -> [u8; 16] { let w = key_schedule(candidate, state.w1, state.c8); // Compile-time feature → inlinable, safe call. #[cfg(all(target_arch = "aarch64", target_feature = "aes"))] return armv8::decrypt(&w, &state.ct); #[cfg(all( any(target_arch = "x86", target_arch = "x86_64"), target_feature = "aes" ))] return x86ni::decrypt(&w, &state.ct); // Runtime detection → not inlinable, unsafe call. #[cfg(all(target_arch = "aarch64", not(target_feature = "aes")))] if hw_aes_available::get() { return unsafe { armv8::decrypt(&w, &state.ct) }; } #[cfg(all( any(target_arch = "x86", target_arch = "x86_64"), not(target_feature = "aes") ))] if hw_aes_available::get() { return unsafe { x86ni::decrypt(&w, &state.ct) }; } #[allow(unreachable_code)] soft::decrypt(&w, &state.ct) } // ----------------------------------------------------------------------- // Python entry point // ----------------------------------------------------------------------- /// Search candidate keys in `[start, end)` for a known-plaintext match. /// /// For each candidate, bytes 0-3 of `key_tpl` are replaced with the /// candidate value (little-endian), the full AES-256 key schedule is /// expanded, one 16-byte ECB block is decrypted, and the first /// `len(prefix)` plaintext bytes are compared against `prefix`. /// /// Returns the first matching candidate, or `None`. #[pyfunction] fn search_range( py: Python<'_>, ct: &[u8], key_tpl: &[u8], start: u64, end: u64, prefix: &[u8], ) -> PyResult> { if ct.len() != 16 || key_tpl.len() != 32 || prefix.len() > 16 { return Err(pyo3::exceptions::PyValueError::new_err( "ct must be 16 bytes, key_tpl must be 32 bytes, prefix must be <= 16 bytes", )); } let ct_arr: [u8; 16] = ct.try_into().unwrap(); let tpl: [u8; 32] = key_tpl.try_into().unwrap(); let prefix_len = prefix.len(); let mut prefix_arr = [0u8; 16]; prefix_arr[..prefix_len].copy_from_slice(prefix); let state = FixedState::new(&tpl, &ct_arr); let result = py.detach(|| { (start..end).into_par_iter().find_any(|&cand| { let pt = decrypt_candidate(&state, cand as u32); pt[0] == prefix_arr[0] && pt[..prefix_len] == prefix_arr[..prefix_len] }) }); Ok(result) } #[pymodule] fn _aes_brute(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(search_range, m)?)?; Ok(()) } jtsylve-spice-crypt-98ea63c/spice_crypt/pspice/attack.py000066400000000000000000000173371521042210700235330ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ Brute-force key recovery for PSpice Mode 4 encryption. Mode 4 uses AES-256 ECB but a key-derivation bug leaves only 4 of the 32 key bytes unknown, shrinking the effective keyspace to 2^32. The encrypted header block always decrypts to ``"0001.0000 "`` in the first 10 bytes, providing a known-plaintext crib for validating candidates. The Rust extension ``_aes_brute`` (compiled at install time via maturin) provides hardware-accelerated AES and rayon parallelism across all cores, completing the search in seconds. """ from __future__ import annotations import struct from typing import TYPE_CHECKING, NamedTuple if TYPE_CHECKING: import os try: from spice_crypt.pspice._aes_brute import search_range as _native_search except ImportError: _native_search = None # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- _HEADER_PREFIX = b"0001.0000 " _TOTAL = 0x1_0000_0000 # 2^32 # --------------------------------------------------------------------------- # Result type # --------------------------------------------------------------------------- class RecoveredKey(NamedTuple): """Result of a Mode 4 key recovery attack.""" short_key_bytes: bytes """The 4 unknown AES key bytes (positions 0-3).""" user_key_short: bytes """User key bytes 0-3: ``XOR(short_key_bytes, b\"8gM2\")``.""" user_key_extended: bytes """User key bytes 4-30, recovered from the encrypted header.""" user_key_full: bytes """Complete user key string (31 bytes) from the CSV file.""" # --------------------------------------------------------------------------- # File parsing # --------------------------------------------------------------------------- def _extract_header_block( file_path: str | os.PathLike, ) -> tuple[str, bytes]: """Return ``(version_str, header_block)`` from *file_path*. The header block is the first 64-byte ciphertext line after a Mode 4 ``$CDNENCSTART`` marker. Raises :class:`ValueError` if no Mode 4 blocks are found. """ from spice_crypt.pspice.keys import mode_from_marker active = False version_str = "" with open(file_path) as f: for line in f: stripped = line.strip() if stripped.startswith("$CDNENCSTART"): mode, ver = mode_from_marker(stripped) active = mode == 4 if active: version_str = ver continue if stripped.startswith("$CDNENCFINISH"): active = False continue if not active or not stripped: continue try: raw = bytes.fromhex(stripped) except ValueError: continue if len(raw) != 64: continue # First valid 64-byte line after the marker is the header return version_str, raw raise ValueError( "No Mode 4 encrypted blocks found. " "The file must contain $CDNENCSTART_ADV3 or " "$CDNENCSTART_USER_ADV3 markers." ) # --------------------------------------------------------------------------- # Default-key fast path # --------------------------------------------------------------------------- def _is_default_key(header_block: bytes, version_str: str) -> bool: """Return ``True`` if the header decrypts with unmodified base keys.""" from spice_crypt.pspice.decrypt import _decrypt_64_block, _make_cipher from spice_crypt.pspice.keys import derive_keys short_key, _ = derive_keys(mode=4, version_str=version_str) cipher = _make_cipher(4, short_key) pt = _decrypt_64_block(cipher, 4, header_block) return pt[:10] == _HEADER_PREFIX # --------------------------------------------------------------------------- # Header decryption & extended key recovery # --------------------------------------------------------------------------- def _recover_extended_key( short_key_bytes: bytes, suffix: bytes, header_block: bytes, ) -> bytes: """Decrypt the header and return ``user_key[4:31]``.""" from spice_crypt.pspice.decrypt import _decrypt_64_block, _make_cipher from spice_crypt.pspice.keys import _EXT_BASE short_key = short_key_bytes + suffix cipher = _make_cipher(4, short_key) header_pt = _decrypt_64_block(cipher, 4, header_block) # Validate structure if header_pt[:10] != _HEADER_PREFIX: raise RuntimeError( f"Header decryption failed: expected {_HEADER_PREFIX!r}, got {header_pt[:10]!r}" ) # g_aesKey sits at fixed offset 10, length = 27 (base) + len(suffix) ext_key_len = 27 + len(suffix) g_aes_key = header_pt[10 : 10 + ext_key_len] # Strip version suffix to get the XOR'd base xord_base = g_aes_key[: -len(suffix)] # Recover user_key[4:31] = XOR(xord_base, ext_base) return bytes(a ^ b for a, b in zip(xord_base, _EXT_BASE, strict=True)) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def recover_mode4_key( file_path: str | os.PathLike, ) -> RecoveredKey: """Recover the user key from a Mode 4 encrypted PSpice file. Brute-forces the 2^32 candidate keyspace using the known header prefix ``"0001.0000 "`` as a plaintext crib, then decrypts the full header to recover the user key. Uses the Rust ``_aes_brute`` extension for hardware-accelerated AES across all CPU cores. Args: file_path: Path to the encrypted PSpice file. Returns: :class:`RecoveredKey` with all recovered key material. Raises: ValueError: If the file lacks Mode 4 blocks or uses default keys. RuntimeError: If the Rust extension is not available or the search fails. """ if _native_search is None: raise RuntimeError( "Brute-force key recovery requires the compiled Rust extension " "(_aes_brute), which is not available. Install from a pre-built " "wheel (pip install spice-crypt) or build from source with a " "Rust toolchain installed." ) from spice_crypt.pspice.keys import _SHORT_BASE, version_suffix version_str, header_block = _extract_header_block(file_path) # Fast path: check default (no user key) Mode 4 key if _is_default_key(header_block, version_str): raise ValueError( "File uses default Mode 4 keys (no user key applied). " "No key recovery needed -- decrypt directly." ) # Build key template: bytes 4+ = version suffix, rest zeros suffix = version_suffix(version_str) key_tpl = bytearray(32) key_tpl[4 : 4 + len(suffix)] = suffix key_tpl = bytes(key_tpl) # Brute-force: first AES sub-block of the header found = _native_search(header_block[0:16], key_tpl, 0, _TOTAL, _HEADER_PREFIX) if found is None: raise RuntimeError( "Exhausted 2^32 keyspace without finding a valid key. " "Verify the file contains genuine Mode 4 encrypted blocks." ) short_key_bytes = struct.pack(" # # SPDX-License-Identifier: AGPL-3.0-or-later """ Decryption support for PSpice® encrypted model files. PSpice files are plain-text SPICE netlists with selected ``.SUBCKT`` and ``.MODEL`` blocks replaced by hex-encoded ciphertext between ``$CDNENCSTART`` / ``$CDNENCFINISH`` marker pairs. Six encryption modes are supported (0-5), spanning DES (modes 0-2) and AES-256 ECB (modes 3-5). See ``SPECIFICATIONS/pspice.md`` for the full specification. """ from __future__ import annotations from typing import TYPE_CHECKING from spice_crypt.pspice.keys import derive_keys, load_user_keys, mode_from_marker if TYPE_CHECKING: import os from collections.abc import Generator _PAD_SENTINEL = b" $jbs$" # Bytes 62-63 of a non-final (overflow) block, written by the encoder when a # plaintext line is longer than the 62-byte payload and continues into the # next block. _OVERFLOW_MARKER = b"$+" def _make_cipher(mode: int, short_key: bytes): """Instantiate and key the correct cipher engine for *mode*.""" if mode <= 2: from spice_crypt.pspice.des import PSpiceDES cipher = PSpiceDES() cipher.set_key(short_key) return cipher # Modes 3-5: AES-256 ECB from spice_crypt._aes import AES256ECB # Short key zero-padded to 32 bytes aes_key = (short_key + b"\x00" * 32)[:32] return AES256ECB(aes_key) def _decrypt_64_block(cipher, mode: int, data: bytes) -> bytes: """Decrypt a single 64-byte block using the appropriate engine.""" if mode <= 2: return cipher.process_block(data, decrypt=True) # AES: 4 x 16-byte ECB blocks result = bytearray(64) for i in range(4): result[i * 16 : i * 16 + 16] = cipher.decrypt_block(data[i * 16 : i * 16 + 16]) return bytes(result) def _split_block(block: bytes) -> tuple[bytes, bool]: """Split a decrypted 64-byte block into ``(content, line_complete)``. A single plaintext line may span several blocks. The encoder fills each block with up to 62 bytes of line content (bytes 0-61); when content remains it writes the ``$+`` overflow marker into bytes 62-63 and continues in the next block. The *final* block of a line is padded with the `` $jbs$`` sentinel followed by random fill, unless the content (plus its terminating carriage return) happens to fill the payload exactly. Returns the line content carried by this block and whether the block terminates the line. Continuation blocks return ``line_complete=False`` and their content is concatenated by the caller. """ payload = block[:62] # Intact padding sentinel within the payload: content ends before it. idx = payload.find(_PAD_SENTINEL) if idx >= 0: return payload[:idx].rstrip(b"\x00"), True # Carriage-return line terminator (CRLF source files). Everything after # it is padding — a possibly-truncated sentinel plus random fill. Bytes # 62-63 are searched too: when the line content plus its ``\r`` fills the # block exactly (63 or 64 bytes), there is no room for the sentinel and the # terminator lands in the bytes normally used for the ``$+`` overflow # marker. An overflow (continuation) block instead holds ``$+`` there, so # it has no ``\r`` and falls through to the overflow case below. cr = block.find(b"\r") if cr >= 0: return block[: cr + 1], True # Sentinel truncated at the payload/byte-62 boundary on a line with no # carriage return: only a prefix `` $jbs`` of the 6-byte sentinel survives # at the tail of the payload (bytes 62-63 are overwritten). Confirm it is # genuine padding via the next sentinel byte, and require that this is not # an overflow block (whose bytes 62-63 are the ``$+`` marker). if block[62:64] != _OVERFLOW_MARKER: for n in range(len(_PAD_SENTINEL) - 1, 1, -1): if payload.endswith(_PAD_SENTINEL[:n]) and block[62] == _PAD_SENTINEL[n]: return payload[:-n].rstrip(b"\x00"), True # Overflow block: 62 bytes of content with more to come in the next block. return payload.rstrip(b"\x00"), False class PSpiceFileParser: """Parser for PSpice encrypted files with streaming support. Reads a text-mode file object, passes non-encrypted lines through unchanged, and decrypts ``$CDNENCSTART`` / ``$CDNENCFINISH`` blocks. """ def __init__( self, file_obj, user_key_file: str | os.PathLike | None = None, encrypted_file_path: str | os.PathLike | None = None, user_key_bytes: bytes | None = None, ): """ Args: file_obj: Seekable text-mode file-like object. user_key_file: Path to user key CSV file for mode 4. encrypted_file_path: Path of the file being decrypted (used for user key identity matching in mode 4). user_key_bytes: Raw user key bytes for mode 4 (alternative to loading from a CSV file via *user_key_file*). """ self.file_obj = file_obj self.user_key_file = user_key_file self.encrypted_file_path = encrypted_file_path self.user_key_bytes = user_key_bytes def decrypt_stream(self) -> Generator[bytes, None, tuple[int, int]]: """Stream-decrypt the file, yielding plaintext chunks. Non-encrypted lines are yielded as-is (encoded to bytes). Encrypted blocks are decrypted and the plaintext content is yielded line by line. Returns: Generator yielding bytes chunks. The return value (via ``StopIteration``) is ``(0, 0)`` (PSpice files have no verification checksums). """ line_buffer = b"" in_encrypted_block = False cipher = None mode = 0 is_header = False for line in self.file_obj: stripped = ( line.strip() if isinstance(line, str) else line.decode("utf-8", "replace").strip() ) # Check for block start marker if stripped.startswith("$CDNENCSTART"): in_encrypted_block = True is_header = True mode, version_str = mode_from_marker(stripped) # Derive keys user_key = self.user_key_bytes if user_key is None and mode == 4 and self.user_key_file: user_key = load_user_keys(self.user_key_file, self.encrypted_file_path) short_key, _ = derive_keys(mode, version_str, user_key) cipher = _make_cipher(mode, short_key) continue # Check for block end marker if stripped.startswith("$CDNENCFINISH"): # Flush any pending (incomplete) line if line_buffer: yield line_buffer + b"\n" line_buffer = b"" in_encrypted_block = False cipher = None continue if not in_encrypted_block: # Pass through non-encrypted lines line_bytes = line.encode("utf-8") if isinstance(line, str) else line yield line_bytes continue # Inside an encrypted block — decode and decrypt the hex line hex_str = stripped if not hex_str: continue try: block_data = bytes.fromhex(hex_str) except ValueError: continue if len(block_data) != 64: continue plaintext_block = _decrypt_64_block(cipher, mode, block_data) # Skip the encrypted header (first block after marker) if is_header: is_header = False continue # A plaintext line may span several blocks; accumulate content # until a block terminates the line. content, line_complete = _split_block(plaintext_block) line_buffer += content if line_complete: yield line_buffer + b"\n" line_buffer = b"" # Flush any trailing line if line_buffer: yield line_buffer + b"\n" return (0, 0) jtsylve-spice-crypt-98ea63c/spice_crypt/pspice/des.py000066400000000000000000000145531521042210700230340ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ PSpice® DES variant implementation. This module implements the custom DES block cipher used by Cadence PSpice (``CDesEncoder``) for encryption modes 0-2. It deviates from standard DES (FIPS 46-3) in its PC-1, PC-2, IP, FP tables and S-boxes, while retaining standard Expansion, P-box, and rotation schedule. See ``SPECIFICATIONS/pspice.md`` Section 4 for full details. """ from spice_crypt._constants import MASK64 from spice_crypt._des_base import DESBase class PSpiceDES(DESBase): """PSpice DES variant (``CDesEncoder``). Uses standard DES structure (right-rotation, no half-swaps, full 64-bit output) with custom PC-1, PC-2, IP, FP tables and S-boxes. """ # --- Behavioral flags --- _SWAP_INPUT = False _SWAP_KEY = False _ROTATE_RIGHT = True # Right-rotation in key schedule (same as LTspice) _OUTPUT_MASK = MASK64 # fmt: off # S-boxes extracted from PSpiceEnc.exe CDesEncoder_initTables. # 8 S-boxes, each 64 entries (4 rows x 16 columns), row-major order. DES_SBOXES = [ # S-box 0 [ 14, 15, 11, 8, 3, 10, 4, 13, 1, 2, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 10, 6, 12, 2, 13, 1, 11, 9, 5, 3, 8, 4, 1, 6, 2, 11, 15, 12, 14, 8, 13, 9, 7, 3, 10, 5, 0, 15, 12, 9, 1, 7, 5, 11, 8, 2, 4, 3, 14, 10, 0, 6, 13, ], # S-box 1 [ 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9, 3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5, 15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, ], # S-box 2 [ 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1, 10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12, 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, ], # S-box 3 [ 4, 7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 15, 3, 13, 8, 11, 5, 6, 15, 0, 4, 7, 2, 12, 1, 10, 14, 9, 2, 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 8, 4, 1, 15, 0, 6, 10, 3, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14, ], # S-box 4 [ 10, 11, 2, 12, 4, 1, 7, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 1, 5, 0, 2, 12, 4, 7, 13, 15, 10, 3, 9, 8, 6, 4, 2, 1, 8, 15, 9, 11, 10, 13, 7, 12, 5, 6, 3, 0, 14, 10, 4, 5, 3, 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, ], # S-box 5 [ 13, 3, 4, 14, 7, 5, 11, 12, 1, 10, 15, 9, 2, 6, 8, 0, 10, 15, 4, 2, 7, 12, 13, 14, 0, 9, 5, 6, 1, 11, 3, 8, 9, 12, 3, 7, 14, 15, 5, 2, 8, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 11, 9, 5, 15, 10, 14, 1, 7, 6, 0, 8, 13, ], # S-box 6 [ 5, 11, 2, 14, 15, 1, 8, 13, 3, 12, 9, 7, 4, 10, 6, 0, 13, 0, 11, 7, 4, 8, 1, 10, 14, 3, 5, 12, 2, 15, 9, 6, 1, 4, 11, 13, 12, 3, 14, 7, 10, 8, 6, 15, 0, 5, 9, 2, 6, 13, 8, 1, 11, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12, ], # S-box 7 [ 3, 12, 8, 4, 6, 15, 11, 1, 10, 9, 13, 14, 5, 0, 2, 7, 1, 15, 13, 0, 10, 3, 7, 4, 12, 5, 6, 11, 8, 14, 9, 2, 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 11, 8, 13, 15, 12, 9, 0, 3, 5, 6, 10, ], ] # Permuted Choice 1 (PC-1) — 0-indexed, converted from 1-indexed binary values. DES_PC1_TABLE = [ 0, 57, 49, 41, 33, 25, 17, 56, 48, 40, 32, 24, 16, 8, 9, 1, 58, 50, 42, 34, 26, 62, 54, 46, 38, 30, 22, 14, 18, 10, 2, 59, 51, 43, 35, 13, 5, 60, 52, 44, 36, 28, 6, 61, 53, 45, 37, 29, 21, 20, 12, 4, 27, 19, 11, 3, ] # Permuted Choice 2 (PC-2) DES_PC2_TABLE = [ 13, 16, 10, 23, 0, 4, 22, 18, 11, 3, 25, 7, 2, 27, 14, 5, 20, 9, 15, 6, 26, 19, 12, 1, 29, 39, 50, 44, 32, 47, 40, 51, 30, 36, 46, 54, 45, 48, 38, 55, 33, 52, 41, 49, 35, 43, 28, 31, ] # Initial Permutation (IP) — PSpice-modified (3 pair-swaps vs standard) DES_INITIAL_PERM = [ 57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 13, 3, 61, 53, 45, 37, 29, 21, 11, 5, 55, 63, 47, 39, 31, 23, 15, 7, 48, 56, 40, 32, 24, 16, 8, 0, 58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, 62, 54, 46, 38, 30, 22, 14, 6, ] # Final Permutation (FP / IP^-1) — complementary swaps DES_FINAL_PERM = [ 39, 7, 47, 15, 55, 23, 63, 31, 38, 6, 46, 22, 54, 14, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29, 36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27, 34, 2, 42, 10, 50, 18, 58, 26, 32, 1, 41, 9, 49, 17, 57, 24, 33, 0, 40, 8, 48, 16, 56, 25, ] # fmt: on def process_block(self, data: bytes, decrypt: bool = True) -> bytes: """Decrypt (or encrypt) a 64-byte buffer as 8 independent DES-ECB blocks. Args: data: 64-byte buffer. decrypt: ``True`` for decryption, ``False`` for encryption. Returns: 64-byte result. """ if len(data) != 64: raise ValueError("PSpice DES processBlock requires exactly 64 bytes") result = bytearray(64) for i in range(8): block = data[i * 8 : i * 8 + 8] block_int = int.from_bytes(block, "little") out = self.crypt(block_int, self._key_int, decrypt_mode=decrypt) result[i * 8 : i * 8 + 8] = out.to_bytes(8, "little") return bytes(result) def set_key(self, key_bytes: bytes): """Set the DES key from a byte string (up to 8 bytes, zero-padded).""" padded = (key_bytes + b"\x00" * 8)[:8] self._key_int = int.from_bytes(padded, "little") # Force key schedule regeneration self.initialized_key = None jtsylve-spice-crypt-98ea63c/spice_crypt/pspice/keys.py000066400000000000000000000115071521042210700232300ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ PSpice® encryption key derivation for all 6 modes. Modes 0-2 use ``CDesEncoder`` (DES); modes 3-5 use ``PSpiceAESEncoder`` (AES-256 ECB). All key material for modes 0-3 and 5 is derived from hardcoded constants. Mode 4 optionally XORs user-provided key bytes. """ from __future__ import annotations import os import re # Marker patterns for mode detection _MARKER_RE = re.compile( r"\$CDNENCSTART(?:_(USER_)?ADV|_CENC)?(\d*)", re.IGNORECASE, ) def mode_from_marker(marker: str) -> tuple[int, str]: """Determine the encryption mode and version from a marker line. Args: marker: The ``$CDNENCSTART`` marker line (leading/trailing whitespace OK). Returns: ``(mode, version_string)`` tuple. *version_string* is the trailing digit(s) from the marker (e.g., ``"2"`` for ``$CDNENCSTART_ADV2``), or ``""`` for bare ``$CDNENCSTART``. """ marker = marker.strip() if marker == "$CDNENCSTART": return (0, "") m = _MARKER_RE.match(marker) if m is None: return (0, "") user_prefix = m.group(1) # "USER_" or None version = m.group(2) # trailing digits (may be empty) # CENC markers if "_CENC" in marker.upper(): if version == "5": return (5, version) return (1, version) # ADV / USER_ADV markers if version == "1": return (2, version) if version == "2": return (3, version) if version == "3": return (4, version) if user_prefix: return (4, version) # Unrecognized version defaults to mode 0 (matches PSpice behavior) return (0, version) _SHORT_BASE = b"8gM2" """Mode 3/4 short key base string.""" _EXT_BASE = b"H41Mlwqaspj1nxasyhq8530nh1r" """Mode 3/4 extended key base string.""" def version_suffix(version_str: str) -> bytes: """Compute the numeric suffix bytes from a marker version string.""" n = int(version_str) + 999 if version_str else 999 return str(n).encode("ascii") def derive_keys( mode: int, version_str: str = "", user_key_bytes: bytes | None = None, ) -> tuple[bytes, bytes]: """Derive the short and extended key strings for a given mode. Args: mode: Encryption mode (0-5). version_str: Version digit string from the marker. user_key_bytes: Optional 31-byte XOR key for mode 4. Returns: ``(short_key, extended_key)`` byte strings. *short_key* is the actual encryption key passed to ``setKey``; *extended_key* appears only in encrypted header metadata. """ if mode == 0: return (b"0a0vr7jo", b"ths0m02ukhy034r6") n_bytes = version_suffix(version_str) if mode == 1: return (b"1b1w" + n_bytes, b"uit1n13vliz1" + n_bytes) if mode == 2: return (b"1b1x" + n_bytes, b"uit1x13vlka1" + n_bytes) if mode == 5: return (b"1yti" + n_bytes, b"nhtti50rplx2" + n_bytes) # Modes 3 and 4 share the same base strings short_base = bytearray(_SHORT_BASE) ext_base = bytearray(_EXT_BASE) if mode == 4 and user_key_bytes and len(user_key_bytes) >= 4: # XOR first 4 bytes of user key into the short key base for i in range(4): short_base[i] ^= user_key_bytes[i] # XOR bytes 4-30 into the extended key base for i in range(min(27, len(user_key_bytes) - 4)): ext_base[i] ^= user_key_bytes[4 + i] return (bytes(short_base) + n_bytes, bytes(ext_base) + n_bytes) def load_user_keys( key_file_path: str | os.PathLike, encrypted_file_path: str | os.PathLike | None = None, ) -> bytes | None: """Load user XOR key bytes from a PSpice key CSV file. The CSV file has lines of the form ``; ``. Lines whose file path matches *encrypted_file_path* are skipped (self-referential key prevention). Returns the first non-matching key bytes, or ``None`` if no valid entry is found. Args: key_file_path: Path to the CSV key file. encrypted_file_path: Path of the file being decrypted (for identity comparison). Returns: Raw XOR key bytes, or ``None``. """ try: enc_path = os.path.abspath(encrypted_file_path) if encrypted_file_path else None with open(key_file_path) as f: for line in f: line = line.strip() if not line or ";" not in line: continue path_part, _, key_part = line.partition(";") path_part = path_part.strip() key_part = key_part.strip() if enc_path and os.path.abspath(path_part) == enc_path: continue return key_part.encode("ascii") except (OSError, ValueError): pass return None jtsylve-spice-crypt-98ea63c/spice_crypt/qspice/000077500000000000000000000000001521042210700217005ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/spice_crypt/qspice/__init__.py000066400000000000000000000005131521042210700240100ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """QSPICE encryption format support.""" from spice_crypt.qspice.cipher import QSpiceCipher from spice_crypt.qspice.decrypt import QSpiceFileParser __all__ = [ "QSpiceCipher", "QSpiceFileParser", ] jtsylve-spice-crypt-98ea63c/spice_crypt/qspice/_keystream.py000066400000000000000000000360441521042210700244240ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ Embedded keystream table for the QSPICE ``.prot`` cipher. QSPICE XORs the compressed payload with two keystreams: a Mersenne Twister stream (see :mod:`spice_crypt.qspice.cipher`) and this fixed 9973-byte table, walked with a seed-derived stride. The table is a fixed constant of the QSPICE protection scheme; it is stored here as base64 to keep the source file plain text. See SPECIFICATIONS/qspice.md Section 3.2 and Appendix A. ``KEYSTREAM_TABLE_LEN`` (9973) is prime, which is why the additive stride walk visits every offset before repeating for any nonzero stride (a zero stride stays on a single offset). """ import base64 KEYSTREAM_TABLE_LEN = 9973 # base64 of the 9973-byte keystream table; SHA-256: # bfd3ff9339f28056922c167e92daffbb10668e993aef4ac7ff3dd6d662004df3 _KEYSTREAM_B64 = ( "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" ) KEYSTREAM_TABLE = base64.b64decode(_KEYSTREAM_B64) if len(KEYSTREAM_TABLE) != KEYSTREAM_TABLE_LEN: # pragma: no cover - static data raise RuntimeError("QSPICE keystream table corrupt") jtsylve-spice-crypt-98ea63c/spice_crypt/qspice/cipher.py000066400000000000000000000160631521042210700235320ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ QSPICE ``.prot`` cipher implementation. A protected QSPICE block is recovered in four stages (see SPECIFICATIONS/qspice.md for the full description): 1. **Decode** — each ciphertext glyph maps to a 4-bit nibble; two glyphs form one byte. The first four decoded bytes are a little-endian 32-bit *seed*; the remainder is the encrypted payload. 2. **Keystream** — the seed drives two byte streams: a Mersenne-Twister stream (custom seeding, standard tempering) and an additive walk over a fixed 9973-byte table. 3. **Decrypt** — XOR each payload byte with both keystream bytes. 4. **Inflate** — the result is a zlib (RFC 1950) stream; decompressing it yields the plaintext sub-circuit body. The seed is stored in the clear, so the cipher provides obfuscation only; there is no user key. See SPECIFICATIONS/qspice.md Section 7 (Security Assessment). """ import zlib from spice_crypt.qspice._keystream import KEYSTREAM_TABLE, KEYSTREAM_TABLE_LEN # 64-glyph encoding alphabet defined by the QSPICE protection scheme, laid out # as four rows of sixteen. A glyph's nibble value is its index modulo 16; the # row (index // 16) is chosen at random by the encoder and carries no information. ALPHABET = ",vUxFKnmJwVOl2YQZRrDPH8f0uedITN7zqMcyih3WpEojA1k56Lts&b9XgGaS4CB" # Reverse map: glyph -> nibble value (0 to 15). _NIBBLE = {glyph: index % 16 for index, glyph in enumerate(ALPHABET)} _MASK32 = 0xFFFFFFFF # Mersenne Twister (MT19937) parameters. _N = 624 _M = 397 _MATRIX_A = 0x9908B0DF _UPPER_MASK = 0x80000000 _LOWER_MASK = 0x7FFFFFFF # Custom seeding multiplier: state[k] = seed * 6069**k (mod 2**32). _SEED_MULT = 6069 class _MersenneTwister: """MT19937 with QSPICE's geometric seeding; yields one keystream byte per word.""" __slots__ = ("_index", "_state") def __init__(self, seed: int): state = [0] * _N # QSPICE substitutes 1 for a zero seed before seeding the MT (the # binary does ``if (!seed) seed = 1``); the table-walk keystream still # uses the raw seed. See SPECIFICATIONS/qspice.md Section 3.1. state[0] = (seed & _MASK32) or 1 for k in range(1, _N): state[k] = (_SEED_MULT * state[k - 1]) & _MASK32 self._state = state self._index = _N # force a twist before the first output def _twist(self) -> None: state = self._state for i in range(_N): y = (state[i] & _UPPER_MASK) | (state[(i + 1) % _N] & _LOWER_MASK) next_val = state[(i + _M) % _N] ^ (y >> 1) if y & 1: next_val ^= _MATRIX_A state[i] = next_val self._index = 0 def next_byte(self) -> int: """Return the low byte of the next tempered MT19937 output word.""" if self._index >= _N: self._twist() y = self._state[self._index] self._index += 1 y ^= y >> 11 y ^= (y << 7) & 0x9D2C5680 y ^= (y << 15) & 0xEFC60000 y ^= y >> 18 return y & 0xFF # --------------------------------------------------------------------------- # Keyword tokenization (Windows-1252) # --------------------------------------------------------------------------- # # QSPICE stores its netlists in the Windows-1252 (CP1252) code page. The # "tokens" described in SPECIFICATIONS/qspice.md Section 5 -- the device # prefixes and operators carried as single bytes with the high bit set -- are # ordinary CP1252 characters, so decoding the decompressed payload as CP1252 # expands every token to the character QSPICE documents for it: # # byte char QSPICE meaning # 0xC3 à Gm-block device prefix (stored lowercased as 0xE3 'ã') # 0xD8 Ø .DLL device prefix, C++/Verilog (stored lowercased as 0xF8 'ø') # 0xA5 ¥ gate/flip-flop device prefix; also the reserved-pin marker # 0x80 € 12-bit DAC device prefix # 0xA3 £ (de)multiplexer / gate-driver device prefix # 0xD7 × saturating-transformer device prefix # 0xAB « node-group open 0xBB » node-group close # 0xB4 ´ separator between a device-type prefix and the instance name # 0xB5 µ micro (1e-6) SI prefix on numeric values # # Of these, only the micro sign has a standard-SPICE ASCII equivalent: ngspice, # Xyce, PySpice and PSpice all expect "u". It is rewritten to "u" so numeric # values parse in other tools. The proprietary device prefixes name # QSPICE-only behavioral devices that have no equivalent elsewhere, so they are # left as the characters QSPICE itself documents. QSPICE_CODEPAGE = "cp1252" _MICRO_SIGN = "µ" class QSpiceCipher: """Decoder/decryptor for a single QSPICE ``.prot`` block.""" @staticmethod def detokenize(data: bytes) -> str: """Expand a decompressed QSPICE payload to plaintext netlist text. The payload is Windows-1252 text (see the table above); decoding it as CP1252 turns every high-bit token byte into the character QSPICE documents for it. The micro sign (``µ``) is additionally rewritten to the ASCII ``u`` that other SPICE tools accept. See SPECIFICATIONS/qspice.md Section 5. """ return data.decode(QSPICE_CODEPAGE).replace(_MICRO_SIGN, "u") @staticmethod def decode(text: str) -> bytes: """Decode encoded glyphs (whitespace ignored) into raw bytes. Each pair of glyphs yields one byte: ``(high_nibble << 4) | low_nibble``. Any trailing unpaired glyph is dropped, matching the QSPICE decoder. """ nibbles = [_NIBBLE[c] for c in text if c in _NIBBLE] return bytes((nibbles[i] << 4) | nibbles[i + 1] for i in range(0, len(nibbles) - 1, 2)) @staticmethod def xor_decrypt(payload: bytes, seed: int) -> bytes: """XOR *payload* with the seed-derived MT and table keystreams.""" mt = _MersenneTwister(seed) stride = (seed >> 3) & 0xFF index = seed % KEYSTREAM_TABLE_LEN out = bytearray(len(payload)) for i, byte in enumerate(payload): out[i] = byte ^ mt.next_byte() ^ KEYSTREAM_TABLE[index] index = (index + stride) % KEYSTREAM_TABLE_LEN return bytes(out) @classmethod def decrypt_block(cls, text: str) -> bytes: """Decode, decrypt, and inflate one ``.prot`` block to plaintext bytes. Args: text: The encoded glyph stream between ``.prot`` and ``.unprot``. Returns: The decompressed plaintext sub-circuit body. Raises: ValueError: If the block is too short to hold a seed or the payload is not a valid zlib stream. """ data = cls.decode(text) if len(data) < 4: raise ValueError("QSPICE .prot block too short to contain a seed") seed = int.from_bytes(data[:4], "little") compressed = cls.xor_decrypt(data[4:], seed) try: return zlib.decompress(compressed) except zlib.error as e: raise ValueError(f"QSPICE payload is not a valid zlib stream: {e}") from e jtsylve-spice-crypt-98ea63c/spice_crypt/qspice/decrypt.py000066400000000000000000000140321521042210700237240ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """ Decryption support for QSPICE ``.prot`` protected model files. This module provides :class:`QSpiceFileParser`, which streams a QSPICE library or sub-circuit file and replaces each ``.prot`` … ``.unprot`` protected block with its decrypted plaintext, passing all other lines through unchanged. The result is a fully usable, plaintext netlist. See SPECIFICATIONS/qspice.md. """ import warnings from collections.abc import Generator from spice_crypt.qspice.cipher import QSpiceCipher # Block delimiters (compared case-insensitively against the stripped line, as # QSPICE itself does when scanning a netlist). The spec defines exactly # ``.prot`` / ``.unprot``; see SPECIFICATIONS/qspice.md Section 1. _PROT_START = ".prot" _PROT_END = ".unprot" class QSpiceFileParser: """Parser for QSPICE ``.prot`` protected files with streaming support.""" def __init__(self, file_obj): """ Initialize the parser with a text-mode file object. Args: file_obj: File-like object (text mode) that supports iteration. Raises: TypeError: If *file_obj* is not iterable. """ if not hasattr(file_obj, "__iter__"): raise TypeError("file_obj must be an iterable file-like object") self.file_obj = file_obj self.block_count = 0 def decrypt_stream(self) -> Generator[bytes, None, tuple[int, int]]: """ Stream decrypt the file, yielding plaintext chunks. Lines outside a protected block are emitted verbatim. Each ``.prot`` … ``.unprot`` block is decoded, decrypted, inflated, and detokenized, and the resulting plaintext sub-circuit body is emitted in its place (the ``.prot`` and ``.unprot`` markers themselves are dropped). A block that cannot be decoded, decrypted, or inflated does not abort the stream: a warning is issued and the original block is emitted unchanged, so other blocks and all passthrough lines are still recovered (mirroring the resilience of the LTspice and PSpice parsers). Returns: Generator that yields decrypted/passthrough chunks as ``bytes``. The final value is ``(block_count, 0)`` — the number of protected blocks successfully decrypted. QSPICE stores no integrity checksum, so the second element is always ``0``. """ in_block = False encoded: list[str] = [] raw_block: list[str] = [] for line in self.file_obj: stripped = line.strip() lowered = stripped.lower() if not in_block: if lowered == _PROT_START: in_block = True encoded = [] raw_block = [line] else: yield line.encode("utf-8", "replace") continue # Inside a protected block. Retain the original lines so the block # can be re-emitted verbatim if decryption fails. raw_block.append(line) if lowered == _PROT_END: yield from self._emit_block("".join(encoded), raw_block) in_block = False encoded = [] raw_block = [] else: encoded.append(stripped) # A block left open at EOF means the file is truncated. Warn, then make # a best-effort attempt to decrypt whatever payload was collected. if in_block: warnings.warn( "QSPICE .prot block was not terminated by .unprot before EOF; " "attempting best-effort decryption", stacklevel=2, ) yield from self._emit_block("".join(encoded), raw_block) return (self.block_count, 0) def _emit_block(self, encoded: str, raw_block: list[str]) -> Generator[bytes, None, None]: """Yield one decrypted block, or warn and pass it through on failure. On success the decrypted plaintext is yielded and ``block_count`` is incremented. If the block cannot be decoded, decrypted, or inflated, a warning is issued and the original block — ``.prot`` marker, encoded payload, and terminator — is emitted unchanged so the remainder of the file is still recovered rather than the whole stream aborting. """ try: plaintext = self._decrypt_block(encoded) except ValueError as e: warnings.warn( f"QSPICE .prot block could not be decrypted ({e}); passing it through unchanged", stacklevel=2, ) for raw in raw_block: yield raw.encode("utf-8", "replace") return self.block_count += 1 yield plaintext @staticmethod def _decrypt_block(encoded: str) -> bytes: """Decrypt, inflate, and detokenize one block to UTF-8 bytes. The recovered text is QSPICE's Windows-1252 netlist body with its keyword tokens expanded (see :meth:`QSpiceCipher.detokenize`). A trailing newline is appended so the block joins cleanly with the lines that follow, and it is emitted as UTF-8 to match the passthrough lines. """ text = QSpiceCipher.detokenize(QSpiceCipher.decrypt_block(encoded)) if text and not text.endswith("\n"): text += "\n" return text.encode("utf-8") def _detect_qspice_format(file_obj, max_lines: int = 200) -> bool: """ Auto-detect whether a seekable text file contains a QSPICE ``.prot`` block. Scans up to *max_lines* lines for a line equal to ``.prot`` (case-insensitive, whitespace-stripped), then restores the stream position. """ pos = file_obj.tell() try: for i, line in enumerate(file_obj): if i >= max_lines: break if line.strip().lower() == _PROT_START: return True finally: file_obj.seek(pos) return False jtsylve-spice-crypt-98ea63c/tests/000077500000000000000000000000001521042210700172325ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/tests/__init__.py000066400000000000000000000001731521042210700213440ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later jtsylve-spice-crypt-98ea63c/tests/conftest.py000066400000000000000000000012321521042210700214270ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """Shared test helpers and constants.""" from __future__ import annotations PLAINTEXT_BODY = ".subckt TEST_RES 1 2\nR1 1 2 1k\n.ends TEST_RES\n" def extract_body(decrypted: str) -> str: """Extract the subcircuit body (.subckt through .ends) from decrypted output.""" lines = decrypted.splitlines(keepends=True) start = next(i for i, line in enumerate(lines) if line.strip().startswith(".subckt")) end = next(i for i, line in enumerate(lines) if line.strip().startswith(".ends")) return "".join(lines[start : end + 1]) jtsylve-spice-crypt-98ea63c/tests/data/000077500000000000000000000000001521042210700201435ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/tests/data/ltspice.lib000066400000000000000000000075771521042210700223160ustar00rootroot00000000000000* LTspice Encrypted File * * This encrypted file has been supplied by a 3rd * party vendor that does not wish to publicize * the technology used to implement this library. * * Permission is granted to use this file for * simulations but not to reverse engineer its * contents. * * Begin: 62 C5 90 92 30 E2 CD 59 EF F2 5E E1 D3 9E 76 87 52 99 FC C4 44 FB 81 E1 59 DC 14 98 17 68 6C 4E 3D 3A EF E2 32 9B 75 81 65 8A F4 A7 03 6A CC 7C 96 0B D1 A2 58 C5 68 75 CE 6A 0F 45 8E 1B B7 4F C0 FD CA BA 10 3A 5D 36 1B 43 E8 26 B0 F5 C5 DC A5 83 30 3E DB 4A 66 9F 35 93 7F 1F 11 76 8E EC 60 89 E6 0B 86 3C AE B5 90 A8 74 F7 2A 43 FC 3A 40 55 61 E8 F9 FF 2A AE 8D 50 34 64 19 49 40 15 F1 B5 F7 69 C7 12 73 04 1F F0 20 74 44 DF 1E C6 49 F4 93 96 65 EE 9A 18 FB EB A3 C7 73 84 CB 7C 64 A1 83 27 79 1B 84 4F 41 16 A7 07 5C 36 A0 E8 DF 3E 84 B3 9B 38 A8 10 9B F6 0C B5 73 DD C0 60 16 59 9F EA CF 08 8D EB 07 F1 15 26 70 DA DA 04 E6 22 94 24 2B 21 A3 7A 28 DB 46 6C 96 84 03 20 87 3D A8 74 49 52 6C 73 26 D6 B9 D3 FF 79 03 8A 0F 49 1F AF 51 17 40 BC C1 6C 84 53 A4 C4 2E 9F 70 E5 FA 2F 1E E7 34 5B 11 95 91 A6 9E 96 10 FF DE 85 D6 AD F8 7A 5B B4 E6 D1 02 DB 9B 29 AE 78 52 14 D6 E9 00 D3 2C 9B 3C 92 AF E6 89 CC 61 02 16 77 45 F7 23 B3 97 75 31 31 37 8C C1 B3 3E F2 23 19 C5 89 FF 87 EA 24 35 40 07 B9 83 49 8D 00 A1 23 C4 66 E7 4C B8 8C DE 40 40 30 10 A9 EF F9 D5 AA B7 D6 69 FA EC 2D 4D A5 4F 78 8B 00 96 97 C7 E9 B5 82 A2 94 86 69 F7 80 6E 46 DA 98 6A 70 63 60 9F 5D 35 99 69 E5 4C 64 60 6A 6F 98 AC C5 4B A9 27 68 34 DC 60 ED 26 54 48 3C F0 3D 0A 12 5D AF B2 F2 7F 8A 85 86 D8 46 01 6A 2B 7D 87 5A B0 A0 17 AA CB 3E DD F3 70 63 10 28 5F D0 90 07 04 8F 14 83 43 1A BC 7E DE E1 E8 DB 9F 30 B9 3C 9A 9E D5 B7 50 E9 C3 8A 37 63 5F 51 EE 6E 15 86 F3 47 EF 56 3F ED 03 4D 79 21 A6 C7 5D 4C 00 D9 FF 2D DB D1 EC 21 33 98 AC 9F F5 AE A6 32 E6 72 8F 51 20 E6 D8 91 00 37 AC FC A2 12 9C D0 EF 05 4F 9C F1 BA 70 16 44 7A FA D6 90 00 AC D2 C8 88 15 27 ED 43 DB 52 53 E9 3A 6F 52 9A BC D1 3D 2A 8F 63 1B FA FD ED 33 0F 23 9B 74 E6 F9 24 42 AE 0C C2 3B D4 CE 06 C9 B8 DF 03 DB 4A CA 73 74 AD 28 F6 F1 2C 5E 97 06 AE 21 3B 8D 79 13 37 6D 37 ED 7F DD A0 21 A0 2A B0 8C E5 47 DD 5C C0 BD 01 86 38 95 29 1E 3C 5E 21 56 C3 88 3D DE F2 C4 F1 7A 66 93 0C BC 3B DD 09 3B E4 B5 1E DF A5 7E 5A CF E5 BE F9 A1 A8 0D 4C CB F3 54 BF 3C 68 48 C5 F3 F4 60 58 B7 5B D0 69 77 1E FC AF EC 07 13 97 0E 0D B4 3A D3 DE 44 95 DA 27 B9 D2 57 9E 59 CE 7E 05 D6 54 FC 6C 13 AD 52 82 47 2B BD 82 B7 F8 06 AE DC 92 57 3D 73 C4 4B 5B E2 C4 1E 92 0F CE 77 20 7E 34 07 0E 72 7B 28 99 51 CB E1 05 53 B0 45 86 4F E1 06 F8 3E 31 BE A3 F8 48 F6 1E 22 F7 EE 1F A9 F6 20 43 D7 F3 CA 83 37 A5 3A 0D BF 22 86 30 D1 F2 09 CB 27 ED 47 D6 63 3C 39 E4 25 FE EA F9 0B 98 19 AA 72 1A 4B 01 17 3B 4A 52 BF BE 24 7A 1A C4 80 B4 EC FB D6 0D 22 50 C4 2A 74 97 55 A1 A5 D5 FB 47 C2 D4 A4 ED B9 21 16 0D 4E FE F2 AA EB 54 BF FE CF AB A9 02 6D AF E4 8A 01 89 82 26 77 39 55 86 E1 38 65 1E 01 F0 1F 96 0F 31 DA 47 C3 F1 51 39 67 DB 5E 1F B5 AD B4 2B 9E E6 13 3A AF 43 BC 46 A5 AF 23 54 2D 05 16 E9 15 E7 D3 73 00 0F 82 CF DD 5F 4B 4D C6 6E AE 05 55 7A A7 A4 AA 41 69 AD F7 BE 5C 0B 7D 22 B4 96 88 D9 B8 6E ED 66 F2 2A 8A A0 7D 77 63 E3 37 B0 3A AD 70 30 8F 48 9B EB D5 F4 5A 39 CE 58 C0 92 6E FE 9D B4 36 CB 1B BD 30 8E 1F 16 3D 50 40 F6 BF F7 DC 0F 61 8E AF A4 41 46 6A 12 23 D1 E3 76 57 23 0A 9E 9E 8B 4C 35 FA 70 5B 26 42 A5 46 DB B2 E9 D8 06 B9 6B 3F 9B DB EE 58 5D A6 12 44 32 79 C3 D0 5B 2C A6 84 6C ED 12 87 53 05 40 32 BB 4F F6 95 D7 57 B9 88 B0 7D 0A E1 DD A0 87 90 46 4F 24 DF 25 8F EC 50 A5 68 CA AE A6 BA 10 BF 0F 53 36 49 49 22 6E 1B 72 32 CE C2 10 99 33 7F 49 6B 24 7F 08 69 AA 77 D6 A1 01 C6 EC 1A 85 77 D5 CD 53 B2 5C F2 B1 6C 99 AB 8B D1 EE 0C E7 2B A5 CB 67 4E CA FB 86 92 55 7B B6 CE 1F 3A 26 CF 46 B8 2E 03 21 6C 77 91 3B 00 6A B5 80 9B B4 34 77 AE 93 8E F5 22 0B D5 35 98 33 B3 9A 13 72 F3 A2 11 45 B2 E6 F1 F9 50 98 DD 48 * End 2881270823 1890297645 jtsylve-spice-crypt-98ea63c/tests/data/pspice/000077500000000000000000000000001521042210700214265ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/tests/data/pspice/mode0.lib000066400000000000000000000005571521042210700231310ustar00rootroot00000000000000**$ENCRYPTED_LIB **$INTERFACE *$ *$ .subckt TEST_RES 1 2 $CDNENCSTART eee8c5c7a2bc4b01f045f303678664e7916da0bae22e8cb0bba041dd67c69ce448ea70148a9ac1670c8926c1ac5057c8ccfcd77bf87ca9dc6355c1192c735fa5 a0388be4342614f5f81201e5bb91a9692329e92554bac8f3a0e177582f348479b90dcc754abdf51c4fd76f2d709b2b3266ac233ca8c7f7a797bbfb320a9881e6 $CDNENCFINISH .ends TEST_RES jtsylve-spice-crypt-98ea63c/tests/data/pspice/mode2.lib000066400000000000000000000005711521042210700231270ustar00rootroot00000000000000**$ENCRYPTED_LIB **$INTERFACE *$ *$ .subckt TEST_RES 1 2 $CDNENCSTART_ADV1 ae1a35865cb3be11a5c981063459615b1402a029d40d0cfc1d684eb8fa9761864ef3e98c6a86d70ccbf78e83028df13166981d0ef6ea7122e1bfd795e0b615a2 ad13d9c8d8510bf7fd739dad643785ee03693793ffa5f106787eb3b6df09429803208bcf6b6e057de6d82560ab3ac27ec171b9797126288514d31e50300c7f7a $CDNENCFINISH_ADV1 .ends TEST_RES jtsylve-spice-crypt-98ea63c/tests/data/pspice/mode3.lib000066400000000000000000000005711521042210700231300ustar00rootroot00000000000000**$ENCRYPTED_LIB **$INTERFACE *$ *$ .subckt TEST_RES 1 2 $CDNENCSTART_ADV2 17f62b1fb318c25603aeb85644ae98a5d179ef807807791f6459efacb9d7ffc37a831b146dc0f06bb1ef275619162e6d8f7ad0dc708bf15ae1a1d5bd0ae623b2 dbac24c925e4202f46036da0f3d509f0c9644889f4422df9f75f35669182f1af947edac4922a07602a3fc6e9060494c38f8d2ce2510c572624e6c29966438613 $CDNENCFINISH_ADV2 .ends TEST_RES jtsylve-spice-crypt-98ea63c/tests/data/pspice/mode4.lib000066400000000000000000000005711521042210700231310ustar00rootroot00000000000000**$ENCRYPTED_LIB **$INTERFACE *$ *$ .subckt TEST_RES 1 2 $CDNENCSTART_ADV3 4ad47fc6ea0161a7bbe517523c13a846a0f8e66a98a1a238959753a6b63b1d1263ca6170e9d37bfb8193cfad805cdb5a0f23c916015dd29f82e78792895f6828 de44520d1c5ea86c83e75130b4d5bf89344064751ef58f486b8b7cd2b03dce2be8256c228d99598c3d8f688cd1589b641b77aadb813a4824eaaeeb86b648656c $CDNENCFINISH_ADV3 .ends TEST_RES jtsylve-spice-crypt-98ea63c/tests/data/pspice/mode4_userkey.lib000066400000000000000000000006031521042210700246740ustar00rootroot00000000000000**$ENCRYPTED_LIB **$INTERFACE *$ *$ .subckt TEST_RES 1 2 $CDNENCSTART_USER_ADV3 8418bc6a5efbbed5f14ea833e137c2e60c3b4f796480b487637d0948cd0b9338ce29ff6ed6ed3300562020edb89845dafe9e852254484105812fc6ed77d33038 0f2e2b76d94fe994d69d1d237ac93fb10181a8e5d8a0ef67edaafb7913f93bef99614b56584c00ceb2a083211fff1b52b4fed14435faf9b773838bd66731675b $CDNENCFINISH_USER_ADV3 .ends TEST_RES jtsylve-spice-crypt-98ea63c/tests/data/pspice/plaintext.lib000066400000000000000000000001231521042210700241220ustar00rootroot00000000000000*$ * PSpice Model - Test Resistor *$ .subckt TEST_RES 1 2 R1 1 2 1k .ends TEST_RES jtsylve-spice-crypt-98ea63c/tests/data/qspice/000077500000000000000000000000001521042210700214275ustar00rootroot00000000000000jtsylve-spice-crypt-98ea63c/tests/data/qspice/basic.lib000066400000000000000000000001371521042210700232010ustar00rootroot00000000000000.subckt TEST_RES 1 2 .prot l2VOxFvU,vvx,mUYUVYmQnYQlnlUQlmFYvVYwJvlvn2w .unprot .ends TEST_RES jtsylve-spice-crypt-98ea63c/tests/data/qspice/comments.lib000066400000000000000000000001771521042210700237510ustar00rootroot00000000000000* synthetic QSPICE test model * .subckt TEST_RES 1 2 .prot ,m,,,,,,,lUwVQnxVOOlVlnxlUvOUQKOQK2VQv2mlmJQ .unprot .ends TEST_RES jtsylve-spice-crypt-98ea63c/tests/data/qspice/multi.lib000066400000000000000000000002641521042210700232530ustar00rootroot00000000000000.subckt FIRST 1 2 .prot UUUUvvvvn2w,UQnnQYn,vKlUO2QlYOUv2wwmQv,m,mlv .unprot .ends FIRST .subckt SECOND 3 4 .prot FFFFxxxxK2Fl2nV2YQVYKOOJ,VJQnnOYvlV,xnOnmvJm .unprot .ends SECOND jtsylve-spice-crypt-98ea63c/tests/data/qspice/passthrough.lib000066400000000000000000000002101521042210700244570ustar00rootroot00000000000000* 2026 Example Corp. All rights reserved. .subckt CP1252 1 2 .prot YYQQl,,,wvYwJUxVxnwmKvwUY2YQJYwFUnJVOFOV2wwY .unprot .ends CP1252 jtsylve-spice-crypt-98ea63c/tests/data/qspice/tokens.lib000066400000000000000000000004251521042210700234230ustar00rootroot00000000000000.subckt TOKENS vdd vss .prot ,2Q,V2,Om,UOvmY,n2KxxxmlO,xYwK,VnKv2QwmmnlwQVwOYJnVJJJFxVnxxFK2KKVmYvlvvlK,J xlYwvln,UUlYQQUvVVnQlO,xUnwJYJOFmwlwvJw,UU2OVxK2lvKUxxOYYVKKUKn2KnUJwJxOxnwK OYnVVxQ,UvQvUwKYQUFUwYQQYOwVvKwnKlmwUnYQnJ2vQJm2YOwFwxvFKF22,KQnQx2Q2QnU .unprot .ends TOKENS jtsylve-spice-crypt-98ea63c/tests/test_ltspice_decrypt.py000066400000000000000000000021541521042210700240420ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """Integration tests for LTspice decryption. Test data was generated by LTspice from the same minimal .subckt model used in the PSpice tests. """ from __future__ import annotations from pathlib import Path from spice_crypt import decrypt_stream from spice_crypt.ltspice.decrypt import LTspiceFileParser from tests.conftest import PLAINTEXT_BODY, extract_body DATA_DIR = Path(__file__).parent / "data" class TestLTspiceDecryption: """Verify decryption of LTspice-encrypted files.""" def test_decrypt_ltspice(self): content, _ = decrypt_stream(str(DATA_DIR / "ltspice.lib")) assert extract_body(content) == PLAINTEXT_BODY class TestLTspiceFileParser: """Test the LTspiceFileParser class directly.""" def test_parser_stream(self): with open(DATA_DIR / "ltspice.lib") as f: parser = LTspiceFileParser(f) chunks = list(parser.decrypt_stream()) text = b"".join(chunks).decode("utf-8", "replace") assert "R1 1 2 1k" in text jtsylve-spice-crypt-98ea63c/tests/test_pspice_decrypt.py000066400000000000000000000226411521042210700236650ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """Integration tests for PSpice decryption. Test data was generated by Cadence PSpiceEnc (PSpice TI, version string "3") from a minimal .subckt model file. Each mode*.lib file was encrypted with PSpiceEnc -m and the mode4_userkey.lib was encrypted with -m 4 and CDN_PSPICE_ENCKEYS pointing to a CSV with user key ``0123456789abcdefghijklmnopqrstu``. """ from __future__ import annotations from pathlib import Path import pytest from spice_crypt import decrypt_stream from spice_crypt.pspice.decrypt import PSpiceFileParser, _split_block from tests.conftest import PLAINTEXT_BODY, extract_body DATA_DIR = Path(__file__).parent / "data" / "pspice" USER_KEY = b"0123456789abcdefghijklmnopqrstu" def _decrypt_file(path: Path, *, user_key: bytes | None = None) -> str: """Decrypt a PSpice file and return the full decrypted text.""" content, _ = decrypt_stream(str(path), user_key=user_key) return content # --------------------------------------------------------------------------- # Decryption tests for each PSpice encryption mode # --------------------------------------------------------------------------- class TestPSpiceDecryption: """Verify decryption of PSpiceEnc-generated files across all modes.""" def test_mode0_des_legacy(self): result = _decrypt_file(DATA_DIR / "mode0.lib") assert extract_body(result) == PLAINTEXT_BODY def test_mode2_des_adv(self): result = _decrypt_file(DATA_DIR / "mode2.lib") assert extract_body(result) == PLAINTEXT_BODY def test_mode3_aes_default(self): result = _decrypt_file(DATA_DIR / "mode3.lib") assert extract_body(result) == PLAINTEXT_BODY def test_mode4_aes_no_user_key(self): result = _decrypt_file(DATA_DIR / "mode4.lib") assert extract_body(result) == PLAINTEXT_BODY def test_mode4_aes_user_key(self): result = _decrypt_file(DATA_DIR / "mode4_userkey.lib", user_key=USER_KEY) assert extract_body(result) == PLAINTEXT_BODY def test_mode4_user_key_wrong_key_fails(self): wrong_key = b"wrong_key_padded_to_31_bytes!!!!" result = _decrypt_file(DATA_DIR / "mode4_userkey.lib", user_key=wrong_key) try: body = extract_body(result) except StopIteration: return # Garbage output with no .subckt/.ends markers — decryption failed as expected assert body != PLAINTEXT_BODY # --------------------------------------------------------------------------- # Format auto-detection # --------------------------------------------------------------------------- class TestAutoDetection: """Verify that decrypt_stream auto-detects PSpice format.""" @pytest.mark.parametrize("filename", ["mode0.lib", "mode2.lib", "mode3.lib", "mode4.lib"]) def test_auto_detect(self, filename): result = _decrypt_file(DATA_DIR / filename) assert "R1 1 2 1k" in result # --------------------------------------------------------------------------- # PSpiceFileParser direct usage # --------------------------------------------------------------------------- class TestPSpiceFileParser: """Test the PSpiceFileParser class directly.""" def test_parser_stream(self): with open(DATA_DIR / "mode3.lib") as f: parser = PSpiceFileParser(f) chunks = list(parser.decrypt_stream()) text = b"".join(chunks).decode("utf-8", "replace") assert "R1 1 2 1k" in text def test_parser_with_user_key(self): with open(DATA_DIR / "mode4_userkey.lib") as f: parser = PSpiceFileParser(f, user_key_bytes=USER_KEY) chunks = list(parser.decrypt_stream()) text = b"".join(chunks).decode("utf-8", "replace") assert "R1 1 2 1k" in text # --------------------------------------------------------------------------- # Block splitting and multi-block line continuation # --------------------------------------------------------------------------- def _block(payload62: bytes, b62: int, b63: int) -> bytes: """Assemble a 64-byte decrypted block from its payload and trailing bytes.""" assert len(payload62) == 62 return payload62 + bytes([b62, b63]) class TestSplitBlock: """Byte-level behaviour of ``_split_block`` for every padding layout. Regression coverage for the bug where padding-sentinel fragments (`` $jbs``, `` $jb``, …) leaked into the output and long/continued lines were reconstructed with wrong line breaks. """ def test_full_sentinel_no_cr(self): # Short LF-source line: full sentinel within the payload. block = _block((b"R1 1 2 1k" + b" $jbs$").ljust(62, b"A"), ord("A"), 0) assert _split_block(block) == (b"R1 1 2 1k", True) def test_full_sentinel_with_cr(self): # CRLF-source line: the carriage return precedes the sentinel and is kept. block = _block((b"R1 1 2 1k\r" + b" $jbs$").ljust(62, b"A"), ord("A"), 0) assert _split_block(block) == (b"R1 1 2 1k\r", True) def test_truncated_sentinel_no_cr(self): # Sentinel straddles the boundary: only `` $jb`` survives, byte 62 == 's'. block = _block(b"A" * 58 + b" $jb", ord("s"), 0) assert _split_block(block) == (b"A" * 58, True) def test_cr_in_payload_with_truncated_sentinel(self): # CRLF line whose trailing sentinel is truncated: cut at the carriage return. block = _block(b"A" * 57 + b"\r" + b" $jb", ord("s"), 0) assert _split_block(block) == (b"A" * 57 + b"\r", True) def test_cr_at_byte_62(self): # Content fills the payload exactly; terminator lands in byte 62. block = _block(b"A" * 62, ord("\r"), 0) assert _split_block(block) == (b"A" * 62 + b"\r", True) def test_cr_at_byte_63(self): # Content plus terminator fills all 64 bytes; terminator in byte 63. block = _block(b"A" * 62, ord("1"), ord("\r")) assert _split_block(block) == (b"A" * 62 + b"1\r", True) def test_overflow_block(self): # 62 content bytes followed by the ``$+`` overflow marker — line continues. block = _block(b"A" * 62, ord("$"), ord("+")) assert _split_block(block) == (b"A" * 62, False) def test_overflow_marker_beats_truncated_sentinel(self): # A payload tail that looks like a sentinel prefix is NOT padding when the # ``$+`` overflow marker is present; the block still continues the line. block = _block(b"A" * 58 + b" $jb", ord("$"), ord("+")) assert _split_block(block) == (b"A" * 58 + b" $jb", False) class TestLineContinuation: """End-to-end multi-block continuation via a DES (mode 0) round trip.""" @staticmethod def _encrypt_fixture(lines: list[bytes]) -> str: """Encode *lines* the way PSpiceEnc would and wrap them in markers. Each source line is terminated with a carriage return and packed into 64-byte blocks (overflow blocks marked ``$+``, the final block padded with the `` $jbs$`` sentinel), then encrypted with the mode-0 DES key. """ from spice_crypt.pspice.des import PSpiceDES from spice_crypt.pspice.keys import derive_keys short_key, _ = derive_keys(0, "", None) cipher = PSpiceDES() cipher.set_key(short_key) def final_block(content: bytes) -> bytes: payload = (content + b" $jbs$").ljust(62, b"A")[:62] assert b" $jbs$" in payload # content short enough to hold the sentinel return payload + b"A\x00" blocks = [b"H" * 64] # header block (skipped during decryption) for line in lines: data = line + b"\r" while len(data) > 62: blocks.append(data[:62] + b"$+") # overflow marker data = data[62:] blocks.append(final_block(data)) hex_lines = [cipher.process_block(b, decrypt=False).hex() for b in blocks] return "$CDNENCSTART\r\n" + "\r\n".join(hex_lines) + "\r\n$CDNENCFINISH\r\n" def test_roundtrip_overflow_and_plus_lines(self): import io lines = [ b"X_LONG_INST n1 n2 n3 n4 n5 n6 n7 n8 SOME_LONG_MODEL_NAME_HERE_END", b"+ AREA=1 TEMP=27", # SPICE continuation line — must be preserved verbatim b"R1 1 2 1k", ] fixture = self._encrypt_fixture(lines) chunks = list(PSpiceFileParser(io.StringIO(fixture)).decrypt_stream()) out = b"".join(chunks).decode() assert out == "".join(line.decode() + "\r\n" for line in lines) assert "$jb" not in out # no padding-sentinel fragments leaked # --------------------------------------------------------------------------- # Key recovery (Mode 4 brute-force) # --------------------------------------------------------------------------- class TestKeyRecovery: """Test Mode 4 AES brute-force key recovery.""" def test_recover_user_key(self): from spice_crypt.pspice import recover_mode4_key result = recover_mode4_key(DATA_DIR / "mode4_userkey.lib") assert result is not None assert result.user_key_full == USER_KEY def test_recover_no_user_key_raises(self): from spice_crypt.pspice import recover_mode4_key # Mode 4 without user key uses default keys; recovery should # raise ValueError indicating no brute-force is needed. with pytest.raises(ValueError, match="default Mode 4 keys"): recover_mode4_key(DATA_DIR / "mode4.lib") jtsylve-spice-crypt-98ea63c/tests/test_qspice_decrypt.py000066400000000000000000000267241521042210700236740ustar00rootroot00000000000000# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. # # SPDX-License-Identifier: AGPL-3.0-or-later """Integration tests for QSPICE ``.prot`` decryption. The fixtures in ``tests/data/qspice`` are synthetic: each was produced by ``scripts/gen_qspice_testdata.py``, an independent encoder for the scheme documented in SPECIFICATIONS/qspice.md, wrapping the shared ``tests.conftest.PLAINTEXT_BODY`` plaintext. Using an independently written encoder means a regression in the library decoder cannot be masked by a matching bug in the fixture generator. """ from __future__ import annotations import io import zlib from pathlib import Path import pytest from spice_crypt import decrypt_stream from spice_crypt.qspice.cipher import ALPHABET, QSpiceCipher from spice_crypt.qspice.decrypt import QSpiceFileParser, _detect_qspice_format from tests.conftest import PLAINTEXT_BODY, extract_body DATA_DIR = Path(__file__).parent / "data" / "qspice" def _decrypt_file(path: Path) -> str: """Decrypt a QSPICE file and return the full decrypted text.""" content, _ = decrypt_stream(str(path)) return content # --------------------------------------------------------------------------- # Decryption of protected blocks # --------------------------------------------------------------------------- class TestQSpiceDecryption: """Verify decryption of QSPICE ``.prot`` protected files.""" def test_basic_block(self): result = _decrypt_file(DATA_DIR / "basic.lib") assert extract_body(result) == PLAINTEXT_BODY def test_block_with_comments(self): result = _decrypt_file(DATA_DIR / "comments.lib") assert extract_body(result) == PLAINTEXT_BODY def test_markers_removed(self): result = _decrypt_file(DATA_DIR / "basic.lib") assert ".prot" not in result.lower() assert ".unprot" not in result.lower() def test_passthrough_preserved(self): result = _decrypt_file(DATA_DIR / "comments.lib") assert "* synthetic QSPICE test model" in result def test_block_count(self): with open(DATA_DIR / "basic.lib") as f: parser = QSpiceFileParser(f) list(parser.decrypt_stream()) assert parser.block_count == 1 def test_multiple_blocks(self): # A file with two protected blocks: both bodies are recovered and the # surrounding (passthrough) sub-circuit lines are preserved in order. result = _decrypt_file(DATA_DIR / "multi.lib") assert "R1 1 2 1k" in result assert "C1 3 4 1n" in result assert ".subckt FIRST 1 2" in result assert ".subckt SECOND 3 4" in result assert result.index("FIRST") < result.index("SECOND") def test_multiple_blocks_count(self): with open(DATA_DIR / "multi.lib") as f: parser = QSpiceFileParser(f) list(parser.decrypt_stream()) assert parser.block_count == 2 def test_output_to_file(self, tmp_path): # The write-to-disk path encodes the recovered CP1252 text as UTF-8. out = tmp_path / "out.lib" content, verification = decrypt_stream(str(DATA_DIR / "tokens.lib"), output_file=str(out)) assert content is None assert verification == (1, 0) written = out.read_text(encoding="utf-8") assert "gm=650u" in written assert "¥" in written def test_crlf_line_endings(self, tmp_path): # Vendor files originate on Windows; CRLF terminators must decrypt too. crlf = tmp_path / "crlf.lib" crlf.write_bytes((DATA_DIR / "basic.lib").read_text().replace("\n", "\r\n").encode("utf-8")) result, _ = decrypt_stream(str(crlf)) assert extract_body(result) == PLAINTEXT_BODY # --------------------------------------------------------------------------- # Keyword tokenization (Windows-1252) # --------------------------------------------------------------------------- class TestQSpiceTokenization: """Verify that high-bit QSPICE keyword tokens are expanded to text.""" def test_device_prefixes_and_operators_decoded(self): # The Ã/Ø device prefixes, the ¥ reserved-pin marker, the « » bus-group # delimiters, and the ´ separator decode to their QSPICE characters. result = _decrypt_file(DATA_DIR / "tokens.lib") assert "ã1 vdd vss out in- in+ ¥ ¥ ¥ ¥ ¥ ¥ ¥ ¥ ¥ ¥ multgmamp" in result assert "ø´x1 «in´d out´d» «com» mymodule cout=100p" in result def test_micro_sign_normalized_to_ascii(self): # The micro sign is the one token with a standard-SPICE equivalent and # is rewritten to ASCII "u" so values parse in other tools. result = _decrypt_file(DATA_DIR / "tokens.lib") assert "gm=650u" in result assert "c1 out com 1u" in result assert "µ" not in result def test_cp1252_passthrough_preserved(self): # High-bit Windows-1252 characters in plaintext (passthrough) lines are # decoded as CP1252, not corrupted into the U+FFFD replacement char. result = _decrypt_file(DATA_DIR / "passthrough.lib") assert "© 2026 Example Corp." in result assert "�" not in result # --------------------------------------------------------------------------- # Format auto-detection # --------------------------------------------------------------------------- class TestAutoDetection: """Verify that decrypt_stream auto-detects QSPICE format.""" @pytest.mark.parametrize("filename", ["basic.lib", "comments.lib"]) def test_auto_detect(self, filename): result = _decrypt_file(DATA_DIR / filename) assert "R1 1 2 1k" in result def test_detect_true(self): with open(DATA_DIR / "basic.lib") as f: assert _detect_qspice_format(f) is True # Position must be restored so decryption can re-read the stream. assert f.tell() == 0 def test_detect_false_on_plaintext(self): assert _detect_qspice_format(io.StringIO(".subckt X 1 2\nR1 1 2 1k\n.ends X\n")) is False # --------------------------------------------------------------------------- # QSpiceCipher unit behavior # --------------------------------------------------------------------------- class TestQSpiceCipher: """Test the QSpiceCipher primitives directly.""" def test_decode_roundtrip_nibbles(self): # ".prot" payload decodes two glyphs per byte; verify a known mapping. # ALPHABET[0]=',' -> nibble 0, ALPHABET[17]='R' -> nibble 1. assert QSpiceCipher.decode(",,") == b"\x00" assert QSpiceCipher.decode("BB") == b"\xff" def test_xor_decrypt_is_involutive(self): seed = 0xDEADBEEF payload = bytes(range(256)) once = QSpiceCipher.xor_decrypt(payload, seed) twice = QSpiceCipher.xor_decrypt(once, seed) assert twice == payload def test_mt_seed_zero_substitutes_one(self): # QSPICE seeds the MT with `state[0] = (seed or 1)` (the binary does # `if (!seed) seed = 1`), so a zero seed yields the same MT keystream as # a seed of 1. The table-walk keystream still uses the raw seed. from spice_crypt.qspice.cipher import _MersenneTwister zero = _MersenneTwister(0) one = _MersenneTwister(1) assert [zero.next_byte() for _ in range(64)] == [one.next_byte() for _ in range(64)] def test_decrypt_block_rejects_short(self): with pytest.raises(ValueError, match="too short"): QSpiceCipher.decrypt_block(",,") def test_decrypt_block_rejects_bad_zlib(self): # 4-byte seed + garbage that is not a zlib stream. seed = 0x11111111 garbage = QSpiceCipher.xor_decrypt(b"not zlib data here", seed) data = seed.to_bytes(4, "little") + garbage glyphs = "".join(ALPHABET[b >> 4] + ALPHABET[b & 0xF] for b in data) with pytest.raises(ValueError, match="zlib"): QSpiceCipher.decrypt_block(glyphs) def test_detokenize_decodes_cp1252_and_normalizes_micro(self): # High-bit bytes are Windows-1252; the micro sign (0xB5) becomes "u". assert ( QSpiceCipher.detokenize(b"\xe31 a \xa5 multgmamp gm=650\xb5") == "ã1 a ¥ multgmamp gm=650u" ) assert QSpiceCipher.detokenize(b"\xf8\xb4x1 \xabin\xbb \xabcom\xbb") == "ø´x1 «in» «com»" def test_detokenize_passes_ascii_through(self): assert QSpiceCipher.detokenize(b"R1 1 2 1k\n") == "R1 1 2 1k\n" def test_known_seed_recovered(self): # The basic fixture was generated with seed 0x1234ABCD; confirm the # decoder reads it back from the header bytes. text = (DATA_DIR / "basic.lib").read_text() block = "".join(line for line in text.splitlines() if line and not line.startswith(".")) data = QSpiceCipher.decode(block) assert int.from_bytes(data[:4], "little") == 0x1234ABCD assert zlib.decompress(QSpiceCipher.xor_decrypt(data[4:], 0x1234ABCD)) == b"R1 1 2 1k\n" # --------------------------------------------------------------------------- # Robustness: malformed / truncated blocks degrade gracefully # --------------------------------------------------------------------------- class TestQSpiceRobustness: """A bad block must warn and pass through, not abort the whole file.""" @staticmethod def _run(text: str) -> tuple[str, int]: parser = QSpiceFileParser(io.StringIO(text)) chunks = list(parser.decrypt_stream()) return b"".join(chunks).decode("utf-8"), parser.block_count def test_unterminated_block_decrypted_with_warning(self): # A block left open at EOF (no .unprot) is still decrypted best-effort, # but a warning flags the truncation. text = (DATA_DIR / "basic.lib").read_text() head = text[: text.lower().index(".unprot")] with pytest.warns(UserWarning, match="not terminated"): result, count = self._run(head) assert "R1 1 2 1k" in result assert count == 1 def test_malformed_block_passed_through_with_warning(self): # A payload that decodes but is not a valid zlib stream must not abort # the file: the block is emitted verbatim and surrounding lines survive. src = ".subckt X 1 2\n.prot\nvvvvvvvvvvvvvvvv\n.unprot\n.ends X\n" with pytest.warns(UserWarning, match="could not be decrypted"): result, count = self._run(src) assert count == 0 assert "vvvvvvvvvvvvvvvv" in result # original payload preserved assert ".prot" in result assert ".ends X" in result def test_empty_block_passed_through_with_warning(self): # An empty .prot/.unprot pair is too short to hold a seed; warn and pass # the block through rather than raising out of the stream. src = ".subckt X 1 2\n.prot\n.unprot\n.ends X\n" with pytest.warns(UserWarning, match="could not be decrypted"): result, count = self._run(src) assert count == 0 assert ".subckt X 1 2" in result assert ".ends X" in result def test_one_bad_block_does_not_lose_good_block(self): # A malformed block followed by a valid one: the good block is still # recovered and counted. good = (DATA_DIR / "basic.lib").read_text() bad = ".subckt BAD 9 9\n.prot\nvvvvvvvvvvvvvvvv\n.unprot\n.ends BAD\n" with pytest.warns(UserWarning, match="could not be decrypted"): result, count = self._run(bad + good) assert "R1 1 2 1k" in result # the good block decrypted assert "vvvvvvvvvvvvvvvv" in result # the bad block passed through assert count == 1 jtsylve-spice-crypt-98ea63c/uv.lock000066400000000000000000001464051521042210700174060ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.10" [[package]] name = "cfgv" version = "3.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "distlib" version = "0.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "filelock" version = "3.25.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] [[package]] name = "identify" version = "2.6.17" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579, upload-time = "2026-03-01T20:04:12.702Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382, upload-time = "2026-03-01T20:04:11.439Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "nodeenv" version = "1.10.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] name = "packaging" version = "26.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] name = "platformdirs" version = "4.9.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pre-commit" version = "4.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, { name = "identify" }, { name = "nodeenv" }, { name = "pyyaml" }, { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pytest" version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] name = "python-discovery" version = "1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945, upload-time = "2026-03-10T15:08:15.038Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "ruff" version = "0.15.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, ] [[package]] name = "spice-crypt" version = "3.0.1" source = { editable = "." } [package.dev-dependencies] dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "ruff" }, ] [package.metadata] [package.metadata.requires-dev] dev = [ { name = "pre-commit", specifier = ">=4.5.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "ruff", specifier = ">=0.15.0" }, ] [[package]] name = "tomli" version = "2.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "virtualenv" version = "21.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, ]