Skip to content

Commit 2040dc2

Browse files
committed
Implement DCSR tracking in ca-state.
OKS as a whole will issue only one DAC per public key. We implement this using the file system / a common directory along with a file naming scheme described in docs/debug-credentials.md.
1 parent 3944407 commit 2040dc2

File tree

6 files changed

+191
-16
lines changed

6 files changed

+191
-16
lines changed

Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,4 @@ zeroize = "1.8.1"
3939
zeroize_derive = "1.4.2"
4040
glob = "0.3.2"
4141
rsa = "0.9.3"
42+
sha2 = "0.10.8"

docs/debug-credentials.md

+54
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,57 @@ The public key for the signer, the collection of trust anchors, and the DCSR are
2626
The output is the DAC.
2727

2828
Most of the hard work in this process is done by the [lpc55_support](https://github.com/oxidecomputer/lpc55_support) crate.
29+
30+
## Issuance Policy
31+
32+
RFD 5280 defines policies that certificate authorities implement and abide by (at least that's what they're supposed to do).
33+
DACs do not come with such guidelines but we do want to put some restrictions on their issuance.
34+
OKS is the policy enforcement point by virtue of hosting the trust anchors that must sign the DAC.
35+
36+
### One key, one DAC
37+
38+
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.
39+
We would be trusting them to use each DAC in the appropriate context.
40+
One could then attack the key holder by attempting to confuse them into using the key in the wrong context.
41+
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.
42+
For now we're below this threshold and assume that will remain a constant.
43+
44+
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.
45+
This requires we iterate over all previously issued DACs.
46+
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.
47+
Instead we can use the file system much like the `openssl ca` command.
48+
49+
Reading back all past DACs from the file system could get expensive over time.
50+
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.
51+
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.
52+
We've been using the suffix `dc.bin` when exporting signed DACs and so we'll use that in this case as well.
53+
54+
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.
55+
If a match is found OKS will load this DAC and calculate the digest of the public key inside manually to verify.
56+
57+
### Digest What
58+
59+
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.
60+
For as long as we're using the LPC55 for our RoT these keys will always be RSA keys.
61+
The `DebugCredentialSigningRequest` structure from the `lpc55_support` crate packages the public key in a format specific to RSA.
62+
RSA keys are just two integers so we could run them both through the digest `update` function effectively concatenating them into a single digest.
63+
64+
While we'll be generating these digests in OKS, we want to enable external verification.
65+
Doing this verification requires generating these digests from public keys obtained elsewhere and likely in other (standardized) formats.
66+
These formats are going to either be PKCS#1, or SPKI.
67+
68+
Both of these formats store the public key as a DER encoded structure.
69+
The prior is specific to RSA public keys and so we can hash this structure in its DER form directly:
70+
71+
```shell
72+
openssl rsa -pubin -in path-to-pkcs1.pem -outform DER -RSAPublicKey_out | sha256sum
73+
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -
74+
```
75+
76+
SPKI is conveniently compatible with PKCS#1 in that SPKI prepends an algorithm identifier on to the PKCS#1 DER encoded key.
77+
This allows us to reconstruct our digest from the SPKI encoded key by dropping the first 24 bytes from its DER encoding:
78+
79+
```shell
80+
openssl rsa -pubin -in path-to-spki.pem -outform DER | tail --bytes=+25 | sha256sum
81+
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -
82+
```

src/ca.rs

+110-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44

55
use anyhow::{anyhow, Context, Result};
6+
use hex::ToHex;
67
use log::{debug, error, info, warn};
8+
use rsa::{pkcs1::EncodeRsaPublicKey, RsaPublicKey};
9+
use sha2::{Digest, Sha256};
710
use std::{
811
collections::HashMap,
912
env,
@@ -21,7 +24,7 @@ use x509_cert::{certificate::Certificate, der::DecodePem};
2124
use yubihsm::Client;
2225
use zeroize::Zeroizing;
2326

24-
use crate::config::{CsrSpec, DcsrSpec, KeySpec, Purpose};
27+
use crate::config::{CsrSpec, DcsrSpec, KeySpec, Purpose, DCSR_EXT};
2528

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

216+
pub struct DacStore {
217+
root: PathBuf,
218+
}
219+
220+
impl DacStore {
221+
fn pubkey_to_digest(pubkey: &RsaPublicKey) -> Result<String> {
222+
// calculate sha256(pub_key) where pub_key is the DER encoded RSA key
223+
let der = pubkey
224+
.to_pkcs1_der()
225+
.context("Encode RSA public key as DER")?;
226+
227+
let mut digest = Sha256::new();
228+
digest.update(der.as_bytes());
229+
let digest = digest.finalize();
230+
231+
Ok(digest.encode_hex::<String>())
232+
}
233+
234+
fn pubkey_to_dcsr_path(&self, pubkey: &RsaPublicKey) -> Result<PathBuf> {
235+
let digest = Self::pubkey_to_digest(pubkey)?;
236+
237+
Ok(self.root.as_path().join(format!("{}.{}", digest, DCSR_EXT)))
238+
}
239+
240+
pub fn new<P: AsRef<Path>>(root: P) -> Result<Self> {
241+
// check that the path provided exists
242+
let metadata = fs::metadata(root.as_ref()).with_context(|| {
243+
format!(
244+
"Getting metadata for DacStore root: {}",
245+
root.as_ref().display()
246+
)
247+
})?;
248+
249+
// - is a directory
250+
if !metadata.is_dir() {
251+
return Err(anyhow!("DacStore root is not a directory"));
252+
}
253+
254+
// - we have write access to it
255+
if metadata.permissions().readonly() {
256+
return Err(anyhow!("DacStore directory is not writable"));
257+
}
258+
259+
Ok(Self {
260+
root: PathBuf::from(root.as_ref()),
261+
})
262+
}
263+
264+
pub fn add(&self, pubkey: &RsaPublicKey, dcsr: &[u8]) -> Result<()> {
265+
// Make sure we haven't already issued a DCSR for this key before
266+
// we save it to disk. The caller should perform this check before
267+
// signing the DCSR but we do it here also to keep from overwriting
268+
// an existing one.
269+
if let Some(path) = self.find(pubkey)? {
270+
return Err(anyhow!(
271+
"DCSR for public key exists: {}",
272+
path.display()
273+
));
274+
}
275+
276+
let path = self.pubkey_to_dcsr_path(pubkey)?;
277+
278+
fs::write(&path, dcsr)
279+
.context(format!("Writing DCSR to destination {}", path.display()))
280+
}
281+
282+
pub fn find(&self, pubkey: &RsaPublicKey) -> Result<Option<PathBuf>> {
283+
let path = self.pubkey_to_dcsr_path(pubkey)?;
284+
285+
if fs::exists(&path).with_context(|| {
286+
format!("Checking for the existance of {}", path.display())
287+
})? {
288+
Ok(Some(path))
289+
} else {
290+
Ok(None)
291+
}
292+
}
293+
}
294+
213295
/// The `Ca` type represents the collection of files / metadata that is a
214296
/// certificate authority.
215297
pub struct Ca {
216298
root: PathBuf,
217299
spec: KeySpec,
300+
dacs: DacStore,
218301
}
219302

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

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

234-
Ok(Self { root, spec })
317+
Ok(Self { root, spec, dacs })
235318
}
236319

237320
/// Get the name of the CA in `String` form. A `Ca`s name comes from the
@@ -487,6 +570,17 @@ impl Ca {
487570
client: &Client,
488571
) -> Result<Vec<u8>> {
489572
debug!("signing DcsrSpec: {:?}", spec);
573+
if let Some(dcsr) = self
574+
.dacs
575+
.find(&spec.dcsr.debug_public_key)
576+
.context("Looking up DCSR for pubkey")?
577+
{
578+
return Err(anyhow!(
579+
"DCSR has already been issued for key: {}",
580+
dcsr.display()
581+
));
582+
}
583+
490584
// Collect certs for the 4 trust anchors listed in the `root_labels`.
491585
// These are the 4 trust anchors trusted by the lpc55 verified boot.
492586
let mut certs: Vec<Certificate> = Vec::new();
@@ -503,6 +597,8 @@ impl Ca {
503597
let cert = self.cert()?;
504598
let signer_public_key = lpc55_sign::cert::public_key(&cert)?;
505599

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

618+
// We do not fail this function if writing the signed DAC to the
619+
// DacStore fails because it has already been signed. Returning it to
620+
// the caller is paramount. We can fixup the DacStore in post.
621+
if let Err(e) = self.dacs.add(&debug_public_key, &dc) {
622+
error!(
623+
"DAC was signed successfully but we failed to write it to the \
624+
DacStore: {}",
625+
e
626+
);
627+
}
628+
522629
Ok(dc)
523630
}
524631
}

src/config.rs

+3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ use yubihsm::{
2323
pub const KEYSPEC_EXT: &str = ".keyspec.json";
2424
pub const CSRSPEC_EXT: &str = ".csrspec.json";
2525
pub const DCSRSPEC_EXT: &str = ".dcsrspec.json";
26+
// when we write out signed debug credentials to the file system this suffix
27+
// is appended
28+
pub const DCSR_EXT: &str = "dc.bin";
2629

2730
#[derive(Error, Debug)]
2831
pub enum ConfigError {

src/main.rs

+22-13
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ use env_logger::Builder;
88
use log::{debug, error, info, LevelFilter};
99
use std::{
1010
collections::HashMap,
11-
env, fs,
11+
env,
12+
ffi::OsStr,
13+
fs,
1214
ops::{Deref, DerefMut},
1315
path::{Path, PathBuf},
1416
str::FromStr,
@@ -19,10 +21,10 @@ use zeroize::Zeroizing;
1921
use oks::{
2022
alphabet::Alphabet,
2123
backup::{BackupKey, Share, Verifier, LIMIT, THRESHOLD},
22-
ca::{Ca, CertOrCsr},
24+
ca::{Ca, CertOrCsr, DacStore},
2325
config::{
2426
self, CsrSpec, DcsrSpec, KeySpec, Transport, CSRSPEC_EXT, DCSRSPEC_EXT,
25-
KEYSPEC_EXT,
27+
DCSR_EXT, KEYSPEC_EXT,
2628
},
2729
hsm::Hsm,
2830
secret_reader::{
@@ -41,16 +43,15 @@ const VERIFIER_PATH: &str = "/usr/share/oks/verifier.json";
4143

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

4550
const GEN_PASSWD_LENGTH: usize = 16;
4651

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

50-
// when we write out signed debug credentials to the file system this suffix
51-
// is appended
52-
const DCSR_SUFFIX: &str = "dc.bin";
53-
5455
// string for environment variable used to pass in the authentication
5556
// password for the HSM
5657
pub const ENV_PASSWORD: &str = "OKS_PASSWORD";
@@ -479,6 +480,7 @@ pub fn initialize_all_ca<P: AsRef<Path>>(
479480
})?;
480481

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

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

532535
pub fn load_all_ca<P: AsRef<Path>>(ca_state: P) -> Result<HashMap<String, Ca>> {
533-
// find all directories under `ca_state`
534-
// for each directory in `ca_state`, Ca::load(directory)
535-
// insert into hash map
536+
// all CAs share a common directory tracking DCSRs issued
537+
let dacs = fs::canonicalize(ca_state.as_ref())?.join(DAC_DIR);
538+
539+
// find all directories under `ca_state` that aren't 'dcsrs'
540+
// for each directory in `ca_state`, assume it's an openssl CA, Ca::load()
541+
// it, then insert into hash map
536542
let dirs: Vec<PathBuf> = fs::read_dir(ca_state.as_ref())?
537543
.filter(|x| x.is_ok()) // filter out error variant to make unwrap safe
538544
.map(|r| r.unwrap().path()) // get paths
539545
.filter(|x| x.is_dir()) // filter out every path that isn't a directory
546+
.filter(|x| x.file_name() != Some(OsStr::new(DAC_DIR))) // filter out non-CA directories
540547
.collect();
541548
let mut cas: HashMap<String, Ca> = HashMap::new();
542549
for dir in dirs {
543-
let ca = Ca::load(dir)?;
550+
let dac_store = DacStore::new(&dacs)?;
551+
let ca = Ca::load(dir, dac_store)?;
544552
if cas.insert(ca.name(), ca).is_some() {
545553
return Err(anyhow!("found CA with duplicate key label"));
546554
}
@@ -670,7 +678,7 @@ pub fn sign_all<P: AsRef<Path>>(
670678
false,
671679
transport,
672680
)?;
673-
(DCSR_SUFFIX, sign_dcsrspec(path, cas, &mut hsm)?)
681+
(DCSR_EXT, sign_dcsrspec(path, cas, &mut hsm)?)
674682
} else {
675683
return Err(anyhow!("Unknown input spec: {}", path.display()));
676684
};
@@ -698,6 +706,7 @@ fn main() -> Result<()> {
698706

699707
make_dir(&args.output)?;
700708
make_dir(&args.state)?;
709+
make_dir(&Path::new(&args.state).join(DAC_DIR))?;
701710

702711
match args.command {
703712
Command::Ca {

0 commit comments

Comments
 (0)