Skip to content

Commit

Permalink
feat: Add support for converting KeyPairs into JWKs
Browse files Browse the repository at this point in the history
Signed-off-by: Joonas Bergius <[email protected]>
  • Loading branch information
joonas committed Jun 7, 2024
1 parent f105687 commit 92af7eb
Show file tree
Hide file tree
Showing 4 changed files with 212 additions and 25 deletions.
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);
}
}
77 changes: 52 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,17 @@ mod tests {
}
}

#[test]
fn from_public_key_rejects_bad_prefix() {
let random_seed = generate_seed_rand();
let unknown_key = encode_prefix(&[PREFIX_BYTE_UNKNOWN], &random_seed);
let pair = KeyPair::from_public_key(&unknown_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

0 comments on commit 92af7eb

Please sign in to comment.