From b47b01ddcfa2a2e7dfc7e4f5d56a67b6bd31c537 Mon Sep 17 00:00:00 2001 From: echo-ilabs Date: Tue, 21 Jul 2026 12:07:22 -0400 Subject: [PATCH] Add an external-signer path for third-party blocks create_block requires the raw PrivateKey, which rules out keys held in HSMs, TPMs and secure enclaves: they will sign bytes for you, but never release the key. Split the operation in two: prepare_block returns the exact bytes to sign, and with_external_signature assembles the block from a signature produced wherever the key lives, verifying it up front so mistakes fail at assembly rather than at token deserialization. create_block is now built on top of the split path, so the two stay equivalent by construction. --- biscuit-auth/src/lib.rs | 2 +- biscuit-auth/src/token/third_party.rs | 195 ++++++++++++++++++++++++-- 2 files changed, 187 insertions(+), 10 deletions(-) diff --git a/biscuit-auth/src/lib.rs b/biscuit-auth/src/lib.rs index 3648912f..41a7ff8a 100644 --- a/biscuit-auth/src/lib.rs +++ b/biscuit-auth/src/lib.rs @@ -259,7 +259,7 @@ pub use token::builder_ext; pub use token::unverified::UnverifiedBiscuit; pub use token::Biscuit; pub use token::RootKeyProvider; -pub use token::{ThirdPartyBlock, ThirdPartyRequest}; +pub use token::{ThirdPartyBlock, ThirdPartyRequest, ThirdPartyUnsignedBlock}; #[cfg(feature = "bwk")] mod bwk; diff --git a/biscuit-auth/src/token/third_party.rs b/biscuit-auth/src/token/third_party.rs index 7150c96a..0d8dd5d8 100644 --- a/biscuit-auth/src/token/third_party.rs +++ b/biscuit-auth/src/token/third_party.rs @@ -98,6 +98,30 @@ impl ThirdPartyRequest { private_key: &PrivateKey, block_builder: BlockBuilder, ) -> Result { + let unsigned = self.prepare_block(block_builder)?; + + let keypair = KeyPair::from(private_key); + let signature = keypair.sign(unsigned.bytes_to_sign())?; + let public_key = keypair.public(); + + unsigned.with_external_signature(public_key, signature.to_bytes()) + } + + /// Prepares a third-party block for an external signer. + /// + /// Some keys can never be handed over as a [`PrivateKey`]: HSMs, TPMs and + /// secure enclaves will sign bytes for you, but won't give up the key. + /// This splits [`ThirdPartyRequest::create_block`] in two so those signers + /// work: take [`ThirdPartyUnsignedBlock::bytes_to_sign`], sign it wherever + /// the key lives, then assemble the block with + /// [`ThirdPartyUnsignedBlock::with_external_signature`]. + /// + /// If the key is available in memory, `create_block` still does both steps + /// in one call (it is built on top of this). + pub fn prepare_block( + self, + block_builder: BlockBuilder, + ) -> Result { let symbols = SymbolTable::new(); let mut block = block_builder.build(symbols); block.version = max(super::DATALOG_3_2, block.version); @@ -109,25 +133,60 @@ impl ThirdPartyRequest { error::Format::SerializationError(format!("serialization error: {e:?}")) })?; - let signed_payload = generate_external_signature_payload_v1( + let bytes_to_sign = generate_external_signature_payload_v1( &payload, &self.previous_signature, THIRD_PARTY_SIGNATURE_VERSION, ); - let keypair = KeyPair::from(private_key); - let signature = keypair.sign(&signed_payload)?; - - let public_key = keypair.public(); - let content = schema::ThirdPartyBlockContents { + Ok(ThirdPartyUnsignedBlock { payload, + bytes_to_sign, + }) + } +} + +/// A third-party block waiting for its external signature. +/// +/// Sign exactly [`ThirdPartyUnsignedBlock::bytes_to_sign`] (Ed25519 signs the +/// bytes directly; ECDSA P-256 signs their SHA-256, which is what hardware +/// modules do natively), then call +/// [`ThirdPartyUnsignedBlock::with_external_signature`]. +#[derive(Clone, Debug)] +pub struct ThirdPartyUnsignedBlock { + payload: Vec, + bytes_to_sign: Vec, +} + +impl ThirdPartyUnsignedBlock { + /// The exact bytes the external signer must sign. + pub fn bytes_to_sign(&self) -> &[u8] { + &self.bytes_to_sign + } + + /// Assembles the block from an externally produced signature. + /// + /// The signature is checked against `public_key` right here, so a wrong + /// key or a corrupted signature fails immediately instead of later, when + /// the token is deserialized. + pub fn with_external_signature( + self, + public_key: crate::PublicKey, + signature_bytes: &[u8], + ) -> Result { + let signature = + crate::crypto::Signature::from_bytes(signature_bytes).map_err(error::Token::Format)?; + public_key + .verify_signature(&self.bytes_to_sign, &signature) + .map_err(error::Token::Format)?; + + Ok(ThirdPartyBlock(schema::ThirdPartyBlockContents { + payload: self.payload, external_signature: schema::ExternalSignature { signature: signature.to_bytes().to_vec(), public_key: public_key.to_proto(), }, - }; - - Ok(ThirdPartyBlock(content)) + })) } } @@ -157,6 +216,124 @@ impl ThirdPartyBlock { mod tests { use super::*; + /// A token and an external keypair for the external-signing tests. + fn setup( + external_alg: crate::builder::Algorithm, + ) -> (KeyPair, crate::Biscuit, KeyPair, BlockBuilder) { + let mut rng: rand::rngs::StdRng = rand::SeedableRng::seed_from_u64(42); + let root = KeyPair::new_with_rng(crate::builder::Algorithm::Ed25519, &mut rng); + let biscuit = crate::Biscuit::builder() + .fact("right(\"file1\", \"read\")") + .unwrap() + .build_with_rng(&root, crate::token::default_symbol_table(), &mut rng) + .unwrap(); + let external = KeyPair::new_with_rng(external_alg, &mut rng); + let block = BlockBuilder::new() + .fact("external_fact(\"hello\")") + .unwrap(); + (root, biscuit, external, block) + } + + /// The prepare/sign/assemble path must produce a block a standard + /// verifier accepts, without the private key ever entering the library. + /// The "external signer" here only ever sees the bytes to sign -- exactly + /// what an HSM would. + #[test] + fn external_signature_round_trip_ed25519() { + let (root, biscuit, external, block) = setup(crate::builder::Algorithm::Ed25519); + + let unsigned = biscuit + .third_party_request() + .unwrap() + .prepare_block(block) + .unwrap(); + let signature = external.sign(unsigned.bytes_to_sign()).unwrap(); + let third_party_block = unsigned + .with_external_signature(external.public(), signature.to_bytes()) + .unwrap(); + + let biscuit2 = biscuit + .append_third_party(external.public(), third_party_block) + .unwrap(); + // Re-parsing verifies every signature in the chain, external included. + crate::Biscuit::from(biscuit2.to_vec().unwrap(), root.public()).unwrap(); + } + + #[test] + fn external_signature_round_trip_p256() { + let (root, biscuit, external, block) = setup(crate::builder::Algorithm::Secp256r1); + + let unsigned = biscuit + .third_party_request() + .unwrap() + .prepare_block(block) + .unwrap(); + let signature = external.sign(unsigned.bytes_to_sign()).unwrap(); + let third_party_block = unsigned + .with_external_signature(external.public(), signature.to_bytes()) + .unwrap(); + + let biscuit2 = biscuit + .append_third_party(external.public(), third_party_block) + .unwrap(); + crate::Biscuit::from(biscuit2.to_vec().unwrap(), root.public()).unwrap(); + } + + /// Deterministic Ed25519: the split path and create_block must produce + /// byte-identical blocks for the same key and content. + #[test] + fn external_signature_matches_create_block_ed25519() { + let (_root, biscuit, external, block) = setup(crate::builder::Algorithm::Ed25519); + + let via_create = biscuit + .third_party_request() + .unwrap() + .create_block(&external.private(), block.clone()) + .unwrap(); + + let unsigned = biscuit + .third_party_request() + .unwrap() + .prepare_block(block) + .unwrap(); + let signature = external.sign(unsigned.bytes_to_sign()).unwrap(); + let via_external = unsigned + .with_external_signature(external.public(), signature.to_bytes()) + .unwrap(); + + assert_eq!( + via_create.serialize_base64().unwrap(), + via_external.serialize_base64().unwrap() + ); + } + + /// A corrupted signature or a mismatched key fails at assembly, not + /// later at token deserialization. + #[test] + fn external_signature_rejects_tampering_and_wrong_key() { + let (_root, biscuit, external, block) = setup(crate::builder::Algorithm::Ed25519); + + let unsigned = biscuit + .third_party_request() + .unwrap() + .prepare_block(block) + .unwrap(); + let signature = external.sign(unsigned.bytes_to_sign()).unwrap(); + + let mut tampered = signature.to_bytes().to_vec(); + tampered[0] ^= 0x01; + assert!(unsigned + .clone() + .with_external_signature(external.public(), &tampered) + .is_err()); + + let mut rng: rand::rngs::StdRng = rand::SeedableRng::seed_from_u64(7); + let other = KeyPair::new_with_rng(crate::builder::Algorithm::Ed25519, &mut rng); + assert!(unsigned + .with_external_signature(other.public(), signature.to_bytes()) + .is_err()); + } + #[test] fn third_party_request_roundtrip() { let mut rng: rand::rngs::StdRng = rand::SeedableRng::seed_from_u64(0);