use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64;
use serde::{Deserialize, Serialize};
use crate::sealed_transfer::{
AssertionProof, AttestationQuoteAssertion, ProducerAssertion, SealedTransferError,
};
pub const CLAIM_VERSION: u8 = 2;
const CLAIM_SIGNATURE_DOMAIN: &[u8] = b"vti/bootstrap-claim/v2\0";
pub fn claim_signing_input(
client_ed25519_pub: &[u8; 32],
nonce: &[u8; 16],
vta_did: &str,
) -> Vec<u8> {
let mut input = Vec::with_capacity(CLAIM_SIGNATURE_DOMAIN.len() + 32 + 16 + vta_did.len());
input.extend_from_slice(CLAIM_SIGNATURE_DOMAIN);
input.extend_from_slice(client_ed25519_pub);
input.extend_from_slice(nonce);
input.extend_from_slice(vta_did.as_bytes());
input
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct FirstBootClaim {
pub version: u8,
pub client_did: String,
pub vta_did: String,
pub nonce: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub claim_signature: String,
}
impl FirstBootClaim {
pub fn new_signed(
client_ed25519_seed: &[u8; 32],
vta_did: &str,
nonce: [u8; 16],
label: Option<String>,
) -> Self {
use ed25519_dalek::Signer;
let signing = ed25519_dalek::SigningKey::from_bytes(client_ed25519_seed);
let public = signing.verifying_key().to_bytes();
let signature = signing.sign(&claim_signing_input(&public, &nonce, vta_did));
Self {
version: CLAIM_VERSION,
client_did: affinidi_crypto::did_key::ed25519_pub_to_did_key(&public),
vta_did: vta_did.to_string(),
nonce: BASE64.encode(nonce),
label,
claim_signature: BASE64.encode(signature.to_bytes()),
}
}
pub fn verify(&self) -> Result<VerifiedFirstBootClaim, SealedTransferError> {
if self.version != CLAIM_VERSION {
return Err(SealedTransferError::UnsupportedVersion(self.version));
}
if !self.vta_did.starts_with("did:") {
return Err(SealedTransferError::Wire("vtaDid must be a DID".into()));
}
let public = affinidi_crypto::did_key::did_key_to_ed25519_pub(&self.client_did)
.map_err(|e| SealedTransferError::Wire(format!("clientDid: {e}")))?;
let nonce: [u8; 16] = BASE64
.decode(&self.nonce)
.map_err(|e| SealedTransferError::Base64(e.to_string()))?
.try_into()
.map_err(|_| SealedTransferError::Wire("nonce must be 16 bytes".into()))?;
let raw: [u8; 64] = BASE64
.decode(&self.claim_signature)
.map_err(|e| SealedTransferError::Base64(e.to_string()))?
.try_into()
.map_err(|_| SealedTransferError::Wire("claimSignature must be 64 bytes".into()))?;
let key = ed25519_dalek::VerifyingKey::from_bytes(&public)
.map_err(|e| SealedTransferError::Wire(format!("clientDid key: {e}")))?;
key.verify_strict(
&claim_signing_input(&public, &nonce, &self.vta_did),
&ed25519_dalek::Signature::from_bytes(&raw),
)
.map_err(|_| SealedTransferError::Wire("claimSignature does not verify".into()))?;
Ok(VerifiedFirstBootClaim {
client_did: self.client_did.clone(),
client_ed25519_pub: public,
vta_did: self.vta_did.clone(),
nonce,
label: self.label.clone(),
})
}
}
#[derive(Debug, Clone)]
pub struct VerifiedFirstBootClaim {
client_did: String,
client_ed25519_pub: [u8; 32],
vta_did: String,
nonce: [u8; 16],
label: Option<String>,
}
impl VerifiedFirstBootClaim {
pub fn client_did(&self) -> &str {
&self.client_did
}
pub fn client_ed25519_pub(&self) -> &[u8; 32] {
&self.client_ed25519_pub
}
pub fn vta_did(&self) -> &str {
&self.vta_did
}
pub fn nonce(&self) -> &[u8; 16] {
&self.nonce
}
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct FirstBootClaimInfo {
pub version: u8,
pub vta_did: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct FirstBootAttestation {
pub format: String,
pub quote_b64: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct FirstBootAck {
pub version: u8,
pub vta_did: String,
pub admin_did: String,
pub expires_at: u64,
pub producer_did: String,
pub attestation: FirstBootAttestation,
}
impl FirstBootAck {
pub fn producer_assertion(&self) -> ProducerAssertion {
ProducerAssertion {
producer_did: self.producer_did.clone(),
proof: AssertionProof::Attested(AttestationQuoteAssertion {
format: self.attestation.format.clone(),
quote_b64: self.attestation.quote_b64.clone(),
}),
}
}
#[cfg(feature = "attest-verify")]
pub fn verify(
self,
claim: &VerifiedFirstBootClaim,
) -> Result<VerifiedFirstBootAck, FirstBootAckError> {
if self.version != CLAIM_VERSION {
return Err(FirstBootAckError::Mismatch(format!(
"acknowledgement version {} (expected {CLAIM_VERSION})",
self.version
)));
}
if self.admin_did != claim.client_did() {
return Err(FirstBootAckError::Mismatch(format!(
"acknowledgement enrols {} but the claim was for {}",
self.admin_did,
claim.client_did()
)));
}
if self.vta_did != claim.vta_did() {
return Err(FirstBootAckError::Mismatch(format!(
"acknowledgement names VTA {} but the claim was signed for {}",
self.vta_did,
claim.vta_did()
)));
}
let attestation = crate::attestation::verify_nitro_assertion(
&self.producer_assertion(),
claim.client_ed25519_pub(),
claim.nonce(),
)?;
Ok(VerifiedFirstBootAck {
ack: self,
attestation,
})
}
}
#[cfg(feature = "attest-verify")]
#[derive(Debug, thiserror::Error)]
pub enum FirstBootAckError {
#[error("{0}")]
Mismatch(String),
#[error("attestation: {0}")]
Attestation(#[from] crate::attestation::AttestationVerifyError),
}
#[cfg(feature = "attest-verify")]
#[derive(Debug)]
pub struct VerifiedFirstBootAck {
ack: FirstBootAck,
attestation: crate::attestation::VerifiedAttestation,
}
#[cfg(feature = "attest-verify")]
impl VerifiedFirstBootAck {
pub fn ack(&self) -> &FirstBootAck {
&self.ack
}
pub fn attestation(&self) -> &crate::attestation::VerifiedAttestation {
&self.attestation
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sealed_transfer::generate_ed25519_keypair;
const VTA: &str = "did:webvh:QmScid:vta.example.com";
#[test]
fn signed_claim_verifies_and_round_trips() {
let (seed, public) = generate_ed25519_keypair();
let claim = FirstBootClaim::new_signed(&seed, VTA, [7; 16], Some("ops".into()));
let json = serde_json::to_value(&claim).unwrap();
assert!(json.get("clientDid").is_some() && json.get("claimSignature").is_some());
let back: FirstBootClaim = serde_json::from_value(json).unwrap();
let verified = back.verify().unwrap();
assert_eq!(verified.client_ed25519_pub(), &public);
assert_eq!(verified.vta_did(), VTA);
assert_eq!(verified.nonce(), &[7; 16]);
}
#[test]
fn signature_binds_key_nonce_and_vta_did() {
let (seed, _) = generate_ed25519_keypair();
let signed = FirstBootClaim::new_signed(&seed, VTA, [7; 16], None);
let mut other_vta = signed.clone();
other_vta.vta_did = "did:webvh:QmOther:evil.example.com".into();
assert!(other_vta.verify().is_err(), "the VTA DID is signed");
let mut renonced = signed.clone();
renonced.nonce = BASE64.encode([8u8; 16]);
assert!(renonced.verify().is_err(), "the nonce is signed");
let (_, other_pub) = generate_ed25519_keypair();
let mut swapped = signed.clone();
swapped.client_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&other_pub);
assert!(swapped.verify().is_err(), "the key is the signer");
let mut garbled = signed;
garbled.claim_signature = "AAAA".into();
assert!(garbled.verify().is_err());
}
#[test]
fn version_one_signature_is_refused() {
use ed25519_dalek::Signer;
let (seed, public) = generate_ed25519_keypair();
let nonce = [9u8; 16];
let mut v1_input = b"vti/bootstrap-claim/v1\0".to_vec();
v1_input.extend_from_slice(&public);
v1_input.extend_from_slice(&nonce);
let sig = ed25519_dalek::SigningKey::from_bytes(&seed).sign(&v1_input);
let mut claim = FirstBootClaim::new_signed(&seed, VTA, nonce, None);
claim.claim_signature = BASE64.encode(sig.to_bytes());
assert!(claim.verify().is_err());
let mut v1 = FirstBootClaim::new_signed(&seed, VTA, nonce, None);
v1.version = 1;
assert!(matches!(
v1.verify(),
Err(SealedTransferError::UnsupportedVersion(1))
));
}
#[test]
fn unknown_members_are_refused() {
let (seed, _) = generate_ed25519_keypair();
let mut json =
serde_json::to_value(FirstBootClaim::new_signed(&seed, VTA, [1; 16], None)).unwrap();
json["requestedRole"] = "admin".into();
assert!(serde_json::from_value::<FirstBootClaim>(json).is_err());
}
}