Aere Quantum: a post-quantum TLS 1.3 gateway (X25519MLKEM768, optional ML-DSA client authentication), a hybrid KMS (X25519 + ML-KEM-768, Ed25519 + ML-DSA-65; root key from the environment or sealed by an HSM through PKCS#11) and an ML-DSA private CA (X.509 v3, RFC 9881). Zero dependencies, Node.js 24 with OpenSSL 3.5. Each with its test suite and a negative control.
This commit is contained in:
commit
7d814d1bb8
15
LICENSE
Normal file
15
LICENSE
Normal file
@ -0,0 +1,15 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Aere Network
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
||||
30
README.md
Normal file
30
README.md
Normal file
@ -0,0 +1,30 @@
|
||||
# Aere Quantum
|
||||
|
||||
Self-hosted post-quantum infrastructure from Aere Network. Three components, each a few files with **no dependencies**:
|
||||
Node.js 24 and the OpenSSL 3.5 it ships with (`node:crypto`), nothing from a package registry.
|
||||
|
||||
| component | what it does |
|
||||
|---|---|
|
||||
| [`pq-gateway/`](pq-gateway/) | a TLS 1.3 terminating proxy in front of any HTTP service, with the hybrid key exchange X25519MLKEM768: `hybrid-only` refuses a classical client at the handshake, `hybrid-preferred` keeps it working; optional client authentication with ML-DSA certificates |
|
||||
| [`pq-kms/`](pq-kms/) | a transit-style key management service where every key is hybrid: X25519 + ML-KEM-768 for encryption, Ed25519 + ML-DSA-65 for signatures (both halves required); versions, rotation, rewrap, data keys, a chained audit log; the root key from the environment or sealed by an HSM through PKCS#11 |
|
||||
| [`pq-pki/`](pq-pki/) | a private certificate authority for ML-DSA (X.509 v3, RFC 9881): root and issuing CAs, leaf certificates, revocation lists, and a strict chain verifier compared against OpenSSL |
|
||||
|
||||
Each component's README says what it is **not** and what is **not measured**. No third party has reviewed any of them.
|
||||
|
||||
## How each is checked
|
||||
|
||||
Every component ships its test suite and a negative control: the control plants a real defect in a copy of the code, one at a time,
|
||||
and requires the named test to fail for the named reason; a planting that cannot be applied, or that breaks the build instead of the
|
||||
test, counts as a failure of the control. Results measured on 2026-09-29 (Node.js 24.14.1, OpenSSL 3.5.5):
|
||||
|
||||
| component | tests | negative control |
|
||||
|---|---|---|
|
||||
| pq-gateway | 33/33 (`node proba-pq-gateway.mjs`) | 33/33 (`bash proba-pq-gateway-control-negativ.sh`) |
|
||||
| pq-kms | 62/62 (`node test/proba.mjs`); HSM root on SoftHSM2 + OpenSC 20/20 (`test/proba-hsm.mjs`, Linux); sealed-file trust rules 7/7 (`test/proba-hsm-incredere.mjs`) | 16/16 (`node test/control-negativ.mjs`); sealed-file rules 2/2 in this repository (`test/control-negativ-hsm-incredere.mjs`) |
|
||||
| pq-pki | 27/27 (`node test/proba.mjs`), each verdict compared with OpenSSL 3.5 | 22/22 (`node test/control-negativ.mjs`) |
|
||||
|
||||
Test names and control messages are in Romanian; identifiers, error codes, messages and documentation are in English.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT, see [LICENSE](LICENSE). Files: 24 (pq-gateway 6, pq-kms 10, pq-pki 6).
|
||||
24
pq-gateway/Dockerfile
Normal file
24
pq-gateway/Dockerfile
Normal file
@ -0,0 +1,24 @@
|
||||
# Aere Cloud, Quantum Security Gateway. Imagine minima: Node 24 si un singur fisier, fara nicio dependinta.
|
||||
# NECONSTRUITA si netrasa pe laptopul de dezvoltare (2026-09-25): versiunea OpenSSL din imagine e NEMASURATA aici.
|
||||
# Gateway-ul o verifica singur la pornire (refuza sub OpenSSL 3.5) si o scrie in randul 'listening' din jurnal.
|
||||
#
|
||||
# docker build -t aere-pq-gateway .
|
||||
# docker run --rm -p 443:8443 \
|
||||
# -e AERE_PQGW_MODE=hybrid-preferred -e AERE_PQGW_UPSTREAM=http://app:8080 \
|
||||
# -v /cale/catre/certificate:/etc/aere-pq-gateway:ro aere-pq-gateway
|
||||
#
|
||||
# Ruleaza ca utilizatorul neprivilegiat 'node' (uid 1000) din imaginea oficiala: cheia montata trebuie sa fie
|
||||
# citibila de el, altfel gateway-ul refuza sa porneasca cu "cannot read KEY (EACCES)".
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY pq-gateway.mjs /app/pq-gateway.mjs
|
||||
|
||||
ENV AERE_PQGW_LISTEN=:8443 \
|
||||
AERE_PQGW_CERT=/etc/aere-pq-gateway/fullchain.pem \
|
||||
AERE_PQGW_KEY=/etc/aere-pq-gateway/privkey.pem
|
||||
|
||||
USER node
|
||||
EXPOSE 8443
|
||||
STOPSIGNAL SIGTERM
|
||||
CMD ["node", "/app/pq-gateway.mjs"]
|
||||
205
pq-gateway/README.md
Normal file
205
pq-gateway/README.md
Normal file
@ -0,0 +1,205 @@
|
||||
# Aere Quantum Security Gateway
|
||||
|
||||
A TLS 1.3 terminating reverse proxy that you place in front of any HTTP service you already run. It offers the
|
||||
hybrid post-quantum key exchange **X25519MLKEM768** (ML-KEM-768 combined with X25519) to your clients and forwards
|
||||
their requests to your service over HTTP/1.1.
|
||||
|
||||
It is one file, `pq-gateway.mjs`, with no dependencies: it uses only the modules built into Node.js. It needs
|
||||
Node.js 24 linked to OpenSSL 3.5 or later, and it checks this at startup and refuses to start otherwise.
|
||||
|
||||
## What it protects, and what it does not
|
||||
|
||||
The hybrid key exchange protects the **confidentiality** of traffic between your clients and the gateway against an
|
||||
adversary who records it today and hopes to decrypt it later with a quantum computer ("harvest now, decrypt later").
|
||||
The session keys depend on both ML-KEM-768 and X25519, so an attacker has to break both.
|
||||
|
||||
It does **not**:
|
||||
|
||||
- **Make server authentication post-quantum by itself.** With an ECDSA or RSA certificate the server still authenticates
|
||||
with a classical signature: the protection is for the key exchange, not the signature. Serve an **ML-DSA chain** (for
|
||||
example issued by `../pq-pki`, the Aere post-quantum CA) and the handshake is post-quantum end to end: the key exchange
|
||||
is `X25519MLKEM768` and the server signs with `mldsa65` (measured with `openssl s_client`, test `(s)`). Set
|
||||
`AERE_PQGW_REQUIRE_PQ_AUTH=true` and the gateway refuses to start unless every served certificate has an ML-DSA key
|
||||
and an ML-DSA signature; the status endpoint reports `certificate.authentication` as `post-quantum`, `mixed` or
|
||||
`classical`. Clients must support ML-DSA signatures (OpenSSL 3.5 does; many browsers do not yet).
|
||||
- **Protect the hop from the gateway to your upstream** when `UPSTREAM` is `http://`. That hop is plain HTTP. Keep it
|
||||
on a trusted network (same host, same private network, loopback) or use an `https://` upstream. When the upstream is
|
||||
`https://`, the gateway also prefers X25519MLKEM768 on that hop, but it makes no claim about which group was used
|
||||
there.
|
||||
- **Speak HTTP/2 or HTTP/3.** The gateway advertises only `http/1.1` over ALPN. A client that offers `h2` and
|
||||
`http/1.1` gets `http/1.1`.
|
||||
- **Report the negotiated group per connection in `hybrid-preferred` mode.** The Node.js TLS server API does not expose
|
||||
which group a connection negotiated (`getEphemeralKeyInfo()` is client-side only). The gateway therefore does not
|
||||
count or claim "hybrid connections". See the modes below.
|
||||
|
||||
## Modes
|
||||
|
||||
Set with `AERE_PQGW_MODE`. There is no default: you choose explicitly.
|
||||
|
||||
| mode | groups offered | what it guarantees |
|
||||
|---|---|---|
|
||||
| `hybrid-only` | `X25519MLKEM768` | Every accepted connection used the hybrid key exchange. The guarantee is structural: it is the only group offered, so a client that does not support it fails the TLS handshake and is counted as refused (`no shared group`). |
|
||||
| `hybrid-preferred` | `X25519MLKEM768`, then `X25519` and `P-256` | Clients that support X25519MLKEM768 get it, including clients that sent only an X25519 key share first: the gateway asks for the hybrid group with a HelloRetryRequest. Clients that do not support it fall back to X25519 or P-256 and are served. No per-connection guarantee. |
|
||||
|
||||
The preference in `hybrid-preferred` is written with OpenSSL 3.5 group tuples (`X25519MLKEM768/X25519:P-256`). A flat
|
||||
list (`X25519MLKEM768:X25519:P-256`) is not enough: in our tests a client that listed X25519 first stayed on X25519
|
||||
with a flat list, and moved to X25519MLKEM768 only with the tuple form.
|
||||
|
||||
Both modes require TLS 1.3 (`minVersion` is always `TLSv1.3`). A TLS 1.2 client is refused and counted as
|
||||
`unsupported protocol`.
|
||||
|
||||
Choose `hybrid-only` when you control the clients (service to service, your own apps). In our tests, both the
|
||||
OpenSSL 3.5 command line client and a Node.js 24 client linked to OpenSSL 3.5 negotiated X25519MLKEM768 with their
|
||||
default settings. Choose `hybrid-preferred` for public traffic where older clients must keep working. Check your own
|
||||
client population before you switch a public endpoint to `hybrid-only`.
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
AERE_PQGW_MODE=hybrid-preferred \
|
||||
AERE_PQGW_LISTEN=:8443 \
|
||||
AERE_PQGW_CERT=/etc/aere-pq-gateway/fullchain.pem \
|
||||
AERE_PQGW_KEY=/etc/aere-pq-gateway/privkey.pem \
|
||||
AERE_PQGW_UPSTREAM=http://localhost:8080 \
|
||||
node pq-gateway.mjs
|
||||
```
|
||||
|
||||
It writes one JSON line per event to stdout (`listening`, `warning`, `upstream_error`, `shutdown`, `stopped`). The
|
||||
`listening` line includes the mode, the groups, the certificate SHA-256 fingerprint and the Node.js and OpenSSL
|
||||
versions it is running with.
|
||||
|
||||
### Docker
|
||||
|
||||
The `Dockerfile` builds a minimal image from `node:24-alpine` that runs as the unprivileged `node` user and listens on
|
||||
port 8443 inside the container.
|
||||
|
||||
```sh
|
||||
docker build -t aere-pq-gateway .
|
||||
docker run --rm -p 443:8443 \
|
||||
-e AERE_PQGW_MODE=hybrid-preferred \
|
||||
-e AERE_PQGW_UPSTREAM=http://app:8080 \
|
||||
-v /path/to/certs:/etc/aere-pq-gateway:ro \
|
||||
aere-pq-gateway
|
||||
```
|
||||
|
||||
The mounted private key must be readable by the container user (uid 1000), otherwise the gateway refuses to start
|
||||
with `cannot read KEY (EACCES)`. The OpenSSL version inside the image is printed on the `listening` line; the gateway
|
||||
refuses to start if it is older than 3.5. Not yet measured by us: a build of this image and the OpenSSL version it
|
||||
carries (the test suite runs the gateway directly on Node.js 24 with OpenSSL 3.5.5).
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables take precedence over the optional JSON file named by `AERE_PQGW_CONFIG`
|
||||
(see `pq-gateway.example.json`). An unknown key in the file is an error, so a typo cannot be silently ignored.
|
||||
|
||||
| variable | JSON key | default | meaning |
|
||||
|---|---|---|---|
|
||||
| `AERE_PQGW_MODE` | `mode` | required | `hybrid-only` or `hybrid-preferred` |
|
||||
| `AERE_PQGW_CERT` | `cert` | required | PEM certificate chain (leaf first) |
|
||||
| `AERE_PQGW_KEY` | `key` | required | PEM private key; must match the leaf certificate or the gateway refuses to start |
|
||||
| `AERE_PQGW_UPSTREAM` | `upstream` | required | `http://host:port[/base]` or `https://host:port[/base]`; no credentials, query or fragment |
|
||||
| `AERE_PQGW_UPSTREAM_CA` | `upstreamCa` | system CAs | PEM CA bundle for an `https://` upstream with a private CA |
|
||||
| `AERE_PQGW_LISTEN` | `listen` | `:8443` | `host:port`, `[ipv6]:port`, or `:port` for all interfaces |
|
||||
| `AERE_PQGW_CONNECT_TIMEOUT_MS` | `connectTimeoutMs` | `5000` | time to connect to the upstream (TCP, plus TLS for https) |
|
||||
| `AERE_PQGW_REQUEST_TIMEOUT_MS` | `requestTimeoutMs` | `60000` | time for the upstream to start its response after the request was fully sent |
|
||||
| `AERE_PQGW_HANDSHAKE_TIMEOUT_MS` | `handshakeTimeoutMs` | `10000` | time for a client to complete the TLS handshake |
|
||||
| `AERE_PQGW_SHUTDOWN_GRACE_MS` | `shutdownGraceMs` | `10000` | how long in-flight requests may finish after SIGTERM |
|
||||
| `AERE_PQGW_MAX_HEADER_SIZE` | `maxHeaderSize` | Node.js default (16 KiB) | maximum size of request headers; larger requests get `431` |
|
||||
| `AERE_PQGW_TRUST_FORWARDED` | `trustForwarded` | `false` | append to an incoming `X-Forwarded-For` (and keep `Forwarded`) instead of replacing it; enable only behind another proxy you trust |
|
||||
| `AERE_PQGW_PRESERVE_HOST` | `preserveHost` | `false` | send the client's `Host` to the upstream instead of the upstream's own host |
|
||||
| `AERE_PQGW_REQUIRE_PQ_AUTH` | `requirePqAuth` | `false` | refuse to start unless the served chain is ML-DSA end to end (keys and signatures) |
|
||||
|
||||
## What the upstream receives
|
||||
|
||||
- The method, path and query as sent by the client (prefixed with the base path of `UPSTREAM`, if any).
|
||||
- The request body as a stream: it is forwarded while it arrives, never buffered whole.
|
||||
- All end-to-end headers. Hop-by-hop headers are removed: `Connection` and every header it names, `Keep-Alive`,
|
||||
`Proxy-*`, `TE`, `Trailer`, `Transfer-Encoding` and `Upgrade` (except on the upgrade path, see below). `Expect` is
|
||||
handled by the gateway and not forwarded.
|
||||
- `X-Forwarded-For` and `X-Real-IP` (the client address, from the socket), `X-Forwarded-Proto: https`,
|
||||
`X-Forwarded-Host` (the client's `Host`), and `x-aere-pq-gateway: <mode>`. Values a client sends under these
|
||||
names are dropped, so they cannot be forged (unless you enable `AERE_PQGW_TRUST_FORWARDED` for
|
||||
`X-Forwarded-For`). Client-sent `X-Client-*` and `X-SSL-*` headers (the names TLS-terminating proxies use to
|
||||
hand over a client certificate identity) are dropped as well: this gateway does not authenticate clients, so
|
||||
an upstream must not see such headers as if it had.
|
||||
|
||||
Upgrade requests (WebSocket) are tunnelled in both directions after the upstream answers `101`. If the upstream
|
||||
declines the upgrade, its response is passed back and the connection is closed.
|
||||
|
||||
If the upstream cannot be reached, the client gets `502` with a short JSON body
|
||||
(`{"error":"bad_gateway",...}`). An `https://` upstream whose certificate cannot be verified is also `502`: there is
|
||||
no option to skip verification; use `AERE_PQGW_UPSTREAM_CA` for a private CA. If the upstream does not connect
|
||||
(including its TLS handshake) or does not start responding in time, the client gets `504`
|
||||
(`{"error":"gateway_timeout",...}`). A request never hangs waiting for an upstream that does not answer. Once the
|
||||
upstream has started its response, the gateway applies no idle timeout to the body, so long-lived streams such as
|
||||
server-sent events keep working. A client that disconnects before the response is not counted as an upstream error.
|
||||
|
||||
## Status endpoint
|
||||
|
||||
`GET /.well-known/aere-pq-gateway` is answered by the gateway itself and never forwarded. It is public, so it contains
|
||||
nothing secret: no private key and no file paths. It returns:
|
||||
|
||||
- `mode`, `groupsOffered`, `groupPreference`, `minTlsVersion`, `alpn`
|
||||
- `guarantee.hybridKeyExchangeOnEveryConnection`: `true` only in `hybrid-only`, with the reason in `guarantee.basis`
|
||||
- `negotiatedGroupPerConnection`: states that the negotiated group is not reported per connection, and why
|
||||
- `certificate`: SHA-256 fingerprint, subject and expiry of the served certificate; `authentication` (`post-quantum`,
|
||||
`mixed`, `classical`) and `chain`, each served certificate with its public key and signature algorithm
|
||||
- `upstream`: only the scheme and whether that hop is encrypted (not the address)
|
||||
- `runtime`: Node.js and OpenSSL versions
|
||||
- `counters`: `connectionsAccepted`, `handshakesRefused` by reason (`no shared group`, `unsupported protocol`,
|
||||
`no shared cipher`, `no shared signature algorithm` (a client that offers no ML-DSA signature algorithm
|
||||
against an ML-DSA chain, which is most browsers today), `handshake timeout`, `other`), `handshakesRefusedByCode`
|
||||
(the OpenSSL error code),
|
||||
`requestsForwarded`, `upgradesTunneled`, `responses502`, `responses504`, `statusRequests`
|
||||
|
||||
## Verifying it yourself
|
||||
|
||||
With OpenSSL 3.5 or later, independent of this gateway:
|
||||
|
||||
```sh
|
||||
openssl s_client -groups X25519MLKEM768 -connect gateway.example.com:443 -servername gateway.example.com </dev/null
|
||||
```
|
||||
|
||||
Look for the line:
|
||||
|
||||
```
|
||||
Negotiated TLS1.3 group: X25519MLKEM768
|
||||
```
|
||||
|
||||
In `hybrid-only` mode, a classical-only client must be refused:
|
||||
|
||||
```sh
|
||||
openssl s_client -groups X25519 -connect gateway.example.com:443 -servername gateway.example.com </dev/null
|
||||
```
|
||||
|
||||
This fails with `alert handshake failure` (alert number 40). Note that `s_client` still prints a
|
||||
`Negotiated TLS1.3 group:` line on failure, with the value `<NULL>`, and `Cipher is (NONE)`.
|
||||
|
||||
If you test with a Node.js client: on Node.js 24 with OpenSSL 3.5, `tlsSocket.getEphemeralKeyInfo()` returns an empty
|
||||
object `{}` for the hybrid group (and `{ type: 'ECDH', name: 'X25519', ... }` for X25519), so it cannot name
|
||||
X25519MLKEM768. Use `openssl s_client` or read the `key_share` extension of the ServerHello from a packet capture
|
||||
(group `0x11ec` is X25519MLKEM768, `0x001d` is X25519).
|
||||
|
||||
## Shutdown
|
||||
|
||||
On `SIGTERM` or `SIGINT` the gateway stops accepting connections, lets in-flight requests finish (responses sent during
|
||||
shutdown carry `Connection: close`), closes remaining connections and tunnels after `AERE_PQGW_SHUTDOWN_GRACE_MS`, and
|
||||
exits on its own.
|
||||
|
||||
## Tests
|
||||
|
||||
`proba-pq-gateway.mjs` starts everything locally on the loopback interface with ports chosen by the system: a
|
||||
self-signed certificate made with the `openssl` command line tool in a temporary directory, an echo upstream, and the
|
||||
gateway as a child process configured through its environment, exactly as you would run it. It reads the negotiated
|
||||
group from the wire (the ServerHello `key_share`), with `openssl s_client` as a second, independent client. Negative
|
||||
tests check the reason for each refusal, not only that it failed.
|
||||
|
||||
`proba-pq-gateway-control-negativ.sh` proves the tests can fail: it disables the gateway's safeguards one at a time
|
||||
in a copy (for example, letting `hybrid-only` also offer X25519, removing the TLS 1.3 minimum, forwarding hop-by-hop
|
||||
headers, putting the private key in the status output) and requires the matching test to fail while the whole suite
|
||||
still runs.
|
||||
|
||||
```sh
|
||||
node proba-pq-gateway.mjs
|
||||
bash proba-pq-gateway-control-negativ.sh
|
||||
```
|
||||
13
pq-gateway/pq-gateway.example.json
Normal file
13
pq-gateway/pq-gateway.example.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"listen": ":8443",
|
||||
"cert": "/etc/aere-pq-gateway/fullchain.pem",
|
||||
"key": "/etc/aere-pq-gateway/privkey.pem",
|
||||
"upstream": "http://app:8080",
|
||||
"mode": "hybrid-preferred",
|
||||
"connectTimeoutMs": 5000,
|
||||
"requestTimeoutMs": 60000,
|
||||
"handshakeTimeoutMs": 10000,
|
||||
"shutdownGraceMs": 10000,
|
||||
"trustForwarded": false,
|
||||
"preserveHost": false
|
||||
}
|
||||
584
pq-gateway/pq-gateway.mjs
Normal file
584
pq-gateway/pq-gateway.mjs
Normal file
@ -0,0 +1,584 @@
|
||||
#!/usr/bin/env node
|
||||
// pq-gateway.mjs, Aere Cloud, Quantum Security Gateway (randul 1 din lista Cloud).
|
||||
//
|
||||
// Ce face: termina TLS 1.3 cu schimb de chei hibrid X25519MLKEM768 (ML-KEM-768 + X25519) si trimite cererile HTTP/1.1
|
||||
// mai departe la serviciul clientului (UPSTREAM), cu corpul in flux. Numai module node:*, fara nicio dependinta; cere
|
||||
// Node legat de OpenSSL >= 3.5 (verificat la pornire, altfel refuza sa porneasca).
|
||||
//
|
||||
// Cele doua moduri:
|
||||
// hybrid-only ofera NUMAI X25519MLKEM768. Un client fara el e refuzat la strangerea de mana. Garantia e
|
||||
// STRUCTURALA: nu exista alt grup pe care sa se poata negocia.
|
||||
// hybrid-preferred X25519MLKEM768, apoi X25519 si P-256. Preferinta e scrisa cu TUPLE OpenSSL 3.5
|
||||
// ('X25519MLKEM768/X25519:P-256'): un client care stie hibridul dar a trimis doar o cota X25519
|
||||
// primeste un HelloRetryRequest si ajunge pe hibrid. Masurat 2026-09-25 pe Node 24.14.1 +
|
||||
// OpenSSL 3.5.5: cu lista PLATA 'X25519MLKEM768:X25519:P-256' acelasi client ramanea pe X25519,
|
||||
// deci "intai hibridul" scris ca lista plata nu era adevarat.
|
||||
//
|
||||
// Ce NU afirma, si de ce: pe partea de SERVER Node nu expune grupul negociat (getEphemeralKeyInfo e numai pentru
|
||||
// client), deci gateway-ul nu spune per conexiune ce grup s-a folosit. In hybrid-only nu are nevoie (e singurul grup);
|
||||
// in hybrid-preferred punctul de stare spune explicit ca nu poate spune, si nu numara conexiuni "hibride".
|
||||
//
|
||||
// Configuratie: variabile AERE_PQGW_* (lista in CHEI, mai jos, si in README), optional un fisier JSON dat prin
|
||||
// AERE_PQGW_CONFIG; mediul are prioritate fata de fisier. O cheie necunoscuta in fisier e o eroare, nu o tacere.
|
||||
//
|
||||
// Oprire: SIGTERM/SIGINT inchid ascultatorul, lasa cererile in curs sa se termine (cel mult AERE_PQGW_SHUTDOWN_GRACE_MS),
|
||||
// pun process.exitCode si lasa bucla de evenimente sa se goleasca; niciun process.exit() dupa I/O.
|
||||
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import crypto from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export const VERSIUNE = '1.0.0';
|
||||
export const CALE_STARE = '/.well-known/aere-pq-gateway';
|
||||
|
||||
// Grupurile oferite, pe mod. '/' separa tuple de preferinta (OpenSSL 3.5), ':' separa grupuri in acelasi tuplu.
|
||||
export const GRUPURI = {
|
||||
'hybrid-only': 'X25519MLKEM768',
|
||||
'hybrid-preferred': 'X25519MLKEM768/X25519:P-256',
|
||||
};
|
||||
|
||||
// Antete hop-by-hop (RFC 9110 7.6.1), plus Proxy-* scoase dupa prefix si orice nume enumerat in Connection.
|
||||
const HOP = new Set(['connection', 'keep-alive', 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
|
||||
|
||||
// Antete pe care le scrie gateway-ul insusi; ce trimite clientul sub aceste nume nu trece, altfel s-ar putea falsifica.
|
||||
// Expect: 100-continue e deja raspuns de serverul Node inaintea handler-ului, deci nu se mai cere o data upstream-ului.
|
||||
const PROPRII = new Set(['x-aere-pq-gateway', 'x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-real-ip', 'forwarded', 'host', 'expect']);
|
||||
// Antete de IDENTITATE pe care un upstream le crede venite de la un proxy de incredere (nginx realip, cadre web, mTLS terminat
|
||||
// in fata): clientul nu are voie sa le scrie. Masurat 2026-09-25 (revizuire): X-Real-IP si X-Client-Cert treceau neatinse.
|
||||
const PREFIXE_IDENTITATE = ['x-client-', 'x-ssl-'];
|
||||
|
||||
// Variabila de mediu -> cheia din fisierul JSON.
|
||||
const CHEI = {
|
||||
AERE_PQGW_LISTEN: 'listen',
|
||||
AERE_PQGW_CERT: 'cert',
|
||||
AERE_PQGW_KEY: 'key',
|
||||
AERE_PQGW_UPSTREAM: 'upstream',
|
||||
AERE_PQGW_UPSTREAM_CA: 'upstreamCa',
|
||||
AERE_PQGW_MODE: 'mode',
|
||||
AERE_PQGW_CONNECT_TIMEOUT_MS: 'connectTimeoutMs',
|
||||
AERE_PQGW_REQUEST_TIMEOUT_MS: 'requestTimeoutMs',
|
||||
AERE_PQGW_HANDSHAKE_TIMEOUT_MS: 'handshakeTimeoutMs',
|
||||
AERE_PQGW_SHUTDOWN_GRACE_MS: 'shutdownGraceMs',
|
||||
AERE_PQGW_MAX_HEADER_SIZE: 'maxHeaderSize',
|
||||
AERE_PQGW_TRUST_FORWARDED: 'trustForwarded',
|
||||
AERE_PQGW_PRESERVE_HOST: 'preserveHost',
|
||||
AERE_PQGW_REQUIRE_PQ_AUTH: 'requirePqAuth',
|
||||
};
|
||||
|
||||
function intreg(nume, v, min, max) {
|
||||
const n = typeof v === 'number' ? v : Number(String(v).trim());
|
||||
if (!Number.isInteger(n) || n < min || n > max) throw new Error(`${nume} must be an integer between ${min} and ${max}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
function boolean(nume, v) {
|
||||
if (typeof v === 'boolean') return v;
|
||||
const s = String(v).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(s)) return true;
|
||||
if (['0', 'false', 'no', 'off', ''].includes(s)) return false;
|
||||
throw new Error(`${nume} must be true or false`);
|
||||
}
|
||||
|
||||
export function citesteAdresa(s) {
|
||||
const t = String(s).trim();
|
||||
let m = t.match(/^\[([0-9a-fA-F:.]+)\]:(\d+)$/);
|
||||
if (m) return { host: m[1], port: intreg('LISTEN port', m[2], 0, 65535) };
|
||||
m = t.match(/^([^:[\]]*):(\d+)$/);
|
||||
if (m) return { host: m[1] || undefined, port: intreg('LISTEN port', m[2], 0, 65535) };
|
||||
throw new Error('LISTEN must be host:port, [ipv6]:port or :port');
|
||||
}
|
||||
|
||||
// Valorile se taie (trim) la citire: un fisier de mediu cu CRLF nu are voie sa lipeasca un CR in cale sau in URL.
|
||||
export function citesteConfig(env = process.env) {
|
||||
let f = {};
|
||||
if (env.AERE_PQGW_CONFIG && String(env.AERE_PQGW_CONFIG).trim()) {
|
||||
let brut;
|
||||
try { brut = fs.readFileSync(String(env.AERE_PQGW_CONFIG).trim(), 'utf8'); } catch (e) { throw new Error(`cannot read AERE_PQGW_CONFIG (${e.code || 'error'})`); }
|
||||
try { f = JSON.parse(brut); } catch { throw new Error('AERE_PQGW_CONFIG is not valid JSON'); }
|
||||
if (!f || typeof f !== 'object' || Array.isArray(f)) throw new Error('AERE_PQGW_CONFIG must contain a JSON object');
|
||||
const stiute = new Set(Object.values(CHEI));
|
||||
for (const k of Object.keys(f)) if (!stiute.has(k)) throw new Error(`unknown key in config file: ${k}`);
|
||||
}
|
||||
const v = (numeEnv, implicit) => {
|
||||
const e = env[numeEnv];
|
||||
if (e !== undefined && String(e).trim() !== '') return String(e).trim();
|
||||
const k = CHEI[numeEnv];
|
||||
if (f[k] !== undefined && f[k] !== null) return typeof f[k] === 'string' ? f[k].trim() : f[k];
|
||||
return implicit;
|
||||
};
|
||||
|
||||
const mode = v('AERE_PQGW_MODE', null);
|
||||
if (!mode) throw new Error('MODE is required: hybrid-only or hybrid-preferred');
|
||||
if (!Object.prototype.hasOwnProperty.call(GRUPURI, mode)) throw new Error('MODE must be hybrid-only or hybrid-preferred');
|
||||
const cert = v('AERE_PQGW_CERT', null);
|
||||
const key = v('AERE_PQGW_KEY', null);
|
||||
if (!cert) throw new Error('CERT is required (PEM certificate chain)');
|
||||
if (!key) throw new Error('KEY is required (PEM private key)');
|
||||
const upstream = v('AERE_PQGW_UPSTREAM', null);
|
||||
if (!upstream) throw new Error('UPSTREAM is required (http://... or https://...)');
|
||||
let u;
|
||||
try { u = new URL(upstream); } catch { throw new Error('UPSTREAM is not a valid URL'); }
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('UPSTREAM must start with http:// or https://');
|
||||
if (u.username || u.password) throw new Error('UPSTREAM must not contain credentials');
|
||||
if (u.search || u.hash) throw new Error('UPSTREAM must not contain a query string or fragment');
|
||||
const { host, port } = citesteAdresa(v('AERE_PQGW_LISTEN', ':8443'));
|
||||
const maxHeaderSize = v('AERE_PQGW_MAX_HEADER_SIZE', null);
|
||||
return {
|
||||
host, port, cert, key, mode,
|
||||
upstream: u.href,
|
||||
upstreamCa: v('AERE_PQGW_UPSTREAM_CA', null),
|
||||
connectTimeoutMs: intreg('CONNECT_TIMEOUT_MS', v('AERE_PQGW_CONNECT_TIMEOUT_MS', 5000), 100, 600000),
|
||||
requestTimeoutMs: intreg('REQUEST_TIMEOUT_MS', v('AERE_PQGW_REQUEST_TIMEOUT_MS', 60000), 100, 3600000),
|
||||
handshakeTimeoutMs: intreg('HANDSHAKE_TIMEOUT_MS', v('AERE_PQGW_HANDSHAKE_TIMEOUT_MS', 10000), 100, 600000),
|
||||
shutdownGraceMs: intreg('SHUTDOWN_GRACE_MS', v('AERE_PQGW_SHUTDOWN_GRACE_MS', 10000), 0, 600000),
|
||||
maxHeaderSize: maxHeaderSize === null ? null : intreg('MAX_HEADER_SIZE', maxHeaderSize, 1024, 1048576),
|
||||
trustForwarded: boolean('TRUST_FORWARDED', v('AERE_PQGW_TRUST_FORWARDED', false)),
|
||||
preserveHost: boolean('PRESERVE_HOST', v('AERE_PQGW_PRESERVE_HOST', false)),
|
||||
requirePqAuth: boolean('REQUIRE_PQ_AUTH', v('AERE_PQGW_REQUIRE_PQ_AUTH', false)),
|
||||
};
|
||||
}
|
||||
|
||||
export function grupuriOferite(mode) {
|
||||
return GRUPURI[mode].split(/[:/]/).filter(Boolean);
|
||||
}
|
||||
|
||||
// Motivul unei strangeri de mana esuate, din codul OpenSSL pe care il pune Node pe eroare.
|
||||
// Masurat 2026-09-25: client numai X25519 fata de hybrid-only -> ERR_SSL_NO_SUITABLE_KEY_SHARE;
|
||||
// client TLS 1.2 -> ERR_SSL_UNSUPPORTED_PROTOCOL.
|
||||
export function motivRefuz(e) {
|
||||
const c = String((e && e.code) || '');
|
||||
if (c === 'ERR_SSL_NO_SUITABLE_KEY_SHARE' || c === 'ERR_SSL_NO_SHARED_GROUPS' || c === 'ERR_SSL_NO_SUITABLE_GROUPS') return 'no shared group';
|
||||
if (c === 'ERR_SSL_UNSUPPORTED_PROTOCOL') return 'unsupported protocol';
|
||||
if (c === 'ERR_SSL_NO_SHARED_CIPHER') return 'no shared cipher';
|
||||
// cu un lant ML-DSA, refuzul cel mai frecvent: clientul nu ofera un algoritm de semnatura post-cuantic (browserele de azi)
|
||||
if (c === 'ERR_SSL_NO_SUITABLE_SIGNATURE_ALGORITHM') return 'no shared signature algorithm';
|
||||
if (c === 'ERR_TLS_HANDSHAKE_TIMEOUT') return 'handshake timeout';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// Scoate antetele hop-by-hop dintr-o lista bruta [nume, valoare, nume, valoare, ...].
|
||||
export function filtreazaAnteturi(brute) {
|
||||
const numite = new Set();
|
||||
for (let i = 0; i < brute.length; i += 2) {
|
||||
if (brute[i].toLowerCase() !== 'connection') continue;
|
||||
for (const t of String(brute[i + 1]).split(',')) { const n = t.trim().toLowerCase(); if (n) numite.add(n); }
|
||||
}
|
||||
const out = [];
|
||||
for (let i = 0; i < brute.length; i += 2) {
|
||||
const n = brute[i].toLowerCase();
|
||||
if (HOP.has(n) || n.startsWith('proxy-') || numite.has(n)) continue;
|
||||
out.push(brute[i], brute[i + 1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Pe un ascultator dual-stack o adresa IPv4 vine ca ::ffff:a.b.c.d; upstream-ul primeste forma IPv4.
|
||||
export function adresaClient(brut) {
|
||||
const s = String(brut || '');
|
||||
return s.toLowerCase().startsWith('::ffff:') && net.isIPv4(s.slice(7)) ? s.slice(7) : s;
|
||||
}
|
||||
|
||||
function versiuneOpenssl() {
|
||||
const m = String(process.versions.openssl || '').match(/^(\d+)\.(\d+)/);
|
||||
return m ? [Number(m[1]), Number(m[2])] : [0, 0];
|
||||
}
|
||||
|
||||
function scrieJurnal(o) {
|
||||
process.stdout.write(JSON.stringify({ time: new Date().toISOString(), ...o }) + '\n');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ autentificarea post-cuantica
|
||||
// Schimbul de chei hibrid apara secretul sesiunii; AUTENTIFICAREA serverului o da semnatura din CertificateVerify (cu cheia
|
||||
// frunzei) si semnaturile de pe lant. Un lant e "post-quantum" numai daca FIECARE certificat servit are cheie ML-DSA si e semnat
|
||||
// ML-DSA (algoritmul exterior citit din DER); radacina nu se serveste, deci semnatura ei de pe ultimul certificat servit e citita
|
||||
// ca oricare alta. Altfel "classical" (nimic ML-DSA) sau "mixed". Cu REQUIRE_PQ_AUTH gateway-ul refuza sa porneasca fara el.
|
||||
const ML_DSA_OID = new Map([['0609608648016503040311', 'ML-DSA-44'], ['0609608648016503040312', 'ML-DSA-65'], ['0609608648016503040313', 'ML-DSA-87']]);
|
||||
function lungimeDer(b, o) {
|
||||
let l = b[o + 1], h = 2;
|
||||
if (l & 0x80) { const n = l & 0x7f; if (n < 1 || n > 4) throw new Error('bad DER length'); l = 0; for (let i = 0; i < n; i++) l = l * 256 + b[o + 2 + i]; h = 2 + n; }
|
||||
return { h, l };
|
||||
}
|
||||
function algSemnaturaDer(der) {
|
||||
// Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm AlgorithmIdentifier, signatureValue }
|
||||
const top = lungimeDer(der, 0);
|
||||
let o = top.h;
|
||||
const tbs = lungimeDer(der, o); o += tbs.h + tbs.l;
|
||||
const alg = lungimeDer(der, o);
|
||||
const oid = der.subarray(o + alg.h, o + alg.h + alg.l);
|
||||
const e = lungimeDer(oid, 0);
|
||||
return ML_DSA_OID.get(Buffer.from(oid.subarray(0, e.h + e.l)).toString('hex')) || 'classical';
|
||||
}
|
||||
export function analizaLant(certPem) {
|
||||
const blocuri = String(certPem).match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || [];
|
||||
const lant = blocuri.map((p) => {
|
||||
const x = new crypto.X509Certificate(p);
|
||||
const cheie = String(x.publicKey.asymmetricKeyType || 'unknown').toUpperCase();
|
||||
return { subject: x.subject, publicKeyAlgorithm: cheie, signatureAlgorithm: algSemnaturaDer(x.raw) };
|
||||
});
|
||||
const pq = (c) => /^ML-DSA-/.test(c.publicKeyAlgorithm) && /^ML-DSA-/.test(c.signatureAlgorithm);
|
||||
const autentificare = lant.length && lant.every(pq) ? 'post-quantum' : lant.some((c) => /^ML-DSA-/.test(c.publicKeyAlgorithm) || /^ML-DSA-/.test(c.signatureAlgorithm)) ? 'mixed' : 'classical';
|
||||
return { lant, autentificare };
|
||||
}
|
||||
|
||||
export function pornesteGateway(cfg, { jurnal = scrieJurnal } = {}) {
|
||||
const [maj, min] = versiuneOpenssl();
|
||||
if (maj < 3 || (maj === 3 && min < 5)) throw new Error(`OpenSSL >= 3.5 is required for X25519MLKEM768; this Node is linked to OpenSSL ${process.versions.openssl}`);
|
||||
|
||||
let certPem, cheiePem;
|
||||
try { certPem = fs.readFileSync(cfg.cert); } catch (e) { throw new Error(`cannot read CERT (${e.code || 'error'})`); }
|
||||
try { cheiePem = fs.readFileSync(cfg.key); } catch (e) { throw new Error(`cannot read KEY (${e.code || 'error'})`); }
|
||||
let x509, cheie;
|
||||
try { x509 = new crypto.X509Certificate(certPem); } catch { throw new Error('CERT does not contain a PEM certificate'); }
|
||||
try { cheie = crypto.createPrivateKey(cheiePem); } catch { throw new Error('KEY does not contain a readable private key'); }
|
||||
if (!x509.checkPrivateKey(cheie)) throw new Error('KEY does not match the first certificate in CERT');
|
||||
let lantCert;
|
||||
try { lantCert = analizaLant(certPem); } catch { throw new Error('CERT contains a certificate that cannot be read'); }
|
||||
if (cfg.requirePqAuth && lantCert.autentificare !== 'post-quantum') {
|
||||
const rau = lantCert.lant.find((c) => !/^ML-DSA-/.test(c.publicKeyAlgorithm) || !/^ML-DSA-/.test(c.signatureAlgorithm));
|
||||
throw new Error(`REQUIRE_PQ_AUTH is set but the served chain is ${lantCert.autentificare}: "${rau ? rau.subject.replace(/\n/g, ', ') : '?'}" has a ${rau ? rau.publicKeyAlgorithm : '?'} key signed with ${rau ? rau.signatureAlgorithm : '?'}; issue an ML-DSA chain (for example with aere-pq-pki)`);
|
||||
}
|
||||
|
||||
const sus = new URL(cfg.upstream);
|
||||
const susHttps = sus.protocol === 'https:';
|
||||
const modul = susHttps ? https : http;
|
||||
const susPort = sus.port || (susHttps ? 443 : 80);
|
||||
const prefix = sus.pathname.replace(/\/+$/, '');
|
||||
let caSus = null;
|
||||
if (susHttps && cfg.upstreamCa) {
|
||||
try { caSus = fs.readFileSync(cfg.upstreamCa); } catch (e) { throw new Error(`cannot read UPSTREAM_CA (${e.code || 'error'})`); }
|
||||
}
|
||||
// Si drumul catre upstream, cand e https, prefera hibridul; nu se afirma nimic despre el in stare.
|
||||
const agent = susHttps
|
||||
? new https.Agent({ keepAlive: true, minVersion: 'TLSv1.2', ecdhCurve: GRUPURI['hybrid-preferred'], ...(caSus ? { ca: caSus } : {}) })
|
||||
: new http.Agent({ keepAlive: true });
|
||||
const caUpgrade = caSus ? { ca: caSus } : {};
|
||||
|
||||
const c = {
|
||||
conexiuniAcceptate: 0,
|
||||
refuzuri: { 'no shared group': 0, 'unsupported protocol': 0, 'no shared cipher': 0, 'no shared signature algorithm': 0, 'handshake timeout': 0, other: 0 },
|
||||
cereriTrimise: 0, tuneluri: 0, r502: 0, r504: 0, cereriStare: 0,
|
||||
};
|
||||
const refuzuriPeCod = new Map();
|
||||
const pornitLa = new Date().toISOString();
|
||||
const tuneluriDeschise = new Set();
|
||||
let seInchide = false;
|
||||
|
||||
function corpStare() {
|
||||
const hibridOnly = cfg.mode === 'hybrid-only';
|
||||
return {
|
||||
service: 'aere-pq-gateway',
|
||||
version: VERSIUNE,
|
||||
mode: cfg.mode,
|
||||
groupsOffered: grupuriOferite(cfg.mode),
|
||||
groupPreference: GRUPURI[cfg.mode],
|
||||
minTlsVersion: 'TLSv1.3',
|
||||
alpn: ['http/1.1'],
|
||||
guarantee: hibridOnly
|
||||
? {
|
||||
hybridKeyExchangeOnEveryConnection: true,
|
||||
basis: 'structural: X25519MLKEM768 is the only key exchange group this listener offers, so a handshake that does not use it fails',
|
||||
}
|
||||
: {
|
||||
hybridKeyExchangeOnEveryConnection: false,
|
||||
basis: 'X25519MLKEM768 is preferred and requested with a HelloRetryRequest when the client supports it; clients without it fall back to X25519 or P-256',
|
||||
},
|
||||
negotiatedGroupPerConnection: 'not reported: the Node.js TLS server API does not expose the negotiated group (getEphemeralKeyInfo is client-side only), so this gateway makes no per-connection claim',
|
||||
certificate: { sha256: x509.fingerprint256, subject: x509.subject, notAfter: x509.validTo,
|
||||
authentication: lantCert.autentificare, chain: lantCert.lant },
|
||||
upstream: { scheme: susHttps ? 'https' : 'http', encrypted: susHttps },
|
||||
runtime: { node: process.version, openssl: process.versions.openssl },
|
||||
startedAt: pornitLa,
|
||||
counters: {
|
||||
connectionsAccepted: c.conexiuniAcceptate,
|
||||
handshakesRefused: { ...c.refuzuri },
|
||||
handshakesRefusedByCode: Object.fromEntries(refuzuriPeCod),
|
||||
requestsForwarded: c.cereriTrimise,
|
||||
upgradesTunneled: c.tuneluri,
|
||||
responses502: c.r502,
|
||||
responses504: c.r504,
|
||||
statusRequests: c.cereriStare,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function esteCaleaStarii(url) {
|
||||
const q = url.indexOf('?');
|
||||
return (q === -1 ? url : url.slice(0, q)) === CALE_STARE;
|
||||
}
|
||||
|
||||
function raspundeJson(req, res, cod, obj, inchide = false) {
|
||||
const corp = JSON.stringify(obj) + '\n';
|
||||
const h = { 'content-type': 'application/json', 'content-length': Buffer.byteLength(corp), 'cache-control': 'no-store' };
|
||||
if (inchide || seInchide) h.connection = 'close';
|
||||
res.writeHead(cod, h);
|
||||
res.end(req.method === 'HEAD' ? undefined : corp);
|
||||
}
|
||||
|
||||
// Raspuns scris direct pe un socket (drumul de upgrade nu are ServerResponse).
|
||||
function scrieBrut(socket, cod, obj) {
|
||||
const corp = JSON.stringify(obj) + '\n';
|
||||
socket.end(`HTTP/1.1 ${cod} ${http.STATUS_CODES[cod]}\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(corp)}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n${corp}`);
|
||||
}
|
||||
|
||||
function anteturiCatreUpstream(req, upgrade) {
|
||||
const h = filtreazaAnteturi(req.rawHeaders);
|
||||
const out = [];
|
||||
let xffVechi = null;
|
||||
for (let i = 0; i < h.length; i += 2) {
|
||||
const n = h[i].toLowerCase();
|
||||
if (n === 'x-forwarded-for') {
|
||||
if (cfg.trustForwarded) xffVechi = xffVechi ? `${xffVechi}, ${h[i + 1]}` : h[i + 1];
|
||||
continue;
|
||||
}
|
||||
if (n === 'forwarded' && cfg.trustForwarded) { out.push(h[i], h[i + 1]); continue; }
|
||||
if (PROPRII.has(n) || PREFIXE_IDENTITATE.some((p) => n.startsWith(p))) continue;
|
||||
out.push(h[i], h[i + 1]);
|
||||
}
|
||||
const hostOriginal = req.headers.host;
|
||||
out.push('Host', cfg.preserveHost && hostOriginal ? hostOriginal : sus.host);
|
||||
const ip = adresaClient(req.socket.remoteAddress);
|
||||
out.push('X-Forwarded-For', xffVechi ? `${xffVechi}, ${ip}` : ip);
|
||||
out.push('X-Real-IP', ip);
|
||||
out.push('X-Forwarded-Proto', 'https');
|
||||
if (hostOriginal) out.push('X-Forwarded-Host', hostOriginal);
|
||||
out.push('x-aere-pq-gateway', cfg.mode);
|
||||
if (upgrade) out.push('Connection', 'Upgrade', 'Upgrade', String(req.headers.upgrade));
|
||||
else if (req.headers['transfer-encoding'] !== undefined) out.push('Transfer-Encoding', 'chunked');
|
||||
return out;
|
||||
}
|
||||
|
||||
function optiuniUpstream(req, anteturi, peUpgrade) {
|
||||
return {
|
||||
protocol: sus.protocol, hostname: sus.hostname, port: susPort,
|
||||
method: req.method, path: req.url === '*' ? '*' : prefix + req.url,
|
||||
headers: anteturi, setHost: false,
|
||||
agent: peUpgrade ? false : agent,
|
||||
...(peUpgrade && susHttps ? { minVersion: 'TLSv1.2', ecdhCurve: GRUPURI['hybrid-preferred'], ...caUpgrade } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Doua cronometre: conectarea la upstream (TCP, plus TLS cand e https) si raspunsul lui, numarat de cand cererea a
|
||||
// fost trimisa intreaga. Oricare expira -> 504. Un socket refolosit din bazin nu mai are faza de conectare.
|
||||
// Un upstream poate raspunde INAINTE sa fi primit tot corpul; atunci 'finish' vine dupa raspuns si nu mai porneste
|
||||
// nimic. Masurat 2026-09-25: fara garda, un raspuns care curgea inca a fost taiat la REQUEST_TIMEOUT dupa 'finish'.
|
||||
function puneCronometre(upReq) {
|
||||
let tConectare = null, tRaspuns = null, raspunsPrimit = false;
|
||||
const expirat = (tip) => Object.assign(new Error(`upstream ${tip} timeout`), { aereTimeout: tip });
|
||||
upReq.on('socket', (s) => {
|
||||
if (!s.connecting) return;
|
||||
tConectare = setTimeout(() => upReq.destroy(expirat('connect')), cfg.connectTimeoutMs);
|
||||
s.once(susHttps ? 'secureConnect' : 'connect', () => { clearTimeout(tConectare); tConectare = null; });
|
||||
});
|
||||
upReq.on('finish', () => {
|
||||
if (raspunsPrimit) return;
|
||||
tRaspuns = setTimeout(() => upReq.destroy(expirat('response')), cfg.requestTimeoutMs);
|
||||
});
|
||||
const opreste = () => { raspunsPrimit = true; clearTimeout(tConectare); clearTimeout(tRaspuns); };
|
||||
upReq.on('close', opreste);
|
||||
return { opreste };
|
||||
}
|
||||
|
||||
function corpEroare(cod, e) {
|
||||
if (cod === 504) return { error: 'gateway_timeout', detail: e && e.aereTimeout === 'connect' ? 'upstream connect timeout' : 'upstream response timeout' };
|
||||
return { error: 'bad_gateway', detail: 'upstream unreachable or connection failed' };
|
||||
}
|
||||
|
||||
function numaraEroare(e) {
|
||||
const cod = e && e.aereTimeout ? 504 : 502;
|
||||
if (cod === 504) c.r504++; else c.r502++;
|
||||
jurnal({ event: 'upstream_error', status: cod, code: (e && (e.aereTimeout || e.code)) || 'unknown' });
|
||||
return cod;
|
||||
}
|
||||
|
||||
function laCerere(req, res) {
|
||||
if (esteCaleaStarii(req.url)) {
|
||||
c.cereriStare++;
|
||||
req.resume();
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') { res.setHeader('allow', 'GET, HEAD'); return raspundeJson(req, res, 405, { error: 'method_not_allowed' }); }
|
||||
return raspundeJson(req, res, 200, corpStare());
|
||||
}
|
||||
if (!(req.url.startsWith('/') || (req.method === 'OPTIONS' && req.url === '*'))) {
|
||||
req.resume();
|
||||
return raspundeJson(req, res, 400, { error: 'bad_request', detail: 'request target must be origin-form' }, true);
|
||||
}
|
||||
const te = req.headers['transfer-encoding'];
|
||||
if (te !== undefined && String(te).trim().toLowerCase() !== 'chunked') {
|
||||
req.resume();
|
||||
return raspundeJson(req, res, 501, { error: 'not_implemented', detail: 'only chunked transfer-encoding is supported' }, true);
|
||||
}
|
||||
|
||||
const upReq = modul.request(optiuniUpstream(req, anteturiCatreUpstream(req, false), false));
|
||||
c.cereriTrimise++;
|
||||
const cron = puneCronometre(upReq);
|
||||
let raspuns = false;
|
||||
|
||||
upReq.on('response', (upRes) => {
|
||||
cron.opreste();
|
||||
if (raspuns) { upRes.resume(); return; }
|
||||
raspuns = true;
|
||||
const h = filtreazaAnteturi(upRes.rawHeaders);
|
||||
if (seInchide) h.push('Connection', 'close');
|
||||
try {
|
||||
res.writeHead(upRes.statusCode, upRes.statusMessage, h);
|
||||
} catch (e) {
|
||||
jurnal({ event: 'upstream_bad_response', code: e.code || 'error' });
|
||||
upRes.destroy();
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
upRes.on('error', () => res.destroy());
|
||||
upRes.on('close', () => { if (!upRes.complete) res.destroy(); });
|
||||
upRes.pipe(res);
|
||||
});
|
||||
|
||||
upReq.on('error', (e) => {
|
||||
if (raspuns) { res.destroy(); return; }
|
||||
raspuns = true;
|
||||
req.unpipe(upReq);
|
||||
req.resume();
|
||||
// Clientul a plecat inainte de raspuns: cererea catre upstream a fost taiata de noi, nu e o eroare a upstream-ului.
|
||||
// Masurat 2026-09-25: fara garda asta, doua plecari de client au iesit "responses502 +2".
|
||||
if (res.headersSent || res.destroyed) { res.destroy(); return; }
|
||||
const cod = numaraEroare(e);
|
||||
raspundeJson(req, res, cod, corpEroare(cod, e), true);
|
||||
});
|
||||
|
||||
res.on('close', () => { if (!res.writableFinished) upReq.destroy(); });
|
||||
req.pipe(upReq);
|
||||
}
|
||||
|
||||
const server = https.createServer({
|
||||
key: cheiePem,
|
||||
cert: certPem,
|
||||
minVersion: 'TLSv1.3',
|
||||
ecdhCurve: GRUPURI[cfg.mode],
|
||||
ALPNProtocols: ['http/1.1'],
|
||||
handshakeTimeout: cfg.handshakeTimeoutMs,
|
||||
...(cfg.maxHeaderSize ? { maxHeaderSize: cfg.maxHeaderSize } : {}),
|
||||
}, laCerere);
|
||||
|
||||
server.on('secureConnection', () => { c.conexiuniAcceptate++; });
|
||||
server.on('tlsClientError', (e, sock) => {
|
||||
c.refuzuri[motivRefuz(e)]++;
|
||||
const cod = String((e && e.code) || 'UNKNOWN').slice(0, 64);
|
||||
const cheieCod = refuzuriPeCod.has(cod) || refuzuriPeCod.size < 31 ? cod : '(other codes)';
|
||||
refuzuriPeCod.set(cheieCod, (refuzuriPeCod.get(cheieCod) || 0) + 1);
|
||||
if (sock && !sock.destroyed) sock.destroy();
|
||||
});
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
socket.on('error', () => {});
|
||||
if (esteCaleaStarii(req.url) || !req.url.startsWith('/')) { scrieBrut(socket, 400, { error: 'bad_request', detail: 'upgrade not allowed on this path' }); return; }
|
||||
const upReq = modul.request(optiuniUpstream(req, anteturiCatreUpstream(req, true), true));
|
||||
c.cereriTrimise++;
|
||||
const cron = puneCronometre(upReq);
|
||||
let gata = false;
|
||||
|
||||
upReq.on('upgrade', (upRes, upSock, upHead) => {
|
||||
cron.opreste();
|
||||
gata = true;
|
||||
upSock.on('error', () => {});
|
||||
if (socket.destroyed) { upSock.destroy(); return; }
|
||||
c.tuneluri++;
|
||||
const h = filtreazaAnteturi(upRes.rawHeaders);
|
||||
const linii = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage || 'Switching Protocols'}`];
|
||||
for (let i = 0; i < h.length; i += 2) linii.push(`${h[i]}: ${h[i + 1]}`);
|
||||
linii.push('Connection: Upgrade', `Upgrade: ${upRes.headers.upgrade || req.headers.upgrade}`);
|
||||
socket.write(linii.join('\r\n') + '\r\n\r\n');
|
||||
if (upHead && upHead.length) socket.write(upHead);
|
||||
if (head && head.length) upSock.write(head);
|
||||
tuneluriDeschise.add(socket);
|
||||
socket.setTimeout(0);
|
||||
socket.setNoDelay(true);
|
||||
upSock.setNoDelay(true);
|
||||
const inchide = () => { tuneluriDeschise.delete(socket); socket.destroy(); upSock.destroy(); };
|
||||
socket.on('close', inchide);
|
||||
upSock.on('close', inchide);
|
||||
upSock.pipe(socket);
|
||||
socket.pipe(upSock);
|
||||
});
|
||||
|
||||
// Upstream-ul nu a acceptat upgrade-ul: raspunsul lui ajunge la client, apoi conexiunea se inchide.
|
||||
upReq.on('response', (upRes) => {
|
||||
cron.opreste();
|
||||
gata = true;
|
||||
const h = filtreazaAnteturi(upRes.rawHeaders);
|
||||
const linii = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage || ''}`];
|
||||
for (let i = 0; i < h.length; i += 2) linii.push(`${h[i]}: ${h[i + 1]}`);
|
||||
linii.push('Connection: close');
|
||||
socket.write(linii.join('\r\n') + '\r\n\r\n');
|
||||
upRes.on('error', () => socket.destroy());
|
||||
upRes.pipe(socket);
|
||||
});
|
||||
|
||||
upReq.on('error', (e) => {
|
||||
if (gata) { socket.destroy(); return; }
|
||||
gata = true;
|
||||
if (socket.destroyed) return; // clientul a plecat: nu se numara ca eroare de upstream
|
||||
const cod = numaraEroare(e);
|
||||
if (!socket.destroyed) scrieBrut(socket, cod, corpEroare(cod, e));
|
||||
});
|
||||
socket.on('close', () => { if (!gata) upReq.destroy(); });
|
||||
upReq.end();
|
||||
});
|
||||
|
||||
function opreste(graceMs) {
|
||||
seInchide = true;
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(() => {
|
||||
server.closeAllConnections();
|
||||
for (const s of tuneluriDeschise) s.destroy();
|
||||
}, graceMs);
|
||||
server.close(() => { clearTimeout(t); agent.destroy(); resolve(); });
|
||||
server.closeIdleConnections();
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(cfg.port, cfg.host, () => {
|
||||
server.off('error', reject);
|
||||
server.on('error', (e) => jurnal({ event: 'server_error', code: e.code || 'error' }));
|
||||
resolve({ server, adresa: server.address(), opreste, stare: corpStare });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let cfg;
|
||||
try { cfg = citesteConfig(process.env); } catch (e) {
|
||||
process.stderr.write(`aere-pq-gateway: configuration error: ${e.message}\n`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
let gw;
|
||||
try { gw = await pornesteGateway(cfg); } catch (e) {
|
||||
process.stderr.write(`aere-pq-gateway: failed to start: ${e.message}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const s = gw.stare();
|
||||
scrieJurnal({
|
||||
event: 'listening', address: gw.adresa.address, port: gw.adresa.port, mode: cfg.mode,
|
||||
groups: GRUPURI[cfg.mode], minTlsVersion: 'TLSv1.3', certificateSha256: s.certificate.sha256,
|
||||
upstreamScheme: s.upstream.scheme, node: process.version, openssl: process.versions.openssl,
|
||||
});
|
||||
if (!s.upstream.encrypted) scrieJurnal({ event: 'warning', detail: 'UPSTREAM is plain http: the hop from this gateway to the upstream is not encrypted; keep it on a trusted network or use https' });
|
||||
let oprire = false;
|
||||
const laSemnal = (semnal) => {
|
||||
if (oprire) return;
|
||||
oprire = true;
|
||||
scrieJurnal({ event: 'shutdown', signal: semnal, graceMs: cfg.shutdownGraceMs });
|
||||
gw.opreste(cfg.shutdownGraceMs).then(() => {
|
||||
scrieJurnal({ event: 'stopped' });
|
||||
process.exitCode = 0;
|
||||
});
|
||||
};
|
||||
process.on('SIGTERM', () => laSemnal('SIGTERM'));
|
||||
process.on('SIGINT', () => laSemnal('SIGINT'));
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|
||||
144
pq-gateway/proba-pq-gateway-control-negativ.sh
Normal file
144
pq-gateway/proba-pq-gateway-control-negativ.sh
Normal file
@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
# Controlul negativ al probelor gateway-ului PQ (proba-pq-gateway.mjs). Paznicii din pq-gateway.mjs se ORBESC pe rand,
|
||||
# intr-o COPIE a dosarului (originalul nu se atinge), si proba tinta TREBUIE sa iasa ROSIE pe numele ei. Suita trebuie
|
||||
# sa fi RULAT intreaga, altfel verdictul e STRICAT, nu rosu. La urma suita neatinsa trebuie sa fie verde.
|
||||
# bash aerenew/cloud-gateway/pq-gateway/proba-pq-gateway-control-negativ.sh
|
||||
# Iesire: un rand pe paznic, apoi "CONTROL NEGATIV: N treceri, M esecuri"; cod nenul la orice esec sau STRICAT.
|
||||
set -u
|
||||
AICI="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
W=$(mktemp -d); trap 'rm -rf "$W"' EXIT
|
||||
# probele (s) folosesc autoritatea de certificare PQ de langa gateway; copia nu o are, deci calea ei vine din mediu
|
||||
export AERE_PQ_PKI="$(cd "$AICI/../pq-pki" && pwd)/pki.mjs"
|
||||
if command -v cygpath >/dev/null 2>&1; then AERE_PQ_PKI="$(cygpath -w "$AERE_PQ_PKI")"; fi
|
||||
for f in pq-gateway.mjs proba-pq-gateway.mjs pq-gateway.example.json; do
|
||||
cp "$AICI/$f" "$W/$f"
|
||||
cmp -s "$AICI/$f" "$W/$f" || { echo "STRICAT: copia lui $f difera de original"; exit 3; }
|
||||
done
|
||||
PROBE=$(grep -c "^await test(" "$AICI/proba-pq-gateway.mjs")
|
||||
[ "$PROBE" -gt 0 ] || { echo "STRICAT: nu am gasit nicio proba in proba-pq-gateway.mjs"; exit 3; }
|
||||
cale_nod() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else echo "$1"; fi; }
|
||||
treceri=0; esecuri=0
|
||||
|
||||
# $1 vechi, $2 nou. Ancora trebuie sa apara EXACT o data, altfel plantarea e un esec al controlului, nu un verde.
|
||||
planteaza() {
|
||||
AERE_VECHI="$1" AERE_NOU="$2" node -e "
|
||||
const fs = require('fs'); const p = process.argv[1]; const t = fs.readFileSync(p, 'utf8'); const a = process.env.AERE_VECHI;
|
||||
const n = t.split(a).length - 1;
|
||||
if (n !== 1) { console.log(' ancora apare de ' + n + ' ori'); process.exitCode = 3; }
|
||||
else fs.writeFileSync(p, t.split(a).join(process.env.AERE_NOU));" "$(cale_nod "$W/pq-gateway.mjs")"
|
||||
}
|
||||
|
||||
# $1 descrierea paznicului orbit, $2 eticheta probei tinta, de ex. (g)
|
||||
masoara() {
|
||||
if cmp -s "$AICI/pq-gateway.mjs" "$W/pq-gateway.mjs"; then
|
||||
echo " $1: STRICAT (plantarea nu a schimbat nimic)"; esecuri=$((esecuri+1)); return
|
||||
fi
|
||||
if ! cmp -s "$AICI/proba-pq-gateway.mjs" "$W/proba-pq-gateway.mjs"; then
|
||||
echo " $1: STRICAT (proba din copie difera de original)"; esecuri=$((esecuri+1)); return
|
||||
fi
|
||||
( cd "$W" && node proba-pq-gateway.mjs > "$W/out.txt" 2>&1 )
|
||||
local cod=$?
|
||||
local rulate; rulate=$(grep -c "^ OK \|^ ESEC \|^ -- " "$W/out.txt")
|
||||
local tinta; tinta=$(grep "^ ESEC " "$W/out.txt" | grep -cF "ESEC $2 ")
|
||||
local rosii; rosii=$(grep -c "^ ESEC " "$W/out.txt")
|
||||
if [ "$rulate" -ne "$PROBE" ]; then
|
||||
echo " $1: STRICAT (au rulat $rulate probe din $PROBE, cod $cod)"; tail -3 "$W/out.txt" | cut -c1-220; esecuri=$((esecuri+1))
|
||||
elif [ "$cod" -eq 0 ]; then
|
||||
echo " $1: CONTROL NEGATIV CAZUT (suita a iesit 0)"; esecuri=$((esecuri+1))
|
||||
elif [ "$tinta" -ge 1 ]; then
|
||||
echo " $1: ROSU cum trebuia ($rosii esecuri in suita, intre ele $2)"
|
||||
grep "^ ESEC " "$W/out.txt" | grep -F "ESEC $2 " | cut -c1-330 | sed 's/^/ /'
|
||||
treceri=$((treceri+1))
|
||||
else
|
||||
echo " $1: CONTROL NEGATIV CAZUT (proba $2 a ramas verde; au picat: $(grep '^ ESEC ' "$W/out.txt" | cut -c8-12 | tr '\n' ' '))"; esecuri=$((esecuri+1))
|
||||
fi
|
||||
cp "$AICI/pq-gateway.mjs" "$W/pq-gateway.mjs"
|
||||
cmp -s "$AICI/pq-gateway.mjs" "$W/pq-gateway.mjs" || { echo "STRICAT: copia nu s-a putut reface"; exit 3; }
|
||||
}
|
||||
|
||||
control() { # $1 descriere, $2 tinta, $3 ancora, $4 inlocuire
|
||||
if planteaza "$3" "$4"; then masoara "$1" "$2"; else echo " $1: STRICAT (plantarea a esuat)"; esecuri=$((esecuri+1)); fi
|
||||
}
|
||||
|
||||
control "autentificarea raportata mereu post-quantum" "(s2)" \
|
||||
"const autentificare = lant.length && lant.every(pq) ? 'post-quantum'" \
|
||||
"const autentificare = lant.length && (lant.every(pq) || Boolean(Number('1'))) ? 'post-quantum'"
|
||||
control "REQUIRE_PQ_AUTH ignorat" "(s3)" \
|
||||
" if (cfg.requirePqAuth && lantCert.autentificare !== 'post-quantum') {" \
|
||||
" if (cfg.requirePqAuth && lantCert.autentificare !== 'post-quantum' && Boolean(Number('0'))) {"
|
||||
control "numai cheile lantului judecate, nu si semnaturile" "(s3)" \
|
||||
"const pq = (c) => /^ML-DSA-/.test(c.publicKeyAlgorithm) && /^ML-DSA-/.test(c.signatureAlgorithm);" \
|
||||
"const pq = (c) => /^ML-DSA-/.test(c.publicKeyAlgorithm);"
|
||||
control "X-Real-IP al clientului crezut (revizuirea 2026-09-25, A8)" "(e3)" \
|
||||
"'x-forwarded-host', 'x-real-ip', 'forwarded'" \
|
||||
"'x-forwarded-host', 'forwarded'"
|
||||
control "X-Client-* si X-SSL-* ale clientului trec la upstream (A8)" "(e3)" \
|
||||
"PREFIXE_IDENTITATE.some((p) => n.startsWith(p))" \
|
||||
"PREFIXE_IDENTITATE.some((p) => n.startsWith(p) && Boolean(Number('0')))"
|
||||
control "refuzul pe algoritmul de semnatura numarat ca other (A9)" "(g3)" \
|
||||
"if (c === 'ERR_SSL_NO_SUITABLE_SIGNATURE_ALGORITHM') return 'no shared signature algorithm';" \
|
||||
"if (c === 'ERR_SSL_NO_SUITABLE_SIGNATURE_ALGORITHM' && Boolean(Number('0'))) return 'no shared signature algorithm';"
|
||||
control "hybrid-only ofera si X25519" "(g)" \
|
||||
"'hybrid-only': 'X25519MLKEM768'," "'hybrid-only': 'X25519MLKEM768:X25519',"
|
||||
control "motivul refuzului nu mai e numarat (totul 'other')" "(g)" \
|
||||
"export function motivRefuz(e) {" "export function motivRefuz(e) { if (!process.env.AERE_NU) return 'other';"
|
||||
control "minVersion TLSv1.3 scos (ramane implicitul Node, TLSv1.2)" "(h)" \
|
||||
"minVersion: 'TLSv1.3'," ""
|
||||
control "antetele hop-by-hop nescoase" "(e)" \
|
||||
"if (HOP.has(n) || n.startsWith('proxy-') || numite.has(n)) continue;" "if (Boolean(process.env.AERE_NU)) continue;"
|
||||
control "numele enumerate in Connection nescoase" "(e)" \
|
||||
"if (HOP.has(n) || n.startsWith('proxy-') || numite.has(n)) continue;" "if (HOP.has(n) || n.startsWith('proxy-')) continue;"
|
||||
control "X-Forwarded-For al clientului crezut" "(e)" \
|
||||
"if (cfg.trustForwarded) xffVechi =" "if (!process.env.AERE_NU) xffVechi ="
|
||||
control "x-aere-pq-gateway nepus (cel falsificat de client trece)" "(e)" \
|
||||
"out.push('x-aere-pq-gateway', cfg.mode);" "if (Boolean(process.env.AERE_NU)) out.push('x-aere-pq-gateway', cfg.mode);"
|
||||
control "cheia privata inclusa in stare" "(f)" \
|
||||
"certificate: { sha256: x509.fingerprint256," "privateKey: cheiePem.toString('utf8'), certificate: { sha256: x509.fingerprint256,"
|
||||
control "starea trimisa la upstream in loc sa fie servita" "(f)" \
|
||||
"if (esteCaleaStarii(req.url)) {" "if (Boolean(process.env.AERE_NU) && esteCaleaStarii(req.url)) {"
|
||||
control "upstream cazut: niciun raspuns (agatare)" "(i)" \
|
||||
"raspundeJson(req, res, cod, corpEroare(cod, e), true);" "if (Boolean(process.env.AERE_NU)) raspundeJson(req, res, cod, corpEroare(cod, e), true);"
|
||||
control "timeout raportat ca 502" "(i2)" \
|
||||
"const cod = e && e.aereTimeout ? 504 : 502;" "const cod = 502;"
|
||||
control "hybrid-preferred pretinde hibrid garantat" "(j)" \
|
||||
"hybridKeyExchangeOnEveryConnection: false," "hybridKeyExchangeOnEveryConnection: true,"
|
||||
control "hybrid-preferred ca lista plata (fara tuplu, fara HelloRetryRequest)" "(j2)" \
|
||||
"'hybrid-preferred': 'X25519MLKEM768/X25519:P-256'," "'hybrid-preferred': 'X25519MLKEM768:X25519:P-256',"
|
||||
control "corpul bufferat intreg inainte de trimitere" "(k)" \
|
||||
"req.pipe(upReq);" "(async () => { const b = []; for await (const x of req) b.push(x); upReq.end(Buffer.concat(b)); })().catch(() => upReq.destroy());"
|
||||
control "tunelul upgrade numai intr-un sens" "(d)" \
|
||||
"upSock.pipe(socket);" ""
|
||||
control "upgrade refuzat de upstream trimis fara Connection: close" "(d2)" \
|
||||
"linii.push('Connection: close');" ""
|
||||
control "upgrade catre upstream cazut: niciun raspuns pe socket" "(i3)" \
|
||||
"if (!socket.destroyed) scrieBrut(socket, cod, corpEroare(cod, e));" "if (Boolean(process.env.AERE_NU)) scrieBrut(socket, cod, corpEroare(cod, e));"
|
||||
control "drumul catre upstream https fara preferinta hibrida" "(q)" \
|
||||
"ecdhCurve: GRUPURI['hybrid-preferred'], ...(caSus ?" "ecdhCurve: 'X25519', ...(caSus ?"
|
||||
control "upgrade catre upstream https fara CA-ul configurat" "(d3)" \
|
||||
"ecdhCurve: GRUPURI['hybrid-preferred'], ...caUpgrade }" "ecdhCurve: GRUPURI['hybrid-preferred'] }"
|
||||
control "plecarea clientului numarata ca 502 (cerere)" "(s)" \
|
||||
"if (res.headersSent || res.destroyed) { res.destroy(); return; }" ""
|
||||
control "plecarea clientului numarata ca 502 (upgrade)" "(s)" \
|
||||
"if (socket.destroyed) return; //" "if (Boolean(process.env.AERE_NU)) return; //"
|
||||
control "raspunsul care curge taiat de cronometrul pornit la 'finish'" "(k2)" \
|
||||
"if (raspunsPrimit) return;" "if (Boolean(process.env.AERE_NU)) return;"
|
||||
control "adresa ::ffff: trimisa neschimbata in X-Forwarded-For" "(e2)" \
|
||||
"return s.toLowerCase().startsWith('::ffff:') && net.isIPv4(s.slice(7)) ? s.slice(7) : s;" "return s;"
|
||||
control "certificatul upstream-ului https nu se mai verifica" "(q2)" \
|
||||
"new https.Agent({ keepAlive: true," "new https.Agent({ rejectUnauthorized: false, keepAlive: true,"
|
||||
control "fara cronometru de conectare la upstream" "(r)" \
|
||||
"tConectare = setTimeout(() => upReq.destroy(expirat('connect')), cfg.connectTimeoutMs);" "tConectare = null;"
|
||||
control "oprirea taie cererile in curs" "(l)" \
|
||||
"server.closeIdleConnections();" "server.closeAllConnections();"
|
||||
|
||||
( cd "$AICI" && node proba-pq-gateway.mjs > "$W/final.txt" 2>&1 )
|
||||
codf=$?
|
||||
rulatef=$(grep -c "^ OK \|^ ESEC \|^ -- " "$W/final.txt")
|
||||
okf=$(grep -c "^ OK " "$W/final.txt")
|
||||
if [ "$codf" -eq 0 ] && [ "$rulatef" -eq "$PROBE" ] && [ "$okf" -eq "$PROBE" ]; then
|
||||
echo " suita neatinsa: VERDE ($(tail -1 "$W/final.txt"))"; treceri=$((treceri+1))
|
||||
else
|
||||
echo " suita neatinsa: NU e verde (cod $codf, $okf din $PROBE OK, rulate $rulatef)"; grep "^ ESEC \|^ -- \|STRICAT" "$W/final.txt" | cut -c1-220; esecuri=$((esecuri+1))
|
||||
fi
|
||||
echo "CONTROL NEGATIV: $treceri treceri, $esecuri esecuri"
|
||||
[ "$esecuri" -eq 0 ] || exit 1
|
||||
965
pq-gateway/proba-pq-gateway.mjs
Normal file
965
pq-gateway/proba-pq-gateway.mjs
Normal file
@ -0,0 +1,965 @@
|
||||
// Probele gateway-ului PQ (pq-gateway.mjs). Totul pe 127.0.0.1, pe porturi alese de sistem; nicio retea reala.
|
||||
// node aerenew/cloud-gateway/pq-gateway/proba-pq-gateway.mjs
|
||||
//
|
||||
// Ce porneste: un certificat auto-semnat facut cu openssl CLI intr-un dosar temporar, un upstream HTTP local care
|
||||
// ecouieste metoda, calea, antetele si sha256-ul corpului, si gateway-ul ca PROCES COPIL (node pq-gateway.mjs cu
|
||||
// mediul lui), exact cum l-ar rula un client. Singura exceptie e (l), oprirea curata, care cheama functia exportata in
|
||||
// proces: pe Windows un semnal trimis unui copil il omoara fara handler, deci semnalul adevarat nu se poate livra aici.
|
||||
//
|
||||
// Grupul negociat se citeste in trei feluri, fiindca fiecare singur ar fi putut minti:
|
||||
// 1. DE PE FIR: un robinet TCP intre client si gateway retine octetii, iar ServerHello (in clar in TLS 1.3) se
|
||||
// desface pana la extensia key_share (0x0033); 0x11ec = X25519MLKEM768, 0x001d = X25519.
|
||||
// 2. openssl s_client, un client independent de codul nostru si de Node ("Negotiated TLS1.3 group").
|
||||
// 3. getEphemeralKeyInfo() din clientul Node, TIPARIT exact. Masurat 2026-09-25 pe Node 24.14.1 + OpenSSL 3.5.5:
|
||||
// intoarce {} pentru grupul hibrid (si {type:'ECDH',name:'X25519'} pentru cel clasic), deci API-ul NU numeste
|
||||
// hibridul; proba cere doar ca el sa nu pretinda un grup clasic, iar numele il ia de pe fir.
|
||||
//
|
||||
// Drumul gateway -> upstream https se citeste la fel, cu un al doilea robinet in fata unui upstream https local.
|
||||
//
|
||||
// Iesire: un rand " OK ", " ESEC " sau " -- " (nemasurat) pe proba, apoi "N treceri, M esecuri". Cod 1 la esec,
|
||||
// 3 cand pregatirea a cazut (STRICAT: nicio proba nu a rulat).
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import tls from 'node:tls';
|
||||
import net from 'node:net';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import readline from 'node:readline';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn, spawnSync, execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
||||
const GATEWAY = path.join(AICI, 'pq-gateway.mjs');
|
||||
const CALE_STARE = '/.well-known/aere-pq-gateway';
|
||||
const GAZDA = '127.0.0.1';
|
||||
const HIBRID = 0x11ec, X25519 = 0x001d;
|
||||
const NUME_GRUP = { 0x11ec: 'X25519MLKEM768', 0x11eb: 'SecP256r1MLKEM768', 0x11ed: 'SecP384r1MLKEM1024', 0x001d: 'X25519', 0x001e: 'X448', 0x0017: 'P-256', 0x0018: 'P-384', 0x0019: 'P-521' };
|
||||
const numeGrup = (g) => (g === null || g === undefined ? 'nimic' : NUME_GRUP[g] || '0x' + g.toString(16).padStart(4, '0'));
|
||||
const HRR = Buffer.from('cf21ad74e59a6111be1d8c021e65b891c2a211167abb8c5e079e09e2c8a8339c', 'hex');
|
||||
const dormi = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
let treceri = 0, esecuri = 0, nemasurate = 0;
|
||||
const NEMASURAT = Symbol('nemasurat');
|
||||
async function test(nume, fn) {
|
||||
try {
|
||||
const r = await fn();
|
||||
if (r && r[NEMASURAT]) { console.log(` -- ${nume}: NEMASURAT, ${r.motiv}`); nemasurate++; return; }
|
||||
console.log(` OK ${nume}${r ? ` [${r}]` : ''}`);
|
||||
treceri++;
|
||||
} catch (e) {
|
||||
console.log(` ESEC ${nume}: ${String(e && e.message ? e.message : e).replace(/\s+/g, ' ').slice(0, 400)}`);
|
||||
esecuri++;
|
||||
}
|
||||
}
|
||||
function cere(cond, mesaj) { if (!cond) throw new Error(mesaj); }
|
||||
|
||||
// ---------------------------------------------------------------- pregatirea
|
||||
const copii = [];
|
||||
const deInchis = [];
|
||||
let TMP = null;
|
||||
function curata() {
|
||||
for (const p of copii) { try { p.kill(); } catch { /* deja mort */ } }
|
||||
for (const s of socluriTacute) s.destroy();
|
||||
for (const s of deInchis) { try { s.close(); } catch { /* deja inchis */ } }
|
||||
if (TMP) { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* ramane in temp */ } }
|
||||
}
|
||||
process.on('exit', curata);
|
||||
// Paznic: o proba agatata nu are voie sa tina suita la nesfarsit. Dupa ultimul rand, un rest agatat (un socket
|
||||
// ramas deschis) nu mai schimba verdictul, doar il spune.
|
||||
let suitaTerminata = false;
|
||||
const paznic = setTimeout(() => {
|
||||
if (suitaTerminata) { console.log('(nota: bucla nu s-a golit singura in 240 s; iesire fortata cu verdictul de mai sus)'); process.exit(process.exitCode || 0); }
|
||||
console.log('STRICAT: suita a depasit 240 s');
|
||||
curata();
|
||||
process.exit(3);
|
||||
}, 240000);
|
||||
paznic.unref();
|
||||
|
||||
function gasesteOpenssl() {
|
||||
const candidati = [process.env.AERE_OPENSSL, 'openssl', 'C:\\Program Files\\Git\\mingw64\\bin\\openssl.exe', '/usr/bin/openssl'].filter(Boolean);
|
||||
for (const bin of candidati) {
|
||||
try {
|
||||
const v = execFileSync(bin, ['version'], { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
|
||||
const m = v.match(/OpenSSL (\d+)\.(\d+)/);
|
||||
if (m && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 5))) return { bin, versiune: v };
|
||||
} catch { /* urmatorul */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Robinet TCP: tine octetii fiecarei conexiuni in ambele sensuri, ca grupul sa se citeasca de pe fir.
|
||||
function robinet(portTinta) {
|
||||
const conexiuni = [];
|
||||
const srv = net.createServer((cl) => {
|
||||
const rec = { c2s: [], s2c: [] };
|
||||
conexiuni.push(rec);
|
||||
const sv = net.connect(portTinta, GAZDA);
|
||||
cl.on('data', (b) => rec.c2s.push(b));
|
||||
sv.on('data', (b) => rec.s2c.push(b));
|
||||
cl.pipe(sv);
|
||||
sv.pipe(cl);
|
||||
const gata = () => { cl.destroy(); sv.destroy(); };
|
||||
cl.on('error', gata); sv.on('error', gata); cl.on('close', gata); sv.on('close', gata);
|
||||
});
|
||||
deInchis.push(srv);
|
||||
return new Promise((r) => srv.listen(0, GAZDA, () => r({ port: srv.address().port, conexiuni })));
|
||||
}
|
||||
|
||||
// Mesajele de handshake in clar (inregistrari de tip 22 dinaintea primei inregistrari criptate, tip 23).
|
||||
function mesajeHandshake(bucati) {
|
||||
const b = Buffer.concat(bucati);
|
||||
const flux = [];
|
||||
let i = 0;
|
||||
while (i + 5 <= b.length) {
|
||||
const tip = b[i], lung = b.readUInt16BE(i + 3);
|
||||
if (i + 5 + lung > b.length || tip === 23) break;
|
||||
if (tip === 22) flux.push(b.subarray(i + 5, i + 5 + lung));
|
||||
i += 5 + lung;
|
||||
}
|
||||
const h = Buffer.concat(flux);
|
||||
const mesaje = [];
|
||||
let p = 0;
|
||||
while (p + 4 <= h.length) {
|
||||
const lung = h.readUIntBE(p + 1, 3);
|
||||
if (p + 4 + lung > h.length) break;
|
||||
mesaje.push({ tip: h[p], corp: h.subarray(p + 4, p + 4 + lung) });
|
||||
p += 4 + lung;
|
||||
}
|
||||
return mesaje;
|
||||
}
|
||||
function extensii(c, p, sfarsit) {
|
||||
const m = new Map();
|
||||
while (p + 4 <= sfarsit) { const t = c.readUInt16BE(p), l = c.readUInt16BE(p + 2); m.set(t, c.subarray(p + 4, p + 4 + l)); p += 4 + l; }
|
||||
return m;
|
||||
}
|
||||
function clientHello(c) {
|
||||
let p = 2 + 32;
|
||||
p += 1 + c[p];
|
||||
p += 2 + c.readUInt16BE(p);
|
||||
p += 1 + c[p];
|
||||
const lung = c.readUInt16BE(p); p += 2;
|
||||
const ext = extensii(c, p, p + lung);
|
||||
const grupuri = [], cote = [], versiuni = [];
|
||||
const sg = ext.get(0x000a);
|
||||
if (sg) for (let q = 2; q + 2 <= 2 + sg.readUInt16BE(0); q += 2) grupuri.push(sg.readUInt16BE(q));
|
||||
const ks = ext.get(0x0033);
|
||||
if (ks) { let q = 2; const sf = 2 + ks.readUInt16BE(0); while (q + 4 <= sf) { cote.push(ks.readUInt16BE(q)); q += 4 + ks.readUInt16BE(q + 2); } }
|
||||
const sv = ext.get(0x002b);
|
||||
if (sv) for (let q = 1; q + 2 <= 1 + sv[0]; q += 2) versiuni.push(sv.readUInt16BE(q));
|
||||
else versiuni.push(c.readUInt16BE(0));
|
||||
return { grupuri, cote, versiuni };
|
||||
}
|
||||
function serverHello(c) {
|
||||
let p = 2;
|
||||
const aleator = c.subarray(p, p + 32); p += 32;
|
||||
p += 1 + c[p];
|
||||
p += 3;
|
||||
const lung = c.readUInt16BE(p); p += 2;
|
||||
const ext = extensii(c, p, p + lung);
|
||||
const ks = ext.get(0x0033), sv = ext.get(0x002b);
|
||||
return { hrr: aleator.equals(HRR), grup: ks ? ks.readUInt16BE(0) : null, versiune: sv ? sv.readUInt16BE(0) : c.readUInt16BE(0) };
|
||||
}
|
||||
function citesteFir(rec) {
|
||||
return {
|
||||
ch: mesajeHandshake(rec.c2s).filter((m) => m.tip === 1).map((m) => clientHello(m.corp)),
|
||||
sh: mesajeHandshake(rec.s2c).filter((m) => m.tip === 2).map((m) => serverHello(m.corp)),
|
||||
};
|
||||
}
|
||||
const descrieSH = (sh) => sh.map((s) => (s.hrr ? `HRR->${numeGrup(s.grup)}` : `SH ${numeGrup(s.grup)}`)).join(', ') || 'niciun ServerHello';
|
||||
|
||||
let CERT_PEM, CHEIE_PEM, CALE_CERT, CALE_CHEIE, OPENSSL, AMPRENTA;
|
||||
|
||||
function cerere({ port, cale = '/', metoda = 'GET', anteturi = {}, corp = null, curbe = 'X25519MLKEM768', maxVersion, timeoutMs = 8000, ca = CERT_PEM }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let eki = null, protocol = null;
|
||||
const r = https.request({
|
||||
host: GAZDA, port, path: cale, method: metoda, headers: anteturi, ca, servername: 'localhost', agent: false,
|
||||
...(curbe ? { ecdhCurve: curbe } : {}), ...(maxVersion ? { maxVersion } : {}),
|
||||
}, (res) => {
|
||||
const b = [];
|
||||
res.on('data', (x) => b.push(x));
|
||||
res.on('end', () => resolve({ status: res.statusCode, anteturi: res.headers, corp: Buffer.concat(b), eki, protocol }));
|
||||
res.on('error', reject);
|
||||
});
|
||||
r.on('socket', (s) => s.once('secureConnect', () => { eki = s.getEphemeralKeyInfo(); protocol = s.getProtocol(); }));
|
||||
r.setTimeout(timeoutMs, () => r.destroy(new Error(`clientul a asteptat ${timeoutMs} ms fara raspuns`)));
|
||||
r.on('error', reject);
|
||||
r.end(corp || undefined);
|
||||
});
|
||||
}
|
||||
const json = (r) => { try { return JSON.parse(r.corp.toString('utf8')); } catch { throw new Error(`corpul nu e JSON (status ${r.status}): ${r.corp.toString('utf8').slice(0, 120)}`); } };
|
||||
|
||||
// Numai strangerea de mana; intoarce ok sau codul erorii din client.
|
||||
function strangere({ port, curbe, maxVersion, alpn }) {
|
||||
return new Promise((resolve) => {
|
||||
const s = tls.connect({
|
||||
host: GAZDA, port, ca: CERT_PEM, servername: 'localhost',
|
||||
...(curbe ? { ecdhCurve: curbe } : {}), ...(maxVersion ? { maxVersion } : {}), ...(alpn ? { ALPNProtocols: alpn } : {}),
|
||||
}, () => {
|
||||
const r = { ok: true, eki: s.getEphemeralKeyInfo(), protocol: s.getProtocol(), alpn: s.alpnProtocol };
|
||||
s.end();
|
||||
resolve(r);
|
||||
});
|
||||
s.setTimeout(8000, () => s.destroy(new Error('strangerea nu s-a terminat in 8 s')));
|
||||
s.on('error', (e) => resolve({ ok: false, cod: e.code, mesaj: e.message }));
|
||||
});
|
||||
}
|
||||
|
||||
// O cerere de upgrade scrisa de mana; intoarce tot ce a venit pana la inchiderea conexiunii.
|
||||
function upgradeBrut(port, cale, ms = 6000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cheie = crypto.randomBytes(16).toString('base64');
|
||||
const s = tls.connect({ host: GAZDA, port, ca: CERT_PEM, servername: 'localhost', ecdhCurve: 'X25519MLKEM768' }, () => {
|
||||
s.write(`GET ${cale} HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${cheie}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
|
||||
});
|
||||
let acc = Buffer.alloc(0);
|
||||
const t = setTimeout(() => { s.destroy(); reject(new Error(`conexiunea nu s-a inchis in ${ms} ms; primit: '${acc.toString('latin1').split('\r\n')[0] || 'nimic'}'`)); }, ms);
|
||||
s.on('data', (b) => { acc = Buffer.concat([acc, b]); });
|
||||
s.on('error', () => {});
|
||||
s.on('close', () => {
|
||||
clearTimeout(t);
|
||||
const txt = acc.toString('latin1');
|
||||
const k = txt.indexOf('\r\n\r\n');
|
||||
resolve({ antet: k === -1 ? txt : txt.slice(0, k), corp: k === -1 ? '' : txt.slice(k + 4) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function stare(port, ca) { const r = await cerere({ port, cale: CALE_STARE, ...(ca ? { ca } : {}) }); cere(r.status === 200, `starea a raspuns ${r.status}`); return json(r); }
|
||||
async function asteaptaStare(port, pred, ms = 4000, ca) {
|
||||
const t0 = Date.now();
|
||||
let s;
|
||||
while (Date.now() - t0 < ms) { s = await stare(port, ca); if (pred(s)) return s; await dormi(100); }
|
||||
return s;
|
||||
}
|
||||
|
||||
function sClient(argumente, intrare = '', ms = 20000) {
|
||||
return new Promise((resolve) => {
|
||||
const p = spawn(OPENSSL, ['s_client', ...argumente], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let o = '', e = '';
|
||||
const t = setTimeout(() => p.kill(), ms);
|
||||
p.stdout.on('data', (b) => { o += b; });
|
||||
p.stderr.on('data', (b) => { e += b; });
|
||||
p.on('close', (cod) => { clearTimeout(t); resolve({ cod, o, e }); });
|
||||
p.on('error', (err) => { clearTimeout(t); resolve({ cod: -1, o, e: e + String(err.message) }); });
|
||||
p.stdin.on('error', () => {});
|
||||
p.stdin.end(intrare);
|
||||
});
|
||||
}
|
||||
|
||||
function mediuGateway(extra) {
|
||||
const env = { ...process.env };
|
||||
for (const k of Object.keys(env)) if (k.startsWith('AERE_PQGW_')) delete env[k];
|
||||
return { ...env, ...extra };
|
||||
}
|
||||
function pornesteGw(nume, extra) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = spawn(process.execPath, [GATEWAY], { env: mediuGateway(extra), stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
copii.push(p);
|
||||
let err = '';
|
||||
const jurnal = [];
|
||||
p.stderr.on('data', (b) => { err += b; });
|
||||
const t = setTimeout(() => reject(new Error(`${nume}: nu a raportat 'listening' in 10 s; stderr: ${err.slice(0, 300)}`)), 10000);
|
||||
readline.createInterface({ input: p.stdout }).on('line', (l) => {
|
||||
let j = null;
|
||||
try { j = JSON.parse(l); } catch { return; }
|
||||
jurnal.push(j);
|
||||
if (j.event === 'listening') { clearTimeout(t); resolve({ p, port: j.port, nume, jurnal, pornire: j }); }
|
||||
});
|
||||
p.on('exit', (cod) => { clearTimeout(t); reject(new Error(`${nume} a iesit cu ${cod}: ${err.slice(0, 300)}`)); });
|
||||
});
|
||||
}
|
||||
|
||||
// Upstream-ul de proba: ecou pentru orice cerere, 101 + salut + ecou pentru upgrade (sau 403 pe /ws-refuz).
|
||||
const jurnalUpstream = [];
|
||||
const evUp = new EventEmitter();
|
||||
// Raspunde INAINTE sa fi primit tot corpul, apoi curge inca ~2,8 s dupa sfarsitul cererii (ca un flux SSE).
|
||||
function devreme(req, res) {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.write('inceput\n');
|
||||
req.resume();
|
||||
req.on('end', () => {
|
||||
let n = 0;
|
||||
const t = setInterval(() => {
|
||||
if (res.destroyed) { clearInterval(t); return; }
|
||||
n++;
|
||||
if (n <= 6) res.write(`bucata ${n}\n`);
|
||||
else { clearInterval(t); res.end('gata\n'); }
|
||||
}, 400);
|
||||
});
|
||||
}
|
||||
function ecou(req, res) {
|
||||
if (req.url === '/devreme') return devreme(req, res);
|
||||
const h = crypto.createHash('sha256');
|
||||
let n = 0;
|
||||
const id = req.headers['x-proba-id'];
|
||||
req.on('data', (b) => { if (n === 0 && id) evUp.emit(`primul:${id}`); n += b.length; h.update(b); });
|
||||
req.on('end', async () => {
|
||||
const u = new URL(req.url, 'http://upstream.invalid');
|
||||
jurnalUpstream.push({ method: req.method, url: req.url });
|
||||
if (u.pathname === '/slow') await dormi(Number(u.searchParams.get('ms')) || 1000);
|
||||
if (res.destroyed) return;
|
||||
const corp = JSON.stringify({ method: req.method, url: req.url, rawHeaders: req.rawHeaders, headers: req.headers, bodySha256: h.digest('hex'), bodyBytes: n });
|
||||
res.writeHead(200, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(corp) });
|
||||
res.end(corp);
|
||||
});
|
||||
}
|
||||
function laUpgrade(req, sock, head) {
|
||||
jurnalUpstream.push({ method: req.method, url: req.url, upgrade: req.headers.upgrade, headers: req.headers, tls: Boolean(sock.encrypted) });
|
||||
sock.on('error', () => {});
|
||||
if (req.url === '/ws-refuz') { sock.end('HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: 5\r\nConnection: close\r\n\r\nnu-ws'); return; }
|
||||
if (req.url === '/ws-lent') { setTimeout(() => sock.destroy(), 2500); return; }
|
||||
const accept = crypto.createHash('sha1').update(String(req.headers['sec-websocket-key']) + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
||||
sock.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
||||
sock.write('SALUT-DE-LA-UPSTREAM\n');
|
||||
if (head && head.length) sock.write(head);
|
||||
sock.on('data', (d) => sock.write(d));
|
||||
}
|
||||
function pornesteUpstreamHttps() {
|
||||
const srv = https.createServer({ key: CHEIE_PEM, cert: CERT_PEM }, ecou);
|
||||
srv.on('upgrade', laUpgrade);
|
||||
deInchis.push(srv);
|
||||
return new Promise((r) => srv.listen(0, GAZDA, () => r(srv.address().port)));
|
||||
}
|
||||
// Accepta TCP si nu spune nimic: o strangere TLS catre el nu se termina niciodata.
|
||||
const socluriTacute = new Set();
|
||||
function pornesteTacut() {
|
||||
const srv = net.createServer((s) => { socluriTacute.add(s); s.on('error', () => {}); s.on('close', () => socluriTacute.delete(s)); });
|
||||
deInchis.push(srv);
|
||||
return new Promise((r) => srv.listen(0, GAZDA, () => r(srv.address().port)));
|
||||
}
|
||||
function pornesteUpstream() {
|
||||
const srv = http.createServer(ecou);
|
||||
srv.on('upgrade', laUpgrade);
|
||||
deInchis.push(srv);
|
||||
return new Promise((r) => srv.listen(0, GAZDA, () => r(srv.address().port)));
|
||||
}
|
||||
|
||||
async function portLiber() {
|
||||
const s = net.createServer();
|
||||
await new Promise((r) => s.listen(0, GAZDA, r));
|
||||
const p = s.address().port;
|
||||
await new Promise((r) => s.close(r));
|
||||
return p;
|
||||
}
|
||||
|
||||
let GH, GP, GM, GS, GSX, GT, TH, TP, TS, PORT_UP;
|
||||
try {
|
||||
const o = gasesteOpenssl();
|
||||
if (!o) throw new Error('nu am gasit openssl >= 3.5 (AERE_OPENSSL, PATH sau Git for Windows)');
|
||||
OPENSSL = o.bin;
|
||||
TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pqgw-proba-'));
|
||||
CALE_CERT = path.join(TMP, 'cert.pem');
|
||||
CALE_CHEIE = path.join(TMP, 'cheie.pem');
|
||||
// execFileSync cu cai native (Node pe Windows da cai Windows), deci nicio conversie MSYS pe drum.
|
||||
execFileSync(OPENSSL, ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-keyout', CALE_CHEIE, '-out', CALE_CERT,
|
||||
'-days', '2', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
CERT_PEM = fs.readFileSync(CALE_CERT);
|
||||
CHEIE_PEM = fs.readFileSync(CALE_CHEIE, 'utf8');
|
||||
AMPRENTA = new crypto.X509Certificate(CERT_PEM).fingerprint256;
|
||||
PORT_UP = await pornesteUpstream();
|
||||
const portUpHttps = await pornesteUpstreamHttps();
|
||||
const portTacut = await pornesteTacut();
|
||||
TS = await robinet(portUpHttps);
|
||||
const baza = { AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: CALE_CERT, AERE_PQGW_KEY: CALE_CHEIE, AERE_PQGW_MODE: 'hybrid-only' };
|
||||
[GH, GP, GM, GS, GSX, GT] = await Promise.all([
|
||||
pornesteGw('gateway hybrid-only', { ...baza, AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUEST_TIMEOUT_MS: '1500' }),
|
||||
pornesteGw('gateway hybrid-preferred', { ...baza, AERE_PQGW_MODE: 'hybrid-preferred', AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}` }),
|
||||
pornesteGw('gateway catre upstream oprit', { ...baza, AERE_PQGW_UPSTREAM: `http://${GAZDA}:${await portLiber()}` }),
|
||||
pornesteGw('gateway catre upstream https (cu CA)', { ...baza, AERE_PQGW_UPSTREAM: `https://${GAZDA}:${TS.port}`, AERE_PQGW_UPSTREAM_CA: CALE_CERT }),
|
||||
pornesteGw('gateway catre upstream https (fara CA)', { ...baza, AERE_PQGW_UPSTREAM: `https://${GAZDA}:${portUpHttps}` }),
|
||||
pornesteGw('gateway catre upstream tacut', { ...baza, AERE_PQGW_UPSTREAM: `https://${GAZDA}:${portTacut}`, AERE_PQGW_UPSTREAM_CA: CALE_CERT, AERE_PQGW_CONNECT_TIMEOUT_MS: '1000' }),
|
||||
]);
|
||||
[TH, TP] = await Promise.all([robinet(GH.port), robinet(GP.port)]);
|
||||
console.log(`pregatire: ${o.versiune}; Node ${process.version} + OpenSSL ${process.versions.openssl}; certificat ${AMPRENTA.slice(0, 23)}...`);
|
||||
} catch (e) {
|
||||
console.log(`STRICAT: pregatirea a cazut, nicio proba nu a rulat: ${e.message}`);
|
||||
process.exitCode = 3;
|
||||
curata();
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- pozitive
|
||||
await test('(a) hybrid-only + client Node X25519MLKEM768: cererea trece, grupul de pe fir e X25519MLKEM768', async () => {
|
||||
const inainte = TH.conexiuni.length;
|
||||
const r = await cerere({ port: TH.port, cale: '/a?x=1', curbe: 'X25519MLKEM768' });
|
||||
cere(r.status === 200, `status ${r.status}`);
|
||||
cere(json(r).url === '/a?x=1', `upstream-ul a vazut ${json(r).url}`);
|
||||
cere(r.protocol === 'TLSv1.3', `protocol ${r.protocol}`);
|
||||
const rec = TH.conexiuni[inainte];
|
||||
cere(rec, 'robinetul nu a vazut conexiunea');
|
||||
const f = citesteFir(rec);
|
||||
cere(f.ch.length >= 1 && f.ch[0].cote.includes(HIBRID), `ClientHello nu poarta o cota X25519MLKEM768 (cote: ${f.ch[0] ? f.ch[0].cote.map(numeGrup) : '-'})`);
|
||||
const final = f.sh.filter((s) => !s.hrr).pop();
|
||||
cere(final && final.grup === HIBRID, `ServerHello de pe fir: ${descrieSH(f.sh)}`);
|
||||
const ekiText = JSON.stringify(r.eki);
|
||||
cere(!r.eki || !r.eki.name || /MLKEM/i.test(r.eki.name), `getEphemeralKeyInfo pretinde un grup clasic: ${ekiText}`);
|
||||
return `fir: ${descrieSH(f.sh)}; getEphemeralKeyInfo() = ${ekiText}${r.eki && r.eki.name ? '' : ' (Node nu numeste grupul hibrid)'}`;
|
||||
});
|
||||
|
||||
await test('(b) openssl s_client -groups X25519MLKEM768 (client independent): strangere reusita, grupul negociat X25519MLKEM768', async () => {
|
||||
const r = await sClient(['-connect', `${GAZDA}:${GH.port}`, '-groups', 'X25519MLKEM768', '-servername', 'localhost', '-CAfile', CALE_CERT,
|
||||
'-verify_return_error', '-ign_eof'], 'GET /b-openssl HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
|
||||
const rand = (r.o.split(/\r?\n/).find((l) => /^Negotiated TLS1\.3 group:/.test(l)) || '').trim();
|
||||
cere(r.cod === 0, `s_client a iesit cu ${r.cod}: ${r.e.slice(0, 200)}`);
|
||||
cere(rand.includes('X25519MLKEM768'), `randul grupului: '${rand || 'lipseste'}'`);
|
||||
cere(/Verify return code: 0 \(ok\)/.test(r.o), 'certificatul nu a fost verificat de s_client');
|
||||
cere(/HTTP\/1\.1 200/.test(r.o) && r.o.includes('"url":"/b-openssl"'), 'raspunsul HTTP prin s_client lipseste sau nu vine de la upstream');
|
||||
return rand;
|
||||
});
|
||||
|
||||
await test('(c) POST de 1 MiB ajunge intreg la upstream (sha256 egal)', async () => {
|
||||
const corp = crypto.randomBytes(1024 * 1024);
|
||||
const r = await cerere({ port: GH.port, cale: '/c', metoda: 'POST', corp, anteturi: { 'content-type': 'application/octet-stream', 'content-length': corp.length } });
|
||||
cere(r.status === 200, `status ${r.status}`);
|
||||
const j = json(r);
|
||||
const asteptat = crypto.createHash('sha256').update(corp).digest('hex');
|
||||
cere(j.bodyBytes === corp.length, `upstream-ul a primit ${j.bodyBytes} octeti din ${corp.length}`);
|
||||
cere(j.bodySha256 === asteptat, 'sha256 diferit la upstream');
|
||||
return `${j.bodyBytes} octeti, sha256 ${asteptat.slice(0, 16)}...`;
|
||||
});
|
||||
|
||||
await test('(k) corpul curge catre upstream fara bufferare intreaga (chunked, cererea ramane deschisa)', async () => {
|
||||
const id = `curge-${crypto.randomUUID()}`;
|
||||
const primul = new Promise((r) => evUp.once(`primul:${id}`, () => r(true)));
|
||||
const p1 = crypto.randomBytes(64 * 1024), p2 = crypto.randomBytes(64 * 1024);
|
||||
let aVazut = null;
|
||||
const r = await new Promise((resolve, reject) => {
|
||||
const q = https.request({ host: GAZDA, port: GH.port, path: '/curge', method: 'POST', headers: { 'x-proba-id': id, 'content-type': 'application/octet-stream' },
|
||||
ca: CERT_PEM, servername: 'localhost', ecdhCurve: 'X25519MLKEM768', agent: false }, (res) => {
|
||||
const b = [];
|
||||
res.on('data', (x) => b.push(x));
|
||||
res.on('end', () => resolve({ status: res.statusCode, corp: Buffer.concat(b) }));
|
||||
});
|
||||
q.setTimeout(15000, () => q.destroy(new Error('fara raspuns in 15 s')));
|
||||
q.on('error', reject);
|
||||
q.write(p1);
|
||||
Promise.race([primul, dormi(5000).then(() => false)]).then((v) => { aVazut = v; q.end(p2); });
|
||||
});
|
||||
cere(aVazut === true, 'upstream-ul nu a primit NIMIC din corp cat timp clientul tinea cererea deschisa (5 s): corpul e bufferat');
|
||||
cere(r.status === 200, `status ${r.status}`);
|
||||
const j = json(r);
|
||||
cere(j.bodySha256 === crypto.createHash('sha256').update(Buffer.concat([p1, p2])).digest('hex') && j.bodyBytes === p1.length + p2.length, `corp diferit la upstream (${j.bodyBytes} octeti)`);
|
||||
cere(!j.headers['transfer-encoding'] || j.headers['transfer-encoding'] === 'chunked', `transfer-encoding la upstream: ${j.headers['transfer-encoding']}`);
|
||||
return `primii octeti la upstream inainte de sfarsitul cererii; ${j.bodyBytes} octeti intregi`;
|
||||
});
|
||||
|
||||
// Un tunel WebSocket prin gateway-ul de pe `port`: 101, salutul upstream-ului, apoi ecoul unei incarcaturi aleatoare.
|
||||
async function verificaTunel(port, cale) {
|
||||
const cheie = crypto.randomBytes(16).toString('base64');
|
||||
const asteptatAccept = crypto.createHash('sha1').update(cheie + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
||||
const incarcatura = crypto.randomBytes(32 * 1024);
|
||||
const salut = 'SALUT-DE-LA-UPSTREAM\n';
|
||||
const rez = await new Promise((resolve, reject) => {
|
||||
const s = tls.connect({ host: GAZDA, port, ca: CERT_PEM, servername: 'localhost', ecdhCurve: 'X25519MLKEM768' }, () => {
|
||||
s.write(`GET ${cale} HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${cheie}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
|
||||
});
|
||||
let acc = Buffer.alloc(0), antet = null, trimis = false;
|
||||
const t = setTimeout(() => { s.destroy(); reject(new Error(`tunelul nu a intors tot in 8 s (antet: ${antet ? antet.split('\r\n')[0] : 'niciunul'}, octeti dupa antet: ${antet ? acc.length : 0})`)); }, 8000);
|
||||
s.on('data', (b) => {
|
||||
acc = Buffer.concat([acc, b]);
|
||||
if (antet === null) {
|
||||
const k = acc.indexOf('\r\n\r\n');
|
||||
if (k === -1) return;
|
||||
antet = acc.subarray(0, k).toString('latin1');
|
||||
acc = acc.subarray(k + 4);
|
||||
}
|
||||
if (!trimis && acc.length >= salut.length) { trimis = true; s.write(incarcatura); }
|
||||
if (acc.length >= salut.length + incarcatura.length) { clearTimeout(t); s.end(); resolve({ antet, acc }); }
|
||||
});
|
||||
s.on('error', (e) => { clearTimeout(t); reject(e); });
|
||||
});
|
||||
cere(/^HTTP\/1\.1 101/.test(rez.antet), `raspuns: ${rez.antet.split('\r\n')[0]}`);
|
||||
cere(rez.antet.toLowerCase().includes(`sec-websocket-accept: ${asteptatAccept.toLowerCase()}`), 'Sec-WebSocket-Accept lipsa sau gresit');
|
||||
cere(rez.acc.subarray(0, salut.length).toString() === salut, 'salutul upstream -> client nu a trecut');
|
||||
cere(rez.acc.subarray(salut.length, salut.length + incarcatura.length).equals(incarcatura), 'ecoul client -> upstream -> client difera');
|
||||
const u = jurnalUpstream.filter((x) => x.upgrade && x.url === cale).pop();
|
||||
cere(u && u.upgrade === 'websocket' && u.headers['x-aere-pq-gateway'] === 'hybrid-only' && u.headers['x-forwarded-proto'] === 'https', 'upstream-ul nu a vazut cererea de upgrade cu antetele gateway-ului');
|
||||
return { octeti: incarcatura.length, u };
|
||||
}
|
||||
|
||||
await test('(k2) raspuns inceput inainte de sfarsitul cererii si care curge mai mult decat REQUEST_TIMEOUT: nu e taiat', async () => {
|
||||
const t0 = Date.now();
|
||||
let msLaSfarsitCerere = null;
|
||||
const r = await new Promise((resolve, reject) => {
|
||||
const q = https.request({ host: GAZDA, port: GH.port, path: '/devreme', method: 'POST', headers: { 'content-type': 'application/octet-stream' },
|
||||
ca: CERT_PEM, servername: 'localhost', ecdhCurve: 'X25519MLKEM768', agent: false }, (res) => {
|
||||
let corp = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (x) => { corp += x; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, corp, complet: res.complete }));
|
||||
res.on('close', () => { if (!res.complete) resolve({ status: res.statusCode, corp, complet: false }); });
|
||||
res.on('error', () => {});
|
||||
// cererea se incheie abia DUPA ce raspunsul a inceput
|
||||
msLaSfarsitCerere = Date.now() - t0;
|
||||
q.end('sfarsitul-cererii');
|
||||
});
|
||||
q.setTimeout(10000, () => q.destroy(new Error('fara raspuns complet in 10 s')));
|
||||
q.on('error', reject);
|
||||
q.write('inceputul-cererii');
|
||||
});
|
||||
const ms = Date.now() - t0;
|
||||
cere(r.status === 200, `status ${r.status}`);
|
||||
cere(r.complet && r.corp.endsWith('gata\n') && (r.corp.match(/bucata/g) || []).length === 6, `raspuns taiat la ${ms} ms (${ms - msLaSfarsitCerere} ms dupa sfarsitul cererii): ${JSON.stringify(r.corp.slice(-30))}`);
|
||||
cere(ms - msLaSfarsitCerere > 2000, `fixtura: raspunsul a curs doar ${ms - msLaSfarsitCerere} ms dupa sfarsitul cererii, sub REQUEST_TIMEOUT+marja, deci nu masoara nimic`);
|
||||
return `raspuns complet, ${ms - msLaSfarsitCerere} ms dupa sfarsitul cererii (REQUEST_TIMEOUT 1500)`;
|
||||
});
|
||||
|
||||
await test('(d) upgrade WebSocket: 101, salutul upstream-ului si ecoul trec prin tunel in ambele sensuri', async () => {
|
||||
const r = await verificaTunel(GH.port, '/ws');
|
||||
cere(r.u.tls === false, 'fixtura: upstream-ul acestui gateway trebuia sa fie http');
|
||||
return `${r.octeti} octeti ecou identici, salut primit`;
|
||||
});
|
||||
|
||||
await test('(d3) upgrade WebSocket catre upstream https (UPSTREAM_CA): tunelul merge, iar drumul catre upstream prefera hibridul', async () => {
|
||||
const inainte = TS.conexiuni.length;
|
||||
const r = await verificaTunel(GS.port, '/ws-tls');
|
||||
cere(r.u.tls === true, 'upstream-ul nu a primit upgrade-ul peste TLS');
|
||||
const rec = TS.conexiuni.slice(inainte).find((x) => citesteFir(x).sh.length > 0);
|
||||
cere(rec, 'robinetul din fata upstream-ului https nu a vazut strangerea de mana a tunelului');
|
||||
const f = citesteFir(rec);
|
||||
const final = f.sh.filter((s) => !s.hrr).pop();
|
||||
cere(final && final.grup === HIBRID, `drumul tunelului catre upstream: ${descrieSH(f.sh)}`);
|
||||
return `${r.octeti} octeti ecou identici peste TLS; drum catre upstream: ${descrieSH(f.sh)}`;
|
||||
});
|
||||
|
||||
await test('(d2) upstream-ul refuza upgrade-ul: raspunsul lui (403) ajunge la client cu Connection: close, apoi conexiunea se inchide', async () => {
|
||||
const r = await upgradeBrut(GH.port, '/ws-refuz');
|
||||
cere(/^HTTP\/1\.1 403/.test(r.antet), `raspuns: '${r.antet.split('\r\n')[0]}'`);
|
||||
cere(/\r\nconnection: close(\r\n|$)/i.test(r.antet), `fara Connection: close in raspuns (${r.antet.replace(/\r\n/g, ' | ').slice(0, 200)})`);
|
||||
cere(r.corp === 'nu-ws', `corp '${r.corp.slice(0, 40)}'`);
|
||||
return '403 + corpul upstream-ului, conexiune inchisa';
|
||||
});
|
||||
|
||||
await test('(e) antetele: X-Forwarded-* puse de gateway, x-aere-pq-gateway prezent, hop-by-hop si numele din Connection NU ajung', async () => {
|
||||
const r = await cerere({ port: GH.port, cale: '/e', anteturi: {
|
||||
Connection: 'keep-alive, X-Secret-Hop', 'X-Secret-Hop': 'nu-trebuie-sa-ajunga', 'Keep-Alive': 'timeout=5', 'Proxy-Authorization': 'Basic bnU=',
|
||||
TE: 'trailers', 'X-Forwarded-For': '203.0.113.9', 'x-aere-pq-gateway': 'falsificat', 'X-End-To-End': 'da', Host: 'aplicatia.example',
|
||||
} });
|
||||
cere(r.status === 200, `status ${r.status}`);
|
||||
const h = json(r).headers;
|
||||
// controlul pozitiv al fixturii: un antet obisnuit TREBUIE sa treaca, altfel lipsa celorlalte nu inseamna nimic
|
||||
cere(h['x-end-to-end'] === 'da', 'nici antetul obisnuit nu a ajuns: fixtura nu masoara nimic');
|
||||
cere(h['x-forwarded-proto'] === 'https', `X-Forwarded-Proto: ${h['x-forwarded-proto']}`);
|
||||
cere(h['x-aere-pq-gateway'] === 'hybrid-only', `x-aere-pq-gateway: ${h['x-aere-pq-gateway']}`);
|
||||
cere(h['x-forwarded-host'] === 'aplicatia.example', `X-Forwarded-Host: ${h['x-forwarded-host']}`);
|
||||
cere(h['x-forwarded-for'] === '127.0.0.1', `X-Forwarded-For: ${h['x-forwarded-for']} (valoarea clientului nu are voie sa treaca)`);
|
||||
const scapate = ['x-secret-hop', 'keep-alive', 'proxy-authorization', 'te'].filter((n) => n in h);
|
||||
cere(scapate.length === 0, `au ajuns la upstream: ${scapate.join(', ')}`);
|
||||
cere(!String(h.connection || '').toLowerCase().includes('x-secret-hop'), `Connection la upstream: ${h.connection}`);
|
||||
return 'x-secret-hop, keep-alive, proxy-authorization, te oprite; x-end-to-end trecut';
|
||||
});
|
||||
|
||||
await test('(e2) X-Forwarded-For pe un ascultator dual-stack: ::ffff:a.b.c.d devine a.b.c.d, restul ramane neatins (functia, fara socket)', async () => {
|
||||
// Masurat pe functie, nu pe un socket: un ascultator dual-stack ar insemna sa ascultam pe toate interfetele.
|
||||
const mod = await import(pathToFileURL(GATEWAY).href);
|
||||
const cazuri = [['::ffff:127.0.0.1', '127.0.0.1'], ['::FFFF:198.51.100.7', '198.51.100.7'], ['::1', '::1'], ['2001:db8::5', '2001:db8::5'], ['127.0.0.1', '127.0.0.1'], ['::ffff:nu-e-ip', '::ffff:nu-e-ip'], [undefined, '']];
|
||||
const rele = cazuri.filter(([i, o]) => mod.adresaClient(i) !== o).map(([i, o]) => `${i} -> ${mod.adresaClient(i)} (asteptat ${o})`);
|
||||
cere(rele.length === 0, rele.join('; '));
|
||||
return `${cazuri.length} cazuri`;
|
||||
});
|
||||
|
||||
await test('(f) punctul de stare: mode, contoare, amprenta certificatului, FARA cheia privata, servit de gateway (nu de upstream)', async () => {
|
||||
const inainte = jurnalUpstream.length;
|
||||
const r = await cerere({ port: GH.port, cale: CALE_STARE });
|
||||
cere(r.status === 200, `status ${r.status}`);
|
||||
cere(String(r.anteturi['content-type']).startsWith('application/json'), `content-type ${r.anteturi['content-type']}`);
|
||||
const text = r.corp.toString('utf8');
|
||||
const s = JSON.parse(text);
|
||||
cere(s.mode === 'hybrid-only', `mode ${s.mode}`);
|
||||
cere(JSON.stringify(s.groupsOffered) === '["X25519MLKEM768"]', `groupsOffered ${JSON.stringify(s.groupsOffered)}`);
|
||||
cere(s.minTlsVersion === 'TLSv1.3', `minTlsVersion ${s.minTlsVersion}`);
|
||||
cere(s.guarantee && s.guarantee.hybridKeyExchangeOnEveryConnection === true, 'hybrid-only nu isi declara garantia structurala');
|
||||
cere(s.certificate && s.certificate.sha256 === AMPRENTA, `amprenta ${s.certificate && s.certificate.sha256} != ${AMPRENTA}`);
|
||||
const k = s.counters || {};
|
||||
for (const n of ['connectionsAccepted', 'requestsForwarded', 'responses502', 'responses504']) cere(Number.isInteger(k[n]), `contorul ${n} lipseste`);
|
||||
cere(k.handshakesRefused && ['no shared group', 'unsupported protocol', 'other'].every((m) => Number.isInteger(k.handshakesRefused[m])), 'contoarele de refuz pe motiv lipsesc');
|
||||
cere(k.connectionsAccepted >= 1 && k.requestsForwarded >= 1, 'contoarele nu numara nimic dupa probele de dinainte');
|
||||
const baza64 = CHEIE_PEM.split(/\r?\n/).filter((l) => l && !l.startsWith('-----')).join('');
|
||||
const fragmente = [baza64.slice(8, 48), baza64.slice(-40, -4), 'PRIVATE KEY', CALE_CHEIE, path.basename(CALE_CHEIE)];
|
||||
const gasite = fragmente.filter((x) => x && text.includes(x));
|
||||
cere(gasite.length === 0, `starea contine material sau cale a cheii (${gasite.length} fragmente)`);
|
||||
cere(jurnalUpstream.slice(inainte).every((x) => !x.url.startsWith(CALE_STARE)), 'cererea de stare a fost trimisa la upstream');
|
||||
return `acceptate ${k.connectionsAccepted}, trimise ${k.requestsForwarded}; ${fragmente.length} fragmente ale cheii cautate, 0 gasite`;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- controale negative, fiecare cu MOTIVUL
|
||||
await test('(g) hybrid-only + client numai X25519: refuzat la strangerea de mana, motivul numarat e "no shared group"', async () => {
|
||||
const s0 = await stare(GH.port);
|
||||
const inainte = TH.conexiuni.length;
|
||||
const r = await strangere({ port: TH.port, curbe: 'X25519' });
|
||||
const f = citesteFir(TH.conexiuni[inainte] || { c2s: [], s2c: [] });
|
||||
// fixtura trebuie sa poata EXPRIMA atacul: clientul chiar nu a oferit hibridul
|
||||
cere(f.ch.length === 1 && f.ch[0].grupuri.length === 1 && f.ch[0].grupuri[0] === X25519, `clientul de proba a oferit ${f.ch[0] ? f.ch[0].grupuri.map(numeGrup) : '-'}, nu numai X25519`);
|
||||
cere(!r.ok, `strangerea de mana a REUSIT fara hibrid (${descrieSH(f.sh)}, getEphemeralKeyInfo ${JSON.stringify(r.eki)})`);
|
||||
cere(f.sh.length === 0, `serverul a trimis ${descrieSH(f.sh)}`);
|
||||
cere(r.cod === 'ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE', `clientul a primit alt refuz: ${r.cod}`);
|
||||
const s1 = await asteaptaStare(GH.port, (s) => s.counters.handshakesRefused['no shared group'] > s0.counters.handshakesRefused['no shared group']);
|
||||
const d = (m) => s1.counters.handshakesRefused[m] - s0.counters.handshakesRefused[m];
|
||||
cere(d('no shared group') === 1, `contorul 'no shared group' a crescut cu ${d('no shared group')} (unsupported protocol +${d('unsupported protocol')}, other +${d('other')})`);
|
||||
cere(d('unsupported protocol') === 0 && d('other') === 0, `au crescut si alte motive: unsupported protocol +${d('unsupported protocol')}, other +${d('other')}`);
|
||||
return `client: ${r.cod}; server: 'no shared group' +1 (${JSON.stringify(s1.counters.handshakesRefusedByCode)})`;
|
||||
});
|
||||
|
||||
await test('(g2) hybrid-only + openssl s_client -groups X25519 (client independent): refuzat cu alerta handshake failure', async () => {
|
||||
const s0 = await stare(GH.port);
|
||||
const r = await sClient(['-connect', `${GAZDA}:${GH.port}`, '-groups', 'X25519', '-servername', 'localhost', '-CAfile', CALE_CERT], '');
|
||||
const tot = r.o + r.e;
|
||||
// Masurat 2026-09-25: la refuz s_client tipareste totusi randul grupului, cu '<NULL>', si 'Cipher is (NONE)'.
|
||||
const rand = (r.o.split(/\r?\n/).find((l) => /^Negotiated TLS1\.3 group:/.test(l)) || '').trim();
|
||||
cere(r.cod !== 0, 's_client a iesit cu 0: strangerea de mana a reusit fara hibrid');
|
||||
cere(/alert handshake failure/i.test(tot) && /SSL alert number 40/.test(tot), `s_client nu a raportat alerta handshake failure (40): ${tot.replace(/\s+/g, ' ').slice(0, 200)}`);
|
||||
cere(rand === '' || /<NULL>$/.test(rand), `s_client raporteaza un grup negociat: '${rand}'`);
|
||||
cere(/Cipher is \(NONE\)/.test(r.o), 's_client raporteaza o suita negociata');
|
||||
const s1 = await asteaptaStare(GH.port, (s) => s.counters.handshakesRefused['no shared group'] > s0.counters.handshakesRefused['no shared group']);
|
||||
cere(s1.counters.handshakesRefused['no shared group'] === s0.counters.handshakesRefused['no shared group'] + 1, "contorul 'no shared group' nu a crescut cu 1");
|
||||
return (tot.match(/[^\n]*alert handshake failure[^\n]*/i) || [''])[0].trim().slice(0, 120);
|
||||
});
|
||||
|
||||
await test('(h) client TLS 1.2 (maxVersion TLSv1.2): refuzat in AMBELE moduri, motivul numarat e "unsupported protocol"', async () => {
|
||||
// Amandoua gateway-urile se masoara inainte de verdict, ca un esec pe primul sa nu ascunda ce spune al doilea.
|
||||
const note = [], probleme = [];
|
||||
for (const [g, t] of [[GH, TH], [GP, TP]]) {
|
||||
try {
|
||||
const s0 = await stare(g.port);
|
||||
const inainte = t.conexiuni.length;
|
||||
const r = await strangere({ port: t.port, maxVersion: 'TLSv1.2' });
|
||||
const f = citesteFir(t.conexiuni[inainte] || { c2s: [], s2c: [] });
|
||||
cere(f.ch.length === 1 && !f.ch[0].versiuni.includes(0x0304), `clientul de proba a oferit TLS 1.3 (${f.ch[0] ? f.ch[0].versiuni.map((v) => v.toString(16)) : '-'})`);
|
||||
cere(!r.ok, `un client TLS 1.2 a fost ACCEPTAT (${r.protocol})`);
|
||||
const s1 = await asteaptaStare(g.port, (s) => s.counters.handshakesRefused['unsupported protocol'] > s0.counters.handshakesRefused['unsupported protocol'], 2000);
|
||||
const d = (m) => s1.counters.handshakesRefused[m] - s0.counters.handshakesRefused[m];
|
||||
cere(r.cod === 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', `clientul a primit alt refuz: ${r.cod} (server: 'no shared cipher' +${d('no shared cipher')}, other +${d('other')})`);
|
||||
cere(d('unsupported protocol') === 1, `'unsupported protocol' +${d('unsupported protocol')}, 'no shared cipher' +${d('no shared cipher')}, other +${d('other')}`);
|
||||
note.push(`${s1.mode}: ${r.cod}, 'unsupported protocol' +1`);
|
||||
} catch (e) { probleme.push(`${g.nume}: ${e.message}`); }
|
||||
}
|
||||
cere(probleme.length === 0, probleme.join(' | '));
|
||||
return note.join('; ');
|
||||
});
|
||||
|
||||
await test('(i) upstream oprit: 502 cu corp JSON, in cateva secunde, numarat', async () => {
|
||||
const s0 = await stare(GM.port);
|
||||
const t0 = Date.now();
|
||||
const r = await cerere({ port: GM.port, cale: '/i', timeoutMs: 9000 });
|
||||
const ms = Date.now() - t0;
|
||||
cere(r.status === 502, `status ${r.status}`);
|
||||
const j = json(r);
|
||||
cere(j.error === 'bad_gateway', `corp ${JSON.stringify(j)}`);
|
||||
cere(ms < 7000, `a durat ${ms} ms`);
|
||||
const s1 = await stare(GM.port);
|
||||
cere(s1.counters.responses502 === s0.counters.responses502 + 1, `responses502 ${s0.counters.responses502} -> ${s1.counters.responses502}`);
|
||||
return `502 in ${ms} ms: ${JSON.stringify(j)}`;
|
||||
});
|
||||
|
||||
await test('(i2) upstream care nu raspunde la timp: 504 cu corp JSON dupa REQUEST_TIMEOUT, numarat', async () => {
|
||||
const s0 = await stare(GH.port);
|
||||
const t0 = Date.now();
|
||||
const r = await cerere({ port: GH.port, cale: '/slow?ms=6000', timeoutMs: 9000 });
|
||||
const ms = Date.now() - t0;
|
||||
cere(r.status === 504, `status ${r.status} dupa ${ms} ms`);
|
||||
const j = json(r);
|
||||
cere(j.error === 'gateway_timeout', `corp ${JSON.stringify(j)}`);
|
||||
cere(ms >= 1400 && ms < 4500, `a durat ${ms} ms (timeout configurat 1500)`);
|
||||
const s1 = await stare(GH.port);
|
||||
cere(s1.counters.responses504 === s0.counters.responses504 + 1, `responses504 ${s0.counters.responses504} -> ${s1.counters.responses504}`);
|
||||
return `504 in ${ms} ms`;
|
||||
});
|
||||
|
||||
await test('(i3) upgrade catre upstream oprit: 502 cu corp JSON pe socket, conexiune inchisa, numarat', async () => {
|
||||
const s0 = await stare(GM.port);
|
||||
const r = await upgradeBrut(GM.port, '/ws');
|
||||
cere(/^HTTP\/1\.1 502/.test(r.antet), `raspuns: '${r.antet.split('\r\n')[0]}'`);
|
||||
let j;
|
||||
try { j = JSON.parse(r.corp); } catch { throw new Error(`corpul nu e JSON: '${r.corp.slice(0, 80)}'`); }
|
||||
cere(j.error === 'bad_gateway', `corp ${r.corp.trim()}`);
|
||||
const s1 = await stare(GM.port);
|
||||
cere(s1.counters.responses502 === s0.counters.responses502 + 1, `responses502 ${s0.counters.responses502} -> ${s1.counters.responses502}`);
|
||||
return r.corp.trim();
|
||||
});
|
||||
|
||||
await test('(s) clientul pleaca inainte de raspuns (cerere si upgrade): nu se numara ca 502/504 si nu apare ca eroare de upstream', async () => {
|
||||
const s0 = await stare(GP.port);
|
||||
const jurnal0 = GP.jurnal.length;
|
||||
const plecat = await new Promise((resolve) => {
|
||||
const q = https.request({ host: GAZDA, port: GP.port, path: '/slow?ms=2500', ca: CERT_PEM, servername: 'localhost', agent: false });
|
||||
q.on('error', () => {});
|
||||
q.end();
|
||||
setTimeout(() => { q.destroy(); resolve(true); }, 400);
|
||||
});
|
||||
const sus = await new Promise((resolve) => {
|
||||
const s = tls.connect({ host: GAZDA, port: GP.port, ca: CERT_PEM, servername: 'localhost' }, () => {
|
||||
s.write('GET /ws-lent HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGVzdGVzdGVzdGVzdGVzdA==\r\nSec-WebSocket-Version: 13\r\n\r\n');
|
||||
setTimeout(() => { s.destroy(); resolve(true); }, 400);
|
||||
});
|
||||
s.on('error', () => {});
|
||||
});
|
||||
cere(plecat && sus, 'fixtura nu a putut pleca');
|
||||
await dormi(700);
|
||||
const s1 = await stare(GP.port);
|
||||
const d502 = s1.counters.responses502 - s0.counters.responses502, d504 = s1.counters.responses504 - s0.counters.responses504;
|
||||
const erori = GP.jurnal.slice(jurnal0).filter((j) => j.event === 'upstream_error');
|
||||
cere(s1.counters.requestsForwarded >= s0.counters.requestsForwarded + 2, `fixtura: cererile nu au ajuns la upstream (trimise +${s1.counters.requestsForwarded - s0.counters.requestsForwarded})`);
|
||||
cere(d502 === 0 && d504 === 0, `plecarea clientului numarata ca eroare de upstream: responses502 +${d502}, responses504 +${d504}`);
|
||||
cere(erori.length === 0, `jurnalul gateway-ului scrie upstream_error pentru o plecare a clientului: ${JSON.stringify(erori.map((j) => j.code))}`);
|
||||
return 'responses502 +0, responses504 +0, niciun upstream_error';
|
||||
});
|
||||
|
||||
await test('(q) upstream https cu AERE_PQGW_UPSTREAM_CA: cererea trece, iar drumul gateway -> upstream prefera hibridul (citit de pe fir)', async () => {
|
||||
const inainte = TS.conexiuni.length;
|
||||
const r = await cerere({ port: GS.port, cale: '/q' });
|
||||
cere(r.status === 200 && json(r).url === '/q', `status ${r.status}`);
|
||||
cere(json(r).headers['x-aere-pq-gateway'] === 'hybrid-only', 'upstream-ul https nu a vazut antetul gateway-ului');
|
||||
const rec = TS.conexiuni[inainte];
|
||||
cere(rec, 'robinetul din fata upstream-ului https nu a vazut conexiunea');
|
||||
const f = citesteFir(rec);
|
||||
const final = f.sh.filter((s) => !s.hrr).pop();
|
||||
cere(final && final.grup === HIBRID, `drumul catre upstream: ${descrieSH(f.sh)}`);
|
||||
const s = await stare(GS.port);
|
||||
cere(s.upstream && s.upstream.scheme === 'https' && s.upstream.encrypted === true, `starea: ${JSON.stringify(s.upstream)}`);
|
||||
cere(JSON.stringify(Object.keys(s.upstream)) === '["scheme","encrypted"]', `starea spune despre upstream mai mult decat schema: ${JSON.stringify(s.upstream)}`);
|
||||
return `drum catre upstream: ${descrieSH(f.sh)}`;
|
||||
});
|
||||
|
||||
await test('(q2) upstream https cu certificat pe care gateway-ul nu il poate verifica (fara UPSTREAM_CA): 502, nicio incredere oarba', async () => {
|
||||
const inainte = jurnalUpstream.length;
|
||||
const r = await cerere({ port: GSX.port, cale: '/q2' });
|
||||
cere(r.status === 502, `status ${r.status}: gateway-ul a trimis cererea unui upstream neverificat`);
|
||||
cere(json(r).error === 'bad_gateway', `corp ${r.corp.toString().trim()}`);
|
||||
cere(jurnalUpstream.slice(inainte).every((x) => x.url !== '/q2'), 'cererea a ajuns la upstream');
|
||||
const t0 = Date.now();
|
||||
let rand;
|
||||
while (!(rand = GSX.jurnal.filter((j) => j.event === 'upstream_error').pop()) && Date.now() - t0 < 2000) await dormi(50);
|
||||
cere(rand && /CERT|SELF_SIGNED|VERIFY/i.test(String(rand.code)), `motivul din jurnalul gateway-ului: ${rand ? rand.code : 'lipseste'}`);
|
||||
return `502, motiv in jurnal: ${rand.code}`;
|
||||
});
|
||||
|
||||
await test('(r) upstream care accepta TCP dar nu termina strangerea TLS: 504 "upstream connect timeout" dupa CONNECT_TIMEOUT, numarat', async () => {
|
||||
const s0 = await stare(GT.port);
|
||||
const t0 = Date.now();
|
||||
const r = await cerere({ port: GT.port, cale: '/r', timeoutMs: 9000 });
|
||||
const ms = Date.now() - t0;
|
||||
cere(r.status === 504, `status ${r.status} dupa ${ms} ms`);
|
||||
const j = json(r);
|
||||
cere(j.detail === 'upstream connect timeout', `corp ${JSON.stringify(j)}`);
|
||||
cere(ms >= 900 && ms < 3500, `a durat ${ms} ms (timeout de conectare configurat 1000)`);
|
||||
const s1 = await stare(GT.port);
|
||||
cere(s1.counters.responses504 === s0.counters.responses504 + 1, `responses504 ${s0.counters.responses504} -> ${s1.counters.responses504}`);
|
||||
return `504 in ${ms} ms: ${JSON.stringify(j)}`;
|
||||
});
|
||||
|
||||
await test('(j) hybrid-preferred + client numai X25519: ACCEPTAT pe X25519, iar starea NU pretinde hibrid', async () => {
|
||||
const s0 = await stare(GP.port);
|
||||
const inainte = TP.conexiuni.length;
|
||||
const r = await cerere({ port: TP.port, cale: '/j', curbe: 'X25519' });
|
||||
cere(r.status === 200, `status ${r.status}`);
|
||||
const f = citesteFir(TP.conexiuni[inainte]);
|
||||
cere(f.ch[0] && f.ch[0].grupuri.join() === String(X25519), `clientul de proba a oferit ${f.ch[0] ? f.ch[0].grupuri.map(numeGrup) : '-'}`);
|
||||
const final = f.sh.filter((s) => !s.hrr).pop();
|
||||
cere(final && final.grup === X25519, `fir: ${descrieSH(f.sh)}`);
|
||||
cere(r.eki && r.eki.name === 'X25519', `getEphemeralKeyInfo ${JSON.stringify(r.eki)}`);
|
||||
const s1 = await stare(GP.port);
|
||||
cere(s1.mode === 'hybrid-preferred', `mode ${s1.mode}`);
|
||||
cere(s1.guarantee && s1.guarantee.hybridKeyExchangeOnEveryConnection === false, 'hybrid-preferred isi declara hibridul garantat pe fiecare conexiune');
|
||||
cere(typeof s1.negotiatedGroupPerConnection === 'string' && /not reported/i.test(s1.negotiatedGroupPerConnection), 'starea nu spune ca grupul per conexiune nu e raportat');
|
||||
const pretentii = [];
|
||||
(function umbla(o, cale) {
|
||||
for (const [k, v] of Object.entries(o || {})) {
|
||||
if (v && typeof v === 'object') umbla(v, `${cale}.${k}`);
|
||||
else if (/hybrid|mlkem|quantum|pq/i.test(k) && (v === true || (typeof v === 'number' && v > 0))) pretentii.push(`${cale}.${k}=${v}`);
|
||||
}
|
||||
})(s1, '');
|
||||
cere(pretentii.length === 0, `starea pretinde hibrid: ${pretentii.join(', ')}`);
|
||||
cere(s1.counters.connectionsAccepted > s0.counters.connectionsAccepted, 'conexiunea clasica nu a fost numarata ca acceptata');
|
||||
return `fir: ${descrieSH(f.sh)}; getEphemeralKeyInfo() = ${JSON.stringify(r.eki)}; nicio pretentie de hibrid in stare`;
|
||||
});
|
||||
|
||||
await test('(j2) hybrid-preferred prefera CU ADEVARAT: client "X25519:X25519MLKEM768" (cota numai X25519) ajunge pe hibrid prin HelloRetryRequest', async () => {
|
||||
const inainte = TP.conexiuni.length;
|
||||
const r = await cerere({ port: TP.port, cale: '/j2', curbe: 'X25519:X25519MLKEM768' });
|
||||
cere(r.status === 200, `status ${r.status}`);
|
||||
const f = citesteFir(TP.conexiuni[inainte]);
|
||||
cere(f.ch[0] && f.ch[0].cote.join() === String(X25519) && f.ch[0].grupuri.includes(HIBRID), `fixtura: primul ClientHello are cotele ${f.ch[0] ? f.ch[0].cote.map(numeGrup) : '-'} si grupurile ${f.ch[0] ? f.ch[0].grupuri.map(numeGrup) : '-'}`);
|
||||
const final = f.sh.filter((s) => !s.hrr).pop();
|
||||
cere(final && final.grup === HIBRID, `fir: ${descrieSH(f.sh)} (serverul nu a cerut hibridul)`);
|
||||
return `fir: ${descrieSH(f.sh)}`;
|
||||
});
|
||||
|
||||
await test('(m) antet peste limita: 431, nimic trimis la upstream', async () => {
|
||||
const inainte = jurnalUpstream.length;
|
||||
let r;
|
||||
try { r = await cerere({ port: GH.port, cale: '/m-mare', anteturi: { 'x-mare': 'a'.repeat(20000) } }); } catch (e) { throw new Error(`fara raspuns HTTP: ${e.message}`); }
|
||||
cere(r.status === 431, `status ${r.status}`);
|
||||
cere(jurnalUpstream.slice(inainte).every((x) => x.url !== '/m-mare'), 'cererea a ajuns la upstream');
|
||||
return '431';
|
||||
});
|
||||
|
||||
await test('(n) ALPN: un client care ofera h2 primeste http/1.1 (gateway-ul nu face HTTP/2)', async () => {
|
||||
const r = await strangere({ port: GH.port, curbe: 'X25519MLKEM768', alpn: ['h2', 'http/1.1'] });
|
||||
cere(r.ok, `strangere esuata: ${r.cod}`);
|
||||
cere(r.alpn === 'http/1.1', `ALPN ${r.alpn}`);
|
||||
return `ALPN ${r.alpn}`;
|
||||
});
|
||||
|
||||
await test('(o) configuratie din fisier JSON (AERE_PQGW_CONFIG), iar mediul are prioritate', async () => {
|
||||
const fis = path.join(TMP, 'config.json');
|
||||
fs.writeFileSync(fis, JSON.stringify({ listen: `${GAZDA}:0`, cert: CALE_CERT, key: CALE_CHEIE, upstream: `http://${GAZDA}:${PORT_UP}`, mode: 'hybrid-preferred', connectTimeoutMs: 3000 }));
|
||||
const g = await pornesteGw('gateway din fisier', { AERE_PQGW_CONFIG: fis, AERE_PQGW_MODE: 'hybrid-only' });
|
||||
try {
|
||||
const s = await stare(g.port);
|
||||
cere(s.mode === 'hybrid-only', `mode ${s.mode} (mediul trebuia sa castige fata de fisier)`);
|
||||
const r = await cerere({ port: g.port, cale: '/o' });
|
||||
cere(r.status === 200 && json(r).url === '/o', `cererea prin gateway-ul din fisier: ${r.status}`);
|
||||
} finally { g.p.kill(); }
|
||||
// exemplul publicat trebuie sa fie o configuratie pe care gateway-ul chiar o accepta
|
||||
const mod = await import(pathToFileURL(GATEWAY).href);
|
||||
const ex = mod.citesteConfig({ AERE_PQGW_CONFIG: path.join(AICI, 'pq-gateway.example.json') });
|
||||
cere(ex.mode === 'hybrid-preferred' && ex.port === 8443, `exemplul citit: ${JSON.stringify({ mode: ex.mode, port: ex.port })}`);
|
||||
return 'fisier citit, MODE din mediu aplicat; pq-gateway.example.json acceptat';
|
||||
});
|
||||
|
||||
await test('(p) configuratie gresita: refuza sa porneasca, cu motivul numit, si nu asculta', async () => {
|
||||
const altaCheie = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem' });
|
||||
const caleAlta = path.join(TMP, 'alta-cheie.pem');
|
||||
fs.writeFileSync(caleAlta, altaCheie);
|
||||
const fisRau = path.join(TMP, 'config-rau.json');
|
||||
fs.writeFileSync(fisRau, JSON.stringify({ upstrem: 'http://x.invalid' }));
|
||||
const baza = { AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: CALE_CERT, AERE_PQGW_KEY: CALE_CHEIE, AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}` };
|
||||
const cazuri = [
|
||||
['fara MODE', { ...baza }, 2, /MODE is required/],
|
||||
['MODE necunoscut', { ...baza, AERE_PQGW_MODE: 'classic' }, 2, /MODE must be/],
|
||||
['cheie in fisier gresita', { ...baza, AERE_PQGW_MODE: 'hybrid-only', AERE_PQGW_CONFIG: fisRau }, 2, /unknown key in config file: upstrem/],
|
||||
['cheia nu se potriveste cu certificatul', { ...baza, AERE_PQGW_MODE: 'hybrid-only', AERE_PQGW_KEY: caleAlta }, 1, /KEY does not match/],
|
||||
];
|
||||
const note = [];
|
||||
for (const [nume, env, codAsteptat, motiv] of cazuri) {
|
||||
const r = spawnSync(process.execPath, [GATEWAY], { env: mediuGateway(env), timeout: 10000, encoding: 'utf8' });
|
||||
cere(r.status === codAsteptat, `${nume}: cod ${r.status}, asteptat ${codAsteptat}; stderr ${String(r.stderr).slice(0, 160)}`);
|
||||
cere(motiv.test(r.stderr), `${nume}: motivul lipseste din stderr: ${String(r.stderr).slice(0, 160)}`);
|
||||
cere(!/"event":"listening"/.test(r.stdout), `${nume}: a ascultat totusi`);
|
||||
cere(!String(r.stderr).includes(caleAlta) && !String(r.stderr).includes(CALE_CHEIE), `${nume}: calea cheii apare in mesaj`);
|
||||
note.push(`${nume}: ${r.status}`);
|
||||
}
|
||||
return note.join('; ');
|
||||
});
|
||||
|
||||
await test('(l) oprire curata: cererea in curs se termina, conexiunile noi sunt refuzate, oprirea se incheie singura', async () => {
|
||||
const mod = await import(pathToFileURL(GATEWAY).href);
|
||||
const cfg = mod.citesteConfig({ AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: CALE_CERT, AERE_PQGW_KEY: CALE_CHEIE, AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_MODE: 'hybrid-only' });
|
||||
const gw = await mod.pornesteGateway(cfg, { jurnal: () => {} });
|
||||
const port = gw.adresa.port;
|
||||
const inCurs = cerere({ port, cale: '/slow?ms=1200', timeoutMs: 9000 }).then((r) => r, (e) => ({ eroare: e }));
|
||||
await dormi(400);
|
||||
const t0 = Date.now();
|
||||
const oprit = gw.opreste(5000).then(() => Date.now() - t0);
|
||||
await dormi(100);
|
||||
const nou = await strangere({ port, curbe: 'X25519MLKEM768' });
|
||||
const r = await inCurs;
|
||||
const msOprire = await Promise.race([oprit, dormi(8000).then(() => null)]);
|
||||
cere(!r.eroare, `cererea in curs a fost taiata: ${r.eroare && r.eroare.message}`);
|
||||
cere(r.status === 200 && json(r).url === '/slow?ms=1200', `cererea in curs: status ${r.status}`);
|
||||
cere(String(r.anteturi.connection).toLowerCase() === 'close', `raspunsul din timpul opririi are Connection: ${r.anteturi.connection}`);
|
||||
cere(!nou.ok && nou.cod === 'ECONNREFUSED', `o conexiune noua dupa oprire: ${nou.ok ? 'ACCEPTATA' : nou.cod}`);
|
||||
cere(msOprire !== null && msOprire < 4000, `oprirea nu s-a incheiat (${msOprire} ms)`);
|
||||
return `cererea in curs 200, conexiune noua ${nou.cod}, oprire in ${msOprire} ms`;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- autentificarea post-cuantica (2026-09-25)
|
||||
function pornireRefuzata(extra) {
|
||||
const r = spawnSync(process.execPath, [GATEWAY], { env: mediuGateway(extra), encoding: 'utf8', timeout: 15000 });
|
||||
return { cod: r.status, stderr: r.stderr || '', stdout: r.stdout || '' };
|
||||
}
|
||||
await test('(s) lant ML-DSA emis de aere-pq-pki + REQUIRE_PQ_AUTH: porneste, starea spune post-quantum, openssl vede mldsa65 + X25519MLKEM768 si verifica lantul', async () => {
|
||||
// AERE_PQ_PKI: controlul negativ ruleaza proba dintr-o copie a dosarului, deci calea autoritatii vine din mediu
|
||||
const P = await import(pathToFileURL(process.env.AERE_PQ_PKI || path.join(AICI, '..', 'pq-pki', 'pki.mjs')).href);
|
||||
const kr = P.generateKey('ml-dsa-87'), ki = P.generateKey('ml-dsa-65'), kl = P.generateKey('ml-dsa-65');
|
||||
const root = P.issue({ issuer: null, signingKey: kr.privateKey, subject: { cn: 'Gateway Proba Root' }, publicKey: kr.publicKey, ca: true, pathLen: 1, days: 30 });
|
||||
const inter = P.issue({ issuer: root, signingKey: kr.privateKey, subject: { cn: 'Gateway Proba Issuing' }, publicKey: ki.publicKey, ca: true, pathLen: 0, days: 30 });
|
||||
const leaf = P.issue({ issuer: inter, signingKey: ki.privateKey, subject: { cn: 'localhost' }, publicKey: kl.publicKey, days: 5, dns: ['localhost'], ips: ['127.0.0.1'], eku: ['serverAuth'] });
|
||||
const cRoot = path.join(TMP, 'pq-root.pem'), cLant = path.join(TMP, 'pq-lant.pem'), cCheie = path.join(TMP, 'pq-cheie.pem');
|
||||
fs.writeFileSync(cRoot, P.pem('CERTIFICATE', root));
|
||||
fs.writeFileSync(cLant, P.pem('CERTIFICATE', leaf) + P.pem('CERTIFICATE', inter));
|
||||
fs.writeFileSync(cCheie, kl.privateKey.export({ type: 'pkcs8', format: 'pem' }));
|
||||
const g = await pornesteGw('gateway cu lant ML-DSA', { AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: cLant, AERE_PQGW_KEY: cCheie, AERE_PQGW_MODE: 'hybrid-only',
|
||||
AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUIRE_PQ_AUTH: '1' });
|
||||
const radacina = fs.readFileSync(cRoot);
|
||||
const s = await stare(g.port, radacina);
|
||||
cere(s.certificate.authentication === 'post-quantum', `autentificarea din stare: ${s.certificate.authentication}`);
|
||||
cere(s.certificate.chain.length === 2 && s.certificate.chain.every((c) => c.publicKeyAlgorithm === 'ML-DSA-65'), `lantul din stare: ${JSON.stringify(s.certificate.chain)}`);
|
||||
cere(s.certificate.chain[1].signatureAlgorithm === 'ML-DSA-87', `semnatura radacinii pe intermediar: ${s.certificate.chain[1].signatureAlgorithm}`);
|
||||
const o = await sClient(['-connect', `${GAZDA}:${g.port}`, '-servername', 'localhost', '-verify_hostname', 'localhost', '-CAfile', cRoot, '-verify_return_error', '-groups', 'X25519MLKEM768'], 'Q\n');
|
||||
const t = o.o + o.e;
|
||||
cere(/Verify return code: 0 \(ok\)/.test(t), `openssl verificarea: ${t.slice(0, 300)}`);
|
||||
cere(/Peer signature type: mldsa65/.test(t), 'semnatura serverului nu e mldsa65');
|
||||
cere(/Negotiated TLS1.3 group: X25519MLKEM768/.test(t), 'grupul nu e X25519MLKEM768');
|
||||
const r = await cerere({ port: g.port, cale: '/ecou', ca: radacina });
|
||||
cere(r.status === 200, `cererea prin gateway: ${r.status}`);
|
||||
return 'post-quantum, mldsa65 + X25519MLKEM768, lantul verificat de openssl';
|
||||
});
|
||||
await test('(s2) REQUIRE_PQ_AUTH cu certificatul clasic (EC P-256): refuza sa porneasca, motivul numeste lantul classical; fara optiune starea spune classical', async () => {
|
||||
const r = pornireRefuzata({ AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: CALE_CERT, AERE_PQGW_KEY: CALE_CHEIE, AERE_PQGW_MODE: 'hybrid-only',
|
||||
AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUIRE_PQ_AUTH: 'true' });
|
||||
cere(r.cod === 1, `cod ${r.cod}`);
|
||||
cere(/REQUIRE_PQ_AUTH is set but the served chain is classical/.test(r.stderr), `motivul: ${r.stderr.slice(0, 200)}`);
|
||||
cere(!/"event":"listening"/.test(r.stdout), 'a ascultat totusi');
|
||||
const s = await stare(GH.port);
|
||||
cere(s.certificate.authentication === 'classical', `starea gateway-ului clasic: ${s.certificate.authentication}`);
|
||||
return 'refuzat la pornire; starea clasicului: classical';
|
||||
});
|
||||
await test('(s3) lant MIXT (frunza ML-DSA semnata de o autoritate EC, facut de openssl): REQUIRE_PQ_AUTH refuza, cu "mixed" si semnatura numita', async () => {
|
||||
const k = (n) => path.join(TMP, n);
|
||||
execFileSync(OPENSSL, ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-keyout', k('ecca.key'), '-out', k('ecca.pem'), '-days', '2',
|
||||
'-subj', '/CN=EC CA', '-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
execFileSync(OPENSSL, ['req', '-new', '-newkey', 'mldsa65', '-nodes', '-keyout', k('mix.key'), '-out', k('mix.csr'), '-subj', '/CN=localhost'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
execFileSync(OPENSSL, ['x509', '-req', '-in', k('mix.csr'), '-CA', k('ecca.pem'), '-CAkey', k('ecca.key'), '-out', k('mix.pem'), '-days', '2', '-set_serial', '9'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const r = pornireRefuzata({ AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: k('mix.pem'), AERE_PQGW_KEY: k('mix.key'), AERE_PQGW_MODE: 'hybrid-only',
|
||||
AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUIRE_PQ_AUTH: '1' });
|
||||
cere(r.cod === 1, `cod ${r.cod}; ${r.stderr.slice(0, 200)}`);
|
||||
cere(/the served chain is mixed/.test(r.stderr) && /ML-DSA-65 key signed with classical/.test(r.stderr), `motivul: ${r.stderr.slice(0, 260)}`);
|
||||
return 'mixed: cheie ML-DSA-65, semnatura clasica';
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- revizuirea adversariala din 2026-09-25 (A8, A9)
|
||||
await test('(e3) antetele de identitate: X-Real-IP e al gateway-ului (nu al clientului), X-Client-* si X-SSL-* ale clientului NU ajung', async () => {
|
||||
const r = await cerere({ port: GH.port, cale: '/e3', anteturi: {
|
||||
'X-Real-IP': '1.2.3.4', 'X-Client-Cert': 'fals', 'X-SSL-Client-S-DN': 'CN=fals', 'X-Client-Verify': 'SUCCESS', 'X-End-To-End': 'da',
|
||||
} });
|
||||
cere(r.status === 200, `status ${r.status}`);
|
||||
const h = json(r).headers;
|
||||
cere(h['x-end-to-end'] === 'da', 'nici antetul obisnuit nu a ajuns: fixtura nu masoara nimic');
|
||||
cere(h['x-real-ip'] === '127.0.0.1', `X-Real-IP la upstream: ${h['x-real-ip']} (trebuia adresa din socket, nu a clientului)`);
|
||||
const scapate = ['x-client-cert', 'x-ssl-client-s-dn', 'x-client-verify'].filter((n) => n in h);
|
||||
cere(scapate.length === 0, `antete de identitate ale clientului ajunse la upstream: ${scapate.join(', ')}`);
|
||||
return 'X-Real-IP din socket; x-client-cert, x-ssl-client-s-dn, x-client-verify oprite';
|
||||
});
|
||||
await test('(g3) lant ML-DSA + client fara algoritm de semnatura PQ (numai ECDSA/RSA-PSS, ca browserele de azi): refuzat si numarat "no shared signature algorithm"', async () => {
|
||||
const P = await import(pathToFileURL(process.env.AERE_PQ_PKI || path.join(AICI, '..', 'pq-pki', 'pki.mjs')).href);
|
||||
const kr = P.generateKey('ml-dsa-65'), kl = P.generateKey('ml-dsa-65');
|
||||
const root = P.issue({ issuer: null, signingKey: kr.privateKey, subject: { cn: 'G3 Root' }, publicKey: kr.publicKey, ca: true, pathLen: 0, days: 5 });
|
||||
const leaf = P.issue({ issuer: root, signingKey: kr.privateKey, subject: { cn: 'localhost' }, publicKey: kl.publicKey, days: 5, dns: ['localhost'], eku: ['serverAuth'] });
|
||||
const cLant = path.join(TMP, 'g3-lant.pem'), cCheie = path.join(TMP, 'g3-cheie.pem'), cRoot = path.join(TMP, 'g3-root.pem');
|
||||
fs.writeFileSync(cLant, P.pem('CERTIFICATE', leaf)); fs.writeFileSync(cRoot, P.pem('CERTIFICATE', root));
|
||||
fs.writeFileSync(cCheie, kl.privateKey.export({ type: 'pkcs8', format: 'pem' }));
|
||||
const g = await pornesteGw('gateway ML-DSA pentru (g3)', { AERE_PQGW_LISTEN: `${GAZDA}:0`, AERE_PQGW_CERT: cLant, AERE_PQGW_KEY: cCheie, AERE_PQGW_MODE: 'hybrid-only',
|
||||
AERE_PQGW_UPSTREAM: `http://${GAZDA}:${PORT_UP}`, AERE_PQGW_REQUIRE_PQ_AUTH: '1' });
|
||||
const radacina = fs.readFileSync(cRoot);
|
||||
// controlul pozitiv: cu algoritmii impliciti (care includ mldsa65) strangerea reuseste
|
||||
// s_client tipareste "Verify return code: 0 (ok)" SI cand strangerea a cazut inainte de orice verificare (masurat 2026-09-25:
|
||||
// alerta 40, 7 octeti cititi, si tot "0 (ok)"); succesul se citeste din "Peer signature type", refuzul din alerta.
|
||||
const bun = await sClient(['-connect', `${GAZDA}:${g.port}`, '-servername', 'localhost', '-CAfile', cRoot, '-verify_return_error', '-groups', 'X25519MLKEM768'], 'Q\n');
|
||||
cere(/Peer signature type: mldsa65/.test(bun.o + bun.e) && /Verify return code: 0 \(ok\)/.test(bun.o + bun.e), 'controlul pozitiv (client cu mldsa65) nu a reusit');
|
||||
const rau = await sClient(['-connect', `${GAZDA}:${g.port}`, '-servername', 'localhost', '-CAfile', cRoot, '-groups', 'X25519MLKEM768', '-sigalgs', 'ECDSA+SHA256:RSA-PSS+SHA256'], 'Q\n');
|
||||
const tr = rau.o + rau.e;
|
||||
cere(/alert handshake failure|SSL alert number 40/.test(tr) && !/Peer signature type:/.test(tr), `clientul fara algoritm PQ de semnatura nu a fost refuzat cu alerta: ${tr.split('\n').filter((l) => /alert|Peer signature|Negotiated/.test(l)).join(' | ').slice(0, 200)}`);
|
||||
const s = await asteaptaStare(g.port, (x) => (x.counters.handshakesRefused['no shared signature algorithm'] || 0) >= 1, 4000, radacina);
|
||||
cere(s && s.counters.handshakesRefused['no shared signature algorithm'] >= 1, `refuzul nu e numarat sub motivul lui: ${JSON.stringify(s && s.counters.handshakesRefused)}`);
|
||||
return `numarat: no shared signature algorithm = ${s.counters.handshakesRefused['no shared signature algorithm']}`;
|
||||
});
|
||||
|
||||
console.log(`PROBA PQ-GATEWAY: ${treceri} treceri, ${esecuri} esecuri${nemasurate ? `, ${nemasurate} nemasurate` : ''}`);
|
||||
process.exitCode = esecuri ? 1 : 0;
|
||||
suitaTerminata = true;
|
||||
curata();
|
||||
2
pq-kms/.gitignore
vendored
Normal file
2
pq-kms/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# dosarul implicit de date (chei sigilate, jurnal de audit); nu intra niciodata in depozit
|
||||
data/
|
||||
284
pq-kms/README.md
Normal file
284
pq-kms/README.md
Normal file
@ -0,0 +1,284 @@
|
||||
# Aere PQ KMS
|
||||
|
||||
A small "transit"-style key management service (in the spirit of Vault's transit engine) where every
|
||||
key is **hybrid classical + post-quantum**. It encrypts, decrypts, rewraps, issues data keys, signs and
|
||||
verifies. Callers never see private keys unless a key was explicitly created as exportable.
|
||||
|
||||
It runs on **Node.js 24 only** and uses nothing but `node:crypto` (OpenSSL 3.5), `node:http`,
|
||||
`node:fs`. No dependencies.
|
||||
|
||||
| key type | algorithms | what the hybrid means |
|
||||
|-----------|------------------------------------|-----------------------|
|
||||
| `encrypt` | X25519 + ML-KEM-768, AES-256-GCM | the data key is derived from **both** shared secrets; recovering it requires both |
|
||||
| `sign` | Ed25519 + ML-DSA-65 | a signature carries **both** halves; verification fails if **either** half fails |
|
||||
|
||||
## What it is NOT
|
||||
|
||||
- **Not an HSM itself.** Private keys live on disk sealed under a root key, and in process memory while in use.
|
||||
Anyone who can read process memory, or who has both the data directory and the root key, has the keys.
|
||||
- **The root key comes from the environment** (`AERE_KMS_ROOT_KEY`) **or, since 2026-09-28, sealed by an HSM**
|
||||
(`AERE_KMS_ROOT_HSM`, see below). With the environment there is no physical separation, no key ceremony, no
|
||||
secret sharing, no unseal quorum; protect the environment accordingly. Only the derived key buffer is zeroed
|
||||
after derivation; the hex string itself stays readable in the process (configuration) for its lifetime.
|
||||
|
||||
- **Not audited.** No third party has reviewed this code or the hybrid construction below.
|
||||
- **Single process.** There is no file locking; do not point two processes at the same data directory. If
|
||||
two processes do write the same audit log, the chain breaks and the next start **refuses** with
|
||||
`AUDIT_CHAIN_INVALID` until the log is moved aside: it is a stop, not a degradation.
|
||||
- **Audit verification is linear.** Startup and `GET /v1/audit/verify` re-read and re-authenticate the whole
|
||||
log; on a very large log a caller holding the token can make that endpoint expensive. Rotate the log by
|
||||
moving it aside (keep it as evidence) and starting a new chain.
|
||||
- **No TLS.** The server speaks plain HTTP and listens on `127.0.0.1` by default. Put a TLS-terminating
|
||||
proxy in front of it before exposing it to anything else.
|
||||
- **One static bearer token.** There are no per-client identities, roles or per-key ACLs, and the audit
|
||||
log does not record who made a request.
|
||||
- No key deletion, no root-key rotation, no rate limiting, no replication or backup.
|
||||
|
||||
## Root key sealed by an HSM (PKCS#11)
|
||||
|
||||
`hsm-radacina.mjs` seals the 32-byte root under an AES-256 key generated **inside** a PKCS#11 token
|
||||
(`CKA_SENSITIVE`, never extractable) and stores only the sealed form on disk. At start the server asks the
|
||||
HSM to open it with the token PIN (`AERE_HSM_PIN`, read by `pkcs11-tool` from the environment, never on a
|
||||
command line). Someone with the disk and the environment but without the HSM (and its PIN) does not have
|
||||
the root. Set exactly one of `AERE_KMS_ROOT_KEY` / `AERE_KMS_ROOT_HSM`; both is refused (`ROOT_KEY_AMBIGUOUS`).
|
||||
|
||||
AERE_HSM_PIN=... node hsm-radacina.mjs sigileaza --modul /usr/lib/softhsm/libsofthsm2.so --token aere-kms --id 0a --iesire root-hsm.json --nou
|
||||
AERE_KMS_ROOT_HSM=root-hsm.json AERE_HSM_MODULE=/usr/lib/softhsm/libsofthsm2.so AERE_HSM_PIN=... AERE_KMS_TOKEN=... node server.mjs
|
||||
|
||||
The sealed file does not choose what the process loads or sends: the PKCS#11 library comes from the process
|
||||
configuration (`AERE_HSM_MODULE`, an absolute path) and must be exactly the one recorded in the file, checked
|
||||
before any HSM tool runs (`HSM_MODULE_NOT_ALLOWED` otherwise); the PIN is always read from `AERE_HSM_PIN`, and a
|
||||
file naming another variable is refused. So write access to the sealed file alone cannot make the KMS load
|
||||
another library or hand it the PIN or another secret. A modified seal is refused with one code and one message
|
||||
whichever check catches it (padding or the root's digest), so startup errors are not a padding oracle.
|
||||
|
||||
Measured on SoftHSM2 2.6.1 + OpenSC 0.25 (`test/proba-hsm.mjs`, 20/20 on 2026-09-29, needs Node 24 and a PKCS#11 module; the
|
||||
library and PIN rules above in `test/proba-hsm-incredere.mjs`, 7/7 without an HSM, with `test/control-negativ-hsm-incredere.mjs`):
|
||||
key generated in the token and not readable out of it, seal/open round trip, the KMS started from the
|
||||
HSM-opened root and a hybrid round trip through it, and named refusals for a wrong PIN, a missing PIN,
|
||||
another token, another key and a modified seal. What it does **not** change: after opening, the root is in
|
||||
process memory as before; the hybrid ML-KEM/ML-DSA operations run in the KMS, not in the HSM (standard
|
||||
PKCS#11 does not expose them); `pkcs11-tool` works on files, so the root passes for milliseconds through a
|
||||
file under `/dev/shm`, zero-filled and removed. The mechanism is AES-CBC-PAD (OpenSC 0.25 does not expose
|
||||
AES-GCM for encryption), so the seal carries 16 bytes of the root's own digest and a modified seal is a named
|
||||
refusal, never a wrong root. Not yet exercised against a physical HSM.
|
||||
|
||||
## Threat model (short)
|
||||
|
||||
Defended, and exercised by the test suite (`test/proba.mjs`):
|
||||
|
||||
- **A future quantum adversary against stored ciphertexts** (harvest now, decrypt later): the data key
|
||||
depends on the ML-KEM-768 secret, so breaking X25519 alone does not open an envelope. The reverse also
|
||||
holds: a flaw in ML-KEM alone does not open an envelope while X25519 stands.
|
||||
- **Signature forgery with one broken scheme:** a forger must produce valid Ed25519 **and** ML-DSA-65
|
||||
signatures; the halves cannot be separated and reused as plain signatures over the raw message.
|
||||
- **Tampering with ciphertexts:** any modified byte, a different `aad`, a different key, a different
|
||||
version or a retired version is refused with a named reason.
|
||||
- **Theft of the data directory without the root key:** private keys are sealed with AES-256-GCM; key
|
||||
files carry an HMAC so that swapped public keys or a lowered `min_decryption_version` are refused.
|
||||
- **Editing the audit log without the root key:** rows are HMAC-chained; a deleted, changed or reordered
|
||||
row is detected.
|
||||
|
||||
Not defended:
|
||||
|
||||
- Compromise of the running process or of the host (memory, environment, debugger).
|
||||
- An attacker with the root key.
|
||||
- **Rollback** of the whole data directory (or of one key file) to an older, validly-MACed state, e.g. to
|
||||
undo a raise of `min_decryption_version`. There is no monotonic counter.
|
||||
- **Truncation of the audit log's tail** is only detected against an externally recorded head
|
||||
(`verifyAudit({ expectedHead })`); on its own a hash chain cannot see missing trailing rows.
|
||||
- Side channels (timing, cache, power) of the underlying OpenSSL implementations were not evaluated.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
export AERE_KMS_ROOT_KEY=<64 hex characters> # required; the service refuses to start without it
|
||||
export AERE_KMS_TOKEN=<at least 32 printable chars> # required; clients send "Authorization: Bearer <token>"
|
||||
node server.mjs
|
||||
```
|
||||
|
||||
| variable | default | meaning |
|
||||
|---------------------|--------------------|---------|
|
||||
| `AERE_KMS_ROOT_KEY` | none (required, or `AERE_KMS_ROOT_HSM`) | 32 bytes as hex. Missing, malformed or all-zero: refuses to start. **Never generated automatically.** |
|
||||
| `AERE_KMS_ROOT_HSM` | none | path of a root sealed by an HSM (see above); exactly one of this and `AERE_KMS_ROOT_KEY` |
|
||||
| `AERE_HSM_MODULE` | none (required with `AERE_KMS_ROOT_HSM`) | absolute path of the PKCS#11 library; must equal the one recorded in the sealed file |
|
||||
| `AERE_HSM_PIN` | none (required with `AERE_KMS_ROOT_HSM`) | the token PIN, read by `pkcs11-tool` from the environment, never on a command line |
|
||||
| `AERE_KMS_TOKEN` | none (required) | bearer token, 32+ printable ASCII characters, must differ from the root key |
|
||||
| `AERE_KMS_HOST` | `127.0.0.1` | listen address |
|
||||
| `AERE_KMS_PORT` | `8420` | listen port (`0` picks a free one) |
|
||||
| `AERE_KMS_MAX_BODY` | `65536` | request body limit in bytes, 8192 to 16777216 (a hybrid signature alone is 4531 base64 characters) |
|
||||
| `AERE_KMS_DATA_DIR` | `./data` next to `server.mjs` | key files, root check, audit log |
|
||||
|
||||
A data directory is bound to the root key it was created with: opening it with another root key fails
|
||||
with `ROOT_KEY_MISMATCH`. No error message ever contains key material, the root key or the token.
|
||||
|
||||
Generate a root key with, for example:
|
||||
`node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.
|
||||
|
||||
## HTTP API
|
||||
|
||||
All bodies are JSON. Binary values (`plaintext`, `aad`, `message`) are **standard base64**; ciphertexts
|
||||
and signatures are the self-describing strings below. Errors are `{ "error": CODE, "message": text }`.
|
||||
Every route except `GET /v1/health` requires `Authorization: Bearer <token>`.
|
||||
|
||||
| method and path | body | response |
|
||||
|---|---|---|
|
||||
| `GET /v1/health` | | `{ ok: true }` (no auth) |
|
||||
| `GET /v1/keys` | | `{ keys: [names] }` |
|
||||
| `POST /v1/keys/:name` | `{ type: "encrypt"\|"sign", exportable?: false }` | key description (public keys only) |
|
||||
| `GET /v1/keys/:name` | | key description |
|
||||
| `POST /v1/keys/:name/rotate` | | key description with a new latest version |
|
||||
| `POST /v1/keys/:name/config` | `{ min_decryption_version: n }` | key description |
|
||||
| `GET /v1/keys/:name/export[/:version]` | | private keys (PKCS#8 DER, base64); `403 KEY_NOT_EXPORTABLE` unless exportable |
|
||||
| `POST /v1/encrypt/:name` | `{ plaintext, aad? }` | `{ ciphertext, version }` |
|
||||
| `POST /v1/decrypt/:name` | `{ ciphertext, aad? }` | `{ plaintext, version }` |
|
||||
| `POST /v1/rewrap/:name` | `{ ciphertext, aad? }` | `{ ciphertext, version }` (the plaintext never leaves the process) |
|
||||
| `POST /v1/datakey/:name` | `{ aad?, bits?: 128\|256\|512, include_plaintext?: true }` | `{ ciphertext, version, plaintext? }` |
|
||||
| `POST /v1/sign/:name` | `{ message }` | `{ signature, version }` |
|
||||
| `POST /v1/verify/:name` | `{ message, signature }` | `{ valid, reason, version, classical, post_quantum }` |
|
||||
| `GET /v1/audit/verify` | | `{ ok, rows, head }` or `{ ok: false, reason, line }` |
|
||||
|
||||
Key names match `^[a-z0-9][a-z0-9_-]{0,63}$`.
|
||||
|
||||
### Versions, rotation and retirement
|
||||
|
||||
- `rotate` adds a version; `encrypt`, `datakey`, `rewrap` and `sign` always use the latest one.
|
||||
- Older versions keep decrypting and verifying until `min_decryption_version` is raised past them.
|
||||
After that, decrypt and rewrap fail with `VERSION_BELOW_MINIMUM`, and verify returns
|
||||
`valid: false, reason: "VERSION_BELOW_MINIMUM"`.
|
||||
- `min_decryption_version` **can only be raised** (`MIN_VERSION_NOT_MONOTONIC`), and never above the latest
|
||||
version (`VERSION_OUT_OF_RANGE`). Retired private keys stay on disk; there is no trim.
|
||||
- `exportable` is set at creation and cannot be changed. It defaults to `false`.
|
||||
|
||||
### Error codes
|
||||
|
||||
| code | when |
|
||||
|---|---|
|
||||
| `MALFORMED_CIPHERTEXT`, `MALFORMED_SIGNATURE` | not `aerekms:v<n>:<base64>`, non-canonical base64, wrong header, wrong length |
|
||||
| `VERSION_MISMATCH` | the prefix version and the embedded version differ, or the envelope belongs to another version of this key |
|
||||
| `KEY_MISMATCH` | the envelope was produced by a different key |
|
||||
| `UNKNOWN_VERSION` | the key has no such version |
|
||||
| `VERSION_BELOW_MINIMUM` | the version is retired by `min_decryption_version` |
|
||||
| `HEADER_AUTH_FAILED` | the key encapsulation, header or aad tag was modified, or the envelope was not produced for this key version (key commitment failed) |
|
||||
| `AAD_MISMATCH` | the `aad` differs from the one used at encryption |
|
||||
| `PAYLOAD_AUTH_FAILED` | the encrypted payload or its GCM tag was modified or truncated |
|
||||
| `CLASSICAL_SIGNATURE_INVALID`, `PQ_SIGNATURE_INVALID`, `BOTH_SIGNATURES_INVALID` | which half of a hybrid signature failed |
|
||||
| `KEY_NOT_FOUND`, `KEY_EXISTS`, `WRONG_KEY_TYPE`, `INVALID_KEY_NAME`, `INVALID_KEY_TYPE`, `INVALID_POLICY`, `INVALID_BITS`, `KEY_NOT_EXPORTABLE` | request errors |
|
||||
| `ROOT_KEY_MISSING`, `ROOT_KEY_INVALID`, `ROOT_KEY_WEAK`, `ROOT_KEY_MISMATCH`, `ROOT_CHECK_MISSING` | refuses to start |
|
||||
| `KEY_FILE_TAMPERED`, `SEAL_AUTH_FAILED` | a key file on disk fails its integrity check |
|
||||
| `AUDIT_CHAIN_INVALID` | refuses to start on an audit log that does not verify (move it aside as evidence to begin a new chain) |
|
||||
| `AUDIT_WRITE_FAILED` | the audit row could not be written; the result is withheld, and any change the operation made to the key file (a new key, a new version, a raised minimum) is rolled back, so a change exists only if its audit row exists |
|
||||
| `UNAUTHENTICATED`, `INVALID_TOKEN` | 401 |
|
||||
| `BODY_TOO_LARGE` | 413 |
|
||||
|
||||
## Library API
|
||||
|
||||
```js
|
||||
import { openKms } from './kms.mjs';
|
||||
const kms = openKms({ dataDir: './data', rootKey: process.env.AERE_KMS_ROOT_KEY });
|
||||
kms.createKey('orders', { type: 'encrypt' }); // exportable: false by default
|
||||
const { ciphertext } = kms.encrypt('orders', 'secret', 'tenant-42');
|
||||
const { plaintext } = kms.decrypt('orders', ciphertext, 'tenant-42');
|
||||
kms.rotate('orders'); kms.rewrap('orders', ciphertext, 'tenant-42');
|
||||
kms.setMinDecryptionVersion('orders', 2);
|
||||
kms.datakey('orders', { aad: 'file-7', bits: 256, includePlaintext: true });
|
||||
kms.createKey('releases', { type: 'sign' });
|
||||
const { signature } = kms.sign('releases', 'artifact bytes');
|
||||
kms.verify('releases', 'artifact bytes', signature); // { valid, reason, classical, post_quantum, version }
|
||||
kms.verifyAudit({ expectedHead }); // expectedHead = an earlier kms.auditHead()
|
||||
```
|
||||
|
||||
Refusals throw `KmsError` with a `code` from the table above (`verify` returns `valid: false` instead).
|
||||
|
||||
## Wire format
|
||||
|
||||
Everything below is fixed by this implementation so that third parties can interoperate using only
|
||||
standard primitives; `test/proba.mjs` contains a second, independent implementation of it (including
|
||||
a hand-written HKDF) that decapsulates the service's envelopes and builds envelopes the service accepts.
|
||||
|
||||
Notation: `u32` is big-endian, `lp(x)` is `u32(len(x)) || x`, strings are UTF-8, `\0` is a zero byte.
|
||||
|
||||
### Public keys and fingerprint
|
||||
|
||||
- `encrypt` version: `x25519` = raw 32-byte public key; `ml_kem_768` = SPKI DER (1206 bytes; the raw
|
||||
1184-byte encapsulation key follows a fixed 22-byte header).
|
||||
- `sign` version: `ed25519` = raw 32 bytes; `ml_dsa_65` = SPKI DER (1974 bytes; raw key 1952 bytes).
|
||||
- `fingerprint = SHA-256("aerekms/v1/fingerprint\0" || kind || lp(name) || u32(version) || pubA_raw || pubB_raw)[0..8]`,
|
||||
with `kind` = `0x45` ('E') or `0x53` ('S').
|
||||
|
||||
### Ciphertext: `aerekms:v<version>:<base64(body)>`
|
||||
|
||||
```
|
||||
body = "AKM1" | 0x45 | u32 version | fp(8) | ePub(32) | ctK(1088) | aadTag(16) | commit(16) | payload | gcmTag(16)
|
||||
```
|
||||
|
||||
1. `ePub` is a fresh X25519 public key; `ssX = X25519(eph, recipient_x25519)`.
|
||||
2. `(ssK, ctK) = ML-KEM-768.Encaps(recipient_ml_kem_768)`.
|
||||
3. `transcript = "aerekms/v1/hybrid-kem\0" || lp(name) || u32(version) || fp || recipient_x25519_raw || SHA-256(recipient_ek_raw) || ePub || ctK`
|
||||
4. `IKM = ssK || ssX`, `salt = SHA-256(transcript)`, and with HKDF-SHA-256:
|
||||
- `commitKey = HKDF(IKM, salt, "aerekms/v1/commit", 32)`
|
||||
- `aadKey = HKDF(IKM, salt, "aerekms/v1/aad", 32)`, `aadTag = HMAC-SHA-256(aadKey, aad)[0..16]`
|
||||
- `key || iv = HKDF(IKM, salt, "aerekms/v1/dek\0" || SHA-256(aad), 44)`
|
||||
5. `commit = HMAC-SHA-256(commitKey, body[0 .. commit offset))[0..16]` (a key commitment over the whole
|
||||
header, including `aadTag`).
|
||||
6. `payload || gcmTag = AES-256-GCM(key, iv, plaintext, AAD = body[0 .. payload offset))`.
|
||||
|
||||
Decryption checks, in order: format, prefix version = embedded version, version exists and is not retired,
|
||||
fingerprint, decapsulation, `commit`, `aadTag`, GCM. Each step has its own error code. Absent `aad` and
|
||||
empty `aad` are the same thing.
|
||||
|
||||
The combiner is the common "concatenate both secrets, bind both encapsulations, then KDF" construction.
|
||||
No formal security proof is provided here.
|
||||
|
||||
### Signature: `aerekms:v<version>:<base64(body)>`
|
||||
|
||||
```
|
||||
body = "AKS1" | 0x53 | u32 version | fp(8) | ed25519_sig(64) | ml_dsa_65_sig(3309)
|
||||
M' = "aerekms/v1/hybrid-sig\0ed25519+ml-dsa-65\0" || fp || u32(version) || message
|
||||
```
|
||||
|
||||
Ed25519 signs `M'`; ML-DSA-65 (pure, FIPS 204) signs `M'` with context string `"aerekms/v1"`. Because both
|
||||
sign `M'` and not the raw message, neither half is a valid signature over the message on its own.
|
||||
|
||||
### Storage
|
||||
|
||||
- `data/root-check.json`: an HMAC under a key derived from the root key; detects a wrong root key.
|
||||
- `data/keys/<name>.json`: key metadata, public keys, and per version `private_sealed` =
|
||||
`base64(iv(12) || AES-256-GCM ciphertext || tag(16))` of the PKCS#8 private keys, with
|
||||
AAD `"aerekms/v1/seal\0" name "\0" type "\0" version "\0" fingerprint_hex`. The whole file carries
|
||||
`mac` = HMAC-SHA-256 over its canonical JSON. Files are written atomically (temp file, fsync, rename).
|
||||
- Sub-keys are derived from the root key with HKDF-SHA-256 (salt `"aerekms/v1/root"`, one info label each:
|
||||
seal, key-file MAC, audit chain, root check). The derived root key buffer is zeroed after derivation; the
|
||||
environment variable it came from is not (see the limits above).
|
||||
|
||||
### Audit log: `data/audit.log`
|
||||
|
||||
One JSON object per line: `{ seq, ts, op, key, version, ok, reason, prev, mac }`, where `prev` is the
|
||||
previous row's `mac` (64 zeros for the first row) and `mac = HMAC-SHA-256(auditKey, canonical JSON of the
|
||||
row without mac)`. Rows never contain plaintext, aad, messages, ciphertexts or key material. Every
|
||||
operation writes a row, including refused ones (with `ok: false` and the reason code); the row is
|
||||
fsynced before the result is returned. `verifyAudit()` reports `AUDIT_ROW_MODIFIED`,
|
||||
`AUDIT_CHAIN_BROKEN` (deleted or reordered rows), `AUDIT_ROW_UNPARSABLE`, and, given an external head,
|
||||
`AUDIT_TRUNCATED` / `AUDIT_HEAD_MISMATCH`. Record `auditHead()` somewhere else if tail truncation matters.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
node test/proba.mjs # the test suite
|
||||
node test/control-negativ.mjs # plants defects in a copy; each must turn named tests red
|
||||
node test/control-negativ.mjs --autoproba # decoy plants; the control itself must report each as a failure
|
||||
```
|
||||
|
||||
`test/proba.mjs` pairs every positive assertion with a negative one and checks the refusal **reason**,
|
||||
not just that something failed. `test/control-negativ.mjs` copies the code to a temp directory, plants
|
||||
one defect at a time (KDF without the ML-KEM secret, KDF without the X25519 secret, verification that
|
||||
accepts one signature, ignored aad, ignored minimum version, unchained audit writer, verifier that skips
|
||||
the chain, verifier that skips row MACs, private key written in clear, key file MAC not checked, root
|
||||
key generated silently, fingerprint not checked, rewrap returning plaintext, missing auth accepted, body
|
||||
limit ignored), and requires the named tests to fail with the named message. It reads the suite's JSON
|
||||
output, not its exit code, and checks that the original files are byte-identical afterwards.
|
||||
|
||||
Measured on 2026-09-25, Windows 11, Node 24.14.1, OpenSSL 3.5.5: 61/61 tests pass; the negative control
|
||||
catches 15/15 plantings; the control's self-test gives 5/5 decoys their failure verdict. Not measured:
|
||||
Linux or macOS (including whether the `0o600`/`0o700` file modes are applied; Windows ignores them),
|
||||
throughput under load, behaviour with concurrent processes, side channels.
|
||||
142
pq-kms/hsm-radacina.mjs
Normal file
142
pq-kms/hsm-radacina.mjs
Normal file
@ -0,0 +1,142 @@
|
||||
// hsm-radacina.mjs - Aere PQ KMS: cheia radacina SIGILATA de un HSM prin PKCS#11 (roadmap master 16, "HSM").
|
||||
//
|
||||
// DE CE: pana acum radacina KMS-ului statea IN CLAR in mediu (AERE_KMS_ROOT_KEY), iar README-ul o spune: "Not an HSM". Cu acest
|
||||
// modul radacina sta pe disc numai SIGILATA de o cheie AES-256 care traieste in HSM si nu poate iesi de acolo (CKA_SENSITIVE,
|
||||
// CKA_EXTRACTABLE=false); la pornire, KMS-ul cere HSM-ului sa o deschida, cu PIN-ul tokenului. Cine fura discul si mediul fara HSM
|
||||
// si fara PIN nu are radacina. Masurat pe SoftHSM2 2.6.1 + OpenSC 0.25 (test/proba-hsm.mjs); un HSM fizic se foloseste la fel,
|
||||
// prin modulul PKCS#11 al producatorului.
|
||||
//
|
||||
// CE NU FACE (spus, nu ascuns): dupa deschidere radacina e in memoria procesului, ca inainte (HSM-ul nu face operatiile hibride
|
||||
// ML-KEM/ML-DSA: niciun HSM de pe piata la data scrierii nu le expune prin PKCS#11 standard); `pkcs11-tool` lucreaza cu fisiere, deci
|
||||
// radacina trece cateva milisecunde printr-un fisier din /dev/shm (memorie, nu disc), umplut cu zerouri si sters imediat. Integritatea:
|
||||
// mecanismul folosit e AES-CBC-PAD (OpenSC 0.25 nu expune AES-GCM la cifrare), deci radacina se sigileaza impreuna cu 16 octeti din
|
||||
// amprenta ei; o sigilare atinsa sau deschisa cu alta cheie da REFUZ numit, nu o radacina gresita (care oricum ar fi refuzata de
|
||||
// verificarea radacinii din kms.mjs).
|
||||
//
|
||||
// node hsm-radacina.mjs sigileaza --modul <lib.so> --token <eticheta> --id <hex> --iesire <fisier> [--nou]
|
||||
// radacina: AERE_KMS_ROOT_KEY (o radacina existenta) sau --nou (una noua, aleatoare); PIN-ul: AERE_HSM_PIN
|
||||
// node hsm-radacina.mjs verifica <fisier> deschide sigilarea si spune doar DA/NU (nu tipareste niciodata radacina);
|
||||
// biblioteca: AERE_HSM_MODULE (trebuie sa fie exact cea din fisier), PIN-ul: AERE_HSM_PIN
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { KmsError, parseRootKey } from './kms.mjs';
|
||||
|
||||
export const FORMAT_HSM = 'aerekms-root-hsm/1';
|
||||
// 2026-09-29, revizuirea adversariala a felii HSM (pista B), inainte de publicare:
|
||||
// H1: fisierul sigilat numea biblioteca PKCS#11 (`modul`) si variabila PIN-ului (`pinEnv`), iar deschiderea le credea. Cine poate scrie
|
||||
// fisierul (sta pe disc, e facut sa stea acolo) facea KMS-ul sa incarce ORICE biblioteca la pornire (cod strain in procesul KMS) si
|
||||
// sa-i dea PIN-ul, sau alta variabila de mediu (de ex. jetonul API) drept PIN. Acum biblioteca vine din configuratia procesului
|
||||
// (AERE_HSM_MODULE, cale absoluta) si trebuie sa fie EXACT cea din fisier, verificat INAINTE de orice rulare a uneltei; variabila
|
||||
// PIN-ului e fixa (AERE_HSM_PIN), fisierul nu o poate alege.
|
||||
// H2: cele doua drumuri de refuz ale unei sigilari atinse (umplutura CBC stricata / amprenta gresita) aveau MESAJE diferite; cine poate
|
||||
// modifica fisierul si citi jurnalul pornirii avea un oracol de umplutura pe AES-CBC-PAD. Acum acelasi cod si acelasi mesaj.
|
||||
export const PIN_ENV = 'AERE_HSM_PIN';
|
||||
const MESAJ_ATINS = 'the sealed root does not open to the sealed root with this HSM key (modified, or sealed by another key)';
|
||||
const ETICHETA = /^[A-Za-z0-9._-]{1,32}$/;
|
||||
const ID_HEX = /^[0-9a-fA-F]{2,64}$/;
|
||||
const amprenta = (r) => crypto.createHash('sha256').update(Buffer.concat([Buffer.from(FORMAT_HSM + '\0', 'utf8'), r])).digest().subarray(0, 16);
|
||||
|
||||
function dosarRam() {
|
||||
const baza = fs.existsSync('/dev/shm') ? '/dev/shm' : os.tmpdir();
|
||||
return fs.mkdtempSync(path.join(baza, 'aerekms-hsm-'));
|
||||
}
|
||||
function stergeSigur(f) {
|
||||
try { const n = fs.statSync(f).size; fs.writeFileSync(f, Buffer.alloc(n)); } catch { /* nu exista */ }
|
||||
try { fs.rmSync(f, { force: true }); } catch { /* */ }
|
||||
}
|
||||
function pkcs11(unealta, args, env) {
|
||||
const r = spawnSync(unealta, args, { env, encoding: 'utf8', timeout: 60_000 });
|
||||
if (r.error && r.error.code === 'ENOENT') throw new KmsError('HSM_TOOL_MISSING', `${unealta} is not installed (OpenSC)`);
|
||||
if (r.error) throw new KmsError('HSM_TOOL_FAILED', `${unealta} could not run: ${r.error.code || 'error'}`);
|
||||
const err = (r.stderr || '') + (r.stdout || '');
|
||||
if (r.status !== 0) {
|
||||
if (/CKR_PIN_INCORRECT|CKR_PIN_LEN_RANGE|CKR_PIN_LOCKED|Login failed/i.test(err)) throw new KmsError('HSM_LOGIN_REFUSED', 'the HSM refused the PIN');
|
||||
if (/No slot|token.*not found|Can't find|No token/i.test(err)) throw new KmsError('HSM_TOKEN_NOT_FOUND', 'the HSM token or key was not found');
|
||||
throw new KmsError('HSM_OPERATION_FAILED', 'the HSM refused the operation');
|
||||
}
|
||||
return r;
|
||||
}
|
||||
function comuni({ modul, token, idCheie, pinEnv }) {
|
||||
if (typeof modul !== 'string' || !path.isAbsolute(modul)) throw new KmsError('HSM_CONFIG_INVALID', 'modul must be the absolute path of the PKCS#11 library');
|
||||
if (!ETICHETA.test(String(token || ''))) throw new KmsError('HSM_CONFIG_INVALID', 'token label must match ^[A-Za-z0-9._-]{1,32}$');
|
||||
if (!ID_HEX.test(String(idCheie || ''))) throw new KmsError('HSM_CONFIG_INVALID', 'key id must be hex');
|
||||
if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(String(pinEnv))) throw new KmsError('HSM_CONFIG_INVALID', 'pinEnv must name an environment variable');
|
||||
return ['--module', modul, '--token-label', token, '--login', '--pin', 'env:' + pinEnv, '--id', idCheie, '--mechanism', 'AES-CBC-PAD'];
|
||||
}
|
||||
|
||||
/** sigileaza o radacina de 32 de octeti cu cheia AES a HSM-ului; intoarce sigilarea (JSON), niciodata radacina */
|
||||
export function sigileazaRadacina({ modul, token, idCheie, radacina, env = process.env, unealta = 'pkcs11-tool' }) {
|
||||
const pinEnv = PIN_ENV;
|
||||
if (!Buffer.isBuffer(radacina) || radacina.length !== 32 || radacina.every((x) => x === 0)) throw new KmsError('ROOT_KEY_INVALID', 'the root to seal must be 32 non-zero bytes');
|
||||
if (!String(env[pinEnv] || '')) throw new KmsError('HSM_PIN_MISSING', `${pinEnv} is not set`);
|
||||
const a = comuni({ modul, token, idCheie, pinEnv });
|
||||
const iv = crypto.randomBytes(16);
|
||||
const d = dosarRam(); const pt = path.join(d, 'r'), ct = path.join(d, 'c');
|
||||
try {
|
||||
fs.writeFileSync(pt, Buffer.concat([radacina, amprenta(radacina)]), { mode: 0o600 });
|
||||
pkcs11(unealta, [...a, '--encrypt', '--iv', iv.toString('hex'), '--input-file', pt, '--output-file', ct], env);
|
||||
const c = fs.readFileSync(ct);
|
||||
if (c.length !== 64) throw new KmsError('HSM_OPERATION_FAILED', `unexpected sealed length ${c.length}`);
|
||||
return { format: FORMAT_HSM, mecanism: 'AES-CBC-PAD', modul, token, idCheie: idCheie.toLowerCase(), pinEnv, iv: iv.toString('hex'), ct: c.toString('hex'), creat: new Date().toISOString() };
|
||||
} finally { stergeSigur(pt); stergeSigur(ct); fs.rmSync(d, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
/** deschide o sigilare cu HSM-ul; intoarce radacina (Buffer de 32) sau arunca un refuz NUMIT */
|
||||
export function deschideRadacina(sig, { env = process.env, unealta = 'pkcs11-tool' } = {}) {
|
||||
if (!sig || sig.format !== FORMAT_HSM || sig.mecanism !== 'AES-CBC-PAD') throw new KmsError('HSM_SEAL_INVALID', `the sealed root is not ${FORMAT_HSM}`);
|
||||
if (!/^[0-9a-f]{32}$/.test(String(sig.iv)) || !/^[0-9a-f]{128}$/.test(String(sig.ct))) throw new KmsError('HSM_SEAL_INVALID', 'the sealed root has a malformed iv or ciphertext');
|
||||
// H1: biblioteca si variabila PIN-ului NU se iau din fisier; nimic nu ruleaza pana nu trec ambele verificari
|
||||
const permis = String(env.AERE_HSM_MODULE || '').trim();
|
||||
if (!permis || !path.isAbsolute(permis)) throw new KmsError('HSM_CONFIG_INVALID', 'AERE_HSM_MODULE must name the PKCS#11 library (absolute path); the sealed file alone does not choose it');
|
||||
if (sig.modul !== permis) throw new KmsError('HSM_MODULE_NOT_ALLOWED', 'the sealed root names a PKCS#11 library other than AERE_HSM_MODULE; it is not loaded');
|
||||
if (sig.pinEnv !== undefined && sig.pinEnv !== PIN_ENV) throw new KmsError('HSM_SEAL_INVALID', `the PIN is always read from ${PIN_ENV}; a sealed file cannot choose another variable`);
|
||||
const pinEnv = PIN_ENV;
|
||||
if (!String(env[pinEnv] || '')) throw new KmsError('HSM_PIN_MISSING', `${pinEnv} is not set`);
|
||||
const a = comuni({ modul: permis, token: sig.token, idCheie: sig.idCheie, pinEnv });
|
||||
const d = dosarRam(); const ct = path.join(d, 'c'), pt = path.join(d, 'r');
|
||||
try {
|
||||
fs.writeFileSync(ct, Buffer.from(sig.ct, 'hex'), { mode: 0o600 });
|
||||
try { pkcs11(unealta, [...a, '--decrypt', '--iv', sig.iv, '--input-file', ct, '--output-file', pt], env); }
|
||||
catch (e) { if (e.code === 'HSM_OPERATION_FAILED') throw new KmsError('HSM_ROOT_TAMPERED', MESAJ_ATINS); throw e; }
|
||||
const p = fs.readFileSync(pt);
|
||||
const r = Buffer.from(p.subarray(0, 32)); const t = p.subarray(32);
|
||||
const bun = p.length === 48 && crypto.timingSafeEqual(t, amprenta(r));
|
||||
p.fill(0);
|
||||
if (!bun) { r.fill(0); throw new KmsError('HSM_ROOT_TAMPERED', MESAJ_ATINS); }
|
||||
return r;
|
||||
} finally { stergeSigur(ct); stergeSigur(pt); fs.rmSync(d, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
/** din mediu: AERE_KMS_ROOT_HSM (calea sigilarii) -> radacina in hex, exact forma pe care o primea kms.mjs din AERE_KMS_ROOT_KEY */
|
||||
export function radacinaDinHsm(env, optiuni = {}) {
|
||||
const f = String(env.AERE_KMS_ROOT_HSM || '').trim();
|
||||
let sig;
|
||||
try { sig = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { throw new KmsError('HSM_SEAL_INVALID', 'AERE_KMS_ROOT_HSM does not point to a readable sealed root'); }
|
||||
const r = deschideRadacina(sig, { env, ...optiuni });
|
||||
const h = r.toString('hex'); r.fill(0);
|
||||
return h;
|
||||
}
|
||||
|
||||
async function cli(argv) {
|
||||
const [cmd, ...rest] = argv;
|
||||
const opt = (n) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : undefined; };
|
||||
try {
|
||||
if (cmd === 'sigileaza') {
|
||||
const iesire = opt('--iesire'); if (!iesire) throw new KmsError('HSM_CONFIG_INVALID', '--iesire <file> is required');
|
||||
if (fs.existsSync(iesire)) throw new KmsError('HSM_CONFIG_INVALID', 'the output file exists; a sealed root is never overwritten');
|
||||
const radacina = rest.includes('--nou') ? crypto.randomBytes(32) : parseRootKey(process.env.AERE_KMS_ROOT_KEY);
|
||||
const sig = sigileazaRadacina({ modul: opt('--modul'), token: opt('--token'), idCheie: opt('--id'), radacina });
|
||||
radacina.fill(0);
|
||||
fs.writeFileSync(iesire, JSON.stringify(sig, null, 1) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(`sealed root written to ${iesire} (${FORMAT_HSM}, token ${sig.token}, key id ${sig.idCheie})`);
|
||||
} else if (cmd === 'verifica') {
|
||||
const r = deschideRadacina(JSON.parse(fs.readFileSync(rest[0], 'utf8'))); r.fill(0);
|
||||
console.log('DA: the sealed root opens with this HSM and PIN');
|
||||
} else { console.error('usage: node hsm-radacina.mjs sigileaza --modul <lib> --token <label> --id <hex> --iesire <file> [--nou] | verifica <file> (PIN in AERE_HSM_PIN; verifica also needs AERE_HSM_MODULE)'); process.exitCode = 2; }
|
||||
} catch (e) { console.error(`NU: ${e.code || 'ERROR'}: ${e.message}`); process.exitCode = 1; }
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) cli(process.argv.slice(2));
|
||||
912
pq-kms/kms.mjs
Normal file
912
pq-kms/kms.mjs
Normal file
@ -0,0 +1,912 @@
|
||||
// kms.mjs - Aere PQ KMS: un KMS de tip "transit" (ca Vault transit), hibrid clasic + post-cuantic.
|
||||
//
|
||||
// Numai node:crypto (Node 24, OpenSSL 3.5), fara dependinte.
|
||||
//
|
||||
// chei "encrypt": X25519 + ML-KEM-768, KEM hibrid; secretul plicului se deriva cu HKDF-SHA-256
|
||||
// peste AMBELE secrete partajate si peste transcriptul ambelor encapsulari,
|
||||
// legat de nume + versiune + aad; apoi AES-256-GCM.
|
||||
// chei "sign": Ed25519 + ML-DSA-65; semnatura poarta AMBELE jumatati si verificarea le cere
|
||||
// pe amandoua.
|
||||
//
|
||||
// Cheile private stau pe disc sigilate cu AES-256-GCM sub o cheie derivata din AERE_KMS_ROOT_KEY.
|
||||
// Fara cheia radacina constructorul refuza (nu se genereaza nicio cheie in tacere).
|
||||
// Fiecare operatie scrie un rand in jurnalul de audit inlantuit (HMAC peste rand + mac-ul
|
||||
// randului anterior), fara date clare si fara material de cheie.
|
||||
//
|
||||
// Niciun mesaj de eroare nu contine material de cheie: mesajele sunt texte fixe plus nume de
|
||||
// chei si numere de versiune.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const PREFIX = 'aerekms';
|
||||
export const KEY_FORMAT = 'aerekms-key/1';
|
||||
const ZERO_MAC = '0'.repeat(64);
|
||||
|
||||
// Etichetele de domeniu. Fac parte din format: un tert care vrea interoperabilitate le
|
||||
// foloseste exact asa (README, sectiunea "Wire format").
|
||||
export const LABELS = Object.freeze({
|
||||
root: 'aerekms/v1/root',
|
||||
seal: 'aerekms/v1/seal-private',
|
||||
sealAad: 'aerekms/v1/seal',
|
||||
fileMac: 'aerekms/v1/key-file-mac',
|
||||
audit: 'aerekms/v1/audit-chain',
|
||||
rootCheck: 'aerekms/v1/root-check',
|
||||
fp: 'aerekms/v1/fingerprint',
|
||||
kem: 'aerekms/v1/hybrid-kem',
|
||||
commit: 'aerekms/v1/commit',
|
||||
aad: 'aerekms/v1/aad',
|
||||
dek: 'aerekms/v1/dek',
|
||||
sig: 'aerekms/v1/hybrid-sig',
|
||||
sigAlg: 'ed25519+ml-dsa-65',
|
||||
sigCtx: 'aerekms/v1',
|
||||
});
|
||||
|
||||
// Marimi masurate pe Node 24.14.1 / OpenSSL 3.5.5 (2026-09-25).
|
||||
const SZ = Object.freeze({ x: 32, ek: 1184, ctK: 1088, ed: 64, pkDsa: 1952, mlSig: 3309, fp: 8 });
|
||||
// Antetele SPKI sunt fixe (22 de octeti); le verificam octet cu octet, nu doar lungimea.
|
||||
const SPKI_HDR = Object.freeze({
|
||||
'ml-kem-768': Buffer.from('308204b2300b0609608648016503040402038204a100', 'hex'),
|
||||
'ml-dsa-65': Buffer.from('308207b2300b0609608648016503040312038207a100', 'hex'),
|
||||
});
|
||||
|
||||
const MAGIC_E = Buffer.from('AKM1', 'ascii');
|
||||
const MAGIC_S = Buffer.from('AKS1', 'ascii');
|
||||
const KIND_E = 0x45; // 'E'
|
||||
const KIND_S = 0x53; // 'S'
|
||||
|
||||
// Plicul de criptare:
|
||||
// magic(4) | kind(1) | version u32be(4) | fp(8) | ePub X25519(32) | ct ML-KEM(1088)
|
||||
// | aadTag(16) | commit(16) | payload AES-256-GCM (n) | gcmTag(16)
|
||||
const OFF = Object.freeze({ ver: 5, fp: 9, ePub: 17, ctK: 49, aadTag: 1137, commit: 1153, payload: 1169 });
|
||||
const ENV_MIN = OFF.payload + 16;
|
||||
// Semnatura: magic(4) | kind(1) | version(4) | fp(8) | Ed25519(64) | ML-DSA-65(3309)
|
||||
const SIG_LEN = 17 + SZ.ed + SZ.mlSig;
|
||||
|
||||
const MAX_PLAINTEXT = 1024 * 1024;
|
||||
const MAX_MESSAGE = 1024 * 1024;
|
||||
const MAX_AAD = 64 * 1024;
|
||||
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
const ENV_RE = /^aerekms:v([1-9][0-9]{0,8}):([A-Za-z0-9+/]+={0,2})$/;
|
||||
|
||||
export class KmsError extends Error {
|
||||
constructor(code, message, extra) {
|
||||
super(message);
|
||||
this.name = 'KmsError';
|
||||
this.code = code;
|
||||
if (extra) Object.assign(this, extra);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// primitive mici
|
||||
|
||||
function u32(n) {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n >>> 0);
|
||||
return b;
|
||||
}
|
||||
function lp(b) {
|
||||
const x = Buffer.from(b);
|
||||
return Buffer.concat([u32(x.length), x]);
|
||||
}
|
||||
function utf8(s) {
|
||||
return Buffer.from(s, 'utf8');
|
||||
}
|
||||
function sha256(...parts) {
|
||||
const h = crypto.createHash('sha256');
|
||||
for (const p of parts) h.update(p);
|
||||
return h.digest();
|
||||
}
|
||||
function hmac(key, ...parts) {
|
||||
const h = crypto.createHmac('sha256', key);
|
||||
for (const p of parts) h.update(p);
|
||||
return h.digest();
|
||||
}
|
||||
function hkdf(ikm, salt, info, len) {
|
||||
return Buffer.from(crypto.hkdfSync('sha256', ikm, salt, info, len));
|
||||
}
|
||||
function ctEq(a, b) {
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export function canonicalJson(v) {
|
||||
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
||||
if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']';
|
||||
const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
|
||||
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(v[k])).join(',') + '}';
|
||||
}
|
||||
|
||||
function writeFileAtomic(p, text) {
|
||||
const tmp = `${p}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
||||
const fd = fs.openSync(tmp, 'w', 0o600);
|
||||
try {
|
||||
fs.writeSync(fd, text);
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.renameSync(tmp, p);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// cheia radacina
|
||||
|
||||
// Citeste AERE_KMS_ROOT_KEY. Refuza lipsa, forma gresita si cheia de zerouri. Mesajul spune
|
||||
// cel mult LUNGIMEA valorii, niciodata valoarea.
|
||||
export function parseRootKey(value) {
|
||||
const s = value === undefined || value === null ? '' : String(value).trim();
|
||||
if (s === '') throw new KmsError('ROOT_KEY_MISSING', 'AERE_KMS_ROOT_KEY is not set; refusing to start (a root key is never generated automatically)');
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(s)) {
|
||||
throw new KmsError('ROOT_KEY_INVALID', `AERE_KMS_ROOT_KEY must be exactly 64 hexadecimal characters (32 bytes); the value given has ${s.length} characters or contains non-hex characters`);
|
||||
}
|
||||
const b = Buffer.from(s, 'hex');
|
||||
if (b.every((x) => x === 0)) throw new KmsError('ROOT_KEY_WEAK', 'AERE_KMS_ROOT_KEY is all zeros; refusing to start');
|
||||
return b;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// intrari
|
||||
|
||||
function checkName(name) {
|
||||
if (typeof name !== 'string' || !NAME_RE.test(name)) {
|
||||
throw new KmsError('INVALID_KEY_NAME', 'key name must match ^[a-z0-9][a-z0-9_-]{0,63}$');
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function toBytes(v, what, max) {
|
||||
let b;
|
||||
if (Buffer.isBuffer(v) || v instanceof Uint8Array) b = Buffer.from(v);
|
||||
else if (typeof v === 'string') b = utf8(v);
|
||||
else throw new KmsError('INVALID_INPUT', `${what} must be a Buffer, Uint8Array or string`);
|
||||
if (b.length > max) throw new KmsError(`${what.toUpperCase()}_TOO_LARGE`, `${what} is larger than ${max} bytes`);
|
||||
return b;
|
||||
}
|
||||
|
||||
function normAad(aad) {
|
||||
if (aad === undefined || aad === null) return Buffer.alloc(0);
|
||||
return toBytes(aad, 'aad', MAX_AAD);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// codificarea cheilor publice
|
||||
|
||||
function rawOkp(pub) {
|
||||
return Buffer.from(pub.export({ format: 'jwk' }).x, 'base64url');
|
||||
}
|
||||
function okpPublic(raw, crv) {
|
||||
return crypto.createPublicKey({ key: { kty: 'OKP', crv, x: Buffer.from(raw).toString('base64url') }, format: 'jwk' });
|
||||
}
|
||||
function rawFromSpki(spki, alg) {
|
||||
const hdr = SPKI_HDR[alg];
|
||||
if (!spki.subarray(0, hdr.length).equals(hdr)) throw new KmsError('INTERNAL', `unexpected SPKI encoding for ${alg}`);
|
||||
return spki.subarray(hdr.length);
|
||||
}
|
||||
|
||||
function fingerprint(kind, name, ver, pubA, pubB) {
|
||||
return sha256(utf8(LABELS.fp + '\0'), Buffer.from([kind]), lp(utf8(name)), u32(ver), pubA, pubB).subarray(0, SZ.fp);
|
||||
}
|
||||
|
||||
function sealAad(name, type, ver, fpHex) {
|
||||
return utf8(`${LABELS.sealAad}\0${name}\0${type}\0${ver}\0${fpHex}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// KEM hibrid
|
||||
|
||||
// Transcriptul leaga: numele cheii, versiunea, amprenta, cheia X25519 a destinatarului,
|
||||
// amprenta cheii ML-KEM a destinatarului, cheia X25519 efemera si textul cifrat ML-KEM.
|
||||
function kemTranscript(name, ver, fp, xPub, ekRaw, ePub, ctK) {
|
||||
return Buffer.concat([utf8(LABELS.kem + '\0'), lp(utf8(name)), u32(ver), fp, xPub, sha256(ekRaw), ePub, ctK]);
|
||||
}
|
||||
|
||||
// Secretul plicului: HKDF-SHA-256 cu IKM = ss_ML-KEM || ss_X25519 si sare = SHA-256(transcript).
|
||||
// Din el ies: cheia de angajament (commit), cheia etichetei aad si cheia AES + IV-ul, ultima
|
||||
// legata si de aad prin info.
|
||||
function deriveKeys(ssK, ssX, transcript, aad) {
|
||||
const ikm = Buffer.concat([ssK, ssX]);
|
||||
const salt = sha256(transcript);
|
||||
const commitKey = hkdf(ikm, salt, utf8(LABELS.commit), 32);
|
||||
const aadKey = hkdf(ikm, salt, utf8(LABELS.aad), 32);
|
||||
const dek = hkdf(ikm, salt, Buffer.concat([utf8(LABELS.dek + '\0'), sha256(aad)]), 44);
|
||||
ikm.fill(0);
|
||||
return { commitKey, aadTag: hmac(aadKey, aad).subarray(0, 16), key: dek.subarray(0, 32), iv: dek.subarray(32, 44), dek };
|
||||
}
|
||||
|
||||
function sigMessage(fp, ver, message) {
|
||||
return Buffer.concat([utf8(LABELS.sig + '\0' + LABELS.sigAlg + '\0'), fp, u32(ver), message]);
|
||||
}
|
||||
|
||||
function parseEnvelope(str, kind) {
|
||||
const what = kind === KIND_E ? 'ciphertext' : 'signature';
|
||||
const bad = kind === KIND_E ? 'MALFORMED_CIPHERTEXT' : 'MALFORMED_SIGNATURE';
|
||||
if (typeof str !== 'string') throw new KmsError(bad, `${what} must be a string of the form aerekms:v<version>:<base64>`);
|
||||
const m = ENV_RE.exec(str);
|
||||
if (!m) throw new KmsError(bad, `${what} is not of the form aerekms:v<version>:<base64>`);
|
||||
const verPrefix = Number(m[1]);
|
||||
const body = Buffer.from(m[2], 'base64');
|
||||
if (body.toString('base64') !== m[2]) throw new KmsError(bad, `${what} uses non-canonical base64`);
|
||||
const magic = kind === KIND_E ? MAGIC_E : MAGIC_S;
|
||||
if (body.length < 17 || !body.subarray(0, 4).equals(magic) || body[4] !== kind) {
|
||||
throw new KmsError(bad, `${what} does not carry the expected ${kind === KIND_E ? 'AKM1/E' : 'AKS1/S'} header`);
|
||||
}
|
||||
const verBody = body.readUInt32BE(OFF.ver);
|
||||
if (verBody !== verPrefix) {
|
||||
throw new KmsError('VERSION_MISMATCH', `the version in the prefix (v${verPrefix}) does not match the version inside the ${what} (v${verBody})`, { version: verPrefix });
|
||||
}
|
||||
return { ver: verBody, body, fp: body.subarray(OFF.fp, OFF.fp + SZ.fp) };
|
||||
}
|
||||
|
||||
function describe(key) {
|
||||
const versions = {};
|
||||
for (const v of Object.keys(key.versions)) {
|
||||
const r = key.versions[v];
|
||||
versions[v] = { created: r.created, fingerprint: r.fingerprint, public: { ...r.public } };
|
||||
}
|
||||
return {
|
||||
name: key.name,
|
||||
type: key.type,
|
||||
created: key.created,
|
||||
policy: { ...key.policy },
|
||||
min_decryption_version: key.min_decryption_version,
|
||||
latest_version: key.latest_version,
|
||||
versions,
|
||||
};
|
||||
}
|
||||
|
||||
const VERIFY_REFUSALS = new Set(['MALFORMED_SIGNATURE', 'VERSION_MISMATCH', 'UNKNOWN_VERSION', 'VERSION_BELOW_MINIMUM', 'KEY_MISMATCH']);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export function openKms(opts) {
|
||||
return new Kms(opts);
|
||||
}
|
||||
|
||||
export class Kms {
|
||||
#dir;
|
||||
#keysDir;
|
||||
#auditPath;
|
||||
#sealKey;
|
||||
#fileKey;
|
||||
#auditKey;
|
||||
#head;
|
||||
#keys = new Map();
|
||||
#priv = new Map();
|
||||
#pub = new Map();
|
||||
#closed = false;
|
||||
|
||||
constructor({ dataDir, rootKey } = {}) {
|
||||
// Intai cheia radacina: fara ea nu se atinge discul deloc.
|
||||
const root = parseRootKey(rootKey);
|
||||
if (typeof dataDir !== 'string' || dataDir === '') {
|
||||
root.fill(0);
|
||||
throw new KmsError('DATA_DIR_MISSING', 'dataDir is required');
|
||||
}
|
||||
const sub = (label) => hkdf(root, utf8(LABELS.root), utf8(label), 32);
|
||||
this.#sealKey = sub(LABELS.seal);
|
||||
this.#fileKey = sub(LABELS.fileMac);
|
||||
this.#auditKey = sub(LABELS.audit);
|
||||
const checkKey = sub(LABELS.rootCheck);
|
||||
root.fill(0);
|
||||
|
||||
this.#dir = path.resolve(dataDir);
|
||||
this.#keysDir = path.join(this.#dir, 'keys');
|
||||
this.#auditPath = path.join(this.#dir, 'audit.log');
|
||||
fs.mkdirSync(this.#keysDir, { recursive: true, mode: 0o700 });
|
||||
this.#checkRoot(checkKey);
|
||||
|
||||
const v = this.verifyAudit();
|
||||
if (!v.ok) {
|
||||
throw new KmsError('AUDIT_CHAIN_INVALID', `the audit log failed verification at line ${v.line} (${v.reason}); refusing to start. Move audit.log aside (keep it as evidence) to start a new chain.`);
|
||||
}
|
||||
this.#head = { seq: v.head.seq, mac: v.head.mac };
|
||||
}
|
||||
|
||||
get dataDir() {
|
||||
return this.#dir;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#closed = true;
|
||||
this.#priv.clear();
|
||||
this.#keys.clear();
|
||||
this.#pub.clear();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------- radacina si fisiere
|
||||
|
||||
#checkRoot(checkKey) {
|
||||
const p = path.join(this.#dir, 'root-check.json');
|
||||
const expected = hmac(checkKey, utf8('aerekms root key check')).toString('hex');
|
||||
checkKey.fill(0);
|
||||
if (fs.existsSync(p)) {
|
||||
let rec;
|
||||
try {
|
||||
rec = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
} catch {
|
||||
throw new KmsError('ROOT_CHECK_UNREADABLE', 'root-check.json is not valid JSON; refusing to start');
|
||||
}
|
||||
if (!rec || typeof rec.check !== 'string' || !ctEq(utf8(rec.check), utf8(expected))) {
|
||||
throw new KmsError('ROOT_KEY_MISMATCH', 'AERE_KMS_ROOT_KEY does not match the key this data directory was created with; refusing to start');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const existing = fs.readdirSync(this.#keysDir).filter((f) => f.endsWith('.json'));
|
||||
if (existing.length > 0 || fs.existsSync(this.#auditPath)) {
|
||||
throw new KmsError('ROOT_CHECK_MISSING', 'the data directory has keys or an audit log but no root-check.json; refusing to start');
|
||||
}
|
||||
writeFileAtomic(p, JSON.stringify({ format: 'aerekms-root-check/1', check: expected }) + '\n');
|
||||
}
|
||||
|
||||
#keyPath(name) {
|
||||
return path.join(this.#keysDir, `${name}.json`);
|
||||
}
|
||||
|
||||
#fileMac(obj) {
|
||||
return hmac(this.#fileKey, utf8(canonicalJson(obj))).toString('hex');
|
||||
}
|
||||
|
||||
#load(name, wantType) {
|
||||
checkName(name);
|
||||
let key = this.#keys.get(name);
|
||||
if (!key) {
|
||||
const p = this.#keyPath(name);
|
||||
if (!fs.existsSync(p)) throw new KmsError('KEY_NOT_FOUND', `key "${name}" does not exist`);
|
||||
let rec;
|
||||
try {
|
||||
rec = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
} catch {
|
||||
throw new KmsError('KEY_FILE_TAMPERED', `the key file for "${name}" is not valid JSON`);
|
||||
}
|
||||
const { mac, ...rest } = rec || {};
|
||||
const macOk = typeof mac === 'string' && ctEq(utf8(mac), utf8(this.#fileMac(rest)));
|
||||
if (!macOk) throw new KmsError('KEY_FILE_TAMPERED', `the key file for "${name}" failed its integrity check`);
|
||||
if (rest.name !== name || rest.format !== KEY_FORMAT) {
|
||||
throw new KmsError('KEY_FILE_TAMPERED', `the key file for "${name}" belongs to another key or format`);
|
||||
}
|
||||
key = rest;
|
||||
this.#keys.set(name, key);
|
||||
}
|
||||
if (wantType && key.type !== wantType) {
|
||||
throw new KmsError('WRONG_KEY_TYPE', `key "${name}" is a ${key.type} key; this operation needs a ${wantType} key`);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
#save(key) {
|
||||
const rec = { ...key, mac: this.#fileMac(key) };
|
||||
writeFileAtomic(this.#keyPath(key.name), JSON.stringify(rec, null, 2) + '\n');
|
||||
this.#keys.set(key.name, key);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------- sigilarea privatelor
|
||||
|
||||
#seal(plain, aad) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const c = crypto.createCipheriv('aes-256-gcm', this.#sealKey, iv);
|
||||
c.setAAD(aad);
|
||||
const ct = Buffer.concat([c.update(plain), c.final()]);
|
||||
plain.fill(0);
|
||||
return Buffer.concat([iv, ct, c.getAuthTag()]).toString('base64');
|
||||
}
|
||||
|
||||
#unseal(sealed, aad) {
|
||||
try {
|
||||
const b = Buffer.from(sealed, 'base64');
|
||||
const d = crypto.createDecipheriv('aes-256-gcm', this.#sealKey, b.subarray(0, 12));
|
||||
d.setAAD(aad);
|
||||
d.setAuthTag(b.subarray(b.length - 16));
|
||||
return Buffer.concat([d.update(b.subarray(12, b.length - 16)), d.final()]);
|
||||
} catch {
|
||||
throw new KmsError('SEAL_AUTH_FAILED', 'a stored private key could not be unsealed (wrong root key or modified key file)');
|
||||
}
|
||||
}
|
||||
|
||||
#newVersion(key) {
|
||||
const ver = key.latest_version + 1;
|
||||
let kind, pubA, pubB, derA, derB, pub;
|
||||
if (key.type === 'encrypt') {
|
||||
kind = KIND_E;
|
||||
const a = crypto.generateKeyPairSync('x25519');
|
||||
const b = crypto.generateKeyPairSync('ml-kem-768');
|
||||
const spki = b.publicKey.export({ format: 'der', type: 'spki' });
|
||||
pubA = rawOkp(a.publicKey);
|
||||
pubB = rawFromSpki(spki, 'ml-kem-768');
|
||||
pub = { x25519: pubA.toString('base64'), ml_kem_768: spki.toString('base64') };
|
||||
derA = a.privateKey.export({ format: 'der', type: 'pkcs8' });
|
||||
derB = b.privateKey.export({ format: 'der', type: 'pkcs8' });
|
||||
} else {
|
||||
kind = KIND_S;
|
||||
const a = crypto.generateKeyPairSync('ed25519');
|
||||
const b = crypto.generateKeyPairSync('ml-dsa-65');
|
||||
const spki = b.publicKey.export({ format: 'der', type: 'spki' });
|
||||
pubA = rawOkp(a.publicKey);
|
||||
pubB = rawFromSpki(spki, 'ml-dsa-65');
|
||||
pub = { ed25519: pubA.toString('base64'), ml_dsa_65: spki.toString('base64') };
|
||||
derA = a.privateKey.export({ format: 'der', type: 'pkcs8' });
|
||||
derB = b.privateKey.export({ format: 'der', type: 'pkcs8' });
|
||||
}
|
||||
const fp = fingerprint(kind, key.name, ver, pubA, pubB);
|
||||
const fpHex = fp.toString('hex');
|
||||
const privJson = JSON.stringify({ a: derA.toString('base64'), b: derB.toString('base64') });
|
||||
const sealed = this.#seal(utf8(privJson), sealAad(key.name, key.type, ver, fpHex));
|
||||
derA.fill(0);
|
||||
derB.fill(0);
|
||||
key.versions[String(ver)] = {
|
||||
created: new Date().toISOString(),
|
||||
fingerprint: fpHex,
|
||||
public: pub,
|
||||
private_sealed: sealed,
|
||||
};
|
||||
key.latest_version = ver;
|
||||
return ver;
|
||||
}
|
||||
|
||||
#privateKeys(key, ver) {
|
||||
const id = `${key.name}:${ver}`;
|
||||
const cached = this.#priv.get(id);
|
||||
if (cached) return cached;
|
||||
const v = key.versions[String(ver)];
|
||||
const plain = this.#unseal(v.private_sealed, sealAad(key.name, key.type, ver, v.fingerprint));
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(plain.toString('utf8'));
|
||||
} catch {
|
||||
throw new KmsError('SEAL_AUTH_FAILED', 'a stored private key has an unexpected form');
|
||||
} finally {
|
||||
plain.fill(0);
|
||||
}
|
||||
const derA = Buffer.from(obj.a, 'base64');
|
||||
const derB = Buffer.from(obj.b, 'base64');
|
||||
let k;
|
||||
try {
|
||||
k = {
|
||||
a: crypto.createPrivateKey({ key: derA, format: 'der', type: 'pkcs8' }),
|
||||
b: crypto.createPrivateKey({ key: derB, format: 'der', type: 'pkcs8' }),
|
||||
};
|
||||
} catch {
|
||||
throw new KmsError('SEAL_AUTH_FAILED', 'a stored private key could not be imported');
|
||||
} finally {
|
||||
derA.fill(0);
|
||||
derB.fill(0);
|
||||
}
|
||||
const want = key.type === 'encrypt' ? ['x25519', 'ml-kem-768'] : ['ed25519', 'ml-dsa-65'];
|
||||
if (k.a.asymmetricKeyType !== want[0] || k.b.asymmetricKeyType !== want[1]) {
|
||||
throw new KmsError('SEAL_AUTH_FAILED', 'a stored private key has the wrong algorithm');
|
||||
}
|
||||
this.#priv.set(id, k);
|
||||
return k;
|
||||
}
|
||||
|
||||
#publicKeys(key, ver) {
|
||||
const id = `${key.name}:${ver}`;
|
||||
const cached = this.#pub.get(id);
|
||||
if (cached) return cached;
|
||||
const v = key.versions[String(ver)];
|
||||
let r;
|
||||
if (key.type === 'encrypt') {
|
||||
const rawA = Buffer.from(v.public.x25519, 'base64');
|
||||
const spki = Buffer.from(v.public.ml_kem_768, 'base64');
|
||||
r = { rawA, rawB: rawFromSpki(spki, 'ml-kem-768'), a: okpPublic(rawA, 'X25519'), b: crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' }) };
|
||||
} else {
|
||||
const rawA = Buffer.from(v.public.ed25519, 'base64');
|
||||
const spki = Buffer.from(v.public.ml_dsa_65, 'base64');
|
||||
r = { rawA, rawB: rawFromSpki(spki, 'ml-dsa-65'), a: okpPublic(rawA, 'Ed25519'), b: crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' }) };
|
||||
}
|
||||
r.fp = Buffer.from(v.fingerprint, 'hex');
|
||||
this.#pub.set(id, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------- versiuni
|
||||
|
||||
#checkVersion(key, ver) {
|
||||
if (!Object.hasOwn(key.versions, String(ver))) {
|
||||
throw new KmsError('UNKNOWN_VERSION', `key "${key.name}" has no version ${ver}`, { version: ver });
|
||||
}
|
||||
if (ver < key.min_decryption_version) throw new KmsError('VERSION_BELOW_MINIMUM', `version ${ver} of key "${key.name}" is below min_decryption_version ${key.min_decryption_version}`, { version: ver });
|
||||
}
|
||||
|
||||
#checkFingerprint(key, ver, fp, what) {
|
||||
const fpExpected = Buffer.from(key.versions[String(ver)].fingerprint, 'hex');
|
||||
if (!fp.equals(fpExpected)) {
|
||||
const other = Object.keys(key.versions).find((v) => Buffer.from(key.versions[v].fingerprint, 'hex').equals(fp));
|
||||
if (other !== undefined) {
|
||||
throw new KmsError('VERSION_MISMATCH', `the ${what} was produced by version ${other} of key "${key.name}", not by version ${ver}`, { version: ver });
|
||||
}
|
||||
throw new KmsError('KEY_MISMATCH', `the ${what} was not produced by key "${key.name}"`, { version: ver });
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------- plicul
|
||||
|
||||
#encryptWith(key, pt, aad) {
|
||||
const ver = key.latest_version;
|
||||
const pk = this.#publicKeys(key, ver);
|
||||
const eph = crypto.generateKeyPairSync('x25519');
|
||||
const ePub = rawOkp(eph.publicKey);
|
||||
const ssX = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: pk.a });
|
||||
const { sharedKey: ssK, ciphertext: ctK } = crypto.encapsulate(pk.b);
|
||||
const d = deriveKeys(ssK, ssX, kemTranscript(key.name, ver, pk.fp, pk.rawA, pk.rawB, ePub, ctK), aad);
|
||||
ssX.fill(0);
|
||||
ssK.fill(0);
|
||||
const pre = Buffer.concat([MAGIC_E, Buffer.from([KIND_E]), u32(ver), pk.fp, ePub, ctK, d.aadTag]);
|
||||
const commit = hmac(d.commitKey, pre).subarray(0, 16);
|
||||
const hdr = Buffer.concat([pre, commit]);
|
||||
const c = crypto.createCipheriv('aes-256-gcm', d.key, d.iv);
|
||||
c.setAAD(hdr);
|
||||
const ct = Buffer.concat([c.update(pt), c.final()]);
|
||||
const body = Buffer.concat([hdr, ct, c.getAuthTag()]);
|
||||
d.dek.fill(0);
|
||||
return { ciphertext: `${PREFIX}:v${ver}:${body.toString('base64')}`, version: ver };
|
||||
}
|
||||
|
||||
#decryptWith(key, ctStr, aad) {
|
||||
const { ver, body, fp } = parseEnvelope(ctStr, KIND_E);
|
||||
try {
|
||||
if (body.length < ENV_MIN) throw new KmsError('MALFORMED_CIPHERTEXT', 'ciphertext is too short');
|
||||
this.#checkVersion(key, ver);
|
||||
this.#checkFingerprint(key, ver, fp, 'ciphertext');
|
||||
const pk = this.#publicKeys(key, ver);
|
||||
const sk = this.#privateKeys(key, ver);
|
||||
const ePub = body.subarray(OFF.ePub, OFF.ePub + SZ.x);
|
||||
const ctK = body.subarray(OFF.ctK, OFF.ctK + SZ.ctK);
|
||||
const aadTag = body.subarray(OFF.aadTag, OFF.aadTag + 16);
|
||||
const commit = body.subarray(OFF.commit, OFF.commit + 16);
|
||||
let ssX, ssK;
|
||||
try {
|
||||
ssX = crypto.diffieHellman({ privateKey: sk.a, publicKey: okpPublic(ePub, 'X25519') });
|
||||
ssK = crypto.decapsulate(sk.b, ctK);
|
||||
} catch {
|
||||
throw new KmsError('HEADER_AUTH_FAILED', 'the ciphertext header or key encapsulation was modified, or it was not produced for this key version');
|
||||
}
|
||||
const d = deriveKeys(ssK, ssX, kemTranscript(key.name, ver, pk.fp, pk.rawA, pk.rawB, ePub, ctK), aad);
|
||||
ssX.fill(0);
|
||||
ssK.fill(0);
|
||||
try {
|
||||
if (!ctEq(hmac(d.commitKey, body.subarray(0, OFF.commit)).subarray(0, 16), commit)) {
|
||||
throw new KmsError('HEADER_AUTH_FAILED', 'the ciphertext header or key encapsulation was modified, or it was not produced for this key version');
|
||||
}
|
||||
if (!ctEq(d.aadTag, aadTag)) {
|
||||
throw new KmsError('AAD_MISMATCH', 'the additional authenticated data (aad) does not match the one used at encryption');
|
||||
}
|
||||
let plaintext;
|
||||
try {
|
||||
const dc = crypto.createDecipheriv('aes-256-gcm', d.key, d.iv);
|
||||
dc.setAAD(body.subarray(0, OFF.payload));
|
||||
dc.setAuthTag(body.subarray(body.length - 16));
|
||||
plaintext = Buffer.concat([dc.update(body.subarray(OFF.payload, body.length - 16)), dc.final()]);
|
||||
} catch {
|
||||
throw new KmsError('PAYLOAD_AUTH_FAILED', 'the encrypted payload or its authentication tag was modified');
|
||||
}
|
||||
return { plaintext, version: ver };
|
||||
} finally {
|
||||
d.dek.fill(0);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof KmsError && e.version === undefined) e.version = ver;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
#verifyWith(key, msg, signature) {
|
||||
const { ver, body, fp } = parseEnvelope(signature, KIND_S);
|
||||
if (body.length !== SIG_LEN) throw new KmsError('MALFORMED_SIGNATURE', `signature must be exactly ${SIG_LEN} bytes after base64 decoding`, { version: ver });
|
||||
this.#checkVersion(key, ver);
|
||||
this.#checkFingerprint(key, ver, fp, 'signature');
|
||||
const pk = this.#publicKeys(key, ver);
|
||||
const m = sigMessage(pk.fp, ver, msg);
|
||||
const edSig = body.subarray(17, 17 + SZ.ed);
|
||||
const mlSig = body.subarray(17 + SZ.ed);
|
||||
const safe = (f) => {
|
||||
try {
|
||||
return f() === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const edOk = safe(() => crypto.verify(null, m, pk.a, edSig));
|
||||
const pqOk = safe(() => crypto.verify(null, m, { key: pk.b, context: utf8(LABELS.sigCtx) }, mlSig));
|
||||
const valid = edOk && pqOk;
|
||||
let reason = null;
|
||||
if (!edOk && !pqOk) reason = 'BOTH_SIGNATURES_INVALID';
|
||||
else if (!edOk) reason = 'CLASSICAL_SIGNATURE_INVALID';
|
||||
else if (!pqOk) reason = 'PQ_SIGNATURE_INVALID';
|
||||
return { valid, reason, version: ver, classical: edOk, post_quantum: pqOk };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------- jurnalul de audit
|
||||
|
||||
#audit(e) {
|
||||
const row = {
|
||||
seq: this.#head.seq + 1,
|
||||
ts: new Date().toISOString(),
|
||||
op: e.op,
|
||||
key: e.key ?? null,
|
||||
version: e.version ?? null,
|
||||
ok: e.ok === true,
|
||||
reason: e.reason ?? null,
|
||||
prev: this.#head.mac,
|
||||
};
|
||||
row.mac = hmac(this.#auditKey, utf8(canonicalJson(row))).toString('hex');
|
||||
try {
|
||||
const fd = fs.openSync(this.#auditPath, 'a', 0o600);
|
||||
try {
|
||||
fs.writeSync(fd, JSON.stringify(row) + '\n');
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
throw new KmsError('AUDIT_WRITE_FAILED', 'the audit log could not be written; the result of the operation is withheld');
|
||||
}
|
||||
this.#head = { seq: row.seq, mac: row.mac };
|
||||
}
|
||||
|
||||
// Ruleaza o operatie si scrie randul ei de audit. fn intoarce { value, version, ok?, reason? }.
|
||||
// Rezultatul nu iese din functie decat dupa ce randul de audit e scris.
|
||||
#run(op, name, fn) {
|
||||
if (this.#closed) throw new KmsError('KMS_CLOSED', 'this KMS instance was closed');
|
||||
const keyName = typeof name === 'string' && NAME_RE.test(name) ? name : null;
|
||||
// A4 (revizuirea din 2026-09-25): o MUTATIE (creare, rotire, minim de versiune) ramanea pe disc cand randul de audit nu se
|
||||
// putea scrie, iar lantul de audit ramanea valid fara nicio urma a ei. Regula e "o schimbare sta numai daca randul ei sta":
|
||||
// starea fisierului cheii se retine INAINTE de operatie si se pune la loc daca randul nu se poate scrie.
|
||||
const keyFile = keyName ? this.#keyPath(keyName) : null;
|
||||
const before = keyFile && fs.existsSync(keyFile) ? fs.readFileSync(keyFile) : null;
|
||||
const rollback = () => {
|
||||
if (!keyFile) return;
|
||||
const after = fs.existsSync(keyFile) ? fs.readFileSync(keyFile) : null;
|
||||
const changed = (before === null) !== (after === null) || (before !== null && after !== null && !before.equals(after));
|
||||
if (!changed) return;
|
||||
if (before === null) fs.rmSync(keyFile, { force: true });
|
||||
else writeFileAtomic(keyFile, before);
|
||||
this.#keys.delete(keyName);
|
||||
};
|
||||
let r;
|
||||
try {
|
||||
r = fn();
|
||||
} catch (e0) {
|
||||
let e = e0;
|
||||
if (!(e instanceof KmsError)) {
|
||||
e = new KmsError('INTERNAL', 'internal error');
|
||||
Object.defineProperty(e, 'cause', { value: e0, enumerable: false });
|
||||
}
|
||||
try {
|
||||
this.#audit({ op, key: keyName, version: e.version ?? null, ok: false, reason: e.code });
|
||||
} catch (ea) {
|
||||
rollback();
|
||||
throw ea;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
this.#audit({ op, key: keyName, version: r.version ?? null, ok: r.ok ?? true, reason: r.reason ?? null });
|
||||
} catch (ea) {
|
||||
rollback();
|
||||
throw ea;
|
||||
}
|
||||
return r.value;
|
||||
}
|
||||
|
||||
auditHead() {
|
||||
return { seq: this.#head.seq, mac: this.#head.mac };
|
||||
}
|
||||
|
||||
// Verifica tot jurnalul: fiecare rand trebuie sa aiba mac-ul corect, numarul de ordine urmator
|
||||
// si mac-ul randului anterior. expectedHead = un cap {seq, mac} tinut in afara (prinde taierea
|
||||
// cozii, pe care un lant singur nu o poate vedea).
|
||||
verifyAudit({ expectedHead } = {}) {
|
||||
const fail = (reason, line, extra) => ({ ok: false, reason, line, ...extra });
|
||||
let lines = [];
|
||||
if (fs.existsSync(this.#auditPath)) {
|
||||
const text = fs.readFileSync(this.#auditPath, 'utf8');
|
||||
if (text !== '') {
|
||||
lines = text.split('\n');
|
||||
if (lines[lines.length - 1] === '') lines.pop();
|
||||
else return fail('AUDIT_ROW_UNPARSABLE', lines.length);
|
||||
}
|
||||
}
|
||||
let prev = ZERO_MAC;
|
||||
let seq = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let row;
|
||||
try {
|
||||
row = JSON.parse(lines[i]);
|
||||
} catch {
|
||||
return fail('AUDIT_ROW_UNPARSABLE', i + 1);
|
||||
}
|
||||
if (!row || typeof row !== 'object' || Array.isArray(row)) return fail('AUDIT_ROW_UNPARSABLE', i + 1);
|
||||
const { mac, ...rest } = row;
|
||||
const macOk = typeof mac === 'string' && ctEq(utf8(mac), utf8(hmac(this.#auditKey, utf8(canonicalJson(rest))).toString('hex')));
|
||||
if (!macOk) return fail('AUDIT_ROW_MODIFIED', i + 1);
|
||||
if (row.seq !== seq + 1 || row.prev !== prev) return fail('AUDIT_CHAIN_BROKEN', i + 1);
|
||||
prev = mac;
|
||||
seq = row.seq;
|
||||
}
|
||||
const head = { seq, mac: prev };
|
||||
if (expectedHead) {
|
||||
if (seq < expectedHead.seq) return fail('AUDIT_TRUNCATED', lines.length, { head });
|
||||
const row = JSON.parse(lines[expectedHead.seq]);
|
||||
if (row.mac !== expectedHead.mac) return fail('AUDIT_HEAD_MISMATCH', expectedHead.seq + 1, { head });
|
||||
}
|
||||
return { ok: true, rows: lines.length, head };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------- cheile
|
||||
|
||||
createKey(name, { type, exportable = false } = {}) {
|
||||
return this.#run('create_key', name, () => {
|
||||
checkName(name);
|
||||
if (type !== 'encrypt' && type !== 'sign') throw new KmsError('INVALID_KEY_TYPE', 'type must be "encrypt" or "sign"');
|
||||
if (typeof exportable !== 'boolean') throw new KmsError('INVALID_POLICY', 'exportable must be a boolean');
|
||||
if (this.#keys.has(name) || fs.existsSync(this.#keyPath(name))) throw new KmsError('KEY_EXISTS', `key "${name}" already exists`);
|
||||
const key = {
|
||||
format: KEY_FORMAT,
|
||||
name,
|
||||
type,
|
||||
created: new Date().toISOString(),
|
||||
policy: { exportable },
|
||||
min_decryption_version: 1,
|
||||
latest_version: 0,
|
||||
versions: {},
|
||||
};
|
||||
const ver = this.#newVersion(key);
|
||||
this.#save(key);
|
||||
return { value: describe(key), version: ver };
|
||||
});
|
||||
}
|
||||
|
||||
getKey(name) {
|
||||
return this.#run('read_key', name, () => {
|
||||
const key = this.#load(name);
|
||||
return { value: describe(key), version: key.latest_version };
|
||||
});
|
||||
}
|
||||
|
||||
listKeys() {
|
||||
return this.#run('list_keys', null, () => {
|
||||
const names = fs.readdirSync(this.#keysDir)
|
||||
.filter((f) => f.endsWith('.json'))
|
||||
.map((f) => f.slice(0, -5))
|
||||
.filter((n) => NAME_RE.test(n))
|
||||
.sort();
|
||||
return { value: names };
|
||||
});
|
||||
}
|
||||
|
||||
rotate(name) {
|
||||
return this.#run('rotate', name, () => {
|
||||
const key = structuredClone(this.#load(name));
|
||||
const ver = this.#newVersion(key);
|
||||
this.#save(key);
|
||||
return { value: describe(key), version: ver };
|
||||
});
|
||||
}
|
||||
|
||||
// Minimul se poate numai RIDICA: o versiune retrasa nu mai decripteaza si nu mai verifica.
|
||||
setMinDecryptionVersion(name, minVersion) {
|
||||
return this.#run('config', name, () => {
|
||||
const key = structuredClone(this.#load(name));
|
||||
if (!Number.isSafeInteger(minVersion) || minVersion < 1 || minVersion > key.latest_version) {
|
||||
throw new KmsError('VERSION_OUT_OF_RANGE', `min_decryption_version must be an integer between 1 and ${key.latest_version}`);
|
||||
}
|
||||
if (minVersion < key.min_decryption_version) {
|
||||
throw new KmsError('MIN_VERSION_NOT_MONOTONIC', `min_decryption_version can only be raised (current: ${key.min_decryption_version})`);
|
||||
}
|
||||
key.min_decryption_version = minVersion;
|
||||
this.#save(key);
|
||||
return { value: describe(key), version: minVersion };
|
||||
});
|
||||
}
|
||||
|
||||
exportKey(name, version) {
|
||||
return this.#run('export', name, () => {
|
||||
const key = this.#load(name);
|
||||
if (key.policy.exportable !== true) throw new KmsError('KEY_NOT_EXPORTABLE', `key "${name}" was not created as exportable`);
|
||||
const ver = version === undefined || version === null ? key.latest_version : version;
|
||||
if (!Number.isSafeInteger(ver) || !Object.hasOwn(key.versions, String(ver))) {
|
||||
throw new KmsError('UNKNOWN_VERSION', `key "${name}" has no version ${ver}`);
|
||||
}
|
||||
const sk = this.#privateKeys(key, ver);
|
||||
const der = (k) => k.export({ format: 'der', type: 'pkcs8' }).toString('base64');
|
||||
const priv = key.type === 'encrypt'
|
||||
? { x25519_pkcs8: der(sk.a), ml_kem_768_pkcs8: der(sk.b) }
|
||||
: { ed25519_pkcs8: der(sk.a), ml_dsa_65_pkcs8: der(sk.b) };
|
||||
return {
|
||||
value: { name, type: key.type, version: ver, fingerprint: key.versions[String(ver)].fingerprint, private: priv },
|
||||
version: ver,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------- operatiile transit
|
||||
|
||||
encrypt(name, plaintext, aad) {
|
||||
return this.#run('encrypt', name, () => {
|
||||
const key = this.#load(name, 'encrypt');
|
||||
const pt = toBytes(plaintext, 'plaintext', MAX_PLAINTEXT);
|
||||
const r = this.#encryptWith(key, pt, normAad(aad));
|
||||
pt.fill(0);
|
||||
return { value: r, version: r.version };
|
||||
});
|
||||
}
|
||||
|
||||
decrypt(name, ciphertext, aad) {
|
||||
return this.#run('decrypt', name, () => {
|
||||
const key = this.#load(name, 'encrypt');
|
||||
const r = this.#decryptWith(key, ciphertext, normAad(aad));
|
||||
return { value: r, version: r.version };
|
||||
});
|
||||
}
|
||||
|
||||
// Reincapsuleaza la ultima versiune. Textul clar nu iese din proces: raspunsul are numai
|
||||
// textul cifrat nou.
|
||||
rewrap(name, ciphertext, aad) {
|
||||
return this.#run('rewrap', name, () => {
|
||||
const key = this.#load(name, 'encrypt');
|
||||
const aadB = normAad(aad);
|
||||
const { plaintext } = this.#decryptWith(key, ciphertext, aadB);
|
||||
try {
|
||||
const r = this.#encryptWith(key, plaintext, aadB);
|
||||
return { value: { ciphertext: r.ciphertext, version: r.version }, version: r.version };
|
||||
} finally {
|
||||
plaintext.fill(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
datakey(name, { aad, bits = 256, includePlaintext = true } = {}) {
|
||||
return this.#run('datakey', name, () => {
|
||||
const key = this.#load(name, 'encrypt');
|
||||
if (![128, 256, 512].includes(bits)) throw new KmsError('INVALID_BITS', 'bits must be 128, 256 or 512');
|
||||
if (typeof includePlaintext !== 'boolean') throw new KmsError('INVALID_INPUT', 'includePlaintext must be a boolean');
|
||||
const dk = crypto.randomBytes(bits / 8);
|
||||
try {
|
||||
const r = this.#encryptWith(key, dk, normAad(aad));
|
||||
const value = { ciphertext: r.ciphertext, version: r.version };
|
||||
if (includePlaintext) value.plaintext = dk.toString('base64');
|
||||
return { value, version: r.version };
|
||||
} finally {
|
||||
dk.fill(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sign(name, message) {
|
||||
return this.#run('sign', name, () => {
|
||||
const key = this.#load(name, 'sign');
|
||||
const msg = toBytes(message, 'message', MAX_MESSAGE);
|
||||
const ver = key.latest_version;
|
||||
const pk = this.#publicKeys(key, ver);
|
||||
const sk = this.#privateKeys(key, ver);
|
||||
const m = sigMessage(pk.fp, ver, msg);
|
||||
const edSig = crypto.sign(null, m, sk.a);
|
||||
const mlSig = crypto.sign(null, m, { key: sk.b, context: utf8(LABELS.sigCtx) });
|
||||
if (edSig.length !== SZ.ed || mlSig.length !== SZ.mlSig) throw new KmsError('INTERNAL', 'unexpected signature length');
|
||||
const body = Buffer.concat([MAGIC_S, Buffer.from([KIND_S]), u32(ver), pk.fp, edSig, mlSig]);
|
||||
return { value: { signature: `${PREFIX}:v${ver}:${body.toString('base64')}`, version: ver }, version: ver };
|
||||
});
|
||||
}
|
||||
|
||||
// Intoarce { valid, reason, version, classical, post_quantum }. valid cere AMBELE semnaturi.
|
||||
verify(name, message, signature) {
|
||||
return this.#run('verify', name, () => {
|
||||
const key = this.#load(name, 'sign');
|
||||
const msg = toBytes(message, 'message', MAX_MESSAGE);
|
||||
let r;
|
||||
try {
|
||||
r = this.#verifyWith(key, msg, signature);
|
||||
} catch (e) {
|
||||
if (e instanceof KmsError && VERIFY_REFUSALS.has(e.code)) {
|
||||
r = { valid: false, reason: e.code, version: e.version ?? null, classical: false, post_quantum: false };
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return { value: r, version: r.version, ok: r.valid, reason: r.reason };
|
||||
});
|
||||
}
|
||||
}
|
||||
333
pq-kms/server.mjs
Normal file
333
pq-kms/server.mjs
Normal file
@ -0,0 +1,333 @@
|
||||
// server.mjs - serverul HTTP al Aere PQ KMS (node:http, fara dependinte).
|
||||
//
|
||||
// Mediu:
|
||||
// AERE_KMS_ROOT_KEY 64 de caractere hexa (32 de octeti), SAU:
|
||||
// AERE_KMS_ROOT_HSM calea radacinii SIGILATE de un HSM (hsm-radacina.mjs), deschisa la pornire cu PIN-ul din AERE_HSM_PIN;
|
||||
// exact una din cele doua; fara niciuna refuza pornirea, cu amandoua refuza (ROOT_KEY_AMBIGUOUS)
|
||||
// AERE_KMS_TOKEN obligatoriu, cel putin 32 de caractere; se cere ca "Authorization: Bearer <token>"
|
||||
// AERE_KMS_HOST implicit 127.0.0.1
|
||||
// AERE_KMS_PORT implicit 8420
|
||||
// AERE_KMS_MAX_BODY implicit 65536 (octeti)
|
||||
// AERE_KMS_DATA_DIR implicit ./data langa acest fisier
|
||||
//
|
||||
// Nicio valoare din mediu nu se tipareste. Raspunsurile de eroare sunt { error: COD, message }.
|
||||
|
||||
import http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { openKms, parseRootKey, KmsError } from './kms.mjs';
|
||||
import { radacinaDinHsm } from './hsm-radacina.mjs';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const STATUS = {
|
||||
NOT_FOUND: 404,
|
||||
KEY_NOT_FOUND: 404,
|
||||
METHOD_NOT_ALLOWED: 405,
|
||||
KEY_EXISTS: 409,
|
||||
KEY_NOT_EXPORTABLE: 403,
|
||||
UNAUTHENTICATED: 401,
|
||||
INVALID_TOKEN: 401,
|
||||
BODY_TOO_LARGE: 413,
|
||||
UNSUPPORTED_MEDIA_TYPE: 415,
|
||||
INTERNAL: 500,
|
||||
AUDIT_WRITE_FAILED: 500,
|
||||
SEAL_AUTH_FAILED: 500,
|
||||
KEY_FILE_TAMPERED: 500,
|
||||
KMS_CLOSED: 503,
|
||||
};
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(status, code, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function configFromEnv(env, optiuniHsm = {}) {
|
||||
const token = String(env.AERE_KMS_TOKEN ?? '').trim();
|
||||
if (token === '') throw new KmsError('TOKEN_MISSING', 'AERE_KMS_TOKEN is not set; refusing to start');
|
||||
if (token.length < 32) throw new KmsError('TOKEN_TOO_SHORT', `AERE_KMS_TOKEN must be at least 32 characters; the value given has ${token.length}`);
|
||||
if (!/^[\x21-\x7e]+$/.test(token)) throw new KmsError('TOKEN_INVALID', 'AERE_KMS_TOKEN must be printable ASCII without spaces');
|
||||
// Validam cheia radacina aici, ca serverul sa nu porneasca deloc fara ea. Din 2026-09-28 poate veni si SIGILATA de un HSM.
|
||||
const inClar = String(env.AERE_KMS_ROOT_KEY ?? '').trim() !== '', dinHsm = String(env.AERE_KMS_ROOT_HSM ?? '').trim() !== '';
|
||||
if (inClar && dinHsm) throw new KmsError('ROOT_KEY_AMBIGUOUS', 'set AERE_KMS_ROOT_KEY or AERE_KMS_ROOT_HSM, not both');
|
||||
const rootHex = dinHsm ? radacinaDinHsm(env, optiuniHsm) : env.AERE_KMS_ROOT_KEY;
|
||||
const root = parseRootKey(rootHex);
|
||||
const sameAsRoot = token.toLowerCase() === root.toString('hex');
|
||||
root.fill(0);
|
||||
if (sameAsRoot) throw new KmsError('TOKEN_EQUALS_ROOT_KEY', 'AERE_KMS_TOKEN must not be the root key');
|
||||
const host = String(env.AERE_KMS_HOST ?? '').trim() || '127.0.0.1';
|
||||
const portRaw = String(env.AERE_KMS_PORT ?? '').trim() || '8420';
|
||||
const port = Number(portRaw);
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new KmsError('INVALID_PORT', 'AERE_KMS_PORT must be an integer between 0 and 65535');
|
||||
const maxRaw = String(env.AERE_KMS_MAX_BODY ?? '').trim() || '65536';
|
||||
const maxBody = Number(maxRaw);
|
||||
// Sub 8192 nu incape nici o cerere de verificare (semnatura hibrida are 4531 de caractere base64).
|
||||
if (!Number.isInteger(maxBody) || maxBody < 8192 || maxBody > 16 * 1024 * 1024) {
|
||||
throw new KmsError('INVALID_MAX_BODY', 'AERE_KMS_MAX_BODY must be an integer between 8192 and 16777216');
|
||||
}
|
||||
const dataDir = String(env.AERE_KMS_DATA_DIR ?? '').trim() || path.join(HERE, 'data');
|
||||
return { token, rootKey: rootHex, rootSource: dinHsm ? 'hsm' : 'env', host, port, maxBody, dataDir };
|
||||
}
|
||||
|
||||
function tooBig(n, maxBody) {
|
||||
return n > maxBody;
|
||||
}
|
||||
|
||||
function authCheck(req, tokenHash) {
|
||||
const header = req.headers['authorization'];
|
||||
if (typeof header !== 'string' || header === '') return 'UNAUTHENTICATED';
|
||||
const m = /^Bearer ([\x21-\x7e]+)$/.exec(header);
|
||||
if (!m) return 'INVALID_TOKEN';
|
||||
const got = crypto.createHash('sha256').update(m[1], 'utf8').digest();
|
||||
if (!crypto.timingSafeEqual(got, tokenHash)) return 'INVALID_TOKEN';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Arunca restul corpului fara sa-l tina in memorie; peste plafon inchide conexiunea.
|
||||
function discard(req, cap) {
|
||||
let n = 0;
|
||||
req.on('data', (c) => {
|
||||
n += c.length;
|
||||
if (n > cap) req.destroy();
|
||||
});
|
||||
req.on('error', () => {});
|
||||
req.resume();
|
||||
}
|
||||
|
||||
function readBody(req, maxBody) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const declared = req.headers['content-length'];
|
||||
if (declared !== undefined) {
|
||||
const n = Number(declared);
|
||||
if (!Number.isSafeInteger(n) || n < 0) return reject(new HttpError(400, 'INVALID_CONTENT_LENGTH', 'invalid Content-Length'));
|
||||
if (tooBig(n, maxBody)) return reject(new HttpError(413, 'BODY_TOO_LARGE', `request body is larger than ${maxBody} bytes`));
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
let done = false;
|
||||
req.on('data', (c) => {
|
||||
if (done) return;
|
||||
size += c.length;
|
||||
if (tooBig(size, maxBody)) {
|
||||
done = true;
|
||||
chunks.length = 0;
|
||||
reject(new HttpError(413, 'BODY_TOO_LARGE', `request body is larger than ${maxBody} bytes`));
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
resolve(Buffer.concat(chunks));
|
||||
}
|
||||
});
|
||||
req.on('error', () => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
reject(new HttpError(400, 'BAD_REQUEST', 'the request body could not be read'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(res, status, obj, close = false) {
|
||||
const body = JSON.stringify(obj);
|
||||
const headers = {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'content-length': Buffer.byteLength(body),
|
||||
'cache-control': 'no-store',
|
||||
'x-content-type-options': 'nosniff',
|
||||
};
|
||||
if (close) headers.connection = 'close';
|
||||
res.writeHead(status, headers);
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function b64Field(obj, field, { optional = false } = {}) {
|
||||
const v = obj[field];
|
||||
if (v === undefined || v === null) {
|
||||
if (optional) return undefined;
|
||||
throw new HttpError(400, 'MISSING_FIELD', `field "${field}" is required`);
|
||||
}
|
||||
if (typeof v !== 'string' || !/^[A-Za-z0-9+/]*={0,2}$/.test(v) || v.length % 4 !== 0) {
|
||||
throw new HttpError(400, 'INVALID_BASE64', `field "${field}" must be standard base64`);
|
||||
}
|
||||
const b = Buffer.from(v, 'base64');
|
||||
if (b.toString('base64') !== v) throw new HttpError(400, 'INVALID_BASE64', `field "${field}" must be canonical base64`);
|
||||
return b;
|
||||
}
|
||||
|
||||
function strField(obj, field) {
|
||||
const v = obj[field];
|
||||
if (typeof v !== 'string') throw new HttpError(400, 'MISSING_FIELD', `field "${field}" is required and must be a string`);
|
||||
return v;
|
||||
}
|
||||
|
||||
export function createServer({ kms, token, maxBody = 65536 }) {
|
||||
if (typeof token !== 'string' || token.length < 32) throw new KmsError('TOKEN_TOO_SHORT', 'token must be at least 32 characters');
|
||||
const tokenHash = crypto.createHash('sha256').update(token, 'utf8').digest();
|
||||
const drainCap = maxBody * 4 + 1024 * 1024;
|
||||
|
||||
async function handle(req, res) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(req.url, 'http://localhost');
|
||||
} catch {
|
||||
discard(req, drainCap);
|
||||
return send(res, 400, { error: 'BAD_REQUEST', message: 'invalid request URL' }, true);
|
||||
}
|
||||
const method = req.method;
|
||||
const parts = url.pathname.split('/').filter((p) => p !== '');
|
||||
|
||||
if (method === 'GET' && url.pathname === '/v1/health') {
|
||||
discard(req, drainCap);
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// Autentificarea se face INAINTE de a citi corpul.
|
||||
const authErr = authCheck(req, tokenHash);
|
||||
if (authErr) {
|
||||
discard(req, drainCap);
|
||||
return send(res, 401, { error: authErr, message: authErr === 'UNAUTHENTICATED' ? 'missing bearer token' : 'invalid bearer token' }, true);
|
||||
}
|
||||
|
||||
let body = null;
|
||||
if (method === 'POST') {
|
||||
const ctype = String(req.headers['content-type'] ?? '');
|
||||
let raw;
|
||||
try {
|
||||
raw = await readBody(req, maxBody);
|
||||
} catch (e) {
|
||||
discard(req, drainCap);
|
||||
throw e;
|
||||
}
|
||||
if (raw.length > 0 && ctype !== '' && !/^application\/json\b/i.test(ctype)) {
|
||||
throw new HttpError(415, 'UNSUPPORTED_MEDIA_TYPE', 'request body must be application/json');
|
||||
}
|
||||
if (raw.length === 0) body = {};
|
||||
else {
|
||||
try {
|
||||
body = JSON.parse(raw.toString('utf8'));
|
||||
} catch {
|
||||
throw new HttpError(400, 'INVALID_JSON', 'request body is not valid JSON');
|
||||
}
|
||||
}
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new HttpError(400, 'INVALID_JSON', 'request body must be a JSON object');
|
||||
} else {
|
||||
discard(req, drainCap);
|
||||
}
|
||||
|
||||
const name = parts[2] !== undefined ? decodeURIComponent(parts[2]) : undefined;
|
||||
if (parts[0] !== 'v1') throw new HttpError(404, 'NOT_FOUND', 'no such route');
|
||||
|
||||
// /v1/keys
|
||||
if (parts[1] === 'keys') {
|
||||
if (parts.length === 2 && method === 'GET') return send(res, 200, { keys: kms.listKeys() });
|
||||
if (parts.length === 3 && method === 'POST') {
|
||||
const exportable = body.exportable === undefined ? false : body.exportable;
|
||||
return send(res, 200, kms.createKey(name, { type: body.type, exportable }));
|
||||
}
|
||||
if (parts.length === 3 && method === 'GET') return send(res, 200, kms.getKey(name));
|
||||
if (parts.length === 4 && parts[3] === 'rotate' && method === 'POST') return send(res, 200, kms.rotate(name));
|
||||
if (parts.length === 4 && parts[3] === 'config' && method === 'POST') {
|
||||
return send(res, 200, kms.setMinDecryptionVersion(name, body.min_decryption_version));
|
||||
}
|
||||
if ((parts.length === 4 || parts.length === 5) && parts[3] === 'export' && method === 'GET') {
|
||||
let ver;
|
||||
if (parts.length === 5) {
|
||||
if (!/^[1-9][0-9]{0,8}$/.test(parts[4])) throw new HttpError(400, 'INVALID_VERSION', 'version must be a positive integer');
|
||||
ver = Number(parts[4]);
|
||||
}
|
||||
return send(res, 200, kms.exportKey(name, ver));
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 3 && method === 'POST') {
|
||||
switch (parts[1]) {
|
||||
case 'encrypt': {
|
||||
const r = kms.encrypt(name, b64Field(body, 'plaintext'), b64Field(body, 'aad', { optional: true }));
|
||||
return send(res, 200, r);
|
||||
}
|
||||
case 'decrypt': {
|
||||
const r = kms.decrypt(name, strField(body, 'ciphertext'), b64Field(body, 'aad', { optional: true }));
|
||||
const out = { plaintext: r.plaintext.toString('base64'), version: r.version };
|
||||
r.plaintext.fill(0);
|
||||
return send(res, 200, out);
|
||||
}
|
||||
case 'rewrap':
|
||||
return send(res, 200, kms.rewrap(name, strField(body, 'ciphertext'), b64Field(body, 'aad', { optional: true })));
|
||||
case 'datakey': {
|
||||
const includePlaintext = body.include_plaintext === undefined ? true : body.include_plaintext;
|
||||
const bits = body.bits === undefined ? 256 : body.bits;
|
||||
return send(res, 200, kms.datakey(name, { aad: b64Field(body, 'aad', { optional: true }), bits, includePlaintext }));
|
||||
}
|
||||
case 'sign':
|
||||
return send(res, 200, kms.sign(name, b64Field(body, 'message')));
|
||||
case 'verify':
|
||||
return send(res, 200, kms.verify(name, b64Field(body, 'message'), strField(body, 'signature')));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 3 && parts[1] === 'audit' && parts[2] === 'verify' && method === 'GET') {
|
||||
return send(res, 200, kms.verifyAudit());
|
||||
}
|
||||
|
||||
throw new HttpError(404, 'NOT_FOUND', 'no such route');
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
handle(req, res).catch((e) => {
|
||||
if (res.headersSent) {
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
if (e instanceof HttpError) return send(res, e.status, { error: e.code, message: e.message }, e.status === 413);
|
||||
if (e instanceof KmsError) return send(res, STATUS[e.code] ?? 400, { error: e.code, message: e.message });
|
||||
if (e instanceof URIError) return send(res, 400, { error: 'BAD_REQUEST', message: 'invalid percent-encoding in path' });
|
||||
return send(res, 500, { error: 'INTERNAL', message: 'internal error' });
|
||||
});
|
||||
});
|
||||
server.headersTimeout = 10_000;
|
||||
server.requestTimeout = 30_000;
|
||||
return server;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let cfg;
|
||||
let kms;
|
||||
try {
|
||||
cfg = configFromEnv(process.env);
|
||||
kms = openKms({ dataDir: cfg.dataDir, rootKey: cfg.rootKey });
|
||||
} catch (e) {
|
||||
const code = e instanceof KmsError ? e.code : 'INTERNAL';
|
||||
const msg = e instanceof KmsError ? e.message : 'internal error';
|
||||
process.stderr.write(`aere-pq-kms: refusing to start: ${code}: ${msg}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const server = createServer({ kms, token: cfg.token, maxBody: cfg.maxBody });
|
||||
server.on('error', (e) => {
|
||||
process.stderr.write(`aere-pq-kms: server error: ${e.code ?? 'ERROR'}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
server.listen(cfg.port, cfg.host, () => {
|
||||
const a = server.address();
|
||||
process.stdout.write(`aere-pq-kms: listening on ${a.address}:${a.port}\n`);
|
||||
});
|
||||
const stop = () => {
|
||||
server.close(() => kms.close());
|
||||
};
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
main();
|
||||
}
|
||||
58
pq-kms/test/control-negativ-hsm-incredere.mjs
Normal file
58
pq-kms/test/control-negativ-hsm-incredere.mjs
Normal file
@ -0,0 +1,58 @@
|
||||
// test/control-negativ-hsm-incredere.mjs (2026-09-29): controlul negativ al probei H1 (proba-hsm-incredere.mjs). Trei stari pe caz:
|
||||
// PRINS (proba a rulat pana la capat si a iesit rosie pe verificarea numita), SCAPAT, STRICAT (copia nu s-a incarcat / proba nu a rulat).
|
||||
// V0 modulul de DINAINTE de reparatie (HEAD-ul lui git la momentul scrierii: `git show <rev>:.../hsm-radacina.mjs`) -> rosu pe H1
|
||||
// P1 verificarea bibliotecii scoasa cu o conditie falsa la rulare -> rosu pe biblioteca straina
|
||||
// P2 verificarea variabilei PIN-ului scoasa cu o conditie falsa la rulare -> rosu pe jetonul API ca PIN
|
||||
// Copiile stau LANGA original (./kms.mjs trebuie sa se rezolve) si se sterg la sfarsit; originalul trebuie sa ramana octet cu octet.
|
||||
// node test/control-negativ-hsm-incredere.mjs -> 0 toate prinse, 1 unul scapa, 2 STRICAT
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = path.join(AICI, '..', 'hsm-radacina.mjs');
|
||||
const PROBA = path.join(AICI, 'proba-hsm-incredere.mjs');
|
||||
const sha = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
|
||||
const inainte = sha(SRC);
|
||||
const REV_VECHE = process.env.AERE_HSM_REV_VECHE || '7f2e7182';
|
||||
let prinse = 0, stricate = 0, total = 0;
|
||||
|
||||
function caz(id, text, tinta) {
|
||||
total++;
|
||||
const copie = path.join(AICI, '..', `.plantat-hsm-${id}.mjs`);
|
||||
try {
|
||||
fs.writeFileSync(copie, text);
|
||||
const r = spawnSync(process.execPath, [PROBA], { encoding: 'utf8', env: { ...process.env, AERE_HSM_MODUL_SUB_PROBA: copie } });
|
||||
const out = (r.stdout || '') + (r.stderr || '');
|
||||
if (!/increderea in fisierul sigilat \(H1\): \d+\/\d+/.test(out)) { stricate++; console.log(` STRICAT ${id}: proba nu a ajuns la capat (${out.trim().split('\n').pop().slice(0, 120)})`); return; }
|
||||
if (!/\[OK \] CONTROL POZITIV/.test(out)) { stricate++; console.log(` STRICAT ${id}: controlul pozitiv al probei nu a trecut pe copie`); return; }
|
||||
const rosii = out.split('\n').filter((l) => l.includes('[RAU ]'));
|
||||
if (r.status === 1 && rosii.some((l) => l.includes(tinta))) { prinse++; console.log(` PRINS ${id}: ${rosii.length} verificari rosii, intre ele "${tinta}"`); }
|
||||
else console.log(` SCAPAT ${id}: cod ${r.status}, "${tinta}" nu e rosie`);
|
||||
} finally { fs.rmSync(copie, { force: true }); }
|
||||
}
|
||||
const inlocuieste = (t, a, b) => { if (t.split(a).length !== 2) return null; return t.replace(a, b); };
|
||||
|
||||
// V0: codul vechi, din git (octetii comisi, fara conversia autocrlf)
|
||||
// In depozitul public (alt istoric) codul de dinainte nu exista: V0 se sare, spus, si nu se numara; in depozitul de lucru trebuie sa ruleze.
|
||||
const CALE_VECHE = `${REV_VECHE}:cloud-gateway/pq-kms/hsm-radacina.mjs`;
|
||||
const exista = spawnSync('git', ['cat-file', '-e', CALE_VECHE], { cwd: path.join(AICI, '..'), encoding: 'utf8' }).status === 0;
|
||||
if (!exista) console.log(` SARIT V0: codul de dinainte de reparatie (${REV_VECHE}) nu e in istoricul acestui depozit; NEMASURAT aici`);
|
||||
else {
|
||||
const g = spawnSync('git', ['-c', 'core.autocrlf=false', 'show', CALE_VECHE], { cwd: path.join(AICI, '..'), encoding: 'utf8' });
|
||||
if (g.status !== 0 || !g.stdout.includes('export function deschideRadacina')) { stricate++; total++; console.log(' STRICAT V0: nu citesc modulul vechi din git (' + REV_VECHE + ')'); }
|
||||
else caz('V0', g.stdout, 'biblioteca din fisier, alta decat AERE_HSM_MODULE');
|
||||
}
|
||||
|
||||
const nou = fs.readFileSync(SRC, 'utf8');
|
||||
const p1 = inlocuieste(nou, "if (sig.modul !== permis) throw", "if (sig.modul !== permis && process.env.AERE_PLANTA_NICIODATA === 'da') throw");
|
||||
if (!p1) { stricate++; total++; console.log(' STRICAT P1: ancora nu apare exact o data'); } else caz('P1', p1, 'biblioteca din fisier, alta decat AERE_HSM_MODULE');
|
||||
const p2 = inlocuieste(nou, "if (sig.pinEnv !== undefined && sig.pinEnv !== PIN_ENV) throw", "if (sig.pinEnv !== undefined && sig.pinEnv !== PIN_ENV && process.env.AERE_PLANTA_NICIODATA === 'da') throw");
|
||||
if (!p2) { stricate++; total++; console.log(' STRICAT P2: ancora nu apare exact o data'); } else caz('P2', p2, 'alta variabila drept PIN');
|
||||
|
||||
const ramase = fs.readdirSync(path.join(AICI, '..')).filter((f) => f.startsWith('.plantat-hsm-'));
|
||||
if (sha(SRC) !== inainte || ramase.length) { stricate++; console.log(' STRICAT originalul s-a schimbat sau au ramas copii: ' + ramase.join(',')); }
|
||||
console.log(`\ncontrolul negativ H1: prinse ${prinse}/${total}, stricate ${stricate}`);
|
||||
process.exitCode = stricate ? 2 : prinse === total ? 0 : 1;
|
||||
423
pq-kms/test/control-negativ.mjs
Normal file
423
pq-kms/test/control-negativ.mjs
Normal file
@ -0,0 +1,423 @@
|
||||
// test/control-negativ.mjs - controlul negativ al probei Aere PQ KMS.
|
||||
//
|
||||
// O proba care nu poate iesi rosie nu masoara nimic. Aici se planteaza, intr-o COPIE a codului,
|
||||
// cate un defect real, si se cere ca proba sa iasa ROSIE pe exact probele numite, pentru
|
||||
// MOTIVUL numit (un fragment din mesajul afirmatiei care a cazut), nu doar "ceva a picat".
|
||||
//
|
||||
// Reguli, platite in alte parti ale casei:
|
||||
// - verdictul se citeste din JSON-ul probei (AERE_KMS_PROBA_JSON), nu din codul de iesire;
|
||||
// o proba care nu a ajuns la capat sau a rulat alt numar de probe e STRICAT, nu ROSU;
|
||||
// - copia se reface pentru FIECARE plantare si se compara sha256 cu originalul inainte de a
|
||||
// planta (altfel s-ar masura plantarea de dinainte);
|
||||
// - ancora unei plantari trebuie sa apara EXACT o data; o plantare care nu se poate pune e un
|
||||
// ESEC al controlului, nu o trecere;
|
||||
// - plantarile se fac cu o conditie falsa la RULARE sau cu o inlocuire care ramane cod valid,
|
||||
// ca rosul sa vina din proba, nu din incarcarea modulului;
|
||||
// - la sfarsit originalul trebuie sa fie identic octet cu octet (sha256).
|
||||
//
|
||||
// Iesire: 0 numai daca proba e VERDE pe copia neatinsa, fiecare plantare e ROSIE pe probele
|
||||
// numite si originalul e neatins; altfel 1.
|
||||
//
|
||||
// --autoproba: controlul controlului. Ruleaza momeli (ancora inexistenta, plantare fara efect,
|
||||
// motiv gresit, proba inexistenta, modul care nu se incarca) si cere ca fiecare sa primeasca
|
||||
// verdictul ei de ESEC. Un control care nu a fost vazut esuand nu se poate crede.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const RADACINA = path.resolve(HERE, '..');
|
||||
// 2026-09-28: server.mjs importa si hsm-radacina.mjs (radacina sigilata de HSM); fara el copia nu mai pornea si linia de baza iesea STRICATA
|
||||
const FISIERE = ['kms.mjs', 'server.mjs', 'hsm-radacina.mjs', 'test/proba.mjs'];
|
||||
const NICIODATA = "process.env.AERE_KMS_PLANTA_NICIODATA === 'da'";
|
||||
|
||||
// Fiecare plantare: fisierul, ancora (exact o aparitie), inlocuirea, si probele care TREBUIE sa
|
||||
// iasa rosii, fiecare cu un fragment din motivul pe care trebuie sa cada.
|
||||
const PLANTARI = [
|
||||
{
|
||||
id: 'P16',
|
||||
defect: 'A4: mutatia ramane pe disc cand randul de audit nu se poate scrie (rollback-ul nu se mai face)',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: ' if (!changed) return;',
|
||||
inlocuire: " if (!changed || !(process.env.AERE_KMS_PLANTA_NICIODATA === 'da')) return;",
|
||||
rosii: [
|
||||
['audit: o mutatie al carei rand de audit nu se poate scrie NU ramane pe disc (rotire, minim, creare), iar lantul ramane valid', 'rotirea a ramas pe disc fara rand de audit'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'P01',
|
||||
defect: 'KDF fara secretul ML-KEM (IKM = numai X25519)',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: 'const ikm = Buffer.concat([ssK, ssX]);',
|
||||
inlocuire: 'const ikm = Buffer.concat([ssX]);',
|
||||
rosii: [
|
||||
['criptare: atacatorul cu doar secretul X25519 nu poate deriva cheia plicului', 'plicul s-a deschis numai cu secretul X25519'],
|
||||
['interop: plicul serviciului decapsulat si decriptat manual cu node:crypto', 'commit'],
|
||||
['interop: plic construit fara unul din secrete e refuzat de serviciu cu HEADER_AUTH_FAILED', 'KDF doar-x: trebuia refuzat'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'P02',
|
||||
defect: 'KDF fara secretul X25519 (IKM = numai ML-KEM)',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: 'const ikm = Buffer.concat([ssK, ssX]);',
|
||||
inlocuire: 'const ikm = Buffer.concat([ssK]);',
|
||||
rosii: [
|
||||
['criptare: atacatorul cu doar secretul ML-KEM nu poate deriva cheia plicului', 'plicul s-a deschis numai cu secretul ML-KEM'],
|
||||
['interop: plic construit fara unul din secrete e refuzat de serviciu cu HEADER_AUTH_FAILED', 'KDF doar-k: trebuia refuzat'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'P03',
|
||||
defect: 'verificarea accepta o singura semnatura (SAU in loc de SI)',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: 'const valid = edOk && pqOk;',
|
||||
inlocuire: 'const valid = edOk || pqOk;',
|
||||
rosii: [
|
||||
['semnare: jumatatea Ed25519 stricata e refuzata cu CLASSICAL_SIGNATURE_INVALID', 'valid: asteptat false'],
|
||||
['semnare: jumatatea ML-DSA-65 stricata e refuzata cu PQ_SIGNATURE_INVALID', 'valid: asteptat false'],
|
||||
['semnare: jumatati amestecate din doua semnaturi valide sunt refuzate', 'amestec peste "unu": asteptat false'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'P04',
|
||||
defect: 'AAD ignorat (inlocuit cu sirul gol)',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: "return toBytes(aad, 'aad', MAX_AAD);",
|
||||
inlocuire: 'return Buffer.alloc(0);',
|
||||
rosii: [
|
||||
['criptare: alt aad refuzat cu AAD_MISMATCH', 'alt aad: trebuia refuzat cu AAD_MISMATCH'],
|
||||
['datakey: cheia clara si cea invelita corespund; forma doar-invelita nu intoarce cheia clara', 'cheia de date legata de aad'],
|
||||
['rewrap: reincapsuleaza la ultima versiune, acelasi text clar, raspunsul nu contine text clar', 'rewrap cu alt aad'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'P05',
|
||||
defect: 'min_decryption_version ignorat',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: 'if (ver < key.min_decryption_version) throw',
|
||||
inlocuire: `if (ver < key.min_decryption_version && ${NICIODATA}) throw`,
|
||||
rosii: [
|
||||
['rotire: sub min_decryption_version decriptarea e refuzata cu VERSION_BELOW_MINIMUM', 'v1 sub minim: trebuia refuzat'],
|
||||
['rotire: sub min_decryption_version verificarea semnaturii e refuzata cu VERSION_BELOW_MINIMUM', 'v1 sub minim: asteptat false'],
|
||||
['server: flux complet prin HTTP (creare, criptare, decriptare, rotire, rewrap, datakey, semnare, verificare, audit)', 'sub minim prin HTTP'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'P06',
|
||||
defect: 'jurnalul scris fara legatura cu randul anterior (prev constant)',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: 'prev: this.#head.mac,',
|
||||
inlocuire: 'prev: ZERO_MAC,',
|
||||
rosii: [
|
||||
['audit: jurnalul neatins se verifica OK, un rand pe operatie, si refuzurile au motivul lor', 'jurnal neatins'],
|
||||
['audit: rand sters detectat cu AUDIT_CHAIN_BROKEN pe randul lui', 'randul: asteptat 4'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'P07',
|
||||
defect: 'verificatorul jurnalului nu verifica legatura (seq/prev)',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: 'if (row.seq !== seq + 1 || row.prev !== prev)',
|
||||
inlocuire: `if ((row.seq !== seq + 1 || row.prev !== prev) && ${NICIODATA})`,
|
||||
rosii: [
|
||||
['audit: rand sters detectat cu AUDIT_CHAIN_BROKEN pe randul lui', 'rand sters: asteptat false'],
|
||||
['audit: randuri reordonate detectate cu AUDIT_CHAIN_BROKEN', 'reordonare: asteptat false'],
|
||||
['audit: pornirea refuza un jurnal stricat cu AUDIT_CHAIN_INVALID', 'trebuia refuzat cu AUDIT_CHAIN_INVALID'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'P08',
|
||||
defect: 'verificatorul jurnalului nu verifica mac-ul randului',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: "if (!macOk) return fail('AUDIT_ROW_MODIFIED', i + 1);",
|
||||
inlocuire: `if (!macOk && ${NICIODATA}) return fail('AUDIT_ROW_MODIFIED', i + 1);`,
|
||||
rosii: [['audit: rand schimbat detectat cu AUDIT_ROW_MODIFIED', 'rand schimbat: asteptat false']],
|
||||
},
|
||||
{
|
||||
id: 'P09',
|
||||
defect: 'cheia privata scrisa si necifrata langa sigiliu',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: 'private_sealed: sealed,',
|
||||
inlocuire: "private_sealed: sealed, private_debug: Buffer.from(privJson).toString('base64'),",
|
||||
rosii: [['stocare: materialul privat nu apare in clar pe disc (cu controlul pozitiv al scanerului)', 'material privat gasit in clar']],
|
||||
},
|
||||
{
|
||||
id: 'P10',
|
||||
defect: 'integritatea fisierului de cheie neverificata',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: "if (!macOk) throw new KmsError('KEY_FILE_TAMPERED'",
|
||||
inlocuire: `if (!macOk && ${NICIODATA}) throw new KmsError('KEY_FILE_TAMPERED'`,
|
||||
rosii: [
|
||||
['stocare: min_decryption_version coborat pe disc refuzat cu KEY_FILE_TAMPERED', 'trebuia refuzat cu KEY_FILE_TAMPERED'],
|
||||
['stocare: cheie publica inlocuita pe disc refuzata cu KEY_FILE_TAMPERED', 'trebuia refuzat cu KEY_FILE_TAMPERED'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'P11',
|
||||
defect: 'fara cheie radacina se genereaza una in tacere',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: "if (s === '') throw new KmsError('ROOT_KEY_MISSING',",
|
||||
inlocuire: "if (s === '') return crypto.randomBytes(32); if (s === '') throw new KmsError('ROOT_KEY_MISSING',",
|
||||
rosii: [
|
||||
['radacina: lipsa cheii refuza pornirea cu ROOT_KEY_MISSING si nu creeaza nimic pe disc', 'trebuia refuzat cu ROOT_KEY_MISSING'],
|
||||
['server: configurarea implicita asculta pe 127.0.0.1 si cere token si cheie radacina', 'fara cheie radacina: trebuia refuzat'],
|
||||
['server: pornit din linia de comanda refuza fara AERE_KMS_ROOT_KEY si nu tipareste tokenul', 'codul de iesire'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'P12',
|
||||
defect: 'amprenta cheii/versiunii din plic neverificata',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: 'if (!fp.equals(fpExpected)) {',
|
||||
inlocuire: `if (!fp.equals(fpExpected) && ${NICIODATA}) {`,
|
||||
rosii: [
|
||||
['criptare: alta cheie refuzata cu KEY_MISMATCH', 'motiv asteptat KEY_MISMATCH'],
|
||||
['semnare: alta cheie refuzata cu KEY_MISMATCH, semnatura malformata cu MALFORMED_SIGNATURE', 'motivul: asteptat "KEY_MISMATCH"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'P13',
|
||||
defect: 'rewrap intoarce si textul clar',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: 'return { value: { ciphertext: r.ciphertext, version: r.version }, version: r.version };',
|
||||
inlocuire: "return { value: { ciphertext: r.ciphertext, version: r.version, plaintext: plaintext.toString('base64') }, version: r.version };",
|
||||
rosii: [
|
||||
['rewrap: reincapsuleaza la ultima versiune, acelasi text clar, raspunsul nu contine text clar', 'campurile raspunsului'],
|
||||
['server: flux complet prin HTTP (creare, criptare, decriptare, rotire, rewrap, datakey, semnare, verificare, audit)', 'rewrap nu intoarce text clar'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'P14',
|
||||
defect: 'serverul lasa sa treaca o cerere fara Authorization',
|
||||
fisier: 'server.mjs',
|
||||
ancora: "if (typeof header !== 'string' || header === '') return 'UNAUTHENTICATED';",
|
||||
inlocuire: "if (typeof header !== 'string' || header === '') return null;",
|
||||
rosii: [
|
||||
['server: fara Authorization raspunde 401 UNAUTHENTICATED', 'fara token: asteptat 401'],
|
||||
['server: pornit din linia de comanda asculta pe 127.0.0.1 si raspunde', 'cerere neautentificata'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'P15',
|
||||
defect: 'serverul ignora limita de marime a cererii',
|
||||
fisier: 'server.mjs',
|
||||
ancora: 'return n > maxBody;',
|
||||
inlocuire: 'return false;',
|
||||
rosii: [
|
||||
['server: cerere peste limita cu Content-Length raspunde 413 BODY_TOO_LARGE', 'peste limita: asteptat 413'],
|
||||
['server: cerere peste limita fara Content-Length (chunked) raspunde 413 BODY_TOO_LARGE', 'peste limita, chunked: asteptat 413'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Momelile pentru --autoproba: controlul insusi trebuie sa poata iesi rosu. Fiecare momeala e o
|
||||
// plantare stricata dinadins si are verdictul pe care controlul TREBUIE sa i-l dea.
|
||||
const MOMELI = [
|
||||
{
|
||||
id: 'M1',
|
||||
defect: 'ancora care nu exista in cod',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: 'ACEASTA ANCORA NU EXISTA IN COD',
|
||||
inlocuire: 'x',
|
||||
rosii: [['semnare: semnatura hibrida se verifica, cu ambele jumatati raportate', 'x']],
|
||||
asteptat: 'ESEC CONTROL',
|
||||
},
|
||||
{
|
||||
id: 'M2',
|
||||
defect: 'plantare fara efect (un comentariu)',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: "export const PREFIX = 'aerekms';",
|
||||
inlocuire: "export const PREFIX = 'aerekms'; // momeala fara efect",
|
||||
rosii: [['semnare: semnatura hibrida se verifica, cu ambele jumatati raportate', 'x']],
|
||||
asteptat: 'VERDE (NEPRINS)',
|
||||
},
|
||||
{
|
||||
id: 'M3',
|
||||
defect: 'defect real, dar motivul cerut nu e cel pe care cade proba',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: 'const valid = edOk && pqOk;',
|
||||
inlocuire: 'const valid = edOk || pqOk;',
|
||||
rosii: [['semnare: jumatatea Ed25519 stricata e refuzata cu CLASSICAL_SIGNATURE_INVALID', 'un motiv care nu apare niciodata']],
|
||||
asteptat: 'ROSU GRESIT',
|
||||
},
|
||||
{
|
||||
id: 'M4',
|
||||
defect: 'proba numita nu exista',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: 'const valid = edOk && pqOk;',
|
||||
inlocuire: 'const valid = edOk || pqOk;',
|
||||
rosii: [['o proba care nu exista', 'x']],
|
||||
asteptat: 'ESEC CONTROL',
|
||||
},
|
||||
{
|
||||
id: 'M5',
|
||||
defect: 'plantare care rupe incarcarea modulului',
|
||||
fisier: 'kms.mjs',
|
||||
ancora: 'const valid = edOk && pqOk;',
|
||||
inlocuire: 'const valid = edOk && ;',
|
||||
rosii: [['semnare: jumatatea Ed25519 stricata e refuzata cu CLASSICAL_SIGNATURE_INVALID', 'valid: asteptat false']],
|
||||
asteptat: 'STRICAT',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const amprente = () => Object.fromEntries(FISIERE.map((f) => [f, sha(path.join(RADACINA, f))]));
|
||||
|
||||
function pregatesteCopie(dest) {
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
for (const f of FISIERE) {
|
||||
const d = path.join(dest, f);
|
||||
fs.mkdirSync(path.dirname(d), { recursive: true });
|
||||
fs.copyFileSync(path.join(RADACINA, f), d);
|
||||
}
|
||||
for (const f of FISIERE) {
|
||||
if (sha(path.join(dest, f)) !== sha(path.join(RADACINA, f))) throw new Error(`copia lui ${f} difera de original inainte de plantare`);
|
||||
}
|
||||
}
|
||||
|
||||
function planteaza(dest, p) {
|
||||
const fp = path.join(dest, p.fisier);
|
||||
const text = fs.readFileSync(fp, 'utf8');
|
||||
const bucati = text.split(p.ancora);
|
||||
if (bucati.length !== 2) return { ok: false, motiv: `ancora apare de ${bucati.length - 1} ori in ${p.fisier} (trebuie exact o data)` };
|
||||
const nou = bucati.join(p.inlocuire);
|
||||
fs.writeFileSync(fp, nou);
|
||||
if (sha(fp) === sha(path.join(RADACINA, p.fisier))) return { ok: false, motiv: 'fisierul plantat e identic cu originalul' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function envCurat() {
|
||||
return Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('AERE_KMS_')));
|
||||
}
|
||||
|
||||
function ruleaza(dest) {
|
||||
const out = path.join(dest, 'rezultat-proba.json');
|
||||
fs.rmSync(out, { force: true });
|
||||
const t0 = Date.now();
|
||||
const r = spawnSync(process.execPath, [path.join(dest, 'test', 'proba.mjs')], {
|
||||
cwd: dest,
|
||||
env: { ...envCurat(), AERE_KMS_PROBA_JSON: out },
|
||||
encoding: 'utf8',
|
||||
timeout: 300000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(fs.readFileSync(out, 'utf8'));
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { json, status: r.status, signal: r.signal, ms: Date.now() - t0, stderr: String(r.stderr || '').slice(-600) };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const autoproba = process.argv.includes('--autoproba');
|
||||
const PL = autoproba ? MOMELI : PLANTARI;
|
||||
if (autoproba) console.log('AUTOPROBA: momelile de mai jos TREBUIE sa primeasca verdictul lor de esec\n');
|
||||
const inainte = amprente();
|
||||
const lucru = fs.mkdtempSync(path.join(os.tmpdir(), 'aerekms-control-'));
|
||||
const dest = path.join(lucru, 'copie');
|
||||
const linii = [];
|
||||
let esecuri = 0;
|
||||
try {
|
||||
// 1. linia de baza: copia neatinsa trebuie sa fie VERDE
|
||||
pregatesteCopie(dest);
|
||||
const baza = ruleaza(dest);
|
||||
if (!baza.json || baza.json.completed !== true) {
|
||||
console.log(`LINIA DE BAZA STRICATA: proba nu a ajuns la capat (cod ${baza.status}, semnal ${baza.signal})\n${baza.stderr}`);
|
||||
return 1;
|
||||
}
|
||||
const numeBaza = new Set(baza.json.results.map((r) => r.nume));
|
||||
if (baza.json.failed.length !== 0) {
|
||||
console.log(`LINIA DE BAZA ROSIE: ${baza.json.failed.length} probe pica pe codul neatins:\n ${baza.json.failed.join('\n ')}`);
|
||||
return 1;
|
||||
}
|
||||
console.log(`linia de baza: VERDE, ${baza.json.passed}/${baza.json.total} probe, ${baza.ms} ms (node ${baza.json.node}, openssl ${baza.json.openssl})\n`);
|
||||
|
||||
// 2. fiecare plantare pe o copie proaspata
|
||||
for (const p of PL) {
|
||||
pregatesteCopie(dest);
|
||||
const numite = p.rosii.map(([n]) => n);
|
||||
const lipsa = numite.filter((n) => !numeBaza.has(n));
|
||||
if (lipsa.length) {
|
||||
esecuri++;
|
||||
linii.push({ p, verdict: 'ESEC CONTROL', detaliu: `proba numita nu exista in proba: ${lipsa.join(' | ')}` });
|
||||
continue;
|
||||
}
|
||||
const pl = planteaza(dest, p);
|
||||
if (!pl.ok) {
|
||||
esecuri++;
|
||||
linii.push({ p, verdict: 'ESEC CONTROL', detaliu: `plantarea nu s-a putut pune: ${pl.motiv}` });
|
||||
continue;
|
||||
}
|
||||
const r = ruleaza(dest);
|
||||
if (!r.json || r.json.completed !== true || r.json.total !== baza.json.total) {
|
||||
esecuri++;
|
||||
linii.push({ p, verdict: 'STRICAT', detaliu: `proba nu a rulat intreaga (cod ${r.status}, probe ${r.json ? r.json.total : '-'}/${baza.json.total}); ${r.stderr.split('\n').slice(-3).join(' ')}` });
|
||||
continue;
|
||||
}
|
||||
const dupaNume = new Map(r.json.results.map((x) => [x.nume, x]));
|
||||
const probleme = [];
|
||||
let rosiiNumite = 0;
|
||||
for (const [nume, motiv] of p.rosii) {
|
||||
const x = dupaNume.get(nume);
|
||||
if (x.ok) probleme.push(`VERDE: ${nume}`);
|
||||
else if (!String(x.motiv).includes(motiv)) probleme.push(`ROSU DIN ALT MOTIV: ${nume} -> "${x.motiv.slice(0, 160)}" (cerut: "${motiv}")`);
|
||||
else rosiiNumite++;
|
||||
}
|
||||
const inPlus = r.json.failed.filter((n) => !numite.includes(n));
|
||||
if (probleme.length === 0) {
|
||||
linii.push({ p, verdict: 'ROSU', detaliu: `${rosiiNumite}/${numite.length} numite rosii pe motivul cerut; alte ${inPlus.length} rosii in plus`, inPlus, ms: r.ms });
|
||||
} else {
|
||||
esecuri++;
|
||||
linii.push({ p, verdict: r.json.failed.length === 0 ? 'VERDE (NEPRINS)' : 'ROSU GRESIT', detaliu: probleme.join('\n '), inPlus, ms: r.ms });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(lucru, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
for (const l of linii) {
|
||||
console.log(`${l.verdict.padEnd(15)} ${l.p.id} ${l.p.defect}${l.ms ? ` (${l.ms} ms)` : ''}\n ${l.detaliu}`);
|
||||
if (l.inPlus && l.inPlus.length) console.log(` in plus: ${l.inPlus.join(' | ')}`);
|
||||
}
|
||||
|
||||
// 3. originalul neatins
|
||||
const dupa = amprente();
|
||||
const schimbate = FISIERE.filter((f) => inainte[f] !== dupa[f]);
|
||||
if (schimbate.length) {
|
||||
esecuri++;
|
||||
console.log(`\nORIGINALUL S-A SCHIMBAT: ${schimbate.join(', ')}`);
|
||||
} else {
|
||||
console.log(`\noriginal neatins (sha256): ${FISIERE.map((f) => `${f}=${dupa[f].slice(0, 12)}`).join(' ')}`);
|
||||
}
|
||||
if (autoproba) {
|
||||
// Aici esecurile sunt ceruse: fiecare momeala trebuie sa primeasca exact verdictul ei.
|
||||
let potrivite = 0;
|
||||
for (const l of linii) {
|
||||
const ok = l.verdict === l.p.asteptat;
|
||||
if (ok) potrivite++;
|
||||
console.log(`${ok ? 'CORECT ' : 'GRESIT '} ${l.p.id}: controlul a spus "${l.verdict}", trebuia "${l.p.asteptat}"`);
|
||||
}
|
||||
const originalOk = schimbate.length === 0;
|
||||
console.log(`\nautoproba controlului: ${potrivite}/${MOMELI.length} momeli cu verdictul cerut; original ${originalOk ? 'neatins' : 'SCHIMBAT'}`);
|
||||
return potrivite === MOMELI.length && linii.length === MOMELI.length && originalOk ? 0 : 1;
|
||||
}
|
||||
const rosii = linii.filter((l) => l.verdict === 'ROSU').length;
|
||||
console.log(`\ncontrol negativ: ${rosii}/${PLANTARI.length} plantari prinse pe probele numite; ${esecuri} esecuri ale controlului`);
|
||||
return esecuri === 0 && rosii === PLANTARI.length ? 0 : 1;
|
||||
}
|
||||
|
||||
try {
|
||||
process.exitCode = main();
|
||||
} catch (e) {
|
||||
console.log(`controlul negativ a cazut: ${(e && e.stack) || e}`);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
41
pq-kms/test/proba-hsm-incredere.mjs
Normal file
41
pq-kms/test/proba-hsm-incredere.mjs
Normal file
@ -0,0 +1,41 @@
|
||||
// test/proba-hsm-incredere.mjs (2026-09-29, revizuirea adversariala a feliei HSM, H1): fisierul sigilat NU alege biblioteca PKCS#11 si
|
||||
// nici variabila de mediu trimisa drept PIN. Nu cere un HSM: unealta primita e o cale care NU exista, deci orice verificare care lasa
|
||||
// cererea sa ajunga la unealta se vede ca HSM_TOOL_MISSING, iar un refuz dat INAINTE de unealta are codul lui. Controlul pozitiv al
|
||||
// metodei: o sigilare corecta ajunge chiar la unealta (HSM_TOOL_MISSING), deci refuzurile de mai jos nu vin din altceva.
|
||||
// node test/proba-hsm-incredere.mjs -> 0 toate cum trebuia, 1 altfel
|
||||
// AERE_HSM_MODUL_SUB_PROBA=<cale> numai pentru controlul negativ (o copie plantata a modulului)
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MOD = process.env.AERE_HSM_MODUL_SUB_PROBA || path.join(AICI, '..', 'hsm-radacina.mjs');
|
||||
const { deschideRadacina, radacinaDinHsm, FORMAT_HSM } = await import(pathToFileURL(MOD).href);
|
||||
let rele = 0, n = 0;
|
||||
const cer = (nume, ok, extra = '') => { n++; console.log(` [${ok ? 'OK ' : 'RAU '}] ${nume}${extra ? ' (' + extra + ')' : ''}`); if (!ok) rele++; };
|
||||
const codul = (fn) => { try { fn(); return 'FARA REFUZ'; } catch (e) { return e.code || String(e.message).slice(0, 60); } };
|
||||
|
||||
const LIB = path.resolve('/usr/lib/softhsm/libsofthsm2.so');
|
||||
const RAU = path.resolve('/tmp/biblioteca-straina.so');
|
||||
const UNEALTA = path.join(os.tmpdir(), 'nu-exista-' + process.pid, 'pkcs11-tool');
|
||||
const sig = { format: FORMAT_HSM, mecanism: 'AES-CBC-PAD', modul: LIB, token: 'aere-kms', idCheie: '0a', pinEnv: 'AERE_HSM_PIN', iv: '00'.repeat(16), ct: '11'.repeat(64) };
|
||||
const env = { AERE_HSM_PIN: '483920', AERE_HSM_MODULE: LIB, AERE_KMS_TOKEN: 'AERE-SINTETIC-jeton-api-care-nu-trebuie-trimis-ca-pin' };
|
||||
const deschide = (s, e = env) => codul(() => deschideRadacina(s, { env: e, unealta: UNEALTA }));
|
||||
|
||||
cer('CONTROL POZITIV: o sigilare corecta ajunge la unealta (HSM_TOOL_MISSING aici), deci refuzurile de mai jos vin din verificari', deschide(sig) === 'HSM_TOOL_MISSING', deschide(sig));
|
||||
cer('H1: o biblioteca din fisier, alta decat AERE_HSM_MODULE -> HSM_MODULE_NOT_ALLOWED, fara sa ruleze unealta', deschide({ ...sig, modul: RAU }) === 'HSM_MODULE_NOT_ALLOWED', deschide({ ...sig, modul: RAU }));
|
||||
cer('H1: fara AERE_HSM_MODULE -> HSM_CONFIG_INVALID (fisierul singur nu alege biblioteca)', deschide(sig, { ...env, AERE_HSM_MODULE: '' }) === 'HSM_CONFIG_INVALID');
|
||||
cer('H1: AERE_HSM_MODULE relativ -> HSM_CONFIG_INVALID', deschide(sig, { ...env, AERE_HSM_MODULE: 'libsofthsm2.so' }) === 'HSM_CONFIG_INVALID');
|
||||
cer('H1: fisierul cere alta variabila drept PIN (jetonul API) -> HSM_SEAL_INVALID, fara sa ruleze unealta', deschide({ ...sig, pinEnv: 'AERE_KMS_TOKEN' }) === 'HSM_SEAL_INVALID', deschide({ ...sig, pinEnv: 'AERE_KMS_TOKEN' }));
|
||||
const { pinEnv, ...faraPinEnv } = sig;
|
||||
cer('o sigilare fara camp pinEnv (PIN-ul din AERE_HSM_PIN) ajunge la unealta', deschide(faraPinEnv) === 'HSM_TOOL_MISSING');
|
||||
// drumul serverului: AERE_KMS_ROOT_HSM spre un fisier cu biblioteca straina
|
||||
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'hsm-incredere-'));
|
||||
try {
|
||||
const f = path.join(T, 'root-hsm.json'); fs.writeFileSync(f, JSON.stringify({ ...sig, modul: RAU }));
|
||||
cer('H1 pe drumul serverului: AERE_KMS_ROOT_HSM cu biblioteca straina -> HSM_MODULE_NOT_ALLOWED', codul(() => radacinaDinHsm({ ...env, AERE_KMS_ROOT_HSM: f }, { unealta: UNEALTA })) === 'HSM_MODULE_NOT_ALLOWED');
|
||||
} finally { fs.rmSync(T, { recursive: true, force: true }); }
|
||||
|
||||
console.log(`\nincrederea in fisierul sigilat (H1): ${n - rele}/${n} cum trebuia`);
|
||||
process.exitCode = rele ? 1 : 0;
|
||||
88
pq-kms/test/proba-hsm.mjs
Normal file
88
pq-kms/test/proba-hsm.mjs
Normal file
@ -0,0 +1,88 @@
|
||||
// test/proba-hsm.mjs - proba radacinii KMS sigilate de un HSM (hsm-radacina.mjs), pe un HSM PKCS#11 REAL (SoftHSM2), nu pe o imitatie
|
||||
// a uneltei: token nou intr-un dosar temporar, cheie AES-256 generata IN token (sensitive, never extractable), apoi sigilare,
|
||||
// deschidere, KMS-ul pornit din radacina deschisa si o runda hibrida (ML-KEM-768 + X25519) prin el. Fiecare refuz se judeca dupa
|
||||
// MOTIV (codul), nu dupa faptul ca a aruncat ceva.
|
||||
// Cere: Node 24 (kms.mjs), softhsm2-util si pkcs11-tool (OpenSC) in PATH; libsofthsm2.so. Ruleaza in WSL / Linux:
|
||||
// /root/node24/bin/node test/proba-hsm.mjs -> 0 toate cum trebuia, 1 altfel, 2 NEMASURAT (HSM-ul de proba nu porneste)
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sigileazaRadacina, deschideRadacina, radacinaDinHsm, FORMAT_HSM } from '../hsm-radacina.mjs';
|
||||
import { configFromEnv } from '../server.mjs';
|
||||
import { openKms } from '../kms.mjs';
|
||||
|
||||
const MODUL = process.env.AERE_PKCS11_MODUL || '/usr/lib/softhsm/libsofthsm2.so';
|
||||
let rele = 0, n = 0;
|
||||
const cer = (nume, ok, extra = '') => { n++; console.log(` [${ok ? 'OK ' : 'RAU '}] ${nume}${extra ? ' (' + extra + ')' : ''}`); if (!ok) rele++; };
|
||||
const codul = (fn) => { try { fn(); return 'FARA REFUZ'; } catch (e) { return e.code || String(e.message).slice(0, 60); } };
|
||||
const nemasurat = (m) => { console.log('NEMASURAT: ' + m); process.exit(2); };
|
||||
if (!fs.existsSync(MODUL)) nemasurat('lipseste modulul PKCS#11 ' + MODUL);
|
||||
|
||||
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'proba-hsm-'));
|
||||
// 2026-09-29 (H1): deschiderea cere biblioteca din configuratia procesului, nu din fisierul sigilat
|
||||
const env = { ...process.env, SOFTHSM2_CONF: path.join(T, 'softhsm2.conf'), AERE_HSM_PIN: '483920', AERE_HSM_MODULE: MODUL };
|
||||
fs.mkdirSync(path.join(T, 'tokens'));
|
||||
fs.writeFileSync(env.SOFTHSM2_CONF, `directories.tokendir = ${path.join(T, 'tokens')}\nobjectstore.backend = file\nlog.level = ERROR\n`);
|
||||
const ruleaza = (c, a) => spawnSync(c, a, { env, encoding: 'utf8' });
|
||||
try {
|
||||
let r = ruleaza('softhsm2-util', ['--init-token', '--free', '--label', 'aere-kms', '--pin', env.AERE_HSM_PIN, '--so-pin', '771122']);
|
||||
if (r.status !== 0) nemasurat('softhsm2-util nu initializeaza tokenul: ' + (r.stderr || r.error));
|
||||
r = ruleaza('pkcs11-tool', ['--module', MODUL, '--token-label', 'aere-kms', '--login', '--pin', 'env:AERE_HSM_PIN', '--keygen', '--key-type', 'AES:32', '--label', 'kms-root', '--id', '0a', '--sensitive']);
|
||||
if (r.status !== 0) nemasurat('cheia AES nu se genereaza in token: ' + (r.stderr || r.error));
|
||||
cer('cheia AES e generata IN token: sensitive, never extractable', /never extractable/.test(r.stdout) && /sensitive/.test(r.stdout));
|
||||
r = ruleaza('pkcs11-tool', ['--module', MODUL, '--token-label', 'aere-kms', '--login', '--pin', 'env:AERE_HSM_PIN', '--read-object', '--type', 'secrkey', '--id', '0a', '--output-file', path.join(T, 'k')]);
|
||||
cer('cheia NU poate fi citita afara din HSM', r.status !== 0 && (!fs.existsSync(path.join(T, 'k')) || fs.statSync(path.join(T, 'k')).size === 0));
|
||||
|
||||
const radacina = crypto.randomBytes(32);
|
||||
const sig = sigileazaRadacina({ modul: MODUL, token: 'aere-kms', idCheie: '0a', radacina: Buffer.from(radacina), env });
|
||||
cer('sigilarea: formatul, 64 de octeti, si radacina NU apare in ea', sig.format === FORMAT_HSM && sig.ct.length === 128 && !JSON.stringify(sig).includes(radacina.toString('hex')));
|
||||
const d = deschideRadacina(sig, { env });
|
||||
cer('deschiderea cu HSM-ul si PIN-ul da exact radacina sigilata', d.equals(radacina));
|
||||
const s2 = sigileazaRadacina({ modul: MODUL, token: 'aere-kms', idCheie: '0a', radacina: Buffer.from(radacina), env });
|
||||
cer('doua sigilari ale aceleiasi radacini difera (iv aleator), amandoua se deschid', s2.ct !== sig.ct && deschideRadacina(s2, { env }).equals(radacina));
|
||||
|
||||
// refuzuri, fiecare cu motivul lui
|
||||
cer('PIN gresit -> HSM_LOGIN_REFUSED', codul(() => deschideRadacina(sig, { env: { ...env, AERE_HSM_PIN: '000000' } })) === 'HSM_LOGIN_REFUSED');
|
||||
cer('PIN lipsa -> HSM_PIN_MISSING (nu se incearca fara)', codul(() => deschideRadacina(sig, { env: { ...env, AERE_HSM_PIN: '' } })) === 'HSM_PIN_MISSING');
|
||||
cer('alt token -> HSM_TOKEN_NOT_FOUND', codul(() => deschideRadacina({ ...sig, token: 'alt-token' }, { env })) === 'HSM_TOKEN_NOT_FOUND');
|
||||
const cti = Buffer.from(sig.ct, 'hex'); cti[3] ^= 0x01; // primul bloc: CBC descifreaza, dar in altceva
|
||||
cer('sigilare atinsa in primul bloc -> HSM_ROOT_TAMPERED (amprenta), nu o radacina gresita', codul(() => deschideRadacina({ ...sig, ct: cti.toString('hex') }, { env })) === 'HSM_ROOT_TAMPERED');
|
||||
const ctu = Buffer.from(sig.ct, 'hex'); ctu[63] ^= 0x80; // ultimul bloc: umplutura strica
|
||||
cer('sigilare atinsa in ultimul bloc -> HSM_ROOT_TAMPERED', codul(() => deschideRadacina({ ...sig, ct: ctu.toString('hex') }, { env })) === 'HSM_ROOT_TAMPERED');
|
||||
// H2 (2026-09-29): umplutura stricata (ultimul bloc) si amprenta gresita (primul bloc) dau ACELASI mesaj: altfel jurnalul pornirii e un oracol de umplutura
|
||||
const mesaj = (s) => { try { deschideRadacina(s, { env }); return 'FARA REFUZ'; } catch (e) { return e.code + ': ' + e.message; } };
|
||||
const m1 = mesaj({ ...sig, ct: cti.toString('hex') }), m2 = mesaj({ ...sig, ct: ctu.toString('hex') });
|
||||
cer('H2: umplutura stricata si amprenta gresita nu se deosebesc prin mesaj', m1 === m2 && m1.startsWith('HSM_ROOT_TAMPERED'), m1 === m2 ? 'identice' : m1 + ' | ' + m2);
|
||||
// CONTROLUL NECESITATII amprentei: fara ea, CBC ar fi dat 48 de octeti de gunoi, deci amprenta e cea care prinde atingerea
|
||||
const ctf = path.join(T, 'c'), ptf = path.join(T, 'p'); fs.writeFileSync(ctf, cti);
|
||||
r = ruleaza('pkcs11-tool', ['--module', MODUL, '--token-label', 'aere-kms', '--login', '--pin', 'env:AERE_HSM_PIN', '--id', '0a', '--mechanism', 'AES-CBC-PAD', '--decrypt', '--iv', sig.iv, '--input-file', ctf, '--output-file', ptf]);
|
||||
cer('CONTROL: HSM-ul singur descifreaza sigilarea atinsa fara sa se planga (48 de octeti) - de aceea amprenta', r.status === 0 && fs.statSync(ptf).size === 48);
|
||||
r = ruleaza('pkcs11-tool', ['--module', MODUL, '--token-label', 'aere-kms', '--login', '--pin', 'env:AERE_HSM_PIN', '--keygen', '--key-type', 'AES:32', '--label', 'alta', '--id', '0b', '--sensitive']);
|
||||
cer('alta cheie din acelasi token -> HSM_ROOT_TAMPERED (nu se deschide cu cheia gresita)', r.status === 0 && codul(() => deschideRadacina({ ...sig, idCheie: '0b' }, { env })) === 'HSM_ROOT_TAMPERED');
|
||||
|
||||
// KMS-ul pornit din radacina deschisa de HSM, capat la capat
|
||||
const fsig = path.join(T, 'root-hsm.json'); fs.writeFileSync(fsig, JSON.stringify(sig));
|
||||
const tok = crypto.randomBytes(24).toString('hex');
|
||||
const cfg = configFromEnv({ ...env, AERE_KMS_ROOT_HSM: fsig, AERE_KMS_TOKEN: tok, AERE_KMS_DATA_DIR: path.join(T, 'date') });
|
||||
cer('serverul ia radacina din HSM (rootSource=hsm) si e chiar cea sigilata', cfg.rootSource === 'hsm' && cfg.rootKey === radacina.toString('hex'));
|
||||
const kms = openKms({ dataDir: cfg.dataDir, rootKey: cfg.rootKey });
|
||||
kms.createKey('client', { type: 'encrypt' });
|
||||
const c = kms.encrypt('client', 'date de pastrat', 'ctx').ciphertext;
|
||||
cer('KMS pornit din radacina HSM: runda hibrida X25519 + ML-KEM-768', kms.decrypt('client', c, 'ctx').plaintext.toString() === 'date de pastrat');
|
||||
kms.close();
|
||||
const cfg2 = configFromEnv({ ...env, AERE_KMS_ROOT_HSM: fsig, AERE_KMS_TOKEN: tok, AERE_KMS_DATA_DIR: path.join(T, 'date') });
|
||||
const kms2 = openKms({ dataDir: cfg2.dataDir, rootKey: cfg2.rootKey });
|
||||
cer('redeschis din HSM, KMS-ul descifreaza ce a cifrat inainte (aceeasi radacina)', kms2.decrypt('client', c, 'ctx').plaintext.toString() === 'date de pastrat');
|
||||
kms2.close();
|
||||
cer('amandoua sursele de radacina deodata -> ROOT_KEY_AMBIGUOUS', codul(() => configFromEnv({ ...env, AERE_KMS_ROOT_HSM: fsig, AERE_KMS_ROOT_KEY: radacina.toString('hex'), AERE_KMS_TOKEN: tok })) === 'ROOT_KEY_AMBIGUOUS');
|
||||
cer('sigilarea lipsa -> HSM_SEAL_INVALID', codul(() => radacinaDinHsm({ ...env, AERE_KMS_ROOT_HSM: path.join(T, 'nu-exista.json') })) === 'HSM_SEAL_INVALID');
|
||||
cer('cu PIN gresit serverul REFUZA sa porneasca (nu cade pe alta radacina)', codul(() => configFromEnv({ ...env, AERE_HSM_PIN: '999999', AERE_KMS_ROOT_HSM: fsig, AERE_KMS_TOKEN: tok })) === 'HSM_LOGIN_REFUSED');
|
||||
// /dev/shm curat dupa operatii: fisierele temporare cu radacina nu raman
|
||||
const ramase = fs.existsSync('/dev/shm') ? fs.readdirSync('/dev/shm').filter((x) => x.startsWith('aerekms-hsm-')) : [];
|
||||
cer('nicio urma a radacinii in /dev/shm dupa operatii', ramase.length === 0, ramase.join(','));
|
||||
} finally { fs.rmSync(T, { recursive: true, force: true }); }
|
||||
console.log(`\nradacina KMS sigilata de HSM (SoftHSM2 + OpenSC): ${n - rele}/${n} cum trebuia`);
|
||||
process.exitCode = rele ? 1 : 0;
|
||||
1339
pq-kms/test/proba.mjs
Normal file
1339
pq-kms/test/proba.mjs
Normal file
File diff suppressed because it is too large
Load Diff
67
pq-pki/README.md
Normal file
67
pq-pki/README.md
Normal file
@ -0,0 +1,67 @@
|
||||
# Aere PQ PKI
|
||||
|
||||
A private certificate authority that issues **post-quantum X.509 v3 certificates** signed with **ML-DSA** (FIPS 204, the pure form with an empty context, as RFC 9881 specifies for certificates), revocation lists signed the same way, and a strict chain verifier. Node 24 with its bundled OpenSSL 3.5, no dependencies.
|
||||
|
||||
With a chain from this CA, a TLS 1.3 server is post-quantum end to end: the key exchange is `X25519MLKEM768` and the server authenticates with an `mldsa65` signature. Measured in the probes: a Node server with our chain, and both `openssl s_client` (3.5) and a Node client accept it (`Verify return code: 0`, `Peer signature type: mldsa65`, `Negotiated TLS1.3 group: X25519MLKEM768`).
|
||||
|
||||
```
|
||||
export AERE_PKI_PASSPHRASE='...12+ characters with 3 character classes, or 20+ characters...'
|
||||
node cli.mjs init --dir ca --name root --org "Example" # ML-DSA-87 root, pathLen 1, 10 years
|
||||
node cli.mjs intermediate --dir ca --ca root --name issuing # ML-DSA-65, pathLen 0, 5 years
|
||||
node cli.mjs issue --dir ca --ca issuing --cn api.internal --dns api.internal --out api # 90 days, serverAuth
|
||||
node cli.mjs revoke --dir ca --ca issuing --cert api.crt
|
||||
node cli.mjs crl --dir ca --ca issuing --out issuing.crl
|
||||
node cli.mjs verify --roots ca/root.crt --chain api.chain.pem --host api.internal --crl issuing.crl
|
||||
node cli.mjs unseal --key api.key --out api.p8.pem # unencrypted PKCS#8 for a TLS server, only on request
|
||||
```
|
||||
|
||||
Nothing is ever overwritten, and without `AERE_PKI_PASSPHRASE` nothing is issued.
|
||||
|
||||
## Private keys on disk
|
||||
|
||||
Private keys are written **sealed** in a format of our own, never as PKCS#8:
|
||||
|
||||
```
|
||||
-----BEGIN AERE PQ PKI PRIVATE KEY-----
|
||||
SEQUENCE {
|
||||
header SEQUENCE { version 1, "scrypt", salt (16 bytes), N=131072, r=8, p=1, "aes-256-gcm", iv (12 bytes) },
|
||||
tag (16 bytes),
|
||||
ciphertext -- the PKCS#8 DER of the key, AES-256-GCM under scrypt(passphrase, salt), with the header as AAD
|
||||
}
|
||||
-----END AERE PQ PKI PRIVATE KEY-----
|
||||
```
|
||||
|
||||
Why: the PKCS#8 encryption that `node:crypto` produces (`key.export({cipher})`) derives its key with PBKDF2 at **2048 iterations**, which the adversarial review measured at 3 to 5 ms per passphrase guess. With scrypt at N=2^17, r=8, p=1 (128 MiB of memory per guess) one guess costs **about 0.7 to 1.0 s on the development laptop** (Intel i7-10870H, Node 24 `scryptSync`; the probe measures it on every run and prints the number, and fails under 25 ms). The header is bound as AAD, so a changed parameter, salt, IV or ciphertext byte fails authentication exactly like a wrong passphrase (`KEY_LOCKED`); the reader accepts N only between 2^14 and 2^20 (`KEY_FORMAT` otherwise), so a crafted file cannot make it allocate gigabytes. **PKCS#8 files, encrypted or not, are refused by `importPrivateKey` with `KEY_FORMAT`**; a key made before this format has to be re-sealed.
|
||||
|
||||
The passphrase policy (`checkPassphrase`, applied when a key is written): at least 12 characters and at least 3 of the 4 classes (lower, upper, digit, other), or at least 20 characters; one repeated character is refused; the obvious ones (`password`, `qwerty`, `123456`, `letmein`, ... with or without digits and punctuation appended) are refused. Refusals carry `PASSPHRASE`.
|
||||
|
||||
A Node TLS server loads the key in process, without ever writing it in clear: `tls.createServer({ key: importPrivateKey(fs.readFileSync('api.key', 'utf8'), passphrase).export({ type: 'pkcs8', format: 'pem' }), cert })`. For servers that only read files, `node cli.mjs unseal` writes an unencrypted PKCS#8 file with mode 0600 and never overwrites; delete it when it is no longer needed. `readSealedKeyHeader(pem)` returns the KDF parameters without opening the key.
|
||||
|
||||
## What the verifier checks
|
||||
|
||||
`verifyChain` always returns a verdict `{ ok, code, reason, chain }`; it never throws, whatever bytes it is given (the probe feeds it hundreds of random mutations, truncations and garbage).
|
||||
|
||||
- **Path building**: issuer/subject matched byte-exactly, key identifiers matched when present, and **every candidate path is tried**, trust anchors first: with a renewed root (two anchors with the same name and key, one expired) or a cross-signed intermediate listed before the right one, the valid path is found. If no path validates, the failure of the last path tried is returned (`UNTRUSTED` when no path reaches an anchor).
|
||||
- **Every link**: a valid ML-DSA signature, a CA issuer (`basicConstraints`) with `keyCertSign`, the issuer's `pathLen`, validity at the time of use, no unknown critical extension; the trusted root must be a valid self-signed certificate.
|
||||
- **Public keys**: the `subjectPublicKeyInfo` of every certificate must carry a key of exactly the FIPS 204 length for its algorithm (1312 / 1952 / 2592 bytes for ML-DSA-44 / 65 / 87) with no unused bits; anything else is the verdict `SPKI`, on the leaf too (its key is never used by the verifier, so without this check a 10-byte "key" passed).
|
||||
- **Extended key usage on authorities**: when an issuer in the chain, root included, carries an EKU, the purpose asked for must be in it (verdict `EKU`, naming the authority), as OpenSSL, Chrome and Mozilla do. `anyExtendedKeyUsage` does not satisfy a purpose, as in OpenSSL.
|
||||
- **Revocation**, for each issuer in the chain: among that issuer's lists that verify under its key and are not dated in the future, **the one with the newest `thisUpdate` is used** (ties broken by the larger `crlNumber`); the order of the input does not matter. That list must be current (`CRL_STALE` after `nextUpdate`), and a listed serial is `REVOKED`. `requireCrl` makes a missing list `CRL_MISSING`. A list whose signature does not verify is `CRL_SIGNATURE`.
|
||||
- **CRL extensions**: `authorityKeyIdentifier` and `crlNumber` are understood; `issuingDistributionPoint` and `deltaCRLIndicator` are refused however they are marked, and so is any other critical extension of the list or of an entry (`certificateIssuer` of an indirect list, for instance): verdict `CRL_EXT`, naming the extension. Non-critical unknown extensions (a `reasonCode` on an entry, for instance) are ignored.
|
||||
- **End entity**: not a CA, `digitalSignature`, the extended key usage of the purpose, and the host name or IPv4 address in the subject alternative name.
|
||||
|
||||
Every refusal carries a code: `SIGNATURE`, `UNTRUSTED`, `ROOT`, `EXPIRED`, `NOT_YET_VALID`, `NOT_CA`, `KEY_USAGE`, `PATH_LEN`, `EKU`, `SPKI`, `REVOKED`, `CRL_MISSING`, `CRL_SIGNATURE`, `CRL_STALE`, `CRL_EXT`, `LEAF_IS_CA`, `HOSTNAME`, `CRITICAL_EXT`, `ALG`, `ALG_MISMATCH`, `ALG_PARAMS`, `VERSION`, `EXT_DUP`, `DER`. The DER parser is strict: no indefinite lengths, no non-minimal lengths, no trailing bytes, no non-canonical integers or object identifiers; object identifiers with a multi-byte first subidentifier (`2.999` is `06 02 88 37`) encode and decode as OpenSSL does.
|
||||
|
||||
## How it is proven
|
||||
|
||||
`node test/proba.mjs` (27 probes, about 25 s) compares every verdict with a foreign implementation, the OpenSSL 3.5 command line in `-x509_strict` mode: the valid chain, revocation, the newest of several lists (both orders), lists made by `openssl ca -gencrl` (plain, with a revoked entry and a reason code, with a critical issuing distribution point, with an unknown critical extension), a delta list (OpenSSL refuses it only with `-extended_crl`), a changed byte, another root, expiry and not-yet-valid, a path-length violation, a non-CA issuer (a chain made by OpenSSL), host and IP names, purpose on the leaf and on authorities (`unsuitable certificate purpose` at depth 1), a renewed root (both anchors in one `CAfile`), malformed public keys (`decode error`), an unknown critical extension (made by OpenSSL), object identifier bytes (`asn1parse -genstr`). It also reads and verifies certificates made by OpenSSL, runs the real TLS handshake, measures the cost of a passphrase guess, exercises the passphrase policy, and drives the command line end to end. `node test/control-negativ.mjs` (about 3 minutes, four plantings in parallel) breaks each check in a copy of the code (22 plantings, at least one per finding of the review) and requires the named probe to turn red, the original to stay byte-identical, and the untouched suite to stay green; the number of probes is taken from the untouched run, not written down.
|
||||
|
||||
## What it does not do, and where it differs from OpenSSL
|
||||
|
||||
- **Composite (hybrid classical + ML-DSA) certificates**: still IETF drafts; this CA issues pure ML-DSA certificates. A client that only knows classical algorithms cannot verify them.
|
||||
- **OCSP, name constraints, certificate policies, IPv6 names, delta and partial (IDP) revocation lists**: not implemented; delta and IDP lists are refused (`CRL_EXT`) rather than misread. OpenSSL without `-extended_crl` silently treats a delta list as a complete one; we do not.
|
||||
- **A list without `nextUpdate`** is accepted for as long as it is the newest one, as OpenSSL does; an operator who wants freshness enforced must publish lists with `nextUpdate`.
|
||||
- **A leaf without `keyUsage`** is refused here (`KEY_USAGE`); OpenSSL `-x509_strict` accepts it.
|
||||
- **Backtracking between intermediates**: we find the valid path when a cross-signed intermediate is listed first; OpenSSL does not and refuses that input.
|
||||
- **Name comparison** is byte-exact on the DER encoding, not the full RFC 5280 normalization; chains issued here always encode names the same way.
|
||||
- **The CA key is protected by a passphrase on disk** (scrypt + AES-256-GCM, above), not by an HSM. Keep the root offline. **PKCS#8 is no longer the on-disk format**; `unseal` produces it only on request.
|
||||
- The second implementation used in the probes (OpenSSL) shares the ML-DSA code with Node, since Node bundles OpenSSL; the X.509 encoding, path building and revocation logic are independent.
|
||||
134
pq-pki/cli.mjs
Normal file
134
pq-pki/cli.mjs
Normal file
@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
// cli.mjs: linia de comanda a autoritatii de certificare post-cuantice (pki.mjs). Starea sta intr-un dosar: fiecare CA are
|
||||
// <nume>.crt (PEM), <nume>.key (cheia SIGILATA cu parola din AERE_PKI_PASSPHRASE: scrypt + AES-256-GCM, formatul propriu din pki.mjs,
|
||||
// niciodata in clar si niciodata PKCS#8 pe disc [A1]), <nume>.revoked.json (seriile revocate) si <nume>.crlnum (numarul ultimei liste).
|
||||
// "unseal" scrie o cheie in PKCS#8 necifrat, singura forma pe care o citesc serverele TLS straine; se face numai la cerere explicita.
|
||||
// Mesajele pentru utilizator sunt in engleza.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as P from './pki.mjs';
|
||||
|
||||
const USAGE = `aere-pq-pki: a private post-quantum certificate authority (X.509 v3, ML-DSA, RFC 9881)
|
||||
node cli.mjs init --dir D --name NAME [--org O] [--alg ml-dsa-87] [--days 3650] [--path-len 1]
|
||||
node cli.mjs intermediate --dir D --ca ROOT --name NAME [--org O] [--alg ml-dsa-65] [--days 1825] [--path-len 0]
|
||||
node cli.mjs issue --dir D --ca CA --cn CN [--dns a,b] [--ip 192.0.2.10] [--client] [--alg ml-dsa-65] [--days 90] --out PREFIX
|
||||
node cli.mjs revoke --dir D --ca CA --cert FILE
|
||||
node cli.mjs crl --dir D --ca CA [--days 7] --out FILE
|
||||
node cli.mjs verify --roots FILE --chain FILE [--host H] [--crl FILE ...] [--purpose serverAuth|clientAuth] [--require-crl]
|
||||
node cli.mjs unseal --key FILE.key --out FILE.pem (writes the key as UNENCRYPTED PKCS#8, for a TLS server; keep it 0600)
|
||||
Private keys are written sealed with AERE_PKI_PASSPHRASE (scrypt 2^17 + AES-256-GCM): at least 12 characters with 3 character classes,
|
||||
or at least 20 characters; obvious passphrases are refused. Without it nothing is issued.`;
|
||||
|
||||
function args(argv) {
|
||||
const o = { _: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (!a.startsWith('--')) { o._.push(a); continue; }
|
||||
const k = a.slice(2);
|
||||
const flag = ['client', 'require-crl'].includes(k);
|
||||
const v = flag ? true : argv[++i];
|
||||
if (v === undefined) throw new P.PkiError('USAGE', '--' + k + ' needs a value');
|
||||
if (k === 'crl') (o.crl ||= []).push(v); else o[k] = v;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
// La deschidere se cere doar sa existe (o cheie sigilata a trecut politica atunci cand a fost scrisa); politica intreaga
|
||||
// (P.checkPassphrase) se aplica la SCRIERE, in exportPrivateKey, ca o inasprire viitoare sa nu incuie cheile vechi.
|
||||
const pass = () => {
|
||||
const p = process.env.AERE_PKI_PASSPHRASE;
|
||||
if (!p || p.length < 12) throw new P.PkiError('PASSPHRASE', 'set AERE_PKI_PASSPHRASE (at least 12 characters with 3 character classes, or at least 20 characters)');
|
||||
return p;
|
||||
};
|
||||
const need = (o, ...ks) => { for (const k of ks) if (!o[k]) throw new P.PkiError('USAGE', '--' + k + ' is required'); };
|
||||
const safeName = (n) => { if (!/^[A-Za-z0-9._-]{1,64}$/.test(n)) throw new P.PkiError('USAGE', 'names use letters, digits, ".", "_" and "-" only'); return n; };
|
||||
function loadCa(dir, ca) {
|
||||
const n = safeName(ca);
|
||||
const cert = P.unpem(fs.readFileSync(path.join(dir, n + '.crt'), 'utf8'))[0];
|
||||
const key = P.importPrivateKey(fs.readFileSync(path.join(dir, n + '.key'), 'utf8'), pass());
|
||||
return { n, cert, key };
|
||||
}
|
||||
function writeNew(file, data, mode = 0o600) {
|
||||
if (fs.existsSync(file)) throw new P.PkiError('EXISTS', file + ' exists; nothing is overwritten');
|
||||
fs.writeFileSync(file, data, { mode, flag: 'wx' });
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const [cmd, ...rest] = argv;
|
||||
if (!cmd || cmd === '--help') { console.log(USAGE); return 0; }
|
||||
const o = args(rest);
|
||||
if (cmd === 'init' || cmd === 'intermediate') {
|
||||
need(o, 'dir', 'name');
|
||||
const n = safeName(o.name);
|
||||
fs.mkdirSync(o.dir, { recursive: true, mode: 0o700 });
|
||||
const alg = o.alg || (cmd === 'init' ? 'ml-dsa-87' : 'ml-dsa-65');
|
||||
const k = P.generateKey(alg);
|
||||
const pl = o['path-len'] !== undefined ? Number(o['path-len']) : (cmd === 'init' ? 1 : 0);
|
||||
let cert;
|
||||
if (cmd === 'init') cert = P.issue({ issuer: null, signingKey: k.privateKey, subject: { cn: o.name, o: o.org }, publicKey: k.publicKey, ca: true, pathLen: pl, days: Number(o.days || 3650) });
|
||||
else { need(o, 'ca'); const ca = loadCa(o.dir, o.ca); cert = P.issue({ issuer: ca.cert, signingKey: ca.key, subject: { cn: o.name, o: o.org }, publicKey: k.publicKey, ca: true, pathLen: pl, days: Number(o.days || 1825) }); }
|
||||
const pem = P.exportPrivateKey(k.privateKey, pass());
|
||||
writeNew(path.join(o.dir, n + '.key'), pem);
|
||||
writeNew(path.join(o.dir, n + '.crt'), P.pem('CERTIFICATE', cert), 0o644);
|
||||
writeNew(path.join(o.dir, n + '.revoked.json'), '[]\n');
|
||||
console.log(`${cmd === 'init' ? 'root' : 'intermediate'} "${o.name}" (${alg}) written to ${o.dir}`);
|
||||
return 0;
|
||||
}
|
||||
if (cmd === 'issue') {
|
||||
need(o, 'dir', 'ca', 'cn', 'out');
|
||||
const ca = loadCa(o.dir, o.ca);
|
||||
const alg = o.alg || 'ml-dsa-65';
|
||||
const k = P.generateKey(alg);
|
||||
const cert = P.issue({ issuer: ca.cert, signingKey: ca.key, subject: { cn: o.cn }, publicKey: k.publicKey, days: Number(o.days || 90),
|
||||
dns: o.dns ? String(o.dns).split(',') : [], ips: o.ip ? String(o.ip).split(',') : [], eku: [o.client ? 'clientAuth' : 'serverAuth'] });
|
||||
writeNew(o.out + '.key', P.exportPrivateKey(k.privateKey, pass()));
|
||||
writeNew(o.out + '.crt', P.pem('CERTIFICATE', cert), 0o644);
|
||||
writeNew(o.out + '.chain.pem', P.pem('CERTIFICATE', cert) + P.pem('CERTIFICATE', ca.cert), 0o644);
|
||||
console.log(`certificate for "${o.cn}" (${alg}, serial ${P.parseCert(cert).serial.toString('hex')}) written to ${o.out}.crt`);
|
||||
return 0;
|
||||
}
|
||||
if (cmd === 'revoke') {
|
||||
need(o, 'dir', 'ca', 'cert');
|
||||
const ca = loadCa(o.dir, o.ca);
|
||||
const c = P.parseCert(P.unpem(fs.readFileSync(o.cert, 'utf8'))[0]);
|
||||
if (!c.issuerRaw.equals(P.parseCert(ca.cert).subjectRaw)) throw new P.PkiError('ISSUER', 'this certificate was not issued by ' + ca.n);
|
||||
const f = path.join(o.dir, ca.n + '.revoked.json');
|
||||
const list = JSON.parse(fs.readFileSync(f, 'utf8'));
|
||||
const s = c.serial.toString('hex');
|
||||
if (!list.some((x) => x.serial === s)) list.push({ serial: s, date: new Date().toISOString() });
|
||||
fs.writeFileSync(f, JSON.stringify(list, null, 1) + '\n');
|
||||
console.log(`serial ${s} revoked by ${ca.n}; publish a new list with "crl"`);
|
||||
return 0;
|
||||
}
|
||||
if (cmd === 'crl') {
|
||||
need(o, 'dir', 'ca', 'out');
|
||||
const ca = loadCa(o.dir, o.ca);
|
||||
const nf = path.join(o.dir, ca.n + '.crlnum');
|
||||
const number = (fs.existsSync(nf) ? Number(fs.readFileSync(nf, 'utf8')) : 0) + 1;
|
||||
const list = JSON.parse(fs.readFileSync(path.join(o.dir, ca.n + '.revoked.json'), 'utf8'));
|
||||
const der = P.crl({ issuer: ca.cert, signingKey: ca.key, revoked: list, days: Number(o.days || 7), number });
|
||||
fs.writeFileSync(o.out, P.pem('X509 CRL', der));
|
||||
fs.writeFileSync(nf, String(number));
|
||||
console.log(`revocation list #${number} of ${ca.n} (${list.length} revoked) written to ${o.out}`);
|
||||
return 0;
|
||||
}
|
||||
if (cmd === 'verify') {
|
||||
need(o, 'roots', 'chain');
|
||||
const chain = P.unpem(fs.readFileSync(o.chain, 'utf8'));
|
||||
const r = P.verifyChain({ leaf: chain[0], intermediates: chain.slice(1), roots: P.unpem(fs.readFileSync(o.roots, 'utf8')),
|
||||
host: o.host || null, purpose: o.purpose || 'serverAuth', requireCrl: !!o['require-crl'],
|
||||
crls: (o.crl || []).flatMap((f) => P.unpem(fs.readFileSync(f, 'utf8'), 'X509 CRL')) });
|
||||
console.log(r.ok ? `OK: ${r.chain.join(' <- ')}` : `REFUSED (${r.code}): ${r.reason}`);
|
||||
return r.ok ? 0 : 1;
|
||||
}
|
||||
if (cmd === 'unseal') {
|
||||
need(o, 'key', 'out');
|
||||
const key = P.importPrivateKey(fs.readFileSync(o.key, 'utf8'), pass());
|
||||
writeNew(o.out, key.export({ type: 'pkcs8', format: 'pem' }));
|
||||
console.log(`${o.key} unsealed to ${o.out} as UNENCRYPTED PKCS#8 (mode 0600); delete it when the server no longer needs it`);
|
||||
return 0;
|
||||
}
|
||||
throw new P.PkiError('USAGE', 'unknown command ' + cmd + '\n' + USAGE);
|
||||
}
|
||||
|
||||
try { process.exitCode = main(process.argv.slice(2)); }
|
||||
catch (e) { console.error(`error (${e.code || 'ERROR'}): ${e.message}`); process.exitCode = 2; }
|
||||
165
pq-pki/der.mjs
Normal file
165
pq-pki/der.mjs
Normal file
@ -0,0 +1,165 @@
|
||||
// der.mjs: codor si decodor DER minimal (ASN.1), numai ce trebuie pentru X.509 v3 si CRL v2. Fara dependinte.
|
||||
// Decodorul e STRICT: lungimi in forma cea mai scurta, fara lungime nedefinita, fara octeti in plus; un DER necanonic e o eroare,
|
||||
// nu o interpretare (doua codificari ale aceluiasi certificat ar da doua amprente).
|
||||
|
||||
export const TAG = {
|
||||
BOOLEAN: 0x01, INTEGER: 0x02, BIT_STRING: 0x03, OCTET_STRING: 0x04, NULL: 0x05, OID: 0x06, UTF8: 0x0c,
|
||||
PRINTABLE: 0x13, IA5: 0x16, UTC_TIME: 0x17, GEN_TIME: 0x18, SEQUENCE: 0x30, SET: 0x31,
|
||||
};
|
||||
|
||||
function lenBytes(n) {
|
||||
if (n < 0x80) return Buffer.from([n]);
|
||||
const out = [];
|
||||
while (n > 0) { out.unshift(n & 0xff); n = Math.floor(n / 256); }
|
||||
return Buffer.from([0x80 | out.length, ...out]);
|
||||
}
|
||||
|
||||
export function tlv(tag, content) {
|
||||
const c = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||||
return Buffer.concat([Buffer.from([tag]), lenBytes(c.length), c]);
|
||||
}
|
||||
|
||||
export const seq = (...items) => tlv(TAG.SEQUENCE, Buffer.concat(items.filter(Boolean)));
|
||||
export const set = (...items) => tlv(TAG.SET, Buffer.concat(items.filter(Boolean)));
|
||||
export const ctx = (n, content, constructed = true) => tlv((constructed ? 0xa0 : 0x80) | n, content);
|
||||
export const octets = (b) => tlv(TAG.OCTET_STRING, b);
|
||||
export const bits = (b, unused = 0) => tlv(TAG.BIT_STRING, Buffer.concat([Buffer.from([unused]), b]));
|
||||
export const bool = (v) => tlv(TAG.BOOLEAN, Buffer.from([v ? 0xff : 0x00]));
|
||||
export const utf8 = (s) => tlv(TAG.UTF8, Buffer.from(s, 'utf8'));
|
||||
export const ia5 = (s) => tlv(TAG.IA5, Buffer.from(s, 'ascii'));
|
||||
|
||||
/** INTEGER din Buffer fara semn (big-endian) sau din numar/BigInt nenegativ, in forma DER minima. */
|
||||
export function int(v) {
|
||||
let b;
|
||||
if (Buffer.isBuffer(v)) b = v;
|
||||
else {
|
||||
let x = BigInt(v);
|
||||
if (x < 0n) throw new Error('DER: numai intregi nenegativi');
|
||||
const a = [];
|
||||
do { a.unshift(Number(x & 0xffn)); x >>= 8n; } while (x > 0n);
|
||||
b = Buffer.from(a);
|
||||
}
|
||||
let i = 0;
|
||||
while (i < b.length - 1 && b[i] === 0 && (b[i + 1] & 0x80) === 0) i++;
|
||||
b = b.subarray(i);
|
||||
if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b]);
|
||||
return tlv(TAG.INTEGER, b);
|
||||
}
|
||||
|
||||
/** Un subidentificator in baza 128, cu bitul 7 pus pe toti octetii in afara de ultimul (X.690 8.19.2). */
|
||||
function base128(x) {
|
||||
const a = [Number(x & 0x7fn)]; x >>= 7n;
|
||||
while (x > 0n) { a.unshift(Number(x & 0x7fn) | 0x80); x >>= 7n; }
|
||||
return a;
|
||||
}
|
||||
|
||||
export function oid(dotted) {
|
||||
const p = String(dotted).split('.').map((x) => { if (!/^\d+$/.test(x)) throw new Error('DER: OID cu componenta nevalida: ' + dotted); return BigInt(x); });
|
||||
if (p.length < 2) throw new Error('DER: OID prea scurt');
|
||||
if (p[0] > 2n || (p[0] < 2n && p[1] > 39n)) throw new Error('DER: OID cu arc nevalid: ' + dotted);
|
||||
// [A10] primul subidentificator poarta impreuna arcul si al doilea numar (40*arc + al doilea) si, ca oricare altul, se scrie in baza 128
|
||||
// pe mai multi octeti cand trece de 127: 2.999 e 88 37, nu un singur octet trunchiat. Pe arcul 2 al doilea numar nu are limita.
|
||||
const out = base128(p[0] * 40n + p[1]);
|
||||
for (const v of p.slice(2)) out.push(...base128(v));
|
||||
return tlv(TAG.OID, Buffer.from(out));
|
||||
}
|
||||
|
||||
/** Timp X.509: UTCTime pana in 2049, GeneralizedTime dupa (RFC 5280 4.1.2.5), cu secunde si Z. */
|
||||
export function time(d) {
|
||||
const y = d.getUTCFullYear();
|
||||
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
||||
const rest = pad(d.getUTCMonth() + 1) + pad(d.getUTCDate()) + pad(d.getUTCHours()) + pad(d.getUTCMinutes()) + pad(d.getUTCSeconds()) + 'Z';
|
||||
if (y >= 1950 && y < 2050) return tlv(TAG.UTC_TIME, Buffer.from(pad(y % 100) + rest, 'ascii'));
|
||||
return tlv(TAG.GEN_TIME, Buffer.from(pad(y, 4) + rest, 'ascii'));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ decodare
|
||||
|
||||
/** Un element: { tag, start, hdr, len, end, raw (tot TLV-ul), content } peste acelasi Buffer. */
|
||||
export function read(buf, off = 0) {
|
||||
if (off + 2 > buf.length) throw new Error('DER: trunchiat la antet');
|
||||
const tag = buf[off];
|
||||
if ((tag & 0x1f) === 0x1f) throw new Error('DER: eticheta cu forma lunga nesuportata');
|
||||
let l = buf[off + 1], hdr = 2;
|
||||
if (l === 0x80) throw new Error('DER: lungime nedefinita (BER), refuzata');
|
||||
if (l & 0x80) {
|
||||
const n = l & 0x7f;
|
||||
if (n === 0 || n > 4) throw new Error('DER: lungime pe ' + n + ' octeti');
|
||||
if (off + 2 + n > buf.length) throw new Error('DER: trunchiat la lungime');
|
||||
if (buf[off + 2] === 0) throw new Error('DER: lungime cu zero initial (necanonica)');
|
||||
l = 0;
|
||||
for (let i = 0; i < n; i++) l = l * 256 + buf[off + 2 + i];
|
||||
if (l < 0x80) throw new Error('DER: lungime scurta scrisa in forma lunga (necanonica)');
|
||||
hdr = 2 + n;
|
||||
}
|
||||
const end = off + hdr + l;
|
||||
if (end > buf.length) throw new Error('DER: continut trunchiat');
|
||||
return { tag, start: off, hdr, len: l, end, raw: buf.subarray(off, end), content: buf.subarray(off + hdr, end) };
|
||||
}
|
||||
|
||||
/** Toate elementele din continutul unui element construit (SEQUENCE, SET, [n]). */
|
||||
export function children(el) {
|
||||
const out = [];
|
||||
let o = 0;
|
||||
while (o < el.content.length) { const c = read(el.content, o); out.push(c); o = c.end; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Decodeaza un document DER intreg si refuza octetii in plus. */
|
||||
export function parse(buf) {
|
||||
const el = read(buf, 0);
|
||||
if (el.end !== buf.length) throw new Error('DER: ' + (buf.length - el.end) + ' octeti dupa structura');
|
||||
return el;
|
||||
}
|
||||
|
||||
export function expect(el, tag, what) {
|
||||
if (el.tag !== tag) throw new Error(`DER: ${what}: eticheta 0x${el.tag.toString(16)}, asteptat 0x${tag.toString(16)}`);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function oidToString(el) {
|
||||
expect(el, TAG.OID, 'OID');
|
||||
const b = el.content;
|
||||
if (b.length === 0) throw new Error('DER: OID gol');
|
||||
if (b[b.length - 1] & 0x80) throw new Error('DER: OID trunchiat');
|
||||
// [A10] intai se citesc TOTI subidentificatorii in baza 128 (si primul poate avea mai multi octeti), abia apoi primul se desparte in
|
||||
// arc si al doilea numar: sub 80 arcul e catul impartirii la 40, de la 80 in sus arcul e 2 (X.690 8.19.4). Un octet 0x80 la inceputul
|
||||
// unui subidentificator e o codificare necanonica si se refuza.
|
||||
const subs = []; let v = 0n, inceput = true;
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
if (inceput && b[i] === 0x80) throw new Error('DER: OID necanonic');
|
||||
v = (v << 7n) | BigInt(b[i] & 0x7f); inceput = false;
|
||||
if ((b[i] & 0x80) === 0) { subs.push(v); v = 0n; inceput = true; }
|
||||
}
|
||||
const first = subs[0];
|
||||
const arc = first < 80n ? first / 40n : 2n;
|
||||
return [arc, first - arc * 40n, ...subs.slice(1)].map(String).join('.');
|
||||
}
|
||||
|
||||
export function intToBigInt(el) {
|
||||
expect(el, TAG.INTEGER, 'INTEGER');
|
||||
const b = el.content;
|
||||
if (b.length === 0) throw new Error('DER: INTEGER gol');
|
||||
if (b.length > 1 && ((b[0] === 0 && (b[1] & 0x80) === 0) || (b[0] === 0xff && (b[1] & 0x80)))) throw new Error('DER: INTEGER necanonic');
|
||||
if (b[0] & 0x80) throw new Error('DER: INTEGER negativ');
|
||||
return BigInt('0x' + (b.toString('hex') || '0'));
|
||||
}
|
||||
|
||||
export function timeToDate(el) {
|
||||
const s = el.content.toString('ascii');
|
||||
let m;
|
||||
if (el.tag === TAG.UTC_TIME && (m = /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(s))) {
|
||||
const yy = +m[1]; const y = yy >= 50 ? 1900 + yy : 2000 + yy;
|
||||
return new Date(Date.UTC(y, +m[2] - 1, +m[3], +m[4], +m[5], +m[6]));
|
||||
}
|
||||
if (el.tag === TAG.GEN_TIME && (m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(s))) {
|
||||
return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]));
|
||||
}
|
||||
throw new Error('DER: timp in forma nepermisa de RFC 5280: ' + s);
|
||||
}
|
||||
|
||||
export function bitString(el) {
|
||||
expect(el, TAG.BIT_STRING, 'BIT STRING');
|
||||
if (el.content.length === 0) throw new Error('DER: BIT STRING gol');
|
||||
return { unused: el.content[0], bytes: el.content.subarray(1) };
|
||||
}
|
||||
520
pq-pki/pki.mjs
Normal file
520
pq-pki/pki.mjs
Normal file
@ -0,0 +1,520 @@
|
||||
// pki.mjs: autoritate de certificare post-cuantica privata. Emite certificate X.509 v3 semnate cu ML-DSA (FIPS 204, forma "pura",
|
||||
// context gol, cum cere RFC 9881 pentru certificate), liste de revocare v2 semnate la fel, si verifica un lant strict (RFC 5280, partea
|
||||
// de care are nevoie un TLS privat). Numai Node 24 + OpenSSL 3.5 (node:crypto), fara dependinte.
|
||||
// Ce NU face: certificate compuse (hibride clasic + PQ; sunt inca drafturi IETF), OCSP, constrangeri de nume, politici, liste delta sau
|
||||
// partiale (IDP). Numele se compara pe octeti (DER identic), nu prin normalizarea completa din RFC 5280: lanturile emise aici folosesc
|
||||
// aceeasi codificare.
|
||||
// Revizuirea adversariala din 2026-09-25 a gasit sapte defecte, reparate aici si marcate in cod cu [A1]..[A10]: cheia pe disc sub
|
||||
// PBKDF2 slab (A1), prima lista de revocare in loc de cea mai noua (A2), extensiile listei ignorate (A3), exceptie pe SPKI malformat si
|
||||
// frunza cu cheie de 10 octeti admisa (A5), EKU pe autoritati neaplicat (A6), prima ancora cu acelasi nume la reinnoirea radacinii (A7),
|
||||
// OID cu primul subidentificator pe mai multi octeti (A10, in der.mjs).
|
||||
import crypto from 'node:crypto';
|
||||
import * as D from './der.mjs';
|
||||
|
||||
export const ALG = {
|
||||
'ml-dsa-44': '2.16.840.1.101.3.4.3.17',
|
||||
'ml-dsa-65': '2.16.840.1.101.3.4.3.18',
|
||||
'ml-dsa-87': '2.16.840.1.101.3.4.3.19',
|
||||
};
|
||||
const ALG_BY_OID = Object.fromEntries(Object.entries(ALG).map(([k, v]) => [v, k]));
|
||||
/** [A5] lungimea cheii publice ML-DSA, FIPS 204 tabelul 2: o cheie de alta lungime nu e o cheie, oricare ar fi OID-ul de deasupra ei. */
|
||||
export const PK_LEN = { 'ml-dsa-44': 1312, 'ml-dsa-65': 1952, 'ml-dsa-87': 2592 };
|
||||
const OID = {
|
||||
CN: '2.5.4.3', O: '2.5.4.10',
|
||||
basicConstraints: '2.5.29.19', keyUsage: '2.5.29.15', extKeyUsage: '2.5.29.37', subjectAltName: '2.5.29.17',
|
||||
subjectKeyIdentifier: '2.5.29.14', authorityKeyIdentifier: '2.5.29.35', crlNumber: '2.5.29.20',
|
||||
issuingDistributionPoint: '2.5.29.28', deltaCRLIndicator: '2.5.29.27',
|
||||
serverAuth: '1.3.6.1.5.5.7.3.1', clientAuth: '1.3.6.1.5.5.7.3.2',
|
||||
};
|
||||
const KU = { digitalSignature: 0, keyCertSign: 5, cRLSign: 6 };
|
||||
const EXT_STIUTE = new Set([OID.basicConstraints, OID.keyUsage, OID.extKeyUsage, OID.subjectAltName, OID.subjectKeyIdentifier, OID.authorityKeyIdentifier]);
|
||||
/** [A3] extensii de CRL pe care le intelegem (AKI, crlNumber) si extensii care schimba INTELESUL listei si pe care le refuzam oricum ar fi
|
||||
* marcate: o lista delta sau una cu punct de distributie (partiala, indirecta, numai CA, numai anumite motive) nu acopera ce pare sa acopere. */
|
||||
const CRL_EXT_STIUTE = new Set([OID.authorityKeyIdentifier, OID.crlNumber]);
|
||||
const CRL_EXT_REFUZATE = { [OID.issuingDistributionPoint]: 'issuingDistributionPoint', [OID.deltaCRLIndicator]: 'deltaCRLIndicator' };
|
||||
|
||||
export class PkiError extends Error { constructor(code, msg) { super(msg); this.code = code; } }
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ chei
|
||||
|
||||
export function generateKey(alg) {
|
||||
if (!ALG[alg]) throw new PkiError('ALG', 'unsupported algorithm ' + alg + ' (ml-dsa-44, ml-dsa-65, ml-dsa-87)');
|
||||
return crypto.generateKeyPairSync(alg);
|
||||
}
|
||||
|
||||
// [A1] Sigilarea proprie a cheii pe disc. PKCS#8 cifrat de Node (key.export cu cipher) foloseste PBKDF2 cu 2048 de iteratii, masurat la
|
||||
// 3-5 ms per incercare de parola, adica o parola de 12 caractere se incearca de sute de ori pe secunda pe un singur fir. Aici cheia de
|
||||
// cifrare vine din scrypt (N=2^17, r=8, p=1: 128 MiB de memorie si ~1 s per incercare pe un laptop, masurat in proba), iar DER-ul PKCS#8
|
||||
// e cifrat cu AES-256-GCM, cu antetul (versiunea formatului si toti parametrii) legat ca AAD: un octet schimbat in antet sau in text
|
||||
// strica eticheta de autentificare, deci "parola gresita" si "fisier atins" au acelasi raspuns, KEY_LOCKED.
|
||||
// AerePqPkiKey ::= SEQUENCE { header SEQUENCE { version INTEGER (1), kdf UTF8String "scrypt", salt OCTET STRING (16), N INTEGER,
|
||||
// r INTEGER, p INTEGER, cipher UTF8String "aes-256-gcm", iv OCTET STRING (12) }, tag OCTET STRING (16),
|
||||
// ciphertext OCTET STRING (PKCS#8 DER cifrat) }
|
||||
// scris PEM sub eticheta KEY_LABEL. PKCS#8 clasic (cifrat sau nu) NU mai e acceptat de pe disc.
|
||||
export const KEY_LABEL = 'AERE PQ PKI PRIVATE KEY';
|
||||
export const SEAL = Object.freeze({ version: 1, kdf: 'scrypt', N: 2 ** 17, r: 8, p: 1, cipher: 'aes-256-gcm', saltLen: 16, ivLen: 12, tagLen: 16, maxmem: 256 * 1024 * 1024 });
|
||||
const SEAL_N_MIN = 2 ** 14, SEAL_N_MAX = 2 ** 20; // la citire: sub 2^14 e prea slab ca sa fi fost scris de noi, peste 2^20 ar cere peste 1 GiB
|
||||
/** Parole pe care orice dictionar le incearca primele; comparate dupa ce se scot cifrele si semnele de la coada. */
|
||||
const PAROLE_EVIDENTE = new Set(['password', 'passw0rd', 'passphrase', 'qwerty', 'qwertyuiop', '123456', '12345678', '123456789', '1234567890',
|
||||
'iloveyou', 'letmein', 'welcome', 'admin', 'administrator', 'abc123', 'monkey', 'dragon', 'secret', 'changeme', 'default']);
|
||||
|
||||
/** [A1] Politica de parola: cel putin 12 caractere; cel putin 3 clase (minuscule, majuscule, cifre, altele) sau cel putin 20 de caractere;
|
||||
* nu toate caracterele identice; nu o parola evidenta (cu sau fara cifre/semne la coada). Arunca PkiError('PASSPHRASE'). */
|
||||
export function checkPassphrase(p) {
|
||||
if (typeof p !== 'string' || p.length < 12) throw new PkiError('PASSPHRASE', 'the passphrase needs at least 12 characters');
|
||||
const clase = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(p)).length;
|
||||
if (clase < 3 && p.length < 20) throw new PkiError('PASSPHRASE', 'the passphrase needs 3 character classes (lower, upper, digit, other) or at least 20 characters');
|
||||
if (/^(.)\1*$/s.test(p)) throw new PkiError('PASSPHRASE', 'a passphrase made of one repeated character is refused');
|
||||
const nucleu = p.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (PAROLE_EVIDENTE.has(nucleu) || PAROLE_EVIDENTE.has(nucleu.replace(/[0-9]+$/, ''))) throw new PkiError('PASSPHRASE', 'this passphrase is on every attacker\'s first list');
|
||||
return p;
|
||||
}
|
||||
|
||||
const sealHeader = (salt, N, r, p, iv) => D.seq(D.int(SEAL.version), D.utf8(SEAL.kdf), D.octets(salt), D.int(N), D.int(r), D.int(p), D.utf8(SEAL.cipher), D.octets(iv));
|
||||
|
||||
export function exportPrivateKey(key, passphrase) {
|
||||
checkPassphrase(passphrase);
|
||||
algOfKey(key);
|
||||
const salt = crypto.randomBytes(SEAL.saltLen), iv = crypto.randomBytes(SEAL.ivLen);
|
||||
const header = sealHeader(salt, SEAL.N, SEAL.r, SEAL.p, iv);
|
||||
const kek = crypto.scryptSync(passphrase, salt, 32, { N: SEAL.N, r: SEAL.r, p: SEAL.p, maxmem: SEAL.maxmem });
|
||||
const plain = key.export({ type: 'pkcs8', format: 'der' });
|
||||
const c = crypto.createCipheriv(SEAL.cipher, kek, iv, { authTagLength: SEAL.tagLen });
|
||||
c.setAAD(header);
|
||||
const ct = Buffer.concat([c.update(plain), c.final()]);
|
||||
const tag = c.getAuthTag();
|
||||
plain.fill(0); kek.fill(0);
|
||||
return pem(KEY_LABEL, D.seq(header, D.octets(tag), D.octets(ct)));
|
||||
}
|
||||
|
||||
/** Citeste antetul unei chei sigilate fara sa o deschida (parametrii KDF, ca sa poata fi masurati). Arunca PkiError('KEY_FORMAT'). */
|
||||
export function readSealedKeyHeader(pemText) {
|
||||
const blobs = unpem(pemText, KEY_LABEL);
|
||||
if (blobs.length !== 1) {
|
||||
if (/-----BEGIN (ENCRYPTED )?PRIVATE KEY-----/.test(pemText)) throw new PkiError('KEY_FORMAT', 'this is a PKCS#8 key; private keys are accepted only in the sealed "' + KEY_LABEL + '" format (scrypt + AES-256-GCM)');
|
||||
throw new PkiError('KEY_FORMAT', 'expected exactly one "' + KEY_LABEL + '" block');
|
||||
}
|
||||
let h;
|
||||
try {
|
||||
const [hdr, tagEl, ctEl, ...rest] = D.children(D.expect(D.parse(blobs[0]), D.TAG.SEQUENCE, 'sealed key'));
|
||||
if (rest.length || !ctEl) throw new Error('three parts expected');
|
||||
const [ver, kdf, salt, N, r, p, cipher, iv, ...hr] = D.children(D.expect(hdr, D.TAG.SEQUENCE, 'header'));
|
||||
if (hr.length || !iv) throw new Error('eight header fields expected');
|
||||
h = {
|
||||
version: Number(D.intToBigInt(ver)), kdf: D.expect(kdf, D.TAG.UTF8, 'kdf').content.toString('utf8'),
|
||||
salt: Buffer.from(D.expect(salt, D.TAG.OCTET_STRING, 'salt').content), N: Number(D.intToBigInt(N)), r: Number(D.intToBigInt(r)), p: Number(D.intToBigInt(p)),
|
||||
cipher: D.expect(cipher, D.TAG.UTF8, 'cipher').content.toString('utf8'), iv: Buffer.from(D.expect(iv, D.TAG.OCTET_STRING, 'iv').content),
|
||||
tag: Buffer.from(D.expect(tagEl, D.TAG.OCTET_STRING, 'tag').content), ct: Buffer.from(D.expect(ctEl, D.TAG.OCTET_STRING, 'ciphertext').content), aad: Buffer.from(hdr.raw),
|
||||
};
|
||||
} catch (e) { throw new PkiError('KEY_FORMAT', 'sealed key: ' + e.message); }
|
||||
if (h.version !== SEAL.version || h.kdf !== SEAL.kdf || h.cipher !== SEAL.cipher) throw new PkiError('KEY_FORMAT', `sealed key: unknown format (version ${h.version}, ${h.kdf}, ${h.cipher})`);
|
||||
if (h.salt.length !== SEAL.saltLen || h.iv.length !== SEAL.ivLen || h.tag.length !== SEAL.tagLen) throw new PkiError('KEY_FORMAT', 'sealed key: salt, iv or tag of the wrong length');
|
||||
if (!Number.isInteger(h.N) || h.N < SEAL_N_MIN || h.N > SEAL_N_MAX || (h.N & (h.N - 1)) !== 0 || !(h.r >= 1 && h.r <= 32) || !(h.p >= 1 && h.p <= 16)) throw new PkiError('KEY_FORMAT', `sealed key: scrypt parameters out of range (N=${h.N}, r=${h.r}, p=${h.p})`);
|
||||
return h;
|
||||
}
|
||||
|
||||
export function importPrivateKey(pemText, passphrase) {
|
||||
const h = readSealedKeyHeader(pemText);
|
||||
if (typeof passphrase !== 'string' || !passphrase) throw new PkiError('KEY_LOCKED', 'the private key could not be opened (missing or wrong passphrase)');
|
||||
let kek;
|
||||
try { kek = crypto.scryptSync(passphrase, h.salt, 32, { N: h.N, r: h.r, p: h.p, maxmem: SEAL.maxmem }); }
|
||||
catch (e) { throw new PkiError('KEY_FORMAT', 'sealed key: scrypt refused the parameters (' + e.message + ')'); }
|
||||
let plain;
|
||||
try {
|
||||
const d = crypto.createDecipheriv(SEAL.cipher, kek, h.iv, { authTagLength: SEAL.tagLen });
|
||||
d.setAAD(h.aad); d.setAuthTag(h.tag);
|
||||
plain = Buffer.concat([d.update(h.ct), d.final()]);
|
||||
} catch { throw new PkiError('KEY_LOCKED', 'the private key could not be opened (missing or wrong passphrase)'); }
|
||||
finally { kek.fill(0); }
|
||||
try {
|
||||
const key = crypto.createPrivateKey({ key: plain, format: 'der', type: 'pkcs8' });
|
||||
algOfKey(key);
|
||||
return key;
|
||||
} catch (e) { throw new PkiError(e.code === 'ALG' ? 'ALG' : 'KEY_FORMAT', 'sealed key: the content is not an ML-DSA PKCS#8 key'); }
|
||||
finally { plain.fill(0); }
|
||||
}
|
||||
|
||||
function algOfKey(k) {
|
||||
const t = k.asymmetricKeyType;
|
||||
if (!ALG[t]) throw new PkiError('ALG', 'key type ' + t + ' is not ML-DSA');
|
||||
return t;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ constructie
|
||||
|
||||
const algId = (alg) => D.seq(D.oid(ALG[alg])); // RFC 9881: parametrii LIPSESC
|
||||
export function name({ cn, o }) {
|
||||
const rdn = (oid, v) => D.set(D.seq(D.oid(oid), D.utf8(v)));
|
||||
return D.seq(o ? rdn(OID.O, o) : null, rdn(OID.CN, cn));
|
||||
}
|
||||
function keyUsageBits(names) {
|
||||
let v = 0, hi = -1;
|
||||
for (const n of names) { const b = KU[n]; v |= 0x80 >> b; hi = Math.max(hi, b); }
|
||||
const bytes = Buffer.from([v & 0xff]);
|
||||
const unused = 7 - hi; // biti ramasi neinsemnati dupa cel mai mare bit pus (DER: fara biti zero la coada)
|
||||
return D.bits(bytes, unused);
|
||||
}
|
||||
const ext = (oid, critical, valueDer) => D.seq(D.oid(oid), critical ? D.bool(true) : null, D.octets(valueDer));
|
||||
function keyId(spkiDer) {
|
||||
const spki = D.parse(spkiDer); const [, pk] = D.children(spki);
|
||||
return crypto.createHash('sha256').update(D.bitString(pk).bytes).digest().subarray(0, 20); // RFC 7093 metoda 1
|
||||
}
|
||||
|
||||
function randomSerial() {
|
||||
const b = crypto.randomBytes(16);
|
||||
b[0] &= 0x7f; if (b[0] === 0) b[0] = 1;
|
||||
return b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emite un certificat. issuer = { cert (DER) , key (KeyObject privat) } sau null pentru o radacina auto-semnata.
|
||||
* opts: { subject: {cn, o}, publicKey (KeyObject), ca: bool, pathLen?: int, days, dns?: [], ips?: [], eku?: ['serverAuth','clientAuth'], notBefore? }
|
||||
*/
|
||||
export function issue({ issuer, signingKey, subject, publicKey, ca = false, pathLen, days, dns = [], ips = [], eku = [], notBefore }) {
|
||||
const alg = algOfKey(signingKey);
|
||||
algOfKey(publicKey);
|
||||
const spki = publicKey.export({ type: 'spki', format: 'der' });
|
||||
const ski = keyId(spki);
|
||||
const ic = issuer ? parseCert(issuer) : null;
|
||||
const signerSpki = crypto.createPublicKey(signingKey).export({ type: 'spki', format: 'der' });
|
||||
if (ic) {
|
||||
if (!ic.isCA || !ic.keyUsage.has('keyCertSign')) throw new PkiError('ISSUER', 'the issuer certificate is not a CA with keyCertSign');
|
||||
if (!signerSpki.equals(ic.spki)) throw new PkiError('ISSUER', 'the signing key does not belong to the issuer certificate');
|
||||
if (!ic.ski) throw new PkiError('ISSUER', 'the issuer certificate has no subject key identifier');
|
||||
} else if (!signerSpki.equals(spki)) throw new PkiError('ISSUER', 'a self-signed root is signed by its own key');
|
||||
const issuerName = ic ? ic.subjectRaw : name(subject);
|
||||
const aki = ic ? ic.ski : null;
|
||||
const nb = notBefore ? new Date(notBefore) : new Date(Date.now() - 60 * 1000);
|
||||
const na = new Date(nb.getTime() + days * 86400 * 1000);
|
||||
const exts = [
|
||||
ext(OID.basicConstraints, true, D.seq(ca ? D.bool(true) : null, ca && Number.isInteger(pathLen) ? D.int(pathLen) : null)),
|
||||
ext(OID.keyUsage, true, keyUsageBits(ca ? ['digitalSignature', 'keyCertSign', 'cRLSign'] : ['digitalSignature'])),
|
||||
eku.length ? ext(OID.extKeyUsage, false, D.seq(...eku.map((e) => D.oid(OID[e])))) : null,
|
||||
dns.length || ips.length ? ext(OID.subjectAltName, false, D.seq(
|
||||
...dns.map((d) => D.tlv(0x82, Buffer.from(d, 'ascii'))),
|
||||
...ips.map((ip) => D.tlv(0x87, ipBytes(ip))))) : null,
|
||||
ext(OID.subjectKeyIdentifier, false, D.octets(ski)),
|
||||
aki ? ext(OID.authorityKeyIdentifier, false, D.seq(D.tlv(0x80, aki))) : null,
|
||||
].filter(Boolean);
|
||||
const tbs = D.seq(
|
||||
D.ctx(0, D.int(2)), D.int(randomSerial()), algId(alg), issuerName,
|
||||
D.seq(D.time(nb), D.time(na)), name(subject), spki,
|
||||
D.ctx(3, D.seq(...exts)));
|
||||
const sig = crypto.sign(null, tbs, signingKey);
|
||||
return D.seq(tbs, algId(alg), D.bits(sig));
|
||||
}
|
||||
|
||||
function ipBytes(ip) {
|
||||
const p = ip.split('.').map(Number);
|
||||
if (p.length !== 4 || p.some((x) => !(x >= 0 && x <= 255))) throw new PkiError('SAN', 'only IPv4 addresses are supported: ' + ip);
|
||||
return Buffer.from(p);
|
||||
}
|
||||
|
||||
/** Lista de revocare v2. revoked = [{ serial (Buffer sau hex), date }]; number = numarul CRL-ului (crescator). */
|
||||
export function crl({ issuer, signingKey, revoked = [], days, number, thisUpdate }) {
|
||||
const alg = algOfKey(signingKey);
|
||||
const c = parseCert(issuer);
|
||||
if (!c.isCA || !c.keyUsage.has('cRLSign')) throw new PkiError('ISSUER', 'the issuer may not sign revocation lists');
|
||||
const tu = thisUpdate ? new Date(thisUpdate) : new Date(Date.now() - 60 * 1000);
|
||||
const nu = new Date(tu.getTime() + days * 86400 * 1000);
|
||||
const rev = revoked.map((r) => D.seq(D.int(Buffer.isBuffer(r.serial) ? r.serial : Buffer.from(r.serial, 'hex')), D.time(new Date(r.date))));
|
||||
const tbs = D.seq(D.int(1), algId(alg), c.subjectRaw, D.time(tu), D.time(nu), rev.length ? D.seq(...rev) : null,
|
||||
D.ctx(0, D.seq(ext(OID.authorityKeyIdentifier, false, D.seq(D.tlv(0x80, c.ski))), ext(OID.crlNumber, false, D.int(number)))));
|
||||
return D.seq(tbs, algId(alg), D.bits(crypto.sign(null, tbs, signingKey)));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ citire
|
||||
|
||||
/** Extensions ::= SEQUENCE OF Extension { extnID OID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING }, cu forma DER ceruta. */
|
||||
function parseExtensions(seqEl, what) {
|
||||
const exts = new Map();
|
||||
for (const x of D.children(D.expect(seqEl, D.TAG.SEQUENCE, what))) {
|
||||
const xs = D.children(D.expect(x, D.TAG.SEQUENCE, 'Extension'));
|
||||
const id = D.oidToString(xs[0]);
|
||||
let critical = false, val;
|
||||
if (xs.length === 3) { if (xs[1].tag !== D.TAG.BOOLEAN || xs[1].content[0] !== 0xff) throw new PkiError('DER', 'critical must be TRUE when present (DER)'); critical = true; val = xs[2]; }
|
||||
else if (xs.length === 2) val = xs[1];
|
||||
else throw new PkiError('DER', 'an Extension has two or three parts');
|
||||
if (exts.has(id)) throw new PkiError('EXT_DUP', 'extension ' + id + ' appears twice');
|
||||
exts.set(id, { critical, value: D.expect(val, D.TAG.OCTET_STRING, 'extnValue').content });
|
||||
}
|
||||
return exts;
|
||||
}
|
||||
|
||||
export function parseCert(der) {
|
||||
const top = D.parse(der);
|
||||
D.expect(top, D.TAG.SEQUENCE, 'certificat');
|
||||
const [tbsEl, algEl, sigEl, ...rest] = D.children(top);
|
||||
if (rest.length || !sigEl) throw new PkiError('DER', 'a certificate has exactly three parts');
|
||||
const t = D.children(D.expect(tbsEl, D.TAG.SEQUENCE, 'tbsCertificate'));
|
||||
let i = 0;
|
||||
if (!t[i] || t[i].tag !== 0xa0) throw new PkiError('VERSION', 'only X.509 v3 certificates are accepted');
|
||||
const ver = D.intToBigInt(D.children(t[i++])[0]);
|
||||
if (ver !== 2n) throw new PkiError('VERSION', 'only X.509 v3 certificates are accepted');
|
||||
if (t.length < 7) throw new PkiError('DER', 'tbsCertificate is missing fields');
|
||||
const serial = t[i++].content;
|
||||
D.intToBigInt(t[i - 1]); // forma canonica si pozitiva (RFC 5280 4.1.2.2)
|
||||
const innerAlg = sigAlgOf(t[i++]);
|
||||
const issuerRaw = D.expect(t[i++], D.TAG.SEQUENCE, 'issuer').raw;
|
||||
const [nbEl, naEl] = D.children(D.expect(t[i++], D.TAG.SEQUENCE, 'validity'));
|
||||
if (!naEl) throw new PkiError('DER', 'validity has two times');
|
||||
const subjectRaw = D.expect(t[i++], D.TAG.SEQUENCE, 'subject').raw;
|
||||
const spkiEl = D.expect(t[i++], D.TAG.SEQUENCE, 'subjectPublicKeyInfo');
|
||||
// [A5] cheia publica se valideaza la citire: doua parti, BIT STRING fara biti nefolositi, si lungimea FIPS 204 a algoritmului declarat.
|
||||
// Fara asta o frunza cu o "cheie" de 10 octeti sub id-ml-dsa-65 trecea verificarea lantului (verificatorul nu foloseste cheia frunzei),
|
||||
// iar un intermediar cu aceeasi cheie arunca o exceptie din node:crypto in loc de un verdict.
|
||||
const spkiKids = D.children(spkiEl);
|
||||
if (spkiKids.length !== 2) throw new PkiError('SPKI', 'subjectPublicKeyInfo has two parts');
|
||||
const pkAlg = sigAlgOf(spkiKids[0]);
|
||||
const pkBits = D.bitString(spkiKids[1]);
|
||||
if (pkBits.unused !== 0 || pkBits.bytes.length !== PK_LEN[pkAlg]) throw new PkiError('SPKI', `the ${pkAlg} public key must be ${PK_LEN[pkAlg]} bytes, this one has ${pkBits.bytes.length}`);
|
||||
let exts = new Map();
|
||||
while (i < t.length) {
|
||||
const e = t[i++];
|
||||
if (e.tag === 0xa1 || e.tag === 0xa2) throw new PkiError('UNIQUE_ID', 'unique identifiers are not accepted');
|
||||
if (e.tag !== 0xa3 || exts.size) throw new PkiError('DER', 'unexpected field in tbsCertificate');
|
||||
const inner = D.children(e);
|
||||
if (inner.length !== 1) throw new PkiError('DER', 'extensions [3] wraps exactly one SEQUENCE');
|
||||
exts = parseExtensions(inner[0], 'extensions');
|
||||
}
|
||||
const outerAlg = sigAlgOf(algEl);
|
||||
if (outerAlg !== innerAlg) throw new PkiError('ALG_MISMATCH', 'the outer signature algorithm differs from the one inside the certificate');
|
||||
const sig = D.bitString(sigEl);
|
||||
if (sig.unused !== 0) throw new PkiError('DER', 'signature bit string has unused bits');
|
||||
const c = {
|
||||
der: Buffer.from(der), tbs: tbsEl.raw, alg: innerAlg, pkAlg, signature: sig.bytes, serial: Buffer.from(serial),
|
||||
issuerRaw: Buffer.from(issuerRaw), subjectRaw: Buffer.from(subjectRaw), spki: Buffer.from(spkiEl.raw),
|
||||
notBefore: D.timeToDate(nbEl), notAfter: D.timeToDate(naEl), exts,
|
||||
isCA: false, pathLen: undefined, keyUsage: new Set(), eku: null, dns: [], ips: [], ski: null, aki: null, unknownCritical: [],
|
||||
};
|
||||
for (const [id, x] of exts) if (x.critical && !EXT_STIUTE.has(id)) c.unknownCritical.push(id);
|
||||
const bc = exts.get(OID.basicConstraints);
|
||||
if (bc) {
|
||||
const f = D.children(D.parse(bc.value));
|
||||
if (f[0] && f[0].tag === D.TAG.BOOLEAN) { c.isCA = f[0].content[0] === 0xff; if (!c.isCA) throw new PkiError('DER', 'cA FALSE must be absent (DER)'); f.shift(); }
|
||||
if (f[0]) c.pathLen = Number(D.intToBigInt(f[0]));
|
||||
}
|
||||
const ku = exts.get(OID.keyUsage);
|
||||
if (ku) {
|
||||
const b = D.bitString(D.parse(ku.value));
|
||||
for (const [n, bit] of Object.entries(KU)) if (b.bytes.length > (bit >> 3) && (b.bytes[bit >> 3] & (0x80 >> (bit & 7)))) c.keyUsage.add(n);
|
||||
}
|
||||
const ek = exts.get(OID.extKeyUsage);
|
||||
if (ek) c.eku = new Set(D.children(D.parse(ek.value)).map((o) => D.oidToString(o)));
|
||||
const san = exts.get(OID.subjectAltName);
|
||||
if (san) for (const g of D.children(D.parse(san.value))) {
|
||||
if (g.tag === 0x82) c.dns.push(g.content.toString('ascii').toLowerCase());
|
||||
else if (g.tag === 0x87 && g.content.length === 4) c.ips.push([...g.content].join('.'));
|
||||
}
|
||||
const s = exts.get(OID.subjectKeyIdentifier);
|
||||
if (s) c.ski = Buffer.from(D.expect(D.parse(s.value), D.TAG.OCTET_STRING, 'SKI').content);
|
||||
const a = exts.get(OID.authorityKeyIdentifier);
|
||||
if (a) { const k = D.children(D.parse(a.value)).find((x) => x.tag === 0x80); if (k) c.aki = Buffer.from(k.content); }
|
||||
return c;
|
||||
}
|
||||
|
||||
function sigAlgOf(el) {
|
||||
const f = D.children(D.expect(el, D.TAG.SEQUENCE, 'AlgorithmIdentifier'));
|
||||
if (!f.length) throw new PkiError('DER', 'empty AlgorithmIdentifier');
|
||||
const o = D.oidToString(f[0]);
|
||||
if (!ALG_BY_OID[o]) throw new PkiError('ALG', 'algorithm ' + o + ' is not ML-DSA');
|
||||
if (f.length !== 1) throw new PkiError('ALG_PARAMS', 'ML-DSA AlgorithmIdentifier must have no parameters (RFC 9881)');
|
||||
return ALG_BY_OID[o];
|
||||
}
|
||||
|
||||
const isTime = (el) => el && (el.tag === D.TAG.UTC_TIME || el.tag === D.TAG.GEN_TIME);
|
||||
|
||||
/**
|
||||
* Citeste o lista de revocare v2. Intoarce { tbs, alg, signature, issuerRaw, thisUpdate, nextUpdate, revoked (Map serialHex -> Date),
|
||||
* crlNumber (BigInt|null), aki (Buffer|null), refusedExt (string|null), exts }. [A3] Extensiile listei si ale intrarilor sunt citite:
|
||||
* AKI si crlNumber sunt intelese; issuingDistributionPoint si deltaCRLIndicator sunt REFUZATE oricum ar fi marcate, si la fel orice alta
|
||||
* extensie critica (a listei sau a unei intrari). Refuzul nu e exceptie: sta in refusedExt, si verifyChain il face verdict CRL_EXT, ca o
|
||||
* lista straina cu o extensie rea sa nu opreasca verificarea unui lant pe care nu il priveste.
|
||||
*/
|
||||
export function parseCrl(der) {
|
||||
const top = D.parse(der);
|
||||
const [tbsEl, algEl, sigEl, ...rest] = D.children(D.expect(top, D.TAG.SEQUENCE, 'CRL'));
|
||||
if (rest.length || !sigEl) throw new PkiError('DER', 'a revocation list has exactly three parts');
|
||||
const t = D.children(D.expect(tbsEl, D.TAG.SEQUENCE, 'tbsCertList'));
|
||||
let i = 0;
|
||||
if (!t[i] || t[i].tag !== D.TAG.INTEGER || D.intToBigInt(t[i++]) !== 1n) throw new PkiError('VERSION', 'only v2 revocation lists are accepted');
|
||||
const innerAlg = sigAlgOf(t[i++]);
|
||||
const issuerRaw = D.expect(t[i++], D.TAG.SEQUENCE, 'issuer').raw;
|
||||
if (!isTime(t[i])) throw new PkiError('DER', 'thisUpdate missing');
|
||||
const thisUpdate = D.timeToDate(t[i++]);
|
||||
let nextUpdate = null;
|
||||
if (isTime(t[i])) nextUpdate = D.timeToDate(t[i++]);
|
||||
const revoked = new Map();
|
||||
let refusedExt = null;
|
||||
const refuza = (what) => { if (!refusedExt) refusedExt = what; };
|
||||
if (t[i] && t[i].tag === D.TAG.SEQUENCE) {
|
||||
for (const r of D.children(t[i++])) {
|
||||
const [s, d, ee, ...er] = D.children(D.expect(r, D.TAG.SEQUENCE, 'revoked entry'));
|
||||
if (!d || er.length) throw new PkiError('DER', 'a revoked entry has two or three parts');
|
||||
D.intToBigInt(s); // seria, in forma canonica
|
||||
if (!isTime(d)) throw new PkiError('DER', 'revocation date missing');
|
||||
revoked.set(s.content.toString('hex'), D.timeToDate(d));
|
||||
if (ee) for (const [id, x] of parseExtensions(ee, 'crlEntryExtensions')) if (x.critical) refuza(`a critical entry extension ${id}`); // reasonCode, invalidityDate: necritice, ignorate
|
||||
}
|
||||
}
|
||||
let exts = new Map(), crlNumber = null, aki = null;
|
||||
if (t[i] && t[i].tag === 0xa0) {
|
||||
const inner = D.children(t[i++]);
|
||||
if (inner.length !== 1) throw new PkiError('DER', 'crlExtensions [0] wraps exactly one SEQUENCE');
|
||||
exts = parseExtensions(inner[0], 'crlExtensions');
|
||||
for (const [id, x] of exts) {
|
||||
if (CRL_EXT_REFUZATE[id]) refuza(`${CRL_EXT_REFUZATE[id]} (${id}); delta and partial lists are not supported`);
|
||||
else if (x.critical && !CRL_EXT_STIUTE.has(id)) refuza(`an unknown critical extension ${id}`);
|
||||
}
|
||||
const n = exts.get(OID.crlNumber);
|
||||
if (n) crlNumber = D.intToBigInt(D.parse(n.value));
|
||||
const a = exts.get(OID.authorityKeyIdentifier);
|
||||
if (a) { const k = D.children(D.parse(a.value)).find((x) => x.tag === 0x80); if (k) aki = Buffer.from(k.content); }
|
||||
}
|
||||
if (i !== t.length) throw new PkiError('DER', 'unexpected field in tbsCertList');
|
||||
if (sigAlgOf(algEl) !== innerAlg) throw new PkiError('ALG_MISMATCH', 'the outer signature algorithm differs from the one inside the list');
|
||||
const sig = D.bitString(sigEl);
|
||||
if (sig.unused !== 0) throw new PkiError('DER', 'signature bit string has unused bits');
|
||||
return { tbs: tbsEl.raw, alg: innerAlg, signature: sig.bytes, issuerRaw: Buffer.from(issuerRaw), thisUpdate, nextUpdate, revoked, crlNumber, aki, refusedExt, exts };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ verificare
|
||||
|
||||
/** [A5] Un SPKI pe care node:crypto nu il poate importa da PkiError('SPKI'), pe care verifyChain o face verdict; niciodata exceptie bruta. */
|
||||
function verifySig(tbs, signature, spkiDer, who) {
|
||||
let key;
|
||||
try { key = crypto.createPublicKey({ key: spkiDer, format: 'der', type: 'spki' }); }
|
||||
catch { throw new PkiError('SPKI', `the public key of "${who}" cannot be decoded`); }
|
||||
return crypto.verify(null, tbs, key, signature);
|
||||
}
|
||||
|
||||
function hostMatches(pattern, host) {
|
||||
if (pattern === host) return true;
|
||||
if (pattern.startsWith('*.')) { const rest = pattern.slice(2); const dot = host.indexOf('.'); return dot > 0 && host.slice(dot + 1) === rest; }
|
||||
return false;
|
||||
}
|
||||
|
||||
const MAX_ADANCIME = 8, MAX_DRUMURI = 32;
|
||||
const cmpBig = (a, b) => ((a ?? -1n) === (b ?? -1n) ? 0 : (a ?? -1n) > (b ?? -1n) ? 1 : -1);
|
||||
|
||||
/**
|
||||
* Verifica un lant: leaf (DER), intermediates [DER], roots [DER] (increderea), at (Date), purpose ('serverAuth'|'clientAuth'|null),
|
||||
* host (nume sau IPv4, pentru serverAuth), crls [DER], requireCrl (fiecare emitator din lant trebuie sa aiba o lista valabila).
|
||||
* Intoarce { ok, code, reason, chain: [subject CN...] }. Intoarce intotdeauna un verdict: pe orice octeti, niciodata exceptie [A5].
|
||||
*/
|
||||
export function verifyChain(opts) {
|
||||
try { return verifyChainInner(opts); }
|
||||
catch (e) { return { ok: false, code: e instanceof PkiError ? e.code : 'DER', reason: String(e && e.message || e) }; }
|
||||
}
|
||||
|
||||
function verifyChainInner({ leaf, intermediates = [], roots = [], at = new Date(), purpose = 'serverAuth', host = null, crls = [], requireCrl = false }) {
|
||||
const fail = (code, reason) => ({ ok: false, code, reason });
|
||||
let certs;
|
||||
try { certs = { leaf: parseCert(leaf), inter: intermediates.map(parseCert), roots: roots.map(parseCert) }; }
|
||||
catch (e) { return fail(e.code || 'DER', e.message); }
|
||||
const lists = [];
|
||||
try { for (const x of crls) lists.push(parseCrl(x)); } catch (e) { return fail(e.code || 'DER', 'revocation list: ' + e.message); }
|
||||
const emite = (p, c) => p.subjectRaw.equals(c.issuerRaw) && (!c.aki || (p.ski && p.ski.equals(c.aki)));
|
||||
|
||||
// 1. drumurile: de la frunza in sus, emitatorul e certificatul al carui subiect e egal (pe octeti) cu emitentul si al carui SKI e AKI-ul.
|
||||
// [A7] Se incearca TOATE candidatele (intai ancorele, apoi intermediarele, fara cicluri), si primul drum care trece intreg e raspunsul:
|
||||
// la reinnoirea radacinii operatorul are doua ancore cu acelasi nume si aceeasi cheie, una expirata, si "prima gasita" refuza un lant
|
||||
// valid; la fel cu un intermediar semnat incrucisat de o radacina straina, pus inaintea celui bun.
|
||||
let blocat = certs.leaf, blocatAdancime = -1, incercate = 0; // cel mai adanc certificat fara niciun emitent candidat (pentru mesajul UNTRUSTED)
|
||||
const stiva = [certs.leaf];
|
||||
function* drumuri(cur) {
|
||||
const ancore = certs.roots.filter((r) => emite(r, cur));
|
||||
for (const r of ancore) yield [...stiva, r];
|
||||
const inter = stiva.length < MAX_ADANCIME ? certs.inter.filter((c) => c !== cur && !stiva.includes(c) && emite(c, cur)) : [];
|
||||
for (const c of inter) { stiva.push(c); yield* drumuri(c); stiva.pop(); }
|
||||
if (!ancore.length && !inter.length && stiva.length - 1 > blocatAdancime) { blocat = cur; blocatAdancime = stiva.length - 1; }
|
||||
}
|
||||
let ultimul = null;
|
||||
for (const path of drumuri(certs.leaf)) {
|
||||
if (++incercate > MAX_DRUMURI) break;
|
||||
const r = judeca(path);
|
||||
if (r.ok) return r;
|
||||
ultimul = r;
|
||||
}
|
||||
if (ultimul) return ultimul;
|
||||
return fail('UNTRUSTED', 'no trusted issuer for "' + cnOf(blocat) + '"');
|
||||
|
||||
function judeca(path) {
|
||||
const top = path[path.length - 1];
|
||||
if (!top.subjectRaw.equals(top.issuerRaw) || !verifySig(top.tbs, top.signature, top.spki, cnOf(top))) return fail('ROOT', 'the trusted root is not a valid self-signed certificate');
|
||||
// 2. fiecare legatura
|
||||
for (let k = 0; k < path.length; k++) {
|
||||
const c = path[k];
|
||||
if (c.unknownCritical.length) return fail('CRITICAL_EXT', `"${cnOf(c)}" carries an unknown critical extension ${c.unknownCritical[0]}`);
|
||||
if (at < c.notBefore) return fail('NOT_YET_VALID', `"${cnOf(c)}" is not valid before ${c.notBefore.toISOString()}`);
|
||||
if (at > c.notAfter) return fail('EXPIRED', `"${cnOf(c)}" expired at ${c.notAfter.toISOString()}`);
|
||||
if (k === path.length - 1) break;
|
||||
const p = path[k + 1];
|
||||
if (!p.isCA) return fail('NOT_CA', `"${cnOf(p)}" is not a CA (basicConstraints) and cannot issue "${cnOf(c)}"`);
|
||||
if (!p.keyUsage.has('keyCertSign')) return fail('KEY_USAGE', `"${cnOf(p)}" has no keyCertSign`);
|
||||
const casBelow = path.slice(1, k + 1).filter((x) => x.isCA).length; // CA-urile intermediare de sub p, fara frunza
|
||||
if (p.pathLen !== undefined && casBelow > p.pathLen) return fail('PATH_LEN', `"${cnOf(p)}" allows ${p.pathLen} CA(s) below it, the chain has ${casBelow}`);
|
||||
// [A6] EKU pe o autoritate restrange tot ce e sub ea (OpenSSL, Chrome, Mozilla fac la fel); anyExtendedKeyUsage NU scuteste, ca la OpenSSL.
|
||||
if (purpose && p.eku && !p.eku.has(OID[purpose])) return fail('EKU', `the issuer "${cnOf(p)}" is not valid for ${purpose} (its extended key usage does not allow it)`);
|
||||
if (!verifySig(c.tbs, c.signature, p.spki, cnOf(c))) return fail('SIGNATURE', `the signature on "${cnOf(c)}" does not verify under "${cnOf(p)}"`);
|
||||
const rv = revocare(c, p);
|
||||
if (rv) return rv;
|
||||
}
|
||||
// 4. frunza
|
||||
const L = certs.leaf;
|
||||
if (purpose) {
|
||||
if (L.isCA) return fail('LEAF_IS_CA', 'a CA certificate is not accepted as an end-entity certificate');
|
||||
if (!L.keyUsage.has('digitalSignature')) return fail('KEY_USAGE', 'the end-entity certificate has no digitalSignature');
|
||||
if (L.eku && !L.eku.has(OID[purpose])) return fail('EKU', `the end-entity certificate is not valid for ${purpose}`);
|
||||
}
|
||||
if (host) {
|
||||
const h = host.toLowerCase();
|
||||
const ok = /^\d+\.\d+\.\d+\.\d+$/.test(h) ? L.ips.includes(h) : L.dns.some((d) => hostMatches(d, h));
|
||||
if (!ok) return fail('HOSTNAME', `"${cnOf(L)}" is not valid for ${host}`);
|
||||
}
|
||||
return { ok: true, code: 'OK', reason: 'valid', chain: path.map(cnOf) };
|
||||
}
|
||||
|
||||
// 3. revocarea lui c de catre emitatorul p. [A2] Dintre listele emitentului care VERIFICA sub cheia lui si nu sunt datate in viitor se
|
||||
// ia cea cu thisUpdate cel mai nou (la egalitate, crlNumber cel mai mare), cum face si OpenSSL; "prima din intrare" lasa o revocare
|
||||
// proaspata sa fie ascunsa de o lista veche pusa inaintea ei. [A3] O lista cu o extensie refuzata e verdict CRL_EXT, nu e sarita.
|
||||
function revocare(c, p) {
|
||||
const ale = lists.filter((l) => l.issuerRaw.equals(p.subjectRaw));
|
||||
if (!ale.length) return requireCrl ? fail('CRL_MISSING', `no revocation list from "${cnOf(p)}"`) : null;
|
||||
const verificate = ale.filter((l) => verifySig(l.tbs, l.signature, p.spki, cnOf(p)));
|
||||
if (!verificate.length) return fail('CRL_SIGNATURE', `the revocation list of "${cnOf(p)}" does not verify`);
|
||||
const rea = verificate.find((l) => l.refusedExt);
|
||||
if (rea) return fail('CRL_EXT', `the revocation list of "${cnOf(p)}" carries ${rea.refusedExt}`);
|
||||
const curente = verificate.filter((l) => at >= l.thisUpdate);
|
||||
if (!curente.length) return fail('CRL_STALE', `the revocation list of "${cnOf(p)}" is dated in the future`);
|
||||
curente.sort((a, b) => (b.thisUpdate - a.thisUpdate) || cmpBig(b.crlNumber, a.crlNumber));
|
||||
const list = curente[0];
|
||||
if (list.nextUpdate && at > list.nextUpdate) return fail('CRL_STALE', `the revocation list of "${cnOf(p)}" is not current (nextUpdate ${list.nextUpdate.toISOString()})`);
|
||||
if (list.revoked.has(c.serial.toString('hex'))) return fail('REVOKED', `"${cnOf(c)}" is revoked by "${cnOf(p)}"`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Semnatura unui certificat auto-semnat, verificata cu propria cheie (interoperabilitatea inversa: certificate facute de altii). */
|
||||
export function selfSignatureValid(der) {
|
||||
const c = parseCert(der);
|
||||
return c.subjectRaw.equals(c.issuerRaw) && verifySig(c.tbs, c.signature, c.spki, cnOf(c));
|
||||
}
|
||||
|
||||
export function cnOf(c) {
|
||||
try {
|
||||
for (const rdn of D.children(D.parse(c.subjectRaw))) for (const atv of D.children(rdn)) {
|
||||
const [o, v] = D.children(atv);
|
||||
if (D.oidToString(o) === OID.CN) return v.content.toString('utf8');
|
||||
}
|
||||
} catch { /* nume necitibil */ }
|
||||
return '?';
|
||||
}
|
||||
|
||||
export const pem = (label, der) => `-----BEGIN ${label}-----\n${der.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END ${label}-----\n`;
|
||||
export function unpem(text, label = 'CERTIFICATE') {
|
||||
const re = new RegExp(`-----BEGIN ${label}-----([\\s\\S]*?)-----END ${label}-----`, 'g');
|
||||
const out = []; let m;
|
||||
while ((m = re.exec(text))) out.push(Buffer.from(m[1].replace(/\s+/g, ''), 'base64'));
|
||||
return out;
|
||||
}
|
||||
99
pq-pki/test/control-negativ.mjs
Normal file
99
pq-pki/test/control-negativ.mjs
Normal file
@ -0,0 +1,99 @@
|
||||
// Controlul negativ al probei PKI: fiecare paznic din pki.mjs / der.mjs se strica pe rand, la RULARE (conditie falsa, ca sa compileze),
|
||||
// intr-o COPIE a modulului (pki.mjs, der.mjs, cli.mjs), iar proba, chemata cu PKI_MODULE pe copie, trebuie sa iasa ROSIE pe EXACT proba
|
||||
// numita. O ancora care nu apare o singura data e un ESEC al controlului, nu o trecere (ancorele stau pe partea STABILA a randului).
|
||||
// Martorul (proba pe original) ruleaza INTAI: din el se deriva numarul de probe pe care fiecare plantare trebuie sa il ruleze intreg
|
||||
// (nu e scris fix: o constanta de ieri minte in ziua in care suita creste). La sfarsit originalul trebuie sa fie identic (sha256).
|
||||
// Plantarile ruleaza cate PARALEL deodata (fiecare proba costa ~10 deschideri scrypt de ~0,7 s).
|
||||
// node test/control-negativ.mjs
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = path.join(AICI, '..');
|
||||
const PROBA = path.join(AICI, 'proba.mjs');
|
||||
const FISIERE = ['pki.mjs', 'der.mjs', 'cli.mjs'];
|
||||
const PARALEL = Math.max(1, Math.min(4, Number(process.env.PKI_PARALEL) || 4));
|
||||
const sha = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
|
||||
const inainte = Object.fromEntries(FISIERE.map((n) => [n, sha(path.join(SRC, n))]));
|
||||
|
||||
// [nume, fisier, ancora (exact o data), inlocuitor, prefixul probei care trebuie sa cada]
|
||||
const PLANTARI = [
|
||||
['semnatura oricum valida', 'pki.mjs', ' return crypto.verify(null, tbs, key, signature);', " return crypto.verify(null, tbs, key, signature) || Boolean(Number('1'));", 'un octet schimbat'],
|
||||
['pathLen ignorat', 'pki.mjs', 'if (p.pathLen !== undefined && casBelow > p.pathLen)', "if (p.pathLen !== undefined && casBelow > p.pathLen && Boolean(Number('0')))", 'pathLen'],
|
||||
['revocarea ignorata', 'pki.mjs', "if (list.revoked.has(c.serial.toString('hex')))", "if (list.revoked.has(c.serial.toString('hex')) && Boolean(Number('0')))", 'revocarea'],
|
||||
['emitentul fara basicConstraints CA', 'pki.mjs', " if (!p.isCA) return fail('NOT_CA'", " if (!p.isCA && Boolean(Number('0'))) return fail('NOT_CA'", 'un certificat de entitate folosit ca emitent'],
|
||||
['numele gazdei ignorat', 'pki.mjs', " if (!ok) return fail('HOSTNAME'", " if (!ok && Boolean(Number('0'))) return fail('HOSTNAME'", 'frunza pentru alt nume'],
|
||||
['expirarea ignorata', 'pki.mjs', " if (at > c.notAfter) return fail('EXPIRED'", " if (at > c.notAfter && Boolean(Number('0'))) return fail('EXPIRED'", 'timpul'],
|
||||
['parametri acceptati pe ML-DSA', 'pki.mjs', " if (f.length !== 1) throw new PkiError('ALG_PARAMS'", " if (f.length !== 1 && Boolean(Number('0'))) throw new PkiError('ALG_PARAMS'", 'forma'],
|
||||
['extensia critica necunoscuta ignorata', 'pki.mjs', ' for (const [id, x] of exts) if (x.critical && !EXT_STIUTE.has(id))', " for (const [id, x] of exts) if (x.critical && !EXT_STIUTE.has(id) && Boolean(Number('0')))", 'extensie critica necunoscuta'],
|
||||
['scopul (EKU) pe frunza ignorat', 'pki.mjs', ' if (L.eku && !L.eku.has(OID[purpose]))', " if (L.eku && !L.eku.has(OID[purpose]) && Boolean(Number('0')))", 'scopul'],
|
||||
['cheia privata scrisa in clar (PKCS#8)', 'pki.mjs', ' return pem(KEY_LABEL, D.seq(header, D.octets(tag), D.octets(ct)));', " return key.export({ type: 'pkcs8', format: 'pem' });", 'linia de comanda'],
|
||||
['DER necanonic acceptat', 'der.mjs', " if (l < 0x80) throw new Error('DER: lungime scurta scrisa in forma lunga (necanonica)');", " if (l < 0x80 && Boolean(Number('0'))) throw new Error('DER: lungime scurta scrisa in forma lunga (necanonica)');", 'forma'],
|
||||
// revizuirea din 2026-09-25
|
||||
['[A1] scrypt coborat la N=2^10', 'pki.mjs', 'N: 2 ** 17,', 'N: 2 ** 10,', 'sigilarea cheii'],
|
||||
['[A1] politica de parola redusa la 12 caractere (accepta aaaaaaaaaaaa)', 'pki.mjs', 'export function checkPassphrase(p) {', "export function checkPassphrase(p) { if (typeof p === 'string' && p.length >= 12 && Boolean(Number('1'))) return p;", 'politica de parola'],
|
||||
['[A1] parolele evidente acceptate', 'pki.mjs', " if (PAROLE_EVIDENTE.has(nucleu) || PAROLE_EVIDENTE.has(nucleu.replace(/[0-9]+$/, '')))", " if ((PAROLE_EVIDENTE.has(nucleu) || PAROLE_EVIDENTE.has(nucleu.replace(/[0-9]+$/, ''))) && Boolean(Number('0')))", 'politica de parola'],
|
||||
['[A2] prima lista din intrare in loc de cea mai noua', 'pki.mjs', ' curente.sort((a, b) => (b.thisUpdate - a.thisUpdate) || cmpBig(b.crlNumber, a.crlNumber));', ' curente.sort(() => 0);', 'cea mai noua lista'],
|
||||
['[A3] extensiile listei ignorate', 'pki.mjs', ' const rea = verificate.find((l) => l.refusedExt);', " const rea = verificate.find((l) => l.refusedExt && Boolean(Number('0')));", 'extensiile listei'],
|
||||
['[A5] lungimea cheii publice neverificata', 'pki.mjs', ' if (pkBits.unused !== 0 || pkBits.bytes.length !== PK_LEN[pkAlg])', " if ((pkBits.unused !== 0 || pkBits.bytes.length !== PK_LEN[pkAlg]) && Boolean(Number('0')))", 'cheie publica malformata'],
|
||||
['[A6] EKU pe autoritati ignorat', 'pki.mjs', ' if (purpose && p.eku && !p.eku.has(OID[purpose]))', " if (purpose && p.eku && !p.eku.has(OID[purpose]) && Boolean(Number('0')))", 'EKU pe autoritati'],
|
||||
['[A7] numai prima ancora candidata', 'pki.mjs', ' const ancore = certs.roots.filter((r) => emite(r, cur));', ' const ancore = certs.roots.filter((r) => emite(r, cur)).slice(0, 1);', 'reinnoirea radacinii'],
|
||||
['[A7] numai primul intermediar candidat', 'pki.mjs', 'certs.inter.filter((c) => c !== cur && !stiva.includes(c) && emite(c, cur)) : [];', 'certs.inter.filter((c) => c !== cur && !stiva.includes(c) && emite(c, cur)).slice(0, 1) : [];', 'reinnoirea radacinii'],
|
||||
['[A10] primul subidentificator OID scris pe un singur octet', 'der.mjs', ' const out = base128(p[0] * 40n + p[1]);', ' const out = [Number((p[0] * 40n + p[1]) & 0x7fn)];', 'OID'],
|
||||
['[A10] arcul OID dedus fara regula de 80', 'der.mjs', ' const arc = first < 80n ? first / 40n : 2n;', ' const arc = first / 40n;', 'OID'],
|
||||
];
|
||||
|
||||
// 1. martorul: originalul trebuie sa fie verde, si din el se ia numarul de probe
|
||||
const martorJson = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pki-martor-')), 'rez.json');
|
||||
const martor = spawnSync(process.execPath, [PROBA], { encoding: 'utf8', env: { ...process.env, PKI_JSON: martorJson }, timeout: 600000 });
|
||||
if (!fs.existsSync(martorJson)) { console.log('STRICAT: martorul nu a scris rezultatul (cod ' + martor.status + ')\n' + (martor.stdout || '').slice(-600)); process.exit(2); }
|
||||
const rezMartor = JSON.parse(fs.readFileSync(martorJson, 'utf8'));
|
||||
const N = rezMartor.length, verde = martor.status === 0 && rezMartor.every((x) => x.ok);
|
||||
console.log(`martorul: ${rezMartor.filter((x) => x.ok).length}/${N} probe trecute pe original${verde ? '' : ' (NU e verde: ' + rezMartor.filter((x) => !x.ok).map((x) => x.nume.slice(0, 40)).join(' | ') + ')'}`);
|
||||
for (const [, , , , tinta] of PLANTARI) if (!rezMartor.some((x) => x.nume.startsWith(tinta))) console.log(` ESEC tinta "${tinta}..." nu exista printre probele martorului`);
|
||||
fs.rmSync(path.dirname(martorJson), { recursive: true, force: true });
|
||||
|
||||
// 2. plantarile, cate PARALEL deodata
|
||||
function planteaza([nume, fis, vechi, nou, tinta]) {
|
||||
return new Promise((gata) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pki-plantat-'));
|
||||
const sfarsit = (linie, ok) => { fs.rmSync(dir, { recursive: true, force: true }); gata({ nume, linie, ok }); };
|
||||
for (const n of FISIERE) fs.copyFileSync(path.join(SRC, n), path.join(dir, n));
|
||||
const t = fs.readFileSync(path.join(dir, fis), 'utf8');
|
||||
const ap = t.split(vechi).length - 1;
|
||||
if (ap !== 1) return sfarsit(` ESEC ${nume}: ancora apare de ${ap} ori (cerut 1), plantarea nu s-a pus`, false);
|
||||
fs.writeFileSync(path.join(dir, fis), t.replace(vechi, nou));
|
||||
const out = path.join(dir, 'rez.json');
|
||||
const p = spawn(process.execPath, [PROBA], { env: { ...process.env, PKI_MODULE: path.join(dir, 'pki.mjs'), PKI_JSON: out }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let log = ''; p.stdout.on('data', (d) => { log += d; }); p.stderr.on('data', (d) => { log += d; });
|
||||
const timer = setTimeout(() => p.kill(), 600000);
|
||||
p.on('close', (cod) => {
|
||||
clearTimeout(timer);
|
||||
if (!fs.existsSync(out)) return sfarsit(` STRICAT ${nume}: proba nu a scris rezultatul (cod ${cod}) ${log.slice(-200).replace(/\s+/g, ' ')}`, false);
|
||||
const rez = JSON.parse(fs.readFileSync(out, 'utf8'));
|
||||
const rosii = rez.filter((x) => !x.ok).map((x) => x.nume);
|
||||
const prins = rosii.some((n) => n.startsWith(tinta));
|
||||
if (rez.length !== N) return sfarsit(` STRICAT ${nume}: au rulat ${rez.length} probe din ${N}`, false);
|
||||
if (prins) return sfarsit(` PRINS ${nume}: ${rosii.length} rosii, intre ele "${tinta}..."`, true);
|
||||
return sfarsit(` NEPRINS ${nume}: proba "${tinta}..." a ramas verde (rosii: ${rosii.map((r) => r.slice(0, 40)).join(' | ').slice(0, 200)})`, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
const rezultate = new Array(PLANTARI.length);
|
||||
let urm = 0;
|
||||
async function lucrator() { while (urm < PLANTARI.length) { const k = urm++; rezultate[k] = await planteaza(PLANTARI[k]); process.stdout.write(`\r ${rezultate.filter(Boolean).length}/${PLANTARI.length} plantari rulate`); } }
|
||||
await Promise.all(Array.from({ length: PARALEL }, lucrator));
|
||||
process.stdout.write('\r');
|
||||
for (const r of rezultate) console.log(r.linie);
|
||||
const bune = rezultate.filter((r) => r.ok).length, rele = rezultate.length - bune;
|
||||
|
||||
// 3. originalul e neatins
|
||||
const dupa = Object.fromEntries(FISIERE.map((n) => [n, sha(path.join(SRC, n))]));
|
||||
const neatins = JSON.stringify(inainte) === JSON.stringify(dupa);
|
||||
const tinteOk = PLANTARI.every(([, , , , tinta]) => rezMartor.some((x) => x.nume.startsWith(tinta)));
|
||||
console.log(`\ncontrol negativ: ${bune}/${PLANTARI.length} plantari prinse; originalul ${neatins ? 'identic' : 'SCHIMBAT'}; martorul ${verde ? `verde ${N}/${N}` : 'NU e verde'}`);
|
||||
process.exitCode = bune === PLANTARI.length && rele === 0 && neatins && verde && tinteOk ? 0 : 1;
|
||||
417
pq-pki/test/proba.mjs
Normal file
417
pq-pki/test/proba.mjs
Normal file
@ -0,0 +1,417 @@
|
||||
// Proba autoritatii de certificare post-cuantice (pki.mjs, cli.mjs, der.mjs). Fiecare verdict al verificatorului nostru e comparat cu cel al
|
||||
// unei implementari STRAINE, OpenSSL 3.5 in linia de comanda (verificarea lui de lant, cu -x509_strict), iar TLS-ul e un handshake
|
||||
// real: server Node cu lantul nostru, client OpenSSL si client Node. Fiecare refuz cere MOTIVUL (codul), nu doar refuzul.
|
||||
// Cere `openssl` 3.5+ in PATH (Git for Windows il are); fara el proba iese STRICAT, nu verde.
|
||||
// node test/proba.mjs (PKI_MODULE=<cale catre pki.mjs> o ruleaza pe o copie; asa o cheama controlul negativ)
|
||||
// Iesire: 0 toate trec, 1 cel putin una cade, 2 STRICAT (unealta straina lipseste).
|
||||
// Probele marcate [A1]..[A10] sunt regresiile celor sapte defecte ale revizuirii adversariale din 2026-09-25.
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import tls from 'node:tls';
|
||||
import crypto from 'node:crypto';
|
||||
import { spawnSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MOD = process.env.PKI_MODULE ? path.resolve(process.env.PKI_MODULE) : path.join(AICI, '..', 'pki.mjs');
|
||||
const P = await import(pathToFileURL(MOD).href);
|
||||
const Dm = await import(pathToFileURL(path.join(path.dirname(MOD), 'der.mjs')).href);
|
||||
const CLI = path.join(path.dirname(MOD), 'cli.mjs');
|
||||
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pki-'));
|
||||
const f = (n) => path.join(T, n);
|
||||
const rez = [];
|
||||
async function test(nume, fn) {
|
||||
try { await fn(); rez.push({ nume, ok: true }); console.log(' OK ' + nume); }
|
||||
catch (e) { rez.push({ nume, ok: false, motiv: String(e.message || e) }); console.log(' ESEC ' + nume + ' -> ' + String(e.message || e).slice(0, 300)); }
|
||||
}
|
||||
const eq = (a, b, m) => { if (a !== b) throw new Error(`${m}: ${JSON.stringify(a)} != ${JSON.stringify(b)}`); };
|
||||
const has = (s, sub, m) => { if (!String(s).includes(sub)) throw new Error(`${m}: lipseste "${sub}" in ${String(s).slice(0, 300)}`); };
|
||||
/** fn trebuie sa arunce PkiError cu codul cerut. */
|
||||
const code = (fn, c, m) => { let e = null; try { fn(); } catch (x) { e = x; } if (!e) throw new Error(m + ': nu a aruncat, asteptat ' + c); if (e.code !== c) throw new Error(`${m}: cod ${e.code} (${e.message}), asteptat ${c}`); };
|
||||
const env = { ...process.env, MSYS_NO_PATHCONV: '1', MSYS2_ARG_CONV_EXCL: '*' };
|
||||
function ossl(args, input) {
|
||||
const r = spawnSync('openssl', args, { encoding: 'utf8', env, input: input ?? '', timeout: 30000 });
|
||||
return { code: r.status, out: (r.stdout || '') + (r.stderr || ''), err: r.error };
|
||||
}
|
||||
// AERE-SINTETIC: parola de proba, valabila numai pentru cheile generate in aceasta rulare, intr-un dosar temporar sters la sfarsit
|
||||
const PASS_PROBA = 'proba-parola-lunga-1';
|
||||
const penv = { ...env, AERE_PKI_PASSPHRASE: PASS_PROBA };
|
||||
const run = (a, e = penv) => { const r = spawnSync(process.execPath, [CLI, ...a], { encoding: 'utf8', env: e, timeout: 120000 }); return { code: r.status, out: (r.stdout || '') + (r.stderr || '') }; };
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ OpenSSL de fata
|
||||
const v = ossl(['version']);
|
||||
if (v.err || !/OpenSSL 3\.(5|6|7|8|9)/.test(v.out)) { console.log('STRICAT: nu exista OpenSSL 3.5+ in PATH (' + (v.err ? v.err.message : v.out.trim()) + ')'); process.exit(2); }
|
||||
console.log('unealta straina: ' + v.out.trim());
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ lantul de baza
|
||||
const kr = P.generateKey('ml-dsa-87'), ki = P.generateKey('ml-dsa-65'), kl = P.generateKey('ml-dsa-65');
|
||||
const root = P.issue({ issuer: null, signingKey: kr.privateKey, subject: { cn: 'Proba Root', o: 'Aere Proba' }, publicKey: kr.publicKey, ca: true, pathLen: 1, days: 3650 });
|
||||
const inter = P.issue({ issuer: root, signingKey: kr.privateKey, subject: { cn: 'Proba Issuing', o: 'Aere Proba' }, publicKey: ki.publicKey, ca: true, pathLen: 0, days: 1825 });
|
||||
const leaf = P.issue({ issuer: inter, signingKey: ki.privateKey, subject: { cn: 'localhost' }, publicKey: kl.publicKey, days: 30, dns: ['localhost'], ips: ['127.0.0.1'], eku: ['serverAuth'] });
|
||||
const w = (n, der, lbl = 'CERTIFICATE') => { fs.writeFileSync(f(n), P.pem(lbl, der)); return f(n); };
|
||||
w('root.pem', root); w('inter.pem', inter); w('leaf.pem', leaf);
|
||||
fs.writeFileSync(f('leaf.key'), kl.privateKey.export({ type: 'pkcs8', format: 'pem' }));
|
||||
fs.writeFileSync(f('inter.key'), ki.privateKey.export({ type: 'pkcs8', format: 'pem' })); // pentru `openssl ca -gencrl` (liste facute de unealta straina)
|
||||
const V = (o) => P.verifyChain({ leaf, intermediates: [inter], roots: [root], host: 'localhost', ...o });
|
||||
const osslVerify = (extra, leafFile = f('leaf.pem'), chain = [f('inter.pem')]) =>
|
||||
ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', f('root.pem'), ...chain.flatMap((c) => ['-untrusted', c]), ...extra, leafFile]);
|
||||
/** O lista de revocare a intermediarului facuta de OpenSSL (`openssl ca -gencrl`), cu extensiile din crlExt si intrarile din index. */
|
||||
function gencrl(crlExt, outName, index = '') {
|
||||
fs.writeFileSync(f('ca.index'), index); fs.writeFileSync(f('ca.crlnumber'), '05\n');
|
||||
fs.writeFileSync(f('ca.cnf'), `[ ca ]\ndefault_ca = CA_default\n[ CA_default ]\ndir = ${T.replace(/\\/g, '/')}\ndatabase = $dir/ca.index\ncrlnumber = $dir/ca.crlnumber\n` +
|
||||
`default_crl_days = 7\ndefault_md = default\ncrl_extensions = crl_ext\n[ crl_ext ]\n${crlExt}\n[ idp ]\nfullname = URI:http://crl.example/issuing.crl\n`);
|
||||
const r = ossl(['ca', '-config', f('ca.cnf'), '-gencrl', '-keyfile', f('inter.key'), '-cert', f('inter.pem'), '-out', f(outName)]);
|
||||
if (r.code !== 0) throw new Error('openssl ca -gencrl: ' + r.out.slice(0, 300));
|
||||
return P.unpem(fs.readFileSync(f(outName), 'utf8'), 'X509 CRL')[0];
|
||||
}
|
||||
const utc = (d) => d.toISOString().replace(/[-:T]/g, '').slice(2, 14) + 'Z'; // YYMMDDHHMMSSZ pentru index.txt
|
||||
|
||||
await test('lantul ML-DSA-87 / ML-DSA-65 / ML-DSA-65: valid la noi SI la OpenSSL (-x509_strict, sslserver)', () => {
|
||||
const r = V({}); eq(r.ok, true, 'noi ' + r.reason); eq(r.chain.join('<'), 'localhost<Proba Issuing<Proba Root', 'lantul');
|
||||
const o = osslVerify([]); eq(o.code, 0, 'openssl ' + o.out); has(o.out, ': OK', 'openssl');
|
||||
});
|
||||
await test('OpenSSL citeste certificatele: algoritmii ML-DSA, cheile, SAN, KeyUsage critic, CA:FALSE pe frunza', () => {
|
||||
const t = ossl(['x509', '-in', f('leaf.pem'), '-noout', '-text']).out;
|
||||
has(t, 'Signature Algorithm: ML-DSA-65', 'semnatura'); has(t, 'Public Key Algorithm: ML-DSA-65', 'cheia');
|
||||
has(t, 'DNS:localhost, IP Address:127.0.0.1', 'SAN'); has(t, 'X509v3 Key Usage: critical', 'KU'); has(t, 'CA:FALSE', 'bc');
|
||||
has(ossl(['x509', '-in', f('root.pem'), '-noout', '-text']).out, 'Signature Algorithm: ML-DSA-87', 'radacina');
|
||||
});
|
||||
await test('interoperabilitatea inversa: un certificat ML-DSA facut de OpenSSL e citit si verificat de noi; unul stricat nu', () => {
|
||||
const o = ossl(['req', '-x509', '-newkey', 'mldsa44', '-keyout', f('o.key'), '-out', f('o.pem'), '-days', '2', '-nodes', '-subj', '/CN=openssl-made']);
|
||||
eq(o.code, 0, 'openssl req ' + o.out);
|
||||
const der = P.unpem(fs.readFileSync(f('o.pem'), 'utf8'))[0];
|
||||
eq(P.selfSignatureValid(der), true, 'semnatura OpenSSL la noi'); eq(P.parseCert(der).alg, 'ml-dsa-44', 'algoritmul');
|
||||
const bad = Buffer.from(der); bad[bad.length - 5] ^= 1; eq(P.selfSignatureValid(bad), false, 'semnatura stricata');
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ revocarea
|
||||
const crlRev = P.crl({ issuer: inter, signingKey: ki.privateKey, revoked: [{ serial: P.parseCert(leaf).serial, date: new Date() }], days: 7, number: 2 });
|
||||
const crlOk = P.crl({ issuer: inter, signingKey: ki.privateKey, revoked: [], days: 7, number: 1 });
|
||||
const crlRoot = P.crl({ issuer: root, signingKey: kr.privateKey, revoked: [], days: 7, number: 1 });
|
||||
w('crl-rev.pem', crlRev, 'X509 CRL'); w('crl-ok.pem', crlOk, 'X509 CRL'); w('crl-root.pem', crlRoot, 'X509 CRL');
|
||||
await test('revocarea: frunza revocata e REFUZATA (REVOKED) la noi si "certificate revoked" la OpenSSL; nerevocata trece la amandoi', () => {
|
||||
const r = V({ crls: [crlRev] }); eq(r.code, 'REVOKED', 'noi');
|
||||
const o = osslVerify(['-crl_check_all', '-CRLfile', f('crl-rev.pem'), '-CRLfile', f('crl-root.pem')]); has(o.out, 'certificate revoked', 'openssl'); eq(o.code === 0, false, 'openssl cod');
|
||||
eq(V({ crls: [crlOk, crlRoot], requireCrl: true }).ok, true, 'noi, nerevocata');
|
||||
eq(osslVerify(['-crl_check_all', '-CRLfile', f('crl-ok.pem'), '-CRLfile', f('crl-root.pem')]).code, 0, 'openssl, nerevocata');
|
||||
});
|
||||
await test('lista ceruta si lipsa: CRL_MISSING; lista semnata de alta cheie: CRL_SIGNATURE; lista expirata: CRL_STALE', () => {
|
||||
eq(V({ crls: [crlOk], requireCrl: true }).code, 'CRL_MISSING', 'radacina fara lista');
|
||||
const altCa = P.generateKey('ml-dsa-65');
|
||||
const fals = P.issue({ issuer: null, signingKey: altCa.privateKey, subject: { cn: 'Proba Issuing', o: 'Aere Proba' }, publicKey: altCa.publicKey, ca: true, days: 10 });
|
||||
const crlFals = P.crl({ issuer: fals, signingKey: altCa.privateKey, revoked: [], days: 7, number: 9 });
|
||||
eq(V({ crls: [crlFals] }).code, 'CRL_SIGNATURE', 'lista unei chei straine cu acelasi nume');
|
||||
eq(V({ crls: [crlOk], at: new Date(Date.now() + 9 * 86400000) }).code, 'CRL_STALE', 'lista dupa nextUpdate');
|
||||
});
|
||||
// [A2] prima lista din intrare nu e lista; e cea mai noua dintre cele care verifica
|
||||
await test('cea mai noua lista castiga [A2]: [veche goala, noua cu seria] in ambele ordini: REVOKED la noi si "certificate revoked" la OpenSSL; [veche cu seria, noua goala]: OK la amandoi; thisUpdate egal: crlNumber mai mare; o lista datata in viitor nu conteaza', () => {
|
||||
const serial = P.parseCert(leaf).serial;
|
||||
const L = (revoked, number, thisUpdate) => P.crl({ issuer: inter, signingKey: ki.privateKey, revoked, days: 7, number, thisUpdate });
|
||||
const veche = L([], 1, new Date(Date.now() - 3600000)), noua = L([{ serial, date: new Date() }], 2, new Date(Date.now() - 60000));
|
||||
eq(V({ crls: [veche, noua] }).code, 'REVOKED', 'veche intai'); eq(V({ crls: [noua, veche] }).code, 'REVOKED', 'noua intai');
|
||||
w('c-veche.pem', veche, 'X509 CRL'); w('c-noua.pem', noua, 'X509 CRL');
|
||||
has(osslVerify(['-crl_check_all', '-CRLfile', f('c-veche.pem'), '-CRLfile', f('c-noua.pem'), '-CRLfile', f('crl-root.pem')]).out, 'certificate revoked', 'openssl veche intai');
|
||||
has(osslVerify(['-crl_check_all', '-CRLfile', f('c-noua.pem'), '-CRLfile', f('c-veche.pem'), '-CRLfile', f('crl-root.pem')]).out, 'certificate revoked', 'openssl noua intai');
|
||||
const vecheCuSeria = L([{ serial, date: new Date() }], 2, new Date(Date.now() - 7200000)), nouaGoala = L([], 3, new Date(Date.now() - 30000));
|
||||
const r = V({ crls: [vecheCuSeria, nouaGoala] }); eq(r.ok, true, 'noua goala castiga ' + r.reason);
|
||||
w('c-vs.pem', vecheCuSeria, 'X509 CRL'); w('c-ng.pem', nouaGoala, 'X509 CRL');
|
||||
eq(osslVerify(['-crl_check_all', '-CRLfile', f('c-vs.pem'), '-CRLfile', f('c-ng.pem'), '-CRLfile', f('crl-root.pem')]).code, 0, 'openssl noua goala castiga');
|
||||
const t = new Date(Date.now() - 120000);
|
||||
const n1 = L([], 1, t), n2 = L([{ serial, date: new Date() }], 2, t);
|
||||
eq(V({ crls: [n1, n2] }).code, 'REVOKED', 'egalitate: #2 castiga'); eq(V({ crls: [n2, n1] }).code, 'REVOKED', 'egalitate: #2 castiga, invers');
|
||||
const viitor = L([{ serial, date: new Date() }], 9, new Date(Date.now() + 3600000));
|
||||
eq(V({ crls: [viitor, veche] }).ok, true, 'lista din viitor nu conteaza langa una curenta'); eq(V({ crls: [viitor] }).code, 'CRL_STALE', 'singura lista e din viitor');
|
||||
w('c-viitor.pem', viitor, 'X509 CRL');
|
||||
eq(osslVerify(['-crl_check_all', '-CRLfile', f('c-viitor.pem'), '-CRLfile', f('c-veche.pem'), '-CRLfile', f('crl-root.pem')]).code, 0, 'openssl: viitor + curenta = OK');
|
||||
eq(osslVerify(['-crl_check_all', '-CRLfile', f('c-viitor.pem'), '-CRLfile', f('crl-root.pem')]).code === 0, false, 'openssl: numai viitor = refuz');
|
||||
});
|
||||
// [A3] extensiile listei
|
||||
await test('extensiile listei [A3]: delta (deltaCRLIndicator, critic sau nu), IDP critic (lista OpenSSL) si o extensie critica necunoscuta (lista OpenSSL) dau CRL_EXT cu numele ei; la OpenSSL "different CRL scope", "unhandled critical CRL extension", delta refuzat cu -extended_crl; necritica necunoscuta ignorata la amandoi; intrare cu extensie critica refuzata', () => {
|
||||
const c = P.parseCert(inter); const alg = Dm.seq(Dm.oid(P.ALG['ml-dsa-65']));
|
||||
const ex = (oid, critical, val) => Dm.seq(Dm.oid(oid), critical ? Dm.bool(true) : null, Dm.octets(val));
|
||||
const aki = ex('2.5.29.35', false, Dm.seq(Dm.tlv(0x80, c.ski))), num = ex('2.5.29.20', false, Dm.int(3));
|
||||
const lista = (exts, rev = null) => { const tbs = Dm.seq(Dm.int(1), alg, c.subjectRaw, Dm.time(new Date(Date.now() - 60000)), Dm.time(new Date(Date.now() + 7 * 86400000)), rev, Dm.ctx(0, Dm.seq(...exts))); return Dm.seq(tbs, alg, Dm.bits(crypto.sign(null, tbs, ki.privateKey))); };
|
||||
const delta = lista([aki, num, ex('2.5.29.27', true, Dm.int(1))]), deltaNecritic = lista([aki, num, ex('2.5.29.27', false, Dm.int(1))]);
|
||||
const r1 = V({ crls: [delta] }); eq(r1.code, 'CRL_EXT', 'delta critic'); has(r1.reason, 'deltaCRLIndicator', 'motivul delta');
|
||||
eq(V({ crls: [deltaNecritic] }).code, 'CRL_EXT', 'delta necritic, tot refuzat');
|
||||
has(P.parseCrl(delta).refusedExt, 'delta', 'parseCrl il vede fara sa arunce');
|
||||
w('c-delta.pem', delta, 'X509 CRL');
|
||||
has(osslVerify(['-crl_check', '-extended_crl', '-CRLfile', f('c-delta.pem')]).out, 'unable to get certificate CRL', 'openssl -extended_crl nu foloseste un delta');
|
||||
const idp = gencrl('authorityKeyIdentifier = keyid\nissuingDistributionPoint = critical, @idp', 'c-idp.crl');
|
||||
const r2 = V({ crls: [idp] }); eq(r2.code, 'CRL_EXT', 'IDP'); has(r2.reason, 'issuingDistributionPoint', 'motivul IDP');
|
||||
has(osslVerify(['-crl_check', '-CRLfile', f('c-idp.crl')]).out, 'different CRL scope', 'openssl IDP');
|
||||
const crit = gencrl('authorityKeyIdentifier = keyid\n1.2.3.4.5 = critical, ASN1:UTF8String:x', 'c-crit.crl');
|
||||
const r3 = V({ crls: [crit] }); eq(r3.code, 'CRL_EXT', 'critica necunoscuta'); has(r3.reason, '1.2.3.4.5', 'motivul critica');
|
||||
has(osslVerify(['-crl_check', '-CRLfile', f('c-crit.crl')]).out, 'unhandled critical CRL extension', 'openssl critica');
|
||||
const nec = gencrl('authorityKeyIdentifier = keyid\n1.2.3.4.5 = ASN1:UTF8String:x', 'c-nec.crl');
|
||||
const r4 = V({ crls: [nec], requireCrl: false }); eq(r4.ok, true, 'necritica necunoscuta ignorata ' + r4.reason);
|
||||
eq(osslVerify(['-crl_check', '-CRLfile', f('c-nec.crl')]).code, 0, 'openssl necritica');
|
||||
const intrare = Dm.seq(Dm.seq(Dm.int(P.parseCert(leaf).serial), Dm.time(new Date()), Dm.seq(ex('2.5.29.29', true, Dm.seq(Dm.tlv(0xa4, c.subjectRaw)))))); // certificateIssuer critic (lista indirecta)
|
||||
const r5 = V({ crls: [lista([aki, num], intrare)] }); eq(r5.code, 'CRL_EXT', 'intrare cu extensie critica'); has(r5.reason, 'entry extension', 'motivul intrarii');
|
||||
const r6 = V({ crls: [lista([aki, num], Dm.seq(Dm.seq(Dm.int(P.parseCert(leaf).serial), Dm.time(new Date()), Dm.seq(ex('2.5.29.21', false, Dm.tlv(0x0a, Buffer.from([1])))))))] });
|
||||
eq(r6.code, 'REVOKED', 'intrare cu reasonCode necritic: seria conteaza');
|
||||
});
|
||||
await test('lista facuta de OpenSSL (openssl ca -gencrl, AKI + crlNumber, reasonCode pe intrare): OK la noi cand e goala, REVOKED cand poarta seria frunzei; AKI si crlNumber citite', () => {
|
||||
const goala = gencrl('authorityKeyIdentifier = keyid', 'o-goala.crl');
|
||||
const r = V({ crls: [goala] }); eq(r.ok, true, 'goala ' + r.reason);
|
||||
const pc = P.parseCrl(goala); eq(pc.crlNumber, 5n, 'crlNumber'); eq(pc.aki && pc.aki.equals(P.parseCert(inter).ski), true, 'AKI = SKI-ul emitentului'); eq(pc.refusedExt, null, 'nimic refuzat');
|
||||
const serial = P.parseCert(leaf).serial.toString('hex').toUpperCase();
|
||||
const rev = gencrl('authorityKeyIdentifier = keyid', 'o-rev.crl', `R\t${utc(new Date(Date.now() + 30 * 86400000))}\t${utc(new Date())},keyCompromise\t${serial}\tunknown\t/CN=localhost\n`);
|
||||
eq(V({ crls: [rev] }).code, 'REVOKED', 'revocata de lista OpenSSL'); eq(P.parseCrl(rev).revoked.size, 1, 'o intrare');
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ refuzuri, cu perechea OpenSSL
|
||||
await test('un octet schimbat in frunza: SIGNATURE la noi, refuz la OpenSSL', () => {
|
||||
const p = P.parseCert(leaf); const bad = Buffer.from(leaf); const o = bad.indexOf(Buffer.from('localhost'), 0); bad[o] = 0x4c; // "Localhost"
|
||||
eq(P.verifyChain({ leaf: bad, intermediates: [inter], roots: [root] }).code, 'SIGNATURE', 'noi');
|
||||
eq(osslVerify([], w('bad.pem', bad)).code === 0, false, 'openssl'); eq(p.serial.length > 0, true, 'seria');
|
||||
});
|
||||
await test('alta radacina: UNTRUSTED la noi, "unable to get local issuer" la OpenSSL', () => {
|
||||
const k = P.generateKey('ml-dsa-65');
|
||||
const alt = P.issue({ issuer: null, signingKey: k.privateKey, subject: { cn: 'Alt Root' }, publicKey: k.publicKey, ca: true, days: 10 });
|
||||
const r = P.verifyChain({ leaf, intermediates: [inter], roots: [alt], host: 'localhost' }); eq(r.code, 'UNTRUSTED', 'noi'); has(r.reason, 'Proba Issuing', 'numeste certificatul ramas fara emitent');
|
||||
const o = ossl(['verify', '-x509_strict', '-CAfile', w('alt.pem', alt), '-untrusted', f('inter.pem'), f('leaf.pem')]); has(o.out, 'unable to get local issuer', 'openssl');
|
||||
});
|
||||
await test('timpul: EXPIRED dupa notAfter si NOT_YET_VALID inainte de notBefore, la noi si la OpenSSL (-attime)', () => {
|
||||
const tarziu = new Date(Date.now() + 40 * 86400000), devreme = new Date(Date.now() - 86400000);
|
||||
eq(V({ at: tarziu }).code, 'EXPIRED', 'noi tarziu'); eq(V({ at: devreme }).code, 'NOT_YET_VALID', 'noi devreme');
|
||||
has(osslVerify(['-attime', String(Math.floor(tarziu / 1000))]).out, 'certificate has expired', 'openssl tarziu');
|
||||
has(osslVerify(['-attime', String(Math.floor(devreme / 1000))]).out, 'not yet valid', 'openssl devreme');
|
||||
});
|
||||
await test('pathLen: o autoritate cu pathLen 0 care are sub ea inca o autoritate: PATH_LEN la noi, refuz la OpenSSL', () => {
|
||||
const k2 = P.generateKey('ml-dsa-65'), k3 = P.generateKey('ml-dsa-65');
|
||||
const sub = P.issue({ issuer: inter, signingKey: ki.privateKey, subject: { cn: 'Sub CA' }, publicKey: k2.publicKey, ca: true, days: 30 });
|
||||
const l2 = P.issue({ issuer: sub, signingKey: k2.privateKey, subject: { cn: 'localhost' }, publicKey: k3.publicKey, days: 30, dns: ['localhost'], eku: ['serverAuth'] });
|
||||
eq(P.verifyChain({ leaf: l2, intermediates: [sub, inter], roots: [root], host: 'localhost' }).code, 'PATH_LEN', 'noi');
|
||||
const o = osslVerify([], w('l2.pem', l2), [w('sub.pem', sub), f('inter.pem')]); eq(o.code === 0, false, 'openssl ' + o.out); has(o.out, 'path length', 'openssl motivul');
|
||||
});
|
||||
await test('un certificat de entitate folosit ca emitent: NOT_CA la noi, "invalid CA certificate" la OpenSSL (lant facut de OpenSSL)', () => {
|
||||
// frunza noastra nu poate semna: o face OpenSSL, care semneaza cu orice certificat
|
||||
let o = ossl(['req', '-new', '-newkey', 'mldsa65', '-keyout', f('x.key'), '-out', f('x.csr'), '-nodes', '-subj', '/CN=victim']);
|
||||
eq(o.code, 0, 'csr ' + o.out);
|
||||
o = ossl(['x509', '-req', '-in', f('x.csr'), '-CA', f('leaf.pem'), '-CAkey', f('leaf.key'), '-out', f('x.pem'), '-days', '2', '-set_serial', '7']);
|
||||
eq(o.code, 0, 'semnat de frunza ' + o.out);
|
||||
const x = P.unpem(fs.readFileSync(f('x.pem'), 'utf8'))[0];
|
||||
const r = P.verifyChain({ leaf: x, intermediates: [leaf, inter], roots: [root], purpose: null });
|
||||
eq(r.code, 'NOT_CA', 'noi ' + r.reason);
|
||||
const ov = ossl(['verify', '-CAfile', f('root.pem'), '-untrusted', f('inter.pem'), '-untrusted', f('leaf.pem'), f('x.pem')]);
|
||||
eq(ov.code === 0, false, 'openssl ' + ov.out);
|
||||
});
|
||||
await test('frunza pentru alt nume: HOSTNAME la noi, "hostname mismatch" la OpenSSL; IP-ul din SAN trece la amandoi', () => {
|
||||
eq(V({ host: 'aere.example' }).code, 'HOSTNAME', 'noi'); has(osslVerify(['-verify_hostname', 'aere.example']).out, 'hostname mismatch', 'openssl');
|
||||
eq(V({ host: '127.0.0.1' }).ok, true, 'noi IP'); eq(osslVerify(['-verify_ip', '127.0.0.1']).code, 0, 'openssl IP');
|
||||
});
|
||||
await test('scopul: o frunza numai clientAuth folosita ca server: EKU la noi, "unsuitable certificate purpose" la OpenSSL', () => {
|
||||
const k = P.generateKey('ml-dsa-65');
|
||||
const cl = P.issue({ issuer: inter, signingKey: ki.privateKey, subject: { cn: 'client' }, publicKey: k.publicKey, days: 30, eku: ['clientAuth'] });
|
||||
eq(P.verifyChain({ leaf: cl, intermediates: [inter], roots: [root], purpose: 'serverAuth' }).code, 'EKU', 'noi');
|
||||
eq(P.verifyChain({ leaf: cl, intermediates: [inter], roots: [root], purpose: 'clientAuth' }).ok, true, 'noi ca client');
|
||||
has(osslVerify([], w("cl.pem", cl)).out, "unsuitable certificate purpose", "openssl");
|
||||
});
|
||||
// [A6] EKU pe autoritati
|
||||
await test('EKU pe autoritati [A6]: o CA restransa la clientAuth cu o frunza serverAuth sub ea: EKU numind autoritatea la noi, "unsuitable certificate purpose" la adancimea 1 la OpenSSL; aceeasi CA cu o frunza clientAuth, ca client: OK la amandoi; o RADACINA restransa e aplicata la fel', () => {
|
||||
const k2 = P.generateKey('ml-dsa-65'), k3 = P.generateKey('ml-dsa-65');
|
||||
const caClient = P.issue({ issuer: root, signingKey: kr.privateKey, subject: { cn: 'Client Only CA' }, publicKey: k2.publicKey, ca: true, pathLen: 0, days: 100, eku: ['clientAuth'] });
|
||||
const srv = P.issue({ issuer: caClient, signingKey: k2.privateKey, subject: { cn: 'localhost' }, publicKey: k3.publicKey, days: 30, dns: ['localhost'], eku: ['serverAuth'] });
|
||||
const cli = P.issue({ issuer: caClient, signingKey: k2.privateKey, subject: { cn: 'client' }, publicKey: k3.publicKey, days: 30, eku: ['clientAuth'] });
|
||||
const r = P.verifyChain({ leaf: srv, intermediates: [caClient], roots: [root], host: 'localhost', purpose: 'serverAuth' });
|
||||
eq(r.code, 'EKU', 'noi'); has(r.reason, 'Client Only CA', 'numeste autoritatea');
|
||||
const o = osslVerify([], w('a6-srv.pem', srv), [w('a6-ca.pem', caClient)]); has(o.out, 'unsuitable certificate purpose', 'openssl'); has(o.out, 'at 1 depth', 'openssl: la autoritate');
|
||||
eq(P.verifyChain({ leaf: cli, intermediates: [caClient], roots: [root], purpose: 'clientAuth' }).ok, true, 'client sub CA de client');
|
||||
eq(ossl(['verify', '-x509_strict', '-purpose', 'sslclient', '-CAfile', f('root.pem'), '-untrusted', f('a6-ca.pem'), w('a6-cli.pem', cli)]).code, 0, 'openssl client');
|
||||
const k4 = P.generateKey('ml-dsa-65');
|
||||
const rootC = P.issue({ issuer: null, signingKey: k4.privateKey, subject: { cn: 'Client Only Root' }, publicKey: k4.publicKey, ca: true, pathLen: 1, days: 100, eku: ['clientAuth'] });
|
||||
const srv2 = P.issue({ issuer: rootC, signingKey: k4.privateKey, subject: { cn: 'localhost' }, publicKey: k3.publicKey, days: 30, dns: ['localhost'], eku: ['serverAuth'] });
|
||||
const r2 = P.verifyChain({ leaf: srv2, roots: [rootC], host: 'localhost' }); eq(r2.code, 'EKU', 'radacina restransa'); has(r2.reason, 'Client Only Root', 'numeste radacina');
|
||||
has(ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', w('a6-rootc.pem', rootC), w('a6-srv2.pem', srv2)]).out, 'unsuitable certificate purpose', 'openssl radacina');
|
||||
});
|
||||
await test('o autoritate folosita ca frunza de server: LEAF_IS_CA', () => {
|
||||
eq(P.verifyChain({ leaf: inter, intermediates: [], roots: [root] }).code, 'LEAF_IS_CA', 'noi');
|
||||
});
|
||||
// [A7] toate ancorele candidate
|
||||
await test('reinnoirea radacinii [A7]: roots [expirata, valida] cu aceeasi cheie si acelasi nume: OK la noi si la OpenSSL (amandoua in CAfile); numai [expirata]: EXPIRED si "certificate has expired"; intermediar semnat incrucisat de o radacina straina pus inaintea celui bun: OK la noi pe drumul bun', () => {
|
||||
const rootExp = P.issue({ issuer: null, signingKey: kr.privateKey, subject: { cn: 'Proba Root', o: 'Aere Proba' }, publicKey: kr.publicKey, ca: true, pathLen: 1, days: 1, notBefore: new Date(Date.now() - 10 * 86400000) });
|
||||
const r = P.verifyChain({ leaf, intermediates: [inter], roots: [rootExp, root], host: 'localhost' }); eq(r.ok, true, 'expirata intai ' + r.reason);
|
||||
eq(P.verifyChain({ leaf, intermediates: [inter], roots: [root, rootExp], host: 'localhost' }).ok, true, 'valida intai');
|
||||
eq(P.verifyChain({ leaf, intermediates: [inter], roots: [rootExp], host: 'localhost' }).code, 'EXPIRED', 'numai expirata');
|
||||
fs.writeFileSync(f('roots2.pem'), P.pem('CERTIFICATE', rootExp) + P.pem('CERTIFICATE', root));
|
||||
const o = ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', f('roots2.pem'), '-untrusted', f('inter.pem'), f('leaf.pem')]); eq(o.code, 0, 'openssl rollover ' + o.out);
|
||||
has(ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', w('rootexp.pem', rootExp), '-untrusted', f('inter.pem'), f('leaf.pem')]).out, 'certificate has expired', 'openssl numai expirata');
|
||||
const k2 = P.generateKey('ml-dsa-87');
|
||||
const root2 = P.issue({ issuer: null, signingKey: k2.privateKey, subject: { cn: 'Alt Root 2' }, publicKey: k2.publicKey, ca: true, pathLen: 1, days: 3650 });
|
||||
const cross = P.issue({ issuer: root2, signingKey: k2.privateKey, subject: { cn: 'Proba Issuing', o: 'Aere Proba' }, publicKey: ki.publicKey, ca: true, pathLen: 0, days: 1825 });
|
||||
const rc = P.verifyChain({ leaf, intermediates: [cross, inter], roots: [root], host: 'localhost' }); eq(rc.ok, true, 'cross intai ' + rc.reason); eq(rc.chain.join('<'), 'localhost<Proba Issuing<Proba Root', 'drumul bun');
|
||||
const rn = P.verifyChain({ leaf, intermediates: [cross], roots: [root], host: 'localhost' }); eq(rn.code, 'UNTRUSTED', 'numai cross: niciun drum');
|
||||
const oc = ossl(['verify', '-x509_strict', '-purpose', 'sslserver', '-CAfile', f('root.pem'), '-untrusted', w('cross.pem', cross), '-untrusted', f('inter.pem'), f('leaf.pem')]);
|
||||
console.log(` (OpenSSL cu [cross, inter]: cod ${oc.code}; OpenSSL nu revine pe pasi intre intermediari, noi da)`);
|
||||
});
|
||||
await test('extensie critica necunoscuta (facuta de OpenSSL): CRITICAL_EXT la noi, "unhandled critical extension" la OpenSSL', () => {
|
||||
const o = ossl(['req', '-x509', '-newkey', 'mldsa65', '-keyout', f('c.key'), '-out', f('c.pem'), '-days', '2', '-nodes', '-subj', '/CN=crit',
|
||||
'-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign,digitalSignature', '-addext', '1.2.3.4.5=critical,ASN1:UTF8String:x']);
|
||||
eq(o.code, 0, 'openssl req ' + o.out);
|
||||
const c = P.unpem(fs.readFileSync(f('c.pem'), 'utf8'))[0];
|
||||
eq(P.verifyChain({ leaf: c, roots: [c], purpose: null }).code, 'CRITICAL_EXT', 'noi');
|
||||
has(ossl(['verify', '-CAfile', f('c.pem'), f('c.pem')]).out, 'unhandled critical extension', 'openssl');
|
||||
});
|
||||
await test('forma: algoritm exterior diferit de cel interior (ALG_MISMATCH), parametri pe ML-DSA (ALG_PARAMS), DER necanonic, octeti in plus', () => {
|
||||
const oid65 = Buffer.from('0609608648016503040312', 'hex'), oid44 = Buffer.from('0609608648016503040311', 'hex');
|
||||
const last = leaf.lastIndexOf(oid65); const m = Buffer.from(leaf); oid44.copy(m, last);
|
||||
let e = null; try { P.parseCert(m); } catch (x) { e = x; } eq(e && e.code, 'ALG_MISMATCH', 'exterior 44 / interior 65');
|
||||
const withNull = Buffer.concat([Buffer.from('300d', 'hex'), oid65, Buffer.from('0500', 'hex')]);
|
||||
const src = Buffer.concat([Buffer.from('300b', 'hex'), oid65]);
|
||||
const j = leaf.lastIndexOf(src);
|
||||
const lung = leaf.readUInt16BE(2) + 2; // continutul SEQUENCE-ului de sus (forma 30 82 LL LL)
|
||||
const n = Buffer.concat([Buffer.from([0x30, 0x82]), Buffer.alloc(2), leaf.subarray(4, j), withNull, leaf.subarray(j + src.length)]);
|
||||
n.writeUInt16BE(lung, 2);
|
||||
e = null; try { P.parseCert(n); } catch (x) { e = x; } eq(e && e.code, 'ALG_PARAMS', 'NULL pe ML-DSA');
|
||||
e = null; try { P.parseCert(Buffer.concat([leaf, Buffer.from([0])])); } catch (x) { e = x; } has(e && e.message, 'octeti dupa structura', 'coada');
|
||||
const nc = Buffer.from([0x30, 0x81, 0x03, 0x02, 0x01, 0x05]); e = null; try { P.parseCert(nc); } catch (x) { e = x; } has(e && e.message, 'necanonica', 'lungime lunga pentru una scurta');
|
||||
});
|
||||
// [A5] SPKI
|
||||
const cuSpkiRau = (der) => { const c = P.parseCert(der); const [algEl] = Dm.children(Dm.parse(c.spki)); const s = Dm.seq(algEl.raw, Dm.bits(Buffer.alloc(10, 7))); return { c, tbs: Dm.seq(...Dm.children(Dm.parse(c.tbs)).map((k) => (k.raw.equals(c.spki) ? s : k.raw))) }; };
|
||||
await test('cheie publica malformata [A5]: SPKI de 10 octeti sub id-ml-dsa pe frunza, pe intermediar si pe radacina: verdict SPKI (cu lungimea) la noi, fara exceptie; "decode error" la OpenSSL; o cheie de 1312 octeti sub id-ml-dsa-65 e tot SPKI', () => {
|
||||
const resemn = (der, key, alg) => { const { tbs } = cuSpkiRau(der); return Dm.seq(tbs, Dm.seq(Dm.oid(P.ALG[alg])), Dm.bits(crypto.sign(null, tbs, key))); };
|
||||
const leafRau = resemn(leaf, ki.privateKey, 'ml-dsa-65'), interRau = resemn(inter, kr.privateKey, 'ml-dsa-87');
|
||||
const rootRau = (() => { const { c, tbs } = cuSpkiRau(root); return Dm.seq(tbs, Dm.seq(Dm.oid(P.ALG['ml-dsa-87'])), Dm.bits(c.signature)); })();
|
||||
for (const [nume, o] of [['frunza', { leaf: leafRau, intermediates: [inter], roots: [root] }], ['intermediar', { leaf, intermediates: [interRau], roots: [root] }], ['radacina', { leaf, intermediates: [inter], roots: [rootRau] }]]) {
|
||||
let r; try { r = P.verifyChain({ host: 'localhost', ...o }); } catch (e) { throw new Error(nume + ': EXCEPTIE ' + e.message); }
|
||||
eq(r.code, 'SPKI', nume); has(r.reason, 'this one has 10', nume + ': motivul');
|
||||
}
|
||||
has(osslVerify([], w('a5-leaf.pem', leafRau)).out, 'decode error', 'openssl frunza');
|
||||
has(osslVerify([], f('leaf.pem'), [w('a5-inter.pem', interRau)]).out, 'decode error', 'openssl intermediar');
|
||||
const k44 = P.generateKey('ml-dsa-44'); const pk44 = Dm.bitString(Dm.children(Dm.parse(k44.publicKey.export({ type: 'spki', format: 'der' })))[1]).bytes;
|
||||
const c = P.parseCert(leaf); const s65 = Dm.seq(Dm.seq(Dm.oid(P.ALG['ml-dsa-65'])), Dm.bits(pk44));
|
||||
const tbs = Dm.seq(...Dm.children(Dm.parse(c.tbs)).map((k) => (k.raw.equals(c.spki) ? s65 : k.raw)));
|
||||
const gresit = Dm.seq(tbs, Dm.seq(Dm.oid(P.ALG['ml-dsa-65'])), Dm.bits(crypto.sign(null, tbs, ki.privateKey)));
|
||||
const r = V({ leaf: gresit }); eq(r.code, 'SPKI', 'cheie 44 sub OID 65'); has(r.reason, '1312', 'lungimea gasita');
|
||||
});
|
||||
await test('orice octeti dau verdict, nu exceptie [A5]: mutatii aleatoare ale frunzei, ale intermediarului si ale listei, trunchieri si gunoi trec prin verifyChain fara sa arunce', () => {
|
||||
let verdicte = 0;
|
||||
const incearca = (o) => { let r; try { r = P.verifyChain(o); } catch (e) { throw new Error('EXCEPTIE: ' + e.message); } if (typeof r.ok !== 'boolean' || !r.code) throw new Error('fara verdict'); verdicte++; };
|
||||
for (let i = 0; i < 300; i++) { const b = Buffer.from(leaf); for (let j = 0; j <= i % 4; j++) b[crypto.randomInt(b.length)] ^= 1 << crypto.randomInt(8); incearca({ leaf: b, intermediates: [inter], roots: [root], host: 'localhost', crls: [crlOk] }); }
|
||||
for (const len of [0, 1, 2, 3, 4, 10, 100, 700, leaf.length - 1]) incearca({ leaf: leaf.subarray(0, len), intermediates: [inter], roots: [root] });
|
||||
for (let i = 0; i < 50; i++) incearca({ leaf: crypto.randomBytes(1 + crypto.randomInt(400)), roots: [root] });
|
||||
for (let i = 0; i < 100; i++) { const b = Buffer.from(crlOk); b[crypto.randomInt(b.length)] ^= 0xff; incearca({ leaf, intermediates: [inter], roots: [root], crls: [b], requireCrl: true }); }
|
||||
for (let i = 0; i < 100; i++) { const b = Buffer.from(inter); b[crypto.randomInt(b.length)] ^= 0xff; incearca({ leaf, intermediates: [b], roots: [root] }); }
|
||||
for (let i = 0; i < 50; i++) { const b = Buffer.from(root); b[crypto.randomInt(b.length)] ^= 0xff; incearca({ leaf, intermediates: [inter], roots: [b] }); }
|
||||
eq(verdicte >= 609, true, 'numarul de verdicte ' + verdicte);
|
||||
});
|
||||
// [A10] OID
|
||||
await test('OID [A10]: 2.999 se codifica 06 02 88 37 si se decodifica inapoi; 2.100.3 si alte sase OID-uri identice octet cu octet cu OpenSSL (asn1parse -genstr) si round-trip; 0x80 initial refuzat ca necanonic; un certificat OpenSSL cu extensia critica 2.999.1 e numit corect in CRITICAL_EXT', () => {
|
||||
eq(Dm.oid('2.999').toString('hex'), '06028837', '2.999');
|
||||
for (const o of ['2.999', '2.100.3', '2.16.840.1.101.3.4.3.18', '1.3.6.1.5.5.7.3.1', '2.5.29.19', '0.9.2342.19200300.100.1.25', '1.2.840.113549.1.1.11', '2.25.329800735698586629295641978511506172918']) {
|
||||
const noi = Dm.oid(o);
|
||||
eq(Dm.oidToString(Dm.parse(noi)), o, 'round-trip ' + o);
|
||||
const r = ossl(['asn1parse', '-genstr', 'OID:' + o, '-noout', '-out', f('oid.der')]); eq(r.code, 0, 'openssl genstr ' + o + ' ' + r.out);
|
||||
eq(fs.readFileSync(f('oid.der')).toString('hex'), noi.toString('hex'), 'octetii OpenSSL pentru ' + o);
|
||||
}
|
||||
let e = null; try { Dm.oidToString(Dm.parse(Buffer.from('06028001', 'hex'))); } catch (x) { e = x; } has(e && e.message, 'necanonic', '0x80 initial');
|
||||
e = null; try { Dm.oid('1.40'); } catch (x) { e = x; } has(e && e.message, 'arc nevalid', '1.40');
|
||||
const o = ossl(['req', '-x509', '-newkey', 'mldsa44', '-keyout', f('c9.key'), '-out', f('c9.pem'), '-days', '2', '-nodes', '-subj', '/CN=crit999',
|
||||
'-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign,digitalSignature', '-addext', '2.999.1=critical,ASN1:UTF8String:x']);
|
||||
eq(o.code, 0, 'req ' + o.out);
|
||||
const c = P.unpem(fs.readFileSync(f('c9.pem'), 'utf8'))[0]; const r = P.verifyChain({ leaf: c, roots: [c], purpose: null });
|
||||
eq(r.code, 'CRITICAL_EXT', 'verdict'); has(r.reason, 'extension 2.999.1', 'numele adevarat al extensiei');
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ TLS real
|
||||
await test('TLS 1.3 complet post-cuantic: server Node cu lantul nostru, X25519MLKEM768 + semnatura mldsa65; OpenSSL si Node il accepta', async () => {
|
||||
const srv = tls.createServer({ key: fs.readFileSync(f('leaf.key')), cert: fs.readFileSync(f('leaf.pem')) + fs.readFileSync(f('inter.pem')),
|
||||
minVersion: 'TLSv1.3', ecdhCurve: 'X25519MLKEM768' }, (c) => c.end('aere\n'));
|
||||
await new Promise((ok) => srv.listen(0, '127.0.0.1', ok));
|
||||
const port = srv.address().port;
|
||||
try {
|
||||
const out = await new Promise((ok) => {
|
||||
const c = spawn('openssl', ['s_client', '-connect', '127.0.0.1:' + port, '-servername', 'localhost', '-verify_hostname', 'localhost',
|
||||
'-CAfile', f('root.pem'), '-verify_return_error', '-groups', 'X25519MLKEM768'], { env });
|
||||
let s = ''; c.stdout.on('data', (d) => { s += d; }); c.stderr.on('data', (d) => { s += d; });
|
||||
c.stdin.end(); const t = setTimeout(() => c.kill(), 15000); c.on('close', () => { clearTimeout(t); ok(s); });
|
||||
});
|
||||
has(out, 'Verify return code: 0 (ok)', 'openssl verificarea'); has(out, 'Peer signature type: mldsa65', 'semnatura'); has(out, 'Negotiated TLS1.3 group: X25519MLKEM768', 'grupul');
|
||||
const nod = await new Promise((ok) => {
|
||||
const s = tls.connect({ host: '127.0.0.1', port, servername: 'localhost', ca: fs.readFileSync(f('root.pem')), ecdhCurve: 'X25519MLKEM768' }, () => {
|
||||
ok({ auth: s.authorized, err: s.authorizationError, proto: s.getProtocol() }); s.end();
|
||||
}); s.on('error', (e) => ok({ auth: false, err: e.message }));
|
||||
});
|
||||
eq(nod.auth, true, 'clientul Node ' + nod.err); eq(nod.proto, 'TLSv1.3', 'protocolul');
|
||||
const alt = P.generateKey('ml-dsa-65');
|
||||
const altRoot = P.issue({ issuer: null, signingKey: alt.privateKey, subject: { cn: 'Alt' }, publicKey: alt.publicKey, ca: true, days: 5 });
|
||||
const neg = await new Promise((ok) => {
|
||||
const s = tls.connect({ host: '127.0.0.1', port, servername: 'localhost', ca: P.pem('CERTIFICATE', altRoot), rejectUnauthorized: true }, () => { ok('conectat'); s.end(); });
|
||||
s.on('error', (e) => ok('refuzat: ' + e.message));
|
||||
});
|
||||
has(neg, 'refuzat', 'clientul Node cu alta radacina');
|
||||
} finally { srv.close(); }
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ cheia pe disc [A1]
|
||||
await test('sigilarea cheii [A1]: fisierul poarta scrypt N=2^17 r=8 p=1 si AES-256-GCM cu antetul ca AAD (citit si direct din DER); o incercare de parola costa peste 25 ms (masurat si tiparit); parola gresita, text cifrat sau antet atins: KEY_LOCKED; PKCS#8 cifrat sau in clar: KEY_FORMAT; OpenSSL nu il deschide', () => {
|
||||
const pass = 'Proba-sigiliu-2026'; // AERE-SINTETIC
|
||||
const sealed = P.exportPrivateKey(ki.privateKey, pass);
|
||||
has(sealed, '-----BEGIN AERE PQ PKI PRIVATE KEY-----', 'eticheta');
|
||||
eq(/-----BEGIN (ENCRYPTED )?PRIVATE KEY-----/.test(sealed), false, 'fara PKCS#8');
|
||||
const h = P.readSealedKeyHeader(sealed);
|
||||
eq(h.N, 2 ** 17, 'N'); eq(h.r, 8, 'r'); eq(h.p, 1, 'p'); eq(h.kdf, 'scrypt', 'kdf'); eq(h.cipher, 'aes-256-gcm', 'cifrul');
|
||||
eq(h.salt.length, 16, 'salt'); eq(h.iv.length, 12, 'iv'); eq(h.tag.length, 16, 'tag'); eq(h.ct.length > 4000, true, 'PKCS#8 cifrat inauntru');
|
||||
const blob = P.unpem(sealed, P.KEY_LABEL)[0]; const [hdr] = Dm.children(Dm.parse(blob)); const hk = Dm.children(hdr);
|
||||
eq(Number(Dm.intToBigInt(hk[3])), 131072, 'N citit direct din DER'); eq(Number(Dm.intToBigInt(hk[4])), 8, 'r din DER'); eq(Number(Dm.intToBigInt(hk[0])), 1, 'versiunea formatului');
|
||||
const back = P.importPrivateKey(sealed, pass);
|
||||
eq(crypto.createPublicKey(back).export({ type: 'spki', format: 'der' }).equals(ki.publicKey.export({ type: 'spki', format: 'der' })), true, 'aceeasi cheie dupa deschidere');
|
||||
const t0 = process.hrtime.bigint(); let n = 0;
|
||||
for (let i = 0; i < 3; i++) { try { P.importPrivateKey(sealed, 'Gresita-parola-' + i); } catch (e) { if (e.code === 'KEY_LOCKED') n++; } }
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 3e6; eq(n, 3, 'parola gresita = KEY_LOCKED');
|
||||
console.log(` cost masurat: ${ms.toFixed(0)} ms per incercare de parola (scrypt N=${h.N}, r=${h.r}, p=${h.p}, ${os.cpus()[0]?.model?.trim() || 'cpu'})`);
|
||||
if (ms < 25) throw new Error('o incercare de parola costa ' + ms.toFixed(1) + ' ms, sub 25 ms: KDF-ul e prea ieftin');
|
||||
const atins = (off) => { const b = Buffer.from(blob); b[off] ^= 1; return P.pem(P.KEY_LABEL, b); };
|
||||
code(() => P.importPrivateKey(atins(blob.length - 1), pass), 'KEY_LOCKED', 'text cifrat atins');
|
||||
code(() => P.importPrivateKey(atins(hk[2].content.byteOffset - blob.byteOffset), pass), 'KEY_LOCKED', 'salt atins (antetul e AAD)');
|
||||
code(() => P.importPrivateKey(atins(hk[7].content.byteOffset - blob.byteOffset), pass), 'KEY_LOCKED', 'iv atins');
|
||||
code(() => P.importPrivateKey(sealed, ''), 'KEY_LOCKED', 'fara parola');
|
||||
code(() => P.importPrivateKey(ki.privateKey.export({ type: 'pkcs8', format: 'pem', cipher: 'aes-256-cbc', passphrase: pass }), pass), 'KEY_FORMAT', 'PKCS#8 cifrat (formatul vechi)');
|
||||
code(() => P.importPrivateKey(ki.privateKey.export({ type: 'pkcs8', format: 'pem' }), pass), 'KEY_FORMAT', 'PKCS#8 in clar');
|
||||
fs.writeFileSync(f('sealed.pem'), sealed);
|
||||
eq(ossl(['pkey', '-in', f('sealed.pem'), '-noout']).code === 0, false, 'openssl nu poate deschide formatul sigilat');
|
||||
});
|
||||
await test('politica de parola [A1]: sub 12 caractere, o singura clasa sub 20, toate identice, evidente (password1234, Password123!, Qwerty123456): PASSPHRASE; 3 clase la 12 sau 20+ caractere trec; linia de comanda refuza init cu o parola slaba si nu scrie nimic', () => {
|
||||
// AERE-SINTETIC: parole de proba pentru politica, niciuna folosita pentru vreo cheie
|
||||
for (const p of ['scurta1A!', 'aaaaaaaaaaaa', 'abcdefghijkl', 'password1234', 'Password123!', 'Qwerty123456', '123456789012', 'AAAAAAAAAAAAAAAAAAAAAAAA', 'Letmein-2026', 'ABCDEFGHIJKL']) code(() => P.checkPassphrase(p), 'PASSPHRASE', p);
|
||||
for (const p of ['Abcdefghijk1', PASS_PROBA, 'abcdefghijklmnopqrstu', 'correct horse battery staple', 'Zx9!Zx9!Zx9!']) eq(P.checkPassphrase(p), p, p);
|
||||
code(() => P.exportPrivateKey(ki.privateKey, 'aaaaaaaaaaaa'), 'PASSPHRASE', 'exportul aplica politica');
|
||||
const r = run(['init', '--dir', f('ca-slaba'), '--name', 'r'], { ...env, AERE_PKI_PASSPHRASE: 'aaaaaaaaaaaa' }); eq(r.code, 2, 'cli'); has(r.out, 'PASSPHRASE', 'motivul');
|
||||
eq(fs.existsSync(path.join(f('ca-slaba'), 'r.key')), false, 'nimic scris'); eq(fs.existsSync(path.join(f('ca-slaba'), 'r.crt')), false, 'niciun certificat scris');
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ linia de comanda
|
||||
await test('linia de comanda: init, intermediate, issue, revoke, crl, verify, unseal; cheile scrise sigilate (nu PKCS#8); fara parola si cu parola gresita refuza; nimic suprascris', () => {
|
||||
const D = f('ca');
|
||||
eq(run(['init', '--dir', D, '--name', 'root', '--org', 'Aere Proba']).code, 0, 'init');
|
||||
eq(run(['intermediate', '--dir', D, '--ca', 'root', '--name', 'issuing']).code, 0, 'intermediate');
|
||||
eq(run(['issue', '--dir', D, '--ca', 'issuing', '--cn', 'svc.local', '--dns', 'svc.local', '--out', f('svc')]).code, 0, 'issue');
|
||||
const rk = fs.readFileSync(path.join(D, 'root.key'), 'utf8'), sk = fs.readFileSync(f('svc.key'), 'utf8');
|
||||
has(rk, 'AERE PQ PKI PRIVATE KEY', 'cheia radacinii sigilata'); has(sk, 'AERE PQ PKI PRIVATE KEY', 'cheia frunzei sigilata');
|
||||
eq(/-----BEGIN (ENCRYPTED )?PRIVATE KEY-----/.test(rk + sk), false, 'niciun PKCS#8 pe disc');
|
||||
eq(P.readSealedKeyHeader(rk).N, 2 ** 17, 'N al cheii radacinii');
|
||||
const ok1 = run(['verify', '--roots', path.join(D, 'root.crt'), '--chain', f('svc.chain.pem'), '--host', 'svc.local']); eq(ok1.code, 0, 'verify ' + ok1.out);
|
||||
eq(run(['revoke', '--dir', D, '--ca', 'issuing', '--cert', f('svc.crt')]).code, 0, 'revoke');
|
||||
eq(run(['crl', '--dir', D, '--ca', 'issuing', '--out', f('svc-crl.pem')]).code, 0, 'crl');
|
||||
const rv = run(['verify', '--roots', path.join(D, 'root.crt'), '--chain', f('svc.chain.pem'), '--host', 'svc.local', '--crl', f('svc-crl.pem')]);
|
||||
eq(rv.code, 1, 'verify revocat'); has(rv.out, 'REFUSED (REVOKED)', 'motivul');
|
||||
const us = run(['unseal', '--key', f('svc.key'), '--out', f('svc.p8.pem')]); eq(us.code, 0, 'unseal ' + us.out);
|
||||
has(fs.readFileSync(f('svc.p8.pem'), 'utf8'), '-----BEGIN PRIVATE KEY-----', 'PKCS#8 in clar, la cerere');
|
||||
has(ossl(['pkey', '-in', f('svc.p8.pem'), '-noout', '-text']).out, 'ML-DSA-65', 'openssl citeste cheia desigilata');
|
||||
const us2 = run(['unseal', '--key', f('svc.key'), '--out', f('svc.p8.pem')]); eq(us2.code, 2, 'unseal peste un fisier existent'); has(us2.out, 'EXISTS', 'nimic suprascris la unseal');
|
||||
// AERE-SINTETIC: parola gresita dinadins (proba refuzului)
|
||||
const us3 = run(['unseal', '--key', f('svc.key'), '--out', f('svc.p8b.pem')], { ...env, AERE_PKI_PASSPHRASE: 'parola-gresita-lunga' }); eq(us3.code, 2, 'unseal cu parola gresita'); has(us3.out, 'KEY_LOCKED', 'motivul');
|
||||
const np = run(['issue', '--dir', D, '--ca', 'issuing', '--cn', 'x', '--out', f('x2')], { ...env, AERE_PKI_PASSPHRASE: '' }); eq(np.code, 2, 'fara parola'); has(np.out, 'PASSPHRASE', 'motivul fara parola');
|
||||
// AERE-SINTETIC: parola gresita dinadins (proba refuzului la emitere)
|
||||
const wp = run(['issue', '--dir', D, '--ca', 'issuing', '--cn', 'x', '--out', f('x3')], { ...env, AERE_PKI_PASSPHRASE: 'parola-gresita-lunga' }); eq(wp.code, 2, 'parola gresita'); has(wp.out, 'KEY_LOCKED', 'motivul parolei gresite');
|
||||
const ow = run(['init', '--dir', D, '--name', 'root']); eq(ow.code, 2, 'suprascriere'); has(ow.out, 'EXISTS', 'nimic suprascris');
|
||||
eq(/-----BEGIN PRIVATE KEY-----/.test(np.out + wp.out + ow.out + us.out + us2.out + us3.out), false, 'niciun material de cheie in mesaje');
|
||||
});
|
||||
|
||||
fs.rmSync(T, { recursive: true, force: true });
|
||||
const picate = rez.filter((r) => !r.ok);
|
||||
console.log(`\n${rez.length - picate.length}/${rez.length} probe trecute`);
|
||||
if (process.env.PKI_JSON) fs.writeFileSync(process.env.PKI_JSON, JSON.stringify(rez, null, 1));
|
||||
process.exitCode = picate.length ? 1 : 0;
|
||||
Loading…
Reference in New Issue
Block a user