Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for converting KeyPairs into JWKs #33

Merged
merged 1 commit into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ cli = [

xkeys = ["dep:crypto_box"]

jwk = ["serde", "serde_json", "sha2"]

[[bin]]
name = "nk"
required-features = ["cli"]
Expand All @@ -47,6 +49,10 @@ exitfailure = { version = "0.5.1", optional = true }
env_logger = { version = "0.9", optional = true }
serde_json = { version = "1.0", optional = true }

# jwk dependencies
serde = { version = "1.0", optional = true, features = ["derive"] }
sha2 = { version = "0.10", optional = true }

[target.'cfg(target_arch = "wasm32")'.dependencies]
# NOTE: We need this due to an underlying dependency being pulled in by
# `ed25519-dalek`. Even if we exclude `rand`, that crate pulls it in. `rand` pulls in the low level
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ pub enum ErrorKind {
IncorrectKeyType,
/// Payload not valid (or failed to be decrypted)
InvalidPayload,
/// Thumbprint could not be calculated over the provided public key value
ThumbprintCalculationFailure,
}

/// A handy macro borrowed from the `signatory` crate that lets library-internal code generate
Expand Down Expand Up @@ -70,6 +72,7 @@ impl ErrorKind {
ErrorKind::SignatureError => "Signature failure",
ErrorKind::IncorrectKeyType => "Incorrect key type",
ErrorKind::InvalidPayload => "Invalid payload",
ErrorKind::ThumbprintCalculationFailure => "Thumbprint calculation failure",
}
}
}
Expand Down
151 changes: 151 additions & 0 deletions src/jwk.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
use std::collections::BTreeMap;

use crate::{err, from_public_key, from_seed, KeyPair, KeyPairType, Result};
use data_encoding::BASE64URL_NOPAD;
use ed25519_dalek::{SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};

// We hard code the value here, because we're using Edwards-curve keys, which OKP represents:
// https://datatracker.ietf.org/doc/html/draft-ietf-jose-cfrg-curves-06#section-2
const JWK_KEY_TYPE: &str = "OKP";
// https://datatracker.ietf.org/doc/html/draft-ietf-jose-cfrg-curves-06#section-3.1
const JWK_ALGORITHM: &str = "EdDSA";
const JWK_SUBTYPE: &str = "Ed25519";

#[derive(Debug, Serialize, Deserialize)]
pub struct JsonWebKey {
/// Intended use of the JWK, this is based on the KeyPairType of the KeyPair the JWK is based on, using "enc" for KeyPairType::Curve, otherwise "sig"
#[serde(rename = "use")]
intended_use: String,
/// Key type, which we default to OKP (Octet Key Pair) to represent Edwards-curve keys
#[serde(rename = "kty")]
key_type: String,
/// Key ID, which will be represented by the thumbprint calculated over the subtype (crv), key_type (kty) and public_key (x) components of the JWK.
/// See https://datatracker.ietf.org/doc/html/rfc7639 for more details.
#[serde(rename = "kid")]
key_id: String,
/// Algorithm used for the JWK, defaults to EdDSA
#[serde(rename = "alg")]
algorithm: String,
/// Subtype of the key (from the "JSON Web Elliptic Curve" registry)
#[serde(rename = "crv")]
subtype: String,
// Public key value encoded using base64url encoding
#[serde(rename = "x")]
public_key: String,
// Private key value, if provided, encoded using base64url encoding
#[serde(rename = "d", skip_serializing_if = "Option::is_none")]
private_key: Option<String>,
}

impl JsonWebKey {
pub fn from_seed(source: &str) -> Result<Self> {
let (prefix, seed) = from_seed(source)?;
let sk = SigningKey::from_bytes(&seed);
let kp_type = &KeyPairType::from(prefix);
let public_key = BASE64URL_NOPAD.encode(sk.verifying_key().as_bytes());
let thumbprint = Self::calculate_thumbprint(&public_key)?;

Ok(JsonWebKey {
intended_use: Self::intended_use_for_key_pair_type(kp_type),
key_id: thumbprint,
public_key,
private_key: Some(BASE64URL_NOPAD.encode(sk.as_bytes())),
..Default::default()
})
}

pub fn from_public_key(source: &str) -> Result<Self> {
let (prefix, bytes) = from_public_key(source)?;
let vk = VerifyingKey::from_bytes(&bytes)?;
let public_key = BASE64URL_NOPAD.encode(vk.as_bytes());
let thumbprint = Self::calculate_thumbprint(&public_key)?;

Ok(JsonWebKey {
intended_use: Self::intended_use_for_key_pair_type(&KeyPairType::from(prefix)),
key_id: thumbprint,
public_key,
..Default::default()
})
}

fn intended_use_for_key_pair_type(typ: &KeyPairType) -> String {
match typ {
KeyPairType::Server
| KeyPairType::Cluster
| KeyPairType::Operator
| KeyPairType::Account
| KeyPairType::User
| KeyPairType::Module
| KeyPairType::Service => "sig".to_owned(),
KeyPairType::Curve => "enc".to_owned(),
}
}

/// For details on how fingerprints are calculated, see: https://datatracker.ietf.org/doc/html/rfc7638#section-3.1
/// For OKP specific details, see https://datatracker.ietf.org/doc/html/draft-ietf-jose-cfrg-curves-06#appendix-A.3
pub fn calculate_thumbprint(public_key: &str) -> Result<String> {
// We use BTreeMap here, because the order needs to be lexicographically sorted:
// https://datatracker.ietf.org/doc/html/rfc7638#section-3.3
let components = BTreeMap::from([
("crv", JWK_SUBTYPE),
("kty", JWK_KEY_TYPE),
("x", public_key),
]);
let value = json!(components);
let mut bytes: Vec<u8> = Vec::new();
serde_json::to_writer(&mut bytes, &value).map_err(|_| {
err!(
ThumbprintCalculationFailure,
"unable to serialize public key"
)
})?;
let mut hasher = Sha256::new();
hasher.update(&*bytes);
let hash = hasher.finalize();
Ok(BASE64URL_NOPAD.encode(&hash))
}
}

impl Default for JsonWebKey {
fn default() -> Self {
Self {
intended_use: Default::default(),
key_type: JWK_KEY_TYPE.to_string(),
key_id: Default::default(),
algorithm: JWK_ALGORITHM.to_string(),
subtype: JWK_SUBTYPE.to_string(),
public_key: Default::default(),
private_key: None,
}
}
}

impl TryFrom<KeyPair> for JsonWebKey {
type Error = crate::error::Error;

fn try_from(value: KeyPair) -> Result<Self> {
if let Ok(seed) = value.seed() {
Ok(Self::from_seed(&seed)?)
} else {
Ok(Self::from_public_key(&value.public_key())?)
}
}
}

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

// Using the example values from https://datatracker.ietf.org/doc/html/draft-ietf-jose-cfrg-curves-06#appendix-A.3
#[test]
fn calculate_thumbprint_provides_correct_thumbprint() {
let input_public_key = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo";
let expected_thumbprint = "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k";
let actual_thumbprint = JsonWebKey::calculate_thumbprint(input_public_key).unwrap();

assert_eq!(expected_thumbprint, actual_thumbprint);
}
}
76 changes: 51 additions & 25 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ mod xkeys;
#[cfg(feature = "xkeys")]
pub use xkeys::XKey;

#[cfg(feature = "jwk")]
mod jwk;

#[cfg(feature = "jwk")]
pub use jwk::JsonWebKey;

const ENCODED_SEED_LENGTH: usize = 58;
const ENCODED_PUBKEY_LENGTH: usize = 56;

Expand Down Expand Up @@ -287,33 +293,17 @@ impl KeyPair {

/// Attempts to produce a public-only key pair from the given encoded public key string
pub fn from_public_key(source: &str) -> Result<KeyPair> {
if source.len() != ENCODED_PUBKEY_LENGTH {
let l = source.len();
return Err(err!(InvalidKeyLength, "Bad key length: {}", l));
}
let (prefix, bytes) = from_public_key(source)?;

let source_bytes = source.as_bytes();
let mut raw = decode_raw(source_bytes)?;
let pk = VerifyingKey::from_bytes(&bytes)
.map_err(|_| err!(VerifyError, "Could not read public key"))?;

let prefix = raw[0];
if !valid_public_key_prefix(prefix) {
Err(err!(
InvalidPrefix,
"Not a valid public key prefix: {}",
raw[0]
))
} else {
raw.remove(0);
match VerifyingKey::try_from(&raw[..]) {
Ok(pk) => Ok(KeyPair {
kp_type: KeyPairType::from(prefix),
pk,
sk: None,
signing_key: None,
}),
Err(_) => Err(err!(VerifyError, "Could not read public key")),
}
}
Ok(KeyPair {
kp_type: KeyPairType::from(prefix),
pk,
sk: None,
signing_key: None,
})
}

/// Attempts to produce a full key pair from the given encoded seed string
Expand Down Expand Up @@ -348,6 +338,32 @@ fn decode_raw(raw: &[u8]) -> Result<Vec<u8>> {
}
}

/// Returns the prefix byte and the underlying public key bytes
fn from_public_key(source: &str) -> Result<(u8, [u8; 32])> {
if source.len() != ENCODED_PUBKEY_LENGTH {
let l = source.len();
return Err(err!(InvalidKeyLength, "Bad key length: {}", l));
}

let source_bytes = source.as_bytes();
let mut raw = decode_raw(source_bytes)?;

let prefix = raw[0];
if !valid_public_key_prefix(prefix) {
return Err(err!(
InvalidPrefix,
"Not a valid public key prefix: {}",
raw[0]
));
}
raw.remove(0);

let mut public_key = [0u8; 32];
public_key.copy_from_slice(&raw[..]);

Ok((prefix, public_key))
}

/// Returns the type and the seed
fn from_seed(source: &str) -> Result<(u8, [u8; 32])> {
if source.len() != ENCODED_SEED_LENGTH {
Expand Down Expand Up @@ -521,6 +537,16 @@ mod tests {
}
}

#[test]
fn from_public_key_rejects_bad_prefix() {
let public_key = "ZCO4XYNKEN7ZFQ42BHYCBYI3K7USOGG43C2DIJZYWSQ2YEMBOZWN6PYH";
let pair = KeyPair::from_public_key(public_key);
assert!(pair.is_err());
if let Err(e) = pair {
assert_eq!(e.kind(), ErrorKind::InvalidPrefix);
}
}

#[test]
fn public_key_round_trip() {
let account =
Expand Down
Loading