diff --git a/.github/workflows/alert.yml b/.github/workflows/alert.yml index 21e8ae67b..bdce80b29 100644 --- a/.github/workflows/alert.yml +++ b/.github/workflows/alert.yml @@ -22,6 +22,7 @@ on: - fuzz - prove - Pullfrog + - python - quality lints - Queue - regenerate diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 000000000..a52e0648c --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,143 @@ +name: python + +# Builds pyrind's wheels for every platform rind supports that PyPI takes, +# and its sdist, and tests each wheel its runner can load. Nightly, on +# demand, and from the release workflow, which publishes what this uploads. +# Read-only: publishing is release.yml's. + +on: + schedule: + - cron: "30 18 * * *" + workflow_dispatch: + workflow_call: + inputs: + ref: + description: The commit or tag to build. + type: string + required: true + +permissions: + contents: read + +concurrency: + group: python-${{ inputs.ref || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + # The dev shell's, which the tests and `just check` use. + MATURIN_VERSION: "1.15.0" + +jobs: + wheel: + name: wheel (${{ matrix.target }}${{ matrix.manylinux && format(', {0}', matrix.manylinux) || '' }}) + strategy: + fail-fast: false + matrix: + include: + # Rust's own glibc floor, 2.17, is manylinux2014's. + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + manylinux: "2014" + test: true + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + manylinux: "2014" + test: true + - os: ubuntu-latest + target: armv7-unknown-linux-gnueabihf + manylinux: "2014" + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + manylinux: musllinux_1_2 + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + manylinux: musllinux_1_2 + - os: macos-15-intel + target: x86_64-apple-darwin + test: true + - os: macos-latest + target: aarch64-apple-darwin + test: true + - os: windows-latest + target: x86_64-pc-windows-msvc + test: true + - os: windows-11-arm + target: aarch64-pc-windows-msvc + test: true + # pyca/cryptography's last Windows on Arm wheel, 46.0.3, lacks + # ML-KEM, so the comparisons against it run on the other runners. + tests: "test_[rw]*.py" + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + # The oldest CPython the stable ABI wheel targets, which both builds + # it on Windows and tests it. + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.11" + + - name: Build + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + maturin-version: ${{ env.MATURIN_VERSION }} + target: ${{ matrix.target }} + manylinux: ${{ matrix.manylinux || 'auto' }} + args: --release --locked --out dist --manifest-path rind-python/Cargo.toml + + - if: matrix.test + uses: ./.github/actions/vectors + + - name: Test + if: matrix.test + shell: bash + env: + TESTS: ${{ matrix.tests || 'test*.py' }} + run: | + python -m pip install --only-binary :all: cryptography mypy + python -m pip install --no-index --find-links dist pyrind + python -m unittest discover --start-directory rind-python/tests --pattern "$TESTS" + python -m mypy.stubtest pyrind + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: pyrind-${{ matrix.target }} + path: dist/*.whl + if-no-files-found: error + + # PyPy and free-threaded CPython cannot load the stable ABI wheels, so they + # build this. + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.11" + + - name: Build + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + maturin-version: ${{ env.MATURIN_VERSION }} + command: sdist + args: --out dist --manifest-path rind-python/Cargo.toml + + - name: Build a wheel from it + run: | + python -m pip install --no-binary pyrind dist/pyrind-*.tar.gz + python -c "from pyrind import digest; print(digest.digest(digest.SHA256, b'').hex())" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: pyrind-sdist + path: dist/*.tar.gz + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c403099b..d40df6799 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,8 @@ jobs: # The environment, which only `main` can deploy to, holds the App key and # the crates.io token, so a workflow on another branch cannot read them. environment: publish + outputs: + pyrind: ${{ steps.pyrind.outputs.tag }} steps: # A GitHub App token, since the repository does not let GITHUB_TOKEN open @@ -61,3 +63,41 @@ jobs: BEFORE: ${{ github.event.before }} GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + # release-plz tags pyrind's version rather than publishing it. + - name: Find an unpublished pyrind release + id: pyrind + run: | + tag=$(nix develop --command sh/pyrind-unpublished.sh) + echo "tag=$tag" >>"$GITHUB_OUTPUT" + + pyrind-wheels: + name: pyrind wheels + needs: release + if: needs.release.outputs.pyrind != '' + permissions: + contents: read + uses: ./.github/workflows/python.yml + with: + ref: ${{ needs.release.outputs.pyrind }} + + # Publishes to PyPI by trusted publishing, which trusts this workflow in the + # `publish` environment, so no PyPI token is stored anywhere. + pyrind-publish: + name: pyrind to PyPI + needs: [release, pyrind-wheels] + runs-on: ubuntu-latest + environment: publish + permissions: + id-token: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: pyrind-* + path: dist + merge-multiple: true + + - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + with: + # A retry after a partial upload sends only what PyPI lacks. + skip-existing: true diff --git a/.gitignore b/.gitignore index 7dce8b126..1f5d26ad1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ proofs/Rind/Aarch64/DigestApi.lean proofs/Rind/Aarch64/Hkdf.lean proofs/Rind/Aarch64/Pbkdf2.lean patch/*/Cargo.lock +__pycache__/ diff --git a/.release-plz.toml b/.release-plz.toml index 74fdcf273..50bd50afe 100644 --- a/.release-plz.toml +++ b/.release-plz.toml @@ -4,3 +4,9 @@ git_release_enable = false # Publish only when the commit is a merged release PR, so an ordinary merge to # `main` never publishes. release_always = false + +# pyrind, which .github/workflows/release.yml publishes to PyPI from the tag +# this creates. It never goes to crates.io, so its versions come from tags. +[[package]] +name = "rind-python" +git_only = true diff --git a/.schema/latest.json b/.schema/latest.json new file mode 100644 index 000000000..effcf4ab3 --- /dev/null +++ b/.schema/latest.json @@ -0,0 +1,843 @@ +{ + "$id": "https://raw.githubusercontent.com/release-plz/release-plz/main/.schema/latest.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Config", + "description": "You can find the documentation of the configuration file\n[here](https://release-plz.dev/docs/config).", + "type": "object", + "properties": { + "changelog": { + "$ref": "#/$defs/ChangelogCfg", + "default": { + "body": null, + "commit_parsers": null, + "commit_preprocessors": null, + "header": null, + "link_parsers": null, + "postprocessors": null, + "protect_breaking_commits": null, + "sort_commits": null, + "tag_pattern": null, + "trim": null + } + }, + "package": { + "title": "Package", + "description": "Package-specific configuration. This overrides `workspace`.\nNot all settings of `workspace` can be overridden.", + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/PackageSpecificConfigWithName" + } + }, + "workspace": { + "title": "Workspace", + "description": "Global configuration. Applied to all packages by default.", + "$ref": "#/$defs/Workspace", + "default": { + "allow_dirty": null, + "changelog_config": null, + "changelog_path": null, + "changelog_update": null, + "custom_major_increment_regex": null, + "custom_minor_increment_regex": null, + "dependencies_update": null, + "features_always_increment_minor": null, + "git_only": null, + "git_release_body": null, + "git_release_draft": null, + "git_release_enable": null, + "git_release_generate_notes": null, + "git_release_latest": null, + "git_release_name": null, + "git_release_type": null, + "git_tag_enable": null, + "git_tag_name": null, + "max_analyze_commits": 1000, + "pr_body": null, + "pr_branch_prefix": null, + "pr_draft": false, + "pr_labels": [], + "pr_name": null, + "publish": null, + "publish_all_features": null, + "publish_allow_dirty": null, + "publish_features": null, + "publish_no_verify": null, + "publish_timeout": null, + "release": null, + "release_always": null, + "release_commits": null, + "repo_url": null, + "semver_check": null + } + } + }, + "additionalProperties": false, + "$defs": { + "ChangelogCfg": { + "type": "object", + "properties": { + "body": { + "description": "Template that represents a single release in the changelog.\nIt contains the commit messages.\nThis is a [tera](https://keats.github.io/tera/) template.", + "type": [ + "string", + "null" + ] + }, + "commit_parsers": { + "description": "Commits that don't match any of the commit parsers are skipped.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/CommitParser" + } + }, + "commit_preprocessors": { + "description": "An array of commit preprocessors for manipulating the commit messages before parsing/grouping them.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/TextProcessor" + } + }, + "header": { + "description": "Text at the beginning of the changelog.", + "type": [ + "string", + "null" + ] + }, + "link_parsers": { + "description": "An array of link parsers for extracting external references, and turning them into URLs, using regex.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/LinkParser" + } + }, + "postprocessors": { + "description": "An array of postprocessors for manipulating the rendered changelog.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/TextProcessor" + } + }, + "protect_breaking_commits": { + "description": "Whether to protect all breaking changes from being skipped by a commit parser.", + "type": [ + "boolean", + "null" + ] + }, + "sort_commits": { + "description": "How to sort the commits inside the various sections.", + "anyOf": [ + { + "$ref": "#/$defs/Sorting" + }, + { + "type": "null" + } + ] + }, + "tag_pattern": { + "description": "A regular expression for matching the git tags to add to the changelog.", + "type": [ + "string", + "null" + ] + }, + "trim": { + "description": "If set to `true`, leading and trailing whitespace are removed from [`Self::body`].", + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": false + }, + "CommitParser": { + "description": "Parser for grouping commits.", + "type": "object", + "properties": { + "body": { + "description": "Regex for matching the commit body.", + "type": [ + "string", + "null" + ] + }, + "continue": { + "description": "Whether to continue applying subsequent parsers after this one matches.\nDefaults to `false`.", + "type": [ + "boolean", + "null" + ] + }, + "default_scope": { + "description": "Default scope of the commit.", + "type": [ + "string", + "null" + ] + }, + "field": { + "description": "Field name of the commit to match the regex against.", + "type": [ + "string", + "null" + ] + }, + "footer": { + "description": "Regex for matching the commit footer.", + "type": [ + "string", + "null" + ] + }, + "group": { + "description": "Group of the commit.", + "type": [ + "string", + "null" + ] + }, + "message": { + "description": "Regex for matching the commit message.", + "type": [ + "string", + "null" + ] + }, + "pattern": { + "description": "Regex for matching the field value.", + "type": [ + "string", + "null" + ] + }, + "scope": { + "description": "Commit scope for overriding the default scope.", + "type": [ + "string", + "null" + ] + }, + "sha": { + "description": "SHA1 of the commit.", + "type": [ + "string", + "null" + ] + }, + "skip": { + "description": "Whether to skip this commit group.", + "type": [ + "boolean", + "null" + ] + } + } + }, + "LinkParser": { + "type": "object", + "properties": { + "href": { + "description": "The string used to generate the link URL.", + "type": "string" + }, + "pattern": { + "description": "Regex for finding links in the commit message.", + "type": "string" + }, + "text": { + "description": "The string used to generate the link text.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pattern", + "href" + ] + }, + "PackageSpecificConfigWithName": { + "description": "Config at the `[[package]]` level.", + "type": "object", + "properties": { + "changelog_include": { + "title": "Changelog Include", + "description": "List of package names.\nInclude the changelogs of these packages in the changelog of the current package.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "changelog_path": { + "title": "Changelog Path", + "description": "Normally the changelog is placed in the same directory of the Cargo.toml file.\nThe user can provide a custom path here.\n`changelog_path` is propagated to the commands:\n`update`, `release-pr` and `release`.", + "type": [ + "string", + "null" + ] + }, + "changelog_update": { + "title": "Changelog Update", + "description": "Whether to create/update changelog or not.\nIf unspecified, the changelog is updated.", + "type": [ + "boolean", + "null" + ] + }, + "custom_major_increment_regex": { + "title": "Custom Major Increment Regex", + "description": "Custom regex to match commit types that should trigger a major version increment.\nUseful when using non-conventional commit prefixes.", + "type": [ + "string", + "null" + ] + }, + "custom_minor_increment_regex": { + "title": "Custom Minor Increment Regex", + "description": "Custom regex to match commit types that should trigger a minor version increment.\nUseful when using non-conventional commit prefixes.", + "type": [ + "string", + "null" + ] + }, + "features_always_increment_minor": { + "title": "Features Always Increment Minor Version", + "description": "- If `true`, feature commits will always bump the minor version, even in 0.x releases.\n- If `false` (default), feature commits will only bump the minor version starting with 1.x releases.", + "type": [ + "boolean", + "null" + ] + }, + "git_only": { + "title": "Git Only", + "description": "Use git tags for release information.\nIf true, release-plz will use git tags to determine what the latest version of the package\nis (i.e newest version is v0.1.3 and is associated with commit ac83762).\nIf false (default), release-plz will use the cargo registry (e.g. crates.io) to get the latest version.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_body": { + "title": "Git Release Body", + "description": "Tera template of the git release body created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "git_release_draft": { + "title": "Git Release Draft", + "description": "If true, will not auto-publish the release.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_enable": { + "title": "Git Release Enable", + "description": "Publish the GitHub/Gitea/GitLab release for the created git tag.\nEnabled by default.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_generate_notes": { + "title": "Git Release Generate Notes", + "description": "Generate release notes on GitHub. Defaults to `false`.\nGenerated notes are appended to the configured release body.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_latest": { + "title": "Git Release Latest", + "description": "If true, will set the git release as latest.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_name": { + "title": "Git Release Name", + "description": "Tera template of the git release name created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "git_release_type": { + "title": "Git Release Type", + "description": "Whether to mark the created release as not ready for production.", + "anyOf": [ + { + "$ref": "#/$defs/ReleaseType" + }, + { + "type": "null" + } + ] + }, + "git_tag_enable": { + "title": "Git Tag Enable", + "description": "Publish the git tag for the new package version.\nEnabled by default.", + "type": [ + "boolean", + "null" + ] + }, + "git_tag_name": { + "title": "Git Tag Name", + "description": "Tera template of the git tag name created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "publish": { + "title": "Publish", + "description": "If `false`, don't run `cargo publish`.", + "type": [ + "boolean", + "null" + ] + }, + "publish_all_features": { + "title": "Publish All Features", + "description": "If `true`, add the `--all-features` flag to the `cargo publish` command.", + "type": [ + "boolean", + "null" + ] + }, + "publish_allow_dirty": { + "title": "Publish Allow Dirty", + "description": "If `true`, add the `--allow-dirty` flag to the `cargo publish` command.", + "type": [ + "boolean", + "null" + ] + }, + "publish_features": { + "title": "Publish Features", + "description": "If `[\"a\", \"b\", \"c\"]`, add the `--features=a,b,c` flag to the `cargo publish` command.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "publish_no_verify": { + "title": "Publish No Verify", + "description": "If `true`, add the `--no-verify` flag to the `cargo publish` command.", + "type": [ + "boolean", + "null" + ] + }, + "release": { + "title": "Release", + "description": "Used to toggle off the update/release process for a workspace or package.", + "type": [ + "boolean", + "null" + ] + }, + "semver_check": { + "title": "Semver Check", + "description": "Controls when to run cargo-semver-checks.\nIf unspecified, run cargo-semver-checks if the package is a library.", + "type": [ + "boolean", + "null" + ] + }, + "version_group": { + "title": "Version group", + "description": "The name of a group of packages that needs to have the same version.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ] + }, + "ReleaseType": { + "oneOf": [ + { + "title": "Prod", + "description": "Will mark the release as ready for production.", + "type": "string", + "const": "prod" + }, + { + "title": "Pre", + "description": "Will mark the release as not ready for production.\nI.e. as pre-release.", + "type": "string", + "const": "pre" + }, + { + "title": "Auto", + "description": "Will mark the release as not ready for production\nin case there is a semver pre-release in the tag e.g. v1.0.0-rc1.\nOtherwise, will mark the release as ready for production.", + "type": "string", + "const": "auto" + } + ] + }, + "Sorting": { + "type": "string", + "enum": [ + "oldest", + "newest" + ] + }, + "TextProcessor": { + "description": "Used for modifying commit messages.", + "type": "object", + "properties": { + "pattern": { + "description": "Regex for matching a text to replace.", + "type": "string" + }, + "replace": { + "description": "Replacement text.", + "type": [ + "string", + "null" + ] + }, + "replace_command": { + "description": "Command that will be run for replacing the commit message.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pattern" + ] + }, + "Workspace": { + "description": "Config at the `[workspace]` level.", + "type": "object", + "properties": { + "allow_dirty": { + "title": "Allow Dirty", + "description": "- If `true`, allow dirty working directories to be updated. The uncommitted changes will be part of the update.\n- If `false` or [`Option::None`], the command will fail if the working directory is dirty.", + "type": [ + "boolean", + "null" + ] + }, + "changelog_config": { + "title": "Changelog Config", + "description": "Path to the git cliff configuration file. Defaults to the `keep a changelog` configuration.", + "type": [ + "string", + "null" + ] + }, + "changelog_path": { + "title": "Changelog Path", + "description": "Normally the changelog is placed in the same directory of the Cargo.toml file.\nThe user can provide a custom path here.\n`changelog_path` is propagated to the commands:\n`update`, `release-pr` and `release`.", + "type": [ + "string", + "null" + ] + }, + "changelog_update": { + "title": "Changelog Update", + "description": "Whether to create/update changelog or not.\nIf unspecified, the changelog is updated.", + "type": [ + "boolean", + "null" + ] + }, + "custom_major_increment_regex": { + "title": "Custom Major Increment Regex", + "description": "Custom regex to match commit types that should trigger a major version increment.\nUseful when using non-conventional commit prefixes.", + "type": [ + "string", + "null" + ] + }, + "custom_minor_increment_regex": { + "title": "Custom Minor Increment Regex", + "description": "Custom regex to match commit types that should trigger a minor version increment.\nUseful when using non-conventional commit prefixes.", + "type": [ + "string", + "null" + ] + }, + "dependencies_update": { + "title": "Dependencies Update", + "description": "- If `true`, update all the dependencies in the Cargo.lock file by running `cargo update`.\n- If `false` or [`Option::None`], only update the workspace packages by running `cargo update --workspace`.", + "type": [ + "boolean", + "null" + ] + }, + "features_always_increment_minor": { + "title": "Features Always Increment Minor Version", + "description": "- If `true`, feature commits will always bump the minor version, even in 0.x releases.\n- If `false` (default), feature commits will only bump the minor version starting with 1.x releases.", + "type": [ + "boolean", + "null" + ] + }, + "git_only": { + "title": "Git Only", + "description": "Use git tags for release information.\nIf true, release-plz will use git tags to determine what the latest version of the package\nis (i.e newest version is v0.1.3 and is associated with commit ac83762).\nIf false (default), release-plz will use the cargo registry (e.g. crates.io) to get the latest version.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_body": { + "title": "Git Release Body", + "description": "Tera template of the git release body created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "git_release_draft": { + "title": "Git Release Draft", + "description": "If true, will not auto-publish the release.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_enable": { + "title": "Git Release Enable", + "description": "Publish the GitHub/Gitea/GitLab release for the created git tag.\nEnabled by default.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_generate_notes": { + "title": "Git Release Generate Notes", + "description": "Generate release notes on GitHub. Defaults to `false`.\nGenerated notes are appended to the configured release body.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_latest": { + "title": "Git Release Latest", + "description": "If true, will set the git release as latest.", + "type": [ + "boolean", + "null" + ] + }, + "git_release_name": { + "title": "Git Release Name", + "description": "Tera template of the git release name created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "git_release_type": { + "title": "Git Release Type", + "description": "Whether to mark the created release as not ready for production.", + "anyOf": [ + { + "$ref": "#/$defs/ReleaseType" + }, + { + "type": "null" + } + ] + }, + "git_tag_enable": { + "title": "Git Tag Enable", + "description": "Publish the git tag for the new package version.\nEnabled by default.", + "type": [ + "boolean", + "null" + ] + }, + "git_tag_name": { + "title": "Git Tag Name", + "description": "Tera template of the git tag name created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "max_analyze_commits": { + "description": "Maximum number of commits to analyze when the package hasn't been published yet.\nDefault: 1000.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "default": 1000, + "minimum": 0 + }, + "pr_body": { + "title": "PR Body", + "description": "Tera template of the pull request's body created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "pr_branch_prefix": { + "title": "PR Branch Prefix", + "description": "Prefix for the PR Branch", + "type": [ + "string", + "null" + ] + }, + "pr_draft": { + "title": "PR Draft", + "description": "If `true`, the created release PR will be marked as a draft.", + "type": "boolean", + "default": false + }, + "pr_labels": { + "title": "PR Labels", + "description": "Labels to add to the release PR.", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "pr_name": { + "title": "PR Name", + "description": "Tera template of the pull request's name created by release-plz.", + "type": [ + "string", + "null" + ] + }, + "publish": { + "title": "Publish", + "description": "If `false`, don't run `cargo publish`.", + "type": [ + "boolean", + "null" + ] + }, + "publish_all_features": { + "title": "Publish All Features", + "description": "If `true`, add the `--all-features` flag to the `cargo publish` command.", + "type": [ + "boolean", + "null" + ] + }, + "publish_allow_dirty": { + "title": "Publish Allow Dirty", + "description": "If `true`, add the `--allow-dirty` flag to the `cargo publish` command.", + "type": [ + "boolean", + "null" + ] + }, + "publish_features": { + "title": "Publish Features", + "description": "If `[\"a\", \"b\", \"c\"]`, add the `--features=a,b,c` flag to the `cargo publish` command.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "publish_no_verify": { + "title": "Publish No Verify", + "description": "If `true`, add the `--no-verify` flag to the `cargo publish` command.", + "type": [ + "boolean", + "null" + ] + }, + "publish_timeout": { + "title": "Publish Timeout", + "description": "Timeout for the publishing process", + "type": [ + "string", + "null" + ] + }, + "release": { + "title": "Release", + "description": "Used to toggle off the update/release process for a workspace or package.", + "type": [ + "boolean", + "null" + ] + }, + "release_always": { + "title": "Release always", + "description": "- If true, release-plz release will try to release your packages every time you run it\n (e.g. on every commit in the main branch). *(Default)*.\n- If false, `release-plz release` will try release your packages only when you merge the\n release pr.\n Use this if you want to commit your packages and publish them later.\n To determine if a pr is a release-pr, release-plz will check if the branch of the PR starts with\n `release-plz-`. So if you want to create a PR that should trigger a release\n (e.g. when you fix the CI), use this branch name format (e.g. `release-plz-fix-ci`).", + "type": [ + "boolean", + "null" + ] + }, + "release_commits": { + "title": "Release Commits", + "description": "Prepare release only if at least one commit respects this regex.", + "type": [ + "string", + "null" + ] + }, + "repo_url": { + "title": "Repo URL", + "description": "GitHub/Gitea/GitLab repository url where your project is hosted.\nIt is used to generate the changelog release link.\nIt defaults to the url of the default remote.", + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "semver_check": { + "title": "Semver Check", + "description": "Controls when to run cargo-semver-checks.\nIf unspecified, run cargo-semver-checks if the package is a library.", + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 327320d87..ec1ea68e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1480,6 +1480,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -2862,6 +2868,63 @@ dependencies = [ "unarray", ] +[[package]] +name = "pyo3" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229ca13b8a15bb0d1487b81ab975bfcd97513416499a8312011b6036e42e0de4" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70130e2b6dd1ee589a8788026a429561ca5c40fa7a2fa7914c2282eddae24700" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7ff73c555c286d212d4ce7e5fbda845a973c4f61bd5f160f5f2c3c2a04d1c4" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c46d2e5862c7bb8207e3b639f713dda3c499933444784154c11d013c5a691b60" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b89f066f2ae72a2aef52d1537ca2fcd104440ebfadee06e97aa90738a25b9b" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -3167,6 +3230,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "rind-python" +version = "0.1.0" +dependencies = [ + "pyo3", + "rind", +] + [[package]] name = "rind-rcgen" version = "0.14.0" @@ -3760,6 +3831,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" diff --git a/Cargo.toml b/Cargo.toml index 887e7827f..4a2d163b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "rind-ring", "rind-rustls", "rind-jsonwebtoken", + "rind-python", "fuzz" ] diff --git a/README.md b/README.md index df3d7c1bd..4667c094f 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Each crate's README shows its setup. | [rind-rcgen][] | certificates through [rcgen](https://github.com/rustls/rcgen) | | [rind-dart][] | Dart and Flutter, through FFI: the `rind` package, unpublished until its first pub.dev release | | [rind-instant-acme][] | ACME through [instant-acme](https://github.com/djc/instant-acme), unpublished until instant-acme releases the interface it targets | +| [rind-python][] | Python, as the `pyrind` package on PyPI, with rind's own API | ### Platforms @@ -199,6 +200,7 @@ from other projects keeps its source's license, which [LICENSE] lists. [rind-rcgen]: https://github.com/moq-dev/rind/tree/main/rind-rcgen [rind-dart]: https://github.com/moq-dev/rind/tree/main/rind-dart [rind-instant-acme]: https://github.com/moq-dev/rind/tree/main/rind-instant-acme +[rind-python]: https://github.com/moq-dev/rind/tree/main/rind-python [quests]: https://github.com/moq-dev/rind/blob/main/quest/README.md [contributing]: https://github.com/moq-dev/rind/blob/main/CONTRIBUTING.md [migrating]: https://github.com/moq-dev/rind/blob/main/MIGRATING.md diff --git a/admin/scan-license b/admin/scan-license index 89e1cb6bf..fcdc8437a 100755 --- a/admin/scan-license +++ b/admin/scan-license @@ -8,4 +8,10 @@ for f in $(find rind/src/ -name *.rs -exec grep -L "SPDX-License-Identifier:" {} bad=1 done +# pyrind's wheels ship rind's code, so they carry rind's whole license. +if ! cmp -s rind/LICENSE rind-python/LICENSE; then + echo "rind-python/LICENSE must be a copy of rind/LICENSE" + bad=1 +fi + exit $bad diff --git a/flake.nix b/flake.nix index 072505464..771e70bc7 100644 --- a/flake.nix +++ b/flake.nix @@ -256,8 +256,15 @@ # sh/alert.sh reads the workflows. yq-go nixfmt - # admin/ scripts. - python3 + # admin/ scripts, and pyrind's tests and benchmarks: they + # check it against pyca/cryptography, and its type stubs + # with mypy's stubtest. + (python3.withPackages (ps: [ + ps.cryptography + ps.mypy + ])) + # Builds pyrind (rind-python). + maturin # admin/bench-report-test runs the script that holds a # leased host. flock diff --git a/quest/m2/adoption/README.md b/quest/m2/adoption/README.md index 95da693fd..bd8a579eb 100644 --- a/quest/m2/adoption/README.md +++ b/quest/m2/adoption/README.md @@ -64,6 +64,7 @@ don't ask again (✅ is the chosen option): - [quinn's rustls support works with any provider](/quest/m2/adoption/quinn.md) - quinn-proto stops forcing ring or aws-lc-rs - [Bun's CryptoHasher takes SHA-3 from rind](/quest/m2/adoption/bun-sha3.md) - SHA-3 in Bun on rind, after the m1 Bun PR - [pyrind on PyPI](/quest/m2/adoption/pyrind.md) - rind for Python with its own API +- [pyrind's musl and armv7 wheels are tested](/quest/m2/adoption/pyrind-wheel-tests.md) - CI loads every wheel it publishes - [Lemmy's federation signs and verifies with rind](/quest/m2/adoption/lemmy.md) - activitypub-federation-rust's HTTP signatures off the `rsa` crate - [rind-snow](/quest/m2/adoption/rind-snow.md) - snow's `CryptoResolver` on rind, for rathole and other Noise users - [tailscale-rs's tunnel runs on rind](/quest/m2/adoption/tailscale-rs.md) - WireGuard's per-packet ChaCha20-Poly1305 on rind diff --git a/quest/m2/adoption/pyrind-wheel-tests.md b/quest/m2/adoption/pyrind-wheel-tests.md new file mode 100644 index 000000000..726658d9d --- /dev/null +++ b/quest/m2/adoption/pyrind-wheel-tests.md @@ -0,0 +1,24 @@ +# [S] pyrind's musl and armv7 wheels are tested + +## Goal + +`python.yml` loads and tests every wheel it publishes. Today the musl wheels +(x86_64 and aarch64) and the armv7 glibc wheel are built and uploaded but +never imported, so a wheel that fails to load or computes wrongly on those +targets reaches PyPI unnoticed. + +## Plan + +Planned on 2026-11-07, when the maintainer settled +[moq-dev/rind#459](https://github.com/moq-dev/rind/pull/459)'s decisions. + +- Run the wheel job's test step (the unit tests, Wycheproof, and mypy + stubtest) for these three targets, as it runs for the others. +- musl: a musl CPython 3.11 container on the runner that built the wheel, + which is already the wheel's architecture. +- armv7: an armhf CPython 3.11 container under qemu-user. Check that the run + lands on a CPU rind supports and say which of rind's armv7 paths it takes. +- Where pyca/cryptography ships no wheel for a target, skip the comparisons + against it, as the Windows on Arm entry does, rather than building pyca. +- The workflow runs nightly and from the release, never in the merge gate, + so the emulated job's time costs no PR. diff --git a/quest/m2/adoption/pyrind.md b/quest/m2/adoption/pyrind.md index b98e42cf0..86ccbf026 100644 --- a/quest/m2/adoption/pyrind.md +++ b/quest/m2/adoption/pyrind.md @@ -1,33 +1,28 @@ -# [L] pyrind on PyPI +# [XS] pyrind on PyPI ## Goal `pip install pyrind` installs rind for Python, with rind's own API: AEADs, hashes, HMAC, HKDF, PBKDF2, X25519, Ed25519, ECDSA and ECDH, RSA, and ML-KEM, from wheels for every platform rind supports. A small-message AEAD or HMAC -call costs a fraction of pyca/cryptography's (about 4 µs for HMAC-SHA256 of -21 B on an i7-8700, almost all of it OpenSSL 3's per-call overhead). +call costs a fraction of pyca/cryptography's. ## Plan -Decided with the maintainer on 2026-11-04, in a planning interview: +[rind-python](/rind-python/README.md) builds the package, with its numbers +against pyca/cryptography in its README. release-plz versions it from git +tags (`git_only`), so the next release PR carries rind-python 0.1.0; when +that PR lands, release-plz tags `rind-python-v0.1.0`, and `release.yml` +builds the wheels with `python.yml` and publishes them by PyPI trusted +publishing from the `publish` environment. A failed publish retries on the +next push to `main` that runs the release job. -- Name: `pyrind` ✅ (PyPI's `rind` is an unrelated active project) / - `rind-crypto`. -- Two packages, as rind and rind-ring are: this one with rind's own - misuse-resistant API, and [a pyca-compatible one](/quest/m2/adoption/pyrind-cryptography.md) - on top ✅ / one package with both APIs. -- It lives in this workspace as a `rind-python` crate (pyo3, maturin), - released by this repository's CI ✅ / a separate repository. +## Waiting -Guidance: - -- Use the stable ABI (abi3) so one wheel per platform covers every CPython - from the oldest still supported; PyPy cannot load abi3 wheels, so decide - when starting whether it gets its own wheels or the sdist; - publish with PyPI trusted publishing, keeping release credentials as the - [m0 credentials quest](/quest/m0/confine-release-credentials.md) decides. -- Measure per-call cost from Python against `cryptography`, `hashlib`, and - `hmac`, and publish the numbers with the package; the pitch is these - numbers. -- Platforms rind refuses get no wheel; there is no pure-Python fallback. +- Maintainer: on PyPI, add a pending trusted publisher for the project + `pyrind` (owner `moq-dev`, repository `rind`, workflow `release.yml`, + environment `publish`) before merging the release PR that carries + rind-python 0.1.0, then merge it. An agent cannot: PyPI has no API for + adding a trusted publisher, only its web form behind the owner's login. + Check: `pip install pyrind==0.1.0` installs, and + `python -c "import pyrind"` runs. diff --git a/rind-python/Cargo.toml b/rind-python/Cargo.toml new file mode 100644 index 000000000..46f4a3bd2 --- /dev/null +++ b/rind-python/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "rind-python" +version = "0.1.0" +edition = "2024" +repository = "https://github.com/moq-dev/rind" +license = "(Apache-2.0 OR ISC OR MIT-0) AND (Apache-2.0 OR ISC OR MIT) AND Apache-2.0 AND ISC AND BSD-3-Clause" +description = "pyrind, rind's API for Python" +rust-version.workspace = true +readme = "README.md" +# Released to PyPI as `pyrind` by .github/workflows/release.yml, never to +# crates.io. +publish = false + +[lib] +name = "_rind" +crate-type = ["cdylib"] +# The tests are Python's, under tests/, run against the built extension +# (sh/python.sh). A Rust test binary would link libpython, which the hosts +# that run `cargo test` need not have. +test = false +doctest = false + +[dependencies] +rind = { version = "0.2.1", path = "../rind" } +# The stable ABI from 3.11, the oldest CPython still supported and the first +# whose stable ABI has the buffer protocol, so one wheel per platform covers +# every CPython since. +pyo3 = { version = "0.30", features = ["abi3-py311"] } diff --git a/rind-python/LICENSE b/rind-python/LICENSE new file mode 100644 index 000000000..8c42cfdd1 --- /dev/null +++ b/rind-python/LICENSE @@ -0,0 +1,523 @@ +rind, a fork of Graviola, is distributed under the following three licenses: + +- Apache License version 2.0. +- ISC license. +- MIT-0 license. + +You may use this software under the terms of any of these licenses, +at your option, except for the third-party code listed below, which +keeps the license given for it. + +Contributions are made under these licenses. + +-- + +Third-party code + +rind copies code from the projects below. Each copied file keeps its +source's copyright lines and names its license in an +SPDX-License-Identifier comment. The license texts follow this list. +ALGORITHMS.md's Sources table names the same projects: a change that copies +code from a new project updates both. + +Graviola + + What: rind began as a copy of Graviola, and rind-rustls as a copy of + its rustls-graviola. Files Graviola wrote carry a "Written for + Graviola by ..." header. Its aarch64 ChaCha20 and its Poly1305 began + as cifra , by the same author under + CC0-1.0. + Copyright 2024 Joe Birr-Pixton + License: Apache-2.0 OR ISC OR MIT-0, the same choice as rind's own code. + Graviola has no NOTICE file. + +s2n-bignum + + What: the files under rind/src/low/x86_64 and rind/src/low/aarch64 + that begin "generated source. do not edit.", which admin/parse-asm + transliterates from s2n-bignum's assembly. + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + License: Apache-2.0 OR ISC OR MIT-0, except for: + + - The ML-KEM routines (mlkem_*.rs) and the aarch64 Keccak-f routines + (sha3_keccak_f1600.rs, sha3_keccak_f1600_alt.rs, + sha3_keccak2_f1600.rs), which s2n-bignum takes from mlkem-native + . + License: Apache-2.0 OR ISC OR MIT. Further copyright lines: + Copyright (c) 2024 The mlkem-native project authors + Copyright (c) 2021-2022 Arm Limited + Copyright (c) 2022 Arm Limited + Copyright (c) 2022 Hanno Becker + Copyright (c) 2022 Matthias Kannwischer + Copyright (c) 2023 Amin Abdulrahman, Matthias Kannwischer + + - The x86_64 Keccak-f routine (x86_64/sha3_keccak_f1600.rs), which + s2n-bignum takes from OpenSSL , + written by Andy Polyakov. + License: Apache-2.0. Further copyright line: + Copyright (c) 2017-2024 The OpenSSL Project Authors + + s2n-bignum's NOTICE file reads: + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +libcrux + + What: rind/src/low/arm/aes.rs, the bitsliced AES block cipher and key + expansion from libcrux-aes 0.0.10. + Copyright 2023 Cryspen Sarl + License: Apache-2.0. libcrux has no NOTICE file. + +HACL* , through libcrux + + What: rind/src/low/arm/chacha20.rs, the ChaCha20 block function that + libcrux-chacha20poly1305 0.0.10 compiles from HACL*'s Hacl_Chacha20. + Copyright (c) 2016-2022 INRIA, CMU and Microsoft Corporation + Copyright (c) 2022-2023 HACL* Contributors + Copyright 2023 Cryspen Sarl + License: Apache-2.0. Neither project has a NOTICE file. + +HACL* , Vale + + What: the files under rind/src/low/x86_64/hacl, Vale's x86_64 AES-GCM, + which admin/import-hacl takes from HACL*'s dist/vale at commit + f557259007db9933442dde10aa995c1b00bd3446. Its gcm*_opt routines port + OpenSSL's aesni-gcm-x86_64, which OpenSSL releases under Apache-2.0. + Copyright (c) 2016-2022 INRIA, CMU and Microsoft Corporation + Copyright (c) 2022-2023 HACL* Contributors + Copyright 2013-2016 The OpenSSL Project Authors + License: Apache-2.0. HACL* has no NOTICE file. + +aws-lc , from OpenSSL and BoringSSL + + What: the files under rind/src/low/{arm,aarch64,x86_64}/aws_lc but + the mlkem_* ones (see mlkem-native), which + admin/import-aws-lc takes from aws-lc's assembly at commit + 91c93a8eb860e51482a871a8772cb9b1b310baea, + rind/src/low/arm/poly1305_arm.rs, + rind/src/low/x86_64/ghash_ssse3_x86_64.rs, + rind/src/low/x86_64/rsaz.rs, rind/src/low/x86_64/aes_nohw.rs, and + rind/src/low/generic/ghash_nohw.rs: + + - For 32-bit ARM, aesv8-armx.s (AES), ghashv8-armx.s (GHASH), + sha1-armv4-large.s (SHA1), sha256-armv4.s (SHA256), sha512-armv4.s + (SHA512), and chacha-armv4.s (ChaCha20); for aarch64 and x86_64, the + sha512_block_data_order_* routines and the K512 tables of + sha512-armv8 and sha512-x86_64 (SHA512); for x86_64, the + sha256_block_data_order_avx, _ssse3, and _nohw routines and the K256 + table of sha256-x86_64 (SHA256), the sha1_block_data_order_avx, + _ssse3, and _nohw routines and the K_XX_XX table of sha1-x86_64 + (SHA1), and the + ChaCha20_ctr32_nohw and _ssse3_4x routines and tables of chacha-x86_64 + (ChaCha20); the bn_mul_mont_nohw, bn_mul4x_mont, bn_mulx4x_mont, and + bn_sqr8x_mont routines of x86_64-mont and bn_sqr8x_internal and + bn_sqrx8x_internal of x86_64-mont5, and the rsaz_1024_* + routines and tables of rsaz-avx2 (RSA); the + aes_hw_ctr32_encrypt_blocks and aes_hw_cbc_encrypt routines of + aesni-x86_64 (AES-CTR and AES-CBC), the + vpaes_* routines and table of vpaes-x86_64 (AES), and the + ecp_nistz256_* routines and tables of p256-x86_64-asm (P-256); for + aarch64, the bn_mul_mont routine of armv8-mont (RSA), the vpaes_* + routines and table of vpaes-armv8 (AES), the gcm_*_neon routines and + table of ghash-neon-armv8 (GHASH), and the *_block_data_order_nohw + routines and table of sha1-armv8 (SHA1) and sha256-armv8 (SHA256). All + are pregenerated from Perl scripts written for OpenSSL by Andy Polyakov, + or for rsaz-avx2 and p256-x86_64-asm by Shay Gueron and Vlad Krasnov + of Intel, which Andy Polyakov extended for p256-x86_64-asm; + vpaes-armv8 and vpaes-x86_64 adapt the public domain vector permutation + AES by Mike Hamburg. ghash_ssse3_x86_64.rs ports aws-lc's + gcm_init_ssse3, from crypto/fipsmodule/modes/gcm.c, to Rust, and + rsaz.rs its RSAZ_1024_mod_exp_avx2, from crypto/fipsmodule/bn/rsaz_exp.c. + Copyright (c) 2008 The OpenSSL Project. All rights reserved. + Copyright 2005-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2006-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2007-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2009-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2010-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2011-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2013-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2014-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2014-2020 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2015-2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2016 The OpenSSL Project Authors. All Rights Reserved. + Copyright (c) 2012, Intel Corporation. All Rights Reserved. + Copyright (c) 2014, Intel Corporation. All Rights Reserved. + Copyright (c) 2015 CloudFlare, Inc. + License: Apache-2.0. + + - poly1305_arm_asm.s (Poly1305 with NEON), and poly1305_arm.rs, which + ports aws-lc's crypto/poly1305/poly1305_arm.c to Rust. BoringSSL took + both from the public domain neon2 version in SUPERCOP by D. J. + Bernstein and Peter Schwabe. + Copyright (c) 2014, Google Inc. + License: ISC. + + - For x86_64 and aarch64, the chacha20_poly1305_open and _seal + routines, the poly_hash_ad_internal helper, and the tables of + chacha20_poly1305_x86_64 and chacha20_poly1305_armv8 + (ChaCha20-Poly1305), which aws-lc takes from BoringSSL. Both are + pregenerated from Perl scripts written by Vlad Krasnov. + Copyright (c) 2015, CloudFlare Ltd. + Copyright (c) 2020, CloudFlare Ltd. + License: ISC. + + - For x86_64, the gcm_ghash_ssse3 routine and tables of + ghash-ssse3-x86_64 (GHASH), which aws-lc takes from BoringSSL; + aes_nohw.rs, which ports aws-lc's crypto/fipsmodule/aes/aes_nohw.c + (AES), in its SSE2 configuration, to Rust; and, for x86_64 and 32-bit ARM, ghash_nohw.rs, which ports aws-lc's + crypto/fipsmodule/modes/gcm_nohw.c (GHASH) to Rust. aws-lc takes both + C files from BoringSSL. + Copyright (c) 2019, Google Inc. + License: ISC. + + aws-lc's NOTICE file reads: + AWS-LC + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +mlkem-native + + What: rind/src/low/generic/mlkem_poly.rs, which ports mlkem-native's + portable C polynomial arithmetic (mlkem/poly.c, poly_k.c, compress.c, + sampling.c, and zetas.inc, as aws-lc vendors them at commit + 91c93a8eb860e51482a871a8772cb9b1b310baea) to Rust; and + rind/src/low/x86_64/aws_lc/mlkem_*, which admin/import-aws-lc takes from + mlkem-native's x86_64 AVX2 assembly as aws-lc vendors it at that commit, + with the tables of its compress_consts.c in + rind/src/low/x86_64/mlkem_compress.rs. + Copyright (c) The mlkem-native project authors + License: Apache-2.0 OR ISC OR MIT. + +BoringSSL + + What: the files under rind/src/low/{x86_64,arm}/boringssl, which + admin/import-boringssl takes from BoringSSL at commit + ed9209cff8b5a2fbc6c10050f29b3355048bda85: for x86_64, AES-GCM on VAES + from its pregenerated gen/bcm/aes-gcm-avx2-x86_64 and + aes-gcm-avx512-x86_64, the AVX-512 one based on aes-gcm-avx10-x86_64.S + from Linux; for 32-bit ARM, X25519 on NEON from + crypto/curve25519/asm/x25519-asm-arm.S, which BoringSSL took from + crypto_scalarmult/curve25519/neon2/scalarmult.s in SUPERCOP 20141124 + , which is public domain. + Copyright 2015 The BoringSSL Authors + Copyright 2024 The BoringSSL Authors + License: Apache-2.0. BoringSSL has no NOTICE file. + +OpenSSL + + What: the files under rind/src/low/{x86_64,arm}/openssl, which + admin/import-openssl takes from OpenSSL at commit + 2f4e3a5fec05394068cfa7379890ab552483a8a6: for x86_64, SHA-256 and + SHA-512 on AVX2 and SHA-512 on the SHA512 instructions, generated from + crypto/sha/asm/sha512-x86_64.pl, written by Andy Polyakov; + for 32-bit ARM, Keccak-f[1600] in integer code and on NEON, generated from + crypto/sha/asm/keccak1600-armv4.pl, written by Andy Polyakov. + Copyright 2005-2026 The OpenSSL Project Authors. All Rights Reserved. + Copyright 2017-2026 The OpenSSL Project Authors. All Rights Reserved. + License: Apache-2.0. OpenSSL has no NOTICE file. + +curve25519-dalek + + What: rind/src/low/x86_64/edwards25519_scalarmuldouble_avx2.rs, a port + of curve25519-dalek 5.0.0's AVX2 backend (its field arithmetic, point + formulas, and table of the base point's odd multiples) and its scalars' + non-adjacent forms, for Ed25519 verification on x86_64 CPUs with AVX2 but + without ADX. + Copyright (c) 2016-2021 isis agora lovecruft. All rights reserved. + Copyright (c) 2016-2021 Henry de Valence. All rights reserved. + License: BSD-3-Clause. + +-- + +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 + +Apache 2.0 license +------------------------------------- + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + +ISC license +------------------------------------- + +Copyright 2024 Joe Birr-Pixton +Copyright Amazon.com, Inc. or its affiliates. +Copyright (c) 2024 The mlkem-native project authors +Copyright (c) 2021-2022 Arm Limited +Copyright (c) 2022 Arm Limited +Copyright (c) 2022 Hanno Becker +Copyright (c) 2022 Matthias Kannwischer +Copyright (c) 2023 Amin Abdulrahman, Matthias Kannwischer +Copyright (c) 2014, Google Inc. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +MIT-0 license +------------------------------------- + +Copyright 2024 Joe Birr-Pixton +Copyright 2021-2024 Amazon.com, Inc. or its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +MIT license +------------------------------------- + +Copyright Amazon.com, Inc. or its affiliates. +Copyright (c) 2024 The mlkem-native project authors +Copyright (c) 2021-2022 Arm Limited +Copyright (c) 2022 Arm Limited +Copyright (c) 2022 Hanno Becker +Copyright (c) 2022 Matthias Kannwischer +Copyright (c) 2023 Amin Abdulrahman, Matthias Kannwischer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +BSD 3-Clause license +------------------------------------- + +Copyright (c) 2016-2021 isis agora lovecruft. All rights reserved. +Copyright (c) 2016-2021 Henry de Valence. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/rind-python/README.md b/rind-python/README.md new file mode 100644 index 000000000..adb812fc1 --- /dev/null +++ b/rind-python/README.md @@ -0,0 +1,132 @@ +# pyrind + +Cryptography for Python from [rind](https://github.com/moq-dev/rind), a +pure-Rust library: AEADs, hashes, HMAC, HKDF, PBKDF2, X25519, Ed25519, ECDSA +and ECDH, RSA, and ML-KEM, with rind's own misuse-resistant API. + +```shell +pip install pyrind +``` + +## Why + +A Python call into pyca/cryptography pays OpenSSL 3's per-call setup, which +dwarfs the work on a small message. pyrind's calls go straight to rind's +assembly. + +On an Intel Core i7-8700 (Coffee Lake, 2017), Linux 6.18, CPython 3.14.8, +against cryptography 50.0.2 on OpenSSL 3.5.9, one call at a time with its +inputs in hand, fastest of seven runs: + +| Operation | pyrind | cryptography | hashlib, hmac | cryptography ÷ pyrind | +|---|---:|---:|---:|---:| +| SHA-256, 21 B | 262 ns | 1.79 µs | 526 ns | 6.8x | +| HMAC-SHA256, 21 B | 401 ns | 2.54 µs | 1.87 µs | 6.3x | +| HKDF-SHA256, 32 B key | 1.68 µs | 5.07 µs | - | 3.0x | +| AES-128-GCM seal, 32 B | 237 ns | 667 ns | - | 2.8x | +| AES-128-GCM open, 32 B | 218 ns | 690 ns | - | 3.2x | +| AES-128-GCM seal, 1350 B | 507 ns | 924 ns | - | 1.8x | +| AES-128-GCM open, 1350 B | 472 ns | 926 ns | - | 2.0x | +| AES-128-GCM seal, 16384 B | 3.04 µs | 3.63 µs | - | 1.2x | +| AES-128-GCM open, 16384 B | 3.02 µs | 3.59 µs | - | 1.2x | +| ChaCha20-Poly1305 seal, 32 B | 315 ns | 858 ns | - | 2.7x | +| ChaCha20-Poly1305 open, 32 B | 293 ns | 894 ns | - | 3.0x | +| ChaCha20-Poly1305 seal, 1350 B | 930 ns | 1.54 µs | - | 1.7x | +| ChaCha20-Poly1305 open, 1350 B | 901 ns | 1.59 µs | - | 1.8x | +| ChaCha20-Poly1305 seal, 16384 B | 7.13 µs | 7.66 µs | - | 1.1x | +| ChaCha20-Poly1305 open, 16384 B | 7.03 µs | 7.72 µs | - | 1.1x | +| X25519 agree | 19.93 µs | 30.44 µs | - | 1.5x | +| ECDH P-256 agree | 37.44 µs | 62.57 µs | - | 1.7x | +| Ed25519 sign, 21 B | 7.10 µs | 28.96 µs | - | 4.1x | +| Ed25519 verify, 21 B | 29.07 µs | 93.03 µs | - | 3.2x | +| ECDSA P-256 sign, 21 B | 14.41 µs | 26.14 µs | - | 1.8x | +| ECDSA P-256 verify, 21 B | 39.42 µs | 60.53 µs | - | 1.5x | +| RSA-2048 PKCS#1 sign, 21 B | 470.21 µs | 483.67 µs | - | 1.0x | +| RSA-2048 PKCS#1 verify, 21 B | 11.00 µs | 20.14 µs | - | 1.8x | +| ML-KEM-768 encapsulate | 3.22 µs | 22.16 µs | - | 6.9x | +| ML-KEM-768 decapsulate | 7.78 µs | 33.81 µs | - | 4.3x | + +The gap is widest on small messages and short-lived keys, which is where +protocols spend their calls. On 16 KiB the assembly dominates both and the +two converge; RSA signing is the same modular exponentiation in each. + +Run `sh/python.sh bench` in the repository for your own machine's numbers. + +## Use + +Each rind module is a submodule. Algorithms are constants, and a key is +made for one algorithm, so the wrong combination fails before any data +moves: passing a hash where a key wants an AEAD algorithm raises `TypeError`. + +```python +import os +from pyrind import aead, digest, hkdf, hmac, signature +from pyrind.signature import sign, verify + +# Hashes and MACs. +digest.digest(digest.SHA256, b"hello") +key = hmac.Key(hmac.HMAC_SHA256, b"secret") +tag = hmac.sign(key, b"message") +hmac.verify(key, b"message", tag) # raises pyrind.BadSignature on a mismatch + +# AEAD with the caller's nonces, which must never repeat under one key. The +# associated data is keyword-only, so it can't be swapped with the plaintext. +key = aead.Key(aead.AES_256_GCM, os.urandom(32)) +sealed = key.seal(bytes(12), b"plaintext", aad=b"header") +assert key.open(bytes(12), sealed, aad=b"header") == b"plaintext" + +# Or random 192-bit nonces, with no nonce state. +key = aead.RandomizedNonceKey(os.urandom(32)) +nonce, sealed = key.seal(b"plaintext", aad=b"") +assert key.open(nonce, sealed, aad=b"") == b"plaintext" + +# HKDF expands straight into keys, whose bytes never reach Python. +prk = hkdf.Salt(hkdf.HKDF_SHA256, b"salt").extract(b"input key material") +aead_key = prk.expand(b"aead", aead.AES_256_GCM) + +# Signatures: a key pair signs with a `sign` algorithm, and a peer's public +# key is parsed for a `verify` algorithm. +key_pair = signature.EcdsaKeyPair.generate(sign.ECDSA_P256_SHA256_ASN1) +signed = key_pair.sign(b"message") +public_key = signature.PublicKey(verify.ECDSA_P256_SHA256_ASN1, bytes(key_pair.public_key())) +public_key.verify(b"message", signed) +``` + +The modules are `aead`, `agreement` (X25519 and ECDH), `cpu`, `digest`, +`hkdf`, `hmac`, `kem` (ML-KEM-768), `pbkdf2`, `rsa` (RSA-OAEP), and +`signature` (ECDSA, Ed25519, and RSA). Algorithms only legacy protocols +should use, such as SHA-1 and MD5, live in each module's `legacy` submodule. +Keys load from and encode to PKCS#8, SEC1, PKCS#1, and +`SubjectPublicKeyInfo` DER. The package ships type stubs, and +[rind's documentation](https://docs.rs/rind/) describes each operation in +depth. + +Every failure raises a subclass of `pyrind.Error`: `WrongLength`, +`KeyRejected` (whose message says why), `BadSignature`, `DecryptFailed`, +`RngFailed`, `Unsupported`, or `Fault`. Arguments take any bytes-like object; +anything but `bytes` is copied first, so no other thread can change it +mid-call. RSA key generation, RSA signing and decryption, and PBKDF2 release +the GIL while they run. + +pyrind wipes keys and keyed state when Python frees them exactly when rind +does, through rind's own drops; the wheels build rind with its default +features. Nothing wipes the `bytes` a key was loaded from or exported to; +Python owns those. + +## Platforms + +Wheels cover CPython 3.11 and later through the stable ABI, on Linux (glibc +2.17 and later on x86_64, aarch64, and armv7; musl on x86_64 and aarch64), +macOS on x86_64 and Apple silicon, and Windows on x86_64 and aarch64. PyPy, +free-threaded CPython, and other platforms build the sdist, which needs a +Rust toolchain. rind runs only on x86_64, aarch64, and 32-bit arm, so other +architectures fail to build; there is no pure-Python fallback. + +Importing pyrind raises `ImportError` on a CPU missing a feature rind +requires, naming it, rather than failing later. + +## License + +pyrind carries rind's licenses: rind's own code is Apache-2.0, ISC, or +MIT-0, at your option, and the code it copies keeps its source's license, as +[LICENSE](LICENSE) lists. diff --git a/rind-python/bench/bench.py b/rind-python/bench/bench.py new file mode 100644 index 000000000..fcfa40231 --- /dev/null +++ b/rind-python/bench/bench.py @@ -0,0 +1,206 @@ +"""Per-call cost of pyrind against pyca/cryptography, hashlib, and hmac. + +Each operation runs as a Python program calls it, inputs already in hand, so +the times include each library's per-call overhead, which dominates small +messages. Prints a Markdown table; `sh/python.sh bench` runs it. +""" + +import argparse +import hashlib +import hmac as std_hmac +import os +import platform +import timeit + +import cryptography +from cryptography.hazmat.backends.openssl import backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import hmac as pyca_hmac +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, mlkem, padding, x25519 +from cryptography.hazmat.primitives.asymmetric import rsa as pyca_rsa +from cryptography.hazmat.primitives.ciphers import aead as pyca_aead +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from pyrind import aead, agreement, digest, hkdf, hmac, kem, signature +from pyrind.signature import sign, verify + + +def cases(): + """Yields (operation, {library: callable}).""" + message = os.urandom(21) + yield "SHA-256, 21 B", { + "pyrind": lambda: digest.digest(digest.SHA256, message), + "cryptography": lambda: _pyca_hash(message), + "hashlib": lambda: hashlib.sha256(message).digest(), + } + + raw = os.urandom(32) + key = hmac.Key(hmac.HMAC_SHA256, raw) + yield "HMAC-SHA256, 21 B", { + "pyrind": lambda: hmac.sign(key, message), + "cryptography": lambda: _pyca_hmac(raw, message), + "hashlib": lambda: std_hmac.digest(raw, message, "sha256"), + } + + yield "HKDF-SHA256, 32 B key", { + "pyrind": lambda: hkdf.Salt(hkdf.HKDF_SHA256, b"salt").extract(raw).expand(b"info", 32), + "cryptography": lambda: HKDF(hashes.SHA256(), 32, b"salt", b"info").derive(raw), + } + + nonce = os.urandom(12) + for name, algorithm, other in ( + ("AES-128-GCM", aead.AES_128_GCM, pyca_aead.AESGCM), + ("ChaCha20-Poly1305", aead.CHACHA20_POLY1305, pyca_aead.ChaCha20Poly1305), + ): + raw_key = os.urandom(algorithm.key_len) + ours, theirs = aead.Key(algorithm, raw_key), other(raw_key) + for size in (32, 1350, 16384): + plaintext = os.urandom(size) + sealed = ours.seal(nonce, plaintext, aad=b"") + yield f"{name} seal, {size} B", { + "pyrind": lambda ours=ours, plaintext=plaintext: ours.seal(nonce, plaintext, aad=b""), + "cryptography": lambda theirs=theirs, plaintext=plaintext: theirs.encrypt(nonce, plaintext, b""), + } + yield f"{name} open, {size} B", { + "pyrind": lambda ours=ours, sealed=sealed: ours.open(nonce, sealed, aad=b""), + "cryptography": lambda theirs=theirs, sealed=sealed: theirs.decrypt(nonce, sealed, b""), + } + + # Key agreement takes the peer's key as bytes, as it arrives. + ours = agreement.PrivateKey.generate(agreement.X25519) + theirs = x25519.X25519PrivateKey.generate() + peer = theirs.public_key().public_bytes_raw() + yield "X25519 agree", { + "pyrind": lambda: ours.agree(peer), + "cryptography": lambda: theirs.exchange(x25519.X25519PublicKey.from_public_bytes(peer)), + } + + ours_p256 = agreement.PrivateKey.generate(agreement.ECDH_P256) + theirs_p256 = ec.generate_private_key(ec.SECP256R1()) + peer_p256 = bytes(ours_p256.public_key()) + yield "ECDH P-256 agree", { + "pyrind": lambda: ours_p256.agree(peer_p256), + "cryptography": lambda: theirs_p256.exchange( + ec.ECDH(), ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), peer_p256) + ), + } + + ours_ed = signature.Ed25519KeyPair.generate() + theirs_ed = ed25519.Ed25519PrivateKey.from_private_bytes(ours_ed.to_seed()) + signed_ed = ours_ed.sign(message) + public_ed, their_public_ed = ours_ed.public_key(), theirs_ed.public_key() + yield "Ed25519 sign, 21 B", { + "pyrind": lambda: ours_ed.sign(message), + "cryptography": lambda: theirs_ed.sign(message), + } + yield "Ed25519 verify, 21 B", { + "pyrind": lambda: public_ed.verify(message, signed_ed), + "cryptography": lambda: their_public_ed.verify(signed_ed, message), + } + + ours_ec = signature.EcdsaKeyPair.generate(sign.ECDSA_P256_SHA256_ASN1) + public_ec = signature.PublicKey(verify.ECDSA_P256_SHA256_ASN1, bytes(ours_ec.public_key())) + theirs_ec = ec.generate_private_key(ec.SECP256R1()) + their_public_ec = theirs_ec.public_key() + signed_ec, their_signed_ec = ours_ec.sign(message), theirs_ec.sign(message, ec.ECDSA(hashes.SHA256())) + yield "ECDSA P-256 sign, 21 B", { + "pyrind": lambda: ours_ec.sign(message), + "cryptography": lambda: theirs_ec.sign(message, ec.ECDSA(hashes.SHA256())), + } + yield "ECDSA P-256 verify, 21 B", { + "pyrind": lambda: public_ec.verify(message, signed_ec), + "cryptography": lambda: their_public_ec.verify(their_signed_ec, message, ec.ECDSA(hashes.SHA256())), + } + + theirs_rsa = pyca_rsa.generate_private_key(65537, 2048) + from cryptography.hazmat.primitives import serialization + + der = theirs_rsa.private_bytes( + serialization.Encoding.DER, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ) + ours_rsa = signature.RsaKeyPair.from_pkcs8(der) + public_rsa = ours_rsa.public_key(sign.RSA_PKCS1_SHA256) + their_public_rsa = theirs_rsa.public_key() + signed_rsa = ours_rsa.sign(sign.RSA_PKCS1_SHA256, message) + yield "RSA-2048 PKCS#1 sign, 21 B", { + "pyrind": lambda: ours_rsa.sign(sign.RSA_PKCS1_SHA256, message), + "cryptography": lambda: theirs_rsa.sign(message, padding.PKCS1v15(), hashes.SHA256()), + } + yield "RSA-2048 PKCS#1 verify, 21 B", { + "pyrind": lambda: public_rsa.verify(message, signed_rsa), + "cryptography": lambda: their_public_rsa.verify(signed_rsa, message, padding.PKCS1v15(), hashes.SHA256()), + } + + ours_kem = kem.DecapsulationKey.generate(kem.ML_KEM_768) + public_kem = ours_kem.public_key() + ciphertext, _ = public_kem.encapsulate() + theirs_kem = mlkem.MLKEM768PrivateKey.generate() + their_public_kem = theirs_kem.public_key() + _, their_ciphertext = their_public_kem.encapsulate() + yield "ML-KEM-768 encapsulate", { + "pyrind": public_kem.encapsulate, + "cryptography": their_public_kem.encapsulate, + } + yield "ML-KEM-768 decapsulate", { + "pyrind": lambda: ours_kem.decapsulate(ciphertext), + "cryptography": lambda: theirs_kem.decapsulate(their_ciphertext), + } + + +def _pyca_hash(message): + context = hashes.Hash(hashes.SHA256()) + context.update(message) + return context.finalize() + + +def _pyca_hmac(key, message): + context = pyca_hmac.HMAC(key, hashes.SHA256()) + context.update(message) + return context.finalize() + + +def seconds_per_call(function, repeat): + """The fastest of `repeat` runs, each long enough to time reliably.""" + timer = timeit.Timer(function) + number, _ = timer.autorange() + return min(timer.repeat(repeat, number)) / number + + +def human(seconds): + if seconds < 1e-6: + return f"{seconds * 1e9:.0f} ns" + if seconds < 1e-3: + return f"{seconds * 1e6:.2f} µs" + return f"{seconds * 1e3:.2f} ms" + + +def cpu_model(): + try: + with open("/proc/cpuinfo") as cpuinfo: + for line in cpuinfo: + if line.startswith("model name"): + return line.split(":", 1)[1].strip() + except OSError: + pass + return platform.processor() or platform.machine() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repeat", type=int, default=7, help="timed runs per operation, of which the fastest counts") + args = parser.parse_args() + + print(f"{cpu_model()}, {platform.system()} {platform.release()}, Python {platform.python_version()},") + print(f"cryptography {cryptography.__version__} on {backend.openssl_version_text()}.\n") + libraries = ["pyrind", "cryptography", "hashlib"] + print("| Operation | pyrind | cryptography | hashlib, hmac | cryptography ÷ pyrind |") + print("|---|" + "---:|" * (len(libraries) + 1)) + for operation, functions in cases(): + times = {name: seconds_per_call(function, args.repeat) for name, function in functions.items()} + cells = [human(times[name]) if name in times else "-" for name in libraries] + ratio = times["cryptography"] / times["pyrind"] + print(f"| {operation} | " + " | ".join(cells) + f" | {ratio:.1f}x |", flush=True) + + +if __name__ == "__main__": + main() diff --git a/rind-python/pyproject.toml b/rind-python/pyproject.toml new file mode 100644 index 000000000..ec184514f --- /dev/null +++ b/rind-python/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["maturin>=1.15,<2"] +build-backend = "maturin" + +[project] +name = "pyrind" +description = "Fast cryptography for Python from rind, a pure-Rust library: AEADs, hashes, HMAC, HKDF, PBKDF2, X25519, Ed25519, ECDSA, ECDH, RSA, and ML-KEM" +readme = "README.md" +requires-python = ">=3.11" +# rind's own licenses, and those of the code it copies (rind/LICENSE). +license = "(Apache-2.0 OR ISC OR MIT-0) AND (Apache-2.0 OR ISC OR MIT) AND Apache-2.0 AND ISC AND BSD-3-Clause" +license-files = ["LICENSE"] +keywords = ["cryptography", "aead", "ed25519", "ecdsa", "ml-kem"] +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Security :: Cryptography", + "Typing :: Typed", +] +# maturin takes the version from rind-python's Cargo.toml, which release-plz +# bumps. +dynamic = ["version"] + +[project.urls] +Repository = "https://github.com/moq-dev/rind" +Documentation = "https://github.com/moq-dev/rind/tree/main/rind-python" + +[tool.maturin] +python-source = "python" +module-name = "pyrind._rind" diff --git a/rind-python/python/pyrind/__init__.py b/rind-python/python/pyrind/__init__.py new file mode 100644 index 000000000..528f529c0 --- /dev/null +++ b/rind-python/python/pyrind/__init__.py @@ -0,0 +1,52 @@ +"""rind's cryptography for Python. + +Each rind module is a submodule: `pyrind.aead`, `pyrind.agreement`, +`pyrind.cpu`, `pyrind.digest`, `pyrind.hkdf`, `pyrind.hmac`, `pyrind.kem`, +`pyrind.pbkdf2`, `pyrind.rsa`, and `pyrind.signature`. Every failure raises +a subclass of `pyrind.Error`. + +Importing pyrind raises `ImportError` on a CPU missing a feature rind +requires, naming it. +""" + +from pyrind._rind import ( + BadSignature, + DecryptFailed, + Error, + Fault, + KeyRejected, + RngFailed, + Unsupported, + WrongLength, + aead, + agreement, + cpu, + digest, + hkdf, + hmac, + kem, + pbkdf2, + rsa, + signature, +) + +__all__ = [ + "BadSignature", + "DecryptFailed", + "Error", + "Fault", + "KeyRejected", + "RngFailed", + "Unsupported", + "WrongLength", + "aead", + "agreement", + "cpu", + "digest", + "hkdf", + "hmac", + "kem", + "pbkdf2", + "rsa", + "signature", +] diff --git a/rind-python/python/pyrind/_rind.pyi b/rind-python/python/pyrind/_rind.pyi new file mode 100644 index 000000000..a8941affb --- /dev/null +++ b/rind-python/python/pyrind/_rind.pyi @@ -0,0 +1,36 @@ +from pyrind import aead as aead +from pyrind import agreement as agreement +from pyrind import cpu as cpu +from pyrind import digest as digest +from pyrind import hkdf as hkdf +from pyrind import hmac as hmac +from pyrind import kem as kem +from pyrind import pbkdf2 as pbkdf2 +from pyrind import rsa as rsa +from pyrind import signature as signature + +class Error(Exception): + """A rind call failed.""" + +class WrongLength(Error): + """An input or output of fixed length, such as a key, a nonce, or a derived key, was another length.""" + +class KeyRejected(Error): + """A private, public, or peer's key was refused; the message says why.""" + +class BadSignature(Error): + """A signature, MAC tag, or PBKDF2 output did not verify.""" + +class DecryptFailed(Error): + """An AEAD ciphertext, its tag, or its associated data did not authenticate, or an RSA-OAEP ciphertext did not decrypt.""" + +class RngFailed(Error): + """The system random source failed.""" + +class Unsupported(Error): + """A call was given an algorithm it does not take.""" + +class Fault(Error): + """An internal consistency check failed, from a hardware fault or a rind bug.""" + +__all__ = ["Error", "WrongLength", "KeyRejected", "BadSignature", "DecryptFailed", "RngFailed", "Unsupported", "Fault", "aead", "agreement", "cpu", "digest", "hkdf", "hmac", "kem", "pbkdf2", "rsa", "signature"] diff --git a/rind-python/python/pyrind/aead.pyi b/rind-python/python/pyrind/aead.pyi new file mode 100644 index 000000000..0736299bb --- /dev/null +++ b/rind-python/python/pyrind/aead.pyi @@ -0,0 +1,54 @@ +"""Authenticated encryption: AES-GCM and ChaCha20-Poly1305 with the caller's +nonces, and XChaCha20-Poly1305 with random ones.""" + +from typing import final + +from typing_extensions import Buffer + +@final +class Algorithm: + """An AEAD algorithm.""" + + @property + def key_len(self) -> int: + """The key's length in bytes.""" + @property + def nonce_len(self) -> int: + """The nonce's length in bytes.""" + @property + def tag_len(self) -> int: + """The tag's length in bytes.""" + +AES_128_GCM: Algorithm +AES_192_GCM: Algorithm +AES_256_GCM: Algorithm +CHACHA20_POLY1305: Algorithm + +@final +class Key: + """An AEAD key, which seals with the caller's nonces. + + Sealing two messages with one nonce under one key loses confidentiality + and lets anyone forge messages, so derive nonces from state that never + repeats, such as a sequence number, or use `RandomizedNonceKey`. + """ + + def __new__(cls, algorithm: Algorithm, key: Buffer) -> Key: + """Expands `key` for `algorithm`; raises `WrongLength` unless it is `algorithm.key_len` bytes.""" + def seal(self, nonce: Buffer, plaintext: Buffer, *, aad: Buffer) -> bytes: + """Encrypts `plaintext` under the 12-byte `nonce`, authenticating `aad` with it, and returns the ciphertext with the tag appended.""" + def open(self, nonce: Buffer, ciphertext: Buffer, *, aad: Buffer) -> bytes: + """Decrypts and verifies `ciphertext`, sealed under `nonce` with `aad`, and returns the plaintext; raises `DecryptFailed` if it does not verify.""" + +@final +class RandomizedNonceKey: + """An XChaCha20-Poly1305 key that picks a random 192-bit nonce for every seal, so the caller tracks no nonce state.""" + + def __new__(cls, key: Buffer) -> RandomizedNonceKey: + """Makes a key from 32 bytes of key material; raises `WrongLength` otherwise.""" + def seal(self, plaintext: Buffer, *, aad: Buffer) -> tuple[bytes, bytes]: + """Encrypts `plaintext` under a fresh random nonce, authenticating `aad` with it, and returns the nonce, which the opener needs, and the ciphertext with the tag appended.""" + def open(self, nonce: Buffer, ciphertext: Buffer, *, aad: Buffer) -> bytes: + """Decrypts and verifies `ciphertext`, sealed under the 24-byte `nonce` with `aad`, and returns the plaintext; raises `DecryptFailed` if it does not verify.""" + +__all__ = ["Algorithm", "Key", "RandomizedNonceKey", "AES_128_GCM", "AES_192_GCM", "AES_256_GCM", "CHACHA20_POLY1305"] diff --git a/rind-python/python/pyrind/agreement.pyi b/rind-python/python/pyrind/agreement.pyi new file mode 100644 index 000000000..0888aa9a7 --- /dev/null +++ b/rind-python/python/pyrind/agreement.pyi @@ -0,0 +1,54 @@ +"""Key agreement: X25519, and ECDH on P-256, P-384, and P-521.""" + +from typing import final + +from typing_extensions import Buffer + +@final +class Algorithm: + """A key agreement algorithm.""" + +X25519: Algorithm +ECDH_P256: Algorithm +ECDH_P384: Algorithm +ECDH_P521: Algorithm + +@final +class PrivateKey: + """A private key for key agreement.""" + + @staticmethod + def generate(algorithm: Algorithm) -> PrivateKey: + """Generates a key for `algorithm`.""" + @staticmethod + def from_scalar(algorithm: Algorithm, scalar: Buffer) -> PrivateKey: + """Loads a key for `algorithm` from its big-endian scalar, or for X25519 its 32 bytes.""" + @staticmethod + def from_pkcs8(algorithm: Algorithm, der: Buffer) -> PrivateKey: + """Loads a key for `algorithm` from PKCS#8 DER.""" + @staticmethod + def from_sec1(algorithm: Algorithm, der: Buffer) -> PrivateKey: + """Loads an ECDH key for `algorithm` from SEC1 `ECPrivateKey` DER.""" + def public_key(self) -> PublicKey: + """The public key to send the peer.""" + def agree(self, peer: Buffer) -> bytes: + """Agrees a shared secret with the peer's public key `peer`, as `bytes(PublicKey)` encodes it; raises `KeyRejected` for an invalid one.""" + def to_pkcs8(self) -> bytes: + """Encodes the key as PKCS#8 DER.""" + def to_scalar(self) -> bytes: + """Returns the key's scalar, as `from_scalar` takes it.""" + +@final +class PublicKey: + """A public key for key agreement, whose `bytes` are what `agree` takes: 32 bytes for X25519, and an uncompressed SEC1 point for ECDH.""" + + def __new__(cls, algorithm: Algorithm, data: Buffer) -> PublicKey: + """Parses a public key for `algorithm` from its `bytes` encoding.""" + @staticmethod + def from_spki(algorithm: Algorithm, der: Buffer) -> PublicKey: + """Parses a public key for `algorithm` from `SubjectPublicKeyInfo` DER.""" + def to_spki(self) -> bytes: + """Encodes the key as `SubjectPublicKeyInfo` DER.""" + def __bytes__(self) -> bytes: ... + +__all__ = ["Algorithm", "PrivateKey", "PublicKey", "X25519", "ECDH_P256", "ECDH_P384", "ECDH_P521"] diff --git a/rind-python/python/pyrind/cpu.pyi b/rind-python/python/pyrind/cpu.pyi new file mode 100644 index 000000000..7b28c5edd --- /dev/null +++ b/rind-python/python/pyrind/cpu.pyi @@ -0,0 +1,6 @@ +"""CPU support.""" + +def has_aes_hardware() -> bool: + """Whether this CPU runs AES and GHASH in hardware; where it does not, prefer ChaCha20-Poly1305.""" + +__all__ = ["has_aes_hardware"] diff --git a/rind-python/python/pyrind/digest/__init__.pyi b/rind-python/python/pyrind/digest/__init__.pyi new file mode 100644 index 000000000..d375dbac9 --- /dev/null +++ b/rind-python/python/pyrind/digest/__init__.pyi @@ -0,0 +1,39 @@ +"""Hash functions.""" + +from typing import final + +from typing_extensions import Buffer + +from pyrind.digest import legacy as legacy + +@final +class Algorithm: + """A hash function.""" + + @property + def output_len(self) -> int: + """The digest's length in bytes.""" + +SHA256: Algorithm +SHA384: Algorithm +SHA512: Algorithm +SHA512_256: Algorithm +SHA3_256: Algorithm +SHA3_384: Algorithm +SHA3_512: Algorithm + +def digest(algorithm: Algorithm, data: Buffer) -> bytes: + """Returns the digest of `data`.""" + +@final +class Context: + """A hash of a message given in pieces.""" + + def __new__(cls, algorithm: Algorithm) -> Context: + """Starts hashing with `algorithm`.""" + def update(self, data: Buffer) -> None: + """Adds `data` to the message.""" + def finish(self) -> bytes: + """Returns the digest of the message, and starts a new one with the same algorithm.""" + +__all__ = ["Algorithm", "Context", "digest", "SHA256", "SHA384", "SHA512", "SHA512_256", "SHA3_256", "SHA3_384", "SHA3_512", "legacy"] diff --git a/rind-python/python/pyrind/digest/legacy.pyi b/rind-python/python/pyrind/digest/legacy.pyi new file mode 100644 index 000000000..ccdf4673a --- /dev/null +++ b/rind-python/python/pyrind/digest/legacy.pyi @@ -0,0 +1,8 @@ +"""Hash functions only legacy protocols should use.""" + +from pyrind.digest import Algorithm + +MD5: Algorithm +SHA1: Algorithm + +__all__ = ["MD5", "SHA1"] diff --git a/rind-python/python/pyrind/hkdf/__init__.pyi b/rind-python/python/pyrind/hkdf/__init__.pyi new file mode 100644 index 000000000..4bda19596 --- /dev/null +++ b/rind-python/python/pyrind/hkdf/__init__.pyi @@ -0,0 +1,38 @@ +"""Key derivation with HKDF.""" + +from typing import final, overload + +from typing_extensions import Buffer + +from pyrind import aead, hmac +from pyrind.hkdf import legacy as legacy + +@final +class Algorithm: + """An HKDF algorithm.""" + +HKDF_SHA256: Algorithm +HKDF_SHA384: Algorithm +HKDF_SHA512: Algorithm + +@final +class Salt: + """An HKDF salt, which extracts pseudorandom keys from secrets.""" + + def __new__(cls, algorithm: Algorithm, value: Buffer) -> Salt: + """Makes a salt for `algorithm` from `value`, of any length.""" + def extract(self, secret: Buffer) -> Prk: + """Extracts a pseudorandom key from `secret`.""" + +@final +class Prk: + """A pseudorandom key, which expands into any number of keys.""" + + @overload + def expand(self, info: Buffer, key_type: int) -> bytes: ... + @overload + def expand(self, info: Buffer, key_type: aead.Algorithm) -> aead.Key: ... + @overload + def expand(self, info: Buffer, key_type: hmac.Algorithm) -> hmac.Key: ... + +__all__ = ["Algorithm", "Salt", "Prk", "HKDF_SHA256", "HKDF_SHA384", "HKDF_SHA512", "legacy"] diff --git a/rind-python/python/pyrind/hkdf/legacy.pyi b/rind-python/python/pyrind/hkdf/legacy.pyi new file mode 100644 index 000000000..b2e70cf1c --- /dev/null +++ b/rind-python/python/pyrind/hkdf/legacy.pyi @@ -0,0 +1,7 @@ +"""HKDF algorithms only legacy protocols should use.""" + +from pyrind.hkdf import Algorithm + +HKDF_SHA1: Algorithm + +__all__ = ["HKDF_SHA1"] diff --git a/rind-python/python/pyrind/hmac/__init__.pyi b/rind-python/python/pyrind/hmac/__init__.pyi new file mode 100644 index 000000000..b410cd832 --- /dev/null +++ b/rind-python/python/pyrind/hmac/__init__.pyi @@ -0,0 +1,45 @@ +"""Message authentication with HMAC.""" + +from typing import final + +from typing_extensions import Buffer + +from pyrind.hmac import legacy as legacy + +@final +class Algorithm: + """An HMAC algorithm.""" + + @property + def tag_len(self) -> int: + """The tag's length in bytes.""" + +HMAC_SHA256: Algorithm +HMAC_SHA384: Algorithm +HMAC_SHA512: Algorithm + +@final +class Key: + """An HMAC key, whose keyed hash states are computed once.""" + + def __new__(cls, algorithm: Algorithm, key: Buffer) -> Key: + """Makes a key for `algorithm` from `key`, of any length.""" + +def sign(key: Key, data: Buffer) -> bytes: + """Returns the tag of `data` under `key`.""" + +def verify(key: Key, data: Buffer, tag: Buffer) -> None: + """Checks in constant time that `tag` is the whole tag of `data` under `key`; raises `BadSignature` otherwise, including for a truncated tag.""" + +@final +class Context: + """A tag of a message given in pieces, which signs once.""" + + def __new__(cls, key: Key) -> Context: + """Starts a tag under `key`.""" + def update(self, data: Buffer) -> None: + """Adds `data` to the message.""" + def sign(self) -> bytes: + """Returns the tag of the message; the context takes no more data.""" + +__all__ = ["Algorithm", "Key", "Context", "sign", "verify", "HMAC_SHA256", "HMAC_SHA384", "HMAC_SHA512", "legacy"] diff --git a/rind-python/python/pyrind/hmac/legacy.pyi b/rind-python/python/pyrind/hmac/legacy.pyi new file mode 100644 index 000000000..63c4ef12f --- /dev/null +++ b/rind-python/python/pyrind/hmac/legacy.pyi @@ -0,0 +1,7 @@ +"""HMAC algorithms only legacy protocols should use.""" + +from pyrind.hmac import Algorithm + +HMAC_SHA1: Algorithm + +__all__ = ["HMAC_SHA1"] diff --git a/rind-python/python/pyrind/kem.pyi b/rind-python/python/pyrind/kem.pyi new file mode 100644 index 000000000..e8a74e9ac --- /dev/null +++ b/rind-python/python/pyrind/kem.pyi @@ -0,0 +1,35 @@ +"""Key encapsulation: ML-KEM-768.""" + +from typing import final + +from typing_extensions import Buffer + +@final +class Algorithm: + """A key encapsulation mechanism.""" + +ML_KEM_768: Algorithm + +@final +class DecapsulationKey: + """The recipient's private key, which decapsulates.""" + + @staticmethod + def generate(algorithm: Algorithm) -> DecapsulationKey: + """Generates a key for `algorithm`.""" + def public_key(self) -> EncapsulationKey: + """The public key to send the sender.""" + def decapsulate(self, ciphertext: Buffer) -> bytes: + """Recovers the shared secret from the sender's `ciphertext`.""" + +@final +class EncapsulationKey: + """The recipient's public key, which encapsulates, and whose `bytes` are its encoding.""" + + def __new__(cls, algorithm: Algorithm, data: Buffer) -> EncapsulationKey: + """Parses a public key for `algorithm`.""" + def encapsulate(self) -> tuple[bytes, bytes]: + """Encapsulates a fresh shared secret, returning the ciphertext to send the recipient and the secret.""" + def __bytes__(self) -> bytes: ... + +__all__ = ["Algorithm", "DecapsulationKey", "EncapsulationKey", "ML_KEM_768"] diff --git a/rind-python/python/pyrind/pbkdf2/__init__.pyi b/rind-python/python/pyrind/pbkdf2/__init__.pyi new file mode 100644 index 000000000..77a8547b3 --- /dev/null +++ b/rind-python/python/pyrind/pbkdf2/__init__.pyi @@ -0,0 +1,31 @@ +"""Password-based key derivation with PBKDF2.""" + +from typing import final + +from typing_extensions import Buffer + +from pyrind.pbkdf2 import legacy as legacy + +@final +class Algorithm: + """A PBKDF2 algorithm.""" + +PBKDF2_HMAC_SHA256: Algorithm +PBKDF2_HMAC_SHA384: Algorithm +PBKDF2_HMAC_SHA512: Algorithm + +@final +class Secret: + """A password keyed into HMAC once, which derives keys for any salt and iteration count. + + Both calls release the GIL while they run. + """ + + def __new__(cls, algorithm: Algorithm, secret: Buffer) -> Secret: + """Keys `algorithm` with `secret`, a password of any length.""" + def derive(self, iterations: int, salt: Buffer, length: int) -> bytes: + """Derives `length` bytes for `salt` with `iterations`.""" + def verify(self, iterations: int, salt: Buffer, previously_derived: Buffer) -> None: + """Checks in constant time that `previously_derived` is what `derive` returns for `salt` and `iterations`; raises `BadSignature` otherwise.""" + +__all__ = ["Algorithm", "Secret", "PBKDF2_HMAC_SHA256", "PBKDF2_HMAC_SHA384", "PBKDF2_HMAC_SHA512", "legacy"] diff --git a/rind-python/python/pyrind/pbkdf2/legacy.pyi b/rind-python/python/pyrind/pbkdf2/legacy.pyi new file mode 100644 index 000000000..bc53091ad --- /dev/null +++ b/rind-python/python/pyrind/pbkdf2/legacy.pyi @@ -0,0 +1,7 @@ +"""PBKDF2 algorithms only legacy formats should use.""" + +from pyrind.pbkdf2 import Algorithm + +PBKDF2_HMAC_SHA1: Algorithm + +__all__ = ["PBKDF2_HMAC_SHA1"] diff --git a/rind-python/python/pyrind/py.typed b/rind-python/python/pyrind/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/rind-python/python/pyrind/rsa/__init__.pyi b/rind-python/python/pyrind/rsa/__init__.pyi new file mode 100644 index 000000000..143cf579e --- /dev/null +++ b/rind-python/python/pyrind/rsa/__init__.pyi @@ -0,0 +1,69 @@ +"""RSA encryption: RSA-OAEP with SHA-256, SHA-384, or SHA-512, and SHA-1 under `rsa.legacy`. + +RSA signatures are under `pyrind.signature`, which takes this module's `KeySize` too. +""" + +from typing import ClassVar, final + +from typing_extensions import Buffer + +from pyrind.rsa import legacy as legacy + +@final +class KeySize: + """An RSA modulus size for key generation.""" + + RSA_2048: ClassVar[KeySize] + RSA_3072: ClassVar[KeySize] + RSA_4096: ClassVar[KeySize] + RSA_6144: ClassVar[KeySize] + RSA_8192: ClassVar[KeySize] + +@final +class OaepAlgorithm: + """An RSA-OAEP algorithm.""" + +OAEP_SHA256: OaepAlgorithm +OAEP_SHA384: OaepAlgorithm +OAEP_SHA512: OaepAlgorithm + +@final +class DecryptingKey: + """An RSA-OAEP private key, which decrypts.""" + + @staticmethod + def generate(algorithm: OaepAlgorithm, size: KeySize) -> DecryptingKey: + """Generates a key of `size` for `algorithm`, releasing the GIL: about 30 milliseconds for 2048 bits, and seconds for the largest sizes.""" + @staticmethod + def from_pkcs8(algorithm: OaepAlgorithm, der: Buffer) -> DecryptingKey: + """Loads a PKCS#8 RSA private key for `algorithm`.""" + @staticmethod + def from_pkcs1(algorithm: OaepAlgorithm, der: Buffer) -> DecryptingKey: + """Loads a PKCS#1 `RSAPrivateKey` for `algorithm`.""" + def public_key(self) -> EncryptingKey: + """The public key, which encrypts to this key with its algorithm.""" + def decrypt(self, ciphertext: Buffer, *, label: Buffer) -> bytes: + """Decrypts `ciphertext`, encrypted with `label`, releasing the GIL; every malformed ciphertext raises the same `DecryptFailed`.""" + def to_pkcs8(self) -> bytes: + """Encodes the private key as PKCS#8 DER.""" + def to_spki(self) -> bytes: + """Encodes the public key as `SubjectPublicKeyInfo` DER.""" + +@final +class EncryptingKey: + """An RSA-OAEP public key, which encrypts.""" + + def __new__(cls, algorithm: OaepAlgorithm, der: Buffer) -> EncryptingKey: + """Parses a PKCS#1 `RSAPublicKey` or a `SubjectPublicKeyInfo` for `algorithm`.""" + @staticmethod + def from_components(algorithm: OaepAlgorithm, n: Buffer, e: Buffer) -> EncryptingKey: + """Loads a key for `algorithm` from its big-endian modulus `n` and exponent `e`, as a JWK carries them.""" + def encrypt(self, message: Buffer, *, label: Buffer) -> bytes: + """Encrypts `message` with `label`, returning a ciphertext as long as the modulus; raises `WrongLength` past `max_message_len`.""" + @property + def max_message_len(self) -> int: + """The longest message this key encrypts.""" + def to_spki(self) -> bytes: + """Encodes the key as `SubjectPublicKeyInfo` DER.""" + +__all__ = ["KeySize", "OaepAlgorithm", "DecryptingKey", "EncryptingKey", "OAEP_SHA256", "OAEP_SHA384", "OAEP_SHA512", "legacy"] diff --git a/rind-python/python/pyrind/rsa/legacy.pyi b/rind-python/python/pyrind/rsa/legacy.pyi new file mode 100644 index 000000000..c99afe381 --- /dev/null +++ b/rind-python/python/pyrind/rsa/legacy.pyi @@ -0,0 +1,7 @@ +"""RSA-OAEP with SHA-1, for protocols that still require it.""" + +from pyrind.rsa import OaepAlgorithm + +OAEP_SHA1: OaepAlgorithm + +__all__ = ["OAEP_SHA1"] diff --git a/rind-python/python/pyrind/signature/__init__.pyi b/rind-python/python/pyrind/signature/__init__.pyi new file mode 100644 index 000000000..ed6456a0f --- /dev/null +++ b/rind-python/python/pyrind/signature/__init__.pyi @@ -0,0 +1,129 @@ +"""Public key signatures: ECDSA on P-256, P-384, and P-521, RSA, and Ed25519. + +A key pair signs with a `signature.sign` algorithm, and a peer's public key +is parsed for a `signature.verify` algorithm. +""" + +from typing import final + +from typing_extensions import Buffer + +from pyrind.rsa import KeySize +from pyrind.signature import sign as sign +from pyrind.signature import verify as verify + +@final +class EcdsaSigningAlgorithm: + """An algorithm an `EcdsaKeyPair` signs with.""" + +@final +class RsaSigningAlgorithm: + """An algorithm an `RsaKeyPair` signs with.""" + +@final +class VerificationAlgorithm: + """An algorithm a `PublicKey` verifies with.""" + +@final +class RsaVerificationAlgorithm: + """An RSA algorithm a `PublicKey` verifies with, which also loads a key from its components.""" + +@final +class PublicKey: + """A public key that verifies signatures for one algorithm, whose `bytes` are its encoding: an uncompressed SEC1 point for ECDSA, 32 bytes for Ed25519, and a PKCS#1 `RSAPublicKey` for RSA.""" + + def __new__(cls, algorithm: VerificationAlgorithm | RsaVerificationAlgorithm, data: Buffer) -> PublicKey: + """Parses a public key for `algorithm` from its `bytes` encoding.""" + @staticmethod + def from_spki(algorithm: VerificationAlgorithm | RsaVerificationAlgorithm, der: Buffer) -> PublicKey: + """Parses a public key for `algorithm` from `SubjectPublicKeyInfo` DER, refusing one that names another algorithm or curve.""" + @staticmethod + def from_components(algorithm: RsaVerificationAlgorithm, n: Buffer, e: Buffer) -> PublicKey: + """Loads an RSA key for `algorithm` from its big-endian modulus `n` and exponent `e`, as a JWK carries them.""" + def verify(self, message: Buffer, signature: Buffer) -> None: + """Checks `signature` over `message`; raises `BadSignature` if it does not verify.""" + def to_spki(self) -> bytes: + """Encodes the key as `SubjectPublicKeyInfo` DER.""" + def __bytes__(self) -> bytes: ... + +@final +class EcdsaKeyPair: + """An ECDSA key pair, which signs with one algorithm.""" + + @staticmethod + def generate(algorithm: EcdsaSigningAlgorithm) -> EcdsaKeyPair: + """Generates a key pair for `algorithm`.""" + @staticmethod + def from_pkcs8(algorithm: EcdsaSigningAlgorithm, der: Buffer) -> EcdsaKeyPair: + """Loads a key pair for `algorithm` from PKCS#8 DER.""" + @staticmethod + def from_sec1(algorithm: EcdsaSigningAlgorithm, der: Buffer) -> EcdsaKeyPair: + """Loads a key pair for `algorithm` from SEC1 `ECPrivateKey` DER.""" + @staticmethod + def from_scalar_and_public_key(algorithm: EcdsaSigningAlgorithm, scalar: Buffer, public_key: Buffer) -> EcdsaKeyPair: + """Loads a key pair for `algorithm` from its big-endian scalar and its uncompressed public key, as a JWK's `d`, `x`, and `y` carry them, refused unless they match.""" + def public_key(self) -> PublicKey: + """The public key, which verifies with the verification algorithm of the same name.""" + def sign(self, message: Buffer) -> bytes: + """Signs `message`.""" + def to_pkcs8(self) -> bytes: + """Encodes the key pair as PKCS#8 DER.""" + def to_sec1(self) -> bytes: + """Encodes the key pair as SEC1 `ECPrivateKey` DER.""" + def to_scalar(self) -> bytes: + """Returns the private key's big-endian scalar.""" + def to_spki(self) -> bytes: + """Encodes the public key as `SubjectPublicKeyInfo` DER.""" + +@final +class Ed25519KeyPair: + """An Ed25519 key pair.""" + + @staticmethod + def generate() -> Ed25519KeyPair: + """Generates a key pair.""" + @staticmethod + def from_pkcs8(der: Buffer) -> Ed25519KeyPair: + """Loads a key pair from PKCS#8 v1 or v2 DER, refused if a v2 key's public key does not match.""" + @staticmethod + def from_seed(seed: Buffer) -> Ed25519KeyPair: + """Loads a 32-byte seed, as a JWK's `d` holds it, deriving its public key.""" + @staticmethod + def from_seed_and_public_key(seed: Buffer, public_key: Buffer) -> Ed25519KeyPair: + """Loads a 32-byte seed, refused unless `public_key` is its public key.""" + def public_key(self) -> PublicKey: + """The public key, which verifies with `verify.ED25519`.""" + def sign(self, message: Buffer) -> bytes: + """Signs `message`.""" + def to_pkcs8(self) -> bytes: + """Encodes the key pair as PKCS#8 v2 DER (RFC 5958), with its public key.""" + def to_pkcs8v1(self) -> bytes: + """Encodes the private key as PKCS#8 v1 DER (RFC 8410), as OpenSSL writes it.""" + def to_seed(self) -> bytes: + """Returns the private key's 32-byte seed.""" + def to_spki(self) -> bytes: + """Encodes the public key as `SubjectPublicKeyInfo` DER.""" + +@final +class RsaKeyPair: + """An RSA key pair, which signs with any RSA signing algorithm.""" + + @staticmethod + def generate(size: KeySize) -> RsaKeyPair: + """Generates a key pair of `size`, releasing the GIL: about 30 milliseconds for 2048 bits, and seconds for the largest sizes.""" + @staticmethod + def from_pkcs8(der: Buffer) -> RsaKeyPair: + """Loads a PKCS#8 RSA private key.""" + @staticmethod + def from_pkcs1(der: Buffer) -> RsaKeyPair: + """Loads a PKCS#1 `RSAPrivateKey`.""" + def public_key(self, algorithm: RsaSigningAlgorithm) -> PublicKey: + """The public key, which verifies what this key pair signs with `algorithm`.""" + def sign(self, algorithm: RsaSigningAlgorithm, message: Buffer) -> bytes: + """Signs `message` with `algorithm`, releasing the GIL.""" + def to_pkcs8(self) -> bytes: + """Encodes the private key as PKCS#8 DER.""" + def to_spki(self) -> bytes: + """Encodes the public key as `SubjectPublicKeyInfo` DER.""" + +__all__ = ["EcdsaSigningAlgorithm", "RsaSigningAlgorithm", "VerificationAlgorithm", "RsaVerificationAlgorithm", "PublicKey", "EcdsaKeyPair", "Ed25519KeyPair", "RsaKeyPair", "sign", "verify"] diff --git a/rind-python/python/pyrind/signature/sign.pyi b/rind-python/python/pyrind/signature/sign.pyi new file mode 100644 index 000000000..db64820ec --- /dev/null +++ b/rind-python/python/pyrind/signature/sign.pyi @@ -0,0 +1,18 @@ +"""Algorithms a key pair signs with; each signs as the `verify` algorithm of the same name verifies.""" + +from pyrind.signature import EcdsaSigningAlgorithm, RsaSigningAlgorithm + +ECDSA_P256_SHA256_ASN1: EcdsaSigningAlgorithm +ECDSA_P256_SHA256_FIXED: EcdsaSigningAlgorithm +ECDSA_P384_SHA384_ASN1: EcdsaSigningAlgorithm +ECDSA_P384_SHA384_FIXED: EcdsaSigningAlgorithm +ECDSA_P521_SHA512_ASN1: EcdsaSigningAlgorithm +ECDSA_P521_SHA512_FIXED: EcdsaSigningAlgorithm +RSA_PKCS1_SHA256: RsaSigningAlgorithm +RSA_PKCS1_SHA384: RsaSigningAlgorithm +RSA_PKCS1_SHA512: RsaSigningAlgorithm +RSA_PSS_SHA256: RsaSigningAlgorithm +RSA_PSS_SHA384: RsaSigningAlgorithm +RSA_PSS_SHA512: RsaSigningAlgorithm + +__all__ = ["ECDSA_P256_SHA256_ASN1", "ECDSA_P256_SHA256_FIXED", "ECDSA_P384_SHA384_ASN1", "ECDSA_P384_SHA384_FIXED", "ECDSA_P521_SHA512_ASN1", "ECDSA_P521_SHA512_FIXED", "RSA_PKCS1_SHA256", "RSA_PKCS1_SHA384", "RSA_PKCS1_SHA512", "RSA_PSS_SHA256", "RSA_PSS_SHA384", "RSA_PSS_SHA512"] diff --git a/rind-python/python/pyrind/signature/verify/__init__.pyi b/rind-python/python/pyrind/signature/verify/__init__.pyi new file mode 100644 index 000000000..4ebfbc5ae --- /dev/null +++ b/rind-python/python/pyrind/signature/verify/__init__.pyi @@ -0,0 +1,29 @@ +"""Algorithms a public key verifies with; RSA algorithms accept moduli of 2048 to 8192 bits.""" + +from pyrind.signature import RsaVerificationAlgorithm, VerificationAlgorithm +from pyrind.signature.verify import legacy as legacy + +ECDSA_P256_SHA256_ASN1: VerificationAlgorithm +ECDSA_P256_SHA256_FIXED: VerificationAlgorithm +ECDSA_P256_SHA384_ASN1: VerificationAlgorithm +ECDSA_P256_SHA512_ASN1: VerificationAlgorithm +ECDSA_P384_SHA256_ASN1: VerificationAlgorithm +ECDSA_P384_SHA384_ASN1: VerificationAlgorithm +ECDSA_P384_SHA384_FIXED: VerificationAlgorithm +ECDSA_P384_SHA512_ASN1: VerificationAlgorithm +ECDSA_P521_SHA256_ASN1: VerificationAlgorithm +ECDSA_P521_SHA384_ASN1: VerificationAlgorithm +ECDSA_P521_SHA512_ASN1: VerificationAlgorithm +ECDSA_P521_SHA512_FIXED: VerificationAlgorithm +ED25519: VerificationAlgorithm +RSA_PKCS1_SHA256: RsaVerificationAlgorithm +RSA_PKCS1_SHA384: RsaVerificationAlgorithm +RSA_PKCS1_SHA512: RsaVerificationAlgorithm +RSA_PSS_SHA256: RsaVerificationAlgorithm +RSA_PSS_SHA256_ANY_SALT: RsaVerificationAlgorithm +RSA_PSS_SHA384: RsaVerificationAlgorithm +RSA_PSS_SHA384_ANY_SALT: RsaVerificationAlgorithm +RSA_PSS_SHA512: RsaVerificationAlgorithm +RSA_PSS_SHA512_ANY_SALT: RsaVerificationAlgorithm + +__all__ = ["ECDSA_P256_SHA256_ASN1", "ECDSA_P256_SHA256_FIXED", "ECDSA_P256_SHA384_ASN1", "ECDSA_P256_SHA512_ASN1", "ECDSA_P384_SHA256_ASN1", "ECDSA_P384_SHA384_ASN1", "ECDSA_P384_SHA384_FIXED", "ECDSA_P384_SHA512_ASN1", "ECDSA_P521_SHA256_ASN1", "ECDSA_P521_SHA384_ASN1", "ECDSA_P521_SHA512_ASN1", "ECDSA_P521_SHA512_FIXED", "ED25519", "RSA_PKCS1_SHA256", "RSA_PKCS1_SHA384", "RSA_PKCS1_SHA512", "RSA_PSS_SHA256", "RSA_PSS_SHA256_ANY_SALT", "RSA_PSS_SHA384", "RSA_PSS_SHA384_ANY_SALT", "RSA_PSS_SHA512", "RSA_PSS_SHA512_ANY_SALT", "legacy"] diff --git a/rind-python/python/pyrind/signature/verify/legacy.pyi b/rind-python/python/pyrind/signature/verify/legacy.pyi new file mode 100644 index 000000000..2744e3496 --- /dev/null +++ b/rind-python/python/pyrind/signature/verify/legacy.pyi @@ -0,0 +1,11 @@ +"""RSA algorithms with another modulus floor, or SHA-1, for old keys and signatures.""" + +from pyrind.signature import RsaVerificationAlgorithm + +RSA_PKCS1_1024_SHA1: RsaVerificationAlgorithm +RSA_PKCS1_1024_SHA256: RsaVerificationAlgorithm +RSA_PKCS1_1024_SHA512: RsaVerificationAlgorithm +RSA_PKCS1_3072_SHA384: RsaVerificationAlgorithm +RSA_PKCS1_SHA1: RsaVerificationAlgorithm + +__all__ = ["RSA_PKCS1_1024_SHA1", "RSA_PKCS1_1024_SHA256", "RSA_PKCS1_1024_SHA512", "RSA_PKCS1_3072_SHA384", "RSA_PKCS1_SHA1"] diff --git a/rind-python/src/aead.rs b/rind-python/src/aead.rs new file mode 100644 index 000000000..ef2de6ff9 --- /dev/null +++ b/rind-python/src/aead.rs @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::aead; + +use crate::{Bytes, WrongLength, algorithm, bytes, bytes_with, constants, error}; + +const TAG_LEN: usize = 16; + +algorithm!( + /// An AEAD algorithm. + Algorithm(aead::Algorithm) as "pyrind.aead"."Algorithm" { + /// The key's length in bytes. + #[getter] + fn key_len(&self) -> usize { + self.inner.key_len() + } + + /// The nonce's length in bytes. + #[getter] + fn nonce_len(&self) -> usize { + self.inner.nonce_len() + } + + /// The tag's length in bytes. + #[getter] + fn tag_len(&self) -> usize { + self.inner.tag_len() + } + } +); + +/// Copies `plaintext` into a new `bytes` one tag longer, and seals it there +/// with `seal`, which returns the tag. +fn seal_into<'py, T: AsRef<[u8]>>( + py: Python<'py>, + plaintext: &[u8], + seal: impl FnOnce(&mut [u8]) -> Result, +) -> PyResult> { + bytes_with(py, plaintext, plaintext.len() + TAG_LEN, |out| { + let (body, tag) = out.split_at_mut(plaintext.len()); + tag.copy_from_slice(seal(body).map_err(error)?.as_ref()); + Ok(()) + }) +} + +/// Copies `ciphertext`, less its tag, into a new `bytes`, and opens it there +/// with `open`, which takes the tag. +fn open_into<'py>( + py: Python<'py>, + ciphertext: &[u8], + open: impl FnOnce(&mut [u8], [u8; TAG_LEN]) -> Result<(), rind::Error>, +) -> PyResult> { + let (body, tag) = ciphertext + .split_last_chunk::() + .ok_or_else(|| error(rind::Error::DecryptFailed))?; + bytes_with(py, body, body.len(), |out| open(out, *tag).map_err(error)) +} + +/// An AEAD key, which seals with the caller's nonces. +/// +/// Sealing two messages with one nonce under one key loses confidentiality +/// and lets anyone forge messages, so derive nonces from state that never +/// repeats, such as a sequence number, or use `RandomizedNonceKey`. +#[pyclass(frozen, module = "pyrind.aead")] +pub(crate) struct Key(pub(crate) Box); + +#[pymethods] +impl Key { + /// Expands `key` for `algorithm`; raises `WrongLength` unless it is + /// `algorithm.key_len` bytes. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>, key: Bytes<'_>) -> PyResult { + aead::Key::new(algorithm.get().inner, &key) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Encrypts `plaintext` under `nonce`, authenticating `aad` with it, and + /// returns the ciphertext with the tag appended. + #[pyo3(signature = (nonce, plaintext, *, aad))] + fn seal<'py>( + &self, + py: Python<'py>, + nonce: Bytes<'_>, + plaintext: Bytes<'_>, + aad: Bytes<'_>, + ) -> PyResult> { + let nonce = nonce_of(&nonce)?; + seal_into(py, &plaintext, |body| { + self.0.seal_in_place_separate_tag(nonce, &aad, body) + }) + } + + /// Decrypts and verifies `ciphertext`, sealed under `nonce` with `aad`, + /// and returns the plaintext; raises `DecryptFailed` if it does not + /// verify. + #[pyo3(signature = (nonce, ciphertext, *, aad))] + fn open<'py>( + &self, + py: Python<'py>, + nonce: Bytes<'_>, + ciphertext: Bytes<'_>, + aad: Bytes<'_>, + ) -> PyResult> { + let nonce = nonce_of(&nonce)?; + open_into(py, &ciphertext, |body, tag| { + self.0 + .open_in_place_separate_tag(nonce, &aad, aead::Tag::try_from(&tag[..])?, body) + .map(drop) + }) + } +} + +fn nonce_of(nonce: &[u8]) -> PyResult { + let nonce = nonce + .try_into() + .map_err(|_| WrongLength::new_err("a nonce is 12 bytes"))?; + Ok(aead::Nonce::assume_unique_for_key(nonce)) +} + +/// An XChaCha20-Poly1305 key that picks a random 192-bit nonce for every +/// seal, so the caller tracks no nonce state. +#[pyclass(frozen, module = "pyrind.aead")] +struct RandomizedNonceKey(Box); + +#[pymethods] +impl RandomizedNonceKey { + /// Makes a key from 32 bytes of key material; raises `WrongLength` + /// otherwise. + #[new] + fn new(key: Bytes<'_>) -> PyResult { + let key = (*key) + .try_into() + .map_err(|_| error(rind::Error::WrongLength))?; + Ok(Self(Box::new(aead::RandomizedNonceKey::new(key)))) + } + + /// Encrypts `plaintext` under a fresh random nonce, authenticating `aad` + /// with it, and returns the nonce, which the opener needs, and the + /// ciphertext with the tag appended. + #[pyo3(signature = (plaintext, *, aad))] + fn seal<'py>( + &self, + py: Python<'py>, + plaintext: Bytes<'_>, + aad: Bytes<'_>, + ) -> PyResult<(Bound<'py, PyBytes>, Bound<'py, PyBytes>)> { + let mut sealed = plaintext.to_vec(); + let nonce = self + .0 + .seal_in_place_append_tag(&aad, &mut sealed) + .map_err(error)?; + Ok((bytes(py, nonce), bytes(py, sealed))) + } + + /// Decrypts and verifies `ciphertext`, sealed under `nonce` with `aad`, + /// and returns the plaintext; raises `DecryptFailed` if it does not + /// verify. + #[pyo3(signature = (nonce, ciphertext, *, aad))] + fn open<'py>( + &self, + py: Python<'py>, + nonce: Bytes<'_>, + ciphertext: Bytes<'_>, + aad: Bytes<'_>, + ) -> PyResult> { + let nonce: [u8; 24] = (*nonce) + .try_into() + .map_err(|_| WrongLength::new_err("a nonce is 24 bytes"))?; + let mut sealed = ciphertext.to_vec(); + let plaintext = self + .0 + .open_in_place(nonce.into(), &aad, &mut sealed) + .map_err(error)?; + Ok(bytes(py, plaintext)) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + constants!(m, Algorithm, "pyrind.aead", { + AES_128_GCM => &aead::AES_128_GCM, + AES_192_GCM => &aead::AES_192_GCM, + AES_256_GCM => &aead::AES_256_GCM, + CHACHA20_POLY1305 => &aead::CHACHA20_POLY1305, + }); + Ok(()) +} diff --git a/rind-python/src/agreement.rs b/rind-python/src/agreement.rs new file mode 100644 index 000000000..9a7c7dbfb --- /dev/null +++ b/rind-python/src/agreement.rs @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::agreement; + +use crate::{Bytes, algorithm, bytes, constants, error}; + +algorithm!( + /// A key agreement algorithm. + Algorithm(agreement::Algorithm) as "pyrind.agreement"."Algorithm" {} +); + +/// A private key for key agreement. +#[pyclass(frozen, module = "pyrind.agreement")] +struct PrivateKey(Box); + +#[pymethods] +impl PrivateKey { + /// Generates a key for `algorithm`. + #[staticmethod] + fn generate(algorithm: &Bound<'_, Algorithm>) -> PyResult { + agreement::PrivateKey::generate(algorithm.get().inner) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key for `algorithm` from its big-endian scalar, or for X25519 + /// its 32 bytes. + #[staticmethod] + fn from_scalar(algorithm: &Bound<'_, Algorithm>, scalar: Bytes<'_>) -> PyResult { + agreement::PrivateKey::from_scalar(algorithm.get().inner, &scalar) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key for `algorithm` from PKCS#8 DER. + #[staticmethod] + fn from_pkcs8(algorithm: &Bound<'_, Algorithm>, der: Bytes<'_>) -> PyResult { + agreement::PrivateKey::from_pkcs8(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads an ECDH key for `algorithm` from SEC1 `ECPrivateKey` DER. + #[staticmethod] + fn from_sec1(algorithm: &Bound<'_, Algorithm>, der: Bytes<'_>) -> PyResult { + agreement::PrivateKey::from_sec1(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// The public key to send the peer. + fn public_key(&self) -> PublicKey { + PublicKey(Box::new(self.0.public_key())) + } + + /// Agrees a shared secret with the peer's public key `peer`, as + /// `bytes(PublicKey)` encodes it; raises `KeyRejected` for an invalid + /// one. + fn agree<'py>(&self, py: Python<'py>, peer: Bytes<'_>) -> PyResult> { + let secret = self.0.agree(&peer).map_err(error)?; + Ok(bytes(py, secret)) + } + + /// Encodes the key as PKCS#8 DER. + fn to_pkcs8<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_pkcs8()) + } + + /// Returns the key's scalar, as `from_scalar` takes it. + fn to_scalar<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_scalar()) + } +} + +/// A public key for key agreement, whose `bytes` are what `agree` takes: +/// 32 bytes for X25519, and an uncompressed SEC1 point for ECDH. +#[pyclass(frozen, module = "pyrind.agreement")] +struct PublicKey(Box); + +#[pymethods] +impl PublicKey { + /// Parses a public key for `algorithm` from its `bytes` encoding. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>, data: Bytes<'_>) -> PyResult { + agreement::PublicKey::new(algorithm.get().inner, &data) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Parses a public key for `algorithm` from `SubjectPublicKeyInfo` DER. + #[staticmethod] + fn from_spki(algorithm: &Bound<'_, Algorithm>, der: Bytes<'_>) -> PyResult { + agreement::PublicKey::from_spki(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Encodes the key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } + + fn __bytes__<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, &*self.0) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + constants!(m, Algorithm, "pyrind.agreement", { + X25519 => &agreement::X25519, + ECDH_P256 => &agreement::ECDH_P256, + ECDH_P384 => &agreement::ECDH_P384, + ECDH_P521 => &agreement::ECDH_P521, + }); + Ok(()) +} diff --git a/rind-python/src/digest.rs b/rind-python/src/digest.rs new file mode 100644 index 000000000..5256aafa1 --- /dev/null +++ b/rind-python/src/digest.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::digest; + +use crate::{Bytes, algorithm, bytes, constants, submodule}; + +algorithm!( + /// A hash function. + Algorithm(digest::Algorithm) as "pyrind.digest"."Algorithm" { + /// The digest's length in bytes. + #[getter] + fn output_len(&self) -> usize { + self.inner.output_len() + } + } +); + +/// Returns the digest of `data`. +#[pyfunction(name = "digest")] +fn digest_of<'py>( + py: Python<'py>, + algorithm: &Bound<'_, Algorithm>, + data: Bytes<'_>, +) -> Bound<'py, PyBytes> { + bytes(py, digest::digest(algorithm.get().inner, &data)) +} + +/// A hash of a message given in pieces. +#[pyclass(module = "pyrind.digest")] +struct Context(Box); + +#[pymethods] +impl Context { + /// Starts hashing with `algorithm`. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>) -> Self { + Self(Box::new(digest::Context::new(algorithm.get().inner))) + } + + /// Adds `data` to the message. + fn update(&mut self, data: Bytes<'_>) { + self.0.update(&data); + } + + /// Returns the digest of the message, and starts a new one with the same + /// algorithm. + fn finish<'py>(&mut self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.finish()) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(digest_of, m)?)?; + constants!(m, Algorithm, "pyrind.digest", { + SHA256 => &digest::SHA256, + SHA384 => &digest::SHA384, + SHA512 => &digest::SHA512, + SHA512_256 => &digest::SHA512_256, + SHA3_256 => &digest::SHA3_256, + SHA3_384 => &digest::SHA3_384, + SHA3_512 => &digest::SHA3_512, + }); + submodule(m, "pyrind.digest.legacy", |legacy| { + constants!(legacy, Algorithm, "pyrind.digest.legacy", { + MD5 => &digest::legacy::MD5, + SHA1 => &digest::legacy::SHA1, + }); + Ok(()) + }) +} diff --git a/rind-python/src/hkdf.rs b/rind-python/src/hkdf.rs new file mode 100644 index 000000000..be85e3ca8 --- /dev/null +++ b/rind-python/src/hkdf.rs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::hkdf; + +use crate::{Bytes, aead, algorithm, constants, error, hmac, submodule}; + +algorithm!( + /// An HKDF algorithm. + Algorithm(hkdf::Algorithm) as "pyrind.hkdf"."Algorithm" {} +); + +/// An HKDF salt, which extracts pseudorandom keys from secrets. +#[pyclass(frozen, module = "pyrind.hkdf")] +struct Salt(Box); + +#[pymethods] +impl Salt { + /// Makes a salt for `algorithm` from `value`, of any length. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>, value: Bytes<'_>) -> Self { + Self(Box::new(hkdf::Salt::new(algorithm.get().inner, &value))) + } + + /// Extracts a pseudorandom key from `secret`. + fn extract(&self, secret: Bytes<'_>) -> Prk { + Prk(Box::new(self.0.extract(&secret))) + } +} + +/// What `Prk.expand` derives: bytes of a length, or a key for an algorithm. +#[derive(FromPyObject)] +enum KeyType<'py> { + Len(usize), + Aead(Bound<'py, aead::Algorithm>), + Hmac(Bound<'py, hmac::Algorithm>), +} + +/// A pseudorandom key, which expands into any number of keys. +#[pyclass(frozen, module = "pyrind.hkdf")] +struct Prk(Box); + +#[pymethods] +impl Prk { + /// Expands the key with `info` into `key_type`: that many bytes for an + /// `int`, or an `aead.Key` or `hmac.Key` for their algorithms, whose key + /// bytes never leave rind. + /// + /// Raises `WrongLength` for more than 255 times the hash's length. + fn expand<'py>( + &self, + py: Python<'py>, + info: Bytes<'_>, + key_type: KeyType<'py>, + ) -> PyResult> { + let info: [&[u8]; 1] = [&info]; + Ok(match key_type { + // The longest any hash expands to, so a longer request fails + // before allocating; rind refuses the rest by the hash's length. + KeyType::Len(len) if len > 255 * 64 => return Err(error(rind::Error::WrongLength)), + KeyType::Len(len) => PyBytes::new_with(py, len, |out| { + self.0.expand(&info, out.len()).fill(out).map_err(error) + })? + .into_any(), + KeyType::Aead(algorithm) => { + let key = rind::aead::Key::from(self.0.expand(&info, algorithm.get().inner)); + Bound::new(py, aead::Key(Box::new(key)))?.into_any() + } + KeyType::Hmac(algorithm) => { + let key = rind::hmac::Key::from(self.0.expand(&info, algorithm.get().inner)); + Bound::new(py, hmac::Key(Box::new(key)))?.into_any() + } + }) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + constants!(m, Algorithm, "pyrind.hkdf", { + HKDF_SHA256 => &hkdf::HKDF_SHA256, + HKDF_SHA384 => &hkdf::HKDF_SHA384, + HKDF_SHA512 => &hkdf::HKDF_SHA512, + }); + submodule(m, "pyrind.hkdf.legacy", |legacy| { + constants!(legacy, Algorithm, "pyrind.hkdf.legacy", { + HKDF_SHA1 => &hkdf::legacy::HKDF_SHA1, + }); + Ok(()) + }) +} diff --git a/rind-python/src/hmac.rs b/rind-python/src/hmac.rs new file mode 100644 index 000000000..328e90cec --- /dev/null +++ b/rind-python/src/hmac.rs @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::hmac; + +use crate::{Bytes, algorithm, bytes, constants, error, submodule}; + +algorithm!( + /// An HMAC algorithm. + Algorithm(hmac::Algorithm) as "pyrind.hmac"."Algorithm" { + /// The tag's length in bytes. + #[getter] + fn tag_len(&self) -> usize { + self.inner.tag_len() + } + } +); + +/// An HMAC key, whose keyed hash states are computed once. +#[pyclass(frozen, module = "pyrind.hmac")] +pub(crate) struct Key(pub(crate) Box); + +#[pymethods] +impl Key { + /// Makes a key for `algorithm` from `key`, of any length. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>, key: Bytes<'_>) -> Self { + Self(Box::new(hmac::Key::new(algorithm.get().inner, &key))) + } +} + +/// Returns the tag of `data` under `key`. +#[pyfunction] +fn sign<'py>(py: Python<'py>, key: &Bound<'_, Key>, data: Bytes<'_>) -> Bound<'py, PyBytes> { + bytes(py, hmac::sign(&key.get().0, &data)) +} + +/// Checks in constant time that `tag` is the whole tag of `data` under `key`; +/// raises `BadSignature` otherwise, including for a truncated tag. +#[pyfunction] +fn verify(key: &Bound<'_, Key>, data: Bytes<'_>, tag: Bytes<'_>) -> PyResult<()> { + hmac::verify(&key.get().0, &data, &tag).map_err(error) +} + +/// A tag of a message given in pieces, which signs once. +#[pyclass(module = "pyrind.hmac")] +struct Context(Option>); + +fn signed() -> PyErr { + PyValueError::new_err("the context has already signed") +} + +#[pymethods] +impl Context { + /// Starts a tag under `key`. + #[new] + fn new(key: &Bound<'_, Key>) -> Self { + Self(Some(Box::new(hmac::Context::with_key(&key.get().0)))) + } + + /// Adds `data` to the message. + fn update(&mut self, data: Bytes<'_>) -> PyResult<()> { + self.0.as_mut().ok_or_else(signed)?.update(&data); + Ok(()) + } + + /// Returns the tag of the message; the context takes no more data. + fn sign<'py>(&mut self, py: Python<'py>) -> PyResult> { + // Signs a stack copy and drops the box, so the heap copy gets rind's + // wipe on drop; moving the context out of its box would skip it. + let context = self.0.take().ok_or_else(signed)?; + Ok(bytes(py, (*context).clone().sign())) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(sign, m)?)?; + m.add_function(wrap_pyfunction!(verify, m)?)?; + constants!(m, Algorithm, "pyrind.hmac", { + HMAC_SHA256 => &hmac::HMAC_SHA256, + HMAC_SHA384 => &hmac::HMAC_SHA384, + HMAC_SHA512 => &hmac::HMAC_SHA512, + }); + submodule(m, "pyrind.hmac.legacy", |legacy| { + constants!(legacy, Algorithm, "pyrind.hmac.legacy", { + HMAC_SHA1 => &hmac::legacy::HMAC_SHA1, + }); + Ok(()) + }) +} diff --git a/rind-python/src/kem.rs b/rind-python/src/kem.rs new file mode 100644 index 000000000..f9b84ec1f --- /dev/null +++ b/rind-python/src/kem.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::kem; + +use crate::{Bytes, algorithm, bytes, constants, error}; + +algorithm!( + /// A key encapsulation mechanism. + Algorithm(kem::Algorithm) as "pyrind.kem"."Algorithm" {} +); + +/// The recipient's private key, which decapsulates. +#[pyclass(frozen, module = "pyrind.kem")] +struct DecapsulationKey(Box); + +#[pymethods] +impl DecapsulationKey { + /// Generates a key for `algorithm`. + #[staticmethod] + fn generate(algorithm: &Bound<'_, Algorithm>) -> PyResult { + kem::DecapsulationKey::generate(algorithm.get().inner) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// The public key to send the sender. + fn public_key(&self) -> EncapsulationKey { + EncapsulationKey(Box::new(self.0.public_key())) + } + + /// Recovers the shared secret from the sender's `ciphertext`. + fn decapsulate<'py>( + &self, + py: Python<'py>, + ciphertext: Bytes<'_>, + ) -> PyResult> { + let secret = self.0.decapsulate(&ciphertext).map_err(error)?; + Ok(bytes(py, secret)) + } +} + +/// The recipient's public key, which encapsulates, and whose `bytes` are its +/// encoding. +#[pyclass(frozen, module = "pyrind.kem")] +struct EncapsulationKey(Box); + +#[pymethods] +impl EncapsulationKey { + /// Parses a public key for `algorithm`. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>, data: Bytes<'_>) -> PyResult { + kem::EncapsulationKey::new(algorithm.get().inner, &data) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Encapsulates a fresh shared secret, returning the ciphertext to send + /// the recipient and the secret. + fn encapsulate<'py>( + &self, + py: Python<'py>, + ) -> PyResult<(Bound<'py, PyBytes>, Bound<'py, PyBytes>)> { + let (ciphertext, secret) = self.0.encapsulate().map_err(error)?; + Ok((bytes(py, ciphertext), bytes(py, secret))) + } + + fn __bytes__<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, &*self.0) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + constants!(m, Algorithm, "pyrind.kem", { + ML_KEM_768 => &kem::ML_KEM_768, + }); + Ok(()) +} diff --git a/rind-python/src/lib.rs b/rind-python/src/lib.rs new file mode 100644 index 000000000..ce6afadb0 --- /dev/null +++ b/rind-python/src/lib.rs @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +//! pyrind: rind's API for Python. +//! +//! Each rind module becomes a Python submodule of the same name, importable +//! as `pyrind.`. Keys and contexts are classes; algorithms are module +//! constants of a class per module, so passing one module's algorithm to +//! another's key is a `TypeError`. Every rind error raises a subclass of +//! `pyrind.Error`. +//! +//! Every class holds its rind value in a `Box`. pyo3 places a class's +//! contents inside the Python object, which CPython's allocator aligns to +//! only 16 bytes (8 on 32-bit targets), while some rind types need 32, such +//! as ML-KEM's coefficients that AVX2 loads aligned +//! (). + +use std::borrow::Cow; +use std::mem::MaybeUninit; + +use pyo3::create_exception; +use pyo3::exceptions::{PyException, PyImportError, PyMemoryError}; +use pyo3::ffi; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyDict}; + +mod aead; +mod agreement; +mod digest; +mod hkdf; +mod hmac; +mod kem; +mod pbkdf2; +mod rsa; +mod signature; + +create_exception!(pyrind, Error, PyException, "A rind call failed."); +create_exception!( + pyrind, + WrongLength, + Error, + "An input or output of fixed length, such as a key, a nonce, or a derived key, was another length." +); +create_exception!( + pyrind, + KeyRejected, + Error, + "A private, public, or peer's key was refused; the message says why." +); +create_exception!( + pyrind, + BadSignature, + Error, + "A signature, MAC tag, or PBKDF2 output did not verify." +); +create_exception!( + pyrind, + DecryptFailed, + Error, + "An AEAD ciphertext, its tag, or its associated data did not authenticate, or an RSA-OAEP ciphertext did not decrypt." +); +create_exception!(pyrind, RngFailed, Error, "The system random source failed."); +create_exception!( + pyrind, + Unsupported, + Error, + "A call was given an algorithm it does not take." +); +create_exception!( + pyrind, + Fault, + Error, + "An internal consistency check failed, from a hardware fault or a rind bug." +); + +/// Raises the `pyrind` exception for a rind error. +pub(crate) fn error(e: rind::Error) -> PyErr { + match e { + rind::Error::WrongLength => WrongLength::new_err(()), + rind::Error::KeyRejected(why) => KeyRejected::new_err(why.to_string()), + rind::Error::BadSignature => BadSignature::new_err(()), + rind::Error::DecryptFailed => DecryptFailed::new_err(()), + rind::Error::RngFailed => RngFailed::new_err(()), + rind::Error::Unsupported => Unsupported::new_err(()), + rind::Error::Fault => Fault::new_err(()), + // `NotEqual` comes only from `constant_time`, which pyrind leaves to + // `hmac.compare_digest`; the rest are variants rind adds later. + other => Error::new_err(format!("{other:?}")), + } +} + +/// A bytes-like argument: borrowed from `bytes`, which is immutable, and +/// copied from any other buffer, such as a `bytearray`, `memoryview`, or +/// `array`, so no other thread can change it while rind reads it, even with +/// the GIL released. Like `hashlib`, it takes a buffer's raw bytes whatever +/// its item format or layout. +pub(crate) struct Bytes<'a>(Cow<'a, [u8]>); + +impl<'a, 'py> FromPyObject<'a, 'py> for Bytes<'a> { + type Error = PyErr; + + fn extract(obj: Borrowed<'a, 'py, PyAny>) -> PyResult { + if let Ok(bytes) = <&'a [u8]>::extract(obj) { + return Ok(Self(Cow::Borrowed(bytes))); + } + Ok(Self(Cow::Owned(copy_buffer(&obj)?))) + } +} + +/// Copies the raw bytes of any buffer, in C order, through CPython's own +/// copy, so no Rust reference ever covers memory another thread may write, +/// as a `bytearray` can be on free-threaded CPython. +fn copy_buffer(obj: &Bound<'_, PyAny>) -> PyResult> { + let py = obj.py(); + let mut view = MaybeUninit::::uninit(); + // SAFETY: `obj` is a live object and `view` has room for a `Py_buffer`, + // which this initializes on success. + if unsafe { ffi::PyObject_GetBuffer(obj.as_ptr(), view.as_mut_ptr(), ffi::PyBUF_FULL_RO) } == -1 + { + return Err(PyErr::fetch(py)); + } + // SAFETY: `PyObject_GetBuffer` succeeded, so `view` is initialized and + // must be released, which happens below in place, never moved. + let view = unsafe { view.assume_init_mut() }; + let len = usize::try_from(view.len).expect("a buffer's length is non-negative"); + let mut out = Vec::::with_capacity(len); + // SAFETY: `out` has room for `view.len` bytes, which this copies; the + // view describes its own layout. + let copied = + unsafe { ffi::PyBuffer_ToContiguous(out.as_mut_ptr().cast(), view, view.len, b'C' as _) }; + // SAFETY: `view` came from `PyObject_GetBuffer` and is released once. + unsafe { ffi::PyBuffer_Release(view) }; + if copied == -1 { + return Err(PyErr::fetch(py)); + } + // SAFETY: `PyBuffer_ToContiguous` wrote all `len` bytes. + unsafe { out.set_len(len) }; + Ok(out) +} + +impl core::ops::Deref for Bytes<'_> { + type Target = [u8]; + + fn deref(&self) -> &[u8] { + &self.0 + } +} + +/// Declares the Python class of a module's algorithm constants, each +/// wrapping a rind algorithm, with any further methods. +macro_rules! algorithm { + ($(#[$doc:meta])* $ty:ident($inner:ty) as $module:literal . $name:literal { $($methods:tt)* }) => { + $(#[$doc])* + #[pyclass(frozen, module = $module, name = $name)] + pub(crate) struct $ty { + pub(crate) inner: &'static $inner, + pub(crate) path: &'static str, + } + + #[pymethods] + impl $ty { + fn __repr__(&self) -> &'static str { + self.path + } + + $($methods)* + } + }; +} +pub(crate) use algorithm; + +/// Adds each algorithm constant to the module `m`, whose path is `$module`. +macro_rules! constants { + ($m:expr, $ty:ident, $module:literal, { $($constant:ident => $value:expr),* $(,)? }) => {{ + $($m.add( + stringify!($constant), + $ty { + inner: $value, + path: concat!($module, ".", stringify!($constant)), + }, + )?;)* + }}; +} +pub(crate) use constants; + +/// Returns a new `bytes` of `len` bytes that begins with `prefix`, the rest +/// zeroed, after `fill` has written it in place. +/// +/// Unlike `PyBytes::new_with`, this writes each byte once before `fill` +/// rather than zeroing the whole buffer and then copying `prefix` over it, +/// which for an AEAD's plaintext is a pass over every byte saved. +pub(crate) fn bytes_with<'py>( + py: Python<'py>, + prefix: &[u8], + len: usize, + fill: impl FnOnce(&mut [u8]) -> PyResult<()>, +) -> PyResult> { + assert!(prefix.len() <= len); + let size = isize::try_from(len).map_err(|_| PyMemoryError::new_err(()))?; + // SAFETY: `PyBytes_FromStringAndSize` with a null pointer returns a new + // object, owned here and referenced nowhere else, whose `len` bytes are + // uninitialized, or null with an exception set. Its buffer is copied and + // zeroed into whole before any slice of it exists, and Python sees the + // object only once it is returned. + unsafe { + let object = pyo3::ffi::PyBytes_FromStringAndSize(core::ptr::null(), size); + let bytes = Bound::from_owned_ptr_or_err(py, object)?.cast_into_unchecked::(); + let buffer = pyo3::ffi::PyBytes_AsString(object).cast::(); + core::ptr::copy_nonoverlapping(prefix.as_ptr(), buffer, prefix.len()); + core::ptr::write_bytes(buffer.add(prefix.len()), 0, len - prefix.len()); + fill(core::slice::from_raw_parts_mut(buffer, len))?; + Ok(bytes) + } +} + +/// Returns `bytes` holding `value`. +pub(crate) fn bytes<'py>(py: Python<'py>, value: impl AsRef<[u8]>) -> Bound<'py, PyBytes> { + PyBytes::new(py, value.as_ref()) +} + +/// Adds the submodule `path`, such as `pyrind.signature.verify`, to +/// `parent` under its last component, and to `sys.modules`, so that +/// `import pyrind.signature.verify` finds it. +pub(crate) fn submodule<'py>( + parent: &Bound<'py, PyModule>, + path: &str, + build: impl FnOnce(&Bound<'py, PyModule>) -> PyResult<()>, +) -> PyResult<()> { + let py = parent.py(); + let module = PyModule::new(py, path)?; + build(&module)?; + let name = path.rsplit('.').next().unwrap_or(path); + parent.add(name, &module)?; + py.import("sys")? + .getattr("modules")? + .cast_into::()? + .set_item(path, module) +} + +/// Whether this CPU runs AES and GHASH (or ARM's PMULL) in hardware; where it +/// does not, prefer ChaCha20-Poly1305. +#[pyfunction] +fn has_aes_hardware() -> bool { + rind::cpu::has_aes_hardware() +} + +#[pymodule] +#[pyo3(name = "_rind")] +fn pyrind(m: &Bound<'_, PyModule>) -> PyResult<()> { + // rind panics on a CPU missing a feature it requires; refuse the import + // instead, naming the feature. + rind::cpu::check().map_err(|missing| PyImportError::new_err(missing.to_string()))?; + + let py = m.py(); + m.add("Error", py.get_type::())?; + m.add("WrongLength", py.get_type::())?; + m.add("KeyRejected", py.get_type::())?; + m.add("BadSignature", py.get_type::())?; + m.add("DecryptFailed", py.get_type::())?; + m.add("RngFailed", py.get_type::())?; + m.add("Unsupported", py.get_type::())?; + m.add("Fault", py.get_type::())?; + + submodule(m, "pyrind.aead", aead::register)?; + submodule(m, "pyrind.agreement", agreement::register)?; + submodule(m, "pyrind.cpu", |cpu| { + cpu.add_function(wrap_pyfunction!(has_aes_hardware, cpu)?) + })?; + submodule(m, "pyrind.digest", digest::register)?; + submodule(m, "pyrind.hkdf", hkdf::register)?; + submodule(m, "pyrind.hmac", hmac::register)?; + submodule(m, "pyrind.kem", kem::register)?; + submodule(m, "pyrind.pbkdf2", pbkdf2::register)?; + submodule(m, "pyrind.rsa", rsa::register)?; + submodule(m, "pyrind.signature", signature::register)?; + Ok(()) +} diff --git a/rind-python/src/pbkdf2.rs b/rind-python/src/pbkdf2.rs new file mode 100644 index 000000000..26ba9cead --- /dev/null +++ b/rind-python/src/pbkdf2.rs @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use std::num::NonZeroU32; +use std::ptr; + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::pbkdf2; + +use crate::{Bytes, algorithm, constants, error, submodule}; + +algorithm!( + /// A PBKDF2 algorithm. + Algorithm(pbkdf2::Algorithm) as "pyrind.pbkdf2"."Algorithm" {} +); + +fn iterations_of(iterations: u32) -> PyResult { + NonZeroU32::new(iterations) + .ok_or_else(|| PyValueError::new_err("iterations must be at least 1")) +} + +/// A password keyed into HMAC once, which derives keys for any salt and +/// iteration count. +/// +/// Both calls release the GIL while they run. +#[pyclass(frozen, module = "pyrind.pbkdf2")] +struct Secret { + secret: Box, + hash_len: usize, +} + +/// The output length of `algorithm`'s hash, which rind keeps private; 64, +/// the longest, for any other. +fn hash_len(algorithm: &'static pbkdf2::Algorithm) -> usize { + [ + (&pbkdf2::PBKDF2_HMAC_SHA256, 32), + (&pbkdf2::PBKDF2_HMAC_SHA384, 48), + (&pbkdf2::PBKDF2_HMAC_SHA512, 64), + (&pbkdf2::legacy::PBKDF2_HMAC_SHA1, 20), + ] + .into_iter() + .find_map(|(known, len)| ptr::eq(known, algorithm).then_some(len)) + .unwrap_or(64) +} + +#[pymethods] +impl Secret { + /// Keys `algorithm` with `secret`, a password of any length. + #[new] + fn new(algorithm: &Bound<'_, Algorithm>, secret: Bytes<'_>) -> Self { + let algorithm = algorithm.get().inner; + Self { + secret: Box::new(pbkdf2::Secret::new(algorithm, &secret)), + hash_len: hash_len(algorithm), + } + } + + /// Derives `length` bytes for `salt` with `iterations`. + fn derive<'py>( + &self, + py: Python<'py>, + iterations: u32, + salt: Bytes<'_>, + length: usize, + ) -> PyResult> { + let iterations = iterations_of(iterations)?; + // PBKDF2 derives at most 2**32 - 1 hash outputs, refused here + // before allocating them. + if length as u64 > u64::from(u32::MAX) * self.hash_len as u64 { + return Err(error(rind::Error::WrongLength)); + } + PyBytes::new_with(py, length, |out| { + py.detach(|| self.secret.derive(iterations, &salt, out)) + .map_err(error) + }) + } + + /// Checks in constant time that `previously_derived` is what `derive` + /// returns for `salt` and `iterations`; raises `BadSignature` otherwise. + fn verify( + &self, + py: Python<'_>, + iterations: u32, + salt: Bytes<'_>, + previously_derived: Bytes<'_>, + ) -> PyResult<()> { + let iterations = iterations_of(iterations)?; + py.detach(|| self.secret.verify(iterations, &salt, &previously_derived)) + .map_err(error) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + constants!(m, Algorithm, "pyrind.pbkdf2", { + PBKDF2_HMAC_SHA256 => &pbkdf2::PBKDF2_HMAC_SHA256, + PBKDF2_HMAC_SHA384 => &pbkdf2::PBKDF2_HMAC_SHA384, + PBKDF2_HMAC_SHA512 => &pbkdf2::PBKDF2_HMAC_SHA512, + }); + submodule(m, "pyrind.pbkdf2.legacy", |legacy| { + constants!(legacy, Algorithm, "pyrind.pbkdf2.legacy", { + PBKDF2_HMAC_SHA1 => &pbkdf2::legacy::PBKDF2_HMAC_SHA1, + }); + Ok(()) + }) +} diff --git a/rind-python/src/rsa.rs b/rind-python/src/rsa.rs new file mode 100644 index 000000000..f0943305d --- /dev/null +++ b/rind-python/src/rsa.rs @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::rsa; + +use crate::{Bytes, algorithm, bytes, constants, error, submodule}; + +/// An RSA modulus size for key generation. +#[pyclass(frozen, eq, eq_int, from_py_object, module = "pyrind.rsa")] +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum KeySize { + #[pyo3(name = "RSA_2048")] + Rsa2048, + #[pyo3(name = "RSA_3072")] + Rsa3072, + #[pyo3(name = "RSA_4096")] + Rsa4096, + #[pyo3(name = "RSA_6144")] + Rsa6144, + #[pyo3(name = "RSA_8192")] + Rsa8192, +} + +impl From for rsa::KeySize { + fn from(size: KeySize) -> Self { + match size { + KeySize::Rsa2048 => Self::Rsa2048, + KeySize::Rsa3072 => Self::Rsa3072, + KeySize::Rsa4096 => Self::Rsa4096, + KeySize::Rsa6144 => Self::Rsa6144, + KeySize::Rsa8192 => Self::Rsa8192, + } + } +} + +algorithm!( + /// An RSA-OAEP algorithm. + OaepAlgorithm(rsa::OaepAlgorithm) as "pyrind.rsa"."OaepAlgorithm" {} +); + +/// An RSA-OAEP private key, which decrypts. +#[pyclass(frozen, module = "pyrind.rsa")] +struct DecryptingKey(Box); + +#[pymethods] +impl DecryptingKey { + /// Generates a key of `size` for `algorithm`, releasing the GIL: about + /// 30 milliseconds for 2048 bits, and seconds for the largest sizes. + #[staticmethod] + fn generate( + py: Python<'_>, + algorithm: &Bound<'_, OaepAlgorithm>, + size: KeySize, + ) -> PyResult { + let algorithm = algorithm.get().inner; + py.detach(|| rsa::DecryptingKey::generate(algorithm, size.into())) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a PKCS#8 RSA private key for `algorithm`. + #[staticmethod] + fn from_pkcs8(algorithm: &Bound<'_, OaepAlgorithm>, der: Bytes<'_>) -> PyResult { + rsa::DecryptingKey::from_pkcs8(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a PKCS#1 `RSAPrivateKey` for `algorithm`. + #[staticmethod] + fn from_pkcs1(algorithm: &Bound<'_, OaepAlgorithm>, der: Bytes<'_>) -> PyResult { + rsa::DecryptingKey::from_pkcs1(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// The public key, which encrypts to this key with its algorithm. + fn public_key(&self) -> EncryptingKey { + EncryptingKey(Box::new(self.0.public_key())) + } + + /// Decrypts `ciphertext`, encrypted with `label`, releasing the GIL. + /// + /// Every malformed ciphertext raises the same `DecryptFailed`, and bad + /// padding takes as long to find as good, so revealing the failure is no + /// padding oracle. + #[pyo3(signature = (ciphertext, *, label))] + fn decrypt<'py>( + &self, + py: Python<'py>, + ciphertext: Bytes<'_>, + label: Bytes<'_>, + ) -> PyResult> { + let plaintext = py + .detach(|| self.0.decrypt(&ciphertext, &label)) + .map_err(error)?; + Ok(bytes(py, plaintext)) + } + + /// Encodes the private key as PKCS#8 DER. + fn to_pkcs8<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_pkcs8()) + } + + /// Encodes the public key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } +} + +/// An RSA-OAEP public key, which encrypts. +#[pyclass(frozen, module = "pyrind.rsa")] +struct EncryptingKey(Box); + +#[pymethods] +impl EncryptingKey { + /// Parses a PKCS#1 `RSAPublicKey` or a `SubjectPublicKeyInfo` for + /// `algorithm`. + #[new] + fn new(algorithm: &Bound<'_, OaepAlgorithm>, der: Bytes<'_>) -> PyResult { + rsa::EncryptingKey::new(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key for `algorithm` from its big-endian modulus `n` and + /// exponent `e`, as a JWK carries them. + #[staticmethod] + fn from_components( + algorithm: &Bound<'_, OaepAlgorithm>, + n: Bytes<'_>, + e: Bytes<'_>, + ) -> PyResult { + let components = rsa::PublicKeyComponents { n: &*n, e: &*e }; + rsa::EncryptingKey::from_components(algorithm.get().inner, &components) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Encrypts `message` with `label`, returning a ciphertext as long as the + /// modulus; raises `WrongLength` past `max_message_len`. + #[pyo3(signature = (message, *, label))] + fn encrypt<'py>( + &self, + py: Python<'py>, + message: Bytes<'_>, + label: Bytes<'_>, + ) -> PyResult> { + let ciphertext = self.0.encrypt(&message, &label).map_err(error)?; + Ok(bytes(py, ciphertext)) + } + + /// The longest message this key encrypts. + #[getter] + fn max_message_len(&self) -> usize { + self.0.max_message_len() + } + + /// Encodes the key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + constants!(m, OaepAlgorithm, "pyrind.rsa", { + OAEP_SHA256 => &rsa::OAEP_SHA256, + OAEP_SHA384 => &rsa::OAEP_SHA384, + OAEP_SHA512 => &rsa::OAEP_SHA512, + }); + submodule(m, "pyrind.rsa.legacy", |legacy| { + constants!(legacy, OaepAlgorithm, "pyrind.rsa.legacy", { + OAEP_SHA1 => &rsa::legacy::OAEP_SHA1, + }); + Ok(()) + }) +} diff --git a/rind-python/src/signature.rs b/rind-python/src/signature.rs new file mode 100644 index 000000000..76979f8b5 --- /dev/null +++ b/rind-python/src/signature.rs @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use rind::signature::{self, sign, verify}; + +use crate::rsa::KeySize; +use crate::{Bytes, algorithm, bytes, constants, error, submodule}; + +algorithm!( + /// An algorithm an `EcdsaKeyPair` signs with. + EcdsaSigningAlgorithm(signature::EcdsaSigningAlgorithm) as "pyrind.signature"."EcdsaSigningAlgorithm" {} +); + +algorithm!( + /// An algorithm an `RsaKeyPair` signs with. + RsaSigningAlgorithm(signature::RsaSigningAlgorithm) as "pyrind.signature"."RsaSigningAlgorithm" {} +); + +algorithm!( + /// An algorithm a `PublicKey` verifies with. + VerificationAlgorithm(signature::VerificationAlgorithm) as "pyrind.signature"."VerificationAlgorithm" {} +); + +algorithm!( + /// An RSA algorithm a `PublicKey` verifies with, which also loads a key + /// from its components. + RsaVerificationAlgorithm(signature::RsaVerificationAlgorithm) as "pyrind.signature"."RsaVerificationAlgorithm" {} +); + +/// Either kind of verification algorithm. +#[derive(FromPyObject)] +enum AnyVerificationAlgorithm<'py> { + Plain(Bound<'py, VerificationAlgorithm>), + Rsa(Bound<'py, RsaVerificationAlgorithm>), +} + +impl AnyVerificationAlgorithm<'_> { + fn get(&self) -> &'static signature::VerificationAlgorithm { + match self { + Self::Plain(algorithm) => algorithm.get().inner, + Self::Rsa(algorithm) => algorithm.get().inner.as_ref(), + } + } +} + +/// A public key that verifies signatures for one algorithm, whose `bytes` +/// are its encoding: an uncompressed SEC1 point for ECDSA, 32 bytes for +/// Ed25519, and a PKCS#1 `RSAPublicKey` for RSA. +#[pyclass(frozen, module = "pyrind.signature")] +struct PublicKey(Box); + +#[pymethods] +impl PublicKey { + /// Parses a public key for `algorithm` from its `bytes` encoding. + #[new] + fn new(algorithm: AnyVerificationAlgorithm<'_>, data: Bytes<'_>) -> PyResult { + signature::PublicKey::new(algorithm.get(), &data) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Parses a public key for `algorithm` from `SubjectPublicKeyInfo` DER, + /// refusing one that names another algorithm or curve. + #[staticmethod] + fn from_spki(algorithm: AnyVerificationAlgorithm<'_>, der: Bytes<'_>) -> PyResult { + signature::PublicKey::from_spki(algorithm.get(), &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads an RSA key for `algorithm` from its big-endian modulus `n` and + /// exponent `e`, as a JWK carries them. + #[staticmethod] + fn from_components( + algorithm: &Bound<'_, RsaVerificationAlgorithm>, + n: Bytes<'_>, + e: Bytes<'_>, + ) -> PyResult { + let components = rind::rsa::PublicKeyComponents { n: &*n, e: &*e }; + signature::PublicKey::from_components(algorithm.get().inner, &components) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Checks `signature` over `message`; raises `BadSignature` if it does + /// not verify. + fn verify(&self, message: Bytes<'_>, signature: Bytes<'_>) -> PyResult<()> { + self.0.verify(&message, &signature).map_err(error) + } + + /// Encodes the key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } + + fn __bytes__<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, &*self.0) + } +} + +/// An ECDSA key pair, which signs with one algorithm. +#[pyclass(frozen, module = "pyrind.signature")] +struct EcdsaKeyPair(Box); + +#[pymethods] +impl EcdsaKeyPair { + /// Generates a key pair for `algorithm`. + #[staticmethod] + fn generate(algorithm: &Bound<'_, EcdsaSigningAlgorithm>) -> PyResult { + signature::EcdsaKeyPair::generate(algorithm.get().inner) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key pair for `algorithm` from PKCS#8 DER. + #[staticmethod] + fn from_pkcs8(algorithm: &Bound<'_, EcdsaSigningAlgorithm>, der: Bytes<'_>) -> PyResult { + signature::EcdsaKeyPair::from_pkcs8(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key pair for `algorithm` from SEC1 `ECPrivateKey` DER. + #[staticmethod] + fn from_sec1(algorithm: &Bound<'_, EcdsaSigningAlgorithm>, der: Bytes<'_>) -> PyResult { + signature::EcdsaKeyPair::from_sec1(algorithm.get().inner, &der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key pair for `algorithm` from its big-endian scalar and its + /// uncompressed public key, as a JWK's `d`, `x`, and `y` carry them, + /// refused unless they match. + #[staticmethod] + fn from_scalar_and_public_key( + algorithm: &Bound<'_, EcdsaSigningAlgorithm>, + scalar: Bytes<'_>, + public_key: Bytes<'_>, + ) -> PyResult { + signature::EcdsaKeyPair::from_scalar_and_public_key( + algorithm.get().inner, + &scalar, + &public_key, + ) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// The public key, which verifies with the verification algorithm of the + /// same name. + fn public_key(&self) -> PublicKey { + PublicKey(Box::new(self.0.public_key())) + } + + /// Signs `message`. + fn sign<'py>(&self, py: Python<'py>, message: Bytes<'_>) -> PyResult> { + let signature = self.0.sign(&message).map_err(error)?; + Ok(bytes(py, signature)) + } + + /// Encodes the key pair as PKCS#8 DER. + fn to_pkcs8<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_pkcs8()) + } + + /// Encodes the key pair as SEC1 `ECPrivateKey` DER. + fn to_sec1<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_sec1()) + } + + /// Returns the private key's big-endian scalar. + fn to_scalar<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_scalar()) + } + + /// Encodes the public key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } +} + +/// An Ed25519 key pair. +#[pyclass(frozen, module = "pyrind.signature")] +struct Ed25519KeyPair(Box); + +#[pymethods] +impl Ed25519KeyPair { + /// Generates a key pair. + #[staticmethod] + fn generate() -> PyResult { + signature::Ed25519KeyPair::generate() + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a key pair from PKCS#8 v1 or v2 DER, refused if a v2 key's + /// public key does not match. + #[staticmethod] + fn from_pkcs8(der: Bytes<'_>) -> PyResult { + signature::Ed25519KeyPair::from_pkcs8(&der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a 32-byte seed, as a JWK's `d` holds it, deriving its public + /// key. + #[staticmethod] + fn from_seed(seed: Bytes<'_>) -> PyResult { + signature::Ed25519KeyPair::from_seed(&seed) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a 32-byte seed, refused unless `public_key` is its public key. + #[staticmethod] + fn from_seed_and_public_key(seed: Bytes<'_>, public_key: Bytes<'_>) -> PyResult { + signature::Ed25519KeyPair::from_seed_and_public_key(&seed, &public_key) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// The public key, which verifies with `verify.ED25519`. + fn public_key(&self) -> PublicKey { + PublicKey(Box::new(self.0.public_key())) + } + + /// Signs `message`. + fn sign<'py>(&self, py: Python<'py>, message: Bytes<'_>) -> Bound<'py, PyBytes> { + bytes(py, self.0.sign(&message)) + } + + /// Encodes the key pair as PKCS#8 v2 DER (RFC 5958), with its public key. + fn to_pkcs8<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_pkcs8()) + } + + /// Encodes the private key as PKCS#8 v1 DER (RFC 8410), as OpenSSL + /// writes it. + fn to_pkcs8v1<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_pkcs8v1()) + } + + /// Returns the private key's 32-byte seed. + fn to_seed<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_seed()) + } + + /// Encodes the public key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } +} + +/// An RSA key pair, which signs with any RSA signing algorithm. +#[pyclass(frozen, module = "pyrind.signature")] +struct RsaKeyPair(Box); + +#[pymethods] +impl RsaKeyPair { + /// Generates a key pair of `size`, releasing the GIL: about 30 + /// milliseconds for 2048 bits, and seconds for the largest sizes. + #[staticmethod] + fn generate(py: Python<'_>, size: KeySize) -> PyResult { + py.detach(|| signature::RsaKeyPair::generate(size.into())) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a PKCS#8 RSA private key. + #[staticmethod] + fn from_pkcs8(der: Bytes<'_>) -> PyResult { + signature::RsaKeyPair::from_pkcs8(&der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// Loads a PKCS#1 `RSAPrivateKey`. + #[staticmethod] + fn from_pkcs1(der: Bytes<'_>) -> PyResult { + signature::RsaKeyPair::from_pkcs1(&der) + .map(Box::new) + .map(Self) + .map_err(error) + } + + /// The public key, which verifies what this key pair signs with + /// `algorithm`. + fn public_key(&self, algorithm: &Bound<'_, RsaSigningAlgorithm>) -> PublicKey { + PublicKey(Box::new(self.0.public_key(algorithm.get().inner))) + } + + /// Signs `message` with `algorithm`, releasing the GIL. + fn sign<'py>( + &self, + py: Python<'py>, + algorithm: &Bound<'_, RsaSigningAlgorithm>, + message: Bytes<'_>, + ) -> PyResult> { + let algorithm = algorithm.get().inner; + let signature = py + .detach(|| self.0.sign(algorithm, &message)) + .map_err(error)?; + Ok(bytes(py, signature)) + } + + /// Encodes the private key as PKCS#8 DER. + fn to_pkcs8<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_pkcs8()) + } + + /// Encodes the public key as `SubjectPublicKeyInfo` DER. + fn to_spki<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + bytes(py, self.0.to_spki()) + } +} + +pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + submodule(m, "pyrind.signature.sign", |m| { + constants!(m, EcdsaSigningAlgorithm, "pyrind.signature.sign", { + ECDSA_P256_SHA256_ASN1 => &sign::ECDSA_P256_SHA256_ASN1, + ECDSA_P256_SHA256_FIXED => &sign::ECDSA_P256_SHA256_FIXED, + ECDSA_P384_SHA384_ASN1 => &sign::ECDSA_P384_SHA384_ASN1, + ECDSA_P384_SHA384_FIXED => &sign::ECDSA_P384_SHA384_FIXED, + ECDSA_P521_SHA512_ASN1 => &sign::ECDSA_P521_SHA512_ASN1, + ECDSA_P521_SHA512_FIXED => &sign::ECDSA_P521_SHA512_FIXED, + }); + constants!(m, RsaSigningAlgorithm, "pyrind.signature.sign", { + RSA_PKCS1_SHA256 => &sign::RSA_PKCS1_SHA256, + RSA_PKCS1_SHA384 => &sign::RSA_PKCS1_SHA384, + RSA_PKCS1_SHA512 => &sign::RSA_PKCS1_SHA512, + RSA_PSS_SHA256 => &sign::RSA_PSS_SHA256, + RSA_PSS_SHA384 => &sign::RSA_PSS_SHA384, + RSA_PSS_SHA512 => &sign::RSA_PSS_SHA512, + }); + Ok(()) + })?; + submodule(m, "pyrind.signature.verify", |m| { + constants!(m, VerificationAlgorithm, "pyrind.signature.verify", { + ECDSA_P256_SHA256_ASN1 => &verify::ECDSA_P256_SHA256_ASN1, + ECDSA_P256_SHA256_FIXED => &verify::ECDSA_P256_SHA256_FIXED, + ECDSA_P256_SHA384_ASN1 => &verify::ECDSA_P256_SHA384_ASN1, + ECDSA_P256_SHA512_ASN1 => &verify::ECDSA_P256_SHA512_ASN1, + ECDSA_P384_SHA256_ASN1 => &verify::ECDSA_P384_SHA256_ASN1, + ECDSA_P384_SHA384_ASN1 => &verify::ECDSA_P384_SHA384_ASN1, + ECDSA_P384_SHA384_FIXED => &verify::ECDSA_P384_SHA384_FIXED, + ECDSA_P384_SHA512_ASN1 => &verify::ECDSA_P384_SHA512_ASN1, + ECDSA_P521_SHA256_ASN1 => &verify::ECDSA_P521_SHA256_ASN1, + ECDSA_P521_SHA384_ASN1 => &verify::ECDSA_P521_SHA384_ASN1, + ECDSA_P521_SHA512_ASN1 => &verify::ECDSA_P521_SHA512_ASN1, + ECDSA_P521_SHA512_FIXED => &verify::ECDSA_P521_SHA512_FIXED, + ED25519 => &verify::ED25519, + }); + constants!(m, RsaVerificationAlgorithm, "pyrind.signature.verify", { + RSA_PKCS1_SHA256 => &verify::RSA_PKCS1_SHA256, + RSA_PKCS1_SHA384 => &verify::RSA_PKCS1_SHA384, + RSA_PKCS1_SHA512 => &verify::RSA_PKCS1_SHA512, + RSA_PSS_SHA256 => &verify::RSA_PSS_SHA256, + RSA_PSS_SHA256_ANY_SALT => &verify::RSA_PSS_SHA256_ANY_SALT, + RSA_PSS_SHA384 => &verify::RSA_PSS_SHA384, + RSA_PSS_SHA384_ANY_SALT => &verify::RSA_PSS_SHA384_ANY_SALT, + RSA_PSS_SHA512 => &verify::RSA_PSS_SHA512, + RSA_PSS_SHA512_ANY_SALT => &verify::RSA_PSS_SHA512_ANY_SALT, + }); + submodule(m, "pyrind.signature.verify.legacy", |legacy| { + constants!(legacy, RsaVerificationAlgorithm, "pyrind.signature.verify.legacy", { + RSA_PKCS1_1024_SHA1 => &verify::legacy::RSA_PKCS1_1024_SHA1, + RSA_PKCS1_1024_SHA256 => &verify::legacy::RSA_PKCS1_1024_SHA256, + RSA_PKCS1_1024_SHA512 => &verify::legacy::RSA_PKCS1_1024_SHA512, + RSA_PKCS1_3072_SHA384 => &verify::legacy::RSA_PKCS1_3072_SHA384, + RSA_PKCS1_SHA1 => &verify::legacy::RSA_PKCS1_SHA1, + }); + Ok(()) + }) + }) +} diff --git a/rind-python/tests/test_api.py b/rind-python/tests/test_api.py new file mode 100644 index 000000000..93e2c2197 --- /dev/null +++ b/rind-python/tests/test_api.py @@ -0,0 +1,530 @@ +"""pyrind's API, checked against known answers and against pyca/cryptography, +hashlib, and hmac.""" + +import array +import hashlib +import hmac as std_hmac +import os +import threading +import unittest + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, mlkem, padding +from cryptography.hazmat.primitives.asymmetric import rsa as pyca_rsa +from cryptography.hazmat.primitives.asymmetric import x25519 +from cryptography.hazmat.primitives.ciphers import aead as pyca_aead +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +import pyrind +from pyrind import aead, agreement, cpu, digest, hkdf, hmac, kem, pbkdf2, rsa, signature +from pyrind.signature import sign, verify + + +class Package(unittest.TestCase): + def test_submodules_import(self): + import pyrind.digest.legacy + import pyrind.signature.verify.legacy + + self.assertIs(pyrind.signature.verify.legacy, verify.legacy) + self.assertIs(pyrind.digest.legacy.SHA1, digest.legacy.SHA1) + + def test_constants_name_themselves(self): + self.assertEqual(repr(aead.AES_128_GCM), "pyrind.aead.AES_128_GCM") + self.assertEqual(repr(verify.legacy.RSA_PKCS1_SHA1), "pyrind.signature.verify.legacy.RSA_PKCS1_SHA1") + self.assertEqual(aead.Key.__module__, "pyrind.aead") + + def test_errors_share_a_base(self): + for error in ( + pyrind.WrongLength, + pyrind.KeyRejected, + pyrind.BadSignature, + pyrind.DecryptFailed, + pyrind.RngFailed, + pyrind.Unsupported, + pyrind.Fault, + ): + self.assertTrue(issubclass(error, pyrind.Error)) + + def test_algorithms_are_not_constructible(self): + with self.assertRaises(TypeError): + aead.Algorithm() + + def test_another_modules_algorithm_is_a_type_error(self): + with self.assertRaises(TypeError): + aead.Key(digest.SHA256, bytes(16)) + with self.assertRaises(TypeError): + signature.EcdsaKeyPair.generate(verify.ECDSA_P256_SHA256_ASN1) + + def test_keys_do_not_pickle(self): + import pickle + + with self.assertRaises(TypeError): + pickle.dumps(aead.Key(aead.AES_128_GCM, bytes(16))) + + def test_cpu(self): + self.assertIsInstance(cpu.has_aes_hardware(), bool) + + +class BytesLike(unittest.TestCase): + def test_any_buffer_is_accepted(self): + expected = hashlib.sha256(b"abc").digest() + for data in (b"abc", bytearray(b"abc"), memoryview(b"xabcx")[1:4]): + self.assertEqual(digest.digest(digest.SHA256, data), expected) + + def test_str_is_refused(self): + with self.assertRaises(TypeError): + digest.digest(digest.SHA256, "abc") + + def test_strided_buffer_is_copied(self): + expected = hashlib.sha256(b"ace").digest() + self.assertEqual(digest.digest(digest.SHA256, memoryview(b"abcdef")[::2]), expected) + + def test_any_item_format_is_its_raw_bytes(self): + strided = memoryview(bytearray(range(8))).cast("I")[::2] + for data in (memoryview(b"abc").cast("b"), array.array("H", [1, 2]), strided): + self.assertEqual(digest.digest(digest.SHA256, data), hashlib.sha256(bytes(data)).digest()) + self.assertEqual(digest.digest(digest.SHA256, bytearray()), hashlib.sha256(b"").digest()) + + +class Digest(unittest.TestCase): + ALGORITHMS = { + "sha256": digest.SHA256, + "sha384": digest.SHA384, + "sha512": digest.SHA512, + "sha512_256": digest.SHA512_256, + "sha3_256": digest.SHA3_256, + "sha3_384": digest.SHA3_384, + "sha3_512": digest.SHA3_512, + "md5": digest.legacy.MD5, + "sha1": digest.legacy.SHA1, + } + + def test_matches_hashlib(self): + for name, algorithm in self.ALGORITHMS.items(): + for length in (0, 1, 55, 56, 64, 127, 128, 200, 1000): + data = os.urandom(length) + expected = hashlib.new(name, data).digest() + self.assertEqual(digest.digest(algorithm, data), expected, name) + self.assertEqual(algorithm.output_len, len(expected)) + + def test_context_finishes_and_restarts(self): + context = digest.Context(digest.SHA256) + context.update(b"hello, ") + context.update(b"world") + self.assertEqual(context.finish(), hashlib.sha256(b"hello, world").digest()) + self.assertEqual(context.finish(), hashlib.sha256(b"").digest()) + + +class Hmac(unittest.TestCase): + def test_rfc4231_case_2(self): + key = hmac.Key(hmac.HMAC_SHA256, b"Jefe") + tag = hmac.sign(key, b"what do ya want for nothing?") + self.assertEqual(tag.hex(), "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843") + hmac.verify(key, b"what do ya want for nothing?", tag) + + def test_matches_stdlib(self): + for algorithm, name in ( + (hmac.HMAC_SHA256, "sha256"), + (hmac.HMAC_SHA384, "sha384"), + (hmac.HMAC_SHA512, "sha512"), + (hmac.legacy.HMAC_SHA1, "sha1"), + ): + for key_len in (0, 16, 64, 200): + raw = os.urandom(key_len) + data = os.urandom(100) + expected = std_hmac.digest(raw, data, name) + self.assertEqual(hmac.sign(hmac.Key(algorithm, raw), data), expected) + self.assertEqual(algorithm.tag_len, len(expected)) + + def test_verify_refuses_wrong_and_truncated_tags(self): + key = hmac.Key(hmac.HMAC_SHA256, b"key") + tag = hmac.sign(key, b"data") + with self.assertRaises(pyrind.BadSignature): + hmac.verify(key, b"data", tag[:16]) + with self.assertRaises(pyrind.BadSignature): + hmac.verify(key, b"Data", tag) + + def test_context_signs_once(self): + key = hmac.Key(hmac.HMAC_SHA256, b"key") + context = hmac.Context(key) + context.update(b"da") + context.update(b"ta") + self.assertEqual(context.sign(), hmac.sign(key, b"data")) + with self.assertRaises(ValueError): + context.sign() + with self.assertRaises(ValueError): + context.update(b"more") + + +class Hkdf(unittest.TestCase): + def test_rfc5869_case_1(self): + prk = hkdf.Salt(hkdf.HKDF_SHA256, bytes.fromhex("000102030405060708090a0b0c")).extract(b"\x0b" * 22) + okm = prk.expand(bytes.fromhex("f0f1f2f3f4f5f6f7f8f9"), 42) + self.assertEqual( + okm.hex(), + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865", + ) + + def test_matches_pyca(self): + salt, secret, info = os.urandom(16), os.urandom(32), os.urandom(10) + expected = HKDF(hashes.SHA384(), 100, salt, info).derive(secret) + self.assertEqual(hkdf.Salt(hkdf.HKDF_SHA384, salt).extract(secret).expand(info, 100), expected) + + def test_expands_into_keys(self): + prk = hkdf.Salt(hkdf.HKDF_SHA256, b"salt").extract(b"secret") + key = prk.expand(b"aead", aead.AES_256_GCM) + self.assertIsInstance(key, aead.Key) + raw = prk.expand(b"aead", 32) + nonce = bytes(12) + self.assertEqual( + key.seal(nonce, b"m", aad=b""), + aead.Key(aead.AES_256_GCM, raw).seal(nonce, b"m", aad=b""), + ) + mac = prk.expand(b"hmac", hmac.HMAC_SHA256) + self.assertEqual(hmac.sign(mac, b"m"), hmac.sign(hmac.Key(hmac.HMAC_SHA256, prk.expand(b"hmac", 32)), b"m")) + + def test_too_long_is_wrong_length(self): + prk = hkdf.Salt(hkdf.HKDF_SHA256, b"").extract(b"secret") + with self.assertRaises(pyrind.WrongLength): + prk.expand(b"", 255 * 32 + 1) + # Refused before allocating, rather than raising MemoryError. + with self.assertRaises(pyrind.WrongLength): + prk.expand(b"", 1 << 62) + + +class Pbkdf2(unittest.TestCase): + def test_matches_pyca(self): + secret = pbkdf2.Secret(pbkdf2.PBKDF2_HMAC_SHA256, b"password") + expected = PBKDF2HMAC(hashes.SHA256(), 40, b"salt", 1000).derive(b"password") + self.assertEqual(secret.derive(1000, b"salt", 40), expected) + secret.verify(1000, b"salt", expected) + with self.assertRaises(pyrind.BadSignature): + secret.verify(1001, b"salt", expected) + + def test_rfc6070_sha1(self): + secret = pbkdf2.Secret(pbkdf2.legacy.PBKDF2_HMAC_SHA1, b"password") + self.assertEqual(secret.derive(2, b"salt", 20).hex(), "ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957") + + def test_zero_iterations_is_refused(self): + secret = pbkdf2.Secret(pbkdf2.PBKDF2_HMAC_SHA256, b"password") + with self.assertRaises(ValueError): + secret.derive(0, b"salt", 32) + with self.assertRaises(OverflowError): + secret.derive(-1, b"salt", 32) + + def test_too_long_is_wrong_length(self): + # One byte past each hash's limit, refused before allocating it. + for algorithm, hash_len in ( + (pbkdf2.PBKDF2_HMAC_SHA256, 32), + (pbkdf2.PBKDF2_HMAC_SHA384, 48), + (pbkdf2.PBKDF2_HMAC_SHA512, 64), + (pbkdf2.legacy.PBKDF2_HMAC_SHA1, 20), + ): + secret = pbkdf2.Secret(algorithm, b"password") + with self.assertRaises(pyrind.WrongLength): + secret.derive(1, b"salt", (2**32 - 1) * hash_len + 1) + + def test_releases_the_gil(self): + secret = pbkdf2.Secret(pbkdf2.PBKDF2_HMAC_SHA256, b"password") + results = [] + threads = [threading.Thread(target=lambda: results.append(secret.derive(20000, b"salt", 32))) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertEqual(len(set(results)), 1) + + +class Aead(unittest.TestCase): + PYCA = { + id(aead.AES_128_GCM): pyca_aead.AESGCM, + id(aead.AES_192_GCM): pyca_aead.AESGCM, + id(aead.AES_256_GCM): pyca_aead.AESGCM, + id(aead.CHACHA20_POLY1305): pyca_aead.ChaCha20Poly1305, + } + + def test_matches_pyca(self): + for algorithm in (aead.AES_128_GCM, aead.AES_192_GCM, aead.AES_256_GCM, aead.CHACHA20_POLY1305): + raw = os.urandom(algorithm.key_len) + key = aead.Key(algorithm, raw) + other = self.PYCA[id(algorithm)](raw) + for length in (0, 1, 15, 16, 17, 255, 1350, 4096): + nonce = os.urandom(algorithm.nonce_len) + plaintext, aad = os.urandom(length), os.urandom(length % 40) + sealed = key.seal(nonce, plaintext, aad=aad) + self.assertEqual(sealed, other.encrypt(nonce, plaintext, aad)) + self.assertEqual(len(sealed), length + algorithm.tag_len) + self.assertEqual(key.open(nonce, sealed, aad=aad), plaintext) + + def test_tampering_fails_to_open(self): + key = aead.Key(aead.AES_128_GCM, bytes(16)) + nonce = bytes(12) + sealed = key.seal(nonce, b"message", aad=b"header") + with self.assertRaises(pyrind.DecryptFailed): + key.open(nonce, sealed, aad=b"Header") + with self.assertRaises(pyrind.DecryptFailed): + key.open(nonce, sealed[:-1] + bytes([sealed[-1] ^ 1]), aad=b"header") + with self.assertRaises(pyrind.DecryptFailed): + key.open(nonce, sealed[:15], aad=b"header") + + def test_lengths_are_checked(self): + with self.assertRaises(pyrind.WrongLength): + aead.Key(aead.AES_128_GCM, bytes(32)) + key = aead.Key(aead.CHACHA20_POLY1305, bytes(32)) + with self.assertRaises(pyrind.WrongLength): + key.seal(bytes(8), b"", aad=b"") + + def test_aad_is_keyword_only_and_required(self): + key = aead.Key(aead.AES_128_GCM, bytes(16)) + with self.assertRaises(TypeError): + key.seal(bytes(12), b"message", b"header") + with self.assertRaises(TypeError): + key.seal(bytes(12), b"message") + + def test_randomized_nonce_key(self): + raw = os.urandom(32) + key = aead.RandomizedNonceKey(raw) + nonce, sealed = key.seal(b"message", aad=b"header") + _, again = key.seal(b"message", aad=b"header") + self.assertEqual(len(nonce), 24) + self.assertNotEqual(sealed, again) + self.assertEqual(key.open(nonce, sealed, aad=b"header"), b"message") + with self.assertRaises(pyrind.DecryptFailed): + key.open(nonce, sealed, aad=b"") + with self.assertRaises(pyrind.WrongLength): + key.open(nonce[:12], sealed, aad=b"header") + with self.assertRaises(pyrind.WrongLength): + aead.RandomizedNonceKey(bytes(16)) + + +class Agreement(unittest.TestCase): + def test_x25519_rfc7748(self): + alice = agreement.PrivateKey.from_scalar( + agreement.X25519, + bytes.fromhex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"), + ) + self.assertEqual( + bytes(alice.public_key()).hex(), + "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a", + ) + bob = bytes.fromhex("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f") + self.assertEqual( + alice.agree(bob).hex(), + "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742", + ) + + def test_x25519_matches_pyca(self): + ours = agreement.PrivateKey.generate(agreement.X25519) + theirs = x25519.X25519PrivateKey.generate() + their_public = theirs.public_key().public_bytes_raw() + self.assertEqual( + ours.agree(their_public), + theirs.exchange(x25519.X25519PublicKey.from_public_bytes(bytes(ours.public_key()))), + ) + + def test_ecdh_matches_pyca(self): + for algorithm, curve in ( + (agreement.ECDH_P256, ec.SECP256R1()), + (agreement.ECDH_P384, ec.SECP384R1()), + (agreement.ECDH_P521, ec.SECP521R1()), + ): + ours = agreement.PrivateKey.generate(algorithm) + theirs = ec.generate_private_key(curve) + their_public = theirs.public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint + ) + our_public = ec.EllipticCurvePublicKey.from_encoded_point(curve, bytes(ours.public_key())) + self.assertEqual(ours.agree(their_public), theirs.exchange(ec.ECDH(), our_public)) + + def test_keys_round_trip(self): + for algorithm in (agreement.X25519, agreement.ECDH_P256): + key = agreement.PrivateKey.generate(algorithm) + public = bytes(key.public_key()) + for loaded in ( + agreement.PrivateKey.from_pkcs8(algorithm, key.to_pkcs8()), + agreement.PrivateKey.from_scalar(algorithm, key.to_scalar()), + ): + self.assertEqual(bytes(loaded.public_key()), public) + spki = key.public_key().to_spki() + self.assertEqual(bytes(agreement.PublicKey.from_spki(algorithm, spki)), public) + self.assertEqual(bytes(agreement.PublicKey(algorithm, public)), public) + + def test_invalid_peer_is_key_rejected(self): + key = agreement.PrivateKey.generate(agreement.ECDH_P256) + with self.assertRaises(pyrind.KeyRejected) as raised: + key.agree(b"\x04" + bytes(64)) + self.assertTrue(str(raised.exception)) + + +class Signature(unittest.TestCase): + def test_ed25519_rfc8032_test_1(self): + key = signature.Ed25519KeyPair.from_seed( + bytes.fromhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") + ) + self.assertEqual( + bytes(key.public_key()).hex(), + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + ) + signed = key.sign(b"") + self.assertEqual( + signed.hex(), + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b", + ) + key.public_key().verify(b"", signed) + with self.assertRaises(pyrind.BadSignature): + key.public_key().verify(b"x", signed) + + def test_ed25519_matches_pyca(self): + ours = signature.Ed25519KeyPair.generate() + theirs = ed25519.Ed25519PrivateKey.from_private_bytes(ours.to_seed()) + self.assertEqual(ours.sign(b"message"), theirs.sign(b"message")) + loaded = serialization.load_der_private_key(ours.to_pkcs8v1(), None) + self.assertEqual(loaded.sign(b"m"), ours.sign(b"m")) + self.assertEqual(signature.Ed25519KeyPair.from_pkcs8(ours.to_pkcs8()).to_seed(), ours.to_seed()) + + def test_ecdsa_against_pyca(self): + for algorithm, check, curve, hash_ in ( + (sign.ECDSA_P256_SHA256_ASN1, verify.ECDSA_P256_SHA256_ASN1, ec.SECP256R1(), hashes.SHA256()), + (sign.ECDSA_P384_SHA384_ASN1, verify.ECDSA_P384_SHA384_ASN1, ec.SECP384R1(), hashes.SHA384()), + (sign.ECDSA_P521_SHA512_ASN1, verify.ECDSA_P521_SHA512_ASN1, ec.SECP521R1(), hashes.SHA512()), + ): + ours = signature.EcdsaKeyPair.generate(algorithm) + their_view = serialization.load_der_public_key(ours.to_spki()) + their_view.verify(ours.sign(b"message"), b"message", ec.ECDSA(hash_)) + + theirs = ec.generate_private_key(curve) + der = theirs.private_bytes( + serialization.Encoding.DER, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ) + loaded = signature.EcdsaKeyPair.from_pkcs8(algorithm, der) + spki = theirs.public_key().public_bytes( + serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo + ) + public = signature.PublicKey.from_spki(check, spki) + public.verify(b"message", theirs.sign(b"message", ec.ECDSA(hash_))) + public.verify(b"message", loaded.sign(b"message")) + with self.assertRaises(pyrind.BadSignature): + public.verify(b"massage", loaded.sign(b"message")) + + def test_ecdsa_fixed_and_encodings(self): + key = signature.EcdsaKeyPair.generate(sign.ECDSA_P256_SHA256_FIXED) + signed = key.sign(b"m") + self.assertEqual(len(signed), 64) + public = signature.PublicKey(verify.ECDSA_P256_SHA256_FIXED, bytes(key.public_key())) + public.verify(b"m", signed) + for loaded in ( + signature.EcdsaKeyPair.from_sec1(sign.ECDSA_P256_SHA256_FIXED, key.to_sec1()), + signature.EcdsaKeyPair.from_scalar_and_public_key( + sign.ECDSA_P256_SHA256_FIXED, key.to_scalar(), bytes(key.public_key()) + ), + ): + public.verify(b"m", loaded.sign(b"m")) + with self.assertRaises(pyrind.KeyRejected): + signature.EcdsaKeyPair.from_pkcs8(sign.ECDSA_P384_SHA384_ASN1, key.to_pkcs8()) + + def test_rsa_against_pyca(self): + theirs = pyca_rsa.generate_private_key(65537, 2048) + der = theirs.private_bytes( + serialization.Encoding.DER, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ) + ours = signature.RsaKeyPair.from_pkcs8(der) + their_public = theirs.public_key() + for algorithm, check, pad in ( + (sign.RSA_PKCS1_SHA256, verify.RSA_PKCS1_SHA256, padding.PKCS1v15()), + (sign.RSA_PSS_SHA256, verify.RSA_PSS_SHA256, padding.PSS(padding.MGF1(hashes.SHA256()), 32)), + ): + their_public.verify(ours.sign(algorithm, b"message"), b"message", pad, hashes.SHA256()) + public = signature.PublicKey.from_spki(check, ours.to_spki()) + public.verify(b"message", theirs.sign(b"message", pad, hashes.SHA256())) + self.assertEqual(bytes(ours.public_key(algorithm)), bytes(public)) + numbers = their_public.public_numbers() + n = numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big") + e = numbers.e.to_bytes(3, "big") + public = signature.PublicKey.from_components(verify.RSA_PKCS1_SHA256, n, e) + public.verify(b"m", ours.sign(sign.RSA_PKCS1_SHA256, b"m")) + with self.assertRaises(TypeError): + signature.PublicKey.from_components(verify.ED25519, n, e) + + def test_rsa_generate(self): + key = signature.RsaKeyPair.generate(rsa.KeySize.RSA_2048) + public = signature.PublicKey.from_spki(verify.RSA_PSS_SHA256, key.to_spki()) + public.verify(b"m", key.sign(sign.RSA_PSS_SHA256, b"m")) + loaded = serialization.load_der_private_key(key.to_pkcs8(), None) + self.assertEqual(loaded.key_size, 2048) + + def test_garbage_keys_are_rejected(self): + for call in ( + lambda: signature.Ed25519KeyPair.from_pkcs8(b"\x30\x00"), + lambda: signature.RsaKeyPair.from_pkcs1(os.urandom(100)), + lambda: signature.PublicKey(verify.ECDSA_P256_SHA256_ASN1, os.urandom(65)), + lambda: signature.PublicKey.from_spki(verify.ED25519, b""), + ): + with self.assertRaises(pyrind.KeyRejected): + call() + + +class Rsa(unittest.TestCase): + def test_oaep_against_pyca(self): + theirs = pyca_rsa.generate_private_key(65537, 2048) + der = theirs.private_bytes( + serialization.Encoding.DER, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ) + ours = rsa.DecryptingKey.from_pkcs8(rsa.OAEP_SHA256, der) + pad = padding.OAEP(padding.MGF1(hashes.SHA256()), hashes.SHA256(), b"label") + self.assertEqual(ours.decrypt(theirs.public_key().encrypt(b"secret", pad), label=b"label"), b"secret") + encrypting = rsa.EncryptingKey(rsa.OAEP_SHA256, ours.to_spki()) + self.assertEqual(theirs.decrypt(encrypting.encrypt(b"secret", label=b"label"), pad), b"secret") + self.assertEqual(encrypting.max_message_len, 256 - 2 * 32 - 2) + with self.assertRaises(pyrind.WrongLength): + encrypting.encrypt(bytes(encrypting.max_message_len + 1), label=b"") + with self.assertRaises(pyrind.DecryptFailed): + ours.decrypt(encrypting.encrypt(b"secret", label=b"label"), label=b"other") + + def test_generate(self): + key = rsa.DecryptingKey.generate(rsa.OAEP_SHA512, rsa.KeySize.RSA_2048) + ciphertext = key.public_key().encrypt(b"m", label=b"") + self.assertEqual(key.decrypt(ciphertext, label=b""), b"m") + self.assertEqual(rsa.KeySize.RSA_2048, rsa.KeySize.RSA_2048) + self.assertNotEqual(rsa.KeySize.RSA_2048, rsa.KeySize.RSA_3072) + + +class Kem(unittest.TestCase): + def test_keys_at_any_address(self): + # rind's AVX2 ML-KEM loads key data aligned to 32 bytes, which an + # object's own storage only reaches by chance; holding many keys at + # once places some of them off it. + keys = [kem.DecapsulationKey.generate(kem.ML_KEM_768) for _ in range(32)] + public = [key.public_key() for key in keys] + for key, encapsulation_key in zip(keys, public): + ciphertext, secret = encapsulation_key.encapsulate() + self.assertEqual(key.decapsulate(ciphertext), secret) + + def test_against_pyca(self): + ours = kem.DecapsulationKey.generate(kem.ML_KEM_768) + their_view = mlkem.MLKEM768PublicKey.from_public_bytes(bytes(ours.public_key())) + secret, ciphertext = their_view.encapsulate() + self.assertEqual(ours.decapsulate(ciphertext), secret) + + theirs = mlkem.MLKEM768PrivateKey.generate() + ciphertext, secret = kem.EncapsulationKey(kem.ML_KEM_768, theirs.public_key().public_bytes_raw()).encapsulate() + self.assertEqual(theirs.decapsulate(ciphertext), secret) + + def test_round_trip(self): + recipient = kem.DecapsulationKey.generate(kem.ML_KEM_768) + public = kem.EncapsulationKey(kem.ML_KEM_768, bytes(recipient.public_key())) + ciphertext, secret = public.encapsulate() + self.assertEqual(len(secret), 32) + self.assertEqual(recipient.decapsulate(ciphertext), secret) + # A tampered ciphertext yields an unrelated secret, not an error. + tampered = bytes([ciphertext[0] ^ 1]) + ciphertext[1:] + self.assertNotEqual(recipient.decapsulate(tampered), secret) + with self.assertRaises(pyrind.Error): + recipient.decapsulate(ciphertext[:-1]) + with self.assertRaises(pyrind.Error): + kem.EncapsulationKey(kem.ML_KEM_768, bytes(100)) + + +if __name__ == "__main__": + unittest.main() diff --git a/rind-python/tests/test_readme.py b/rind-python/tests/test_readme.py new file mode 100644 index 000000000..4b5ffb880 --- /dev/null +++ b/rind-python/tests/test_readme.py @@ -0,0 +1,19 @@ +"""The README's example runs as written.""" + +import pathlib +import re +import unittest + +README = pathlib.Path(__file__).resolve().parents[1] / "README.md" + + +class Readme(unittest.TestCase): + def test_example_runs(self): + blocks = re.findall(r"```python\n(.*?)```", README.read_text(), re.S) + self.assertTrue(blocks) + for block in blocks: + exec(compile(block, str(README), "exec"), {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/rind-python/tests/test_wycheproof.py b/rind-python/tests/test_wycheproof.py new file mode 100644 index 000000000..e901faf9d --- /dev/null +++ b/rind-python/tests/test_wycheproof.py @@ -0,0 +1,155 @@ +"""Wycheproof's vectors through pyrind, so that every malformed input raises a +`pyrind.Error` rather than crashing, and every valid one passes. + +rind runs the full suites itself; these check the binding's mapping of inputs +and errors. A vector Wycheproof calls acceptable may go either way. +""" + +import json +import pathlib +import unittest + +import pyrind +from pyrind import aead, agreement, hkdf, hmac, kem, rsa, signature +from pyrind.signature import verify + +VECTORS = pathlib.Path(__file__).resolve().parents[2] / "thirdparty" / "wycheproof" / "testvectors_v1" + + +def groups(name): + path = VECTORS / f"{name}_test.json" + if not path.exists(): + raise FileNotFoundError(f"{path} is missing; run: git submodule update --init thirdparty/wycheproof") + return json.loads(path.read_text())["testGroups"] + + +class Wycheproof(unittest.TestCase): + def run_vectors(self, name, check, select=lambda group: True): + count = 0 + for group in groups(name): + if not select(group): + continue + for test in group["tests"]: + with self.subTest(file=name, tcId=test["tcId"]): + try: + check(group, test) + except pyrind.Error: + self.assertNotEqual(test["result"], "valid", test.get("comment")) + else: + self.assertNotEqual(test["result"], "invalid", test.get("comment")) + count += 1 + self.assertGreater(count, 0) + + def test_aead(self): + def check(algorithm): + def run(group, test): + key = aead.Key(algorithm[group["keySize"]], bytes.fromhex(test["key"])) + nonce, aad = bytes.fromhex(test["iv"]), bytes.fromhex(test["aad"]) + msg = bytes.fromhex(test["msg"]) + sealed = bytes.fromhex(test["ct"] + test["tag"]) + self.assertEqual(key.open(nonce, sealed, aad=aad), msg) + self.assertEqual(key.seal(nonce, msg, aad=aad), sealed) + + return run + + # rind takes only 96-bit nonces, which these files' other groups vary. + def standard(group): + return group["ivSize"] == 96 and group["tagSize"] == 128 + + gcm = {128: aead.AES_128_GCM, 192: aead.AES_192_GCM, 256: aead.AES_256_GCM} + self.run_vectors("aes_gcm", check(gcm), standard) + self.run_vectors("chacha20_poly1305", check({256: aead.CHACHA20_POLY1305}), standard) + + def test_xchacha20_poly1305(self): + def run(group, test): + key = aead.RandomizedNonceKey(bytes.fromhex(test["key"])) + sealed = bytes.fromhex(test["ct"] + test["tag"]) + opened = key.open(bytes.fromhex(test["iv"]), sealed, aad=bytes.fromhex(test["aad"])) + self.assertEqual(opened, bytes.fromhex(test["msg"])) + + self.run_vectors("xchacha20_poly1305", run, lambda group: group["tagSize"] == 128) + + def test_hmac(self): + def run(group, test): + key = hmac.Key(hmac.HMAC_SHA256, bytes.fromhex(test["key"])) + hmac.verify(key, bytes.fromhex(test["msg"]), bytes.fromhex(test["tag"])) + + # rind verifies whole tags only. + self.run_vectors("hmac_sha256", run, lambda group: group["tagSize"] == 256) + + def test_hkdf(self): + def run(group, test): + prk = hkdf.Salt(hkdf.HKDF_SHA256, bytes.fromhex(test["salt"])).extract(bytes.fromhex(test["ikm"])) + self.assertEqual(prk.expand(bytes.fromhex(test["info"]), test["size"]), bytes.fromhex(test["okm"])) + + self.run_vectors("hkdf_sha256", run) + + def test_x25519(self): + def run(group, test): + key = agreement.PrivateKey.from_scalar(agreement.X25519, bytes.fromhex(test["private"])) + self.assertEqual(key.agree(bytes.fromhex(test["public"])), bytes.fromhex(test["shared"])) + + self.run_vectors("x25519", run) + + def test_ecdh_p256(self): + def run(group, test): + key = agreement.PrivateKey.from_scalar(agreement.ECDH_P256, int(test["private"], 16).to_bytes(32, "big")) + self.assertEqual(key.agree(bytes.fromhex(test["public"])), bytes.fromhex(test["shared"])) + + self.run_vectors("ecdh_secp256r1_ecpoint", run) + + def verify_vectors(self, name, algorithm, key_of): + def run(group, test): + public = key_of(group) + public.verify(bytes.fromhex(test["msg"]), bytes.fromhex(test["sig"])) + + self.run_vectors(name, run) + + def test_ed25519(self): + self.verify_vectors( + "ed25519", + verify.ED25519, + lambda group: signature.PublicKey(verify.ED25519, bytes.fromhex(group["publicKey"]["pk"])), + ) + + def test_ecdsa_p256(self): + algorithm = verify.ECDSA_P256_SHA256_ASN1 + self.verify_vectors( + "ecdsa_secp256r1_sha256", + algorithm, + lambda group: signature.PublicKey.from_spki(algorithm, bytes.fromhex(group["publicKeyDer"])), + ) + + def test_rsa_pkcs1(self): + algorithm = verify.RSA_PKCS1_SHA256 + self.verify_vectors( + "rsa_signature_2048_sha256", + algorithm, + lambda group: signature.PublicKey.from_spki(algorithm, bytes.fromhex(group["publicKeyDer"])), + ) + + def test_rsa_pss(self): + algorithm = verify.RSA_PSS_SHA256 + self.verify_vectors( + "rsa_pss_2048_sha256_mgf1_32", + algorithm, + lambda group: signature.PublicKey.from_spki(algorithm, bytes.fromhex(group["publicKeyDer"])), + ) + + def test_rsa_oaep(self): + def run(group, test): + key = rsa.DecryptingKey.from_pkcs8(rsa.OAEP_SHA256, bytes.fromhex(group["privateKeyPkcs8"])) + plaintext = key.decrypt(bytes.fromhex(test["ct"]), label=bytes.fromhex(test["label"])) + self.assertEqual(plaintext, bytes.fromhex(test["msg"])) + + self.run_vectors("rsa_oaep_2048_sha256_mgf1sha256", run) + + def test_ml_kem_encapsulation_keys(self): + def run(group, test): + kem.EncapsulationKey(kem.ML_KEM_768, bytes.fromhex(test["ek"])) + + self.run_vectors("mlkem_768_encaps", run) + + +if __name__ == "__main__": + unittest.main() diff --git a/sh/dispatch.sh b/sh/dispatch.sh index 5616af041..e8371e0cf 100755 --- a/sh/dispatch.sh +++ b/sh/dispatch.sh @@ -55,7 +55,7 @@ declare -A scope=( [deny]='(^|/)Cargo\.(toml|lock)$|^deny\.toml$' # An adapter's manifest holds both its version and its upstream requirement. [versions]='^(rind-[^/]+/Cargo\.toml$|sh/adapter-versions\.sh$)' - [license]='^(rind/src/|admin/scan-license$)' + [license]='^(rind/src/|rind/LICENSE$|rind-python/LICENSE$|admin/scan-license$)' # Dependents may build with the workspace's declared rust-version. [msrv]='^(rind[^/]*/|fuzz/|Cargo\.(toml|lock)$|flake\.(nix|lock)$)' # rind's aarch64 and armv7 backends, which the host's own build skips. @@ -72,6 +72,9 @@ declare -A scope=( [fixtures]='^(rind/|rind-rustls/|Cargo\.toml$|admin/build-cost-fixtures/(rind|rustls)/)' # The fixture builds this checkout's rind-ring under the rind-patch shim. [patch]='^(patch/|rind-ring/|(rind/)?Cargo\.toml$|sh/patch\.sh$)' + # pyrind's Python tests and type stubs, against its built wheel. rind's + # own changes reach them in the nightly wheels workflow instead. + [py]='^(rind-python/|sh/python\.sh$)' # rind-dart's Dart package, whose build hook compiles the crate. [dart]='^rind-dart/' # Quest documents form one graph, so any change validates the whole tree, @@ -102,6 +105,7 @@ declare -A tools=( [bench]='python3' [fixtures]='cargo' [patch]='cargo jq git' + [py]='cargo jq maturin python3' [dart]='nix' [quest]='quest' [gh]='actionlint yq jq' @@ -114,7 +118,7 @@ verb=$action [[ "$verb" != ci ]] || verb=check case "$verb" in - check) modules=(rs msrv cross deny versions license inplace compat bench fixtures patch dart quest gh hooks shell nix) ;; + check) modules=(rs msrv cross deny versions license inplace compat bench fixtures patch py dart quest gh hooks shell nix) ;; fix) modules=(rs compat patch dart nix) ;; esac @@ -228,6 +232,7 @@ for module in "${selected[@]}"; do cargo check --manifest-path admin/build-cost-fixtures/rustls/Cargo.toml --features rind-lean --target-dir target ;; check:patch) sh/patch.sh ;; + check:py) sh/python.sh test ;; check:dart) nix develop .#dart --command sh/dart.sh check ;; fix:dart) nix develop .#dart --command sh/dart.sh fix ;; fix:patch) cargo fmt --manifest-path patch/fixture/Cargo.toml ;; diff --git a/sh/pyrind-unpublished.sh b/sh/pyrind-unpublished.sh new file mode 100755 index 000000000..358c79cbd --- /dev/null +++ b/sh/pyrind-unpublished.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Print the tag of pyrind's current version if release-plz has released it +# and PyPI does not have all of it yet, or nothing. +# +# Usage: sh/pyrind-unpublished.sh +# release-plz tags rind-python when a release PR lands, since it never goes +# to crates.io; this lets release.yml publish that tag to PyPI, and retry on a +# later push if publishing failed. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +version=$(cargo metadata --format-version 1 --no-deps --locked | + jq -r '.packages[] | select(.name == "rind-python") | .version') +tag="rind-python-v$version" + +# Exit status 2 means no such tag: this version is unreleased. +status=0 +git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null || status=$? +case "$status" in + 0) ;; + 2) exit 0 ;; + *) exit "$status" ;; +esac + +# twine uploads the wheels before the sdist, so a version whose sdist is on +# PyPI is complete; one without it may be partly uploaded, and release.yml +# skips the files already there. +json=$(mktemp) +trap 'rm -f "$json"' EXIT +status=$(curl --silent --output "$json" --write-out '%{http_code}' "https://pypi.org/pypi/pyrind/$version/json") +case "$status" in + 200) + if ! jq -e '.urls | any(.packagetype == "sdist")' "$json" >/dev/null; then + echo "$tag" + fi + ;; + 404) echo "$tag" ;; + *) + echo "error: PyPI answered $status for pyrind $version" >&2 + exit 1 + ;; +esac diff --git a/sh/python.sh b/sh/python.sh new file mode 100755 index 000000000..a50b47474 --- /dev/null +++ b/sh/python.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Build pyrind's wheel and run its tests, or its benchmarks, against it. +# +# Usage: sh/python.sh test|bench [ARGS...] +# The tests need pyca/cryptography, the differential oracle, mypy, and the +# Wycheproof submodule; the benchmarks compare against cryptography, +# hashlib, and hmac. ARGS go to unittest or the benchmark script. +set -euo pipefail + +usage="usage: sh/python.sh test|bench [ARGS...]" +action=${1:?$usage} +shift + +cd "$(git rev-parse --show-toplevel)" + +out=$(cargo metadata --format-version 1 --no-deps | jq -r .target_directory)/pyrind +rm -rf "$out" +maturin build --release --quiet --manifest-path rind-python/Cargo.toml --out "$out/wheel" +# A wheel is a zip of the installed package, so unpacking it is installing it +# without pip. +python3 -m zipfile -e "$out"/wheel/pyrind-*.whl "$out/site" + +case "$action" in + test) + PYTHONPATH="$out/site" python3 -m unittest discover --start-directory rind-python/tests "$@" + # Checks the type stubs against the built module. + (cd "$out/site" && python3 -m mypy.stubtest pyrind) + ;; + bench) PYTHONPATH="$out/site" python3 rind-python/bench/bench.py "$@" ;; + *) + echo "$usage" >&2 + exit 2 + ;; +esac