Files
werkjournal/scripts/setup-push-remote.py
2026-09-09 11:43:20 +02:00

112 lines
4.7 KiB
Python

#!/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()))