From 92fcef2b4b5985da23d2dd4acf285144f2a6c330 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sun, 30 Aug 2026 17:06:41 -0400 Subject: [PATCH] feat(attestation): add the AWS EC2 instance identity backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first evidence this project admits that somebody other than us signed. EC2 exposes a JSON instance identity document and a detached PKCS#7/RSA-2048 signature over it via IMDSv2; this backend checks that signature against regional AWS certificates configured by the deployment, and refuses a tampered document, a signature from the wrong key, or one already spent. The format carries no audience and no expiry, so admission is bound to the seen-nonce store instead, keyed on the document's instanceId and pendingTime (never a renameable tag). node_id is never trusted from the wire claim alone: attest_at re-derives it from the verified document and refuses a mismatch, so a spoofed envelope cannot borrow a genuine signature's admission. A stale pendingTime is refused through the same Stale error the previous commit sharpened, and admission records ExecutionEnvironment::CloudInstance. No CMS/PKCS#7 crate in the registry is compatible with the der 0.7 line already in this workspace's dependency graph without doubling it (cms exists only as 0.3.0-pre.2, and pulls der 0.8) — see the workspace Cargo.toml's note on jacquard for why that duplication is a real hazard here. So SignedData parsing is hand-rolled against der/spki/x509-cert/rsa, all pinned to versions already resolved elsewhere in the tree, verified by `cargo tree` to add no second copy of der, spki, const-oid, pkcs8 or sha2. Tested against a real AWS RSA-2048 certificate for us-east-1 (published in AWS's docs) for certificate loading, and against a full attest_at round trip signed with a locally generated key for the signature-verification path itself, since AWS does not publish a matching (document, signature) pair — only certificates. The true end-to-end path can only be exercised from a running EC2 instance. Co-Authored-By: Claude Sonnet 5 Change-Id: Ie494e6cc2f9b983e6a704fdbafc66b1df4bac4d5 --- Cargo.lock | 161 ++++- crates/didbot-attest/Cargo.toml | 9 +- crates/didbot-attest/src/aws/document.rs | 51 ++ crates/didbot-attest/src/aws/mod.rs | 562 ++++++++++++++++++ crates/didbot-attest/src/aws/pkcs7.rs | 338 +++++++++++ .../src/aws/testdata/us-east-1-rsa2048.pem | 24 + crates/didbot-attest/src/lib.rs | 10 +- plan/attestation.md | 40 +- 8 files changed, 1178 insertions(+), 17 deletions(-) create mode 100644 crates/didbot-attest/src/aws/document.rs create mode 100644 crates/didbot-attest/src/aws/mod.rs create mode 100644 crates/didbot-attest/src/aws/pkcs7.rs create mode 100644 crates/didbot-attest/src/aws/testdata/us-east-1-rsa2048.pem diff --git a/Cargo.lock b/Cargo.lock index 6df6e4c3..3bd6753b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,10 +573,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -615,16 +628,21 @@ dependencies = [ name = "didbot-attest" version = "0.1.0" dependencies = [ + "der", "didbot-key", "didbot-lexicon", "hex", "hmac", + "rand 0.8.7", + "rsa", "serde", "serde_json", "sha2", + "spki", "subtle", "thiserror", "time", + "x509-cert", ] [[package]] @@ -1006,6 +1024,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "fluent-uri" version = "0.4.1" @@ -1623,6 +1647,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.9", +] [[package]] name = "libc" @@ -1630,6 +1657,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "litemap" version = "0.8.3" @@ -1792,12 +1825,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1805,6 +1873,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1944,6 +2013,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -2108,6 +2188,8 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -2117,7 +2199,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -2132,6 +2214,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2340,6 +2432,26 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2846,6 +2958,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -3443,6 +3576,18 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", + "tls_codec", +] + [[package]] name = "yoke" version = "0.8.3" @@ -3512,6 +3657,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/crates/didbot-attest/Cargo.toml b/crates/didbot-attest/Cargo.toml index 57b05785..0e48a298 100644 --- a/crates/didbot-attest/Cargo.toml +++ b/crates/didbot-attest/Cargo.toml @@ -9,16 +9,23 @@ repository.workspace = true publish.workspace = true [dependencies] +der = { version = "0.7.10", default-features = false, features = ["alloc", "derive", "oid"] } didbot-key.workspace = true didbot-lexicon.workspace = true hex.workspace = true hmac.workspace = true +rsa = { version = "0.9.8", default-features = false, features = ["std", "pem"] } serde.workspace = true serde_json.workspace = true -sha2.workspace = true +sha2 = { workspace = true, features = ["oid"] } +spki = { version = "0.7.3", default-features = false, features = ["alloc"] } subtle.workspace = true thiserror.workspace = true time.workspace = true +x509-cert = { version = "0.2.5", default-features = false, features = ["std", "pem"] } [lints] workspace = true + +[dev-dependencies] +rand = "0.8" diff --git a/crates/didbot-attest/src/aws/document.rs b/crates/didbot-attest/src/aws/document.rs new file mode 100644 index 00000000..2bb20257 --- /dev/null +++ b/crates/didbot-attest/src/aws/document.rs @@ -0,0 +1,51 @@ +//! The EC2 instance identity document's shape, and nothing this backend +//! does not read. +//! +//! AWS documents many more fields than these — `imageId`, `architecture`, +//! `privateIp`, and others — and this backend has no use for any of them. +//! `serde` ignores unknown fields by default, so a field AWS adds later +//! parses cleanly here without a code change; the same is true of a field +//! this deployment's evidence happens to omit, so long as it is not one of +//! the four below. + +use serde::Deserialize; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; + +use crate::claim::AttestError; + +/// The fields this backend reads out of an instance identity document. +/// +/// `instanceId` and `accountId` are AWS's stable identifiers — assigned at +/// creation, never reused, never renamed. `region` picks which of this +/// deployment's configured certificates to verify against. `pendingTime` is +/// the moment this document was generated, which stands in for both a +/// freshness timestamp and, paired with `instanceId`, the tuple the +/// seen-nonce store spends: see [`super::AwsInstanceIdentityBackend`]. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InstanceIdentityDocument { + pub(crate) instance_id: String, + pub(crate) account_id: String, + pub(crate) region: String, + pub(crate) pending_time: String, +} + +impl InstanceIdentityDocument { + pub(crate) fn parse(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) + .map_err(|e| AttestError::Malformed(format!("instance identity document: {e}"))) + } + + /// `pendingTime`, parsed. AWS documents this as RFC 3339, matching the + /// example this backend's tests were written against + /// (`2016-11-19T16:32:11Z`). + pub(crate) fn pending_time(&self) -> Result { + OffsetDateTime::parse(&self.pending_time, &Rfc3339).map_err(|e| { + AttestError::Malformed(format!( + "instance identity document: pendingTime `{}`: {e}", + self.pending_time + )) + }) + } +} diff --git a/crates/didbot-attest/src/aws/mod.rs b/crates/didbot-attest/src/aws/mod.rs new file mode 100644 index 00000000..2c63964b --- /dev/null +++ b/crates/didbot-attest/src/aws/mod.rs @@ -0,0 +1,562 @@ +//! The AWS EC2 instance identity backend. +//! +//! EC2 exposes, to code running on the instance, a JSON *instance identity +//! document* and a detached PKCS#7 signature over it — signed by AWS, with a +//! per-region RSA-2048 key, retrievable only from the Instance Metadata +//! Service (IMDS) on that instance. This backend does not talk to IMDS +//! itself; see [`AwsInstanceIdentityBackend::claim_from_parts`] for why that +//! is a caller's job, not this crate's. Given a document and a signature, it +//! checks the signature against a certificate this deployment was configured +//! with, and nothing else. +//! +//! # What this backend proves, and what it does not +//! +//! **Proves:** the document was produced by AWS's control plane for the +//! instance and account it names, at the `pendingTime` it carries. That +//! instance existed, in that account, and asked for this at that moment. +//! +//! **Does not prove:** what code is running on the instance now, or that the +//! caller presenting this claim to the provisioning endpoint is the instance +//! itself rather than something that captured the document and signature off +//! it — which is exactly why every claim is spent by [`SeenNonces`] the +//! moment it is admitted, and why [`with_window`](AwsInstanceIdentityBackend::with_window) +//! keeps a captured claim from being useful for long. It also does not prove +//! the instance still exists: EC2 does not revoke a signature when an +//! instance is terminated, so this is a claim about instance creation, read +//! once, not an ongoing credential — matching every other backend in this +//! crate, and explicitly not what [`Assurance::PlatformAttested`] promises. +//! +//! # The two properties this document format does not have +//! +//! An instance identity document carries no audience — nothing in it says +//! *this claim is for didbot* rather than for any other service an instance +//! might present it to — and no expiry. A JWT-shaped credential would refuse +//! itself once its `exp` passed; this format never does, so a captured +//! document-and-signature pair is valid for as long as anyone still trusts +//! that AWS certificate. Both gaps are closed the same way every other +//! backend in this crate closes replay: a freshness window bounds how old +//! `pendingTime` may be, and [`SeenNonces`] refuses a claim already admitted +//! within it. Binding the seen-nonce tuple to `instanceId` and `pendingTime` +//! — both supplied by AWS, inside the signed document, and out of this +//! backend's control — is what stands in for a nonce this format has no +//! field for. +//! +//! # Why the binding is `accountId` and `instanceId`, and not a name +//! +//! EC2 instances have no name AWS itself guarantees stays put — a `Name` tag +//! is operator-assigned and renameable at any time. `instanceId` and +//! `accountId` are AWS's own stable identifiers: assigned once, at creation, +//! never reused and never renamed. Binding admission to those, rather than +//! to anything readable, is what keeps a tag rename from silently becoming +//! an admission failure — or worse, from silently binding the wrong node. + +mod document; +mod pkcs7; + +use std::collections::{BTreeMap, BTreeSet}; + +use rsa::RsaPublicKey; +use time::{Duration, OffsetDateTime}; + +use crate::backend::AttestationBackend; +use crate::claim::{Assurance, AttestError, AttestationClaim, ExecutionEnvironment, Provenance}; +use crate::seen::SeenNonces; +use crate::shared_secret::DEFAULT_WINDOW_SECS; +use document::InstanceIdentityDocument; + +/// Verifies AWS EC2 instance identity documents against this deployment's +/// configured regional certificates. +/// +/// The certificate registry doubles as the region allowlist, the same way +/// [`NodeCredentialBackend`](crate::NodeCredentialBackend)'s key registry +/// doubles as its node allowlist: a region with no certificate configured has +/// no way to produce a claim this backend can verify. `accounts` is a +/// separate allowlist on top of that, because a correct AWS signature only +/// proves *an* AWS account asked for this, and a deployment almost never +/// wants to admit every AWS account on Earth. +pub struct AwsInstanceIdentityBackend { + /// RSA-2048 public keys, by region, extracted from AWS's published + /// certificates — one per region, published at + /// , + /// and rotated by AWS on a schedule this deployment does not control. A + /// deployment needs a certificate configured for every region it expects + /// instances to provision from. + certs: BTreeMap, + /// Allowlisted AWS account IDs. Checked before any DER parsing or RSA + /// verification runs, for the same reason every other backend here + /// checks its allowlist first: an unrecognized account costs nothing + /// beyond a JSON parse and a string comparison. + accounts: BTreeSet, + window: Duration, + name: String, + seen: SeenNonces, +} + +impl AwsInstanceIdentityBackend { + /// Builds a backend trusting `certs` — PEM-encoded X.509 certificates, + /// keyed by AWS region — and admitting only the listed `accounts`. + /// + /// Fails if any certificate does not parse, or does not carry an RSA + /// public key: a misconfigured certificate is a deployment error worth + /// refusing at startup, not one worth discovering the first time an + /// instance tries to provision. + pub fn new(accounts: A, certs: C) -> Result + where + S: Into, + A: IntoIterator, + R: Into, + P: AsRef, + C: IntoIterator, + { + let mut parsed = BTreeMap::new(); + for (region, pem) in certs { + parsed.insert(region.into(), parse_rsa_certificate(pem.as_ref())?); + } + Ok(Self { + certs: parsed, + accounts: accounts.into_iter().map(Into::into).collect(), + window: Duration::seconds(DEFAULT_WINDOW_SECS), + name: "aws-instance-identity".to_string(), + seen: SeenNonces::new(), + }) + } + + /// Sets the freshness window applied to `pendingTime`. + /// + /// See [`SharedSecretBackend::with_window`](crate::SharedSecretBackend::with_window), + /// which this matches exactly, and the module documentation's note on why + /// this format needs a window at all: it has no `exp` of its own. + #[must_use] + pub fn with_window(mut self, window: Duration) -> Self { + self.window = window; + self + } + + /// Overrides the backend name recorded in provenance. + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + /// Every region this backend holds a certificate for, sorted. + pub fn regions(&self) -> Vec<&str> { + self.certs.keys().map(String::as_str).collect() + } + + /// Assembles a claim from a fetched instance identity document and its + /// detached RSA-2048 PKCS#7 signature. + /// + /// Deliberately not a network call. Fetching both means an IMDSv2 token + /// request followed by two GETs to the metadata service, all of it + /// `async` in any executor this crate might plausibly be embedded in — + /// and this crate does not choose an async runtime for its callers; see + /// the note on [`AttestationBackend`]. The caller — the hook running on + /// the instance — owns the HTTP client and the token dance; this only + /// does the part that has to agree with [`attest`](AttestationBackend::attest) + /// about what a claim contains. + /// + /// `document` is the raw response body from + /// `/latest/dynamic/instance-identity/document`; `pkcs7_der` is the + /// **decoded** bytes of `/latest/dynamic/instance-identity/rsa2048` + /// (that endpoint returns base64 with no PEM header — decode it, do not + /// wrap it in one). + pub fn claim_from_parts( + document: &[u8], + pkcs7_der: &[u8], + ) -> Result { + let doc = InstanceIdentityDocument::parse(document)?; + let issued_at = doc.pending_time()?; + let node_id = node_id(&doc.account_id, &doc.instance_id); + let evidence = pack_evidence(document, pkcs7_der); + Ok(AttestationClaim::new( + node_id, + doc.pending_time.clone(), + issued_at, + evidence, + )) + } + + /// Verifies a claim as of `now`, rather than the system clock. + /// + /// Check order matches the other two backends in this crate: the + /// allowlist first, so an account this deployment never intended to + /// admit costs nothing beyond a JSON parse; the envelope's agreement with + /// its own evidence next, so a claim whose `node_id` was set to something + /// other than what its evidence actually names is caught before the + /// freshness check has a chance to admit it under the wrong identity; + /// freshness before the RSA verification, so a stale claim never reaches + /// the modular exponentiation; and the replay store last, so only + /// evidence that has actually verified can spend a nonce. + pub fn attest_at( + &self, + claim: &AttestationClaim, + now: OffsetDateTime, + ) -> Result { + let (document, pkcs7_der) = unpack_evidence(&claim.evidence)?; + let doc = InstanceIdentityDocument::parse(document)?; + let expected_node_id = node_id(&doc.account_id, &doc.instance_id); + + if !self.accounts.contains(&doc.account_id) { + return Err(AttestError::UnknownNode(expected_node_id)); + } + + let issued_at = doc.pending_time()?; + if claim.node_id != expected_node_id + || claim.nonce != doc.pending_time + || claim.issued_at != issued_at + { + // The envelope disagrees with the document it is supposed to + // describe. Whatever the evidence itself would verify to, an + // envelope that does not match it is not a claim this backend + // signed off on, so it is refused the same way any other + // tampered field is: as evidence that did not verify, not as a + // more specific "envelope" error a prober could learn from. + return Err(AttestError::InvalidEvidence); + } + + let skew = now - issued_at; + if skew.abs() > self.window { + return Err(AttestError::Stale { + issued_at, + now, + window_secs: self.window.whole_seconds(), + }); + } + + let Some(key) = self.certs.get(&doc.region) else { + // No certificate configured for the claimed region is the same + // failure as an unknown account: this deployment did not choose + // to trust it. + return Err(AttestError::UnknownNode(expected_node_id)); + }; + + pkcs7::verify_detached_rsa_sha256(document, pkcs7_der, key)?; + + self.seen.spend(claim, self.window, now)?; + + Ok(Provenance::new( + &self.name, + &expected_node_id, + now, + Assurance::PlatformAttested, + Some(hex::encode(pkcs7_der)), + ) + .with_environment(ExecutionEnvironment::CloudInstance)) + } +} + +impl AttestationBackend for AwsInstanceIdentityBackend { + fn name(&self) -> &str { + &self.name + } + + fn attest(&self, claim: &AttestationClaim) -> Result { + self.attest_at(claim, OffsetDateTime::now_utc()) + } +} + +impl std::fmt::Debug for AwsInstanceIdentityBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Written by hand, like NodeCredentialBackend's, so a future field + // cannot land in a log line unnoticed. Certificates are public but + // noisy; a debugging session wants to know which regions and + // accounts are configured, not the RSA moduli. + f.debug_struct("AwsInstanceIdentityBackend") + .field("name", &self.name) + .field("regions", &self.regions()) + .field("accounts", &self.accounts) + .field("window_secs", &self.window.whole_seconds()) + .finish_non_exhaustive() + } +} + +/// The stable subject a claim is bound to: the account and instance AWS +/// itself assigned, never a name either can be given later. +fn node_id(account_id: &str, instance_id: &str) -> String { + format!("aws:{account_id}:{instance_id}") +} + +/// Packs a document and a PKCS#7 signature into one evidence blob. +/// +/// [`AttestationClaim::evidence`] is one field; this format needs two blobs +/// verified against each other, so they travel together, length-prefixed the +/// same way [`crate::signing`] length-prefixes fields — an unambiguous +/// boundary rather than a delimiter a document's own bytes might contain. +fn pack_evidence(document: &[u8], pkcs7_der: &[u8]) -> Vec { + let mut out = Vec::with_capacity(4 + document.len() + pkcs7_der.len()); + out.extend_from_slice( + &u32::try_from(document.len()) + .unwrap_or(u32::MAX) + .to_be_bytes(), + ); + out.extend_from_slice(document); + out.extend_from_slice(pkcs7_der); + out +} + +fn unpack_evidence(evidence: &[u8]) -> Result<(&[u8], &[u8]), AttestError> { + let Some((len_bytes, rest)) = evidence.split_first_chunk::<4>() else { + return Err(AttestError::Malformed( + "evidence is shorter than its own length prefix".to_string(), + )); + }; + let len = u32::from_be_bytes(*len_bytes) as usize; + if len > rest.len() { + return Err(AttestError::Malformed( + "evidence is shorter than its declared document length".to_string(), + )); + } + Ok(rest.split_at(len)) +} + +fn parse_rsa_certificate(pem: &str) -> Result { + use der::{DecodePem, Encode}; + use rsa::pkcs8::DecodePublicKey; + + let cert = x509_cert::Certificate::from_pem(pem.as_bytes()) + .map_err(|e| AttestError::Malformed(format!("AWS certificate: {e}")))?; + let spki_der = cert + .tbs_certificate + .subject_public_key_info + .to_der() + .map_err(|e| AttestError::Malformed(format!("AWS certificate: {e}")))?; + RsaPublicKey::from_public_key_der(&spki_der) + .map_err(|e| AttestError::Malformed(format!("AWS certificate: not an RSA public key: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use time::macros::datetime; + + /// A real AWS RSA-2048 certificate for `us-east-1`, published at + /// . + /// It has no signature paired with it here: AWS does not publish a + /// matching (document, signature) pair anywhere, since a real signature + /// only exists once, produced for one running instance. What this proves + /// is that this backend loads AWS's actual certificate shape without + /// choking on it — not that a real AWS-signed document verifies. The + /// full `attest_at` round trip is instead tested below against a + /// document signed with a locally generated key + /// ([`backend_with_test_key`]); the true end-to-end path, a real + /// signature checked against this real certificate, can only be + /// exercised from a running EC2 instance. + const US_EAST_1_CERT: &str = include_str!("testdata/us-east-1-rsa2048.pem"); + + fn backend() -> AwsInstanceIdentityBackend { + AwsInstanceIdentityBackend::new(["123456789012"], [("us-east-1", US_EAST_1_CERT)]) + .expect("the real AWS certificate parses") + } + + #[test] + fn loads_the_real_aws_certificate() { + let backend = backend(); + assert_eq!(backend.regions(), vec!["us-east-1"]); + } + + /// Builds a backend trusting a freshly generated key directly, bypassing + /// [`AwsInstanceIdentityBackend::new`]'s PEM parsing. There is no real + /// AWS-signed (document, signature) pair to test against — AWS does not + /// publish one, only certificates — so this is what stands in for it: a + /// full [`AwsInstanceIdentityBackend::attest_at`] round trip against a + /// signature this test produced itself, checked with the same PKCS#7 + /// verifier a real AWS signature would go through. + fn backend_with_test_key( + account: &str, + region: &str, + ) -> (AwsInstanceIdentityBackend, &'static rsa::RsaPrivateKey) { + let key = pkcs7::test_support::keypair(); + let backend = AwsInstanceIdentityBackend { + certs: BTreeMap::from([(region.to_string(), key.to_public_key())]), + accounts: BTreeSet::from([account.to_string()]), + window: Duration::seconds(DEFAULT_WINDOW_SECS), + name: "aws-instance-identity".to_string(), + seen: SeenNonces::new(), + }; + (backend, key) + } + + #[test] + fn admits_a_genuine_claim_and_records_what_it_proved() { + let (backend, key) = backend_with_test_key("123456789012", "us-east-1"); + let document = br#"{"instanceId":"i-0123456789abcdef0","accountId":"123456789012","region":"us-east-1","pendingTime":"2026-08-24T12:00:00Z"}"#; + let pkcs7 = pkcs7::test_support::build_pkcs7_no_attrs(document, key); + let claim = AwsInstanceIdentityBackend::claim_from_parts(document, &pkcs7) + .expect("a well-formed document parses"); + + let provenance = backend + .attest_at(&claim, datetime!(2026-08-24 12:00:01 UTC)) + .expect("a genuine, allowlisted, fresh claim is admitted"); + + assert_eq!(provenance.backend, "aws-instance-identity"); + assert_eq!(provenance.node_id, "aws:123456789012:i-0123456789abcdef0"); + assert_eq!(provenance.assurance, Assurance::PlatformAttested); + assert_eq!( + provenance.environment, + Some(ExecutionEnvironment::CloudInstance) + ); + assert!(provenance.evidence.is_some()); + } + + #[test] + fn a_genuine_claim_is_admitted_once_and_refused_the_second_time() { + let (backend, key) = backend_with_test_key("123456789012", "us-east-1"); + let document = br#"{"instanceId":"i-0123456789abcdef0","accountId":"123456789012","region":"us-east-1","pendingTime":"2026-08-24T12:00:00Z"}"#; + let pkcs7 = pkcs7::test_support::build_pkcs7_no_attrs(document, key); + let claim = AwsInstanceIdentityBackend::claim_from_parts(document, &pkcs7) + .expect("a well-formed document parses"); + + assert!(backend + .attest_at(&claim, datetime!(2026-08-24 12:00:01 UTC)) + .is_ok()); + assert_eq!( + backend.attest_at(&claim, datetime!(2026-08-24 12:00:02 UTC)), + Err(AttestError::Replayed { + node_id: "aws:123456789012:i-0123456789abcdef0".to_string(), + nonce: "2026-08-24T12:00:00Z".to_string(), + }) + ); + } + + #[test] + fn rejects_a_genuine_signature_over_a_tampered_document() { + let (backend, key) = backend_with_test_key("123456789012", "us-east-1"); + let document = br#"{"instanceId":"i-0123456789abcdef0","accountId":"123456789012","region":"us-east-1","pendingTime":"2026-08-24T12:00:00Z"}"#; + let pkcs7 = pkcs7::test_support::build_pkcs7_no_attrs(document, key); + + // The account field changed after signing — an attacker rewriting + // which account a captured claim is presented under. The signature + // covers the document bytes, so this does not verify. + let tampered = br#"{"instanceId":"i-0123456789abcdef0","accountId":"999999999999","region":"us-east-1","pendingTime":"2026-08-24T12:00:00Z"}"#; + let claim = AwsInstanceIdentityBackend::claim_from_parts(tampered, &pkcs7) + .expect("a well-formed document parses"); + + assert_eq!( + backend.attest_at(&claim, datetime!(2026-08-24 12:00:01 UTC)), + Err(AttestError::UnknownNode( + "aws:999999999999:i-0123456789abcdef0".to_string() + )) + ); + } + + #[test] + fn rejects_the_same_genuine_signature_replayed_against_a_different_backend_key() { + let (backend, _signer_key) = backend_with_test_key("123456789012", "us-east-1"); + let document = br#"{"instanceId":"i-0123456789abcdef0","accountId":"123456789012","region":"us-east-1","pendingTime":"2026-08-24T12:00:00Z"}"#; + // Signed with a key this backend does not trust for this region. + let pkcs7 = pkcs7::test_support::build_pkcs7_no_attrs( + document, + pkcs7::test_support::other_keypair(), + ); + let claim = AwsInstanceIdentityBackend::claim_from_parts(document, &pkcs7) + .expect("a well-formed document parses"); + + assert_eq!( + backend.attest_at(&claim, datetime!(2026-08-24 12:00:01 UTC)), + Err(AttestError::InvalidEvidence) + ); + } + + #[test] + fn rejects_an_unconfigured_region() { + let backend = backend(); + let claim = AwsInstanceIdentityBackend::claim_from_parts( + br#"{"instanceId":"i-0123456789abcdef0","accountId":"123456789012","region":"eu-west-1","pendingTime":"2026-08-24T12:00:00Z"}"#, + b"irrelevant, never reached", + ) + .expect("a well-formed document parses"); + assert_eq!( + backend.attest_at(&claim, datetime!(2026-08-24 12:00:00 UTC)), + Err(AttestError::UnknownNode( + "aws:123456789012:i-0123456789abcdef0".to_string() + )) + ); + } + + #[test] + fn rejects_an_unallowlisted_account() { + let backend = backend(); + let claim = AwsInstanceIdentityBackend::claim_from_parts( + br#"{"instanceId":"i-0123456789abcdef0","accountId":"999999999999","region":"us-east-1","pendingTime":"2026-08-24T12:00:00Z"}"#, + b"irrelevant, never reached", + ) + .expect("a well-formed document parses"); + assert_eq!( + backend.attest_at(&claim, datetime!(2026-08-24 12:00:00 UTC)), + Err(AttestError::UnknownNode( + "aws:999999999999:i-0123456789abcdef0".to_string() + )) + ); + } + + #[test] + fn rejects_a_document_that_is_not_json() { + let err = AwsInstanceIdentityBackend::claim_from_parts(b"not json", b"sig"); + assert!(matches!(err, Err(AttestError::Malformed(_)))); + } + + #[test] + fn rejects_a_claim_whose_envelope_disagrees_with_its_evidence() { + let backend = backend(); + let mut claim = AwsInstanceIdentityBackend::claim_from_parts( + br#"{"instanceId":"i-0123456789abcdef0","accountId":"123456789012","region":"us-east-1","pendingTime":"2026-08-24T12:00:00Z"}"#, + b"irrelevant, never reached because the envelope check runs first", + ) + .expect("a well-formed document parses"); + // A node_id the evidence never actually names — the shape a spoofed + // envelope would take, since this format's signature does not cover + // node_id at all. + claim.node_id = "aws:123456789012:i-attacker-controlled".to_string(); + assert_eq!( + backend.attest_at(&claim, datetime!(2026-08-24 12:00:00 UTC)), + Err(AttestError::InvalidEvidence) + ); + } + + #[test] + fn rejects_evidence_too_short_to_carry_a_length_prefix() { + let claim = AttestationClaim::new( + "aws:123456789012:i-0123456789abcdef0", + "2026-08-24T12:00:00Z", + datetime!(2026-08-24 12:00:00 UTC), + vec![0u8; 2], + ); + assert_eq!( + backend().attest_at(&claim, datetime!(2026-08-24 12:00:00 UTC)), + Err(AttestError::Malformed( + "evidence is shorter than its own length prefix".to_string() + )) + ); + } + + #[test] + fn a_stale_pending_time_is_refused_as_a_clock_problem() { + let backend = backend().with_window(Duration::seconds(60)); + let claim = AwsInstanceIdentityBackend::claim_from_parts( + br#"{"instanceId":"i-0123456789abcdef0","accountId":"123456789012","region":"us-east-1","pendingTime":"2026-08-24T12:00:00Z"}"#, + b"irrelevant, never reached", + ) + .expect("a well-formed document parses"); + let result = backend.attest_at(&claim, datetime!(2026-08-24 12:05:00 UTC)); + match result { + Err(AttestError::Stale { + issued_at, + now, + window_secs, + }) => { + assert_eq!(issued_at, datetime!(2026-08-24 12:00:00 UTC)); + assert_eq!(now, datetime!(2026-08-24 12:05:00 UTC)); + assert_eq!(window_secs, 60); + let message = AttestError::Stale { + issued_at, + now, + window_secs, + } + .to_string(); + assert!(message.contains("clock skew"), "{message}"); + } + other => panic!("expected a Stale refusal naming both timestamps, got {other:?}"), + } + } +} diff --git a/crates/didbot-attest/src/aws/pkcs7.rs b/crates/didbot-attest/src/aws/pkcs7.rs new file mode 100644 index 00000000..2eae3020 --- /dev/null +++ b/crates/didbot-attest/src/aws/pkcs7.rs @@ -0,0 +1,338 @@ +//! Just enough PKCS#7 (RFC 2315) `SignedData` to check one shape of signature: +//! RSA-2048 over a SHA-256 digest, with or without CMS signed attributes. +//! +//! This is not a general PKCS#7 or CMS implementation. It parses exactly the +//! fields the RSA-2048 instance identity signature format uses and treats +//! everything else — embedded certificates, CRLs, unauthenticated attributes, +//! a second signer — as either absent or a reason to refuse. A general parser +//! would accept shapes this backend has never seen from AWS and has no way to +//! test against; a narrow one fails closed on anything unexpected instead. +//! +//! No dependency in this workspace already parses CMS/PKCS#7 at a version +//! compatible with the `der` 0.7 line already in the dependency graph (see +//! the workspace `Cargo.toml`'s note on `jacquard` for why a second copy of a +//! crypto-adjacent crate is a real hazard, not a style objection) — the `cms` +//! crate exists but only as a `0.3.0-pre.2`, and pulls `der` 0.8. So this is +//! hand-rolled against `der` and `spki`, which the workspace already carries +//! at the versions used here via the `k256`/`p256`/`pkcs8` stack. + +use der::asn1::{AnyRef, ObjectIdentifier, OctetStringRef, SetOfVec}; +use der::{Decode, Encode, Sequence, ValueOrd}; +use rsa::pkcs1v15::Pkcs1v15Sign; +use rsa::RsaPublicKey; +use sha2::{Digest, Sha256}; + +use crate::claim::AttestError; + +/// `signedData` from the PKCS#7 content-type OID arc. +const OID_SIGNED_DATA: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.7.2"); +/// `id-sha256`. +const OID_SHA256: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.1"); +/// `messageDigest`, the CMS/PKCS#9 signed attribute this backend checks for. +const OID_MESSAGE_DIGEST: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.4"); + +#[derive(Sequence)] +struct ContentInfo<'a> { + content_type: ObjectIdentifier, + #[asn1(context_specific = "0", optional = "true", tag_mode = "EXPLICIT")] + content: Option>, +} + +#[derive(Sequence)] +struct SignedData<'a> { + version: u8, + /// `SET OF AlgorithmIdentifier`. Unread: the digest algorithm that + /// matters is the one named inside the `SignerInfo`, which is what is + /// actually applied to the document. + digest_algorithms: SetOfVec>, + /// The encapsulated `ContentInfo`. Unread: this backend is always handed + /// the document bytes directly by its caller and hashes those, rather + /// than trusting whatever this field claims — so it makes no difference + /// to verification whether AWS's signature attaches the document here or + /// leaves it detached, and this parser does not have to guess which. + content_info: AnyRef<'a>, + #[asn1(context_specific = "0", optional = "true", tag_mode = "IMPLICIT")] + certificates: Option>>, + #[asn1(context_specific = "1", optional = "true", tag_mode = "IMPLICIT")] + crls: Option>>, + signer_infos: SetOfVec>, +} + +#[derive(Sequence, ValueOrd)] +struct SignerInfo<'a> { + version: u8, + /// `IssuerAndSerialNumber`. Unread: this backend never resolves trust + /// through an embedded certificate or a CA chain. The AWS certificate is + /// supplied out of band by the deployment's own configuration, precisely + /// so that a PKCS#7 blob cannot vouch for its own trust. + issuer_and_serial_number: AnyRef<'a>, + digest_algorithm: spki::AlgorithmIdentifierRef<'a>, + #[asn1(context_specific = "0", optional = "true", tag_mode = "IMPLICIT")] + authenticated_attributes: Option>>, + digest_encryption_algorithm: spki::AlgorithmIdentifierRef<'a>, + encrypted_digest: OctetStringRef<'a>, + #[asn1(context_specific = "1", optional = "true", tag_mode = "IMPLICIT")] + unauthenticated_attributes: Option>>, +} + +#[derive(Sequence)] +struct Attribute<'a> { + attr_type: ObjectIdentifier, + attr_values: SetOfVec>, +} + +fn malformed(context: &str, err: impl std::fmt::Display) -> AttestError { + AttestError::Malformed(format!("pkcs7: {context}: {err}")) +} + +/// Verifies a detached RSA-2048/SHA-256 PKCS#7 signature over `document`. +/// +/// `pkcs7_der` is the raw (not PEM-wrapped) DER bytes of a PKCS#7 +/// `ContentInfo` carrying exactly one `SignerInfo`. Refuses, rather than +/// guesses, on anything this format was not built to describe: more than one +/// signer, a digest algorithm other than SHA-256, or a signed-attributes +/// block whose `messageDigest` does not match `document`. +/// +/// Every failure — a structural parse error, a digest mismatch, a signature +/// that does not verify — comes back as [`AttestError::InvalidEvidence`] or +/// [`AttestError::Malformed`], the same two outcomes every other backend in +/// this crate collapses tampering into. A caller probing the verifier learns +/// nothing from which one it got, only that the evidence did not hold up. +pub(crate) fn verify_detached_rsa_sha256( + document: &[u8], + pkcs7_der: &[u8], + key: &RsaPublicKey, +) -> Result<(), AttestError> { + let content_info = + ContentInfo::from_der(pkcs7_der).map_err(|e| malformed("outer ContentInfo", e))?; + if content_info.content_type != OID_SIGNED_DATA { + return Err(AttestError::Malformed( + "pkcs7: content type is not signedData".to_string(), + )); + } + let signed_data_bytes = content_info + .content + .ok_or_else(|| AttestError::Malformed("pkcs7: signedData content is absent".to_string()))? + .to_der() + .map_err(|e| malformed("re-encoding signedData", e))?; + let signed_data = + SignedData::from_der(&signed_data_bytes).map_err(|e| malformed("SignedData", e))?; + + // Exactly one signer. AWS's rsa2048 endpoint produces one; anything else + // is a shape this backend was not built to interpret, not a shape it + // should guess at by picking the first entry. + let signer_infos: Vec<&SignerInfo<'_>> = signed_data.signer_infos.iter().collect(); + let [signer] = signer_infos.as_slice() else { + return Err(AttestError::InvalidEvidence); + }; + let signer = *signer; + + if signer.digest_algorithm.oid != OID_SHA256 { + // Refusing here rather than verifying under whatever algorithm the + // evidence names is what keeps a downgrade to a weaker digest from + // being the evidence's own choice to make. + return Err(AttestError::InvalidEvidence); + } + + let document_digest = Sha256::digest(document); + + // The bytes actually covered by the RSA signature. RFC 2315 signs the + // content digest directly when there are no signed attributes; CMS-style + // signers instead sign the digest of the re-encoded attribute set, and + // fold the content digest into it as the `messageDigest` attribute. Which + // shape AWS produces is exactly the kind of detail this parser should not + // assume, so both are handled. + let hashed = match &signer.authenticated_attributes { + None => document_digest.to_vec(), + Some(attrs) => { + let mut digest_matched = false; + for entry in attrs.iter() { + let der = entry.to_der().map_err(|e| malformed("attribute", e))?; + let attribute = Attribute::from_der(&der).map_err(|e| malformed("attribute", e))?; + if attribute.attr_type != OID_MESSAGE_DIGEST { + continue; + } + let [value] = attribute.attr_values.iter().collect::>()[..] else { + return Err(AttestError::InvalidEvidence); + }; + let value_der = value.to_der().map_err(|e| malformed("messageDigest", e))?; + let octets = OctetStringRef::from_der(&value_der) + .map_err(|e| malformed("messageDigest", e))?; + if octets.as_bytes() != &document_digest[..] { + return Err(AttestError::InvalidEvidence); + } + digest_matched = true; + } + if !digest_matched { + return Err(AttestError::InvalidEvidence); + } + // Re-serializing the decoded SET yields its canonical, universally + // SET-tagged (0x31) encoding, which is what was actually signed: + // the [0] IMPLICIT tag used on the wire replaces that tag only + // for the purpose of distinguishing this field from an absent + // one, and is never itself part of the signed bytes. + let set_der = attrs.to_der().map_err(|e| malformed("attributes SET", e))?; + Sha256::digest(&set_der).to_vec() + } + }; + + let scheme = Pkcs1v15Sign::new::(); + key.verify(scheme, &hashed, signer.encrypted_digest.as_bytes()) + .map_err(|_| AttestError::InvalidEvidence) +} + +/// Test-only fixtures shared with [`super::super`]'s own tests, so that +/// module does not need a second implementation of "build a detached PKCS#7 +/// blob" just to exercise [`super::AwsInstanceIdentityBackend`] end to end. +#[cfg(test)] +pub(crate) mod test_support { + use rsa::pkcs1v15::SigningKey; + use rsa::signature::{RandomizedSigner, SignatureEncoding}; + use rsa::RsaPrivateKey; + + use super::*; + + /// Hand-encodes a minimal detached PKCS#7 `SignedData` — no embedded + /// certificates, no signed attributes — so the verifier above can be + /// tested without a second implementation to encode against. `der`'s own + /// `Encode` impls do the actual byte-level work; this only assembles them + /// in the right shape. + pub(crate) fn build_pkcs7_no_attrs(document: &[u8], key: &RsaPrivateKey) -> Vec { + // `SigningKey::sign_with_rng` hashes its input itself, so it is + // handed the document, not a digest of it — passing an + // already-hashed digest here would sign SHA-256 of the digest + // instead of the digest itself, which is not what a real AWS + // signature (or the verifier under test) computes. + let signing_key = SigningKey::::new(key.clone()); + let signature = signing_key.sign_with_rng(&mut rand::thread_rng(), document); + let signature_bytes = signature.to_bytes(); + + let alg_sha256 = spki::AlgorithmIdentifierRef { + oid: OID_SHA256, + parameters: Some(der::asn1::Null.into()), + }; + let rsa_encryption = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1"); + let alg_rsa = spki::AlgorithmIdentifierRef { + oid: rsa_encryption, + parameters: Some(der::asn1::Null.into()), + }; + + let signer_info = SignerInfo { + version: 1, + issuer_and_serial_number: AnyRef::from(der::asn1::Null), + digest_algorithm: alg_sha256, + authenticated_attributes: None, + digest_encryption_algorithm: alg_rsa, + encrypted_digest: OctetStringRef::new(signature_bytes.as_ref()) + .expect("signature bytes encode as an OCTET STRING"), + unauthenticated_attributes: None, + }; + let signer_infos = SetOfVec::try_from(vec![signer_info]).expect("one element is sorted"); + + let signed_data = SignedData { + version: 1, + digest_algorithms: SetOfVec::try_from(vec![AnyRef::from(der::asn1::Null)]) + .expect("one element is sorted"), + content_info: AnyRef::from(der::asn1::Null), + certificates: None, + crls: None, + signer_infos, + }; + let signed_data_der = signed_data.to_der().expect("SignedData encodes"); + + let content_info = ContentInfo { + content_type: OID_SIGNED_DATA, + content: Some(AnyRef::from_der(&signed_data_der).expect("re-decodes as ANY")), + }; + content_info.to_der().expect("ContentInfo encodes") + } + + /// A real 2048-bit RSA keypair, generated once and shared across every + /// test that calls it. Generation is real (not a fixture) so the + /// signatures these tests check are ones the verifier could not have + /// been written to expect — but RSA-2048 key generation is slow enough + /// in a debug build that paying for it once per process, rather than + /// once per test, is the difference between a fast suite and a slow one. + pub(crate) fn keypair() -> &'static RsaPrivateKey { + static KEY: std::sync::OnceLock = std::sync::OnceLock::new(); + KEY.get_or_init(|| { + RsaPrivateKey::new(&mut rand::thread_rng(), 2048).expect("2048-bit RSA key generation") + }) + } + + /// A second keypair, distinct from [`keypair`], for a test that needs to + /// prove two different keys are not interchangeable. + pub(crate) fn other_keypair() -> &'static RsaPrivateKey { + static KEY: std::sync::OnceLock = std::sync::OnceLock::new(); + KEY.get_or_init(|| { + RsaPrivateKey::new(&mut rand::thread_rng(), 2048).expect("2048-bit RSA key generation") + }) + } +} + +#[cfg(test)] +mod tests { + use super::test_support::{build_pkcs7_no_attrs, keypair, other_keypair}; + use super::*; + + #[test] + fn verifies_a_well_formed_detached_signature() { + let key = keypair(); + let document = br#"{"instanceId":"i-0123456789abcdef0"}"#; + let pkcs7 = build_pkcs7_no_attrs(document, key); + + assert!(verify_detached_rsa_sha256(document, &pkcs7, &key.to_public_key()).is_ok()); + } + + #[test] + fn rejects_a_tampered_document() { + let key = keypair(); + let document = br#"{"instanceId":"i-0123456789abcdef0"}"#; + let pkcs7 = build_pkcs7_no_attrs(document, key); + + let tampered = br#"{"instanceId":"i-attacker-controlled"}"#; + assert_eq!( + verify_detached_rsa_sha256(tampered, &pkcs7, &key.to_public_key()), + Err(AttestError::InvalidEvidence) + ); + } + + #[test] + fn rejects_a_signature_from_a_different_key() { + let signer = keypair(); + let attacker = other_keypair(); + let document = br#"{"instanceId":"i-0123456789abcdef0"}"#; + let pkcs7 = build_pkcs7_no_attrs(document, signer); + + // The genuine signature, checked against a different public key — + // the case that catches "any RSA-2048 signature over some document" + // being accepted instead of "a signature this deployment's + // configured AWS certificate actually produced". + assert_eq!( + verify_detached_rsa_sha256(document, &pkcs7, &attacker.to_public_key()), + Err(AttestError::InvalidEvidence) + ); + } + + #[test] + fn rejects_a_bit_flipped_in_the_signature() { + let key = keypair(); + let document = br#"{"instanceId":"i-0123456789abcdef0"}"#; + let mut pkcs7 = build_pkcs7_no_attrs(document, key); + *pkcs7.last_mut().expect("non-empty DER") ^= 0x01; + + assert_eq!( + verify_detached_rsa_sha256(document, &pkcs7, &key.to_public_key()), + Err(AttestError::InvalidEvidence) + ); + } + + #[test] + fn rejects_garbage_that_is_not_der_at_all() { + let key = keypair(); + assert!(matches!( + verify_detached_rsa_sha256(b"document", b"not a pkcs7 blob", &key.to_public_key()), + Err(AttestError::Malformed(_)) + )); + } +} diff --git a/crates/didbot-attest/src/aws/testdata/us-east-1-rsa2048.pem b/crates/didbot-attest/src/aws/testdata/us-east-1-rsa2048.pem new file mode 100644 index 00000000..022486d2 --- /dev/null +++ b/crates/didbot-attest/src/aws/testdata/us-east-1-rsa2048.pem @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEEjCCAvqgAwIBAgIJALFpzEAVWaQZMA0GCSqGSIb3DQEBCwUAMFwxCzAJBgNV +BAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0 +dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzAgFw0xNTA4MTQw +ODU5MTJaGA8yMTk1MDExNzA4NTkxMlowXDELMAkGA1UEBhMCVVMxGTAXBgNVBAgT +EFdhc2hpbmd0b24gU3RhdGUxEDAOBgNVBAcTB1NlYXR0bGUxIDAeBgNVBAoTF0Ft +YXpvbiBXZWIgU2VydmljZXMgTExDMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAjS2vqZu9mEOhOq+0bRpAbCUiapbZMFNQqRg7kTlr7Cf+gDqXKpHPjsng +SfNz+JHQd8WPI+pmNs+q0Z2aTe23klmf2U52KH9/j1k8RlIbap/yFibFTSedmegX +E5r447GbJRsHUmuIIfZTZ/oRlpuIIO5/Vz7SOj22tdkdY2ADp7caZkNxhSP915fk +2jJMTBUOzyXUS2rBU/ulNHbTTeePjcEkvzVYPahD30TeQ+/A+uWUu89bHSQOJR8h +Um4cFApzZgN3aD5j2LrSMu2pctkQwf9CaWyVznqrsGYjYOY66LuFzSCXwqSnFBfv +fFBAFsjCgY24G2DoMyYkF3MyZlu+rwIDAQABo4HUMIHRMAsGA1UdDwQEAwIHgDAd +BgNVHQ4EFgQUrynSPp4uqSECwy+PiO4qyJ8TWSkwgY4GA1UdIwSBhjCBg4AUrynS +Pp4uqSECwy+PiO4qyJ8TWSmhYKReMFwxCzAJBgNVBAYTAlVTMRkwFwYDVQQIExBX +YXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0dGxlMSAwHgYDVQQKExdBbWF6 +b24gV2ViIFNlcnZpY2VzIExMQ4IJALFpzEAVWaQZMBIGA1UdEwEB/wQIMAYBAf8C +AQAwDQYJKoZIhvcNAQELBQADggEBADW/s8lXijwdP6NkEoH1m9XLrvK4YTqkNfR6 +er/uRRgTx2QjFcMNrx+g87gAml11z+D0crAZ5LbEhDMs+JtZYR3ty0HkDk6SJM85 +haoJNAFF7EQ/zCp1EJRIkLLsC7bcDL/Eriv1swt78/BB4RnC9W9kSp/sxd5svJMg +N9a6FAplpNRsWAnbP8JBlAP93oJzblX2LQXgykTghMkQO7NaY5hg/H5o4dMPclTK +lYGqlFUCH6A2vdrxmpKDLmTn5//5pujdD2MN0df6sZWtxwZ0osljV4rDjm9Q3VpA +NWIsDEcp3GUB4proOR+C7PNkY+VGODitBOw09qBGosCBstwyEqY= +-----END CERTIFICATE----- diff --git a/crates/didbot-attest/src/lib.rs b/crates/didbot-attest/src/lib.rs index a46f8279..096bae86 100644 --- a/crates/didbot-attest/src/lib.rs +++ b/crates/didbot-attest/src/lib.rs @@ -24,12 +24,13 @@ //! 4. SPIFFE/SPIRE SVIDs //! 5. TPM quotes //! -//! [`SharedSecretBackend`] and [`NodeCredentialBackend`] exist today. The -//! [`AttestationBackend`] trait is shaped so the rest fit without changing -//! callers. +//! [`SharedSecretBackend`], [`NodeCredentialBackend`] and +//! [`AwsInstanceIdentityBackend`] exist today. The [`AttestationBackend`] +//! trait is shaped so the rest fit without changing callers. #![forbid(unsafe_code)] +mod aws; mod backend; mod claim; mod node_credential; @@ -38,8 +39,9 @@ mod seen; mod shared_secret; mod signing; +pub use aws::AwsInstanceIdentityBackend; pub use backend::AttestationBackend; -pub use claim::{Assurance, AttestError, AttestationClaim, Provenance}; +pub use claim::{Assurance, AttestError, AttestationClaim, ExecutionEnvironment, Provenance}; pub use node_credential::{NodeCredentialBackend, NODE_CREDENTIAL_DOMAIN}; pub use seen::SeenNonces; pub use shared_secret::{SharedSecretBackend, DEFAULT_WINDOW_SECS, SHARED_SECRET_DOMAIN}; diff --git a/plan/attestation.md b/plan/attestation.md index e688c04e..c321431a 100644 --- a/plan/attestation.md +++ b/plan/attestation.md @@ -19,20 +19,11 @@ The strongest claim available is about the node. Which session and which subagent are the harness's word, and nothing here pretends otherwise. -- [ ] **A cloud instance identity backend.** The first evidence here signed by - somebody other than us. - [ ] **A SPIFFE/SPIRE backend**, as a portable wrapper over the others. - [ ] **A hardware root of trust**, as a design stub. -- [ ] **Execution environment recorded at provisioning.** What the backend - observed — laptop, container, cloud instance — read by - [provenance](provenance.md). - [ ] **Say what each backend's evidence is worth to a reader who is not us.** -- [ ] **Watch for clock skew.** A claim is only good inside a window, so a host - with a wrong clock cannot provision and the failure does not look like a - clock problem. -- [ ] **Retire the `agent.attestation` lexicon** in favour of the origin record - in [provenance](provenance.md), which carries the same claim beside the - rest of an agent's origins and labels the source of each. + Done for the AWS instance identity backend; the SPIFFE/SPIRE and + hardware backends still need their own paragraph once they exist. - [ ] **An account that outlives its proof.** A claim answers once, at provisioning, and is spent when it is admitted. An account that renews — @@ -50,3 +41,30 @@ otherwise. - [x] A seen-nonce store, so replay is prevented inside the window as well as bounded by it. - [x] An attestation claim is spent when it is admitted. +- [x] **A cloud instance identity backend**, for AWS EC2. Verifies the + detached PKCS#7/RSA-2048 signature over an instance identity document + against this deployment's configured regional certificates — + hand-rolled against `der`/`spki` rather than pulling the `cms` crate, + which only exists as a pre-release and would have doubled the `der` + stack already in the dependency graph. GCP and Azure are not + implemented; the trait needed no change to make room for them. +- [x] **Execution environment recorded at provisioning.** `Provenance` grew + an optional `environment` field and an `ExecutionEnvironment` enum + (laptop, container, cloud instance); the AWS backend is the first to + set it, to `CloudInstance`. The two existing backends leave it unset + rather than guess. +- [x] **Watch for clock skew.** `AttestError::Stale` now carries the + verifier's own clock alongside the claim's timestamp, and its message + names clock skew explicitly rather than reading as a generic refusal. + Applies to every backend, not just AWS's. +- [x] **Retired the `agent.attestation` lexicon** — already done, in + [account-types](account-types.md): it folded into `actor.registration`. + Verified rather than redone. +- [x] **The two missing properties of a detached-signature document — no + audience, no expiry.** Bounded by the seen-nonce store, exactly as + [account-types](account-types.md) says: the tuple spent is + `instanceId` + `pendingTime`, both read out of the signed document. +- [x] **The binding is an issuer and a stable subject.** The subject is + `accountId` + `instanceId`, AWS's own identifiers, never a tag or a + name. The claim's `node_id` is never trusted on its own — `attest_at` + re-derives it from the verified document and refuses a mismatch. -- 2.51.2