mirror of
https://github.com/sigp/lighthouse.git
synced 2026-03-23 14:54:45 +00:00
Implement standard keystore API (#2736)
## Issue Addressed Implements the standard key manager API from https://ethereum.github.io/keymanager-APIs/, formerly https://github.com/ethereum/beacon-APIs/pull/151 Related to https://github.com/sigp/lighthouse/issues/2557 ## Proposed Changes - [x] Add all of the new endpoints from the standard API: GET, POST and DELETE. - [x] Add a `validators.enabled` column to the slashing protection database to support atomic disable + export. - [x] Add tests for all the common sequential accesses of the API - [x] Add tests for interactions with remote signer validators - [x] Add end-to-end tests for migration of validators from one VC to another - [x] Implement the authentication scheme from the standard (token bearer auth) ## Additional Info The `enabled` column in the validators SQL database is necessary to prevent a race condition when exporting slashing protection data. Without the slashing protection database having a way of knowing that a key has been disabled, a concurrent request to sign a message could insert a new record into the database. The `delete_concurrent_with_signing` test exercises this code path, and was indeed failing before the `enabled` column was added. The validator client authentication has been modified from basic auth to bearer auth, with basic auth preserved for backwards compatibility.
This commit is contained in:
@@ -28,6 +28,7 @@ use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::convert::TryFrom;
|
||||
use std::fmt;
|
||||
use std::iter::Iterator;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const V1: EndpointVersion = EndpointVersion(1);
|
||||
@@ -59,6 +60,12 @@ pub enum Error {
|
||||
InvalidServerSentEvent(String),
|
||||
/// The server returned an invalid SSZ response.
|
||||
InvalidSsz(ssz::DecodeError),
|
||||
/// An I/O error occurred while loading an API token from disk.
|
||||
TokenReadError(PathBuf, std::io::Error),
|
||||
/// The client has been configured without a server pubkey, but requires one for this request.
|
||||
NoServerPubkey,
|
||||
/// The client has been configured without an API token, but requires one for this request.
|
||||
NoToken,
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
@@ -82,6 +89,8 @@ impl Error {
|
||||
Error::InvalidJson(_) => None,
|
||||
Error::InvalidServerSentEvent(_) => None,
|
||||
Error::InvalidSsz(_) => None,
|
||||
Error::TokenReadError(..) => None,
|
||||
Error::NoServerPubkey | Error::NoToken => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ use reqwest::{
|
||||
use ring::digest::{digest, SHA256};
|
||||
use sensitive_url::SensitiveUrl;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::fmt::{self, Display};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub use reqwest;
|
||||
pub use reqwest::{Response, StatusCode, Url};
|
||||
@@ -20,18 +23,36 @@ pub use reqwest::{Response, StatusCode, Url};
|
||||
pub struct ValidatorClientHttpClient {
|
||||
client: reqwest::Client,
|
||||
server: SensitiveUrl,
|
||||
secret: ZeroizeString,
|
||||
server_pubkey: PublicKey,
|
||||
send_authorization_header: bool,
|
||||
secret: Option<ZeroizeString>,
|
||||
server_pubkey: Option<PublicKey>,
|
||||
authorization_header: AuthorizationHeader,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum AuthorizationHeader {
|
||||
/// Do not send any Authorization header.
|
||||
Omit,
|
||||
/// Send a `Basic` Authorization header (legacy).
|
||||
Basic,
|
||||
/// Send a `Bearer` Authorization header.
|
||||
Bearer,
|
||||
}
|
||||
|
||||
impl Display for AuthorizationHeader {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
// The `Omit` variant should never be `Display`ed, but would result in a harmless rejection.
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an API token and return a secp256k1 public key.
|
||||
pub fn parse_pubkey(secret: &str) -> Result<PublicKey, Error> {
|
||||
///
|
||||
/// If the token does not start with the Lighthouse token prefix then `Ok(None)` will be returned.
|
||||
/// An error will be returned if the token looks like a Lighthouse token but doesn't correspond to a
|
||||
/// valid public key.
|
||||
pub fn parse_pubkey(secret: &str) -> Result<Option<PublicKey>, Error> {
|
||||
let secret = if !secret.starts_with(SECRET_PREFIX) {
|
||||
return Err(Error::InvalidSecret(format!(
|
||||
"secret does not start with {}",
|
||||
SECRET_PREFIX
|
||||
)));
|
||||
return Ok(None);
|
||||
} else {
|
||||
&secret[SECRET_PREFIX.len()..]
|
||||
};
|
||||
@@ -52,16 +73,31 @@ pub fn parse_pubkey(secret: &str) -> Result<PublicKey, Error> {
|
||||
PublicKey::parse_compressed(&arr)
|
||||
.map_err(|e| Error::InvalidSecret(format!("invalid secp256k1 pubkey: {:?}", e)))
|
||||
})
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
impl ValidatorClientHttpClient {
|
||||
/// Create a new client pre-initialised with an API token.
|
||||
pub fn new(server: SensitiveUrl, secret: String) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
client: reqwest::Client::new(),
|
||||
server,
|
||||
server_pubkey: parse_pubkey(&secret)?,
|
||||
secret: secret.into(),
|
||||
send_authorization_header: true,
|
||||
secret: Some(secret.into()),
|
||||
authorization_header: AuthorizationHeader::Bearer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a client without an API token.
|
||||
///
|
||||
/// A token can be fetched by using `self.get_auth`, and then reading the token from disk.
|
||||
pub fn new_unauthenticated(server: SensitiveUrl) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
client: reqwest::Client::new(),
|
||||
server,
|
||||
secret: None,
|
||||
server_pubkey: None,
|
||||
authorization_header: AuthorizationHeader::Omit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,8 +110,35 @@ impl ValidatorClientHttpClient {
|
||||
client,
|
||||
server,
|
||||
server_pubkey: parse_pubkey(&secret)?,
|
||||
secret: secret.into(),
|
||||
send_authorization_header: true,
|
||||
secret: Some(secret.into()),
|
||||
authorization_header: AuthorizationHeader::Bearer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a reference to this client's API token, if any.
|
||||
pub fn api_token(&self) -> Option<&ZeroizeString> {
|
||||
self.secret.as_ref()
|
||||
}
|
||||
|
||||
/// Read an API token from the specified `path`, stripping any trailing whitespace.
|
||||
pub fn load_api_token_from_file(path: &Path) -> Result<ZeroizeString, Error> {
|
||||
let token = fs::read_to_string(path).map_err(|e| Error::TokenReadError(path.into(), e))?;
|
||||
Ok(ZeroizeString::from(token.trim_end().to_string()))
|
||||
}
|
||||
|
||||
/// Add an authentication token to use when making requests.
|
||||
///
|
||||
/// If the token is Lighthouse-like, a pubkey derivation will be attempted. In the case
|
||||
/// of failure the token will still be stored, and the client can continue to be used to
|
||||
/// communicate with non-Lighthouse nodes.
|
||||
pub fn add_auth_token(&mut self, token: ZeroizeString) -> Result<(), Error> {
|
||||
let pubkey_res = parse_pubkey(token.as_str());
|
||||
|
||||
self.secret = Some(token);
|
||||
self.authorization_header = AuthorizationHeader::Bearer;
|
||||
|
||||
pubkey_res.map(|opt_pubkey| {
|
||||
self.server_pubkey = opt_pubkey;
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,10 +147,20 @@ impl ValidatorClientHttpClient {
|
||||
/// Failing to send the `Authorization` header will cause the VC to reject requests with a 403.
|
||||
/// This function is intended only for testing purposes.
|
||||
pub fn send_authorization_header(&mut self, should_send: bool) {
|
||||
self.send_authorization_header = should_send;
|
||||
if should_send {
|
||||
self.authorization_header = AuthorizationHeader::Bearer;
|
||||
} else {
|
||||
self.authorization_header = AuthorizationHeader::Omit;
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the legacy basic auth style (bearer auth preferred by default now).
|
||||
pub fn use_basic_auth(&mut self) {
|
||||
self.authorization_header = AuthorizationHeader::Basic;
|
||||
}
|
||||
|
||||
async fn signed_body(&self, response: Response) -> Result<Bytes, Error> {
|
||||
let server_pubkey = self.server_pubkey.as_ref().ok_or(Error::NoServerPubkey)?;
|
||||
let sig = response
|
||||
.headers()
|
||||
.get("Signature")
|
||||
@@ -105,7 +178,7 @@ impl ValidatorClientHttpClient {
|
||||
.ok()
|
||||
.and_then(|bytes| {
|
||||
let sig = Signature::parse_der(&bytes).ok()?;
|
||||
Some(libsecp256k1::verify(&message, &sig, &self.server_pubkey))
|
||||
Some(libsecp256k1::verify(&message, &sig, server_pubkey))
|
||||
})
|
||||
.filter(|is_valid| *is_valid)
|
||||
.ok_or(Error::InvalidSignatureHeader)?;
|
||||
@@ -121,11 +194,18 @@ impl ValidatorClientHttpClient {
|
||||
fn headers(&self) -> Result<HeaderMap, Error> {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
if self.send_authorization_header {
|
||||
let header_value = HeaderValue::from_str(&format!("Basic {}", self.secret.as_str()))
|
||||
.map_err(|e| {
|
||||
Error::InvalidSecret(format!("secret is invalid as a header value: {}", e))
|
||||
})?;
|
||||
if self.authorization_header == AuthorizationHeader::Basic
|
||||
|| self.authorization_header == AuthorizationHeader::Bearer
|
||||
{
|
||||
let secret = self.secret.as_ref().ok_or(Error::NoToken)?;
|
||||
let header_value = HeaderValue::from_str(&format!(
|
||||
"{} {}",
|
||||
self.authorization_header,
|
||||
secret.as_str()
|
||||
))
|
||||
.map_err(|e| {
|
||||
Error::InvalidSecret(format!("secret is invalid as a header value: {}", e))
|
||||
})?;
|
||||
|
||||
headers.insert("Authorization", header_value);
|
||||
}
|
||||
@@ -133,8 +213,8 @@ impl ValidatorClientHttpClient {
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
/// Perform a HTTP GET request.
|
||||
async fn get<T: DeserializeOwned, U: IntoUrl>(&self, url: U) -> Result<T, Error> {
|
||||
/// Perform a HTTP GET request, returning the `Response` for further processing.
|
||||
async fn get_response<U: IntoUrl>(&self, url: U) -> Result<Response, Error> {
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
@@ -142,20 +222,25 @@ impl ValidatorClientHttpClient {
|
||||
.send()
|
||||
.await
|
||||
.map_err(Error::Reqwest)?;
|
||||
let response = ok_or_error(response).await?;
|
||||
ok_or_error(response).await
|
||||
}
|
||||
|
||||
async fn get<T: DeserializeOwned, U: IntoUrl>(&self, url: U) -> Result<T, Error> {
|
||||
let response = self.get_response(url).await?;
|
||||
self.signed_json(response).await
|
||||
}
|
||||
|
||||
async fn get_unsigned<T: DeserializeOwned, U: IntoUrl>(&self, url: U) -> Result<T, Error> {
|
||||
self.get_response(url)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.map_err(Error::Reqwest)
|
||||
}
|
||||
|
||||
/// Perform a HTTP GET request, returning `None` on a 404 error.
|
||||
async fn get_opt<T: DeserializeOwned, U: IntoUrl>(&self, url: U) -> Result<Option<T>, Error> {
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers()?)
|
||||
.send()
|
||||
.await
|
||||
.map_err(Error::Reqwest)?;
|
||||
match ok_or_error(response).await {
|
||||
match self.get_response(url).await {
|
||||
Ok(resp) => self.signed_json(resp).await.map(Option::Some),
|
||||
Err(err) => {
|
||||
if err.status() == Some(StatusCode::NOT_FOUND) {
|
||||
@@ -168,11 +253,11 @@ impl ValidatorClientHttpClient {
|
||||
}
|
||||
|
||||
/// Perform a HTTP POST request.
|
||||
async fn post<T: Serialize, U: IntoUrl, V: DeserializeOwned>(
|
||||
async fn post_with_raw_response<T: Serialize, U: IntoUrl>(
|
||||
&self,
|
||||
url: U,
|
||||
body: &T,
|
||||
) -> Result<V, Error> {
|
||||
) -> Result<Response, Error> {
|
||||
let response = self
|
||||
.client
|
||||
.post(url)
|
||||
@@ -181,10 +266,27 @@ impl ValidatorClientHttpClient {
|
||||
.send()
|
||||
.await
|
||||
.map_err(Error::Reqwest)?;
|
||||
let response = ok_or_error(response).await?;
|
||||
ok_or_error(response).await
|
||||
}
|
||||
|
||||
async fn post<T: Serialize, U: IntoUrl, V: DeserializeOwned>(
|
||||
&self,
|
||||
url: U,
|
||||
body: &T,
|
||||
) -> Result<V, Error> {
|
||||
let response = self.post_with_raw_response(url, body).await?;
|
||||
self.signed_json(response).await
|
||||
}
|
||||
|
||||
async fn post_with_unsigned_response<T: Serialize, U: IntoUrl, V: DeserializeOwned>(
|
||||
&self,
|
||||
url: U,
|
||||
body: &T,
|
||||
) -> Result<V, Error> {
|
||||
let response = self.post_with_raw_response(url, body).await?;
|
||||
Ok(response.json().await?)
|
||||
}
|
||||
|
||||
/// Perform a HTTP PATCH request.
|
||||
async fn patch<T: Serialize, U: IntoUrl>(&self, url: U, body: &T) -> Result<(), Error> {
|
||||
let response = self
|
||||
@@ -200,6 +302,24 @@ impl ValidatorClientHttpClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Perform a HTTP DELETE request.
|
||||
async fn delete_with_unsigned_response<T: Serialize, U: IntoUrl, V: DeserializeOwned>(
|
||||
&self,
|
||||
url: U,
|
||||
body: &T,
|
||||
) -> Result<V, Error> {
|
||||
let response = self
|
||||
.client
|
||||
.delete(url)
|
||||
.headers(self.headers()?)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(Error::Reqwest)?;
|
||||
let response = ok_or_error(response).await?;
|
||||
Ok(response.json().await?)
|
||||
}
|
||||
|
||||
/// `GET lighthouse/version`
|
||||
pub async fn get_lighthouse_version(&self) -> Result<GenericResponse<VersionData>, Error> {
|
||||
let mut path = self.server.full.clone();
|
||||
@@ -317,7 +437,7 @@ impl ValidatorClientHttpClient {
|
||||
pub async fn post_lighthouse_validators_web3signer(
|
||||
&self,
|
||||
request: &[Web3SignerValidatorRequest],
|
||||
) -> Result<GenericResponse<ValidatorData>, Error> {
|
||||
) -> Result<(), Error> {
|
||||
let mut path = self.server.full.clone();
|
||||
|
||||
path.path_segments_mut()
|
||||
@@ -345,6 +465,50 @@ impl ValidatorClientHttpClient {
|
||||
|
||||
self.patch(path, &ValidatorPatchRequest { enabled }).await
|
||||
}
|
||||
|
||||
fn make_keystores_url(&self) -> Result<Url, Error> {
|
||||
let mut url = self.server.full.clone();
|
||||
url.path_segments_mut()
|
||||
.map_err(|()| Error::InvalidUrl(self.server.clone()))?
|
||||
.push("eth")
|
||||
.push("v1")
|
||||
.push("keystores");
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// `GET lighthouse/auth`
|
||||
pub async fn get_auth(&self) -> Result<AuthResponse, Error> {
|
||||
let mut url = self.server.full.clone();
|
||||
url.path_segments_mut()
|
||||
.map_err(|()| Error::InvalidUrl(self.server.clone()))?
|
||||
.push("lighthouse")
|
||||
.push("auth");
|
||||
self.get_unsigned(url).await
|
||||
}
|
||||
|
||||
/// `GET eth/v1/keystores`
|
||||
pub async fn get_keystores(&self) -> Result<ListKeystoresResponse, Error> {
|
||||
let url = self.make_keystores_url()?;
|
||||
self.get_unsigned(url).await
|
||||
}
|
||||
|
||||
/// `POST eth/v1/keystores`
|
||||
pub async fn post_keystores(
|
||||
&self,
|
||||
req: &ImportKeystoresRequest,
|
||||
) -> Result<ImportKeystoresResponse, Error> {
|
||||
let url = self.make_keystores_url()?;
|
||||
self.post_with_unsigned_response(url, req).await
|
||||
}
|
||||
|
||||
/// `DELETE eth/v1/keystores`
|
||||
pub async fn delete_keystores(
|
||||
&self,
|
||||
req: &DeleteKeystoresRequest,
|
||||
) -> Result<DeleteKeystoresResponse, Error> {
|
||||
let url = self.make_keystores_url()?;
|
||||
self.delete_with_unsigned_response(url, req).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `Ok(response)` if the response is a `200 OK` response. Otherwise, creates an
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod http_client;
|
||||
pub mod std_types;
|
||||
pub mod types;
|
||||
|
||||
/// The number of bytes in the secp256k1 public key used as the authorization token for the VC API.
|
||||
|
||||
104
common/eth2/src/lighthouse_vc/std_types.rs
Normal file
104
common/eth2/src/lighthouse_vc/std_types.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use account_utils::ZeroizeString;
|
||||
use eth2_keystore::Keystore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use slashing_protection::interchange::Interchange;
|
||||
use types::PublicKeyBytes;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct AuthResponse {
|
||||
pub token_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct ListKeystoresResponse {
|
||||
pub data: Vec<SingleKeystoreResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct SingleKeystoreResponse {
|
||||
pub validating_pubkey: PublicKeyBytes,
|
||||
pub derivation_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub readonly: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImportKeystoresRequest {
|
||||
pub keystores: Vec<KeystoreJsonStr>,
|
||||
pub passwords: Vec<ZeroizeString>,
|
||||
pub slashing_protection: Option<InterchangeJsonStr>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct KeystoreJsonStr(#[serde(with = "eth2_serde_utils::json_str")] pub Keystore);
|
||||
|
||||
impl std::ops::Deref for KeystoreJsonStr {
|
||||
type Target = Keystore;
|
||||
fn deref(&self) -> &Keystore {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct InterchangeJsonStr(#[serde(with = "eth2_serde_utils::json_str")] pub Interchange);
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ImportKeystoresResponse {
|
||||
pub data: Vec<Status<ImportKeystoreStatus>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Status<T> {
|
||||
pub status: T,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl<T> Status<T> {
|
||||
pub fn ok(status: T) -> Self {
|
||||
Self {
|
||||
status,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(status: T, message: String) -> Self {
|
||||
Self {
|
||||
status,
|
||||
message: Some(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ImportKeystoreStatus {
|
||||
Imported,
|
||||
Duplicate,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteKeystoresRequest {
|
||||
pub pubkeys: Vec<PublicKeyBytes>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct DeleteKeystoresResponse {
|
||||
pub data: Vec<Status<DeleteKeystoreStatus>>,
|
||||
#[serde(with = "eth2_serde_utils::json_str")]
|
||||
pub slashing_protection: Interchange,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DeleteKeystoreStatus {
|
||||
Deleted,
|
||||
NotActive,
|
||||
NotFound,
|
||||
Error,
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub use crate::lighthouse::Health;
|
||||
pub use crate::lighthouse_vc::std_types::*;
|
||||
pub use crate::types::{GenericResponse, VersionData};
|
||||
pub use types::*;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user