signalk-ssl 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,6 +55,20 @@ The CA private key is always encrypted at rest with PBES2 / PBKDF2-SHA256 / AES-
55
55
  | `env` | Reads `SIGNALK_SSL_PASSPHRASE` from the environment at startup. | Boxes where you set the env var via systemd / Compose. |
56
56
  | `webapp` | Prompts in the webapp on each restart. | High-security setups where the passphrase lives in your head. |
57
57
 
58
+ ### Changing the passphrase
59
+
60
+ The **Change passphrase** panel on the status dashboard re-encrypts the CA
61
+ private key under a new passphrase. The CA certificate itself is untouched, so
62
+ every device that already trusts your CA keeps working and no restart is
63
+ needed. Enter the current passphrase plus the new one; a wrong current
64
+ passphrase is rejected without changing anything on disk.
65
+
66
+ In `env` mode you must **also** update `SIGNALK_SSL_PASSPHRASE` to the new value
67
+ (systemd unit / Compose file), or the next restart won't be able to decrypt the
68
+ CA. In `convenience` mode the passphrase is machine-derived and not typeable, so
69
+ this flow doesn't apply — to re-key a convenience-mode install, switch to `env`
70
+ or `webapp` mode first.
71
+
58
72
  ## Mode of operation
59
73
 
60
74
  - **Generate** (default) — fresh CA on first run.
@@ -67,6 +81,7 @@ The CA private key is always encrypted at rest with PBES2 / PBKDF2-SHA256 / AES-
67
81
  - `POST /plugins/signalk-ssl/renew` — issue / renew leaf (admin auth required)
68
82
  - `POST /plugins/signalk-ssl/unlock` — supply passphrase (webapp mode, admin auth required)
69
83
  - `POST /plugins/signalk-ssl/lock` — drop in-memory passphrase
84
+ - `POST /plugins/signalk-ssl/rotate` — re-encrypt the CA key under a new passphrase (admin auth required)
70
85
  - `GET /signalk/v1/api/ssl/ca.crt` — **public** download of CA cert (PEM)
71
86
  - `GET /signalk/v1/api/ssl/ca.mobileconfig` — **public** download of Apple profile
72
87
 
@@ -97,6 +112,15 @@ The renewal scheduler runs daily and re-issues whenever the configured SANs no l
97
112
 
98
113
  SignalK refuses to start with a TLS key that isn't `0600`. The plugin writes `0600` and re-chmods on every install. If you see this error, check that no other process has rewritten the file.
99
114
 
115
+ ### "Cannot write the CA / certificate files" (rootless Podman UID shift)
116
+
117
+ The plugin probes write access to its data dir and the cert path at startup. Inside **rootless Podman**, a bind-mounted host directory can look present but reject child creation when the directory is owned by a UID that doesn't match the container's effective UID — the classic UID-shift symptom. When this happens the plugin logs a warning and shows a red banner on the status dashboard instead of silently failing on the first cert write.
118
+
119
+ Fixes:
120
+
121
+ - Run the container with `--userns=keep-id` (Podman) so in-container writes land as the host owner. On hosts where the SignalK user isn't UID 1000, use the explicit form `--userns=keep-id:uid=<in-image-uid>,gid=<in-image-gid>`.
122
+ - Or `chown` the mounted directory to the UID the container process runs as.
123
+
100
124
  ### "I rotated the CA and now every phone is broken"
101
125
 
102
126
  That's the cost of rotating a CA. Every device needs to re-install the new root. The webapp's "Regenerate CA" button shows this consequence before it lets you proceed.
@@ -36,6 +36,28 @@ export const registerAdminRoutes = (router, deps) => {
36
36
  deps.service.acknowledgeRestart();
37
37
  sendJson(res, 200, { kind: 'locked' });
38
38
  });
39
+ router.post('/rotate', asyncRoute(async (req, res) => {
40
+ const body = (req.body ?? {});
41
+ if (typeof body.oldPassphrase !== 'string' || body.oldPassphrase.length === 0) {
42
+ sendJson(res, 400, { error: 'oldPassphrase required' });
43
+ return;
44
+ }
45
+ if (typeof body.newPassphrase !== 'string' || body.newPassphrase.length === 0) {
46
+ sendJson(res, 400, { error: 'newPassphrase required' });
47
+ return;
48
+ }
49
+ const out = await deps.service.rotatePassphrase(body.oldPassphrase, body.newPassphrase);
50
+ // Map the outcome to a status: a wrong old passphrase is a client error,
51
+ // a missing CA is a 409 (nothing to rotate yet), an internal failure 500.
52
+ const status = out.kind === 'rotated'
53
+ ? 200
54
+ : out.kind === 'wrong-passphrase'
55
+ ? 403
56
+ : out.kind === 'no-ca'
57
+ ? 409
58
+ : 500;
59
+ sendJson(res, status, out);
60
+ }));
39
61
  };
40
62
  /** Mounted by the server at /signalk/v1/api/* via signalKApiRoutes — read-only public. */
41
63
  export const buildPublicRoutes = (router, deps) => {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Best-effort detection that we're running inside a container. None of these
3
+ * signals is authoritative on its own, but any one of them is a strong hint:
4
+ * - `/.dockerenv` — Docker writes this into every container.
5
+ * - `/run/.containerenv` — Podman's equivalent.
6
+ * - `container` env var — set by systemd-nspawn and some Podman setups.
7
+ */
8
+ export declare const detectContainer: (env?: NodeJS.ProcessEnv) => boolean;
9
+ export interface PermissionProbe {
10
+ readonly dir: string;
11
+ readonly writable: boolean;
12
+ readonly error: string | null;
13
+ }
14
+ /**
15
+ * Verify the process can actually create + remove a file under `dir`. A bare
16
+ * `access(dir, W_OK)` is not enough on bind-mounted volumes where the dir
17
+ * looks writable but the effective UID can't create children (the classic
18
+ * rootless-Podman UID-shift symptom). So we write a real probe file.
19
+ */
20
+ export declare const probeWritable: (dir: string) => Promise<PermissionProbe>;
21
+ export interface PermissionCheckInput {
22
+ readonly dataDir: string;
23
+ readonly configPath: string;
24
+ readonly containerized?: boolean;
25
+ }
26
+ /**
27
+ * Run write probes against the two directories the plugin must write to and,
28
+ * if either fails while containerized, return an operator-facing warning that
29
+ * names the rootless-Podman UID-shift failure mode explicitly. Returns null
30
+ * when everything is writable.
31
+ */
32
+ export declare const checkPermissions: (input: PermissionCheckInput) => Promise<string | null>;
@@ -0,0 +1,62 @@
1
+ import { access, constants, mkdir, rm, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ /**
5
+ * Best-effort detection that we're running inside a container. None of these
6
+ * signals is authoritative on its own, but any one of them is a strong hint:
7
+ * - `/.dockerenv` — Docker writes this into every container.
8
+ * - `/run/.containerenv` — Podman's equivalent.
9
+ * - `container` env var — set by systemd-nspawn and some Podman setups.
10
+ */
11
+ export const detectContainer = (env = process.env) => {
12
+ return (existsSync('/.dockerenv') ||
13
+ existsSync('/run/.containerenv') ||
14
+ (env.container !== undefined && env.container !== ''));
15
+ };
16
+ /**
17
+ * Verify the process can actually create + remove a file under `dir`. A bare
18
+ * `access(dir, W_OK)` is not enough on bind-mounted volumes where the dir
19
+ * looks writable but the effective UID can't create children (the classic
20
+ * rootless-Podman UID-shift symptom). So we write a real probe file.
21
+ */
22
+ export const probeWritable = async (dir) => {
23
+ const probe = join(dir, `.signalk-ssl-write-probe.${process.pid.toString()}`);
24
+ try {
25
+ await mkdir(dir, { recursive: true });
26
+ await access(dir, constants.W_OK);
27
+ // The access() check passes on some UID-shifted mounts that still reject
28
+ // child creation, so follow it with an actual create + remove.
29
+ await rm(probe, { force: true });
30
+ await writeFile(probe, '');
31
+ await rm(probe, { force: true });
32
+ return { dir, writable: true, error: null };
33
+ }
34
+ catch (e) {
35
+ return { dir, writable: false, error: e instanceof Error ? e.message : String(e) };
36
+ }
37
+ };
38
+ /**
39
+ * Run write probes against the two directories the plugin must write to and,
40
+ * if either fails while containerized, return an operator-facing warning that
41
+ * names the rootless-Podman UID-shift failure mode explicitly. Returns null
42
+ * when everything is writable.
43
+ */
44
+ export const checkPermissions = async (input) => {
45
+ const containerized = input.containerized ?? detectContainer();
46
+ const probes = await Promise.all([probeWritable(input.dataDir), probeWritable(input.configPath)]);
47
+ const failed = probes.filter((p) => !p.writable);
48
+ if (failed.length === 0) {
49
+ return null;
50
+ }
51
+ const dirs = failed.map((p) => p.dir).join(', ');
52
+ if (containerized) {
53
+ return (`Cannot write to ${dirs}. This is the classic rootless-Podman UID-shift ` +
54
+ `symptom: the bind-mounted host directory is owned by a UID that doesn't ` +
55
+ `match the container's effective UID. Run the container with ` +
56
+ `--userns=keep-id (Podman) so in-container writes land as the host owner, ` +
57
+ `or chown the mounted directory to the container UID. Until this is fixed ` +
58
+ `the plugin can't persist the CA or install certificates.`);
59
+ }
60
+ return (`Cannot write to ${dirs}. Check directory ownership and permissions — the ` +
61
+ `plugin needs to write the CA state and install ssl-*.pem there.`);
62
+ };
@@ -42,6 +42,15 @@ const pluginConstructor = (app) => {
42
42
  app.error(`${PLUGIN_ID} scheduled renewal failed: ${String(err)}`);
43
43
  };
44
44
  void store.init().then(async () => {
45
+ try {
46
+ const warning = await svcLocal.checkWritePermissions();
47
+ if (warning !== null) {
48
+ app.error(`${PLUGIN_ID} permission warning: ${warning}`);
49
+ }
50
+ }
51
+ catch (e) {
52
+ app.error(`${PLUGIN_ID} permission probe failed: ${String(e)}`);
53
+ }
45
54
  try {
46
55
  const outcome = await svcLocal.issueIfNeeded();
47
56
  app.debug(`${PLUGIN_ID} initial issueIfNeeded: ${JSON.stringify(outcome)}`);
@@ -17,6 +17,16 @@ export type IssueOutcome = {
17
17
  readonly kind: 'error';
18
18
  readonly message: string;
19
19
  };
20
+ export type RotateOutcome = {
21
+ readonly kind: 'rotated';
22
+ } | {
23
+ readonly kind: 'no-ca';
24
+ } | {
25
+ readonly kind: 'wrong-passphrase';
26
+ } | {
27
+ readonly kind: 'error';
28
+ readonly message: string;
29
+ };
20
30
  export interface ServiceStatus {
21
31
  readonly hasCa: boolean;
22
32
  readonly caFingerprint: string | null;
@@ -27,6 +37,7 @@ export interface ServiceStatus {
27
37
  readonly leafSansDns: readonly string[];
28
38
  readonly leafSansIp: readonly string[];
29
39
  readonly restartRequired: boolean;
40
+ readonly permissionWarning: string | null;
30
41
  }
31
42
  export interface SslServiceDeps {
32
43
  readonly store: CertStore;
@@ -37,10 +48,32 @@ export interface SslServiceDeps {
37
48
  export declare class SslService {
38
49
  private readonly deps;
39
50
  private restartRequired;
51
+ private permissionWarning;
40
52
  constructor(deps: SslServiceDeps);
53
+ /**
54
+ * Probe write access to the data dir and the cert install path. On a
55
+ * rootless-Podman UID-shift the bind-mounted host dir looks present but
56
+ * rejects child creation; this surfaces that as an operator-facing warning
57
+ * (logged at start, exposed via /status) rather than failing silently on
58
+ * the first cert write. Idempotent — safe to call on every start.
59
+ */
60
+ checkWritePermissions(): Promise<string | null>;
41
61
  targets(): InstallTargets;
42
62
  private resolveSans;
43
63
  issueIfNeeded(): Promise<IssueOutcome>;
64
+ /**
65
+ * Re-encrypt the CA private key under a new passphrase. The CA key is the
66
+ * only passphrase-protected artifact (leaf keys are written plaintext at
67
+ * 0o600 to the TLS path), so rotation is a single re-wrap: decrypt with the
68
+ * old passphrase, re-encrypt with the new one, write back atomically.
69
+ *
70
+ * `oldPassphrase` must match whatever the CA key is currently wrapped with
71
+ * — for `env`/`webapp` modes that's the operator's typed value; for
72
+ * `convenience` mode it's the machine-derived digest, which the caller
73
+ * can't type, so this route is only meaningful for env/webapp installs.
74
+ * We verify by attempting decryption and never touch disk on a mismatch.
75
+ */
76
+ rotatePassphrase(oldPassphrase: string, newPassphrase: string): Promise<RotateOutcome>;
44
77
  private bootstrapCa;
45
78
  private importCa;
46
79
  private signAndStoreLeaf;
@@ -4,13 +4,29 @@ import { computeSpkiFingerprint, decryptPrivateKeyPkcs8, encryptPrivateKeyPkcs8,
4
4
  import { needsRenewal } from './needs-renewal.js';
5
5
  import { parseSans, sanitizeHostname } from './sans.js';
6
6
  import { defaultTargets, installCerts } from './cert-installer.js';
7
+ import { checkPermissions } from './container-env.js';
7
8
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
8
9
  export class SslService {
9
10
  deps;
10
11
  restartRequired = false;
12
+ permissionWarning = null;
11
13
  constructor(deps) {
12
14
  this.deps = deps;
13
15
  }
16
+ /**
17
+ * Probe write access to the data dir and the cert install path. On a
18
+ * rootless-Podman UID-shift the bind-mounted host dir looks present but
19
+ * rejects child creation; this surfaces that as an operator-facing warning
20
+ * (logged at start, exposed via /status) rather than failing silently on
21
+ * the first cert write. Idempotent — safe to call on every start.
22
+ */
23
+ async checkWritePermissions() {
24
+ this.permissionWarning = await checkPermissions({
25
+ dataDir: this.deps.store.dataDir,
26
+ configPath: this.deps.configPath
27
+ });
28
+ return this.permissionWarning;
29
+ }
14
30
  targets() {
15
31
  return defaultTargets(this.deps.configPath);
16
32
  }
@@ -49,6 +65,42 @@ export class SslService {
49
65
  this.restartRequired = true;
50
66
  return { kind: 'issued', reason: 'first-run' };
51
67
  }
68
+ /**
69
+ * Re-encrypt the CA private key under a new passphrase. The CA key is the
70
+ * only passphrase-protected artifact (leaf keys are written plaintext at
71
+ * 0o600 to the TLS path), so rotation is a single re-wrap: decrypt with the
72
+ * old passphrase, re-encrypt with the new one, write back atomically.
73
+ *
74
+ * `oldPassphrase` must match whatever the CA key is currently wrapped with
75
+ * — for `env`/`webapp` modes that's the operator's typed value; for
76
+ * `convenience` mode it's the machine-derived digest, which the caller
77
+ * can't type, so this route is only meaningful for env/webapp installs.
78
+ * We verify by attempting decryption and never touch disk on a mismatch.
79
+ */
80
+ async rotatePassphrase(oldPassphrase, newPassphrase) {
81
+ const caState = await this.deps.store.readCaState();
82
+ if (caState === null) {
83
+ return { kind: 'no-ca' };
84
+ }
85
+ let caPrivateKey;
86
+ try {
87
+ caPrivateKey = await decryptPrivateKeyPkcs8(caState.encryptedKeyPem, oldPassphrase);
88
+ }
89
+ catch {
90
+ return { kind: 'wrong-passphrase' };
91
+ }
92
+ try {
93
+ const reEncrypted = await encryptPrivateKeyPkcs8(caPrivateKey, newPassphrase);
94
+ await this.deps.store.writeCaState({ ...caState, encryptedKeyPem: reEncrypted });
95
+ }
96
+ catch (e) {
97
+ return { kind: 'error', message: e instanceof Error ? e.message : String(e) };
98
+ }
99
+ // The in-memory passphrase (webapp mode) must follow the re-wrap, or the
100
+ // next resolve() would still hand back the old value and fail to decrypt.
101
+ this.deps.passphrase.unlockWith(newPassphrase);
102
+ return { kind: 'rotated' };
103
+ }
52
104
  async bootstrapCa(passphrase) {
53
105
  if (this.deps.config.mode === 'import') {
54
106
  return this.importCa(passphrase);
@@ -129,7 +181,8 @@ export class SslService {
129
181
  leafDaysRemaining: null,
130
182
  leafSansDns: [],
131
183
  leafSansIp: [],
132
- restartRequired: this.restartRequired
184
+ restartRequired: this.restartRequired,
185
+ permissionWarning: this.permissionWarning
133
186
  };
134
187
  }
135
188
  const decision = needsRenewal(leafState.certificatePem, this.resolveSans(), this.deps.config.renewalThresholdDays);
@@ -142,7 +195,8 @@ export class SslService {
142
195
  leafDaysRemaining: Math.round(decision.daysUntilExpiry),
143
196
  leafSansDns: this.deps.config.sans.dnsNames.map((d) => sanitizeHostname(d).toLowerCase()),
144
197
  leafSansIp: this.deps.config.sans.ipAddresses,
145
- restartRequired: this.restartRequired
198
+ restartRequired: this.restartRequired,
199
+ permissionWarning: this.permissionWarning
146
200
  };
147
201
  }
148
202
  acknowledgeRestart() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-ssl",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "SSL/TLS certificate management plugin for SignalK Node Server — generate a local CA, issue server certs, distribute the root via QR to phones/tablets",
5
5
  "type": "module",
6
6
  "main": "dist/plugin/index.js",