fix(push): restrict APNs certificate secret

Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
This commit is contained in:
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp
2026-07-30 17:16:48 -07:00
parent a8eabb623d
commit 80001b6eae
8 changed files with 99 additions and 3 deletions
+6
View File
@@ -5,5 +5,11 @@ exclude_rules_for_files:
# Self-signed test fixture generated solely to exercise reqwest identity parsing.
kingfisher.privkey.2:
- "*apns-test-identity.pem"
- "*apns-test-key-only.pem"
- "*apns-test-encrypted-identity.pem"
- "*apns-test-mismatched-identity.pem"
np.pem.1:
- "*apns-test-identity.pem"
- "*apns-test-key-only.pem"
- "*apns-test-encrypted-identity.pem"
- "*apns-test-mismatched-identity.pem"
+39 -2
View File
@@ -237,8 +237,14 @@ mod tests {
};
use std::sync::{Arc, Mutex};
// Self-signed test-only identity. It is not an Apple credential.
// Self-signed test-only identity material. None of these are Apple credentials.
const TEST_IDENTITY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-identity.pem");
const TEST_CERT_ONLY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-cert-only.pem");
const TEST_KEY_ONLY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-key-only.pem");
const TEST_ENCRYPTED_IDENTITY_PEM: &[u8] =
include_bytes!("../tests/fixtures/apns-test-encrypted-identity.pem");
const TEST_MISMATCHED_IDENTITY_PEM: &[u8] =
include_bytes!("../tests/fixtures/apns-test-mismatched-identity.pem");
#[derive(Default)]
struct CapturedRequest {
@@ -350,10 +356,41 @@ mod tests {
assert_eq!(body, r#"{"reason":"BadDeviceToken"}"#);
}
#[test]
fn empty_certificate_identity_fails_as_a_credential_error() {
assert_credential_error(b"");
}
#[test]
fn malformed_certificate_identity_fails_as_a_credential_error() {
assert_credential_error(b"not a PEM identity");
}
#[test]
fn certificate_without_private_key_fails_as_a_credential_error() {
assert_credential_error(TEST_CERT_ONLY_PEM);
}
#[test]
fn private_key_without_certificate_fails_as_a_credential_error() {
assert_credential_error(TEST_KEY_ONLY_PEM);
}
#[test]
fn encrypted_private_key_fails_as_a_credential_error() {
assert_credential_error(TEST_ENCRYPTED_IDENTITY_PEM);
}
#[test]
fn mismatched_private_key_fails_as_a_credential_error() {
// reqwest parses both PEM blocks, then rejects the mismatched pair while
// building the TLS client. This locks the ClientBuilder error mapping.
assert_credential_error(TEST_MISMATCHED_IDENTITY_PEM);
}
fn assert_credential_error(identity_pem: &[u8]) {
assert!(matches!(
ApnsTransport::certificate(b"not a PEM identity", "app.topic".to_owned()),
ApnsTransport::certificate(identity_pem, "app.topic".to_owned()),
Err(ApnsError::Credential)
));
}
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw
ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2
NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5
LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy
0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS
o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU
wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD
AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP
2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k
-----END CERTIFICATE-----
@@ -0,0 +1,19 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBCrxiLXIJU5iHcD0IMS
sRI0AgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQmlIQbhuOv5VUfS6I
MVPLEwSBkNqbXztd0jeDg0nA1RCDPerWJUZqN5i6TtZtLwxLhpfcrDPT0aVEoFLv
dyRLcdzRmYNmHAoEaO0o0nLahGOlu4PlYqEoTahIq/ursix7JV5NhUJUWMFJFTz9
qgYSTxsvecejzM4SvMMVx5zVhgn/ojMDbocNOA8DfMW/U6gP9AxBV5RqyMGsMcuK
OPn9XCFJsg==
-----END ENCRYPTED PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw
ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2
NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5
LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy
0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS
o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU
wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD
AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP
2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k
-----END CERTIFICATE-----
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/p0z63nx4o4jOiA0
AEwfcyxe4NyuSjl0wPYOW5u3SQahRANCAATVJOs+qdG7RX0ma7NcjEyy0tNu8pEu
RWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS
-----END PRIVATE KEY-----
@@ -0,0 +1,16 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg18TP8zUw6UBPuIc2
4zZIQ7TMe4Iu9VtXGxVXMV3PRPqhRANCAASN9Thxojkwcn1d2XN3KswViaVM+tpK
v69Qne0M1q8A6finFJ7chBwu8/G+nFPyYszJZnm6vGwxzxIBEpd9KJT1
-----END PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw
ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2
NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5
LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy
0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS
o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU
wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD
AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP
2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k
-----END CERTIFICATE-----
@@ -50,7 +50,7 @@ spec:
- name: app-attest-root
secret: { secretName: {{ .Values.appAttestRoot.secretName }}, items: [{ key: {{ .Values.appAttestRoot.secretKey }}, path: root.pem }] }
- name: apns-cert
secret: { secretName: {{ .Values.apnsCert.secretName }}, items: [{ key: {{ .Values.apnsCert.secretKey }}, path: identity.pem }] }
secret: { secretName: {{ .Values.apnsCert.secretName }}, defaultMode: 0400, items: [{ key: {{ .Values.apnsCert.secretKey }}, path: identity.pem }] }
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}
{{- end }}
@@ -44,6 +44,8 @@ assert j['metadata']['annotations']=={
env={e['name'] for e in d['spec']['template']['spec']['containers'][0]['env']}
required={'DATABASE_URL','BUZZ_PUSH_APNS_CERT_PATH','BUZZ_PUSH_APNS_TOPIC','BUZZ_PUSH_GRANT_KEYS','BUZZ_PUSH_TOKEN_KEYS','BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS'}
assert required <= env
apns_volume=next(v for v in d['spec']['template']['spec']['volumes'] if v['name']=='apns-cert')
assert apns_volume['secret']['defaultMode']==0o400, apns_volume
assert d['spec']['replicas'] >= 2
assert not any(x and x.get('kind')=='HTTPRoute' for x in xs)
# Observability is opt-in: default render exposes no scrape CRDs and 8081 stays