From fea4b5f5512ac80057fc6aa2b82d027fdd3fb85d Mon Sep 17 00:00:00 2001 From: Heikki Linnakangas Date: Mon, 20 Mar 2023 15:50:30 +0200 Subject: [PATCH] Switch to EdDSA algorithm for the storage JWT authentication tokens. The control plane currently only supports EdDSA. We need to either teach the storage to use EdDSA, or the control plane to use RSA. EdDSA is more modern, so let's use that. We could support both, but it would require a little more code and tests, and we don't really need the flexibility since we control both sides. --- README.md | 5 +- control_plane/src/local_env.rs | 14 ++-- docs/authentication.md | 15 ++-- libs/utils/src/auth.rs | 99 +++++++-------------------- safekeeper/src/bin/safekeeper.rs | 2 +- test_runner/fixtures/neon_fixtures.py | 2 +- 6 files changed, 49 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 819693f1f3..43f3e3a02b 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,14 @@ postgresql-libs cmake postgresql protobuf curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` -#### Installing dependencies on OSX (12.3.1) +#### Installing dependencies on macOS (12.3.1) 1. Install XCode and dependencies ``` xcode-select --install brew install protobuf openssl flex bison + +# add openssl to PATH, required for ed25519 keys generation in neon_local +echo 'export PATH="$(brew --prefix openssl)/bin:$PATH"' >> ~/.zshrc ``` 2. [Install Rust](https://www.rust-lang.org/tools/install) diff --git a/control_plane/src/local_env.rs b/control_plane/src/local_env.rs index d4de72b6bf..8cc6329ce6 100644 --- a/control_plane/src/local_env.rs +++ b/control_plane/src/local_env.rs @@ -452,10 +452,13 @@ fn base_path() -> PathBuf { /// Generate a public/private key pair for JWT authentication fn generate_auth_keys(private_key_path: &Path, public_key_path: &Path) -> anyhow::Result<()> { + // Generate the key pair + // + // openssl genpkey -algorithm ed25519 -out auth_private_key.pem let keygen_output = Command::new("openssl") - .arg("genrsa") + .arg("genpkey") + .args(["-algorithm", "ed25519"]) .args(["-out", private_key_path.to_str().unwrap()]) - .arg("2048") .stdout(Stdio::null()) .output() .context("failed to generate auth private key")?; @@ -465,12 +468,13 @@ fn generate_auth_keys(private_key_path: &Path, public_key_path: &Path) -> anyhow String::from_utf8_lossy(&keygen_output.stderr) ); } - // openssl rsa -in private_key.pem -pubout -outform PEM -out public_key.pem + // Extract the public key from the private key file + // + // openssl pkey -in auth_private_key.pem -pubout -out auth_public_key.pem let keygen_output = Command::new("openssl") - .arg("rsa") + .arg("pkey") .args(["-in", private_key_path.to_str().unwrap()]) .arg("-pubout") - .args(["-outform", "PEM"]) .args(["-out", public_key_path.to_str().unwrap()]) .output() .context("failed to extract public key from private key")?; diff --git a/docs/authentication.md b/docs/authentication.md index e6b5fa5707..dc402d1bca 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -29,15 +29,22 @@ These components should not have access to the private key and may only get toke The key pair is generated once for an installation of compute/pageserver/safekeeper, e.g. by `neon_local init`. There is currently no way to rotate the key without bringing down all components. +### Best practices + +See [RFC 8725: JSON Web Token Best Current Practices](https://www.rfc-editor.org/rfc/rfc8725) + + ### Token format -The JWT tokens in Neon use RSA as the algorithm. Example: +The JWT tokens in Neon use "EdDSA" as the algorithm (defined in [RFC8037](https://www.rfc-editor.org/rfc/rfc8037)). + +Example: Header: ``` { - "alg": "RS512", # RS256, RS384, or RS512 + "alg": "EdDSA", "typ": "JWT" } ``` @@ -68,8 +75,8 @@ Currently also used for connection from any pageserver to any safekeeper. CLI generates a key pair during call to `neon_local init` with the following commands: ```bash -openssl genrsa -out auth_private_key.pem 2048 -openssl rsa -in auth_private_key.pem -pubout -outform PEM -out auth_public_key.pem +openssl genpkey -algorithm ed25519 -out auth_private_key.pem +openssl pkey -in auth_private_key.pem -pubout -out auth_public_key.pem ``` Configuration files for all components point to `public_key.pem` for JWT validation. diff --git a/libs/utils/src/auth.rs b/libs/utils/src/auth.rs index 027950cb39..0fb45e01c6 100644 --- a/libs/utils/src/auth.rs +++ b/libs/utils/src/auth.rs @@ -1,7 +1,4 @@ // For details about authentication see docs/authentication.md -// -// TODO: use ed25519 keys -// Relevant issue: https://github.com/Keats/jsonwebtoken/issues/162 use serde; use std::fs; @@ -9,26 +6,15 @@ use std::path::Path; use anyhow::Result; use jsonwebtoken::{ - decode, encode, Algorithm, Algorithm::*, DecodingKey, EncodingKey, Header, TokenData, - Validation, + decode, encode, Algorithm, DecodingKey, EncodingKey, Header, TokenData, Validation, }; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; use crate::id::TenantId; -/// Algorithms accepted during validation. -/// -/// Accept all RSA-based algorithms. We pass this list to jsonwebtoken::decode, -/// which checks that the algorithm in the token is one of these. -/// -/// XXX: It also fails the validation if there are any algorithms in this list that belong -/// to different family than the token's algorithm. In other words, we can *not* list any -/// non-RSA algorithms here, or the validation always fails with InvalidAlgorithm error. -const ACCEPTED_ALGORITHMS: &[Algorithm] = &[RS256, RS384, RS512]; - -/// Algorithm to use when generating a new token in [`encode_from_key_file`] -const ENCODE_ALGORITHM: Algorithm = Algorithm::RS256; +/// Algorithm to use. We require EdDSA. +const STORAGE_TOKEN_ALGORITHM: Algorithm = Algorithm::EdDSA; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "lowercase")] @@ -69,7 +55,7 @@ pub struct JwtAuth { impl JwtAuth { pub fn new(decoding_key: DecodingKey) -> Self { let mut validation = Validation::default(); - validation.algorithms = ACCEPTED_ALGORITHMS.into(); + validation.algorithms = vec![STORAGE_TOKEN_ALGORITHM]; // The default 'required_spec_claims' is 'exp'. But we don't want to require // expiration. validation.required_spec_claims = [].into(); @@ -81,7 +67,7 @@ impl JwtAuth { pub fn from_key_path(key_path: &Path) -> Result { let public_key = fs::read(key_path)?; - Ok(Self::new(DecodingKey::from_rsa_pem(&public_key)?)) + Ok(Self::new(DecodingKey::from_ed_pem(&public_key)?)) } pub fn decode(&self, token: &str) -> Result> { @@ -99,8 +85,8 @@ impl std::fmt::Debug for JwtAuth { // this function is used only for testing purposes in CLI e g generate tokens during init pub fn encode_from_key_file(claims: &Claims, key_data: &[u8]) -> Result { - let key = EncodingKey::from_rsa_pem(key_data)?; - Ok(encode(&Header::new(ENCODE_ALGORITHM), claims, &key)?) + let key = EncodingKey::from_ed_pem(key_data)?; + Ok(encode(&Header::new(STORAGE_TOKEN_ALGORITHM), claims, &key)?) } #[cfg(test)] @@ -108,49 +94,19 @@ mod tests { use super::*; use std::str::FromStr; - // generated with: + // Generated with: // - // openssl genpkey -algorithm rsa -out storage-auth-priv.pem - // openssl pkey -in storage-auth-priv.pem -pubout -out storage-auth-pub.pem - const TEST_PUB_KEY_RSA: &[u8] = br#" + // openssl genpkey -algorithm ed25519 -out ed25519-priv.pem + // openssl pkey -in ed25519-priv.pem -pubout -out ed25519-pub.pem + const TEST_PUB_KEY_ED25519: &[u8] = br#" -----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy6OZ+/kQXcueVJA/KTzO -v4ljxylc/Kcb0sXWuXg1GB8k3nDA1gK66LFYToH0aTnqrnqG32Vu6wrhwuvqsZA7 -jQvP0ZePAbWhpEqho7EpNunDPcxZ/XDy5TQlB1P58F9I3lkJXDC+DsHYLuuzwhAv -vo2MtWRdYlVHblCVLyZtANHhUMp2HUhgjHnJh5UrLIKOl4doCBxkM3rK0wjKsNCt -M92PCR6S9rvYzldfeAYFNppBkEQrXt2CgUqZ4KaS4LXtjTRUJxljijA4HWffhxsr -euRu3ufq8kVqie7fum0rdZZSkONmce0V0LesQ4aE2jB+2Sn48h6jb4dLXGWdq8TV -wQIDAQAB +MCowBQYDK2VwAyEARYwaNBayR+eGI0iXB4s3QxE3Nl2g1iWbr6KtLWeVD/w= -----END PUBLIC KEY----- "#; - const TEST_PRIV_KEY_RSA: &[u8] = br#" + + const TEST_PRIV_KEY_ED25519: &[u8] = br#" -----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDLo5n7+RBdy55U -kD8pPM6/iWPHKVz8pxvSxda5eDUYHyTecMDWArrosVhOgfRpOequeobfZW7rCuHC -6+qxkDuNC8/Rl48BtaGkSqGjsSk26cM9zFn9cPLlNCUHU/nwX0jeWQlcML4Owdgu -67PCEC++jYy1ZF1iVUduUJUvJm0A0eFQynYdSGCMecmHlSssgo6Xh2gIHGQzesrT -CMqw0K0z3Y8JHpL2u9jOV194BgU2mkGQRCte3YKBSpngppLgte2NNFQnGWOKMDgd -Z9+HGyt65G7e5+ryRWqJ7t+6bSt1llKQ42Zx7RXQt6xDhoTaMH7ZKfjyHqNvh0tc -ZZ2rxNXBAgMBAAECggEAVz3u4Wlx3o02dsoZlSQs+xf0PEX3RXKeU+1YMbtTG9Nz -6yxpIQaoZrpbt76rJE2gwkFR+PEu1NmjoOuLb6j4KlQuI4AHz1auOoGSwFtM6e66 -K4aZ4x95oEJ3vqz2fkmEIWYJwYpMUmwvnuJx76kZm0xvROMLsu4QHS2+zCVtO5Tr -hvS05IMVuZ2TdQBZw0+JaFdwXbgDjQnQGY5n9MoTWSx1a4s/FF4Eby65BbDutcpn -Vt3jQAOmO1X2kbPeWSGuPJRzyUs7Kg8qfeglBIR3ppGP3vPYAdWX+ho00bmsVkSp -Q8vjul6C3WiM+kjwDxotHSDgbl/xldAl7OqPh0bfAQKBgQDnycXuq14Vg8nZvyn9 -rTnvucO8RBz5P6G+FZ+44cAS2x79+85onARmMnm+9MKYLSMo8fOvsK034NDI68XM -04QQ/vlfouvFklMTGJIurgEImTZbGCmlMYCvFyIxaEWixon8OpeI4rFe4Hmbiijh -PxhxWg221AwvBS2sco8J/ylEkQKBgQDg6Rh2QYb/j0Wou1rJPbuy3NhHofd5Rq35 -4YV3f2lfVYcPrgRhwe3T9SVII7Dx8LfwzsX5TAlf48ESlI3Dzv40uOCDM+xdtBRI -r96SfSm+jup6gsXU3AsdNkrRK3HoOG9Z/TkrUp213QAIlVnvIx65l4ckFMlpnPJ0 -lo1LDXZWMQKBgFArzjZ7N5OhfdO+9zszC3MLgdRAivT7OWqR+CjujIz5FYMr8Xzl -WfAvTUTrS9Nu6VZkObFvHrrRG+YjBsuN7YQjbQXTSFGSBwH34bgbn2fl9pMTjHQC -50uoaL9GHa/rlBaV/YvvPQJgCi/uXa1rMX0jdNLkDULGO8IF7cu7Yf7BAoGBAIUU -J29BkpmAst0GDs/ogTlyR18LTR0rXyHt+UUd1MGeH859TwZw80JpWWf4BmkB4DTS -hH3gKePdJY7S65ci0XNsuRupC4DeXuorde0DtkGU2tUmr9wlX0Ynq9lcdYfMbMa4 -eK1TsxG69JwfkxlWlIWITWRiEFM3lJa7xlrUWmLhAoGAFpKWF/hn4zYg3seU9gai -EYHKSbhxA4mRb+F0/9IlCBPMCqFrL5yftUsYIh2XFKn8+QhO97Nmk8wJSK6TzQ5t -ZaSRmgySrUUhx4nZ/MgqWCFv8VUbLM5MBzwxPKhXkSTfR4z2vLYLJwVY7Tb4kZtp -8ismApXVGHpOCstzikV9W7k= +MC4CAQAwBQYDK2VwBCIEID/Drmc1AA6U/znNRWpF3zEGegOATQxfkdWxitcOMsIH -----END PRIVATE KEY----- "#; @@ -161,8 +117,7 @@ ZaSRmgySrUUhx4nZ/MgqWCFv8VUbLM5MBzwxPKhXkSTfR4z2vLYLJwVY7Tb4kZtp scope: Scope::Tenant, }; - // Here are tokens containing the following payload, signed using TEST_PRIV_KEY_RSA - // using RS512, RS384 and RS256 algorithms: + // A test token containing the following payload, signed using TEST_PRIV_KEY_ED25519: // // ``` // { @@ -174,21 +129,13 @@ ZaSRmgySrUUhx4nZ/MgqWCFv8VUbLM5MBzwxPKhXkSTfR4z2vLYLJwVY7Tb4kZtp // } // ``` // - // These were encoded with the online debugger at https://jwt.io - // - let encoded_rs512 = "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6InRlbmFudCIsInRlbmFudF9pZCI6IjNkMWY3NTk1YjQ2ODIzMDMwNGUwYjczY2VjYmNiMDgxIiwiaXNzIjoibmVvbi5jb250cm9scGxhbmUiLCJleHAiOjE3MDkyMDA4NzksImlhdCI6MTY3ODQ0MjQ3OX0.QmqfteDQmDGoxQ5EFkasbt35Lx0W0Nh63muQnYZvFq93DSh4ZbOG9Mc4yaiXZoiS5HgeKtFKv3mbWkDqjz3En06aY17hWwguBtAsGASX48lYeCPADYGlGAuaWnOnVRwe3iiOC7tvPFvwX_45S84X73sNUXyUiXv6nLdcDqVXudtNrGST_DnZDnjuUJX11w7sebtKqQQ8l9-iGHiXOl5yevpMCoB1OcTWcT6DfDtffoNuMHDC3fyhmEGG5oKAt1qBybqAIiyC9-UBAowRZXhdfxrzUl-I9jzKWvk85c5ulhVRwbPeP6TTTlPKwFzBNHg1i2U-1GONew5osQ3aoptwsA"; + let encoded_eddsa = "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6InRlbmFudCIsInRlbmFudF9pZCI6IjNkMWY3NTk1YjQ2ODIzMDMwNGUwYjczY2VjYmNiMDgxIiwiaXNzIjoibmVvbi5jb250cm9scGxhbmUiLCJleHAiOjE3MDkyMDA4NzksImlhdCI6MTY3ODQ0MjQ3OX0.U3eA8j-uU-JnhzeO3EDHRuXLwkAUFCPxtGHEgw6p7Ccc3YRbFs2tmCdbD9PZEXP-XsxSeBQi1FY0YPcT3NXADw"; - let encoded_rs384 = "eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6InRlbmFudCIsInRlbmFudF9pZCI6IjNkMWY3NTk1YjQ2ODIzMDMwNGUwYjczY2VjYmNiMDgxIiwiaXNzIjoibmVvbi5jb250cm9scGxhbmUiLCJleHAiOjE3MDkyMDA4NzksImlhdCI6MTY3ODQ0MjQ3OX0.qqk4nkxKzOJP38c_g57_w_SfdQVmCsDT_bsLmdFj_N6LIB22gr6U6_P_5mvk3pIAsp0VCTDwPrCU908TxqjibEkwvQoJwbogHamSGHpD7eJBxGblSnA-Nr3MlEMxpFtec8QokSm6C5mH7DoBYjB2xzeOlxAmpR2GAzInKiMkU4kZ_OcqqrmVcMXY_6VnbxZWMekuw56zE1-PP_qNF1HvYOH-P08ONP8qdo5UPtBG7QBEFlCqZXJZCFihQaI4Vzil9rDuZGCm3I7xQJ8-yh1PX3BTbGo8EzqLdRyBeTpr08UTuRbp_MJDWevHpP3afvJetAItqZXIoZQrbJjcByHqKw"; + // Check it can be validated with the public key + let auth = JwtAuth::new(DecodingKey::from_ed_pem(TEST_PUB_KEY_ED25519)?); + let claims_from_token = auth.decode(encoded_eddsa)?.claims; + assert_eq!(claims_from_token, expected_claims); - let encoded_rs256 = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6InRlbmFudCIsInRlbmFudF9pZCI6IjNkMWY3NTk1YjQ2ODIzMDMwNGUwYjczY2VjYmNiMDgxIiwiaXNzIjoibmVvbi5jb250cm9scGxhbmUiLCJleHAiOjE3MDkyMDA4NzksImlhdCI6MTY3ODQ0MjQ3OX0.dF2N9KXG8ftFKHYbd5jQtXMQqv0Ej8FISGp1b_dmqOCotXj5S1y2AWjwyB_EXHM77JXfbEoJPAPrFFBNfd8cWtkCSTvpxWoHaecGzegDFGv5ZSc5AECFV1Daahc3PI3jii9wEiGkFOiwiBNfZ5INomOAsV--XXxlqIwKbTcgSYI7lrOTfecXAbAHiMKQlQYiIBSGnytRCgafhRkyGzPAL8ismthFJ9RHfeejyskht-9GbVHURw02bUyijuHEulpf9eEY3ZiB28de6jnCdU7ftIYaUMaYWt0nZQGkzxKPSfSLZNy14DTOYLDS04DVstWQPqnCUW_ojg0wJETOOfo9Zw"; - - // Check that RS512, RS384 and RS256 tokens can all be validated - let auth = JwtAuth::new(DecodingKey::from_rsa_pem(TEST_PUB_KEY_RSA)?); - - for encoded in [encoded_rs512, encoded_rs384, encoded_rs256] { - let claims_from_token = auth.decode(encoded)?.claims; - assert_eq!(claims_from_token, expected_claims); - } Ok(()) } @@ -199,10 +146,10 @@ ZaSRmgySrUUhx4nZ/MgqWCFv8VUbLM5MBzwxPKhXkSTfR4z2vLYLJwVY7Tb4kZtp scope: Scope::Tenant, }; - let encoded = encode_from_key_file(&claims, TEST_PRIV_KEY_RSA)?; + let encoded = encode_from_key_file(&claims, TEST_PRIV_KEY_ED25519)?; // decode it back - let auth = JwtAuth::new(DecodingKey::from_rsa_pem(TEST_PUB_KEY_RSA)?); + let auth = JwtAuth::new(DecodingKey::from_ed_pem(TEST_PUB_KEY_ED25519)?); let decoded = auth.decode(&encoded)?; assert_eq!(decoded.claims, claims); diff --git a/safekeeper/src/bin/safekeeper.rs b/safekeeper/src/bin/safekeeper.rs index 848b955af8..8966e8c49b 100644 --- a/safekeeper/src/bin/safekeeper.rs +++ b/safekeeper/src/bin/safekeeper.rs @@ -111,7 +111,7 @@ struct Args { /// WAL backup horizon. #[arg(long)] disable_wal_backup: bool, - /// Path to an RSA .pem public key which is used to check JWT tokens. + /// Path to a .pem public key which is used to check JWT tokens. #[arg(long)] auth_validation_public_key_path: Option, /// Format for logging, either 'plain' or 'json'. diff --git a/test_runner/fixtures/neon_fixtures.py b/test_runner/fixtures/neon_fixtures.py index cd4f0678b5..6429b1e940 100644 --- a/test_runner/fixtures/neon_fixtures.py +++ b/test_runner/fixtures/neon_fixtures.py @@ -431,7 +431,7 @@ class AuthKeys: priv: str def generate_token(self, *, scope: str, **token_data: str) -> str: - token = jwt.encode({"scope": scope, **token_data}, self.priv, algorithm="RS256") + token = jwt.encode({"scope": scope, **token_data}, self.priv, algorithm="EdDSA") # cast(Any, self.priv) # jwt.encode can return 'bytes' or 'str', depending on Python version or type