diff --git a/apps/portal/package.json b/apps/portal/package.json index 986c813..7330e13 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "ioredis": "^5.11.1", + "node-forge": "^1.4.0", "nuxt": "^4.4.6", "nuxt-oidc-auth": "1.0.0-beta.11", "undici": "^7.2.1", @@ -20,6 +21,7 @@ }, "devDependencies": { "@types/node": "^20.0.0", + "@types/node-forge": "^1.3.14", "typescript": "^5.6.0", "vue-tsc": "^3.2.6" }, diff --git a/apps/portal/pnpm-lock.yaml b/apps/portal/pnpm-lock.yaml index d22a335..2beaef1 100644 --- a/apps/portal/pnpm-lock.yaml +++ b/apps/portal/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: ioredis: specifier: ^5.11.1 version: 5.11.1 + node-forge: + specifier: ^1.4.0 + version: 1.4.0 nuxt: 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) @@ -30,6 +33,9 @@ importers: '@types/node': specifier: ^20.0.0 version: 20.19.41 + '@types/node-forge': + specifier: ^1.3.14 + version: 1.3.14 typescript: specifier: ^5.6.0 version: 5.9.3 @@ -1406,6 +1412,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node-forge@1.3.14': + resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} + '@types/node@20.19.41': resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} @@ -5244,6 +5253,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node-forge@1.3.14': + dependencies: + '@types/node': 20.19.41 + '@types/node@20.19.41': dependencies: undici-types: 6.21.0 diff --git a/apps/portal/server/api/apple-mailconfig.get.ts b/apps/portal/server/api/apple-mailconfig.get.ts index 0ea788a..82a202d 100644 --- a/apps/portal/server/api/apple-mailconfig.get.ts +++ b/apps/portal/server/api/apple-mailconfig.get.ts @@ -12,6 +12,8 @@ import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' import { randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' +import forge from 'node-forge' const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ @@ -124,5 +126,45 @@ export default defineEventHandler(async (event) => { 'Content-Disposition', `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 + } +} diff --git a/infrastructure/production/fleet/apps/portal.yaml b/infrastructure/production/fleet/apps/portal.yaml index 851b8e9..ac70205 100644 --- a/infrastructure/production/fleet/apps/portal.yaml +++ b/infrastructure/production/fleet/apps/portal.yaml @@ -41,6 +41,12 @@ spec: value: https://booking.dezky.eu - name: NUXT_PUBLIC_MAIL_URL 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. - name: PLATFORM_API_INTERNAL_URL value: http://platform-api.dezky-apps.svc.cluster.local:3001 @@ -50,6 +56,10 @@ spec: envFrom: - secretRef: name: portal-secrets + volumeMounts: + - name: profile-sign + mountPath: /profile-sign + readOnly: true resources: requests: cpu: 100m @@ -66,6 +76,12 @@ spec: port: http initialDelaySeconds: 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 kind: Service