synta-certificate 0.2.6

X.509 certificate structures for synta ASN.1 library
Documentation
//! Minimal RFC 7512 `pkcs11:` URI parser.
//!
//! Parses the path and query attributes of a PKCS#11 URI into a
//! [`Pkcs11UriAttributes`] struct.  No external crate dependency.
//!
//! Only the attributes consumed by the synta-certificate backends are decoded:
//! - `token` — token label (`CKA_LABEL` of the `CKO_TOKEN`).
//! - `object` — object (key) label (`CKA_LABEL`).
//! - `id` — CKA_ID, percent-encoded hex bytes.
//! - `pin-value` — PIN passed as a query attribute (`?pin-value=…`).
//! - `module-path` — path to the PKCS#11 module `.so` (non-standard, widely
//!   supported extension; silently ignored if absent).

/// Parsed attributes from a `pkcs11:` URI (RFC 7512).
#[derive(Clone, Default)]
pub struct Pkcs11UriAttributes {
    /// Token label (`token=…` path component).
    pub token: Option<String>,
    /// Object (key) label (`object=…` path component).
    pub object: Option<String>,
    /// CKA_ID bytes decoded from the percent-encoded `id=…` path component.
    pub id: Option<Vec<u8>>,
    /// PIN supplied via the `?pin-value=…` query attribute.
    ///
    /// Not directly accessible from outside the crate to prevent accidental
    /// inclusion in logs; use [`Pkcs11UriAttributes::pin_value`] to retrieve it
    /// or [`Pkcs11UriAttributes::has_pin`] to test presence.
    pub(crate) pin_value: Option<String>,
    /// Path to the PKCS#11 module shared library (`module-path=…` path component).
    ///
    /// Non-standard but widely supported.  When present, overrides the system
    /// default module path used by [`Pkcs11Manager`](crate::pkcs11_mgmt::Pkcs11Manager).
    pub module_path: Option<String>,
}

impl Pkcs11UriAttributes {
    /// Return the PIN if one was present in the URI, or `None`.
    pub fn pin_value(&self) -> Option<&str> {
        self.pin_value.as_deref()
    }

    /// Return `true` if the URI included a `pin-value=` query component.
    pub fn has_pin(&self) -> bool {
        self.pin_value.is_some()
    }

    /// Set or clear the PIN.  Used when inheriting credentials from a parent URI.
    pub fn set_pin_value(&mut self, pin: Option<String>) {
        self.pin_value = pin;
    }
}

impl std::fmt::Debug for Pkcs11UriAttributes {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Pkcs11UriAttributes")
            .field("token", &self.token)
            .field("object", &self.object)
            .field("id", &self.id)
            .field("pin_value", &self.pin_value.as_ref().map(|_| "***"))
            .field("module_path", &self.module_path)
            .finish()
    }
}

/// A parsed `pkcs11:` URI — holds both the original string and the decoded
/// attributes so callers never need to re-parse.
///
/// Construct with [`Pkcs11Uri::parse`].  The `raw` field is the verbatim URI
/// and is passed unchanged to backend store APIs (e.g. `OSSL_STORE_open_ex`).
/// The `attrs` field gives structured access to the decoded path and query
/// components without an allocation on each use.
#[derive(Clone)]
pub struct Pkcs11Uri {
    /// The original URI string (e.g. `"pkcs11:token=T;object=k?pin-value=…"`).
    pub raw: String,
    /// Decoded attributes from the URI path and query components.
    pub attrs: Pkcs11UriAttributes,
}

impl std::fmt::Debug for Pkcs11Uri {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Redact `?pin-value=…` from the raw URI to prevent PIN leakage in logs.
        let display = redact_raw_uri(&self.raw);
        f.debug_struct("Pkcs11Uri")
            .field("raw", &display)
            .field("attrs", &self.attrs)
            .finish()
    }
}

/// Replace the `?pin-value=…` portion of a raw PKCS#11 URI string with `?pin-value=***`
/// so the result is safe to include in log output or error messages.
pub(crate) fn redact_raw_uri(uri: &str) -> String {
    if let Some((before, after)) = uri.split_once("pin-value=") {
        let rest = after.find('&').map_or("", |i| &after[i..]);
        format!("{before}pin-value=***{rest}")
    } else {
        uri.to_owned()
    }
}

impl Pkcs11Uri {
    /// Parse a `pkcs11:` URI string.
    ///
    /// Returns `None` if `raw` does not start with `"pkcs11:"`.
    ///
    /// The raw string is stored verbatim; the attributes are decoded once and
    /// cached in `attrs`.
    pub fn parse(raw: impl Into<String>) -> Option<Self> {
        let raw = raw.into();
        let attrs = parse_attrs(&raw)?;
        Some(Self { raw, attrs })
    }
}

/// Parse a `pkcs11:` URI into its path and query attributes.
///
/// Returns `None` if `uri` does not start with `"pkcs11:"`.
///
/// Unrecognised path and query attributes are silently ignored, as required
/// by RFC 7512 §2.1.
///
/// Prefer [`Pkcs11Uri::parse`] when you also need to retain the original string.
pub(crate) fn parse_attrs(uri: &str) -> Option<Pkcs11UriAttributes> {
    let body = uri.strip_prefix("pkcs11:")?;
    let (path_part, query_part) = body.split_once('?').unwrap_or((body, ""));

    let mut attrs = Pkcs11UriAttributes::default();

    for segment in path_part.split(';').filter(|s| !s.is_empty()) {
        if let Some((k, v)) = segment.split_once('=') {
            match k {
                "token" => attrs.token = Some(pct_decode(v)),
                "object" => attrs.object = Some(pct_decode(v)),
                "id" => attrs.id = Some(decode_id(v)),
                "module-path" => attrs.module_path = Some(pct_decode(v)),
                _ => {}
            }
        }
    }

    for segment in query_part.split('&').filter(|s| !s.is_empty()) {
        if let Some((k, v)) = segment.split_once('=') {
            if k == "pin-value" {
                attrs.pin_value = Some(pct_decode(v));
            }
        }
    }

    Some(attrs)
}

/// Percent-decode a UTF-8 string value from a PKCS#11 URI path component.
///
/// Decodes `%XX` sequences to their corresponding bytes.  Invalid sequences
/// are passed through unchanged (best-effort, suitable for token/object labels
/// that RFC 7512 restricts to printable ASCII + unreserved characters).
fn pct_decode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(s.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' && i + 2 < bytes.len() {
            if let (Some(hi), Some(lo)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) {
                out.push((hi << 4) | lo);
                i += 3;
                continue;
            }
        }
        out.push(bytes[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// Decode a percent-encoded `id=…` attribute value into raw bytes.
///
/// RFC 7512 §2.3 specifies the `id` attribute as a sequence of percent-encoded
/// byte values (e.g. `%01%02%03`).
fn decode_id(s: &str) -> Vec<u8> {
    let bytes = s.as_bytes();
    let mut out = Vec::new();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' && i + 2 < bytes.len() {
            if let (Some(hi), Some(lo)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) {
                out.push((hi << 4) | lo);
                i += 3;
                continue;
            }
        }
        // Non-percent-encoded byte: include as-is (lenient parsing).
        out.push(bytes[i]);
        i += 1;
    }
    out
}

/// Convert an ASCII hex character to its nibble value, or `None` if invalid.
fn hex_nibble(b: u8) -> Option<u8> {
    match b {
        b'0'..=b'9' => Some(b - b'0'),
        b'a'..=b'f' => Some(b - b'a' + 10),
        b'A'..=b'F' => Some(b - b'A' + 10),
        _ => None,
    }
}

/// Percent-encode a string value for use in a PKCS#11 URI path component.
///
/// RFC 7512 §2.3 unreserved characters (letters, digits, `-._~`) pass through
/// unchanged; everything else is encoded as `%XX` using upper-case hex digits.
pub fn pct_encode_path(s: &str) -> String {
    const HEX: &[u8; 16] = b"0123456789ABCDEF";
    let mut out = String::with_capacity(s.len());
    for b in s.bytes() {
        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
            out.push(b as char);
        } else {
            out.push('%');
            out.push(HEX[(b >> 4) as usize] as char);
            out.push(HEX[(b & 0xf) as usize] as char);
        }
    }
    out
}

/// Insert `object=<label>` into the path portion of a `pkcs11:` URI, replacing
/// any existing `object=` segment.
///
/// The query portion (including `?pin-value=…`) is preserved unchanged.
/// The label is percent-encoded per RFC 7512 §2.3 so that special characters
/// do not corrupt the URI structure.
pub fn merge_object_label(uri: &str, label: &str) -> String {
    let body = uri.strip_prefix("pkcs11:").unwrap_or(uri);
    let (path, query) = body.split_once('?').unwrap_or((body, ""));

    let mut segments: Vec<&str> = path
        .split(';')
        .filter(|s| !s.starts_with("object="))
        .collect();

    let encoded = pct_encode_path(label);
    let label_seg = format!("object={encoded}");
    segments.push(&label_seg);

    let new_path = segments.join(";");
    if query.is_empty() {
        format!("pkcs11:{new_path}")
    } else {
        format!("pkcs11:{new_path}?{query}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_non_pkcs11_uri() {
        assert!(Pkcs11Uri::parse("file:///tmp/key.pem").is_none());
        assert!(Pkcs11Uri::parse("").is_none());
    }

    #[test]
    fn minimal_uri_empty_attributes() {
        let p = Pkcs11Uri::parse("pkcs11:").unwrap();
        assert!(p.attrs.token.is_none());
        assert!(p.attrs.object.is_none());
        assert_eq!(p.raw, "pkcs11:");
    }

    #[test]
    fn parses_token_and_object() {
        let p = Pkcs11Uri::parse("pkcs11:token=MyToken;object=cakey;type=private").unwrap();
        assert_eq!(p.attrs.token.as_deref(), Some("MyToken"));
        assert_eq!(p.attrs.object.as_deref(), Some("cakey"));
        assert!(p.raw.starts_with("pkcs11:"));
    }

    #[test]
    fn parses_pin_value_query() {
        let p = Pkcs11Uri::parse("pkcs11:token=T;object=k;type=private?pin-value=12345").unwrap();
        assert_eq!(p.attrs.pin_value(), Some("12345"));
        assert!(p.attrs.has_pin());
    }

    #[test]
    fn parses_percent_encoded_token() {
        // Space encoded as %20
        let p = Pkcs11Uri::parse("pkcs11:token=My%20Token;object=key").unwrap();
        assert_eq!(p.attrs.token.as_deref(), Some("My Token"));
    }

    #[test]
    fn parses_id_bytes() {
        // id=%01%02%03
        let p = Pkcs11Uri::parse("pkcs11:token=T;object=k;id=%01%02%03").unwrap();
        assert_eq!(p.attrs.id.as_deref(), Some(&[0x01u8, 0x02, 0x03][..]));
    }

    #[test]
    fn ignores_unknown_attributes() {
        // `type` and `manufacturer` are valid RFC 7512 attributes but not
        // decoded by this parser — they should be silently ignored.
        let p =
            Pkcs11Uri::parse("pkcs11:token=T;object=k;type=private;manufacturer=OpenSC").unwrap();
        assert_eq!(p.attrs.token.as_deref(), Some("T"));
        assert_eq!(p.attrs.object.as_deref(), Some("k"));
    }
}