feat(portal): sign Apple profiles — Verified instead of 'unsigned' warning
ci / changes (push) Successful in 4s
ci / tc_booking (push) Has been skipped
ci / tc_operator (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Has been skipped
ci / test_platform_api (push) Has been skipped
ci / build_booking (push) Has been skipped
ci / build_operator (push) Has been skipped
ci / build_platform_api (push) Has been skipped
ci / tc_portal (push) Successful in 26s
ci / build_portal (push) Successful in 49s
ci / deploy (push) Successful in 42s
ci / changes (push) Successful in 4s
ci / tc_booking (push) Has been skipped
ci / tc_operator (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Has been skipped
ci / test_platform_api (push) Has been skipped
ci / build_booking (push) Has been skipped
ci / build_operator (push) Has been skipped
ci / build_platform_api (push) Has been skipped
ci / tc_portal (push) Successful in 26s
ci / build_portal (push) Successful in 49s
ci / deploy (push) Successful in 42s
Unsigned .mobileconfig installs trip macOS warnings ('unknown developer')
and an extra System Settings hunt. The route now wraps the profile in
PKCS#7 SignedData (node-forge, SHA-256, full chain embedded) using the
portal's own cert-manager LE certificate mounted read-only into the pod
(PROFILE_SIGN_CERT/KEY). Publicly-trusted chain → Apple shows Verified.
Dev (no env) and any signing failure fall back to unsigned — the
download must never break over the badge. Signature round-trip verified
with openssl smime.
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
|
"node-forge": "^1.4.0",
|
||||||
"nuxt": "^4.4.6",
|
"nuxt": "^4.4.6",
|
||||||
"nuxt-oidc-auth": "1.0.0-beta.11",
|
"nuxt-oidc-auth": "1.0.0-beta.11",
|
||||||
"undici": "^7.2.1",
|
"undici": "^7.2.1",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/node-forge": "^1.3.14",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vue-tsc": "^3.2.6"
|
"vue-tsc": "^3.2.6"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+13
@@ -11,6 +11,9 @@ importers:
|
|||||||
ioredis:
|
ioredis:
|
||||||
specifier: ^5.11.1
|
specifier: ^5.11.1
|
||||||
version: 5.11.1
|
version: 5.11.1
|
||||||
|
node-forge:
|
||||||
|
specifier: ^1.4.0
|
||||||
|
version: 1.4.0
|
||||||
nuxt:
|
nuxt:
|
||||||
specifier: ^4.4.6
|
specifier: ^4.4.6
|
||||||
version: 4.4.6(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@20.19.41)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.3(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.2(typescript@5.9.3))(yaml@2.9.0)
|
version: 4.4.6(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@20.19.41)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.3(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.2(typescript@5.9.3))(yaml@2.9.0)
|
||||||
@@ -30,6 +33,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20.0.0
|
specifier: ^20.0.0
|
||||||
version: 20.19.41
|
version: 20.19.41
|
||||||
|
'@types/node-forge':
|
||||||
|
specifier: ^1.3.14
|
||||||
|
version: 1.3.14
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -1406,6 +1412,9 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/node-forge@1.3.14':
|
||||||
|
resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==}
|
||||||
|
|
||||||
'@types/node@20.19.41':
|
'@types/node@20.19.41':
|
||||||
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
|
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
|
||||||
|
|
||||||
@@ -5244,6 +5253,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/node-forge@1.3.14':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.19.41
|
||||||
|
|
||||||
'@types/node@20.19.41':
|
'@types/node@20.19.41':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import forge from 'node-forge'
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
@@ -124,5 +126,45 @@ export default defineEventHandler(async (event) => {
|
|||||||
'Content-Disposition',
|
'Content-Disposition',
|
||||||
`attachment; filename="dezky-mail-${localPart.replace(/[^a-z0-9.-]/gi, '_')}.mobileconfig"`,
|
`attachment; filename="dezky-mail-${localPart.replace(/[^a-z0-9.-]/gi, '_')}.mobileconfig"`,
|
||||||
)
|
)
|
||||||
return profile
|
return signProfile(profile)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sign the profile (PKCS#7 SignedData with embedded content) using the TLS
|
||||||
|
// cert mounted from cert-manager (PROFILE_SIGN_CERT/KEY in production —
|
||||||
|
// fleet/apps/portal.yaml). A publicly-trusted signature turns Apple's
|
||||||
|
// "unsigned profile" warning into a green Verified badge. Unset env (dev)
|
||||||
|
// → unsigned profile, still installable with the warning.
|
||||||
|
function signProfile(profileXml: string): Buffer | string {
|
||||||
|
const certPath = process.env.PROFILE_SIGN_CERT
|
||||||
|
const keyPath = process.env.PROFILE_SIGN_KEY
|
||||||
|
if (!certPath || !keyPath) return profileXml
|
||||||
|
try {
|
||||||
|
const certPem = readFileSync(certPath, 'utf8')
|
||||||
|
const keyPem = readFileSync(keyPath, 'utf8')
|
||||||
|
// cert-manager's tls.crt is the full chain — leaf first. Include every
|
||||||
|
// cert so Apple can build the path to the trusted root.
|
||||||
|
const certs = certPem
|
||||||
|
.split(/(?=-----BEGIN CERTIFICATE-----)/)
|
||||||
|
.filter((c) => c.includes('BEGIN CERTIFICATE'))
|
||||||
|
.map((c) => forge.pki.certificateFromPem(c))
|
||||||
|
const p7 = forge.pkcs7.createSignedData()
|
||||||
|
p7.content = forge.util.createBuffer(profileXml, 'utf8')
|
||||||
|
for (const c of certs) p7.addCertificate(c)
|
||||||
|
p7.addSigner({
|
||||||
|
key: forge.pki.privateKeyFromPem(keyPem) as forge.pki.rsa.PrivateKey,
|
||||||
|
certificate: certs[0]!,
|
||||||
|
digestAlgorithm: forge.pki.oids.sha256!,
|
||||||
|
authenticatedAttributes: [
|
||||||
|
{ type: forge.pki.oids.contentType!, value: forge.pki.oids.data! },
|
||||||
|
{ type: forge.pki.oids.messageDigest! },
|
||||||
|
{ type: forge.pki.oids.signingTime! },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
p7.sign()
|
||||||
|
return Buffer.from(forge.asn1.toDer(p7.toAsn1()).getBytes(), 'binary')
|
||||||
|
} catch (err) {
|
||||||
|
// Never fail the download over signing — fall back to unsigned.
|
||||||
|
console.error('apple-mailconfig: profile signing failed, serving unsigned:', err)
|
||||||
|
return profileXml
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ spec:
|
|||||||
value: https://booking.dezky.eu
|
value: https://booking.dezky.eu
|
||||||
- name: NUXT_PUBLIC_MAIL_URL
|
- name: NUXT_PUBLIC_MAIL_URL
|
||||||
value: https://mail.dezky.eu
|
value: https://mail.dezky.eu
|
||||||
|
# Sign Apple .mobileconfig profiles with the portal's own LE cert
|
||||||
|
# (mounted below) so macOS shows Verified instead of "unsigned".
|
||||||
|
- name: PROFILE_SIGN_CERT
|
||||||
|
value: /profile-sign/tls.crt
|
||||||
|
- name: PROFILE_SIGN_KEY
|
||||||
|
value: /profile-sign/tls.key
|
||||||
# Cluster-internal address of platform-api for the nitro proxy.
|
# Cluster-internal address of platform-api for the nitro proxy.
|
||||||
- name: PLATFORM_API_INTERNAL_URL
|
- name: PLATFORM_API_INTERNAL_URL
|
||||||
value: http://platform-api.dezky-apps.svc.cluster.local:3001
|
value: http://platform-api.dezky-apps.svc.cluster.local:3001
|
||||||
@@ -50,6 +56,10 @@ spec:
|
|||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: portal-secrets
|
name: portal-secrets
|
||||||
|
volumeMounts:
|
||||||
|
- name: profile-sign
|
||||||
|
mountPath: /profile-sign
|
||||||
|
readOnly: true
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 100m
|
cpu: 100m
|
||||||
@@ -66,6 +76,12 @@ spec:
|
|||||||
port: http
|
port: http
|
||||||
initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
periodSeconds: 30
|
periodSeconds: 30
|
||||||
|
volumes:
|
||||||
|
# cert-manager-maintained app.dezky.eu cert — reused as the profile
|
||||||
|
# signing identity (any publicly-trusted cert works for PKCS#7).
|
||||||
|
- name: profile-sign
|
||||||
|
secret:
|
||||||
|
secretName: app-dezky-eu-tls
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
Reference in New Issue
Block a user