Skip to content

Implement DCSR tracking in ca-state. #291

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

Merged
merged 1 commit into from
Feb 7, 2025
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ zeroize = "1.8.1"
zeroize_derive = "1.4.2"
glob = "0.3.2"
rsa = "0.9.3"
sha2 = "0.10.8"
54 changes: 54 additions & 0 deletions docs/debug-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,57 @@ The public key for the signer, the collection of trust anchors, and the DCSR are
The output is the DAC.

Most of the hard work in this process is done by the [lpc55_support](https://github.com/oxidecomputer/lpc55_support) crate.

## Issuance Policy

RFD 5280 defines policies that certificate authorities implement and abide by (at least that's what they're supposed to do).
DACs do not come with such guidelines but we do want to put some restrictions on their issuance.
OKS is the policy enforcement point by virtue of hosting the trust anchors that must sign the DAC.

### One key, one DAC

If we're willing to issue more than one DAC for a given key we create a situation where we must trust the key holder.
We would be trusting them to use each DAC in the appropriate context.
One could then attack the key holder by attempting to confuse them into using the key in the wrong context.
If the number of DACs we may issue is reasonably bounded (under some threshold) we can mitigate this threat by issuing only one DAC per signing key.
For now we're below this threshold and assume that will remain a constant.

Implementing this policy when OKS is presented with a DAC to sign requires that we compare the public key from the request (DcsrSpec) to each previously issued DAC.
This requires we iterate over all previously issued DACs.
We don't need to be able to do this particularly quickly so setting up and maintaining a database that we can query is overkill.
Instead we can use the file system much like the `openssl ca` command.

Reading back all past DACs from the file system could get expensive over time.
To avoid this we need an identifier that we can put into the DAC file names such that we can enforce this policy by reading a directory entry.
We can't put the full 4k RSA public key in the file name so we assign each a name that is the hex encoded sha256 digest.
We've been using the suffix `dc.bin` when exporting signed DACs and so we'll use that in this case as well.

Before OKS signs a DAC it calculates the digest of the public key and then searches through the collection of previosly issued DAC files looking for a file name that begins with the same digest.
If a match is found OKS will load this DAC and calculate the digest of the public key inside manually to verify.

### Digest What

When calculating the digest of the public key from a DAC we need to be explicit about the bytes we're running through the hash function.
For as long as we're using the LPC55 for our RoT these keys will always be RSA keys.
The `DebugCredentialSigningRequest` structure from the `lpc55_support` crate packages the public key in a format specific to RSA.
RSA keys are just two integers so we could run them both through the digest `update` function effectively concatenating them into a single digest.

While we'll be generating these digests in OKS, we want to enable external verification.
Doing this verification requires generating these digests from public keys obtained elsewhere and likely in other (standardized) formats.
These formats are going to either be PKCS#1, or SPKI.

Both of these formats store the public key as a DER encoded structure.
The prior is specific to RSA public keys and so we can hash this structure in its DER form directly:

```shell
openssl rsa -pubin -in path-to-pkcs1.pem -outform DER -RSAPublicKey_out | sha256sum
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -
```

SPKI is conveniently compatible with PKCS#1 in that SPKI prepends an algorithm identifier on to the PKCS#1 DER encoded key.
This allows us to reconstruct our digest from the SPKI encoded key by dropping the first 24 bytes from its DER encoding:

```shell
openssl rsa -pubin -in path-to-spki.pem -outform DER | tail --bytes=+25 | sha256sum
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -
```
113 changes: 110 additions & 3 deletions src/ca.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

use anyhow::{anyhow, Context, Result};
use hex::ToHex;
use log::{debug, error, info, warn};
use rsa::{pkcs1::EncodeRsaPublicKey, RsaPublicKey};
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
env,
Expand All @@ -21,7 +24,7 @@ use x509_cert::{certificate::Certificate, der::DecodePem};
use yubihsm::Client;
use zeroize::Zeroizing;

use crate::config::{CsrSpec, DcsrSpec, KeySpec, Purpose};
use crate::config::{CsrSpec, DcsrSpec, KeySpec, Purpose, DCSR_EXT};

/// Name of file in root of a CA directory with key spec used to generate key
/// in HSM.
Expand Down Expand Up @@ -210,17 +213,97 @@ pub enum CertOrCsr {
Csr(String),
}

pub struct DacStore {
root: PathBuf,
}

impl DacStore {
fn pubkey_to_digest(pubkey: &RsaPublicKey) -> Result<String> {
// calculate sha256(pub_key) where pub_key is the DER encoded RSA key
let der = pubkey
.to_pkcs1_der()
.context("Encode RSA public key as DER")?;

let mut digest = Sha256::new();
digest.update(der.as_bytes());
let digest = digest.finalize();

Ok(digest.encode_hex::<String>())
}

fn pubkey_to_dcsr_path(&self, pubkey: &RsaPublicKey) -> Result<PathBuf> {
let digest = Self::pubkey_to_digest(pubkey)?;

Ok(self.root.as_path().join(format!("{}.{}", digest, DCSR_EXT)))
}

pub fn new<P: AsRef<Path>>(root: P) -> Result<Self> {
// check that the path provided exists
let metadata = fs::metadata(root.as_ref()).with_context(|| {
format!(
"Getting metadata for DacStore root: {}",
root.as_ref().display()
)
})?;

// - is a directory
if !metadata.is_dir() {
return Err(anyhow!("DacStore root is not a directory"));
}

// - we have write access to it
if metadata.permissions().readonly() {
return Err(anyhow!("DacStore directory is not writable"));
}

Ok(Self {
root: PathBuf::from(root.as_ref()),
})
}

pub fn add(&self, pubkey: &RsaPublicKey, dcsr: &[u8]) -> Result<()> {
// Make sure we haven't already issued a DCSR for this key before
// we save it to disk. The caller should perform this check before
// signing the DCSR but we do it here also to keep from overwriting
// an existing one.
if let Some(path) = self.find(pubkey)? {
return Err(anyhow!(
"DCSR for public key exists: {}",
path.display()
));
}

let path = self.pubkey_to_dcsr_path(pubkey)?;

fs::write(&path, dcsr)
.context(format!("Writing DCSR to destination {}", path.display()))
}

pub fn find(&self, pubkey: &RsaPublicKey) -> Result<Option<PathBuf>> {
let path = self.pubkey_to_dcsr_path(pubkey)?;

if fs::exists(&path).with_context(|| {
format!("Checking for the existance of {}", path.display())
})? {
Ok(Some(path))
} else {
Ok(None)
}
}
}

/// The `Ca` type represents the collection of files / metadata that is a
/// certificate authority.
pub struct Ca {
root: PathBuf,
spec: KeySpec,
dacs: DacStore,
}

impl Ca {
/// Create a Ca instance from a directory. This directory must be the
/// root of a previously initialized Ca.
pub fn load<P: AsRef<Path>>(root: P) -> Result<Self> {
pub fn load<P: AsRef<Path>>(root: P, dacs: DacStore) -> Result<Self> {
let root = PathBuf::from(root.as_ref());

let spec = root.join(CA_KEY_SPEC);
Expand All @@ -231,7 +314,7 @@ impl Ca {
let spec = fs::read_to_string(spec)?;
let spec = KeySpec::from_str(spec.as_ref())?;

Ok(Self { root, spec })
Ok(Self { root, spec, dacs })
}

/// Get the name of the CA in `String` form. A `Ca`s name comes from the
Expand Down Expand Up @@ -487,6 +570,17 @@ impl Ca {
client: &Client,
) -> Result<Vec<u8>> {
debug!("signing DcsrSpec: {:?}", spec);
if let Some(dcsr) = self
.dacs
.find(&spec.dcsr.debug_public_key)
.context("Looking up DCSR for pubkey")?
{
return Err(anyhow!(
"DCSR has already been issued for key: {}",
dcsr.display()
));
}

// Collect certs for the 4 trust anchors listed in the `root_labels`.
// These are the 4 trust anchors trusted by the lpc55 verified boot.
let mut certs: Vec<Certificate> = Vec::new();
Expand All @@ -503,6 +597,8 @@ impl Ca {
let cert = self.cert()?;
let signer_public_key = lpc55_sign::cert::public_key(&cert)?;

// lpc55_sign ergonomics
let debug_public_key = spec.dcsr.debug_public_key.clone();
// Construct the to-be-signed debug credential
let dc_tbs = lpc55_sign::debug_auth::debug_credential_tbs(
certs,
Expand All @@ -519,6 +615,17 @@ impl Ca {
dc.extend_from_slice(&dc_tbs);
dc.extend_from_slice(&dc_sig.into_vec());

// We do not fail this function if writing the signed DAC to the
// DacStore fails because it has already been signed. Returning it to
// the caller is paramount. We can fixup the DacStore in post.
if let Err(e) = self.dacs.add(&debug_public_key, &dc) {
error!(
"DAC was signed successfully but we failed to write it to the \
DacStore: {}",
e
);
}

Ok(dc)
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ use yubihsm::{
pub const KEYSPEC_EXT: &str = ".keyspec.json";
pub const CSRSPEC_EXT: &str = ".csrspec.json";
pub const DCSRSPEC_EXT: &str = ".dcsrspec.json";
// when we write out signed debug credentials to the file system this suffix
// is appended
pub const DCSR_EXT: &str = "dc.bin";

#[derive(Error, Debug)]
pub enum ConfigError {
Expand Down
35 changes: 22 additions & 13 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ use env_logger::Builder;
use log::{debug, error, info, LevelFilter};
use std::{
collections::HashMap,
env, fs,
env,
ffi::OsStr,
fs,
ops::{Deref, DerefMut},
path::{Path, PathBuf},
str::FromStr,
Expand All @@ -19,10 +21,10 @@ use zeroize::Zeroizing;
use oks::{
alphabet::Alphabet,
backup::{BackupKey, Share, Verifier, LIMIT, THRESHOLD},
ca::{Ca, CertOrCsr},
ca::{Ca, CertOrCsr, DacStore},
config::{
self, CsrSpec, DcsrSpec, KeySpec, Transport, CSRSPEC_EXT, DCSRSPEC_EXT,
KEYSPEC_EXT,
DCSR_EXT, KEYSPEC_EXT,
},
hsm::Hsm,
secret_reader::{
Expand All @@ -41,16 +43,15 @@ const VERIFIER_PATH: &str = "/usr/share/oks/verifier.json";

const OUTPUT_PATH: &str = "/var/lib/oks";
const STATE_PATH: &str = "/var/lib/oks/ca-state";
// Name of directory where we store signed DACs. The caller can override the
// default location of the ca-state but DAC_DIR will always be in ca-state.
const DAC_DIR: &str = "dacs";

const GEN_PASSWD_LENGTH: usize = 16;

// when we write out signed certs to the file system this suffix is appended
const CERT_SUFFIX: &str = "cert.pem";

// when we write out signed debug credentials to the file system this suffix
// is appended
const DCSR_SUFFIX: &str = "dc.bin";

// string for environment variable used to pass in the authentication
// password for the HSM
pub const ENV_PASSWORD: &str = "OKS_PASSWORD";
Expand Down Expand Up @@ -479,6 +480,7 @@ pub fn initialize_all_ca<P: AsRef<Path>>(
})?;

let mut map = HashMap::new();
let dcsr_dir = fs::canonicalize(ca_state.as_ref())?.join(DAC_DIR);
for key_spec in paths {
let spec = fs::canonicalize(&key_spec)?;
debug!("canonical KeySpec path: {}", spec.display());
Expand Down Expand Up @@ -520,7 +522,8 @@ pub fn initialize_all_ca<P: AsRef<Path>>(
})?;

//
let ca = Ca::load(ca_dir.as_path())?;
let dcsr_store = DacStore::new(&dcsr_dir)?;
let ca = Ca::load(ca_dir.as_path(), dcsr_store)?;
if map.insert(ca.name(), ca).is_some() {
return Err(anyhow!("duplicate key label"));
}
Expand All @@ -530,17 +533,22 @@ pub fn initialize_all_ca<P: AsRef<Path>>(
}

pub fn load_all_ca<P: AsRef<Path>>(ca_state: P) -> Result<HashMap<String, Ca>> {
// find all directories under `ca_state`
// for each directory in `ca_state`, Ca::load(directory)
// insert into hash map
// all CAs share a common directory tracking DCSRs issued
let dacs = fs::canonicalize(ca_state.as_ref())?.join(DAC_DIR);

// find all directories under `ca_state` that aren't 'dcsrs'
// for each directory in `ca_state`, assume it's an openssl CA, Ca::load()
// it, then insert into hash map
let dirs: Vec<PathBuf> = fs::read_dir(ca_state.as_ref())?
.filter(|x| x.is_ok()) // filter out error variant to make unwrap safe
.map(|r| r.unwrap().path()) // get paths
.filter(|x| x.is_dir()) // filter out every path that isn't a directory
.filter(|x| x.file_name() != Some(OsStr::new(DAC_DIR))) // filter out non-CA directories
.collect();
let mut cas: HashMap<String, Ca> = HashMap::new();
for dir in dirs {
let ca = Ca::load(dir)?;
let dac_store = DacStore::new(&dacs)?;
let ca = Ca::load(dir, dac_store)?;
if cas.insert(ca.name(), ca).is_some() {
return Err(anyhow!("found CA with duplicate key label"));
}
Expand Down Expand Up @@ -670,7 +678,7 @@ pub fn sign_all<P: AsRef<Path>>(
false,
transport,
)?;
(DCSR_SUFFIX, sign_dcsrspec(path, cas, &mut hsm)?)
(DCSR_EXT, sign_dcsrspec(path, cas, &mut hsm)?)
} else {
return Err(anyhow!("Unknown input spec: {}", path.display()));
};
Expand Down Expand Up @@ -698,6 +706,7 @@ fn main() -> Result<()> {

make_dir(&args.output)?;
make_dir(&args.state)?;
make_dir(&Path::new(&args.state).join(DAC_DIR))?;

match args.command {
Command::Ca {
Expand Down
Loading