112 lines
4.7 KiB
Python
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()))
|