feat(core): RDP client on IronRDP

clients::rdp connects over any caller-dialled TCP stream (direct, proxy
or SSH tunnel), upgrades to TLS and logs on with NLA (CredSSP/NTLM),
optionally falling back to TLS-only logon for servers without NLA such
as xrdp (credentials are passed so they log straight in). The server
certificate is pinned through a caller check on its SHA256 fingerprint
before any credentials are sent. The desktop decodes into the VNC
client's RGBA framebuffer; input (IronRDP's scancode/Unicode/mouse
operations), Display Control resizes and the deactivation-reactivation
sequence run on the session thread.

IronRDP's releases need three vendored patches (vendor/README.md):
picky and sspi with their RustCrypto release-candidate pins relaxed so
they share russh's stable versions (their own suites, 402 tests, pass
against them), and ironrdp-session with two unreleased upstream bitmap
fixes (#1486 stride, #1524 RDP6 orientation) without which xrdp desktops
render sheared.

Verified live against xrdp (scottyhardy/docker-remote-desktop): logon,
full desktop rendering, keyboard and mouse input.
This commit is contained in:
Andy Dixon
2026-10-12 10:50:24 +01:00
parent 6a4d7381d9
commit 50c0104d57
227 changed files with 66687 additions and 20 deletions
+19
View File
@@ -0,0 +1,19 @@
# Vendored crates
Patched copies used through `[patch.crates-io]` in `rust/Cargo.toml`. Remove each once an
IronRDP release includes the fix it carries.
| Crate | Upstream | Change |
|---|---|---|
| `picky` | 7.0.0-rc.25 (crates.io) | `=x.y.z-rc.N` RustCrypto pins relaxed to `^x.y.z-rc.N` (so the stable releases russh uses satisfy them); one unused import removed. |
| `sspi` | 0.21.3 (crates.io) | Same pin relaxation. |
| `ironrdp-session` | 0.11.0 (crates.io) | Upstream fixes not yet released: #1486 "preserve bitmap source stride" (commit `80bb81b344`, applied as-is) and the RDP6 orientation flip from #1524 (`8f1737a287`). Without them xrdp desktops render sheared and upside-down tile by tile. |
Why: IronRDP pins `picky = "=7.0.0-rc.25"`, which pins `curve25519-dalek = "=5.0.0-rc.1"` and
other release candidates; russh needs the stable `curve25519-dalek 5`, and Cargo can't build two
semver-compatible versions of one crate. With the pins relaxed both resolve to the stable
releases, and the `picky` and `sspi` unit suites (402 tests) pass against them.
The `ironrdp-session` fixes are verified by the live test in `core/src/clients/rdp.rs`
(`NEXUS_RDP_TEST="host port user password"`), run against xrdp; the published crate ships no
unit tests.
+214
View File
@@ -0,0 +1,214 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [[0.11.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.10.0...ironrdp-session-v0.11.0)] - 2026-07-10
### <!-- 0 -->Security
- [**breaking**] Send NetworkAutoDetect over the MCS message channel ([#1348](https://github.com/Devolutions/IronRDP/issues/1348)) ([8a1fd0118e](https://github.com/Devolutions/IronRDP/commit/8a1fd0118e0bac214c9050b6ca6b36a040046dd3))
Corrects Network Auto-Detect framing and routing to match MS-RDPBCGR by
moving it off the I/O channel slow-path Share Data PDUs and onto the MCS
message channel with the required Basic Security Header
(SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP). This aligns IronRDP with
mstsc/xfreerdp behavior and enables both connect-time and continuous
auto-detection to actually function.
### <!-- 4 -->Bug Fixes
- Propagate caller location through error constructor helpers ([#1392](https://github.com/Devolutions/IronRDP/issues/1392)) ([d6990d81a1](https://github.com/Devolutions/IronRDP/commit/d6990d81a17e8349e52768ad8a82f673b1e1462d))
The error constructor helpers in several crates wrap the #[track_caller]
ironrdp_error::Error::new, but were not themselves marked
#[track_caller]. As a result, the captured location pointed at the
helper body instead of the real call site, giving misleading "@
file:line" info in error reports.
- Reduce dependency on ironrdp-connector ([#1419](https://github.com/Devolutions/IronRDP/issues/1419)) ([5c22f86a71](https://github.com/Devolutions/IronRDP/commit/5c22f86a7150bc10c26a3be39bfaebf84c67d781))
Drops session's reliance on ironrdp-connector legacy helpers, now sourced from ironrdp-pdu.
- [**breaking**] Remove ironrdp-connector dependency ([#1435](https://github.com/Devolutions/IronRDP/issues/1435)) ([c6a0286dcb](https://github.com/Devolutions/IronRDP/commit/c6a0286dcb49d9ac54c65c4f9325b41e05d541b8))
Removes the last ironrdp-connector coupling from ironrdp-session by
turning Deactivate-All handling into a bare signal and shifting ownership
of the Deactivation-Reactivation activation sequence back to each consumer.
It introduces a ConnectionActivationFactory (fresh sequence per reactivation)
and an ActiveStageBuilder so session construction no longer depends on
ConnectionResult.
## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.9.0...ironrdp-session-v0.10.0)] - 2026-06-05
### <!-- 4 -->Bug Fixes
- Decode RGBA QOI bitmaps instead of dropping the frame ([#1341](https://github.com/Devolutions/IronRDP/issues/1341)) ([ef20ea4e90](https://github.com/Devolutions/IronRDP/commit/ef20ea4e90455d6c6db0d3521f6522d1e960c0bb))
Fixes the client-side QOI decode path in ironrdp-session so RGBA-channel QOI frames are decoded and applied to the framebuffer instead of being dropped, improving interoperability with third-party RDP servers and older ironrdp-server builds that emit RGBA QOI.
### <!-- 7 -->Build
- [**breaking**] Update `ironrdp-dvc` public dependency
## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.8.0...ironrdp-session-v0.9.0)] - 2026-05-27
### <!-- 1 -->Features
- Dispatch multitransport PDUs on IO channel ([#1096](https://github.com/Devolutions/IronRDP/issues/1096)) ([7853e3cc6f](https://github.com/Devolutions/IronRDP/commit/7853e3cc6f26acaf3da000c6177ca3cef6ef85fd))
`decode_io_channel()` assumes all IO channel PDUs begin with
a`ShareControlHeader`. Multitransport Request PDUs use a
`BasicSecurityHeader` with `SEC_TRANSPORT_REQ` instead ([MS-RDPBCGR]
2.2.15.1).
This adds a peek-based dispatch: check the first `u16`
for`TRANSPORT_REQ`, decode as `MultitransportRequestPdu` if set,
otherwise fall through to the existing `decode_share_control()` path
unchanged.
The new variant is propagated through `ProcessorOutput` and
'ActiveStageOutput` so applications can handle multitransport requests.
Client and web consumers log the request (no UDP transport yet).
- Add bulk compression and wire negotiation ([ebf5da5f33](https://github.com/Devolutions/IronRDP/commit/ebf5da5f3380a3355f6c95814d669f8190425ded))
- add ironrdp-bulk crate with MPPC/NCRUSH/XCRUSH, bitstream, benches, and metrics
- advertise compression in Client Info and plumb compression_type through connector
- decode compressed FastPath/ShareData updates using BulkCompressor
- update CLI to numeric compression flags (enabled by default, level 0-3)
- extend screenshot example with compression options and negotiated logging
- refresh tests, FFI/web configs, typos, and Cargo.lock
- Complete pixel format support for bitmap updates ([#1134](https://github.com/Devolutions/IronRDP/issues/1134)) ([a6b41093ce](https://github.com/Devolutions/IronRDP/commit/a6b41093ce4ece081d2538c157f6bc547c3b2607))
Wires missing bitmap pixel formats (8/15/24bpp) into the session rendering
pipeline so bitmap updates at those depths are rendered instead of being
dropped, and adds fast-path palette update parsing to support 8bpp indexed
color sessions.
- Handle Auto-Detect Request PDUs from server ([#1178](https://github.com/Devolutions/IronRDP/issues/1178)) ([4dcad09980](https://github.com/Devolutions/IronRDP/commit/4dcad09980e4f5354e4e435a134cc0956e2fcf9e))
Fixes a crash when the server sends Auto-Detect Request PDUs during an
active session. After #1176 added ShareDataPdu::AutoDetectReq routing,
these PDUs decode correctly but hit the catch-all error path in the x224
processor: "unhandled PDU: Auto-Detect Request PDU".
- Handle slow-path graphics and pointer updates ([#1132](https://github.com/Devolutions/IronRDP/issues/1132)) ([9383380292](https://github.com/Devolutions/IronRDP/commit/938338029290f1be82a7f784d544bb77ac797aeb))
Adds support for slow-path graphics and pointer updates to IronRDP, fixing connectivity issues with servers like XRDP that use slow-path output instead of fast-path. The implementation parses slow-path framing headers and routes the inner payload structures through the existing fast-path processing pipeline by extracting shared bitmap and pointer processing methods.
### <!-- 4 -->Bug Fixes
- Fix pixel format handling in bitmap decoders ([#1101](https://github.com/Devolutions/IronRDP/issues/1101)) ([75863245ab](https://github.com/Devolutions/IronRDP/commit/75863245ab376f15e35c00df434860c93b123633))
- Handle row padding in uncompressed bitmap updates ([4262ae75ff](https://github.com/Devolutions/IronRDP/commit/4262ae75ffa5cb1fabb4ca07d598e33d855e8fdd))
Uncompressed bitmap data has rows padded to 4-byte boundaries per
[MS-RDPBCGR] 2.2.9.1.1.3.1.2.2, but the bitmap apply functions
expect tightly packed pixel data. Strip the per-row padding before
passing raw bitmap data to the apply functions.
This fixes garbled bitmap rendering when connecting to servers that
send uncompressed bitmaps with non-aligned row widths, such as XRDP
at 16 bpp.
- Skip bitmap updates that exceed bounds ([#1146](https://github.com/Devolutions/IronRDP/issues/1146)) ([2b97a95e6d](https://github.com/Devolutions/IronRDP/commit/2b97a95e6da8833e8a84e9f42960da91eee87cd6))
After a desktop resize, an RDP server can send a burst of bitmap updates
for the old resolution before its rendering pipeline has fully
transitioned to the new one. These updates reference coordinates beyond
the current image buffer in `DecodedImage`, causing index-out-of-bounds
panics in the `apply_*` methods. On the server side, the same stale
bitmaps can reach the encoder with dimensions exceeding the negotiated
desktop size, panicking in `NoneHandler::handle()`.
This commit adds bounds checks at two levels:
- `DecodedImage::rect_fits()` guard at the entry of each `apply_*`
method, returning an empty rectangle when the update doesn't fit
- Encoder-level guard in `EncoderIter::next()` that drops
`BitmapUpdate`s exceeding the current desktop size
- Propagate negotiated share_id to all outgoing ShareDataPdu ([#1147](https://github.com/Devolutions/IronRDP/issues/1147)) ([2b24e9664d](https://github.com/Devolutions/IronRDP/commit/2b24e9664dd05620ff63a24d092377477fdde863))
## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.7.0...ironrdp-session-v0.8.0)] - 2025-12-18
## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.5.0...ironrdp-session-v0.6.0)] - 2025-08-29
### <!-- 1 -->Features
- Add QOI image codec ([613fd51f26](https://github.com/Devolutions/IronRDP/commit/613fd51f26315d8212662c46f8e625c541e4bb59))
The Quite OK Image format ([1]) losslessly compresses images to a similar size
of PNG, while offering 20x-50x faster encoding and 3x-4x faster decoding.
- Add QOIZ image codec ([87df67fdc7](https://github.com/Devolutions/IronRDP/commit/87df67fdc76ff4f39d4b83521e34bf3b5e2e73bb))
Add a new QOIZ codec for SetSurface command. The PDU data contains the same
data as the QOI codec, with zstd compression.
## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.4.0...ironrdp-session-v0.4.1)] - 2025-06-27
### <!-- 1 -->Features
- More functions on `ActiveStage` (#791) ([5482365655](https://github.com/Devolutions/IronRDP/commit/5482365655e5c171cd967eda401b01161a9f6602))
- `get_dvc_by_channel_id`
- `encode_dvc_messages`
## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.3.0...ironrdp-session-v0.4.0)] - 2025-05-27
### <!-- 1 -->Features
- [**breaking**] Make DecodedImage Send ([45f66117ba](https://github.com/Devolutions/IronRDP/commit/45f66117ba05170d95b21ec7d97017b44b954f28))
- Add DecodeImage helpers ([cd7a60ba45](https://github.com/Devolutions/IronRDP/commit/cd7a60ba45a0241be4ecf3860ec4f82b431a7ce2))
### <!-- 4 -->Bug Fixes
- Update rectangle when applying None codecs updates (#728) ([a50cd643dc](https://github.com/Devolutions/IronRDP/commit/a50cd643dce9621f314231b7598d2fd31e4718c6))
- Return the correct updated region ([7507a152f1](https://github.com/Devolutions/IronRDP/commit/7507a152f14db594e4067bbc01e243cfba77770f))
"update_rectangle" is set to empty(). The surface updates are then added
by "union". But a union with an empty rectangle at (0,0) is still a
rectangle at (0,0). We end up with big region updates rooted at (0,0)...
- Decrease verbosity of Rfx frame_index ([b31b99eafb](https://github.com/Devolutions/IronRDP/commit/b31b99eafb0aac2a5e5a610af21a4027ae5cd698))
- Decrease verbosity of FastPath header ([f9b6992e74](https://github.com/Devolutions/IronRDP/commit/f9b6992e74abb929f3001e76abaff5d7215e1cb4))
## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.2.3...ironrdp-session-v0.3.0)] - 2025-03-12
### <!-- 7 -->Build
- Bump ironrdp-pdu
## [[0.2.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.2.2...ironrdp-session-v0.2.3)] - 2025-03-12
### <!-- 7 -->Build
- Update dependencies (#695) ([c21fa44fd6](https://github.com/Devolutions/IronRDP/commit/c21fa44fd6f3c6a6b74788ff68e83133c1314caa))
## [[0.2.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.2.1...ironrdp-session-v0.2.2)] - 2025-01-28
### <!-- 6 -->Documentation
- Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b))
## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.2.0...ironrdp-session-v0.2.1)] - 2024-12-14
### Other
- Symlinks to license files in packages ([#604](https://github.com/Devolutions/IronRDP/pull/604)) ([6c2de344c2](https://github.com/Devolutions/IronRDP/commit/6c2de344c2dd93ce9621834e0497ed7c3bfaf91a))
+735
View File
@@ -0,0 +1,735 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bitvec"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.2.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"der_derive",
"flagset",
"zeroize",
]
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "der_derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "ironrdp-bulk"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e548d9fd162558a5a8aaed72e528556ce88e77773634e3722b8d01d6f170388"
[[package]]
name = "ironrdp-core"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef0875b98275068b88652e49ba2e0a1150a1390aad69d26c942c8c59e8ec918e"
dependencies = [
"ironrdp-error",
]
[[package]]
name = "ironrdp-displaycontrol"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74a111a6fd25abbe51b6a15276fe980c6b9eee50a1421224b0c3f45848d45bcf"
dependencies = [
"ironrdp-core",
"ironrdp-dvc",
"ironrdp-pdu",
"ironrdp-svc",
"tracing",
]
[[package]]
name = "ironrdp-dvc"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a5de64988ddabf96928e2f042e1772a5f7201f0001ea99012129c73a105fc52"
dependencies = [
"ironrdp-core",
"ironrdp-pdu",
"ironrdp-svc",
"tracing",
]
[[package]]
name = "ironrdp-error"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd344ce9518ab83f6f7568ca4f1bc6dc8c55bd2da04cb5ee7b3e8740d5041734"
[[package]]
name = "ironrdp-graphics"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7493e426b6a8104cd497e518ba7781a9c7fc9a5f58a9cc0c1111e6d66f63bcc"
dependencies = [
"bit_field",
"bitflags",
"bitvec",
"byteorder",
"ironrdp-core",
"ironrdp-pdu",
"num-derive",
"num-traits",
"yuv",
]
[[package]]
name = "ironrdp-pdu"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ccd1179a4d106df1930347701388b5c79bc3725fa7dab4438d57db0d9d19347"
dependencies = [
"bit_field",
"bitflags",
"byteorder",
"der-parser",
"ironrdp-core",
"ironrdp-error",
"md-5",
"num-bigint",
"num-derive",
"num-integer",
"num-traits",
"pkcs1",
"sha1",
"tap",
"x509-cert",
]
[[package]]
name = "ironrdp-session"
version = "0.11.0"
dependencies = [
"ironrdp-bulk",
"ironrdp-core",
"ironrdp-displaycontrol",
"ironrdp-dvc",
"ironrdp-error",
"ironrdp-graphics",
"ironrdp-pdu",
"ironrdp-svc",
"qoicoubeh",
"tracing",
"zstd-safe",
]
[[package]]
name = "ironrdp-svc"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24c36b82ab0f7fef2668fb7004008a0f3c100a3d9a7b18bd4495a71aba55b796"
dependencies = [
"bitflags",
"ironrdp-core",
"ironrdp-pdu",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom",
"libc",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "qoicoubeh"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9b82aa3fef8a980075775b8c46f874823b5b4a15de327d2dbb3b6fd818480ba"
dependencies = [
"bytemuck",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tls_codec"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
dependencies = [
"tls_codec_derive",
"zeroize",
]
[[package]]
name = "tls_codec_derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "x509-cert"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
dependencies = [
"const-oid",
"der",
"spki",
"tls_codec",
]
[[package]]
name = "yuv"
version = "0.8.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d85a782d94ee43f078bcfd6fa82d4e6a5b2d1cfbbad168e4df5a9f7b39ef48c"
dependencies = [
"num-traits",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
+210
View File
@@ -0,0 +1,210 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
rust-version = "1.89"
name = "ironrdp-session"
version = "0.11.0"
authors = [
"Devolutions Inc. <infos@devolutions.net>",
"Teleport <goteleport.com>",
]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "State machines to drive an RDP session"
homepage = "https://github.com/Devolutions/IronRDP"
readme = "README.md"
keywords = [
"rdp",
"remote-desktop",
"network",
"client",
"protocol",
]
categories = ["network-programming"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/Devolutions/IronRDP"
resolver = "2"
[features]
default = []
qoi = [
"dep:qoicoubeh",
"ironrdp-pdu/qoi",
]
qoiz = [
"dep:zstd-safe",
"qoi",
]
[lib]
name = "ironrdp_session"
path = "src/lib.rs"
test = false
doctest = false
[dependencies.ironrdp-bulk]
version = "0.1"
[dependencies.ironrdp-core]
version = "0.2"
[dependencies.ironrdp-displaycontrol]
version = "0.8"
[dependencies.ironrdp-dvc]
version = "0.8"
[dependencies.ironrdp-error]
version = "0.2"
[dependencies.ironrdp-graphics]
version = "0.9"
[dependencies.ironrdp-pdu]
version = "0.9"
features = ["std"]
[dependencies.ironrdp-svc]
version = "0.8"
[dependencies.qoicoubeh]
version = "0.5"
optional = true
[dependencies.tracing]
version = "0.1"
features = ["log"]
[dependencies.zstd-safe]
version = "7.2"
features = ["std"]
optional = true
[lints.clippy]
alloc_instead_of_core = "warn"
allow_attributes = "warn"
as_conversions = "warn"
as_pointer_underscore = "warn"
as_ptr_cast_mut = "warn"
as_underscore = "warn"
cast_lossless = "warn"
cast_possible_truncation = "warn"
cast_possible_wrap = "warn"
cast_ptr_alignment = "warn"
cast_sign_loss = "warn"
cfg_not_test = "warn"
checked_conversions = "warn"
clone_on_ref_ptr = "warn"
cloned_instead_of_copied = "warn"
collection_is_never_read = "warn"
copy_iterator = "warn"
dbg_macro = "warn"
deref_by_slicing = "warn"
disallowed_script_idents = "warn"
doc_include_without_cfg = "warn"
empty_drop = "warn"
empty_enum_variants_with_brackets = "warn"
expl_impl_clone_on_copy = "warn"
filetype_is_file = "warn"
float_cmp = "warn"
float_cmp_const = "warn"
fn_to_numeric_cast_any = "warn"
get_unwrap = "warn"
implicit_clone = "warn"
infinite_loop = "warn"
inline_always = "warn"
large_futures = "warn"
large_include_file = "warn"
large_stack_frames = "warn"
large_types_passed_by_value = "warn"
lossy_float_literal = "warn"
map_with_unused_argument_over_ranges = "warn"
mem_forget = "warn"
missing_panics_doc = "warn"
missing_safety_doc = "warn"
mixed_read_write_in_expression = "warn"
multiple_inherent_impl = "warn"
multiple_unsafe_ops_per_block = "warn"
needless_raw_strings = "warn"
non_ascii_literal = "warn"
non_zero_suggestions = "warn"
or_fun_call = "warn"
panic = "warn"
partial_pub_fields = "warn"
precedence_bits = "warn"
print_stderr = "warn"
print_stdout = "warn"
ptr_cast_constness = "warn"
pub_without_shorthand = "warn"
range_plus_one = "warn"
rc_buffer = "warn"
rc_mutex = "warn"
redundant_clone = "warn"
redundant_type_annotations = "warn"
renamed_function_params = "warn"
rest_pat_in_fully_bound_structs = "warn"
return_self_not_must_use = "warn"
same_name_method = "warn"
self_named_module_files = "warn"
semicolon_outside_block = "warn"
separated_literal_suffix = "warn"
similar_names = "warn"
std_instead_of_core = "warn"
str_to_string = "warn"
string_add = "warn"
string_lit_chars_any = "warn"
string_slice = "warn"
suspicious_xor_used_as_pow = "warn"
todo = "warn"
trait_duplication_in_bounds = "warn"
transmute_ptr_to_ptr = "warn"
try_err = "warn"
type_repetition_in_bounds = "warn"
undocumented_unsafe_blocks = "warn"
unnecessary_box_returns = "warn"
unnecessary_safety_comment = "warn"
unnecessary_self_imports = "warn"
unused_result_ok = "warn"
unused_self = "warn"
unused_trait_names = "warn"
unwrap_used = "warn"
useless_let_if_seq = "warn"
wildcard_dependencies = "warn"
wildcard_imports = "warn"
[lints.rust]
absolute_paths_not_starting_with_crate = "warn"
ambiguous_negative_literals = "warn"
elided_lifetimes_in_paths = "warn"
invalid_reference_casting = "warn"
keyword_idents = "warn"
macro_use_extern_crate = "warn"
missing_unsafe_on_extern = "warn"
noop_method_call = "warn"
redundant_imports = "warn"
redundant_lifetimes = "warn"
single_use_lifetimes = "warn"
trivial_numeric_casts = "warn"
unit_bindings = "warn"
unreachable_pub = "warn"
unsafe_attr_outside_unsafe = "warn"
unsafe_op_in_unsafe_fn = "warn"
unused_crate_dependencies = "warn"
unused_lifetimes = "warn"
unused_macro_rules = "warn"
unused_qualifications = "warn"
unused_unsafe = "warn"
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "ironrdp-session"
version = "0.11.0"
readme = "README.md"
description = "State machines to drive an RDP session"
edition.workspace = true
rust-version = "1.89"
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lib]
doctest = false
test = false
[features]
default = []
qoi = ["dep:qoicoubeh", "ironrdp-pdu/qoi"]
qoiz = ["dep:zstd-safe", "qoi"]
[dependencies]
ironrdp-bulk = { path = "../ironrdp-bulk", version = "0.1" }
ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public
ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public
ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public
ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public
ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9" } # public
ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["std"] } # public
ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.8" }
tracing = { version = "0.1", features = ["log"] }
qoicoubeh = { version = "0.5", optional = true }
zstd-safe = { version = "7.2", optional = true, features = ["std"] }
[lints]
workspace = true
+202
View File
@@ -0,0 +1,202 @@
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
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+23
View File
@@ -0,0 +1,23 @@
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.
+7
View File
@@ -0,0 +1,7 @@
# IronRDP Session
Abstract state machine to drive an RDP session.
This crate is part of the [IronRDP] project.
[IronRDP]: https://github.com/Devolutions/IronRDP
+469
View File
@@ -0,0 +1,469 @@
use std::sync::Arc;
use ironrdp_bulk::BulkCompressor;
use ironrdp_core::{ReadCursor, WriteBuf};
use ironrdp_displaycontrol::client::DisplayControlClient;
use ironrdp_dvc::{DrdynvcClient, DvcProcessor, DynamicVirtualChannel};
use ironrdp_graphics::pointer::DecodedPointer;
use ironrdp_pdu::geometry::InclusiveRectangle;
use ironrdp_pdu::input::fast_path::{FastPathInput, FastPathInputEvent};
use ironrdp_pdu::rdp::autodetect::AutoDetectRequest;
use ironrdp_pdu::rdp::client_info::CompressionType as PduCompressionType;
use ironrdp_pdu::rdp::headers::ShareDataPdu;
use ironrdp_pdu::rdp::multitransport::MultitransportRequestPdu;
use ironrdp_pdu::slow_path::{self, GraphicsUpdateType};
use ironrdp_pdu::{Action, mcs};
use ironrdp_svc::{StaticChannelSet, SvcMessage, SvcProcessor, SvcProcessorMessages};
use tracing::{debug, info, warn};
use crate::fast_path::UpdateKind;
use crate::image::DecodedImage;
use crate::{SessionError, SessionErrorExt as _, SessionResult, fast_path, x224};
/// Converts the PDU-layer compression type to the bulk crate's compression type.
fn to_bulk_compression_type(ct: PduCompressionType) -> ironrdp_bulk::CompressionType {
match ct {
PduCompressionType::K8 => ironrdp_bulk::CompressionType::Rdp4,
PduCompressionType::K64 => ironrdp_bulk::CompressionType::Rdp5,
PduCompressionType::Rdp6 => ironrdp_bulk::CompressionType::Rdp6,
PduCompressionType::Rdp61 => ironrdp_bulk::CompressionType::Rdp61,
}
}
pub struct ActiveStage {
x224_processor: x224::Processor,
fast_path_processor: fast_path::Processor,
enable_server_pointer: bool,
}
/// Builder for [`ActiveStage`].
///
/// All fields are required; they are typically taken straight from `ironrdp-connector`’s
/// `ConnectionResult` once the connection sequence is finalized.
pub struct ActiveStageBuilder {
pub static_channels: StaticChannelSet,
pub user_channel_id: u16,
pub io_channel_id: u16,
pub message_channel_id: Option<u16>,
pub share_id: u32,
/// The bulk compression type that was negotiated, if any.
pub compression_type: Option<PduCompressionType>,
/// Enable server-side pointer updates (client-side pointer rendering).
pub enable_server_pointer: bool,
/// Use software rendering mode for pointer bitmap generation.
pub pointer_software_rendering: bool,
}
impl ActiveStageBuilder {
pub fn build(self) -> ActiveStage {
let Self {
static_channels,
user_channel_id,
io_channel_id,
message_channel_id,
share_id,
compression_type,
enable_server_pointer,
pointer_software_rendering,
} = self;
let x224_processor = x224::Processor::new(
static_channels,
user_channel_id,
io_channel_id,
message_channel_id,
share_id,
);
// Create bulk decompressor if compression was negotiated
let bulk_decompressor = compression_type.and_then(|ct| {
let bulk_ct = to_bulk_compression_type(ct);
match BulkCompressor::new(bulk_ct) {
Ok(compressor) => {
info!(compression_type = %bulk_ct, "Bulk decompressor initialized for FastPath");
Some(compressor)
}
Err(e) => {
tracing::error!(error = %e, "Failed to create bulk decompressor, compression disabled");
None
}
}
});
let fast_path_processor = fast_path::ProcessorBuilder {
io_channel_id,
user_channel_id,
share_id,
enable_server_pointer,
pointer_software_rendering,
bulk_decompressor,
}
.build();
ActiveStage {
x224_processor,
fast_path_processor,
enable_server_pointer,
}
}
}
impl ActiveStage {
pub fn update_mouse_pos(&mut self, x: u16, y: u16) {
self.fast_path_processor.update_mouse_pos(x, y);
}
/// Encodes outgoing input events and modifies image if necessary (e.g for client-side pointer
/// rendering).
pub fn process_fastpath_input(
&mut self,
image: &mut DecodedImage,
events: &[FastPathInputEvent],
) -> SessionResult<Vec<ActiveStageOutput>> {
if events.is_empty() {
return Ok(Vec::new());
}
// Mouse move events are prevalent, so we can preallocate space for
// response frame + graphics update
let mut output = Vec::with_capacity(2);
// Encoding fastpath response frame
// PERF: unnecessary copy
let fastpath_input = FastPathInput::new(events.to_vec()).map_err(SessionError::decode)?;
let frame = ironrdp_core::encode_vec(&fastpath_input).map_err(SessionError::encode)?;
output.push(ActiveStageOutput::ResponseFrame(frame));
// If pointer rendering is disabled - we can skip the rest
if !self.enable_server_pointer {
return Ok(output);
}
// If mouse was moved by client - we should update framebuffer to reflect new
// pointer position
let mouse_pos = events.iter().find_map(|event| match event {
FastPathInputEvent::MouseEvent(event) => Some((event.x_position, event.y_position)),
FastPathInputEvent::MouseEventEx(event) => Some((event.x_position, event.y_position)),
_ => None,
});
let (mouse_x, mouse_y) = match mouse_pos {
Some(mouse_pos) => mouse_pos,
None => return Ok(output),
};
// Graphics update is only sent when update is visually changed the framebuffer
if let Some(rect) = image.move_pointer(mouse_x, mouse_y)? {
output.push(ActiveStageOutput::GraphicsUpdate(rect));
}
Ok(output)
}
/// Process a frame received from the server.
pub fn process(
&mut self,
image: &mut DecodedImage,
action: Action,
frame: &[u8],
) -> SessionResult<Vec<ActiveStageOutput>> {
let (mut stage_outputs, processor_updates) = match action {
Action::FastPath => {
let mut output = WriteBuf::new();
let processor_updates = self.fast_path_processor.process(image, frame, &mut output)?;
(
vec![ActiveStageOutput::ResponseFrame(output.into_inner())],
processor_updates,
)
}
Action::X224 => {
let x224_outputs = self.x224_processor.process(frame)?;
let mut stage_outputs = Vec::new();
let mut processor_updates = Vec::new();
for output in x224_outputs {
match output {
x224::ProcessorOutput::GraphicsUpdate(data) => {
let updates = process_slow_path_graphics(&mut self.fast_path_processor, image, &data)?;
processor_updates.extend(updates);
}
x224::ProcessorOutput::PointerUpdate(data) => {
let updates = process_slow_path_pointer(&mut self.fast_path_processor, image, &data)?;
processor_updates.extend(updates);
}
other => {
stage_outputs.push(ActiveStageOutput::try_from(other)?);
}
}
}
(stage_outputs, processor_updates)
}
};
for update in processor_updates {
match update {
UpdateKind::None => {}
UpdateKind::Region(region) => {
stage_outputs.push(ActiveStageOutput::GraphicsUpdate(region));
}
UpdateKind::PointerDefault => {
stage_outputs.push(ActiveStageOutput::PointerDefault);
}
UpdateKind::PointerHidden => {
stage_outputs.push(ActiveStageOutput::PointerHidden);
}
UpdateKind::PointerPosition { x, y } => {
stage_outputs.push(ActiveStageOutput::PointerPosition { x, y });
}
UpdateKind::PointerBitmap(pointer) => {
stage_outputs.push(ActiveStageOutput::PointerBitmap(pointer));
}
}
}
Ok(stage_outputs)
}
pub fn set_fastpath_processor(&mut self, processor: fast_path::Processor) {
self.fast_path_processor = processor;
}
/// Updates the share_id used by the x224 processor for encoding ShareDataPdu.
/// Must be called during Deactivation-Reactivation if the server assigns a new share_id.
pub fn set_share_id(&mut self, share_id: u32) {
self.x224_processor.set_share_id(share_id);
}
pub fn set_enable_server_pointer(&mut self, enable_server_pointer: bool) {
self.enable_server_pointer = enable_server_pointer;
}
/// Encodes client-side graceful shutdown request. Note that upon sending this request,
/// client should wait for server's ShutdownDenied PDU before closing the connection.
///
/// Client-side graceful shutdown is defined in [MS-RDPBCGR]
///
/// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/27915739-8f77-487e-9927-55008af7fd68
pub fn graceful_shutdown(&self) -> SessionResult<Vec<ActiveStageOutput>> {
let mut frame = WriteBuf::new();
self.x224_processor
.encode_static(&mut frame, ShareDataPdu::ShutdownRequest)?;
Ok(vec![ActiveStageOutput::ResponseFrame(frame.into_inner())])
}
/// Send a pdu on the static global channel. Typically used to send input events
pub fn encode_static(&self, output: &mut WriteBuf, pdu: ShareDataPdu) -> SessionResult<usize> {
self.x224_processor.encode_static(output, pdu)
}
pub fn get_svc_processor<T: SvcProcessor + 'static>(&mut self) -> Option<&T> {
self.x224_processor.get_svc_processor()
}
pub fn get_svc_processor_mut<T: SvcProcessor + 'static>(&mut self) -> Option<&mut T> {
self.x224_processor.get_svc_processor_mut()
}
pub fn get_dvc<T: DvcProcessor + 'static>(&mut self) -> Option<&DynamicVirtualChannel> {
self.x224_processor.get_dvc::<T>()
}
pub fn get_dvc_by_channel_id(&mut self, channel_id: u32) -> Option<&DynamicVirtualChannel> {
self.x224_processor.get_dvc_by_channel_id(channel_id)
}
/// Completes user's SVC request with data, required to sent it over the network and returns
/// a buffer with encoded data.
pub fn process_svc_processor_messages<C: SvcProcessor + 'static>(
&self,
messages: SvcProcessorMessages<C>,
) -> SessionResult<Vec<u8>> {
self.x224_processor.process_svc_processor_messages(messages)
}
/// Fully encodes a resize request for sending over the Display Control Virtual Channel.
///
/// If the Display Control Virtual Channel is not available, or not yet connected, this method
/// will return `None`.
///
/// Per [2.2.2.2.1]:
/// - The `width` MUST be greater than or equal to 200 pixels and less than or equal to 8192 pixels, and MUST NOT be an odd value.
/// - The `height` MUST be greater than or equal to 200 pixels and less than or equal to 8192 pixels.
/// - The `scale_factor` MUST be ignored if it is less than 100 percent or greater than 500 percent.
/// - The `physical_dims` (width, height) MUST be ignored if either is less than 10 mm or greater than 10,000 mm.
///
/// Use [`ironrdp_displaycontrol::pdu::MonitorLayoutEntry::adjust_display_size`] to adjust `width` and `height` before calling this function
/// to ensure the display size is within the valid range.
///
/// [2.2.2.2.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpedisp/ea2de591-9203-42cd-9908-be7a55237d1c
pub fn encode_resize(
&mut self,
width: u32,
height: u32,
scale_factor: Option<u32>,
physical_dims: Option<(u32, u32)>,
) -> Option<SessionResult<Vec<u8>>> {
if let Some(dvc) = self.get_dvc::<DisplayControlClient>() {
if let Some(channel_id) = dvc.channel_id() {
let display_control = dvc.channel_processor_downcast_ref::<DisplayControlClient>()?;
let svc_messages = match display_control.encode_single_primary_monitor(
channel_id,
width,
height,
scale_factor,
physical_dims,
) {
Ok(messages) => messages,
Err(e) => return Some(Err(SessionError::encode(e))),
};
return Some(
self.process_svc_processor_messages(SvcProcessorMessages::<DrdynvcClient>::new(svc_messages)),
);
} else {
debug!("Could not encode a resize: Display Control Virtual Channel is not yet connected");
}
} else {
debug!("Could not encode a resize: Display Control Virtual Channel is not available");
}
None
}
pub fn encode_dvc_messages(&mut self, messages: Vec<SvcMessage>) -> SessionResult<Vec<u8>> {
self.process_svc_processor_messages(SvcProcessorMessages::<DrdynvcClient>::new(messages))
}
}
#[derive(Debug)]
pub enum ActiveStageOutput {
ResponseFrame(Vec<u8>),
GraphicsUpdate(InclusiveRectangle),
PointerDefault,
PointerHidden,
PointerPosition {
x: u16,
y: u16,
},
PointerBitmap(Arc<DecodedPointer>),
Terminate(GracefulDisconnectReason),
/// Received a Server Deactivate All PDU. The consumer should execute the [Deactivation-Reactivation Sequence].
///
/// [Deactivation-Reactivation Sequence]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432
DeactivateAll,
/// Server Initiate Multitransport Request. The application should establish a
/// sideband UDP transport using the provided request parameters.
///
/// See [\[MS-RDPBCGR\] 2.2.15.1].
///
/// [\[MS-RDPBCGR\] 2.2.15.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/de783158-8b01-4818-8fb0-62523a5b3490
MultitransportRequest(MultitransportRequestPdu),
/// Server-reported network characteristics ([\[MS-RDPBCGR\] 2.2.14.1.5]).
///
/// Contains an [`AutoDetectRequest::NetworkCharacteristicsResult`] with
/// RTT and/or bandwidth measurements computed by the server.
///
/// See [\[MS-RDPBCGR\] 2.2.14.1.5].
///
/// [\[MS-RDPBCGR\] 2.2.14.1.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/228ffc5c-b60c-4d3e-9781-ac613f822fdf
AutoDetect(AutoDetectRequest),
}
impl TryFrom<x224::ProcessorOutput> for ActiveStageOutput {
type Error = SessionError;
fn try_from(value: x224::ProcessorOutput) -> Result<Self, Self::Error> {
match value {
x224::ProcessorOutput::ResponseFrame(frame) => Ok(Self::ResponseFrame(frame)),
x224::ProcessorOutput::Disconnect(desc) => {
let desc = match desc {
x224::DisconnectDescription::McsDisconnect(reason) => match reason {
mcs::DisconnectReason::ProviderInitiated => GracefulDisconnectReason::ServerInitiated,
mcs::DisconnectReason::UserRequested => GracefulDisconnectReason::UserInitiated,
other => GracefulDisconnectReason::Other(other.description().to_owned()),
},
x224::DisconnectDescription::ErrorInfo(info) => GracefulDisconnectReason::Other(info.description()),
};
Ok(Self::Terminate(desc))
}
x224::ProcessorOutput::DeactivateAll => Ok(Self::DeactivateAll),
x224::ProcessorOutput::MultitransportRequest(pdu) => Ok(Self::MultitransportRequest(pdu)),
x224::ProcessorOutput::AutoDetect(request) => Ok(Self::AutoDetect(request)),
// GraphicsUpdate and PointerUpdate are consumed in ActiveStage::process()
// before reaching this conversion.
x224::ProcessorOutput::GraphicsUpdate(_) | x224::ProcessorOutput::PointerUpdate(_) => Err(
SessionError::general("slow-path graphics/pointer updates should be handled before this conversion"),
),
}
}
}
/// Reasons for graceful disconnect. This type provides GUI-friendly descriptions for
/// disconnect reasons.
#[derive(Debug, Clone)]
pub enum GracefulDisconnectReason {
UserInitiated,
ServerInitiated,
Other(String),
}
impl GracefulDisconnectReason {
pub fn description(&self) -> String {
match self {
GracefulDisconnectReason::UserInitiated => "user initiated disconnect".to_owned(),
GracefulDisconnectReason::ServerInitiated => "server initiated disconnect".to_owned(),
GracefulDisconnectReason::Other(description) => description.clone(),
}
}
}
impl core::fmt::Display for GracefulDisconnectReason {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.description())
}
}
/// Parse and process a slow-path graphics update through the shared bitmap pipeline.
fn process_slow_path_graphics(
fast_path_processor: &mut fast_path::Processor,
image: &mut DecodedImage,
data: &[u8],
) -> SessionResult<Vec<UpdateKind>> {
let mut src = ReadCursor::new(data);
let update_type = slow_path::read_graphics_update_type(&mut src).map_err(SessionError::decode)?;
match update_type {
GraphicsUpdateType::Bitmap => {
let bitmap = slow_path::decode_slow_path_bitmap(&mut src).map_err(SessionError::decode)?;
fast_path_processor.process_bitmap_update(image, bitmap)
}
GraphicsUpdateType::Orders => {
warn!("Slow-path drawing orders not supported (MS-RDPEGDI)");
Ok(Vec::new())
}
GraphicsUpdateType::Palette => {
warn!("Slow-path palette update not supported (8bpp)");
Ok(Vec::new())
}
// Synchronize is an artifact from the T.128 multipoint protocol
// and carries no data. Safe to ignore.
GraphicsUpdateType::Synchronize => {
debug!("Ignoring slow-path synchronize update");
Ok(Vec::new())
}
}
}
/// Parse and process a slow-path pointer update through the shared pointer pipeline.
fn process_slow_path_pointer(
fast_path_processor: &mut fast_path::Processor,
image: &mut DecodedImage,
data: &[u8],
) -> SessionResult<Vec<UpdateKind>> {
let mut src = ReadCursor::new(data);
let pointer = slow_path::decode_slow_path_pointer(&mut src).map_err(SessionError::decode)?;
fast_path_processor.process_pointer_update(image, pointer)
}
+821
View File
@@ -0,0 +1,821 @@
use std::sync::Arc;
use ironrdp_bulk::BulkCompressor;
use ironrdp_core::{DecodeErrorKind, ReadCursor, WriteBuf, decode_cursor};
use ironrdp_graphics::image_processing::PixelFormat;
use ironrdp_graphics::pointer::{DecodedPointer, PointerBitmapTarget};
use ironrdp_graphics::rdp6::BitmapStreamDecoder;
use ironrdp_graphics::rle::RlePixelFormat;
use ironrdp_pdu::bitmap::BitmapUpdateData;
use ironrdp_pdu::codecs::rfx::FrameAcknowledgePdu;
use ironrdp_pdu::fast_path::{FastPathHeader, FastPathUpdate, FastPathUpdatePdu, Fragmentation};
use ironrdp_pdu::geometry::{InclusiveRectangle, Rectangle as _};
use ironrdp_pdu::pointer::PointerUpdateData;
use ironrdp_pdu::rdp::capability_sets::{CODEC_ID_NONE, CODEC_ID_REMOTEFX, CodecId};
use ironrdp_pdu::rdp::headers::{CompressionFlags, ShareDataPdu};
use ironrdp_pdu::surface_commands::{FrameAction, FrameMarkerPdu, SurfaceCommand};
use tracing::{debug, trace, warn};
use crate::image::DecodedImage;
use crate::palette::Palette;
use crate::pointer::PointerCache;
use crate::{SessionError, SessionErrorExt as _, SessionResult, custom_err, reason_err, rfx};
#[cfg(test)]
mod tests {
use ironrdp_pdu::bitmap::{BitmapData, Compression};
use super::*;
#[test]
fn raw_bitmap_removes_byte_padding_without_changing_source_stride() {
let mut processor = ProcessorBuilder {
io_channel_id: 0,
user_channel_id: 0,
share_id: 0,
enable_server_pointer: false,
pointer_software_rendering: false,
bulk_decompressor: None,
}
.build();
let mut image = DecodedImage::new(PixelFormat::RgbA32, 4, 3);
// Two bottom-up BGR rows of three source pixels each. The final source
// column is outside the destination and each row has three pad bytes.
let bitmap_data = [
3, 2, 1, 6, 5, 4, 9, 8, 7, 0xff, 0xff, 0xff, // bottom row
15, 14, 13, 18, 17, 16, 21, 20, 19, 0xff, 0xff, 0xff, // top row
];
let bitmap_update = BitmapUpdateData {
rectangles: vec![BitmapData {
rectangle: InclusiveRectangle {
left: 1,
top: 0,
right: 2,
bottom: 1,
},
width: 3,
height: 2,
bits_per_pixel: 24,
compression_flags: Compression::empty(),
compressed_data_header: None,
bitmap_data: &bitmap_data,
}],
};
processor.process_bitmap_update(&mut image, bitmap_update).unwrap();
let pixel = |x: usize, y: usize| -> [u8; 4] {
let offset = (y * usize::from(image.width()) + x) * 4;
image.data()[offset..offset + 4]
.try_into()
.expect("pixel has four channels")
};
assert_eq!(pixel(1, 0), [13, 14, 15, 255]);
assert_eq!(pixel(2, 0), [16, 17, 18, 255]);
assert_eq!(pixel(1, 1), [1, 2, 3, 255]);
assert_eq!(pixel(2, 1), [4, 5, 6, 255]);
assert_eq!(pixel(3, 0), [0, 0, 0, 0]);
}
}
#[derive(Debug)]
pub enum UpdateKind {
None,
Region(InclusiveRectangle),
PointerDefault,
PointerHidden,
PointerPosition { x: u16, y: u16 },
PointerBitmap(Arc<DecodedPointer>),
}
pub struct Processor {
complete_data: CompleteData,
rfx_handler: rfx::DecodingContext,
marker_processor: FrameMarkerProcessor,
bitmap_stream_decoder: BitmapStreamDecoder,
pointer_cache: PointerCache,
use_system_pointer: bool,
mouse_pos_update: Option<(u16, u16)>,
enable_server_pointer: bool,
pointer_software_rendering: bool,
/// Bulk decompressor for server-to-client compressed PDUs.
/// `None` when compression was not negotiated.
bulk_decompressor: Option<BulkCompressor>,
/// Current 8bpp color palette. Updated by Palette fast-path updates.
palette: Palette,
#[cfg(feature = "qoiz")]
zdctx: zstd_safe::DCtx<'static>,
}
impl Processor {
pub fn update_mouse_pos(&mut self, x: u16, y: u16) {
self.mouse_pos_update = Some((x, y));
}
/// Process input fast path frame and return list of updates.
pub fn process(
&mut self,
image: &mut DecodedImage,
input: &[u8],
output: &mut WriteBuf,
) -> SessionResult<Vec<UpdateKind>> {
let mut processor_updates = Vec::new();
if let Some((x, y)) = self.mouse_pos_update.take() {
if let Some(rect) = image.move_pointer(x, y)? {
processor_updates.push(UpdateKind::Region(rect));
}
}
let mut input = ReadCursor::new(input);
let header = decode_cursor::<FastPathHeader>(&mut input).map_err(SessionError::decode)?;
trace!(fast_path_header = ?header, "Received Fast-Path packet");
// A single FastPath output PDU can contain multiple updates.
// Loop over all updates within the PDU payload.
while !input.is_empty() {
let update_result = self.process_single_update(&mut input, image, output)?;
processor_updates.extend(update_result);
}
Ok(processor_updates)
}
/// Process a single FastPath update from the cursor, advancing past it.
fn process_single_update(
&mut self,
input: &mut ReadCursor<'_>,
image: &mut DecodedImage,
output: &mut WriteBuf,
) -> SessionResult<Vec<UpdateKind>> {
let mut processor_updates = Vec::new();
let update_pdu = decode_cursor::<FastPathUpdatePdu<'_>>(input).map_err(SessionError::decode)?;
trace!(fast_path_update_fragmentation = ?update_pdu.fragmentation);
// Decompress the payload if the server sent it compressed.
let decompressed_data;
let payload = if let Some(flags) = update_pdu.compression_flags {
if flags.contains(CompressionFlags::COMPRESSED) || flags.contains(CompressionFlags::FLUSHED) {
let bulk_flags =
u32::from(flags.bits()) | u32::from(update_pdu.compression_type.map_or(0, |ct| ct.as_u8()));
if let Some(ref mut decompressor) = self.bulk_decompressor {
let decompressed = decompressor
.decompress(update_pdu.data, bulk_flags)
.map_err(|e| reason_err!("FastPath", "bulk decompression failed: {}", e))?;
// Copy decompressed data before accessing metrics (releases the mutable borrow).
decompressed_data = decompressed.to_vec();
debug!(
compressed_size = update_pdu.data.len(),
decompressed_size = decompressed_data.len(),
compression_type = ?update_pdu.compression_type,
compression_ratio = format_args!("{:.2}x", decompressor.compression_ratio()),
total_compressed = decompressor.total_compressed_bytes(),
total_uncompressed = decompressor.total_uncompressed_bytes(),
"Decompressed FastPath update"
);
decompressed_data.as_slice()
} else {
warn!("Received compressed FastPath data but no decompressor is configured");
update_pdu.data
}
} else {
// Compression flags present but COMPRESSED bit not set — pass data through.
// Still need to inform the decompressor of FLUSHED/AT_FRONT flags even
// without compressed payload.
update_pdu.data
}
} else {
update_pdu.data
};
let processed_complete_data = self.complete_data.process_data(payload, update_pdu.fragmentation);
let update_code = update_pdu.update_code;
let Some(data) = processed_complete_data else {
return Ok(processor_updates);
};
let update = FastPathUpdate::decode_with_code(data.as_slice(), update_code);
match update {
Ok(FastPathUpdate::SurfaceCommands(surface_commands)) => {
trace!("Received Surface Commands: {} pieces", surface_commands.len());
let update_region = self.process_surface_commands(image, output, surface_commands)?;
processor_updates.push(UpdateKind::Region(update_region));
}
Ok(FastPathUpdate::Bitmap(bitmap_update)) => {
trace!("Received bitmap update");
let updates = self.process_bitmap_update(image, bitmap_update)?;
processor_updates.extend(updates);
}
Ok(FastPathUpdate::Pointer(update)) => {
let updates = self.process_pointer_update(image, update)?;
processor_updates.extend(updates);
}
Ok(FastPathUpdate::Palette(palette_data)) => {
trace!("Received palette update");
self.palette.process_update(palette_data);
}
Err(e) => {
// FIXME: This seems to be a way of special-handling the error case in FastPathUpdate::decode_cursor_with_code
// to ignore the unsupported update PDUs, but this is a fragile logic and the rationale behind it is not
// obvious.
if let DecodeErrorKind::InvalidField { field, reason } = e.kind() {
warn!(field, reason, "Received invalid Fast-Path update");
processor_updates.push(UpdateKind::None);
} else {
return Err(custom_err!("Fast-Path", e));
}
}
};
Ok(processor_updates)
}
/// Process a bitmap update, shared between fast-path and slow-path pipelines.
pub fn process_bitmap_update(
&mut self,
image: &mut DecodedImage,
bitmap_update: BitmapUpdateData<'_>,
) -> SessionResult<Vec<UpdateKind>> {
let mut buf = Vec::new();
let mut update_kind = UpdateKind::None;
for update in bitmap_update.rectangles {
trace!("{update:?}");
buf.clear();
let width = update.width.min(update.rectangle.width());
let height = update.height.min(update.rectangle.height());
if width == 0 || height == 0 {
warn!(
bitmap_width = update.width,
bitmap_height = update.height,
destination = ?update.rectangle,
"Skipping bitmap with an empty source or destination"
);
continue;
}
// `width` and `height` describe the source bitmap layout, while
// destination bounds describe where its visible portion is drawn.
// Servers can send source rows wider than the destination; preserve
// that source stride and discard only the excess source extent.
let bitmap_rectangle = InclusiveRectangle {
left: update.rectangle.left,
top: update.rectangle.top,
right: update.rectangle.left + width - 1,
bottom: update.rectangle.top + height - 1,
};
// Bitmap data is either compressed or uncompressed, depending
// on whether the BITMAP_COMPRESSION flag is present in the
// flags field.
let update_rectangle = if update
.compression_flags
.contains(ironrdp_pdu::bitmap::Compression::BITMAP_COMPRESSION)
{
if update.bits_per_pixel == 32 {
// Compressed bitmaps at a color depth of 32 bpp are compressed using RDP 6.0
// Bitmap Compression and stored inside an RDP 6.0 Bitmap Compressed Stream
// structure ([MS-RDPEGDI] section 2.2.2.5.1).
debug!("32 bpp compressed RDP6_BITMAP_STREAM");
match self.bitmap_stream_decoder.decode_bitmap_stream_to_rgb24(
update.bitmap_data,
&mut buf,
usize::from(update.width),
usize::from(update.height),
) {
// RDP6 bitmap streams are bottom-up (upstream #1524).
Ok(()) => image.apply_rgb24(&buf, &bitmap_rectangle, update.width, true)?,
Err(err) => {
warn!("Invalid RDP6_BITMAP_STREAM: {err}");
update.rectangle.clone()
}
}
} else {
// Compressed bitmaps not in 32 bpp format are compressed using Interleaved
// RLE and encapsulated in an RLE Compressed Bitmap Stream structure (section
// 2.2.9.1.1.3.1.2.4).
debug!(bpp = update.bits_per_pixel, "Non-32 bpp compressed RLE_BITMAP_STREAM",);
match ironrdp_graphics::rle::decompress(
update.bitmap_data,
&mut buf,
usize::from(update.width),
usize::from(update.height),
usize::from(update.bits_per_pixel),
) {
Ok(RlePixelFormat::Rgb16) => image.apply_rgb16_bitmap(&buf, &bitmap_rectangle, update.width)?,
Ok(RlePixelFormat::Rgb15) => image.apply_rgb15_bitmap(&buf, &bitmap_rectangle, update.width)?,
Ok(RlePixelFormat::Rgb24) => image.apply_bgr24_bitmap(&buf, &bitmap_rectangle, update.width)?,
Ok(RlePixelFormat::Rgb8) => image.apply_rgb8_with_palette(
&buf,
&bitmap_rectangle,
self.palette.colors(),
update.width,
)?,
Err(e) => {
warn!("Invalid RLE-compressed bitmap: {e}");
update.rectangle.clone()
}
}
}
} else {
// Uncompressed bitmap data is formatted as a bottom-up, left-to-right series of
// pixels. Each pixel is a whole number of bytes. Each row contains a multiple of
// four bytes (including up to three bytes of padding, as necessary).
// [MS-RDPBCGR] 2.2.9.1.1.3.1.2.2
trace!("Uncompressed raw bitmap");
let bpp = usize::from(update.bits_per_pixel);
let width = usize::from(update.width);
let bytes_per_pixel = bpp.div_ceil(8);
let row_bytes = width * bytes_per_pixel;
let padded_row_bytes = (row_bytes + 3) & !3;
if padded_row_bytes != row_bytes {
// Strip only byte padding; the tightly packed rows still retain
// `update.width` pixels and therefore their source stride.
buf.clear();
for row in update
.bitmap_data
.chunks_exact(padded_row_bytes)
.take(usize::from(update.height))
{
buf.extend_from_slice(&row[..row_bytes]);
}
match update.bits_per_pixel {
8 => image.apply_rgb8_with_palette(
&buf,
&bitmap_rectangle,
self.palette.colors(),
update.width,
)?,
15 => image.apply_rgb15_bitmap(&buf, &bitmap_rectangle, update.width)?,
16 => image.apply_rgb16_bitmap(&buf, &bitmap_rectangle, update.width)?,
24 => image.apply_bgr24_bitmap(&buf, &bitmap_rectangle, update.width)?,
32 => image.apply_rgb32_bitmap(&buf, PixelFormat::BgrX32, &bitmap_rectangle, update.width)?,
_ => {
warn!("Unsupported uncompressed bitmap depth: {bpp} bpp");
update.rectangle.clone()
}
}
} else {
match update.bits_per_pixel {
8 => image.apply_rgb8_with_palette(
update.bitmap_data,
&bitmap_rectangle,
self.palette.colors(),
update.width,
)?,
15 => image.apply_rgb15_bitmap(update.bitmap_data, &bitmap_rectangle, update.width)?,
16 => image.apply_rgb16_bitmap(update.bitmap_data, &bitmap_rectangle, update.width)?,
24 => image.apply_bgr24_bitmap(update.bitmap_data, &bitmap_rectangle, update.width)?,
32 => image.apply_rgb32_bitmap(
update.bitmap_data,
PixelFormat::BgrX32,
&bitmap_rectangle,
update.width,
)?,
_ => {
warn!("Unsupported uncompressed bitmap depth: {bpp} bpp");
update.rectangle.clone()
}
}
}
};
match update_kind {
UpdateKind::Region(current) => update_kind = UpdateKind::Region(current.union(&update_rectangle)),
_ => update_kind = UpdateKind::Region(update_rectangle),
}
}
Ok(vec![update_kind])
}
/// Process a pointer update, shared between fast-path and slow-path pipelines.
pub fn process_pointer_update(
&mut self,
image: &mut DecodedImage,
update: PointerUpdateData<'_>,
) -> SessionResult<Vec<UpdateKind>> {
let mut processor_updates = Vec::new();
if !self.enable_server_pointer {
return Ok(processor_updates);
}
let bitmap_target = if self.pointer_software_rendering {
PointerBitmapTarget::Software
} else {
PointerBitmapTarget::Accelerated
};
match update {
PointerUpdateData::SetHidden => {
processor_updates.push(UpdateKind::PointerHidden);
if self.pointer_software_rendering && !self.use_system_pointer {
self.use_system_pointer = true;
if let Some(rect) = image.hide_pointer()? {
processor_updates.push(UpdateKind::Region(rect));
}
}
}
PointerUpdateData::SetDefault => {
processor_updates.push(UpdateKind::PointerDefault);
if self.pointer_software_rendering && !self.use_system_pointer {
self.use_system_pointer = true;
if let Some(rect) = image.hide_pointer()? {
processor_updates.push(UpdateKind::Region(rect));
}
}
}
PointerUpdateData::SetPosition(position) => {
if self.use_system_pointer || !self.pointer_software_rendering {
processor_updates.push(UpdateKind::PointerPosition {
x: position.x,
y: position.y,
});
} else if let Some(rect) = image.move_pointer(position.x, position.y)? {
processor_updates.push(UpdateKind::Region(rect));
}
}
PointerUpdateData::Color(pointer) => {
let cache_index = pointer.cache_index;
let decoded_pointer = Arc::new(
DecodedPointer::decode_color_pointer_attribute(&pointer, bitmap_target)
.map_err(|e| SessionError::custom("failed to decode color pointer attribute", e))?,
);
let _ = self
.pointer_cache
.insert(usize::from(cache_index), Arc::clone(&decoded_pointer));
if !self.pointer_software_rendering {
processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&decoded_pointer)));
} else if let Some(rect) = image.update_pointer(decoded_pointer)? {
processor_updates.push(UpdateKind::Region(rect));
}
}
PointerUpdateData::Cached(cached) => {
let cache_index = cached.cache_index;
if let Some(cached_pointer) = self.pointer_cache.get(usize::from(cache_index)) {
// Disable system pointer
processor_updates.push(UpdateKind::PointerHidden);
self.use_system_pointer = false;
// Send graphics update
if !self.pointer_software_rendering {
processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&cached_pointer)));
} else if let Some(rect) = image.update_pointer(cached_pointer)? {
processor_updates.push(UpdateKind::Region(rect));
} else {
// In case pointer was hidden previously
if let Some(rect) = image.show_pointer()? {
processor_updates.push(UpdateKind::Region(rect));
}
}
} else {
warn!("Cached pointer not found {}", cache_index);
}
}
PointerUpdateData::New(pointer) => {
let cache_index = pointer.color_pointer.cache_index;
let decoded_pointer = Arc::new(
DecodedPointer::decode_pointer_attribute(&pointer, bitmap_target)
.map_err(|e| SessionError::custom("failed to decode pointer attribute", e))?,
);
let _ = self
.pointer_cache
.insert(usize::from(cache_index), Arc::clone(&decoded_pointer));
if !self.pointer_software_rendering {
processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&decoded_pointer)));
} else if let Some(rect) = image.update_pointer(decoded_pointer)? {
processor_updates.push(UpdateKind::Region(rect));
}
}
PointerUpdateData::Large(pointer) => {
let cache_index = pointer.cache_index;
let decoded_pointer: Arc<DecodedPointer> = Arc::new(
DecodedPointer::decode_large_pointer_attribute(&pointer, bitmap_target)
.map_err(|e| SessionError::custom("failed to decode large pointer attribute", e))?,
);
let _ = self
.pointer_cache
.insert(usize::from(cache_index), Arc::clone(&decoded_pointer));
if !self.pointer_software_rendering {
processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&decoded_pointer)));
} else if let Some(rect) = image.update_pointer(decoded_pointer)? {
processor_updates.push(UpdateKind::Region(rect));
}
}
};
Ok(processor_updates)
}
fn process_surface_commands(
&mut self,
image: &mut DecodedImage,
output: &mut WriteBuf,
surface_commands: Vec<SurfaceCommand<'_>>,
) -> SessionResult<InclusiveRectangle> {
let mut update_rectangle = None;
for command in surface_commands {
match command {
SurfaceCommand::SetSurfaceBits(bits) | SurfaceCommand::StreamSurfaceBits(bits) => {
let codec_id = CodecId::from_u8(bits.extended_bitmap_data.codec_id).ok_or_else(|| {
reason_err!(
"Fast-Path",
"unexpected codec ID: {:x}",
bits.extended_bitmap_data.codec_id
)
})?;
trace!(?codec_id, "Surface bits");
let destination = bits.destination;
// TODO(@pacmancoder): Correct rectangle conversion logic should
// be revisited when `rectangle_processing.rs` from
// `ironrdp-graphics` will be refactored to use generic `Rectangle`
// trait instead of hardcoded `InclusiveRectangle`.
let destination = InclusiveRectangle {
left: destination.left,
top: destination.top,
right: destination.right - 1,
bottom: destination.bottom - 1,
};
match codec_id {
CODEC_ID_NONE => {
let ext_data = bits.extended_bitmap_data;
let source_width = destination.width();
let rectangle = match ext_data.bpp {
8 => image.apply_rgb8_with_palette(
ext_data.data,
&destination,
self.palette.colors(),
source_width,
)?,
15 => image.apply_rgb15_bitmap(ext_data.data, &destination, source_width)?,
16 => image.apply_rgb16_bitmap(ext_data.data, &destination, source_width)?,
24 => image.apply_bgr24_bitmap(ext_data.data, &destination, source_width)?,
32 => image.apply_rgb32_bitmap(
ext_data.data,
PixelFormat::BgrX32,
&destination,
source_width,
)?,
bpp => {
warn!("Unsupported surface CODEC_ID_NONE bpp: {bpp}");
continue;
}
};
update_rectangle = update_rectangle
.map(|rect: InclusiveRectangle| rect.union(&rectangle))
.or(Some(rectangle));
}
CODEC_ID_REMOTEFX => {
let mut data = ReadCursor::new(bits.extended_bitmap_data.data);
while !data.is_empty() {
let (_frame_id, rectangle) = self.rfx_handler.decode(image, &destination, &mut data)?;
update_rectangle = update_rectangle
.map(|rect: InclusiveRectangle| rect.union(&rectangle))
.or(Some(rectangle));
}
}
#[cfg(feature = "qoi")]
ironrdp_pdu::rdp::capability_sets::CODEC_ID_QOI => {
qoi_apply(
image,
destination,
bits.extended_bitmap_data.data,
&mut update_rectangle,
)?;
}
#[cfg(feature = "qoiz")]
ironrdp_pdu::rdp::capability_sets::CODEC_ID_QOIZ => {
let compressed = &bits.extended_bitmap_data.data;
let mut input = zstd_safe::InBuffer::around(compressed);
let mut data = vec![0; compressed.len() * 4];
let mut pos = 0;
loop {
let mut output = zstd_safe::OutBuffer::around_pos(data.as_mut_slice(), pos);
self.zdctx
.decompress_stream(&mut output, &mut input)
.map_err(zstd_safe::get_error_name)
.map_err(|e| reason_err!("zstd", "{}", e))?;
pos = output.pos();
if pos == output.capacity() {
data.resize(data.capacity() * 2, 0);
} else {
break;
}
}
qoi_apply(image, destination, &data, &mut update_rectangle)?;
}
_ => {
warn!("Unsupported codec ID: {}", bits.extended_bitmap_data.codec_id);
}
}
}
SurfaceCommand::FrameMarker(marker) => {
trace!(
"Frame marker: action {:?} with ID #{}",
marker.frame_action,
marker.frame_id.unwrap_or(0)
);
self.marker_processor.process(&marker, output)?;
}
}
}
Ok(update_rectangle.unwrap_or_else(InclusiveRectangle::empty))
}
}
#[cfg(feature = "qoi")]
fn qoi_apply(
image: &mut DecodedImage,
destination: InclusiveRectangle,
data: &[u8],
update_rectangle: &mut Option<InclusiveRectangle>,
) -> SessionResult<()> {
let (header, decoded) = qoi::decode_to_vec(data).map_err(|e| reason_err!("QOI decode", "{}", e))?;
// Guard against a decoded buffer that doesn't match the destination
// rectangle. `apply_rgb24`/`apply_rgba32` derive the row count from the
// decoded length, and the only bounds check downstream (`rect_fits`)
// validates the rectangle against the image, not the buffer against the
// rectangle. A malformed/oversized QOI payload would otherwise drive the
// per-row index past `self.data` and panic (client-side DoS).
let channels = match header.channels {
qoi::Channels::Rgb => 3,
qoi::Channels::Rgba => 4,
};
let expected = usize::from(destination.width()) * usize::from(destination.height()) * channels;
if decoded.len() != expected {
return Err(reason_err!(
"QOI decode",
"decoded {} bytes, expected {} for {}x{} ({} channels)",
decoded.len(),
expected,
destination.width(),
destination.height(),
channels
));
}
let rectangle = match header.channels {
qoi::Channels::Rgb => image.apply_rgb24(&decoded, &destination, destination.width(), false)?,
qoi::Channels::Rgba => image.apply_rgba32(&decoded, &destination, false)?,
};
*update_rectangle = update_rectangle
.as_ref()
.map(|rect: &InclusiveRectangle| rect.union(&rectangle))
.or(Some(rectangle));
Ok(())
}
pub struct ProcessorBuilder {
pub io_channel_id: u16,
pub user_channel_id: u16,
pub share_id: u32,
/// Ignore server pointer updates.
pub enable_server_pointer: bool,
/// Use software rendering mode for pointer bitmap generation. When this option is active,
/// `UpdateKind::PointerBitmap` will not be generated. Remote pointer will be drawn
/// via software rendering on top of the output image.
pub pointer_software_rendering: bool,
/// Bulk decompressor for server-to-client compressed PDUs.
/// `None` when compression was not negotiated.
pub bulk_decompressor: Option<BulkCompressor>,
}
impl ProcessorBuilder {
pub fn build(self) -> Processor {
Processor {
complete_data: CompleteData::new(),
rfx_handler: rfx::DecodingContext::new(),
marker_processor: FrameMarkerProcessor::new(self.user_channel_id, self.io_channel_id, self.share_id),
bitmap_stream_decoder: BitmapStreamDecoder::default(),
pointer_cache: PointerCache::default(),
use_system_pointer: true,
mouse_pos_update: None,
enable_server_pointer: self.enable_server_pointer,
pointer_software_rendering: self.pointer_software_rendering,
bulk_decompressor: self.bulk_decompressor,
palette: Palette::system_default(),
#[cfg(feature = "qoiz")]
zdctx: zstd_safe::DCtx::default(),
}
}
}
#[derive(Debug, PartialEq)]
struct CompleteData {
fragmented_data: Option<Vec<u8>>,
}
impl CompleteData {
fn new() -> Self {
Self { fragmented_data: None }
}
fn process_data(&mut self, data: &[u8], fragmentation: Fragmentation) -> Option<Vec<u8>> {
match fragmentation {
Fragmentation::Single => {
self.check_data_is_empty();
Some(data.to_vec())
}
Fragmentation::First => {
self.check_data_is_empty();
self.fragmented_data = Some(data.to_vec());
None
}
Fragmentation::Next => {
self.append_data(data);
None
}
Fragmentation::Last => {
self.append_data(data);
self.fragmented_data.take()
}
}
}
fn check_data_is_empty(&mut self) {
if self.fragmented_data.is_some() {
warn!("Skipping pending Fast-Path Update internal multiple elements data");
self.fragmented_data = None;
}
}
fn append_data(&mut self, data: &[u8]) {
if let Some(fragmented_data) = self.fragmented_data.as_mut() {
fragmented_data.extend_from_slice(data);
} else {
warn!("Got unexpected Next fragmentation PDU without prior First fragmentation PDU");
}
}
}
struct FrameMarkerProcessor {
user_channel_id: u16,
io_channel_id: u16,
share_id: u32,
}
impl FrameMarkerProcessor {
fn new(user_channel_id: u16, io_channel_id: u16, share_id: u32) -> Self {
Self {
user_channel_id,
io_channel_id,
share_id,
}
}
fn process(&mut self, marker: &FrameMarkerPdu, output: &mut WriteBuf) -> SessionResult<()> {
match marker.frame_action {
FrameAction::Begin => Ok(()),
FrameAction::End => {
ironrdp_pdu::rdp::headers::encode_share_data(
self.user_channel_id,
self.io_channel_id,
self.share_id,
ShareDataPdu::FrameAcknowledge(FrameAcknowledgePdu {
frame_id: marker.frame_id.unwrap_or(0),
}),
output,
)
.map_err(SessionError::encode)?;
Ok(())
}
}
}
}
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
#![cfg_attr(doc, doc = include_str!("../README.md"))]
#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")]
#![allow(clippy::arithmetic_side_effects)] // FIXME: remove
mod macros;
pub mod fast_path;
pub mod image;
pub mod pointer;
pub mod rfx; // FIXME: maybe this module should not be in this crate
pub mod x224;
mod active_stage;
mod palette;
use core::fmt;
pub use active_stage::{ActiveStage, ActiveStageBuilder, ActiveStageOutput, GracefulDisconnectReason};
pub type SessionResult<T> = Result<T, SessionError>;
#[non_exhaustive]
#[derive(Debug)]
pub enum SessionErrorKind {
Pdu(ironrdp_pdu::PduError),
Encode(ironrdp_core::EncodeError),
Decode(ironrdp_core::DecodeError),
Reason(String),
General,
Custom,
}
impl fmt::Display for SessionErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self {
SessionErrorKind::Pdu(_) => write!(f, "PDU error"),
SessionErrorKind::Encode(_) => write!(f, "encode error"),
SessionErrorKind::Decode(_) => write!(f, "decode error"),
SessionErrorKind::Reason(description) => write!(f, "reason: {description}"),
SessionErrorKind::General => write!(f, "general error"),
SessionErrorKind::Custom => write!(f, "custom error"),
}
}
}
impl core::error::Error for SessionErrorKind {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match &self {
SessionErrorKind::Pdu(e) => Some(e),
SessionErrorKind::Encode(e) => Some(e),
SessionErrorKind::Decode(e) => Some(e),
SessionErrorKind::Reason(_) => None,
SessionErrorKind::General => None,
SessionErrorKind::Custom => None,
}
}
}
pub type SessionError = ironrdp_error::Error<SessionErrorKind>;
pub trait SessionErrorExt {
fn pdu(error: ironrdp_pdu::PduError) -> Self;
fn encode(error: ironrdp_core::EncodeError) -> Self;
fn decode(error: ironrdp_core::DecodeError) -> Self;
fn general(context: &'static str) -> Self;
fn reason(context: &'static str, reason: impl Into<String>) -> Self;
fn custom<E>(context: &'static str, e: E) -> Self
where
E: core::error::Error + Sync + Send + 'static;
}
impl SessionErrorExt for SessionError {
#[track_caller]
fn pdu(error: ironrdp_pdu::PduError) -> Self {
Self::new("payload error", SessionErrorKind::Pdu(error))
}
#[track_caller]
fn encode(error: ironrdp_core::EncodeError) -> Self {
Self::new("encode error", SessionErrorKind::Encode(error))
}
#[track_caller]
fn decode(error: ironrdp_core::DecodeError) -> Self {
Self::new("decode error", SessionErrorKind::Decode(error))
}
#[track_caller]
fn general(context: &'static str) -> Self {
Self::new(context, SessionErrorKind::General)
}
#[track_caller]
fn reason(context: &'static str, reason: impl Into<String>) -> Self {
Self::new(context, SessionErrorKind::Reason(reason.into()))
}
#[track_caller]
fn custom<E>(context: &'static str, e: E) -> Self
where
E: core::error::Error + Sync + Send + 'static,
{
Self::new(context, SessionErrorKind::Custom).with_source(e)
}
}
pub trait SessionResultExt {
#[must_use]
fn with_context(self, context: &'static str) -> Self;
#[must_use]
fn with_source<E>(self, source: E) -> Self
where
E: core::error::Error + Sync + Send + 'static;
}
impl<T> SessionResultExt for SessionResult<T> {
fn with_context(self, context: &'static str) -> Self {
self.map_err(|mut e| {
e.set_context(context);
e
})
}
fn with_source<E>(self, source: E) -> Self
where
E: core::error::Error + Sync + Send + 'static,
{
self.map_err(|e| e.with_source(source))
}
}
+57
View File
@@ -0,0 +1,57 @@
/// Creates a `SessionError` with `General` kind
///
/// Shorthand for
/// ```ignore
/// <ironrdp_session::SessionError as ironrdp_session::SessionErrorExt>::general(context)
/// ```
#[macro_export]
macro_rules! general_err {
( $context:expr $(,)? ) => {{ <$crate::SessionError as $crate::SessionErrorExt>::general($context) }};
}
/// Creates a `SessionError` with `Reason` kind
///
/// Shorthand for
/// ```ignore
/// <ironrdp_session::SessionError as ironrdp_session::SessionErrorExt>::reason(context, reason)
/// ```
#[macro_export]
macro_rules! reason_err {
( $context:expr, $($arg:tt)* ) => {{
<$crate::SessionError as $crate::SessionErrorExt>::reason($context, format!($($arg)*))
}};
}
/// Creates a `SessionError` with `Custom` kind and a source error attached to it
///
/// Shorthand for
/// ```ignore
/// <ironrdp_session::SessionError as ironrdp_session::SessionErrorExt>::custom(context, source)
/// ```
#[macro_export]
macro_rules! custom_err {
( $context:expr, $source:expr $(,)? ) => {{ <$crate::SessionError as $crate::SessionErrorExt>::custom($context, $source) }};
}
#[macro_export]
macro_rules! eof_try {
($e:expr) => {
match $e {
Err(ref e) if e.kind() == io::ErrorKind::UnexpectedEof => {
return Ok(None);
}
result => result,
}
};
}
#[macro_export]
macro_rules! try_ready {
($e:expr) => {
match $e {
Ok(Some(v)) => Ok(v),
Ok(None) => return Ok(None),
Err(e) => Err(e),
}
};
}
+89
View File
@@ -0,0 +1,89 @@
use tracing::{debug, warn};
/// 8bpp color palette (256 RGB entries).
///
/// Initialized with the default Windows system palette (VGA colors)
/// per MS-RDPBCGR 2.2.9.1.1.3.1.1. Updated by TS_UPDATE_PALETTE_DATA
/// fast-path updates during the session.
#[derive(Debug, Clone)]
pub(crate) struct Palette {
colors: [[u8; 3]; 256],
}
impl Palette {
/// Create a palette initialized with the 20 static colors from the
/// Windows default system palette. Indices 0-9 and 246-255 are the
/// reserved static colors; the middle 236 entries (10-245) are black.
///
/// Reference: <https://learn.microsoft.com/en-us/windows/win32/gdi/default-palette>
pub(crate) fn system_default() -> Self {
let mut colors = [[0u8; 3]; 256];
// Lower 10 static colors (indices 0-9)
colors[0] = [0, 0, 0]; // Black
colors[1] = [128, 0, 0]; // Dark Red
colors[2] = [0, 128, 0]; // Dark Green
colors[3] = [128, 128, 0]; // Dark Yellow
colors[4] = [0, 0, 128]; // Dark Blue
colors[5] = [128, 0, 128]; // Dark Magenta
colors[6] = [0, 128, 128]; // Dark Cyan
colors[7] = [192, 192, 192]; // Light Gray
colors[8] = [192, 220, 192]; // Money Green
colors[9] = [166, 202, 240]; // Sky Blue
// Upper 10 static colors (indices 246-255)
colors[246] = [255, 251, 240]; // Cream
colors[247] = [160, 160, 164]; // Medium Gray
colors[248] = [128, 128, 128]; // Dark Gray
colors[249] = [255, 0, 0]; // Red
colors[250] = [0, 255, 0]; // Green
colors[251] = [255, 255, 0]; // Yellow
colors[252] = [0, 0, 255]; // Blue
colors[253] = [255, 0, 255]; // Magenta
colors[254] = [0, 255, 255]; // Cyan
colors[255] = [255, 255, 255]; // White
Self { colors }
}
/// Parse TS_UPDATE_PALETTE_DATA and update palette entries.
/// Wire format: pad(2) + numberColors(u32) + N x TS_COLOR_QUAD [B, G, R, pad].
pub(crate) fn process_update(&mut self, data: &[u8]) {
if data.len() < 6 {
warn!("Palette update too short: {} bytes", data.len());
return;
}
let raw_count = u32::from_le_bytes([data[2], data[3], data[4], data[5]]);
// Palette can have at most 256 entries; clamp before any arithmetic
// to prevent overflow on untrusted input
let clamped = raw_count.min(256);
let number_colors = usize::try_from(clamped).unwrap_or(256);
let entry_data = &data[6..];
let Some(required_len) = number_colors.checked_mul(4) else {
warn!("Palette entry count overflow");
return;
};
if entry_data.len() < required_len {
warn!(
"Palette data truncated: expected {} bytes for {} colors, got {}",
required_len,
number_colors,
entry_data.len()
);
return;
}
for i in 0..number_colors {
let offset = i * 4;
// TS_COLOR_QUAD: Blue, Green, Red, Pad
self.colors[i] = [entry_data[offset + 2], entry_data[offset + 1], entry_data[offset]];
}
debug!("Updated palette with {} colors", number_colors);
}
/// Borrow the underlying color table for bitmap application.
pub(crate) fn colors(&self) -> &[[u8; 3]; 256] {
&self.colors
}
}
+24
View File
@@ -0,0 +1,24 @@
use std::collections::HashMap;
use std::sync::Arc;
use ironrdp_graphics::pointer::DecodedPointer;
#[derive(Debug, Clone, Default)]
pub struct PointerCache {
// TODO(@pacancoder) maybe use Vec<Optional<...>> instead?
cache: HashMap<usize, Arc<DecodedPointer>>,
}
impl PointerCache {
pub fn insert(&mut self, id: usize, pointer: Arc<DecodedPointer>) -> Option<Arc<DecodedPointer>> {
self.cache.insert(id, pointer)
}
pub fn get(&self, id: usize) -> Option<Arc<DecodedPointer>> {
self.cache.get(&id).cloned()
}
pub fn is_cached(&self, id: usize) -> bool {
self.cache.contains_key(&id)
}
}
+286
View File
@@ -0,0 +1,286 @@
use core::cmp::min;
use ironrdp_graphics::color_conversion::{self, YCbCrBuffer};
use ironrdp_graphics::image_processing::PixelFormat;
use ironrdp_graphics::rectangle_processing::Region;
use ironrdp_graphics::{dwt, quantization, rlgr, subband_reconstruction};
use ironrdp_pdu::codecs::rfx::{self, EntropyAlgorithm, Quant, RfxRectangle, Tile};
use ironrdp_pdu::geometry::{InclusiveRectangle, Rectangle as _};
use ironrdp_pdu::{Decode as _, ReadCursor, decode_cursor};
use tracing::{instrument, trace};
use crate::image::DecodedImage;
use crate::{SessionResult, custom_err, general_err, reason_err};
const TILE_SIZE: u16 = 64;
pub type FrameId = u32;
pub struct DecodingContext {
context: rfx::ContextPdu,
channels: rfx::ChannelsPdu,
decoding_tiles: DecodingTileContext,
}
impl Default for DecodingContext {
fn default() -> Self {
Self {
context: rfx::ContextPdu {
flags: rfx::OperatingMode::empty(),
entropy_algorithm: EntropyAlgorithm::Rlgr1,
},
channels: rfx::ChannelsPdu(Vec::new()),
decoding_tiles: DecodingTileContext::new(),
}
}
}
impl DecodingContext {
pub fn new() -> Self {
Self::default()
}
pub fn decode(
&mut self,
image: &mut DecodedImage,
destination: &InclusiveRectangle,
input: &mut ReadCursor<'_>,
) -> SessionResult<(FrameId, InclusiveRectangle)> {
loop {
let block = rfx::Block::decode(input).map_err(|e| custom_err!("decode block", e))?;
match block {
rfx::Block::Sync(_) => {
self.process_sync(input)?;
}
rfx::Block::CodecChannel(rfx::CodecChannel::FrameBegin(f)) => {
return self.process_frame(f, input, image, destination);
}
_ => {
return Err(reason_err!(
"rfx::DecodingContext",
"unexpected RFX block type: {:?}",
block.block_type()
));
}
}
}
}
fn process_sync(&mut self, input: &mut ReadCursor<'_>) -> SessionResult<()> {
self.process_headers(input)
}
fn process_headers(&mut self, input: &mut ReadCursor<'_>) -> SessionResult<()> {
let mut context = None;
let mut channels = None;
// headers can appear in any order: CodecVersions, Channels, Context
for _ in 0..3 {
match decode_cursor(input).map_err(|e| custom_err!("decode headers", e))? {
rfx::Block::CodecChannel(rfx::CodecChannel::Context(c)) => context = Some(c),
rfx::Block::Channels(c) => channels = Some(c),
rfx::Block::CodecVersions(_) => (),
_ => {
return Err(general_err!("unexpected RFX block type"));
}
}
}
let context = context.ok_or_else(|| general_err!("context header is missing"))?;
let channels = channels.ok_or_else(|| general_err!("channels header is missing"))?;
if channels.0.is_empty() {
return Err(general_err!("no RFX channel announced"));
}
self.context = context;
self.channels = channels;
Ok(())
}
#[instrument(skip_all)]
fn process_frame(
&mut self,
frame_begin: rfx::FrameBeginPdu,
input: &mut ReadCursor<'_>,
image: &mut DecodedImage,
destination: &InclusiveRectangle,
) -> SessionResult<(FrameId, InclusiveRectangle)> {
let channel = self
.channels
.0
.first()
.ok_or_else(|| general_err!("no RFX channel found"))?;
let width = channel.width.try_into().map_err(|_| general_err!("invalid width"))?;
let height = channel.height.try_into().map_err(|_| general_err!("invalid height"))?;
let entropy_algorithm = self.context.entropy_algorithm;
let region: rfx::Block<'_> = decode_cursor(input).map_err(|e| custom_err!("decode region", e))?;
let mut region = match region {
rfx::Block::CodecChannel(rfx::CodecChannel::Region(region)) => region,
_ => return Err(general_err!("unexpected block type")),
};
let tile_set: rfx::Block<'_> = decode_cursor(input).map_err(|e| custom_err!("decode tile_set", e))?;
let tile_set = match tile_set {
rfx::Block::CodecChannel(rfx::CodecChannel::TileSet(t)) => t,
_ => return Err(general_err!("unexpected block type")),
};
let frame_end: rfx::Block<'_> = decode_cursor(input).map_err(|e| custom_err!("decode frame_end", e))?;
if !matches!(frame_end, rfx::Block::CodecChannel(rfx::CodecChannel::FrameEnd(_))) {
return Err(general_err!("unexpected block type"));
}
if region.rectangles.is_empty() {
region.rectangles = vec![RfxRectangle {
x: 0,
y: 0,
width,
height,
}];
}
let region = region;
trace!(frame_index = frame_begin.index);
trace!(destination_rectangle = ?destination);
trace!(context = ?self.context);
trace!(channels = ?self.channels);
trace!(?region);
let clipping_rectangles = clipping_rectangles(region.rectangles.as_slice(), destination, width, height);
trace!("Clipping rectangles: {:?}", clipping_rectangles);
let mut final_update_rectangle = clipping_rectangles.extents.clone();
for (update_rectangle, tile_data) in tiles_to_rectangles(tile_set.tiles.as_slice(), destination)
.zip(map_tiles_data(tile_set.tiles.as_slice(), tile_set.quants.as_slice()))
{
decode_tile(
&tile_data,
entropy_algorithm,
self.decoding_tiles.tile_output.as_mut(),
self.decoding_tiles.ycbcr_buffer.as_mut(),
self.decoding_tiles.ycbcr_temp_buffer.as_mut(),
)?;
let current_update_rectangle = image.apply_tile(
&self.decoding_tiles.tile_output,
PixelFormat::RgbA32,
&clipping_rectangles,
&update_rectangle,
)?;
final_update_rectangle = final_update_rectangle.union(&current_update_rectangle);
}
Ok((frame_begin.index, final_update_rectangle))
}
}
#[derive(Debug, Clone)]
struct DecodingTileContext {
tile_output: Vec<u8>,
ycbcr_buffer: Vec<Vec<i16>>,
ycbcr_temp_buffer: Vec<i16>,
}
impl DecodingTileContext {
fn new() -> Self {
let tile_size = usize::from(TILE_SIZE);
Self {
tile_output: vec![0; tile_size * tile_size * 4],
ycbcr_buffer: vec![vec![0; tile_size * tile_size]; 3],
ycbcr_temp_buffer: vec![0; tile_size * tile_size],
}
}
}
fn decode_tile(
tile: &TileData<'_>,
entropy_algorithm: EntropyAlgorithm,
output: &mut [u8],
ycbcr_temp: &mut [Vec<i16>],
temp: &mut [i16],
) -> SessionResult<()> {
for ((quant, data), ycbcr_buffer) in tile.quants.iter().zip(tile.data.iter()).zip(ycbcr_temp.iter_mut()) {
decode_component(quant, entropy_algorithm, data, ycbcr_buffer.as_mut_slice(), temp)?;
}
let ycbcr_buffer = YCbCrBuffer {
y: ycbcr_temp[0].as_slice(),
cb: ycbcr_temp[1].as_slice(),
cr: ycbcr_temp[2].as_slice(),
};
color_conversion::ycbcr_to_rgba(ycbcr_buffer, output).map_err(|e| custom_err!("decode_tile", e))?;
Ok(())
}
fn decode_component(
quant: &Quant,
entropy_algorithm: EntropyAlgorithm,
data: &[u8],
output: &mut [i16],
temp: &mut [i16],
) -> SessionResult<()> {
rlgr::decode(entropy_algorithm, data, output).map_err(|e| custom_err!("decode_component", e))?;
subband_reconstruction::decode(&mut output[4032..]);
quantization::decode(output, quant);
dwt::decode(output, temp);
Ok(())
}
fn clipping_rectangles(
rectangles: &[RfxRectangle],
destination: &InclusiveRectangle,
width: u16,
height: u16,
) -> Region {
let mut clipping_rectangles = Region::new();
rectangles
.iter()
.map(|r| InclusiveRectangle {
left: min(destination.left + r.x, width - 1),
top: min(destination.top + r.y, height - 1),
right: min(destination.left + r.x + r.width - 1, width - 1),
bottom: min(destination.top + r.y + r.height - 1, height - 1),
})
.for_each(|r| clipping_rectangles.union_rectangle(r));
clipping_rectangles
}
fn tiles_to_rectangles<'a>(
tiles: &'a [Tile<'_>],
destination: &'a InclusiveRectangle,
) -> impl Iterator<Item = InclusiveRectangle> + 'a {
tiles.iter().map(|t| InclusiveRectangle {
left: destination.left + t.x * TILE_SIZE,
top: destination.top + t.y * TILE_SIZE,
right: destination.left + t.x * TILE_SIZE + TILE_SIZE - 1,
bottom: destination.top + t.y * TILE_SIZE + TILE_SIZE - 1,
})
}
fn map_tiles_data<'a>(tiles: &[Tile<'a>], quants: &[Quant]) -> Vec<TileData<'a>> {
tiles
.iter()
.map(|t| TileData {
quants: [
quants[usize::from(t.y_quant_index)].clone(),
quants[usize::from(t.cb_quant_index)].clone(),
quants[usize::from(t.cr_quant_index)].clone(),
],
data: [t.y_data, t.cb_data, t.cr_data],
})
.collect()
}
struct TileData<'a> {
quants: [Quant; 3],
data: [&'a [u8]; 3],
}
+290
View File
@@ -0,0 +1,290 @@
use ironrdp_core::{WriteBuf, decode};
use ironrdp_dvc::{DrdynvcClient, DvcProcessor, DynamicVirtualChannel};
use ironrdp_pdu::mcs::{DisconnectProviderUltimatum, DisconnectReason, McsMessage, SendDataIndicationCtx};
use ironrdp_pdu::rdp::autodetect::{AutoDetectReqPdu, AutoDetectRequest, AutoDetectResponse, AutoDetectRspPdu};
use ironrdp_pdu::rdp::headers::ShareDataPdu;
use ironrdp_pdu::rdp::multitransport::MultitransportRequestPdu;
use ironrdp_pdu::rdp::server_error_info::{ErrorInfo, ProtocolIndependentCode, ServerSetErrorInfoPdu};
use ironrdp_pdu::x224::X224;
use ironrdp_svc::{StaticChannelSet, SvcMessage, SvcProcessor, SvcProcessorMessages, client_encode_svc_messages};
use tracing::debug;
use crate::{SessionError, SessionErrorExt as _, SessionResult, reason_err};
/// X224 Processor output
#[derive(Debug, Clone)]
pub enum ProcessorOutput {
/// A buffer with encoded data to send to the server.
ResponseFrame(Vec<u8>),
/// A graceful disconnect notification. Client should close the connection upon receiving this.
Disconnect(DisconnectDescription),
/// Received a [`ironrdp_pdu::rdp::headers::ServerDeactivateAll`] PDU. Client should execute the
/// [Deactivation-Reactivation Sequence].
///
/// [Deactivation-Reactivation Sequence]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432
DeactivateAll,
/// Server Initiate Multitransport Request. The application should establish a
/// sideband UDP transport using the request ID and security cookie, then send
/// a [`MultitransportResponsePdu`] back on the IO channel.
///
/// See [\[MS-RDPBCGR\] 2.2.15.1].
///
/// [\[MS-RDPBCGR\] 2.2.15.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/de783158-8b01-4818-8fb0-62523a5b3490
/// [`MultitransportResponsePdu`]: ironrdp_pdu::rdp::multitransport::MultitransportResponsePdu
MultitransportRequest(MultitransportRequestPdu),
/// Auto-detect network characteristics from server ([\[MS-RDPBCGR\] 2.2.14]).
///
/// Currently only surfaces [`AutoDetectRequest::NetworkCharacteristicsResult`].
/// RTT requests are handled internally with automatic responses.
///
/// [\[MS-RDPBCGR\] 2.2.14]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dc672839-4f4e-40b1-a71c-cd6a959baa38
AutoDetect(AutoDetectRequest),
/// Slow-path graphics update ([MS-RDPBCGR] 2.2.9.1.1.3).
/// Raw update payload starting with `updateType(u16)`.
GraphicsUpdate(Vec<u8>),
/// Slow-path pointer update ([MS-RDPBCGR] 2.2.9.1.1.4).
/// Raw pointer payload starting with `messageType(u16) + pad(u16)`.
PointerUpdate(Vec<u8>),
}
#[derive(Debug, Clone)]
pub enum DisconnectDescription {
/// Includes the reason from the MCS Disconnect Provider Ultimatum.
/// This is the least-specific disconnect reason and is only used
/// when a more specific disconnect code is not available.
McsDisconnect(DisconnectReason),
/// Includes the error information sent by the RDP server when there
/// is a connection or disconnection failure.
ErrorInfo(ErrorInfo),
}
pub struct Processor {
static_channels: StaticChannelSet,
user_channel_id: u16,
io_channel_id: u16,
message_channel_id: Option<u16>,
share_id: u32,
}
impl Processor {
pub fn new(
static_channels: StaticChannelSet,
user_channel_id: u16,
io_channel_id: u16,
message_channel_id: Option<u16>,
share_id: u32,
) -> Self {
Self {
static_channels,
user_channel_id,
io_channel_id,
message_channel_id,
share_id,
}
}
pub fn set_share_id(&mut self, share_id: u32) {
self.share_id = share_id;
}
pub fn get_svc_processor<T: SvcProcessor + 'static>(&self) -> Option<&T> {
self.static_channels
.get_by_type::<T>()
.and_then(|svc| svc.channel_processor_downcast_ref())
}
pub fn get_svc_processor_mut<T: SvcProcessor + 'static>(&mut self) -> Option<&mut T> {
self.static_channels
.get_by_type_mut::<T>()
.and_then(|svc| svc.channel_processor_downcast_mut())
}
/// Completes user's SVC request with data, required to sent it over the network and returns
/// a buffer with encoded data.
pub fn process_svc_processor_messages<C: SvcProcessor + 'static>(
&self,
messages: SvcProcessorMessages<C>,
) -> SessionResult<Vec<u8>> {
let channel_id = self
.static_channels
.get_channel_id_by_type::<C>()
.ok_or_else(|| reason_err!("SVC", "channel not found"))?;
process_svc_messages(messages.into(), channel_id, self.user_channel_id)
}
pub fn get_dvc<T: DvcProcessor + 'static>(&self) -> Option<&DynamicVirtualChannel> {
self.get_svc_processor::<DrdynvcClient>()?.get_dvc_by_type_id::<T>()
}
pub fn get_dvc_by_channel_id(&self, channel_id: u32) -> Option<&DynamicVirtualChannel> {
self.get_svc_processor::<DrdynvcClient>()?
.get_dvc_by_channel_id(channel_id)
}
/// Processes a received PDU. Returns a vector of [`ProcessorOutput`] that must be processed
/// in the returned order.
pub fn process(&mut self, frame: &[u8]) -> SessionResult<Vec<ProcessorOutput>> {
let data_ctx: SendDataIndicationCtx<'_> =
ironrdp_pdu::mcs::decode_send_data_indication(frame).map_err(SessionError::decode)?;
let channel_id = data_ctx.channel_id;
if channel_id == self.io_channel_id {
self.process_io_channel(data_ctx)
} else if self.message_channel_id == Some(channel_id) {
self.process_message_channel(data_ctx)
} else if let Some(svc) = self.static_channels.get_by_channel_id_mut(channel_id) {
let response_pdus = svc.process(data_ctx.user_data).map_err(SessionError::pdu)?;
process_svc_messages(response_pdus, channel_id, data_ctx.initiator_id)
.map(|data| vec![ProcessorOutput::ResponseFrame(data)])
} else {
Err(reason_err!("X224", "unexpected channel received: ID {channel_id}"))
}
}
fn process_io_channel(&self, data_ctx: SendDataIndicationCtx<'_>) -> SessionResult<Vec<ProcessorOutput>> {
debug_assert_eq!(data_ctx.channel_id, self.io_channel_id);
let io_channel = ironrdp_pdu::rdp::headers::decode_io_channel(data_ctx).map_err(SessionError::decode)?;
match io_channel {
ironrdp_pdu::rdp::headers::IoChannelPdu::Data(ctx) => {
match ctx.pdu {
ShareDataPdu::SaveSessionInfo(session_info) => {
debug!("Got Session Save Info PDU: {session_info:?}");
Ok(Vec::new())
}
// FIXME: workaround fix to not terminate the session on "unhandled PDU: Set Keyboard Indicators PDU"
ShareDataPdu::SetKeyboardIndicators(data) => {
debug!("Got Keyboard Indicators PDU: {data:?}");
Ok(Vec::new())
}
ShareDataPdu::ServerSetErrorInfo(ServerSetErrorInfoPdu(ErrorInfo::ProtocolIndependentCode(
ProtocolIndependentCode::None,
))) => {
debug!("Received None server error");
Ok(Vec::new())
}
ShareDataPdu::ServerSetErrorInfo(ServerSetErrorInfoPdu(e)) => {
// This is a part of server-side graceful disconnect procedure defined
// in [MS-RDPBCGR].
//
// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/149070b0-ecec-4c20-af03-934bbc48adb8
let desc = DisconnectDescription::ErrorInfo(e);
Ok(vec![ProcessorOutput::Disconnect(desc)])
}
ShareDataPdu::ShutdownDenied => {
debug!("ShutdownDenied received, session will be closed");
// As defined in [MS-RDPBCGR], when `ShareDataPdu::ShutdownDenied` is received, we
// need to send a disconnect ultimatum to the server if we want to proceed with the
// session shutdown.
//
// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/27915739-8f77-487e-9927-55008af7fd68
let ultimatum = McsMessage::DisconnectProviderUltimatum(
DisconnectProviderUltimatum::from_reason(DisconnectReason::UserRequested),
);
let encoded_pdu = ironrdp_core::encode_vec(&X224(ultimatum)).map_err(SessionError::encode);
Ok(vec![
ProcessorOutput::ResponseFrame(encoded_pdu?),
ProcessorOutput::Disconnect(DisconnectDescription::McsDisconnect(
DisconnectReason::UserRequested,
)),
])
}
// TODO: slow-path payloads may be bulk-compressed when
// ClientInfoFlags::COMPRESSION is negotiated. Decompression
// should happen here before passing data downstream. Currently
// IronRDP does not wire bulk decompression into this path.
// FIXME: until this is wired, the client deliberately defaults to the simple,
// stateless-friendly MPPC 64K (RDP5) compression level rather than XCRUSH; a
// stateful codec would risk silent corruption on slow-path updates.
ShareDataPdu::Update(data) => {
debug!("Got slow-path graphics update ({} bytes)", data.len());
Ok(vec![ProcessorOutput::GraphicsUpdate(data)])
}
ShareDataPdu::Pointer(data) => {
debug!("Got slow-path pointer update ({} bytes)", data.len());
Ok(vec![ProcessorOutput::PointerUpdate(data)])
}
_ => Err(reason_err!(
"IO channel",
"unhandled PDU: {:?}",
ctx.pdu.as_short_name()
)),
}
}
ironrdp_pdu::rdp::headers::IoChannelPdu::MultitransportRequest(pdu) => {
debug!(
"Received Initiate Multitransport Request: request_id={}",
pdu.request_id
);
Ok(vec![ProcessorOutput::MultitransportRequest(pdu)])
}
ironrdp_pdu::rdp::headers::IoChannelPdu::DeactivateAll(_) => Ok(vec![ProcessorOutput::DeactivateAll]),
}
}
/// Process an auto-detect request received on the MCS message channel.
///
/// During continuous auto-detection ([MS-RDPBCGR] 2.2.14) the server sends
/// RTT (and bandwidth) requests on the message channel; the client answers
/// RTT requests and surfaces the final Network Characteristics Result.
fn process_message_channel(&self, data_ctx: SendDataIndicationCtx<'_>) -> SessionResult<Vec<ProcessorOutput>> {
let Some(message_channel_id) = self.message_channel_id else {
return Err(reason_err!("message channel", "no message channel negotiated"));
};
let req = decode::<AutoDetectReqPdu>(data_ctx.user_data).map_err(SessionError::decode)?;
match req.request {
AutoDetectRequest::RttRequest { sequence_number, .. } => {
let response = AutoDetectRspPdu::new(AutoDetectResponse::RttResponse { sequence_number });
let mut frame = WriteBuf::new();
ironrdp_pdu::mcs::encode_send_data_request(
self.user_channel_id,
message_channel_id,
&response,
&mut frame,
)
.map_err(SessionError::encode)?;
debug!(sequence_number, "Responded to auto-detect RTT request");
Ok(vec![ProcessorOutput::ResponseFrame(frame.into_inner())])
}
req @ AutoDetectRequest::NetworkCharacteristicsResult { .. } => {
debug!(?req, "Received network characteristics from server");
Ok(vec![ProcessorOutput::AutoDetect(req)])
}
req => {
debug!(?req, "Auto-detect request not yet implemented");
Ok(Vec::new())
}
}
}
/// Send a pdu on the static global channel. Typically used to send input events
pub fn encode_static(&self, output: &mut WriteBuf, pdu: ShareDataPdu) -> SessionResult<usize> {
let written = ironrdp_pdu::rdp::headers::encode_share_data(
self.user_channel_id,
self.io_channel_id,
self.share_id,
pdu,
output,
)
.map_err(SessionError::encode)?;
Ok(written)
}
}
/// Processes a vector of [`SvcMessage`] in preparation for sending them to the server on the `channel_id` channel.
///
/// This includes chunkifying the messages, adding MCS, x224, and tpkt headers, and encoding them into a buffer.
/// The messages returned here are ready to be sent to the server.
///
/// The caller is responsible for ensuring that the `channel_id` corresponds to the correct channel.
fn process_svc_messages(messages: Vec<SvcMessage>, channel_id: u16, initiator_id: u16) -> SessionResult<Vec<u8>> {
client_encode_svc_messages(messages, channel_id, initiator_id).map_err(SessionError::encode)
}
+380
View File
@@ -0,0 +1,380 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [[7.0.0-rc.25](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.24...picky-v7.0.0-rc.25)] - 2026-06-26
### <!-- 7 -->Build
- Align RustCrypto ECC dependency versions and remove unnecessary transitive pins ([#506](https://github.com/Devolutions/picky-rs/issues/506)) ([a4346f2853](https://github.com/Devolutions/picky-rs/commit/a4346f2853e726eb05b1fb5de2fa9351c8c44e2e))
## [[7.0.0-rc.24](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.23...picky-v7.0.0-rc.24)] - 2026-06-19
### <!-- 7 -->Build
- Bump sha3 from 0.11.0 to 0.12.0 ([#495](https://github.com/Devolutions/picky-rs/issues/495)) ([0b065a59fb](https://github.com/Devolutions/picky-rs/commit/0b065a59fb1e65c354084016166a7822ab10f7bf))
- Advance RustCrypto pins to the current rc.18/rc.33/rc.10 cluster ([#501](https://github.com/Devolutions/picky-rs/issues/501)) ([7d4b2675b9](https://github.com/Devolutions/picky-rs/commit/7d4b2675b91f7803905d972501130fc09d42ce1c))
## [[7.0.0-rc.23](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.22...picky-v7.0.0-rc.23)] - 2026-04-21
### <!-- 7 -->Build
- Upgrade RustCrypto dependencies ([#476](https://github.com/Devolutions/picky-rs/issues/476)) ([8ce838e347](https://github.com/Devolutions/picky-rs/commit/8ce838e3470ac605c912f61dc67c1c6d388b140d))
## [[7.0.0-rc.22](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.21...picky-v7.0.0-rc.22)] - 2026-02-27
### <!-- 4 -->Bug Fixes
- Bump `sha1` to v0.11.0-rc.3 ([#463](https://github.com/Devolutions/picky-rs/issues/463)) ([e40aee72bd](https://github.com/Devolutions/picky-rs/commit/e40aee72bd248bda84e6fe9e84a648784a6374ed))
## [[7.0.0-rc.21](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.20...picky-v7.0.0-rc.21)] - 2026-02-02
### <!-- 7 -->Build
- Update crypto dependencies ([#448](https://github.com/Devolutions/picky-rs/issues/448)) ([0bf42c1c4b](https://github.com/Devolutions/picky-rs/commit/0bf42c1c4bd727eaf5b4b0a877e8698986e0bd37))
- Remove transitive pem-rfc7468 crate ([#449](https://github.com/Devolutions/picky-rs/issues/449)) ([cc4caf455d](https://github.com/Devolutions/picky-rs/commit/cc4caf455defd38ef37fc15434b173275937d534))
- Bump reqwest from 0.12.24 to 0.13.1 ([#453](https://github.com/Devolutions/picky-rs/issues/453)) ([9cbaab9395](https://github.com/Devolutions/picky-rs/commit/9cbaab93955f2fef8f96a589b22e2582d4a8cbf0))
## [[7.0.0-rc.20](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.19...picky-v7.0.0-rc.20)] - 2025-11-07
### <!-- 4 -->Bug Fixes
- Pin leftover pre-release crypto crates ([#430](https://github.com/Devolutions/picky-rs/issues/430)) ([0fe43dae7b](https://github.com/Devolutions/picky-rs/commit/0fe43dae7b463a16d48b00b08ca6ce21a013af74))
The patch version upgrade is not allowed to bring breaking changes, but
this rule doesn't work for an `rc` version. So we should pin the `rc`
versions, to not allow _cargo_ update to a new `rc` automatically.
## [[7.0.0-rc.19](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.18...picky-v7.0.0-rc.19)] - 2025-10-10
### <!-- 1 -->Features
- Support parsing certs with MLDSA public keys ([#411](https://github.com/Devolutions/picky-rs/issues/411)) ([abc63dde88](https://github.com/Devolutions/picky-rs/commit/abc63dde88360e35ef225cfd1c279958f47c3abc))
### <!-- 7 -->Build
- Pin RustCrypto release candidate crates ([#417](https://github.com/Devolutions/picky-rs/issues/417)) ([8a79282bbc](https://github.com/Devolutions/picky-rs/commit/8a79282bbc0dae9df222f16d261b7dd1f03cd66f))
## [[7.0.0-rc.18](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.17...picky-v7.0.0-rc.18)] - 2025-09-26
### <!-- 7 -->Build
- Bump the crypto group across 1 directory with 3 updates (#388) ([58d179a0c3](https://github.com/Devolutions/picky-rs/commit/58d179a0c39d701025a363c3f294912c2881a8f5))
## [[7.0.0-rc.17](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.16...picky-v7.0.0-rc.17)] - 2025-08-18
### <!-- 7 -->Build
- Bump rstest from 0.25.0 to 0.26.1 (#391) ([2a169deba2](https://github.com/Devolutions/picky-rs/commit/2a169deba2ba177685d58550f4214f59f25ebec0))
## [[7.0.0-rc.16](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.15...picky-v7.0.0-rc.16)] - 2025-07-15
### <!-- 0 -->Security
- Reject issuer certificates missing basic constraints (#385) ([4d8be23912](https://github.com/Devolutions/picky-rs/commit/4d8be239126cd34843a74de017118823699b8aa7))
As per RFC 5280:
> The cA boolean indicates whether the certified public key may be used
> to verify certificate signatures. If the cA boolean is not asserted,
> then the keyCertSign bit in the key usage extension MUST NOT be
> asserted. If the basic constraints extension is not present in a
> version 3 certificate, or the extension is present but the cA boolean
> is not asserted, then the certified public key MUST NOT be used to
> verify certificate signatures.
## [[7.0.0-rc.15](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.14...picky-v7.0.0-rc.15)] - 2025-06-24
### <!-- 7 -->Build
- Bump MSVC to 1.85 (#379) ([302df9912d](https://github.com/Devolutions/picky-rs/commit/302df9912d6f038f21b64fc916bd82836944c382))
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [[7.0.0-rc.14](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.13...picky-v7.0.0-rc.14)] - 2025-05-14
### <!-- 1 -->Features
- Add HttpSignature::to_signing_string() method ([fe054c47f0](https://github.com/Devolutions/picky-rs/commit/fe054c47f04920383d93371bda948a961cbefdfd))
Equivalent to HttpSignature::to_string().
## [[7.0.0-rc.13](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.12...picky-v7.0.0-rc.13)] - 2025-03-03
### <!-- 4 -->Bug Fixes
- [**breaking**] Comply with draft-cavage-http-signatures-12 when signing HTTP messages (#351) ([f043d04e14](https://github.com/Devolutions/picky-rs/commit/f043d04e1462cf0eddd9140f22a4d364cb3ad9cd))
Change request target handling to comply with draft-cavage-http-signatures-12 when signing HTTP messages.
## [[7.0.0-rc.12](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.11...picky-v7.0.0-rc.12)] - 2025-01-16
### <!-- 4 -->Bug Fixes
- Symlinks to license files in packages (#339) ([1834c04f39](https://github.com/Devolutions/picky-rs/commit/1834c04f3930fb1bbf040deb6525b166e378b8aa))
Use symlinks instead of copying files to avoid a “dirty” state during
cargo publish and preserve VCS info. With #337 merged, CI handles
publishing consistently, so developer environments no longer matter.
## [7.0.0-rc.1 to 7.0.0-rc.11](https://github.com/Devolutions/picky-rs/compare/picky-6.4.0...picky-v7.0.0-rc.11)
### Added
- Support for ECDSA p256 p384 and p521 signatures
- Support for MD5 hashing
- CTL implementation behind `ctl` feature
- CTL fetching over HTTP is behind `ctl_http_fetch` feature
- `Pkcs7::digest_algorithms`
- `Pkcs7::signer_infos`
- `Pkcs7::encapsulated_content_info`
- `Pkcs7::decode_certificates`
- `impl From<Pkcs7Certificate> for Pkcs7`
- `impl From<Pkcs7> for Pkcs7Certificate`
- `AuthenticodeSignature` struct
- `AuthenticodeSignature::new`
- `AuthenticodeSignature::from_der`
- `AuthenticodeSignature::from_pem`
- `AuthenticodeSignature::from_pem_str`
- `AuthenticodeSignature::to_der`
- `AuthenticodeSignature::to_pem`
- `AuthenticodeSignature::signing_certificate`
- `AuthenticodeSignature::authenticode_verifier`
- `AuthenticodeSignature::file_hash`
- `Authenticated_attributes::authenticated_attributes`
- `Authenticated_attributes::unauthenticated_attributes`
- `impl From<Pkcs7> for AuthenticodeSignature`
- `From<AuthenticodeSignature> for Pkcs7`
- Authenticode validation
- Support for `AuthenticodeSignature` timestamping:
- Method `timestamp` to `AuthenticodeSignature`
- `Timestamper` trait.
- Timestamping implementation using reqwest is behind `http_timestamp` feature
- Authenticode timestamp request struct - `TimestampRequest`
- `AuthenticodeBuilder` for easier `AuthenticodeSignature` creation
- `SignatureAlgorithm::hash_algorithm`
- Support for `time 0.3` types conversions behind `time_conversion` feature gate
- `PrivateKey::to_pem_str`
- `PrivateKey::from_ec_components`
- `PrivateKey::from_ec_der_with_curve`
- `PrivateKey::from_ec_der_with_curve_oid`
- `PrivateKey::generate_ec`
- `PrivateKey::generate_ed`
- `PrivateKey::from_ec_encoded_components`
- `PrivateKey::from_ed_encoded_components`
- `PublicKey::to_pem_str`
- Support SSH keys and certificates
- `CheckedJwtEnc::new_with_cty`
- `CheckedJwtSig::new_with_cty`
- Support for JWT additional header parameters
- Support for EC private/public keys
- JWS and JWK EC curves support
- EC curves support for SSH keys and certificates
- EC x509 certificates verification/signing
- `JwkKeyType::new_ec_key`
- `JwkKeyType::new_ed_key`
- `JwkKeyType::as_ec`
- `JwkKeyType::as_ed`
- `JwkKeyType::is_ec`
- `JwkKeyType::is_ed`
- `SshPrivateKey::generate_ec`
- `SshPrivateKey::generate_ed`
- `JwkEcPublicKeyCurve` enum
- `JwkEdPublicKeyAlgorithm` enum
- `JwsAlg::EdDSA` variant
- `JwsAlg::ED25519` variant (`#[deprecated]` from the start, just for compatibility with other libs)
- Support for X25519 and Ed25519 public/private keys
- JWS and JWK Ed25519 keys/signing support
- Ed25519 support for SSH keys and certificates
- Ed25519 x509 certificates verification/signing
- Support for `ECDH-ES` JWE algorithm (P256, P384 and P521 EC keys, X25519 ED keys)
- Support for `PKCS12`(PFX) files parsing/building
- PuTTY PPK key format support
### Changed
- Bump minimal rustc version to 1.70
- Updated `p256`/`p384` to latest version
- (Breaking) Move Authenticode related code from `picky::x509::wincert` to `picky::x509::pkcs7::authenticode` module
- (Breaking) Authenticode implementation is now behind `pkcs7` feature
- (Breaking) `PrivateKey::to_pem` and `PublicKey::to_pem` now return a `Pem`
- (Breaking) Separate JWT validation from decoding in JOSE API (this makes API more convenient to first process header
and then decide what kind of validation should be applied, or what claims type to deserialize into)
- (Breaking) `PrivateKey` now have private struct representation instead of open enum
- (Breaking) Now all `PrivateKey::to_public` and all related APIs for private keys return `Result`
instead of plain `PublicKey`. This change was required because added support for EC/ED keys is not
infallible in regard to public key extraction (public key could be missing from the file and could
not be generated because of an unsupported algorithm/curve)
- "picky-test-data" is a new dev-dependency.
### Removed
- (Breaking) `ec` non-default cargo feature (EC operations now enabled by default)
### Fixed
- `BufReader` panic in `WinCertificate::decode` and `WinCertificate::encode` if data len is bigger than default capacity.
- `WinCertificate` encoding: `length` wasn’t correct.
- Leading zeros in JWK encoding.
JWK encoding of a value is the unsigned big-endian representation as an octet sequence.
The octet sequence MUST utilize the minimum number of octets needed to represent the value.
That is: **no leading zero** must be present.
See issue [#140](https://github.com/Devolutions/picky-rs/issues/140)
- Fetch curve info from private_key_algorithm ([#143](https://github.com/Devolutions/picky-rs/issues/143))
Pick curve info from private_key_algorithm field.
- Missing `zeroize` on drop of internal `EcdsaKeypair` structure
- Fixed invalid padding logic of encrypted SSH keys
- Fixed invalid generated SSH EC keys
### Removed
- (Breaking) `Jwt::new_encrypted`
- (Breaking) `Jwt::new_signed`
## [6.4.0](https://github.com/Devolutions/picky-rs/compare/picky-6.3.0...picky-6.4.0) – 2021-08-10
### Changed
- Bump minimum supported Rust version to 1.51
- Update `rsa` dependency to `0.5`
- Update `picky-asn1` dependency to `0.4`
- More robust certification validation (see commit [f5f8cb60e41](https://github.com/Devolutions/picky-rs/commit/f5f8cb60e410ffe49aabace131f7b802e206ced0) for details)
## [6.3.0](https://github.com/Devolutions/picky-rs/compare/picky-6.2.0...picky-6.3.0) – 2021-05-27
### Added
- PKCS7 implementation behind `pkcs7` feature
- `WinCertificate` for authenticode behind `wincert` feature
### Changed
- Update `aes-gcm` dependency to `0.9`
## [6.2.0](https://github.com/Devolutions/picky-rs/compare/picky-6.1.1...picky-6.2.0) – 2021-03-04
### Added
- `Csr::generate_with_attributes` to generate CSR with attributes.
- `CertificateBuilder::inherit_extensions_from_csr_attributes` to allow certificate to inherit extensions requested by CSR attribute.
- Various API additions to `GeneralNames` for improved ergonomics.
## 6.1.2 – 2021-01-11
### Fixed
- Fix bad `use`s statements to `serde::export`
## 6.1.1 – 2020-12-11
### Fixed
- Fix `HttpSignatureError`'s Display trait implementation (`certificate expired` → `signature expired`).
- Fix certificate validity period that MUST be encoded as `UTCTime` through the year 2049 as per RFC 5280.
Previously, they were always encoded as `GeneralizedTime`.
## 6.1.0 – 2020-10-21
### Added
- `CertificateBuilder::serial_number` can be used to provide a custom serial number instead of generating one.
### Fixed
- Bad generation for second exponent (`pq`) when generating PKCS#8 structure.
- Serial number was sometimes generated as negative.
## 6.0.0 – 2020-10-13
### Added
- Implementation of `Jwe` (JSON Web Encryption) RFC.
- Email attribute can be added to `DirectoryName` using `add_email` method.
- `from_pem_str` method to `Cert`, `Csr`, `PrivateKey` and `PublicKey`.
### Changed
- Separate `SignatureHashType` into two separate enums `HashAlgorithm` and `SignatureAlgorithm`.
- `KeyIdGenError` is now wrapping a `HashAlgorithm`.
- Update `rsa` dependency.
- `HttpSignature` store custom `algorithm` names instead of just ignoring them.
- Major `jose` module changes
- JOSE header fields are renamed to be identical to RFCs when possible.
- `Jwt` is now divided into `Jws` (JSON Web Signature) and `Jwe` (JSON Web Encryption)
- `Jws` provides an API to sign any kind of data (binary). JSON claims are part of `Jwt` only.
- `Jwe` provides an API to encrypt any kind of data (binary). JSON claims are part of `Jwt` only.
- Typo in `CertificateBuilder` API: `valididy` has been renamed to `validity`.
### Fixed
- RSA private key generation ([#53](https://github.com/Devolutions/picky-rs/issues/53)).
## 5.1.1 – 2020-07-13
### Changed
- Better `CaChainError::AuthorityKeyIdMismatch` display.
## 5.1.0 – 2020-07-07
### Added
- Add `BufRead`-based API to read PEM streams: `pem::Pem::read_from` and `pem::read_pem`.
### Changed
- Some internal types are moved to a new `picky_asn1_x509` crate but API is unchanged.
- Dependencies clean up.
## 5.0.0 – 2020-05-06
### Added
- Add `into_public_key` method to `x509::Cert`.
- Support for the ["algorithm" http signature parameter](https://tools.ietf.org/html/draft-cavage-http-signatures-12#section-2.1.3).
### Changed
- `Jwt` has no generic lifetime parameter anymore.
### Removed
- `Cert::verify` and `Cert::verify_chain` methods (previously deprecated)
- `HttpRequest` trait impl for `http` crate v0.1
### Fixed
- Add missing parameters for AES and SHA object identifiers ([668c06e8d](https://github.com/Devolutions/picky-rs/commit/668c06e8d8e8a0caae8bd13cf81c189bbc2e4918))
## 4.7.0 – 2020-04-16
### Added
- Implement `From<UTCDate>` trait on `DateTime<Utc>`.
- Support for leeway in HTTP signature verifier.
- Verifier API to X509 Cert.
### Deprecated
- `Cert::verify` and `Cert::verify_chain` methods in favor of the `Cert::verifier` method.
+2564
View File
File diff suppressed because it is too large Load Diff
+353
View File
@@ -0,0 +1,353 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
rust-version = "1.85"
name = "picky"
version = "7.0.0-rc.25"
authors = [
"Benoît CORTIER <bcortier@devolutions.net>",
"Jonathan Trepanier <jtrepanier@devolutions.net>",
"François Dubois <fdubois@devolutions.net>",
"Richard Markiewicz <rmarkiewicz@devolutions.net>",
"Ionut Mihalcea <ionut.mihalcea@arm.com>",
"Kim Altintop <kim@monadic.xyz>",
"Alexandr Yusuk <aleksandr.yusuk@apriorit.com>",
"Flavio Castelli <flavio@castelli.me>",
"Xynnn_ <xynnn@linux.alibaba.com>",
]
build = false
include = [
"src/**/*",
"README.md",
"CHANGELOG.md",
"LICENSE-*",
]
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Portable X.509, PKI, JOSE and HTTP signature implementation."
readme = "README.md"
keywords = [
"x509",
"jwt",
"signature",
"jose",
"pki",
]
license = "MIT OR Apache-2.0"
repository = "https://github.com/Devolutions/picky-rs"
resolver = "2"
[package.metadata.docs.rs]
all-features = true
[features]
chrono_conversion = [
"dep:chrono",
"picky-asn1/chrono_conversion",
]
ctl = [
"picky-asn1-x509/ctl",
"pkcs7",
"chrono_conversion",
]
ctl_http_fetch = [
"dep:reqwest",
"dep:cab",
"ctl",
]
default = [
"x509",
"jose",
"http_signature",
"http_trait_impl",
"pkcs12",
]
http_signature = []
http_timestamp = ["dep:reqwest"]
http_trait_impl = ["dep:http"]
jose = [
"dep:serde_json",
"dep:aes-gcm",
"dep:ctr",
"dep:cbc",
"dep:aes-kw",
"dep:aes",
]
pkcs12 = [
"picky-asn1-x509/pkcs12",
"dep:des",
"dep:rc2",
"dep:cbc",
"dep:hmac",
"dep:aes",
"x509",
"dep:pbkdf2",
]
pkcs7 = [
"x509",
"picky-asn1-x509/pkcs7",
]
putty = [
"dep:argon2",
"dep:hmac",
"ssh",
"dep:blake2",
]
ssh = [
"dep:byteorder",
"dep:aes",
"dep:ctr",
"dep:cbc",
"dep:bcrypt-pbkdf",
"dep:lexical-sort",
"dep:hmac",
"dep:pbkdf2",
]
time_conversion = [
"dep:time",
"picky-asn1/time_conversion",
]
wincert = [
"x509",
"dep:byteorder",
]
x509 = []
[lib]
name = "picky"
path = "src/lib.rs"
[dependencies.aes]
version = "0.9"
optional = true
[dependencies.aes-gcm]
version = "^0.11.0-rc.4"
optional = true
[dependencies.aes-kw]
version = "0.3"
optional = true
[dependencies.argon2]
version = "^0.6.0-rc.8"
optional = true
[dependencies.base64]
version = "0.22"
[dependencies.bcrypt-pbkdf]
version = "0.11"
optional = true
[dependencies.blake2]
version = "^0.11.0-rc.6"
optional = true
[dependencies.byteorder]
version = "1.5"
optional = true
[dependencies.cab]
version = "0.6"
optional = true
[dependencies.cbc]
version = "0.2"
features = ["alloc"]
optional = true
[dependencies.chrono]
version = "0.4"
features = ["clock"]
optional = true
default-features = false
[dependencies.crypto-bigint]
version = "0.7"
[dependencies.crypto-common]
version = "0.2"
[dependencies.ctr]
version = "0.10"
optional = true
[dependencies.curve25519-dalek]
version = "^5.0.0-rc.1"
[dependencies.des]
version = "0.9"
optional = true
[dependencies.digest]
version = "0.11"
[dependencies.ecdsa]
version = "^0.17.0-rc.22"
[dependencies.ed25519-dalek]
version = "^3.0.0-rc.1"
features = [
"hazmat",
"rand_core",
]
[dependencies.hex]
version = "0.4"
[dependencies.hmac]
version = "0.13"
optional = true
[dependencies.http]
version = "1.4"
optional = true
[dependencies.inout]
version = "0.2.2"
[dependencies.lexical-sort]
version = "0.3"
optional = true
[dependencies.md5]
version = "0.11"
features = ["oid"]
package = "md-5"
[dependencies.p256]
version = "^0.14.0-rc.14"
features = ["ecdh"]
[dependencies.p384]
version = "^0.14.0-rc.14"
features = ["ecdh"]
[dependencies.p521]
version = "^0.14.0-rc.14"
features = ["ecdh"]
[dependencies.pbkdf2]
version = "0.13"
optional = true
[dependencies.picky-asn1]
version = "0.10"
features = ["zeroize"]
[dependencies.picky-asn1-der]
version = "0.5"
[dependencies.picky-asn1-x509]
version = "0.15"
features = [
"legacy",
"zeroize",
]
[dependencies.pkcs1]
version = "^0.8.0-rc.4"
[dependencies.primeorder]
version = "^0.14.0-rc.14"
[dependencies.rand]
version = "0.10"
[dependencies.rand_core]
version = "0.10"
[dependencies.rc2]
version = "0.9"
optional = true
[dependencies.reqwest]
version = "0.13"
features = ["blocking"]
optional = true
default-features = false
[dependencies.rsa]
version = "^0.10.0-rc.18"
features = ["std"]
[dependencies.rustcrypto-ff]
version = "^0.14.0-rc.1"
[dependencies.rustcrypto-ff_derive]
version = "^0.14.0-rc.0"
[dependencies.rustcrypto-group]
version = "^0.14.0-rc.1"
[dependencies.serde]
version = "1"
features = ["derive"]
[dependencies.serde_json]
version = "1"
optional = true
[dependencies.sha1]
version = "0.11"
features = ["oid"]
[dependencies.sha2]
version = "0.11"
features = ["oid"]
[dependencies.sha3]
version = "0.12"
features = ["oid"]
[dependencies.thiserror]
version = "2"
[dependencies.time]
version = "0.3"
optional = true
[dependencies.x25519-dalek]
version = "^3.0.0-rc.1"
features = ["static_secrets"]
[dependencies.zeroize]
version = "1.8"
[dev-dependencies.cfg-if]
version = "1.0"
[dev-dependencies.expect-test]
version = "1"
[dev-dependencies.picky-test-data]
version = "0.1"
[dev-dependencies.pretty_assertions]
version = "1.4"
[dev-dependencies.rand_chacha]
version = "0.10"
[dev-dependencies.ring]
version = "0.17"
[dev-dependencies.rstest]
version = "0.26"
[dev-dependencies.tempfile]
version = "3.22"
+128
View File
@@ -0,0 +1,128 @@
[package]
name = "picky"
version = "7.0.0-rc.25"
authors = [
"Benoît CORTIER <bcortier@devolutions.net>",
"Jonathan Trepanier <jtrepanier@devolutions.net>",
"François Dubois <fdubois@devolutions.net>",
"Richard Markiewicz <rmarkiewicz@devolutions.net>",
"Ionut Mihalcea <ionut.mihalcea@arm.com>",
"Kim Altintop <kim@monadic.xyz>",
"Alexandr Yusuk <aleksandr.yusuk@apriorit.com>",
"Flavio Castelli <flavio@castelli.me>",
"Xynnn_ <xynnn@linux.alibaba.com>",
]
description = "Portable X.509, PKI, JOSE and HTTP signature implementation."
keywords = ["x509", "jwt", "signature", "jose", "pki"]
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
repository = "https://github.com/Devolutions/picky-rs"
include = ["src/**/*", "README.md", "CHANGELOG.md", "LICENSE-*"]
[dependencies]
picky-asn1 = { version = "0.10", path = "../picky-asn1", features = ["zeroize"] }
picky-asn1-der = { version = "0.5", path = "../picky-asn1-der" }
picky-asn1-x509 = { version = "0.15", path = "../picky-asn1-x509", features = ["legacy", "zeroize"] }
serde = { version = "1", features = ["derive"] }
base64 = "0.22"
thiserror = "2"
byteorder = { version = "1.5", optional = true }
chrono = { version = "0.4", default-features = false, features = ["clock"], optional = true }
time = { version = "0.3", optional = true }
serde_json = { version = "1", optional = true }
hex = "0.4"
http = { version = "1.4", optional = true }
cab = { version = "0.6", optional = true }
lexical-sort = { version = "0.3", optional = true }
zeroize = "1.8"
# FIXME: either use ureq, or even better: do not require this kind of dependency at all to let user decide which lib to use.
# (currently users should *really* not forget to use `spawn_blocking` when calling associated functions from async context)
reqwest = { version = "0.13", default-features = false, features = ["blocking"], optional = true }
# /!\ ===== cryptography dependencies ===== /!\
# These should be updated as soon as possible.
# /!\ ===================================== /!\
rand = "0.10"
rand_core = "0.10"
crypto-bigint = "0.7"
ed25519-dalek = { version = "=3.0.0-rc.1", features = ["hazmat", "rand_core"] }
x25519-dalek = { version = "=3.0.0-rc.1", features = ["static_secrets"] }
p256 = { version = "=0.14.0-rc.14", features = ["ecdh"] }
p384 = { version = "=0.14.0-rc.14", features = ["ecdh"] }
p521 = { version = "=0.14.0-rc.14", features = ["ecdh"] }
rsa = { version = "=0.10.0-rc.18", features = ["std"] }
digest = "0.11"
md5 = { package = "md-5", version = "0.11", features = ["oid"] }
sha1 = { version = "0.11", features = ["oid"] }
sha2 = { version = "0.11", features = ["oid"] }
sha3 = { version = "0.12", features = ["oid"] }
aes-gcm = { version = "=0.11.0-rc.4", optional = true }
aes = { version = "0.9", optional = true }
aes-kw = { version = "0.3", optional = true }
argon2 = { version = "=0.6.0-rc.8", optional = true }
ctr = { version = "0.10", optional = true }
cbc = { version = "0.2", optional = true, features = ["alloc"] }
bcrypt-pbkdf = { version = "0.11", optional = true }
des = { version = "0.9", optional = true }
rc2 = { version = "0.9", optional = true }
pbkdf2 = { version = "0.13", optional = true }
hmac = { version = "0.13", optional = true }
crypto-common = "0.2"
inout = "0.2.2"
# Pin transitive dependencies versions.
# TODO: Remove when stable versions will be released.
blake2 = { version = "=0.11.0-rc.6", optional = true }
ecdsa = "=0.17.0-rc.22"
pkcs1 = "=0.8.0-rc.4"
primeorder = "=0.14.0-rc.14"
rustcrypto-ff = "=0.14.0-rc.1"
rustcrypto-ff_derive = "=0.14.0-rc.0"
rustcrypto-group = "=0.14.0-rc.1"
curve25519-dalek = "=5.0.0-rc.1"
[dev-dependencies]
pretty_assertions = "1.4"
cfg-if = "1.0"
rand_chacha = "0.10"
ring = "0.17"
rstest = "0.26"
expect-test = "1"
tempfile = "3.22"
picky-test-data = { path = "../picky-test-data", version = "0.1" }
[features]
default = ["x509", "jose", "http_signature", "http_trait_impl", "pkcs12"]
# main features
x509 = []
jose = ["dep:serde_json", "dep:aes-gcm", "dep:ctr", "dep:cbc", "dep:aes-kw", "dep:aes"]
http_signature = []
pkcs12 = ["picky-asn1-x509/pkcs12", "dep:des", "dep:rc2", "dep:cbc", "dep:hmac", "dep:aes", "x509", "dep:pbkdf2"]
# secondary features
pkcs7 = ["x509", "picky-asn1-x509/pkcs7"]
http_timestamp = ["dep:reqwest"]
ctl = ["picky-asn1-x509/ctl", "pkcs7", "chrono_conversion"]
ctl_http_fetch = ["dep:reqwest", "dep:cab", "ctl"]
wincert = ["x509", "dep:byteorder"]
ssh = ["dep:byteorder", "dep:aes", "dep:ctr", "dep:cbc", "dep:bcrypt-pbkdf", "dep:lexical-sort", "dep:hmac", "dep:pbkdf2"]
http_trait_impl = ["dep:http"]
chrono_conversion = ["dep:chrono", "picky-asn1/chrono_conversion"]
time_conversion = ["dep:time", "picky-asn1/time_conversion"]
putty = ["dep:argon2", "dep:hmac", "ssh",
# Transitive dependencies
"dep:blake2"
]
[package.metadata.docs.rs]
# Enable all features when building documentation for docs.rs
all-features = true
+202
View File
@@ -0,0 +1,202 @@
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
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+23
View File
@@ -0,0 +1,23 @@
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.
+22
View File
@@ -0,0 +1,22 @@
[![Crates.io](https://img.shields.io/crates/v/picky.svg)](https://crates.io/crates/picky)
[![docs.rs](https://docs.rs/picky/badge.svg)](https://docs.rs/picky)
![Crates.io](https://img.shields.io/crates/l/picky)
Compatible with rustc 1.85.
Minimal rustc version bumps happen [only with minor number bumps in this project](https://github.com/Devolutions/picky-rs/issues/89#issuecomment-868303478).
# picky
Portable X.509, PKI, JOSE and HTTP signature implementation.
## X.509 / PKI
[See doc](https://docs.rs/picky/latest/picky/x509/index.html) for tested examples.
## HTTP signature
[See doc](https://docs.rs/picky/latest/picky/http/index.html) for tested examples.
## JOSE
Doc doesn't have example yet, but [tests](https://github.com/Devolutions/picky-rs/blob/master/picky/src/jose/jwt.rs#L438) are good reference.
+101
View File
@@ -0,0 +1,101 @@
//! Hash algorithms supported by picky
use digest::Digest;
use picky_asn1_x509::ShaVariant;
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::fmt;
/// unsupported algorithm
#[derive(Debug)]
pub struct UnsupportedHashAlgorithmError {
pub algorithm: String,
}
impl fmt::Display for UnsupportedHashAlgorithmError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "unsupported algorithm: {}", self.algorithm)
}
}
impl Error for UnsupportedHashAlgorithmError {}
/// Supported hash algorithms
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum HashAlgorithm {
MD5,
SHA1,
SHA2_224,
SHA2_256,
SHA2_384,
SHA2_512,
SHA3_384,
SHA3_512,
}
impl TryFrom<HashAlgorithm> for ShaVariant {
type Error = UnsupportedHashAlgorithmError;
fn try_from(v: HashAlgorithm) -> Result<ShaVariant, UnsupportedHashAlgorithmError> {
match v {
HashAlgorithm::MD5 => Ok(ShaVariant::MD5),
HashAlgorithm::SHA1 => Ok(ShaVariant::SHA1),
HashAlgorithm::SHA2_256 => Ok(ShaVariant::SHA2_256),
HashAlgorithm::SHA2_384 => Ok(ShaVariant::SHA2_384),
HashAlgorithm::SHA2_512 => Ok(ShaVariant::SHA2_512),
HashAlgorithm::SHA3_384 => Ok(ShaVariant::SHA3_384),
HashAlgorithm::SHA3_512 => Ok(ShaVariant::SHA3_512),
_ => Err(UnsupportedHashAlgorithmError {
algorithm: format!("{v:?}"),
}),
}
}
}
impl TryFrom<ShaVariant> for HashAlgorithm {
type Error = UnsupportedHashAlgorithmError;
fn try_from(v: ShaVariant) -> Result<HashAlgorithm, UnsupportedHashAlgorithmError> {
match v {
ShaVariant::MD5 => Ok(HashAlgorithm::MD5),
ShaVariant::SHA1 => Ok(HashAlgorithm::SHA1),
ShaVariant::SHA2_256 => Ok(HashAlgorithm::SHA2_256),
ShaVariant::SHA2_384 => Ok(HashAlgorithm::SHA2_384),
ShaVariant::SHA2_512 => Ok(HashAlgorithm::SHA2_512),
ShaVariant::SHA3_384 => Ok(HashAlgorithm::SHA3_384),
ShaVariant::SHA3_512 => Ok(HashAlgorithm::SHA3_512),
_ => Err(UnsupportedHashAlgorithmError {
algorithm: format!("{v:?}"),
}),
}
}
}
impl HashAlgorithm {
pub fn digest(self, msg: &[u8]) -> Vec<u8> {
match self {
Self::MD5 => md5::Md5::digest(msg).as_slice().to_vec(),
Self::SHA1 => sha1::Sha1::digest(msg).as_slice().to_vec(),
Self::SHA2_224 => sha2::Sha224::digest(msg).as_slice().to_vec(),
Self::SHA2_256 => sha2::Sha256::digest(msg).as_slice().to_vec(),
Self::SHA2_384 => sha2::Sha384::digest(msg).as_slice().to_vec(),
Self::SHA2_512 => sha2::Sha512::digest(msg).as_slice().to_vec(),
Self::SHA3_384 => sha3::Sha3_384::digest(msg).as_slice().to_vec(),
Self::SHA3_512 => sha3::Sha3_512::digest(msg).as_slice().to_vec(),
}
}
pub fn output_size(self) -> usize {
match self {
Self::MD5 => md5::Md5::output_size(),
Self::SHA1 => sha1::Sha1::output_size(),
Self::SHA2_224 => sha2::Sha224::output_size(),
Self::SHA2_256 => sha2::Sha256::output_size(),
Self::SHA2_384 => sha2::Sha384::output_size(),
Self::SHA2_512 => sha2::Sha512::output_size(),
Self::SHA3_384 => sha3::Sha3_384::output_size(),
Self::SHA3_512 => sha3::Sha3_512::output_size(),
}
}
}
+150
View File
@@ -0,0 +1,150 @@
use std::borrow::Cow;
use thiserror::Error;
#[derive(Debug, Error, Clone)]
#[non_exhaustive]
pub enum HttpRequestError {
/// couldn't convert a http header value to string
#[error("couldn't convert http header value to string for header key {key}")]
HeaderValueToStr { key: String },
/// unexpected error occurred
#[error("unexpected error: {reason}")]
Unexpected { reason: String },
}
pub trait HttpRequest {
fn get_header_concatenated_values<'a>(&'a self, header_name: &str) -> Result<Cow<'a, str>, HttpRequestError>;
fn get_lowercased_method(&self) -> Result<Cow<'_, str>, HttpRequestError>;
fn get_target(&self) -> Result<Cow<'_, str>, HttpRequestError>;
}
#[cfg(feature = "http_trait_impl")]
mod http_trait_impl {
use super::*;
impl HttpRequest for http::request::Parts {
fn get_header_concatenated_values<'a>(&'a self, header_name: &str) -> Result<Cow<'a, str>, HttpRequestError> {
let mut values = Vec::new();
let all_values = self.headers.get_all(header_name);
for value in all_values {
let value_str = value.to_str().map_err(|_| HttpRequestError::HeaderValueToStr {
key: header_name.to_owned(),
})?;
values.push(value_str.trim());
}
Ok(Cow::Owned(values.join(", ")))
}
fn get_lowercased_method(&self) -> Result<Cow<'_, str>, HttpRequestError> {
Ok(Cow::Owned(self.method.as_str().to_lowercase()))
}
fn get_target(&self) -> Result<Cow<'_, str>, HttpRequestError> {
Ok(Cow::Borrowed(self.uri.path()))
}
}
impl<T> HttpRequest for http::request::Request<T> {
fn get_header_concatenated_values<'a>(&'a self, header_name: &str) -> Result<Cow<'a, str>, HttpRequestError> {
let mut values = Vec::new();
let all_values = self.headers().get_all(header_name);
for value in all_values {
let value_str = value.to_str().map_err(|_| HttpRequestError::HeaderValueToStr {
key: header_name.to_owned(),
})?;
values.push(value_str.trim());
}
Ok(Cow::Owned(values.join(", ")))
}
fn get_lowercased_method(&self) -> Result<Cow<'_, str>, HttpRequestError> {
Ok(Cow::Owned(self.method().as_str().to_lowercase()))
}
fn get_target(&self) -> Result<Cow<'_, str>, HttpRequestError> {
Ok(Cow::Borrowed(self.uri().path()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use http::method::Method;
use http::{header, request};
#[test]
fn http_request_parts() {
let req = request::Builder::new()
.method(Method::GET)
.uri("/foo")
.header("Host", "example.org")
.header(header::DATE, "Tue, 07 Jun 2014 20:51:35 GMT")
.header("X-Example", " Example header with some whitespace. ")
.header("X-EmptyHeader", "")
.header(header::CACHE_CONTROL, "max-age=60")
.header(header::CACHE_CONTROL, "must-revalidate")
.body(())
.expect("couldn't build request");
let (parts, _) = req.into_parts();
assert_eq!(parts.get_target().expect("target"), "/foo");
assert_eq!(parts.get_lowercased_method().expect("method"), "get");
assert_eq!(
parts.get_header_concatenated_values("host").expect("host"),
"example.org"
);
assert_eq!(
parts.get_header_concatenated_values("date").expect("date"),
"Tue, 07 Jun 2014 20:51:35 GMT"
);
assert_eq!(
parts.get_header_concatenated_values("x-example").expect("example"),
"Example header with some whitespace."
);
assert_eq!(
parts.get_header_concatenated_values("X-EmptyHeader").expect("empty"),
""
);
assert_eq!(
parts
.get_header_concatenated_values(header::CACHE_CONTROL.as_str())
.expect("cache control"),
"max-age=60, must-revalidate"
);
}
#[test]
fn http_request_request() {
let req = request::Builder::new()
.method(Method::GET)
.uri("/foo")
.header("Host", "example.org")
.header(header::DATE, "Tue, 07 Jun 2014 20:51:35 GMT")
.header("X-Example", " Example header with some whitespace. ")
.header("X-EmptyHeader", "")
.header(header::CACHE_CONTROL, "max-age=60")
.header(header::CACHE_CONTROL, "must-revalidate")
.body(())
.expect("couldn't build request");
assert_eq!(req.get_target().expect("target"), "/foo");
assert_eq!(req.get_lowercased_method().expect("method"), "get");
assert_eq!(req.get_header_concatenated_values("host").expect("host"), "example.org");
assert_eq!(
req.get_header_concatenated_values("date").expect("date"),
"Tue, 07 Jun 2014 20:51:35 GMT"
);
assert_eq!(
req.get_header_concatenated_values("x-example").expect("example"),
"Example header with some whitespace."
);
assert_eq!(req.get_header_concatenated_values("X-EmptyHeader").expect("empty"), "");
assert_eq!(
req.get_header_concatenated_values(header::CACHE_CONTROL.as_str())
.expect("cache control"),
"max-age=60, must-revalidate"
);
}
}
}
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
//! Signing HTTP Messages
//!
//! This module provides an implementation of a subset of
//! [draft-cavage-http-signatures-12 RFC](https://tools.ietf.org/html/draft-cavage-http-signatures-12).
//!
//! # Example
//! ```
//! use picky::{
//! http::http_signature::{HttpSignatureBuilder, HttpSignature},
//! signature::SignatureAlgorithm,
//! hash::HashAlgorithm,
//! key::PrivateKey,
//! pem::parse_pem,
//! };
//! use http::{request, header::{self, HeaderName}, method::Method};
//!
//! // all you need to generate a http signature
//!
//! let private_rsa_key: &str = "-----BEGIN RSA PRIVATE KEY-----\n\
//! MIIEpgIBAAKCAQEApDx0MjvRzmxYXKfqHy0gN1znX6rSU2EnsDTbZaU1UcsMmRNx\n\
//! L+FqDNzwNutCSQlkujzR37+bHGTxOnRvvSG3lwRvDBZepYWPum9WDqa9T5gTS/Cj\n\
//! luq/oSsOyt/tUDO/GcNPTTfUQlgtOZ+zRo6FA0rpAQ8CrQm7XzGQ0DMoDU1SVNnu\n\
//! tFJowlece9Y4NtAfhA+kJ5IEmcE9AgwxJY/iCyCxUEBUe7biwbUafLdtA3+3S8Bu\n\
//! hBXAr+1BING3qS0vl08+3eaFq5q7f7VwcYOhUmH13itqSGwDznCk4oDQl+qn9DQZ\n\
//! X9/09KtsgxuIcozxj0RwGKX8qkz4TlAGJw+oNwIDAQABAoIBAQCN9IrimH3iFBfU\n\
//! Dnb4d4KvF6gNMpMU6pbpYOZ51vBdQEolTX65yfZmI9mlPndOtcXQi51D7lNdmYo/\n\
//! 4kBqk2giKfzpz7QDEYyHspAJnelnkKStMNPVMBZucc8ZX6+5cOCunfg/YBAhQCHm\n\
//! +rh0Nd+WVvtKpPTFJ/JCd48Zxf3KcDZD+AsWTjPt4zte8KdcwxiD3MrFunxgeujX\n\
//! n0U0/f7hvX/7JBQ20gu2tD9whEaS2Gn8E4WpEV8wC6Ah1pU9mZNZ0u8clW9SV0de\n\
//! ay0mHw8y/Wx6rkEMvrecK6mWbwSQGfRq+crI9PCwA5wn/EZmpQrQs9r5MLtDKVsQ\n\
//! r9axQrSRAoGBANB53u3mxY9ByiYGT5Ge/33+BjANXxmIPG0TWgV1D8MhwFiaiHF+\n\
//! tiEzoz4vi23Q+GeHyoM1wxw8VurDX+vcIJbZ0dyGOM/6F0eago7ZAtvHMdUdahAO\n\
//! X+klqG8kIysgFXSzaU2w76816iIaXiZlDZUghrnd3wmgu9jhl3HCUlltAoGBAMms\n\
//! 2uufuk26nssF+woQuy017lgLUNFCRrO9F3iwIiyY5R/q372gsx8HVzjYGKY8CF6v\n\
//! m6JFfxogp44ZcafYOeu+iXbqoCAK4BTdbFB7/D3rX7WgidaxUlLGoXsNFIUIVubR\n\
//! jaRA7l3tl3fkpdqUAye6zosMKp2oybQLyX5hLAWzAoGBAJKhVUIA8W1cOaFbCPYE\n\
//! XfExDQsZLI1ZvB5/4O47srVtdMsdDeC93b4mgqfHawr3UvAGm1KEKtIeQofmmP3c\n\
//! mvNfCvNPWIA3h84uB6wPSKpqRUt+382hPqZOfVSGl1HKxCyL0AH78+lJQ39vCk94\n\
//! /f+om/n46tnrupPFv+4cXi1VAoGBALzSSmYxtozwHZyYjOJvp9A8nltwvMov82J1\n\
//! uHQW9OgsftnTXoh83Tg/9zoRmYKK0otUf7L+vnIIANjamb88g35ldu8P3bwicosW\n\
//! hUMV0qVmqsWy+Vs5yooVzzsWlA+6LyMNMECJSqRGv3pRabesvQeFr7wgOAZE8hTQ\n\
//! tGbPNBhhAoGBAIkXxIJT0OMKSt/A7wDE9wd3dtC8mbkqr5aZTvwiuD6OvNDdXb/J\n\
//! i03ns56mIflifVLPYVmCEXdYIzSv7HfeR4d78bAvqiMFfnQ2PF3tuoKMSvQM0m8/\n\
//! f3VhEFFMrUTTRMX/9PR0ITQtnZlWIDfBVgXPmWqTqCGOMYsRPv70LGse\n\
//! -----END RSA PRIVATE KEY-----";
//! let pem = parse_pem(private_rsa_key).expect("couldn't parse pem");
//! let private_key = PrivateKey::from_pem(&pem).expect("couldn't parse private key");
//!
//! let req = request::Builder::new()
//! .method(Method::GET)
//! .uri("/foo")
//! .header("Host", "example.org")
//! .header(header::DATE, "Tue, 07 Jun 2014 20:51:35 GMT")
//! .header("X-Example", " Example header with some whitespace. ")
//! .header("X-EmptyHeader", "")
//! .header(header::CACHE_CONTROL, "max-age=60")
//! .header(header::CACHE_CONTROL, "must-revalidate")
//! .body(())
//! .expect("couldn't build request");
//!
//! let (parts, _) = req.into_parts();
//!
//! // generate http signature
//!
//! let http_signature = HttpSignatureBuilder::new()
//! .key_id("my-rsa-key")
//! .signature_method(&private_key, SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_224))
//! // `picky::http::http_request::HttpRequest` trait is implemented for `http::request::Parts`
//! // for `http` crate with `http_trait_impl` feature gate
//! .generate_signing_string_using_http_request(&parts)
//! .request_target()
//! .created(1402170695)
//! .http_header("host")
//! .http_header(header::DATE.as_str())
//! .http_header(header::CACHE_CONTROL.as_str())
//! .http_header("x-emptyheader")
//! .http_header("X-EXAMPLE")
//! .build()
//! .expect("couldn't generate http signature");
//!
//! let http_signature_str = http_signature.to_string();
//!
//! assert_eq!(
//! http_signature_str,
//! "Signature keyId=\"my-rsa-key\",algorithm=\"rsa-sha224\",created=1402170695,\
//! headers=\"(request-target) (created) host date cache-control x-emptyheader x-example\",\
//! signature=\"JueyecQbV5rQ3TI1EfqZRjAZMMOb4ABZNS0yDcBDgyfbOLORYipT2An2MCH8n/HequVJkEE\
//! 86/vj9ZFLbyqFkV3a8uQGB6gaE79l9YNdzVeO5k7GBb1jskwBXnqVtGmn8aT2f+cJzkDtu6ptg+UtaU\
//! ZOQKdutc8aHq1NCLwvqMbA410XP0pA5r/VTbMg/yW8rHguue0Trh0WYCw8zHfNuZtheWxvWGLdYxvC0\
//! u5oJA0PdxFceqVd/304+RQsrDGLtX8J9vSeqEsQJfvswFyMTkdl1gDbP/YdXp7ADzc2D9IefT9zqvFd\
//! yEDDXEXKmZm+22395xRtnFmeWXu/+PM6wg==\""
//! );
//!
//! // parse a http signature and verify it
//!
//! let parsed_http_signature = http_signature_str.parse::<HttpSignature>()
//! .expect("couldn't parse http signature");
//!
//! assert_eq!(parsed_http_signature, http_signature);
//!
//! parsed_http_signature.verifier()
//! .signature_method(&private_key.to_public_key().unwrap(), SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_224))
//! .generate_signing_string_using_http_request(&parts)
//! .now(1402170695)
//! .verify()
//! .expect("couldn't verify signature");
//!
//! // alternatively you can provide a pre-generated signing string
//!
//! let signing_string =
//! "(request-target): get /foo\n\
//! (created): 1402170695\n\
//! host: example.org\n\
//! date: Tue, 07 Jun 2014 20:51:35 GMT\n\
//! cache-control: max-age=60, must-revalidate\n\
//! x-emptyheader:\n\
//! x-example: Example header with some whitespace.";
//!
//! let http_signature_pre_generated = HttpSignatureBuilder::new()
//! .key_id("my-rsa-key")
//! .signature_method(&private_key, SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_224))
//! .pre_generated_signing_string(signing_string)
//! .build()
//! .expect("couldn't generate http signature using pre-generated signing string");
//!
//! let http_signature_pre_generated_str = http_signature_pre_generated.to_string();
//!
//! assert_eq!(http_signature_pre_generated, http_signature);
//! assert_eq!(http_signature_pre_generated_str, http_signature_str);
//!
//! parsed_http_signature.verifier()
//! .signature_method(&private_key.to_public_key().unwrap(), SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_224))
//! .pre_generated_signing_string(signing_string)
//! .now(1402170695)
//! .verify()
//! .expect("couldn't verify signature using pre-generated signing string");
//! ```
pub mod http_request;
pub mod http_signature;
pub use http_request::HttpRequest;
pub use http_signature::HttpSignature;
+1602
View File
File diff suppressed because it is too large Load Diff
+767
View File
@@ -0,0 +1,767 @@
//! A JSON Web Key (JWK) is a JavaScript Object Notation (JSON) data structure that represents a cryptographic key.
//!
//! See [RFC7517](https://tools.ietf.org/html/rfc7517).
use crate::jose::jwe::{JweAlg, JweEnc};
use crate::jose::jws::JwsAlg;
use crate::key::ec::{EcdsaPublicKey, NamedEcCurve};
use crate::key::ed::{EdPublicKey, NamedEdAlgorithm};
use crate::key::{EcCurve, EdAlgorithm, PublicKey};
use base64::engine::general_purpose;
use base64::{DecodeError, Engine as _};
use crypto_bigint::BoxedUint;
use picky_asn1::wrapper::IntegerAsn1;
use picky_asn1_x509::SubjectPublicKeyInfo;
use serde::{Deserialize, Serialize};
use thiserror::Error;
// === error type === //
#[derive(Debug, Error)]
pub enum JwkError {
/// Json error
#[error("JSON error: {source}")]
Json { source: serde_json::Error },
/// couldn't decode base64
#[error("couldn't decode base64: {source}")]
Base64Decoding { source: DecodeError },
/// unsupported algorithm
#[error("unsupported algorithm: {algorithm}")]
UnsupportedAlgorithm { algorithm: &'static str },
#[error("invalid ec public key: {cause}")]
InvalidEcPublicKey { cause: String },
#[error("invalid ec point coordinates in JWK")]
InvalidEcPointCoordinates,
#[error("invalid ed public key: {cause}")]
InvalidEdPublicKey { cause: String },
}
impl From<serde_json::Error> for JwkError {
fn from(e: serde_json::Error) -> Self {
Self::Json { source: e }
}
}
impl From<DecodeError> for JwkError {
fn from(e: DecodeError) -> Self {
Self::Base64Decoding { source: e }
}
}
// === key type === //
/// Algorithm type for JWK
///
/// See [RFC7518 #6](https://tools.ietf.org/html/rfc7518#section-6.1)
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kty")]
pub enum JwkKeyType {
/// Edwards curve-based cryptography
///
/// Defined by separate [RFC 8037](https://www.rfc-editor.org/rfc/rfc8037)
#[serde(rename = "OKP")]
Ed(JwkPublicEdKey),
/// Elliptic Curve
///
/// Recommended+ by RFC
#[serde(rename = "EC")]
Ec(JwkPublicEcKey),
/// Elliptic Curve
///
/// Required by RFC
#[serde(rename = "RSA")]
Rsa(JwkPublicRsaKey),
/// Octet sequence (used to represent symmetric keys) (unsupported)
///
/// Required by RFC
#[serde(rename = "oct")]
Oct,
}
impl JwkKeyType {
/// Build a JWK key from RSA components.
///
/// Each argument is the unsigned big-endian representation as an octet sequence of the value.
/// If a signed representation is provided, leading zero is removed for any number bigger than 0x7F.
pub fn new_rsa_key(modulus: &[u8], public_exponent: &[u8]) -> Self {
let modulus = h_strip_unrequired_leading_zero(modulus);
let public_exponent = h_strip_unrequired_leading_zero(public_exponent);
Self::Rsa(JwkPublicRsaKey {
n: general_purpose::URL_SAFE_NO_PAD.encode(modulus),
e: general_purpose::URL_SAFE_NO_PAD.encode(public_exponent),
})
}
/// Build a JWK key from EC components.
///
/// `x` and `y` are big-endian representation of the affine point coordinates.
pub fn new_ec_key(curve: JwkEcPublicKeyCurve, x: &[u8], y: &[u8]) -> Self {
let x = h_strip_unrequired_leading_zero(x);
let y = h_strip_unrequired_leading_zero(y);
Self::Ec(JwkPublicEcKey {
crv: curve,
x: general_purpose::URL_SAFE_NO_PAD.encode(x),
y: general_purpose::URL_SAFE_NO_PAD.encode(y),
})
}
/// Build a JWK key from Edwards curve components.
///
/// `crv` is ed-based algorithm name.
/// `x` is raw public key bytes.
pub fn new_ed_key(crv: JwkEdPublicKeyAlgorithm, x: &[u8]) -> Self {
Self::Ed(JwkPublicEdKey {
crv,
x: general_purpose::URL_SAFE_NO_PAD.encode(x),
})
}
/// Build a JWK key from RSA components already encoded following base64 url format.
///
/// Each argument is the unsigned big-endian representation as an octet sequence of the value.
/// The octet sequence MUST utilize the minimum number of octets needed to represent the value.
/// That is: **no leading zero** must be present.
///
/// See definition for term `Base64urlUInt` in [RFC7518 section 2](https://datatracker.ietf.org/doc/html/rfc7518#section-2)
pub fn new_rsa_key_from_base64_url(modulus: String, public_exponent: String) -> Self {
Self::Rsa(JwkPublicRsaKey {
n: modulus,
e: public_exponent,
})
}
pub fn as_rsa(&self) -> Option<&JwkPublicRsaKey> {
match self {
JwkKeyType::Rsa(rsa) => Some(rsa),
_ => None,
}
}
pub fn as_ec(&self) -> Option<&JwkPublicEcKey> {
match self {
JwkKeyType::Ec(ec) => Some(ec),
_ => None,
}
}
pub fn as_ed(&self) -> Option<&JwkPublicEdKey> {
match self {
JwkKeyType::Ed(ed) => Some(ed),
_ => None,
}
}
pub fn is_rsa(&self) -> bool {
self.as_rsa().is_some()
}
pub fn is_ec(&self) -> bool {
self.as_ec().is_some()
}
pub fn is_ed(&self) -> bool {
self.as_ed().is_some()
}
}
/// Strips leading zero for any number bigger than 0x7F.
fn h_strip_unrequired_leading_zero(value: &[u8]) -> &[u8] {
if let [0x00, rest @ ..] = value { rest } else { value }
}
/// Big integers from 0x00 to 0x7F are all base64-encoded using two ASCII characters ranging from "AA" to "fw".
/// We know the required capacity is _exactly_ of one byte.
/// The value 0 is valid and is represented as the array [0x00] ("AA").
/// For numbers greater than 0x7F, logic is a bit more complex.
/// There is no leading zero in JWK keys because _unsigned_ numbers are used.
/// As such, there is no need to disambiguate the high-order bit (0x80)
/// which is used as the sign bit for _signed_ numbers.
/// The high-order bit is set when base64 encoding's leading character matches [g-z0-9_-].
fn h_allocate_signed_big_int_buffer(base64_url_encoding: &str) -> Vec<u8> {
match base64_url_encoding.chars().next() {
// The leading zero is re-introduced for any number whose high-order bit is set
Some('g'..='z' | '0'..='9' | '_' | '-') => vec![0],
// Otherwise, there is nothing more to do
_ => Vec::with_capacity(1),
}
}
// === public key use === //
/// Public Key Use, identifies the intended use of the public key.
///
/// See [RFC7517 #4](https://tools.ietf.org/html/rfc7517#section-4.2)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum JwkPubKeyUse {
#[serde(rename = "sig")]
Signature,
#[serde(rename = "enc")]
Encryption,
}
// === key operations === //
/// Key Operations, identifies the operation(s) for which the key is intended to be used.
///
/// See [RFC7517 #4](https://tools.ietf.org/html/rfc7517#section-4.3)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum JwkKeyOps {
#[serde(rename = "sign")]
Sign,
#[serde(rename = "verify")]
Verify,
#[serde(rename = "encrypt")]
Encrypt,
#[serde(rename = "decrypt")]
Decrypt,
#[serde(rename = "wrapKey")]
WrapKey,
#[serde(rename = "unwrapKey")]
UnwrapKey,
#[serde(rename = "deriveKey")]
DeriveKey,
#[serde(rename = "deriveBits")]
DeriveBits,
}
// === algorithms === //
/// JOSE algorithms names as defined by [RFC7518](https://tools.ietf.org/html/rfc7518)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Jwa {
Sig(JwsAlg),
Enc(JweEnc),
CEKAlg(JweAlg),
}
// === json web key === //
/// Represents a cryptographic key as defined by [RFC7517](https://tools.ietf.org/html/rfc7517).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Jwk {
// -- specific to JWK -- //
#[serde(flatten)]
pub key: JwkKeyType,
/// Identifies the algorithm intended for use with the key.
#[serde(skip_serializing_if = "Option::is_none")]
pub alg: Option<Jwa>,
/// Public Key Use
///
/// Intended use of the public key.
#[serde(rename = "use", skip_serializing_if = "Option::is_none")]
pub key_use: Option<JwkPubKeyUse>,
/// Key Operations
///
/// identifies the operation(s) for which the key is intended to be used.
#[serde(skip_serializing_if = "Option::is_none")]
pub key_ops: Option<Vec<JwkKeyOps>>,
// -- common with all -- //
/// Key ID Header
///
/// A hint indicating which key was used.
#[serde(skip_serializing_if = "Option::is_none")]
pub kid: Option<String>,
/// X.509 URL Header
///
/// URI that refers to a resource for an X.509 public key certificate or certificate chain.
#[serde(skip_serializing_if = "Option::is_none")]
pub x5u: Option<String>,
/// X.509 Certificate Chain
///
/// Chain of one or more PKIX certificates.
#[serde(skip_serializing_if = "Option::is_none")]
pub x5c: Option<Vec<String>>,
/// X.509 Certificate SHA-1 Thumbprint
///
/// base64url-encoded SHA-1 thumbprint (a.k.a. digest) of the DER encoding of an X.509 certificate.
#[serde(skip_serializing_if = "Option::is_none")]
pub x5t: Option<String>,
/// X.509 Certificate SHA-256 Thumbprint
///
/// base64url-encoded SHA-256 thumbprint (a.k.a. digest) of the DER encoding of an X.509 certificate.
#[serde(rename = "x5t#S256", alias = "x5t#s256", skip_serializing_if = "Option::is_none")]
pub x5t_s256: Option<String>,
}
impl Jwk {
pub fn new(key: JwkKeyType) -> Self {
Jwk {
key,
alg: None,
key_use: None,
key_ops: None,
kid: None,
x5u: None,
x5c: None,
x5t: None,
x5t_s256: None,
}
}
pub fn from_json(json: &str) -> Result<Self, JwkError> {
Ok(serde_json::from_str(json)?)
}
pub fn from_public_key(public_key: &PublicKey) -> Result<Self, JwkError> {
use picky_asn1::wrapper::BitStringAsn1Container;
use picky_asn1_x509::PublicKey as SerdePublicKey;
match &public_key.as_inner().subject_public_key {
SerdePublicKey::Rsa(BitStringAsn1Container(rsa)) => {
let modulus = rsa.modulus.as_signed_bytes_be();
let public_exponent = rsa.public_exponent.as_signed_bytes_be();
Ok(Self::new(JwkKeyType::new_rsa_key(modulus, public_exponent)))
}
SerdePublicKey::Ec(_) => {
let ec_key = EcdsaPublicKey::try_from(public_key)
.map_err(|e| JwkError::InvalidEcPublicKey { cause: e.to_string() })?;
match ec_key.curve() {
NamedEcCurve::Known(EcCurve::NistP256) => {
let point = p256::Sec1Point::from_bytes(ec_key.encoded_point()).map_err(|_| {
JwkError::InvalidEcPublicKey {
cause: "invalid P-256 EC point encoding".to_string(),
}
})?;
match (point.x(), point.y()) {
(Some(x), Some(y)) => Ok(Self::new(JwkKeyType::new_ec_key(
JwkEcPublicKeyCurve::P256,
x.as_slice(),
y.as_slice(),
))),
_ => Err(JwkError::InvalidEcPublicKey {
cause: "Invalid P-256 curve EC public point coordinates".to_string(),
}),
}
}
NamedEcCurve::Known(EcCurve::NistP384) => {
let point = p384::Sec1Point::from_bytes(ec_key.encoded_point()).map_err(|_| {
JwkError::InvalidEcPublicKey {
cause: "invalid P-384 EC point encoding".to_string(),
}
})?;
match (point.x(), point.y()) {
(Some(x), Some(y)) => Ok(Self::new(JwkKeyType::new_ec_key(
JwkEcPublicKeyCurve::P384,
x.as_slice(),
y.as_slice(),
))),
_ => Err(JwkError::InvalidEcPublicKey {
cause: "Invalid P-384 curve EC public point coordinates".to_string(),
}),
}
}
NamedEcCurve::Known(EcCurve::NistP521) => {
let point = p521::Sec1Point::from_bytes(ec_key.encoded_point()).map_err(|_| {
JwkError::InvalidEcPublicKey {
cause: "invalid P-521 EC point encoding".to_string(),
}
})?;
match (point.x(), point.y()) {
(Some(x), Some(y)) => Ok(Self::new(JwkKeyType::new_ec_key(
JwkEcPublicKeyCurve::P521,
x.as_slice(),
y.as_slice(),
))),
_ => Err(JwkError::InvalidEcPublicKey {
cause: "Invalid P-521 curve EC public point coordinates".to_string(),
}),
}
}
NamedEcCurve::Unsupported(_) => Err(JwkError::UnsupportedAlgorithm {
algorithm: "Unsupported EC curve",
}),
}
}
SerdePublicKey::Ed(_) => {
let ed_key = EdPublicKey::try_from(public_key)
.map_err(|e| JwkError::InvalidEdPublicKey { cause: e.to_string() })?;
let algorithm = match ed_key.algorithm() {
NamedEdAlgorithm::Known(EdAlgorithm::Ed25519) => JwkEdPublicKeyAlgorithm::Ed25519,
NamedEdAlgorithm::Known(EdAlgorithm::X25519) => JwkEdPublicKeyAlgorithm::X25519,
NamedEdAlgorithm::Unsupported(_) => {
return Err(JwkError::UnsupportedAlgorithm {
algorithm: "Unsupported ED algorithm",
});
}
};
Ok(Self::new(JwkKeyType::new_ed_key(algorithm, ed_key.data())))
}
SerdePublicKey::Mldsa(_) => Err(JwkError::UnsupportedAlgorithm {
algorithm: "JWK unsupported with MLDSA keys",
}),
}
}
pub fn to_json(&self) -> Result<String, JwkError> {
Ok(serde_json::to_string(self)?)
}
pub fn to_json_pretty(&self) -> Result<String, JwkError> {
Ok(serde_json::to_string_pretty(self)?)
}
pub fn to_public_key(&self) -> Result<PublicKey, JwkError> {
match &self.key {
JwkKeyType::Rsa(rsa) => {
let modulus = IntegerAsn1::from_bytes_be_signed(rsa.modulus_signed_bytes_be()?);
let public_exponent = IntegerAsn1::from_bytes_be_signed(rsa.public_exponent_signed_bytes_be()?);
let spki = SubjectPublicKeyInfo::new_rsa_key(modulus, public_exponent);
Ok(spki.into())
}
JwkKeyType::Ec(ec) => {
let curve = match ec.crv {
JwkEcPublicKeyCurve::P256 => EcCurve::NistP256,
JwkEcPublicKeyCurve::P384 => EcCurve::NistP384,
JwkEcPublicKeyCurve::P521 => EcCurve::NistP521,
};
let x = BoxedUint::from_be_slice_vartime(&ec.x_signed_bytes_be()?);
let y = BoxedUint::from_be_slice_vartime(&ec.y_signed_bytes_be()?);
PublicKey::from_ec_components(curve, &x, &y).map_err(|_| JwkError::InvalidEcPointCoordinates)
}
JwkKeyType::Ed(ed) => {
let algorithm = match ed.crv {
JwkEdPublicKeyAlgorithm::Ed25519 => Ok(EdAlgorithm::Ed25519),
JwkEdPublicKeyAlgorithm::X25519 => Ok(EdAlgorithm::X25519),
JwkEdPublicKeyAlgorithm::Ed448 => Err("ed448 algorithm"),
JwkEdPublicKeyAlgorithm::X448 => Err("x448 algorithm"),
}
.map_err(|algorithm| JwkError::UnsupportedAlgorithm { algorithm })?;
Ok(PublicKey::from_ed_encoded_components(
&algorithm.into(),
ed.public_key_bytes()?.as_ref(),
))
}
JwkKeyType::Oct => Err(JwkError::UnsupportedAlgorithm {
algorithm: "octet sequence",
}),
}
}
}
// === jwk set === //
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct JwkSet {
pub keys: Vec<Jwk>,
}
impl JwkSet {
pub fn from_json(json: &str) -> Result<Self, JwkError> {
Ok(serde_json::from_str(json)?)
}
pub fn to_json(&self) -> Result<String, JwkError> {
Ok(serde_json::to_string(self)?)
}
pub fn to_json_pretty(&self) -> Result<String, JwkError> {
Ok(serde_json::to_string_pretty(self)?)
}
}
// === public rsa key === //
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct JwkPublicRsaKey {
n: String,
e: String,
}
impl JwkPublicRsaKey {
pub fn modulus_signed_bytes_be(&self) -> Result<Vec<u8>, JwkError> {
let mut buf = h_allocate_signed_big_int_buffer(&self.n);
general_purpose::URL_SAFE_NO_PAD
.decode_vec(&self.n, &mut buf)
.map_err(JwkError::from)?;
Ok(buf)
}
pub fn modulus_unsigned_bytes_be(&self) -> Result<Vec<u8>, JwkError> {
general_purpose::URL_SAFE_NO_PAD.decode(&self.n).map_err(JwkError::from)
}
pub fn public_exponent_signed_bytes_be(&self) -> Result<Vec<u8>, JwkError> {
let mut buf = h_allocate_signed_big_int_buffer(&self.e);
general_purpose::URL_SAFE_NO_PAD
.decode_vec(&self.e, &mut buf)
.map_err(JwkError::from)?;
Ok(buf)
}
pub fn public_exponent_unsigned_bytes_be(&self) -> Result<Vec<u8>, JwkError> {
general_purpose::URL_SAFE_NO_PAD.decode(&self.e).map_err(JwkError::from)
}
}
/// The key type of a JWK defined in
/// [RFC 7518, section 6.1](https://tools.ietf.org/html/rfc7518#section-6.1).
///
/// Note that P521 is not supported yet for signning and verification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum JwkEcPublicKeyCurve {
#[serde(rename = "P-256")]
P256,
#[serde(rename = "P-384")]
P384,
#[serde(rename = "P-521")]
P521,
}
// === public ec key === //
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct JwkPublicEcKey {
crv: JwkEcPublicKeyCurve,
x: String,
y: String,
}
impl JwkPublicEcKey {
pub fn x_signed_bytes_be(&self) -> Result<Vec<u8>, JwkError> {
let mut buf = h_allocate_signed_big_int_buffer(&self.x);
general_purpose::URL_SAFE_NO_PAD
.decode_vec(&self.x, &mut buf)
.map_err(JwkError::from)?;
Ok(buf)
}
pub fn y_signed_bytes_be(&self) -> Result<Vec<u8>, JwkError> {
let mut buf = h_allocate_signed_big_int_buffer(&self.y);
general_purpose::URL_SAFE_NO_PAD
.decode_vec(&self.y, &mut buf)
.map_err(JwkError::from)?;
Ok(buf)
}
}
/// Defined in [RFC 8037](https://tools.ietf.org/html/rfc8037)
///
/// Note that X25519, Ed448 and X448 are not yet supported by picky for jws/jwe.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum JwkEdPublicKeyAlgorithm {
#[serde(rename = "Ed25519")]
Ed25519,
#[serde(rename = "Ed448")]
Ed448,
#[serde(rename = "X25519")]
X25519,
#[serde(rename = "X448")]
X448,
}
// === public ed key === //
/// Defined in [RFC 8037](https://tools.ietf.org/html/rfc8037)
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct JwkPublicEdKey {
/// NOTE: "crv" defines not exactly the curve but the algorithm.
crv: JwkEdPublicKeyAlgorithm,
/// In contrast to EC keys, the `x` coordinate is an octet string, not an encoded big integer.
x: String,
}
impl JwkPublicEdKey {
pub fn public_key_bytes(&self) -> Result<Vec<u8>, JwkError> {
let mut buf = Vec::new();
general_purpose::URL_SAFE_NO_PAD
.decode_vec(&self.x, &mut buf)
.map_err(JwkError::from)?;
Ok(buf)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jose::jws::JwsAlg;
use crate::pem::Pem;
use rstest::rstest;
const RSA_MODULUS: &str = "rpJjxW0nNZiq1mPC3ZAxqf9qNjmKurP7XuKrpWrfv3IOUldqChQVPNg8zCvDOMZIO-ZDuRmVH\
EZ5E1vz5auHNACnpl6AvDGJ-4qyX42vfUDMNZx8i86d7bQpwJkO_MVMLj8qMGmTVbQ8zqVw2z\
MyKUFfa2V83nvx2wz4FJh2Thw2uZX2P7h8nlDVSuXO0wJ_OY_2qtqRIAnNXMzL5BF5pEFh4hi\
JIFiMTNkhVtUjT1QSB9E8DtDme8g4u769Oc0My45fgqSNE7kKKyaDhTfqSovyhj-qWiD-X_Gw\
pWkW4ungpHzz_97-ZDB3yQ7AMwKAsw5EW2cMqseAp3f-kf159w";
const RSA_PUBLIC_EXPONENT: &str = "AQAB";
const X509_SHA1_THUMBPRINT: &str = "N3ORVnr9T6opxpS9iRbkKGwKiQI";
const X509_CERT_0: &str = "MIIDWjCCAkKgAwIBAgIUWRsBqKmpXGP/OwrwLWicwxhuCFowDQYJKoZIhvc\
NAQELBQAwKjEoMCYGA1UEAwwfbG9naW4uZGV2b2x1dGlvbnMuY29tIEF1dG\
hvcml0eTAeFw0xOTAzMTMxMzE1MzVaFw0yMDAzMTIxMzE1MzVaMCYxJDAiB\
gNVBAMMG2xvZ2luLmRldm9sdXRpb25zLmNvbSBUb2tlbjCCASIwDQYJKoZI\
hvcNAQEBBQADggEPADCCAQoCggEBAK6SY8VtJzWYqtZjwt2QMan/ajY5irq\
z+17iq6Vq379yDlJXagoUFTzYPMwrwzjGSDvmQ7kZlRxGeRNb8+WrhzQAp6\
ZegLwxifuKsl+Nr31AzDWcfIvOne20KcCZDvzFTC4/KjBpk1W0PM6lcNszM\
ilBX2tlfN578dsM+BSYdk4cNrmV9j+4fJ5Q1UrlztMCfzmP9qrakSAJzVzM\
y+QReaRBYeIYiSBYjEzZIVbVI09UEgfRPA7Q5nvIOLu+vTnNDMuOX4KkjRO\
5Cismg4U36kqL8oY/qlog/l/xsKVpFuLp4KR88//e/mQwd8kOwDMCgLMORF\
tnDKrHgKd3/pH9efcCAwEAAaN8MHowCQYDVR0TBAIwADAOBgNVHQ8BAf8EB\
AMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQW\
BBQQW2Cx8HUpXfFM3B76WzBb/BhCBDAfBgNVHSMEGDAWgBRWAUlOiE4Z3ww\
aHgz284/sYB9NaDANBgkqhkiG9w0BAQsFAAOCAQEAkliCiJF9Z/Y57V6Rrn\
gHCBBWtqR+N/A+KHQqWxP2MmJiHVBBnZAueVPsvykO+EfbazNEkUoPVKhUd\
5NxEmTEMOBu9HUEzlmA5xDjl5xS7fejJIr7pgbxIup4m+DsNsPVnF1Snk56\
F6660RhRb9fsHQ0pgvWuG+tQXJ4J1Zi0cp+xi4yze6hJGAyAqj6wU46AUiL\
6kUr9GUVHqEsl5mNMIW18JT4KM/s5DWxFGO2soSTkaVHwGSkMBQSTgHMWs0\
L3bBfimjw9FwjwwHAbe1W5QU6uVXGApuKANRsXxgCn566QkE/BuV3WVR6uy\
n2P1J/vU9hxasgRIcjf3jHC4lGpew==";
const X509_CERT_1: &str = "MIIDRjCCAi6gAwIBAgIUUqhc3/U6OhKtEk1b8JfX3GL0FPYwDQYJKoZIhvc\
NAQELBQAwKDEmMCQGA1UEAwwdbG9naW4uZGV2b2x1dGlvbnMuY29tIFJvb3\
QgQ0EwHhcNMTkwMzEzMTMxNTM1WhcNMjAwMzEyMTMxNTM1WjAqMSgwJgYDV\
QQDDB9sb2dpbi5kZXZvbHV0aW9ucy5jb20gQXV0aG9yaXR5MIIBIjANBgkq\
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlbRwXVPc/WH4t/Yti5qv24pAu8Q\
m0eOVvbum23bYtfJDbCSDh7sY/vvQXgIkM8/0C3tFZ3XaXHbyDHAMn6OC+S\
Obzs6SjpfKk9s69Yo/aWFl9oRnAK/+dZ0Y6MTdZO1w+PpR81q5QOFMLpWX1\
YNdahaZec31sBmsHqlW04OrHUhGOTGdWNots9/PWvN//x++FL+Sqgh/jxF7\
khbgfAuz1QKa8P0ZlE4cOcRIs5bSnUFwtoytKH02/YZnCJD7I/iXFuCPV/+\
LZO6yobkTREE3npeXvAKr1OKF2F0JVORMhHiYyguh9t3bMwHTCFqmfQkIMD\
GjaTJD7bd8y2Au+eDzgwIDAQABo2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDV\
R0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUVgFJTohOGd8MGh4M9vOP7GAf\
TWgwHwYDVR0jBBgwFoAU42BA1coGHUUPUSeacQfTzicjosgwDQYJKoZIhvc\
NAQELBQADggEBAKyyDs+uIughmloEmlf8s1cSP8cLtC1Di2TfYSG0bpEM3B\
EPTond/7ujDlv0eug9NRurvWd5v7bWvy9VlJo+x2rLBmkzaNcBSVHZ4UbFU\
90MSvHjxNZ7VbUfbWsJVeaYHtqf1m3z0fYT0tUor3chD+wbSqraWw4+t54h\
fJl22jExTWS9X0F5/Gf3LQOiOvtjHP+b3VkpXkEPIBbvIO/X6kgoGDLm/lA\
IPdZmpI956z5+acLHu3AQkxNXQPzCjSSdJphLVU1XeHXOMWldVtE9BqSMVI\
HZ6oCz/FtMA4F6R7WiVXXGR+ywRwFyeiFoRea2ImUK9TRWFsaXKeOBMm+TL\
bk=";
const X509_CERT_2: &str = "MIIDRDCCAiygAwIBAgIUCAKwhsjTttdG4koEAV7zqlnI7wkwDQYJKoZIhvc\
NAQELBQAwKDEmMCQGA1UEAwwdbG9naW4uZGV2b2x1dGlvbnMuY29tIFJvb3\
QgQ0EwHhcNMTkwMzEzMTMxNTM1WhcNMjQwMzExMTMxNTM1WjAoMSYwJAYDV\
QQDDB1sb2dpbi5kZXZvbHV0aW9ucy5jb20gUm9vdCBDQTCCASIwDQYJKoZI\
hvcNAQEBBQADggEPADCCAQoCggEBANRZxxg9eTCMVr4DsIUcytQOLnlZ7tl\
uliP+jM76mjJEuWqizHzZ1ZoPcEbdW9sV8kgWdPHL3KOlXAr0DEobnhQsNx\
uzJ8B73TcV7AKp2HR+xCTKPEha1gVHgQMmzQyCIgLEsdcjhsFeFYqMflELZ\
rMy+7DBSZWWf3wCnxiKbzTL01wKqylVWeSiXsniTpsoUSSk8Fe2/Li8dBMY\
he1vTb57GI8ta24P4lfJv6CPTNTVsr+6ue3lRuY/UIMNTybhBSc00qbuo0K\
ahWHyzDgY+iNEaALbyWeNOoTBQIO8lp4mhHcO/Znh2PxdqCi/FSCB2+A1Xd\
uOArn+MKegU5aVJN0CAwEAAaNmMGQwEgYDVR0TAQH/BAgwBgEB/wIBAjAOB\
gNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFONgQNXKBh1FD1EnmnEH084nI6LI\
MB8GA1UdIwQYMBaAFONgQNXKBh1FD1EnmnEH084nI6LIMA0GCSqGSIb3DQE\
BCwUAA4IBAQB+v34Vk/+qQgA7eWlczWNVWM0J67om+QwtMEo+VgzE2OHNID\
2o5QXsxcck0j8dANutkoqsUXpos/RG+QPNng5RBWA/sWUYWdfwZgrE30rBK\
waP8Yi8gVsZpz3/RClbPcfkUXI12ANw3bRI1TscOK165p1TV6nmeEus5LZq\
CJV37/WRt47CccsDNZaqSN7T5lQ045jsZVYpfgx/I1l9Q/fICrTOFwqYbXJ\
9DTe1v8C+LFbtTNcEzRGwZefLTNH2yuZjGy1/t4+cnmFJUzmC4abOoZcpkr\
z6U68caCbQA+wdmFs4XaO2bFaiyM+m0LVMOQfLuX/0RZc2KB7fAbb7oHQl";
fn get_jwk_set() -> JwkSet {
JwkSet {
keys: vec![Jwk {
alg: Some(Jwa::Sig(JwsAlg::RS256)),
key_ops: Some(vec![JwkKeyOps::Verify]),
kid: Some("bG9naW4uZGV2b2x1dGlvbnMuY29tIFRva2VuLk1hciAxMyAxMzoxNTozNSAyMDE5IEdNVA".to_owned()),
x5t: Some(X509_SHA1_THUMBPRINT.to_owned()),
x5c: Some(vec![
X509_CERT_0.to_owned(),
X509_CERT_1.to_owned(),
X509_CERT_2.to_owned(),
]),
..Jwk::new(JwkKeyType::new_rsa_key_from_base64_url(
RSA_MODULUS.into(),
RSA_PUBLIC_EXPONENT.into(),
))
}],
}
}
#[test]
fn rsa_key() {
let expected = get_jwk_set();
let decoded = JwkSet::from_json(picky_test_data::JOSE_JWK_SET).unwrap();
pretty_assertions::assert_eq!(decoded, expected);
let encoded = expected.to_json_pretty().unwrap();
let decoded = JwkSet::from_json(&encoded).unwrap();
pretty_assertions::assert_eq!(decoded, expected);
}
#[rstest]
#[case(picky_test_data::JOSE_JWK_EC_P256_JSON)]
#[case(picky_test_data::JOSE_JWK_EC_P384_JSON)]
#[case(picky_test_data::JOSE_JWK_EC_P521_JSON)]
fn ecdsa_key_roundtrip(#[case] json: &str) {
let decoded = Jwk::from_json(json).unwrap();
let encoded = decoded.to_json().unwrap();
pretty_assertions::assert_eq!(encoded, json);
}
#[rstest]
#[case(picky_test_data::JOSE_JWK_ED25519_JSON)]
#[case(picky_test_data::JOSE_JWK_X25519_JSON)]
fn ed_key_roundtrip(#[case] json: &str) {
let decoded = Jwk::from_json(json).unwrap();
let encoded = decoded.to_json().unwrap();
pretty_assertions::assert_eq!(encoded, json);
}
const PUBLIC_KEY_PEM: &str = r#"-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA61BjmfXGEvWmegnBGSuS
+rU9soUg2FnODva32D1AqhwdziwHINFaD1MVlcrYG6XRKfkcxnaXGfFDWHLEvNBS
EVCgJjtHAGZIm5GL/KA86KDp/CwDFMSwluowcXwDwoyinmeOY9eKyh6aY72xJh7n
oLBBq1N0bWi1e2i+83txOCg4yV2oVXhBo8pYEJ8LT3el6Smxol3C1oFMVdwPgc0v
Tl25XucMcG/ALE/KNY6pqC2AQ6R2ERlVgPiUWOPatVkt7+Bs3h5Ramxh7XjBOXeu
lmCpGSynXNcpZ/06+vofGi/2MlpQZNhHAo8eayMp6FcvNucIpUndo1X8dKMv3Y26
ZQIDAQAB
-----END PUBLIC KEY-----"#;
#[test]
fn x509_and_jwk_conversion_rsa() {
let initial_key = PublicKey::from_pem(&PUBLIC_KEY_PEM.parse::<Pem>().expect("pem")).expect("public key");
let jwk = Jwk::from_public_key(&initial_key).unwrap();
if let JwkKeyType::Rsa(rsa_key) = &jwk.key {
let modulus = general_purpose::URL_SAFE_NO_PAD.decode(&rsa_key.n).unwrap();
assert_ne!(modulus[0], 0x00);
let public_exponent = general_purpose::URL_SAFE_NO_PAD.decode(&rsa_key.e).unwrap();
assert_ne!(public_exponent[0], 0x00);
} else {
panic!("Unexpected key type");
}
let from_jwk_key = jwk.to_public_key().unwrap();
assert_eq!(from_jwk_key, initial_key);
}
#[rstest]
#[case(picky_test_data::EC_NIST256_PK_1_PUB)]
#[case(picky_test_data::EC_NIST384_PK_1_PUB)]
fn x509_and_jwk_conversion_ec(#[case] pem: &str) {
let initial_key = PublicKey::from_pem(&pem.parse::<Pem>().expect("pem")).expect("public key");
let jwk = Jwk::from_public_key(&initial_key).unwrap();
if let JwkKeyType::Ec(rsa_key) = &jwk.key {
let x = general_purpose::URL_SAFE_NO_PAD.decode(&rsa_key.x).unwrap();
assert_ne!(x[0], 0x00);
let y = general_purpose::URL_SAFE_NO_PAD.decode(&rsa_key.y).unwrap();
assert_ne!(y[0], 0x00);
} else {
panic!("Unexpected key type");
}
let from_jwk_key = jwk.to_public_key().unwrap();
assert_eq!(from_jwk_key, initial_key);
}
}
+668
View File
@@ -0,0 +1,668 @@
//! JSON Web Signature (JWS) represents content secured with digital signatures or Message Authentication Codes (MACs) using JSON-based data structures.
//!
//! See [RFC7515](https://tools.ietf.org/html/rfc7515).
use crate::hash::HashAlgorithm;
use crate::jose::jwk::Jwk;
use crate::key::{EcCurve, PrivateKey, PublicKey};
use crate::signature::{SignatureAlgorithm, SignatureError};
use base64::engine::general_purpose;
use base64::{DecodeError, Engine as _};
use picky_asn1::wrapper::IntegerAsn1;
use picky_asn1_x509::signature::EcdsaSignatureValue;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::HashMap;
use thiserror::Error;
// === error type === //
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum JwsError {
/// RSA error
#[error("RSA error: {context}")]
Rsa { context: String },
/// Json error
#[error("JSON error: {source}")]
Json { source: serde_json::Error },
/// signature error
#[error("signature error: {source}")]
Signature { source: SignatureError },
/// invalid token encoding
#[error("input isn't a valid token string: {input}")]
InvalidEncoding { input: String },
/// couldn't decode base64
#[error("couldn't decode base64: {source}")]
Base64Decoding { source: DecodeError },
/// input isn't valid utf8
#[error("input isn't valid utf8: {source}, input: {input:?}")]
InvalidUtf8 {
source: std::string::FromUtf8Error,
input: Vec<u8>,
},
}
impl From<rsa::errors::Error> for JwsError {
fn from(e: rsa::errors::Error) -> Self {
Self::Rsa { context: e.to_string() }
}
}
impl From<serde_json::Error> for JwsError {
fn from(e: serde_json::Error) -> Self {
Self::Json { source: e }
}
}
impl From<SignatureError> for JwsError {
fn from(e: SignatureError) -> Self {
Self::Signature { source: e }
}
}
impl From<DecodeError> for JwsError {
fn from(e: DecodeError) -> Self {
Self::Base64Decoding { source: e }
}
}
// === JWS algorithms === //
mod jws_alg {
// We use #[deprecated] for `JwsAlg::ED22519` to tell users that this algorithm is invalid
// (`JwsAlg::EdDSA` should be used instead) but we should keep it inside enum to allow decoding
// of invalid tokens generated by other libraries such as `golang-jws`. However, to avoid
// compilation warnings caused by enum derives, we need to allow deprecated items inside this
// module.
#![allow(deprecated)]
use super::*;
/// `alg` header parameter values for JWS
///
/// [JSON Web Algorithms (JWA) draft-ietf-jose-json-web-algorithms-40 #3](https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40#section-3.1)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum JwsAlg {
/// HMAC using SHA-256 (unsupported)
///
/// Required by RFC
HS256,
/// HMAC using SHA-384 (unsupported)
HS384,
/// HMAC using SHA-512 (unsupported)
HS512,
/// RSASSA-PKCS-v1_5 using SHA-256
///
/// Recommended by RFC
RS256,
/// RSASSA-PKCS-v1_5 using SHA-384
RS384,
/// RSASSA-PKCS-v1_5 using SHA-512
RS512,
/// ECDSA using P-256 and SHA-256
///
/// Recommended+ by RFC
ES256,
/// ECDSA using P-384 and SHA-384
ES384,
/// ECDSA using P-521 and SHA-512
ES512,
/// RSASSA-PSS using SHA-256 and MGF1 with SHA-256 (unsupported)
PS256,
/// RSASSA-PSS using SHA-384 and MGF1 with SHA-384 (unsupported)
PS384,
/// RSASSA-PSS using SHA-512 and MGF1 with SHA-512 (unsupported)
PS512,
/// EdDSA using Ed25519/Ed448
EdDSA,
/// [DO NOT USE] EdDSA using Ed25519
///
/// This value is used by some popular libraries (e.g. `golang-jwt) instead of `EdDSA` due to
/// mistake in the implementation. This value is deprecated and should not be used.
#[deprecated(note = "You should not use this value, but it may appear in the wild")]
ED25519,
}
}
// Hack to localize #![allow(deprecated)]
pub use jws_alg::JwsAlg;
impl TryFrom<SignatureAlgorithm> for JwsAlg {
type Error = SignatureError;
fn try_from(v: SignatureAlgorithm) -> Result<Self, Self::Error> {
match v {
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_256) => Ok(Self::RS256),
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_384) => Ok(Self::RS384),
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_512) => Ok(Self::RS512),
SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_256) => Ok(Self::ES256),
SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_384) => Ok(Self::ES384),
SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_512) => Ok(Self::ES512),
SignatureAlgorithm::Ed25519 => Ok(Self::EdDSA),
unsupported => Err(SignatureError::UnsupportedAlgorithm {
algorithm: format!("{unsupported:?}"),
}),
}
}
}
impl TryFrom<JwsAlg> for SignatureAlgorithm {
type Error = SignatureError;
fn try_from(v: JwsAlg) -> Result<Self, Self::Error> {
match v {
JwsAlg::RS256 => Ok(SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_256)),
JwsAlg::RS384 => Ok(SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_384)),
JwsAlg::RS512 => Ok(SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_512)),
JwsAlg::ES256 => Ok(SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_256)),
JwsAlg::ES384 => Ok(SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_384)),
JwsAlg::ES512 => Ok(SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_512)),
JwsAlg::EdDSA => Ok(SignatureAlgorithm::Ed25519),
#[allow(deprecated)]
JwsAlg::ED25519 => Ok(SignatureAlgorithm::Ed25519),
unsupported => Err(SignatureError::UnsupportedAlgorithm {
algorithm: format!("{unsupported:?}"),
}),
}
}
}
// === JWS header === //
/// JOSE header of a JWS
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JwsHeader {
// -- specific to JWS -- //
/// Algorithm Header
///
/// identifies the cryptographic algorithm used to secure the JWS.
pub alg: JwsAlg,
// -- common with JWE -- //
/// JWK Set URL
///
/// URI that refers to a resource for a set of JSON-encoded public keys,
/// one of which corresponds to the key used to digitally sign the JWS.
#[serde(skip_serializing_if = "Option::is_none")]
pub jku: Option<String>,
/// JSON Web Key
///
/// The public key that corresponds to the key used to digitally sign the JWS.
/// This key is represented as a JSON Web Key (JWK).
#[serde(skip_serializing_if = "Option::is_none")]
pub jwk: Option<Jwk>,
/// Type header
///
/// Used by JWS applications to declare the media type [IANA.MediaTypes] of this complete JWS.
#[serde(skip_serializing_if = "Option::is_none")]
pub typ: Option<String>,
/// Content Type header
///
/// Used by JWS applications to declare the media type [IANA.MediaTypes] of the secured content (the payload).
#[serde(skip_serializing_if = "Option::is_none")]
pub cty: Option<String>,
// -- common with all -- //
/// Key ID Header
///
/// A hint indicating which key was used.
#[serde(skip_serializing_if = "Option::is_none")]
pub kid: Option<String>,
/// X.509 URL Header
///
/// URI that refers to a resource for an X.509 public key certificate or certificate chain.
#[serde(skip_serializing_if = "Option::is_none")]
pub x5u: Option<String>,
/// X.509 Certificate Chain
///
/// Chain of one or more PKIX certificates.
#[serde(skip_serializing_if = "Option::is_none")]
pub x5c: Option<Vec<String>>,
/// X.509 Certificate SHA-1 Thumbprint
///
/// base64url-encoded SHA-1 thumbprint (a.k.a. digest) of the DER encoding of an X.509 certificate.
#[serde(skip_serializing_if = "Option::is_none")]
pub x5t: Option<String>,
/// X.509 Certificate SHA-256 Thumbprint
///
/// base64url-encoded SHA-256 thumbprint (a.k.a. digest) of the DER encoding of an X.509 certificate.
#[serde(rename = "x5t#S256", alias = "x5t#s256", skip_serializing_if = "Option::is_none")]
pub x5t_s256: Option<String>,
// -- extra parameters -- //
/// Additional header parameters (both public and private)
#[serde(flatten)]
pub additional: HashMap<String, serde_json::Value>,
}
impl JwsHeader {
pub fn new(alg: JwsAlg) -> Self {
Self {
alg,
jku: None,
jwk: None,
typ: None,
cty: None,
kid: None,
x5u: None,
x5c: None,
x5t: None,
x5t_s256: None,
additional: HashMap::new(),
}
}
pub fn new_with_cty(alg: JwsAlg, cty: impl Into<String>) -> Self {
Self {
cty: Some(cty.into()),
..Self::new(alg)
}
}
}
// === json web signature === //
/// Provides an API to sign any kind of data (binary). JSON claims are part of `Jwt` only.
#[derive(Debug, Clone)]
pub struct Jws {
pub header: JwsHeader,
pub payload: Vec<u8>,
}
impl Jws {
pub fn new(alg: JwsAlg, payload: Vec<u8>) -> Self {
Self {
header: JwsHeader::new(alg),
payload,
}
}
pub fn encode(&self, private_key: &PrivateKey) -> Result<String, JwsError> {
let header_base64 = general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&self.header)?);
let payload_base64 = general_purpose::URL_SAFE_NO_PAD.encode(&self.payload);
let header_and_payload = [header_base64, payload_base64].join(".");
let signature_algo = SignatureAlgorithm::try_from(self.header.alg)?;
let signature = signature_algo.sign(header_and_payload.as_bytes(), private_key)?;
let signature = match self.header.alg {
// EC sugnatures have special encoding rules (RFC 7518, section 3.4)
JwsAlg::ES256 | JwsAlg::ES384 | JwsAlg::ES512 => {
// Parse signature as ASN.1 DER sequence
let signature: EcdsaSignatureValue =
picky_asn1_der::from_bytes(&signature).map_err(|e| SignatureError::Ec {
context: format!("Invalid EC DER signature encoding: {e}"),
})?;
let curve = match self.header.alg {
JwsAlg::ES256 => EcCurve::NistP256,
JwsAlg::ES384 => EcCurve::NistP384,
JwsAlg::ES512 => EcCurve::NistP521,
_ => unreachable!("Checked in match above"),
};
let signature_component_size = curve.field_bytes_size();
let r = signature.r.as_unsigned_bytes_be();
let s = signature.s.as_unsigned_bytes_be();
// We should add zero padding (leading zeros) for R & S components to match the
// size of the curve, as ASN.1 DER encoding removes leading zeros.
let mut jws_signature = Vec::with_capacity(signature_component_size * 2);
let r_padding = signature_component_size - r.len();
(0..r_padding).for_each(|_| jws_signature.push(0));
jws_signature.extend_from_slice(r);
let s_padding = signature_component_size - s.len();
(0..s_padding).for_each(|_| jws_signature.push(0));
jws_signature.extend_from_slice(s);
jws_signature
}
_ => signature,
};
let signature_base64 = general_purpose::URL_SAFE_NO_PAD.encode(signature);
Ok([header_and_payload, signature_base64].join("."))
}
/// Verifies signature and returns decoded JWS payload.
pub fn decode(encoded_token: &str, public_key: &PublicKey) -> Result<Self, JwsError> {
RawJws::decode(encoded_token).and_then(|raw_jws| raw_jws.verify(public_key))
}
}
/// Raw low-level interface to the yet to be verified JWS token.
///
/// This is useful to inspect the structure before performing further processing.
/// For most usecases, use `Jws` directly.
#[derive(Debug, Clone)]
pub struct RawJws<'repr> {
pub compact_repr: Cow<'repr, str>,
pub header: JwsHeader,
payload: Vec<u8>,
pub signature: Vec<u8>,
}
/// An owned `RawJws` for convenience.
pub type OwnedRawJws = RawJws<'static>;
impl<'repr> RawJws<'repr> {
/// Decodes a JWS in compact representation.
pub fn decode(compact_repr: impl Into<Cow<'repr, str>>) -> Result<Self, JwsError> {
decode_impl(compact_repr.into())
}
/// Peeks the payload before signature verification.
pub fn peek_payload(&self) -> &[u8] {
&self.payload
}
/// Verifies signature and returns a verified `Jws` structure.
pub fn verify(self, public_key: &PublicKey) -> Result<Jws, JwsError> {
verify_signature(&self.compact_repr, public_key, self.header.alg)?;
Ok(self.discard_signature())
}
/// Discards the signature without verifying it and hands a `Jws` structure.
///
/// Generally, you should not do that.
pub fn discard_signature(self) -> Jws {
Jws {
header: self.header,
payload: self.payload,
}
}
}
fn decode_impl(compact_repr: Cow<'_, str>) -> Result<RawJws<'_>, JwsError> {
let first_dot_idx = compact_repr.find('.').ok_or_else(|| JwsError::InvalidEncoding {
input: compact_repr.clone().into_owned(),
})?;
let last_dot_idx = compact_repr.rfind('.').ok_or_else(|| JwsError::InvalidEncoding {
input: compact_repr.clone().into_owned(),
})?;
if first_dot_idx == last_dot_idx || compact_repr.starts_with('.') || compact_repr.ends_with('.') {
return Err(JwsError::InvalidEncoding {
input: compact_repr.into_owned(),
});
}
let header_json = general_purpose::URL_SAFE_NO_PAD.decode(&compact_repr[..first_dot_idx])?;
let header = serde_json::from_slice::<JwsHeader>(&header_json)?;
let signature = general_purpose::URL_SAFE_NO_PAD.decode(&compact_repr[last_dot_idx + 1..])?;
let payload = general_purpose::URL_SAFE_NO_PAD.decode(&compact_repr[first_dot_idx + 1..last_dot_idx])?;
Ok(RawJws {
compact_repr,
header,
payload,
signature,
})
}
/// JWS verification primitive
pub fn verify_signature(encoded_token: &str, public_key: &PublicKey, algorithm: JwsAlg) -> Result<(), JwsError> {
let last_dot_idx = encoded_token.rfind('.').ok_or_else(|| JwsError::InvalidEncoding {
input: encoded_token.to_owned(),
})?;
if encoded_token.ends_with('.') {
return Err(JwsError::InvalidEncoding {
input: encoded_token.to_owned(),
});
}
let signature = general_purpose::URL_SAFE_NO_PAD.decode(&encoded_token[last_dot_idx + 1..])?;
let signature_algo = SignatureAlgorithm::try_from(algorithm)?;
let signature = match algorithm {
// Special decoding rules for ECDSA
JwsAlg::ES256 | JwsAlg::ES384 | JwsAlg::ES512 => {
let curve = match algorithm {
JwsAlg::ES256 => EcCurve::NistP256,
JwsAlg::ES384 => EcCurve::NistP384,
JwsAlg::ES512 => EcCurve::NistP521,
_ => unreachable!("Checked in match above"),
};
let component_size = curve.field_bytes_size();
let jws_encoded_signature_size = component_size * 2;
if signature.len() != jws_encoded_signature_size {
return Err(SignatureError::Ec {
context: format!(
"Invalid JWS EC signature size. Expected: {}; Actual: {}",
jws_encoded_signature_size,
signature.len()
),
}
.into());
}
let (r, s) = signature.split_at(component_size);
let signature = EcdsaSignatureValue {
r: IntegerAsn1::from_bytes_be_unsigned(r.to_vec()),
s: IntegerAsn1::from_bytes_be_unsigned(s.to_vec()),
};
picky_asn1_der::to_vec(&signature).map_err(|e| SignatureError::Ec {
context: format!("Failed to encode EC signature to DER format: {e}"),
})?
}
_ => signature,
};
signature_algo.verify(public_key, &encoded_token.as_bytes()[..last_dot_idx], &signature)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pem::Pem;
use rstest::rstest;
const PAYLOAD: &str = r#"{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}"#;
fn get_private_key_1() -> PrivateKey {
let pk_pem = picky_test_data::RSA_2048_PK_1.parse::<Pem>().unwrap();
PrivateKey::from_pem(&pk_pem).unwrap()
}
fn get_private_key_2() -> PrivateKey {
let pk_pem = picky_test_data::RSA_2048_PK_7.parse::<Pem>().unwrap();
PrivateKey::from_pem(&pk_pem).unwrap()
}
#[test]
fn encode_rsa_sha256() {
let jwt = Jws {
header: JwsHeader {
typ: Some(String::from("JWT")),
..JwsHeader::new(JwsAlg::RS256)
},
payload: PAYLOAD.as_bytes().to_vec(),
};
let encoded = jwt.encode(&get_private_key_1()).unwrap();
assert_eq!(encoded, picky_test_data::JOSE_JWT_SIG_EXAMPLE);
}
#[rstest]
#[case(JwsAlg::ES256, picky_test_data::EC_NIST256_PK_1)]
#[case(JwsAlg::ES384, picky_test_data::EC_NIST384_PK_1)]
#[case(JwsAlg::ES512, picky_test_data::EC_NIST521_PK_1)]
fn ecdsa_sign_verify(#[case] alg: JwsAlg, #[case] key_pem: &str) {
let key = PrivateKey::from_pem_str(key_pem).unwrap();
let jwt = Jws {
header: JwsHeader {
typ: Some(String::from("JWT")),
..JwsHeader::new(alg)
},
payload: PAYLOAD.as_bytes().to_vec(),
};
// Check encode + sign
let encoded = jwt.encode(&key).unwrap();
// Check decode + verify
let jws = RawJws::decode(&encoded).unwrap();
jws.clone().verify(&key.to_public_key().unwrap()).unwrap();
assert_eq!(&jws.header, &jwt.header);
assert_eq!(&jws.payload, &jwt.payload);
}
#[rstest]
#[case(picky_test_data::EC_NIST256_PK_1, picky_test_data::JOSE_JWT_SIG_ES256)]
#[case(picky_test_data::EC_NIST384_PK_1, picky_test_data::JOSE_JWT_SIG_ES384)]
#[case(picky_test_data::EC_NIST521_PK_1, picky_test_data::JOSE_JWT_SIG_ES512)]
fn ecdsa_parse_and_verify(#[case] key_pem: &str, #[case] signature: &str) {
let key = PrivateKey::from_pem_str(key_pem).unwrap();
// Check decode + verify
let jws = RawJws::decode(signature).unwrap();
jws.clone().verify(&key.to_public_key().unwrap()).unwrap();
}
const JWT_ED25519_BODY: &str = r#"{"username":"kataras"}"#;
const JWT_ED25519_GO_BODY: &str = r#"{"foo":"bar"}"#;
/// Validate that invalid header with alg set to "ED25519" could be parsed by picky
#[rstest]
#[allow(deprecated)]
#[case(
picky_test_data::JOSE_JWT_SIG_ED25519_GO_PRIVATE_KEY,
picky_test_data::JOSE_JWT_SIG_ED25519_GO,
JwsAlg::ED25519,
JWT_ED25519_GO_BODY
)]
#[case(
picky_test_data::JOSE_JWT_SIG_ED25519_PRIVATE_KEY,
picky_test_data::JOSE_JWT_SIG_ED25519,
JwsAlg::EdDSA,
JWT_ED25519_BODY
)]
fn ed25519_algorithm(#[case] key: &str, #[case] encoded_expected: &str, #[case] alg: JwsAlg, #[case] body: &str) {
let key = PrivateKey::from_pem_str(key).unwrap();
let jwt = Jws {
header: JwsHeader {
typ: Some(String::from("JWT")),
..JwsHeader::new(alg)
},
payload: body.as_bytes().to_vec(),
};
// Check encode + sign
let encoded = jwt.encode(&key).unwrap();
assert_eq!(encoded, encoded_expected);
// Check decode + verify
let jws = RawJws::decode(&encoded).unwrap();
jws.verify(&key.to_public_key().unwrap()).unwrap();
}
#[test]
fn decode_rsa_sha256() {
let public_key = get_private_key_1().to_public_key().unwrap();
let jwt = Jws::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key).unwrap();
assert_eq!(jwt.payload.as_slice(), PAYLOAD.as_bytes());
}
#[test]
fn decode_rsa_sha256_delayed_signature_check() {
let jws = RawJws::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE).unwrap();
println!("{}", String::from_utf8_lossy(&jws.payload));
assert_eq!(jws.peek_payload(), PAYLOAD.as_bytes());
let public_key = get_private_key_2().to_public_key().unwrap();
let err = jws.verify(&public_key).err().unwrap();
assert_eq!(err.to_string(), "signature error: invalid signature");
}
#[test]
fn decode_rsa_sha256_invalid_signature_err() {
let public_key = get_private_key_2().to_public_key().unwrap();
let err = Jws::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key)
.err()
.unwrap();
assert_eq!(err.to_string(), "signature error: invalid signature");
}
#[test]
fn decode_invalid_base64_err() {
let public_key = get_private_key_1().to_public_key().unwrap();
let err = Jws::decode("aieoè~†.tésp.à", &public_key).err().unwrap();
assert_eq!(
err.to_string(),
"couldn\'t decode base64: Invalid symbol 195, offset 4."
);
}
#[test]
fn decode_invalid_json_err() {
let public_key = get_private_key_1().to_public_key().unwrap();
let err = Jws::decode("abc.abc.abc", &public_key).err().unwrap();
assert_eq!(err.to_string(), "JSON error: expected value at line 1 column 1");
let err = Jws::decode("eyAiYWxnIjogIkhTMjU2IH0K.abc.abc", &public_key)
.err()
.unwrap();
assert_eq!(
err.to_string(),
"JSON error: control character (\\u0000-\\u001F) \
found while parsing a string at line 2 column 0"
);
}
#[test]
fn decode_invalid_encoding_err() {
let public_key = get_private_key_1().to_public_key().unwrap();
let err = Jws::decode(".abc.abc", &public_key).err().unwrap();
assert_eq!(err.to_string(), "input isn\'t a valid token string: .abc.abc");
let err = Jws::decode("abc.abc.", &public_key).err().unwrap();
assert_eq!(err.to_string(), "input isn\'t a valid token string: abc.abc.");
let err = Jws::decode("abc.abc", &public_key).err().unwrap();
assert_eq!(err.to_string(), "input isn\'t a valid token string: abc.abc");
let err = Jws::decode("abc", &public_key).err().unwrap();
assert_eq!(err.to_string(), "input isn\'t a valid token string: abc");
}
}
+690
View File
@@ -0,0 +1,690 @@
use super::jwe::Jwe;
use crate::jose::jwe::{JweAlg, JweEnc, JweError, JweHeader};
use crate::jose::jws::{Jws, JwsAlg, JwsError, JwsHeader};
use crate::key::{PrivateKey, PublicKey};
use core::fmt;
use serde::Serialize;
use serde::de::DeserializeOwned;
use thiserror::Error;
// === error type === //
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum JwtError {
/// JWS error
#[error("JWS error: {source}")]
Jws { source: JwsError },
/// JWE error
#[error("JWE error: {source}")]
Jwe { source: JweError },
/// Json error
#[error("JSON error: {source}")]
Json { source: serde_json::Error },
/// registered claim type is invalid
#[error("registered claim `{claim}` has invalid type")]
InvalidRegisteredClaimType { claim: &'static str },
/// a required claim is missing
#[error("required claim `{claim}` is missing")]
RequiredClaimMissing { claim: &'static str },
/// token not yet valid
#[error("token not yet valid (not before: {}, now: {} [leeway: {}])", not_before, now.numeric_date, now.leeway)]
NotYetValid { not_before: i64, now: JwtDate },
/// token expired
#[error("token expired (not after: {}, now: {} [leeway: {}])", not_after, now.numeric_date, now.leeway)]
Expired { not_after: i64, now: JwtDate },
/// validator is invalid
#[error("invalid validator: {description}")]
InvalidValidator { description: &'static str },
}
impl From<JwsError> for JwtError {
fn from(s: JwsError) -> Self {
Self::Jws { source: s }
}
}
impl From<serde_json::Error> for JwtError {
fn from(e: serde_json::Error) -> Self {
Self::Json { source: e }
}
}
impl From<JweError> for JwtError {
fn from(s: JweError) -> Self {
Self::Jwe { source: s }
}
}
// === Validation states === //
pub struct CheckedState<C> {
pub claims: C,
}
impl<C> Clone for CheckedState<C>
where
C: Clone,
{
fn clone(&self) -> Self {
Self {
claims: self.claims.clone(),
}
}
}
impl<C> fmt::Debug for CheckedState<C>
where
C: fmt::Debug,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ValidatedClaims({:?})", self.claims)
}
}
#[derive(Clone, Debug)]
pub struct UncheckedState {
payload: Vec<u8>,
}
// === JWT date === //
/// Represent date as defined by [RFC7519](https://tools.ietf.org/html/rfc7519#section-2).
///
/// A leeway can be configured to account clock skew when comparing with another date.
/// Should be small (less than 120).
#[derive(Clone, Debug)]
pub struct JwtDate {
pub numeric_date: i64,
pub leeway: u16,
}
impl JwtDate {
pub const fn new(numeric_date: i64) -> Self {
Self {
numeric_date,
leeway: 0,
}
}
pub const fn new_with_leeway(numeric_date: i64, leeway: u16) -> Self {
Self { numeric_date, leeway }
}
pub const fn is_before(&self, other_numeric_date: i64) -> bool {
self.numeric_date <= other_numeric_date + self.leeway as i64
}
pub const fn is_before_strict(&self, other_numeric_date: i64) -> bool {
self.numeric_date < other_numeric_date + self.leeway as i64
}
pub const fn is_after(&self, other_numeric_date: i64) -> bool {
self.numeric_date >= other_numeric_date - self.leeway as i64
}
pub const fn is_after_strict(&self, other_numeric_date: i64) -> bool {
self.numeric_date > other_numeric_date - self.leeway as i64
}
}
// === validator === //
#[derive(Debug, Clone, Copy)]
enum CheckStrictness {
Ignored,
Optional,
Required,
}
#[derive(Debug, Clone)]
pub struct JwtValidator {
current_date: Option<JwtDate>,
expiration_claim: CheckStrictness,
not_before_claim: CheckStrictness,
}
pub const NO_CHECK_VALIDATOR: JwtValidator = JwtValidator::no_check();
impl JwtValidator {
/// Check signature and the registered exp and nbf claims. If a claim is missing token is rejected.
pub const fn strict(current_date: JwtDate) -> Self {
Self {
current_date: Some(current_date),
expiration_claim: CheckStrictness::Required,
not_before_claim: CheckStrictness::Required,
}
}
/// Check signature and the registered exp and nbf claims. Token isn't rejected if a claim is missing.
pub const fn lenient(current_date: JwtDate) -> Self {
Self {
current_date: Some(current_date),
expiration_claim: CheckStrictness::Optional,
not_before_claim: CheckStrictness::Optional,
}
}
/// No check.
pub const fn no_check() -> Self {
Self {
current_date: None,
expiration_claim: CheckStrictness::Ignored,
not_before_claim: CheckStrictness::Ignored,
}
}
pub fn current_date(self, current_date: JwtDate) -> Self {
Self {
current_date: Some(current_date),
expiration_claim: CheckStrictness::Required,
not_before_claim: CheckStrictness::Required,
}
}
pub fn expiration_check_required(self) -> Self {
Self {
expiration_claim: CheckStrictness::Required,
..self
}
}
pub fn expiration_check_optional(self) -> Self {
Self {
expiration_claim: CheckStrictness::Optional,
..self
}
}
pub fn expiration_check_ignored(self) -> Self {
Self {
expiration_claim: CheckStrictness::Ignored,
..self
}
}
pub fn not_before_check_required(self) -> Self {
Self {
not_before_claim: CheckStrictness::Required,
..self
}
}
pub fn not_before_check_optional(self) -> Self {
Self {
not_before_claim: CheckStrictness::Optional,
..self
}
}
pub fn not_before_check_ignored(self) -> Self {
Self {
not_before_claim: CheckStrictness::Ignored,
..self
}
}
}
// === JWT === //
const JWT_TYPE: &str = "JWT";
const EXPIRATION_TIME_CLAIM: &str = "exp";
const NOT_BEFORE_CLAIM: &str = "nbf";
pub struct Jwt<H, State> {
pub header: H,
pub state: State,
}
pub type JwtSig = Jwt<JwsHeader, UncheckedState>;
pub type CheckedJwtSig<C> = Jwt<JwsHeader, CheckedState<C>>;
pub type JwtEnc = Jwt<JweHeader, UncheckedState>;
pub type CheckedJwtEnc<C> = Jwt<JweHeader, CheckedState<C>>;
impl<H, State> Clone for Jwt<H, State>
where
H: Clone,
State: Clone,
{
fn clone(&self) -> Self {
Self {
header: self.header.clone(),
state: self.state.clone(),
}
}
}
impl<H, State> fmt::Debug for Jwt<H, State>
where
H: fmt::Debug,
State: fmt::Debug,
{
fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
fmt.debug_struct("Jwt")
.field("header", &self.header)
.field("state", &self.state)
.finish()
}
}
impl<H, C> Jwt<H, CheckedState<C>> {
pub fn new_with_header(header: H, claims: C) -> Self {
Jwt {
header,
state: CheckedState { claims },
}
}
}
impl<C> CheckedJwtSig<C> {
pub fn new(alg: JwsAlg, claims: C) -> Self {
Jwt {
header: JwsHeader {
typ: Some(JWT_TYPE.to_owned()),
..JwsHeader::new(alg)
},
state: CheckedState { claims },
}
}
pub fn new_with_cty(alg: JwsAlg, cty: impl Into<String>, claims: C) -> Self {
Jwt {
header: JwsHeader {
typ: Some(JWT_TYPE.to_owned()),
..JwsHeader::new_with_cty(alg, cty)
},
state: CheckedState { claims },
}
}
}
impl<C> CheckedJwtSig<C>
where
C: Serialize,
{
pub fn encode(self, private_key: &PrivateKey) -> Result<String, JwtError> {
let jws = Jws {
header: self.header,
payload: serde_json::to_vec(&self.state.claims)?,
};
let encoded = jws.encode(private_key)?;
Ok(encoded)
}
}
impl JwtSig {
pub fn encode(self, private_key: &PrivateKey) -> Result<String, JwtError> {
let jws = Jws {
header: self.header,
payload: self.state.payload,
};
let encoded = jws.encode(private_key)?;
Ok(encoded)
}
}
impl JwtSig {
/// Verifies signature and returns decoded JWS payload.
pub fn decode(encoded_token: &str, public_key: &PublicKey) -> Result<Self, JwtError> {
let jws = Jws::decode(encoded_token, public_key)?;
Ok(Self::from(jws))
}
}
impl From<Jws> for JwtSig {
fn from(jws: Jws) -> Self {
Self {
header: jws.header,
state: UncheckedState { payload: jws.payload },
}
}
}
impl<C> CheckedJwtEnc<C> {
pub fn new(alg: JweAlg, enc: JweEnc, claims: C) -> Self {
Jwt {
header: JweHeader {
typ: Some(JWT_TYPE.to_owned()),
..JweHeader::new(alg, enc)
},
state: CheckedState { claims },
}
}
pub fn new_with_cty(alg: JweAlg, enc: JweEnc, cty: impl Into<String>, claims: C) -> Self {
Jwt {
header: JweHeader {
typ: Some(JWT_TYPE.to_owned()),
..JweHeader::new_with_cty(alg, enc, cty)
},
state: CheckedState { claims },
}
}
}
impl<C> CheckedJwtEnc<C>
where
C: Serialize,
{
/// Encode with CEK encrypted and included in the token using asymmetric cryptography.
pub fn encode(self, asymmetric_key: &PublicKey) -> Result<String, JwtError> {
let jwe = Jwe {
header: self.header,
payload: serde_json::to_vec(&self.state.claims)?,
};
let encoded = jwe.encode(asymmetric_key)?;
Ok(encoded)
}
/// Encode with provided CEK (a symmetric key). This will ignore `alg` value and override it with "dir".
pub fn encode_direct(self, cek: &[u8]) -> Result<String, JweError> {
let jwe = Jwe {
header: self.header,
payload: serde_json::to_vec(&self.state.claims)?,
};
let encoded = jwe.encode_direct(cek)?;
Ok(encoded)
}
}
impl JwtEnc {
/// Decode using asymmetric cryptography.
pub fn decode(encoded_token: &str, key: &PrivateKey) -> Result<Self, JwtError> {
let jwe = Jwe::decode(encoded_token, key)?;
Ok(Self::from(jwe))
}
/// Decode with provided CEK (a symmetric key).
pub fn decode_direct(encoded_token: &str, cek: &[u8]) -> Result<Self, JwtError> {
let jwe = Jwe::decode_direct(encoded_token, cek)?;
Ok(Self::from(jwe))
}
}
impl From<Jwe> for JwtEnc {
fn from(jwe: Jwe) -> Self {
Self {
header: jwe.header,
state: UncheckedState { payload: jwe.payload },
}
}
}
impl<H> Jwt<H, UncheckedState> {
/// Validate JWT claims using validator and convert payload to a user-defined typed struct.
pub fn validate<C>(self, validator: &JwtValidator) -> Result<Jwt<H, CheckedState<C>>, JwtError>
where
C: DeserializeOwned,
{
Ok(Jwt {
header: self.header,
state: CheckedState {
claims: h_decode_and_validate_claims(&self.state.payload, validator)?,
},
})
}
}
fn h_decode_and_validate_claims<C: DeserializeOwned>(
claims_json: &[u8],
validator: &JwtValidator,
) -> Result<C, JwtError> {
let claims = match (
&validator.current_date,
validator.not_before_claim,
validator.expiration_claim,
) {
(None, CheckStrictness::Required, _) | (None, _, CheckStrictness::Required) => {
return Err(JwtError::InvalidValidator {
description: "current date is missing",
});
}
(Some(current_date), nbf_strictness, exp_strictness) => {
let claims = serde_json::from_slice::<serde_json::Value>(claims_json)?;
let nbf_opt = claims.get(NOT_BEFORE_CLAIM);
match (nbf_strictness, nbf_opt) {
(CheckStrictness::Ignored, _) | (CheckStrictness::Optional, None) => {}
(CheckStrictness::Required, None) => {
return Err(JwtError::RequiredClaimMissing {
claim: NOT_BEFORE_CLAIM,
});
}
(_, Some(nbf)) => {
let nbf_i64 = nbf.as_i64().ok_or(JwtError::InvalidRegisteredClaimType {
claim: NOT_BEFORE_CLAIM,
})?;
if !current_date.is_after(nbf_i64) {
return Err(JwtError::NotYetValid {
not_before: nbf_i64,
now: current_date.clone(),
});
}
}
}
let exp_opt = claims.get(EXPIRATION_TIME_CLAIM);
match (exp_strictness, exp_opt) {
(CheckStrictness::Ignored, _) | (CheckStrictness::Optional, None) => {}
(CheckStrictness::Required, None) => {
return Err(JwtError::RequiredClaimMissing {
claim: EXPIRATION_TIME_CLAIM,
});
}
(_, Some(exp)) => {
let exp_i64 = exp.as_i64().ok_or(JwtError::InvalidRegisteredClaimType {
claim: EXPIRATION_TIME_CLAIM,
})?;
if !current_date.is_before_strict(exp_i64) {
return Err(JwtError::Expired {
not_after: exp_i64,
now: current_date.clone(),
});
}
}
}
serde_json::value::from_value(claims)?
}
(None, _, _) => serde_json::from_slice(claims_json)?,
};
Ok(claims)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jose::jws::RawJws;
use crate::pem::Pem;
use serde::Deserialize;
use std::borrow::Cow;
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct MyClaims {
sub: Cow<'static, str>,
name: Cow<'static, str>,
admin: bool,
iat: i32,
}
const fn get_strongly_typed_claims() -> MyClaims {
MyClaims {
sub: Cow::Borrowed("1234567890"),
name: Cow::Borrowed("John Doe"),
admin: true,
iat: 1516239022,
}
}
fn get_private_key_1() -> PrivateKey {
let pk_pem = picky_test_data::RSA_2048_PK_1.parse::<Pem>().unwrap();
PrivateKey::from_pkcs8(pk_pem.data()).unwrap()
}
#[test]
fn encode_jws_rsa_sha256() {
let claims = get_strongly_typed_claims();
let jwt = CheckedJwtSig::new(JwsAlg::RS256, claims);
let encoded = jwt.encode(&get_private_key_1()).unwrap();
assert_eq!(encoded, picky_test_data::JOSE_JWT_SIG_EXAMPLE);
}
#[test]
fn decode_jws_rsa_sha256() {
let public_key = get_private_key_1().to_public_key().unwrap();
let jwt = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key)
.unwrap()
.validate::<MyClaims>(&JwtValidator::no_check())
.unwrap();
assert_eq!(jwt.state.claims, get_strongly_typed_claims());
// exp and nbf claims aren't present but this should pass with lenient validator
let now = JwtDate::new(0);
JwtSig::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key)
.unwrap()
.validate::<MyClaims>(&JwtValidator::lenient(now))
.unwrap();
}
#[test]
fn decode_jws_invalid_validator_err() {
let public_key = get_private_key_1().to_public_key().unwrap();
let validator = JwtValidator::no_check()
.expiration_check_required()
.not_before_check_optional();
let err = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key)
.unwrap()
.validate::<MyClaims>(&validator)
.err()
.unwrap();
assert_eq!(err.to_string(), "invalid validator: current date is missing");
}
#[test]
fn decode_jws_required_claim_missing_err() {
let public_key = get_private_key_1().to_public_key().unwrap();
let now = JwtDate::new(0);
let validator = JwtValidator::strict(now);
let err = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key)
.unwrap()
.validate::<MyClaims>(&validator)
.err()
.unwrap();
assert_eq!(err.to_string(), "required claim `nbf` is missing");
}
#[test]
fn decode_jws_rsa_sha256_using_json_value_claims() {
let public_key = get_private_key_1().to_public_key().unwrap();
let validator = JwtValidator::no_check();
let jwt = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key)
.unwrap()
.validate::<serde_json::Value>(&validator)
.unwrap();
assert_eq!(jwt.state.claims["sub"].as_str().expect("sub"), "1234567890");
assert_eq!(jwt.state.claims["name"].as_str().expect("name"), "John Doe");
assert!(jwt.state.claims["admin"].as_bool().expect("sub"));
assert_eq!(jwt.state.claims["iat"].as_i64().expect("iat"), 1516239022);
}
#[test]
fn jwe_direct_aes_256_gcm() {
let claims = get_strongly_typed_claims();
let key = crate::hash::HashAlgorithm::SHA2_256.digest(b"magic_password");
let jwt = CheckedJwtEnc::new(JweAlg::Direct, JweEnc::Aes256Gcm, claims);
let encoded = jwt.encode_direct(&key).unwrap();
let decoded = JwtEnc::decode_direct(&encoded, &key)
.unwrap()
.validate::<MyClaims>(&NO_CHECK_VALIDATOR)
.unwrap();
assert_eq!(decoded.state.claims, get_strongly_typed_claims());
}
#[derive(Deserialize)]
struct MyExpirableClaims {
exp: i64,
nbf: i64,
msg: String,
}
#[test]
fn decode_jws_not_expired() {
let public_key = get_private_key_1().to_public_key().unwrap();
let jwt = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_WITH_EXP, &public_key)
.unwrap()
.validate::<MyExpirableClaims>(&JwtValidator::strict(JwtDate::new(1545263999)))
.expect("couldn't decode jwt without leeway");
assert_eq!(jwt.state.claims.exp, 1545264000);
assert_eq!(jwt.state.claims.nbf, 1545263000);
assert_eq!(jwt.state.claims.msg, "THIS IS TIME SENSITIVE DATA");
// alternatively, a leeway can account for small clock skew
JwtSig::decode(picky_test_data::JOSE_JWT_SIG_WITH_EXP, &public_key)
.unwrap()
.validate::<MyExpirableClaims>(&JwtValidator::strict(JwtDate::new_with_leeway(1545264001, 10)))
.expect("couldn't decode jwt with leeway for exp");
JwtSig::decode(picky_test_data::JOSE_JWT_SIG_WITH_EXP, &public_key)
.unwrap()
.validate::<MyExpirableClaims>(&JwtValidator::strict(JwtDate::new_with_leeway(1545262999, 10)))
.expect("couldn't decode jwt with leeway for nbf");
}
#[test]
fn decode_jws_invalid_date_err() {
let public_key = get_private_key_1().to_public_key().unwrap();
let err = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_WITH_EXP, &public_key)
.unwrap()
.validate::<MyExpirableClaims>(&JwtValidator::strict(JwtDate::new(1545264001)))
.err()
.unwrap();
assert_eq!(
err.to_string(),
"token expired (not after: 1545264000, now: 1545264001 [leeway: 0])"
);
let err = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_WITH_EXP, &public_key)
.unwrap()
.validate::<MyExpirableClaims>(&JwtValidator::strict(JwtDate::new_with_leeway(1545262998, 1)))
.err()
.unwrap();
assert_eq!(
err.to_string(),
"token not yet valid (not before: 1545263000, now: 1545262998 [leeway: 1])"
);
}
#[test]
fn decode_step_cli_generated_token() {
#[derive(Deserialize)]
struct SomeJetClaims {
jet_ap: String,
prx_usr: String,
nbf: i64,
}
let jws = RawJws::decode("eyJhbGciOiJSUzI1NiIsImtpZCI6InUzQkF1b3lrZ21FY0F2Z21ydm5PVWxNZUYxN2JjS09EbGYweFdHcDhMY2MiLCJ0eXAiOiJKV1QifQ.eyJpYXQiOjE2NTkxMTg1NjMsImpldF9hcCI6InJkcCIsImp0aSI6IjY1YjkwZmQwMjM2YWU3Mjg1OWE1YWZlZTM3MTEzOTdjOWU4NTI1YzA4YzIyNjE4N2NlNjJjOWQwNTEzNDUzOTUiLCJuYmYiOjE2NTkxMTg1NjMsInByeF91c3IiOiJ1c2VybmFtZSJ9.MzULmkNyVY48nOgN7zbtN9q8Ni8JRavpkbw34aD-lMfqJzl5pFEJQPV9G1iM1HCbcMPRJfMDjVP31dAHOVtsu-gqGRx9qw1ogpNffcJI0nh5-VPPnqBbT5u8H2rJ7WeXO5kx4KAnD2Fbc45Nb6YEM-f_s9RyFipub0LI5AwiUHcbicJno0Lxz0dFKMiSA4cTNOe22vY7STf-E52LnsdHhnTt3JKDPP-7i5FzL1wOdBHzvxhRpyLqNU1kcSXrV_1L07XekeR6Kp3JoWaaJsIWm1Sk27W13Q575gS0a9OJgGX0bumq9fCneOJgLU8HrelUP8-qRM2IaGV81NRAr5HasQ")
.map(RawJws::discard_signature)
.map(JwtSig::from)
.unwrap()
.validate::<SomeJetClaims>(&JwtValidator::no_check())
.unwrap();
assert_eq!(jws.state.claims.jet_ap, "rdp");
assert_eq!(jws.state.claims.prx_usr, "username");
assert_eq!(jws.state.claims.nbf, 1659118563);
}
}
+14
View File
@@ -0,0 +1,14 @@
//! JOSE framework subset implementation.
//!
//! A Json Web Token (JWT) comes in two flavors, roughly:
//! - Json Web Encryption (JWE), used to transfer data securely
//! - Json Web Signature (JWS), used to assert one's identity
//!
//! Common part is known as the "JOSE header".
//!
//! JSON Web Key (JWK) are used to represent cryptographic keys using JSON.
pub mod jwe;
pub mod jwk;
pub mod jws;
pub mod jwt;
+396
View File
@@ -0,0 +1,396 @@
use crate::key::{KeyError, PrivateKey, PrivateKeyKind, PublicKey};
use crate::oid::ObjectIdentifier;
use picky_asn1::wrapper::BitStringAsn1;
use picky_asn1_x509::{EcParameters, oids};
use std::fmt::Display;
use zeroize::Zeroize;
#[derive(Debug)]
pub(crate) struct EcdsaKeypair {
curve: NamedEcCurve,
private_key: Vec<u8>,
public_key: Option<Vec<u8>>,
}
impl EcdsaKeypair {
pub fn curve(&self) -> &NamedEcCurve {
&self.curve
}
pub fn secret(&self) -> &[u8] {
&self.private_key
}
}
impl Drop for EcdsaKeypair {
fn drop(&mut self) {
self.private_key.zeroize();
}
}
pub(crate) enum EcComponent<'a> {
PointX(&'a [u8]),
PointY(&'a [u8]),
Secret(&'a [u8]),
}
/// Elliptic curve name to use for curve operations which require curve-specific arithmetic.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EcCurve {
/// NIST P-256 curve (secp256r1)
NistP256,
/// NIST P-384 curve (secp384r1)
NistP384,
/// NIST P-521 curve (secp521r1)
NistP521,
}
impl EcCurve {
/// Get size of field compoennet in bytes (e.g. X and Y point values, Secret key,
/// R and S signature values)
pub(crate) fn field_bytes_size(self) -> usize {
match self {
EcCurve::NistP256 => {
use p256::elliptic_curve::FieldBytesSize;
use p256::elliptic_curve::array::typenum::Unsigned;
<FieldBytesSize<p256::NistP256> as Unsigned>::USIZE
}
EcCurve::NistP384 => {
use p384::elliptic_curve::FieldBytesSize;
use p384::elliptic_curve::array::typenum::Unsigned;
<FieldBytesSize<p384::NistP384> as Unsigned>::USIZE
}
EcCurve::NistP521 => {
use p521::elliptic_curve::FieldBytesSize;
use p521::elliptic_curve::array::typenum::Unsigned;
<FieldBytesSize<p521::NistP521> as Unsigned>::USIZE
}
}
}
/// We need to validate input data sizes to prevent panics in the underlying `generic_array`
/// library code.
pub(crate) fn validate_component<'a>(&self, component: EcComponent<'a>) -> Result<&'a [u8], KeyError> {
let (buffer, error_message) = match component {
EcComponent::PointX(buf) => (buf, "Invalid `point.x` component size"),
EcComponent::PointY(buf) => (buf, "Invalid `point.y` component size"),
EcComponent::Secret(buf) => (buf, "Invalid `secret` component size"),
};
if buffer.len() != self.field_bytes_size() {
return Err(KeyError::EC {
context: error_message.to_string(),
});
}
Ok(buffer)
}
}
/// Describes the curve type of an ECDSA keypair
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum NamedEcCurve {
Known(EcCurve),
Unsupported(ObjectIdentifier),
}
impl Display for EcCurve {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NistP256 => write!(f, "NIST-P256"),
Self::NistP384 => write!(f, "NIST-P384"),
Self::NistP521 => write!(f, "NIST-P521"),
}
}
}
impl Display for NamedEcCurve {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Known(curve) => curve.fmt(f),
Self::Unsupported(oid) => {
let oid: String = oid.into();
write!(f, "Unsupported(OID: {oid})")
}
}
}
}
impl From<&'_ ObjectIdentifier> for NamedEcCurve {
fn from(value: &ObjectIdentifier) -> Self {
let oid: String = value.into();
match oid.as_str() {
oids::SECP256R1 => NamedEcCurve::Known(EcCurve::NistP256),
oids::SECP384R1 => NamedEcCurve::Known(EcCurve::NistP384),
oids::SECP521R1 => NamedEcCurve::Known(EcCurve::NistP521),
_ => NamedEcCurve::Unsupported(value.clone()),
}
}
}
impl From<NamedEcCurve> for ObjectIdentifier {
fn from(value: NamedEcCurve) -> Self {
match value {
NamedEcCurve::Known(curve) => match curve {
EcCurve::NistP256 => oids::secp256r1(),
EcCurve::NistP384 => oids::secp384r1(),
EcCurve::NistP521 => oids::secp521r1(),
},
NamedEcCurve::Unsupported(oid) => oid,
}
}
}
impl<'a> TryFrom<&'a PrivateKey> for EcdsaKeypair {
type Error = KeyError;
fn try_from(v: &'a PrivateKey) -> Result<Self, Self::Error> {
match &v.kind {
PrivateKeyKind::Ec {
public_key,
private_key,
curve_oid,
..
} => Ok(Self {
curve: NamedEcCurve::from(curve_oid),
private_key: private_key.clone(),
public_key: public_key.clone(),
}),
_ => Err(KeyError::EC {
context: "EC keypair cannot be built from Non-EC private key".to_string(),
}),
}
}
}
pub(crate) fn calculate_public_ec_key(
curve_oid: &ObjectIdentifier,
private_key: &[u8],
compress: bool,
) -> Result<Option<Vec<u8>>, KeyError> {
let curve = NamedEcCurve::from(curve_oid);
match curve {
NamedEcCurve::Known(EcCurve::NistP256) => {
use p256::elliptic_curve::sec1::ToSec1Point as _;
let private_key_validated = EcCurve::NistP256.validate_component(EcComponent::Secret(private_key))?;
let secret_bytes =
p256::elliptic_curve::array::Array::try_from(private_key_validated).map_err(|_| KeyError::EC {
context: format!(
"validated private key is the not right length(expected: {}, actual: {})",
EcCurve::NistP256.field_bytes_size(),
private_key_validated.len()
),
})?;
let secret_key = p256::SecretKey::from_bytes(&secret_bytes).map_err(|_| KeyError::EC {
context: "Failed to construct P256 SecretKey from private key bytes".to_string(),
})?;
// Calculate public key from secret key
let public_key = secret_key.public_key().as_affine().to_sec1_point(compress);
Ok(Some(public_key.to_bytes().to_vec()))
}
NamedEcCurve::Known(EcCurve::NistP384) => {
use p384::elliptic_curve::sec1::ToSec1Point as _;
let private_key_validated = EcCurve::NistP384.validate_component(EcComponent::Secret(private_key))?;
let secret_bytes =
p384::elliptic_curve::array::Array::try_from(private_key_validated).map_err(|_| KeyError::EC {
context: format!(
"validated private key is the not right length(expected: {}, actual: {})",
EcCurve::NistP384.field_bytes_size(),
private_key_validated.len()
),
})?;
let secret_key = p384::SecretKey::from_bytes(&secret_bytes).map_err(|_| KeyError::EC {
context: "Failed to construct P384 SecretKey from private key bytes".to_string(),
})?;
// Calculate public key from secret key
let public_key = secret_key.public_key().as_affine().to_sec1_point(compress);
Ok(Some(public_key.to_bytes().to_vec()))
}
NamedEcCurve::Known(EcCurve::NistP521) => {
use p521::elliptic_curve::sec1::ToSec1Point as _;
let private_key_validated = EcCurve::NistP521.validate_component(EcComponent::Secret(private_key))?;
let secret_bytes =
p521::elliptic_curve::array::Array::try_from(private_key_validated).map_err(|_| KeyError::EC {
context: format!(
"validated private key is the not right length(expected: {}, actual: {})",
EcCurve::NistP521.field_bytes_size(),
private_key_validated.len()
),
})?;
let secret_key = p521::SecretKey::from_bytes(&secret_bytes).map_err(|_| KeyError::EC {
context: "Failed to construct P521 SecretKey from private key bytes".to_string(),
})?;
// Calculate public key from secret key
let public_key = secret_key.public_key().as_affine().to_sec1_point(compress);
Ok(Some(public_key.to_bytes().to_vec()))
}
NamedEcCurve::Unsupported(_) => Ok(None),
}
}
#[derive(Debug)]
pub(crate) struct EcdsaPublicKey<'a> {
data: &'a [u8],
curve: NamedEcCurve,
}
impl EcdsaPublicKey<'_> {
pub fn curve(&self) -> &NamedEcCurve {
&self.curve
}
pub fn encoded_point(&self) -> &[u8] {
self.data
}
}
impl<'a> TryFrom<&'a PublicKey> for EcdsaPublicKey<'a> {
type Error = KeyError;
fn try_from(v: &'a PublicKey) -> Result<Self, Self::Error> {
use picky_asn1_x509::PublicKey as InnerPublicKey;
let curve_oid = match &v.as_inner().algorithm.parameters() {
picky_asn1_x509::AlgorithmIdentifierParameters::Ec(EcParameters::NamedCurve(curve_oid)) => {
curve_oid.0.clone()
}
_ => {
return Err(KeyError::EC {
context: "EC public key cannot be constructed from non-EC public key".to_string(),
});
}
};
match &v.as_inner().subject_public_key {
InnerPublicKey::Rsa(_) => Err(KeyError::EC {
context: "EC public key cannot be constructed from RSA public key".to_string(),
}),
InnerPublicKey::Ec(BitStringAsn1(bitstring)) => {
let data = bitstring.payload_view();
Ok(EcdsaPublicKey {
data,
curve: NamedEcCurve::from(&curve_oid),
})
}
InnerPublicKey::Ed(_) => Err(KeyError::EC {
context: "EC public key cannot be constructed from ED25519 public key".to_string(),
}),
InnerPublicKey::Mldsa(_) => Err(KeyError::EC {
context: "EC public key cannot be constructed from MLDSA public key".to_string(),
}),
}
}
}
impl<'a> TryFrom<&'a EcdsaKeypair> for EcdsaPublicKey<'a> {
type Error = KeyError;
fn try_from(v: &'a EcdsaKeypair) -> Result<Self, Self::Error> {
match v.public_key.as_ref() {
Some(key) => Ok(Self {
data: key.as_slice(),
curve: v.curve.clone(),
}),
None => Err(KeyError::EC {
context: "EC public key cannot be constructed from EC private key without public key".to_string(),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::*;
const RSA_PUBLIC_KEY_PEM: &str = "-----BEGIN RSA PUBLIC KEY-----\n\
MIIBCgKCAQEA61BjmfXGEvWmegnBGSuS+rU9soUg2FnODva32D1AqhwdziwHINFa\n\
D1MVlcrYG6XRKfkcxnaXGfFDWHLEvNBSEVCgJjtHAGZIm5GL/KA86KDp/CwDFMSw\n\
luowcXwDwoyinmeOY9eKyh6aY72xJh7noLBBq1N0bWi1e2i+83txOCg4yV2oVXhB\n\
o8pYEJ8LT3el6Smxol3C1oFMVdwPgc0vTl25XucMcG/ALE/KNY6pqC2AQ6R2ERlV\n\
gPiUWOPatVkt7+Bs3h5Ramxh7XjBOXeulmCpGSynXNcpZ/06+vofGi/2MlpQZNhH\n\
Ao8eayMp6FcvNucIpUndo1X8dKMv3Y26ZQIDAQAB\n\
-----END RSA PUBLIC KEY-----";
#[rstest]
#[case(picky_test_data::EC_NIST256_DER_PK_1)]
#[case(picky_test_data::EC_NIST384_DER_PK_1)]
#[case(picky_test_data::EC_NIST521_DER_PK_1)]
#[case(picky_test_data::EC_NIST256_PK_1)] // PKCS8
fn private_key_from_ec_pem(#[case] key_pem: &str) {
PrivateKey::from_pem_str(key_pem).unwrap();
}
#[rstest]
#[case(picky_test_data::EC_NIST256_NOPUBLIC_DER_PK_1)]
#[case(picky_test_data::EC_NIST384_NOPUBLIC_DER_PK_1)]
#[case(picky_test_data::EC_NIST521_NOPUBLIC_DER_PK_1)]
fn ecdsa_private_key_without_public(#[case] key_pem: &str) {
// This should succeed for supported curves
let key = PrivateKey::from_pem_str(key_pem).unwrap();
key.to_public_key().unwrap().to_pem_str().unwrap();
}
#[rstest]
// Known curves
#[case(picky_test_data::EC_NIST256_PK_1_PUB)]
#[case(picky_test_data::EC_NIST384_PK_1_PUB)]
#[case(picky_test_data::EC_NIST521_PK_1_PUB)]
// Unsupported curve, should still work as long as pem contains the public key
// (in that case no arithmetic operations are performed on the key)
#[case(picky_test_data::EC_PUBLIC_KEY_SECP256K1_PEM)]
fn ecdsa_public_valid_key_conversions(#[case] key_pem: &str) {
let pk: &PublicKey = &PublicKey::from_pem_str(key_pem).unwrap();
let epk: Result<EcdsaPublicKey, KeyError> = pk.try_into();
assert!(epk.is_ok());
}
#[test]
fn ecdsa_public_invalid_key_conversions() {
// PEM public key conversion fails with an error
let pk: &PublicKey = &PublicKey::from_pem_str(RSA_PUBLIC_KEY_PEM).unwrap();
let epk: Result<EcdsaPublicKey, KeyError> = pk.try_into();
assert!(epk.is_err());
assert!(matches!(epk, Err(KeyError::EC { context: _ })));
// TODO: add check for attempted conversion from ED keys - which are not supported yet
}
#[rstest]
#[case(picky_test_data::EC_NIST256_DER_PK_1, NamedEcCurve::Known(EcCurve::NistP256))]
#[case(picky_test_data::EC_NIST384_DER_PK_1, NamedEcCurve::Known(EcCurve::NistP384))]
#[case(picky_test_data::EC_NIST521_DER_PK_1, NamedEcCurve::Known(EcCurve::NistP521))]
fn ecdsa_key_pair_from_ec_private_key(#[case] key: &str, #[case] curve: NamedEcCurve) {
let pk = PrivateKey::from_pem_str(key).unwrap();
let pair = EcdsaKeypair::try_from(&pk).unwrap();
assert_eq!(curve, pair.curve);
}
#[test]
fn ring_ecdsa_pkcs8_keys_could_be_parsed() {
let algo = &ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING;
let rng = ring::rand::SystemRandom::new();
let pkcs8_bytes = ring::signature::EcdsaKeyPair::generate_pkcs8(algo, &rng).unwrap();
// Validate that missing `parameters` field from ECPriavteKeyInfo is handled correctly.
// rings skips it during pkcs8 serialization
let key = PrivateKey::from_pkcs8(&pkcs8_bytes).unwrap();
let pair = EcdsaKeypair::try_from(&key).unwrap();
assert_eq!(pair.curve(), &NamedEcCurve::Known(EcCurve::NistP256))
}
}
+240
View File
@@ -0,0 +1,240 @@
use crate::key::{KeyError, PrivateKey, PrivateKeyKind, PublicKey};
use crate::oid::ObjectIdentifier;
use picky_asn1::wrapper::BitStringAsn1;
use picky_asn1_x509::oids;
use std::fmt::Display;
use zeroize::Zeroize;
pub(crate) const X25519_FIELD_ELEMENT_SIZE: usize = 32;
pub(crate) type X25519FieldElement = [u8; X25519_FIELD_ELEMENT_SIZE];
/// Name of supported Curve25519 and Curve448 based algorithms.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EdAlgorithm {
/// Curve25519-based EdDSA algorithm
Ed25519,
/// Curve25519-based ECDH algorithm (mainly used for jwe key agreement)
X25519,
// (Unsupported) Ed448 -- Curve448-based EdDSA algorithm
// (Unsupported) X448 -- Curve448-based ECDH algorithm (mainly used for jwe key agreement)
}
// Describes Edwards curve-based EC algorithm
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum NamedEdAlgorithm {
Known(EdAlgorithm),
Unsupported(ObjectIdentifier),
}
impl Display for NamedEdAlgorithm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NamedEdAlgorithm::Known(alg) => write!(f, "{alg}"),
NamedEdAlgorithm::Unsupported(oid) => {
// We don't support Ed448 and X448 algorithms, but we can still print their named
// representation of OID to make errrs more readable.
if oid == &oids::ed448() {
write!(f, "Ed448")
} else if oid == &oids::x448() {
write!(f, "X448")
} else {
let oid: String = oid.into();
write!(f, "Unsupported(OID: {oid})")
}
}
}
}
}
impl Display for EdAlgorithm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Ed25519 => write!(f, "Ed25519"),
Self::X25519 => write!(f, "X25519"),
}
}
}
impl From<&'_ ObjectIdentifier> for NamedEdAlgorithm {
fn from(value: &'_ ObjectIdentifier) -> Self {
let oid: String = value.into();
match oid.as_str() {
oids::ED25519 => NamedEdAlgorithm::Known(EdAlgorithm::Ed25519),
oids::X25519 => NamedEdAlgorithm::Known(EdAlgorithm::X25519),
_ => NamedEdAlgorithm::Unsupported(value.clone()),
}
}
}
impl From<EdAlgorithm> for ObjectIdentifier {
fn from(value: EdAlgorithm) -> Self {
match value {
EdAlgorithm::Ed25519 => oids::ed25519(),
EdAlgorithm::X25519 => oids::x25519(),
}
}
}
impl From<NamedEdAlgorithm> for ObjectIdentifier {
fn from(value: NamedEdAlgorithm) -> Self {
match value {
NamedEdAlgorithm::Known(alg) => alg.into(),
NamedEdAlgorithm::Unsupported(oid) => oid,
}
}
}
#[derive(Debug)]
pub(crate) struct EdKeypair {
algorithm: NamedEdAlgorithm,
private_key: Vec<u8>,
public_key: Option<Vec<u8>>,
}
impl EdKeypair {
pub fn algorithm(&self) -> &NamedEdAlgorithm {
&self.algorithm
}
pub fn secret(&self) -> &[u8] {
&self.private_key
}
}
impl Drop for EdKeypair {
fn drop(&mut self) {
self.private_key.zeroize();
}
}
impl<'a> TryFrom<&'a PrivateKey> for EdKeypair {
type Error = KeyError;
fn try_from(value: &'a PrivateKey) -> Result<Self, Self::Error> {
match &value.kind {
PrivateKeyKind::Ed {
public_key,
private_key,
algorithm_oid,
} => Ok(Self {
algorithm: NamedEdAlgorithm::from(algorithm_oid),
private_key: private_key.clone(),
public_key: public_key.clone(),
}),
_ => Err(KeyError::ED {
context: "Ed keypair cannot be constructed from non-Ed private key".to_string(),
}),
}
}
}
#[derive(Debug)]
pub(crate) struct EdPublicKey<'a> {
data: &'a [u8],
algorithm: NamedEdAlgorithm,
}
impl EdPublicKey<'_> {
pub fn algorithm(&self) -> &NamedEdAlgorithm {
&self.algorithm
}
pub fn data(&self) -> &[u8] {
self.data
}
}
impl<'a> TryFrom<&'a EdKeypair> for EdPublicKey<'a> {
type Error = KeyError;
fn try_from(v: &'a EdKeypair) -> Result<Self, Self::Error> {
match v.public_key.as_ref() {
Some(key) => Ok(Self {
data: key.as_slice(),
algorithm: v.algorithm.clone(),
}),
None => Err(KeyError::ED {
context: "Ed public key cannot be constructed from Ed private key without public key".to_string(),
}),
}
}
}
impl<'a> TryFrom<&'a PublicKey> for EdPublicKey<'a> {
type Error = KeyError;
fn try_from(v: &'a PublicKey) -> Result<Self, Self::Error> {
use picky_asn1_x509::PublicKey as InnerPublicKey;
let oid = v.as_inner().algorithm.oid();
match &v.as_inner().subject_public_key {
InnerPublicKey::Rsa(_) => Err(KeyError::ED {
context: "Ed public key cannot be constructed from RSA public key".to_string(),
}),
InnerPublicKey::Ec(_) => Err(KeyError::ED {
context: "Ed public key cannot be constructed from Ec public key".to_string(),
}),
InnerPublicKey::Ed(BitStringAsn1(bitstring)) => {
let data = bitstring.payload_view();
Ok(EdPublicKey {
data,
algorithm: NamedEdAlgorithm::from(oid),
})
}
InnerPublicKey::Mldsa(_) => Err(KeyError::ED {
context: "Ed public key cannot be constructed from Mldsa public key".to_string(),
}),
}
}
}
#[cfg(test)]
mod tests {
use crate::key::{PrivateKey, PublicKey};
use rstest::rstest;
#[rstest]
#[case(picky_test_data::ED25519_PEM_PK_1)]
#[case(picky_test_data::X25519_PEM_PK_1)]
// Although X448 and ED448 are not supported, we should still be able to decode and encode them
#[case(picky_test_data::ED448_PEM_PK_1)]
#[case(picky_test_data::X448_PEM_PK_1)]
fn private_key_roundtrip(#[case] key_pem: &str) {
let decoded = PrivateKey::from_pem_str(key_pem).unwrap();
let encoded = decoded.to_pem_str().unwrap();
assert_eq!(encoded.as_str(), key_pem);
}
#[rstest]
#[case(picky_test_data::ED25519_PEM_PK_1_PUB)]
#[case(picky_test_data::X25519_PEM_PK_1_PUB)]
// Although X448 and ED448 are not supported, we should still be able to decode and encode them
#[case(picky_test_data::ED448_PEM_PK_1_PUB)]
#[case(picky_test_data::X448_PEM_PK_1_PUB)]
fn public_key_roundtrip(#[case] key_pem: &str) {
let decoded = PublicKey::from_pem_str(key_pem).unwrap();
let encoded = decoded.to_pem_str().unwrap();
assert_eq!(encoded.as_str(), key_pem);
}
#[rstest]
#[case(picky_test_data::ED25519_PEM_PK_1, picky_test_data::ED25519_PEM_PK_1_PUB)]
#[case(picky_test_data::X25519_PEM_PK_1, picky_test_data::X25519_PEM_PK_1_PUB)]
fn extract_public_key(#[case] key_pem: &str, #[case] expected_public_pem: &str) {
let private = PrivateKey::from_pem_str(key_pem).unwrap();
let public = private.to_public_key().unwrap();
let public_expected = PublicKey::from_pem_str(expected_public_pem).unwrap();
assert_eq!(public, public_expected);
}
#[rstest]
#[case(picky_test_data::ED448_PEM_PK_1)]
#[case(picky_test_data::X448_PEM_PK_1)]
fn extract_public_key_for_unsupported_algorithm_fails(#[case] key_pem: &str) {
let private = PrivateKey::from_pem_str(key_pem).unwrap();
assert!(private.to_public_key().is_err());
}
}
+1299
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
//! [![Crates.io](https://img.shields.io/crates/v/picky.svg)](https://crates.io/crates/picky)
//! [![docs.rs](https://docs.rs/picky/badge.svg)](https://docs.rs/picky)
//! ![Crates.io](https://img.shields.io/crates/l/picky)
//! # picky
//!
//! Portable X.509, PKI, JOSE and HTTP signature implementation.
#[cfg(feature = "http_signature")]
pub mod http;
#[cfg(feature = "jose")]
pub mod jose;
#[cfg(feature = "x509")]
pub mod x509;
#[cfg(feature = "ssh")]
pub mod ssh;
#[cfg(feature = "pkcs12")]
pub mod pkcs12;
#[cfg(feature = "putty")]
pub mod putty;
pub mod hash;
pub mod key;
pub mod pem;
pub mod signature;
pub use picky_asn1_x509::{AlgorithmIdentifier, oid, oids};
+292
View File
@@ -0,0 +1,292 @@
//! Privacy-Enhanced Mail (PEM) format utilities
//!
//! Based on the RFC-7468
//! ([Textual Encodings of PKIX, PKCS, and CMS Structures](https://tools.ietf.org/html/rfc7468)).
use base64::engine::general_purpose;
use base64::{DecodeError, Engine as _};
use std::borrow::Cow;
use std::fmt;
use std::io::BufRead;
use std::str::FromStr;
use thiserror::Error;
const PEM_HEADER_START: &str = "-----BEGIN";
const PEM_FOOTER_START: &str = "-----END";
const PEM_DASHES_BOUNDARIES: &str = "-----";
#[derive(Debug, Clone, Error)]
pub enum PemError {
/// header not found
#[error("header not found")]
HeaderNotFound,
/// invalid pem header
#[error("invalid pem header")]
InvalidHeader,
/// footer not found
#[error("footer not found")]
FooterNotFound,
/// couldn't decode base64
#[error("couldn't decode base64: {source}")]
Base64Decoding { source: DecodeError },
}
/// Privacy-Enhanced Mail (PEM) format structured representation
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pem<'a> {
label: String,
data: Cow<'a, [u8]>,
}
impl<'a> Pem<'a> {
pub fn new<S: Into<String>, D: Into<Cow<'a, [u8]>>>(label: S, data: D) -> Self {
Self {
label: label.into(),
data: data.into(),
}
}
pub fn label(&self) -> &str {
&self.label
}
pub fn data(&self) -> &[u8] {
&self.data
}
pub fn into_data(self) -> Cow<'a, [u8]> {
self.data
}
}
impl Pem<'static> {
pub fn read_from(reader: &mut impl BufRead) -> Result<Self, PemError> {
read_pem(reader)
}
}
impl FromStr for Pem<'static> {
type Err = PemError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_pem(s.as_bytes())
}
}
impl fmt::Display for Pem<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{} {}-----", PEM_HEADER_START, self.label)?;
let encoded = general_purpose::STANDARD.encode(&self.data);
let bytes = encoded.as_bytes();
for chunk in bytes.chunks(64) {
let chunk = std::str::from_utf8(chunk).map_err(|_| fmt::Error)?;
writeln!(f, "{chunk}")?;
}
write!(f, "{} {}-----", PEM_FOOTER_START, self.label)?;
Ok(())
}
}
impl From<Pem<'_>> for String {
fn from(pem: Pem<'_>) -> Self {
pem.to_string()
}
}
/// Parse a PEM-encoded stream from a [u8] representation
///
/// If the input contains line ending characters (`\r`, `\n`), a copy of input
/// is allocated striping these. If you can strip these with minimal data copy
/// you should do it beforehand.
pub fn parse_pem<T: ?Sized + AsRef<[u8]>>(input: &T) -> Result<Pem<'static>, PemError> {
parse_pem_impl(input.as_ref())
}
fn parse_pem_impl(input: &[u8]) -> Result<Pem<'static>, PemError> {
let header_start_idx = h_find(input, PEM_HEADER_START.as_bytes()).ok_or(PemError::HeaderNotFound)?;
let label_start_idx = header_start_idx + PEM_HEADER_START.len();
let label_end_idx = h_find(&input[label_start_idx..], b"-").ok_or(PemError::InvalidHeader)? + label_start_idx;
let label = String::from_utf8_lossy(&input[label_start_idx..label_end_idx])
.trim()
.to_owned();
let header_end_idx = h_find(&input[label_end_idx..], PEM_DASHES_BOUNDARIES.as_bytes())
.ok_or(PemError::InvalidHeader)?
+ label_end_idx
+ PEM_DASHES_BOUNDARIES.len();
let footer_start_idx =
h_find(&input[header_end_idx..], PEM_FOOTER_START.as_bytes()).ok_or(PemError::FooterNotFound)? + header_end_idx;
let raw_data = &input[header_end_idx..footer_start_idx];
let data = if h_find(raw_data, b"\n").is_some() {
// Line ending characters should be striped... Sadly, this means we need to copy and allocate.
let striped_raw_data: Vec<u8> = raw_data
.iter()
.copied()
.filter(|&byte| byte != b'\r' && byte != b'\n')
.collect();
general_purpose::STANDARD
.decode(striped_raw_data)
.map_err(|source| PemError::Base64Decoding { source })?
} else {
// Can be decoded as is!
general_purpose::STANDARD
.decode(raw_data)
.map_err(|source| PemError::Base64Decoding { source })?
};
Ok(Pem {
label,
data: Cow::Owned(data),
})
}
fn h_find(buffer: &[u8], value: &[u8]) -> Option<usize> {
buffer.windows(value.len()).position(|window| window == value)
}
/// Parse a PEM-encoded stream from a BufRead object.
///
/// Maybe slower than the AsRef<[u8]>-based implementation because additional copies are incurred,
/// but in most cases it's probably easier to work with and not that bad anyway.
pub fn read_pem(reader: &mut impl BufRead) -> Result<Pem<'static>, PemError> {
let mut buf = Vec::with_capacity(1024);
// skip until start of header
h_read_until(reader, PEM_HEADER_START.as_bytes(), &mut buf).ok_or(PemError::HeaderNotFound)?;
buf.clear();
// read until end of header
h_read_until(reader, PEM_DASHES_BOUNDARIES.as_bytes(), &mut buf).ok_or(PemError::InvalidHeader)?;
let buf_utf8 = core::str::from_utf8(&buf).map_err(|_| PemError::InvalidHeader)?;
let label = buf_utf8.trim_end_matches(PEM_DASHES_BOUNDARIES).trim().to_owned();
buf.clear();
// read to footer
h_read_until(reader, PEM_FOOTER_START.as_bytes(), &mut buf).ok_or(PemError::FooterNotFound)?;
let base64_data: Vec<u8> = h_trim_end_matches(&buf, PEM_FOOTER_START.as_bytes())
.iter()
.cloned()
.filter(|&byte| byte != b'\r' && byte != b'\n')
.collect();
let data = general_purpose::STANDARD
.decode(base64_data)
.map_err(|source| PemError::Base64Decoding { source })?;
// read until end of footer
h_read_until(reader, PEM_DASHES_BOUNDARIES.as_bytes(), &mut buf).ok_or(PemError::FooterNotFound)?;
Ok(Pem {
label,
data: Cow::Owned(data),
})
}
// Helper to read until some pattern is matched. Returns None on any error
// (cannot be copy pasted for any purpose and should stay private!).
fn h_read_until(reader: &mut impl BufRead, pat: &[u8], buf: &mut Vec<u8>) -> Option<usize> {
let mut read = 0;
let first_delim = *pat.first()?;
'outer: loop {
read += reader.read_until(first_delim, buf).ok()?;
for &next_delim in &pat[1..] {
let mut next = [0];
reader.read_exact(&mut next).ok()?;
buf.push(next[0]);
read += 1;
if next[0] != next_delim {
continue 'outer;
}
}
break Some(read);
}
}
// Helper to trim trailing characters matching the given pattern for bytes slice
fn h_trim_end_matches<'a>(slice: &'a [u8], pat: &[u8]) -> &'a [u8] {
for (&slice_elem, &pat_elem) in slice.iter().rev().zip(pat.iter().rev()) {
if slice_elem != pat_elem {
return slice; // pattern doesn't match, return all the slice
}
}
// pattern did match, return sub-slice
&slice[..slice.len() - pat.len()]
}
/// Build a PEM-encoded structure into a String.
pub fn to_pem<S, T>(label: S, data: &T) -> String
where
S: Into<String>,
T: ?Sized + AsRef<[u8]>,
{
Pem::new(label, data.as_ref()).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::BufReader;
#[test]
fn parse() {
let pem_from_bytes = parse_pem(picky_test_data::PEM_BYTES).unwrap();
assert_eq!(pem_from_bytes.label, "CERTIFICATE");
let pem_from_str = picky_test_data::PEM_STR.parse::<Pem>().unwrap();
pretty_assertions::assert_eq!(pem_from_bytes, pem_from_str);
}
#[test]
fn reader_based() {
let mut reader = BufReader::new(picky_test_data::PEM_BYTES);
let pem_from_reader = read_pem(&mut reader).unwrap();
assert_eq!(pem_from_reader.label, "CERTIFICATE");
let pem_from_str = picky_test_data::PEM_STR.parse::<Pem>().unwrap();
pretty_assertions::assert_eq!(pem_from_reader, pem_from_str);
}
// This test should not run on Windows. writeln! add `/r` ending character to Pem in String format on Windows targets.
#[cfg(not(windows))]
#[test]
fn to_string() {
let pem = picky_test_data::PEM_STR.parse::<Pem>().unwrap();
let reconverted_pem = pem.to_string();
pretty_assertions::assert_eq!(reconverted_pem, picky_test_data::PEM_STR);
}
const FLATTENED_PEM: &str = "-----BEGIN GARBAGE-----R0FSQkFHRQo=-----END GARBAGE-----";
#[test]
fn flattened() {
FLATTENED_PEM.parse::<Pem>().unwrap();
read_pem(&mut BufReader::new(FLATTENED_PEM.as_bytes())).unwrap();
}
const MULTIPLE_PEM: &str = "-----BEGIN GARBAGE1-----R0FSQkFHRQo=-----END GARBAGE1-----\
-----BEGIN GARBAGE2-----R0FSQkFHRQo=-----END GARBAGE2-----";
#[test]
fn multiple() {
// reading multiple PEM from some bytes stream is easier with read-based API
let mut reader = BufReader::new(MULTIPLE_PEM.as_bytes());
let pem1 = read_pem(&mut reader).unwrap();
assert_eq!(pem1.label, "GARBAGE1");
let pem2 = read_pem(&mut reader).unwrap();
assert_eq!(pem2.label, "GARBAGE2");
}
}
+249
View File
@@ -0,0 +1,249 @@
use crate::pkcs12::Pkcs12Error;
use picky_asn1::restricted_string::BmpString;
use picky_asn1::wrapper::OctetStringAsn1;
use picky_asn1_der::Asn1RawDer;
use picky_asn1_x509::oid::ObjectIdentifier;
use picky_asn1_x509::pkcs12::Pkcs12Attribute as Pkcs12AttributeAsn1;
use serde::{Deserialize, Serialize};
/// Represents a PKCS#12 attributes which can be used to store additional information about safe
/// bag contents (e.g. private key or certificate).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pkcs12Attribute {
kind: Pkcs12AttributeKind,
inner: Pkcs12AttributeAsn1,
}
impl Pkcs12Attribute {
pub(crate) fn from_asn1(asn1: Pkcs12AttributeAsn1) -> Self {
let kind = match &asn1 {
Pkcs12AttributeAsn1::FriendlyName(value) => Pkcs12AttributeKind::FriendlyName(value.clone()),
Pkcs12AttributeAsn1::LocalKeyId(value) => Pkcs12AttributeKind::LocalKeyId(value.0.clone()),
Pkcs12AttributeAsn1::Unknown { oid, value } => Pkcs12AttributeKind::Custom(CustomPkcs12Attribute {
oid: oid.clone(),
value: value.clone(),
}),
};
Self { kind, inner: asn1 }
}
/// Creates a new `friendly name` attribute. This attribute is used to store a human-readable
/// name of the safe bag contents (e.g. certificate name).
pub fn new_friendly_name(value: BmpString) -> Self {
let kind = Pkcs12AttributeKind::FriendlyName(value);
let inner = kind.to_inner();
Self { kind, inner }
}
/// Creates a new `local key id` attribute. This attribute is used to indicate relation between
/// private key and certificate (when set to same value on both objects).
pub fn new_local_key_id(value: impl Into<Vec<u8>>) -> Self {
let kind = Pkcs12AttributeKind::LocalKeyId(value.into());
let inner = kind.to_inner();
Self { kind, inner }
}
/// Create a new custom attribute (e.g. Microsoft-specific attributes).
pub fn new_custom(attr: CustomPkcs12Attribute) -> Self {
let kind = Pkcs12AttributeKind::Custom(attr);
let inner = kind.to_inner();
Self { kind, inner }
}
pub fn kind(&self) -> &Pkcs12AttributeKind {
&self.kind
}
pub fn inner(&self) -> &Pkcs12AttributeAsn1 {
&self.inner
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pkcs12AttributeKind {
FriendlyName(BmpString),
LocalKeyId(Vec<u8>),
Custom(CustomPkcs12Attribute),
}
impl Pkcs12AttributeKind {
pub(crate) fn to_inner(&self) -> Pkcs12AttributeAsn1 {
match self {
Self::FriendlyName(value) => Pkcs12AttributeAsn1::FriendlyName(value.clone()),
Self::LocalKeyId(value) => Pkcs12AttributeAsn1::LocalKeyId(OctetStringAsn1::from(value.clone())),
Self::Custom(value) => Pkcs12AttributeAsn1::Unknown {
oid: value.oid.clone(),
value: value.value.clone(),
},
}
}
}
impl From<Pkcs12AttributeAsn1> for Pkcs12AttributeKind {
fn from(value: Pkcs12AttributeAsn1) -> Self {
match value {
Pkcs12AttributeAsn1::FriendlyName(value) => Self::FriendlyName(value),
Pkcs12AttributeAsn1::LocalKeyId(value) => Self::LocalKeyId(value.0),
Pkcs12AttributeAsn1::Unknown { oid, value } => CustomPkcs12Attribute { oid, value }.into(),
}
}
}
impl From<CustomPkcs12Attribute> for Pkcs12AttributeKind {
fn from(value: CustomPkcs12Attribute) -> Self {
Self::Custom(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomPkcs12Attribute {
oid: ObjectIdentifier,
value: Vec<Asn1RawDer>,
}
impl CustomPkcs12Attribute {
/// Get attribute oid.
pub fn oid(&self) -> &ObjectIdentifier {
&self.oid
}
pub fn new_empty(oid: ObjectIdentifier) -> Self {
Self { oid, value: Vec::new() }
}
pub fn has_value(&self) -> bool {
!self.value.is_empty()
}
pub fn new_raw(oid: ObjectIdentifier, value: Vec<Asn1RawDer>) -> Self {
Self { oid, value }
}
/// Creates a new custom attribute from any DER-serializable value. Ut is advised to use types
/// from `picky-asn1-der` crate.
pub fn new_single_value<T: Serialize>(oid: ObjectIdentifier, value: &T) -> Result<Self, Pkcs12Error> {
let encoded = picky_asn1_der::to_vec(value)?;
Ok(Self {
oid,
value: vec![Asn1RawDer(encoded)],
})
}
/// Creates a new custom attribute from multiple any DER-serializable value (PKCS#12 allows
/// attribute to have list of values). It is advised to use types from `picky-asn1-der` crate.
pub fn new_multiple_values<'a, T: Serialize + 'a>(
oid: ObjectIdentifier,
values: impl IntoIterator<Item = &'a T>,
) -> Result<Self, Pkcs12Error> {
let mut encoded_values = Vec::new();
for value in values {
let encoded = picky_asn1_der::to_vec(value)?;
encoded_values.push(Asn1RawDer(encoded));
}
Ok(Self {
oid,
value: encoded_values,
})
}
/// Convert inner value to any DER-deserializable value. It is advised to use types from
/// `picky-asn1-der` crate.
pub fn to_single_value<'a, T: Deserialize<'a>>(&'a self) -> Result<T, Pkcs12Error> {
if self.value.len() != 1 {
return Err(Pkcs12Error::UnexpectedAttributeValuesCount {
expected: 1,
actual: self.value.len(),
});
}
let deserialized = picky_asn1_der::from_bytes(&self.value[0].0)?;
Ok(deserialized)
}
/// Convert inner value to multiple DER-deserializable values. It is advised to use types from
/// `picky-asn1-der` crate.
pub fn to_multiple_values<'a, T: Deserialize<'a>>(&'a self) -> Result<Vec<T>, Pkcs12Error> {
let values_result: Result<Vec<_>, _> = self
.value
.iter()
.map(|value| picky_asn1_der::from_bytes(&value.0))
.collect();
values_result.map_err(Into::into)
}
pub fn raw_data(&self) -> &[Asn1RawDer] {
&self.value
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
use expect_test::expect;
fn fake_oid() -> ObjectIdentifier {
"1.3.6.1.4.1.311.17.1".to_string().try_into().unwrap()
}
#[test]
fn single_custom_attribute_roundtrip() {
let value = BmpString::from_str("Microsoft Software Key Storage Provider").unwrap();
let attr = CustomPkcs12Attribute::new_single_value(fake_oid(), &value).unwrap();
let decoded = attr.to_single_value::<BmpString>().unwrap();
assert_eq!(decoded, value);
}
#[test]
fn single_custom_attribute_roundtrip_no_value() {
let attr = CustomPkcs12Attribute::new_empty(fake_oid());
assert!(!attr.has_value());
let decoded = attr.to_single_value::<BmpString>();
expect![[r#"
Err(
UnexpectedAttributeValuesCount {
expected: 1,
actual: 0,
},
)
"#]]
.assert_debug_eq(&decoded);
}
#[test]
fn single_custom_attribute_roundtrip_too_many_values() {
let value = vec![OctetStringAsn1(vec![0x01]), OctetStringAsn1(vec![0x02])];
let attr = CustomPkcs12Attribute::new_multiple_values(fake_oid(), &value).unwrap();
let decoded = attr.to_single_value::<BmpString>();
expect![[r#"
Err(
UnexpectedAttributeValuesCount {
expected: 1,
actual: 2,
},
)
"#]]
.assert_debug_eq(&decoded);
}
#[test]
fn multiple_custom_attributes_roundtrip() {
let value = vec![
OctetStringAsn1(vec![0x01, 0x02, 0x03]),
OctetStringAsn1(vec![0x04, 0x05, 0x06]),
];
let attr = CustomPkcs12Attribute::new_multiple_values(fake_oid(), &value).unwrap();
let decoded = attr.to_multiple_values::<OctetStringAsn1>().unwrap();
assert_eq!(decoded, value);
}
#[test]
fn multiple_custom_attributes_empty_list_allowed() {
let value: Vec<OctetStringAsn1> = vec![];
let attr = CustomPkcs12Attribute::new_multiple_values(fake_oid(), &value).unwrap();
let decoded = attr.to_multiple_values::<OctetStringAsn1>().unwrap();
assert_eq!(decoded, value);
}
}
+674
View File
@@ -0,0 +1,674 @@
use crate::pkcs12::{Pbkdf1Usage, Pkcs12Error, Pkcs12HashAlgorithm, pbkdf1};
use picky_asn1::restricted_string::BmpString;
use picky_asn1::wrapper::OctetStringAsn1;
pub use picky_asn1_x509::pkcs12::Pbes1AlgorithmKind as Pbes1Cipher;
use picky_asn1_x509::pkcs12::{
Pbes1Params as Pbes1ParamsAsn1, Pbes2AesCbcEncryption as Pbes2AesCbcEncryptionAsn1,
Pbes2EncryptionScheme as Pbes2EncryptionSchemeAsn1, Pbes2KeyDerivationFunc as Pbes2KeyDerivationFuncAsn1,
Pbes2Params as Pbes2ParamsAsn1, Pbkdf2Params as Pbkdf2ParamsAsn1, Pbkdf2Prf as Pbkdf2PrfAsn1,
Pbkdf2SaltSource as Pbkdf2SaltSourceAsn1, Pkcs12EncryptionAlgorithm as Pkcs12EncryptionAsn1,
};
use rand::rngs::{StdRng, SysRng};
use rand_core::SeedableRng as _;
use std::str::FromStr as _;
/// Same default KDF iterations as in OpenSSL
const DEFAULT_KDF_ITERATIONS: usize = 2048;
const DEFAULT_SALT_SIZE: usize = 8;
const AES_BLOCK_SIZE: usize = 16;
/// Crypto operations context for PFX file parsing/building. Contains password inside as a secure
/// string and RNG.
pub struct Pkcs12CryptoContext {
password: zeroize::Zeroizing<String>,
rng: Box<dyn rand_core::Rng>,
}
impl Pkcs12CryptoContext {
/// Creates new context with given password and default
pub fn new_with_password(password: &str) -> Result<Self, Pkcs12Error> {
Ok(Self {
password: password.to_string().into(),
rng: Box::new(StdRng::try_from_rng(&mut SysRng)?),
})
}
/// Sets RNG for this context
pub fn with_rng(mut self, rng: impl rand::CryptoRng + 'static) -> Self {
self.rng = Box::new(rng);
self
}
/// Creates new context with empty password and default RNG
pub fn new_without_password() -> Result<Self, Pkcs12Error> {
Ok(Self {
password: String::new().into(),
rng: Box::new(StdRng::try_from_rng(&mut SysRng)?),
})
}
/// Returns password in PBES1 password representation - UCS2 encoded string with null terminator
pub(crate) fn password_bytes_pbes1(&self) -> Result<zeroize::Zeroizing<Vec<u8>>, Pkcs12Error> {
let mut bmp = zeroize::Zeroizing::new(BmpString::from_str(&self.password)?.into_bytes());
bmp.extend_from_slice(&[0, 0]);
Ok(bmp)
}
/// Returns password in PBES2 password representation - UTF8 encoded string
pub(crate) fn password_bytes_pbes2(&self) -> &[u8] {
self.password.as_bytes()
}
pub(crate) fn generate_bytes(&mut self, len: usize) -> Vec<u8> {
let mut data = vec![0u8; len];
self.rng.fill_bytes(&mut data);
data
}
}
/// This type holds all information required to encrypt/decrypt data in PKCS#12 file, including
/// encryption algorithm, salt, IV and KDF iterations.
///
/// This type is not cloneable, because it is intended to be used only once for each encryptable
/// object. If for some reason you need to have exactly the same salt/IV values for multiple
/// encryptable objects, you should create crypto context with custom RNG set to same seed and
/// create multiple Pkcs12Encryption objects from it.
#[derive(Debug, PartialEq, Eq)]
pub struct Pkcs12Encryption {
kind: Pkcs12EncryptionKind,
inner: Pkcs12EncryptionAsn1,
}
impl Pkcs12Encryption {
/// Clone-like operation only could be performed internally when cloning higher level structures.
/// Pkcs12Encryption non-cloneable nature is intentional to provide hint to the user that this
/// structure should be used only once for each encryptable object.
pub(crate) fn duplicate(&self) -> Self {
Self {
kind: self.kind.clone(),
inner: self.inner.clone(),
}
}
pub(crate) fn from_asn1(inner: Pkcs12EncryptionAsn1) -> Result<Self, Pkcs12Error> {
let kind = match &inner {
Pkcs12EncryptionAsn1::Pbes1 { kind, params } => Pkcs12EncryptionKind::Pbes1(Pbes1Encryption {
cipher: *kind,
kdf_iterations: Some(params.iterations),
}),
Pkcs12EncryptionAsn1::Pbes2(Pbes2ParamsAsn1 {
key_derivation_func:
Pbes2KeyDerivationFuncAsn1::Pbkdf2(Pbkdf2ParamsAsn1 {
iteration_count, prf, ..
}),
encryption_scheme: Pbes2EncryptionSchemeAsn1::AesCbc { kind, .. },
}) => {
let hmac_kdf = match &prf {
Some(algorithm) => Pkcs12HashAlgorithm::from_asn1_pbkdf2_prf(algorithm)?,
None => Pkcs12HashAlgorithm::Sha1,
};
Pkcs12EncryptionKind::Pbes2(Pbes2Encryption {
cipher: (*kind).into(),
hmac_kdf,
kdf_iterations: Some(*iteration_count),
})
}
_ => Pkcs12EncryptionKind::Unknown,
};
Ok(Self { kind, inner })
}
/// Create new legacy PBES1 encryption (Not recommended for new files)
pub fn new_pbes1(encryption: Pbes1Encryption, context: &mut Pkcs12CryptoContext) -> Self {
let salt = context.generate_bytes(DEFAULT_SALT_SIZE);
let inner = Pkcs12EncryptionAsn1::Pbes1 {
kind: encryption.cipher,
params: Pbes1ParamsAsn1 {
salt: OctetStringAsn1(salt),
iterations: encryption.kdf_iterations.unwrap_or(DEFAULT_KDF_ITERATIONS as u32),
},
};
Self {
kind: Pkcs12EncryptionKind::Pbes1(encryption),
inner,
}
}
/// Create new PBES2 encryption (It is advised to use PBES2 for new files)
pub fn new_pbes2(encryption: Pbes2Encryption, context: &mut Pkcs12CryptoContext) -> Self {
let iv = context.generate_bytes(AES_BLOCK_SIZE);
let encryption_scheme = Pbes2EncryptionSchemeAsn1::AesCbc {
kind: encryption.cipher.into(),
iv: OctetStringAsn1(iv),
};
// Skip serialization if set to SHA1 as specified in RFC
let prf = match Pbkdf2PrfAsn1::from(encryption.hmac_kdf) {
Pbkdf2PrfAsn1::HmacWithSha1 => None,
value => Some(value),
};
let kdf_params = Pbkdf2ParamsAsn1 {
salt: Pbkdf2SaltSourceAsn1::Specified(OctetStringAsn1(context.generate_bytes(DEFAULT_SALT_SIZE))),
iteration_count: encryption.kdf_iterations.unwrap_or(DEFAULT_KDF_ITERATIONS as u32),
// key length is not set by most implementations
key_length: None,
prf,
};
let pbes2_params = Pbes2ParamsAsn1 {
key_derivation_func: Pbes2KeyDerivationFuncAsn1::Pbkdf2(kdf_params),
encryption_scheme,
};
let inner = Pkcs12EncryptionAsn1::Pbes2(pbes2_params);
Self {
kind: Pkcs12EncryptionKind::Pbes2(encryption),
inner,
}
}
/// Parsed encryption representation
pub fn kind(&self) -> &Pkcs12EncryptionKind {
&self.kind
}
pub fn inner(&self) -> &Pkcs12EncryptionAsn1 {
&self.inner
}
pub(crate) fn decrypt(&self, data: &[u8], context: &Pkcs12CryptoContext) -> Result<Vec<u8>, Pkcs12Error> {
match self.inner() {
Pkcs12EncryptionAsn1::Pbes1 { kind, params } => {
let password = context.password_bytes_pbes1()?;
decrypt_pbes1(
*kind,
password.as_slice(),
params.salt.as_slice(),
params.iterations as usize,
data,
)
}
Pkcs12EncryptionAsn1::Pbes2(params) => {
let password = context.password_bytes_pbes2();
decrypt_pbes2(params, password, data)
}
Pkcs12EncryptionAsn1::Unknown(raw) => {
let oid = raw.algorithm().clone();
Err(Pkcs12Error::NotSupportedAlgorithm {
algorithm: super::UnsupportedPkcs12Algorithm::Oid(oid),
context: "decryption".to_string(),
})
}
}
}
pub(crate) fn encrypt(&self, data: &[u8], context: &Pkcs12CryptoContext) -> Result<Vec<u8>, Pkcs12Error> {
match self.inner() {
Pkcs12EncryptionAsn1::Pbes1 { kind, params } => {
let password = context.password_bytes_pbes1()?;
encrypt_pbes1(
*kind,
password.as_slice(),
params.salt.as_slice(),
params.iterations as usize,
data,
)
}
Pkcs12EncryptionAsn1::Pbes2(params) => {
let password = context.password_bytes_pbes2();
encrypt_pbes2(params, password, data)
}
Pkcs12EncryptionAsn1::Unknown(raw) => {
let oid = raw.algorithm().clone();
Err(Pkcs12Error::NotSupportedAlgorithm {
algorithm: super::UnsupportedPkcs12Algorithm::Oid(oid),
context: "encryption".to_string(),
})
}
}
}
}
/// Supported PKCS12 encryption algorithm descriptor.
/// If parsed PFX file contains unknown encryption algorithm, [`Pkcs12EncryptionKind::Unknown`]
/// variant is returned instead. If such encryption is encountered, then we can't use encrypted PFX
/// nodes, but unencrypted data still could be extracted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pkcs12EncryptionKind {
Pbes1(Pbes1Encryption),
Pbes2(Pbes2Encryption),
Unknown,
}
/// PBES1 encryption descriptor.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pbes1Encryption {
cipher: Pbes1Cipher,
kdf_iterations: Option<u32>,
}
impl Pbes1Encryption {
/// Creates new PBES1 encryption descriptor with given cipher algorithm.
pub fn new(cipher: Pbes1Cipher) -> Self {
Self {
cipher,
kdf_iterations: None,
}
}
/// Sets KDF iteraions count to `iterations`. If not set, [`DEFAULT_KDF_ITERATIONS`] is used
/// instead.
pub fn with_kdf_iterations(mut self, iterations: u32) -> Self {
self.kdf_iterations = Some(iterations);
self
}
}
/// PBES2 encryption descriptor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Pbes2Encryption {
cipher: Pbes2Cipher,
hmac_kdf: Pkcs12HashAlgorithm,
kdf_iterations: Option<u32>,
}
impl Pbes2Encryption {
/// Creates new PBES2 encryption descriptor with given kdf and cipher algorithms.
pub fn new(cipher: Pbes2Cipher, hmac_kdf: Pkcs12HashAlgorithm) -> Self {
Self {
cipher,
hmac_kdf,
kdf_iterations: None,
}
}
/// Sets KDF iteraions count to `iterations`. If not set, [`DEFAULT_KDF_ITERATIONS`] is used
/// instead.
pub fn with_kdf_iterations(mut self, iterations: u32) -> Self {
self.kdf_iterations = Some(iterations);
self
}
}
/// PBES2 cipher algorithm
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pbes2Cipher {
Aes128Cbc,
Aes192Cbc,
Aes256Cbc,
}
impl Pbes2Cipher {
/// Returns cipher key size in bytes.
pub fn key_size(self) -> usize {
match self {
Self::Aes128Cbc => 16,
Self::Aes192Cbc => 24,
Self::Aes256Cbc => 32,
}
}
}
impl From<Pbes2Cipher> for Pbes2AesCbcEncryptionAsn1 {
fn from(value: Pbes2Cipher) -> Self {
match value {
Pbes2Cipher::Aes128Cbc => Self::Aes128,
Pbes2Cipher::Aes192Cbc => Self::Aes192,
Pbes2Cipher::Aes256Cbc => Self::Aes256,
}
}
}
impl From<Pbes2AesCbcEncryptionAsn1> for Pbes2Cipher {
fn from(value: Pbes2AesCbcEncryptionAsn1) -> Self {
match value {
Pbes2AesCbcEncryptionAsn1::Aes128 => Self::Aes128Cbc,
Pbes2AesCbcEncryptionAsn1::Aes192 => Self::Aes192Cbc,
Pbes2AesCbcEncryptionAsn1::Aes256 => Self::Aes256Cbc,
}
}
}
impl From<Pkcs12HashAlgorithm> for Pbkdf2PrfAsn1 {
fn from(value: Pkcs12HashAlgorithm) -> Self {
match value {
Pkcs12HashAlgorithm::Sha1 => Self::HmacWithSha1,
Pkcs12HashAlgorithm::Sha224 => Self::HmacWithSha224,
Pkcs12HashAlgorithm::Sha256 => Self::HmacWithSha256,
Pkcs12HashAlgorithm::Sha384 => Self::HmacWithSha384,
Pkcs12HashAlgorithm::Sha512 => Self::HmacWithSha512,
}
}
}
struct Pbes2CipherInputs {
key: Vec<u8>,
iv: Vec<u8>,
cipher: Pbes2Cipher,
}
fn prepare_pbes2_cipher_inputs(
params: &Pbes2ParamsAsn1,
password: &[u8],
cipher_context: &str,
) -> Result<Pbes2CipherInputs, Pkcs12Error> {
let (salt, kdf_iterations, prf) = match &params.key_derivation_func {
Pbes2KeyDerivationFuncAsn1::Pbkdf2(kdf) => {
let salt = match &kdf.salt {
Pbkdf2SaltSourceAsn1::Specified(salt) => salt.0.clone(),
Pbkdf2SaltSourceAsn1::OtherSource(raw) => {
let oid = raw.algorithm().clone();
return Err(Pkcs12Error::NotSupportedAlgorithm {
algorithm: super::UnsupportedPkcs12Algorithm::Oid(oid),
context: format!("pbes2 {cipher_context} (kdf salt source)"),
});
}
};
let prf = match &kdf.prf {
None => Pkcs12HashAlgorithm::Sha1,
Some(Pbkdf2PrfAsn1::HmacWithSha1) => Pkcs12HashAlgorithm::Sha1,
Some(Pbkdf2PrfAsn1::HmacWithSha224) => Pkcs12HashAlgorithm::Sha224,
Some(Pbkdf2PrfAsn1::HmacWithSha256) => Pkcs12HashAlgorithm::Sha256,
Some(Pbkdf2PrfAsn1::HmacWithSha384) => Pkcs12HashAlgorithm::Sha384,
Some(Pbkdf2PrfAsn1::HmacWithSha512) => Pkcs12HashAlgorithm::Sha512,
Some(Pbkdf2PrfAsn1::Unknown(raw)) => {
let oid = raw.algorithm().clone();
return Err(Pkcs12Error::NotSupportedAlgorithm {
algorithm: super::UnsupportedPkcs12Algorithm::Oid(oid),
context: format!("pbes2 {cipher_context} (kdf prf)"),
});
}
};
(salt, kdf.iteration_count, prf)
}
Pbes2KeyDerivationFuncAsn1::Unknown(raw) => {
let oid = raw.algorithm().clone();
return Err(Pkcs12Error::NotSupportedAlgorithm {
algorithm: super::UnsupportedPkcs12Algorithm::Oid(oid),
context: format!("pbes2 {cipher_context} (kdf)"),
});
}
};
let (cipher, iv) = match &params.encryption_scheme {
Pbes2EncryptionSchemeAsn1::AesCbc { kind, iv } => (Pbes2Cipher::from(*kind), iv.0.clone()),
Pbes2EncryptionSchemeAsn1::Unknown(raw) => {
let oid = raw.algorithm().clone();
return Err(Pkcs12Error::NotSupportedAlgorithm {
algorithm: super::UnsupportedPkcs12Algorithm::Oid(oid),
context: format!("pbes2 {cipher_context} (cipher)"),
});
}
};
let calculate_kdf = match prf {
Pkcs12HashAlgorithm::Sha1 => pbkdf2::pbkdf2_hmac::<sha1::Sha1>,
Pkcs12HashAlgorithm::Sha224 => pbkdf2::pbkdf2_hmac::<sha2::Sha224>,
Pkcs12HashAlgorithm::Sha256 => pbkdf2::pbkdf2_hmac::<sha2::Sha256>,
Pkcs12HashAlgorithm::Sha384 => pbkdf2::pbkdf2_hmac::<sha2::Sha384>,
Pkcs12HashAlgorithm::Sha512 => pbkdf2::pbkdf2_hmac::<sha2::Sha512>,
};
let mut key = vec![0u8; cipher.key_size()];
calculate_kdf(password, salt.as_slice(), kdf_iterations, key.as_mut_slice());
Ok(Pbes2CipherInputs { key, iv, cipher })
}
fn decrypt_pbes2(params: &Pbes2ParamsAsn1, password: &[u8], data: &[u8]) -> Result<Vec<u8>, Pkcs12Error> {
let Pbes2CipherInputs { key, iv, cipher } = prepare_pbes2_cipher_inputs(params, password, "decryption")?;
use aes::cipher::BlockModeDecrypt;
use cbc::Decryptor;
use cbc::cipher::KeyIvInit;
use cbc::cipher::block_padding::Pkcs7;
let decrypted = match cipher {
Pbes2Cipher::Aes128Cbc => {
use aes::Aes128;
type Aes128Cbc = Decryptor<Aes128>;
let aes = Aes128Cbc::new_from_slices(key.as_slice(), iv.as_slice()).map_err(|_| Pkcs12Error::Pbes2 {
context: "AES128 decryptor initialization failed".to_string(),
})?;
aes.decrypt_padded_vec::<Pkcs7>(data).map_err(|_| Pkcs12Error::Pbes2 {
context: "AES128 decryption with padding failed".to_string(),
})?
}
Pbes2Cipher::Aes192Cbc => {
use aes::Aes192;
type Aes192Cbc = Decryptor<Aes192>;
let aes = Aes192Cbc::new_from_slices(key.as_slice(), iv.as_slice()).map_err(|_| Pkcs12Error::Pbes2 {
context: "AES192 decryptor initialization failed".to_string(),
})?;
aes.decrypt_padded_vec::<Pkcs7>(data).map_err(|_| Pkcs12Error::Pbes2 {
context: "AES192 decryption with padding failed".to_string(),
})?
}
Pbes2Cipher::Aes256Cbc => {
use aes::Aes256;
type Aes256Cbc = Decryptor<Aes256>;
let aes = Aes256Cbc::new_from_slices(key.as_slice(), iv.as_slice()).map_err(|_| Pkcs12Error::Pbes2 {
context: "AES256 decryptor initialization failed".to_string(),
})?;
aes.decrypt_padded_vec::<Pkcs7>(data).map_err(|_| Pkcs12Error::Pbes2 {
context: "AES256 decryption with padding failed".to_string(),
})?
}
};
Ok(decrypted)
}
fn encrypt_pbes2(params: &Pbes2ParamsAsn1, password: &[u8], data: &[u8]) -> Result<Vec<u8>, Pkcs12Error> {
let Pbes2CipherInputs { key, iv, cipher } = prepare_pbes2_cipher_inputs(params, password, "encryption")?;
use aes::cipher::BlockModeEncrypt;
use cbc::Encryptor;
use cbc::cipher::KeyIvInit;
use cbc::cipher::block_padding::Pkcs7;
let encrypted = match cipher {
Pbes2Cipher::Aes128Cbc => {
use aes::Aes128;
type Aes128Cbc = Encryptor<Aes128>;
let aes = Aes128Cbc::new_from_slices(key.as_slice(), iv.as_slice()).map_err(|_| Pkcs12Error::Pbes2 {
context: "AES128 encryptor initialization failed".to_string(),
})?;
aes.encrypt_padded_vec::<Pkcs7>(data)
}
Pbes2Cipher::Aes192Cbc => {
use aes::Aes192;
type Aes192Cbc = Encryptor<Aes192>;
let aes = Aes192Cbc::new_from_slices(key.as_slice(), iv.as_slice()).map_err(|_| Pkcs12Error::Pbes2 {
context: "AES192 encryptor initialization failed".to_string(),
})?;
aes.encrypt_padded_vec::<Pkcs7>(data)
}
Pbes2Cipher::Aes256Cbc => {
use aes::Aes256;
type Aes256Cbc = Encryptor<Aes256>;
let aes = Aes256Cbc::new_from_slices(key.as_slice(), iv.as_slice()).map_err(|_| Pkcs12Error::Pbes2 {
context: "AES256 encryptor initialization failed".to_string(),
})?;
aes.encrypt_padded_vec::<Pkcs7>(data)
}
};
Ok(encrypted)
}
fn generate_pbes1_key_and_iv(
cipher: Pbes1Cipher,
password: &[u8],
salt: &[u8],
kdf_iterations: usize,
) -> (Vec<u8>, Vec<u8>) {
let (key_size, iv_size) = match cipher {
Pbes1Cipher::ShaAnd40BitRc2Cbc => (5, 8),
Pbes1Cipher::ShaAnd3Key3DesCbc => (24, 8),
};
let key = pbkdf1(
Pkcs12HashAlgorithm::Sha1,
password,
salt,
kdf_iterations,
Pbkdf1Usage::Key,
key_size,
);
let iv = pbkdf1(
Pkcs12HashAlgorithm::Sha1,
password,
salt,
kdf_iterations,
Pbkdf1Usage::Iv,
iv_size,
);
(key, iv)
}
fn encrypt_pbes1(
scheme: Pbes1Cipher,
password: &[u8],
salt: &[u8],
kdf_iterations: usize,
data: &[u8],
) -> Result<Vec<u8>, Pkcs12Error> {
use cbc::Encryptor;
use cbc::cipher::block_padding::Pkcs7;
use cbc::cipher::{BlockModeEncrypt, KeyIvInit};
let (dk, iv) = generate_pbes1_key_and_iv(scheme, password, salt, kdf_iterations);
match scheme {
Pbes1Cipher::ShaAnd40BitRc2Cbc => {
use rc2::Rc2;
type Rc2Cbc = Encryptor<Rc2>;
let rc2 = Rc2Cbc::new_from_slices(&dk, &iv).map_err(|_| Pkcs12Error::Pbes1 {
context: "RC2 encryption initialization failed".to_string(),
})?;
Ok(rc2.encrypt_padded_vec::<Pkcs7>(data))
}
Pbes1Cipher::ShaAnd3Key3DesCbc => {
use des::TdesEde3;
type TDesCbc = Encryptor<TdesEde3>;
let tdes = TDesCbc::new_from_slices(&dk, &iv).map_err(|_| Pkcs12Error::Pbes1 {
context: "3DES encryptor initialization failed".to_string(),
})?;
Ok(tdes.encrypt_padded_vec::<Pkcs7>(data))
}
}
}
fn decrypt_pbes1(
scheme: Pbes1Cipher,
password: &[u8],
salt: &[u8],
kdf_iterations: usize,
data: &[u8],
) -> Result<Vec<u8>, Pkcs12Error> {
use cbc::Decryptor;
use cbc::cipher::block_padding::Pkcs7;
use cbc::cipher::{BlockModeDecrypt, KeyIvInit};
let (dk, iv) = generate_pbes1_key_and_iv(scheme, password, salt, kdf_iterations);
match scheme {
Pbes1Cipher::ShaAnd40BitRc2Cbc => {
use rc2::Rc2;
type Rc2Cbc = Decryptor<Rc2>;
let rc2 = Rc2Cbc::new_from_slices(&dk, &iv).map_err(|_| Pkcs12Error::Pbes1 {
context: "RC2 decryptor initialization failed".to_string(),
})?;
rc2.decrypt_padded_vec::<Pkcs7>(data).map_err(|_| Pkcs12Error::Pbes1 {
context: "RC2 decryption with padding failed".to_string(),
})
}
Pbes1Cipher::ShaAnd3Key3DesCbc => {
use des::TdesEde3;
type TDesCbc = Decryptor<TdesEde3>;
let tdes = TDesCbc::new_from_slices(&dk, &iv).map_err(|_| Pkcs12Error::Pbes1 {
context: "3DES decryptor initialization failed".to_string(),
})?;
tdes.decrypt_padded_vec::<Pkcs7>(data).map_err(|_| Pkcs12Error::Pbes1 {
context: "3DES decryption with padding failed".to_string(),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn pbes1_3des_roundtrip() {
let password = b"\0a\0b\0c\0\0";
let salt = (0..8).collect::<Vec<u8>>();
let iterations = 2000;
let data = (0..123).collect::<Vec<u8>>();
let encrypted = encrypt_pbes1(Pbes1Cipher::ShaAnd3Key3DesCbc, password, &salt, iterations, &data).unwrap();
let decrypted = decrypt_pbes1(Pbes1Cipher::ShaAnd3Key3DesCbc, password, &salt, iterations, &encrypted).unwrap();
assert_eq!(decrypted, data);
}
#[test]
fn pbes1_rc2_roundtrip() {
let password = b"\0b\0c\0a\0\0";
let salt = (0..8).collect::<Vec<u8>>();
let iterations = 2048;
let data = (0..124).collect::<Vec<u8>>();
let encrypted = encrypt_pbes1(Pbes1Cipher::ShaAnd40BitRc2Cbc, password, &salt, iterations, &data).unwrap();
let decrypted = decrypt_pbes1(Pbes1Cipher::ShaAnd40BitRc2Cbc, password, &salt, iterations, &encrypted).unwrap();
assert_eq!(decrypted, data);
}
#[rstest]
#[case(Pbes2AesCbcEncryptionAsn1::Aes128)]
#[case(Pbes2AesCbcEncryptionAsn1::Aes192)]
#[case(Pbes2AesCbcEncryptionAsn1::Aes256)]
fn pbes2_aes256_roundtrip(#[case] aes: Pbes2AesCbcEncryptionAsn1) {
let password = b"test";
let data = (0..123).collect::<Vec<u8>>();
let params = Pbes2ParamsAsn1 {
key_derivation_func: Pbes2KeyDerivationFuncAsn1::Pbkdf2(Pbkdf2ParamsAsn1 {
salt: Pbkdf2SaltSourceAsn1::Specified(OctetStringAsn1((0..8).collect())),
iteration_count: 2000,
key_length: None,
prf: Some(Pbkdf2PrfAsn1::HmacWithSha256),
}),
encryption_scheme: Pbes2EncryptionSchemeAsn1::AesCbc {
kind: aes,
iv: OctetStringAsn1((0..16).collect()),
},
};
let encrypted = encrypt_pbes2(&params, password, &data).unwrap();
let decrypted = decrypt_pbes2(&params, password, &encrypted).unwrap();
assert_eq!(decrypted, data);
}
}
+196
View File
@@ -0,0 +1,196 @@
use crate::pkcs12::{Pbkdf1Usage, Pkcs12CryptoContext, Pkcs12Error, Pkcs12HashAlgorithm, pbkdf1};
use hmac::KeyInit;
use picky_asn1::wrapper::OctetStringAsn1;
use picky_asn1_x509::pkcs12::{MacData as MacDataAsn1, Pkcs12DigestInfo as Pkcs12DigestInfoAsn1};
use thiserror::Error;
const DEFAULT_MAC_KDF_ITERATIONS: u32 = 1;
const DEFAULT_SALT_SIZE: usize = 20;
#[derive(Debug, Clone, Error)]
pub enum Pkcs12MacError {
#[error("Invalid hmac input size")]
InvalidHmacInputSize,
#[error("MAC validation failed (wrong password or corrupted data)")]
MacValidation,
}
/// HMAC algorithm parameters (used for PFX integrity data)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pkcs12MacAlgorithmHmac {
hash_algorithm: Pkcs12HashAlgorithm,
iterations: Option<u32>,
}
impl Pkcs12MacAlgorithmHmac {
pub fn new(hash_algorithm: Pkcs12HashAlgorithm) -> Self {
Self {
hash_algorithm,
iterations: None,
}
}
pub fn with_iterations(mut self, iterations: u32) -> Self {
self.iterations = Some(iterations);
self
}
pub fn hash_algorithm(&self) -> Pkcs12HashAlgorithm {
self.hash_algorithm
}
pub fn iterations(&self) -> Option<u32> {
self.iterations
}
}
/// Parsed MAC algorithm parameters
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pkcs12MacAlgorithm {
Hmac(Pkcs12MacAlgorithmHmac),
Unknown,
}
/// Parsed PFX MAC data
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pkcs12MacData {
algorithm: Pkcs12MacAlgorithm,
inner: MacDataAsn1,
}
impl Pkcs12MacData {
pub(crate) fn from_asn1(inner: MacDataAsn1, skip_unknown_hash_algorithm: bool) -> Result<Self, Pkcs12Error> {
let hash_algorithm = match Pkcs12HashAlgorithm::from_asn1_digest_algorithm(&inner.mac.digest_algorithm) {
Ok(algorithm) => algorithm,
Err(_) if skip_unknown_hash_algorithm => {
return Ok(Self {
algorithm: Pkcs12MacAlgorithm::Unknown,
inner,
});
}
Err(e) => {
return Err(e);
}
};
let kdf_iterations = inner.iterations.unwrap_or(DEFAULT_MAC_KDF_ITERATIONS);
Ok(Self {
algorithm: Pkcs12MacAlgorithm::Hmac(Pkcs12MacAlgorithmHmac {
hash_algorithm,
iterations: Some(kdf_iterations),
}),
inner,
})
}
pub(crate) fn new_hmac(
algorithm: Pkcs12MacAlgorithmHmac,
context: &mut Pkcs12CryptoContext,
data: &[u8],
) -> Result<Self, Pkcs12Error> {
let hash_algorithm = algorithm.hash_algorithm;
let kdf_iterations = algorithm.iterations.unwrap_or(DEFAULT_MAC_KDF_ITERATIONS);
let salt = context.generate_bytes(DEFAULT_SALT_SIZE);
// PKCS12 MAC uses BMPString as password representation
let password = context.password_bytes_pbes1()?;
let digest = Self::calculate_digest(
hash_algorithm,
kdf_iterations,
password.as_slice(),
salt.as_slice(),
data,
)?;
Ok(Self {
algorithm: Pkcs12MacAlgorithm::Hmac(algorithm),
inner: MacDataAsn1 {
mac: Pkcs12DigestInfoAsn1 {
digest_algorithm: hash_algorithm.into(),
digest: OctetStringAsn1(digest),
},
salt: OctetStringAsn1(salt),
iterations: Some(kdf_iterations),
},
})
}
pub(crate) fn validate(&self, context: &Pkcs12CryptoContext, data: &[u8]) -> Result<(), Pkcs12Error> {
let hash_algorithm = Pkcs12HashAlgorithm::from_asn1_digest_algorithm(&self.inner.mac.digest_algorithm)?;
let kdf_iterations = self.inner.iterations.unwrap_or(DEFAULT_MAC_KDF_ITERATIONS);
let salt = self.inner.salt.0.as_slice();
// PKCS12 MAC uses BMPString as password representation
let password = context.password_bytes_pbes1()?;
let digest = Self::calculate_digest(hash_algorithm, kdf_iterations, password.as_slice(), salt, data)?;
if digest == self.inner.mac.digest.0.as_slice() {
Ok(())
} else {
Err(Pkcs12MacError::MacValidation.into())
}
}
fn calculate_digest(
hash_algorithm: Pkcs12HashAlgorithm,
kdf_iterations: u32,
password: &[u8],
salt: &[u8],
data: &[u8],
) -> Result<Vec<u8>, Pkcs12Error> {
let key = pbkdf1(
hash_algorithm,
password,
salt,
kdf_iterations as usize,
Pbkdf1Usage::Mac,
hash_algorithm.digest_size(),
);
use hmac::Mac;
let map_hmac_err = |_| Pkcs12MacError::InvalidHmacInputSize;
let mac = match hash_algorithm {
Pkcs12HashAlgorithm::Sha1 => {
let mut hmac = hmac::Hmac::<sha1::Sha1>::new_from_slice(&key).map_err(map_hmac_err)?;
hmac.update(data);
hmac.finalize().into_bytes().to_vec()
}
Pkcs12HashAlgorithm::Sha224 => {
let mut hmac = hmac::Hmac::<sha2::Sha224>::new_from_slice(&key).map_err(map_hmac_err)?;
hmac.update(data);
hmac.finalize().into_bytes().to_vec()
}
Pkcs12HashAlgorithm::Sha256 => {
let mut hmac = hmac::Hmac::<sha2::Sha256>::new_from_slice(&key).map_err(map_hmac_err)?;
hmac.update(data);
hmac.finalize().into_bytes().to_vec()
}
Pkcs12HashAlgorithm::Sha384 => {
let mut hmac = hmac::Hmac::<sha2::Sha384>::new_from_slice(&key).map_err(map_hmac_err)?;
hmac.update(data);
hmac.finalize().into_bytes().to_vec()
}
Pkcs12HashAlgorithm::Sha512 => {
let mut hmac = hmac::Hmac::<sha2::Sha512>::new_from_slice(&key).map_err(map_hmac_err)?;
hmac.update(data);
hmac.finalize().into_bytes().to_vec()
}
};
Ok(mac)
}
pub fn inner(&self) -> &MacDataAsn1 {
&self.inner
}
/// Parsed MAC algorithm parameters
pub fn algorithm(&self) -> &Pkcs12MacAlgorithm {
&self.algorithm
}
}
+614
View File
@@ -0,0 +1,614 @@
//! This module provides types for parsing and building PKCS#12 files (PFX).
//!
//! # PFX file structure
//!
//! PFX files are constructed from `safe contents` on the highest level, which are containers for
//! various types of `safe bags`. They could be encrypted or not and contain one or more `safe bags`.
//!
//! `Safe bags` are polymorphic containers for various types of data, such as private keys, certificates,
//! custom data (`secrets`), CRLs, etc. They could also be nested, which means that one `safe bag` could
//! contain multiple `safe bags` inside, making it possible to represent complex tree-like structures.
//!
//! Each `safe bag` could also contain `attributes`, which are key-value pairs of various types of data.
//! High-level API defines only `friendly name` and `local key id` attributes, but it is possible to
//! parse/build any custom attributes as well.
//!
//! There are also special `safe bags` called `shrouded key bags`, which are basically encrypted
//! PKCS8 private keys. Encryption on `safe bag` level is performed to allow PFX files that have
//! unencrypted certificates and encrypted private keys, to be preprocessed by the software without
//! knowing PFX password (e.g. check if PFX contains private key for the certificate or not). Usually
//! relations between contained certificates and private keys are marked with `local key id` attribute
//! which is set to the same value for both certificate and private key.
//!
//! # Parsing
//!
//! Almost any kind of PKCS#12 object specified by [RFC](https://datatracker.ietf.org/doc/html/rfc7292)
//! could be parsed or constructed. However, only the most used PFX types are wrapped in high level
//! convenient API such as private keys, certificates, nested pfx nodes, etc. If parsed
//! data was in fact some rarely used PFX type, it is still possible to access it via low level API
//! via `.inner()` method usually returns ASN.1 DER encoded data (`picky-asn1-x509` crate types).
//!
//! Parsing process could be controlled even more with [`Pkcs12ParsingParams`], which allows to skip
//! some parsing errors (e.g. Skip failed mapping of private key ASN.1 structure to high-level picky
//! wrapper)
//!
//! Parsing of PFX file is simple as calling [`Pfx::from_der`] method with required params
//! (crypto context which holds PFX password, parsing params and data itself), after which its safe
//! bags could be inspected and data extracted from PFX.
//!
//! # Building
//!
//! Building process of PFX files is down-to-top, in contrast to parsing. It starts with
//! building of required safe bags/attributes structures, wrapping them inside safe contents
//! (encrypted or unencrypted) and then wrapping them in PFX structure. This allows to keep API
//! flexible while representing Pfx file both after parsing and building as the same type ([`Pfx])
//!
//! # Encryption
//!
//! - It is advised to always use Pbes2 AES-based encryption for PFX files. The only use case for new
//! PFX files with Pbes1 encryption is to support legacy software that does not support Pbes2.
//! - Usually PFX files without passwords are actually encrypted with empty-string passwords if
//! generated by modern software by default. (e.g. certmgr or OpenSSL). However, it is possible to
//! create PFX files without any encryption and MAC in picky if PFX will be used just as a plain
//! container for certificates and private keys and stored somewhere securely (e.g. wrapped in
//! another encryption layer).
mod attribute;
mod encryption;
mod mac;
mod pbkdf1;
mod safe_bag;
mod safe_contents;
use picky_asn1::restricted_string::CharSetError;
use picky_asn1_der::Asn1RawDer;
use picky_asn1_x509::oid::ObjectIdentifier;
use picky_asn1_x509::pkcs12::{
AuthenticatedSafeContentInfo as AuthenticatedSafeContentInfoAsn1,
ParsedAuthenticatedSafeDataRepr as ParsedAuthenticatedSafeDataReprAsn1, Pbkdf2Prf as Pbkdf2PrfAsn1, Pfx as PfxAsn1,
Pkcs12DigestAlgorithm as Pkcs12DigestAlgorithmAsn1, Pkcs12DigestAlgorithm,
RawAuthenticatedSafeContentInfo as RawAuthenticatedSafeContentInfoAsn1, RawPfx as RawPfxAsn1,
SafeContentsContentInfo as SafeContentsContentInfoAsn1,
};
use std::fmt::Display;
use thiserror::Error;
pub(crate) use pbkdf1::{Pbkdf1Usage, pbkdf1};
pub use attribute::{CustomPkcs12Attribute, Pkcs12Attribute, Pkcs12AttributeKind};
pub use encryption::{
Pbes1Cipher, Pbes1Encryption, Pbes2Cipher, Pbes2Encryption, Pkcs12CryptoContext, Pkcs12Encryption,
Pkcs12EncryptionKind,
};
pub use mac::{Pkcs12MacAlgorithm, Pkcs12MacAlgorithmHmac, Pkcs12MacData, Pkcs12MacError};
pub use safe_bag::{SafeBag, SafeBagKind, SecretSafeBag};
pub use safe_contents::{SafeContents, SafeContentsKind};
const PFX_VERSION: u8 = 3;
/// Parsed PFX (PKCS12 archive). See module docs for more info on PFX file structure and API usage.
#[derive(Debug)]
pub struct Pfx {
safe_contents: Vec<SafeContents>,
mac_data: Option<Pkcs12MacData>,
/// Pre-serialized auth safe data. Just an optimization to avoid re-serializing the auth safe
/// data if it was already serialized for the MAC calculation.
auth_safe_data: Option<Vec<u8>>,
}
impl Pfx {
/// Create new PFX file with HMAC MAC algorithm. Usually this is the default in modern software
pub fn new_with_hmac(
safe_contents: Vec<SafeContents>,
mac: Pkcs12MacAlgorithmHmac,
crypto_context: &mut Pkcs12CryptoContext,
) -> Result<Self, Pkcs12Error> {
let safe_contents_asn1 = safe_contents.iter().map(|sc| sc.inner().clone()).collect::<Vec<_>>();
let serialized_auth_safe = picky_asn1_der::to_vec(&safe_contents_asn1)?;
let mac_data = Pkcs12MacData::new_hmac(mac, crypto_context, &serialized_auth_safe)?;
Ok(Self {
safe_contents,
mac_data: Some(mac_data),
auth_safe_data: Some(serialized_auth_safe),
})
}
/// Create new PFX file without MAC algorithm if needed for some reason (e.g. MAC is performed
/// on the higher level)
pub fn new_without_mac(safe_contents: Vec<SafeContents>) -> Self {
Self {
safe_contents,
mac_data: None,
auth_safe_data: None,
}
}
/// Serialize PFX file to DER bytes
pub fn to_der(&self) -> Result<Vec<u8>, Pkcs12Error> {
match &self.auth_safe_data {
Some(auth_safe) => {
let pfx_asn1 = RawPfxAsn1 {
version: PFX_VERSION,
auth_safe: RawAuthenticatedSafeContentInfoAsn1::Data(Asn1RawDer(auth_safe.clone())),
mac_data: self.mac_data.as_ref().map(|mac_data| mac_data.inner().clone()),
};
picky_asn1_der::to_vec(&pfx_asn1).map_err(Into::into)
}
None => {
let pfx_asn1 = PfxAsn1 {
version: PFX_VERSION,
auth_safe: AuthenticatedSafeContentInfoAsn1::<ParsedAuthenticatedSafeDataReprAsn1>::Data(
self.safe_contents
.iter()
.map(|sc| sc.inner().clone())
.collect::<Vec<_>>(),
),
mac_data: self.mac_data.as_ref().map(|mac_data| mac_data.inner().clone()),
};
picky_asn1_der::to_vec(&pfx_asn1).map_err(Into::into)
}
}
}
/// Parses a PKCS12 archive (PFX) from its DER representation.
pub fn from_der(
data: &[u8],
crypto_context: &Pkcs12CryptoContext,
parsing_params: &Pkcs12ParsingParams,
) -> Result<Self, Pkcs12Error> {
let (auth_safe, mac_data) = if parsing_params.skip_mac_validation {
let pfx_asn1: PfxAsn1 = picky_asn1_der::from_bytes(data)?;
if pfx_asn1.version != PFX_VERSION {
return Err(Pkcs12Error::InvalidVersion(pfx_asn1.version));
}
let mac_data = pfx_asn1
.mac_data
.map(|asn1| Pkcs12MacData::from_asn1(asn1, true))
.transpose()?;
let auth_safe = match pfx_asn1.auth_safe {
AuthenticatedSafeContentInfoAsn1::Data(data) => data,
AuthenticatedSafeContentInfoAsn1::Unknown { content_type, .. } => {
return Err(Pkcs12Error::InvalidAuthenticatedSafeContentType(content_type.into()));
}
};
(auth_safe, mac_data)
} else {
let pfx_asn1: RawPfxAsn1 = picky_asn1_der::from_bytes(data)?;
if pfx_asn1.version != PFX_VERSION {
return Err(Pkcs12Error::InvalidVersion(pfx_asn1.version));
}
let mac_data = pfx_asn1
.mac_data
.map(|asn1| Pkcs12MacData::from_asn1(asn1, false))
.transpose()?;
let auth_safe = match pfx_asn1.auth_safe {
AuthenticatedSafeContentInfoAsn1::Data(data) => {
if let Some(mac_data) = &mac_data {
mac_data.validate(crypto_context, data.0.as_slice())?;
}
let parsed: Vec<SafeContentsContentInfoAsn1> = picky_asn1_der::from_bytes(data.0.as_slice())?;
parsed
}
AuthenticatedSafeContentInfoAsn1::Unknown { content_type, .. } => {
return Err(Pkcs12Error::InvalidAuthenticatedSafeContentType(content_type.into()));
}
};
(auth_safe, mac_data)
};
auth_safe
.into_iter()
.map(|safe_contents| SafeContents::from_asn1(safe_contents, crypto_context, parsing_params))
.collect::<Result<Vec<_>, _>>()
.map(|safe_contents| Self {
safe_contents,
mac_data,
auth_safe_data: None,
})
}
/// Inspect parsed PFX data
pub fn safe_contents(&self) -> &[SafeContents] {
&self.safe_contents
}
/// Inspect parsed MAC data
pub fn mac_data(&self) -> Option<&Pkcs12MacData> {
self.mac_data.as_ref()
}
}
/// Parameters which control some aspects of PFX file parsing process
#[derive(Debug, Clone, Default)]
pub struct Pkcs12ParsingParams {
/// Continue parsing if conversion to high level picky data structure fails (e.g. due to
/// unsupported private key or certificate kind)
pub skip_soft_parsing_errors: bool,
/// Continue parsing if decryption fails and keep data in encrypted form
pub skip_decryption_errors: bool,
/// Continue parsing if MAC validation fails.
///
/// This is useful for parsing available unencrypted data from
/// password-protected PFX files. Also could be useful if PFX integrity has been intentionally
/// violated for testing purposes.
pub skip_mac_validation: bool,
}
/// Hashing algorithm used for MAC or KDF in PFX file
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pkcs12HashAlgorithm {
Sha1,
Sha224,
Sha256,
Sha384,
Sha512,
}
impl Pkcs12HashAlgorithm {
pub(crate) fn pbkdf1_u_bits(self) -> usize {
match self {
Self::Sha1 => 160,
Self::Sha224 => 224,
Self::Sha256 => 256,
Self::Sha384 => 384,
Self::Sha512 => 512,
}
}
pub(crate) fn pbkdf1_v_bits(self) -> usize {
match self {
Self::Sha1 => 512,
Self::Sha224 => 512,
Self::Sha256 => 512,
Self::Sha384 => 1024,
Self::Sha512 => 1024,
}
}
pub(crate) fn digest_size(self) -> usize {
match self {
Self::Sha1 => 20,
Self::Sha224 => 28,
Self::Sha256 => 32,
Self::Sha384 => 48,
Self::Sha512 => 64,
}
}
pub(crate) fn from_asn1_pbkdf2_prf(value: &Pbkdf2PrfAsn1) -> Result<Self, Pkcs12Error> {
let algorithm = match value {
Pbkdf2PrfAsn1::HmacWithSha1 => Pkcs12HashAlgorithm::Sha1,
Pbkdf2PrfAsn1::HmacWithSha224 => Pkcs12HashAlgorithm::Sha224,
Pbkdf2PrfAsn1::HmacWithSha256 => Pkcs12HashAlgorithm::Sha256,
Pbkdf2PrfAsn1::HmacWithSha384 => Pkcs12HashAlgorithm::Sha384,
Pbkdf2PrfAsn1::HmacWithSha512 => Pkcs12HashAlgorithm::Sha512,
Pbkdf2PrfAsn1::Unknown(raw) => {
let oid = raw.algorithm().clone();
return Err(Pkcs12Error::NotSupportedAlgorithm {
algorithm: UnsupportedPkcs12Algorithm::Oid(oid),
context: "Crypto operation (pbkdf2 prf algorithm)".to_string(),
});
}
};
Ok(algorithm)
}
pub(crate) fn from_asn1_digest_algorithm(value: &Pkcs12DigestAlgorithmAsn1) -> Result<Self, Pkcs12Error> {
let algorithm = match value {
Pkcs12DigestAlgorithm::Sha1 => Pkcs12HashAlgorithm::Sha1,
Pkcs12DigestAlgorithm::Sha224 => Pkcs12HashAlgorithm::Sha224,
Pkcs12DigestAlgorithm::Sha256 => Pkcs12HashAlgorithm::Sha256,
Pkcs12DigestAlgorithm::Sha384 => Pkcs12HashAlgorithm::Sha384,
Pkcs12DigestAlgorithm::Sha512 => Pkcs12HashAlgorithm::Sha512,
Pkcs12DigestAlgorithm::Unknown(raw) => {
let oid = raw.algorithm().clone();
return Err(Pkcs12Error::NotSupportedAlgorithm {
algorithm: UnsupportedPkcs12Algorithm::Oid(oid),
context: "MAC calculation (pbkdf1 prf algorithm)".to_string(),
});
}
};
Ok(algorithm)
}
}
impl From<Pkcs12HashAlgorithm> for Pkcs12DigestAlgorithm {
fn from(value: Pkcs12HashAlgorithm) -> Self {
match value {
Pkcs12HashAlgorithm::Sha1 => Self::Sha1,
Pkcs12HashAlgorithm::Sha224 => Self::Sha224,
Pkcs12HashAlgorithm::Sha256 => Self::Sha256,
Pkcs12HashAlgorithm::Sha384 => Self::Sha384,
Pkcs12HashAlgorithm::Sha512 => Self::Sha512,
}
}
}
#[derive(Debug, Error)]
pub enum Pkcs12Error {
#[error("Not supported algorithm `{algorithm}` in context of {context}")]
NotSupportedAlgorithm {
algorithm: UnsupportedPkcs12Algorithm,
context: String,
},
#[error("Failed to perform PBES1 crypto operation: {context}")]
Pbes1 { context: String },
#[error("Failed to perform PBES2 crypto operation: {context}")]
Pbes2 { context: String },
#[error(transparent)]
CharSet(#[from] CharSetError),
#[error(transparent)]
Mac(#[from] mac::Pkcs12MacError),
#[error("Invalid ASN.1 DER encoding")]
Asn1Der(#[from] picky_asn1_der::Asn1DerError),
#[error(transparent)]
Key(#[from] crate::key::KeyError),
#[error(transparent)]
Certificate(#[from] crate::x509::certificate::CertError),
#[error(transparent)]
RandError(#[from] rand::rngs::SysError),
#[error("Not supported or invalid PFX version: {0}")]
InvalidVersion(u8),
#[error("Invalid PFX AuthenticatedSafe content type: {0}")]
InvalidAuthenticatedSafeContentType(UnsupportedPkcs12Algorithm),
#[error("Unexpected attribute values count. Expected: `{expected}`, got: `{actual}`")]
UnexpectedAttributeValuesCount { expected: usize, actual: usize },
}
#[derive(Debug, Clone)]
pub enum UnsupportedPkcs12Algorithm {
Named(&'static str),
Oid(ObjectIdentifier),
}
impl From<&'static str> for UnsupportedPkcs12Algorithm {
fn from(name: &'static str) -> Self {
Self::Named(name)
}
}
impl From<ObjectIdentifier> for UnsupportedPkcs12Algorithm {
fn from(oid: ObjectIdentifier) -> Self {
Self::Oid(oid)
}
}
impl Display for UnsupportedPkcs12Algorithm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UnsupportedPkcs12Algorithm::Named(name) => f.write_str(name),
UnsupportedPkcs12Algorithm::Oid(oid) => {
write!(f, "OID({})", format_oid(oid))
}
}
}
}
impl Pkcs12Error {
pub fn unsupported_algorithm(algorithm: impl Into<UnsupportedPkcs12Algorithm>, context: impl Into<String>) -> Self {
Self::NotSupportedAlgorithm {
algorithm: algorithm.into(),
context: context.into(),
}
}
}
fn format_oid(oid: &ObjectIdentifier) -> String {
let oid_str: String = oid.clone().into();
format!("OID({oid_str})")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::key::PrivateKey;
use crate::pem::Pem;
use rstest::rstest;
#[test]
fn pfx_certmgr_aes256() {
let encoded = picky_test_data::CERTMGR_AES256;
let crypto_context = Pkcs12CryptoContext::new_with_password("test").unwrap();
let _decoded = Pfx::from_der(encoded, &crypto_context, &Pkcs12ParsingParams::default()).unwrap();
}
#[test]
fn pfx_certmgr_3des() {
let encoded = picky_test_data::CERTMGR_3DES;
let crypto_context = Pkcs12CryptoContext::new_with_password("test").unwrap();
let _decoded = Pfx::from_der(encoded, &crypto_context, &Pkcs12ParsingParams::default()).unwrap();
}
#[test]
fn pfx_certmgr_rc2() {
let encoded = picky_test_data::LEAF_PASSWORD_IS_ABC;
let crypto_context = Pkcs12CryptoContext::new_with_password("abc").unwrap();
let _decoded = Pfx::from_der(encoded, &crypto_context, &Pkcs12ParsingParams::default()).unwrap();
}
#[test]
fn pfx_certmgr_rc2_empty_pass() {
let encoded = picky_test_data::LEAF_EMPTY_PASSWORD;
let crypto_context = Pkcs12CryptoContext::new_without_password().unwrap();
let _decoded = Pfx::from_der(encoded, &crypto_context, &Pkcs12ParsingParams::default()).unwrap();
}
#[test]
fn pfx_openssl_aes_empty_pass() {
let encoded = picky_test_data::OPENSSL_NOCRYPT;
let crypto_context = Pkcs12CryptoContext::new_without_password().unwrap();
let _decoded = Pfx::from_der(encoded, &crypto_context, &Pkcs12ParsingParams::default()).unwrap();
}
fn stable_rand() -> impl rand_core::CryptoRng {
use rand_core::SeedableRng as _;
rand_chacha::ChaChaRng::seed_from_u64(42)
}
fn build_cert_bags() -> [SafeBag; 3] {
let leaf = crate::x509::Cert::from_der(picky_test_data::ASSERT_LEAF).unwrap();
let intermediate = crate::x509::Cert::from_der(picky_test_data::ASSERT_INTERMEDIATE).unwrap();
let root = crate::x509::Cert::from_der(picky_test_data::ASSERT_ROOT).unwrap();
let leaf_cert_bag = SafeBag::new_certificate(leaf, build_leaf_attributes()).unwrap();
let intermediate_cert_bag = SafeBag::new_certificate(
intermediate,
vec![Pkcs12Attribute::new_friendly_name(
"PICKY_INTERMEDIATE".parse().unwrap(),
)],
)
.unwrap();
let root_cert_bag = SafeBag::new_certificate(
root,
vec![Pkcs12Attribute::new_friendly_name("PICKY_ROOT".parse().unwrap())],
)
.unwrap();
[leaf_cert_bag, intermediate_cert_bag, root_cert_bag]
}
fn build_leaf_attributes() -> Vec<Pkcs12Attribute> {
vec![
Pkcs12Attribute::new_local_key_id([0x01, 0x00, 0x00, 0x00]),
Pkcs12Attribute::new_friendly_name("PICKY_LEAF".parse().unwrap()),
]
}
fn make_crypto_context(password: Option<&str>) -> Pkcs12CryptoContext {
if let Some(password) = password {
Pkcs12CryptoContext::new_with_password(password)
.unwrap()
.with_rng(stable_rand())
} else {
Pkcs12CryptoContext::new_without_password()
.unwrap()
.with_rng(stable_rand())
}
}
fn validate_pfx(der_data: &[u8], password: Option<&str>) {
let crypto_context = make_crypto_context(password);
// Check that we can decode PFX encoded by picky itself
let _decoded = Pfx::from_der(der_data, &crypto_context, &Pkcs12ParsingParams::default()).unwrap();
#[cfg(windows)]
{
let temp_path = tempfile::NamedTempFile::new().unwrap().into_temp_path();
std::fs::write(&temp_path, der_data).unwrap();
let certutil_args = vec![
"-dump".to_string(),
"-p".to_string(),
password.unwrap_or("").to_string(),
temp_path.to_str().unwrap().to_string(),
];
let certutil_output = std::process::Command::new("certutil")
.args(certutil_args)
.output()
.unwrap();
assert!(
certutil_output.status.success(),
"certutil failed: {}",
String::from_utf8_lossy(&certutil_output.stdout)
);
}
}
fn encryption_3des(crypto_context: &mut Pkcs12CryptoContext) -> Pkcs12Encryption {
Pkcs12Encryption::new_pbes1(Pbes1Encryption::new(Pbes1Cipher::ShaAnd3Key3DesCbc), crypto_context)
}
fn encryption_rc2(crypto_context: &mut Pkcs12CryptoContext) -> Pkcs12Encryption {
Pkcs12Encryption::new_pbes1(Pbes1Encryption::new(Pbes1Cipher::ShaAnd40BitRc2Cbc), crypto_context)
}
fn encryption_aes256(crypto_context: &mut Pkcs12CryptoContext) -> Pkcs12Encryption {
Pkcs12Encryption::new_pbes2(
Pbes2Encryption::new(Pbes2Cipher::Aes256Cbc, Pkcs12HashAlgorithm::Sha256),
crypto_context,
)
}
pub fn leaf_private_key_rsa() -> PrivateKey {
let pem = picky_test_data::RSA_2048_PK_3.parse::<Pem>().unwrap();
PrivateKey::from_pkcs8(pem.data()).unwrap()
}
type EncryptionFn = fn(&mut Pkcs12CryptoContext) -> Pkcs12Encryption;
#[rstest]
#[case(encryption_3des, Some("test"), Pkcs12HashAlgorithm::Sha1)]
#[case(encryption_rc2, Some("test"), Pkcs12HashAlgorithm::Sha1)]
#[case(encryption_aes256, Some("test"), Pkcs12HashAlgorithm::Sha256)]
#[case(encryption_3des, None, Pkcs12HashAlgorithm::Sha1)]
// RC2 uses same KDF as 3DES so we could skip case of RC2 without password
#[case(encryption_aes256, None, Pkcs12HashAlgorithm::Sha256)]
fn build_pfx_encrypted(
#[case] encryption_fn: EncryptionFn,
#[case] password: Option<&'static str>,
#[case] hmac_algorithm: Pkcs12HashAlgorithm,
) {
let leaf_key = leaf_private_key_rsa();
let [leaf_cert_bag, intermediate_cert_bag, root_cert_bag] = build_cert_bags();
let mut crypto_context = make_crypto_context(password);
let key_encryption = encryption_fn(&mut crypto_context);
let leaf_key_bag =
SafeBag::new_encrypted_key(leaf_key, build_leaf_attributes(), key_encryption, &crypto_context).unwrap();
let cert_encryption = encryption_fn(&mut crypto_context);
let cert_safe_contents = SafeContents::new_encrypted(
vec![leaf_cert_bag, intermediate_cert_bag, root_cert_bag],
cert_encryption,
&crypto_context,
)
.unwrap();
let key_safe_contents = SafeContents::new(vec![leaf_key_bag]);
let secret_safe_bag = SafeBag::new_secret(
SecretSafeBag::new(picky_asn1_x509::oids::content_info_type_data(), &42u8).unwrap(),
vec![Pkcs12Attribute::new_custom(
CustomPkcs12Attribute::new_single_value(picky_asn1_x509::oids::content_info_type_data(), &256u64)
.unwrap(),
)],
);
let secret_safe_contents = SafeContents::new(vec![secret_safe_bag]);
let pfx = Pfx::new_with_hmac(
vec![cert_safe_contents, key_safe_contents, secret_safe_contents],
Pkcs12MacAlgorithmHmac::new(hmac_algorithm),
&mut crypto_context,
)
.unwrap();
let der_data = pfx.to_der().unwrap();
validate_pfx(&der_data, password);
}
}
+114
View File
@@ -0,0 +1,114 @@
use crate::pkcs12::Pkcs12HashAlgorithm;
pub enum Pbkdf1Usage {
Key,
Iv,
Mac,
}
impl Pbkdf1Usage {
fn to_id_byte(&self) -> u8 {
match self {
Pbkdf1Usage::Key => 1,
Pbkdf1Usage::Iv => 2,
Pbkdf1Usage::Mac => 3,
}
}
}
/// PBKDF1 implementation for PKCS#12 as defined in [RFC](https://datatracker.ietf.org/doc/html/rfc7292#appendix-B.2)
pub fn pbkdf1(
hash: Pkcs12HashAlgorithm,
password: &[u8],
salt: &[u8],
kdf_iterations: usize,
usage: Pbkdf1Usage,
output_size: usize,
) -> Vec<u8> {
let u = hash.pbkdf1_u_bits() / 8;
let v = hash.pbkdf1_v_bits() / 8;
let hash_round = match hash {
Pkcs12HashAlgorithm::Sha1 => pbkdf1_hash_round::<sha1::Sha1>,
Pkcs12HashAlgorithm::Sha224 => pbkdf1_hash_round::<sha2::Sha224>,
Pkcs12HashAlgorithm::Sha256 => pbkdf1_hash_round::<sha2::Sha256>,
Pkcs12HashAlgorithm::Sha384 => pbkdf1_hash_round::<sha2::Sha384>,
Pkcs12HashAlgorithm::Sha512 => pbkdf1_hash_round::<sha2::Sha512>,
};
// Construct "diversifier" string
let d = vec![usage.to_id_byte(); v];
let expanded_length = |len: usize| v * len.div_ceil(v);
// Expand salt and password length to multiple of V
let expanded_salt = salt.iter().cycle().take(expanded_length(salt.len()));
let expanded_password = password.iter().cycle().take(expanded_length(password.len()));
// I = S || P
let mut key_material: Vec<u8> = expanded_salt.chain(expanded_password).cloned().collect();
let c = output_size.div_ceil(u);
let mut output: Vec<u8> = vec![];
// Temporary buffer for key blocks produced by SHA1
let mut key_block = vec![];
let mut b = vec![];
for _ in 1..c {
hash_round(&d, &key_material, kdf_iterations, &mut key_block);
output.extend_from_slice(&key_block);
// Create concatenated string B of length V
b.clear();
b.extend(key_block.iter().cycle().take(v).copied());
// Pretty convoluted operation which is defined in RFC as follows:
//
// C. Treating I as a concatenation I_0, I_1, ..., I_(k-1) of v-bit
// blocks, where k=ceiling(s/v)+ceiling(p/v), modify I by
// setting I_j=(I_j+B+1) mod 2^v for each j.
//
// Implementation of this part has been borrowed from [p12 crate](https://github.com/hjiayz/p12)
let b_iter = b.iter().rev().cycle().take(key_material.len());
let i_b_iter = key_material.iter_mut().rev().zip(b_iter);
let mut inc = 1u8;
for (i3, (ii, bi)) in i_b_iter.enumerate() {
if (i3 % v) == 0 {
inc = 1;
}
let (ii2, inc2) = ii.overflowing_add(*bi);
let (ii3, inc3) = ii2.overflowing_add(inc);
inc = (inc2 || inc3) as u8;
*ii = ii3;
}
}
hash_round(&d, &key_material, kdf_iterations, &mut key_block);
output.extend_from_slice(&key_block);
// Truncate to output_size
output.resize(output_size, 0);
output
}
fn pbkdf1_hash_round<H: digest::Digest + digest::FixedOutputReset>(
d: &[u8],
i: &[u8],
iterations: usize,
output_buffer: &mut Vec<u8>,
) {
let mut hasher = H::new();
output_buffer.clear();
output_buffer.extend_from_slice(d);
output_buffer.extend_from_slice(i);
for _ in 0..iterations {
digest::Digest::update(&mut hasher, &output_buffer);
let hash = hasher.finalize_reset();
output_buffer.clear();
output_buffer.extend_from_slice(&hash[..]);
}
}
+331
View File
@@ -0,0 +1,331 @@
use crate::key::PrivateKey;
use crate::pkcs12::{Pkcs12Attribute, Pkcs12CryptoContext, Pkcs12Encryption, Pkcs12Error, Pkcs12ParsingParams};
use crate::x509::Cert;
use picky_asn1::wrapper::OctetStringAsn1;
use picky_asn1_der::Asn1RawDer;
use picky_asn1_x509::oid::ObjectIdentifier;
use picky_asn1_x509::pkcs12::{
CertificateBag as CertificateBagAsn1, EncryptedKeyBag as EncryptedKeyBagAsn1,
Pkcs12Attribute as Pkcs12AttributeAsn1, SafeBag as SafeBagAsn1, SafeBagKind as SafeBagKindAsn1,
SafeContents as SafeContentsAsn1, SecretBag as SecretBagAsn1,
};
use serde::{Deserialize, Serialize};
/// PFX safe bag, see module docs for more information
#[derive(Debug, Clone)]
pub struct SafeBag {
kind: SafeBagKind,
attributes: Vec<Pkcs12Attribute>,
inner: SafeBagAsn1,
}
impl SafeBag {
/// Create new safe bag holding a private key
pub fn new_key(key: PrivateKey, attributes: Vec<Pkcs12Attribute>) -> Result<Self, Pkcs12Error> {
// Convert to `PrivateKeyInfo` structure in DER representation
let der_data = key.to_pkcs8()?;
let inner = SafeBagAsn1 {
kind: SafeBagKindAsn1::Key(Asn1RawDer(der_data)),
attributes: attributes_to_asn1(&attributes),
};
Ok(Self {
kind: SafeBagKind::PrivateKey(key),
attributes,
inner,
})
}
/// Create new safe bag with encrypted key. Note that attributes are not encrypted.
pub fn new_encrypted_key(
key: PrivateKey,
attributes: Vec<Pkcs12Attribute>,
encryption: Pkcs12Encryption,
crypto_context: &Pkcs12CryptoContext,
) -> Result<Self, Pkcs12Error> {
let der_data = key.to_pkcs8()?;
let encrypted = encryption.encrypt(&der_data, crypto_context)?;
let inner = SafeBagAsn1 {
kind: SafeBagKindAsn1::EncryptedKey(EncryptedKeyBagAsn1 {
algorithm: encryption.inner().clone(),
encrypted_data: OctetStringAsn1(encrypted),
}),
attributes: attributes_to_asn1(&attributes),
};
Ok(Self {
kind: SafeBagKind::EncryptedPrivateKey { encryption, key },
attributes,
inner,
})
}
/// Create new safe bag with certificate
pub fn new_certificate(cert: Cert, attributes: Vec<Pkcs12Attribute>) -> Result<Self, Pkcs12Error> {
let der_data = cert.to_der()?;
let inner = SafeBagAsn1 {
kind: SafeBagKindAsn1::Certificate(CertificateBagAsn1::X509(OctetStringAsn1(der_data))),
attributes: attributes_to_asn1(&attributes),
};
Ok(Self {
kind: SafeBagKind::Certificate(cert),
attributes,
inner,
})
}
/// Creates new [`SecretSafeBag`] bag
pub fn new_secret(secret: SecretSafeBag, attributes: Vec<Pkcs12Attribute>) -> Self {
let inner = SafeBagAsn1 {
kind: SafeBagKindAsn1::Secret(SecretBagAsn1 {
type_id: secret.oid.clone(),
value: secret.data.clone(),
}),
attributes: attributes_to_asn1(&attributes),
};
Self {
kind: SafeBagKind::Secret(secret),
attributes,
inner,
}
}
/// Creates safe bag with nested safe bag list.
pub fn new_nested(safe_bags: Vec<SafeBag>, attributes: Vec<Pkcs12Attribute>) -> Self {
let safe_contents = SafeContentsAsn1(safe_bags.iter().map(|sb| sb.inner.clone()).collect());
let inner = SafeBagAsn1 {
kind: SafeBagKindAsn1::SafeContents(safe_contents),
attributes: attributes_to_asn1(&attributes),
};
Self {
kind: SafeBagKind::Nested(safe_bags),
attributes,
inner,
}
}
/// PKCS#12 allows for arbitrary SafeBags to be included in the PKCS#12 file as long as they
/// a unique OID.
pub fn new_custom(oid: ObjectIdentifier, value: Asn1RawDer, attributes: Vec<Pkcs12Attribute>) -> Self {
let inner = SafeBagAsn1 {
kind: SafeBagKindAsn1::Unknown { type_id: oid, value },
attributes: attributes_to_asn1(&attributes),
};
Self {
kind: SafeBagKind::Unknown,
attributes,
inner,
}
}
pub(crate) fn from_asn1(
safe_bag: SafeBagAsn1,
crypto_context: &Pkcs12CryptoContext,
parsing_params: &Pkcs12ParsingParams,
) -> Result<Self, Pkcs12Error> {
let attributes = safe_bag
.attributes
.clone()
.unwrap_or_default()
.into_iter()
.map(Pkcs12Attribute::from_asn1)
.collect::<Vec<_>>();
let to_unparsed = |inner, attributes| Self {
kind: SafeBagKind::Unknown,
attributes,
inner,
};
let kind = match &safe_bag.kind {
SafeBagKindAsn1::Key(Asn1RawDer(der_data)) => {
let key = match PrivateKey::from_pkcs8(&der_data) {
Ok(key) => key,
Err(_) if parsing_params.skip_soft_parsing_errors => {
return Ok(to_unparsed(safe_bag, attributes));
}
Err(e) => return Err(e.into()),
};
SafeBagKind::PrivateKey(key)
}
SafeBagKindAsn1::EncryptedKey(encrypted_key) => {
let encryption = match Pkcs12Encryption::from_asn1(encrypted_key.algorithm.clone()) {
Ok(encryption) => encryption,
Err(_) if parsing_params.skip_decryption_errors => {
return Ok(to_unparsed(safe_bag, attributes));
}
Err(e) => return Err(e),
};
let der_data = match encryption.decrypt(&encrypted_key.encrypted_data.0, crypto_context) {
Ok(der_data) => der_data,
Err(_) if parsing_params.skip_decryption_errors => {
return Ok(to_unparsed(safe_bag, attributes));
}
Err(e) => return Err(e),
};
let key = match PrivateKey::from_pkcs8(&der_data) {
Ok(key) => key,
Err(_) if parsing_params.skip_soft_parsing_errors => {
return Ok(to_unparsed(safe_bag, attributes));
}
Err(e) => return Err(e.into()),
};
SafeBagKind::EncryptedPrivateKey { encryption, key }
}
SafeBagKindAsn1::Certificate(CertificateBagAsn1::X509(OctetStringAsn1(der_data))) => {
let cert = match Cert::from_der(&der_data) {
Ok(cert) => cert,
Err(_) if parsing_params.skip_soft_parsing_errors => {
return Ok(to_unparsed(safe_bag, attributes));
}
Err(e) => return Err(e.into()),
};
SafeBagKind::Certificate(cert)
}
SafeBagKindAsn1::Secret(SecretBagAsn1 { type_id, value }) => {
let secret = SecretSafeBag {
oid: type_id.clone(),
data: value.clone(),
};
SafeBagKind::Secret(secret)
}
SafeBagKindAsn1::SafeContents(safe_contents) => {
let safe_bags = safe_contents
.0
.iter()
.map(|sb| Self::from_asn1(sb.clone(), crypto_context, parsing_params))
.collect::<Result<Vec<_>, _>>()?;
SafeBagKind::Nested(safe_bags)
}
SafeBagKindAsn1::Crl(_)
| SafeBagKindAsn1::Certificate(CertificateBagAsn1::Unknown { .. })
| SafeBagKindAsn1::Unknown { .. } => {
return Ok(to_unparsed(safe_bag, attributes));
}
};
Ok(Self {
kind,
attributes,
inner: safe_bag,
})
}
/// Adds a PKCS12 attribute to this safe bag.
///
/// Note that there is an additional performance cost: the inner DER representation must be updated.
pub fn add_attribute(&mut self, attribute: Pkcs12Attribute) {
self.attributes.push(attribute);
self.inner.attributes = attributes_to_asn1(&self.attributes);
}
pub fn attributes(&self) -> &[Pkcs12Attribute] {
&self.attributes
}
pub fn kind(&self) -> &SafeBagKind {
&self.kind
}
pub fn into_kind(self) -> SafeBagKind {
self.kind
}
pub fn inner(&self) -> &SafeBagAsn1 {
&self.inner
}
pub fn into_inner(self) -> SafeBagAsn1 {
self.inner
}
}
/// Parsed safe bag representation.
#[derive(Debug)]
pub enum SafeBagKind {
PrivateKey(PrivateKey),
EncryptedPrivateKey {
encryption: Pkcs12Encryption,
key: PrivateKey,
},
Certificate(Cert),
Secret(SecretSafeBag),
Nested(Vec<SafeBag>),
Unknown,
}
impl Clone for SafeBagKind {
fn clone(&self) -> Self {
match self {
Self::PrivateKey(key) => Self::PrivateKey(key.clone()),
Self::EncryptedPrivateKey { encryption, key } => Self::EncryptedPrivateKey {
encryption: encryption.duplicate(),
key: key.clone(),
},
Self::Certificate(cert) => Self::Certificate(cert.clone()),
Self::Secret(secret) => Self::Secret(secret.clone()),
Self::Nested(nested) => Self::Nested(nested.clone()),
Self::Unknown => Self::Unknown,
}
}
}
/// Secret bag which could contain any user-defined data, as long as it could be DER-encoded.
/// It is advised to use types from `picky-asn1-der` crate.
#[derive(Debug, Clone)]
pub struct SecretSafeBag {
oid: ObjectIdentifier,
data: Asn1RawDer,
}
impl SecretSafeBag {
pub fn new_raw(oid: ObjectIdentifier, data: Asn1RawDer) -> Self {
Self { oid, data }
}
/// Create new secret bag from serializable data.
pub fn new<T: Serialize>(oid: ObjectIdentifier, value: &T) -> Result<Self, Pkcs12Error> {
let encoded = picky_asn1_der::to_vec(value)?;
Ok(Self {
oid,
data: Asn1RawDer(encoded),
})
}
pub fn oid(&self) -> &ObjectIdentifier {
&self.oid
}
pub fn raw_data(&self) -> &[u8] {
&self.data.0
}
/// Get secret bag data as deserialized type.
pub fn get_data<'a, T: Deserialize<'a>>(&'a self) -> Result<T, Pkcs12Error> {
let deserialized = picky_asn1_der::from_bytes(&self.data.0)?;
Ok(deserialized)
}
}
fn attributes_to_asn1(attributes: &[Pkcs12Attribute]) -> Option<Vec<Pkcs12AttributeAsn1>> {
if attributes.is_empty() {
None
} else {
Some(attributes.iter().map(|a| a.inner().clone()).collect())
}
}
+170
View File
@@ -0,0 +1,170 @@
use crate::pkcs12::{Pkcs12CryptoContext, Pkcs12Encryption, Pkcs12Error, Pkcs12ParsingParams, SafeBag};
use picky_asn1::wrapper::OctetStringAsn1;
use picky_asn1_x509::pkcs12::{
EncryptedSafeContents as EncryptedSafeContentsAsn1, SafeContents as SafeContentsAsn1,
SafeContentsContentInfo as SafeContentsContentInfoAsn1,
};
/// Top-level PFX container object, which holds list of safe bags and could be encrypted or not.
#[derive(Debug, Clone)]
pub struct SafeContents {
kind: SafeContentsKind,
inner: SafeContentsContentInfoAsn1,
}
impl SafeContents {
pub(crate) fn from_asn1(
inner: SafeContentsContentInfoAsn1,
crypto_context: &Pkcs12CryptoContext,
parsing_params: &Pkcs12ParsingParams,
) -> Result<Self, Pkcs12Error> {
let to_unparsed = |inner| Self {
kind: SafeContentsKind::Unknown,
inner,
};
let kind = match &inner {
SafeContentsContentInfoAsn1::Data(data) => {
let safe_bags = data
.0
.iter()
.map(|sb| SafeBag::from_asn1(sb.clone(), crypto_context, parsing_params))
.collect::<Result<Vec<_>, _>>()?;
Self {
kind: SafeContentsKind::SafeBags(safe_bags),
inner,
}
}
SafeContentsContentInfoAsn1::EncryptedData(encrypted) => {
let encryption = match Pkcs12Encryption::from_asn1(encrypted.algorithm.clone()) {
Ok(encryption) => encryption,
Err(_) if parsing_params.skip_decryption_errors => {
return Ok(to_unparsed(inner));
}
Err(e) => {
return Err(e);
}
};
let encrypted_content = match encrypted.encrypted_content.as_ref() {
Some(content) => content.0.as_slice(),
None => {
return Ok(Self {
// No content to decrypt
kind: SafeContentsKind::EncryptedSafeBags {
encryption,
safe_bags: vec![],
},
inner,
});
}
};
let decrypted = match encryption.decrypt(encrypted_content, crypto_context) {
Ok(decrypted) => decrypted,
Err(_) if parsing_params.skip_decryption_errors => {
return Ok(to_unparsed(inner));
}
Err(e) => {
return Err(e);
}
};
let safe_bags_asn1 = match picky_asn1_der::from_bytes::<SafeContentsAsn1>(&decrypted) {
Ok(safe_contents) => safe_contents.0,
Err(_) if parsing_params.skip_decryption_errors => {
return Ok(to_unparsed(inner));
}
Err(e) => {
return Err(e.into());
}
};
let safe_bags = safe_bags_asn1
.into_iter()
.map(|sb| SafeBag::from_asn1(sb, crypto_context, parsing_params))
.collect::<Result<Vec<_>, _>>()?;
Self {
kind: SafeContentsKind::EncryptedSafeBags { encryption, safe_bags },
inner,
}
}
SafeContentsContentInfoAsn1::Unknown { .. } => return Ok(to_unparsed(inner)),
};
Ok(kind)
}
pub fn new(safe_bags: Vec<SafeBag>) -> Self {
let safe_contents = SafeContentsAsn1(safe_bags.iter().map(|sb| sb.inner().clone()).collect());
Self {
kind: SafeContentsKind::SafeBags(safe_bags),
inner: SafeContentsContentInfoAsn1::Data(safe_contents),
}
}
pub fn new_encrypted(
safe_bags: Vec<SafeBag>,
encryption: Pkcs12Encryption,
crypto_context: &Pkcs12CryptoContext,
) -> Result<Self, Pkcs12Error> {
let safe_contents = SafeContentsAsn1(safe_bags.iter().map(|sb| sb.inner().clone()).collect());
let der_data = picky_asn1_der::to_vec(&safe_contents)?;
let encrypted = encryption.encrypt(&der_data, crypto_context)?;
let inner = SafeContentsContentInfoAsn1::EncryptedData(EncryptedSafeContentsAsn1 {
algorithm: encryption.inner().clone(),
encrypted_content: Some(OctetStringAsn1(encrypted)),
});
Ok(Self {
kind: SafeContentsKind::EncryptedSafeBags { encryption, safe_bags },
inner,
})
}
pub fn kind(&self) -> &SafeContentsKind {
&self.kind
}
pub fn into_kind(self) -> SafeContentsKind {
self.kind
}
pub fn inner(&self) -> &SafeContentsContentInfoAsn1 {
&self.inner
}
pub fn into_inner(self) -> SafeContentsContentInfoAsn1 {
self.inner
}
}
// Clippy triggers lint because of relatively big `Pkcs12Encryption` (~200 bytes) in comparison with
// `SafeContentsKind::Unknown` (0 bytes), but just to keep it consistent with other enums, we do allow
// such difference in size.
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub enum SafeContentsKind {
SafeBags(Vec<SafeBag>),
EncryptedSafeBags {
encryption: Pkcs12Encryption,
safe_bags: Vec<SafeBag>,
},
Unknown,
}
impl Clone for SafeContentsKind {
fn clone(&self) -> Self {
match self {
Self::SafeBags(bags) => Self::SafeBags(bags.clone()),
Self::EncryptedSafeBags { encryption, safe_bags } => Self::EncryptedSafeBags {
encryption: encryption.duplicate(),
safe_bags: safe_bags.clone(),
},
Self::Unknown => Self::Unknown,
}
}
}
+53
View File
@@ -0,0 +1,53 @@
#[derive(thiserror::Error, Debug)]
pub enum PuttyError {
#[error("end of input")]
EndOfInput,
#[error("invalid input")]
InvalidInput {
context: &'static str,
expected: &'static str,
actual: String,
},
#[error("invalid key value format")]
InvalidKeyValueFormat,
#[error("AES encryption failed")]
Aes,
#[error("invalid argon2 params")]
Argon2,
#[error("MAC validation failed (wrong password or corrupted data)")]
MacValidation,
#[error("public and private key mismatch")]
PublicAndPrivateKeyMismatch,
#[error("invalid private key data")]
InvalidPrivateKeyData,
#[error("invalid public key data")]
InvalidPublicKeyData,
#[error("invalid public key container")]
InvalidPublicKeyContainer,
#[error("invalid public key comment")]
InvalidPublicKeyComment,
#[error("private key is already decrypted")]
AlreadyDecrypted,
#[error("private key is already encrypted")]
AlreadyEncrypted,
#[error("private key decryption is required prior to this operation")]
Encrypted,
#[error("unsupported feature")]
NotSupported { feature: &'static str },
#[error("RSA params precomputation failed")]
RsaPrecompute,
#[error("RSA primes count should be exactly 2")]
RsaInvalidPrimesCount { count: usize },
#[error(transparent)]
SshPublicKey(#[from] crate::ssh::public_key::SshPublicKeyError),
#[error(transparent)]
SshPivateKey(#[from] crate::ssh::private_key::SshPrivateKeyError),
#[error(transparent)]
KeyError(#[from] crate::key::KeyError),
#[error(transparent)]
IoError(#[from] std::io::Error),
#[error(transparent)]
OutIsTooSmallError(#[from] inout::OutIsTooSmallError),
#[error(transparent)]
RandError(#[from] rand::rngs::SysError),
}
+152
View File
@@ -0,0 +1,152 @@
macro_rules! ppk_const {
($name:ident, $key:expr) => {
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct $name;
impl std::str::FromStr for $name {
type Err = $crate::putty::key_value::PpkValueParsingError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == $key {
Ok(Self)
} else {
Err($crate::putty::key_value::PpkValueParsingError {
expected: $key,
actual: s.to_string(),
})
}
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str($key)
}
}
impl PpkLiteral for $name {
fn context() -> &'static str {
stringify!($name)
}
fn as_static_str(&self) -> &'static str {
$key
}
}
};
}
macro_rules! impl_ppk_enum_expected_str {
($first:expr, $($keys:expr),+) => {
concat!($first, ", ", $($keys),+)
};
($first:expr) => {
$first
};
}
macro_rules! ppk_enum {
($name:ident, $($variant:ident => $key:expr),+) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $name {
$($variant),+
}
impl std::str::FromStr for $name {
type Err = $crate::putty::key_value::PpkValueParsingError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
$($key => Ok(Self::$variant),)+
_ => Err($crate::putty::key_value::PpkValueParsingError {
expected: concat!("[", impl_ppk_enum_expected_str!($($key),+), "]"),
actual: s.to_string()
})
}
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
$(Self::$variant => f.write_str($key),)+
}
}
}
impl PpkLiteral for $name {
fn context() -> &'static str {
stringify!($name)
}
fn as_static_str(&self) -> &'static str {
match self {
$(Self::$variant => $key,)+
}
}
}
};
}
macro_rules! ppk_generic_value {
($name:ident, $type:ident) => {
pub struct $name($type);
impl std::str::FromStr for $name {
type Err = $crate::putty::key_value::PpkValueParsingError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse()
.map(Self)
.map_err(|_| $crate::putty::key_value::PpkValueParsingError {
expected: concat!("<valid ", stringify!($type), " value>"),
actual: s.to_string(),
})
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl From<$type> for $name {
fn from(value: $type) -> Self {
Self(value)
}
}
impl From<$name> for $type {
fn from(value: $name) -> Self {
value.0
}
}
};
}
macro_rules! ppk_key_value {
($name:ident, $key:ident, $value:ident) => {
pub struct $name;
impl $crate::putty::key_value::PpkKeyValue for $name {
type Key = $key;
type Value = $value;
}
};
}
macro_rules! ppk_multiline_key_value {
($name:ident, $key:ident, $value:ident) => {
pub struct $name;
impl $crate::putty::key_value::PpkMultilineKeyValue for $name {
type Key = $key;
type Value = $value;
}
};
}
pub(crate) use {
impl_ppk_enum_expected_str, ppk_const, ppk_enum, ppk_generic_value, ppk_key_value, ppk_multiline_key_value,
};
+195
View File
@@ -0,0 +1,195 @@
//! This module provides a set of traits and macros and types to parse and write PuTTY key-value
//! format in strongly-typed manner.
mod macros;
mod reader;
mod writer;
use std::fmt;
use std::str::FromStr;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_ENGINE;
use self::macros::*;
pub(crate) use reader::PuttyKvReader;
pub(crate) use writer::PuttyKvWriter;
pub struct PpkValueParsingError {
pub expected: &'static str,
pub actual: String,
}
const KV_DELIMITER: &str = ": ";
/// Trait for keys/values that could be represented as singular or set of static strings.
pub(crate) trait PpkLiteral {
fn context() -> &'static str;
fn as_static_str(&self) -> &'static str;
}
/// Trait for key-value pairs that use multiline format.
pub(crate) trait PpkMultilineKeyValue {
type Key: FromStr<Err = PpkValueParsingError> + ToString + PpkLiteral;
type Value: FromStr<Err = PpkValueParsingError> + ToString;
}
/// Trait for key-value pairs that use single-line format.
pub(crate) trait PpkKeyValue {
type Key: FromStr<Err = PpkValueParsingError> + ToString + PpkLiteral;
type Value: FromStr<Err = PpkValueParsingError> + ToString;
}
/// Wrapper type for base64 multiline data inside PPK file.
pub(crate) struct Base64PpkValue(Vec<u8>);
impl FromStr for Base64PpkValue {
type Err = PpkValueParsingError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
BASE64_ENGINE
.decode(s)
.map_err(|_| PpkValueParsingError {
expected: "<valid base64 data>",
actual: s.to_string(),
})
.map(Self)
}
}
impl fmt::Display for Base64PpkValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", BASE64_ENGINE.encode(&self.0))
}
}
impl From<Vec<u8>> for Base64PpkValue {
fn from(value: Vec<u8>) -> Self {
Self(value)
}
}
impl From<Base64PpkValue> for Vec<u8> {
fn from(value: Base64PpkValue) -> Self {
value.0
}
}
/// Wrapper type for hex-string multiline data inside PPK file.
pub(crate) struct HexPpkValue(Vec<u8>);
impl FromStr for HexPpkValue {
type Err = PpkValueParsingError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
hex::decode(s)
.map_err(|_| PpkValueParsingError {
expected: "<valid hex data>",
actual: s.to_string(),
})
.map(Self)
}
}
impl fmt::Display for HexPpkValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", hex::encode(&self.0))
}
}
impl From<Vec<u8>> for HexPpkValue {
fn from(value: Vec<u8>) -> Self {
Self(value)
}
}
impl From<HexPpkValue> for Vec<u8> {
fn from(value: HexPpkValue) -> Self {
value.0
}
}
// Key value type definitions
ppk_enum!(
PpkVersionKey,
V2 => "PuTTY-User-Key-File-2",
V3 => "PuTTY-User-Key-File-3"
);
ppk_enum!(
PpkKeyAlgorithmValue,
Rsa => "ssh-rsa",
Dss => "ssh-dss",
EcdsaSha2Nistp256 => "ecdsa-sha2-nistp256",
EcdsaSha2Nistp384 => "ecdsa-sha2-nistp384",
EcdsaSha2Nistp521 => "ecdsa-sha2-nistp521",
Ed25519 => "ssh-ed25519",
Ed448 => "ssh-ed448"
);
ppk_key_value!(PpkHeader, PpkVersionKey, PpkKeyAlgorithmValue);
ppk_const!(PpkEncryptionKey, "Encryption");
ppk_enum!(
PpkEncryptionValue,
None => "none",
Aes256Cbc => "aes256-cbc"
);
ppk_key_value!(PpkEncryption, PpkEncryptionKey, PpkEncryptionValue);
ppk_const!(PpkCommentKey, "Comment");
ppk_generic_value!(PpkCommentValue, String);
ppk_key_value!(PpkComment, PpkCommentKey, PpkCommentValue);
ppk_const!(PpkPublicLinesKey, "Public-Lines");
ppk_multiline_key_value!(PpkPublicLines, PpkPublicLinesKey, Base64PpkValue);
ppk_const!(PpkKeyDerivationKey, "Key-Derivation");
ppk_enum!(
Argon2FlavourValue,
Argon2d => "Argon2d",
Argon2i => "Argon2i",
Argon2id => "Argon2id"
);
ppk_key_value!(PpkKeyDerivation, PpkKeyDerivationKey, Argon2FlavourValue);
ppk_const!(PpkArgon2MemoryKey, "Argon2-Memory");
ppk_generic_value!(PpkArgon2MemoryValue, u32);
ppk_key_value!(PpkArgon2Memory, PpkArgon2MemoryKey, PpkArgon2MemoryValue);
ppk_const!(PpkArgon2PassesKey, "Argon2-Passes");
ppk_generic_value!(PpkArgon2PassesValue, u32);
ppk_key_value!(PpkArgon2Passes, PpkArgon2PassesKey, PpkArgon2PassesValue);
ppk_const!(PpkArgon2ParallelismKey, "Argon2-Parallelism");
ppk_generic_value!(PpkArgon2ParallelismValue, u32);
ppk_key_value!(PpkArgon2Parallelism, PpkArgon2ParallelismKey, PpkArgon2ParallelismValue);
ppk_const!(PpkArgon2SaltKey, "Argon2-Salt");
ppk_key_value!(PpkArgon2Salt, PpkArgon2SaltKey, HexPpkValue);
ppk_const!(PpkPrivateLinesKey, "Private-Lines");
ppk_multiline_key_value!(PpkPrivateLines, PpkPrivateLinesKey, Base64PpkValue);
ppk_const!(PpkPrivateMacKey, "Private-MAC");
ppk_key_value!(PpkPrivateMac, PpkPrivateMacKey, HexPpkValue);
impl From<Argon2FlavourValue> for argon2::Algorithm {
fn from(value: Argon2FlavourValue) -> Self {
match value {
Argon2FlavourValue::Argon2d => argon2::Algorithm::Argon2d,
Argon2FlavourValue::Argon2i => argon2::Algorithm::Argon2i,
Argon2FlavourValue::Argon2id => argon2::Algorithm::Argon2id,
}
}
}
impl PpkKeyAlgorithmValue {
pub fn key_mpint_values_count(&self) -> usize {
match self {
PpkKeyAlgorithmValue::Rsa => 4,
PpkKeyAlgorithmValue::Dss
| PpkKeyAlgorithmValue::EcdsaSha2Nistp256
| PpkKeyAlgorithmValue::EcdsaSha2Nistp384
| PpkKeyAlgorithmValue::EcdsaSha2Nistp521
| PpkKeyAlgorithmValue::Ed25519
| PpkKeyAlgorithmValue::Ed448 => 1,
}
}
}
+87
View File
@@ -0,0 +1,87 @@
use super::{KV_DELIMITER, PpkKeyValue, PpkLiteral, PpkMultilineKeyValue, PpkValueParsingError};
use crate::putty::PuttyError;
/// Reader for PPK key-value format.
pub struct PuttyKvReader<'a> {
input: std::str::Lines<'a>,
}
impl<'a> PuttyKvReader<'a> {
pub fn from_str(input: &'a str) -> Self {
Self { input: input.lines() }
}
pub fn next_value<T: PpkKeyValue>(&mut self) -> Result<T::Value, PuttyError> {
let (_, value) = self.next_key_value::<T>()?;
Ok(value)
}
pub fn next_key_value<T: PpkKeyValue>(&mut self) -> Result<(T::Key, T::Value), PuttyError> {
let line = self.input.next().ok_or(PuttyError::EndOfInput)?;
let (key, value) = line.split_once(KV_DELIMITER).ok_or(PuttyError::InvalidKeyValueFormat)?;
let parsed_key = key
.parse()
.map_err(|e: PpkValueParsingError| PuttyError::InvalidInput {
context: T::Key::context(),
expected: e.expected,
actual: e.actual,
})?;
let parsed_value = value
.parse()
.map_err(|e: PpkValueParsingError| PuttyError::InvalidInput {
context: T::Key::context(),
expected: e.expected,
actual: e.actual,
})?;
Ok((parsed_key, parsed_value))
}
pub fn next_multiline_value<T: PpkMultilineKeyValue>(&mut self) -> Result<T::Value, PuttyError> {
let (_, value) = self.next_multiline_key_value::<T>()?;
Ok(value)
}
pub fn next_multiline_key_value<T: PpkMultilineKeyValue>(&mut self) -> Result<(T::Key, T::Value), PuttyError> {
let line = self.input.next().ok_or(PuttyError::EndOfInput)?;
let (key, value) = line.split_once(KV_DELIMITER).ok_or(PuttyError::InvalidKeyValueFormat)?;
// Parse key early to check if it's valid before reading multiline value
let parsed_key: T::Key = key
.parse()
.map_err(|e: PpkValueParsingError| PuttyError::InvalidInput {
context: T::Key::context(),
expected: e.expected,
actual: e.actual,
})?;
// NOTE: u16 is enough for multiline fields, as in PPK format they are storing
// base64-encoded private/public key data, and 65535 lines is more than enough for any
// supported PPK key type.
let lines_count: u16 = value.parse().map_err(|_| PuttyError::InvalidInput {
context: T::Key::context(),
expected: "<valid lines count>",
actual: value.to_string(),
})?;
let mut encoded = String::new();
for _ in 0..lines_count {
// NOTE: we do not preserve newlines for multiline fields as in PPK they are
// only used to split base64-encoded data into lines.
encoded.push_str(self.input.next().ok_or(PuttyError::EndOfInput)?);
}
let parsed_value = encoded
.parse()
.map_err(|e: PpkValueParsingError| PuttyError::InvalidInput {
context: T::Key::context(),
expected: e.expected,
actual: e.actual,
})?;
Ok((parsed_key, parsed_value))
}
}
+67
View File
@@ -0,0 +1,67 @@
use super::{KV_DELIMITER, PpkKeyValue, PpkLiteral as _, PpkMultilineKeyValue};
/// Writer for PPK key-value format.
pub(crate) struct PuttyKvWriter {
output: String,
line_end: &'static str,
}
impl PuttyKvWriter {
pub fn new(crlf: bool) -> Self {
Self {
output: String::new(),
line_end: if crlf { "\r\n" } else { "\n" },
}
}
fn write_key_value_impl(&mut self, key: &str, value: &str) {
self.output.push_str(key);
self.output.push_str(KV_DELIMITER);
self.output.push_str(value);
self.output.push_str(self.line_end);
}
pub fn write_value<T: PpkKeyValue>(&mut self, value: T::Value)
where
T::Key: Default,
{
self.write_key_value::<T>(T::Key::default(), value);
}
pub fn write_key_value<T: PpkKeyValue>(&mut self, key: T::Key, value: T::Value) {
self.write_key_value_impl(key.as_static_str(), &value.to_string());
}
pub fn write_multiline_value<T: PpkMultilineKeyValue>(&mut self, value: T::Value)
where
T::Key: Default,
{
self.write_multiline_key_value::<T>(T::Key::default(), value);
}
pub fn write_multiline_key_value<T: PpkMultilineKeyValue>(&mut self, key: T::Key, value: T::Value) {
// PuTTY uses a maximum of 64 characters per line
const MAX_CHARS_PER_LINE: usize = 64;
let value = value.to_string();
let lines_count = value.len() / MAX_CHARS_PER_LINE + (value.len() % MAX_CHARS_PER_LINE != 0) as usize;
self.write_key_value_impl(key.as_static_str(), &lines_count.to_string());
let mut value_remaining = value.as_str();
while !value_remaining.is_empty() {
let line_len = value_remaining.len().min(MAX_CHARS_PER_LINE);
let (line, remaining) = value_remaining.split_at(line_len);
value_remaining = remaining;
self.output.push_str(line);
self.output.push_str(self.line_end);
}
}
pub fn finish(self) -> String {
self.output
}
}
+18
View File
@@ -0,0 +1,18 @@
//! PuTTY key format described in [Appendix C][1] of the PuTTY User Manual.
//!
//! Both private([`Ppk`]) and public([`PuttyPublicKey`]) keys are supported.
//!
//! [1]: https://the.earth.li/~sgtatham/putty/0.75/htmldoc/AppendixC.html#ppk
mod error;
mod key_value;
mod ppk;
mod private_key;
mod public_key;
pub use error::PuttyError;
pub use key_value::{
Argon2FlavourValue as Argon2Flavour, PpkKeyAlgorithmValue as PpkKeyAlgorithm, PpkVersionKey as PpkVersion,
};
pub use ppk::{Argon2Params, Ppk, PpkEncryptionConfig, PpkEncryptionConfigBuilder};
pub use public_key::PuttyPublicKey;
+47
View File
@@ -0,0 +1,47 @@
//! AES encryption and decryption utilities.
use crate::putty::PuttyError;
use aes::cipher::KeyIvInit;
use aes::cipher::block_padding::NoPadding;
use cbc::cipher::{BlockModeDecrypt, BlockModeEncrypt};
use inout::InOutBufReserved;
use rand_core::Rng;
pub const KEY_SIZE: usize = 32;
pub const BLOCK_SIZE: usize = 16;
/// Adds padding to the message if it is not a multiple of the AES block size.
pub fn make_padding<R: Rng>(mut message: Vec<u8>, mut rng: R) -> Vec<u8> {
if message.len() % BLOCK_SIZE != 0 {
let unpadded_size = message.len();
let padding_size = BLOCK_SIZE - (unpadded_size % BLOCK_SIZE);
message.resize(unpadded_size + padding_size, 0);
rng.fill_bytes(&mut message[unpadded_size..]);
}
message
}
/// Encrypts the message in-place using AES-256 in CBC mode.
pub fn encrypt(message: &mut [u8], key: &[u8], iv: &[u8]) -> Result<(), PuttyError> {
let encryptor = cbc::Encryptor::<aes::Aes256>::new_from_slices(key, iv).map_err(|_| PuttyError::Aes)?;
let inout = InOutBufReserved::from_mut_slice(message, message.len())?;
encryptor
.encrypt_padded_inout::<NoPadding>(inout)
.map_err(|_| PuttyError::Aes)?;
Ok(())
}
/// Decrypts the message in-place using AES-256 in CBC mode.
pub fn decrypt(message: &mut [u8], key: &[u8], iv: &[u8]) -> Result<(), PuttyError> {
let decryptor = cbc::Decryptor::<aes::Aes256>::new_from_slices(key, iv).map_err(|_| PuttyError::Aes)?;
let _ = decryptor
.decrypt_padded_inout::<NoPadding>(message.into())
.map_err(|_| PuttyError::Aes);
Ok(())
}
+101
View File
@@ -0,0 +1,101 @@
//! PPK encoding and decoding functions.
use crate::putty::key_value::{
Base64PpkValue, HexPpkValue, PpkArgon2Memory, PpkArgon2MemoryValue, PpkArgon2Parallelism,
PpkArgon2ParallelismValue, PpkArgon2Passes, PpkArgon2PassesValue, PpkArgon2Salt, PpkComment, PpkCommentValue,
PpkEncryption, PpkEncryptionValue, PpkHeader, PpkKeyDerivation, PpkPrivateLines, PpkPrivateMac, PpkPublicLines,
PpkVersionKey, PuttyKvReader, PuttyKvWriter,
};
use crate::putty::ppk::encryption::PpkEncryptionKind;
use crate::putty::{Argon2Params, Ppk, PuttyError};
use std::str::FromStr;
impl FromStr for Ppk {
type Err = PuttyError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let mut reader = PuttyKvReader::from_str(input);
let (version, algorithm) = reader.next_key_value::<PpkHeader>()?;
let encryption = reader.next_value::<PpkEncryption>()?;
let comment = reader.next_value::<PpkComment>()?;
let public_key = reader.next_multiline_value::<PpkPublicLines>()?;
let encryption = match encryption {
PpkEncryptionValue::None => None,
PpkEncryptionValue::Aes256Cbc if version == PpkVersionKey::V2 => Some(PpkEncryptionKind::Aes256CbcV2),
PpkEncryptionValue::Aes256Cbc => {
let argon2_flavor = reader.next_value::<PpkKeyDerivation>()?;
let argon2_memory = reader.next_value::<PpkArgon2Memory>()?;
let argon2_passes = reader.next_value::<PpkArgon2Passes>()?;
let argon2_parallelism = reader.next_value::<PpkArgon2Parallelism>()?;
let argon2_salt = reader.next_value::<PpkArgon2Salt>()?;
Some(PpkEncryptionKind::Aes256CbcV3(Argon2Params {
flavor: argon2_flavor,
memory: argon2_memory.into(),
passes: argon2_passes.into(),
parallelism: argon2_parallelism.into(),
salt: argon2_salt.into(),
}))
}
};
let private_key = reader.next_multiline_value::<PpkPrivateLines>()?;
let mac = reader.next_value::<PpkPrivateMac>()?;
let ppk = Ppk {
version,
algorithm,
encryption,
comment: comment.into(),
public_key: public_key.into(),
private_key: private_key.into(),
mac: mac.into(),
};
// Validate MAC for file integrity check
if ppk.encryption.is_none() {
let mac = ppk.calculate_unencrypted_mac(ppk.private_key.as_slice())?;
if mac.as_slice() != ppk.mac.as_slice() {
return Err(PuttyError::MacValidation);
}
}
Ok(ppk)
}
}
impl Ppk {
/// Encodes the PPK key to a string.
pub fn to_string(&self) -> Result<String, PuttyError> {
// NOTE: V2 uses CRLF line endings, V3 uses LF
let crlf = match self.version {
PpkVersionKey::V2 => true,
PpkVersionKey::V3 => false,
};
let mut writer = PuttyKvWriter::new(crlf);
writer.write_key_value::<PpkHeader>(self.version, self.algorithm);
writer.write_value::<PpkEncryption>((self.encryption.as_ref()).into());
writer.write_value::<PpkComment>(PpkCommentValue::from(self.comment.clone()));
writer.write_multiline_value::<PpkPublicLines>(Base64PpkValue::from(self.public_key.clone()));
match &self.encryption {
Some(PpkEncryptionKind::Aes256CbcV3(argon2)) => {
writer.write_value::<PpkKeyDerivation>(argon2.flavor);
writer.write_value::<PpkArgon2Memory>(PpkArgon2MemoryValue::from(argon2.memory));
writer.write_value::<PpkArgon2Passes>(PpkArgon2PassesValue::from(argon2.passes));
writer.write_value::<PpkArgon2Parallelism>(PpkArgon2ParallelismValue::from(argon2.parallelism));
writer.write_value::<PpkArgon2Salt>(HexPpkValue::from(argon2.salt.clone()));
}
None | Some(PpkEncryptionKind::Aes256CbcV2) => {}
}
writer.write_multiline_value::<PpkPrivateLines>(Base64PpkValue::from(self.private_key.clone()));
writer.write_value::<PpkPrivateMac>(HexPpkValue::from(self.mac.clone()));
Ok(writer.finish())
}
}
+240
View File
@@ -0,0 +1,240 @@
//! PPK encryption/decryption types and fucntions
use crate::putty::PuttyError;
use crate::putty::key_value::{Argon2FlavourValue, PpkEncryptionValue, PpkVersionKey};
use crate::putty::ppk::kdf::{self, KeyMaterialV2};
use crate::putty::ppk::{Argon2Params, Ppk, aes as ppk_aes};
use crate::ssh::decode::SshReadExt;
use rand::rngs::{StdRng, SysRng};
use rand_core::SeedableRng as _;
/// PPK encryption configuration builder.
///
/// Could be constructed via [`PpkEncryptionConfig::builder()`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PpkEncryptionConfigBuilder {
inner: PpkEncryptionConfig,
}
impl PpkEncryptionConfigBuilder {
pub fn argon2_flavour(mut self, argon2_flavour: Argon2FlavourValue) -> Self {
self.inner.argon2_flavour = argon2_flavour;
self
}
pub fn argon2_memory(mut self, argon2_memory: u32) -> Self {
self.inner.argon2_memory = argon2_memory;
self
}
pub fn argon2_passes(mut self, argon2_passes: u32) -> Self {
self.inner.argon2_passes = argon2_passes;
self
}
pub fn argon2_parallelism(mut self, argon2_parallelism: u32) -> Self {
self.inner.argon2_parallelism = argon2_parallelism;
self
}
pub fn argon2_salt_size(mut self, argon2_salt_size: u32) -> Self {
self.inner.argon2_salt_size = argon2_salt_size;
self
}
pub fn build(self) -> PpkEncryptionConfig {
self.inner
}
}
/// PPK encryption configuration.
///
/// Could be either constructed via [`Default::default()`] or [`PpkEncryptionConfig::builder()`]
///
/// Defaults are the same as in PuTTY.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PpkEncryptionConfig {
argon2_flavour: Argon2FlavourValue,
argon2_memory: u32,
argon2_passes: u32,
argon2_parallelism: u32,
argon2_salt_size: u32,
}
impl Default for PpkEncryptionConfig {
fn default() -> Self {
Self {
argon2_flavour: Argon2FlavourValue::Argon2id,
argon2_memory: 8192,
argon2_passes: 34,
argon2_parallelism: 1,
argon2_salt_size: 16,
}
}
}
impl PpkEncryptionConfig {
pub fn builder() -> PpkEncryptionConfigBuilder {
PpkEncryptionConfigBuilder {
inner: Default::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PpkEncryptionKind {
Aes256CbcV2,
Aes256CbcV3(Argon2Params),
}
impl From<Option<&PpkEncryptionKind>> for PpkEncryptionValue {
fn from(params: Option<&PpkEncryptionKind>) -> Self {
match params {
None => PpkEncryptionValue::None,
Some(PpkEncryptionKind::Aes256CbcV2) => PpkEncryptionValue::Aes256Cbc,
Some(PpkEncryptionKind::Aes256CbcV3(_)) => PpkEncryptionValue::Aes256Cbc,
}
}
}
impl Ppk {
/// Returns true if the key is encrypted
pub fn is_encrypted(&self) -> bool {
self.encryption.is_some()
}
/// Argon2 KDF parameters if encryption is used (V3 only)
pub fn argon2_params(&self) -> Option<&Argon2Params> {
match &self.encryption {
Some(PpkEncryptionKind::Aes256CbcV3(params)) => Some(params),
_ => None,
}
}
/// Returns PPK key encrypted with the specified passphrase and config.
pub fn encrypt(&self, passphrase: &str, config: PpkEncryptionConfig) -> Result<Self, PuttyError> {
self.encrypt_with_rng(passphrase, config, StdRng::try_from_rng(&mut SysRng)?)
}
/// Returns PPK key encrypted with the specified passphrase, config and RNG.
pub fn encrypt_with_rng(
&self,
passphrase: &str,
config: PpkEncryptionConfig,
mut rng: impl rand_core::Rng,
) -> Result<Ppk, PuttyError> {
if self.encryption.is_some() {
return Err(PuttyError::AlreadyEncrypted);
}
let ppk = match self.version {
PpkVersionKey::V2 => {
let key_material = kdf::derive_key_material_v2(passphrase)?;
let mut private_key = ppk_aes::make_padding(self.private_key.clone(), rng);
let mac = self.calculate_mac_v2(passphrase, &private_key, PpkEncryptionValue::Aes256Cbc)?;
ppk_aes::encrypt(&mut private_key, key_material.key(), KeyMaterialV2::iv())?;
Ppk {
version: self.version,
algorithm: self.algorithm,
encryption: Some(PpkEncryptionKind::Aes256CbcV2),
comment: self.comment.clone(),
public_key: self.public_key.clone(),
private_key,
mac,
}
}
PpkVersionKey::V3 => {
let mut argon2_salt = vec![0u8; config.argon2_salt_size as usize];
rng.fill_bytes(&mut argon2_salt);
let argon2_params = Argon2Params {
flavor: config.argon2_flavour,
memory: config.argon2_memory,
passes: config.argon2_passes,
parallelism: config.argon2_parallelism,
salt: argon2_salt,
};
let key_material = kdf::derive_key_material_v3(&argon2_params, passphrase)?;
let mut private_key = ppk_aes::make_padding(self.private_key.clone(), rng);
let mac =
self.calculate_mac_v3(key_material.hmac_key(), &private_key, PpkEncryptionValue::Aes256Cbc)?;
ppk_aes::encrypt(&mut private_key, key_material.key(), key_material.iv())?;
Ppk {
version: self.version,
algorithm: self.algorithm,
encryption: Some(PpkEncryptionKind::Aes256CbcV3(argon2_params)),
comment: self.comment.clone(),
public_key: self.public_key.clone(),
private_key,
mac,
}
}
};
Ok(ppk)
}
/// Returns PPK key decrypted with the specified passphrase
pub fn decrypt(&self, passphrase: &str) -> Result<Self, PuttyError> {
let encrytion = if let Some(encryption) = &self.encryption {
encryption
} else {
return Err(PuttyError::AlreadyDecrypted);
};
let (mut private_key, mac) = match encrytion {
PpkEncryptionKind::Aes256CbcV2 => {
let key_material = kdf::derive_key_material_v2(passphrase)?;
let mut private_key = self.private_key.clone();
ppk_aes::decrypt(&mut private_key, key_material.key(), KeyMaterialV2::iv())?;
let mac = self.calculate_mac_v2(passphrase, &private_key, PpkEncryptionValue::Aes256Cbc)?;
(private_key, mac)
}
PpkEncryptionKind::Aes256CbcV3(argon2_params) => {
let key_material = kdf::derive_key_material_v3(argon2_params, passphrase)?;
let mut private_key = self.private_key.clone();
ppk_aes::decrypt(&mut private_key, key_material.key(), key_material.iv())?;
let mac =
self.calculate_mac_v3(key_material.hmac_key(), &private_key, PpkEncryptionValue::Aes256Cbc)?;
(private_key, mac)
}
};
// Verify MAC
if mac.as_slice() != self.mac.as_slice() {
return Err(PuttyError::MacValidation);
}
// Truncate private key padding if any
let truncated_size = {
let mut mpint_cursor = private_key.as_slice();
for _ in 0..self.algorithm.key_mpint_values_count() {
// NOTE: Bytes and mpint stored the same way, therefore to avoid BigUint
// construction we can just read the bytes (we discard them either way)
let _value = mpint_cursor.read_ssh_bytes()?;
}
private_key.len().wrapping_sub(mpint_cursor.len())
};
private_key.truncate(truncated_size);
let mac = self.calculate_unencrypted_mac(private_key.as_slice())?;
let ppk = Ppk {
version: self.version,
algorithm: self.algorithm,
encryption: None,
comment: self.comment.clone(),
public_key: self.public_key.clone(),
private_key,
mac,
};
Ok(ppk)
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Key derivation utilities for PPK files.
use crate::putty::key_value::Argon2FlavourValue;
use crate::putty::ppk::{PuttyError, aes as ppk_aes};
use digest::Digest;
use zeroize::Zeroizing;
const SHA256_DIGEST_SIZE: usize = 32;
const SHA1_DIGEST_SIZE: usize = 20;
pub const MAC_SIZE_V3: usize = SHA256_DIGEST_SIZE;
/// Argon2 key derivation function parameters.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Argon2Params {
pub flavor: Argon2FlavourValue,
pub memory: u32,
pub passes: u32,
pub parallelism: u32,
pub salt: Vec<u8>,
}
const V3_KEY_MATERIAL_SIZE: usize = ppk_aes::KEY_SIZE // AES key
+ ppk_aes::BLOCK_SIZE // AES IV
+ MAC_SIZE_V3; // HMAC key
pub(crate) struct KeyMaterialV3 {
key: Zeroizing<[u8; ppk_aes::KEY_SIZE]>,
iv: Zeroizing<[u8; ppk_aes::BLOCK_SIZE]>,
hmac_key: Zeroizing<[u8; MAC_SIZE_V3]>,
}
impl KeyMaterialV3 {
pub fn key(&self) -> &[u8] {
self.key.as_ref()
}
pub fn iv(&self) -> &[u8] {
self.iv.as_ref()
}
pub fn hmac_key(&self) -> &[u8] {
self.hmac_key.as_ref()
}
}
pub(crate) struct KeyMaterialV2 {
key: Zeroizing<[u8; ppk_aes::KEY_SIZE]>,
}
impl KeyMaterialV2 {
pub fn key(&self) -> &[u8] {
self.key.as_ref()
}
pub fn iv() -> &'static [u8; ppk_aes::BLOCK_SIZE] {
&[0u8; ppk_aes::BLOCK_SIZE]
}
}
pub(crate) fn derive_key_material_v2(passphrase: &str) -> Result<KeyMaterialV2, PuttyError> {
let tagged_hash = |tag: u32| -> [u8; SHA1_DIGEST_SIZE] {
let mut digest = sha1::Sha1::new();
digest.update(tag.to_be_bytes());
digest.update(passphrase.as_bytes());
digest.finalize().into()
};
let hash1 = tagged_hash(0);
let hash2 = tagged_hash(1);
let mut key = [0u8; ppk_aes::KEY_SIZE];
key[..SHA1_DIGEST_SIZE].copy_from_slice(&hash1[..]);
key[SHA1_DIGEST_SIZE..].copy_from_slice(&hash2[..ppk_aes::KEY_SIZE - SHA1_DIGEST_SIZE]);
Ok(KeyMaterialV2 { key: key.into() })
}
pub(crate) fn derive_key_material_v3(
argon2_params: &Argon2Params,
passphrase: &str,
) -> Result<KeyMaterialV3, PuttyError> {
let mut key_material = [0u8; V3_KEY_MATERIAL_SIZE];
let kdf = argon2::Argon2::new(
argon2_params.flavor.into(),
argon2::Version::V0x13,
argon2::Params::new(
argon2_params.memory,
argon2_params.passes,
argon2_params.parallelism,
Some(V3_KEY_MATERIAL_SIZE),
)
.map_err(|_| PuttyError::Argon2)?,
);
kdf.hash_password_into(passphrase.as_bytes(), &argon2_params.salt, &mut key_material)
.map_err(|_| PuttyError::Argon2)?;
let key_material = key_material.as_ref();
const IV_OFFSET: usize = ppk_aes::KEY_SIZE;
const HMAC_KEY_OFFSET: usize = IV_OFFSET + ppk_aes::BLOCK_SIZE;
let mut key = [0u8; ppk_aes::KEY_SIZE];
key.copy_from_slice(&key_material[..ppk_aes::KEY_SIZE]);
let mut iv = [0u8; ppk_aes::BLOCK_SIZE];
iv.copy_from_slice(&key_material[IV_OFFSET..IV_OFFSET + ppk_aes::BLOCK_SIZE]);
let mut hmac_key = [0u8; MAC_SIZE_V3];
hmac_key.copy_from_slice(&key_material[HMAC_KEY_OFFSET..]);
Ok(KeyMaterialV3 {
key: key.into(),
iv: iv.into(),
hmac_key: hmac_key.into(),
})
}
+79
View File
@@ -0,0 +1,79 @@
//! PPK MAC calculation functions
use crate::putty::key_value::{PpkEncryptionValue, PpkLiteral, PpkVersionKey};
use crate::putty::{Ppk, PuttyError};
use digest::Digest;
use hmac::{KeyInit, Mac};
impl Ppk {
pub(super) fn calculate_mac_v3(
&self,
mac_key: &[u8],
private_key_data: &[u8],
encryption: PpkEncryptionValue,
) -> Result<Vec<u8>, PuttyError> {
let mut hmac = hmac::Hmac::<sha2::Sha256>::new_from_slice(mac_key).map_err(|_| PuttyError::MacValidation)?;
let mut hash_bytes = |data: &[u8]| -> Result<(), PuttyError> {
hmac.update(
&u32::try_from(data.len())
.map_err(|_| PuttyError::MacValidation)?
.to_be_bytes(),
);
hmac.update(data);
Ok(())
};
hash_bytes(self.algorithm.as_static_str().as_bytes())?;
hash_bytes(encryption.as_static_str().as_bytes())?;
hash_bytes(self.comment.as_bytes())?;
hash_bytes(&self.public_key)?;
hash_bytes(private_key_data)?;
let mac = hmac.finalize().into_bytes().to_vec();
Ok(mac)
}
pub(super) fn calculate_mac_v2(
&self,
passphrase: &str,
private_key_data: &[u8],
encryption: PpkEncryptionValue,
) -> Result<Vec<u8>, PuttyError> {
let mac_key = {
let mut digest = sha1::Sha1::new();
digest.update(b"putty-private-key-file-mac-key");
digest.update(passphrase.as_bytes());
digest.finalize()
};
let mut hmac = hmac::Hmac::<sha1::Sha1>::new_from_slice(&mac_key).map_err(|_| PuttyError::MacValidation)?;
let mut hash_bytes = |data: &[u8]| -> Result<(), PuttyError> {
hmac.update(
&u32::try_from(data.len())
.map_err(|_| PuttyError::MacValidation)?
.to_be_bytes(),
);
hmac.update(data);
Ok(())
};
hash_bytes(self.algorithm.as_static_str().as_bytes())?;
hash_bytes(encryption.as_static_str().as_bytes())?;
hash_bytes(self.comment.as_bytes())?;
hash_bytes(&self.public_key)?;
hash_bytes(private_key_data)?;
let mac = hmac.finalize().into_bytes().to_vec();
Ok(mac)
}
pub(super) fn calculate_unencrypted_mac(&self, private_key_data: &[u8]) -> Result<Vec<u8>, PuttyError> {
match self.version {
PpkVersionKey::V2 => self.calculate_mac_v2("", private_key_data, PpkEncryptionValue::None),
PpkVersionKey::V3 => self.calculate_mac_v3(&[], private_key_data, PpkEncryptionValue::None),
}
}
}
+258
View File
@@ -0,0 +1,258 @@
mod aes;
mod encoding;
mod encryption;
mod kdf;
mod mac;
use crate::key::{EcCurve, PrivateKey, PublicKey};
use crate::putty::PuttyError;
use crate::putty::key_value::{PpkKeyAlgorithmValue, PpkVersionKey};
use crate::putty::private_key::{PuttyBasePrivateKey, PuttyPrivateKey};
use crate::putty::public_key::{PuttyBasePublicKey, PuttyPublicKey};
use crate::ssh::SshPrivateKey;
use self::encryption::PpkEncryptionKind;
pub use encryption::{PpkEncryptionConfig, PpkEncryptionConfigBuilder};
pub use kdf::Argon2Params;
/// PuTTY Private Key (PPK) format.
///
/// ### Functionality
/// - Generation of new keys.
/// - Conversion to/from OpenSSH format.
/// - Encoding/decoding to/from string.
/// - Version upgrade/downgrade.
///
/// ### Usage notes
/// - Ppk structure is immutable. All operations that modify the key return a new instance.
/// - When input file is encrypted, all operations with the private key will be unavailable until
/// ppk is decrypted via [`Ppk::decrypt`].
/// - Newly generated keys are always unencrypted. They should be encrypted via [`Ppk::encrypt`]
/// when required
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Ppk {
version: PpkVersionKey,
algorithm: PpkKeyAlgorithmValue,
encryption: Option<PpkEncryptionKind>,
comment: String,
public_key: Vec<u8>,
private_key: Vec<u8>,
mac: Vec<u8>,
}
impl Ppk {
pub fn generate_rsa(bits: usize, comment: Option<&str>) -> Result<Self, PuttyError> {
let ssh_key = SshPrivateKey::generate_rsa(bits, None, comment.map(From::from))?;
Self::from_openssh_private_key(&ssh_key)
}
pub fn generate_ec(curve: EcCurve, comment: Option<&str>) -> Result<Self, PuttyError> {
let ssh_key = SshPrivateKey::generate_ec(curve, None, comment.map(From::from))?;
Self::from_openssh_private_key(&ssh_key)
}
pub fn generate_ed25519(comment: Option<&str>) -> Result<Self, PuttyError> {
let ssh_key = SshPrivateKey::generate_ed25519(None, comment.map(From::from))?;
Self::from_openssh_private_key(&ssh_key)
}
/// Converts the OpenSSH private key to a PPK key.
pub fn from_openssh_private_key(key: &SshPrivateKey) -> Result<Self, PuttyError> {
let PuttyPrivateKey { base, comment }: PuttyPrivateKey = PuttyPrivateKey::from_openssh(key)?;
let mut ppk = Ppk {
version: PpkVersionKey::V3,
algorithm: base.algorithm,
encryption: None,
comment,
public_key: base.public_key.data,
private_key: base.data,
mac: vec![],
};
ppk.mac = ppk.calculate_unencrypted_mac(ppk.private_key.as_slice())?;
Ok(ppk)
}
/// Converts the PPK key to an OpenSSH private key (with or without encryption).
pub fn to_openssh_private_key(&self, passphrase: Option<&str>) -> Result<SshPrivateKey, PuttyError> {
if self.is_encrypted() {
return Err(PuttyError::Encrypted);
}
let base = PuttyBasePrivateKey {
algorithm: self.algorithm,
public_key: PuttyBasePublicKey {
data: self.public_key.clone(),
},
data: self.private_key.clone(),
};
let key = PuttyPrivateKey {
base,
comment: self.comment.clone(),
};
key.to_openssh(passphrase)
}
/// Returns PPK public key.
pub fn public_key(&self) -> Result<PublicKey, PuttyError> {
PuttyBasePublicKey {
data: self.public_key.clone(),
}
.to_inner_key()
}
/// Returns PPK private key.
pub fn private_key(&self) -> Result<PrivateKey, PuttyError> {
if self.is_encrypted() {
return Err(PuttyError::Encrypted);
}
PuttyBasePrivateKey {
algorithm: self.algorithm,
public_key: PuttyBasePublicKey {
data: self.public_key.clone(),
},
data: self.private_key.clone(),
}
.to_inner_key()
}
/// Returns extracted public key in PuTTY format.
pub fn extract_putty_public_key(&self) -> Result<PuttyPublicKey, PuttyError> {
Ok(PuttyPublicKey {
base: PuttyBasePublicKey {
data: self.public_key.clone(),
},
comment: self.comment.clone(),
})
}
/// Returns a new PPK key instance with a different comment.
pub fn with_comment(&self, comment: &str) -> Result<Self, PuttyError> {
if self.is_encrypted() {
// We need to decrypt the key to change the comment (MAC should be recalculated).
return Err(PuttyError::Encrypted);
}
let mut ppk = Self {
comment: comment.to_string(),
..self.clone()
};
ppk.mac = ppk.calculate_unencrypted_mac(ppk.private_key.as_slice())?;
Ok(ppk)
}
/// Returns the version of the PPK file format.
pub fn version(&self) -> PpkVersionKey {
self.version
}
/// Returns the key algorithm.
pub fn algorithm(&self) -> PpkKeyAlgorithmValue {
self.algorithm
}
/// Returns key comment.
pub fn comment(&self) -> &str {
&self.comment
}
/// Returns new PPK kew with the specified format version.
///
/// NOTE: `PpkVersionKey::V2` is considered insecure and should not be used for new keys in
/// normal circumstances.
pub fn to_version(&self, version: PpkVersionKey) -> Result<Ppk, PuttyError> {
if self.is_encrypted() {
return Err(PuttyError::Encrypted);
}
let ppk = Ppk {
version,
algorithm: self.algorithm,
encryption: None,
comment: self.comment.clone(),
public_key: self.public_key.clone(),
private_key: self.private_key.clone(),
mac: self.calculate_unencrypted_mac(self.private_key.as_slice())?,
};
Ok(ppk)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use picky_test_data::{
PUTTY_KEY_ED25519, PUTTY_KEY_ED25519_ENCRYPTED, PUTTY_KEY_ED25519_V2, PUTTY_KEY_ED25519_V2_ENCRYPTED,
SSH_PRIVATE_KEY_EC_P256, SSH_PRIVATE_KEY_EC_P384, SSH_PRIVATE_KEY_EC_P521, SSH_PRIVATE_KEY_ED25519,
SSH_PRIVATE_KEY_RSA,
};
#[rstest]
#[case(PUTTY_KEY_ED25519)]
#[case(PUTTY_KEY_ED25519_ENCRYPTED)]
#[case(PUTTY_KEY_ED25519_V2)]
#[case(PUTTY_KEY_ED25519_V2_ENCRYPTED)]
fn ppk_encode_decode_roundtrip(#[case] input: &str) {
let key: Ppk = input.parse().unwrap();
let encoded = key.to_string().unwrap();
assert_eq!(encoded, input);
}
#[rstest]
#[case(PUTTY_KEY_ED25519_ENCRYPTED, PUTTY_KEY_ED25519)]
#[case(PUTTY_KEY_ED25519_V2_ENCRYPTED, PUTTY_KEY_ED25519_V2)]
fn decrypt_produces_same_key_as_puttygen(#[case] encrypted: &str, #[case] decrypted: &str) {
let mut key: Ppk = encrypted.parse().unwrap();
assert!(key.is_encrypted());
key = key.decrypt("test").unwrap();
let encoded = key.to_string().unwrap();
assert_eq!(encoded, decrypted);
assert!(!key.is_encrypted());
}
#[rstest]
#[case(PUTTY_KEY_ED25519, PpkVersionKey::V3, "eddsa-key-20240414")]
#[case(PUTTY_KEY_ED25519_V2, PpkVersionKey::V2, "ed25519-key-20240418")]
fn encrypt_decrypt_roundtrip(#[case] input: &str, #[case] version: PpkVersionKey, #[case] comment: &str) {
let mut key: Ppk = input.parse().unwrap();
key = key.encrypt("test", Default::default()).unwrap();
assert!(key.is_encrypted());
key = key.decrypt("test").unwrap();
assert_eq!(key.to_string().unwrap(), input);
assert!(!key.is_encrypted());
assert_eq!(key.version(), version);
assert_eq!(key.algorithm(), PpkKeyAlgorithmValue::Ed25519);
assert_eq!(key.comment(), comment);
}
#[rstest]
#[case(SSH_PRIVATE_KEY_RSA)]
#[case(SSH_PRIVATE_KEY_EC_P256)]
#[case(SSH_PRIVATE_KEY_EC_P384)]
#[case(SSH_PRIVATE_KEY_EC_P521)]
#[case(SSH_PRIVATE_KEY_ED25519)]
fn test_openssh_roundtrip(#[case] input: &str) {
let ssh_key = SshPrivateKey::from_pem_str(input, None).unwrap();
let key = Ppk::from_openssh_private_key(&ssh_key).unwrap();
let mut ssh_key2 = key.to_openssh_private_key(None).unwrap();
// Check is re-generated when new ssh is created from scratch
ssh_key2.check = ssh_key.check;
let ssh_key_str = ssh_key2.to_string().unwrap();
assert_eq!(ssh_key_str, input);
}
}
+235
View File
@@ -0,0 +1,235 @@
use crate::key::ec::{EcdsaKeypair, EcdsaPublicKey, NamedEcCurve};
use crate::key::ed::{EdKeypair, EdPublicKey, NamedEdAlgorithm};
use crate::key::{EcCurve, EdAlgorithm, PrivateKey};
use crate::putty::PuttyError;
use crate::putty::key_value::PpkKeyAlgorithmValue;
use crate::putty::public_key::PuttyBasePublicKey;
use crate::ssh::SshPrivateKey;
use crate::ssh::decode::SshReadExt;
use crate::ssh::encode::SshWriteExt;
use crate::ssh::private_key::SshBasePrivateKey;
use crate::ssh::public_key::SshBasePublicKey;
use crypto_bigint::BoxedUint;
use rsa::traits::{PrivateKeyParts, PublicKeyParts};
use rsa::{RsaPrivateKey, RsaPublicKey};
/// PuTTY private key wrapper
pub(crate) struct PuttyPrivateKey {
pub(crate) base: PuttyBasePrivateKey,
pub(crate) comment: String,
}
impl PuttyPrivateKey {
pub fn from_openssh(key: &SshPrivateKey) -> Result<Self, PuttyError> {
let base = PuttyBasePrivateKey::from_openssh(&key.base_key)?;
let comment = key.comment.clone();
Ok(Self { base, comment })
}
/// Converts the key to an OpenSSH key (with or without encryption)
pub fn to_openssh(&self, passphrase: Option<&str>) -> Result<SshPrivateKey, PuttyError> {
let base = self.base.to_openssh()?;
let comment = if self.comment.is_empty() {
None
} else {
Some(self.comment.clone())
};
let key = match base {
SshBasePrivateKey::Rsa(key) => key,
SshBasePrivateKey::Ec(key) => key,
SshBasePrivateKey::Ed(key) => key,
SshBasePrivateKey::SkEcdsaSha2NistP256 { .. } | SshBasePrivateKey::SkEd25519 { .. } => {
return Err(PuttyError::NotSupported { feature: "SK keys" });
}
};
Ok(SshPrivateKey::h_picky_private_key_to_ssh_private_key(
key,
passphrase.map(From::from),
comment,
)?)
}
}
pub(crate) struct PuttyBasePrivateKey {
pub(crate) algorithm: PpkKeyAlgorithmValue,
pub(crate) public_key: PuttyBasePublicKey,
pub(crate) data: Vec<u8>,
}
impl PuttyBasePrivateKey {
pub fn from_openssh(key: &SshBasePrivateKey) -> Result<Self, PuttyError> {
let mut data = Vec::new();
let cursor = &mut data;
match key {
SshBasePrivateKey::SkEcdsaSha2NistP256 { .. } | SshBasePrivateKey::SkEd25519 { .. } => {
// Putty does not support SK keys
Err(PuttyError::NotSupported { feature: "SK keys" })
}
SshBasePrivateKey::Rsa(key) => {
let mut rsa_key = RsaPrivateKey::try_from(key)?;
cursor.write_ssh_mpint(rsa_key.d())?;
if rsa_key.primes().len() != 2 {
return Err(PuttyError::RsaInvalidPrimesCount {
count: rsa_key.primes().len(),
});
}
cursor.write_ssh_mpint(&rsa_key.primes()[0])?;
cursor.write_ssh_mpint(&rsa_key.primes()[1])?;
rsa_key.precompute().map_err(|_| PuttyError::RsaPrecompute)?;
let qinv = rsa_key
.qinv()
.expect("BUG: should be precomuted above")
.retrieve()
.to_be_bytes_trimmed_vartime();
cursor.write_ssh_bytes(&qinv)?;
let ssh_public_key = SshBasePublicKey::Rsa(key.to_public_key()?);
let public_key = PuttyBasePublicKey::from_openssh(&ssh_public_key)?;
Ok(Self {
algorithm: PpkKeyAlgorithmValue::Rsa,
data,
public_key,
})
}
SshBasePrivateKey::Ec(key) => {
let ec_key = EcdsaKeypair::try_from(key)?;
let secret = BoxedUint::from_be_slice_vartime(ec_key.secret());
cursor.write_ssh_mpint(&secret)?;
let algorithm = match ec_key.curve() {
NamedEcCurve::Known(EcCurve::NistP256) => PpkKeyAlgorithmValue::EcdsaSha2Nistp256,
NamedEcCurve::Known(EcCurve::NistP384) => PpkKeyAlgorithmValue::EcdsaSha2Nistp384,
NamedEcCurve::Known(EcCurve::NistP521) => PpkKeyAlgorithmValue::EcdsaSha2Nistp521,
_ => {
return Err(PuttyError::NotSupported {
feature: "unknown EC curve",
});
}
};
let ssh_public_key = SshBasePublicKey::Ec(key.to_public_key()?);
let public_key = PuttyBasePublicKey::from_openssh(&ssh_public_key)?;
Ok(Self {
algorithm,
data,
public_key,
})
}
SshBasePrivateKey::Ed(key) => {
let ed_key = EdKeypair::try_from(key)?;
cursor.write_ssh_mpint(&BoxedUint::from_be_slice_vartime(ed_key.secret()))?;
let algorithm = match ed_key.algorithm() {
NamedEdAlgorithm::Known(EdAlgorithm::Ed25519) => PpkKeyAlgorithmValue::Ed25519,
NamedEdAlgorithm::Known(EdAlgorithm::X25519) => {
return Err(PuttyError::NotSupported { feature: "X25519 keys" });
}
_ => {
return Err(PuttyError::NotSupported {
feature: "unknown EdDSA algorithm",
});
}
};
let ssh_public_key = SshBasePublicKey::Ed(key.to_public_key()?);
let public_key = PuttyBasePublicKey::from_openssh(&ssh_public_key)?;
Ok(Self {
algorithm,
data,
public_key,
})
}
}
}
pub fn to_openssh(&self) -> Result<SshBasePrivateKey, PuttyError> {
let ssh_public_key = self.public_key.to_openssh()?;
let mut data = self.data.as_slice();
match self.algorithm {
PpkKeyAlgorithmValue::Rsa => {
let public = match &ssh_public_key {
SshBasePublicKey::Rsa(rsa) => RsaPublicKey::try_from(rsa)?,
_ => return Err(PuttyError::PublicAndPrivateKeyMismatch),
};
let d = data.read_ssh_mpint()?;
let p1 = data.read_ssh_mpint()?;
let p2 = data.read_ssh_mpint()?;
let _qinv = data.read_ssh_mpint()?;
let private_key = PrivateKey::from_rsa_components(public.n(), public.e(), &d, &[p1, p2])?;
Ok(SshBasePrivateKey::Rsa(private_key))
}
PpkKeyAlgorithmValue::EcdsaSha2Nistp256
| PpkKeyAlgorithmValue::EcdsaSha2Nistp384
| PpkKeyAlgorithmValue::EcdsaSha2Nistp521 => {
let public = match &ssh_public_key {
SshBasePublicKey::Ec(rsa) => EcdsaPublicKey::try_from(rsa)?,
_ => return Err(PuttyError::PublicAndPrivateKeyMismatch),
};
let secret = data.read_ssh_mpint()?;
let curve = match self.algorithm {
PpkKeyAlgorithmValue::EcdsaSha2Nistp256 => NamedEcCurve::Known(EcCurve::NistP256),
PpkKeyAlgorithmValue::EcdsaSha2Nistp384 => NamedEcCurve::Known(EcCurve::NistP384),
PpkKeyAlgorithmValue::EcdsaSha2Nistp521 => NamedEcCurve::Known(EcCurve::NistP521),
_ => unreachable!("BUG: algorithm is checked above"),
};
let private_key = PrivateKey::from_ec_encoded_components(
curve.into(),
&secret.to_be_bytes_trimmed_vartime(),
Some(public.encoded_point()),
);
Ok(SshBasePrivateKey::Ec(private_key))
}
PpkKeyAlgorithmValue::Ed25519 => {
let public = match &ssh_public_key {
SshBasePublicKey::Ed(rsa) => EdPublicKey::try_from(rsa)?,
_ => return Err(PuttyError::PublicAndPrivateKeyMismatch),
};
let secret = data.read_ssh_mpint()?;
let private_key = PrivateKey::from_ed_encoded_components(
NamedEdAlgorithm::Known(EdAlgorithm::Ed25519).into(),
&secret.to_be_bytes_trimmed_vartime(),
Some(public.data()),
);
Ok(SshBasePrivateKey::Ed(private_key))
}
_ => Err(PuttyError::NotSupported {
feature: "unsupported key algorithm",
}),
}
}
pub fn to_inner_key(&self) -> Result<PrivateKey, PuttyError> {
let inner = match self.to_openssh()? {
SshBasePrivateKey::Rsa(key) => key,
SshBasePrivateKey::Ec(key) => key,
SshBasePrivateKey::Ed(key) => key,
SshBasePrivateKey::SkEcdsaSha2NistP256 { .. } | SshBasePrivateKey::SkEd25519 { .. } => {
return Err(PuttyError::NotSupported { feature: "SK keys" });
}
};
Ok(inner)
}
}
+224
View File
@@ -0,0 +1,224 @@
use crate::key::PublicKey;
use crate::putty::PuttyError;
use crate::ssh::SshPublicKey;
use crate::ssh::decode::SshComplexTypeDecode;
use crate::ssh::encode::SshComplexTypeEncode;
use crate::ssh::public_key::SshBasePublicKey;
use std::str::FromStr;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_ENGINE;
const PUTTY_PUBKEY_HEADER: &str = "---- BEGIN SSH2 PUBLIC KEY ----";
const PUTTY_PUBKEY_FOOTER: &str = "---- END SSH2 PUBLIC KEY ----";
/// PuTTY public key format.
///
/// ### Functionality:
/// - Conversion to/from OpenSSH format.
/// - Encoding/decoding to/from string.
/// - Could be extracted from [`crate::putty::Ppk`] private keys.
///
/// ### Notes
/// - Although top-level containeris similar to PEM, it is not compatible with it because of
/// additional comment field after the header.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuttyPublicKey {
pub(crate) base: PuttyBasePublicKey,
pub(crate) comment: String,
}
impl PuttyPublicKey {
/// Converts an OpenSSH public key to a PuTTY public key.
pub fn from_openssh(key: &SshPublicKey) -> Result<Self, PuttyError> {
let base = PuttyBasePublicKey::from_openssh(&key.inner_key)?;
Ok(Self {
base,
comment: key.comment.clone(),
})
}
/// Converts the key to an OpenSSH public key.
pub fn to_openssh(&self) -> Result<SshPublicKey, PuttyError> {
let base = self.base.to_openssh()?;
Ok(SshPublicKey {
inner_key: base,
comment: self.comment.clone(),
})
}
/// Returns key comment.
pub fn comment(&self) -> &str {
&self.comment
}
/// Returns a new public key instance with a different comment.
pub fn with_comment(&self, comment: &str) -> Self {
Self {
comment: comment.to_string(),
..self.clone()
}
}
/// Parses and returns the inner key as standard picky key type.
pub fn to_inner_key(&self) -> Result<PublicKey, PuttyError> {
self.base.to_inner_key()
}
}
impl std::fmt::Display for PuttyPublicKey {
// False positive, clippy does not take into account that [`String::replace`] requires both
// arguments to be the same type, e.g. we can't use `&str` as a replacement for `char`.
#[allow(clippy::single_char_pattern)]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
const MAX_CHARS_PER_LINE: usize = 64;
const LINE_END: &str = "\r\n";
let encoded_key = BASE64_ENGINE.encode(&self.base.data);
let escaped_comment = self.comment.replace("\\", "\\\\").replace("\"", "\\\"");
let mut output = String::new();
output.push_str(PUTTY_PUBKEY_HEADER);
output.push_str(LINE_END);
output.push_str("Comment: \"");
output.push_str(&escaped_comment);
output.push('"');
output.push_str(LINE_END);
let mut value_remaining = encoded_key.as_str();
while !value_remaining.is_empty() {
let line_len = value_remaining.len().min(MAX_CHARS_PER_LINE);
let (line, remaining) = value_remaining.split_at(line_len);
value_remaining = remaining;
output.push_str(line);
output.push_str(LINE_END);
}
output.push_str(PUTTY_PUBKEY_FOOTER);
output.push_str(LINE_END);
f.write_str(&output)
}
}
impl FromStr for PuttyPublicKey {
type Err = PuttyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut lines = s.lines();
let header = lines.next().ok_or(PuttyError::EndOfInput)?;
if header != PUTTY_PUBKEY_HEADER {
return Err(PuttyError::InvalidPublicKeyContainer);
}
let comment_line = lines.next().ok_or(PuttyError::EndOfInput)?;
if !comment_line.starts_with("Comment: ") {
return Err(PuttyError::InvalidPublicKeyComment);
}
let unescaped_comment = comment_line
.split_once('"')
.and_then(|(_, remainder)| remainder.rsplit_once('"'))
.map(|(comment, _)| comment)
.ok_or(PuttyError::InvalidPublicKeyComment)?;
let comment = unescaped_comment.replace("\\\"", "\"").replace("\\\\", "\\");
let mut encoded_key = String::new();
for line in lines {
if line == PUTTY_PUBKEY_FOOTER {
let decoded = BASE64_ENGINE
.decode(encoded_key)
.map_err(|_| PuttyError::InvalidPublicKeyData)?;
return Ok(PuttyPublicKey {
base: PuttyBasePublicKey { data: decoded },
comment,
});
}
encoded_key.push_str(line);
}
Err(PuttyError::EndOfInput)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PuttyBasePublicKey {
pub(crate) data: Vec<u8>,
}
impl PuttyBasePublicKey {
pub fn from_openssh(key: &SshBasePublicKey) -> Result<Self, PuttyError> {
match key {
SshBasePublicKey::SkEcdsaSha2NistP256 { .. } | SshBasePublicKey::SkEd25519 { .. } => {
// Putty does not support SK keys
return Err(PuttyError::NotSupported { feature: "SK keys" });
}
_ => {}
};
let mut data = Vec::new();
SshBasePublicKey::encode(key, &mut data)?;
Ok(Self { data })
}
pub fn to_openssh(&self) -> Result<SshBasePublicKey, PuttyError> {
let key = SshBasePublicKey::decode(self.data.as_slice())?;
Ok(key)
}
pub fn to_inner_key(&self) -> Result<PublicKey, PuttyError> {
let inner = match self.to_openssh()? {
SshBasePublicKey::Rsa(key) => key,
SshBasePublicKey::Ec(key) => key,
SshBasePublicKey::Ed(key) => key,
SshBasePublicKey::SkEcdsaSha2NistP256 { .. } | SshBasePublicKey::SkEd25519 { .. } => {
return Err(PuttyError::NotSupported { feature: "SK keys" });
}
};
Ok(inner)
}
}
#[cfg(test)]
mod tests {
use super::*;
use picky_test_data::{
PUTTY_KEY_ED25519_PUBLIC, PUTTY_KEY_RSA_PUBLIC_EMPTY_COMMENT, PUTTY_KEY_RSA_PUBLIC_ESCAPED_COMMENT,
SSH_PUBLIC_KEY_EC_P256, SSH_PUBLIC_KEY_EC_P384, SSH_PUBLIC_KEY_EC_P521, SSH_PUBLIC_KEY_ED25519,
SSH_PUBLIC_KEY_RSA,
};
use rstest::rstest;
#[rstest]
#[case(PUTTY_KEY_ED25519_PUBLIC)]
#[case(PUTTY_KEY_RSA_PUBLIC_EMPTY_COMMENT)]
#[case(PUTTY_KEY_RSA_PUBLIC_ESCAPED_COMMENT)]
fn public_key_rountrip(#[case] input: &str) {
let key: PuttyPublicKey = input.parse().unwrap();
let output = key.to_string();
assert_eq!(input, output);
}
#[rstest]
#[case(SSH_PUBLIC_KEY_RSA)]
#[case(SSH_PUBLIC_KEY_EC_P256)]
#[case(SSH_PUBLIC_KEY_EC_P384)]
#[case(SSH_PUBLIC_KEY_EC_P521)]
#[case(SSH_PUBLIC_KEY_ED25519)]
fn ssh_key_roundtrip(#[case] input: &str) {
let ssh_key: SshPublicKey = input.parse().unwrap();
let key = PuttyPublicKey::from_openssh(&ssh_key).unwrap();
let ssh_key2 = key.to_openssh().unwrap();
let ssh_key_str = ssh_key2.to_string().unwrap();
assert_eq!(ssh_key_str, input);
}
}
+633
View File
@@ -0,0 +1,633 @@
//! Signature algorithms supported by picky
use crate::hash::HashAlgorithm;
use crate::key::ec::{EcComponent, EcCurve, NamedEcCurve};
use crate::key::{KeyError, PrivateKey, PublicKey};
use picky_asn1_x509::{AlgorithmIdentifier, oids};
use rsa::signature::Signer;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SignatureError {
/// Key error
#[error("Key error: {source}")]
Key { source: KeyError },
/// RSA error
#[error("RSA error: {context}")]
Rsa { context: String },
/// EC error
#[error("EC error: {context}")]
Ec { context: String },
/// ED error
#[error("ED error: {context}")]
Ed { context: String },
/// invalid signature
#[error("invalid signature")]
BadSignature,
/// unsupported algorithm
#[error("unsupported algorithm: {algorithm}")]
UnsupportedAlgorithm { algorithm: String },
}
impl From<rsa::errors::Error> for SignatureError {
fn from(e: rsa::errors::Error) -> Self {
SignatureError::Rsa { context: e.to_string() }
}
}
impl From<rsa::signature::Error> for SignatureError {
fn from(e: rsa::signature::Error) -> Self {
SignatureError::Rsa { context: e.to_string() }
}
}
impl From<KeyError> for SignatureError {
fn from(e: KeyError) -> Self {
SignatureError::Key { source: e }
}
}
/// Supported signature algorithms
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SignatureAlgorithm {
RsaPkcs1v15(HashAlgorithm),
Ecdsa(HashAlgorithm),
Ed25519,
}
impl TryFrom<&'_ AlgorithmIdentifier> for SignatureAlgorithm {
type Error = SignatureError;
fn try_from(v: &AlgorithmIdentifier) -> Result<Self, Self::Error> {
let oid_string: String = v.oid().into();
match oid_string.as_str() {
oids::MD5_WITH_RSA_ENCRYPTHION => Ok(Self::RsaPkcs1v15(HashAlgorithm::MD5)),
oids::SHA1_WITH_RSA_ENCRYPTION => Ok(Self::RsaPkcs1v15(HashAlgorithm::SHA1)),
oids::SHA224_WITH_RSA_ENCRYPTION => Ok(Self::RsaPkcs1v15(HashAlgorithm::SHA2_224)),
oids::SHA256_WITH_RSA_ENCRYPTION => Ok(Self::RsaPkcs1v15(HashAlgorithm::SHA2_256)),
oids::SHA384_WITH_RSA_ENCRYPTION => Ok(Self::RsaPkcs1v15(HashAlgorithm::SHA2_384)),
oids::SHA512_WITH_RSA_ENCRYPTION => Ok(Self::RsaPkcs1v15(HashAlgorithm::SHA2_512)),
oids::ID_RSASSA_PKCS1_V1_5_WITH_SHA3_384 => Ok(Self::RsaPkcs1v15(HashAlgorithm::SHA3_384)),
oids::ID_RSASSA_PKCS1_V1_5_WITH_SHA3_512 => Ok(Self::RsaPkcs1v15(HashAlgorithm::SHA3_512)),
oids::ECDSA_WITH_SHA256 => Ok(Self::Ecdsa(HashAlgorithm::SHA2_256)),
oids::ECDSA_WITH_SHA384 => Ok(Self::Ecdsa(HashAlgorithm::SHA2_384)),
oids::ED25519 => Ok(Self::Ed25519),
_ => Err(SignatureError::UnsupportedAlgorithm { algorithm: oid_string }),
}
}
}
impl TryFrom<SignatureAlgorithm> for AlgorithmIdentifier {
type Error = SignatureError;
fn try_from(ty: SignatureAlgorithm) -> Result<Self, Self::Error> {
match ty {
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::MD5) => {
Ok(AlgorithmIdentifier::new_md5_with_rsa_encryption())
}
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA1) => {
Ok(AlgorithmIdentifier::new_sha1_with_rsa_encryption())
}
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_224) => {
Ok(AlgorithmIdentifier::new_sha224_with_rsa_encryption())
}
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_256) => {
Ok(AlgorithmIdentifier::new_sha256_with_rsa_encryption())
}
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_384) => {
Ok(AlgorithmIdentifier::new_sha384_with_rsa_encryption())
}
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_512) => {
Ok(AlgorithmIdentifier::new_sha512_with_rsa_encryption())
}
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA3_384) => {
Ok(AlgorithmIdentifier::new_sha3_384_with_rsa_encryption())
}
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA3_512) => {
Ok(AlgorithmIdentifier::new_sha3_512_with_rsa_encryption())
}
SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_256) => Ok(AlgorithmIdentifier::new_ecdsa_with_sha256()),
SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_384) => Ok(AlgorithmIdentifier::new_ecdsa_with_sha384()),
SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_512) => Ok(AlgorithmIdentifier::new_ecdsa_with_sha512()),
SignatureAlgorithm::Ecdsa(hash) => {
let msg = format!("ECDSA doesn't support {hash:?} hashing algorithm");
Err(SignatureError::Ec { context: msg })
}
SignatureAlgorithm::Ed25519 => Ok(AlgorithmIdentifier::new_ed25519()),
}
}
}
impl SignatureAlgorithm {
pub fn from_algorithm_identifier(algorithm_identifier: &AlgorithmIdentifier) -> Result<Self, SignatureError> {
Self::try_from(algorithm_identifier)
}
pub fn sign(self, msg: &[u8], private_key: &PrivateKey) -> Result<Vec<u8>, SignatureError> {
match self {
SignatureAlgorithm::RsaPkcs1v15(picky_hash_algo) => {
use rsa::signature::SignatureEncoding as _;
use rsa::{RsaPrivateKey, pkcs1v15};
let rsa_private_key = RsaPrivateKey::try_from(private_key)?;
let signature = match picky_hash_algo {
HashAlgorithm::MD5 => pkcs1v15::SigningKey::<md5::Md5>::new(rsa_private_key).try_sign(msg)?,
HashAlgorithm::SHA1 => pkcs1v15::SigningKey::<sha1::Sha1>::new(rsa_private_key).try_sign(msg)?,
HashAlgorithm::SHA2_224 => {
pkcs1v15::SigningKey::<sha2::Sha224>::new(rsa_private_key).try_sign(msg)?
}
HashAlgorithm::SHA2_256 => {
pkcs1v15::SigningKey::<sha2::Sha256>::new(rsa_private_key).try_sign(msg)?
}
HashAlgorithm::SHA2_384 => {
pkcs1v15::SigningKey::<sha2::Sha384>::new(rsa_private_key).try_sign(msg)?
}
HashAlgorithm::SHA2_512 => {
pkcs1v15::SigningKey::<sha2::Sha512>::new(rsa_private_key).try_sign(msg)?
}
HashAlgorithm::SHA3_384 => {
pkcs1v15::SigningKey::<sha3::Sha3_384>::new(rsa_private_key).try_sign(msg)?
}
HashAlgorithm::SHA3_512 => {
pkcs1v15::SigningKey::<sha3::Sha3_512>::new(rsa_private_key).try_sign(msg)?
}
};
Ok(signature.to_vec())
}
SignatureAlgorithm::Ecdsa(picky_hash_algo) => {
use crate::key::ec::EcdsaKeypair;
use p256::ecdsa::signature::Signer;
let ec_keypair = EcdsaKeypair::try_from(private_key)?;
match ec_keypair.curve() {
NamedEcCurve::Known(EcCurve::NistP256) => match picky_hash_algo {
HashAlgorithm::SHA2_256 => {
let secret_validated =
EcCurve::NistP256.validate_component(EcComponent::Secret(ec_keypair.secret()))?;
let key_bytes =
p256::elliptic_curve::array::Array::try_from(secret_validated).map_err(|_| {
SignatureError::Ec {
context: format!(
"validated secret is not the right size(expected: {}, actual: {})",
EcCurve::NistP256.field_bytes_size(),
secret_validated.len(),
),
}
})?;
let key =
p256::ecdsa::SigningKey::from_bytes(&key_bytes).map_err(|e| SignatureError::Ec {
context: format!("Cannot decode p256 EC keypair: {e}"),
})?;
let sig: p256::ecdsa::Signature = key.try_sign(msg).map_err(|e| SignatureError::Ec {
context: format!("Cannot produce p256 signature: {e}"),
})?;
Ok(sig.to_der().as_bytes().to_vec())
}
_ => Err(SignatureError::UnsupportedAlgorithm {
algorithm: format!(
"ECDSA P-256 curve with {picky_hash_algo:?} hash algorithm is not supported"
),
}),
},
NamedEcCurve::Known(EcCurve::NistP384) => match picky_hash_algo {
HashAlgorithm::SHA2_384 => {
let secret_validated =
EcCurve::NistP384.validate_component(EcComponent::Secret(ec_keypair.secret()))?;
let key_bytes =
p384::elliptic_curve::array::Array::try_from(secret_validated).map_err(|_| {
SignatureError::Ec {
context: format!(
"validated secret is not the right size(expected: {}, actual: {})",
EcCurve::NistP384.field_bytes_size(),
secret_validated.len(),
),
}
})?;
let key =
p384::ecdsa::SigningKey::from_bytes(&key_bytes).map_err(|e| SignatureError::Ec {
context: format!("Cannot decode p384 EC keypair: {e}"),
})?;
let sig: p384::ecdsa::Signature = key.try_sign(msg).map_err(|e| SignatureError::Ec {
context: format!("Cannot produce p384 signature: {e}"),
})?;
Ok(sig.to_der().as_bytes().to_vec())
}
_ => Err(SignatureError::UnsupportedAlgorithm {
algorithm: format!(
"ECDSA P-384 curve with {picky_hash_algo:?} hash algorithm is not supported"
),
}),
},
NamedEcCurve::Known(EcCurve::NistP521) => match picky_hash_algo {
HashAlgorithm::SHA2_512 => {
let secret_validated =
EcCurve::NistP521.validate_component(EcComponent::Secret(ec_keypair.secret()))?;
let key_bytes =
p521::elliptic_curve::array::Array::try_from(secret_validated).map_err(|_| {
SignatureError::Ec {
context: format!(
"validated secret is not the right size(expected: {}, actual: {})",
EcCurve::NistP521.field_bytes_size(),
secret_validated.len(),
),
}
})?;
let key =
p521::ecdsa::SigningKey::from_bytes(&key_bytes).map_err(|e| SignatureError::Ec {
context: format!("Cannot decode p521 EC keypair: {e}"),
})?;
let sig: p521::ecdsa::Signature = key.try_sign(msg).map_err(|e| SignatureError::Ec {
context: format!("Cannot produce p521 signature: {e}"),
})?;
Ok(sig.to_der().as_bytes().to_vec())
}
_ => Err(SignatureError::UnsupportedAlgorithm {
algorithm: format!(
"ECDSA P-521 curve with {picky_hash_algo:?} hash algorithm is not supported"
),
}),
},
NamedEcCurve::Unsupported(oid) => Err(KeyError::unsupported_curve(oid, "signing").into()),
}
}
SignatureAlgorithm::Ed25519 => {
use crate::key::ed::{EdAlgorithm, EdKeypair, EdPublicKey, NamedEdAlgorithm};
let keypair = EdKeypair::try_from(private_key)?;
let public_key = EdPublicKey::try_from(&keypair)?;
match keypair.algorithm() {
NamedEdAlgorithm::Known(EdAlgorithm::Ed25519) => {
let public_key = public_key.data().try_into().map_err(|e| SignatureError::Ed {
context: format!("invalid key size: {e}"),
})?;
let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(public_key).map_err(
|e: ed25519_dalek::ed25519::Error| SignatureError::Ed {
context: format!("Cannot decode ed25519 public key: {e}"),
},
)?;
let secret_key: ed25519_dalek::SecretKey =
keypair.secret().try_into().map_err(|e| SignatureError::Ed {
context: format!("invalid secret key size: {e}"),
})?;
let esk = ed25519_dalek::hazmat::ExpandedSecretKey::from(&secret_key);
let signature = ed25519_dalek::hazmat::raw_sign::<sha2::Sha512>(&esk, msg, &verifying_key);
Ok(signature.to_vec())
}
NamedEdAlgorithm::Known(EdAlgorithm::X25519) => Err(SignatureError::Ed {
context: "X25519 algorithm is not designed for signing".to_string(),
}),
NamedEdAlgorithm::Unsupported(oid) => {
Err(KeyError::unsupported_ed_algorithm(oid, "signing").into())
}
}
}
}
}
pub fn verify(self, public_key: &PublicKey, msg: &[u8], signature: &[u8]) -> Result<(), SignatureError> {
match self {
SignatureAlgorithm::RsaPkcs1v15(picky_hash_algo) => {
use rsa::signature::Verifier as _;
use rsa::{RsaPublicKey, pkcs1v15};
let rsa_public_key = RsaPublicKey::try_from(public_key)?;
let signature = pkcs1v15::Signature::try_from(signature)?;
match picky_hash_algo {
HashAlgorithm::MD5 => {
pkcs1v15::VerifyingKey::<md5::Md5>::new(rsa_public_key).verify(msg, &signature)
}
HashAlgorithm::SHA1 => {
pkcs1v15::VerifyingKey::<sha1::Sha1>::new(rsa_public_key).verify(msg, &signature)
}
HashAlgorithm::SHA2_224 => {
pkcs1v15::VerifyingKey::<sha2::Sha224>::new(rsa_public_key).verify(msg, &signature)
}
HashAlgorithm::SHA2_256 => {
pkcs1v15::VerifyingKey::<sha2::Sha256>::new(rsa_public_key).verify(msg, &signature)
}
HashAlgorithm::SHA2_384 => {
pkcs1v15::VerifyingKey::<sha2::Sha384>::new(rsa_public_key).verify(msg, &signature)
}
HashAlgorithm::SHA2_512 => {
pkcs1v15::VerifyingKey::<sha2::Sha512>::new(rsa_public_key).verify(msg, &signature)
}
HashAlgorithm::SHA3_384 => {
pkcs1v15::VerifyingKey::<sha3::Sha3_384>::new(rsa_public_key).verify(msg, &signature)
}
HashAlgorithm::SHA3_512 => {
pkcs1v15::VerifyingKey::<sha3::Sha3_512>::new(rsa_public_key).verify(msg, &signature)
}
}
.map_err(|_| SignatureError::BadSignature)?;
}
SignatureAlgorithm::Ecdsa(picky_hash_algo) => {
let ec_pub_key = crate::key::ec::EcdsaPublicKey::try_from(public_key)?;
let curve = match ec_pub_key.curve() {
NamedEcCurve::Known(curve) => curve,
NamedEcCurve::Unsupported(oid) => return Err(KeyError::unsupported_curve(oid, "verifying").into()),
};
match picky_hash_algo {
HashAlgorithm::SHA2_256 => {
use p256::ecdsa::signature::Verifier;
match curve {
EcCurve::NistP256 => {}
curve => {
return Err(SignatureError::UnsupportedAlgorithm {
algorithm: format!("SHA256 hash algorithm can't be used with `{curve}` curve"),
});
}
};
let encoded_point = p256::Sec1Point::from_bytes(ec_pub_key.encoded_point()).map_err(|e| {
SignatureError::Ec {
context: format!("Cannot parse p256 public key from der bytes: {e}"),
}
})?;
let vkey = p256::ecdsa::VerifyingKey::from_sec1_point(&encoded_point).map_err(|e| {
SignatureError::Ec {
context: format!("Cannot parse p256 encoded point: {e}"),
}
})?;
let signature =
p256::ecdsa::Signature::from_der(signature).map_err(|e| SignatureError::Ec {
context: format!("Cannot parse p256 signature: {e}"),
})?;
vkey.verify(msg, &signature).map_err(|_| SignatureError::BadSignature)?
}
HashAlgorithm::SHA2_384 => {
use p384::ecdsa::signature::Verifier;
match curve {
EcCurve::NistP384 => {}
curve => {
return Err(SignatureError::UnsupportedAlgorithm {
algorithm: format!("SHA384 hash algorithm can't be used with `{curve}` curve"),
});
}
};
let encoded_point = p384::Sec1Point::from_bytes(ec_pub_key.encoded_point()).map_err(|e| {
SignatureError::Ec {
context: format!("Cannot parse p384 public key from der bytes: {e}"),
}
})?;
let vkey = p384::ecdsa::VerifyingKey::from_sec1_point(&encoded_point).map_err(|e| {
SignatureError::Ec {
context: format!("Cannot parse p384 encoded point: {e}"),
}
})?;
let signature =
p384::ecdsa::Signature::from_der(signature).map_err(|e| SignatureError::Ec {
context: format!("Cannot parse p384 signature: {e}"),
})?;
vkey.verify(msg, &signature).map_err(|_| SignatureError::BadSignature)?
}
HashAlgorithm::SHA2_512 => {
use p521::ecdsa::signature::Verifier;
match curve {
EcCurve::NistP521 => {}
curve => {
return Err(SignatureError::UnsupportedAlgorithm {
algorithm: format!("SHA512 hash algorithm can't be used with `{curve}` curve"),
});
}
};
let encoded_point = p521::Sec1Point::from_bytes(ec_pub_key.encoded_point()).map_err(|e| {
SignatureError::Ec {
context: format!("Cannot parse p521 public key from der bytes: {e}"),
}
})?;
let vkey = p521::ecdsa::VerifyingKey::from_sec1_point(&encoded_point).map_err(|e| {
SignatureError::Ec {
context: format!("Cannot parse p521 encoded point: {e}"),
}
})?;
let signature =
p521::ecdsa::Signature::from_der(signature).map_err(|e| SignatureError::Ec {
context: format!("Cannot parse p521 signature: {e}"),
})?;
vkey.verify(msg, &signature).map_err(|_| SignatureError::BadSignature)?
}
_ => {
return Err(SignatureError::UnsupportedAlgorithm {
algorithm: format!("ECDSA with {picky_hash_algo:?} hash algorithm is not supported"),
});
}
}
}
SignatureAlgorithm::Ed25519 => {
use crate::key::ed::{EdAlgorithm, EdPublicKey, NamedEdAlgorithm};
use ed25519_dalek::Verifier;
let public_key = EdPublicKey::try_from(public_key)?;
match public_key.algorithm() {
NamedEdAlgorithm::Known(EdAlgorithm::Ed25519) => {
let public_key = public_key.data().try_into().map_err(|e| SignatureError::Ed {
context: format!("invalid key size: {e}"),
})?;
let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(public_key).map_err(
|e: ed25519_dalek::ed25519::Error| SignatureError::Ed {
context: format!("Cannot decode ed25519 public key: {e}"),
},
)?;
let signature = signature.try_into().map_err(|e| SignatureError::Ed {
context: format!("invalid signature size: {e}"),
})?;
let signature = ed25519_dalek::Signature::from_bytes(signature);
verifying_key
.verify(msg, &signature)
.map_err(|_| SignatureError::BadSignature)?;
}
NamedEdAlgorithm::Known(EdAlgorithm::X25519) => {
return Err(SignatureError::Ed {
context: "X25519 algorithm is not designed for signing".to_string(),
});
}
NamedEdAlgorithm::Unsupported(oid) => {
return Err(KeyError::unsupported_ed_algorithm(oid, "verifying").into());
}
}
}
}
Ok(())
}
pub fn hash_algorithm(&self) -> HashAlgorithm {
match &self {
SignatureAlgorithm::RsaPkcs1v15(hash_algo) => *hash_algo,
SignatureAlgorithm::Ecdsa(hash_algo) => *hash_algo,
SignatureAlgorithm::Ed25519 => HashAlgorithm::SHA2_512,
}
}
}
#[cfg(test)]
mod ec_tests {
use super::*;
use rstest::*;
const EC_PRIVATE_KEY_NIST256_PEM: &str = r#"-----BEGIN EC PRIVATE KEY-----
MHcCAQEEICHio5XUa+RbeFfGtGHfbPWehTFJJtCB4/izKHJ9Vm+goAoGCCqGSM49
AwEHoUQDQgAEh7ZqcI6f0tgqq7nqdcxWM6P4GGCfkWc4q11uXFjtXOKHKCV3LzMY
g8/V1PD/YOh0HodRJAjkjXub8AmYxiTcXw==
-----END EC PRIVATE KEY-----"#;
const EC_PRIVATE_KEY_NIST384_PEM: &str = r#"-----BEGIN EC PRIVATE KEY-----
MIGkAgEBBDDT8VOfdzHbIRaWOO1F0vgotY2qM2FfYS3zpdKE7Vqbh26hFsUw+iaG
GmGnT+29kg+gBwYFK4EEACKhZANiAAQFvVVUKRdN3/bqaEpDA1aHu8FEd3ujuyS0
AadG6QAiZxH37BGumBcyTTeGHyArqb+GTpsHTUXASbP+P+p5JgkfF9wBMF1SVTvu
ACZOYcqzGbsAXXdMYqewckhc42ye0u0=
-----END EC PRIVATE KEY-----"#;
#[rstest]
#[case(HashAlgorithm::MD5, false)]
#[case(HashAlgorithm::SHA1, false)]
#[case(HashAlgorithm::SHA2_224, false)]
#[case(HashAlgorithm::SHA2_256, true)]
#[case(HashAlgorithm::SHA2_384, true)]
#[case(HashAlgorithm::SHA2_512, true)]
#[case(HashAlgorithm::SHA3_384, false)]
#[case(HashAlgorithm::SHA3_512, false)]
fn algorithm_identifier_conversions(#[case] hash: HashAlgorithm, #[case] success: bool) {
let signature_algorithm = SignatureAlgorithm::Ecdsa(hash);
let algorithm_identifier = AlgorithmIdentifier::try_from(signature_algorithm);
if success {
assert!(algorithm_identifier.is_ok());
} else {
assert!(matches!(algorithm_identifier, Err(SignatureError::Ec { context: _ })));
}
}
#[test]
fn verify_bad_signature() {
let private_key_signature = PrivateKey::from_pem_str(EC_PRIVATE_KEY_NIST256_PEM).unwrap();
let signature_algorithm = SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_256);
let msg = b"hello world";
let signature = signature_algorithm.sign(msg, &private_key_signature).unwrap();
let another_ec_private_key_nist256_pem = r#"-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIBVYtZ17YMj89Kuu47TOxJlLVlk7MDUuAlFrVXxexgkSoAoGCCqGSM49
AwEHoUQDQgAE/irzdOJk28zjVv3sov15/NLIOxoIwL9kM2p/RfQAslATwHpD/T79
csaQwO9jFvbQFIpCvcMRjaunLfhIWiYDdg==
-----END EC PRIVATE KEY-----"#;
let another_private_key = PrivateKey::from_pem_str(another_ec_private_key_nist256_pem).unwrap();
let wrong_public_key = PublicKey::try_from(another_private_key).unwrap();
assert!(matches!(
signature_algorithm.verify(&wrong_public_key, msg, &signature),
Err(SignatureError::BadSignature)
));
}
#[rstest]
#[case(picky_test_data::EC_NIST256_PK_1, HashAlgorithm::SHA2_256, true)]
#[case(picky_test_data::EC_NIST384_PK_1, HashAlgorithm::SHA2_384, true)]
#[case(picky_test_data::EC_NIST521_PK_1, HashAlgorithm::SHA2_512, true)]
fn sign_and_verify(#[case] key_pem: &str, #[case] hash: HashAlgorithm, #[case] sign_successful: bool) {
let private_key = PrivateKey::from_pem_str(key_pem).unwrap();
let signature_algorithm = SignatureAlgorithm::Ecdsa(hash);
let msg = b"hello world";
let signature = signature_algorithm.sign(msg, &private_key);
assert_eq!(signature.is_ok(), sign_successful);
if !sign_successful {
return;
}
let public_key = PublicKey::try_from(private_key).unwrap();
signature_algorithm
.verify(&public_key, msg, &signature.unwrap())
.unwrap();
}
#[rstest]
#[case(EC_PRIVATE_KEY_NIST256_PEM, &ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING, HashAlgorithm::SHA2_256)]
#[case(EC_PRIVATE_KEY_NIST384_PEM, &ring::signature::ECDSA_P384_SHA384_ASN1_SIGNING, HashAlgorithm::SHA2_384)]
fn sign_and_verify_compatibility_with_ring(
#[case] key_pem: &str,
#[case] algorithm: &'static ring::signature::EcdsaSigningAlgorithm,
#[case] hash: HashAlgorithm,
) {
// sign using ring
let private_key = PrivateKey::from_pem_str(key_pem).unwrap();
let public_key = PublicKey::try_from(private_key).unwrap();
let msg = b"hello world";
let (privk, pubk) = match hash {
HashAlgorithm::SHA2_256 => {
use p256::Sec1Point;
let k = p256::SecretKey::from_sec1_pem(key_pem).unwrap();
(
k.to_bytes().as_slice().to_vec(),
Into::<Sec1Point>::into(k.public_key()).as_bytes().to_vec(),
)
}
HashAlgorithm::SHA2_384 => {
use p384::Sec1Point;
let k = p384::SecretKey::from_sec1_pem(key_pem).unwrap();
(
k.to_bytes().as_slice().to_vec(),
Into::<Sec1Point>::into(k.public_key()).as_bytes().to_vec(),
)
}
_ => panic!("no this condition"),
};
let rng = ring::rand::SystemRandom::new();
let keypair =
ring::signature::EcdsaKeyPair::from_private_key_and_public_key(algorithm, &privk, &pubk, &rng).unwrap();
let rng = ring::rand::SystemRandom::new();
let signature = keypair.sign(&rng, msg).unwrap();
let sig = signature.as_ref().to_vec();
// verify using rust-crypto
let signature_algorithm = SignatureAlgorithm::Ecdsa(hash);
signature_algorithm.verify(&public_key, msg, &sig).unwrap();
}
}
File diff suppressed because it is too large Load Diff
+595
View File
@@ -0,0 +1,595 @@
use crate::key::ec::{EcCurve, NamedEcCurve};
use crate::key::ed::NamedEdAlgorithm;
use crate::key::{EdAlgorithm, PrivateKey, PublicKey};
use crate::ssh::certificate::{
SshCertKeyType, SshCertType, SshCertTypeError, SshCertificate, SshCertificateError, SshCriticalOption,
SshCriticalOptionError, SshCriticalOptionType, SshExtension, SshExtensionError, SshExtensionType, SshSignature,
SshSignatureError, SshSignatureFormat, Timestamp,
};
use crate::ssh::private_key::{KdfOption, SshBasePrivateKey, SshPrivateKeyError};
use crate::ssh::public_key::{SshBasePublicKey, SshPublicKey, SshPublicKeyError};
use crate::ssh::{Base64Reader, SSH_COMBO_ED25519_KEY_LENGTH, key_type, read_until_linebreak, read_until_whitespace};
use super::certificate::SshSignatureBlob;
use base64::engine::general_purpose;
use byteorder::{BigEndian, ReadBytesExt};
use crypto_bigint::BoxedUint;
use picky_asn1_x509::oid::ObjectIdentifier;
use picky_asn1_x509::oids;
use std::io::{self, Cursor, Read};
pub trait SshReadExt {
type Error;
fn read_ssh_string(&mut self) -> Result<String, Self::Error>;
fn read_ssh_bytes(&mut self) -> Result<Vec<u8>, Self::Error>;
fn read_ssh_mpint(&mut self) -> Result<BoxedUint, Self::Error>;
}
impl<T> SshReadExt for T
where
T: Read,
{
type Error = io::Error;
fn read_ssh_string(&mut self) -> Result<String, Self::Error> {
let size = self.read_u32::<BigEndian>()? as usize;
let mut buffer = vec![0; size];
self.read_exact(&mut buffer)?;
Ok(String::from_utf8_lossy(&buffer).into_owned())
}
fn read_ssh_bytes(&mut self) -> Result<Vec<u8>, Self::Error> {
let size = self.read_u32::<BigEndian>()? as usize;
let mut buffer = vec![0; size];
self.read_exact(&mut buffer)?;
Ok(buffer)
}
fn read_ssh_mpint(&mut self) -> Result<BoxedUint, Self::Error> {
let size = self.read_u32::<BigEndian>()? as usize;
let mut buffer = vec![0; size];
self.read_exact(&mut buffer)?;
if buffer[0] == 0 {
buffer.remove(0);
}
Ok(BoxedUint::from_be_slice_vartime(&buffer))
}
}
pub trait SshComplexTypeDecode: Sized {
type Error;
fn decode(stream: impl Read) -> Result<Self, Self::Error>;
}
impl SshComplexTypeDecode for SshCertType {
type Error = SshCertTypeError;
fn decode(mut stream: impl Read) -> Result<Self, Self::Error> {
SshCertType::try_from(stream.read_u32::<BigEndian>()?)
}
}
impl SshComplexTypeDecode for SshCriticalOption {
type Error = SshCriticalOptionError;
fn decode(mut stream: impl Read) -> Result<Self, Self::Error> {
let option_type: String = stream.read_ssh_string()?;
let data: String = stream.read_ssh_string()?;
Ok(SshCriticalOption {
option_type: SshCriticalOptionType::try_from(option_type)?,
data,
})
}
}
impl<T> SshComplexTypeDecode for Vec<T>
where
T: SshComplexTypeDecode,
T::Error: From<std::io::Error>,
{
type Error = T::Error;
fn decode(mut stream: impl Read) -> Result<Self, Self::Error> {
let data = stream.read_ssh_bytes()?;
let len = data.len() as u64;
let mut cursor = Cursor::new(data);
let mut res = Vec::new();
while cursor.position() < len {
let elem: Result<T, Self::Error> = SshComplexTypeDecode::decode(&mut cursor);
res.push(elem?);
}
Ok(res)
}
}
impl SshComplexTypeDecode for SshExtension {
type Error = SshExtensionError;
fn decode(mut stream: impl Read) -> Result<Self, Self::Error> {
let extension_type = stream.read_ssh_string()?;
let data = stream.read_ssh_string()?;
Ok(SshExtension {
extension_type: SshExtensionType::try_from(extension_type)?,
data,
})
}
}
impl SshComplexTypeDecode for Vec<String> {
type Error = io::Error;
fn decode(mut stream: impl Read) -> Result<Self, Self::Error> {
let data = stream.read_ssh_bytes()?;
let len = data.len();
let mut cursor = Cursor::new(data);
let mut res = Vec::new();
while cursor.position() < len as u64 {
res.push(cursor.read_ssh_string()?);
}
Ok(res)
}
}
impl SshComplexTypeDecode for SshSignature {
type Error = SshSignatureError;
fn decode(mut stream: impl Read) -> Result<Self, Self::Error> {
let _overall_size = stream.read_u32::<BigEndian>()?;
let format = SshSignatureFormat::new(stream.read_ssh_string()?.as_str())?;
let data = stream.read_ssh_bytes()?;
match format {
SshSignatureFormat::SkEd25519 | SshSignatureFormat::SkEcdsaSha2NistP256 => {
let flags = stream.read_u8()?;
let counter = stream.read_u32::<BigEndian>()?;
Ok(SshSignature {
format,
blob: SshSignatureBlob::Sk { data, flags, counter },
})
}
_ => Ok(SshSignature {
format,
blob: SshSignatureBlob::Standard(data),
}),
}
}
}
impl SshComplexTypeDecode for KdfOption {
type Error = io::Error;
fn decode(mut stream: impl Read) -> Result<Self, Self::Error> {
let data = stream.read_ssh_bytes()?;
if data.is_empty() {
return Ok(KdfOption::default());
}
let mut data = data.as_slice();
let salt = data.read_ssh_bytes()?;
let rounds = data.read_u32::<BigEndian>()?;
Ok(KdfOption { salt, rounds })
}
}
impl SshComplexTypeDecode for Timestamp {
type Error = io::Error;
fn decode(mut stream: impl Read) -> Result<Self, Self::Error> {
let timestamp = stream.read_u64::<BigEndian>()?;
let time = Timestamp::from(timestamp);
Ok(time)
}
}
impl SshComplexTypeDecode for SshBasePublicKey {
type Error = SshPublicKeyError;
fn decode(mut stream: impl Read) -> Result<Self, Self::Error> {
let key_type = stream.read_ssh_string()?;
match key_type.as_str() {
key_type::RSA => {
let e = stream.read_ssh_mpint()?;
let n = stream.read_ssh_mpint()?;
Ok(SshBasePublicKey::Rsa(PublicKey::from_rsa_components(&n, &e)))
}
key_type::ECDSA_SHA2_NIST_P256 | key_type::ECDSA_SHA2_NIST_P384 | key_type::ECDSA_SHA2_NIST_P521 => {
let (curve, point) = decode_ec_public_key_body_impl(key_type.as_str(), &mut stream)?;
Ok(SshBasePublicKey::Ec(PublicKey::from_ec_encoded_components(
&curve.into(),
&point,
)))
}
key_type::ED25519 => {
let (algorithm, public_key) = decode_ed25519_public_key_body_impl(key_type.as_str(), &mut stream)?;
Ok(SshBasePublicKey::Ed(PublicKey::from_ed_encoded_components(
&algorithm.into(),
&public_key,
)))
}
key_type::SK_ECDSA_SHA2_NIST_P256 => {
let (curve, point) = decode_ec_public_key_body_impl(key_type.as_str(), &mut stream)?;
let base_key = PublicKey::from_ec_encoded_components(&curve.into(), &point);
let application = stream.read_ssh_string()?;
Ok(SshBasePublicKey::SkEcdsaSha2NistP256 { base_key, application })
}
key_type::SK_ED25519 => {
let (algorithm, public_key) = decode_ed25519_public_key_body_impl(key_type.as_str(), &mut stream)?;
let base_key = PublicKey::from_ed_encoded_components(&algorithm.into(), &public_key);
let application = stream.read_ssh_string()?;
Ok(SshBasePublicKey::SkEd25519 { base_key, application })
}
_ => Err(SshPublicKeyError::UnknownKeyType),
}
}
}
fn decode_ed25519_public_key_body_impl(
key_type: &str,
stream: &mut impl Read,
) -> Result<(NamedEdAlgorithm, Vec<u8>), SshPublicKeyError> {
let algorithm = match key_type {
key_type::ED25519 => NamedEdAlgorithm::Known(EdAlgorithm::Ed25519),
key_type::SK_ED25519 => NamedEdAlgorithm::Known(EdAlgorithm::Ed25519),
_ => {
return Err(SshPublicKeyError::UnknownKeyType);
}
};
let public_key = stream.read_ssh_bytes()?;
Ok((algorithm, public_key))
}
fn decode_ec_public_key_body_impl(
key_type: &str,
stream: &mut impl Read,
) -> Result<(NamedEcCurve, Vec<u8>), SshPublicKeyError> {
let curve = match key_type {
key_type::ECDSA_SHA2_NIST_P256 => NamedEcCurve::Known(EcCurve::NistP256),
key_type::ECDSA_SHA2_NIST_P384 => NamedEcCurve::Known(EcCurve::NistP384),
key_type::ECDSA_SHA2_NIST_P521 => NamedEcCurve::Unsupported(oids::secp521r1()),
key_type::SK_ECDSA_SHA2_NIST_P256 => NamedEcCurve::Known(EcCurve::NistP256),
_ => {
return Err(SshPublicKeyError::UnknownKeyType);
}
};
// Duplicated information about key type
let _identifier = stream.read_ssh_string()?;
// Public key encoded from an elliptic curve point into an
// octet string as per [RFC](https://datatracker.ietf.org/doc/html/rfc5656#section-3.1).
let point_data = stream.read_ssh_bytes()?;
Ok((curve, point_data))
}
impl SshComplexTypeDecode for SshPublicKey {
type Error = SshPublicKeyError;
fn decode(mut stream: impl Read) -> Result<Self, Self::Error> {
let mut buffer = Vec::with_capacity(1024);
read_until_whitespace(&mut stream, &mut buffer)?;
let header = String::from_utf8_lossy(&buffer).into_owned();
buffer.clear();
let inner_key = match header.as_str() {
key_type::RSA
| key_type::ECDSA_SHA2_NIST_P256
| key_type::ECDSA_SHA2_NIST_P384
| key_type::ECDSA_SHA2_NIST_P521
| key_type::ED25519
| key_type::SK_ECDSA_SHA2_NIST_P256
| key_type::SK_ED25519 => {
read_until_whitespace(&mut stream, &mut buffer)?;
let mut slice = buffer.as_slice();
let decoder = Base64Reader::new(&mut slice, &general_purpose::STANDARD);
SshComplexTypeDecode::decode(decoder)?
}
_ => return Err(SshPublicKeyError::UnknownKeyType),
};
buffer.clear();
read_until_linebreak(&mut stream, &mut buffer)?;
let comment = core::str::from_utf8(&buffer)?.trim_end().to_owned();
Ok(SshPublicKey { inner_key, comment })
}
}
impl SshComplexTypeDecode for SshBasePrivateKey {
type Error = SshPrivateKeyError;
fn decode(mut stream: impl Read) -> Result<Self, Self::Error> {
let key_type = stream.read_ssh_string()?;
match key_type.as_str() {
key_type::RSA => {
let n_constant = stream.read_ssh_mpint()?;
let e_constant = stream.read_ssh_mpint()?;
let d_constant = stream.read_ssh_mpint()?;
let _iqmp = stream.read_ssh_mpint()?;
let p_constant = stream.read_ssh_mpint()?;
let q_constant = stream.read_ssh_mpint()?;
Ok(SshBasePrivateKey::Rsa(PrivateKey::from_rsa_components(
&n_constant,
&e_constant,
&d_constant,
&[p_constant, q_constant],
)?))
}
key_type::ECDSA_SHA2_NIST_P256 | key_type::ECDSA_SHA2_NIST_P384 | key_type::ECDSA_SHA2_NIST_P521 => {
let (curve, point) = decode_ec_public_key_body_impl(key_type.as_str(), &mut stream)?;
let private_key_secret = stream.read_ssh_mpint()?.to_be_bytes_trimmed_vartime();
Ok(SshBasePrivateKey::Ec(PrivateKey::from_ec_encoded_components(
curve.into(),
&private_key_secret,
Some(point.as_slice()),
)))
}
key_type::ED25519 => {
let (algorithm, public_key) = decode_ed25519_public_key_body_impl(key_type.as_str(), &mut stream)?;
let private_key_secret = stream.read_ssh_mpint()?.to_be_bytes_trimmed_vartime();
// OpenSSH is really strange in regards to private ed25519 keys. It stores them as
// 64 byte-array, but actually only first 32 bytes are the private key, and the rest
// is public key copy
if private_key_secret.len() != SSH_COMBO_ED25519_KEY_LENGTH {
return Err(SshPrivateKeyError::InvalidKeyFormat);
}
let private_key_secret = &private_key_secret[..ed25519_dalek::SECRET_KEY_LENGTH];
Ok(SshBasePrivateKey::Ed(PrivateKey::from_ed_encoded_components(
algorithm.into(),
private_key_secret,
Some(&public_key),
)))
}
key_type::SK_ECDSA_SHA2_NIST_P256 => {
let (_curve, point) = decode_ec_public_key_body_impl(key_type.as_str(), &mut stream)?;
let application = stream.read_ssh_string()?;
let flags = stream.read_u8()?;
let handle = stream.read_ssh_bytes()?;
let _reserved = stream.read_ssh_bytes()?;
Ok(SshBasePrivateKey::SkEcdsaSha2NistP256 {
public_key: PublicKey::from_ec_encoded_components(
&ObjectIdentifier::from(NamedEcCurve::Known(EcCurve::NistP256)),
&point,
),
application,
flags,
handle,
})
}
key_type::SK_ED25519 => {
let (_algorithm, public_key) = decode_ed25519_public_key_body_impl(key_type.as_str(), &mut stream)?;
let application = stream.read_ssh_string()?;
let flags = stream.read_u8()?;
let handle = stream.read_ssh_bytes()?;
let _reserved = stream.read_ssh_bytes()?;
Ok(SshBasePrivateKey::SkEd25519 {
public_key: PublicKey::from_ed_encoded_components(
&ObjectIdentifier::from(EdAlgorithm::Ed25519),
&public_key,
),
application,
flags,
handle,
})
}
key_type => Err(SshPrivateKeyError::UnsupportedKeyType(key_type.to_owned())),
}
}
}
impl SshComplexTypeDecode for SshCertificate {
type Error = SshCertificateError;
fn decode(mut stream: impl Read) -> Result<Self, Self::Error> {
let mut cert_type = Vec::new();
read_until_whitespace(&mut stream, &mut cert_type)?;
let _ = SshCertKeyType::try_from(String::from_utf8(cert_type)?)?;
let mut cert_data = Vec::new();
read_until_whitespace(&mut stream, &mut cert_data)?;
let mut cert_data = cert_data.as_slice();
let mut cert_data = Base64Reader::new(&mut cert_data, &general_purpose::STANDARD);
let cert_key_type = cert_data.read_ssh_string()?;
let cert_key_type = SshCertKeyType::try_from(cert_key_type)?;
let nonce = cert_data.read_ssh_bytes()?;
let inner_public_key = match &cert_key_type {
SshCertKeyType::SshRsaV01 | SshCertKeyType::RsaSha2_256V01 | SshCertKeyType::RsaSha2_512v01 => {
let e = cert_data.read_ssh_mpint()?;
let n = cert_data.read_ssh_mpint()?;
SshBasePublicKey::Rsa(PublicKey::from_rsa_components(&n, &e))
}
SshCertKeyType::EcdsaSha2Nistp256V01
| SshCertKeyType::EcdsaSha2Nistp384V01
| SshCertKeyType::EcdsaSha2Nistp521V01 => {
let curve = match cert_key_type {
SshCertKeyType::EcdsaSha2Nistp256V01 => NamedEcCurve::Known(EcCurve::NistP256),
SshCertKeyType::EcdsaSha2Nistp384V01 => NamedEcCurve::Known(EcCurve::NistP384),
SshCertKeyType::EcdsaSha2Nistp521V01 => NamedEcCurve::Known(EcCurve::NistP521),
_ => unreachable!("Already validated in match above"),
};
let _curve_identifier = cert_data.read_ssh_string()?;
let public_key_data = cert_data.read_ssh_bytes()?;
SshBasePublicKey::Ec(PublicKey::from_ec_encoded_components(&curve.into(), &public_key_data))
}
SshCertKeyType::SshEd25519V01 => {
let algorithm = NamedEdAlgorithm::Known(EdAlgorithm::Ed25519).into();
let public_key_data = cert_data.read_ssh_bytes()?;
SshBasePublicKey::Ed(PublicKey::from_ed_encoded_components(&algorithm, &public_key_data))
}
SshCertKeyType::SshDssV01 => {
return Err(SshCertificateError::UnsupportedCertificateType(
cert_key_type.as_str().to_owned(),
));
}
SshCertKeyType::SkSshSha2Nistp256V01 => {
let _curve_identifier = cert_data.read_ssh_string()?;
let public_key_data = cert_data.read_ssh_bytes()?;
let application = cert_data.read_ssh_string()?;
SshBasePublicKey::SkEcdsaSha2NistP256 {
base_key: PublicKey::from_ec_encoded_components(
&NamedEcCurve::Known(EcCurve::NistP256).into(),
&public_key_data,
),
application,
}
}
SshCertKeyType::SkSshEd25519V01 => {
let public_key_data = cert_data.read_ssh_bytes()?;
let application = cert_data.read_ssh_string()?;
SshBasePublicKey::SkEd25519 {
base_key: PublicKey::from_ed_encoded_components(
&NamedEdAlgorithm::Known(EdAlgorithm::Ed25519).into(),
&public_key_data,
),
application,
}
}
};
let serial = cert_data.read_u64::<BigEndian>()?;
let cert_type: SshCertType = SshComplexTypeDecode::decode(&mut cert_data)?;
let key_id = cert_data.read_ssh_string()?;
let valid_principals: Vec<String> = SshComplexTypeDecode::decode(&mut cert_data)?;
let valid_after: Timestamp = SshComplexTypeDecode::decode(&mut cert_data)?;
let valid_before: Timestamp = SshComplexTypeDecode::decode(&mut cert_data)?;
let critical_options: Vec<SshCriticalOption> = SshComplexTypeDecode::decode(&mut cert_data)?;
let extensions: Vec<SshExtension> = SshComplexTypeDecode::decode(&mut cert_data)?;
let _ = cert_data.read_ssh_bytes()?; // reserved
// here is public key
let signature_key = cert_data.read_ssh_bytes()?;
let signature_public_key: SshBasePublicKey = SshComplexTypeDecode::decode(signature_key.as_slice())?;
let signature = SshSignature::decode(cert_data)?;
let mut comment = Vec::new();
read_until_linebreak(&mut stream, &mut comment)?;
let comment = core::str::from_utf8(&comment)?.trim_end().to_owned();
Ok(SshCertificate {
cert_key_type,
public_key: SshPublicKey {
inner_key: inner_public_key,
comment: String::new(),
},
nonce,
serial,
cert_type,
key_id,
valid_principals,
valid_after,
valid_before,
critical_options,
extensions,
signature_key: SshPublicKey {
inner_key: signature_public_key,
comment: String::new(),
},
signature,
comment,
})
}
}
#[cfg(test)]
mod test {
use super::SshReadExt;
use std::io::Cursor;
#[test]
fn ssh_string_decode() {
let mut cursor = Cursor::new([0, 0, 0, 5, 112, 105, 99, 107, 121].to_vec());
let ssh_string = cursor.read_ssh_string().unwrap();
assert_eq!(5, ssh_string.len());
assert_eq!("picky".to_owned(), ssh_string);
assert_eq!(9, cursor.position());
let mut cursor = Cursor::new([0, 0, 0, 0].to_vec());
let ssh_string = cursor.read_ssh_string().unwrap();
assert_eq!(0, ssh_string.len());
assert_eq!("".to_owned(), ssh_string);
assert_eq!(4, cursor.position());
}
#[test]
fn byte_array_decode() {
let mut cursor = Cursor::new([0, 0, 0, 5, 1, 2, 3, 4, 5].to_vec());
let byte_array = cursor.read_ssh_bytes().unwrap();
assert_eq!(5, byte_array.len());
assert_eq!([1, 2, 3, 4, 5].to_vec(), byte_array);
assert_eq!(9, cursor.position());
let mut cursor = Cursor::new([0, 0, 0, 0].to_vec());
let byte_array = cursor.read_ssh_bytes().unwrap();
assert_eq!(0, byte_array.len());
assert_eq!(Vec::<u8>::new(), byte_array);
assert_eq!(4, cursor.position());
}
#[test]
fn mpint_decoding() {
let mut cursor = Cursor::new(vec![
0x00, 0x00, 0x00, 0x08, 0x09, 0xa3, 0x78, 0xf9, 0xb2, 0xe3, 0x32, 0xa7,
]);
let mpint = cursor.read_ssh_mpint().unwrap();
assert_eq!(
mpint.to_be_bytes_trimmed_vartime().as_ref(),
&[0x09, 0xa3, 0x78, 0xf9, 0xb2, 0xe3, 0x32, 0xa7]
);
let mut cursor = Cursor::new(vec![0x00, 0x00, 0x00, 0x02, 0x00, 0x80]);
let mpint = cursor.read_ssh_mpint().unwrap();
assert_eq!(mpint.to_be_bytes_trimmed_vartime().as_ref(), [0x80]);
let mut cursor = Cursor::new(vec![0x00, 0x00, 0x00, 0x02, 0xed, 0xcc]);
let mpint = cursor.read_ssh_mpint().unwrap();
assert_eq!(mpint.to_be_bytes_trimmed_vartime().as_ref(), &[0xed, 0xcc]);
}
}
+574
View File
@@ -0,0 +1,574 @@
use crate::key::ec::{EcdsaKeypair, EcdsaPublicKey};
use crate::key::ed::{EdKeypair, EdPublicKey};
use crate::ssh::certificate::{
SshCertType, SshCertTypeError, SshCertificate, SshCertificateError, SshCriticalOption, SshCriticalOptionError,
SshExtension, SshExtensionError, SshSignature, SshSignatureError, Timestamp,
};
use crate::ssh::private_key::{
AES256_CTR, AUTH_MAGIC, Aes256Ctr, BCRYPT, KdfOption, NONE, SshBasePrivateKey, SshPrivateKey, SshPrivateKeyError,
};
use crate::ssh::public_key::{SshBasePublicKey, SshPublicKey, SshPublicKeyError};
use crate::ssh::{Base64Writer, EcCurveSshExt as _, EdAlgorithmSshExt as _, SSH_COMBO_ED25519_KEY_LENGTH, key_type};
use super::certificate::SshSignatureBlob;
use super::key_identifier;
use aes::cipher::{KeyIvInit, StreamCipher};
use base64::engine::general_purpose;
use byteorder::{BigEndian, WriteBytesExt};
use crypto_bigint::NonZero;
use rsa::traits::{PrivateKeyParts as _, PublicKeyParts as _};
use rsa::{BoxedUint, RsaPrivateKey, RsaPublicKey};
use std::io::{self, Write};
pub trait SshWriteExt {
type Error;
fn write_ssh_string(&mut self, data: &str) -> Result<(), Self::Error>;
fn write_ssh_bytes(&mut self, data: &[u8]) -> Result<(), Self::Error>;
fn write_ssh_mpint(&mut self, data: &BoxedUint) -> Result<(), Self::Error>;
}
impl<T> SshWriteExt for T
where
T: Write,
{
type Error = io::Error;
fn write_ssh_string(&mut self, data: &str) -> Result<(), Self::Error> {
self.write_u32::<BigEndian>(data.len() as u32)?;
self.write_all(data.as_bytes())
}
fn write_ssh_bytes(&mut self, data: &[u8]) -> Result<(), Self::Error> {
self.write_u32::<BigEndian>(data.len() as u32)?;
self.write_all(data)
}
fn write_ssh_mpint(&mut self, data: &BoxedUint) -> Result<(), Self::Error> {
let data = data.to_be_bytes_trimmed_vartime();
let size = data.len() as u32;
// If the most significant bit would be set for
// a positive number, the number MUST be preceded by a zero byte.
if size > 0 && data[0] & 0b10000000 != 0 {
self.write_u32::<BigEndian>(size + 1)?;
self.write_u8(0)?;
} else {
self.write_u32::<BigEndian>(size)?;
}
self.write_all(&data)
}
}
pub trait SshComplexTypeEncode {
type Error;
fn encode(&self, stream: impl Write) -> Result<(), Self::Error>;
}
impl SshComplexTypeEncode for SshCertType {
type Error = SshCertTypeError;
fn encode(&self, mut stream: impl Write) -> Result<(), Self::Error> {
stream.write_u32::<BigEndian>((*self).into())?;
Ok(())
}
}
impl SshComplexTypeEncode for SshCriticalOption {
type Error = SshCriticalOptionError;
fn encode(&self, mut stream: impl Write) -> Result<(), Self::Error> {
stream.write_ssh_string(self.option_type.as_str())?;
stream.write_ssh_string(self.data.as_str())?;
Ok(())
}
}
impl<T> SshComplexTypeEncode for Vec<T>
where
T: SshComplexTypeEncode,
T::Error: From<std::io::Error>,
{
type Error = T::Error;
fn encode(&self, mut stream: impl Write) -> Result<(), Self::Error> {
let mut data = Vec::new();
for elem in self.iter() {
elem.encode(&mut data)?;
}
stream.write_ssh_bytes(&data)?;
Ok(())
}
}
impl SshComplexTypeEncode for SshExtension {
type Error = SshExtensionError;
fn encode(&self, mut stream: impl Write) -> Result<(), Self::Error> {
stream.write_ssh_string(self.extension_type.as_str())?;
stream.write_ssh_string(self.data.as_str())?;
Ok(())
}
}
impl SshComplexTypeEncode for Vec<String> {
type Error = io::Error;
fn encode(&self, mut stream: impl Write) -> Result<(), Self::Error> {
let mut data = Vec::new();
for s in self.iter() {
data.write_ssh_string(s)?;
}
stream.write_ssh_bytes(&data)?;
Ok(())
}
}
impl SshComplexTypeEncode for SshSignature {
type Error = SshSignatureError;
fn encode(&self, mut stream: impl Write) -> Result<(), Self::Error> {
let overall_size = self.format.as_str().len() + self.blob.size() + 8;
stream.write_u32::<BigEndian>(overall_size as u32)?;
stream.write_ssh_string(self.format.as_str())?;
match &self.blob {
SshSignatureBlob::Standard(data) => {
stream.write_ssh_bytes(data)?;
}
SshSignatureBlob::Sk { data, flags, counter } => {
stream.write_ssh_bytes(data)?;
stream.write_u8(*flags)?;
stream.write_u32::<BigEndian>(*counter)?;
}
};
Ok(())
}
}
impl SshComplexTypeEncode for KdfOption {
type Error = io::Error;
fn encode(&self, mut stream: impl Write) -> Result<(), Self::Error> {
if self.salt.is_empty() {
stream.write_u32::<BigEndian>(0)?;
return Ok(());
}
let mut data = Vec::new();
data.write_ssh_bytes(&self.salt)?;
data.write_u32::<BigEndian>(self.rounds)?;
stream.write_ssh_bytes(&data)?;
Ok(())
}
}
impl SshComplexTypeEncode for Timestamp {
type Error = io::Error;
fn encode(&self, mut stream: impl Write) -> Result<(), Self::Error> {
stream.write_u64::<BigEndian>(self.0)?;
Ok(())
}
}
impl SshComplexTypeEncode for SshBasePublicKey {
type Error = SshPublicKeyError;
fn encode(&self, mut stream: impl Write) -> Result<(), Self::Error> {
match self {
SshBasePublicKey::Rsa(rsa) => {
let rsa = RsaPublicKey::try_from(rsa)?;
stream.write_ssh_string(key_type::RSA)?;
stream.write_ssh_mpint(rsa.e())?;
stream.write_ssh_mpint(rsa.n())?;
Ok(())
}
SshBasePublicKey::Ec(ec) => {
let key = EcdsaPublicKey::try_from(ec)?;
encode_ecdsa_public_key_body(&mut stream, &key)?;
Ok(())
}
SshBasePublicKey::Ed(ed) => {
let key = EdPublicKey::try_from(ed)?;
encode_ed_public_key_body(&mut stream, &key)
}
SshBasePublicKey::SkEcdsaSha2NistP256 { base_key, application } => {
let key = EcdsaPublicKey::try_from(base_key)?;
stream.write_ssh_string(key_type::SK_ECDSA_SHA2_NIST_P256)?;
stream.write_ssh_string(key_identifier::ECDSA_SHA2_NIST_P256)?;
stream.write_ssh_bytes(key.encoded_point())?;
stream.write_ssh_string(application.as_str())?;
Ok(())
}
SshBasePublicKey::SkEd25519 { base_key, application } => {
let key = EdPublicKey::try_from(base_key)?;
stream.write_ssh_string(key_type::SK_ED25519)?;
stream.write_ssh_bytes(key.data())?;
stream.write_ssh_string(application.as_str())?;
Ok(())
}
}
}
}
impl SshComplexTypeEncode for SshPublicKey {
type Error = SshPublicKeyError;
fn encode(&self, mut stream: impl Write) -> Result<(), Self::Error> {
// Write key type
match &self.inner_key {
SshBasePublicKey::Rsa(_) => {
stream.write_all(key_type::RSA.as_bytes())?;
}
SshBasePublicKey::Ec(key) => {
let key = EcdsaPublicKey::try_from(key)?;
stream.write_all(key.curve().to_ecdsa_ssh_key_type()?.as_bytes())?;
}
SshBasePublicKey::Ed(key) => {
let key = EdPublicKey::try_from(key)?;
stream.write_all(key.algorithm().to_ed_ssh_key_type()?.as_bytes())?;
}
SshBasePublicKey::SkEcdsaSha2NistP256 { .. } => {
stream.write_all(key_type::SK_ECDSA_SHA2_NIST_P256.as_bytes())?;
}
SshBasePublicKey::SkEd25519 { .. } => {
stream.write_all(key_type::SK_ED25519.as_bytes())?;
}
};
stream.write_u8(b' ')?;
{
let mut base64_write = Base64Writer::new(&mut stream, &general_purpose::STANDARD);
self.inner_key.encode(&mut base64_write)?;
base64_write.finish()?;
}
stream.write_u8(b' ')?;
stream.write_all(self.comment.as_bytes())?;
stream.write_all("\r\n".as_bytes())?;
Ok(())
}
}
impl SshComplexTypeEncode for SshBasePrivateKey {
type Error = SshPrivateKeyError;
fn encode(&self, mut stream: impl Write) -> Result<(), Self::Error> {
match self {
SshBasePrivateKey::Rsa(rsa) => {
let rsa = RsaPrivateKey::try_from(rsa)?;
stream.write_ssh_string(key_type::RSA)?;
stream.write_ssh_mpint(rsa.n())?;
stream.write_ssh_mpint(rsa.e())?;
stream.write_ssh_mpint(rsa.d())?;
let prime = NonZero::new(rsa.primes()[0].clone())
.into_option()
.ok_or(SshPrivateKeyError::RsaPrimeIsZero)?;
let iqmp = rsa.primes()[1]
.invert_mod(&prime)
.into_option()
.ok_or(SshPrivateKeyError::RsaSecondPrimeInvertModFirstPrimeFailed)?;
stream.write_ssh_mpint(&iqmp)?;
for prime in rsa.primes().iter() {
stream.write_ssh_mpint(prime)?;
}
}
SshBasePrivateKey::Ec(key) => {
let keypair = EcdsaKeypair::try_from(key)?;
let public_key = EcdsaPublicKey::try_from(&keypair)?;
// Encode the public key part
encode_ecdsa_public_key_body(&mut stream, &public_key)?;
// Ecnode encoded secret
let secret = BoxedUint::from_be_slice_vartime(keypair.secret());
stream.write_ssh_mpint(&secret)?;
}
SshBasePrivateKey::Ed(key) => {
let keypair = EdKeypair::try_from(key)?;
let public_key = EdPublicKey::try_from(&keypair)?;
encode_ed_public_key_body(&mut stream, &public_key)?;
// SSH Ed25519 key private kye field contains secret in first 32 bytes and the
// public key copy in the last 32 bytes.
let mut secret = Vec::with_capacity(SSH_COMBO_ED25519_KEY_LENGTH);
secret.extend_from_slice(keypair.secret());
secret.extend_from_slice(public_key.data());
stream.write_ssh_bytes(&secret)?;
}
SshBasePrivateKey::SkEcdsaSha2NistP256 {
public_key,
application,
flags,
handle,
} => {
let ec_key = EcdsaPublicKey::try_from(public_key)?;
// Encode the public key part
stream.write_ssh_string(key_type::SK_ECDSA_SHA2_NIST_P256)?;
stream.write_ssh_string(key_identifier::ECDSA_SHA2_NIST_P256)?;
stream.write_ssh_bytes(ec_key.encoded_point())?;
stream.write_ssh_string(application.as_str())?;
stream.write_u8(*flags)?;
stream.write_ssh_bytes(handle)?;
// Reserved
stream.write_ssh_bytes(&[])?;
}
SshBasePrivateKey::SkEd25519 {
public_key,
application,
flags,
handle,
} => {
let ed_key = EdPublicKey::try_from(public_key)?;
stream.write_ssh_string(key_type::SK_ED25519)?;
stream.write_ssh_bytes(ed_key.data())?;
stream.write_ssh_string(application.as_str())?;
stream.write_u8(*flags)?;
stream.write_ssh_bytes(handle)?;
// Reserved
stream.write_ssh_bytes(&[])?;
}
};
Ok(())
}
}
fn encode_ed_public_key_body(mut stream: impl Write, key: &EdPublicKey<'_>) -> Result<(), SshPublicKeyError> {
stream.write_ssh_string(key.algorithm().to_ed_ssh_key_type()?)?;
stream.write_ssh_bytes(key.data())?;
Ok(())
}
fn encode_ecdsa_public_key_body(mut stream: impl Write, key: &EcdsaPublicKey<'_>) -> Result<(), SshPublicKeyError> {
stream.write_ssh_string(key.curve().to_ecdsa_ssh_key_type()?)?;
stream.write_ssh_string(key.curve().to_ecdsa_ssh_key_identifier()?)?;
// So called "Q" value from RFC5656. In fact - standard SEC1 encoded public key representation
stream.write_ssh_bytes(key.encoded_point())?;
Ok(())
}
impl SshComplexTypeEncode for SshPrivateKey {
type Error = SshPrivateKeyError;
fn encode(&self, mut stream: impl Write) -> Result<(), Self::Error> {
const AES256_CTR_BLOCK_SIZE: usize = 16;
const UNENCRYPTED_PADDING_SIZE: usize = 8;
stream.write_all(AUTH_MAGIC.as_bytes())?;
stream.write_u8(b'\0')?;
if self.passphrase.is_some() {
stream.write_ssh_string(AES256_CTR)?;
stream.write_ssh_string(BCRYPT)?;
let salt = &self.kdf.option.salt;
let rounds = self.kdf.option.rounds;
let mut kdf_options = Vec::new();
kdf_options.write_ssh_bytes(salt)?;
kdf_options.write_u32::<BigEndian>(rounds)?;
stream.write_ssh_bytes(&kdf_options)?;
} else {
stream.write_ssh_string(NONE)?;
stream.write_ssh_string(NONE)?;
stream.write_ssh_string("")?;
}
stream.write_u32::<BigEndian>(1)?; // keys amount
let mut public_key = Vec::new();
self.public_key().inner_key.encode(&mut public_key)?;
stream.write_ssh_bytes(&public_key)?;
public_key.clear();
let mut private_key = public_key;
private_key.write_u32::<BigEndian>(self.check)?;
private_key.write_u32::<BigEndian>(self.check)?;
self.base_key.encode(&mut private_key)?;
private_key.write_ssh_string(&self.comment)?;
let padding_size = if self.passphrase.is_some() {
AES256_CTR_BLOCK_SIZE
} else {
UNENCRYPTED_PADDING_SIZE
};
// add padding
for i in 1..=(padding_size - (private_key.len() % padding_size)) {
private_key.push(i as u8);
}
if let Some(passphrase) = &self.passphrase {
// encrypt private_key
let n = 48;
let mut hash = [0; 48];
let salt = &self.kdf.option.salt;
let rounds = self.kdf.option.rounds;
bcrypt_pbkdf::bcrypt_pbkdf(passphrase, salt, rounds, &mut hash)?;
let (key, iv) = hash.split_at(n - 16);
let mut cipher = Aes256Ctr::new_from_slices(key, iv).unwrap();
let private_key_len = private_key.len();
private_key.resize(private_key_len + 32, 0u8);
cipher.apply_keystream(&mut private_key);
private_key.truncate(private_key_len);
}
stream.write_ssh_bytes(&private_key)?;
Ok(())
}
}
impl SshComplexTypeEncode for SshCertificate {
type Error = SshCertificateError;
fn encode(&self, mut stream: impl Write) -> Result<(), Self::Error> {
stream.write_all(self.cert_key_type.as_str().as_bytes())?;
stream.write_u8(b' ')?;
let mut cert_data = Base64Writer::new(stream, &general_purpose::STANDARD);
cert_data.write_ssh_string(self.cert_key_type.as_str())?;
cert_data.write_ssh_bytes(&self.nonce)?;
match &self.public_key.inner_key {
SshBasePublicKey::Rsa(rsa) => {
let rsa = RsaPublicKey::try_from(rsa)?;
cert_data.write_ssh_mpint(rsa.e())?;
cert_data.write_ssh_mpint(rsa.n())?;
}
SshBasePublicKey::Ec(ec) => {
let ec = EcdsaPublicKey::try_from(ec)?;
cert_data.write_ssh_string(ec.curve().to_ecdsa_ssh_key_identifier()?)?;
cert_data.write_ssh_bytes(ec.encoded_point())?;
}
SshBasePublicKey::Ed(ed) => {
let ed = EdPublicKey::try_from(ed)?;
cert_data.write_ssh_bytes(ed.data())?;
}
SshBasePublicKey::SkEcdsaSha2NistP256 { base_key, application } => {
let ec = EcdsaPublicKey::try_from(base_key)?;
cert_data.write_ssh_string(key_identifier::ECDSA_SHA2_NIST_P256)?;
cert_data.write_ssh_bytes(ec.encoded_point())?;
cert_data.write_ssh_string(application.as_str())?;
}
SshBasePublicKey::SkEd25519 { base_key, application } => {
let ed = EdPublicKey::try_from(base_key)?;
cert_data.write_ssh_bytes(ed.data())?;
cert_data.write_ssh_string(application.as_str())?;
}
};
cert_data.write_u64::<BigEndian>(self.serial)?;
self.cert_type.encode(&mut cert_data)?;
cert_data.write_ssh_string(self.key_id.as_str())?;
self.valid_principals.encode(&mut cert_data)?;
self.valid_after.encode(&mut cert_data)?;
self.valid_before.encode(&mut cert_data)?;
self.critical_options.encode(&mut cert_data)?;
self.extensions.encode(&mut cert_data)?;
cert_data.write_ssh_bytes(&[])?; // reserved
let mut rsa_key = Vec::new();
self.signature_key.inner_key.encode(&mut rsa_key)?;
cert_data.write_ssh_bytes(&rsa_key)?;
self.signature.encode(&mut cert_data)?;
// stream.write_all(cert_data.finish()?.as_slice())?;
let mut stream = cert_data.finish().unwrap();
stream.write_u8(b' ')?;
stream.write_all(self.comment.as_bytes())?;
stream.write_all("\r\n".as_bytes())?;
Ok(())
}
}
#[cfg(test)]
mod test {
use super::SshWriteExt;
use rsa::BoxedUint;
#[test]
fn ssh_string_encode() {
let mut res = Vec::new();
let ssh_string = "picky";
res.write_ssh_string(ssh_string).unwrap();
assert_eq!(vec![0, 0, 0, 5, 112, 105, 99, 107, 121], res);
res.clear();
let ssh_string = "";
res.write_ssh_string(ssh_string).unwrap();
assert_eq!(vec![0, 0, 0, 0], res);
}
#[test]
fn byte_array_encode() {
let mut res = Vec::new();
let byte_array = [1, 2, 3, 4, 5, 6];
res.write_ssh_bytes(&byte_array).unwrap();
assert_eq!(vec![0, 0, 0, 6, 1, 2, 3, 4, 5, 6], res);
res.clear();
let byte_array = [];
res.write_ssh_bytes(&byte_array).unwrap();
assert_eq!(vec![0, 0, 0, 0], res);
}
#[test]
fn mpint_encoding() {
let mpint = BoxedUint::from_be_slice_vartime(&[0x09, 0xa3, 0x78, 0xf9, 0xb2, 0xe3, 0x32, 0xa7]);
let mut res = Vec::new();
res.write_ssh_mpint(&mpint).unwrap();
assert_eq!(
res,
vec![0x00, 0x00, 0x00, 0x08, 0x09, 0xa3, 0x78, 0xf9, 0xb2, 0xe3, 0x32, 0xa7],
);
let mpint = BoxedUint::from_be_slice_vartime(&[0x80]);
let mut res = Vec::new();
res.write_ssh_mpint(&mpint).unwrap();
assert_eq!(res, vec![0x00, 0x00, 0x00, 0x02, 0x00, 0x80]);
}
}
+115
View File
@@ -0,0 +1,115 @@
pub mod certificate;
pub mod decode;
pub mod encode;
pub mod private_key;
pub mod public_key;
use crate::key::ec::NamedEcCurve;
use crate::key::ed::NamedEdAlgorithm;
use crate::key::{EcCurve, EdAlgorithm, KeyError};
use byteorder::ReadBytesExt;
use std::io::{self, Read};
pub use certificate::{SshCertKeyType, SshCertType, SshCertificate, SshCertificateBuilder};
pub use private_key::SshPrivateKey;
pub use public_key::SshPublicKey;
pub(crate) type Base64Writer<'a, T, E> = base64::write::EncoderWriter<'a, T, E>;
pub(crate) type Base64Reader<'a, T, E> = base64::read::DecoderReader<'a, T, E>;
const SSH_COMBO_ED25519_KEY_LENGTH: usize = ed25519_dalek::SECRET_KEY_LENGTH + ed25519_dalek::PUBLIC_KEY_LENGTH;
mod key_type {
pub const RSA: &str = "ssh-rsa";
pub const ECDSA_SHA2_NIST_P256: &str = "ecdsa-sha2-nistp256";
pub const ECDSA_SHA2_NIST_P384: &str = "ecdsa-sha2-nistp384";
pub const ECDSA_SHA2_NIST_P521: &str = "ecdsa-sha2-nistp521";
pub const ED25519: &str = "ssh-ed25519";
pub const SK_ECDSA_SHA2_NIST_P256: &str = "sk-ecdsa-sha2-nistp256@openssh.com";
pub const SK_ED25519: &str = "sk-ssh-ed25519@openssh.com";
}
mod key_identifier {
pub const ECDSA_SHA2_NIST_P256: &str = "nistp256";
pub const ECDSA_SHA2_NIST_P384: &str = "nistp384";
pub const ECDSA_SHA2_NIST_P521: &str = "nistp521";
}
trait EcCurveSshExt {
fn to_ecdsa_ssh_key_type(&self) -> Result<&'static str, KeyError>;
fn to_ecdsa_ssh_key_identifier(&self) -> Result<&'static str, KeyError>;
}
impl EcCurveSshExt for NamedEcCurve {
fn to_ecdsa_ssh_key_type(&self) -> Result<&'static str, KeyError> {
match self {
NamedEcCurve::Known(EcCurve::NistP256) => Ok(key_type::ECDSA_SHA2_NIST_P256),
NamedEcCurve::Known(EcCurve::NistP384) => Ok(key_type::ECDSA_SHA2_NIST_P384),
NamedEcCurve::Known(EcCurve::NistP521) => Ok(key_type::ECDSA_SHA2_NIST_P521),
NamedEcCurve::Unsupported(oid) => Err(KeyError::unsupported_curve(oid, "ssh key type serialization")),
}
}
fn to_ecdsa_ssh_key_identifier(&self) -> Result<&'static str, KeyError> {
match self {
NamedEcCurve::Known(EcCurve::NistP256) => Ok(key_identifier::ECDSA_SHA2_NIST_P256),
NamedEcCurve::Known(EcCurve::NistP384) => Ok(key_identifier::ECDSA_SHA2_NIST_P384),
NamedEcCurve::Known(EcCurve::NistP521) => Ok(key_identifier::ECDSA_SHA2_NIST_P521),
NamedEcCurve::Unsupported(oid) => Err(KeyError::unsupported_curve(oid, "ssh key identifier serialization")),
}
}
}
trait EdAlgorithmSshExt {
fn to_ed_ssh_key_type(&self) -> Result<&'static str, KeyError>;
}
impl EdAlgorithmSshExt for NamedEdAlgorithm {
fn to_ed_ssh_key_type(&self) -> Result<&'static str, KeyError> {
match self {
NamedEdAlgorithm::Known(EdAlgorithm::Ed25519) => Ok(key_type::ED25519),
NamedEdAlgorithm::Known(EdAlgorithm::X25519) => Err(KeyError::UnsupportedAlgorithm {
algorithm: "X25519 can't be use for SSH EdDSA keys",
}),
NamedEdAlgorithm::Unsupported(oid) => {
Err(KeyError::unsupported_ed_algorithm(oid, "ssh key type serialization"))
}
}
}
}
fn read_until_whitespace(stream: &mut dyn Read, buffer: &mut Vec<u8>) -> io::Result<()> {
loop {
match stream.read_u8() {
Ok(symbol) => {
if symbol as char == ' ' {
break;
} else {
buffer.push(symbol);
}
}
Err(ref e) if e.kind() == io::ErrorKind::UnexpectedEof => {
break;
}
Err(e) => return Err(e),
};
}
Ok(())
}
fn read_until_linebreak(stream: &mut dyn Read, buffer: &mut Vec<u8>) -> io::Result<()> {
loop {
match stream.read_u8() {
Ok(b'\r') | Ok(b'\n') => break,
Ok(c) => buffer.push(c),
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
break;
}
Err(e) => return Err(e),
}
}
Ok(())
}
+780
View File
@@ -0,0 +1,780 @@
use crate::key::{EcCurve, EdAlgorithm, KeyError, PrivateKey, PrivateKeyKind, PublicKey};
use crate::pem::{Pem, PemError, parse_pem};
use crate::ssh::decode::{SshComplexTypeDecode, SshReadExt};
use crate::ssh::encode::SshComplexTypeEncode;
use crate::ssh::public_key::{SshBasePublicKey, SshPublicKey, SshPublicKeyError};
use aes::cipher::block_padding::NoPadding;
use aes::cipher::{KeyIvInit, StreamCipher};
use byteorder::{BigEndian, ReadBytesExt};
use cbc::cipher::BlockModeDecrypt;
use rand::RngExt;
use std::io::{Cursor, Read};
use std::string;
use thiserror::Error;
pub type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
pub type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
pub type Aes128Ctr = ctr::Ctr32BE<aes::Aes128>;
pub type Aes256Ctr = ctr::Ctr32BE<aes::Aes256>;
const SSH_PRIVATE_KEY_LABEL: &str = "OPENSSH PRIVATE KEY";
pub(crate) const AUTH_MAGIC: &str = "openssh-key-v1";
const AES128_CTR: &str = "aes128-ctr";
pub(crate) const AES256_CTR: &str = "aes256-ctr";
const AES128_CBC: &str = "aes128-cbc";
const AES256_CBC: &str = "aes256-cbc";
pub(crate) const BCRYPT: &str = "bcrypt";
pub(crate) const NONE: &str = "none";
#[derive(Debug, Error)]
pub enum SshPrivateKeyError {
#[error(transparent)]
FromUtf8Error(#[from] string::FromUtf8Error),
#[error(transparent)]
Base64DecodeError(#[from] base64::DecodeError),
#[error(transparent)]
IoError(#[from] std::io::Error),
#[error("Unsupported key type: {0}")]
UnsupportedKeyType(String),
#[error("Unsupported cipher: {0}")]
UnsupportedCipher(String),
#[error("Unsupported kdf: {0}")]
UnsupportedKdf(String),
#[error("Invalid auth magic header")]
InvalidAuthMagicHeader,
#[error("Invalid keys amount. Expected 1 but got {0}")]
InvalidKeysAmount(u32),
#[error("Check numbers are not equal: {0} {1}. Wrong passphrase or key is corrupted")]
InvalidCheckNumbers(u32, u32),
#[error("Invalid public key: {0:?}")]
InvalidPublicKey(#[from] SshPublicKeyError),
#[error("Invalid key format")]
InvalidKeyFormat,
#[error("Can not decrypt private key: {0}")]
DecryptionError(String),
#[error("Can not hash the passphrase: {0:?}")]
HashingError(#[from] bcrypt_pbkdf::Error),
#[error("Passphrase required for encrypted private key")]
MissingPassphrase,
#[error(transparent)]
KeyError(#[from] KeyError),
#[error(transparent)]
PemError(#[from] PemError),
#[error("RSA prime is zero")]
RsaPrimeIsZero,
#[error("RSA second prime invert mod first prime failed")]
RsaSecondPrimeInvertModFirstPrimeFailed,
}
#[derive(Debug, Eq, PartialEq, Clone, Default)]
pub struct KdfOption {
pub salt: Vec<u8>,
pub rounds: u32,
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct Kdf {
pub name: String,
pub option: KdfOption,
}
impl Default for Kdf {
fn default() -> Self {
Self {
name: NONE.to_owned(),
option: Default::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SshBasePrivateKey {
Rsa(PrivateKey),
Ec(PrivateKey),
Ed(PrivateKey),
/// U2F ecdsa SSH key. Note that this key does not contain the private key data, only handle
/// is stored which could be used to sign data via hardware U2F key.
SkEcdsaSha2NistP256 {
public_key: PublicKey,
application: String,
flags: u8,
handle: Vec<u8>,
},
/// U2F ed25519 SSH key. Note that this key does not contain the private key data, only handle
/// is stored which could be used to sign data via hardware U2F key.
SkEd25519 {
public_key: PublicKey,
application: String,
flags: u8,
handle: Vec<u8>,
},
}
impl SshBasePrivateKey {
pub fn base_public_key(&self) -> Result<SshBasePublicKey, SshPrivateKeyError> {
let key = match self {
SshBasePrivateKey::Rsa(rsa) => SshBasePublicKey::Rsa(rsa.to_public_key()?),
SshBasePrivateKey::Ec(ec) => SshBasePublicKey::Ec(ec.to_public_key()?),
SshBasePrivateKey::Ed(ed) => SshBasePublicKey::Ed(ed.to_public_key()?),
SshBasePrivateKey::SkEcdsaSha2NistP256 {
public_key,
application,
..
} => SshBasePublicKey::SkEcdsaSha2NistP256 {
base_key: public_key.clone(),
application: application.clone(),
},
SshBasePrivateKey::SkEd25519 {
public_key,
application,
..
} => SshBasePublicKey::SkEd25519 {
base_key: public_key.clone(),
application: application.clone(),
},
};
Ok(key)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshPrivateKey {
pub cipher_name: String,
pub kdf: Kdf,
pub base_key: SshBasePrivateKey,
pub public_key: SshPublicKey,
pub check: u32,
pub comment: String,
pub passphrase: Option<String>,
}
impl SshPrivateKey {
pub fn generate_rsa(
bits: usize,
passphrase: Option<String>,
comment: Option<String>,
) -> Result<Self, SshPrivateKeyError> {
SshPrivateKey::h_picky_private_key_to_ssh_private_key(PrivateKey::generate_rsa(bits)?, passphrase, comment)
}
pub fn generate_ec(
curve: EcCurve,
passphrase: Option<String>,
comment: Option<String>,
) -> Result<Self, SshPrivateKeyError> {
SshPrivateKey::h_picky_private_key_to_ssh_private_key(PrivateKey::generate_ec(curve)?, passphrase, comment)
}
pub fn generate_ed25519(passphrase: Option<String>, comment: Option<String>) -> Result<Self, SshPrivateKeyError> {
SshPrivateKey::h_picky_private_key_to_ssh_private_key(
PrivateKey::generate_ed(EdAlgorithm::Ed25519, true)?,
passphrase,
comment,
)
}
pub fn from_pem(pem: &Pem, passphrase: Option<String>) -> Result<Self, SshPrivateKeyError> {
SshPrivateKey::decode(&mut pem.data(), passphrase)
}
pub fn from_pem_str(pem: &str, passphrase: Option<String>) -> Result<Self, SshPrivateKeyError> {
let pem = parse_pem(pem)?;
SshPrivateKey::decode(&mut pem.data(), passphrase)
}
pub fn to_pem(&self) -> Result<Pem<'static>, SshPrivateKeyError> {
let mut buffer = Vec::with_capacity(2048);
self.encode(&mut buffer)?;
Ok(Pem::new(SSH_PRIVATE_KEY_LABEL, buffer))
}
pub fn to_string(&self) -> Result<String, SshPrivateKeyError> {
let mut buffer = Vec::with_capacity(2048);
self.encode(&mut buffer)?;
let mut result = Pem::new(SSH_PRIVATE_KEY_LABEL, buffer).to_string();
// ssh private key must contain \x0A (\n) character at the end
result.push('\x0A');
Ok(result)
}
pub fn public_key(&self) -> &SshPublicKey {
&self.public_key
}
pub fn base_key(&self) -> &SshBasePrivateKey {
&self.base_key
}
pub fn inner_key(&self) -> Option<&PrivateKey> {
match self.base_key() {
SshBasePrivateKey::Rsa(key) => Some(key),
SshBasePrivateKey::Ec(key) => Some(key),
SshBasePrivateKey::Ed(key) => Some(key),
SshBasePrivateKey::SkEcdsaSha2NistP256 { .. } | SshBasePrivateKey::SkEd25519 { .. } => None,
}
}
pub(crate) fn h_picky_private_key_to_ssh_private_key(
private_key: PrivateKey,
passphrase: Option<String>,
comment: Option<String>,
) -> Result<SshPrivateKey, SshPrivateKeyError> {
let (kdf, cipher_name) = match &passphrase {
Some(_) => {
let mut salt: Vec<u8> = Vec::new();
let rounds = 16;
let mut rnd = rand::rng();
for _ in 0..rounds {
salt.push(rnd.random::<u8>());
}
let kdf = Kdf {
name: BCRYPT.to_owned(),
option: KdfOption { salt, rounds },
};
(kdf, String::new())
}
None => (Kdf::default(), NONE.to_owned()),
};
let public_key = private_key.to_public_key()?;
let (public, private) = match private_key.as_kind() {
PrivateKeyKind::Rsa => {
let private = SshBasePrivateKey::Rsa(private_key);
let public = SshBasePublicKey::Rsa(public_key);
(public, private)
}
PrivateKeyKind::Ec { .. } => {
let private = SshBasePrivateKey::Ec(private_key);
let public = SshBasePublicKey::Ec(public_key);
(public, private)
}
PrivateKeyKind::Ed { .. } => {
let private = SshBasePrivateKey::Ed(private_key);
let public = SshBasePublicKey::Ed(public_key);
(public, private)
}
};
let public_key = SshPublicKey {
inner_key: public,
comment: String::new(),
};
Ok(SshPrivateKey {
cipher_name,
kdf,
base_key: private,
public_key,
check: 0,
comment: comment.unwrap_or_default(),
passphrase,
})
}
fn decode(mut stream: impl Read, passphrase: Option<String>) -> Result<Self, SshPrivateKeyError>
where
Self: Sized,
{
let mut auth_magic = [0; AUTH_MAGIC.len()];
stream.read_exact(&mut auth_magic)?;
if auth_magic != AUTH_MAGIC.as_bytes() {
return Err(SshPrivateKeyError::InvalidAuthMagicHeader);
}
stream.read_u8()?; // skip 1 byte (null-byte)
let cipher_name = stream.read_ssh_string()?;
let kdf_name = stream.read_ssh_string()?;
let kdf_option: KdfOption = SshComplexTypeDecode::decode(&mut stream)?;
let keys_amount = stream.read_u32::<BigEndian>()?;
if keys_amount != 1 {
return Err(SshPrivateKeyError::InvalidKeysAmount(keys_amount));
}
// read public key
let _ = stream.read_ssh_bytes()?;
// read private key
let private_key = stream.read_ssh_bytes()?;
let data = decrypt(&cipher_name, &kdf_name, &kdf_option, passphrase.as_deref(), private_key)?;
let mut cursor = Cursor::new(data);
let check0 = cursor.read_u32::<BigEndian>()?;
let check1 = cursor.read_u32::<BigEndian>()?;
if check0 != check1 {
return Err(SshPrivateKeyError::InvalidCheckNumbers(check0, check1));
}
let base_key: SshBasePrivateKey = SshComplexTypeDecode::decode(&mut cursor)?;
let base_public_key = base_key.base_public_key()?;
let comment = cursor.read_ssh_string()?.trim_end().to_owned();
Ok(SshPrivateKey {
base_key,
public_key: SshPublicKey {
inner_key: base_public_key,
comment: String::new(),
},
passphrase,
kdf: Kdf {
name: kdf_name,
option: kdf_option,
},
cipher_name,
check: check0,
comment,
})
}
}
impl TryFrom<PrivateKey> for SshPrivateKey {
type Error = SshPrivateKeyError;
fn try_from(private_key: PrivateKey) -> Result<Self, Self::Error> {
SshPrivateKey::h_picky_private_key_to_ssh_private_key(private_key, None, None)
}
}
pub(crate) fn decrypt(
cipher_name: &str,
kdf_name: &str,
kdf_options: &KdfOption,
passphrase: Option<&str>,
mut data: Vec<u8>,
) -> Result<Vec<u8>, SshPrivateKeyError> {
if kdf_name == NONE {
Ok(data)
} else {
let n = match cipher_name {
AES128_CBC | AES128_CTR => 32,
AES256_CBC | AES256_CTR => 48,
name => return Err(SshPrivateKeyError::UnsupportedCipher(name.to_owned())),
};
let mut key = [0; 48];
match kdf_name {
BCRYPT => {
let salt = &kdf_options.salt;
let rounds = kdf_options.rounds;
let passphrase = passphrase.ok_or(SshPrivateKeyError::MissingPassphrase)?;
bcrypt_pbkdf::bcrypt_pbkdf(passphrase, salt, rounds, &mut key[..n])?;
}
name => return Err(SshPrivateKeyError::UnsupportedKdf(name.to_owned())),
};
let (key, iv) = key.split_at(n - 16);
let start_len = data.len();
data.resize(data.len() + 32, 0u8);
match cipher_name {
AES128_CBC => {
let cipher = Aes128CbcDec::new_from_slices(key, iv).unwrap();
let n = cipher
.decrypt_padded_inout::<NoPadding>(data.as_mut_slice().into())
.map_err(|e| SshPrivateKeyError::DecryptionError(e.to_string()))?
.len();
data.truncate(n);
Ok(data)
}
AES256_CBC => {
let cipher = Aes256CbcDec::new_from_slices(key, iv).unwrap();
let n = cipher
.decrypt_padded_inout::<NoPadding>(data.as_mut_slice().into())
.map_err(|e| SshPrivateKeyError::DecryptionError(e.to_string()))?
.len();
data.truncate(n);
Ok(data)
}
AES128_CTR => {
let mut cipher = Aes128Ctr::new_from_slices(key, iv).unwrap();
cipher.apply_keystream(&mut data);
data.truncate(start_len);
Ok(data)
}
AES256_CTR => {
let mut cipher = Aes256Ctr::new_from_slices(key, iv).unwrap();
cipher.apply_keystream(&mut data);
data.truncate(start_len);
Ok(data)
}
name => Err(SshPrivateKeyError::UnsupportedCipher(name.to_owned())),
}
}
}
#[cfg(test)]
pub mod tests {
use super::*;
use crate::key::ec::EcdsaKeypair;
use crate::key::ed::EdKeypair;
use crate::ssh::private_key::SshPrivateKey;
use rsa::RsaPrivateKey;
use rstest::rstest;
#[test]
fn decode_without_passphrase_2048() {
// ssh-keygen -t rsa -b 2048 -C "test2@picky.com" (without the passphrase)
let ssh_private_key_pem = "-----BEGIN OPENSSH PRIVATE KEY-----\n\
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdz\n\
c2gtcnNhAAAAAwEAAQAAAQEAyPYbdoNqjj4EhuYblWIxVKLsmsOff+kLkKlFRsIJ\n\
yE5YUWzPm5LyUH3LoqnL/rw/f/Og37oJO/bEn4P2lSvlf6ZagAGaLo8/8ACw4xKY\n\
UsQFHAEfreIthd/T2u9TEnN+yPS99M99bXG2tV+6He4c61TJfYrq5DsgQuMXCFmt\n\
R/IdJg8qF8lj06qEzjQ1HvXQdXruhm4sQn1HMb3VbdKQFSU3TpmzVysEaOVl3zK7\n\
KirBU9gHIOFZuE3y0oUklFuK6jOhjgQnxeo58Rb00g3p7R+YcpI1i95TAoIQ/tYS\n\
cjnZzByQv+ak1BjgfOjMbEeEQl6kvi2axqTEnFcg0IHu6wAAA8iqDGUDqgxlAwAA\n\
AAdzc2gtcnNhAAABAQDI9ht2g2qOPgSG5huVYjFUouyaw59/6QuQqUVGwgnITlhR\n\
bM+bkvJQfcuiqcv+vD9/86Dfugk79sSfg/aVK+V/plqAAZoujz/wALDjEphSxAUc\n\
AR+t4i2F39Pa71MSc37I9L30z31tcba1X7od7hzrVMl9iurkOyBC4xcIWa1H8h0m\n\
DyoXyWPTqoTONDUe9dB1eu6GbixCfUcxvdVt0pAVJTdOmbNXKwRo5WXfMrsqKsFT\n\
2Acg4Vm4TfLShSSUW4rqM6GOBCfF6jnxFvTSDentH5hykjWL3lMCghD+1hJyOdnM\n\
HJC/5qTUGOB86MxsR4RCXqS+LZrGpMScVyDQge7rAAAAAwEAAQAAAQATZEw6H2xE\n\
1Y8yRTocLCF+fUo/lOjrOt22096veUHgZk73bHyMEp33Tmw8Ag6BQkEOY7/+VsFV\n\
W/aVPfKpalb2/mJ1P7JVE9Wjny1ye/Te57NmhGU+LjkeVf7nfXiSqzpswdEisnL0\n\
AKkUz2vyP2vi+YeH6cPIyjvOuIMcdyrVakejnGbss19ZoXw660X/7TRqG/41KhTm\n\
lkN610JBKI2Rozecx9l3LZ3CTRpOOJ2sfssegvL+qxvvH1YVkRat4dwNZxsi+cho\n\
zqWOciXrbzifBghBp0Upe5fgR2JRpyB6sMVXIHKkeP9YBQUARm1ECdbdJmPSiNYP\n\
gMKpTaEObMahAAAAgCtugmDSAwIPibrD9MAbJB6KbN15heA6vTtCLOvFe1Hikw94\n\
DYAJz+vlKadbOZW5SfGAOuIe7IynafthWm4RcbXEXxhnVtqHxzMHOZo/Mnoh+bUO\n\
esDSoERyNHokpNK6m1NKbmQeFj4n7rkcrR8hrwX8+Ng8CsBEglDi+ULtVivbAAAA\n\
gQD1vEPRUu9aD7CjkYgDyD2vNRRevARf01ImgT1tpiEA+GLHJ0xMetd7OH0wutAZ\n\
uH26V19Kt4sWpsTwfdl2fIw7XHPc+G1OSqiOk6AS9qT/sy/VL1Wn7CqyAN2jikzn\n\
quE6MbebTUJQSNHK9vQhn+u4hUDdEoMOLTYdWxxcjdJirQAAAIEA0VsOxBRDSTLc\n\
Ar0Y97oCmb/6tU9XGAZwL2E14GVK85PnJNwHrx4aqb0qATE4iPLfE7ms+eBtT8Uj\n\
HF0fxM3KDQiFSrvtgM4JjGTDS4dTYIBD/eQ0/aTaRgLOQqplyBgYVr3x7ATfcIP5\n\
961TfdiJ/QESutdb1KQquFXIMRII4vcAAAAPdGVzdDJAcGlja3kuY29tAQIDBA==\n\
-----END OPENSSH PRIVATE KEY-----";
let private_key: SshPrivateKey = SshPrivateKey::from_pem_str(ssh_private_key_pem, None).unwrap();
let kdf = Kdf::default();
assert_eq!("test2@picky.com".to_owned(), private_key.comment);
assert_eq!(kdf, private_key.kdf);
assert_eq!("none", private_key.cipher_name);
}
#[test]
fn decode_without_passphrase_4096() {
// ssh-keygen -t rsa -b 4096 -C "test@picky.com" (without the passphrase)
let ssh_private_key_pem = "-----BEGIN OPENSSH PRIVATE KEY-----b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAACFwAAAAdzc2gtcnNhAAAAAwEAAQAAAgEA21AiuHR9Z+HThQb/7I3zJmuKuanu0mePY9hjgxiq/A7nmTFmC03JOtblDDJVQU918l+pnul+FrAaIo80Fr4MKSwhk6pYUE57ZuRaYVxx5CsRb4zIT8wpxzUvi9Hm83sHHnLGOa7YMPugYRcHWRoRQX4n9f+rPau8u/vBnt4VCBKi3YjAw88XOusyGltuo2cTuATB7iqe15Z9iXg47ER789LwTQHXTn5L7afoDO9jh+LZvcEv1fG1TmevKFNKLPA7ohBp8AOUZ4zo2hXR1rdZg/Afp0SDcSPM2MkHKqd7eKeedj9Ba4b44IsYuu0cmsdA1DbszdjKUNDkVIEZH8v8VryJlLHj/wX6rzYlpBQFhzQw0rHOdFpq/oNCYnBtoKMBy2D8SkYyyGzqviYMR6xOE3WgNjSaHlKaSYFlOMrhpeX8dRvgXHa9AvpbDI9eB6fmhmoxDi0OzKtx81hKMfRtSoDeK9uujKH3fE+L64xeiWvRPqadKV4BL9nL7WCSz9Knax1mn295VrD+ISVp7/zWlz+mQMYhHh7IoK2PfJJoGWx5v+gJogSe2ykP0vz3pWI95ky9GmJBhe/albQM0pe8iPclch7Je3beY3ZqeviKH7hLTX5wHH6Gki7tDo6LafVQTL4peqI0nGyTSwS/LRjePrqyHLDVL1YwDp8HN56LYSsAAAdIA4ihRQOIoUUAAAAHc3NoLXJzYQAAAgEA21AiuHR9Z+HThQb/7I3zJmuKuanu0mePY9hjgxiq/A7nmTFmC03JOtblDDJVQU918l+pnul+FrAaIo80Fr4MKSwhk6pYUE57ZuRaYVxx5CsRb4zIT8wpxzUvi9Hm83sHHnLGOa7YMPugYRcHWRoRQX4n9f+rPau8u/vBnt4VCBKi3YjAw88XOusyGltuo2cTuATB7iqe15Z9iXg47ER789LwTQHXTn5L7afoDO9jh+LZvcEv1fG1TmevKFNKLPA7ohBp8AOUZ4zo2hXR1rdZg/Afp0SDcSPM2MkHKqd7eKeedj9Ba4b44IsYuu0cmsdA1DbszdjKUNDkVIEZH8v8VryJlLHj/wX6rzYlpBQFhzQw0rHOdFpq/oNCYnBtoKMBy2D8SkYyyGzqviYMR6xOE3WgNjSaHlKaSYFlOMrhpeX8dRvgXHa9AvpbDI9eB6fmhmoxDi0OzKtx81hKMfRtSoDeK9uujKH3fE+L64xeiWvRPqadKV4BL9nL7WCSz9Knax1mn295VrD+ISVp7/zWlz+mQMYhHh7IoK2PfJJoGWx5v+gJogSe2ykP0vz3pWI95ky9GmJBhe/albQM0pe8iPclch7Je3beY3ZqeviKH7hLTX5wHH6Gki7tDo6LafVQTL4peqI0nGyTSwS/LRjePrqyHLDVL1YwDp8HN56LYSsAAAADAQABAAACAC7OXIqnefhIzx7uDoLLDODfRN05Mlo/de/mR967zgo7mBwu2cuBz3e6U2oV9/IXZmHTHt1mkd1/uiQ0Efbkmq3S2FuumGiTR2z/QXbUBw6eTntTPZEiTqxQYpRhuPuv/yX1cu7urP9PRLxT8OKIWLR0m0y6Qy7HT2GDaqBgX3a4m3/SZumjch7GAYx0hRlkr2Wvxj/xYrM6UBKd0PBD8XxpQZX91ZjQBZ50HmdcVA61UKlZ6L6tdneEU3K0y/jpUKDXBfUOnoa3IR8iVwWPXhB1mBvX2IG2FUsTJG9rDUQD6iLsfybWyJkLtrx2TIuQCPsBuep44Tz8SC7s2pLZs0HeihnrM5YmqprMggvZ1TkVFoR3bq/42XO6ULy5k8QPuP6t91UN5iVljgr8H/6Jo9MuCeRA45ZPZN94Cn1mKJWYamrqRuCqDR5za3A0oHPKYUAfzzD90BLL6Yaib75VpiEDTkOiBuW3MJUcJsqZipDDl/6eas2Qyloplw60dx42FzcRIDXkXzRNn8hBSy7xmQ5MOKGBszCeV/eTBtRITQN38yDVMerb8xDlwOsTtjo3PHCg4HEqqSzjv/B0op9aP7RJ8zp9xLOGlxRZ9YhAlHctUOO6ATsv4uCFwCniZbVOdcUEYwNebYQ0x3IRGUF6RpqjOudUwgLlo0Lq1KV05fM5AAABAC7fkAB4l5YMAseu+lcj+CwHySzcI+baRFCrMIKldNjEPvvZcCSOU/n5pgp2bw0ulw8c4mFQv0GsG//qQCBX1IrIWO0/nRBjEUTPIe2BUswoxm3+F7pirphdIpABKMzV7ZvENn53p2ByrW9+uiwwXLo/z4tH18JW41Jyp5mXH2+1iWIYzq5d4gVgMKLGnqWG3DisViHBGg/ExxQCayeXAhlcXVaWZiaVYsgyreaQg58S2RRUIveWP+ZAeb8+ZJ72ZjIYLc0GIbP673GpcNWkRlCykTJXF9x+Ts0trffqvSxF+2YJnaacLSWJmWFU1BsxUO2pIM4SI8VeHYBdEoAVqcQAAAEBAPUodhyNIr8dtcJona8Lkn+3BxLdvYAV1bnlWnUcG9m0RQ2L95kH6folOG00aWhRgJHFDoXcCaHND8Mg3PkAXYUKCucipiIITyd8YeYnF0ckau5GmUEzwc6s4HcGyFilX1yBoyLE7hFMzOJ4+Rcq+zpD2TfaWcuoo+njDWEHeTbzvGIDQoBYsPnGOtw57q9IA5oWYAG3LtwygazmNF2xeEnMEtYPyPu7+W0teO0QIJiHWEuK/yLPOb+RHBfA6YJ1f9Jcgc614DxyW6qnB5YuzQBovLzgp/7j9J4Z9F8n8f9PAwYScf7IG8icVVhl5NwNgfNOpcjdg6+YB8Z0AXa4dYcAAAEBAOUDEl6yS1nwZ0QsJwfHE232dpsOqxxqfV4ei4R8/obq+b5YPHiUgbt2PlHyHtgfQr639BwMmIaAMSR9CLti44Mw6Z3k2DEz3Ef4+XilPeScNiZmWfYanWmVwFEtb2c+YT3QweUH3DUAViHL+UdU7xp+zhkrd04daVPpYc9NNN9b9Gwmj6Pm0RP05UJxsG1ipvN1rGpaCsJiLfS9IoSsKh0Vzdzdty1YvFhEErTl0WBVGGK6xaA5lfMtaclWi2mGGNXfWflyQzkz87eYlPe2RhM7jW1Lo9h1BBYE6R+jKt3q0mHwRehj+updAAXJx0RWF7EDQVJtlTfSrUCm+SSFoD0AAAAOdGVzdEBwaWNreS5jb20BAgMEBQ==-----END OPENSSH PRIVATE KEY-----";
let private_key: SshPrivateKey = SshPrivateKey::from_pem_str(ssh_private_key_pem, None).unwrap();
let kdf = Kdf::default();
assert_eq!("test@picky.com".to_owned(), private_key.comment);
assert_eq!(kdf, private_key.kdf);
assert_eq!("none", private_key.cipher_name);
}
#[test]
fn decode_with_passphrase_2048() {
// ssh-keygen -t rsa -b 2048 -C "test_with_pass2@picky.com"
let passphrase = Some("123123".to_string());
let ssh_private_key_pem = "-----BEGIN OPENSSH PRIVATE KEY-----\n\
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBI\n\
MsVovOqXSrZa+iEvQwXzAAAAEAAAAAEAAAEXAAAAB3NzaC1yc2EAAAADAQABAAAB\n\
AQCkR5WaC3NTPZdj9X/bX88YYbR2k5r3aE+I/ezxzbG6xIJi+So9AohypAhReyW9\n\
7XSGut5n6a9O+n/c9nCiXFVoyXbMSdM90Av5bu799+V4w3kBlRzN5D3A6uIZRjgl\n\
wc3Xso9kthneNByB7OjZuSDdmuWE3YOgmW0TirP3dztbtVScLPZUSsEveIMt90aw\n\
uOFWaEUshqb7l713bdEB0Tb77Z1wZpt6UmIgpraV58kN/ahepbY8lirMS4ym75wt\n\
Be6PgyKGKIR3aNQdbfHYMHCgxNQMAFUt2yD9f+JE5HWG7kyKDcLTHCY60dtKTNfp\n\
cByi4Bwm3209V4rGYSKAzFXvAAAD0CdYEpFE8Dda2GBNy1l5vDNdbyJvx7SSP49l\n\
4OmHsgRE2WneNC9CfO2IxPRNXsPEmXimeubqm6alsmJ1Ch+KsdjvyU7WIEnjuonC\n\
lLWx6rhsuppJqZSICbikMUXjhlHpLirGnL0WoaBnLYEVYgu8cMbIgE9BNho+bS+1\n\
qvyIrIdIblJwwc66CJKUYPz1yRA84WIMZOWlsYfeHnCvTGjiYUG2YFayVAuXvAz/\n\
ND3bQYUlO34XOOsJvZxfQNEg1/tzhB7RvcGOG1InoQxT6dZtTp85CkTU/QQ6w2eY\n\
j4qDDmsFm/eSDgEFfOJDLrfHsB4+G2aBZLmgk2bn7vo3JBkcPAETX6kKd7bkyEfh\n\
LVph9i48vbmNJ8mXWiXMoRXqRgkKqBAMVnuXtbKVDVzzlZXIFu1cbuKyt0zUg7jB\n\
IeIdG+5U0L6qygTjOKU6aP+dK1wRc0XyC8jxTJupt2eTEKBLzy4TwlLH5QhEcj1c\n\
coV97PyslJ/NnQx8IKflHxxxQF4CbYgyXt9fWZpBfaD9TVWgsFoKrlZ9HOb6s5WJ\n\
MwijgNLfllKNkJB/KpQUIwMAqEjkfk4HyKeC9sfCHkjkXoZO28GypRR8Bd5M+/Qf\n\
otFvcdRHqbvv+mj1y6nBIv0hv5eqJEil5s/dwGI7cexMGBjPVOPK63kbh6JlMcrb\n\
58jKid1VTzUbxxKm6YfL2aQpGp/veGPZRkm+x3DHoANYLYJ64WRQgBOGcf4QSqiT\n\
xP9Y5ZxfQuheDzOkiQCt3ToTWwguXtVLm3AAUKxUhHVgMy2PQNFXcNsPWGCzhOW1\n\
FzC82iZhuQi7SlTX7iA40np23nMkHu37hkHpfpipySxEIIjv1T0UglqN25hPlHDI\n\
jrTRwcBVxikVhP0IFbDUtlmqSP5MkDEE2ZKTeD0ivd8c2WLO5RUoEICaTVHOx+Mx\n\
OJ9L07ZhA2NMKiMMqhe0bXwZoFFHMUxXh8+iTTy89oE1PQ7xz/d6hJUtbqJ/N2xp\n\
cWMNtnvbjWpxzwhjPGiqKx8GCtpGoAjpUeNqWL9V0a20rJBYqzJGLYfKDd+PW2XT\n\
tOHbQwl0DFNq41jP4nYnaFo2YCjWb3mleRUWkU5SoUHq+vUvs4dxqKjlzvKnK5pc\n\
yH9bnpKPaBI28QHtye7o25AfkOj7eHVSe5CV4u8okVaBEq1OFhBeWm+jx1fBrk82\n\
hEGamuq1GZsZre2y9jauusOFcMXrV5oxJjBLLbGCi0i5ES0O+kBOlB/kY3hdkReC\n\
HCJlMN7v92mkSsadahzwx3fTQWCwgVDg6LLN+xCPGFTMts4XDwg=\n\
-----END OPENSSH PRIVATE KEY-----";
let private_key: SshPrivateKey = SshPrivateKey::from_pem_str(ssh_private_key_pem, passphrase).unwrap();
assert_eq!("test_with_pass2@picky.com".to_owned(), private_key.comment);
assert_eq!(
Kdf {
name: "bcrypt".to_owned(),
option: KdfOption {
salt: vec![72, 50, 197, 104, 188, 234, 151, 74, 182, 90, 250, 33, 47, 67, 5, 243],
rounds: 16,
}
},
private_key.kdf
);
assert_eq!("aes256-ctr", private_key.cipher_name);
}
#[test]
fn encode_without_passphrase_2048() {
// ssh-keygen -t rsa -b 2048 -C "test2@picky.com" (without the passphrase)
let ssh_private_key_pem = "-----BEGIN OPENSSH PRIVATE KEY-----\n\
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdz\n\
c2gtcnNhAAAAAwEAAQAAAQEAyPYbdoNqjj4EhuYblWIxVKLsmsOff+kLkKlFRsIJ\n\
yE5YUWzPm5LyUH3LoqnL/rw/f/Og37oJO/bEn4P2lSvlf6ZagAGaLo8/8ACw4xKY\n\
UsQFHAEfreIthd/T2u9TEnN+yPS99M99bXG2tV+6He4c61TJfYrq5DsgQuMXCFmt\n\
R/IdJg8qF8lj06qEzjQ1HvXQdXruhm4sQn1HMb3VbdKQFSU3TpmzVysEaOVl3zK7\n\
KirBU9gHIOFZuE3y0oUklFuK6jOhjgQnxeo58Rb00g3p7R+YcpI1i95TAoIQ/tYS\n\
cjnZzByQv+ak1BjgfOjMbEeEQl6kvi2axqTEnFcg0IHu6wAAA8iqDGUDqgxlAwAA\n\
AAdzc2gtcnNhAAABAQDI9ht2g2qOPgSG5huVYjFUouyaw59/6QuQqUVGwgnITlhR\n\
bM+bkvJQfcuiqcv+vD9/86Dfugk79sSfg/aVK+V/plqAAZoujz/wALDjEphSxAUc\n\
AR+t4i2F39Pa71MSc37I9L30z31tcba1X7od7hzrVMl9iurkOyBC4xcIWa1H8h0m\n\
DyoXyWPTqoTONDUe9dB1eu6GbixCfUcxvdVt0pAVJTdOmbNXKwRo5WXfMrsqKsFT\n\
2Acg4Vm4TfLShSSUW4rqM6GOBCfF6jnxFvTSDentH5hykjWL3lMCghD+1hJyOdnM\n\
HJC/5qTUGOB86MxsR4RCXqS+LZrGpMScVyDQge7rAAAAAwEAAQAAAQATZEw6H2xE\n\
1Y8yRTocLCF+fUo/lOjrOt22096veUHgZk73bHyMEp33Tmw8Ag6BQkEOY7/+VsFV\n\
W/aVPfKpalb2/mJ1P7JVE9Wjny1ye/Te57NmhGU+LjkeVf7nfXiSqzpswdEisnL0\n\
AKkUz2vyP2vi+YeH6cPIyjvOuIMcdyrVakejnGbss19ZoXw660X/7TRqG/41KhTm\n\
lkN610JBKI2Rozecx9l3LZ3CTRpOOJ2sfssegvL+qxvvH1YVkRat4dwNZxsi+cho\n\
zqWOciXrbzifBghBp0Upe5fgR2JRpyB6sMVXIHKkeP9YBQUARm1ECdbdJmPSiNYP\n\
gMKpTaEObMahAAAAgCtugmDSAwIPibrD9MAbJB6KbN15heA6vTtCLOvFe1Hikw94\n\
DYAJz+vlKadbOZW5SfGAOuIe7IynafthWm4RcbXEXxhnVtqHxzMHOZo/Mnoh+bUO\n\
esDSoERyNHokpNK6m1NKbmQeFj4n7rkcrR8hrwX8+Ng8CsBEglDi+ULtVivbAAAA\n\
gQD1vEPRUu9aD7CjkYgDyD2vNRRevARf01ImgT1tpiEA+GLHJ0xMetd7OH0wutAZ\n\
uH26V19Kt4sWpsTwfdl2fIw7XHPc+G1OSqiOk6AS9qT/sy/VL1Wn7CqyAN2jikzn\n\
quE6MbebTUJQSNHK9vQhn+u4hUDdEoMOLTYdWxxcjdJirQAAAIEA0VsOxBRDSTLc\n\
Ar0Y97oCmb/6tU9XGAZwL2E14GVK85PnJNwHrx4aqb0qATE4iPLfE7ms+eBtT8Uj\n\
HF0fxM3KDQiFSrvtgM4JjGTDS4dTYIBD/eQ0/aTaRgLOQqplyBgYVr3x7ATfcIP5\n\
961TfdiJ/QESutdb1KQquFXIMRII4vcAAAAPdGVzdDJAcGlja3kuY29tAQIDBA==\n\
-----END OPENSSH PRIVATE KEY-----\x0A";
let private_key = SshPrivateKey::from_pem_str(ssh_private_key_pem, None).unwrap();
let ssh_private_key_after = private_key.to_string().unwrap();
pretty_assertions::assert_eq!(ssh_private_key_pem, ssh_private_key_after.as_str());
}
#[test]
fn encode_with_passphrase_2048() {
// ssh-keygen -t rsa -b 2048 -C "test_with_pass2@picky.com"
let passphrase = Some("123123".to_string());
let ssh_private_key_pem = "-----BEGIN OPENSSH PRIVATE KEY-----\n\
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBI\n\
MsVovOqXSrZa+iEvQwXzAAAAEAAAAAEAAAEXAAAAB3NzaC1yc2EAAAADAQABAAAB\n\
AQCkR5WaC3NTPZdj9X/bX88YYbR2k5r3aE+I/ezxzbG6xIJi+So9AohypAhReyW9\n\
7XSGut5n6a9O+n/c9nCiXFVoyXbMSdM90Av5bu799+V4w3kBlRzN5D3A6uIZRjgl\n\
wc3Xso9kthneNByB7OjZuSDdmuWE3YOgmW0TirP3dztbtVScLPZUSsEveIMt90aw\n\
uOFWaEUshqb7l713bdEB0Tb77Z1wZpt6UmIgpraV58kN/ahepbY8lirMS4ym75wt\n\
Be6PgyKGKIR3aNQdbfHYMHCgxNQMAFUt2yD9f+JE5HWG7kyKDcLTHCY60dtKTNfp\n\
cByi4Bwm3209V4rGYSKAzFXvAAAD0CdYEpFE8Dda2GBNy1l5vDNdbyJvx7SSP49l\n\
4OmHsgRE2WneNC9CfO2IxPRNXsPEmXimeubqm6alsmJ1Ch+KsdjvyU7WIEnjuonC\n\
lLWx6rhsuppJqZSICbikMUXjhlHpLirGnL0WoaBnLYEVYgu8cMbIgE9BNho+bS+1\n\
qvyIrIdIblJwwc66CJKUYPz1yRA84WIMZOWlsYfeHnCvTGjiYUG2YFayVAuXvAz/\n\
ND3bQYUlO34XOOsJvZxfQNEg1/tzhB7RvcGOG1InoQxT6dZtTp85CkTU/QQ6w2eY\n\
j4qDDmsFm/eSDgEFfOJDLrfHsB4+G2aBZLmgk2bn7vo3JBkcPAETX6kKd7bkyEfh\n\
LVph9i48vbmNJ8mXWiXMoRXqRgkKqBAMVnuXtbKVDVzzlZXIFu1cbuKyt0zUg7jB\n\
IeIdG+5U0L6qygTjOKU6aP+dK1wRc0XyC8jxTJupt2eTEKBLzy4TwlLH5QhEcj1c\n\
coV97PyslJ/NnQx8IKflHxxxQF4CbYgyXt9fWZpBfaD9TVWgsFoKrlZ9HOb6s5WJ\n\
MwijgNLfllKNkJB/KpQUIwMAqEjkfk4HyKeC9sfCHkjkXoZO28GypRR8Bd5M+/Qf\n\
otFvcdRHqbvv+mj1y6nBIv0hv5eqJEil5s/dwGI7cexMGBjPVOPK63kbh6JlMcrb\n\
58jKid1VTzUbxxKm6YfL2aQpGp/veGPZRkm+x3DHoANYLYJ64WRQgBOGcf4QSqiT\n\
xP9Y5ZxfQuheDzOkiQCt3ToTWwguXtVLm3AAUKxUhHVgMy2PQNFXcNsPWGCzhOW1\n\
FzC82iZhuQi7SlTX7iA40np23nMkHu37hkHpfpipySxEIIjv1T0UglqN25hPlHDI\n\
jrTRwcBVxikVhP0IFbDUtlmqSP5MkDEE2ZKTeD0ivd8c2WLO5RUoEICaTVHOx+Mx\n\
OJ9L07ZhA2NMKiMMqhe0bXwZoFFHMUxXh8+iTTy89oE1PQ7xz/d6hJUtbqJ/N2xp\n\
cWMNtnvbjWpxzwhjPGiqKx8GCtpGoAjpUeNqWL9V0a20rJBYqzJGLYfKDd+PW2XT\n\
tOHbQwl0DFNq41jP4nYnaFo2YCjWb3mleRUWkU5SoUHq+vUvs4dxqKjlzvKnK5pc\n\
yH9bnpKPaBI28QHtye7o25AfkOj7eHVSe5CV4u8okVaBEq1OFhBeWm+jx1fBrk82\n\
hEGamuq1GZsZre2y9jauusOFcMXrV5oxJjBLLbGCi0i5ES0O+kBOlB/kY3hdkReC\n\
HCJlMN7v92mkSsadahzwx3fTQWCwgVDg6LLN+xCPGFTMts4XDwg=\n\
-----END OPENSSH PRIVATE KEY-----\x0A";
let private_key = SshPrivateKey::from_pem_str(ssh_private_key_pem, passphrase).unwrap();
let ssh_private_key_after = private_key.to_string().unwrap();
pretty_assertions::assert_eq!(ssh_private_key_pem, ssh_private_key_after.as_str());
}
#[rstest]
#[case(picky_test_data::SSH_PRIVATE_KEY_EC_P256)]
#[case(picky_test_data::SSH_PRIVATE_KEY_EC_P384)]
#[case(picky_test_data::SSH_PRIVATE_KEY_EC_P521)]
fn ecdsa_keys_unencrypted(#[case] pem: &str) {
let key = SshPrivateKey::from_pem_str(pem, None).unwrap();
let encoded = key.to_string().unwrap();
pretty_assertions::assert_eq!(encoded.as_str(), pem);
}
#[test]
fn ecdsa_keys_encrypted() {
let passphrase = Some("test".to_string());
let key = SshPrivateKey::from_pem_str(picky_test_data::SSH_PRIVATE_KEY_EC_P256_ENCRYPTED, passphrase).unwrap();
let encoded = key.to_string().unwrap();
pretty_assertions::assert_eq!(encoded.as_str(), picky_test_data::SSH_PRIVATE_KEY_EC_P256_ENCRYPTED);
}
#[test]
fn rsa_rounttrip() {
let private_key = SshPrivateKey::from_pem_str(picky_test_data::SSH_PRIVATE_KEY_RSA, None).unwrap();
let encoded = private_key.to_string().unwrap();
assert_eq!(picky_test_data::SSH_PRIVATE_KEY_RSA, encoded.as_str());
}
#[test]
fn ed25519_roundtrip() {
let private_key = SshPrivateKey::from_pem_str(picky_test_data::SSH_PRIVATE_KEY_ED25519, None).unwrap();
let encoded = private_key.to_string().unwrap();
assert_eq!(picky_test_data::SSH_PRIVATE_KEY_ED25519, encoded.as_str());
}
#[test]
fn ed25519_roundtrip_encrypted() {
let passphrase = Some("test".to_string());
let private_key =
SshPrivateKey::from_pem_str(picky_test_data::SSH_PRIVATE_KEY_ED25519_ENCRYPTED, passphrase).unwrap();
let encoded = private_key.to_string().unwrap();
assert_eq!(picky_test_data::SSH_PRIVATE_KEY_ED25519_ENCRYPTED, encoded.as_str());
}
#[test]
fn sk_ed25519_roundtrip() {
let private_key = SshPrivateKey::from_pem_str(picky_test_data::SSH_PRIVATE_KEY_SK_ED25519, None).unwrap();
let encoded = private_key.to_string().unwrap();
assert_eq!(picky_test_data::SSH_PRIVATE_KEY_SK_ED25519, encoded.as_str());
}
#[test]
fn sk_ed25519_roundtrip_encrypted() {
let private_key = SshPrivateKey::from_pem_str(
picky_test_data::SSH_PRIVATE_KEY_SK_ED25519_ENCRYPTED,
Some("test".to_string()),
)
.unwrap();
let encoded = private_key.to_string().unwrap();
assert_eq!(picky_test_data::SSH_PRIVATE_KEY_SK_ED25519_ENCRYPTED, encoded.as_str());
}
#[test]
fn sk_ecdsa_roundtrip() {
let private_key = SshPrivateKey::from_pem_str(picky_test_data::SSH_PRIVATE_KEY_SK_ECDSA, None).unwrap();
let encoded = private_key.to_string().unwrap();
assert_eq!(picky_test_data::SSH_PRIVATE_KEY_SK_ECDSA, encoded.as_str());
}
#[test]
fn sk_ecdsa_roundtrip_encrypted() {
let private_key = SshPrivateKey::from_pem_str(
picky_test_data::SSH_PRIVATE_KEY_SK_ECDSA_ENCRYPTED,
Some("test".to_string()),
)
.unwrap();
let encoded = private_key.to_string().unwrap();
assert_eq!(picky_test_data::SSH_PRIVATE_KEY_SK_ECDSA_ENCRYPTED, encoded.as_str());
}
#[test]
fn test_rsa_private_key_generation() {
let private_key = SshPrivateKey::generate_rsa(2048, Option::Some("123".to_string()), None).unwrap();
let data = private_key.to_pem().unwrap();
let parsed = SshPrivateKey::from_pem(&data, Option::Some("123".to_string())).unwrap();
match parsed.base_key() {
SshBasePrivateKey::Rsa(key) => {
let _rsa: RsaPrivateKey = key.try_into().unwrap();
}
_ => panic!("Invalid key type"),
}
}
#[rstest]
#[case(EcCurve::NistP256)]
#[case(EcCurve::NistP384)]
#[case(EcCurve::NistP521)]
fn test_ec_private_key_generation(#[case] curve: EcCurve) {
let private_key = SshPrivateKey::generate_ec(curve, Option::Some("123".to_string()), None).unwrap();
let data = private_key.to_pem().unwrap();
let parsed = SshPrivateKey::from_pem(&data, Option::Some("123".to_string())).unwrap();
match parsed.base_key() {
SshBasePrivateKey::Ec(ec) => {
let _ec: EcdsaKeypair = ec.try_into().unwrap();
}
_ => panic!("Invalid key type"),
}
}
#[test]
fn test_ed_private_key_generation() {
let private_key = SshPrivateKey::generate_ed25519(Option::Some("123".to_string()), None).unwrap();
let data = private_key.to_pem().unwrap();
let parsed = SshPrivateKey::from_pem(&data, Option::Some("123".to_string())).unwrap();
match parsed.base_key() {
SshBasePrivateKey::Ed(ed) => {
let _ed: EdKeypair = ed.try_into().unwrap();
}
_ => panic!("Invalid key type"),
}
}
#[test]
fn kdf_option_decode() {
let mut cursor = Cursor::new(vec![
0, 0, 0, 24, 0, 0, 0, 16, 72, 50, 197, 104, 188, 234, 151, 74, 182, 90, 250, 33, 47, 67, 5, 243, 0, 0, 0,
16,
]);
let kdf_option: KdfOption = SshComplexTypeDecode::decode(&mut cursor).unwrap();
let KdfOption { salt, rounds } = kdf_option;
assert_eq!(
vec![72, 50, 197, 104, 188, 234, 151, 74, 182, 90, 250, 33, 47, 67, 5, 243],
salt
);
assert_eq!(16, rounds);
let mut cursor = Cursor::new(vec![0, 0, 0, 0]);
let kdf_option: KdfOption = SshComplexTypeDecode::decode(&mut cursor).unwrap();
let KdfOption { salt, rounds } = kdf_option;
assert!(salt.is_empty());
assert_eq!(0, rounds);
}
#[test]
fn kdf_option_encode() {
let mut res: Vec<u8> = Vec::new();
let kdf_option = KdfOption {
salt: vec![72, 50, 197, 104, 188, 234, 151, 74, 182, 90, 250, 33, 47, 67, 5, 243],
rounds: 16,
};
kdf_option.encode(&mut res).unwrap();
assert_eq!(
vec![
0, 0, 0, 24, 0, 0, 0, 16, 72, 50, 197, 104, 188, 234, 151, 74, 182, 90, 250, 33, 47, 67, 5, 243, 0, 0,
0, 16
],
res
);
res.clear();
let kdf_option = KdfOption::default();
kdf_option.encode(&mut res).unwrap();
assert_eq!(vec![0, 0, 0, 0], res);
}
}
+309
View File
@@ -0,0 +1,309 @@
use crate::key::{KeyError, PublicKey};
use crate::ssh::decode::SshComplexTypeDecode;
use crate::ssh::encode::SshComplexTypeEncode;
use std::io;
use std::str::FromStr;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SshPublicKeyError {
#[error(transparent)]
IoError(#[from] io::Error),
#[error("invalid UTF-8")]
InvalidUtf8,
#[error(transparent)]
RsaError(#[from] rsa::errors::Error),
#[error(transparent)]
Base64DecodeError(#[from] base64::DecodeError),
#[error("Unknown key type. We only support RSA")]
UnknownKeyType,
#[error(transparent)]
KeyError(#[from] KeyError),
}
impl From<core::str::Utf8Error> for SshPublicKeyError {
fn from(_: core::str::Utf8Error) -> Self {
Self::InvalidUtf8
}
}
impl From<std::string::FromUtf8Error> for SshPublicKeyError {
fn from(_: std::string::FromUtf8Error) -> Self {
Self::InvalidUtf8
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SshBasePublicKey {
Rsa(PublicKey),
Ec(PublicKey),
Ed(PublicKey),
/// U2F ecdsa SSH key
SkEcdsaSha2NistP256 {
base_key: PublicKey,
application: String,
},
/// U2F ed25519 SSH key
SkEd25519 {
base_key: PublicKey,
application: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshPublicKey {
pub inner_key: SshBasePublicKey,
pub comment: String,
}
impl SshPublicKey {
pub fn to_string(&self) -> Result<String, SshPublicKeyError> {
let mut buffer = Vec::with_capacity(1024);
self.encode(&mut buffer)?;
Ok(String::from_utf8(buffer)?)
}
pub fn inner_key(&self) -> &PublicKey {
match &self.inner_key {
SshBasePublicKey::Rsa(key) => key,
SshBasePublicKey::Ec(key) => key,
SshBasePublicKey::Ed(key) => key,
SshBasePublicKey::SkEcdsaSha2NistP256 { base_key, .. } => base_key,
SshBasePublicKey::SkEd25519 { base_key, .. } => base_key,
}
}
pub fn fingerprint_md5(&self) -> Result<[u8; 16], SshPublicKeyError> {
use md5::{Digest, Md5};
let mut encoded = Vec::new();
self.inner_key.encode(&mut encoded)?;
let mut hasher = Md5::new();
hasher.update(&encoded);
let fingerprint = hasher.finalize();
Ok(fingerprint.into())
}
pub fn fingerprint_sha1(&self) -> Result<[u8; 20], SshPublicKeyError> {
use sha1::{Digest, Sha1};
let mut encoded = Vec::new();
self.inner_key.encode(&mut encoded)?;
let mut hasher = Sha1::new();
hasher.update(&encoded);
let fingerprint = hasher.finalize();
Ok(fingerprint.into())
}
pub fn fingerprint_sha256(&self) -> Result<[u8; 32], SshPublicKeyError> {
use sha2::{Digest, Sha256};
let mut encoded = Vec::new();
self.inner_key.encode(&mut encoded)?;
let mut hasher = Sha256::new();
hasher.update(&encoded);
let fingerprint = hasher.finalize();
Ok(fingerprint.into())
}
}
impl FromStr for SshPublicKey {
type Err = SshPublicKeyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
SshComplexTypeDecode::decode(s.as_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use base64::engine::general_purpose::STANDARD_NO_PAD;
use crypto_bigint::BoxedUint;
use rstest::rstest;
#[test]
fn decode_ssh_rsa_4096_public_key() {
// ssh-keygen -t rsa -b 4096 -C "test@picky.com"
let ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDbUCK4dH1n4dOFBv/sjfMma4q5qe7SZ49j2GODGKr8DueZMWYLTck61uUMMlVBT3XyX6me6X4WsBoijzQWvgwpLCGTqlhQTntm5FphXHHkKxFvjMhPzCnHNS+L0ebzewcecsY5rtgw+6BhFwdZGhFBfif1/6s9q7y7+8Ge3hUIEqLdiMDDzxc66zIaW26jZxO4BMHuKp7Xln2JeDjsRHvz0vBNAddOfkvtp+gM72OH4tm9wS/V8bVOZ68oU0os8DuiEGnwA5RnjOjaFdHWt1mD8B+nRINxI8zYyQcqp3t4p552P0Frhvjgixi67Ryax0DUNuzN2MpQ0ORUgRkfy/xWvImUseP/BfqvNiWkFAWHNDDSsc50Wmr+g0JicG2gowHLYPxKRjLIbOq+JgxHrE4TdaA2NJoeUppJgWU4yuGl5fx1G+Bcdr0C+lsMj14Hp+aGajEOLQ7Mq3HzWEox9G1KgN4r266Mofd8T4vrjF6Ja9E+pp0pXgEv2cvtYJLP0qdrHWafb3lWsP4hJWnv/NaXP6ZAxiEeHsigrY98kmgZbHm/6AmiBJ7bKQ/S/PelYj3mTL0aYkGF79qVtAzSl7yI9yVyHsl7dt5jdmp6+IofuEtNfnAcfoaSLu0Ojotp9VBMvil6ojScbJNLBL8tGN4+urIcsNUvVjAOnwc3nothKw== test@picky.com\r\n";
let public_key = SshPublicKey::from_str(ssh_public_key).unwrap();
assert_eq!("test@picky.com".to_owned(), public_key.comment);
assert_eq!(
SshBasePublicKey::Rsa(PublicKey::from_rsa_components(
&BoxedUint::from_be_slice_vartime(&[
219, 80, 34, 184, 116, 125, 103, 225, 211, 133, 6, 255, 236, 141, 243, 38, 107, 138, 185, 169, 238,
210, 103, 143, 99, 216, 99, 131, 24, 170, 252, 14, 231, 153, 49, 102, 11, 77, 201, 58, 214, 229,
12, 50, 85, 65, 79, 117, 242, 95, 169, 158, 233, 126, 22, 176, 26, 34, 143, 52, 22, 190, 12, 41,
44, 33, 147, 170, 88, 80, 78, 123, 102, 228, 90, 97, 92, 113, 228, 43, 17, 111, 140, 200, 79, 204,
41, 199, 53, 47, 139, 209, 230, 243, 123, 7, 30, 114, 198, 57, 174, 216, 48, 251, 160, 97, 23, 7,
89, 26, 17, 65, 126, 39, 245, 255, 171, 61, 171, 188, 187, 251, 193, 158, 222, 21, 8, 18, 162, 221,
136, 192, 195, 207, 23, 58, 235, 50, 26, 91, 110, 163, 103, 19, 184, 4, 193, 238, 42, 158, 215,
150, 125, 137, 120, 56, 236, 68, 123, 243, 210, 240, 77, 1, 215, 78, 126, 75, 237, 167, 232, 12,
239, 99, 135, 226, 217, 189, 193, 47, 213, 241, 181, 78, 103, 175, 40, 83, 74, 44, 240, 59, 162,
16, 105, 240, 3, 148, 103, 140, 232, 218, 21, 209, 214, 183, 89, 131, 240, 31, 167, 68, 131, 113,
35, 204, 216, 201, 7, 42, 167, 123, 120, 167, 158, 118, 63, 65, 107, 134, 248, 224, 139, 24, 186,
237, 28, 154, 199, 64, 212, 54, 236, 205, 216, 202, 80, 208, 228, 84, 129, 25, 31, 203, 252, 86,
188, 137, 148, 177, 227, 255, 5, 250, 175, 54, 37, 164, 20, 5, 135, 52, 48, 210, 177, 206, 116, 90,
106, 254, 131, 66, 98, 112, 109, 160, 163, 1, 203, 96, 252, 74, 70, 50, 200, 108, 234, 190, 38, 12,
71, 172, 78, 19, 117, 160, 54, 52, 154, 30, 82, 154, 73, 129, 101, 56, 202, 225, 165, 229, 252,
117, 27, 224, 92, 118, 189, 2, 250, 91, 12, 143, 94, 7, 167, 230, 134, 106, 49, 14, 45, 14, 204,
171, 113, 243, 88, 74, 49, 244, 109, 74, 128, 222, 43, 219, 174, 140, 161, 247, 124, 79, 139, 235,
140, 94, 137, 107, 209, 62, 166, 157, 41, 94, 1, 47, 217, 203, 237, 96, 146, 207, 210, 167, 107,
29, 102, 159, 111, 121, 86, 176, 254, 33, 37, 105, 239, 252, 214, 151, 63, 166, 64, 198, 33, 30,
30, 200, 160, 173, 143, 124, 146, 104, 25, 108, 121, 191, 232, 9, 162, 4, 158, 219, 41, 15, 210,
252, 247, 165, 98, 61, 230, 76, 189, 26, 98, 65, 133, 239, 218, 149, 180, 12, 210, 151, 188, 136,
247, 37, 114, 30, 201, 123, 118, 222, 99, 118, 106, 122, 248, 138, 31, 184, 75, 77, 126, 112, 28,
126, 134, 146, 46, 237, 14, 142, 139, 105, 245, 80, 76, 190, 41, 122, 162, 52, 156, 108, 147, 75,
4, 191, 45, 24, 222, 62, 186, 178, 28, 176, 213, 47, 86, 48, 14, 159, 7, 55, 158, 139, 97, 43
]),
&BoxedUint::from_be_slice_vartime(&[1, 0, 1])
)),
public_key.inner_key
);
}
#[test]
fn decode_ssh_rsa_2048_public_key() {
// ssh-keygen -t rsa -b 2048 -C "test2@picky.com"
let ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDI9ht2g2qOPgSG5huVYjFUouyaw59/6QuQqUVGwgnITlhRbM+bkvJQfcuiqcv+vD9/86Dfugk79sSfg/aVK+V/plqAAZoujz/wALDjEphSxAUcAR+t4i2F39Pa71MSc37I9L30z31tcba1X7od7hzrVMl9iurkOyBC4xcIWa1H8h0mDyoXyWPTqoTONDUe9dB1eu6GbixCfUcxvdVt0pAVJTdOmbNXKwRo5WXfMrsqKsFT2Acg4Vm4TfLShSSUW4rqM6GOBCfF6jnxFvTSDentH5hykjWL3lMCghD+1hJyOdnMHJC/5qTUGOB86MxsR4RCXqS+LZrGpMScVyDQge7r test2@picky.com\r\n";
let public_key: SshPublicKey = SshPublicKey::from_str(ssh_public_key).unwrap();
assert_eq!("test2@picky.com".to_owned(), public_key.comment);
assert_eq!(
SshBasePublicKey::Rsa(PublicKey::from_rsa_components(
&BoxedUint::from_be_slice_vartime(&[
200, 246, 27, 118, 131, 106, 142, 62, 4, 134, 230, 27, 149, 98, 49, 84, 162, 236, 154, 195, 159,
127, 233, 11, 144, 169, 69, 70, 194, 9, 200, 78, 88, 81, 108, 207, 155, 146, 242, 80, 125, 203,
162, 169, 203, 254, 188, 63, 127, 243, 160, 223, 186, 9, 59, 246, 196, 159, 131, 246, 149, 43, 229,
127, 166, 90, 128, 1, 154, 46, 143, 63, 240, 0, 176, 227, 18, 152, 82, 196, 5, 28, 1, 31, 173, 226,
45, 133, 223, 211, 218, 239, 83, 18, 115, 126, 200, 244, 189, 244, 207, 125, 109, 113, 182, 181,
95, 186, 29, 238, 28, 235, 84, 201, 125, 138, 234, 228, 59, 32, 66, 227, 23, 8, 89, 173, 71, 242,
29, 38, 15, 42, 23, 201, 99, 211, 170, 132, 206, 52, 53, 30, 245, 208, 117, 122, 238, 134, 110, 44,
66, 125, 71, 49, 189, 213, 109, 210, 144, 21, 37, 55, 78, 153, 179, 87, 43, 4, 104, 229, 101, 223,
50, 187, 42, 42, 193, 83, 216, 7, 32, 225, 89, 184, 77, 242, 210, 133, 36, 148, 91, 138, 234, 51,
161, 142, 4, 39, 197, 234, 57, 241, 22, 244, 210, 13, 233, 237, 31, 152, 114, 146, 53, 139, 222,
83, 2, 130, 16, 254, 214, 18, 114, 57, 217, 204, 28, 144, 191, 230, 164, 212, 24, 224, 124, 232,
204, 108, 71, 132, 66, 94, 164, 190, 45, 154, 198, 164, 196, 156, 87, 32, 208, 129, 238, 235
]),
&BoxedUint::from_be_slice_vartime(&[1, 0, 1])
)),
public_key.inner_key
);
}
#[test]
fn encode_ssh_rsa_4096_public_key() {
// ssh-keygen -t rsa -b 4096 -C "test@picky.com"
let ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDbUCK4dH1n4dOFBv/sjfMma4q5qe7SZ49j2GODGKr8DueZMWYLTck61uUMMlVBT3XyX6me6X4WsBoijzQWvgwpLCGTqlhQTntm5FphXHHkKxFvjMhPzCnHNS+L0ebzewcecsY5rtgw+6BhFwdZGhFBfif1/6s9q7y7+8Ge3hUIEqLdiMDDzxc66zIaW26jZxO4BMHuKp7Xln2JeDjsRHvz0vBNAddOfkvtp+gM72OH4tm9wS/V8bVOZ68oU0os8DuiEGnwA5RnjOjaFdHWt1mD8B+nRINxI8zYyQcqp3t4p552P0Frhvjgixi67Ryax0DUNuzN2MpQ0ORUgRkfy/xWvImUseP/BfqvNiWkFAWHNDDSsc50Wmr+g0JicG2gowHLYPxKRjLIbOq+JgxHrE4TdaA2NJoeUppJgWU4yuGl5fx1G+Bcdr0C+lsMj14Hp+aGajEOLQ7Mq3HzWEox9G1KgN4r266Mofd8T4vrjF6Ja9E+pp0pXgEv2cvtYJLP0qdrHWafb3lWsP4hJWnv/NaXP6ZAxiEeHsigrY98kmgZbHm/6AmiBJ7bKQ/S/PelYj3mTL0aYkGF79qVtAzSl7yI9yVyHsl7dt5jdmp6+IofuEtNfnAcfoaSLu0Ojotp9VBMvil6ojScbJNLBL8tGN4+urIcsNUvVjAOnwc3nothKw== test@picky.com\r\n";
let public_key = SshPublicKey::from_str(ssh_public_key).unwrap();
let ssh_public_key_after = public_key.to_string().unwrap();
assert_eq!(ssh_public_key, ssh_public_key_after.as_str());
}
#[test]
fn encode_ssh_rsa_2048_public_key() {
// ssh-keygen -t rsa -b 4096 -C "test@picky.com"
let ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDI9ht2g2qOPgSG5huVYjFUouyaw59/6QuQqUVGwgnITlhRbM+bkvJQfcuiqcv+vD9/86Dfugk79sSfg/aVK+V/plqAAZoujz/wALDjEphSxAUcAR+t4i2F39Pa71MSc37I9L30z31tcba1X7od7hzrVMl9iurkOyBC4xcIWa1H8h0mDyoXyWPTqoTONDUe9dB1eu6GbixCfUcxvdVt0pAVJTdOmbNXKwRo5WXfMrsqKsFT2Acg4Vm4TfLShSSUW4rqM6GOBCfF6jnxFvTSDentH5hykjWL3lMCghD+1hJyOdnMHJC/5qTUGOB86MxsR4RCXqS+LZrGpMScVyDQge7r test2@picky.com\r\n";
let public_key = SshPublicKey::from_str(ssh_public_key).unwrap();
let ssh_public_key_after = public_key.to_string().unwrap();
assert_eq!(ssh_public_key, ssh_public_key_after.as_str());
}
#[test]
fn rsa_roundtrip() {
let public_key = SshPublicKey::from_str(picky_test_data::SSH_PUBLIC_KEY_RSA).unwrap();
let ssh_public_key_after = public_key.to_string().unwrap();
assert_eq!(picky_test_data::SSH_PUBLIC_KEY_RSA, ssh_public_key_after.as_str());
}
#[rstest]
#[case(picky_test_data::SSH_PUBLIC_KEY_EC_P256)]
#[case(picky_test_data::SSH_PUBLIC_KEY_EC_P384)]
#[case(picky_test_data::SSH_PUBLIC_KEY_EC_P521)]
fn ecdsa_roundtrip(#[case] key_str: &str) {
let public_key = SshPublicKey::from_str(key_str).unwrap();
let ssh_public_key_after = public_key.to_string().unwrap();
assert_eq!(key_str, ssh_public_key_after.as_str());
}
#[test]
fn ed25519_roundtrip() {
let public_key = SshPublicKey::from_str(picky_test_data::SSH_PUBLIC_KEY_ED25519).unwrap();
let ssh_public_key_after = public_key.to_string().unwrap();
assert_eq!(picky_test_data::SSH_PUBLIC_KEY_ED25519, ssh_public_key_after.as_str());
}
#[test]
fn sk_ed25519_roundtrip() {
let public_key: SshPublicKey = SshPublicKey::from_str(picky_test_data::SSH_PUBLIC_KEY_SK_ED25519).unwrap();
let ssh_public_key_after = public_key.to_string().unwrap();
assert_eq!(
picky_test_data::SSH_PUBLIC_KEY_SK_ED25519,
ssh_public_key_after.as_str()
);
}
#[test]
fn sk_ecdsa_roundtrip() {
let public_key = SshPublicKey::from_str(picky_test_data::SSH_PUBLIC_KEY_SK_ECDSA).unwrap();
let ssh_public_key_after = public_key.to_string().unwrap();
assert_eq!(picky_test_data::SSH_PUBLIC_KEY_SK_ECDSA, ssh_public_key_after.as_str());
}
#[test]
fn fingerprint_md5_ssh_rsa_2048_public_key() {
let ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDI9ht2g2qOPgSG5huVYjFUouyaw59/6QuQqUVGwgnITlhRbM+bkvJQfcuiqcv+vD9/86Dfugk79sSfg/aVK+V/plqAAZoujz/wALDjEphSxAUcAR+t4i2F39Pa71MSc37I9L30z31tcba1X7od7hzrVMl9iurkOyBC4xcIWa1H8h0mDyoXyWPTqoTONDUe9dB1eu6GbixCfUcxvdVt0pAVJTdOmbNXKwRo5WXfMrsqKsFT2Acg4Vm4TfLShSSUW4rqM6GOBCfF6jnxFvTSDentH5hykjWL3lMCghD+1hJyOdnMHJC/5qTUGOB86MxsR4RCXqS+LZrGpMScVyDQge7r test2@picky.com\r\n";
let public_key: SshPublicKey = SshPublicKey::from_str(ssh_public_key).unwrap();
let md5 = hex::encode(public_key.fingerprint_md5().unwrap());
assert_eq!(md5, "7b6b9cc2e44452aec58c3a0a31d6258d");
}
#[test]
fn fingerprint_sha1_ssh_rsa_2048_public_key() {
let ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDI9ht2g2qOPgSG5huVYjFUouyaw59/6QuQqUVGwgnITlhRbM+bkvJQfcuiqcv+vD9/86Dfugk79sSfg/aVK+V/plqAAZoujz/wALDjEphSxAUcAR+t4i2F39Pa71MSc37I9L30z31tcba1X7od7hzrVMl9iurkOyBC4xcIWa1H8h0mDyoXyWPTqoTONDUe9dB1eu6GbixCfUcxvdVt0pAVJTdOmbNXKwRo5WXfMrsqKsFT2Acg4Vm4TfLShSSUW4rqM6GOBCfF6jnxFvTSDentH5hykjWL3lMCghD+1hJyOdnMHJC/5qTUGOB86MxsR4RCXqS+LZrGpMScVyDQge7r test2@picky.com\r\n";
let public_key: SshPublicKey = SshPublicKey::from_str(ssh_public_key).unwrap();
let sha1 = STANDARD_NO_PAD.encode(public_key.fingerprint_sha1().unwrap());
assert_eq!(sha1, "ezHoULh4V/R9NybfxCW2pL9ADcU");
}
#[test]
fn fingerprint_sha256_ssh_rsa_2048_public_key() {
let ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDI9ht2g2qOPgSG5huVYjFUouyaw59/6QuQqUVGwgnITlhRbM+bkvJQfcuiqcv+vD9/86Dfugk79sSfg/aVK+V/plqAAZoujz/wALDjEphSxAUcAR+t4i2F39Pa71MSc37I9L30z31tcba1X7od7hzrVMl9iurkOyBC4xcIWa1H8h0mDyoXyWPTqoTONDUe9dB1eu6GbixCfUcxvdVt0pAVJTdOmbNXKwRo5WXfMrsqKsFT2Acg4Vm4TfLShSSUW4rqM6GOBCfF6jnxFvTSDentH5hykjWL3lMCghD+1hJyOdnMHJC/5qTUGOB86MxsR4RCXqS+LZrGpMScVyDQge7r test2@picky.com\r\n";
let public_key: SshPublicKey = SshPublicKey::from_str(ssh_public_key).unwrap();
let sha256 = STANDARD_NO_PAD.encode(public_key.fingerprint_sha256().unwrap());
assert_eq!(sha256, "cTXkM4frGl07u46Bhzy+YMOS01lX51oE2j6STi7g568");
}
#[test]
fn decode_ssh_rsa_2024_public_key_with_multiwords_comment() {
// ssh-keygen -t rsa -b 2048 -C "test using several words"
let ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+76yL1+ocu4iuVam4VO5YlODohmHbIuhjyQgiMGS8ZtdFKcltzAH0ot4zJDH/Z3ja6xO2IOc/4+UMABgPOgFwmzyl414/Zo42CYqk9OB5GJylFYI99HrATqH03Wz2qJ3dzP6QJVf8g05hY27RKaU5H+0fo471SACeHet9uqstRecsUcauPS91xwpPhrcpRXjGH1yLBdWTpDq5R6c1Wgh9SVuzY/ITMB3pq8rzwal8e2rR4T+wHc48l61LGwmuOTkhAo5/0sn72CzKWQZVd0CarfCr3biCW7cUai0FvH79aAfIBV/FIMXgtgqdpY/Qg7v+JWIyJk/OB8Be1ix8YVRV test using several words\r\n";
let public_key = SshPublicKey::from_str(ssh_public_key).unwrap();
assert_eq!(public_key.comment, "test using several words");
}
}
+131
View File
@@ -0,0 +1,131 @@
// TODO: support `SshTime` without `chrono` nor `time`
#[cfg(not(any(feature = "chrono_conversion", feature = "time_conversion")))]
compile_error!(
"Either feature \"chrono_conversion\" or \"time_conversion\" must be enabled when the feature \"ssh\" is set."
);
pub use time_impl::SshTime;
#[cfg(feature = "time_conversion")]
mod time_impl {
use time::OffsetDateTime;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct SshTime(pub(crate) OffsetDateTime);
impl SshTime {
pub fn now() -> Self {
Self(OffsetDateTime::now_utc())
}
pub fn from_timestamp(timestamp: u64) -> Self {
Self(OffsetDateTime::from_unix_timestamp(timestamp as i64).unwrap())
}
pub fn timestamp(&self) -> u64 {
self.0.unix_timestamp() as u64
}
pub fn month(&self) -> u8 {
u8::from(self.0.month())
}
pub fn day(&self) -> u8 {
self.0.day()
}
pub fn hour(&self) -> u8 {
self.0.hour()
}
pub fn minute(&self) -> u8 {
self.0.minute()
}
pub fn second(&self) -> u8 {
self.0.second()
}
pub fn year(&self) -> u16 {
self.0.year().try_into().unwrap()
}
}
impl From<SshTime> for OffsetDateTime {
fn from(time: SshTime) -> Self {
time.0
}
}
impl From<OffsetDateTime> for SshTime {
fn from(time: OffsetDateTime) -> Self {
Self::from_timestamp(time.unix_timestamp() as u64)
}
}
impl From<SshTime> for u64 {
fn from(time: SshTime) -> u64 {
time.0.unix_timestamp() as u64
}
}
}
#[cfg(all(feature = "chrono_conversion", not(feature = "time_conversion")))]
mod time_impl {
use chrono::{DateTime, Utc};
pub use chrono::{Datelike, Timelike};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct SshTime(pub(crate) DateTime<Utc>);
impl SshTime {
pub fn now() -> Self {
SshTime(DateTime::<Utc>::from(SystemTime::now()))
}
pub fn from_timestamp(timestamp: u64) -> Self {
Self(DateTime::<Utc>::from(UNIX_EPOCH + Duration::from_secs(timestamp)))
}
pub fn timestamp(&self) -> u64 {
self.0.timestamp() as u64
}
pub fn month(&self) -> u8 {
self.0.month().try_into().unwrap()
}
pub fn day(&self) -> u8 {
self.0.day().try_into().unwrap()
}
pub fn hour(&self) -> u8 {
self.0.hour().try_into().unwrap()
}
pub fn minute(&self) -> u8 {
self.0.minute().try_into().unwrap()
}
pub fn second(&self) -> u8 {
self.0.second().try_into().unwrap()
}
pub fn year(&self) -> u16 {
self.0.year().try_into().unwrap()
}
}
impl From<DateTime<Utc>> for SshTime {
fn from(date: DateTime<Utc>) -> Self {
Self(date)
}
}
impl From<SshTime> for DateTime<Utc> {
fn from(time: SshTime) -> Self {
time.0
}
}
}
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
use super::utils::{from_der, from_pem, from_pem_str, to_der, to_pem};
use crate::key::{KeyError, PrivateKey, PublicKey};
use crate::pem::{Pem, PemError};
use crate::signature::{SignatureAlgorithm, SignatureError};
use crate::x509::certificate::CertError;
use crate::x509::name::DirectoryName;
use picky_asn1::bit_string::BitString;
use picky_asn1_der::Asn1DerError;
use picky_asn1_x509::{CertificationRequest, CertificationRequestInfo};
use thiserror::Error;
pub use picky_asn1_x509::Attribute;
const ELEMENT_NAME: &str = "certification request";
#[derive(Debug, Error)]
pub enum CsrError {
/// ASN1 serialization error
#[error("(ASN1) couldn't serialize {element}: {source}")]
Asn1Serialization {
element: &'static str,
source: Asn1DerError,
},
/// ASN1 deserialization error
#[error("(ASN1) couldn't deserialize {}: {}", element, source)]
Asn1Deserialization {
element: &'static str,
source: Asn1DerError,
},
/// signature error
#[error("signature error: {}", source)]
Signature { source: SignatureError },
/// invalid PEM label error
#[error("invalid PEM label: {}", label)]
InvalidPemLabel { label: String },
/// invalid PEM provided
#[error("invalid PEM provided: {source}")]
Pem { source: PemError },
#[error("failed to get public key from private key: {source}")]
PrivateKeyToPublicKey { source: KeyError },
}
impl From<CertError> for CsrError {
fn from(e: CertError) -> Self {
match e {
CertError::Asn1Deserialization { element, source } => CsrError::Asn1Deserialization { element, source },
CertError::Asn1Serialization { element, source } => CsrError::Asn1Serialization { element, source },
CertError::Pem { source } => CsrError::Pem { source },
CertError::InvalidPemLabel { label } => CsrError::InvalidPemLabel { label },
_ => unreachable!(),
}
}
}
const CSR_PEM_LABEL: &str = "CERTIFICATE REQUEST";
/// Certificate Signing Request
#[derive(Clone, Debug, PartialEq)]
pub struct Csr(pub(crate) CertificationRequest);
impl From<CertificationRequest> for Csr {
fn from(certification_request: CertificationRequest) -> Self {
Self(certification_request)
}
}
impl Csr {
pub fn from_der<T: ?Sized + AsRef<[u8]>>(der: &T) -> Result<Self, CsrError> {
Ok(from_der(der, ELEMENT_NAME).map(Self)?)
}
pub fn from_pem_str(pem_str: &str) -> Result<Self, CsrError> {
Ok(from_pem_str(pem_str, &[CSR_PEM_LABEL], ELEMENT_NAME).map(Self)?)
}
pub fn from_pem(pem: &Pem) -> Result<Self, CsrError> {
Ok(from_pem(pem, &[CSR_PEM_LABEL], ELEMENT_NAME).map(Self)?)
}
pub fn to_der(&self) -> Result<Vec<u8>, CsrError> {
Ok(to_der(&self.0, ELEMENT_NAME)?)
}
pub fn to_pem(&self) -> Result<Pem<'static>, CsrError> {
Ok(to_pem(&self.0, CSR_PEM_LABEL, ELEMENT_NAME)?)
}
pub fn generate(
subject: DirectoryName,
private_key: &PrivateKey,
signature_hash_type: SignatureAlgorithm,
) -> Result<Self, CsrError> {
let public_key = private_key
.to_public_key()
.map_err(|source| CsrError::PrivateKeyToPublicKey { source })?;
let cri = CertificationRequestInfo::new(subject.into(), public_key.into());
h_generate_from_cri(cri, private_key, signature_hash_type)
}
pub fn generate_with_attributes(
subject: DirectoryName,
private_key: &PrivateKey,
signature_hash_type: SignatureAlgorithm,
attributes: Vec<Attribute>,
) -> Result<Self, CsrError> {
let public_key = private_key
.to_public_key()
.map_err(|source| CsrError::PrivateKeyToPublicKey { source })?;
let mut cri = CertificationRequestInfo::new(subject.into(), public_key.into());
for attr in attributes {
cri.add_attribute(attr);
}
h_generate_from_cri(cri, private_key, signature_hash_type)
}
pub fn subject_name(&self) -> DirectoryName {
self.0.certification_request_info.subject.clone().into()
}
pub fn public_key(&self) -> &PublicKey {
(&self.0.certification_request_info.subject_public_key_info).into()
}
pub fn into_subject_infos(self) -> (DirectoryName, PublicKey) {
(
self.0.certification_request_info.subject.into(),
self.0.certification_request_info.subject_public_key_info.into(),
)
}
pub fn verify(&self) -> Result<(), CsrError> {
let hash_type = SignatureAlgorithm::from_algorithm_identifier(&self.0.signature_algorithm)
.map_err(|e| CsrError::Signature { source: e })?;
let public_key = &self.0.certification_request_info.subject_public_key_info;
let msg =
picky_asn1_der::to_vec(&self.0.certification_request_info).map_err(|e| CsrError::Asn1Serialization {
source: e,
element: "certification request info",
})?;
hash_type
.verify(&public_key.clone().into(), &msg, self.0.signature.0.payload_view())
.map_err(|e| CsrError::Signature { source: e })?;
Ok(())
}
}
fn h_generate_from_cri(
cri: CertificationRequestInfo,
private_key: &PrivateKey,
signature_hash_type: SignatureAlgorithm,
) -> Result<Csr, CsrError> {
let cri_der = picky_asn1_der::to_vec(&cri).map_err(|e| CsrError::Asn1Serialization {
source: e,
element: "certification request cri",
})?;
let signature = BitString::with_bytes(
signature_hash_type
.sign(&cri_der, private_key)
.map_err(|e| CsrError::Signature { source: e })?,
);
let signature_algorithm = signature_hash_type
.try_into()
.map_err(|e| CsrError::Signature { source: e })?;
Ok(Csr(CertificationRequest {
certification_request_info: cri,
signature_algorithm,
signature: signature.into(),
}))
}
+182
View File
@@ -0,0 +1,182 @@
use picky_asn1::date::{Date, GeneralizedTime, UTCTime, UTCTimeRepr};
use picky_asn1_x509::validity::Time;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct UtcDate(GeneralizedTime);
impl UtcDate {
#[inline]
pub fn new(year: u16, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> Option<Self> {
Some(Self(GeneralizedTime::new(year, month, day, hour, minute, second)?))
}
#[inline]
pub fn ymd(year: u16, month: u8, day: u8) -> Option<Self> {
Some(Self(GeneralizedTime::new(year, month, day, 0, 0, 0)?))
}
#[cfg(any(feature = "time_conversion", feature = "chrono_conversion"))]
#[inline]
pub fn now() -> Self {
#[cfg(feature = "time_conversion")]
{
Self(time::OffsetDateTime::now_utc().into())
}
#[cfg(all(feature = "chrono_conversion", not(feature = "time_conversion")))]
{
Self(chrono::offset::Utc::now().into())
}
}
#[inline]
pub fn year(&self) -> u16 {
self.0.year()
}
#[inline]
pub fn month(&self) -> u8 {
self.0.month()
}
#[inline]
pub fn day(&self) -> u8 {
self.0.day()
}
#[inline]
pub fn hour(&self) -> u8 {
self.0.hour()
}
#[inline]
pub fn minute(&self) -> u8 {
self.0.minute()
}
#[inline]
pub fn second(&self) -> u8 {
self.0.second()
}
}
impl From<UtcDate> for UTCTime {
fn from(date: UtcDate) -> Self {
unsafe {
UTCTime::new_unchecked(
date.0.year(),
date.0.month(),
date.0.day(),
date.0.hour(),
date.0.minute(),
date.0.second(),
)
}
}
}
impl From<UTCTime> for UtcDate {
fn from(date: Date<UTCTimeRepr>) -> Self {
Self(unsafe {
GeneralizedTime::new_unchecked(
date.year(),
date.month(),
date.day(),
date.hour(),
date.minute(),
date.second(),
)
})
}
}
impl From<UtcDate> for GeneralizedTime {
fn from(date: UtcDate) -> GeneralizedTime {
date.0
}
}
impl From<GeneralizedTime> for UtcDate {
fn from(date: GeneralizedTime) -> Self {
Self(date)
}
}
impl From<UtcDate> for Time {
fn from(date: UtcDate) -> Self {
// Time is used to encode validity period.
// As per RFC 5280,
// > CAs conforming to this profile MUST always encode certificate
// > validity dates through the year 2049 as UTCTime; certificate validity
// > dates in 2050 or later MUST be encoded as GeneralizedTime.
// > Conforming applications MUST be able to process validity dates that
// > are encoded in either UTCTime or GeneralizedTime.
if date.year() >= 2050 {
Self::Generalized(Into::<GeneralizedTime>::into(date).into())
} else {
Self::Utc(Into::<UTCTime>::into(date).into())
}
}
}
impl From<Time> for UtcDate {
fn from(time: Time) -> Self {
match time {
Time::Utc(utc) => utc.0.into(),
Time::Generalized(gen_time) => gen_time.0.into(),
}
}
}
impl fmt::Display for UtcDate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
self.year(),
self.month(),
self.day(),
self.hour(),
self.minute(),
self.second()
)
}
}
#[cfg(feature = "time_conversion")]
mod time_convert {
use super::*;
use time::OffsetDateTime;
impl From<OffsetDateTime> for UtcDate {
fn from(dt: OffsetDateTime) -> Self {
Self(dt.into())
}
}
impl TryFrom<UtcDate> for OffsetDateTime {
type Error = time::error::ComponentRange;
fn try_from(date: UtcDate) -> Result<Self, Self::Error> {
date.0.try_into()
}
}
}
#[cfg(feature = "chrono_conversion")]
mod chrono_convert {
use super::*;
use chrono::{DateTime, Utc};
impl From<DateTime<Utc>> for UtcDate {
fn from(dt: DateTime<Utc>) -> Self {
Self(dt.into())
}
}
impl From<UtcDate> for DateTime<Utc> {
fn from(date: UtcDate) -> Self {
date.0.into()
}
}
}
+69
View File
@@ -0,0 +1,69 @@
use crate::hash::HashAlgorithm;
use crate::key::{KeyError, PublicKey};
use picky_asn1::wrapper::BitStringAsn1Container;
use picky_asn1_der::Asn1DerError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum KeyIdGenError {
/// asn1 serialization error
#[error("(asn1) couldn't serialize {element}: {source}")]
Asn1Serialization {
element: &'static str,
source: Asn1DerError,
},
/// invalid key
#[error("invalid key: {source}")]
InvalidKey { source: KeyError },
}
/// Describes which method to use to generate key identifiers.
///
/// See [RFC5280 #4](https://tools.ietf.org/html/rfc5280#section-4.2.1.2) and
/// [RFC7093 #2](https://tools.ietf.org/html/rfc7093#section-2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KeyIdGenMethod {
/// Hash the leftmost 160-bits of the
/// SHA-256 hash of the value of the BIT STRING subjectPublicKey
/// (excluding the tag, length, and number of unused bits).
SPKValueHashedLeftmost160(HashAlgorithm),
/// Hash the DER encoding of the SubjectPublicKeyInfo value.
SPKFullDER(HashAlgorithm),
}
impl KeyIdGenMethod {
pub fn generate_from(self, public_key: &PublicKey) -> Result<Vec<u8>, KeyIdGenError> {
use picky_asn1_x509::PublicKey as InnerPublicKey;
match self {
KeyIdGenMethod::SPKValueHashedLeftmost160(hash_algo) => match &public_key.as_inner().subject_public_key {
InnerPublicKey::Rsa(BitStringAsn1Container(rsa_pk)) => {
let der = picky_asn1_der::to_vec(rsa_pk).map_err(|e| KeyIdGenError::Asn1Serialization {
source: e,
element: "RSA private key",
})?;
Ok(hash_algo.digest(&der)[..20].to_vec())
}
InnerPublicKey::Ec(bitstring) => {
let der = bitstring.0.payload_view();
Ok(hash_algo.digest(der)[..20].to_vec())
}
InnerPublicKey::Ed(bitstring) => {
let der = bitstring.0.payload_view();
Ok(hash_algo.digest(der)[..20].to_vec())
}
InnerPublicKey::Mldsa(bitstring) => {
let der = bitstring.0.payload_view();
Ok(hash_algo.digest(der)[..20].to_vec())
}
},
KeyIdGenMethod::SPKFullDER(hash_algo) => {
let der = public_key
.to_der()
.map_err(|e| KeyIdGenError::InvalidKey { source: e })?;
Ok(hash_algo.digest(&der))
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
//! X.509 certificates implementation based on [RFC5280](https://tools.ietf.org/html/rfc5280)
//!
//! ## Example
//!
//! ```rust
//! use picky::key::PrivateKey;
//! use picky::pem::{parse_pem, Pem};
//! use picky::{signature::SignatureAlgorithm, hash::HashAlgorithm};
//! use picky::x509::key_id_gen_method::{KeyIdGenError, KeyIdGenMethod};
//! use picky::x509::{certificate::CertType, csr::Csr};
//! use picky::x509::{name::DirectoryName, date::UtcDate};
//! use picky::x509::certificate::CertificateBuilder;
//!
//!# use std::error::Error;
//!# fn main() -> Result<(), Box<dyn Error>> {
//!# let root_key_pem_str =
//!# "-----BEGIN PRIVATE KEY-----\n\
//!# MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCzl5R1QzQGznTe\n\
//!# Jw1STzQz1ZOrbG58ZWdJJDOrjBB60PO+MgOIxDzn41p4W/OeqmzhxUPyzs9d3iAx\n\
//!# RcF8gWkS8QvQXtHQtFlux61oE8GHvK9pqrEERQKjjTN/yculekauopAMQOcGDS1L\n\
//!# t55z5oByunc/ZQYKHIZ82zfuw/VyKOdJ31/fXn2Djhnfq19YE8pgBDGxEPqCRv7S\n\
//!# t/ThptUD+Xr3jwR2ycemq/OVLmsTETh8fYeGrcDIx2EsjL18ptPOg6rZly/UNN+w\n\
//!# Sy8L5HlJqzDj03ytYTkgpT442TK3eGpxwX5uaHF7GuL6l9xOIDH+FuPKMt1huFWC\n\
//!# Y+hHC8yXAgMBAAECggEATpCbM4lIs0mSOrUe0MkxbxOZ3D49P1IlEwwxRnSpqPDM\n\
//!# EIJUpXojotYE9+5R7zFjHIltpkTu1oOb1bJMaKk8TDsDDg7M/t5G2X2k0IkD+tuC\n\
//!# aVlwwwtlkiLtSpP+8M9DKJaZKaMi7bTKAfu757wIqSgFjsglEjtUYj6Mg3laYKvk\n\
//!# EoR6NsOojR1tOhqGfPFJTH21ZBWDmFHhsyRHXyZOQ9I2u8pv5l33yasgmuZLZ2g3\n\
//!# Hok6j0uU/fPpjSRTu6khVZV8+stjnFaHezTWGsz/CLqzC1EWJ1icFTb7UwafyWts\n\
//!# EHgdMjqpu2qfqB+miFElhld4fdZIoBWTQwCdN7MnUQKBgQDnBIK6RUJYUjcX9Nei\n\
//!# Qvf7Kl3sEY+QTQdI4na3puXjOk6eFqTUAbdsKVwU/yJyR3L+bQNTUmy3GdPlZkYt\n\
//!# mx18fGpq9t8whDIFZG9jI5YX4/vEMOHa9zY9FI3QLpIgbifIBEIR76UVe/xakT+I\n\
//!# bx8C1p/9msDuqVBJm7pZLIX3DQKBgQDHA2ftRkQVL0lOmvwFl56n2gbnnk0KAZ/8\n\
//!# LyuN9FvadqzbpXQqfEUS5f1cfM4JSAXj2e9POBw2FWD1uFROIGPrPEAfSqInI6G9\n\
//!# i/MSeBwlcvTXmWMphVGCnJwmCPlznT0Vizr5wIBImgUOpXZpxAqUQUvxOl4R4VYI\n\
//!# gLW8iFSpMwKBgCAzacFrC/9hnlpRf4kXipdQ5XyVSgyUIBLRtjiNI/gTVYgFof4H\n\
//!# KzkBXttyYKvLN8UtDsybbZnsGLQeGGQc/fQvJ2o3dQ6/LnW/9SK9gBteZOaI5cJu\n\
//!# uPm0lrvQ8f9hO1xO86KqY7ll6dv56QAsdQchQXXJD2F06kMIWOY7JYU1AoGAJSKg\n\
//!# kOjsqVtSfYV0A4MgSsfnQ+8JBxX4iXEv2mQ/g4tjg/TisU7RAM7DsS9krtyupK60\n\
//!# 9f5NXVYt6owDxzRKEMoEWRJvIYiHlLv5lnetINvLjjOECmpjJFEe3gYMriMoE84Q\n\
//!# Kixeg62hxfLgHqpDIxjwF8pBZWq7yAhkYRK7YXsCgYAxqdZwpOYyntEBIQa3mOE8\n\
//!# a2mthICUXKE30OkMJdrbP3qlFECWZcvKspPHzXWiy8cP9dS2D06ax/UBZmKGXUFv\n\
//!# D48nECaQ2glgMPLyyd+tznXed9nZVYBlOFjUQ0fvfpsmhp975n1b/UxAg1jqvigK\n\
//!# YHWKXfDNtZ9Ci6BMYVELsw==\n\
//!# -----END PRIVATE KEY-----";
//!# let intermediate_key_pem_str = root_key_pem_str;
//!# let leaf_key_pem_str = root_key_pem_str;
//!#
//! // Generate a self-signed root certificate
//!
//! // Load private key
//! let root_key = PrivateKey::from_pem_str(root_key_pem_str)?;
//!
//! let root = CertificateBuilder::new()
//! .validity(UtcDate::ymd(2020, 9, 28).unwrap(), UtcDate::ymd(2023, 9, 28).unwrap())
//! .self_signed(DirectoryName::new_common_name("My Root CA"), &root_key)
//! .ca(true)
//! .signature_hash_type(SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_512))
//! .key_id_gen_method(KeyIdGenMethod::SPKFullDER(HashAlgorithm::SHA2_384))
//! .build()?;
//!
//! assert_eq!(root.ty(), CertType::Root);
//!
//! // Generate intermediate certificate signed by root CA
//!
//! let intermediate_key = PrivateKey::from_pem_str(intermediate_key_pem_str)?;
//!
//! let intermediate = CertificateBuilder::new()
//! .validity(UtcDate::ymd(2020, 10, 15).unwrap(), UtcDate::ymd(2021, 10, 15).unwrap())
//! .subject(DirectoryName::new_common_name("My Authority"), intermediate_key.to_public_key().unwrap())
//! .issuer_cert(&root, &root_key)
//! .signature_hash_type(SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_224))
//! .key_id_gen_method(KeyIdGenMethod::SPKValueHashedLeftmost160(HashAlgorithm::SHA1))
//! .ca(true)
//! .pathlen(0)
//! .build()?;
//!
//! assert_eq!(intermediate.ty(), CertType::Intermediate);
//!
//! // Generate leaf certificate signed by intermediate authority
//!
//! let leaf_key = PrivateKey::from_pem_str(leaf_key_pem_str)?;
//!
//! // A CSR can be used
//! let csr = Csr::generate(
//! DirectoryName::new_common_name("My Leaf"),
//! &leaf_key,
//! SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA1),
//! )?;
//!
//! let signed_leaf = CertificateBuilder::new()
//! .validity(UtcDate::ymd(2020, 11, 1).unwrap(), UtcDate::ymd(2021, 1, 1).unwrap())
//! .subject_from_csr(csr)
//! .issuer_cert(&intermediate, &intermediate_key)
//! .signature_hash_type(SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_384))
//! .key_id_gen_method(KeyIdGenMethod::SPKFullDER(HashAlgorithm::SHA2_512))
//! .build()?;
//!
//! assert_eq!(signed_leaf.ty(), CertType::Leaf);
//!
//! // Check leaf using CA chain
//!
//! let chain = [intermediate, root];
//!
//! signed_leaf
//! .verifier()
//! .chain(chain.iter())
//! .exact_date(&UtcDate::ymd(2020, 12, 20).unwrap())
//! .verify()?;
//!
//! // If `not_after` date is behind…
//!
//! let err = signed_leaf
//! .verifier()
//! .chain(chain.iter())
//! .exact_date(&UtcDate::ymd(2021, 1, 2).unwrap())
//! .verify()
//! .err()
//! .unwrap();
//!
//! assert_eq!(
//! err.to_string(),
//! "invalid certificate \'CN=My Leaf\': \
//! certificate expired (not after: 2021-01-01 00:00:00, now: 2021-01-02 00:00:00)"
//! );
//!#
//!# Ok(())
//!# }
//! ```
#[cfg(feature = "pkcs7")]
pub mod pkcs7;
#[cfg(feature = "wincert")]
pub mod wincert;
pub mod certificate;
pub mod csr;
pub mod date;
pub mod key_id_gen_method;
pub mod name;
pub use certificate::Cert;
pub use csr::Csr;
pub use key_id_gen_method::KeyIdGenMethod;
pub use picky_asn1_x509::{DirectoryString, Extension, Extensions};
pub mod extension {
pub use picky_asn1_x509::extension::*;
}
mod utils;
+313
View File
@@ -0,0 +1,313 @@
use crate::oid::ObjectIdentifier;
use picky_asn1::restricted_string::{CharSetError, Ia5String};
use picky_asn1::wrapper::{Asn1SequenceOf, Ia5StringAsn1};
use picky_asn1_x509::{
DirectoryString, GeneralName as SerdeGeneralName, GeneralNames as SerdeGeneralNames, Name, NamePrettyFormatter,
OtherName,
};
use std::fmt;
// === DirectoryName ===
pub use picky_asn1_x509::NameAttr;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirectoryName(Name);
impl Default for DirectoryName {
fn default() -> Self {
Self::new()
}
}
impl DirectoryName {
pub fn new() -> Self {
Self(Name::new())
}
pub fn new_common_name<S: Into<DirectoryString>>(name: S) -> Self {
Self(Name::new_common_name(name))
}
/// Find the first common name contained in this `Name`
pub fn find_common_name(&self) -> Option<&DirectoryString> {
self.0.find_common_name()
}
pub fn add_attr<S: Into<DirectoryString>>(&mut self, attr: NameAttr, value: S) {
self.0.add_attr(attr, value)
}
/// Add an emailAddress attribute.
/// NOTE: this attribute does not conform with the RFC 5280, email should be placed in SAN instead
pub fn add_email<S: Into<Ia5StringAsn1>>(&mut self, value: S) {
self.0.add_email(value)
}
}
impl fmt::Display for DirectoryName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
NamePrettyFormatter(&self.0).fmt(f)
}
}
impl From<Name> for DirectoryName {
fn from(name: Name) -> Self {
Self(name)
}
}
impl From<DirectoryName> for Name {
fn from(name: DirectoryName) -> Self {
name.0
}
}
// === GeneralNames === //
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum GeneralName {
OtherName(OtherName),
RFC822Name(Ia5String),
DNSName(Ia5String),
DirectoryName(DirectoryName),
EDIPartyName {
name_assigner: Option<DirectoryString>,
party_name: DirectoryString,
},
URI(Ia5String),
IpAddress(Vec<u8>),
RegisteredId(ObjectIdentifier),
}
impl GeneralName {
pub fn new_rfc822_name<S: Into<String>>(name: S) -> Result<Self, CharSetError> {
Ok(Self::RFC822Name(Ia5String::from_string(name.into())?))
}
pub fn new_dns_name<S: Into<String>>(name: S) -> Result<Self, CharSetError> {
Ok(Self::DNSName(Ia5String::from_string(name.into())?))
}
pub fn new_directory_name<N: Into<DirectoryName>>(name: N) -> Self {
Self::DirectoryName(name.into())
}
pub fn new_edi_party_name<PN, NA>(party_name: PN, name_assigner: Option<NA>) -> Self
where
PN: Into<DirectoryString>,
NA: Into<DirectoryString>,
{
Self::EDIPartyName {
name_assigner: name_assigner.map(Into::into),
party_name: party_name.into(),
}
}
pub fn new_uri<S: Into<String>>(uri: S) -> Result<Self, CharSetError> {
Ok(Self::URI(Ia5String::from_string(uri.into())?))
}
pub fn new_ip_address<ADDR: Into<Vec<u8>>>(ip_address: ADDR) -> Self {
Self::IpAddress(ip_address.into())
}
pub fn new_registered_id<OID: Into<ObjectIdentifier>>(oid: OID) -> Self {
Self::RegisteredId(oid.into())
}
}
impl From<SerdeGeneralName> for GeneralName {
fn from(gn: SerdeGeneralName) -> Self {
match gn {
SerdeGeneralName::OtherName(other_name) => Self::OtherName(other_name),
SerdeGeneralName::Rfc822Name(name) => Self::RFC822Name(name.0),
SerdeGeneralName::DnsName(name) => Self::DNSName(name.0),
SerdeGeneralName::DirectoryName(name) => Self::DirectoryName(name.into()),
SerdeGeneralName::EdiPartyName(edi_pn) => Self::EDIPartyName {
name_assigner: edi_pn.name_assigner.0.map(|na| na.0),
party_name: edi_pn.party_name.0,
},
SerdeGeneralName::Uri(uri) => Self::URI(uri.0),
SerdeGeneralName::IpAddress(ip_addr) => Self::IpAddress(ip_addr.0),
SerdeGeneralName::RegisteredId(id) => Self::RegisteredId(id.0),
}
}
}
impl From<GeneralName> for SerdeGeneralName {
fn from(gn: GeneralName) -> Self {
match gn {
GeneralName::OtherName(other_name) => SerdeGeneralName::OtherName(other_name),
GeneralName::RFC822Name(name) => SerdeGeneralName::Rfc822Name(name.into()),
GeneralName::DNSName(name) => SerdeGeneralName::DnsName(name.into()),
GeneralName::DirectoryName(name) => SerdeGeneralName::DirectoryName(name.into()),
GeneralName::EDIPartyName {
name_assigner,
party_name,
} => SerdeGeneralName::new_edi_party_name(party_name, name_assigner),
GeneralName::URI(uri) => SerdeGeneralName::Uri(uri.into()),
GeneralName::IpAddress(ip_addr) => SerdeGeneralName::IpAddress(ip_addr.into()),
GeneralName::RegisteredId(id) => SerdeGeneralName::RegisteredId(id.into()),
}
}
}
impl From<GeneralName> for SerdeGeneralNames {
fn from(gn: GeneralName) -> Self {
GeneralNames::new(gn).into()
}
}
/// Wraps x509 `GeneralNames` into an easy to use API.
///
/// # Example
///
/// ```
/// use picky::x509::name::{GeneralNames, GeneralName, DirectoryName};
///
/// let common_name = GeneralName::new_directory_name(DirectoryName::new_common_name("MyName"));
/// let dns_name = GeneralName::new_dns_name("localhost").expect("invalid name string");
/// let names = GeneralNames::from(vec![common_name, dns_name]);
/// ```
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneralNames(SerdeGeneralNames);
impl GeneralNames {
/// # Example
///
/// ```
/// use picky::x509::name::{GeneralName, GeneralNames};
///
/// let dns_name = GeneralName::new_dns_name("localhost").expect("invalid name string");
/// let names = GeneralNames::new(dns_name);
/// ```
pub fn new<Gn: Into<GeneralName>>(gn: Gn) -> Self {
let gn = gn.into();
Self(Asn1SequenceOf(vec![gn.into()]))
}
pub fn new_directory_name<Dn: Into<DirectoryName>>(name: Dn) -> Self {
let gn = GeneralName::new_directory_name(name);
Self::new(gn)
}
pub fn with_directory_name<Dn: Into<DirectoryName>>(mut self, name: Dn) -> Self {
let gn = GeneralName::new_directory_name(name);
(self.0).0.push(gn.into());
self
}
pub fn find_directory_name(&self) -> Option<DirectoryName> {
for name in &(self.0).0 {
if let SerdeGeneralName::DirectoryName(name) = name {
return Some(name.clone().into());
}
}
None
}
/// # Example
///
/// ```
/// use picky::x509::name::GeneralNames;
/// use picky_asn1::restricted_string::Ia5String;
///
/// let names = GeneralNames::new_dns_name(Ia5String::new("localhost").unwrap());
/// ```
pub fn new_dns_name<Ia5: Into<Ia5String>>(dns_name: Ia5) -> Self {
let gn = GeneralName::DNSName(dns_name.into());
Self::new(gn)
}
/// # Example
///
/// ```
/// use picky::x509::name::{GeneralNames, DirectoryName};
/// use picky_asn1::restricted_string::Ia5String;
///
/// let names = GeneralNames::new_directory_name(DirectoryName::new_common_name("MyName"))
/// .with_dns_name(Ia5String::new("localhost").unwrap());
/// ```
pub fn with_dns_name<Ia5: Into<Ia5String>>(mut self, dns_name: Ia5) -> Self {
let gn = GeneralName::DNSName(dns_name.into());
(self.0).0.push(gn.into());
self
}
pub fn find_dns_name(&self) -> Option<&Ia5String> {
for name in &(self.0).0 {
if let SerdeGeneralName::DnsName(name) = name {
return Some(&name.0);
}
}
None
}
pub fn add_name<Gn: Into<GeneralName>>(&mut self, name: Gn) {
let gn = name.into();
(self.0).0.push(gn.into());
}
/// # Example
///
/// ```
/// use picky::x509::name::{GeneralNames, GeneralName, DirectoryName};
///
/// let common_name = GeneralName::new_directory_name(DirectoryName::new_common_name("MyName"));
/// let dns_name = GeneralName::new_dns_name("localhost").expect("invalid name string");
/// let names = GeneralNames::new(common_name).with_name(dns_name);
/// ```
pub fn with_name<GN: Into<GeneralName>>(mut self, name: GN) -> Self {
let gn = name.into();
(self.0).0.push(gn.into());
self
}
pub fn into_general_names(self) -> Vec<GeneralName> {
(self.0).0.into_iter().map(|gn| gn.into()).collect()
}
pub fn to_general_names(&self) -> Vec<GeneralName> {
(self.0).0.iter().map(|gn| gn.clone().into()).collect()
}
}
impl From<SerdeGeneralNames> for GeneralNames {
fn from(gn: SerdeGeneralNames) -> Self {
Self(gn)
}
}
impl From<GeneralNames> for SerdeGeneralNames {
fn from(gn: GeneralNames) -> Self {
gn.0
}
}
impl From<Vec<GeneralName>> for GeneralNames {
fn from(names: Vec<GeneralName>) -> Self {
let serde_names = names.into_iter().map(|n| n.into()).collect();
Self(Asn1SequenceOf(serde_names))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_and_format_directory_name() {
let mut my_name = DirectoryName::new_common_name("CommonName");
my_name.add_attr(NameAttr::StateOrProvinceName, "SomeState");
my_name.add_attr(NameAttr::CountryName, "SomeCountry");
assert_eq!(my_name.to_string(), "CN=CommonName,ST=SomeState,C=SomeCountry");
}
#[test]
fn find_common_name() {
let my_name = DirectoryName::new_common_name("CommonName");
let cn = my_name.find_common_name().unwrap();
assert_eq!(cn.to_utf8_lossy(), "CommonName");
}
}
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
use crate::x509::pkcs7::{Pkcs7, Pkcs7Error};
use picky_asn1_x509::content_info::ContentValue;
use picky_asn1_x509::pkcs7::ctl::CTLEntry;
pub use picky_asn1_x509::pkcs7::ctl::CTLEntryAttributeValues;
use std::io;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CtlError {
#[error("Failed to download CTL: {description}")]
DownloadError { description: String },
#[error("{description}")]
ExtractingError { description: String },
#[error("Failed to parse CertificateTrustList: {0}")]
FailedToParseCtl(Pkcs7Error),
#[error(transparent)]
IoError(#[from] io::Error),
#[error("For CTL we expect CertificateTrustList content in EncapsulatedContentInfo, but something else")]
IncorrectContentValue,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CertificateTrustList {
pkcs7: Pkcs7,
}
impl CertificateTrustList {
pub fn ctl_entries(&self) -> Result<&[CTLEntry], CtlError> {
let content_value = self
.pkcs7
.0
.signed_data
.content_info
.content
.as_ref()
.expect("CTL Content should be present in EncapsulatedContentInfo");
let ctl = match &content_value.0 {
ContentValue::CertificateTrustList(ctl) => ctl,
_ => return Err(CtlError::IncorrectContentValue),
};
Ok(&ctl.crl_entries.0)
}
}
#[cfg(feature = "ctl_http_fetch")]
pub mod http_fetch {
use super::*;
use std::io::{Cursor, Read};
pub trait CtlHttpFetch {
fn fetch() -> Result<CertificateTrustList, CtlError>;
}
impl CtlHttpFetch for CertificateTrustList {
fn fetch() -> Result<CertificateTrustList, CtlError> {
let ctl_url: &str =
"http://www.download.windowsupdate.com/msdownload/update/v3/static/trustedr/en/authrootstl.cab";
let mut cab = reqwest::blocking::get(ctl_url).map_err(|err| CtlError::DownloadError {
description: err.to_string(),
})?;
if !cab.status().is_success() {
return Err(CtlError::DownloadError {
description: format!("Response status code is {}", cab.status()),
});
}
let mut buffer = Vec::new();
cab.copy_to(&mut buffer).map_err(|err| CtlError::ExtractingError {
description: format!("Failed to copy Response body to Vec: {err}"),
})?;
let mut cabinet = cab::Cabinet::new(Cursor::new(&mut buffer)).map_err(|err| CtlError::ExtractingError {
description: format!("Failed to parse Cabinet file: {err}"),
})?;
let mut authroot = cabinet
.read_file("authroot.stl")
.expect("authroot.stl should be present in authrootstl.cab");
let mut ctl_buffer = Vec::new();
authroot.read_to_end(&mut ctl_buffer)?;
let pkcs7: Pkcs7 = Pkcs7::from_der(&ctl_buffer).map_err(CtlError::FailedToParseCtl)?;
Ok(CertificateTrustList { pkcs7 })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::x509::pkcs7::Pkcs7;
#[test]
fn parse_certificate_trust_list_in_der() {
Pkcs7::from_der(picky_test_data::CERTIFICATE_TRUST_LIST).unwrap();
}
#[cfg(feature = "ctl_http_fetch")]
#[ignore = "temporarily disabled for CI issues,which is under investigation"]
#[test]
fn create_ctl() {
use super::http_fetch::CtlHttpFetch;
CertificateTrustList::fetch().unwrap();
}
}
+104
View File
@@ -0,0 +1,104 @@
use crate::AlgorithmIdentifier;
use crate::pem::Pem;
use crate::x509::certificate::{Cert, CertError};
use crate::x509::utils::{from_der, from_pem, from_pem_str, to_der, to_pem};
use picky_asn1_der::Asn1DerError;
use picky_asn1_x509::content_info::EncapsulatedContentInfo;
use picky_asn1_x509::pkcs7::Pkcs7Certificate;
use picky_asn1_x509::signed_data::CertificateChoices;
use picky_asn1_x509::signer_info::SignerInfo;
use thiserror::Error;
pub mod authenticode;
#[cfg(feature = "ctl")]
pub mod ctl;
pub mod timestamp;
type Pkcs7Result<T> = Result<T, Pkcs7Error>;
const ELEMENT_NAME: &str = "pkcs7 certificate";
#[derive(Debug, Error)]
pub enum Pkcs7Error {
#[error(transparent)]
Cert(#[from] CertError),
#[error(transparent)]
Asn1DerError(#[from] Asn1DerError),
}
const PKCS7_PEM_LABEL: &str = "PKCS7";
#[derive(Clone, Debug, PartialEq)]
pub struct Pkcs7(Pkcs7Certificate);
impl Pkcs7 {
pub fn from_der<V: ?Sized + AsRef<[u8]>>(data: &V) -> Pkcs7Result<Self> {
Ok(from_der(data, ELEMENT_NAME).map(Self)?)
}
pub fn from_pem(pem: &Pem) -> Pkcs7Result<Self> {
Ok(from_pem(pem, &[PKCS7_PEM_LABEL], ELEMENT_NAME).map(Self)?)
}
pub fn from_pem_str(pem_str: &str) -> Pkcs7Result<Self> {
Ok(from_pem_str(pem_str, &[PKCS7_PEM_LABEL], ELEMENT_NAME).map(Self)?)
}
pub fn to_der(&self) -> Pkcs7Result<Vec<u8>> {
Ok(to_der(&self.0, ELEMENT_NAME)?)
}
pub fn to_pem(&self) -> Pkcs7Result<Pem<'static>> {
Ok(to_pem(&self.0, PKCS7_PEM_LABEL, ELEMENT_NAME)?)
}
pub fn digest_algorithms(&self) -> &[AlgorithmIdentifier] {
self.0.signed_data.digest_algorithms.0.0.as_slice()
}
pub fn signer_infos(&self) -> &[SignerInfo] {
&self.0.signed_data.signers_infos.0
}
pub fn encapsulated_content_info(&self) -> &EncapsulatedContentInfo {
&self.0.signed_data.0.content_info
}
pub fn decode_certificates(&self) -> Vec<Cert> {
self.0
.signed_data
.certificates
.0
.0
.iter()
.filter_map(|cert| match cert {
CertificateChoices::Certificate(certificate) => Cert::from_der(&certificate.0).ok(),
CertificateChoices::Other(_) => None,
})
.collect::<Vec<Cert>>()
}
}
impl From<Pkcs7Certificate> for Pkcs7 {
fn from(pkcs7_certificate: Pkcs7Certificate) -> Self {
Pkcs7(pkcs7_certificate)
}
}
impl From<Pkcs7> for Pkcs7Certificate {
fn from(pkcs7: Pkcs7) -> Self {
pkcs7.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pem::parse_pem;
#[test]
fn read_pem_and_parse_certificate() {
let pem = parse_pem(picky_test_data::PKCS7.as_bytes()).unwrap();
Pkcs7::from_pem(&pem).unwrap();
}
}
+182
View File
@@ -0,0 +1,182 @@
use crate::hash::HashAlgorithm;
use crate::x509::certificate::CertError;
use crate::x509::pkcs7::{Pkcs7, Pkcs7Error};
use crate::x509::utils::{from_der, to_der};
use picky_asn1::wrapper::ExplicitContextTag0;
use picky_asn1_x509::oids;
use picky_asn1_x509::pkcs7::content_info::{ContentValue, EncapsulatedContentInfo};
use picky_asn1_x509::pkcs7::signed_data::SignedData;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum TimestampError {
#[error(transparent)]
Asn1DerError(#[from] CertError),
#[error(transparent)]
Pkcs7Error(#[from] Pkcs7Error),
#[error("Timestamp token is empty")]
TimestampTokenEmpty,
#[cfg(feature = "http_timestamp")]
#[error("Failed to decode base64 response")]
Base64DecodeError,
#[cfg(feature = "http_timestamp")]
#[error("Remote Authenticode TSA server responded with `{0}` status code")]
BadResponse(reqwest::StatusCode),
#[cfg(feature = "http_timestamp")]
#[error("Remote Authenticode TSA server response error: {0}")]
RemoteServerResponseError(reqwest::Error),
#[cfg(feature = "http_timestamp")]
#[error("Badly formatted URL")]
BadUrl,
}
pub trait Timestamper: Sized {
fn timestamp(&self, digest: Vec<u8>, hash_algo: HashAlgorithm) -> Result<Pkcs7, TimestampError>; // hash_algo is used in RFC3161
fn modify_signed_data(&self, token: Pkcs7, signed_data: &mut SignedData);
}
#[cfg(feature = "http_timestamp")]
pub mod http_timestamp {
use super::*;
use base64::engine::{Engine as _, general_purpose};
use picky_asn1_x509::pkcs7::signer_info::{UnsignedAttribute, UnsignedAttributeValue};
use reqwest::blocking::Client;
use reqwest::header::{CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE};
use reqwest::{Method, StatusCode, Url};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthenticodeTimestamper {
url: Url,
}
impl AuthenticodeTimestamper {
pub fn new<U: AsRef<str>>(url: U) -> Result<AuthenticodeTimestamper, TimestampError> {
let url = Url::parse(url.as_ref()).map_err(|_| TimestampError::BadUrl)?;
Ok(Self { url })
}
}
impl Timestamper for AuthenticodeTimestamper {
fn timestamp(&self, digest: Vec<u8>, _: HashAlgorithm) -> Result<Pkcs7, TimestampError> {
let timestamp_request = TimestampRequest::new(digest);
let client = Client::new();
let content = general_purpose::STANDARD.encode(timestamp_request.to_der()?);
let request = client
.request(Method::POST, self.url.clone())
.header(CACHE_CONTROL, "no-cache")
.header(CONTENT_TYPE, "application/octet-stream")
.header(CONTENT_LENGTH, content.len())
.body(content)
.build()
.expect("RequestBuilder should not panic");
let response = client
.execute(request)
.map_err(TimestampError::RemoteServerResponseError)?;
if response.status() != StatusCode::OK {
return Err(TimestampError::BadResponse(response.status()));
}
let mut body = response
.bytes()
.map_err(TimestampError::RemoteServerResponseError)?
.to_vec();
body.retain(|&x| x != b'\n' && x != b'\r' && x != b'\0'); // Removing CRLF entries
let der = general_purpose::STANDARD
.decode(body)
.map_err(|_| TimestampError::Base64DecodeError)?;
let token = Pkcs7::from_der(&der).map_err(TimestampError::Pkcs7Error)?;
Ok(token)
}
fn modify_signed_data(&self, token: Pkcs7, signed_data: &mut SignedData) {
let SignedData {
certificates,
signers_infos,
..
} = token.0.signed_data.0;
let singer_info = signers_infos
.0
.first()
.expect("Exactly one SignedInfo should be present");
let unsigned_attribute = UnsignedAttribute {
ty: oids::counter_sign().into(),
value: UnsignedAttributeValue::CounterSign(vec![singer_info.clone()].into()),
};
let signer_info = signed_data
.signers_infos
.0
.0
.first_mut()
.expect("Exactly one SignedInfo should be present");
signer_info.unsigned_attrs.0.0.push(unsigned_attribute);
signed_data.certificates.0.0.extend(certificates.0.0);
}
}
}
const ELEMENT_NAME: &str = "TimestampRequest";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TimestampRequest(picky_asn1_x509::timestamp::TimestampRequest);
impl TimestampRequest {
pub fn new(digest: Vec<u8>) -> TimestampRequest {
Self(picky_asn1_x509::timestamp::TimestampRequest {
countersignature_type: oids::timestamp_request().into(),
content: EncapsulatedContentInfo {
content_type: oids::content_info_type_data().into(),
content: Some(ContentValue::Data(digest.into()).into()),
},
})
}
pub fn content(&self) -> &EncapsulatedContentInfo {
&self.0.content
}
pub fn into_content(self) -> EncapsulatedContentInfo {
self.0.content
}
pub fn digest(&self) -> &[u8] {
if let ExplicitContextTag0(ContentValue::Data(data)) = self.0.content.content.as_ref().unwrap() {
&data.0
} else {
unreachable!()
}
}
pub fn from_der<V: ?Sized + AsRef<[u8]>>(data: &V) -> Result<Self, CertError> {
from_der(data, ELEMENT_NAME).map(Self)
}
pub fn to_der(&self) -> Result<Vec<u8>, CertError> {
to_der(&self.0, ELEMENT_NAME)
}
}
impl From<TimestampRequest> for picky_asn1_x509::timestamp::TimestampRequest {
fn from(tr: TimestampRequest) -> picky_asn1_x509::timestamp::TimestampRequest {
tr.0
}
}
impl From<picky_asn1_x509::timestamp::TimestampRequest> for TimestampRequest {
fn from(tr: picky_asn1_x509::timestamp::TimestampRequest) -> TimestampRequest {
Self(tr)
}
}
+42
View File
@@ -0,0 +1,42 @@
use serde::{Deserialize, Serialize};
use super::certificate::CertError;
use crate::pem::{Pem, parse_pem};
pub(super) fn from_der<'a, T, V>(data: &'a V, element: &'static str) -> Result<T, CertError>
where
T: Deserialize<'a>,
V: ?Sized + AsRef<[u8]>,
{
picky_asn1_der::from_bytes(data.as_ref()).map_err(|e| CertError::Asn1Deserialization { source: e, element })
}
pub(super) fn from_pem<'a, T: Deserialize<'a>>(
pem: &'a Pem,
valid_pem_labels: &[&str],
element: &'static str,
) -> Result<T, CertError> {
if valid_pem_labels.iter().any(|&label| pem.label() == label) {
from_der(pem.data(), element)
} else {
Err(CertError::InvalidPemLabel {
label: pem.label().to_owned(),
})
}
}
pub(super) fn from_pem_str<T>(pem_str: &str, valid_pem_labels: &[&str], element: &'static str) -> Result<T, CertError>
where
for<'a> T: Deserialize<'a>,
{
let pem = parse_pem(pem_str)?;
from_pem(&pem, valid_pem_labels, element)
}
pub(super) fn to_der<T: Serialize>(val: &T, element: &'static str) -> Result<Vec<u8>, CertError> {
picky_asn1_der::to_vec(val).map_err(|e| CertError::Asn1Serialization { source: e, element })
}
pub(super) fn to_pem<T: Serialize>(val: &T, pem_label: &str, element: &'static str) -> Result<Pem<'static>, CertError> {
Ok(Pem::new(pem_label, to_der(val, element)?))
}
+291
View File
@@ -0,0 +1,291 @@
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
use std::error;
use std::io::{self, BufReader, BufWriter, Write};
use thiserror::Error;
const MINIMUM_BYTES_TO_DECODE: usize = 4 /* WinCertificate::length */ + 2 /* WinCertificate::revision */ + 2 /* WinCertificate::certificate */;
#[derive(Debug, Error)]
pub enum WinCertificateError {
#[error("Revision value is wrong(expected any of {expected}, but {got} got)")]
WrongRevisionValue { expected: String, got: u16 },
#[error("Certificate type is wrong(expected any of {expected}, but {got} got)")]
WrongCertificateType { expected: String, got: u16 },
#[error("Length is wrong({minimum} at least, but {got} got)")]
WrongLength { minimum: usize, got: usize },
#[error("Certificate data is empty")]
CertificateDataIsEmpty,
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Other(#[from] Box<dyn error::Error>),
}
pub type WinCertificateResult<T> = Result<T, WinCertificateError>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WinCertificate {
length: u32,
revision: RevisionType,
certificate_type: CertificateType,
certificate: Vec<u8>,
}
impl WinCertificate {
pub fn decode<V: ?Sized + AsRef<[u8]>>(data: &V) -> WinCertificateResult<Self> {
if data.as_ref().len() < MINIMUM_BYTES_TO_DECODE {
return Err(WinCertificateError::WrongLength {
minimum: MINIMUM_BYTES_TO_DECODE,
got: data.as_ref().len(),
});
}
let mut buffer = BufReader::with_capacity(data.as_ref().len(), data.as_ref());
let length = buffer.read_u32::<LittleEndian>()?;
if length == 0 {
return Err(WinCertificateError::CertificateDataIsEmpty);
}
let revision = RevisionType::try_from(buffer.read_u16::<LittleEndian>()?)?;
let certificate_type = CertificateType::try_from(buffer.read_u16::<LittleEndian>()?)?;
let certificate_length = length as usize - MINIMUM_BYTES_TO_DECODE;
let mut certificate = Vec::with_capacity(certificate_length);
for _ in 0..certificate_length {
certificate.push(buffer.read_u8()?);
}
Ok(Self {
length,
revision,
certificate_type,
certificate,
})
}
pub fn encode(self) -> WinCertificateResult<Vec<u8>> {
let Self {
length,
revision,
certificate_type,
certificate,
} = self;
let padding = (8 - (certificate.len() % 8)) % 8;
let mut buffer = BufWriter::with_capacity(length as usize + padding, Vec::new());
buffer.write_u32::<LittleEndian>(length + padding as u32)?;
buffer.write_u16::<LittleEndian>(revision as u16)?;
buffer.write_u16::<LittleEndian>(certificate_type as u16)?;
buffer.write_all(&certificate)?;
buffer.write_all(&vec![0; padding])?;
buffer
.into_inner()
.map_err(|err| WinCertificateError::Other(Box::new(err) as Box<dyn error::Error>))
}
pub fn from_certificate<V: Into<Vec<u8>>>(certificate: V, certificate_type: CertificateType) -> Self {
let certificate = certificate.into();
Self {
// According to Authenticode_PE.docx WinCertificate::length is set to the WinCertificate::certificate length,
// but if the length does not include other WinCertificate's fields (WinCertificate::revision,
// WinCertificate::certificate_type and WinCertificate::length itself) sizes the signature will invalid.
// MSDN says the same:
// dwLength - Specifies the length, in bytes, of the signature
// (see https://docs.microsoft.com/en-us/windows/win32/api/wintrust/ns-wintrust-win_certificate).
length: (MINIMUM_BYTES_TO_DECODE + certificate.len()) as u32,
revision: RevisionType::WinCertificateRevision20,
certificate_type,
certificate,
}
}
#[inline]
pub fn get_certificate(&self) -> &[u8] {
self.certificate.as_ref()
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
#[repr(u16)]
pub enum RevisionType {
WinCertificateRevision10 = 0x0100,
WinCertificateRevision20 = 0x0200,
}
impl TryFrom<u16> for RevisionType {
type Error = WinCertificateError;
fn try_from(value: u16) -> Result<Self, Self::Error> {
match value {
0x0100 => Ok(RevisionType::WinCertificateRevision10),
0x0200 => Ok(RevisionType::WinCertificateRevision20),
_ => Err(WinCertificateError::WrongRevisionValue {
expected: format!("{:?}", [0x0100, 0x0200]),
got: value,
}),
}
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
#[repr(u16)]
pub enum CertificateType {
WinCertTypeX509 = 0x0001,
WinCertTypePkcsSignedData = 0x0002,
WinCertTypeReserved1 = 0x0003,
WinCertTypePkcs1Sign = 0x0009,
}
impl TryFrom<u16> for CertificateType {
type Error = WinCertificateError;
fn try_from(value: u16) -> Result<Self, Self::Error> {
match value {
0x0001 => Ok(CertificateType::WinCertTypeX509),
0x0002 => Ok(CertificateType::WinCertTypePkcsSignedData),
0x0003 => Ok(CertificateType::WinCertTypeReserved1),
0x0009 => Ok(CertificateType::WinCertTypePkcs1Sign),
_ => Err(WinCertificateError::WrongCertificateType {
expected: format!("{:?}", [0x0001, 0x0002, 0x0003, 0x0009]),
got: value,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const WINCERT_WITH_INVALID_LENGTH: [u8; 8] = [
0x00, 0x00, 0x00, 0x00, // -> WIN_CERTIFICATE::dwLength = 0x00 = 0
0x00, 0x01, // -> WIN_CERTIFICATE::wRevision = 0x0100 = WIN_CERT_REVISION_1_0
0x01,
0x00, // -> WIN_CERTIFICATE::wCertificateType = 0x01 = WIN_CERTIFICATE::WIN_CERT_TYPE_X509
// empty WIN_CERTIFICATE::bCertificate field
];
const WINCERT_WITH_ONE_BYTE_CERTIFICATE: [u8; 16] = [
0x10, 0x00, 0x00, 0x00, // -> WIN_CERTIFICATE::dwLength = 0x10 = 16
0x00, 0x01, // -> WIN_CERTIFICATE::wRevision = 0x0100 = WIN_CERT_REVISION_1_0
0x01, 0x00, // -> WIN_CERTIFICATE::wCertificateType = 0x01 = WIN_CERTIFICATE::WIN_CERT_TYPE_X509
0x01, // -> WIN_CERTIFICATE::bCertificate = bCertificate[0] = 1
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // padding
];
const WINCERT_WITH_TEN_BYTES_CERTIFICATE: [u8; 24] = [
0x18, 0x00, 0x00, 0x00, // -> WIN_CERTIFICATE::dwLength = 0x18 = 24
0x00, 0x02, // -> WIN_CERTIFICATE::wRevision = 0x0200 = WIN_CERT_REVISION_2_0
0x09, 0x00, // -> WIN_CERTIFICATE::wCertificateType = 0x09 = WIN_CERTIFICATE::WinCertTypePkcs1Sign
0x01, 0x20, 0x03, 0x40, 0x05, 0x60, 0x70, 0x08, // -> WIN_CERTIFICATE::bCertificate = bCertificate[10]
0x90, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // padding
];
const WINCERT_WITH_INVALID_REVISION: [u8; 9] = [
0x09, 0x00, 0x00, 0x00, // -> WIN_CERTIFICATE::dwLength = 0x09 = 9
0x00, 0x03, // -> WIN_CERTIFICATE::wRevision = 0x0300(not existing)
0x01, 0x00, // -> WIN_CERTIFICATE::wCertificateType = 0x01 = WIN_CERTIFICATE::WIN_CERT_TYPE_X509
0x01, // -> WIN_CERTIFICATE::bCertificate = bCertificate[0] = 1
];
const WINCERT_WITH_X509_CERTIFICATE: [u8; 136] = [
0x88, 0x00, 0x00, 0x00, // -> WIN_CERTIFICATE::dwLength = 0x80 = 136
0x00, 0x02, // -> WIN_CERTIFICATE::wRevision = 0x0200 = WIN_CERT_REVISION_2_0
0x01, 0x00, // -> WIN_CERTIFICATE::wCertificateType = 0x01 = WIN_CERTIFICATE::WIN_CERT_TYPE_X509
// X509 certificate
0x0B, 0x04, 0x55, 0x03, 0x06, 0x15, 0x30, 0x17, 0x31, 0x74, 0x69, 0x72, 0x6F, 0x69, 0x72, 0x70, 0x41, 0x08,
0x0C, 0x0A, 0x04, 0x55, 0x03, 0x06, 0x0F, 0x30, 0x11, 0x31, 0x6F, 0x72, 0x70, 0x69, 0x6E, 0x44, 0x06, 0x0C,
0x07, 0x04, 0x55, 0x03, 0x06, 0x0D, 0x30, 0x0F, 0x31, 0x6F, 0x72, 0x70, 0x69, 0x6E, 0x44, 0x06, 0x0C, 0x08,
0x04, 0x55, 0x03, 0x06, 0x0D, 0x30, 0x0F, 0x31, 0x41, 0x55, 0x02, 0x13, 0x06, 0x04, 0x55, 0x03, 0x06, 0x09,
0x30, 0x0B, 0x31, 0x97, 0x81, 0x30, 0x00, 0x05, 0x0B, 0x01, 0x01, 0x0D, 0xF7, 0x86, 0x48, 0x86, 0x2A, 0x09,
0x06, 0x0D, 0x30, 0x92, 0x72, 0xDC, 0xE9, 0x6B, 0x13, 0x8F, 0x0D, 0x06, 0xFB, 0xC1, 0xD1, 0x69, 0x97, 0x79,
0x4B, 0xA1, 0x69, 0x63, 0x19, 0x14, 0x02, 0x02, 0x01, 0x02, 0x03, 0xA0, 0xF9, 0x03, 0x82, 0x30, 0x11, 0x06,
0x82, 0x30,
];
#[test]
fn decode_with_invalid_length() {
let decoded = WinCertificate::decode(WINCERT_WITH_INVALID_LENGTH.as_ref());
assert!(decoded.is_err());
}
#[test]
fn decode_wincert_with_one_byte_certificate() {
let decoded = WinCertificate::decode(WINCERT_WITH_ONE_BYTE_CERTIFICATE.as_ref()).unwrap();
pretty_assertions::assert_eq!(decoded.length, 16);
pretty_assertions::assert_eq!(decoded.revision, RevisionType::WinCertificateRevision10);
pretty_assertions::assert_eq!(decoded.certificate_type, CertificateType::WinCertTypeX509);
pretty_assertions::assert_eq!(decoded.certificate, vec![1, 0, 0, 0, 0, 0, 0, 0]);
}
#[test]
fn encode_into_wincert_with_one_byte_certificate() {
let wincert = WinCertificate {
length: 9,
revision: RevisionType::WinCertificateRevision10,
certificate_type: CertificateType::WinCertTypeX509,
certificate: vec![1],
};
let encoded = wincert.encode().unwrap();
assert_eq!(encoded, WINCERT_WITH_ONE_BYTE_CERTIFICATE.to_vec());
}
#[test]
fn decode_wincert_with_ten_bytes_certificate() {
let decoded = WinCertificate::decode(WINCERT_WITH_TEN_BYTES_CERTIFICATE.as_ref()).unwrap();
pretty_assertions::assert_eq!(decoded.length, 24);
pretty_assertions::assert_eq!(decoded.revision, RevisionType::WinCertificateRevision20);
pretty_assertions::assert_eq!(decoded.certificate_type, CertificateType::WinCertTypePkcs1Sign);
pretty_assertions::assert_eq!(
decoded.certificate,
vec![1, 32, 3, 64, 5, 96, 112, 8, 144, 1, 0, 0, 0, 0, 0, 0]
);
}
#[test]
fn encode_into_wincert_with_ten_bytes_certificate() {
let wincert = WinCertificate {
length: 18,
revision: RevisionType::WinCertificateRevision20,
certificate_type: CertificateType::WinCertTypePkcs1Sign,
certificate: vec![1, 32, 3, 64, 5, 96, 112, 8, 144, 1],
};
let encoded = wincert.encode().unwrap();
assert_eq!(encoded, WINCERT_WITH_TEN_BYTES_CERTIFICATE.to_vec());
}
#[test]
fn decode_wincert_with_invalid_revision() {
let decoded = WinCertificate::decode(WINCERT_WITH_INVALID_REVISION.as_ref());
assert!(decoded.is_err());
}
#[test]
fn decode_wincert_with_x509_certificate() {
WinCertificate::decode(WINCERT_WITH_X509_CERTIFICATE.as_ref()).unwrap();
}
#[test]
fn encode_wincert_with_x509_certificate() {
let wincert = WinCertificate {
length: 136,
revision: RevisionType::WinCertificateRevision20,
certificate_type: CertificateType::WinCertTypeX509,
certificate: WINCERT_WITH_X509_CERTIFICATE[8..].to_vec(),
};
let encoded = wincert.encode().unwrap();
pretty_assertions::assert_eq!(encoded, WINCERT_WITH_X509_CERTIFICATE.to_vec());
}
}
+3
View File
@@ -0,0 +1,3 @@
# File auto-generated and managed by Devops
/.github/ @devolutions/devops @devolutions/architecture-maintainers
/.github/dependabot.yml @devolutions/security-managers
+38
View File
@@ -0,0 +1,38 @@
version: 2
updates:
- package-ecosystem: "cargo"
directories:
- "/"
- "/fuzz/"
schedule:
interval: "weekly"
assignees:
- "CBenoit"
open-pull-requests-limit: 3
groups:
crypto:
patterns:
- "md-5"
- "md5"
- "md4"
- "sha1"
- "sha2"
- "sha3"
- "pkcs*"
- "x509-cert"
- "hmac"
- "der"
- "crypto-mac"
- "*tls*"
- "*rand*"
- "picky*"
windows:
patterns:
- "windows*"
- "winapi"
patch:
dependency-type: "production"
update-types:
- "patch"
dev:
dependency-type: "development"
+503
View File
@@ -0,0 +1,503 @@
name: Build Native
on:
workflow_call:
jobs:
preflight:
name: Preflight
runs-on: ubuntu-latest
outputs:
project-version: ${{ steps.get-version.outputs.project-version }}
package-version: ${{ steps.get-version.outputs.package-version }}
steps:
- name: Checkout ${{ github.repository }}
uses: actions/checkout@v6
- name: Get version
id: get-version
shell: pwsh
run: |
$CsprojXml = [Xml] (Get-Content .\ffi\dotnet\Devolutions.Sspi\Devolutions.Sspi.csproj)
$ProjectVersion = $CsprojXml.Project.PropertyGroup.Version | Select-Object -First 1
$PackageVersion = $ProjectVersion -Replace "^(\d+)\.(\d+)\.(\d+).(\d+)$", "`$1.`$2.`$3"
echo "project-version=$ProjectVersion" >> $Env:GITHUB_OUTPUT
echo "package-version=$PackageVersion" >> $Env:GITHUB_OUTPUT
build-native:
name: Native build
runs-on: ${{ matrix.runner }}
needs: preflight
strategy:
fail-fast: false
matrix:
os: [ win, osx, linux, ios, iossimulator, android ]
arch: [ x86, x64, arm, arm64 ]
build: [ debug, release ]
include:
- os: win
runner: windows-2022
- os: osx
runner: macos-14
- os: linux
runner: ubuntu-22.04
- os: ios
runner: macos-14
- os: iossimulator
runner: macos-14
- os: android
runner: ubuntu-22.04
exclude:
- arch: arm
os: win
- arch: arm
os: osx
- arch: arm
os: linux
- arch: arm
os: ios
- arch: x86
os: win
- arch: x86
os: osx
- arch: x86
os: linux
- arch: x86
os: ios
- arch: x64
os: ios
- arch: arm
os: iossimulator
- arch: x86
os: iossimulator
- arch: x64
os: iossimulator
steps:
- name: Checkout ${{ github.repository }}
uses: actions/checkout@v6
- name: Configure Android NDK
uses: Devolutions/actions-public/cargo-android-ndk@v1
if: matrix.os == 'android'
with:
android_api_level: "21"
- name: Install dependencies for rustls
if: runner.os == 'Windows'
shell: pwsh
run: |
choco install ninja nasm
# We need to add the NASM binary folder to the PATH manually.
# We don’t need to do that for ninja.
Write-Output "PATH=$Env:PATH;$Env:ProgramFiles\NASM" >> $Env:GITHUB_ENV
# libclang / LLVM is a requirement for AWS LC.
# https://aws.github.io/aws-lc-rs/requirements/windows.html#libclang--llvm
$VSINSTALLDIR = $(vswhere.exe -latest -requires Microsoft.VisualStudio.Component.VC.Llvm.Clang -property installationPath)
Write-Output "LIBCLANG_PATH=$VSINSTALLDIR\VC\Tools\Llvm\x64\bin" >> $Env:GITHUB_ENV
# Install Visual Studio Developer PowerShell Module for cmdlets such as Enter-VsDevShell
Install-Module VsDevShell -Force
- name: Install dependencies
if: matrix.os == 'ios' || matrix.os == 'iossimulator'
run: cargo install --force --locked bindgen-cli
- name: Setup build environment
shell: pwsh
run: |
if ('${{ matrix.os }}' -Eq 'osx') {
echo "MACOSX_DEPLOYMENT_TARGET=10.10" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
} elseif ('${{ matrix.os }}' -Eq 'ios') {
echo "IPHONEOS_DEPLOYMENT_TARGET=12.1" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
} elseif ('${{ matrix.os }}' -Eq 'iossimulator') {
echo "IPHONESIMULATOR_DEPLOYMENT_TARGET=12.1" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
$SimulatorSdk = xcrun --sdk iphonesimulator --show-sdk-path
echo "SDKROOT=$SimulatorSdk" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
}
- name: Setup LLVM
uses: Devolutions/actions-public/setup-llvm@v1
if: matrix.os == 'linux'
with:
version: "18.1.8"
- name: Setup CBake
uses: Devolutions/actions-public/setup-cbake@v1
if: matrix.os == 'linux'
with:
sysroots: |
- ubuntu-20.04-amd64
- ubuntu-20.04-arm64
cargo_env_scripts: true
- name: Build sspi (${{matrix.os}}-${{matrix.arch}}) (${{matrix.build}})
shell: pwsh
run: |
Set-PSDebug -Trace 1
$BuildType = '${{matrix.build}}'
$DotNetOs = '${{matrix.os}}'
$DotNetArch = '${{matrix.arch}}'
$DotNetRid = '${{matrix.os}}-${{matrix.arch}}'
$RustArch = @{'x64'='x86_64';'arm64'='aarch64';
'x86'='i686';'arm'='armv7'}[$DotNetArch]
$AppleArch = @{'x64'='x86_64';'arm64'='arm64';
'x86'='i386';'arm'='arm'}[$DotNetArch]
$MsvcArch = @{'x64'='x64';'arm64'='arm64'}[$DotNetArch]
$RustPlatform = @{'win'='pc-windows-msvc';
'osx'='apple-darwin';'ios'='apple-ios';'iossimulator'='apple-ios-sim';
'linux'='unknown-linux-gnu';'android'='linux-android'}[$DotNetOs]
$LibPrefix = @{'win'='';'osx'='lib';'ios'='lib';'iossimulator'='lib';
'linux'='lib';'android'='lib'}[$DotNetOs]
$LibSuffix = @{'win'='.dll';'osx'='.dylib';'ios'='.dylib';'iossimulator'='.dylib';
'linux'='.so';'android'='.so'}[$DotNetOs]
$RustTarget = "$RustArch-$RustPlatform"
$TargetFolder = @{'debug'='debug';'release'='ffi-production'}[$BuildType]
if (($DotNetOs -eq 'android') -and ($DotNetArch -eq 'arm')) {
$RustTarget = "armv7-linux-androideabi"
}
if ($DotNetOs -eq 'osx') {
Set-Item "Env:CFLAGS_${RustArch}-apple-darwin" "-arch $AppleArch"
}
rustup target add $RustTarget
if ($DotNetOs -eq 'win') {
Enter-VsDevShell $MsvcArch
$Env:RUSTFLAGS="-C target-feature=+crt-static"
}
$ProjectVersion = '${{ needs.preflight.outputs.project-version }}'
$PackageVersion = '${{ needs.preflight.outputs.package-version }}'
$CargoToml = Get-Content .\ffi\Cargo.toml
$CargoToml = $CargoToml | ForEach-Object {
if ($_.StartsWith("version =")) { "version = `"$PackageVersion`"" } else { $_ }
}
Set-Content -Path .\ffi\Cargo.toml -Value $CargoToml
$CargoArgs = @('build', '-vvv', '-p', 'sspi-ffi', '--target', $RustTarget)
if (-Not ($BuildType -Eq 'debug')) {
$CargoArgs += @('--profile', 'ffi-production')
}
if ($DotNetOs -Eq 'win') {
$CargoArgs += @('--features', 'tsssp')
}
# No pregenerated Android bindings are provided for aws-lc-sys at this time.
# See: https://github.com/aws/aws-lc-rs/tree/main/aws-lc-sys#pregenerated-bindings-availability
# For simplicity, we’re using the ring crypto backend.
if ($DotNetOs -Eq 'android') {
$CargoArgs += @('--no-default-features', '--features', 'scard,ring')
}
if ($DotNetOs -eq 'linux') {
$LinuxArch = @{'x64'='amd64';'arm64'='arm64'}[$DotNetArch]
$Env:SYSROOT_NAME = "ubuntu-20.04-$LinuxArch"
. "$HOME/.cargo/cbake/${RustTarget}-enter.ps1"
$Env:AWS_LC_SYS_CMAKE_BUILDER="true"
}
$CargoCmd = $(@('cargo') + $CargoArgs) -Join ' '
Write-Host $CargoCmd
& cargo $CargoArgs | Out-Host
$OutputLibraryName = "${LibPrefix}sspi$LibSuffix"
$RenamedLibraryName = "${LibPrefix}DevolutionsSspi$LibSuffix"
$OutputLibrary = Join-Path "target" $RustTarget $TargetFolder $OutputLibraryName
$OutputPath = Join-Path "dependencies" "runtimes" $DotNetRid "native"
New-Item -ItemType Directory -Path $OutputPath | Out-Null
Copy-Item $OutputLibrary $(Join-Path $OutputPath $RenamedLibraryName)
# Export variables for symbol collection step
echo "RUST_TARGET=$RustTarget" >> $Env:GITHUB_ENV
echo "TARGET_FOLDER=$TargetFolder" >> $Env:GITHUB_ENV
echo "LIB_PREFIX=$LibPrefix" >> $Env:GITHUB_ENV
echo "DOTNET_OS=$DotNetOs" >> $Env:GITHUB_ENV
echo "DOTNET_RID=$DotNetRid" >> $Env:GITHUB_ENV
- name: Collect debug symbols
if: matrix.build == 'release'
shell: pwsh
run: |
Set-PSDebug -Trace 1
$DotNetOs = $Env:DOTNET_OS
$DotNetRid = $Env:DOTNET_RID
$RustTarget = $Env:RUST_TARGET
$TargetFolder = $Env:TARGET_FOLDER
$LibPrefix = $Env:LIB_PREFIX
$SymbolsPath = Join-Path "symbols" $DotNetRid
New-Item -ItemType Directory -Path $SymbolsPath -Force | Out-Null
$TargetDir = Join-Path "target" $RustTarget $TargetFolder
if ($DotNetOs -eq 'win') {
# Windows: Copy PDB file
$PdbFile = Join-Path $TargetDir "sspi.pdb"
if (Test-Path $PdbFile) {
Copy-Item $PdbFile (Join-Path $SymbolsPath "DevolutionsSspi.pdb")
Write-Host "Copied PDB: $PdbFile"
} else {
Write-Warning "PDB file not found: $PdbFile"
Get-ChildItem $TargetDir -Filter "*.pdb" | ForEach-Object { Write-Host "Found PDB: $($_.FullName)" }
}
} elseif ($DotNetOs -eq 'osx' -or $DotNetOs -eq 'ios' -or $DotNetOs -eq 'iossimulator') {
# macOS/iOS: Generate dSYM bundle using dsymutil
$DylibFile = Join-Path $TargetDir "${LibPrefix}sspi.dylib"
$DsymOutput = Join-Path $SymbolsPath "libDevolutionsSspi.dylib.dSYM"
if (Test-Path $DylibFile) {
& dsymutil $DylibFile -o $DsymOutput
Write-Host "Generated dSYM: $DsymOutput"
} else {
Write-Warning "Dylib file not found: $DylibFile"
}
} elseif ($DotNetOs -eq 'linux' -or $DotNetOs -eq 'android') {
# Linux/Android: Extract debug info into a separate file
$DwpFile = Join-Path $TargetDir "${LibPrefix}sspi.dwp"
$SoFile = Join-Path $TargetDir "${LibPrefix}sspi.so"
$DebugOutput = Join-Path $SymbolsPath "libDevolutionsSspi.so.debug"
if (Test-Path $DwpFile) {
# split-debuginfo=packed may produce a .dwp file
Copy-Item $DwpFile (Join-Path $SymbolsPath "libDevolutionsSspi.so.dwp")
Write-Host "Copied DWP: $DwpFile"
} elseif (Test-Path $SoFile) {
# Extract debug sections from the binary using objcopy
if ($DotNetOs -eq 'android') {
$ObjCopy = Join-Path $Env:ANDROID_NDK_HOME "toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy"
if (-not (Test-Path $ObjCopy)) {
throw "llvm-objcopy not found at $ObjCopy"
}
} else {
$ObjCopy = 'llvm-objcopy'
$ObjCopyCmd = Get-Command $ObjCopy -ErrorAction SilentlyContinue
if (-not $ObjCopyCmd) {
throw "llvm-objcopy not found on PATH"
}
}
Write-Host "Extracting debug info with: $ObjCopy --only-keep-debug"
& $ObjCopy --only-keep-debug $SoFile $DebugOutput 2>&1 | Write-Host
Write-Host "Extracted debug info: $DebugOutput"
$SizeMB = [math]::Round((Get-Item $DebugOutput).Length / 1MB, 2)
Write-Host "Debug file size: $SizeMB MB"
} else {
Write-Warning "No debug symbols found for Linux/Android"
}
}
# List collected symbols
Write-Host "Collected symbols:"
Get-ChildItem -Recurse $SymbolsPath | ForEach-Object { Write-Host $_.FullName }
- name: Strip release binaries
if: matrix.build == 'release'
shell: pwsh
run: |
$DotNetOs = $Env:DOTNET_OS
$DotNetRid = $Env:DOTNET_RID
$LibPrefix = $Env:LIB_PREFIX
$RuntimePath = Join-Path "dependencies" "runtimes" "$DotNetRid" "native"
if ($DotNetOs -eq 'win') {
# Windows: DLL is already separate from PDB, no stripping needed
Write-Host "Windows binaries are already compact (PDB is separate)"
} elseif ($DotNetOs -eq 'osx' -or $DotNetOs -eq 'ios' -or $DotNetOs -eq 'iossimulator') {
$DylibFile = Join-Path $RuntimePath "${LibPrefix}DevolutionsSspi.dylib"
if (Test-Path $DylibFile) {
& strip -Sx $DylibFile
Write-Host "Stripped dylib: $DylibFile"
$SizeMB = [math]::Round((Get-Item $DylibFile).Length / 1MB, 2)
Write-Host "Size after stripping: $SizeMB MB"
}
} elseif ($DotNetOs -eq 'android' -or $DotNetOs -eq 'linux') {
$SoFile = Join-Path $RuntimePath "${LibPrefix}DevolutionsSspi.so"
if (Test-Path $SoFile) {
if ($DotNetOs -eq 'android') {
$StripTool = Join-Path $Env:ANDROID_NDK_HOME "toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip"
} else {
$StripTool = 'llvm-strip'
}
Write-Host "Using strip tool: $StripTool"
& $StripTool --strip-unneeded $SoFile 2>&1 | Write-Host
Write-Host "Stripped .so ($DotNetOs): $SoFile"
$SizeMB = [math]::Round((Get-Item $SoFile).Length / 1MB, 2)
Write-Host "Size after stripping: $SizeMB MB"
}
}
- name: Framework (${{matrix.os}}-${{matrix.arch}}) (${{matrix.build}})
if: matrix.os == 'ios' || matrix.os == 'iossimulator'
shell: pwsh
run: |
$Version = '${{ needs.preflight.outputs.project-version }}'
$ShortVersion = '${{ needs.preflight.outputs.package-version }}'
$BundleName = "libDevolutionsSspi"
$RuntimesDir = Join-Path "dependencies" "runtimes" "${{ matrix.os }}-${{ matrix.arch }}" "native"
$FrameworkDir = Join-Path "$RuntimesDir" "$BundleName.framework"
New-Item -Path $FrameworkDir -ItemType "directory" -Force
$FrameworkExecutable = Join-Path $FrameworkDir $BundleName
Copy-Item -Path (Join-Path "$RuntimesDir" "$BundleName.dylib") -Destination $FrameworkExecutable -Force
$RPathCmd = $(@('install_name_tool', '-id', "@rpath/$BundleName.framework/$BundleName", "$FrameworkExecutable")) -Join ' '
Write-Host $RPathCmd
Invoke-Expression $RPathCmd
[xml] $InfoPlistXml = Get-Content "Info.plist"
Select-Xml -xml $InfoPlistXml -XPath "/plist/dict/key[. = 'CFBundleIdentifier']/following-sibling::string[1]" |
%{
$_.Node.InnerXml = "com.devolutions.sspi"
}
Select-Xml -xml $InfoPlistXml -XPath "/plist/dict/key[. = 'CFBundleExecutable']/following-sibling::string[1]" |
%{
$_.Node.InnerXml = $BundleName
}
Select-Xml -xml $InfoPlistXml -XPath "/plist/dict/key[. = 'CFBundleVersion']/following-sibling::string[1]" |
%{
$_.Node.InnerXml = $Version
}
Select-Xml -xml $InfoPlistXml -XPath "/plist/dict/key[. = 'CFBundleShortVersionString']/following-sibling::string[1]" |
%{
$_.Node.InnerXml = $ShortVersion
}
# Write the plist *without* a BOM
$Encoding = New-Object System.Text.UTF8Encoding($false)
$Writer = New-Object System.IO.StreamWriter((Join-Path $FrameworkDir "Info.plist"), $false, $Encoding)
$InfoPlistXml.Save($Writer)
$Writer.Close()
# .NET XML document inserts two square brackets at the end of the DOCTYPE tag
# It's perfectly valid XML, but we're dealing with plists here and dyld will not be able to read the file
((Get-Content -Path (Join-Path $FrameworkDir "Info.plist") -Raw) -Replace 'PropertyList-1.0.dtd"\[\]', 'PropertyList-1.0.dtd"') | Set-Content -Path (Join-Path $FrameworkDir "Info.plist")
- name: Upload native components
uses: actions/upload-artifact@v7
with:
name: sspi-${{matrix.os}}-${{matrix.arch}}-${{matrix.build}}
path: dependencies/runtimes/${{matrix.os}}-${{matrix.arch}}
- name: Upload debug symbols
if: matrix.build == 'release'
uses: actions/upload-artifact@v7
with:
name: sspi-${{matrix.os}}-${{matrix.arch}}-${{matrix.build}}-symbols
path: symbols/${{matrix.os}}-${{matrix.arch}}
build-universal:
name: Universal Build
runs-on: ubuntu-24.04
needs: [preflight, build-native]
strategy:
fail-fast: false
matrix:
os: [ osx ]
build: [ debug, release ]
steps:
- name: Checkout ${{ github.repository }}
uses: actions/checkout@v6
- name: Setup CCTools
uses: Devolutions/actions-public/setup-cctools@v1
- name: Install Apple strip (cctools)
if: matrix.build == 'release'
shell: pwsh
run: |
$DownloadVersion = "v2025.2.0"
$HostPlatform = "ubuntu-$(lsb_release -rs)"
$HostArch = @{'X64'='x86_64';'ARM64'='aarch64'}['${{ runner.arch }}']
$PackageBaseName = "cctools-$HostArch-$HostPlatform"
$DownloadFile = "${PackageBaseName}.tar.xz"
$BaseDownloadUrl = "https://github.com/awakecoding/llvm-prebuilt/releases/download/$DownloadVersion"
curl -L -o $DownloadFile "${BaseDownloadUrl}/${DownloadFile}"
tar -xf $DownloadFile -C /tmp
sudo mv "/tmp/${PackageBaseName}/bin/strip" /usr/bin/apple-strip
sudo chmod +x /usr/bin/apple-strip
Remove-Item $DownloadFile -Force | Out-Null
- name: Setup LLVM
uses: Devolutions/actions-public/setup-llvm@v1
if: matrix.build == 'release'
with:
version: "18.1.8"
- name: Download native components
uses: actions/download-artifact@v8
with:
path: dependencies/runtimes
- name: Lipo (${{matrix.build}})
shell: pwsh
run: |
Set-Location "dependencies/runtimes"
# No RID for universal binaries, see: https://github.com/dotnet/runtime/issues/53156
$OutputPath = Join-Path "${{ matrix.os }}-universal" "native"
New-Item -ItemType Directory -Path $OutputPath | Out-Null
$Libraries = Get-ChildItem -Recurse -Path "sspi-${{ matrix.os }}-*-${{ matrix.build }}" -Filter "*.dylib" | Foreach-Object { $_.FullName } | Select -Unique
$LipoCmd = $(@('lipo', '-create', '-output', (Join-Path -Path $OutputPath -ChildPath "libDevolutionsSspi.dylib")) + $Libraries) -Join ' '
Write-Host $LipoCmd
Invoke-Expression $LipoCmd
- name: Generate universal dSYM
if: matrix.build == 'release'
shell: pwsh
run: |
Set-PSDebug -Trace 1
$SymbolsPath = Join-Path "symbols" "${{ matrix.os }}-universal"
New-Item -ItemType Directory -Path $SymbolsPath -Force | Out-Null
$DylibFile = Join-Path "dependencies" "runtimes" "${{ matrix.os }}-universal" "native" "libDevolutionsSspi.dylib"
$DsymOutput = Join-Path $SymbolsPath "libDevolutionsSspi.dylib.dSYM"
if (Test-Path $DylibFile) {
& dsymutil $DylibFile -o $DsymOutput
Write-Host "Generated universal dSYM: $DsymOutput"
} else {
Write-Warning "Universal dylib not found: $DylibFile"
}
# List collected symbols
Write-Host "Collected symbols:"
Get-ChildItem -Recurse $SymbolsPath | ForEach-Object { Write-Host $_.FullName }
- name: Strip universal binary
if: matrix.build == 'release'
shell: pwsh
run: |
$DylibFile = Join-Path "dependencies" "runtimes" "${{ matrix.os }}-universal" "native" "libDevolutionsSspi.dylib"
if (Test-Path $DylibFile) {
if (-not (Test-Path "/usr/bin/apple-strip")) {
throw "apple-strip not found at /usr/bin/apple-strip"
}
& /usr/bin/apple-strip -Sx $DylibFile
Write-Host "Stripped universal dylib with: /usr/bin/apple-strip -Sx"
$SizeMB = [math]::Round((Get-Item $DylibFile).Length / 1MB, 2)
Write-Host "Size after stripping: $SizeMB MB"
}
- name: Upload native components
uses: actions/upload-artifact@v7
with:
name: sspi-${{ matrix.os }}-universal-${{ matrix.build }}
path: dependencies/runtimes/${{ matrix.os }}-universal
- name: Upload universal debug symbols
if: matrix.build == 'release'
uses: actions/upload-artifact@v7
with:
name: sspi-${{ matrix.os }}-universal-${{ matrix.build }}-symbols
path: symbols/${{ matrix.os }}-universal
+193
View File
@@ -0,0 +1,193 @@
name: CI
on:
push:
branches:
- master
pull_request:
types: [ opened, synchronize, reopened ]
workflow_dispatch:
jobs:
formatting:
name: Check formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check formatting
shell: pwsh
run: |
Write-Host "Check formatting"
cargo fmt --all -- --check
if ($LastExitCode -eq 1) {
throw "Bad formatting, please run 'cargo +stable fmt --all'"
}
lints:
name: Lints [${{ matrix.os }}]
runs-on: ${{ matrix.runner }}
needs: formatting
strategy:
fail-fast: true
matrix:
os: [ win, osx, linux ]
include:
- os: win
runner: windows-2022
additional-args: --features tsssp
- os: osx
runner: macos-14
- os: linux
runner: ubuntu-latest
steps:
- uses: actions/checkout@v6
# Compiling the ffi module is enough to lint the whole sspi workspace
- name: Check clippy
env:
AWS_LC_SYS_NO_ASM: true
run: cargo clippy --manifest-path ffi/Cargo.toml ${{ matrix.additional-args }} -- -D warnings -D clippy::print_stdout
tests:
name: Tests [${{ matrix.os }}] [${{ matrix.crate-name }}]
runs-on: ${{ matrix.runner }}
needs: formatting
env:
SSPI_RS_IS_RUNNING_TESTS: true
strategy:
fail-fast: true
matrix:
os: [ win, osx, linux ]
manifest:
- Cargo.toml
- ffi/Cargo.toml
- crates/dpapi/Cargo.toml
- crates/winscard/Cargo.toml
- crates/dpapi-pdu/Cargo.toml
- crates/ffi-types/Cargo.toml
- crates/dpapi-core/Cargo.toml
- crates/dpapi-transport/Cargo.toml
- crates/dpapi-native-transport/Cargo.toml
include:
# Map runner per OS
- os: win
runner: windows-2022
- os: osx
runner: macos-14
- os: linux
runner: ubuntu-latest
# Map crate name per manifest
- manifest: Cargo.toml
crate-name: sspi
- manifest: ffi/Cargo.toml
crate-name: sspi-ffi
- manifest: crates/dpapi/Cargo.toml
crate-name: dpapi
- manifest: crates/winscard/Cargo.toml
crate-name: winscard
- manifest: crates/dpapi-pdu/Cargo.toml
crate-name: dpapi-pdu
- manifest: crates/ffi-types/Cargo.toml
crate-name: ffi-types
- manifest: crates/dpapi-core/Cargo.toml
crate-name: dpapi-core
- manifest: crates/dpapi-transport/Cargo.toml
crate-name: dpapi-transport
- manifest: crates/dpapi-native-transport/Cargo.toml
crate-name: dpapi-native-transport
# Per-OS feature overrides for specific manifests.
# `__test-data` enables the `tests/sspi/client_server` suite (gated on
# `all(network_client, __test-data)`), which otherwise never runs in CI.
- os: win
manifest: Cargo.toml
additional-args: --features network_client,dns_resolver,scard,tsssp,__test-data
- os: osx
manifest: Cargo.toml
additional-args: --features network_client,scard,__test-data
- os: linux
manifest: Cargo.toml
additional-args: --features network_client,dns_resolver,scard,__test-data
- os: win
manifest: ffi/Cargo.toml
additional-args: --features tsssp
- os: win
manifest: crates/dpapi/Cargo.toml
additional-args: --features tsssp
steps:
- uses: actions/checkout@v6
- name: Test
env:
AWS_LC_SYS_NO_ASM: true
run: cargo test --manifest-path ${{ matrix.manifest }} ${{ matrix.additional-args }}
miri:
name: Miri FFI tests
runs-on: ubuntu-latest
needs: formatting
env:
SSPI_RS_IS_RUNNING_TESTS: true
steps:
- uses: actions/checkout@v6
# NOTE: Pinned to a specific nightly version for reproducibility.
# Update this manually on a regular basis.
- name: Install nightly toolchain and Miri
run: |
rustup toolchain install nightly-2026-02-11 --component miri
rustup override set nightly-2026-02-11
- name: Test
run: cargo miri test --manifest-path ffi/Cargo.toml --no-default-features --features ring
wasm:
name: WASM target
runs-on: ubuntu-latest
needs: formatting
steps:
- uses: actions/checkout@v6
- name: Prepare runner
run: sudo apt-get install wabt
- name: Check
shell: pwsh
run: ./tools/wasm-testcompile/check.ps1
build-native:
name: Build native
needs: formatting
uses: ./.github/workflows/build-native.yml
success:
name: Success
runs-on: ubuntu-latest
if: ${{ always() }}
needs:
- formatting
- lints
- tests
- miri
- wasm
- build-native
steps:
- name: CI succeeded
id: succeeded
if: ${{ !contains(needs.*.result, 'failure') }}
run: exit 0
- name: CI failed
if: ${{ steps.succeeded.outcome == 'skipped' }}
run: exit 1
+322
View File
@@ -0,0 +1,322 @@
name: Publish NuGet package
on:
workflow_dispatch:
inputs:
dry-run:
description: 'Dry run'
required: true
type: boolean
default: true
version:
description: 'Package version'
default: "latest"
required: true
schedule:
- cron: '21 3 * * 1' # 3:21 AM UTC every Monday
jobs:
preflight:
name: Preflight
runs-on: ubuntu-latest
outputs:
dry-run: ${{ steps.get-dry-run.outputs.dry-run }}
package-version: ${{ steps.info.outputs.package-version }}
steps:
- name: Get dry run
id: get-dry-run
shell: pwsh
run: |
Set-PSDebug -Trace 1
$IsDryRun = '${{ github.event.inputs.dry-run }}' -Eq 'true' -Or '${{ github.event_name }}' -Eq 'schedule'
if ($IsDryRun) {
echo "dry-run=true" >> $Env:GITHUB_OUTPUT
} else {
echo "dry-run=false" >> $Env:GITHUB_OUTPUT
}
- name: Package information
id: info
shell: pwsh
run: |
$PackageVersion = '${{ inputs.version }}'
if ([string]::IsNullOrEmpty($PackageVersion) -or $PackageVersion -eq 'latest') {
$PackageVersion = (Get-Date -Format "yyyy.MM.dd") + ".0"
}
if ($PackageVersion -NotMatch '^\d+\.\d+\.\d+\.\d+$') {
throw "invalid version format: $PackageVersion, expected: 1.2.3.4"
}
echo "package-version=$PackageVersion" >> $Env:GITHUB_OUTPUT
echo "::notice::Version: $PackageVersion"
build-native:
uses: ./.github/workflows/build-native.yml
build-managed:
name: Managed build
runs-on: windows-2022
needs: [ preflight, build-native ]
steps:
- name: Check out ${{ github.repository }}
uses: actions/checkout@v6
- name: Prepare dependencies
shell: pwsh
run: |
New-Item -ItemType Directory -Path "dependencies/runtimes" | Out-Null
- name: Download native components
uses: actions/download-artifact@v8
with:
path: dependencies/runtimes
- name: Rename dependencies
shell: pwsh
run: |
Set-PSDebug -Trace 1
Set-Location "dependencies/runtimes"
$(Get-Item ".\sspi-*-release") | ForEach-Object { Rename-Item $_ $_.Name.Replace("-release", "") }
$(Get-Item ".\sspi-*") | ForEach-Object { Rename-Item $_ $_.Name.Replace("sspi-", "") }
Get-ChildItem * -Recurse
- name: Set package version
shell: pwsh
run: |
$PackageVersion = '${{ needs.preflight.outputs.package-version }}'
$csprojPath = "ffi\dotnet\Devolutions.Sspi\Devolutions.Sspi.csproj"
$csprojContent = Get-Content $csprojPath -Raw
$csprojContent = $csprojContent -Replace '(<Version>).*?(</Version>)', "<Version>$PackageVersion</Version>"
Set-Content -Path $csprojPath -Value $csprojContent -Encoding UTF8
- name: Build sspi (managed)
shell: pwsh
run: |
dotnet build .\ffi\dotnet\Devolutions.Sspi\Devolutions.Sspi.csproj -o package
- name: Upload managed components
uses: actions/upload-artifact@v7
with:
name: sspi-nupkg
path: package/*.nupkg
publish:
name: Publish NuGet package
runs-on: ubuntu-latest
environment: nuget-publish
if: needs.preflight.outputs.dry-run == 'false'
needs:
- preflight
- build-managed
permissions:
id-token: write
steps:
- name: Download NuGet package artifact
uses: actions/download-artifact@v8
with:
name: sspi-nupkg
path: package
- name: NuGet login (OIDC)
uses: NuGet/login@v1
id: nuget-login
with:
user: ${{ secrets.NUGET_BOT_USERNAME }}
- name: Publish to nuget.org
shell: pwsh
run: |
Set-PSDebug -Trace 1
$Files = Get-ChildItem -Recurse package/*.nupkg
foreach ($File in $Files) {
$PushCmd = @(
'dotnet',
'nuget',
'push',
"$File",
'--api-key',
'${{ steps.nuget-login.outputs.NUGET_API_KEY }}',
'--source',
'https://api.nuget.org/v3/index.json',
'--skip-duplicate'
)
Write-Host "Publishing $($File.Name)..."
$PushCmd = $PushCmd -Join ' '
Invoke-Expression $PushCmd
}
package-symbols:
name: Package debug symbols
runs-on: ubuntu-latest
needs: [ preflight, build-native ]
steps:
- name: Download all symbol artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
pattern: sspi-*-release-symbols
- name: Package symbols into zip
shell: pwsh
run: |
Set-PSDebug -Trace 1
$PackageVersion = '${{ needs.preflight.outputs.package-version }}'
$SymbolsDir = "symbols"
New-Item -ItemType Directory -Path $SymbolsDir -Force | Out-Null
# Organize symbols by RID
$SymbolArtifacts = Get-ChildItem -Directory "artifacts" -Filter "sspi-*-release-symbols"
foreach ($Artifact in $SymbolArtifacts) {
# Extract RID from artifact name (e.g., sspi-win-x64-release-symbols -> win-x64)
$Rid = $Artifact.Name -Replace '^sspi-(.+)-release-symbols$', '$1'
$DestDir = Join-Path $SymbolsDir $Rid
New-Item -ItemType Directory -Path $DestDir -Force | Out-Null
# Copy all contents from the artifact
Get-ChildItem -Path $Artifact.FullName -Recurse | ForEach-Object {
if (-not $_.PSIsContainer) {
$RelativePath = $_.FullName.Substring($Artifact.FullName.Length + 1)
$DestPath = Join-Path $DestDir $RelativePath
$DestParent = Split-Path $DestPath -Parent
if (-not (Test-Path $DestParent)) {
New-Item -ItemType Directory -Path $DestParent -Force | Out-Null
}
Copy-Item $_.FullName $DestPath
}
}
}
Write-Host "Symbol directory structure:"
Get-ChildItem -Recurse $SymbolsDir | ForEach-Object { Write-Host $_.FullName }
# Create the zip file
$ZipName = "Devolutions.Sspi.$PackageVersion.symbols.zip"
Compress-Archive -Path "$SymbolsDir/*" -DestinationPath $ZipName -Force
Write-Host "Created symbols archive: $ZipName"
- name: Upload symbols zip
uses: actions/upload-artifact@v7
with:
name: sspi-symbols-zip
path: Devolutions.Sspi.*.symbols.zip
create-release:
name: Create GitHub Release
runs-on: ubuntu-latest
if: needs.preflight.outputs.dry-run == 'false'
needs:
- preflight
- build-managed
- package-symbols
- publish
permissions:
contents: write
steps:
- name: Download NuGet package
uses: actions/download-artifact@v8
with:
name: sspi-nupkg
path: release-assets
- name: Download symbols zip
uses: actions/download-artifact@v8
with:
name: sspi-symbols-zip
path: release-assets
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
shell: pwsh
run: |
Set-PSDebug -Trace 1
$PackageVersion = '${{ needs.preflight.outputs.package-version }}'
$TagName = "v$PackageVersion"
$ReleaseName = "Devolutions.Sspi $PackageVersion"
# Get the list of assets to upload
$Assets = Get-ChildItem -Path "release-assets" -File
Write-Host "Release assets:"
$Assets | ForEach-Object { Write-Host " - $($_.Name)" }
# Build the release notes
$ReleaseNotes = @"
## Devolutions.Sspi $PackageVersion
### NuGet Package
- [Devolutions.Sspi on NuGet.org](https://www.nuget.org/packages/Devolutions.Sspi/$PackageVersion)
### Included Platforms
- Windows (x64, arm64)
- macOS (x64, arm64, universal)
- Linux (x64, arm64)
- iOS device (arm64)
- iOS Simulator (arm64)
- Android (x86, x64, arm, arm64)
### Debug Symbols
The `Devolutions.Sspi.$PackageVersion.symbols.zip` archive contains debug symbols for all platforms:
- Windows: `.pdb` files
- macOS/iOS: `.dSYM` bundles
- Linux/Android: `.debug` or `.dwp` files
"@
# Create the release with assets
$AssetArgs = @()
foreach ($Asset in $Assets) {
$AssetArgs += $Asset.FullName
}
gh release create $TagName `
--title $ReleaseName `
--notes $ReleaseNotes `
--target ${{ github.sha }} `
@AssetArgs
Write-Host "Created release: $ReleaseName with tag $TagName"
notify:
name: Notify failure
runs-on: ubuntu-latest
if: ${{ always() && contains(needs.*.result, 'failure') && github.event_name == 'schedule' }}
needs:
- preflight
- build-native
- build-managed
- package-symbols
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_ARCHITECTURE }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
steps:
- name: Send slack notification
id: slack
uses: slackapi/slack-github-action@v1.26.0
with:
payload: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*${{ github.repository }}* :fire::fire::fire::fire::fire: \n The scheduled build for *${{ github.repository }}* is <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|broken>"
}
}
]
}
+61
View File
@@ -0,0 +1,61 @@
name: Release crates
permissions:
pull-requests: write
contents: write
on:
workflow_dispatch:
push:
branches:
- master
jobs:
# Create a PR with the new versions and changelog, preparing the next release.
open-pr:
name: Open release PR
environment: cratesio-publish
runs-on: ubuntu-latest
concurrency:
group: release-plz-${{ github.ref }}
cancel-in-progress: false
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 512
- name: Run release-plz
id: release-plz
uses: Devolutions/actions-public/release-plz@v1
with:
command: release-pr
git-name: Devolutions Bot
git-email: bot@devolutions.net
github-token: ${{ secrets.DEVOLUTIONSBOT_WRITE_TOKEN }}
# Release unpublished packages.
release:
name: Release crates
environment: cratesio-publish
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write # Required to push the version tag.
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 512
- name: Authenticate with crates.io
id: auth
uses: rust-lang/crates-io-auth-action@v1
- name: Run release-plz
uses: Devolutions/actions-public/release-plz@v1
with:
command: release
registry-token: ${{ steps.auth.outputs.token }}
+405
View File
@@ -0,0 +1,405 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
Properties/
target
**/*.rs.bk
.idea/
.DS_Store
+407
View File
@@ -0,0 +1,407 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [[0.21.3](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.21.2...sspi-v0.21.3)] - 2026-07-16
### <!-- 1 -->Features
- Add format-tagged Username::parts view ([#709](https://github.com/Devolutions/sspi-rs/issues/709)) ([80492e4c61](https://github.com/Devolutions/sspi-rs/commit/80492e4c616f7e475391d9b877759b463733fe82))
Add `UsernameParts`, a format-tagged, borrowed view into a `Username`,
and `Username::parts()` returning it. Each variant only exposes the
fields that are meaningful for its user name format, so a UPN suffix can
no longer be mistaken for a NetBIOS domain: the `UserPrincipalName` arm
has a `suffix` (and the full `upn`) but no "domain" field, while
`DownLevelLogonName` carries an `Option<netbios_domain>` that models
accurately the no-separator case. Matching is exhaustive over the two
Microsoft user name formats.
## [[0.21.2](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.21.1...sspi-v0.21.2)] - 2026-07-15
### <!-- 4 -->Bug Fixes
- Classify UPN keytab principals as NT_ENTERPRISE ([#475](https://github.com/Devolutions/sspi-rs/issues/475)) ([491cf36fea](https://github.com/Devolutions/sspi-rs/commit/491cf36feafb01dac9490842d2472acc3602ffcb))
The keytab credentials branch passed keytab.principal.account_name() to
get_client_principal_name_type, which strips the @suffix of a UPN. Without
the @, the principal was misclassified as NT_PRINCIPAL instead of
NT_ENTERPRISE (MS-KILE 3.3.5.6.1).
- Avoid panic on short SRV record RDATA on macOS ([#706](https://github.com/Devolutions/sspi-rs/issues/706)) ([a4ab1bbf0c](https://github.com/Devolutions/sspi-rs/commit/a4ab1bbf0c1c214e921a3189264885c7c278e909))
## [[0.21.1](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.21.0...sspi-v0.21.1)] - 2026-06-26
### <!-- 1 -->Features
- Add support for SECPKG_ATTR_NAMES ([#676](https://github.com/Devolutions/sspi-rs/issues/676)) ([be6315d475](https://github.com/Devolutions/sspi-rs/commit/be6315d475d7c4ce4c571220e2cfd7cb0881b7c7))
- Add keytab client credentials and standards-compliant SASL/GSSAPI acceptor support ([#681](https://github.com/Devolutions/sspi-rs/issues/681)) ([d95f249622](https://github.com/Devolutions/sspi-rs/commit/d95f2496226d45fd4d02437c0c84afe870d9f510))
Extends the `sspi` Kerberos implementation to interoperate with standards-compliant SASL/GSSAPI (RFC 4752 / RFC 4121) peers by adding keytab-backed client credentials, supporting integrity-only (unsealed) `GSS_Wrap` tokens, and allowing acceptors to validate tickets for multiple configured service principals.
- Support for Kerberos cross realm referral ([#694](https://github.com/Devolutions/sspi-rs/issues/694)) ([797365417f](https://github.com/Devolutions/sspi-rs/commit/797365417f6f75ee175eb0f8eb01d0c0b907d01f))
### <!-- 4 -->Bug Fixes
- Ntlm: NTLM_SSP_NEGOTIATE_KEY_EXCH flag usage ([#670](https://github.com/Devolutions/sspi-rs/issues/670)) ([a26a3692b0](https://github.com/Devolutions/sspi-rs/commit/a26a3692b029c5617f75a6dc618e5c7f78d3bc1b))
- Package list handling ([#677](https://github.com/Devolutions/sspi-rs/issues/677)) ([ee75161bcd](https://github.com/Devolutions/sspi-rs/commit/ee75161bcdce0998b9173701129c0c7d607e1f17))
- Disable `mechListMIC` for NTLM guest logon in negotiate module ([#680](https://github.com/Devolutions/sspi-rs/issues/680)) ([80bdedf387](https://github.com/Devolutions/sspi-rs/commit/80bdedf387da7341b95ff947d3bf68f48a0bc404))
- Read channel bindings on the client authenticate step ([#695](https://github.com/Devolutions/sspi-rs/issues/695)) ([82741f6630](https://github.com/Devolutions/sspi-rs/commit/82741f6630c9b6038064caf8f508ac50e9f59738))
### <!-- 7 -->Build
- Advance RustCrypto pins to the current rc.18/rc.33/rc.10 cluster ([#692](https://github.com/Devolutions/sspi-rs/issues/692)) ([095e3f341c](https://github.com/Devolutions/sspi-rs/commit/095e3f341c74d57daafb015bfb0226188e9e8e18))
- Update RC and crypto dependencies to latest versions ([#699](https://github.com/Devolutions/sspi-rs/issues/699)) ([c5922db0f4](https://github.com/Devolutions/sspi-rs/commit/c5922db0f4921323856a041c5e2987e569e62056))
## [[0.21.0](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.20.1...sspi-v0.21.0)] - 2026-05-06
### <!-- 4 -->Bug Fixes
- [**breaking**] Remove redundant PKCS#1 padding when signing hashes ([#660](https://github.com/Devolutions/sspi-rs/issues/660)) ([9e8a108f02](https://github.com/Devolutions/sspi-rs/commit/9e8a108f02704fdbcab23d0fadb2c0d3f5514c0e))
## [[0.20.1](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.20.0...sspi-v0.20.1)] - 2026-04-29
### <!-- 7 -->Build
- Upgrade picky and RustCrypto dependencies ([#655](https://github.com/Devolutions/sspi-rs/issues/655)) ([26765bbe1f](https://github.com/Devolutions/sspi-rs/commit/26765bbe1f57f490ba1c37703eda8267c3b2dfb6))
## [[0.20.0](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.19.2...sspi-v0.20.0)] - 2026-04-27
### <!-- 4 -->Bug Fixes
- [**breaking**] Remove legacy UTF-16 helpers ([#648](https://github.com/Devolutions/sspi-rs/issues/648)) ([b8f21e1bf3](https://github.com/Devolutions/sspi-rs/commit/b8f21e1bf33eea2f66a1818c3c190bca5be6e568))
- Add KDC connect timeout to prevent 75s hang on unreachable KDC ([#647](https://github.com/Devolutions/sspi-rs/issues/647)) ([abb9ffe16d](https://github.com/Devolutions/sspi-rs/commit/abb9ffe16d86941290466c7a53b56d1f0b738f18))
## [[0.19.2](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.19.1...sspi-v0.19.2)] - 2026-03-26
### <!-- 4 -->Bug Fixes
- Fall back to NTLM when the target SPN is an IP address ([#637](https://github.com/Devolutions/sspi-rs/issues/637)) ([67dbac66cc](https://github.com/Devolutions/sspi-rs/commit/67dbac66cc98cb4fcec9864f345290e52745122e))
## [[0.19.1](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.19.0...sspi-v0.19.1)] - 2026-03-23
### <!-- 4 -->Bug Fixes
- Improve NTLM fallback ([#627](https://github.com/Devolutions/sspi-rs/issues/627)) ([88bb31796c](https://github.com/Devolutions/sspi-rs/commit/88bb31796c9bfb0d248b6e32eedbd166d8603679))
Improved SPNEGO/Negotiate authentication resilience by introducing
an explicit Kerberos-first attempt with a controlled fallback to NTLM for
a defined set of Kerberos failure conditions (per the referenced FreeRDP
behavior), and updates the Kerberos/SPNEGO token flow to support
that behavior reliably.
- Handle ISC_REQ_USE_DCE_STYLE and SECBUFFER_READONLY in EncryptMessage ([#629](https://github.com/Devolutions/sspi-rs/issues/629)) ([93e962be78](https://github.com/Devolutions/sspi-rs/commit/93e962be78d6b43bf41e9e30d4935903786602c7))
Adjusts the NTLM SSPI implementation to better interoperate with RPC/FreeRDP over RD Gateway by (a) treating `ISC_REQ_USE_DCE_STYLE` as implying sealing/signing, and (b) allowing `EncryptMessage` to operate in sign-only mode when all `SECBUFFER_DATA` buffers are marked `SECBUFFER_READONLY`.
## [[0.19.0](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.18.9...sspi-v0.19.0)] - 2026-03-13
### <!-- 1 -->Features
- [**breaking**] Introduce UTF-16 strings helpers and refactor auth identity types ([#606](https://github.com/Devolutions/sspi-rs/issues/606)) ([1bfee5e545](https://github.com/Devolutions/sspi-rs/commit/1bfee5e545c4a97ae631fe734b3bcf45b4b486f9))
## [[0.18.9](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.18.8...sspi-v0.18.9)] - 2026-03-06
### <!-- 1 -->Features
- Support multiple credential candidates ([#611](https://github.com/Devolutions/sspi-rs/issues/611)) ([2ded76b767](https://github.com/Devolutions/sspi-rs/commit/2ded76b76734110d45de2270d3c782c561fe54d1))
Enable the CredSSP server-side NTLM flow to authenticate against a bounded set of
concurrently-valid credential candidates for a single user, selecting the one
the client proves knowledge of.
### <!-- 4 -->Bug Fixes
- Include full error source chain in reqwest client build error ([#621](https://github.com/Devolutions/sspi-rs/issues/621)) ([3ba4886776](https://github.com/Devolutions/sspi-rs/commit/3ba4886776600f06b1a338b06ed4f828bd001588))
- Remove NTLM password length heuristic ([#619](https://github.com/Devolutions/sspi-rs/issues/619)) ([c607a229da](https://github.com/Devolutions/sspi-rs/commit/c607a229da8f5fd394f4be4c26fe7e5b90805df7))
`compute_ntlm_v2_hash` treats passwords longer than 512 bytes as
pre-computed NT hashes and tries to decode them with
`convert_password_hash`. This silently breaks authentication for any
plaintext password that exceeds that threshold (e.g. long JWT tokens).
## [[0.18.8](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.18.7...sspi-v0.18.8)] - 2026-02-26
### <!-- 1 -->Features
- Improve domain->realm mapping in kerberos backend ([#604](https://github.com/Devolutions/sspi-rs/issues/604)) ([50cff70f0e](https://github.com/Devolutions/sspi-rs/commit/50cff70f0e64df4630320883f3846872a68d7e27))
### <!-- 4 -->Bug Fixes
- Correct Negotiate (SPNEGO) implementation ([#600](https://github.com/Devolutions/sspi-rs/issues/600)) ([c016e18863](https://github.com/Devolutions/sspi-rs/commit/c016e188633b202c1de7fd84ae1f4f7253fe5dd2))
### <!-- 7 -->Build
- Update dependencies
## [[0.18.7](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.18.6...sspi-v0.18.7)] - 2026-01-16
### <!-- 1 -->Features
- NT Hash Authentication ([#585](https://github.com/Devolutions/sspi-rs/issues/585)) ([21b45e16dc](https://github.com/Devolutions/sspi-rs/commit/21b45e16dcee7171cd9ae70df37cee8cdf41df0e))
Adds support for NT hash (pass-the-hash) authentication,
allowing authentication using pre-computed NT hashes instead of
plaintext passwords.
## [[0.18.6](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.18.5...sspi-v0.18.6)] - 2026-01-05
### <!-- 4 -->Bug Fixes
- Accept variable-length MsvAvSingleHost AvPair ([#580](https://github.com/Devolutions/sspi-rs/issues/580)) ([28f8d74b8c](https://github.com/Devolutions/sspi-rs/commit/28f8d74b8c4f982e047f0bc7738498ac217f6553))
Windows 11 Build 26200+ sends an 80-byte MsvAvSingleHost structure
instead of the traditional 48 bytes. Per MS-NLMP specification, fields
after MachineID MUST be ignored on receipt.
This change:
- Changes SingleHost from fixed [u8; 48] to Vec<u8>
- Relaxes validation from == 48 to >= 48 bytes
- Preserves full data for round-trip serialization
Fixes RDP credential injection failures when clients use Windows 11
Build 26200 or later.
## [[0.18.5](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.18.4...sspi-v0.18.5)] - 2025-12-11
### <!-- 4 -->Bug Fixes
- Don't use eprintln on unknown packages ([#558](https://github.com/Devolutions/sspi-rs/issues/558)) ([df6181291c](https://github.com/Devolutions/sspi-rs/commit/df6181291ccf6f9fb2a0a59151b43676663b2d74))
- Use `String::from_utf16_lossy` over `String::from_utf16` to avoid changing the meaning of some buffers in case of invalid UTF-16 input ([#568](https://github.com/Devolutions/sspi-rs/issues/568)) ([a4889f5b1e](https://github.com/Devolutions/sspi-rs/commit/a4889f5b1e802395b09954f5846d1a6433546281))
### <!-- 7 -->Build
- Update dependencies
## [[0.18.4](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.18.3...sspi-v0.18.4)] - 2025-11-18
### <!-- 7 -->Build
- Exclude cryptoki for WASM ([#545](https://github.com/Devolutions/sspi-rs/issues/545)) ([9e4a84a9ee](https://github.com/Devolutions/sspi-rs/commit/9e4a84a9ee07c81da07e799e3e209094c9045c83))
This fixes builds for the wasm32 target.
## [[0.18.3](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.18.2...sspi-v0.18.3)] - 2025-11-07
### <!-- 1 -->Features
- Add `NT_ENTERPRISE` support in server-side Kerberos ([#535](https://github.com/Devolutions/sspi-rs/issues/535)) ([40785e3123](https://github.com/Devolutions/sspi-rs/commit/40785e3123e381180ac9a75e99248a0287fc5d71))
This is needed when want to connect using FQDN instead of down-level logon name.
### <!-- 3 -->Revert
- Implement `Default` for `SmartCardType` ([#534](https://github.com/Devolutions/sspi-rs/issues/534)) ([7280f7a67b](https://github.com/Devolutions/sspi-rs/commit/7280f7a67b100719052ade72333d373bf956541f))
It actually does not make sense to implement Default for SmartCardType.
A user-provided PIN must be set.
Release 0.18.2 was yanked.
### <!-- 4 -->Bug Fixes
- TLS 1.3 support in TSSSP module ([#536](https://github.com/Devolutions/sspi-rs/issues/536)) ([0605cf01f8](https://github.com/Devolutions/sspi-rs/commit/0605cf01f861aca8191e3134fb885bde5eb50506))
* Adds `CipherSuite::TLS13_AES_256_GCM_SHA384` support.
* Fixes TLS packet header validation: TLS 1.3 uses TLS 1.2 version in the packet header.
- Pin leftover pre-release crypto crates ([#538](https://github.com/Devolutions/sspi-rs/issues/538)) ([6fc91fa977](https://github.com/Devolutions/sspi-rs/commit/6fc91fa977d217b04cdce23e66d56a8a398988e3))
The patch version upgrade is not allowed to bring breaking changes, but
this rule doesn't work for an `rc` version. So we should pin the `rc`
versions, to not allow _cargo_ update to a new `rc` automatically.
## [[0.18.2](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.18.1...sspi-v0.18.2)] - 2025-11-04 (Yanked)
### <!-- 1 -->Features
- Implement `Default` for `SmartCardType` ([#532](https://github.com/Devolutions/sspi-rs/issues/532)) ([3555b377e8](https://github.com/Devolutions/sspi-rs/commit/3555b377e8aeb5a8c376b2ecec9dcb1a2fd79560))
## [[0.18.1](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.18.0...sspi-v0.18.1)] - 2025-10-29
### <!-- 7 -->Build
- Bump hickory-resolver from 0.24.4 to 0.25.2 ([#426](https://github.com/Devolutions/sspi-rs/issues/426)) ([59857e66c2](https://github.com/Devolutions/sspi-rs/commit/59857e66c29e9130af2d24e3c7d79858ce1b55cd))
## [[0.18.0](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.17.0...sspi-v0.18.0)] - 2025-10-14
### <!-- 4 -->Bug Fixes
- DH client default parameters: remove leading zero ([#514](https://github.com/Devolutions/sspi-rs/issues/514)) ([8114b570bc](https://github.com/Devolutions/sspi-rs/commit/8114b570bcfa4320b4972c300705a5e93e762965))
- [**breaking**] Async network client returns `!Send` future ([#513](https://github.com/Devolutions/sspi-rs/issues/513)) ([218ddf3e79](https://github.com/Devolutions/sspi-rs/commit/218ddf3e792580caf1f094ff197a3790f61248e5))
### <!-- 7 -->Build
- Bump picky to the latest version ([#516](https://github.com/Devolutions/sspi-rs/issues/516)) ([972e04b153](https://github.com/Devolutions/sspi-rs/commit/972e04b15393acf2028ae7fde0f55d104e8a9294))
- Bump the patch group across 1 directory with 2 updates ([#519](https://github.com/Devolutions/sspi-rs/issues/519)) ([aaa6e78617](https://github.com/Devolutions/sspi-rs/commit/aaa6e78617b17ee0870c3044f2ac6922c6831017))
## [[0.17.0](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.16.1...sspi-v0.17.0)] - 2025-10-06
### <!-- 1 -->Features
- Data sigining using scard (#491) ([6728fb525c](https://github.com/Devolutions/sspi-rs/commit/6728fb525cedc96b395eed5dc4a8ea357b036b36))
### <!-- 4 -->Bug Fixes
- [**breaking**] Move `cert_utils` from `sspi` crate to `ffi` (#507) ([c9337c8f64](https://github.com/Devolutions/sspi-rs/commit/c9337c8f64a6236396242275847989a35db735ac))
- [**breaking**] Fix lifetimes in initialize_security_context_impl (#495) ([370951c1b0](https://github.com/Devolutions/sspi-rs/commit/370951c1b017bfef4276185b374345e8b6b1e532))
### <!-- 7 -->Build
- Bump the windows crates
- Bump the crypto dependencies (#489) ([1ecba764ec](https://github.com/Devolutions/sspi-rs/commit/1ecba764ec3b04e147ae76d018414afa8bec5f88))
## [[0.16.1](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.16.0...sspi-v0.16.1)] - 2025-08-19
### <!-- 1 -->Features
- Add method to set the channel bindings for a session (#479) ([0c0e225fe7](https://github.com/Devolutions/sspi-rs/commit/0c0e225fe7d7ddffc18ad0176deba207edaf3524))
Setting and sending the CBs is described in Sec. 3.1.5.2.1 of the NTLM spec, admittedly in a slightly confusing way, which may seem to suggest that the bindings are somehow part of the CHALLENGE message, but they are not: knowledge of CBs is strictly local to the client, therefore the client should send them in AUTHENTICATE.
## [[0.16.0](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.14...sspi-v0.16.0)] - 2025-07-07
### <!-- 1 -->Features
- Server-side Kerberos implementation (#440) ([943a297edd](https://github.com/Devolutions/sspi-rs/commit/943a297eddad91bf6dfa02bdb53b422453df0ed9))
### <!-- 4 -->Bug Fixes
- Server-side Kerberos fixes (#457) ([27ce28dad5](https://github.com/Devolutions/sspi-rs/commit/27ce28dad5aa490d094b4ea1db5a315ea1478264))
- Kerberos server MIC token generation and validation (#464) ([12fbd706a8](https://github.com/Devolutions/sspi-rs/commit/12fbd706a8e807b4e4ea9b6bb39f4bace60afd9a))
- Kerberos server WRAP token generation and validation (#463) ([4bbe4071c8](https://github.com/Devolutions/sspi-rs/commit/4bbe4071c80172ee2c85552ef1060a65394a45c0))
## [[0.15.14](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.13...sspi-v0.15.14)] - 2025-07-01
### <!-- 7 -->Build
- Update picky-krb to 0.11 (#460) ([5157bee02b](https://github.com/Devolutions/sspi-rs/commit/5157bee02b0383571c726801bda15f0dd9dc7934))
## [[0.15.13](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.12...sspi-v0.15.13)] - 2025-06-23
### <!-- 7 -->Build
- Bump windows-sys from 0.59.0 to 0.60.2 in the windows group across 1 directory (#455) ([5744c8b4b3](https://github.com/Devolutions/sspi-rs/commit/5744c8b4b3aa5a47a2a25e9375434333de769002))
## [[0.15.12](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.11...sspi-v0.15.12)] - 2025-06-20
### <!-- 4 -->Bug Fixes
- Invalid Kerberos token DER encoding (#453) ([0ec3e687dd](https://github.com/Devolutions/sspi-rs/commit/0ec3e687dd28ff95910c79b4781d538cbffb9a80))
The default behavior of serializing a KrbMessage<T> was accidentally changed.
## [[0.15.11](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.10...sspi-v0.15.11)] - 2025-06-11
### <!-- 4 -->Bug Fixes
- Negotiate attempts KDC detection even when Kerberos is disabled (#447) ([c56132c3f8](https://github.com/Devolutions/sspi-rs/commit/c56132c3f8d3b7e957e64577109158511ee3f4b8))
## [[0.15.10](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.9...sspi-v0.15.10)] - 2025-06-10
### <!-- 7 -->Build
- Update picky-krb to 0.10 (#448) ([b8b983d7ae](https://github.com/Devolutions/sspi-rs/commit/b8b983d7aecb8e1c84037d157c2b932668e069b1))
## [[0.15.9](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.8...sspi-v0.15.9)] - 2025-06-05
### <!-- 7 -->Build
- Bump windows-registry from 0.4.0 to 0.5.2 in the windows group across 1 directory (#444) ([9a349f7bdc](https://github.com/Devolutions/sspi-rs/commit/9a349f7bdcfe33658af27420af3dd38a88d773ab))
## [[0.15.8](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.7...sspi-v0.15.8)] - 2025-06-05
### Build
- Migrate from `winreg` to `windows-registry` crate (#441) ([8631235c8a](https://github.com/Devolutions/sspi-rs/commit/8631235c8a3f93e6f4573142101a25210adb49a5))
## [[0.15.7](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.6...sspi-v0.15.7)] - 2025-05-29
### <!-- 4 -->Bug Fixes
- Do not log at info-level return values (#438) ([68d02e410d](https://github.com/Devolutions/sspi-rs/commit/68d02e410dadf0278ed2a109117c71c43920ea4f))
## [[0.15.6](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.5...sspi-v0.15.6)] - 2025-05-27
### <!-- 4 -->Bug Fixes
- Lower info-level logs to debug-level (#436) ([665cb1e1dc](https://github.com/Devolutions/sspi-rs/commit/665cb1e1dc148683779e65f66d1408ddaa911bea))
## [[0.15.5](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.4...sspi-v0.15.5)] - 2025-04-25
### <!-- 1 -->Features
- Add `query_context_session_key` (#417) ([862657a57c](https://github.com/Devolutions/sspi-rs/commit/862657a57c781e348ef5ccafe540511b19148b44))
This addition is an implementation for the SSP API
`QueryContextAttributesEx(SECPKG_ATTR_SESSION_KEY)`. It is required for
protocols such as SMB, and adding it to the `Sspi` trait, enables access
to it across all the SSP packages implemented.
This adds the option for using Kerberos and Negotiate session keys.
## [[0.15.4](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.3...sspi-v0.15.4)] - 2025-03-24
### <!-- 7 -->Build
- Update dependencies
## [[0.15.3](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.2...sspi-v0.15.3)] - 2025-03-12
### <!-- 4 -->Bug Fixes
- Set correct seq number in MIC token (#390) ([69f03c2933](https://github.com/Devolutions/sspi-rs/commit/69f03c2933c1da106b64a565a757666cd4d94bde))
Fixes Kerberos LDAP auth.
The problem was in the invalid sequence number in MIC token.
- NTLM RPC auth (#395) ([34d896c9ce](https://github.com/Devolutions/sspi-rs/commit/34d896c9cee7e0b3e0d5ee3c5002c781d9fc8fbf))
## [[0.15.2](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.1...sspi-v0.15.2)] - 2025-02-27
### <!-- 1 -->Features
- Support `SECBUFFER_READONLY_WITH_CHECKSUM` flag (#357) ([397fd9502d](https://github.com/Devolutions/sspi-rs/commit/397fd9502dc315e4e8e7c4700b6e789c5e7b44c3))
- Add `USE_DCE_STYLE` flag support (#358) ([0f78bccaea](https://github.com/Devolutions/sspi-rs/commit/0f78bccaea7ac5620f83de68d3559f212262c789))
### <!-- 4 -->Bug Fixes
- Kerberos authentication and encryption for RPC and RDP (#372) ([442dfc1382](https://github.com/Devolutions/sspi-rs/commit/442dfc1382033f6f81bb4cd021cca7318cce224e))
## [[0.15.1](https://github.com/Devolutions/sspi-rs/compare/sspi-v0.15.0...sspi-v0.15.1)] - 2025-02-04
### <!-- 1 -->Features
- Add `make_signature` and `verify_signature` to `Sspi` trait (#343) ([040188a34d](https://github.com/Devolutions/sspi-rs/commit/040188a34d5d7b8607825b25a4eb78c25c6b57cc))
### <!-- 4 -->Bug Fixes
- Store session key when using server-side NTLM implementation (#354) ([41d1ca7fed](https://github.com/Devolutions/sspi-rs/commit/41d1ca7fed623759dcc9ff6f28c7558ecfa6fcbd))
+443
View File
@@ -0,0 +1,443 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
rust-version = "1.89"
name = "sspi"
version = "0.21.3"
authors = ["Devolutions Inc. <infos@devolutions.net>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "A Rust implementation of the Security Support Provider Interface (SSPI) API"
homepage = "https://github.com/devolutions/sspi-rs"
readme = "README.md"
keywords = [
"ntlm",
"auth",
"sspi",
"windows",
"kerberos",
]
license = "MIT OR Apache-2.0"
repository = "https://github.com/devolutions/sspi-rs"
resolver = "2"
[features]
__install-crypto-provider = []
__rustls-used = ["dep:rustls"]
__test-data = []
aws-lc-rs = [
"rustls?/aws-lc-rs",
"__install-crypto-provider",
]
default = ["aws-lc-rs"]
dns_resolver = [
"dep:hickory-resolver",
"dep:hickory-proto",
"dep:tokio",
]
network_client = [
"dep:reqwest",
"dep:portpicker",
"dep:rustls-native-certs",
"__rustls-used",
]
ring = [
"rustls?/ring",
"reqwest?/rustls-tls-native-roots",
"__install-crypto-provider",
]
scard = [
"dep:winscard",
"dep:cryptoki",
]
tsssp = [
"dep:rustls",
"__rustls-used",
]
[lib]
name = "sspi"
path = "src/lib.rs"
[[example]]
name = "client"
path = "examples/client.rs"
[[example]]
name = "kerberos"
path = "examples/kerberos.rs"
required-features = ["network_client"]
[[example]]
name = "server"
path = "examples/server.rs"
[[test]]
name = "sspi"
path = "tests/sspi/main.rs"
[dependencies.async-recursion]
version = "1.1"
[dependencies.bitflags]
version = "2.11"
[dependencies.bytemuck]
version = "1.24"
[dependencies.byteorder]
version = "1.5"
[dependencies.cfg-if]
version = "1"
[dependencies.crypto-bigint]
version = "0.7"
features = ["alloc"]
default-features = false
[dependencies.crypto-mac]
version = "0.11"
[dependencies.getrandom]
version = "0.3"
[dependencies.hickory-proto]
version = "0.25"
optional = true
[dependencies.hickory-resolver]
version = "0.25"
optional = true
[dependencies.hmac]
version = "0.13"
[dependencies.md-5]
version = "0.11"
[dependencies.md4]
version = "0.10"
[dependencies.num-derive]
version = "0.4"
[dependencies.num-traits]
version = "0.2"
default-features = true
[dependencies.oid]
version = "0.2"
[dependencies.picky]
version = "^7.0.0-rc.25"
default-features = false
[dependencies.picky-asn1]
version = "0.10"
features = ["time_conversion"]
[dependencies.picky-asn1-der]
version = "0.5"
[dependencies.picky-asn1-x509]
version = "0.15"
features = ["pkcs7"]
[dependencies.picky-krb]
version = "0.12"
[dependencies.portpicker]
version = "0.1"
optional = true
[dependencies.rand]
version = "0.10"
[dependencies.rand_core]
version = "0.10"
[dependencies.reqwest]
version = "0.12"
features = [
"blocking",
"rustls-tls-no-provider",
]
optional = true
default-features = false
[dependencies.rsa]
version = "^0.10.0-rc.18"
default-features = false
[dependencies.rustls]
version = "0.23"
features = [
"logging",
"std",
"tls12",
]
optional = true
default-features = false
[dependencies.rustls-native-certs]
version = "0.8"
optional = true
[dependencies.serde]
version = "1"
[dependencies.sha1]
version = "0.11"
default-features = false
[dependencies.sha2]
version = "0.11"
[dependencies.tokio]
version = "1.52"
features = [
"time",
"rt",
"rt-multi-thread",
]
optional = true
[dependencies.tracing]
version = "0.1"
default-features = true
[dependencies.url]
version = "2.5"
[dependencies.uuid]
version = "1.23"
features = ["v4"]
default-features = true
[dependencies.widestring]
version = "1.2"
[dependencies.winscard]
version = "0.3"
optional = true
[dependencies.zeroize]
version = "1.8"
features = ["zeroize_derive"]
[dev-dependencies.base64]
version = "0.22"
[dev-dependencies.cfg-if]
version = "1"
[dev-dependencies.picky]
version = "^7.0.0-rc.25"
features = ["x509"]
default-features = false
[dev-dependencies.proptest]
version = "1.6"
[dev-dependencies.static_assertions]
version = "1"
[dev-dependencies.tracing-subscriber]
version = "0.3"
features = ["env-filter"]
[dev-dependencies.whoami]
version = "2.1"
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies.async-dnssd]
version = "0.5"
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies.curve25519-dalek]
version = "^5.0.0-rc.1"
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies.ed25519-dalek]
version = "^3.0.0-rc.1"
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies.futures]
version = "0.3"
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies.p256]
version = "^0.14.0-rc.14"
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies.p384]
version = "^0.14.0-rc.14"
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies.p521]
version = "^0.14.0-rc.14"
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies.pkcs1]
version = "^0.8.0-rc.4"
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies.primeorder]
version = "^0.14.0-rc.14"
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies.rustcrypto-ff]
version = "^0.14.0-rc.1"
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies.rustcrypto-ff_derive]
version = "^0.14.0-rc.0"
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies.rustcrypto-group]
version = "^0.14.0-rc.1"
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies.tokio]
version = "1.52"
features = [
"time",
"rt",
"rt-multi-thread",
]
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.cryptoki]
version = "0.12"
optional = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.time]
version = "0.3"
features = ["std"]
default-features = false
[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom]
version = "0.3"
features = ["wasm_js"]
[target.'cfg(target_arch = "wasm32")'.dependencies.time]
version = "0.3"
features = ["wasm-bindgen"]
default-features = false
[target.'cfg(target_arch = "wasm32")'.dependencies.uuid]
version = "1.23"
features = ["js"]
default-features = false
[target."cfg(windows)".dependencies.windows]
version = "0.62"
features = [
"Win32_Foundation",
"Win32_System",
"Win32_NetworkManagement_Dns",
"Win32_Security_Cryptography",
]
[target."cfg(windows)".dependencies.windows-registry]
version = "0.6"
[lints.clippy]
as_pointer_underscore = "warn"
as_ptr_cast_mut = "warn"
as_underscore = "warn"
filetype_is_file = "warn"
float_cmp = "warn"
float_cmp_const = "warn"
fn_to_numeric_cast_any = "warn"
get_unwrap = "warn"
lossy_float_literal = "warn"
mem_forget = "warn"
missing_safety_doc = "warn"
mixed_read_write_in_expression = "warn"
multiple_unsafe_ops_per_block = "warn"
needless_raw_strings = "warn"
non_ascii_literal = "warn"
panic = "warn"
ptr_cast_constness = "warn"
rc_mutex = "warn"
suspicious_xor_used_as_pow = "warn"
undocumented_unsafe_blocks = "warn"
unnecessary_safety_comment = "warn"
unused_result_ok = "warn"
[lints.rust]
ambiguous_negative_literals = "warn"
elided_lifetimes_in_paths = "warn"
redundant_lifetimes = "warn"
single_use_lifetimes = "warn"
trivial_numeric_casts = "warn"
unreachable_pub = "warn"
unused_lifetimes = "warn"
unused_qualifications = "warn"
[lints.rust.unexpected_cfgs]
level = "warn"
priority = 0
check-cfg = ["cfg(fuzzing)"]
[profile.ffi-production]
opt-level = "z"
lto = true
codegen-units = 1
split-debuginfo = "packed"
panic = "abort"
inherits = "release"
strip = false
[profile.ffi-production.package.aes]
opt-level = 3
[profile.ffi-production.package.aes-gcm]
opt-level = 3
[profile.ffi-production.package.aes-kw]
opt-level = 3
[profile.ffi-production.package.argon2]
opt-level = 3
[profile.ffi-production.package.cbc]
opt-level = 3
[profile.ffi-production.package.crypto-bigint]
opt-level = 3
[profile.ffi-production.package.ctr]
opt-level = 3
[profile.ffi-production.package.des]
opt-level = 3
[profile.ffi-production.package.hmac]
opt-level = 3
[profile.ffi-production.package.p256]
opt-level = 3
[profile.ffi-production.package.p384]
opt-level = 3
[profile.ffi-production.package.p521]
opt-level = 3
[profile.ffi-production.package.pbkdf2]
opt-level = 3
[profile.ffi-production.package.rsa]
opt-level = 3
[profile.ffi-production.package.sha1]
opt-level = 3
[profile.ffi-production.package.sha2]
opt-level = 3
[profile.ffi-production.package.sha3]
opt-level = 3
+279
View File
@@ -0,0 +1,279 @@
[package]
name = "sspi"
version = "0.21.3"
edition = "2024"
readme = "README.md"
license = "MIT OR Apache-2.0"
homepage = "https://github.com/devolutions/sspi-rs"
repository = "https://github.com/devolutions/sspi-rs"
authors = ["Devolutions Inc. <infos@devolutions.net>"]
description = "A Rust implementation of the Security Support Provider Interface (SSPI) API"
keywords = ["ntlm", "auth", "sspi", "windows", "kerberos"]
rust-version = "1.89"
[workspace]
resolver = "2"
members = [
"ffi",
"ffi/symbol-rename-macro",
"crates/winscard",
"crates/ffi-types",
"crates/dpapi",
"crates/dpapi-core",
"crates/dpapi-pdu",
"crates/dpapi-transport",
"crates/dpapi-native-transport",
"crates/dpapi-fuzzing",
"crates/dpapi-web",
"crates/kdc",
]
exclude = [
"tools/wasm-testcompile",
"tools/dpapi-cli-client",
]
[workspace.lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(fuzzing)'] }
# == Correctness == #
ambiguous_negative_literals = "warn"
# == Style, readability == #
unreachable_pub = "warn"
single_use_lifetimes = "warn"
elided_lifetimes_in_paths = "warn"
unused_lifetimes = "warn"
redundant_lifetimes = "warn"
trivial_numeric_casts = "warn"
unused_qualifications = "warn"
[workspace.lints.clippy]
# == Safer unsafe == #
as_ptr_cast_mut = "warn"
fn_to_numeric_cast_any = "warn"
ptr_cast_constness = "warn"
unnecessary_safety_comment = "warn"
missing_safety_doc = "warn"
as_pointer_underscore = "warn"
undocumented_unsafe_blocks = "warn"
multiple_unsafe_ops_per_block = "warn"
# == Correctness == #
as_underscore = "warn"
filetype_is_file = "warn"
float_cmp_const = "warn"
float_cmp = "warn"
get_unwrap = "warn"
lossy_float_literal = "warn"
mem_forget = "warn"
mixed_read_write_in_expression = "warn"
needless_raw_strings = "warn"
non_ascii_literal = "warn"
panic = "warn"
rc_mutex = "warn"
suspicious_xor_used_as_pow = "warn"
unused_result_ok = "warn"
[workspace.dependencies]
uuid = { version = "1.23", default-features = false }
tracing = { version = "0.1", default-features = false }
bitflags = "2.11"
rand = "0.10"
rand_core = "0.10"
getrandom = "0.3"
cfg-if = "1"
time = { version = "0.3", default-features = false }
sha1 = { version = "0.11", default-features = false }
sha2 = "0.11"
num-derive = "0.4"
num-traits = { version = "0.2", default-features = false }
picky = { version = "=7.0.0-rc.25", default-features = false }
picky-asn1 = "0.10"
picky-asn1-der = "0.5"
picky-asn1-x509 = "0.15"
picky-krb = "0.12"
tokio = "1.52"
ffi-types = { path = "crates/ffi-types" }
winscard = { version = "0.3", path = "crates/winscard" }
dpapi = { version = "0.0.0", path = "crates/dpapi" }
dpapi-core = { version = "0.1.0", path = "crates/dpapi-core" }
dpapi-pdu = { version = "0.1.0", path = "crates/dpapi-pdu" }
dpapi-transport = { version = "0.1.0", path = "crates/dpapi-transport" }
dpapi-native-transport = { version = "0.1.0", path = "crates/dpapi-native-transport" }
rsa = { version = "=0.10.0-rc.18", default-features = false }
base64 = "0.22"
whoami = "2.1"
tracing-subscriber = "0.3"
proptest = "1.6"
serde = "1"
byteorder = "1.5"
crypto-bigint = { version = "0.7", default-features = false, features = ["alloc"] }
hmac = "0.13"
[features]
default = ["aws-lc-rs"]
# Enable reqwest for HTTP requests.
network_client = ["dep:reqwest", "dep:portpicker", "dep:rustls-native-certs", "__rustls-used"]
# KDC discovery by querying DNS records.
dns_resolver = ["dep:hickory-resolver", "dep:hickory-proto","dep:tokio"]
# TSSSP should be used only on Windows as a native CredSSP replacement.
tsssp = ["dep:rustls", "__rustls-used"]
# Turns on Kerberos smart card logon.
scard = ["dep:winscard", "dep:cryptoki"]
# Use AWS LC as TLS cryptography provider.
aws-lc-rs = ["rustls?/aws-lc-rs", "__install-crypto-provider"]
# Use ring as TLS cryptography provider.
ring = ["rustls?/ring", "reqwest?/rustls-tls-native-roots", "__install-crypto-provider"]
# Internal (PRIVATE!) features. Do not rely on these whatsoever. They may disappear at anytime.
__test-data = []
__rustls-used = ["dep:rustls"]
__install-crypto-provider = []
[dependencies]
bitflags.workspace = true
rand.workspace = true
rand_core.workspace = true
getrandom.workspace = true
cfg-if.workspace = true
picky.workspace = true
sha1.workspace = true
sha2.workspace = true
num-derive.workspace = true
num-traits = { workspace = true, default-features = true }
picky-asn1-der.workspace = true
picky-asn1-x509 = { workspace = true, features = ["pkcs7"] }
uuid = { workspace = true, features = ["v4"], default-features = true }
# `rt-multi-thread` feature is required for `tokio::task::block_in_place` function.
tokio = { workspace = true, optional = true, features = ["time", "rt", "rt-multi-thread"] }
winscard = { workspace = true, optional = true }
rsa = { workspace = true }
tracing = { workspace = true, default-features = true }
serde.workspace = true
picky-krb.workspace = true
picky-asn1 = { workspace = true, features = ["time_conversion"] }
byteorder.workspace = true
crypto-bigint.workspace = true
hmac.workspace = true
widestring = "1.2"
url = "2.5"
md-5 = "0.11"
md4 = "0.10"
crypto-mac = "0.11"
oid = "0.2"
reqwest = { version = "0.12", optional = true, default-features = false, features = ["blocking", "rustls-tls-no-provider"] }
hickory-resolver = { version = "0.25", optional = true }
hickory-proto = { version = "0.25", optional = true }
portpicker = { version = "0.1", optional = true }
rustls = { version = "0.23", optional = true, default-features = false, features = ["logging", "std", "tls12"] }
rustls-native-certs = { version = "0.8", optional = true }
zeroize = { version = "1.8", features = ["zeroize_derive"] }
async-recursion = "1.1"
bytemuck = "1.24"
[target.'cfg(windows)'.dependencies]
windows-registry = "0.6"
windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_System", "Win32_NetworkManagement_Dns", "Win32_Security_Cryptography"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
uuid = { workspace = true, features = ["js"]}
getrandom = { workspace = true, features = ["wasm_js"] }
time = { workspace = true, features = ["wasm-bindgen"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
cryptoki = { version = "0.12", optional = true }
time = { workspace = true, features = ["std"] }
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
async-dnssd = "0.5"
futures = "0.3"
tokio = { workspace = true, features = ["time", "rt", "rt-multi-thread"] }
# Pin transitive dependencies
# TODO: Remove when stable versions will be released
# RustCrypto crates
pkcs1 = "=0.8.0-rc.4"
p256 = "=0.14.0-rc.14"
primeorder = "=0.14.0-rc.14"
p384 = "=0.14.0-rc.14"
p521 = "=0.14.0-rc.14"
# "Zero-knowledge Cryptography in Rust" crates
rustcrypto-ff = "=0.14.0-rc.1"
rustcrypto-ff_derive = "=0.14.0-rc.0"
rustcrypto-group = "=0.14.0-rc.1"
# "dalek cryptography" crates
ed25519-dalek = "=3.0.0-rc.1"
curve25519-dalek = "=5.0.0-rc.1"
[dev-dependencies]
base64.workspace = true
static_assertions = "1"
whoami.workspace = true
picky = { workspace = true, features = ["x509"] }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
proptest.workspace = true
cfg-if.workspace = true
[lints]
workspace = true
[[example]]
name = "kerberos"
required-features = ["network_client"]
[profile.ffi-production]
inherits = "release"
strip = false
split-debuginfo = "packed"
lto = true
opt-level = "z"
codegen-units = 1
panic = "abort"
# Optimize for speed the cryptographic libraries.
[profile.ffi-production.package.crypto-bigint]
opt-level = 3
[profile.ffi-production.package.p256]
opt-level = 3
[profile.ffi-production.package.p384]
opt-level = 3
[profile.ffi-production.package.p521]
opt-level = 3
[profile.ffi-production.package.rsa]
opt-level = 3
[profile.ffi-production.package.sha1]
opt-level = 3
[profile.ffi-production.package.sha2]
opt-level = 3
[profile.ffi-production.package.sha3]
opt-level = 3
[profile.ffi-production.package.aes-gcm]
opt-level = 3
[profile.ffi-production.package.aes]
opt-level = 3
[profile.ffi-production.package.aes-kw]
opt-level = 3
[profile.ffi-production.package.argon2]
opt-level = 3
[profile.ffi-production.package.ctr]
opt-level = 3
[profile.ffi-production.package.cbc]
opt-level = 3
[profile.ffi-production.package.des]
opt-level = 3
[profile.ffi-production.package.pbkdf2]
opt-level = 3
[profile.ffi-production.package.hmac]
opt-level = 3
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleExecutable</key>
<string></string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string></string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string></string>
<key>CFBundleShortVersionString</key>
<string>1.0.0.0</string>
<key>CSResourcesFileMapped</key>
<true/>
<key>MinimumOSVersion</key>
<string>12.1</string>
</dict>
</plist>
+202
View File
@@ -0,0 +1,202 @@
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
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+23
View File
@@ -0,0 +1,23 @@
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.
+102
View File
@@ -0,0 +1,102 @@
# sspi-rs
[![](https://docs.rs/sspi/badge.svg)](https://docs.rs/sspi/) [![](https://img.shields.io/crates/v/sspi)](https://crates.io/crates/sspi)
**sspi-rs** is a Rust implementation of [Security Support Provider Interface (SSPI)](https://docs.microsoft.com/en-us/windows/win32/rpc/security-support-provider-interface-sspi-). It ships with platform-independent implementations of [Security Support Providers (SSP)](https://docs.microsoft.com/en-us/windows/win32/rpc/security-support-providers-ssps-), and is able to utilize native Microsoft libraries when ran under Windows.
The purpose of sspi-rs is to clean the original interface from cluttering and provide users with Rust-friendly SSPs for execution under *nix or any other platform that is able to compile Rust.
## Overview
The sspi-rs works in accordance with the MSDN documentation. At the moment, [NT LAN Manager (NTLM)](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-nlmp/b38c36ed-2804-4868-a9ff-8dd3182128e4) is implemented and available for platform independent execution. It is also possible to create your own SSPs by implementing the [`SspiImpl`]() trait.
###### Ease of use
Some SSPI functions tend to be cumbersome, that's why sspi-rs allows to use SSPI in a convenient way by utilizing builders.
## Example
The usage of the SSPs is as simple as creating an instance of the security provider and calling its functions.
Here is an example of acquiring a credentials handle and a timestamp of their validity:
```rust
use sspi::{CredentialUse, Ntlm, Sspi, Username, builders::EmptyInitializeSecurityContext, SecurityBuffer, ClientRequestFlags, DataRepresentation, BufferType, SspiImpl};
fn main() {
let account_name = "example_user";
let computer_name = "example_computer";
let mut ntlm = Ntlm::new();
let username = Username::new(&account_name, Some(&computer_name)).unwrap();
let identity = sspi::AuthIdentity {
username,
password: String::from("example_password").into(),
};
let mut acq_cred_result = ntlm
.acquire_credentials_handle()
.with_credential_use(CredentialUse::Outbound)
.with_auth_data(&identity)
.execute()
.unwrap();
let mut output_buffer = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)];
// first time calling initialize_security_context, the input buffer should be empty
let mut input_buffer = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)];
// create a builder for the first call to initialize_security_context
// the target should start with the protocol name, e.g. "HTTP/example.com" or "LDAP/example.com"
let mut builder = EmptyInitializeSecurityContext::<<Ntlm as SspiImpl>::CredentialsHandle>::new()
.with_credentials_handle(&mut acq_cred_result.credentials_handle)
.with_context_requirements(ClientRequestFlags::CONFIDENTIALITY | ClientRequestFlags::ALLOCATE_MEMORY)
.with_target_data_representation(DataRepresentation::Native)
.with_target_name("LDAP/example.com")
.with_input(&mut input_buffer)
.with_output(&mut output_buffer);
// call initialize_security_context
// Note: the initialize_security_context_impl returns a generator, for NTLM,
// this generator will never yield as NTLM requires no network communication to a third party
// but negotiate and kerberos do require network communication, so the generator is used to
// allow the caller to provide the network information through the generator.resume() method
// take a look at the examples/kerberos.rs for more information
let _result = ntlm
.initialize_security_context_impl(&mut builder)
.resolve_to_result()
.unwrap();
// ... exchange your token in output buffer with the server and repeat the process until either server is satisfied or an error is thrown
}
```
Example of acquiring an SSP provided by Windows:
```Rust
let mut negotiate = SecurityPackage::from_package_type(
SecurityPackageType::Other(String::from("Negotiate"))
);
```
## Projects using sspi-rs
* [Devolutions Gateway](https://github.com/Devolutions/devolutions-gateway)
* [IronRDP](https://github.com/Devolutions/IronRDP)
* [Python SSPI Library](https://github.com/jborean93/sspilib)
* [NetExec](https://github.com/Pennyw0rth/NetExec)
* [LDAP client library](https://github.com/kanidm/ldap3/blob/master/proto/examples/sasltest/main.rs)
* [Remote Desktop Manager](https://devolutions.net/remote-desktop-manager/)
(Feel free to open a PR if you know about other projects!)
## License
Licensed under either of:
* [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0)
* [MIT license](http://opensource.org/licenses/MIT)
at your option.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
dual licensed as above, without any additional terms or conditions.
+94
View File
@@ -0,0 +1,94 @@
# Configuration file for git-cliff
[changelog]
trim = false
header = """
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
"""
# https://tera.netlify.app/docs/#introduction
body = """
{% if version -%}
## [[{{ version | trim_start_matches(pat="v") }}]{%- if release_link -%}({{ release_link }}){% endif %}] - {{ timestamp | date(format="%Y-%m-%d") }}
{%- else -%}
## [Unreleased]
{%- endif %}
{% for group, commits in commits | group_by(attribute="group") -%}
### {{ group | upper_first }}
{%- for commit in commits %}
{%- set message = commit.message | upper_first %}
{%- if commit.breaking %}
{%- set breaking = "[**breaking**] " %}
{%- else %}
{%- set breaking = "" %}
{%- endif %}
{%- set short_sha = commit.id | truncate(length=10, end="") %}
{%- set commit_url = "https://github.com/Devolutions/sspi-rs/commit/" ~ commit.id %}
{%- set commit_link = "[" ~ short_sha ~ "](" ~ commit_url ~ ")" %}
- {{ breaking }}{{ message }} ({{ commit_link }}) \
{% if commit.body %}\n\n {{ commit.body | replace(from="\n", to="\n ") }}{% endif %}
{%- endfor %}
{% endfor -%}
"""
footer = ""
[git]
conventional_commits = true
filter_unconventional = false
filter_commits = false
date_order = false
protect_breaking_commits = true
sort_commits = "oldest"
commit_preprocessors = [
# Replace issue number with the link
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/Devolutions/sspi-rs/issues/${2}))"},
# Replace commit sha1 with the link
{ pattern = '([a-f0-9]{10})([a-f0-9]{30})', replace = "[${0}](https://github.com/Devolutions/sspi-rs/commit/${1}${2})" }
]
# regex for parsing and grouping commits
# <!-- <NUMBER> --> is a trick to control the section order: https://github.com/orhun/git-cliff/issues/9#issuecomment-914521594
commit_parsers = [
{ message = "^chore", skip = true },
{ message = "^style", skip = true },
{ message = "^refactor", skip = true },
{ message = "^test", skip = true },
{ message = "^ci", skip = true },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ footer = "^[Cc]hangelog: ?ignore", skip = true },
{ message = "(?i)security", group = "<!-- 0 -->Security" },
{ body = "(?i)security", group = "<!-- 0 -->Security" },
{ footer = "^[Ss]ecurity: ?yes", group = "<!-- 0 -->Security" },
{ message = "^feat", group = "<!-- 1 -->Features" },
{ message = "^revert", group = "<!-- 3 -->Revert" },
{ message = "^fix", group = "<!-- 4 -->Bug Fixes" },
{ message = "^perf", group = "<!-- 5 -->Performance" },
{ message = "^doc", group = "<!-- 6 -->Documentation" },
{ message = "^build", group = "<!-- 7 -->Build" },
{ message = "(?i)improve", group = "<!-- 2 -->Improvements" },
{ message = "(?i)adjust", group = "<!-- 2 -->Improvements" },
{ message = "(?i)change", group = "<!-- 2 -->Improvements" },
{ message = ".*", group = "<!-- 99 -->Please Sort" },
]
+2
View File
@@ -0,0 +1,2 @@
msrv = "1.89"
allow-panic-in-tests = true
+179
View File
@@ -0,0 +1,179 @@
// Based on the SSPI client example from MSDN: https://docs.microsoft.com/en-us/windows/win32/secauthn/using-sspi-with-a-windows-sockets-client
// This example works with the server example using SSPI. The client and server examples are designed to work together.
// This example demonstrates initializing an authenticating SSPI session with the NTLM, connecting with a server, establishing
// a secure communication session, receiving and decrypting a message from the server within the secure session.
use std::io;
use std::net::TcpStream;
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
use sspi::{
AuthIdentity, BufferType, ClientRequestFlags, CredentialUse, DataRepresentation, Ntlm, SecurityBuffer,
SecurityBufferRef, SecurityStatus, Sspi, SspiImpl, Username,
};
const IP: &str = "127.0.0.1:8080";
fn main() -> Result<(), io::Error> {
let mut stream = TcpStream::connect(IP).expect("Failed to connect to the server");
println!("Connected to the server.");
let mut ntlm = Ntlm::new();
let account_name = whoami::username().unwrap();
let computer_name = whoami::hostname().unwrap();
let username = Username::new(&account_name, Some(&computer_name)).map_err(io::Error::other)?;
let identity = AuthIdentity {
username,
password: String::from("password").into(),
};
do_authentication(&mut ntlm, &identity, &mut stream).expect("Failed to authenticate connection");
println!("Authenticated!");
let mut trailer = Vec::new();
// By agreement, the server encrypted the message and set the size
// of the trailer block to be just what it needed. decrypt_message
// needs the size of the trailer block.
//
// By agreement, the server placed the trailer at the beginning
// of the message, and the data comes after the trailer.
println!("Receiving the trailer...");
read_message(&mut stream, &mut trailer)?;
let mut data = Vec::new();
println!("Receiving the data...");
read_message(&mut stream, &mut data)?;
println!("Encrypted message: {data:?}");
let mut msg_buffer = vec![
SecurityBufferRef::token_buf(&mut trailer),
SecurityBufferRef::data_buf(&mut data),
];
let _decryption_flags = ntlm.decrypt_message(&mut msg_buffer)?;
println!("Decrypting...");
println!(
"Decrypted message: [{}]",
std::str::from_utf8(msg_buffer[1].data()).unwrap()
);
println!("Communication successfully finished.");
Ok(())
}
fn do_authentication(ntlm: &mut Ntlm, identity: &AuthIdentity, mut stream: &mut TcpStream) -> Result<(), io::Error> {
let mut acq_cred_result = ntlm
.acquire_credentials_handle()
.with_credential_use(CredentialUse::Outbound)
.with_auth_data(identity)
.execute(ntlm)?;
let mut output_buffer = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)];
let username = whoami::username().unwrap();
let mut builder = ntlm
.initialize_security_context()
.with_credentials_handle(&mut acq_cred_result.credentials_handle)
.with_context_requirements(ClientRequestFlags::CONFIDENTIALITY | ClientRequestFlags::ALLOCATE_MEMORY)
.with_target_data_representation(DataRepresentation::Native)
.with_target_name(username.as_str())
.with_output(&mut output_buffer);
let _result = ntlm
.initialize_security_context_impl(&mut builder)?
.resolve_to_result()?;
write_message(&mut stream, &output_buffer[0].buffer)?;
let mut input_buffer = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)];
loop {
output_buffer[0].buffer.clear();
read_message(&mut stream, &mut input_buffer[0].buffer)?;
let mut builder = ntlm
.initialize_security_context()
.with_credentials_handle(&mut acq_cred_result.credentials_handle)
.with_context_requirements(ClientRequestFlags::CONFIDENTIALITY | ClientRequestFlags::ALLOCATE_MEMORY)
.with_target_data_representation(DataRepresentation::Native)
.with_target_name(username.as_str())
.with_input(&mut input_buffer)
.with_output(&mut output_buffer);
let result = ntlm
.initialize_security_context_impl(&mut builder)?
.resolve_to_result()?;
if [SecurityStatus::CompleteAndContinue, SecurityStatus::CompleteNeeded].contains(&result.status) {
println!("Completing the token...");
ntlm.complete_auth_token(&mut output_buffer)?;
}
write_message(&mut stream, &output_buffer[0].buffer)?;
input_buffer[0].buffer.clear();
if ![SecurityStatus::CompleteAndContinue, SecurityStatus::ContinueNeeded].contains(&result.status) {
break;
}
}
Ok(())
}
// By agreement, the message length is read from the first 2 bytes of the message in little-endian order.
pub fn read_message<T: io::Read, A: io::Write>(stream: &mut T, output_buffer: &mut A) -> Result<(), io::Error> {
let msg_len = stream.read_u16::<LittleEndian>()?;
let mut buff = vec![0u8; msg_len as usize];
stream.read_exact(&mut buff)?;
output_buffer.write_all(&buff)?;
println!("Received the buffer [{} bytes]: {:?}", buff.len(), buff);
Ok(())
}
// By agreement, the message length is written in the first 2 bytes of the message in little-endian order.
pub fn write_message<T: io::Write>(stream: &mut T, input_buffer: &[u8]) -> Result<(), io::Error> {
if !input_buffer.is_empty() {
println!("Sending the buffer [{} bytes]: {:?}", input_buffer.len(), input_buffer);
stream.write_u16::<LittleEndian>(input_buffer.len() as u16)?;
stream.write_all(input_buffer)?;
}
Ok(())
}
#[test]
fn buffer_read_correctly() {
let mut msg = vec![0x3, 0x0];
msg.append(&mut b"abc".to_vec());
let mut buffer = Vec::new();
read_message(&mut msg.as_slice(), &mut buffer).unwrap();
assert_eq!(buffer, b"abc".to_vec());
}
#[test]
fn buffer_written_correctly() {
let mut msg = vec![9, 8, 7];
let mut stream = Vec::new();
write_message(&mut stream, &mut msg).unwrap();
assert_eq!(stream, vec![3, 0, 9, 8, 7]);
}
+169
View File
@@ -0,0 +1,169 @@
use std::error::Error;
use base64::Engine;
use reqwest::StatusCode;
use reqwest::header::{
ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, AUTHORIZATION, CONNECTION, CONTENT_LENGTH, HOST, USER_AGENT,
WWW_AUTHENTICATE,
};
use sspi::{
AcquireCredentialsHandleResult, BufferType, ClientRequestFlags, CredentialsBuffers, DataRepresentation,
InitializeSecurityContextResult, Kerberos, KerberosConfig, SecurityBuffer, SecurityStatus, Sspi, SspiImpl,
Username,
};
use tracing_subscriber::prelude::*;
use tracing_subscriber::{EnvFilter, fmt};
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
let kdc_url = std::env::var("SSPI_KDC_URL").expect("missing KDC URL set in SSPI_KDC_URL"); //tcp://ad-compter-name.domain:88
let hostname = std::env::var("SSPI_WINRM_HOST").expect("missing host name set in SSPI_WINRM_HOST"); // winrm_server_name.domain
let username = std::env::var("SSPI_WINRM_USER").expect("missing username set in SSPI_WINRM_USER"); // username@domain
let password = std::env::var("SSPI_WINRM_PASS").expect("missing password set in SSPI_WINRM_PASS");
let auth_method = std::env::var("SSPI_WINRM_AUTH").expect("missing auth METHOD set in SSPI_WINRM_AUTH"); // Negotiate or Kerberos
tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_env("SSPI_LOG_LEVEL"))
.init();
let kerberos_config = KerberosConfig::new(&kdc_url, hostname.clone());
let mut kerberos = Kerberos::new_client_from_config(kerberos_config).unwrap();
let mut acq_creds_handle_result = get_cred_handle(&mut kerberos, username, password);
let mut input_token = String::new();
let mut client = reqwest::blocking::Client::new(); // super IMPORTANT, KEEP-ALIVE the http connection!
loop {
let (output_token, status) = step(
&mut kerberos,
&mut acq_creds_handle_result.credentials_handle,
&input_token,
&hostname,
)?;
if status == SecurityStatus::ContinueNeeded || status == SecurityStatus::Ok {
let (token_from_server, status_code) =
process_authentication(&output_token, &mut client, &auth_method, &hostname)?;
if status_code == StatusCode::OK {
println!("authenticated");
break Ok(());
}
input_token = token_from_server;
} else {
return Err("Having problem continue authentication".into());
}
}
}
pub(crate) fn get_cred_handle(
kerberos: &mut Kerberos,
username: String,
password: String,
) -> AcquireCredentialsHandleResult<Option<CredentialsBuffers>> {
let identity = sspi::AuthIdentity {
username: Username::parse(&username).expect("username is not in the correct format"),
password: password.into(),
};
kerberos
.acquire_credentials_handle()
.with_credential_use(sspi::CredentialUse::Outbound)
.with_auth_data(&identity.into())
.execute(kerberos)
.expect("AcquireCredentialsHandle resulted in error")
}
pub(crate) fn process_authentication(
token_neeeds_to_be_sent: &String,
client: &mut reqwest::blocking::Client,
auth_method: &str,
hostname: &str,
) -> Result<(String, StatusCode), Box<dyn Error + Send + Sync>> {
let server_result = send_http(token_neeeds_to_be_sent, client, hostname, auth_method)?;
if server_result.status() == StatusCode::OK {
return Ok((String::new(), StatusCode::OK));
}
let www_authenticate = server_result
.headers()
.get(WWW_AUTHENTICATE)
.ok_or("expecting www-authentication header from server but not found")?;
let server_token = www_authenticate
.to_str()
.unwrap()
.replace(format!("{auth_method} ").as_str(), "");
Ok((server_token, server_result.status()))
}
pub(crate) fn send_http(
negotiate_token: &String,
client: &mut reqwest::blocking::Client,
hostname: &str,
auth_method: &str,
) -> Result<reqwest::blocking::Response, Box<dyn Error + Send + Sync>> {
let resp = client
.post(format!("http://{hostname}:5985/wsman?PSVersion=7.3.8"))
.header(AUTHORIZATION, format!("{auth_method} {negotiate_token}"))
.header(HOST, format!("{hostname}:5985"))
.header(CONNECTION, "keep-alive")
.header(CONTENT_LENGTH, "0")
.header(
USER_AGENT,
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/118.0",
)
.header(ACCEPT, "*/*")
.header(ACCEPT_ENCODING, "gzip, deflate")
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.9")
.send()?;
Ok(resp)
}
fn step_helper(
kerberos: &mut Kerberos,
cred_handle: &mut <Kerberos as SspiImpl>::CredentialsHandle,
input_buffer: &mut [SecurityBuffer],
output_buffer: &mut [SecurityBuffer],
hostname: &str,
) -> Result<InitializeSecurityContextResult, Box<dyn Error + Send + Sync>> {
let target_name = format!("HTTP/{hostname}");
let mut builder = kerberos
.initialize_security_context()
.with_credentials_handle(cred_handle)
.with_context_requirements(ClientRequestFlags::MUTUAL_AUTH)
.with_target_data_representation(DataRepresentation::Native)
.with_target_name(&target_name)
.with_input(input_buffer)
.with_output(output_buffer);
let result = kerberos
.initialize_security_context_impl(&mut builder)?
.resolve_with_default_network_client()?;
Ok(result)
}
pub fn step(
kerberos: &mut Kerberos,
cred_handle: &mut <Kerberos as SspiImpl>::CredentialsHandle,
input_token: &String,
hostname: &str,
) -> Result<(String, SecurityStatus), Box<dyn Error + Send + Sync>> {
let input_buffer = base64::engine::general_purpose::STANDARD.decode(input_token).unwrap();
let mut secure_input_buffer = vec![SecurityBuffer::new(input_buffer, BufferType::Token)];
let mut secure_output_buffer = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)];
let result = step_helper(
kerberos,
cred_handle,
&mut secure_input_buffer,
&mut secure_output_buffer,
hostname,
)?;
let output_buffer = secure_output_buffer[0].to_owned();
Ok((
base64::engine::general_purpose::STANDARD.encode(output_buffer.buffer),
result.status,
))
}
+159
View File
@@ -0,0 +1,159 @@
// Based on the SSPI server example from MSDN: https://docs.microsoft.com/en-us/windows/win32/secauthn/using-sspi-with-a-windows-sockets-server
// This example works with the client example using SSPI.
// It demonstrates how to connect with a client, establish a secure communication session, and send the client an encrypted message.
use std::io;
use std::net::{TcpListener, TcpStream};
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
use sspi::{
AuthIdentity, BufferType, CredentialUse, DataRepresentation, EncryptionFlags, Ntlm, SecurityBuffer,
SecurityBufferRef, SecurityStatus, ServerRequestFlags, Sspi, SspiImpl, Username,
};
const IP: &str = "127.0.0.1:8080";
fn main() -> Result<(), io::Error> {
let listener = TcpListener::bind(IP).expect("Unable to start the server");
println!("Started the server.");
let (mut stream, _client_addr) = listener.accept()?;
let mut ntlm = Ntlm::new();
let account_name = whoami::username().unwrap();
let computer_name = whoami::hostname().unwrap();
let username = Username::new(&account_name, Some(&computer_name)).map_err(io::Error::other)?;
let identity = AuthIdentity {
username,
password: String::from("password").into(),
};
do_authentication(&mut ntlm, &identity, &mut stream).expect("Failed to authenticate connection");
println!("Authenticated!");
println!("Sending the encrypted message...");
let msg = "This is your server speaking!".to_string();
// By agreement, the server encrypts and sets the size of the
// trailer block to be just what it needed. decrypt_message
// needs the size of the trailer block.
//
// By agreement, the server places the trailer at the beginning
// of the message, and the data comes after the trailer.
let mut token = vec![0u8; ntlm.query_context_sizes()?.security_trailer as usize];
let mut data = msg.as_bytes().to_vec();
let mut msg_buffer = vec![
SecurityBufferRef::token_buf(token.as_mut_slice()),
SecurityBufferRef::data_buf(data.as_mut_slice()),
];
println!("Unencrypted message: [{msg}]");
println!("Encrypting...");
let _result = ntlm.encrypt_message(EncryptionFlags::empty(), &mut msg_buffer)?;
println!("Encrypted message: {:?}", msg_buffer[1].data());
println!("Sending the trailer...");
write_message(&mut stream, msg_buffer[0].data())?;
println!("Sending the data...");
write_message(&mut stream, msg_buffer[1].data())?;
println!("Communication successfully finished.");
Ok(())
}
fn do_authentication(ntlm: &mut Ntlm, identity: &AuthIdentity, mut stream: &mut TcpStream) -> Result<(), sspi::Error> {
let mut acq_cred_result = ntlm
.acquire_credentials_handle()
.with_credential_use(CredentialUse::Inbound)
.with_auth_data(identity)
.execute(ntlm)?;
let mut input_buffer = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)];
let mut output_buffer = vec![SecurityBuffer::new(Vec::new(), BufferType::Token)];
loop {
read_message(&mut stream, &mut input_buffer[0].buffer)?;
let builder = ntlm
.accept_security_context()
.with_credentials_handle(&mut acq_cred_result.credentials_handle)
.with_context_requirements(ServerRequestFlags::ALLOCATE_MEMORY)
.with_target_data_representation(DataRepresentation::Native)
.with_input(&mut input_buffer)
.with_output(&mut output_buffer);
let result = ntlm.accept_security_context_impl(builder)?.resolve_to_result()?;
if [SecurityStatus::CompleteAndContinue, SecurityStatus::CompleteNeeded].contains(&result.status) {
println!("Completing the token...");
ntlm.complete_auth_token(&mut output_buffer)?;
}
write_message(&mut stream, &output_buffer[0].buffer)?;
output_buffer[0].buffer.clear();
input_buffer[0].buffer.clear();
if ![SecurityStatus::CompleteAndContinue, SecurityStatus::ContinueNeeded].contains(&result.status) {
break;
}
}
Ok(())
}
// By agreement, the message length is read from the first 2 bytes of the message in little-endian order.
pub fn read_message<T: io::Read, A: io::Write>(stream: &mut T, output_buffer: &mut A) -> Result<(), io::Error> {
let msg_len = stream.read_u16::<LittleEndian>()?;
let mut buff = vec![0u8; msg_len as usize];
stream.read_exact(&mut buff)?;
output_buffer.write_all(&buff)?;
println!("Received the buffer [{} bytes]: {:?}", buff.len(), buff);
Ok(())
}
// By agreement, the message length is written in the first 2 bytes of the message in little-endian order.
pub fn write_message<T: io::Write>(stream: &mut T, input_buffer: &[u8]) -> Result<(), io::Error> {
if !input_buffer.is_empty() {
println!("Sending the buffer [{} bytes]: {:?}", input_buffer.len(), input_buffer);
stream.write_u16::<LittleEndian>(input_buffer.len() as u16)?;
stream.write_all(input_buffer)?;
}
Ok(())
}
#[test]
fn buffer_read_correctly() {
let mut msg = vec![0x3, 0x0];
msg.append(&mut b"abc".to_vec());
let mut buffer = Vec::new();
read_message(&mut msg.as_slice(), &mut buffer).unwrap();
assert_eq!(buffer, b"abc".to_vec());
}
#[test]
fn buffer_written_correctly() {
let mut msg = vec![9, 8, 7];
let mut stream = Vec::new();
write_message(&mut stream, &mut msg).unwrap();
assert_eq!(stream, vec![3, 0, 9, 8, 7]);
}
+10
View File
@@ -0,0 +1,10 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 22a32450b3cada4bb46952cb5109e3b305e8c756d58d37136a195c3bc3dee4cb # shrinks to account_name = "@", domain_name = "A"
cc 7f7cdbd18aad0a1d970b6ff9ce7fbfaa7e4dabeb0b55cca0c7f7610f8f0e3cbf # shrinks to value = "A@\\.a"
cc f946f1001aacc60322cba7ba063fa386f4e0c6d9497d8144e300f8b4652e378e # shrinks to value = "A\\a\\a"
cc 033e00adafc5d7bc9402013f261b9f7de65938d4ce9f66f2ea4ec1419a0ae286 # shrinks to value = "A@0@a"
+8
View File
@@ -0,0 +1,8 @@
[workspace]
dependencies_update = true
git_release_enable = false
semver_check = true
pr_branch_prefix = "release-plz/"
pr_name = "chore(release): prepare for publishing"
changelog_config = "cliff.toml"
release_commits = "^(feat|docs|fix|build|perf)"

Some files were not shown because too many files have changed in this diff Show More