Skip to content

Support for SecMod and RSA OAEP Wrapping - #53

Open
beurdouche wants to merge 2 commits into
mainfrom
lockstore-rsa-oaep-wrap
Open

Support for SecMod and RSA OAEP Wrapping#53
beurdouche wants to merge 2 commits into
mainfrom
lockstore-rsa-oaep-wrap

Conversation

@beurdouche

Copy link
Copy Markdown
Member

No description provided.

Adds bindings + Rust wrappers for two NSS features that downstream
consumers (notably Mozilla's lockstore) need to talk to external PKCS#11
tokens like the YubiKey PIV applet:

* Runtime module loader: a new `secmod` module exposes
  `SecmodModule::load_user` over `SECMOD_LoadUserModule` /
  `SECMOD_UnloadUserModule` / `SECMOD_DestroyModule`, so callers can
  register a vendor PKCS#11 .so / .dylib at runtime without going
  through the user's `secmod.db`.

* RSA-OAEP-SHA256 wrap/unwrap: two free functions
  `rsa_oaep_sha256_encrypt` / `rsa_oaep_sha256_decrypt` wrap
  `PK11_PubEncrypt` / `PK11_PrivDecrypt` with MGF1-SHA256 and an
  empty label. Useful for wrapping a fresh AES KEK under an RSA
  public key held on a hardware token, then unwrapping later via the
  token's private key.

Also exposes `Slot::find_cert_by_nickname`,
`Slot::find_private_key_for_cert`, and `Certificate::public_key` to
complete the YubiKey-style flow (find cert by label, extract pubkey,
authenticate, decrypt with the matching privkey).

No behaviour change for existing consumers.
Adds three optional fields to `Pkcs11Uri` so callers can locate
specific objects on a token by the standard RFC 7512 query
attributes:

  * `object`  - percent-decoded label (cert/key)
  * `id`      - percent-decoded raw CKA_ID bytes
  * `type`    - object-type discriminator (public, private, cert,
                secret-key, data)

The existing `token`-only parse path is preserved; unknown
attributes continue to be ignored. Two new tests cover the
extended parse and the back-compat empty case.
Comment thread src/pk11_utils.rs
match key {
"token" => token = Some(percent_decode(value)),
"object" => object = Some(percent_decode(value)),
"id" => id = Some(percent_decode(value).into_bytes()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

percent_decode returns a String via from_utf8_lossy, which replaces non-UTF-8 bytes with U+FFFD. For the id attribute this silently corrupts binary CKA_ID values — e.g. id=%FF yields [0xEF, 0xBF, 0xBD] instead of [0xFF].

Per RFC 7512 §2.3, pk11-id is %-encoded arbitrary octets. You need a raw-bytes decode path here.

Suggested change
"id" => id = Some(percent_decode(value).into_bytes()),
"id" => id = Some(percent_decode_bytes(value)),

With a percent_decode_bytes helper that returns Vec<u8> (same loop as percent_decode, but returns result directly without String::from_utf8_lossy). Then percent_decode itself can be built on top:

fn percent_decode(s: &str) -> String {
    String::from_utf8_lossy(&percent_decode_bytes(s)).into_owned()
}

Comment thread src/p11.rs
Comment on lines +239 to +251
/// Find a certificate on this slot by nickname.
#[must_use]
pub fn find_cert_by_nickname(&self, nickname: &str) -> Option<Certificate> {
let c_nickname = std::ffi::CString::new(nickname).ok()?;
let ptr = unsafe {
PK11_FindCertFromNickname(c_nickname.as_ptr().cast_mut(), null_mut())
};
if ptr.is_null() {
None
} else {
Certificate::from_ptr(ptr).ok()
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

PK11_FindCertFromNickname searches across all loaded slots — it does not constrain to self. Placing this on Slot is misleading.

Consider either:

  1. Making this a free function (or a Certificate associated fn), since the slot isn't used.
  2. Documenting that the search is global and the nickname can be prefixed with "tokenname:" to scope it.

Comment thread src/secmod.rs
Comment on lines +108 to +111
// `SECMODModule` is internally synchronised by NSS; the handle can move
// across threads safely once loaded.
unsafe impl Send for SecmodModule {}
unsafe impl Sync for SecmodModule {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

The Send impl is reasonable (ownership transfer). The Sync impl is a stronger claim — it asserts that &SecmodModule is safe to share across threads, meaning name() and slot_count() (reading raw struct fields via unsafe) must be data-race-free.

NSS holds locks for operations, but reading commonName / slotCount fields from Rust doesn't go through NSS locking. If another thread loads/unloads modules while these fields are read, that's a data race.

Unless you can confirm NSS guarantees these fields are immutable after SECMOD_LoadUserModule returns, consider dropping Sync or documenting the invariant.

Comment thread src/secmod.rs
Comment on lines +55 to +58
// unquoted in the module spec; bail early on the rare case.
if name.contains('"') || path_str.contains('"') {
return Err(Error::InvalidInput);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

The " rejection is good. Consider also rejecting backslashes — on Windows, library_path will naturally contain \, and NSS module-spec parsing may interpret \" as an escape.

Suggested change
// unquoted in the module spec; bail early on the rare case.
if name.contains('"') || path_str.contains('"') {
return Err(Error::InvalidInput);
}
if name.contains('"') || name.contains('\\') || path_str.contains('"') || path_str.contains('\\') {
return Err(Error::InvalidInput);
}

Comment thread src/p11.rs
Comment on lines +300 to +307
pub fn rsa_oaep_sha256_encrypt(pubkey: &PublicKey, plaintext: &[u8]) -> Res<Vec<u8>> {
let params = CK_RSA_PKCS_OAEP_PARAMS {
hashAlg: CKM_SHA256.into(),
mgf: CKG_MGF1_SHA256.into(),
source: CKZ_DATA_SPECIFIED.into(),
pSourceData: null_mut(),
ulSourceDataLen: 0,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

The OAEP params construction is duplicated verbatim in rsa_oaep_sha256_decrypt. Consider extracting a small helper:

fn oaep_sha256_params() -> CK_RSA_PKCS_OAEP_PARAMS {
    CK_RSA_PKCS_OAEP_PARAMS {
        hashAlg: CKM_SHA256.into(),
        mgf: CKG_MGF1_SHA256.into(),
        source: CKZ_DATA_SPECIFIED.into(),
        pSourceData: null_mut(),
        ulSourceDataLen: 0,
    }
}

Comment thread src/p11.rs
Comment on lines +70 to +73
pub fn public_key(&self) -> Res<PublicKey> {
let ptr = unsafe { CERT_ExtractPublicKey(**self) };
if ptr.is_null() {
Err(Error::Internal)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

CERT_ExtractPublicKey can return null when the SPKI uses an unsupported algorithm. Error::Internal loses the underlying NSS error code. Consider Error::last_nss_error() for consistency with secstatus_to_res / into_result:

Suggested change
pub fn public_key(&self) -> Res<PublicKey> {
let ptr = unsafe { CERT_ExtractPublicKey(**self) };
if ptr.is_null() {
Err(Error::Internal)
let ptr = unsafe { CERT_ExtractPublicKey(**self) };
if ptr.is_null() {
Err(Error::last_nss_error())
} else {

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good additions — SecMod loading, RSA-OAEP wrapping, and the Pkcs11Uri extensions are well-structured and follow existing codebase patterns. The FFI boundary handling is careful (null checks, loaded field verification, spec-injection guard on ").

One correctness bug: the id attribute percent-decoding goes through String::from_utf8_lossy, which silently corrupts non-UTF-8 binary CKA_IDs. This needs a raw-bytes decode path per RFC 7512.

Also flagged: find_cert_by_nickname on Slot doesn't actually scope to the slot (PK11_FindCertFromNickname is global), and the unsafe impl Sync on SecmodModule warrants verification that the accessed struct fields are immutable post-load. Details in inline comments.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good additions — the SecMod wrapper, RSA-OAEP encrypt/decrypt, and extended Pkcs11Uri parsing are well-structured and follow existing codebase conventions (scoped_ptr, SECItemBorrowed, etc.). The FFI boundary handling is careful throughout.

Two items to address before merge:

  1. Breaking API change on Pkcs11Uri: Adding pub fields to a public struct without #[non_exhaustive] breaks downstream struct-literal construction. This is the right time to add the attribute.

  2. No test coverage for the core new functionality: The RSA-OAEP encrypt/decrypt round-trip, SecmodModule error paths, and new Slot/Certificate methods are all untested. The pk11_utils parser tests are good — the rest of the PR needs similar attention.

I've also noted that Certificate::public_key() can be simplified to a one-liner by leaning on the existing from_ptrinto_result null-handling path, which also addresses the Error::Internal concern from the prior review.

The prior review's points about percent_decode corrupting binary CKA_IDs and find_cert_by_nickname being misleadingly scoped to Slot remain open and valid — I won't repeat them here.

Comment thread src/pk11_utils.rs
Comment on lines 9 to 10
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pkcs11Uri {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Adding three pub fields to Pkcs11Uri is a breaking API change — any downstream code constructing the struct directly (e.g. Pkcs11Uri { token: Some(...) }) will fail to compile. Since this struct is already public without #[non_exhaustive], consider adding it now to prevent future breakage when more RFC 7512 attributes are added.

Suggested change
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pkcs11Uri {
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Pkcs11Uri {

Comment thread src/p11.rs
Comment on lines +70 to +78
pub fn public_key(&self) -> Res<PublicKey> {
let ptr = unsafe { CERT_ExtractPublicKey(**self) };
if ptr.is_null() {
Err(Error::Internal)
} else {
PublicKey::from_ptr(ptr)
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Expanding on the existing comment about Error::Internal: the manual null check is entirely redundant here. PublicKey::from_ptr already calls into_result, which returns Error::last_nss_error() on null. The whole method simplifies to:

Suggested change
pub fn public_key(&self) -> Res<PublicKey> {
let ptr = unsafe { CERT_ExtractPublicKey(**self) };
if ptr.is_null() {
Err(Error::Internal)
} else {
PublicKey::from_ptr(ptr)
}
}
}
pub fn public_key(&self) -> Res<PublicKey> {
unsafe { PublicKey::from_ptr(CERT_ExtractPublicKey(**self)) }
}

Comment thread src/p11.rs
///
/// Returns an NSS error if the public key is not an RSA key, the
/// plaintext is too long, or the underlying token rejects the request.
pub fn rsa_oaep_sha256_encrypt(pubkey: &PublicKey, plaintext: &[u8]) -> Res<Vec<u8>> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

rsa_oaep_sha256_encrypt / rsa_oaep_sha256_decrypt have no tests. At minimum, add a round-trip test that generates an RSA key pair, encrypts a known plaintext, decrypts, and asserts equality. Edge cases worth covering: empty plaintext (valid for OAEP), plaintext at the maximum length boundary, and a mismatched-key failure path.

Certificate::public_key(), Slot::find_cert_by_nickname, Slot::find_private_key_for_cert are also untested, though those may be harder to test without a populated token. Even a #[cfg(test)] smoke test against the internal NSS softtoken would add confidence.

Comment thread src/secmod.rs
}
return Err(Error::Internal);
}
Self::from_ptr(ptr)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

SecmodModule has no tests either. While load_user is difficult to test in CI without a real .so/.dylib, the failure paths are testable: a non-existent library path should return Err, and the name/path validation (embedded ") should reject bad input. For example:

#[test]
fn load_user_rejects_embedded_quote() {
    fixture_init();
    assert!(SecmodModule::load_user("bad\"name", Path::new("/nonexistent.so")).is_err());
}

#[test]
fn load_user_nonexistent_library() {
    fixture_init();
    assert!(SecmodModule::load_user("test", Path::new("/no/such/lib.so")).is_err());
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good feature additions — the SecmodModule wrapper, RSA-OAEP encrypt/decrypt, Certificate::public_key(), and the Pkcs11Uri extensions are well-structured and consistent with existing crate patterns.

The prior two reviews raised several valid points that remain open (CKA_ID binary corruption via from_utf8_lossy, find_cert_by_nickname not scoping to the slot, #[non_exhaustive] on Pkcs11Uri, Certificate::public_key() simplification, unsafe impl Sync justification, missing tests). I won't repeat those — they should be addressed before merge.

Additional observations (new in this review):

  • Recovered key material from rsa_oaep_sha256_decrypt is returned as a plain Vec<u8> with no zeroization guidance. Since this is specifically a key-unwrapping API, callers need to know they're responsible for scrubbing the buffer. At minimum a doc note; ideally a Zeroizing<Vec<u8>> return type.
  • Pkcs11Uri::object_type stores a free-form String but RFC 7512 §2.3 defines a closed set (public, private, cert, secret-key, data). An enum or at least documented valid values would prevent silent misuse.
  • Minor doc inaccuracy in SecmodModule::load_user: the comment says NSS builds the spec string, but this code builds it.

Comment thread src/p11.rs
Comment on lines +333 to +343
///
/// Returns the recovered plaintext; on a token-resident private key the
/// underlying `PK11_PrivDecrypt` performs the operation on the token, so
/// the private key material never leaves the device.
///
/// # Errors
///
/// Returns an NSS error if the private key is not RSA, the ciphertext
/// length doesn't match the key's modulus, OAEP padding verification
/// fails, or the token rejects the request (e.g. not authenticated).
pub fn rsa_oaep_sha256_decrypt(privkey: &PrivateKey, ciphertext: &[u8]) -> Res<Vec<u8>> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Since this is a key-wrapping API, rsa_oaep_sha256_decrypt will typically recover raw cryptographic key material. The returned Vec<u8> is not zeroized on drop — if the caller doesn't scrub it, the key material persists in freed heap memory.

The crate doesn't use zeroize today, so this isn't necessarily blocking, but it's worth either:

  1. Adding a # Security doc section noting the caller's responsibility to zeroize, or
  2. Returning a zeroize::Zeroizing<Vec<u8>> (would add a dependency).

At minimum, document the expectation:

Suggested change
///
/// Returns the recovered plaintext; on a token-resident private key the
/// underlying `PK11_PrivDecrypt` performs the operation on the token, so
/// the private key material never leaves the device.
///
/// # Errors
///
/// Returns an NSS error if the private key is not RSA, the ciphertext
/// length doesn't match the key's modulus, OAEP padding verification
/// fails, or the token rejects the request (e.g. not authenticated).
pub fn rsa_oaep_sha256_decrypt(privkey: &PrivateKey, ciphertext: &[u8]) -> Res<Vec<u8>> {
/// RSA-OAEP-SHA256 decryption symmetric to `rsa_oaep_sha256_encrypt`.
///
/// Returns the recovered plaintext; on a token-resident private key the
/// underlying `PK11_PrivDecrypt` performs the operation on the token, so
/// the private key material never leaves the device.
///
/// # Security
///
/// The returned `Vec` may contain sensitive key material. Callers should
/// zeroize the buffer when it is no longer needed.

Comment thread src/pk11_utils.rs
Comment on lines +19 to 22
/// `data`. Callers use this to pick the right NSS lookup (e.g.
/// `find_cert_by_nickname` vs `find_key_by_nickname`).
pub object_type: Option<String>,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

RFC 7512 §2.3 restricts type to a closed set: public, private, cert, secret-key, data. Storing it as a free-form String means callers can silently pass an invalid type through the parser.

Consider either an enum:

pub enum Pkcs11ObjectType {
    Public,
    Private,
    Cert,
    SecretKey,
    Data,
}

…or at least documenting the valid values in the field doc so callers know what to match on.

Comment thread src/secmod.rs
Comment on lines +42 to +45
/// module-spec string `library="<path>" name="<name>"` and dlopen's
/// the library.
///
/// # Errors

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

The doc comment says "NSS internally builds the module-spec string" — but in fact this code builds the spec string itself. Consider:

Suggested change
/// module-spec string `library="<path>" name="<name>"` and dlopen's
/// the library.
///
/// # Errors
/// `library_path` is the absolute path to the shared library (e.g.
/// `/opt/homebrew/lib/libykcs11.dylib`). The wrapper builds the NSS
/// module-spec string `library="<path>" name="<name>"` and passes it to
/// `SECMOD_LoadUserModule`, which dlopen's the library.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-structured additions — the SecmodModule RAII wrapper, RSA-OAEP encrypt/decrypt, and the Pkcs11Uri extensions all follow existing crate conventions cleanly. The FFI boundary handling is careful throughout (null checks, loaded field verification, spec-injection guard).

The prior two reviews raised several valid points that remain unaddressed: binary CKA_ID corruption via percent_decode, find_cert_by_nickname not actually scoping to the slot, #[non_exhaustive] on Pkcs11Uri, Certificate::public_key() simplification, unsafe impl Sync justification, missing tests, zeroization guidance on decrypt output, and the doc inaccuracy in load_user. I won't repeat those — see the inline threads.

New observations in this review:

  • RFC 7512 query component not stripped before parsing path attributes — a URI with ?pin-source=... will corrupt the last path attribute value. Pre-existing but exacerbated by the expanded parser. Inline suggestion provided.
  • find_private_key_for_cert authentication prerequisite is undocumented — callers need to know slot.authenticate() must precede this call.

Comment thread src/pk11_utils.rs
@@ -53,15 +63,27 @@ pub fn parse(uri: &str) -> Res<Pkcs11Uri> {
let path = uri.strip_prefix("pkcs11:").ok_or(Error::InvalidInput)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

RFC 7512 §2 separates path attributes (;-delimited) from query attributes (&-delimited) with a ?. The parser treats everything after pkcs11: as path, so a URI like pkcs11:token=foo;object=bar?pin-source=... would pollute the last path attribute's value with the query string.

Pre-existing for token, but now that the parser handles more attributes this is more likely to bite callers.

Suggested change
let path = uri.strip_prefix("pkcs11:").ok_or(Error::InvalidInput)?;
let path = uri.strip_prefix("pkcs11:").ok_or(Error::InvalidInput)?;
// RFC 7512 §2: query attributes (pin-source, etc.) follow `?`.
let (path, _query) = path.split_once('?').unwrap_or((path, ""));

Comment thread src/p11.rs
Comment on lines +253 to +254
/// Find the private key associated with a certificate on this slot.
#[must_use]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

PK11_FindPrivateKeyFromCert receives null_mut() for wincx, so if the slot hasn't been authenticated yet this call will silently fail and return None. The commit message describes an "authenticate, decrypt" flow — worth a brief doc note so callers know to call slot.authenticate() first.

Suggested change
/// Find the private key associated with a certificate on this slot.
#[must_use]
/// Find the private key associated with a certificate on this slot.
///
/// The slot must already be authenticated (via [`Slot::authenticate`])
/// before calling this method; otherwise the lookup will fail.
#[must_use]

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-structured additions that follow existing crate conventions (scoped_ptr!, SECItemBorrowed, secstatus_to_res). The FFI boundary handling is careful — the SECMOD_LoadUserModule wrapper correctly checks both the pointer and the loaded field, and the OAEP functions properly size output buffers and propagate NSS errors.

Architectural note: SecmodModule exposes slot_count() but no way to obtain individual Slot handles from the loaded module. For the YubiKey workflow described in the commit message (load module → find slot → find cert → extract pubkey → encrypt), callers currently need to bridge the gap with p11::all_token_slots() and filter by token name. If that's the intended pattern, it works — but a SecmodModule::slots() iterator would make the API more self-contained.

Prior reviews identified several important issues — in particular the percent_decode / CKA_ID correctness bug (CAUTION), the #[non_exhaustive] concern on Pkcs11Uri (WARNING), and the RFC 7512 query-string stripping (WARNING) — that should be addressed before merge.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean additions — the SecmodModule RAII wrapper, RSA-OAEP encrypt/decrypt, Certificate::public_key(), and the Pkcs11Uri parser extensions are well-structured and consistent with existing crate conventions.

Prior reviews raised several important issues that remain open and should be addressed before merge — in particular the binary CKA_ID corruption (CAUTION), #[non_exhaustive] on Pkcs11Uri (WARNING), RFC 7512 query-string stripping (WARNING), and missing test coverage for the core new functionality (WARNING). I won't repeat those.

Additional observation: pk11_utils::build() only emits token=, but Pkcs11Uri now carries object, id, and object_type. A parse→modify→build round-trip silently drops the new fields. Consider either updating build() to accept &Pkcs11Uri and emit all populated attributes, or documenting the limitation.

Comment thread src/lib.rs
err::{Error, IntoResult, PRErrorCode, Res, secstatus_to_res},
ext::{ExtensionHandler, ExtensionHandlerResult, ExtensionWriterResult},
p11::{PrivateKey, PublicKey, SymKey, random, randomize},
p11::{Certificate, PrivateKey, PublicKey, SymKey, random, randomize},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Certificate and SecmodModule are added to the crate-root re-exports, but the two new public functions rsa_oaep_sha256_encrypt / rsa_oaep_sha256_decrypt are not. Callers must use the fully-qualified nss::p11::rsa_oaep_sha256_encrypt(…) path to reach them.

If these are intended as primary API (which the doc comments suggest), re-export them for consistency:

Suggested change
p11::{Certificate, PrivateKey, PublicKey, SymKey, random, randomize},
p11::{Certificate, PrivateKey, PublicKey, SymKey, random, randomize,
rsa_oaep_sha256_encrypt, rsa_oaep_sha256_decrypt},

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant