RavensPort

Client certificates (mTLS) new in 4.2.0

Not in the Microsoft Store build. Store certification rejected certificate generation under policies 10.2.10 and 10.2.10.1, so the Store package has no mTLS, no Client Certificate card, and no certificate-minting code. Install from a release if you need it — see docs/STORE-MSIX.md.

Optional, off by default. Turn on Require mTLS for all routes and funnels on the Settings tab and the proxy switches from http://127.0.0.1:5559 to https://127.0.0.1:5559 and demands a client certificate on every connection — routes, funnels, everything.

This is a second factor for the same door, not a replacement for the proxy key. A key sits in whatever config file the client reads it from, so any process that can read that file can spend it; a certificate has to be installed as well, and both are checked. Every request still needs the key of the endpoint it is calling.

Changing the setting requires a full restart of RavensPort. The listener's scheme and its certificate demand are fixed when Kestrel binds, so nothing about this takes effect until the app is restarted — the Settings tab says so in red until it is.

Generate the certificate first. The switch refuses to turn on until one exists. RavensPort never mints a certificate behind the switch, because generating one means choosing the PFX password and a password the app picked for you is a password every install shares. Use Generate new certificate below, choose your own, then come back and enable mTLS.

Generating and exporting#

Generate new certificate asks for a password, then mints a self-signed certificate. RavensPort keeps it in the vault with everything else and presents it at both ends: the listener serves it and demands it back, and the funnel presents it when it dials this app's own routes.

Pointing a client at it#

The certificate is self-signed, so nothing trusts it until you say so. Install it as a trust anchor on the machine that will be calling the proxy, and every client there verifies the listener the ordinary way from then on.

Two things have to be true before a call succeeds, and they are easy to conflate because one file carries both. Your client must trust the certificate RavensPort serves (steps 1–2), and it must present that certificate back (step 3). Getting only the first right produces a handshake that fails after the trust check passes, which is a confusing place to land.

Please execute the following powershell commands from the location where cert file (RavensPort_ClientCert.pfx) was downloaded.

1. Get the public half out of the export. The .pfx carries the private key; a trust store only needs the certificate, and only the certificate should travel.

$cert = Get-PfxCertificate -FilePath RavensPort_ClientCert.pfx          # prompts for the password
Export-Certificate -Cert $cert -FilePath ravensport.cer
certutil -encode ravensport.cer ravensport.pem         # PEM, for clients that want one

2. Install it into the Windows trust store.

# This user only — no administrator rights needed
Import-Certificate -FilePath ravensport.cer -CertStoreLocation Cert:\CurrentUser\Root

# Machine-wide, for services and other accounts — run as Administrator
Import-Certificate -FilePath ravensport.cer -CertStoreLocation Cert:\LocalMachine\Root

Windows asks you to confirm, and it is worth reading: a root you install is one that machine will trust wherever it is presented. Take it back out when the certificate is rotated or retired, or the machine goes on trusting a certificate you no longer control.

Get-ChildItem Cert:\CurrentUser\Root |
  Where-Object { $_.Subject -eq 'CN=RavensPort MCP Client' } |
  Remove-Item

3. Install the client certificate where the caller can find it. Step 2 taught the machine to trust RavensPort; this is the other half of the handshake — the certificate your client presents back. Import the .pfx into your personal store:

$client = Import-PfxCertificate `
  -FilePath RavensPort_ClientCert.pfx `
  -CertStoreLocation Cert:\CurrentUser\My `
  -Password (Read-Host 'PFX password' -AsSecureString)

$client.Thumbprint

Keep that thumbprint. It is how you name this certificate later, and the name matters: every certificate RavensPort mints carries the same CN=RavensPort MCP Client, and these certificates can be indistinguishable by subject alone.

4. Call the proxy. In PowerShell, curl is an alias for Invoke-WebRequest and \ is not a line continuation — write curl.exe and use a backtick, or the command silently becomes something else entirely.

curl.exe --cert "CurrentUser\MY\<thumbprint from step 3>" `
     -H "X-Proxy-Key: <this-route's-key>" `
     https://127.0.0.1:5559/app/my-service/foo

The certificate is named by store reference, not by file, and on Windows that is the only form that works. The curl.exe in System32 is built against Schannel, and Schannel cannot load a client certificate from a .pfx on disk: --cert cert.pfx:<password> --cert-type P12 fails the handshake with schannel: AcquireCredentialsHandle failed: SEC_E_UNKNOWN_CREDENTIALS, which reads like a bad password and is not one. That P12 form belongs to an OpenSSL-backed curl — WSL, most Linux distributions — where the file and password are passed directly and no import is needed:

curl --cert cert.pfx:<your-password> --cert-type P12 \
     --cacert ravensport.pem \
     -H "X-Proxy-Key: <this-route's-key>" \
     https://127.0.0.1:5559/app/my-service/foo

Run curl --version if you are unsure which you have; the backend is named on the first line.

PowerShell can also make the call natively, taking the .pfx directly and needing no import:

$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 `
  ((Resolve-Path RavensPort_ClientCert.pfx).Path, '<your-password>', 'PersistKeySet')

Invoke-WebRequest -Uri https://127.0.0.1:5559/app/my-service/foo `
  -Certificate $cert `
  -Headers @{ 'X-Proxy-Key' = '<this-route''s-key>' } `
  -UseBasicParsing

PersistKeySet is load-bearing: without it the private key can be discarded after loading, and the handshake then fails on a certificate that appeared to read back perfectly.

Neither form disables verification — no --insecure on the curl line, no -SkipCertificateCheck on the PowerShell one — and neither should. Step 2 is what makes ordinary verification succeed; reaching for those flags instead is the failure described below.

Node needs both halves handed to it explicitly, since it carries its own OpenSSL and reads nothing from the Windows stores — steps 2 and 3 do nothing for it:

const https = require('node:https');
const fs = require('node:fs');
const path = require('node:path');

// Resolved against this file, not the working directory: a bare 'cert.pfx' is looked up
// wherever node happened to be started from, which is rarely where the file is.
const here = (name) => path.join(__dirname, name);

https.request({
  host: '127.0.0.1',
  port: 5559,
  path: '/app/my-service/foo',
  headers: { 'X-Proxy-Key': '<this-route\'s-key>' },

  pfx: fs.readFileSync(here('RavensPort_ClientCert.pfx')),
  passphrase: '<your-password>',
  ca: fs.readFileSync(here('ravensport.pem')),
  rejectUnauthorized: true
}, (res) => {
  // socket.authorized is the answer to "did verification actually happen", which the status
  // code does not tell you.
  console.log(res.statusCode, res.socket.authorized);
}).end();

ca: replaces Node's trust store rather than adding to it, so this agent verifies RavensPort and nothing else. That is the right trade for a client that only ever calls the proxy; a process that also talks to ordinary HTTPS hosts wants NODE_EXTRA_CA_CERTS=ravensport.pem instead, which appends.

What each failure looks like#

Four ways to run that snippet, and what each one returns. The point of the table is the middle two: they are what proves the first row means anything.

Variant Result
As written above HTTP 200, socket.authorized === true
ca: removed DEPTH_ZERO_SELF_SIGNED_CERT — self-signed certificate
pfx:/passphrase: removed ERR_SSL_SSLV3_ALERT_CERTIFICATE_UNKNOWN, SSL alert 46
Wrong passphrase: Error: mac verify failure, thrown synchronously

Testing these variants: pass agent: false. Node's global agent reuses keep-alive sockets and its cache key does not cover passphrase, so a failing variant run after a successful one can ride the already-authenticated connection and return HTTP 200. A test that cannot fail is worse than no test.

rejectUnauthorized: true is the most important line in that snippet. What it prevents is worth being precise about.

Loopback is not a trust boundary. 127.0.0.1:5559 is first come, first served: any process running as any user on the machine can bind it whenever RavensPort is not already holding it — before the app starts at boot, after it exits, in the window a crash leaves open. Binding a loopback port needs no privilege and no consent, and nothing about the address makes whatever answers on it the proxy.

Then look at the order things happen in. The TLS handshake completes first. The request — method, path, and every header, X-Proxy-Key and anything else your client attaches included — goes out after it. Verifying the server is the only step that happens before your client has said anything. A client that skips it has already handed the endpoint's proxy key to whatever answered by the time there is any way to notice, and that key does not come back: it spends the OAuth grant behind that route, and it works against the real proxy afterwards.

The client certificate does not cover this. mTLS is mutual, and the two halves protect different parties — the certificate your client presents proves the client to RavensPort, and the certificate RavensPort presents is what proves the server to your client. An impostor on 5559 can demand a client certificate exactly as RavensPort does; asking costs it nothing, and your client will present one. The trust anchor from step 2 is the only thing that tells the two listeners apart.

RavensPort itself refuses to start when the port is already taken and says so on the setup page, so a squatter is visible from the app's side. Your client has no such view. It connects, something answers, and it either checks the certificate or it does not — and that check is the whole of what stands between a local process and your credentials.

Three things decide whether that works:

On RavensPort's side of the same handshake: it compares the thumbprint of what it was handed against its own and refuses anything else, it enforces the certificate's expiry date itself, and every request still needs the proxy key of the endpoint it is calling.

Expiry#

Certificates are minted with a 90-day life. Nothing renews them, and there is no CA behind them — no revocation list to publish, no way to recall a copy that leaked — so the expiry date is the only thing that retires one.

It is enforced, at both ends. Past the date the proxy refuses the certificate it issued, including its own funnel's hop into its own routes. An expired certificate fails during the TLS handshake, so clients see a dropped connection rather than a status code — there is no 403 to read, which is why the date is worth watching.

The Settings tab shows when the current certificate expires, and says so in red once it is within 14 days. Rotating means generating, exporting, installing on every client, and restarting, so it is not something to start on the day it stops working.


Edit this page on the wiki Request support · Report vulnerabilities privately