#!/usr/bin/env python3 """Provision VAPID keys locally on the server, without printing key material.""" import fcntl import os from pathlib import Path import re import subprocess import tempfile PUBLIC = 'WERKJOURNAL_PUSH_PUBLIC_KEY' PRIVATE = 'WERKJOURNAL_PUSH_PRIVATE_KEY' ENABLED = 'WERKJOURNAL_PUSH_ENABLED' JAVA = ''' import java.security.*; import java.security.interfaces.*; import java.security.spec.*; import java.util.*; public class PushKeys { static byte[] fixed(java.math.BigInteger n) { byte[] source = n.toByteArray(), result = new byte[32]; int count = Math.min(32, source.length); System.arraycopy(source, source.length-count, result, 32-count, count); return result; } public static void main(String[] args) throws Exception { var generator = KeyPairGenerator.getInstance("EC"); generator.initialize(new ECGenParameterSpec("secp256r1")); var pair = generator.generateKeyPair(); var point = ((ECPublicKey)pair.getPublic()).getW(); byte[] publicKey = new byte[65]; publicKey[0] = 4; System.arraycopy(fixed(point.getAffineX()), 0, publicKey, 1, 32); System.arraycopy(fixed(point.getAffineY()), 0, publicKey, 33, 32); var encoder = Base64.getUrlEncoder().withoutPadding(); System.out.println(encoder.encodeToString(publicKey)); System.out.println(encoder.encodeToString(fixed(((ECPrivateKey)pair.getPrivate()).getS()))); } } ''' def generate(home): with tempfile.TemporaryDirectory(prefix='werkjournal-push-') as folder: source = Path(folder) / 'PushKeys.java' source.write_text(JAVA) process = subprocess.run([str(home / 'opt/jdk25/bin/java'), str(source)], capture_output=True, text=True, timeout=30) if process.returncode: raise RuntimeError('Push key generation failed') lines = process.stdout.splitlines() if len(lines) != 2 or not re.fullmatch('[A-Za-z0-9_-]{87}', lines[0]) or not re.fullmatch('[A-Za-z0-9_-]{43}', lines[1]): raise RuntimeError('Unexpected key generator output') return lines def configure(home, keygen=generate): app = home / 'opt/werkjournal' if not app.is_dir(): raise RuntimeError('Existing Werkjournal installation required') with (app / '.deploy.lock').open('a') as lock: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) directory = home / '.config/werkjournal' if directory.is_symlink() or (home / '.config').is_symlink(): raise RuntimeError('Configuration directory must not be a symlink') directory.mkdir(parents=True, exist_ok=True, mode=0o700) path = directory / 'environment' if path.is_symlink(): raise RuntimeError('Environment file must not be a symlink') original = path.read_text() if path.exists() else '' if path.exists() and path.stat().st_mode & 0o077: raise RuntimeError('Existing environment file must be owner-only') values = {} for line in original.splitlines(): if '=' not in line or line.lstrip().startswith('#'): continue name, value = line.split('=', 1) if name in (PUBLIC, PRIVATE, ENABLED): if name in values: raise RuntimeError('Duplicate push configuration') values[name] = value if PUBLIC in values or PRIVATE in values: if not values.get(PUBLIC) or not values.get(PRIVATE): raise RuntimeError('Incomplete push key configuration; refusing rotation') return 'Existing push keys preserved; configuration unchanged.' if values.get(ENABLED, 'false') != 'false': raise RuntimeError('Disable push before provisioning missing keys') public, private = keygen(home) additions = f'{PUBLIC}={public}\n{PRIVATE}={private}\n' if ENABLED not in values: additions += f'{ENABLED}=false\n' fd, temporary = tempfile.mkstemp(prefix='.environment-', dir=directory) try: with os.fdopen(fd, 'w') as output: output.write(original + ('\n' if original and not original.endswith('\n') else '') + additions) output.flush() os.fsync(output.fileno()) os.replace(temporary, path) parent = os.open(directory, os.O_RDONLY | os.O_DIRECTORY) try: os.fsync(parent) finally: os.close(parent) finally: if os.path.exists(temporary): os.unlink(temporary) return 'Push keys provisioned in protected environment; delivery remains disabled. No restart performed.' if __name__ == '__main__': os.umask(0o077) print(configure(Path.home()))