signalk-ssl 0.0.1 → 0.1.6
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 +99 -10
- package/dist/plugin/api.d.ts +15 -0
- package/dist/plugin/api.js +63 -0
- package/dist/plugin/cert-installer.d.ts +13 -0
- package/dist/plugin/cert-installer.js +41 -0
- package/dist/plugin/crypto.d.ts +15 -0
- package/dist/plugin/crypto.js +163 -0
- package/dist/plugin/index.d.ts +3 -0
- package/dist/plugin/index.js +81 -0
- package/dist/plugin/mobileconfig.d.ts +10 -0
- package/dist/plugin/mobileconfig.js +95 -0
- package/dist/plugin/needs-renewal.d.ts +10 -0
- package/dist/plugin/needs-renewal.js +56 -0
- package/dist/plugin/passphrase-source.d.ts +28 -0
- package/dist/plugin/passphrase-source.js +69 -0
- package/dist/plugin/sans.d.ts +8 -0
- package/dist/plugin/sans.js +62 -0
- package/dist/plugin/scheduler.d.ts +5 -0
- package/dist/plugin/scheduler.js +13 -0
- package/dist/plugin/schema.d.ts +29 -0
- package/dist/plugin/schema.js +73 -0
- package/dist/plugin/service.d.ts +51 -0
- package/dist/plugin/service.js +179 -0
- package/dist/plugin/storage.d.ts +37 -0
- package/dist/plugin/storage.js +133 -0
- package/dist/plugin/types.d.ts +30 -0
- package/dist/plugin/types.js +1 -0
- package/package.json +84 -9
- package/public/assets/index-Bx2jIpfl.js +9 -0
- package/public/assets/index-CVCKF5kt.css +2 -0
- package/public/index.html +13 -0
- package/index.js +0 -19
package/README.md
CHANGED
|
@@ -1,22 +1,111 @@
|
|
|
1
1
|
# signalk-ssl
|
|
2
2
|
|
|
3
|
-
SSL/TLS certificate management
|
|
3
|
+
SSL/TLS certificate management for [SignalK Node Server](https://signalk.org/).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Generates a local Certificate Authority, signs server certificates for your boat's hostnames and IP addresses, and provides a QR code so phones and tablets can install the CA root without SSH.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Why
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- Marina Wi-Fi has no public DNS name, so Let's Encrypt is out.
|
|
10
|
+
- Self-signed certs without a CA make every browser scream.
|
|
11
|
+
- Doing it by hand means SSH, `openssl`, and `update-ca-certificates` per device — for non-technical boaters that's not happening.
|
|
10
12
|
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
13
|
+
`signalk-ssl` collapses the whole flow into "open plugin → configure SANs → scan QR on each phone".
|
|
14
|
+
|
|
15
|
+
## Prerequisites
|
|
16
|
+
|
|
17
|
+
- Node.js ≥ 22.5.0 (matches the `engines.node` floor in `package.json`)
|
|
18
|
+
- SignalK Node Server ≥ 2.0 (uses the `@signalk/server-api` v2 plugin contract)
|
|
16
19
|
|
|
17
20
|
## Install
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
In the SignalK admin UI: **Appstore → Available → signalk-ssl → Install**.
|
|
23
|
+
|
|
24
|
+
The Appstore installs plugins with `npm install --ignore-scripts`, so this package ships with `dist/` and `public/` pre-built. No build step runs on your server.
|
|
25
|
+
|
|
26
|
+
## Configure
|
|
27
|
+
|
|
28
|
+
1. Enable the plugin in the SignalK admin UI.
|
|
29
|
+
2. Open the plugin configuration screen and fill in:
|
|
30
|
+
- **SANs** — at least one DNS name (e.g. `signalk.local`, `boat.local`) and/or the boat's LAN IP. The webapp shows the discovered IPv4 addresses.
|
|
31
|
+
- **Passphrase mode** — `convenience` is the default and just works.
|
|
32
|
+
- Defaults for CA validity (10 years), leaf validity (397 days), renewal threshold (30 days), clock-skew backdate (24 hours) are fine for most boats.
|
|
33
|
+
3. Save the config. The plugin generates the CA, signs a leaf certificate, and writes both to SignalK's TLS path (`ssl-cert.pem`, `ssl-key.pem`, `ssl-chain.pem` in the configured config directory).
|
|
34
|
+
4. Open the plugin webapp at `/plugins/signalk-ssl/`.
|
|
35
|
+
5. Restart SignalK so the new certificate is picked up by the HTTPS listener. (The webapp shows a banner reminding you.)
|
|
36
|
+
|
|
37
|
+
## Distribute the CA to phones
|
|
38
|
+
|
|
39
|
+
In the webapp, the **Install on your devices** panel shows a QR code. The target URL is auto-selected by user-agent:
|
|
40
|
+
|
|
41
|
+
- iPhone / iPad → `.mobileconfig` profile (installs as a configuration profile; user enables full trust in Settings)
|
|
42
|
+
- Android / desktop → plain `.crt` with `application/x-x509-ca-cert` MIME
|
|
43
|
+
|
|
44
|
+
Scan with the device's camera, follow the OS prompts. The webapp includes step-by-step instructions for each platform.
|
|
45
|
+
|
|
46
|
+
Verify out-of-band by comparing the SHA-256 fingerprint shown on the boat against the one displayed on the device after install.
|
|
47
|
+
|
|
48
|
+
## Passphrase modes
|
|
49
|
+
|
|
50
|
+
The CA private key is always encrypted at rest with PBES2 / PBKDF2-SHA256 / AES-256-CBC PKCS#8.
|
|
51
|
+
|
|
52
|
+
| Mode | What it does | When to use |
|
|
53
|
+
| ----------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
54
|
+
| `convenience` (default) | Derives the wrapping key from this host's identity. Nothing to type. | Single-purpose SignalK boxes where physical access ≈ root. |
|
|
55
|
+
| `env` | Reads `SIGNALK_SSL_PASSPHRASE` from the environment at startup. | Boxes where you set the env var via systemd / Compose. |
|
|
56
|
+
| `webapp` | Prompts in the webapp on each restart. | High-security setups where the passphrase lives in your head. |
|
|
57
|
+
|
|
58
|
+
## Mode of operation
|
|
59
|
+
|
|
60
|
+
- **Generate** (default) — fresh CA on first run.
|
|
61
|
+
- **Import** — load an existing CA cert + encrypted key from configured paths. Useful if you're moving from Keeper / the SignalK Universal Installer, or running multiple SignalK servers behind one CA.
|
|
62
|
+
|
|
63
|
+
## Routes
|
|
64
|
+
|
|
65
|
+
- `GET /plugins/signalk-ssl/` — webapp (admin auth required)
|
|
66
|
+
- `GET /plugins/signalk-ssl/status` — JSON status (admin auth required)
|
|
67
|
+
- `POST /plugins/signalk-ssl/renew` — issue / renew leaf (admin auth required)
|
|
68
|
+
- `POST /plugins/signalk-ssl/unlock` — supply passphrase (webapp mode, admin auth required)
|
|
69
|
+
- `POST /plugins/signalk-ssl/lock` — drop in-memory passphrase
|
|
70
|
+
- `GET /signalk/v1/api/ssl/ca.crt` — **public** download of CA cert (PEM)
|
|
71
|
+
- `GET /signalk/v1/api/ssl/ca.mobileconfig` — **public** download of Apple profile
|
|
72
|
+
|
|
73
|
+
The two `/ssl/` paths are intentionally unauthenticated so phones without SignalK accounts can fetch the CA via the QR-coded URL. (`PUT`/`POST` on `/signalk/v1/api/*` is auto-protected by the server, so we can't accidentally expose a destructive endpoint here.)
|
|
74
|
+
|
|
75
|
+
## Container (Podman / Docker) notes
|
|
76
|
+
|
|
77
|
+
- The plugin uses `app.getDataDirPath()`, which means the per-plugin data lives under SignalK's data volume — survives container rebuilds.
|
|
78
|
+
- Certs land at `${app.config.configPath}/ssl-{cert,key,chain}.pem`, again on the data volume.
|
|
79
|
+
- mDNS `.local` resolution does **not** work through Podman's default bridge network. Either run with `--network=host` or use IP-based SANs and DNS on your router. The webapp can show the LAN IP URL as a fallback when it detects this case.
|
|
80
|
+
- For outbound trust **inside** the container (so the SignalK Node process trusts its own CA when calling itself or another boat service), set `NODE_EXTRA_CA_CERTS=/path/to/ca.crt` in the container env. Node reads this before plugins load, so the plugin can't set it on itself — the value must be in your Quadlet/Compose/run script.
|
|
81
|
+
|
|
82
|
+
## Troubleshooting
|
|
83
|
+
|
|
84
|
+
### "iOS Safari says the certificate is invalid"
|
|
85
|
+
|
|
86
|
+
Three usual causes:
|
|
87
|
+
|
|
88
|
+
1. The leaf cert doesn't list the hostname in the SAN. Check the SANs in the plugin config; re-issue if you added one after the fact.
|
|
89
|
+
2. The phone's clock is more than 24 hours behind. This plugin backdates `notBefore` by 24h to soften this; if it's still too far off, fix NTP on the boat.
|
|
90
|
+
3. The CA root isn't installed (or full trust isn't enabled in Settings → General → About → Certificate Trust Settings).
|
|
91
|
+
|
|
92
|
+
### "I changed the LAN IP and now nothing works"
|
|
93
|
+
|
|
94
|
+
The renewal scheduler runs daily and re-issues whenever the configured SANs no longer cover the leaf cert. To force an immediate refresh, click **Renew now** in the webapp.
|
|
95
|
+
|
|
96
|
+
### "Permission denied" on the cert key
|
|
97
|
+
|
|
98
|
+
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
|
+
|
|
100
|
+
### "I rotated the CA and now every phone is broken"
|
|
101
|
+
|
|
102
|
+
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.
|
|
103
|
+
|
|
104
|
+
## Out of scope
|
|
105
|
+
|
|
106
|
+
- ACME / Let's Encrypt (would live in a separate `signalk-acme` plugin)
|
|
107
|
+
- Revocation lists / OCSP
|
|
108
|
+
- Multi-CA / multi-tenant
|
|
20
109
|
|
|
21
110
|
## License
|
|
22
111
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IRouter } from 'express';
|
|
2
|
+
import type { SslService } from './service.js';
|
|
3
|
+
import type { SignalkSslConfig } from './schema.js';
|
|
4
|
+
import type { PassphraseSource } from './passphrase-source.js';
|
|
5
|
+
import type { CertStore } from './storage.js';
|
|
6
|
+
export interface ApiDeps {
|
|
7
|
+
readonly service: SslService;
|
|
8
|
+
readonly store: CertStore;
|
|
9
|
+
readonly passphrase: PassphraseSource;
|
|
10
|
+
readonly config: SignalkSslConfig;
|
|
11
|
+
}
|
|
12
|
+
/** Mounted by the server at /plugins/signalk-ssl/* — admin auth applied. */
|
|
13
|
+
export declare const registerAdminRoutes: (router: IRouter, deps: ApiDeps) => void;
|
|
14
|
+
/** Mounted by the server at /signalk/v1/api/* via signalKApiRoutes — read-only public. */
|
|
15
|
+
export declare const buildPublicRoutes: (router: IRouter, deps: ApiDeps) => IRouter;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { buildMobileconfig } from './mobileconfig.js';
|
|
2
|
+
import { discoverLocalIps } from './service.js';
|
|
3
|
+
const sendJson = (res, status, body) => {
|
|
4
|
+
res.status(status).type('application/json').send(JSON.stringify(body));
|
|
5
|
+
};
|
|
6
|
+
const asyncRoute = (fn) => (req, res, next) => {
|
|
7
|
+
fn(req, res).catch((err) => {
|
|
8
|
+
next(err);
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
/** Mounted by the server at /plugins/signalk-ssl/* — admin auth applied. */
|
|
12
|
+
export const registerAdminRoutes = (router, deps) => {
|
|
13
|
+
router.get('/status', asyncRoute(async (_req, res) => {
|
|
14
|
+
const s = await deps.service.status();
|
|
15
|
+
sendJson(res, 200, s);
|
|
16
|
+
}));
|
|
17
|
+
router.get('/api/local-ips', (_req, res) => {
|
|
18
|
+
sendJson(res, 200, { ipAddresses: discoverLocalIps() });
|
|
19
|
+
});
|
|
20
|
+
router.post('/renew', asyncRoute(async (_req, res) => {
|
|
21
|
+
const out = await deps.service.issueIfNeeded();
|
|
22
|
+
sendJson(res, 200, out);
|
|
23
|
+
}));
|
|
24
|
+
router.post('/unlock', asyncRoute(async (req, res) => {
|
|
25
|
+
const body = (req.body ?? {});
|
|
26
|
+
if (typeof body.passphrase !== 'string' || body.passphrase.length === 0) {
|
|
27
|
+
sendJson(res, 400, { error: 'passphrase required' });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
deps.passphrase.unlockWith(body.passphrase);
|
|
31
|
+
const out = await deps.service.issueIfNeeded();
|
|
32
|
+
sendJson(res, 200, out);
|
|
33
|
+
}));
|
|
34
|
+
router.post('/lock', (_req, res) => {
|
|
35
|
+
deps.passphrase.lock();
|
|
36
|
+
deps.service.acknowledgeRestart();
|
|
37
|
+
sendJson(res, 200, { kind: 'locked' });
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
/** Mounted by the server at /signalk/v1/api/* via signalKApiRoutes — read-only public. */
|
|
41
|
+
export const buildPublicRoutes = (router, deps) => {
|
|
42
|
+
router.get('/ssl/ca.crt', asyncRoute(async (_req, res) => {
|
|
43
|
+
const ca = await deps.store.readCaState();
|
|
44
|
+
if (ca === null) {
|
|
45
|
+
sendJson(res, 404, { error: 'no CA configured' });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
res.type('application/x-x509-ca-cert').send(ca.certificatePem);
|
|
49
|
+
}));
|
|
50
|
+
router.get('/ssl/ca.mobileconfig', asyncRoute(async (_req, res) => {
|
|
51
|
+
const ca = await deps.store.readCaState();
|
|
52
|
+
if (ca === null) {
|
|
53
|
+
sendJson(res, 404, { error: 'no CA configured' });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const xml = buildMobileconfig(ca.certificatePem, {
|
|
57
|
+
caName: deps.config.commonName,
|
|
58
|
+
organization: deps.config.organization
|
|
59
|
+
});
|
|
60
|
+
res.type('application/x-apple-aspen-config').send(xml);
|
|
61
|
+
}));
|
|
62
|
+
return router;
|
|
63
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface InstallTargets {
|
|
2
|
+
readonly certPath: string;
|
|
3
|
+
readonly keyPath: string;
|
|
4
|
+
readonly chainPath: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const defaultTargets: (configPath: string) => InstallTargets;
|
|
7
|
+
/**
|
|
8
|
+
* Atomically install the leaf certificate, key, and chain into the paths
|
|
9
|
+
* signalk-server reads at boot (`${configPath}/ssl-cert.pem`,
|
|
10
|
+
* `ssl-key.pem`, `ssl-chain.pem`). signalk-server enforces strict perms on
|
|
11
|
+
* the key file (refuses to start if it's group/world-readable).
|
|
12
|
+
*/
|
|
13
|
+
export declare const installCerts: (targets: InstallTargets, leafPem: string, leafKeyPem: string, caPem: string) => Promise<void>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { chmod, mkdir, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
const PEM_KEY_MODE = 0o600;
|
|
4
|
+
const PEM_CERT_MODE = 0o644;
|
|
5
|
+
export const defaultTargets = (configPath) => ({
|
|
6
|
+
certPath: join(configPath, 'ssl-cert.pem'),
|
|
7
|
+
keyPath: join(configPath, 'ssl-key.pem'),
|
|
8
|
+
chainPath: join(configPath, 'ssl-chain.pem')
|
|
9
|
+
});
|
|
10
|
+
const atomicWrite = async (path, data, mode, pid = process.pid) => {
|
|
11
|
+
const tmp = `${path}.${pid.toString()}.${Date.now().toString()}.tmp`;
|
|
12
|
+
await writeFile(tmp, data, { mode });
|
|
13
|
+
await rename(tmp, path);
|
|
14
|
+
// rename preserves mode on Linux; chmod is belt-and-braces for filesystems
|
|
15
|
+
// where the inherited umask might widen it (some mounted volumes do).
|
|
16
|
+
await chmod(path, mode);
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Atomically install the leaf certificate, key, and chain into the paths
|
|
20
|
+
* signalk-server reads at boot (`${configPath}/ssl-cert.pem`,
|
|
21
|
+
* `ssl-key.pem`, `ssl-chain.pem`). signalk-server enforces strict perms on
|
|
22
|
+
* the key file (refuses to start if it's group/world-readable).
|
|
23
|
+
*/
|
|
24
|
+
export const installCerts = async (targets, leafPem, leafKeyPem, caPem) => {
|
|
25
|
+
// mkdir every distinct parent directory. defaultTargets() puts all three
|
|
26
|
+
// files under the same configPath, but the InstallTargets contract doesn't
|
|
27
|
+
// require that, and a caller picking custom paths would otherwise ENOENT.
|
|
28
|
+
const parents = new Set([
|
|
29
|
+
dirname(targets.certPath),
|
|
30
|
+
dirname(targets.keyPath),
|
|
31
|
+
dirname(targets.chainPath)
|
|
32
|
+
]);
|
|
33
|
+
await Promise.all([...parents].map((d) => mkdir(d, { recursive: true })));
|
|
34
|
+
await atomicWrite(targets.keyPath, leafKeyPem, PEM_KEY_MODE);
|
|
35
|
+
await atomicWrite(targets.certPath, leafPem, PEM_CERT_MODE);
|
|
36
|
+
// Chain file = leaf + CA, separated by newline. Caddy/Go's tls.X509KeyPair
|
|
37
|
+
// expects the leaf first; Node's https.createServer doesn't strictly need
|
|
38
|
+
// a chain file but signalk-server's interface exposes one, so we write it.
|
|
39
|
+
const chain = `${leafPem.trimEnd()}\n${caPem.trimEnd()}\n`;
|
|
40
|
+
await atomicWrite(targets.chainPath, chain, PEM_CERT_MODE);
|
|
41
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FingerprintAlgorithm, GenerateCaInput, GeneratedCa, SignLeafInput, SignedLeaf } from './types.js';
|
|
2
|
+
export declare const generateKeyPair: () => Promise<CryptoKeyPair>;
|
|
3
|
+
export declare const generateCa: (input: GenerateCaInput) => Promise<GeneratedCa>;
|
|
4
|
+
export declare const signLeaf: (input: SignLeafInput) => Promise<SignedLeaf>;
|
|
5
|
+
/**
|
|
6
|
+
* Encrypt a CryptoKey as PBES2 / PBKDF2-SHA256 / AES-256-CBC PKCS#8 PEM.
|
|
7
|
+
* (Node's openssl bindings don't expose AES-GCM as a PBES2 cipher; AES-256-CBC
|
|
8
|
+
* is the strongest cipher Node will emit and is what `openssl pkcs8 -topk8`
|
|
9
|
+
* produces by default for `-v2 aes-256-cbc`.)
|
|
10
|
+
*/
|
|
11
|
+
export declare const encryptPrivateKeyPkcs8: (key: CryptoKey, passphrase: string) => Promise<string>;
|
|
12
|
+
export declare const decryptPrivateKeyPkcs8: (pem: string, passphrase: string) => Promise<CryptoKey>;
|
|
13
|
+
export declare const derivePassphraseKey: (passphrase: string, salt: Uint8Array, iterations?: number) => Promise<CryptoKey>;
|
|
14
|
+
export declare const computeSpkiFingerprint: (certificatePem: string, algorithm: FingerprintAlgorithm) => Promise<string>;
|
|
15
|
+
export declare const verifyChain: (leafPem: string, caPem: string, atDate?: Date) => Promise<boolean>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createPrivateKey, randomBytes, webcrypto } from 'node:crypto';
|
|
2
|
+
import { AuthorityKeyIdentifierExtension, BasicConstraintsExtension, cryptoProvider, ExtendedKeyUsage, ExtendedKeyUsageExtension, KeyUsageFlags, KeyUsagesExtension, SubjectAlternativeNameExtension, SubjectKeyIdentifierExtension, X509Certificate, X509CertificateGenerator } from '@peculiar/x509';
|
|
3
|
+
// Build the DN as a structured JsonName so @peculiar/x509 handles RFC 4514
|
|
4
|
+
// escaping internally. Hand-rolled `CN=${name}, O=${org}` interpolation
|
|
5
|
+
// would break on common names containing comma, equals, plus, backslash, etc.
|
|
6
|
+
const dn = (commonName, organization) => [
|
|
7
|
+
{ CN: [commonName] },
|
|
8
|
+
{ O: [organization] }
|
|
9
|
+
];
|
|
10
|
+
// Node's `webcrypto.Crypto` and the WebWorker lib's global `Crypto` diverge on
|
|
11
|
+
// the Ed25519 overload; the runtime objects are identical but the structural
|
|
12
|
+
// types aren't compatible. @peculiar/x509 v1 only declares the global type.
|
|
13
|
+
cryptoProvider.set(webcrypto);
|
|
14
|
+
const EC_ALGORITHM = {
|
|
15
|
+
name: 'ECDSA',
|
|
16
|
+
namedCurve: 'P-256',
|
|
17
|
+
hash: 'SHA-256'
|
|
18
|
+
};
|
|
19
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
20
|
+
const MS_PER_HOUR = 60 * 60 * 1000;
|
|
21
|
+
const PBKDF2_DEFAULT_ITERATIONS = 600_000;
|
|
22
|
+
const generateSerialNumber = () => {
|
|
23
|
+
const bytes = randomBytes(16);
|
|
24
|
+
// Clear leading bit so DER encoding stays positive (an unsigned integer in
|
|
25
|
+
// X.509 must not start with 0x80+; setting bit 7 of byte 0 to 0 enforces it).
|
|
26
|
+
const firstByte = bytes[0] ?? 0;
|
|
27
|
+
bytes[0] = firstByte & 0x7f;
|
|
28
|
+
return bytes.toString('hex');
|
|
29
|
+
};
|
|
30
|
+
export const generateKeyPair = async () => {
|
|
31
|
+
return webcrypto.subtle.generateKey(EC_ALGORITHM, true, ['sign', 'verify']);
|
|
32
|
+
};
|
|
33
|
+
export const generateCa = async (input) => {
|
|
34
|
+
const keys = await generateKeyPair();
|
|
35
|
+
const notBefore = new Date();
|
|
36
|
+
const notAfter = new Date(notBefore.getTime() + input.validityDays * MS_PER_DAY);
|
|
37
|
+
const distinguishedName = dn(input.commonName, input.organization);
|
|
38
|
+
const cert = await X509CertificateGenerator.create({
|
|
39
|
+
serialNumber: generateSerialNumber(),
|
|
40
|
+
subject: distinguishedName,
|
|
41
|
+
issuer: distinguishedName,
|
|
42
|
+
notBefore,
|
|
43
|
+
notAfter,
|
|
44
|
+
publicKey: keys.publicKey,
|
|
45
|
+
signingKey: keys.privateKey,
|
|
46
|
+
signingAlgorithm: EC_ALGORITHM,
|
|
47
|
+
extensions: [
|
|
48
|
+
new BasicConstraintsExtension(true, undefined, true),
|
|
49
|
+
new KeyUsagesExtension(KeyUsageFlags.keyCertSign | KeyUsageFlags.cRLSign, true),
|
|
50
|
+
await SubjectKeyIdentifierExtension.create(keys.publicKey)
|
|
51
|
+
]
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
certificatePem: cert.toString('pem'),
|
|
55
|
+
privateKey: keys.privateKey
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
const sansToJsonGeneralNames = (sans) => {
|
|
59
|
+
return [
|
|
60
|
+
...sans.dnsNames.map((value) => ({ type: 'dns', value })),
|
|
61
|
+
...sans.ipAddresses.map((value) => ({ type: 'ip', value }))
|
|
62
|
+
];
|
|
63
|
+
};
|
|
64
|
+
export const signLeaf = async (input) => {
|
|
65
|
+
const issuerCert = new X509Certificate(input.issuer.certificatePem);
|
|
66
|
+
const keys = await generateKeyPair();
|
|
67
|
+
// Clock-skew defence: a phone whose clock lags the server's would reject a
|
|
68
|
+
// cert whose notBefore is "right now". Backdating by clockSkewHours lets the
|
|
69
|
+
// common offline-boat scenario work without manual NTP intervention.
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const notBefore = new Date(now - input.clockSkewHours * MS_PER_HOUR);
|
|
72
|
+
const notAfter = new Date(now + input.validityDays * MS_PER_DAY);
|
|
73
|
+
const subject = dn(input.subjectCommonName, input.organization);
|
|
74
|
+
const cert = await X509CertificateGenerator.create({
|
|
75
|
+
serialNumber: generateSerialNumber(),
|
|
76
|
+
subject,
|
|
77
|
+
issuer: issuerCert.subject,
|
|
78
|
+
notBefore,
|
|
79
|
+
notAfter,
|
|
80
|
+
publicKey: keys.publicKey,
|
|
81
|
+
signingKey: input.issuer.privateKey,
|
|
82
|
+
signingAlgorithm: EC_ALGORITHM,
|
|
83
|
+
extensions: [
|
|
84
|
+
new BasicConstraintsExtension(false, undefined, true),
|
|
85
|
+
new KeyUsagesExtension(KeyUsageFlags.digitalSignature | KeyUsageFlags.keyEncipherment, true),
|
|
86
|
+
new ExtendedKeyUsageExtension([ExtendedKeyUsage.serverAuth], false),
|
|
87
|
+
new SubjectAlternativeNameExtension(sansToJsonGeneralNames(input.sans), false),
|
|
88
|
+
await SubjectKeyIdentifierExtension.create(keys.publicKey),
|
|
89
|
+
await AuthorityKeyIdentifierExtension.create(issuerCert.publicKey)
|
|
90
|
+
]
|
|
91
|
+
});
|
|
92
|
+
const privateKeyPem = await exportPrivateKeyPlainPkcs8(keys.privateKey);
|
|
93
|
+
return {
|
|
94
|
+
certificatePem: cert.toString('pem'),
|
|
95
|
+
privateKey: keys.privateKey,
|
|
96
|
+
privateKeyPem
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
const cryptoKeyToKeyObject = async (key) => {
|
|
100
|
+
const exported = await webcrypto.subtle.exportKey('pkcs8', key);
|
|
101
|
+
return createPrivateKey({
|
|
102
|
+
key: Buffer.from(exported),
|
|
103
|
+
format: 'der',
|
|
104
|
+
type: 'pkcs8'
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
const keyObjectToCryptoKey = async (keyObject, usages) => {
|
|
108
|
+
const pkcs8 = keyObject.export({ type: 'pkcs8', format: 'der' });
|
|
109
|
+
return webcrypto.subtle.importKey('pkcs8', pkcs8, EC_ALGORITHM, true, usages);
|
|
110
|
+
};
|
|
111
|
+
const exportPrivateKeyPlainPkcs8 = async (key) => {
|
|
112
|
+
const keyObject = await cryptoKeyToKeyObject(key);
|
|
113
|
+
return keyObject.export({ type: 'pkcs8', format: 'pem' });
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Encrypt a CryptoKey as PBES2 / PBKDF2-SHA256 / AES-256-CBC PKCS#8 PEM.
|
|
117
|
+
* (Node's openssl bindings don't expose AES-GCM as a PBES2 cipher; AES-256-CBC
|
|
118
|
+
* is the strongest cipher Node will emit and is what `openssl pkcs8 -topk8`
|
|
119
|
+
* produces by default for `-v2 aes-256-cbc`.)
|
|
120
|
+
*/
|
|
121
|
+
export const encryptPrivateKeyPkcs8 = async (key, passphrase) => {
|
|
122
|
+
const keyObject = await cryptoKeyToKeyObject(key);
|
|
123
|
+
return keyObject.export({
|
|
124
|
+
type: 'pkcs8',
|
|
125
|
+
format: 'pem',
|
|
126
|
+
cipher: 'aes-256-cbc',
|
|
127
|
+
passphrase
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
export const decryptPrivateKeyPkcs8 = async (pem, passphrase) => {
|
|
131
|
+
const keyObject = createPrivateKey({
|
|
132
|
+
key: pem,
|
|
133
|
+
format: 'pem',
|
|
134
|
+
passphrase
|
|
135
|
+
});
|
|
136
|
+
return keyObjectToCryptoKey(keyObject, ['sign']);
|
|
137
|
+
};
|
|
138
|
+
export const derivePassphraseKey = async (passphrase, salt, iterations = PBKDF2_DEFAULT_ITERATIONS) => {
|
|
139
|
+
const keyMaterial = await webcrypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']);
|
|
140
|
+
return webcrypto.subtle.deriveKey({
|
|
141
|
+
name: 'PBKDF2',
|
|
142
|
+
hash: 'SHA-256',
|
|
143
|
+
salt,
|
|
144
|
+
iterations
|
|
145
|
+
}, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
146
|
+
};
|
|
147
|
+
const DIGEST_ALG = {
|
|
148
|
+
sha256: 'SHA-256'
|
|
149
|
+
};
|
|
150
|
+
export const computeSpkiFingerprint = async (certificatePem, algorithm) => {
|
|
151
|
+
const cert = new X509Certificate(certificatePem);
|
|
152
|
+
const spki = await webcrypto.subtle.exportKey('spki', await cert.publicKey.export());
|
|
153
|
+
const digest = await webcrypto.subtle.digest(DIGEST_ALG[algorithm], spki);
|
|
154
|
+
const bytes = new Uint8Array(digest);
|
|
155
|
+
return Array.from(bytes)
|
|
156
|
+
.map((b) => b.toString(16).padStart(2, '0').toUpperCase())
|
|
157
|
+
.join(':');
|
|
158
|
+
};
|
|
159
|
+
export const verifyChain = async (leafPem, caPem, atDate = new Date()) => {
|
|
160
|
+
const leaf = new X509Certificate(leafPem);
|
|
161
|
+
const ca = new X509Certificate(caPem);
|
|
162
|
+
return leaf.verify({ publicKey: ca.publicKey, date: atDate });
|
|
163
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { CertStore } from './storage.js';
|
|
2
|
+
import { PassphraseSource } from './passphrase-source.js';
|
|
3
|
+
import { SslService } from './service.js';
|
|
4
|
+
import { startRenewalScheduler } from './scheduler.js';
|
|
5
|
+
import { buildPublicRoutes, registerAdminRoutes } from './api.js';
|
|
6
|
+
import { ConfigSchema, DEFAULT_CONFIG } from './schema.js';
|
|
7
|
+
const PLUGIN_ID = 'signalk-ssl';
|
|
8
|
+
const PLUGIN_NAME = 'SignalK SSL';
|
|
9
|
+
const PLUGIN_DESCRIPTION = 'Generate a local CA and issue trusted HTTPS certificates for your SignalK server.';
|
|
10
|
+
const resolveConfig = (raw) => {
|
|
11
|
+
return { ...DEFAULT_CONFIG, ...raw };
|
|
12
|
+
};
|
|
13
|
+
const resolveConfigPath = (app, fallback) => {
|
|
14
|
+
return app.config?.configPath ?? fallback;
|
|
15
|
+
};
|
|
16
|
+
const pluginConstructor = (app) => {
|
|
17
|
+
const extended = app;
|
|
18
|
+
let scheduler = null;
|
|
19
|
+
let service = null;
|
|
20
|
+
let store = null;
|
|
21
|
+
let passphrase = null;
|
|
22
|
+
let config = DEFAULT_CONFIG;
|
|
23
|
+
const plugin = {
|
|
24
|
+
id: PLUGIN_ID,
|
|
25
|
+
name: PLUGIN_NAME,
|
|
26
|
+
description: PLUGIN_DESCRIPTION,
|
|
27
|
+
schema: () => ConfigSchema,
|
|
28
|
+
start(rawConfig, _restart) {
|
|
29
|
+
config = resolveConfig(rawConfig);
|
|
30
|
+
const dataDir = app.getDataDirPath();
|
|
31
|
+
const configPath = resolveConfigPath(extended, dataDir);
|
|
32
|
+
store = new CertStore(dataDir);
|
|
33
|
+
passphrase = new PassphraseSource(config.passphraseMode, store);
|
|
34
|
+
service = new SslService({ store, passphrase, config, configPath });
|
|
35
|
+
const svcLocal = service;
|
|
36
|
+
const logSchedulerError = (err) => {
|
|
37
|
+
app.error(`${PLUGIN_ID} scheduled renewal failed: ${String(err)}`);
|
|
38
|
+
};
|
|
39
|
+
void store.init().then(async () => {
|
|
40
|
+
try {
|
|
41
|
+
const outcome = await svcLocal.issueIfNeeded();
|
|
42
|
+
app.debug(`${PLUGIN_ID} initial issueIfNeeded: ${JSON.stringify(outcome)}`);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
app.error(`${PLUGIN_ID} initial issue failed: ${String(e)}`);
|
|
46
|
+
}
|
|
47
|
+
// Start the scheduler only after init + initial issue have completed,
|
|
48
|
+
// so a short test interval can't race against an uninitialised store.
|
|
49
|
+
scheduler = startRenewalScheduler(svcLocal, logSchedulerError);
|
|
50
|
+
});
|
|
51
|
+
app.debug(`${PLUGIN_ID} started; data dir=${dataDir}; configPath=${configPath}`);
|
|
52
|
+
},
|
|
53
|
+
stop() {
|
|
54
|
+
scheduler?.stop();
|
|
55
|
+
scheduler = null;
|
|
56
|
+
passphrase?.lock();
|
|
57
|
+
app.debug(`${PLUGIN_ID} stopped`);
|
|
58
|
+
},
|
|
59
|
+
registerWithRouter(router) {
|
|
60
|
+
if (service === null || store === null || passphrase === null) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
registerAdminRoutes(router, { service, store, passphrase, config });
|
|
64
|
+
},
|
|
65
|
+
signalKApiRoutes(router) {
|
|
66
|
+
if (service === null || store === null || passphrase === null) {
|
|
67
|
+
return router;
|
|
68
|
+
}
|
|
69
|
+
return buildPublicRoutes(router, { service, store, passphrase, config });
|
|
70
|
+
},
|
|
71
|
+
statusMessage() {
|
|
72
|
+
if (service === null) {
|
|
73
|
+
return 'starting';
|
|
74
|
+
}
|
|
75
|
+
// statusMessage must be synchronous; just describe the cached config.
|
|
76
|
+
return `mode=${config.mode}, sans=${(config.sans.dnsNames.length + config.sans.ipAddresses.length).toString()}`;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
return plugin;
|
|
80
|
+
};
|
|
81
|
+
export default pluginConstructor;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface MobileconfigOptions {
|
|
2
|
+
readonly caName: string;
|
|
3
|
+
readonly organization: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Build an unsigned .mobileconfig profile that installs `caCertPem` as a
|
|
7
|
+
* trusted root on iOS/iPadOS. Localised consent text mirrors what Keeper
|
|
8
|
+
* ships in https-service.ts:227-297 (en/de/fr/es/nl).
|
|
9
|
+
*/
|
|
10
|
+
export declare const buildMobileconfig: (caCertPem: string, options: MobileconfigOptions) => string;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
const escapeXml = (s) => s.replace(/[<>&'"]/g, (ch) => {
|
|
3
|
+
switch (ch) {
|
|
4
|
+
case '<':
|
|
5
|
+
return '<';
|
|
6
|
+
case '>':
|
|
7
|
+
return '>';
|
|
8
|
+
case '&':
|
|
9
|
+
return '&';
|
|
10
|
+
case "'":
|
|
11
|
+
return ''';
|
|
12
|
+
case '"':
|
|
13
|
+
return '"';
|
|
14
|
+
default:
|
|
15
|
+
return ch;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
const pemToDerBase64 = (pem) => pem
|
|
19
|
+
.replace(/-----BEGIN CERTIFICATE-----/g, '')
|
|
20
|
+
.replace(/-----END CERTIFICATE-----/g, '')
|
|
21
|
+
.replace(/\s/g, '');
|
|
22
|
+
/**
|
|
23
|
+
* Build an unsigned .mobileconfig profile that installs `caCertPem` as a
|
|
24
|
+
* trusted root on iOS/iPadOS. Localised consent text mirrors what Keeper
|
|
25
|
+
* ships in https-service.ts:227-297 (en/de/fr/es/nl).
|
|
26
|
+
*/
|
|
27
|
+
export const buildMobileconfig = (caCertPem, options) => {
|
|
28
|
+
const der = pemToDerBase64(caCertPem);
|
|
29
|
+
const profileUuid = randomUUID().toUpperCase();
|
|
30
|
+
const certUuid = randomUUID().toUpperCase();
|
|
31
|
+
const name = escapeXml(options.caName);
|
|
32
|
+
const org = escapeXml(options.organization);
|
|
33
|
+
// Use the already-XML-escaped `name` here so the template literals are
|
|
34
|
+
// injection-safe at construction time. Below we no longer pass the strings
|
|
35
|
+
// through escapeXml again, since they're already safe.
|
|
36
|
+
const consent = {
|
|
37
|
+
en: `After installing this profile, enable full trust: Settings → General → About → Certificate Trust Settings → enable "${name}".`,
|
|
38
|
+
de: `Nach der Installation dieses Profils aktivieren Sie volles Vertrauen: Einstellungen → Allgemein → Info → Zertifikatsvertrauenseinstellungen → "${name}" aktivieren.`,
|
|
39
|
+
fr: `Après l'installation de ce profil, activez la confiance totale : Réglages → Général → Informations → Réglages des certificats → activer « ${name} ».`,
|
|
40
|
+
es: `Después de instalar este perfil, habilite la confianza total: Ajustes → General → Información → Ajustes de certificados → activar "${name}".`,
|
|
41
|
+
nl: `Schakel na installatie van dit profiel volledig vertrouwen in: Instellingen → Algemeen → Info → Instellingen vertrouwde certificaten → "${name}" inschakelen.`
|
|
42
|
+
};
|
|
43
|
+
const consentXml = Object.entries(consent)
|
|
44
|
+
.map(([lang, text]) => `\t\t<key>${lang}</key>\n\t\t<string>${text}</string>`)
|
|
45
|
+
.join('\n');
|
|
46
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
47
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
48
|
+
<plist version="1.0">
|
|
49
|
+
<dict>
|
|
50
|
+
\t<key>PayloadContent</key>
|
|
51
|
+
\t<array>
|
|
52
|
+
\t\t<dict>
|
|
53
|
+
\t\t\t<key>PayloadCertificateFileName</key>
|
|
54
|
+
\t\t\t<string>signalk-ssl-ca.crt</string>
|
|
55
|
+
\t\t\t<key>PayloadContent</key>
|
|
56
|
+
\t\t\t<data>${der}</data>
|
|
57
|
+
\t\t\t<key>PayloadDescription</key>
|
|
58
|
+
\t\t\t<string>Adds the ${name} root certificate</string>
|
|
59
|
+
\t\t\t<key>PayloadDisplayName</key>
|
|
60
|
+
\t\t\t<string>${name}</string>
|
|
61
|
+
\t\t\t<key>PayloadIdentifier</key>
|
|
62
|
+
\t\t\t<string>com.signalk.ssl.ca-cert</string>
|
|
63
|
+
\t\t\t<key>PayloadType</key>
|
|
64
|
+
\t\t\t<string>com.apple.security.root</string>
|
|
65
|
+
\t\t\t<key>PayloadUUID</key>
|
|
66
|
+
\t\t\t<string>${certUuid}</string>
|
|
67
|
+
\t\t\t<key>PayloadVersion</key>
|
|
68
|
+
\t\t\t<integer>1</integer>
|
|
69
|
+
\t\t</dict>
|
|
70
|
+
\t</array>
|
|
71
|
+
\t<key>PayloadDescription</key>
|
|
72
|
+
\t<string>Installs the ${name} certificate so your device trusts HTTPS connections to your SignalK server.</string>
|
|
73
|
+
\t<key>PayloadDisplayName</key>
|
|
74
|
+
\t<string>SignalK Secure Connection</string>
|
|
75
|
+
\t<key>PayloadIdentifier</key>
|
|
76
|
+
\t<string>com.signalk.ssl.https-profile</string>
|
|
77
|
+
\t<key>PayloadOrganization</key>
|
|
78
|
+
\t<string>${org}</string>
|
|
79
|
+
\t<key>PayloadRemovalDisallowed</key>
|
|
80
|
+
\t<false/>
|
|
81
|
+
\t<key>PayloadType</key>
|
|
82
|
+
\t<string>Configuration</string>
|
|
83
|
+
\t<key>PayloadUUID</key>
|
|
84
|
+
\t<string>${profileUuid}</string>
|
|
85
|
+
\t<key>PayloadVersion</key>
|
|
86
|
+
\t<integer>1</integer>
|
|
87
|
+
\t<key>ConsentText</key>
|
|
88
|
+
\t<dict>
|
|
89
|
+
\t\t<key>default</key>
|
|
90
|
+
\t\t<string>${consent.en}</string>
|
|
91
|
+
${consentXml}
|
|
92
|
+
\t</dict>
|
|
93
|
+
</dict>
|
|
94
|
+
</plist>`;
|
|
95
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ParsedSans } from './types.js';
|
|
2
|
+
export type RenewalReason = 'expired' | 'expiring-soon' | 'san-mismatch' | 'ok';
|
|
3
|
+
export interface RenewalDecision {
|
|
4
|
+
readonly needsRenewal: boolean;
|
|
5
|
+
readonly reason: RenewalReason;
|
|
6
|
+
readonly daysUntilExpiry: number;
|
|
7
|
+
readonly missingDnsNames: readonly string[];
|
|
8
|
+
readonly missingIpAddresses: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
export declare const needsRenewal: (certificatePem: string, requiredSans: ParsedSans, thresholdDays: number, now?: Date) => RenewalDecision;
|