Fix cross-vault OCSP snapshot poisoning
This commit is contained in:
@@ -150,8 +150,8 @@ only as SHA-256 digests, and cannot mint/revoke tokens.
|
|||||||
KCA can publish full per-CA certificate-status snapshots to any S3-compatible
|
KCA can publish full per-CA certificate-status snapshots to any S3-compatible
|
||||||
bucket and ships a standalone Rust/Axum responder on port `2560`.
|
bucket and ships a standalone Rust/Axum responder on port `2560`.
|
||||||
|
|
||||||
- KCA publishes `ocsp/<CA certificate SHA-256>.json` whenever certificates are
|
- KCA publishes `ocsp/<KCA CA UUID>.json` whenever certificates are issued,
|
||||||
issued, renewed, revoked, deleted or imported. Revocation reasons are
|
renewed, revoked, deleted or imported. Revocation reasons are
|
||||||
included; this is a full status snapshot, not a revoked-only list.
|
included; this is a full status snapshot, not a revoked-only list.
|
||||||
- The responder has no Postgres/Redis/KCA master-key access. It only reads the
|
- The responder has no Postgres/Redis/KCA master-key access. It only reads the
|
||||||
configured S3 prefix and uses a delegated OCSP signing certificate/key per
|
configured S3 prefix and uses a delegated OCSP signing certificate/key per
|
||||||
@@ -166,8 +166,8 @@ bucket and ships a standalone Rust/Axum responder on port `2560`.
|
|||||||
|
|
||||||
Enable publishing in KCA with `KCA_OCSP_S3_BUCKET` and the other
|
Enable publishing in KCA with `KCA_OCSP_S3_BUCKET` and the other
|
||||||
`KCA_OCSP_*` variables in `server/.env.example`. Run the responder with a
|
`KCA_OCSP_*` variables in `server/.env.example`. Run the responder with a
|
||||||
configuration derived from `ocsp-responder/responder.example.yaml`, or with
|
configuration derived from `ocsp-responder/responder.example.yaml`, setting
|
||||||
Compose:
|
each issuer's `ca_id` to its CA certificate UUID in KCA, or with Compose:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose --profile ocsp up --build
|
docker compose --profile ocsp up --build
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ It has **no KCA database access**, no Redis access, and no KCA master key. It
|
|||||||
only needs:
|
only needs:
|
||||||
|
|
||||||
- `s3:GetObject` on the configured bucket/prefix;
|
- `s3:GetObject` on the configured bucket/prefix;
|
||||||
- the CA certificate for each issuer;
|
- the CA certificate and its KCA CA UUID for each issuer;
|
||||||
- a delegated OCSP signer certificate and private key for each CA. The
|
- a delegated OCSP signer certificate and private key for each CA. The
|
||||||
responder currently accepts RSA-SHA256 and ECDSA P-256 signers in PKCS#8,
|
responder currently accepts RSA-SHA256 and ECDSA P-256 signers in PKCS#8,
|
||||||
PKCS#1 RSA, or SEC1 EC PEM form.
|
PKCS#1 RSA, or SEC1 EC PEM form.
|
||||||
@@ -18,16 +18,30 @@ only needs:
|
|||||||
KCA publishes objects as:
|
KCA publishes objects as:
|
||||||
|
|
||||||
```
|
```
|
||||||
<prefix>/<CA certificate SHA-256>.json
|
<prefix>/<KCA CA UUID>.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Set each issuer's required `ca_id` to the UUID of its CA certificate row in KCA.
|
||||||
|
This isolates snapshots when the same public CA certificate exists in multiple
|
||||||
|
vaults. The responder validates both this ID and the CA certificate fingerprint
|
||||||
|
against every downloaded snapshot.
|
||||||
|
|
||||||
KCA publishes these objects when `KCA_OCSP_S3_BUCKET` is configured. The JSON
|
KCA publishes these objects when `KCA_OCSP_S3_BUCKET` is configured. The JSON
|
||||||
contains all certificates issued by that CA, both valid and revoked. The
|
contains all certificates issued by that CA, both valid and revoked. The
|
||||||
responder never infers status from a partial revoked-only list.
|
responder never infers status from a partial revoked-only list.
|
||||||
|
|
||||||
|
## Upgrading from fingerprint-named snapshots
|
||||||
|
|
||||||
|
Deploy the updated responder with `ca_id` before or together with the updated
|
||||||
|
KCA publisher. The responder deliberately does not fall back to legacy
|
||||||
|
`<prefix>/<CA certificate SHA-256>.json` objects. Until KCA publishes the new
|
||||||
|
objects, OCSP requests return `tryLater`. After verifying the new snapshots,
|
||||||
|
the obsolete fingerprint-named objects can be removed.
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|
||||||
1. Create a config based on `responder.example.yaml`.
|
1. Create a config based on `responder.example.yaml`, setting each issuer's
|
||||||
|
`ca_id` to its CA certificate UUID in KCA.
|
||||||
2. Put your CA certificates and delegated signer files where the config points.
|
2. Put your CA certificates and delegated signer files where the config points.
|
||||||
3. Export standard AWS credentials / endpoint variables as needed.
|
3. Export standard AWS credentials / endpoint variables as needed.
|
||||||
4. Make the signer key readable by the container user (`65532:65532` in the
|
4. Make the signer key readable by the container user (`65532:65532` in the
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ s3:
|
|||||||
|
|
||||||
issuers:
|
issuers:
|
||||||
- name: Example Root CA
|
- name: Example Root CA
|
||||||
|
# UUID of this CA's certificate row in KCA (not its fingerprint).
|
||||||
|
ca_id: 00000000-0000-0000-0000-000000000000
|
||||||
ca_cert: /etc/ocsp/ca/root.pem
|
ca_cert: /etc/ocsp/ca/root.pem
|
||||||
signer_cert: /etc/ocsp/signers/root.pem
|
signer_cert: /etc/ocsp/signers/root.pem
|
||||||
signer_key: /etc/ocsp/signers/root.key
|
signer_key: /etc/ocsp/signers/root.key
|
||||||
# - name: Example Intermediate CA
|
# - name: Example Intermediate CA
|
||||||
|
# ca_id: 00000000-0000-0000-0000-000000000000
|
||||||
# ca_cert: /etc/ocsp/ca/intermediate.pem
|
# ca_cert: /etc/ocsp/ca/intermediate.pem
|
||||||
# signer_cert: /etc/ocsp/signers/intermediate.pem
|
# signer_cert: /etc/ocsp/signers/intermediate.pem
|
||||||
# signer_key: /etc/ocsp/signers/intermediate.key
|
# signer_key: /etc/ocsp/signers/intermediate.key
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ impl SnapshotCache {
|
|||||||
store: &S,
|
store: &S,
|
||||||
key: &str,
|
key: &str,
|
||||||
expected_sha256: &str,
|
expected_sha256: &str,
|
||||||
|
expected_ca_id: &str,
|
||||||
ttl: Duration,
|
ttl: Duration,
|
||||||
stale_grace: Duration,
|
stale_grace: Duration,
|
||||||
) -> Result<Arc<Snapshot>, CacheError> {
|
) -> Result<Arc<Snapshot>, CacheError> {
|
||||||
@@ -58,7 +59,7 @@ impl SnapshotCache {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match store.get(key).await {
|
match store.get(key).await {
|
||||||
Ok(bytes) => match Snapshot::parse(&bytes, expected_sha256) {
|
Ok(bytes) => match Snapshot::parse(&bytes, expected_sha256, expected_ca_id) {
|
||||||
Ok(snapshot) => {
|
Ok(snapshot) => {
|
||||||
let snapshot = Arc::new(snapshot);
|
let snapshot = Arc::new(snapshot);
|
||||||
*self.cached.write().await = Some(CachedSnapshot {
|
*self.cached.write().await = Some(CachedSnapshot {
|
||||||
@@ -135,7 +136,7 @@ mod tests {
|
|||||||
|
|
||||||
fn body(sha: &str) -> String {
|
fn body(sha: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
r#"{{"schema":"kca-ocsp-status/v1","issuer":{{"sha256":"{sha}"}},"entries":[{{"serial_hex":"01","status":"valid"}}]}}"#
|
r#"{{"schema":"kca-ocsp-status/v1","issuer":{{"id":"ca","sha256":"{sha}"}},"entries":[{{"serial_hex":"01","status":"valid"}}]}}"#
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +150,7 @@ mod tests {
|
|||||||
&store,
|
&store,
|
||||||
"key",
|
"key",
|
||||||
&sha,
|
&sha,
|
||||||
|
"ca",
|
||||||
Duration::from_millis(100),
|
Duration::from_millis(100),
|
||||||
Duration::from_secs(1)
|
Duration::from_secs(1)
|
||||||
),
|
),
|
||||||
@@ -156,6 +158,7 @@ mod tests {
|
|||||||
&store,
|
&store,
|
||||||
"key",
|
"key",
|
||||||
&sha,
|
&sha,
|
||||||
|
"ca",
|
||||||
Duration::from_millis(100),
|
Duration::from_millis(100),
|
||||||
Duration::from_secs(1)
|
Duration::from_secs(1)
|
||||||
)
|
)
|
||||||
@@ -175,6 +178,7 @@ mod tests {
|
|||||||
&store,
|
&store,
|
||||||
"key",
|
"key",
|
||||||
&sha,
|
&sha,
|
||||||
|
"ca",
|
||||||
Duration::from_millis(20),
|
Duration::from_millis(20),
|
||||||
Duration::from_secs(1),
|
Duration::from_secs(1),
|
||||||
)
|
)
|
||||||
@@ -187,6 +191,7 @@ mod tests {
|
|||||||
&store,
|
&store,
|
||||||
"key",
|
"key",
|
||||||
&sha,
|
&sha,
|
||||||
|
"ca",
|
||||||
Duration::from_millis(20),
|
Duration::from_millis(20),
|
||||||
Duration::from_secs(1),
|
Duration::from_secs(1),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub struct Config {
|
|||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct IssuerConfig {
|
pub struct IssuerConfig {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub ca_id: String,
|
||||||
pub ca_cert: PathBuf,
|
pub ca_cert: PathBuf,
|
||||||
pub signer_cert: PathBuf,
|
pub signer_cert: PathBuf,
|
||||||
pub signer_key: PathBuf,
|
pub signer_key: PathBuf,
|
||||||
@@ -55,9 +56,9 @@ impl Default for S3Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl S3Config {
|
impl S3Config {
|
||||||
pub fn object_key(&self, ca_fingerprint_sha256: &str) -> String {
|
pub fn object_key(&self, ca_id: &str) -> String {
|
||||||
let prefix = self.prefix.trim_matches('/');
|
let prefix = self.prefix.trim_matches('/');
|
||||||
let name = format!("{}.json", ca_fingerprint_sha256.to_lowercase());
|
let name = format!("{}.json", ca_id.to_lowercase());
|
||||||
if prefix.is_empty() {
|
if prefix.is_empty() {
|
||||||
name
|
name
|
||||||
} else {
|
} else {
|
||||||
@@ -104,3 +105,20 @@ pub fn load_config() -> Result<Config, Box<dyn std::error::Error + Send + Sync>>
|
|||||||
}
|
}
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_key_uses_ca_id() {
|
||||||
|
let config = S3Config {
|
||||||
|
prefix: "ocsp".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
config.object_key("123E4567-E89B-12D3-A456-426614174000"),
|
||||||
|
"ocsp/123e4567-e89b-12d3-a456-426614174000.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use crate::snapshot::{hash_sha1, hash_sha256, sha256_hex};
|
|||||||
|
|
||||||
pub struct Issuer {
|
pub struct Issuer {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub ca_id: String,
|
||||||
pub signer_cert: Certificate,
|
pub signer_cert: Certificate,
|
||||||
pub signer: Mutex<SignerKey>,
|
pub signer: Mutex<SignerKey>,
|
||||||
pub fingerprint: String,
|
pub fingerprint: String,
|
||||||
@@ -85,6 +86,7 @@ impl Issuer {
|
|||||||
|
|
||||||
Ok(Arc::new(Self {
|
Ok(Arc::new(Self {
|
||||||
name: config.name.clone(),
|
name: config.name.clone(),
|
||||||
|
ca_id: config.ca_id.clone(),
|
||||||
signer_cert,
|
signer_cert,
|
||||||
signer: Mutex::new(signer),
|
signer: Mutex::new(signer),
|
||||||
fingerprint,
|
fingerprint,
|
||||||
@@ -111,12 +113,13 @@ impl Issuer {
|
|||||||
store: &S,
|
store: &S,
|
||||||
config: &crate::config::S3Config,
|
config: &crate::config::S3Config,
|
||||||
) -> Result<Arc<crate::snapshot::Snapshot>, crate::cache::CacheError> {
|
) -> Result<Arc<crate::snapshot::Snapshot>, crate::cache::CacheError> {
|
||||||
let key = config.object_key(&self.fingerprint);
|
let key = config.object_key(&self.ca_id);
|
||||||
self.cache
|
self.cache
|
||||||
.get(
|
.get(
|
||||||
store,
|
store,
|
||||||
&key,
|
&key,
|
||||||
&self.fingerprint,
|
&self.fingerprint,
|
||||||
|
&self.ca_id,
|
||||||
Duration::from_millis(config.cache_ttl_ms),
|
Duration::from_millis(config.cache_ttl_ms),
|
||||||
Duration::from_secs(config.stale_grace_seconds),
|
Duration::from_secs(config.stale_grace_seconds),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ struct RawSnapshot {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct RawIssuer {
|
struct RawIssuer {
|
||||||
|
#[serde(default)]
|
||||||
|
id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
sha256: String,
|
sha256: String,
|
||||||
}
|
}
|
||||||
@@ -53,12 +55,22 @@ struct RawEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Snapshot {
|
impl Snapshot {
|
||||||
/// Parse and validate a snapshot against the configured CA fingerprint.
|
/// Parse and validate a snapshot against the configured CA identity.
|
||||||
pub fn parse(bytes: &[u8], expected_sha256: &str) -> Result<Self, SnapshotError> {
|
pub fn parse(
|
||||||
|
bytes: &[u8],
|
||||||
|
expected_sha256: &str,
|
||||||
|
expected_ca_id: &str,
|
||||||
|
) -> Result<Self, SnapshotError> {
|
||||||
let raw: RawSnapshot = serde_json::from_slice(bytes)?;
|
let raw: RawSnapshot = serde_json::from_slice(bytes)?;
|
||||||
if raw.schema != SCHEMA {
|
if raw.schema != SCHEMA {
|
||||||
return Err(SnapshotError::Schema(raw.schema));
|
return Err(SnapshotError::Schema(raw.schema));
|
||||||
}
|
}
|
||||||
|
if !raw.issuer.id.eq_ignore_ascii_case(expected_ca_id) {
|
||||||
|
return Err(SnapshotError::IssuerIdMismatch {
|
||||||
|
expected: expected_ca_id.to_string(),
|
||||||
|
actual: raw.issuer.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
if !raw.issuer.sha256.eq_ignore_ascii_case(expected_sha256) {
|
if !raw.issuer.sha256.eq_ignore_ascii_case(expected_sha256) {
|
||||||
return Err(SnapshotError::IssuerMismatch {
|
return Err(SnapshotError::IssuerMismatch {
|
||||||
expected: expected_sha256.to_string(),
|
expected: expected_sha256.to_string(),
|
||||||
@@ -158,6 +170,8 @@ pub enum SnapshotError {
|
|||||||
Json(#[from] serde_json::Error),
|
Json(#[from] serde_json::Error),
|
||||||
#[error("unsupported snapshot schema {0:?}")]
|
#[error("unsupported snapshot schema {0:?}")]
|
||||||
Schema(String),
|
Schema(String),
|
||||||
|
#[error("snapshot issuer ID {actual} does not match configured CA ID {expected}")]
|
||||||
|
IssuerIdMismatch { expected: String, actual: String },
|
||||||
#[error("snapshot issuer {actual} does not match configured CA {expected}")]
|
#[error("snapshot issuer {actual} does not match configured CA {expected}")]
|
||||||
IssuerMismatch { expected: String, actual: String },
|
IssuerMismatch { expected: String, actual: String },
|
||||||
#[error("invalid serial {0:?}")]
|
#[error("invalid serial {0:?}")]
|
||||||
@@ -191,7 +205,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parses_statuses_and_reasons() {
|
fn parses_statuses_and_reasons() {
|
||||||
let sha = sha256_hex(b"ca");
|
let sha = sha256_hex(b"ca");
|
||||||
let snapshot = Snapshot::parse(body(&sha).as_bytes(), &sha).unwrap();
|
let snapshot = Snapshot::parse(body(&sha).as_bytes(), &sha, "ca").unwrap();
|
||||||
let good = snapshot.status_for_serial(&[0x01, 0xab]).unwrap();
|
let good = snapshot.status_for_serial(&[0x01, 0xab]).unwrap();
|
||||||
assert_eq!(good.status, Status::Good);
|
assert_eq!(good.status, Status::Good);
|
||||||
let revoked = snapshot.status_for_serial(&[0xff]).unwrap();
|
let revoked = snapshot.status_for_serial(&[0xff]).unwrap();
|
||||||
@@ -203,11 +217,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rejects_wrong_issuer_schema_and_duplicates() {
|
fn rejects_wrong_issuer_schema_and_duplicates() {
|
||||||
let sha = sha256_hex(b"ca");
|
let sha = sha256_hex(b"ca");
|
||||||
assert!(Snapshot::parse(body(&sha).as_bytes(), "wrong").is_err());
|
assert!(Snapshot::parse(body(&sha).as_bytes(), "wrong", "ca").is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
Snapshot::parse(body(&sha).as_bytes(), &sha, "other"),
|
||||||
|
Err(SnapshotError::IssuerIdMismatch { .. })
|
||||||
|
));
|
||||||
let wrong_schema = body(&sha).replace(SCHEMA, "other");
|
let wrong_schema = body(&sha).replace(SCHEMA, "other");
|
||||||
assert!(Snapshot::parse(wrong_schema.as_bytes(), &sha).is_err());
|
assert!(Snapshot::parse(wrong_schema.as_bytes(), &sha, "ca").is_err());
|
||||||
let duplicate = body(&sha).replace("\"00ff\"", "\"01ab\"");
|
let duplicate = body(&sha).replace("\"00ff\"", "\"01ab\"");
|
||||||
assert!(Snapshot::parse(duplicate.as_bytes(), &sha).is_err());
|
assert!(Snapshot::parse(duplicate.as_bytes(), &sha, "ca").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ def _iso(value: Optional[datetime]) -> Optional[str]:
|
|||||||
return value.astimezone(timezone.utc).isoformat()
|
return value.astimezone(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def object_key(ca_fingerprint_sha256: str) -> str:
|
def object_key(ca_id: str) -> str:
|
||||||
"""The S3 key for one CA; the responder derives the same key."""
|
"""The S3 key for one CA database row; the responder uses the same CA ID."""
|
||||||
prefix = settings.ocsp_s3_prefix.strip("/")
|
prefix = settings.ocsp_s3_prefix.strip("/")
|
||||||
name = f"{ca_fingerprint_sha256.lower()}.json"
|
name = f"{ca_id.lower()}.json"
|
||||||
return f"{prefix}/{name}" if prefix else name
|
return f"{prefix}/{name}" if prefix else name
|
||||||
|
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] =
|
|||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
snapshot = await snapshot_for_ca(ca)
|
snapshot = await snapshot_for_ca(ca)
|
||||||
body = json.dumps(snapshot, indent=2, sort_keys=True).encode("utf-8")
|
body = json.dumps(snapshot, indent=2, sort_keys=True).encode("utf-8")
|
||||||
key = object_key(ca.fingerprint_sha256)
|
key = object_key(str(ca.id))
|
||||||
|
|
||||||
# boto3 is synchronous and credential resolution can block; do both the
|
# boto3 is synchronous and credential resolution can block; do both the
|
||||||
# client creation and the PUT outside the event loop.
|
# client creation and the PUT outside the event loop.
|
||||||
|
|||||||
@@ -496,8 +496,96 @@ class OcspSnapshotTests(unittest.TestCase):
|
|||||||
assert len(fake.calls) == 1
|
assert len(fake.calls) == 1
|
||||||
call = fake.calls[0]
|
call = fake.calls[0]
|
||||||
assert call["Bucket"] == "test-bucket"
|
assert call["Bucket"] == "test-bucket"
|
||||||
assert call["Key"] == f"ocsp/{ca['fingerprint_sha256']}.json"
|
assert call["Key"] == f"ocsp/{ca['id']}.json"
|
||||||
assert b"kca-ocsp-status/v1" in call["Body"]
|
assert b"kca-ocsp-status/v1" in call["Body"]
|
||||||
row = await OcspSnapshot.get(ca_id=ca["id"])
|
row = await OcspSnapshot.get(ca_id=ca["id"])
|
||||||
assert row.dirty is False
|
assert row.dirty is False
|
||||||
assert row.published_at is not None
|
assert row.published_at is not None
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_imported_ca_cannot_overwrite_other_vault_snapshot(self) -> None:
|
||||||
|
import json
|
||||||
|
from dataclasses import replace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from kca import ocsp
|
||||||
|
from kca.models import Certificate, OcspSnapshot
|
||||||
|
|
||||||
|
class FakeS3:
|
||||||
|
def __init__(self):
|
||||||
|
self.objects = {}
|
||||||
|
|
||||||
|
def put_object(self, **kwargs):
|
||||||
|
self.objects[kwargs["Key"]] = kwargs["Body"]
|
||||||
|
|
||||||
|
fake = FakeS3()
|
||||||
|
async with api_client() as client:
|
||||||
|
with oidc_user("alice"):
|
||||||
|
vault_id = await _vault(client)
|
||||||
|
ca_key = await _key(client, vault_id, "target ca")
|
||||||
|
ca = await _cert(client, vault_id, ca_key["id"], "Target CA", ca=True)
|
||||||
|
leaf_key = await _key(client, vault_id, "target leaf")
|
||||||
|
leaf = await _cert(
|
||||||
|
client, vault_id, leaf_key["id"], "Target Leaf", signer_id=ca["id"]
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
|
||||||
|
json={"reason": "keyCompromise"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
exported = await client.post(
|
||||||
|
f"/api/vaults/{vault_id}/certificates/{ca['id']}/export",
|
||||||
|
json={"format": "pem"},
|
||||||
|
)
|
||||||
|
assert exported.status_code == 200, exported.text
|
||||||
|
public_ca = exported.json()["content_base64"]
|
||||||
|
|
||||||
|
configured = replace(
|
||||||
|
ocsp.settings,
|
||||||
|
ocsp_s3_bucket="test-bucket",
|
||||||
|
ocsp_s3_prefix="ocsp",
|
||||||
|
)
|
||||||
|
with patch.object(ocsp, "settings", configured), patch.object(
|
||||||
|
ocsp, "_s3_client", lambda: fake
|
||||||
|
):
|
||||||
|
target = await Certificate.get(id=ca["id"])
|
||||||
|
target_key = await ocsp.publish_snapshot(target)
|
||||||
|
|
||||||
|
with oidc_user("bob"):
|
||||||
|
attacker_vault_id = await _vault(client)
|
||||||
|
payload = {"filename": "target-ca.crt", "content_base64": public_ca}
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/vaults/{attacker_vault_id}/import", json=payload
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert len(response.json()["imported"]) == 1
|
||||||
|
imported = await Certificate.get(
|
||||||
|
vault_id=attacker_vault_id,
|
||||||
|
fingerprint_sha256=ca["fingerprint_sha256"],
|
||||||
|
)
|
||||||
|
assert imported.id != target.id
|
||||||
|
assert imported.fingerprint_sha256 == target.fingerprint_sha256
|
||||||
|
|
||||||
|
row = await OcspSnapshot.get(ca_id=imported.id)
|
||||||
|
assert row.dirty is True
|
||||||
|
row.dirty = False
|
||||||
|
await row.save(update_fields=["dirty", "updated_at"])
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/vaults/{attacker_vault_id}/import", json=payload
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert len(response.json()["skipped"]) == 1
|
||||||
|
row = await OcspSnapshot.get(ca_id=imported.id)
|
||||||
|
assert row.dirty is True
|
||||||
|
imported_key = await ocsp.publish_snapshot(imported)
|
||||||
|
|
||||||
|
assert target_key == f"ocsp/{ca['id']}.json"
|
||||||
|
assert imported_key == f"ocsp/{imported.id}.json"
|
||||||
|
assert imported_key != target_key
|
||||||
|
assert set(fake.objects) == {target_key, imported_key}
|
||||||
|
target_snapshot = json.loads(fake.objects[target_key])
|
||||||
|
target_entries = {entry["serial_hex"]: entry for entry in target_snapshot["entries"]}
|
||||||
|
assert target_entries[leaf["serial_hex"].lower()]["status"] == "revoked"
|
||||||
|
imported_snapshot = json.loads(fake.objects[imported_key])
|
||||||
|
imported_serials = {entry["serial_hex"] for entry in imported_snapshot["entries"]}
|
||||||
|
assert leaf["serial_hex"].lower() not in imported_serials
|
||||||
|
|||||||
Reference in New Issue
Block a user