Initial Commit3
This commit is contained in:
Submodule PatchProbe.Server deleted from 08eab2f7a2
7
PatchProbe.Server/.gitignore
vendored
Normal file
7
PatchProbe.Server/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
patchprobe.db
|
||||
patchprobe.db-shm
|
||||
patchprobe.db-wal
|
||||
.env
|
||||
public/
|
||||
logs/
|
||||
108
PatchProbe.Server/README.md
Normal file
108
PatchProbe.Server/README.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# PatchProbe Server
|
||||
|
||||
Node.js / Express server that receives scan payloads from enrolled PatchProbe devices and exposes a management API.
|
||||
|
||||
---
|
||||
|
||||
## First-run setup
|
||||
|
||||
### 1. Configure environment
|
||||
|
||||
```bash
|
||||
cp config.example.env .env
|
||||
# Edit .env — at minimum set PATCHPROBE_ADMIN_KEY_HASH (see below)
|
||||
```
|
||||
|
||||
### 2. Generate an admin key
|
||||
|
||||
```bash
|
||||
node server.js --gen-admin-key
|
||||
# Prints a random key and its bcrypt hash.
|
||||
# Paste the hash into PATCHPROBE_ADMIN_KEY_HASH in .env
|
||||
# Store the key itself securely — it is only shown once.
|
||||
```
|
||||
|
||||
### 3. Start the server
|
||||
|
||||
```bash
|
||||
npm start # production
|
||||
npm run dev # development (auto-reload, pretty logs)
|
||||
```
|
||||
|
||||
On first start the SQLite database is created automatically at `DB_PATH` (default `./patchprobe.db`).
|
||||
|
||||
---
|
||||
|
||||
## Enrolling a device end-to-end
|
||||
|
||||
### Step 1 — Create an enrollment token (admin)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/admin/tokens \
|
||||
-H "Authorization: Bearer <admin-key>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"label": "lab-devices", "expiresInDays": 7, "maxUses": 10}'
|
||||
# Response: { "token": "<64-char hex>", ... }
|
||||
# The token value is only returned here — copy it now.
|
||||
```
|
||||
|
||||
### Step 2 — Enroll the device (on the endpoint)
|
||||
|
||||
```bash
|
||||
PatchProbe.exe enroll --server-url http://your-server:3000 --enrollment-key <token>
|
||||
```
|
||||
|
||||
The client POSTs to `POST /api/enrollments`, receives a `deviceId`, and stores credentials encrypted on disk.
|
||||
|
||||
### Step 3 — Run a scan
|
||||
|
||||
```bash
|
||||
PatchProbe.exe scan
|
||||
# Collects evidence and POSTs signed payload to POST /api/scans
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Admin API reference
|
||||
|
||||
All `/api/admin/*` routes require `Authorization: Bearer <admin-key>`.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/api/admin/tokens` | Create enrollment token (`label`, `expiresInDays?`, `maxUses?`) |
|
||||
| `GET` | `/api/admin/tokens` | List tokens (value masked as `ab12****`) |
|
||||
| `DELETE` | `/api/admin/tokens/:token` | Revoke a token |
|
||||
| `GET` | `/api/admin/devices` | List all enrolled devices |
|
||||
| `DELETE` | `/api/admin/devices/:id` | Revoke a device (blocks future scan uploads) |
|
||||
| `GET` | `/api/admin/devices/:id/scans` | Scan summaries for a device |
|
||||
|
||||
---
|
||||
|
||||
## Device API (used by PatchProbe.exe — do not change)
|
||||
|
||||
| Method | Path | Auth |
|
||||
|--------|------|------|
|
||||
| `POST` | `/api/enrollments` | Enrollment token in body |
|
||||
| `POST` | `/api/scans` | ECDSA device signature headers |
|
||||
| `GET` | `/api/scans` | None |
|
||||
| `GET` | `/api/scans/:id` | None |
|
||||
| `DELETE` | `/api/scans/:id` | None |
|
||||
|
||||
---
|
||||
|
||||
## Smoke test
|
||||
|
||||
```bash
|
||||
# Start server first (auth must be enabled, admin key configured)
|
||||
node test-auth.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture notes
|
||||
|
||||
- **Persistence**: SQLite via Node.js built-in `node:sqlite` (no native compilation). WAL mode, foreign keys enforced.
|
||||
- **Device auth**: ECDSA-P256-SHA256 over `deviceId\ntimestamp\nbase64(sha256(rawBody))`. Raw body bytes are captured before JSON parsing so re-serialization cannot alter the signed content.
|
||||
- **Admin auth**: bcrypt-hashed bearer key. bcrypt comparison is async (non-blocking).
|
||||
- **Enrollment tokens**: constant-time comparison iterates all active tokens without early exit to prevent timing oracles.
|
||||
- **Rate limiting**: 10 req/min on `/api/enrollments`, 60 req/min on `/api/scans`.
|
||||
32
PatchProbe.Server/config.example.env
Normal file
32
PatchProbe.Server/config.example.env
Normal file
@@ -0,0 +1,32 @@
|
||||
# PatchProbe Server — environment configuration
|
||||
# Copy to .env and fill in values before starting the server.
|
||||
|
||||
# ── HTTP ─────────────────────────────────────────────────────────────────────
|
||||
PORT=3000
|
||||
|
||||
# ── Runtime ──────────────────────────────────────────────────────────────────
|
||||
NODE_ENV=development
|
||||
|
||||
# ── Database ─────────────────────────────────────────────────────────────────
|
||||
DB_PATH=./patchprobe.db
|
||||
|
||||
# ── Device authentication ─────────────────────────────────────────────────────
|
||||
PATCHPROBE_AUTH_ENABLED=true
|
||||
|
||||
# ── Admin API key (bearer token for curl/API access) ─────────────────────────
|
||||
# Generate with: node server.js --gen-admin-key
|
||||
PATCHPROBE_ADMIN_KEY_HASH=
|
||||
|
||||
# ── Dashboard session ─────────────────────────────────────────────────────────
|
||||
# Random 64-char hex string. Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
# Must be set in production or sessions will not survive server restarts.
|
||||
PATCHPROBE_JWT_SECRET=
|
||||
|
||||
# ── WebAuthn passkeys ─────────────────────────────────────────────────────────
|
||||
# rpId: hostname only (no port, no protocol) — must match the domain serving the dashboard.
|
||||
# Use 'localhost' in development.
|
||||
PATCHPROBE_RP_ID=localhost
|
||||
|
||||
# origin: full origin the browser uses to reach this server (protocol + host + port).
|
||||
# Must match exactly what appears in the browser URL bar.
|
||||
PATCHPROBE_ORIGIN=http://localhost:3000
|
||||
6
PatchProbe.Server/config.json.example
Normal file
6
PatchProbe.Server/config.json.example
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"authEnabled": true,
|
||||
"enrollmentKeys": [
|
||||
"change-this-to-a-strong-random-key"
|
||||
]
|
||||
}
|
||||
1660
PatchProbe.Server/package-lock.json
generated
Normal file
1660
PatchProbe.Server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
PatchProbe.Server/package.json
Normal file
23
PatchProbe.Server/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "patchprobe-server",
|
||||
"version": "2.0.0",
|
||||
"description": "PatchProbe scan ingestion and management server",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js",
|
||||
"gen-admin-key": "node server.js --gen-admin-key"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.3.1",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pino": "^9.3.2",
|
||||
"pino-pretty": "^11.2.2"
|
||||
}
|
||||
}
|
||||
6027
PatchProbe.Server/scans/2026-05-13T00-14-14-616Z_SSIWKSBT04.json
Normal file
6027
PatchProbe.Server/scans/2026-05-13T00-14-14-616Z_SSIWKSBT04.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"collector": {
|
||||
"machineName": "TEST-HOST",
|
||||
"collectedAt": "2026-05-22T12:00:00Z",
|
||||
"ranAsAdministrator": true
|
||||
},
|
||||
"windowsUpdate": {
|
||||
"applicableUpdates": [
|
||||
{
|
||||
"title": "KB12345"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pendingReboot": {
|
||||
"anyPending": false
|
||||
}
|
||||
}
|
||||
3949
PatchProbe.Server/scans/patchprobe_20260513_010721_SSIWKSBT03.json
Normal file
3949
PatchProbe.Server/scans/patchprobe_20260513_010721_SSIWKSBT03.json
Normal file
File diff suppressed because it is too large
Load Diff
3365
PatchProbe.Server/scans/patchprobe_20260513_012830_SSIWKSZO02.json
Normal file
3365
PatchProbe.Server/scans/patchprobe_20260513_012830_SSIWKSZO02.json
Normal file
File diff suppressed because it is too large
Load Diff
5236
PatchProbe.Server/scans/patchprobe_20260513_014129_SSIWKSAP01.json
Normal file
5236
PatchProbe.Server/scans/patchprobe_20260513_014129_SSIWKSAP01.json
Normal file
File diff suppressed because it is too large
Load Diff
176
PatchProbe.Server/server.js
Normal file
176
PatchProbe.Server/server.js
Normal file
@@ -0,0 +1,176 @@
|
||||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// --gen-admin-key utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (process.argv.includes('--gen-admin-key')) {
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const key = crypto.randomBytes(32).toString('hex');
|
||||
const hash = bcrypt.hashSync(key, 12);
|
||||
console.log('\nGenerated admin key (store securely — shown only once):');
|
||||
console.log(' Key: ' + key);
|
||||
console.log(' Hash: ' + hash);
|
||||
console.log('\nAdd to your .env file:');
|
||||
console.log(' PATCHPROBE_ADMIN_KEY_HASH=' + hash + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server startup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const cookieParser = require('cookie-parser');
|
||||
|
||||
const config = require('./src/config');
|
||||
const logger = require('./src/logger');
|
||||
const { initDb, closeDb } = require('./src/db');
|
||||
const authRouter = require('./src/routes/auth');
|
||||
const enrollmentRouter = require('./src/routes/enrollments');
|
||||
const scansRouter = require('./src/routes/scans');
|
||||
const adminRouter = require('./src/routes/admin');
|
||||
|
||||
initDb();
|
||||
|
||||
const app = express();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security headers
|
||||
// Relax CSP slightly so the built React SPA can load scripts/styles from self
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"], // Vite injects a small inline style in dev
|
||||
imgSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'none'"],
|
||||
upgradeInsecureRequests: config.nodeEnv === 'production' ? [] : null,
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limiting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.use('/api/enrollments', rateLimit({
|
||||
windowMs: 60_000, max: 10,
|
||||
standardHeaders: true, legacyHeaders: false,
|
||||
message: { error: 'Too many enrollment requests — try again in a minute' },
|
||||
}));
|
||||
|
||||
app.use('/api/scans', rateLimit({
|
||||
windowMs: 60_000, max: 60,
|
||||
standardHeaders: true, legacyHeaders: false,
|
||||
message: { error: 'Too many scan requests — try again in a minute' },
|
||||
}));
|
||||
|
||||
app.use('/api/auth', rateLimit({
|
||||
windowMs: 60_000, max: 20,
|
||||
standardHeaders: true, legacyHeaders: false,
|
||||
message: { error: 'Too many auth requests — try again in a minute' },
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Body parsing + cookie parsing
|
||||
// raw body capture must come before cookie-parser to avoid interference
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.use(express.json({
|
||||
limit: '50mb',
|
||||
verify: (req, _res, buf) => { req.rawBody = buf; },
|
||||
}));
|
||||
app.use(cookieParser());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static files (built SPA)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/enrollments', enrollmentRouter);
|
||||
app.use('/api/scans', scansRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
|
||||
// API 404
|
||||
app.use('/api/', (req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SPA catch-all — serve index.html for all non-API GET requests
|
||||
// React Router handles client-side routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
const indexPath = path.join(__dirname, 'public', 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.sendFile(indexPath);
|
||||
} else {
|
||||
res.status(503).send(
|
||||
'Dashboard not built — run: cd PatchProbe.Dashboard && npm install && npm run build',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
if (err.type === 'entity.parse.failed') {
|
||||
return res.status(400).json({ error: 'Invalid JSON in request body' });
|
||||
}
|
||||
logger.error({ err, method: req.method, path: req.path }, 'Unhandled error');
|
||||
res.status(err.status ?? 500).json({ error: err.message ?? 'Internal server error' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const server = app.listen(config.port, '0.0.0.0', () => {
|
||||
logger.info({ port: config.port, auth: config.authEnabled, env: config.nodeEnv }, 'PatchProbe Server started');
|
||||
if (!config.adminKeyHash) {
|
||||
logger.warn('PATCHPROBE_ADMIN_KEY_HASH not set — run: node server.js --gen-admin-key');
|
||||
}
|
||||
if (!process.env.PATCHPROBE_JWT_SECRET) {
|
||||
logger.warn('PATCHPROBE_JWT_SECRET not set — sessions will not survive server restarts');
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graceful shutdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function shutdown(signal) {
|
||||
logger.info({ signal }, 'Shutdown signal received');
|
||||
server.close(() => {
|
||||
closeDb();
|
||||
logger.info('Server stopped cleanly');
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => { logger.error('Forced shutdown'); process.exit(1); }, 10_000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
20
PatchProbe.Server/src/config.js
Normal file
20
PatchProbe.Server/src/config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
require('dotenv').config();
|
||||
|
||||
const config = {
|
||||
port: parseInt(process.env.PORT ?? '3000', 10),
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
dbPath: process.env.DB_PATH ?? './patchprobe.db',
|
||||
authEnabled: (process.env.PATCHPROBE_AUTH_ENABLED ?? 'true') === 'true',
|
||||
adminKeyHash: process.env.PATCHPROBE_ADMIN_KEY_HASH ?? null,
|
||||
// WebAuthn — rpId must be a plain hostname (no port, no protocol)
|
||||
rpId: process.env.PATCHPROBE_RP_ID ?? 'localhost',
|
||||
// WebAuthn — full origin that the browser will use to reach this server
|
||||
origin: process.env.PATCHPROBE_ORIGIN ?? 'http://localhost:3000',
|
||||
// JWT — auto-generated if unset; set in production so sessions survive restarts
|
||||
jwtSecret: process.env.PATCHPROBE_JWT_SECRET ?? crypto.randomBytes(32).toString('hex'),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
126
PatchProbe.Server/src/db.js
Normal file
126
PatchProbe.Server/src/db.js
Normal file
@@ -0,0 +1,126 @@
|
||||
'use strict';
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const config = require('./config');
|
||||
const logger = require('./logger');
|
||||
|
||||
let db;
|
||||
|
||||
function getVersion() {
|
||||
try {
|
||||
const row = db.prepare('SELECT MAX(version) AS v FROM schema_version').get();
|
||||
return row?.v ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function migrate() {
|
||||
const current = getVersion();
|
||||
|
||||
if (current < 1) {
|
||||
logger.info('Applying DB migration v1');
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
machine_name TEXT NOT NULL,
|
||||
device_fingerprint TEXT UNIQUE,
|
||||
public_key_spki TEXT NOT NULL,
|
||||
enrolled_at TEXT NOT NULL,
|
||||
last_seen_at TEXT,
|
||||
revoked_at TEXT
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
id TEXT PRIMARY KEY,
|
||||
device_id TEXT REFERENCES devices(device_id),
|
||||
machine_name TEXT,
|
||||
collected_at TEXT,
|
||||
ran_as_administrator INTEGER,
|
||||
applicable_update_count INTEGER,
|
||||
pending_reboot INTEGER,
|
||||
raw_json TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS enrollment_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
used_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_uses INTEGER,
|
||||
revoked_at TEXT
|
||||
)
|
||||
`);
|
||||
db.prepare('DELETE FROM schema_version').run();
|
||||
db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(1);
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
try { db.exec('ROLLBACK'); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (current < 2) {
|
||||
logger.info('Applying DB migration v2');
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS admin_credentials (
|
||||
credential_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES admin_users(user_id),
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
device_type TEXT,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT
|
||||
)
|
||||
`);
|
||||
db.prepare('DELETE FROM schema_version').run();
|
||||
db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(2);
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
try { db.exec('ROLLBACK'); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initDb() {
|
||||
db = new DatabaseSync(config.dbPath);
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
migrate();
|
||||
logger.info({ dbPath: config.dbPath }, 'Database ready');
|
||||
return db;
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
if (!db) throw new Error('Database not initialized — call initDb() first');
|
||||
return db;
|
||||
}
|
||||
|
||||
function closeDb() {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { initDb, getDb, closeDb };
|
||||
18
PatchProbe.Server/src/logger.js
Normal file
18
PatchProbe.Server/src/logger.js
Normal file
@@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
|
||||
const pino = require('pino');
|
||||
const config = require('./config');
|
||||
|
||||
const logger = pino(
|
||||
config.nodeEnv === 'production'
|
||||
? { level: 'info' }
|
||||
: {
|
||||
level: 'debug',
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = logger;
|
||||
70
PatchProbe.Server/src/middleware/adminAuth.js
Normal file
70
PatchProbe.Server/src/middleware/adminAuth.js
Normal file
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../config');
|
||||
const logger = require('../logger');
|
||||
|
||||
const SESSION_COOKIE = 'pp_session';
|
||||
|
||||
// Bearer key check (bcrypt) — for curl / API access
|
||||
function requireAdmin(req, res, next) {
|
||||
const authHeader = req.headers['authorization'] ?? '';
|
||||
const key = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
|
||||
if (!key) {
|
||||
return res.status(401).json({ error: 'Admin authentication required (Authorization: Bearer <key>)' });
|
||||
}
|
||||
|
||||
if (!config.adminKeyHash) {
|
||||
logger.error('Admin endpoint called but PATCHPROBE_ADMIN_KEY_HASH is not configured');
|
||||
return res.status(503).json({ error: 'Admin authentication not configured — run: node server.js --gen-admin-key' });
|
||||
}
|
||||
|
||||
bcrypt.compare(key, config.adminKeyHash, (err, match) => {
|
||||
if (err) {
|
||||
logger.error({ err }, 'bcrypt compare error during admin auth');
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
if (!match) {
|
||||
logger.warn({ reason: 'invalid_key' }, 'Admin auth failure');
|
||||
return res.status(401).json({ error: 'Invalid admin key' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// JWT session check — for browser / dashboard access
|
||||
function requireSession(req, res, next) {
|
||||
const token = req.cookies?.[SESSION_COOKIE];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
try {
|
||||
req.adminSession = jwt.verify(token, config.jwtSecret);
|
||||
next();
|
||||
} catch {
|
||||
res.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||
return res.status(401).json({ error: 'Session expired — please log in again' });
|
||||
}
|
||||
}
|
||||
|
||||
// Session cookie OR bearer key — used by all /api/admin/* routes
|
||||
function requireAdminAccess(req, res, next) {
|
||||
const token = req.cookies?.[SESSION_COOKIE];
|
||||
if (token) {
|
||||
try {
|
||||
req.adminSession = jwt.verify(token, config.jwtSecret);
|
||||
return next();
|
||||
} catch {
|
||||
res.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||
}
|
||||
}
|
||||
const authHeader = req.headers['authorization'] ?? '';
|
||||
if (authHeader.startsWith('Bearer ')) {
|
||||
return requireAdmin(req, res, next);
|
||||
}
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
module.exports = { requireAdmin, requireSession, requireAdminAccess };
|
||||
74
PatchProbe.Server/src/middleware/auth.js
Normal file
74
PatchProbe.Server/src/middleware/auth.js
Normal file
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const config = require('../config');
|
||||
const { getDb } = require('../db');
|
||||
const logger = require('../logger');
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (!config.authEnabled) return next();
|
||||
|
||||
const deviceId = req.headers['x-device-id'];
|
||||
const timestamp = req.headers['x-timestamp'];
|
||||
const signature = req.headers['x-signature'];
|
||||
|
||||
if (!deviceId || !timestamp || !signature) {
|
||||
logger.warn({ reason: 'missing_headers' }, 'Device auth failure');
|
||||
return res.status(401).json({ error: 'Missing authentication headers (X-Device-Id, X-Timestamp, X-Signature)' });
|
||||
}
|
||||
|
||||
const reqTime = parseInt(timestamp, 10);
|
||||
if (isNaN(reqTime) || Math.abs(Date.now() - reqTime) > 5 * 60 * 1000) {
|
||||
logger.warn({ deviceId, reason: 'timestamp_expired' }, 'Device auth failure');
|
||||
return res.status(401).json({ error: 'Request timestamp is expired or invalid' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const device = db.prepare('SELECT * FROM devices WHERE device_id = ?').get(deviceId);
|
||||
|
||||
if (!device) {
|
||||
logger.warn({ deviceId, reason: 'unknown_device' }, 'Device auth failure');
|
||||
return res.status(401).json({ error: 'Unknown device' });
|
||||
}
|
||||
|
||||
if (device.revoked_at) {
|
||||
logger.warn({ deviceId, reason: 'device_revoked' }, 'Device auth failure');
|
||||
return res.status(401).json({ error: 'Device has been revoked' });
|
||||
}
|
||||
|
||||
// Reconstruct the signed message: deviceId LF timestamp LF base64(sha256(rawBody))
|
||||
const bodyHash = crypto.createHash('sha256').update(req.rawBody ?? '').digest('base64');
|
||||
const message = `${deviceId}\n${timestamp}\n${bodyHash}`;
|
||||
|
||||
let valid = false;
|
||||
try {
|
||||
const publicKey = crypto.createPublicKey({
|
||||
key: Buffer.from(device.public_key_spki, 'base64'),
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
});
|
||||
const verifier = crypto.createVerify('SHA256');
|
||||
verifier.update(message);
|
||||
// .NET ECDsa.SignData() produces IEEE P1363 (raw r‖s), not DER
|
||||
valid = verifier.verify(
|
||||
{ key: publicKey, dsaEncoding: 'ieee-p1363' },
|
||||
Buffer.from(signature, 'base64'),
|
||||
);
|
||||
} catch {
|
||||
logger.warn({ deviceId, reason: 'sig_error' }, 'Device auth failure');
|
||||
return res.status(401).json({ error: 'Signature verification error' });
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
logger.warn({ deviceId, reason: 'invalid_sig' }, 'Device auth failure');
|
||||
return res.status(401).json({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
db.prepare('UPDATE devices SET last_seen_at = ? WHERE device_id = ?')
|
||||
.run(new Date().toISOString(), deviceId);
|
||||
|
||||
req.device = device;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { requireAuth };
|
||||
155
PatchProbe.Server/src/routes/admin.js
Normal file
155
PatchProbe.Server/src/routes/admin.js
Normal file
@@ -0,0 +1,155 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { getDb } = require('../db');
|
||||
const { requireAdminAccess } = require('../middleware/adminAuth');
|
||||
const logger = require('../logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAdminAccess);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enrollment tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// POST /api/admin/tokens — create an enrollment token
|
||||
router.post('/tokens', (req, res) => {
|
||||
const { label, expiresInDays, maxUses } = req.body ?? {};
|
||||
|
||||
if (typeof label !== 'string' || label.trim().length === 0 || label.length > 255) {
|
||||
return res.status(400).json({ error: 'label is required (string, max 255 chars)' });
|
||||
}
|
||||
if (expiresInDays !== undefined && (!Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > 3650)) {
|
||||
return res.status(400).json({ error: 'expiresInDays must be a positive integer (max 3650)' });
|
||||
}
|
||||
if (maxUses !== undefined && (!Number.isInteger(maxUses) || maxUses < 1)) {
|
||||
return res.status(400).json({ error: 'maxUses must be a positive integer' });
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const now = new Date().toISOString();
|
||||
const expiresAt = expiresInDays
|
||||
? new Date(Date.now() + expiresInDays * 86_400_000).toISOString()
|
||||
: null;
|
||||
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
'INSERT INTO enrollment_tokens (token, label, created_at, expires_at, max_uses) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(token, label.trim(), now, expiresAt, maxUses ?? null);
|
||||
|
||||
logger.info({ label: label.trim(), expiresInDays, maxUses }, 'Enrollment token created');
|
||||
|
||||
// Token value only returned once — on creation
|
||||
res.status(201).json({
|
||||
token,
|
||||
label: label.trim(),
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
maxUses: maxUses ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/admin/tokens — list tokens (masked)
|
||||
router.get('/tokens', (req, res) => {
|
||||
const db = getDb();
|
||||
const tokens = db.prepare(
|
||||
'SELECT rowid, token, label, created_at, expires_at, used_count, max_uses, revoked_at FROM enrollment_tokens ORDER BY created_at DESC'
|
||||
).all();
|
||||
|
||||
const now = new Date();
|
||||
res.json(tokens.map(t => ({
|
||||
id: t.rowid,
|
||||
tokenMasked: `${t.token.slice(0, 4)}****`,
|
||||
label: t.label,
|
||||
createdAt: t.created_at,
|
||||
expiresAt: t.expires_at,
|
||||
usedCount: t.used_count,
|
||||
maxUses: t.max_uses,
|
||||
revokedAt: t.revoked_at,
|
||||
active: !t.revoked_at
|
||||
&& (!t.expires_at || new Date(t.expires_at) > now)
|
||||
&& (t.max_uses === null || t.used_count < t.max_uses),
|
||||
})));
|
||||
});
|
||||
|
||||
// DELETE /api/admin/tokens/:id — revoke a token by rowid
|
||||
router.delete('/tokens/:id', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) {
|
||||
return res.status(400).json({ error: 'Invalid token id' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT token FROM enrollment_tokens WHERE rowid = ?').get(id);
|
||||
if (!row) return res.status(404).json({ error: 'Token not found' });
|
||||
if (row.revoked_at) return res.status(409).json({ error: 'Token already revoked' });
|
||||
|
||||
db.prepare('UPDATE enrollment_tokens SET revoked_at = ? WHERE rowid = ?')
|
||||
.run(new Date().toISOString(), id);
|
||||
|
||||
logger.info({ tokenId: id, tokenMasked: row.token.slice(0, 4) + '****' }, 'Enrollment token revoked');
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Devices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/admin/devices — list all devices
|
||||
router.get('/devices', (req, res) => {
|
||||
const db = getDb();
|
||||
const devices = db.prepare(
|
||||
'SELECT device_id, machine_name, device_fingerprint, enrolled_at, last_seen_at, revoked_at FROM devices ORDER BY enrolled_at DESC'
|
||||
).all();
|
||||
|
||||
res.json(devices.map(d => ({
|
||||
id: d.device_id,
|
||||
machineName: d.machine_name,
|
||||
deviceFingerprint: d.device_fingerprint,
|
||||
enrolledAt: d.enrolled_at,
|
||||
lastSeenAt: d.last_seen_at,
|
||||
revoked: !!d.revoked_at,
|
||||
revokedAt: d.revoked_at,
|
||||
})));
|
||||
});
|
||||
|
||||
// DELETE /api/admin/devices/:id — revoke a device
|
||||
router.delete('/devices/:id', (req, res) => {
|
||||
const db = getDb();
|
||||
const device = db.prepare('SELECT device_id FROM devices WHERE device_id = ?').get(req.params.id);
|
||||
if (!device) return res.status(404).json({ error: 'Device not found' });
|
||||
|
||||
db.prepare('UPDATE devices SET revoked_at = ? WHERE device_id = ?')
|
||||
.run(new Date().toISOString(), req.params.id);
|
||||
|
||||
logger.info({ deviceId: req.params.id }, 'Device revoked');
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// GET /api/admin/devices/:id/scans — scan summaries for one device
|
||||
router.get('/devices/:id/scans', (req, res) => {
|
||||
const db = getDb();
|
||||
const device = db.prepare('SELECT device_id FROM devices WHERE device_id = ?').get(req.params.id);
|
||||
if (!device) return res.status(404).json({ error: 'Device not found' });
|
||||
|
||||
const scans = db.prepare(`
|
||||
SELECT id, machine_name, collected_at, ran_as_administrator, applicable_update_count, pending_reboot, received_at
|
||||
FROM scans
|
||||
WHERE device_id = ?
|
||||
ORDER BY received_at DESC
|
||||
`).all(req.params.id);
|
||||
|
||||
res.json(scans.map(s => ({
|
||||
id: s.id,
|
||||
machineName: s.machine_name,
|
||||
collectedAt: s.collected_at,
|
||||
receivedAt: s.received_at,
|
||||
ranAsAdministrator: s.ran_as_administrator === 1,
|
||||
applicableUpdateCount: s.applicable_update_count,
|
||||
pendingReboot: s.pending_reboot === 1,
|
||||
})));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
350
PatchProbe.Server/src/routes/auth.js
Normal file
350
PatchProbe.Server/src/routes/auth.js
Normal file
@@ -0,0 +1,350 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
} = require('@simplewebauthn/server');
|
||||
|
||||
const config = require('../config');
|
||||
const { getDb } = require('../db');
|
||||
const { requireAdminAccess, requireSession } = require('../middleware/adminAuth');
|
||||
const logger = require('../logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory challenge store (TTL 2 minutes, single-server only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const pending = new Map();
|
||||
|
||||
function storeEntry(data) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
pending.set(token, { ...data, expiresAt: Date.now() + 120_000 });
|
||||
return token;
|
||||
}
|
||||
|
||||
function consumeEntry(token) {
|
||||
if (!token) return null;
|
||||
const entry = pending.get(token);
|
||||
pending.delete(token);
|
||||
if (!entry || entry.expiresAt < Date.now()) return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of pending) if (v.expiresAt < now) pending.delete(k);
|
||||
}, 60_000).unref();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cookie helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SESSION_COOKIE = 'pp_session';
|
||||
const CEREMONY_COOKIE = 'pp_ceremony';
|
||||
|
||||
const BASE_OPTS = {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: config.nodeEnv === 'production',
|
||||
path: '/',
|
||||
};
|
||||
|
||||
function setSession(res, user) {
|
||||
const token = jwt.sign(
|
||||
{ sub: user.user_id, username: user.username, displayName: user.display_name },
|
||||
config.jwtSecret,
|
||||
{ expiresIn: '8h' },
|
||||
);
|
||||
res.cookie(SESSION_COOKIE, token, { ...BASE_OPTS, maxAge: 8 * 60 * 60 * 1000 });
|
||||
return token;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap guard: allow if no admin users exist yet, else require admin access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function requireAdminOrBootstrap(req, res, next) {
|
||||
const db = getDb();
|
||||
const { n } = db.prepare('SELECT COUNT(*) AS n FROM admin_users').get();
|
||||
if (n === 0) return next();
|
||||
return requireAdminAccess(req, res, next);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status — lets the login page know if first-time setup is needed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/status', (req, res) => {
|
||||
const db = getDb();
|
||||
const { n } = db.prepare('SELECT COUNT(*) AS n FROM admin_users').get();
|
||||
res.json({ hasUsers: n > 0 });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/me', (req, res) => {
|
||||
const token = req.cookies?.[SESSION_COOKIE];
|
||||
if (!token) return res.status(401).json({ error: 'Not authenticated' });
|
||||
try {
|
||||
const p = jwt.verify(token, config.jwtSecret);
|
||||
res.json({ userId: p.sub, username: p.username, displayName: p.displayName });
|
||||
} catch {
|
||||
res.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||
res.status(401).json({ error: 'Session expired' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
res.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post('/register/begin', requireAdminOrBootstrap, async (req, res, next) => {
|
||||
try {
|
||||
const { username, displayName } = req.body ?? {};
|
||||
if (typeof username !== 'string' || !username.trim() || username.length > 64) {
|
||||
return res.status(400).json({ error: 'username required (max 64 chars)' });
|
||||
}
|
||||
if (typeof displayName !== 'string' || !displayName.trim() || displayName.length > 128) {
|
||||
return res.status(400).json({ error: 'displayName required (max 128 chars)' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const cleanName = username.trim().toLowerCase();
|
||||
const cleanDisplay = displayName.trim();
|
||||
const userId = crypto.randomUUID();
|
||||
|
||||
// Exclude credentials already registered for this username
|
||||
const existingCreds = db.prepare(`
|
||||
SELECT ac.credential_id, ac.transports
|
||||
FROM admin_credentials ac
|
||||
JOIN admin_users au ON au.user_id = ac.user_id
|
||||
WHERE au.username = ?
|
||||
`).all(cleanName);
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: 'PatchProbe',
|
||||
rpID: config.rpId,
|
||||
userID: Buffer.from(userId),
|
||||
userName: cleanName,
|
||||
userDisplayName: cleanDisplay,
|
||||
attestation: 'none',
|
||||
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
|
||||
excludeCredentials: existingCreds.map(c => ({
|
||||
id: c.credential_id,
|
||||
transports: JSON.parse(c.transports ?? '[]'),
|
||||
})),
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const ceremonyToken = storeEntry({
|
||||
challenge: options.challenge,
|
||||
pendingUser: { userId, username: cleanName, displayName: cleanDisplay },
|
||||
});
|
||||
|
||||
res.cookie(CEREMONY_COOKIE, ceremonyToken, { ...BASE_OPTS, maxAge: 120_000 });
|
||||
logger.info({ username: cleanName }, 'Passkey registration ceremony started');
|
||||
res.json(options);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/register/finish', async (req, res, next) => {
|
||||
try {
|
||||
const entry = consumeEntry(req.cookies?.[CEREMONY_COOKIE]);
|
||||
res.clearCookie(CEREMONY_COOKIE, { path: '/' });
|
||||
|
||||
if (!entry?.challenge || !entry?.pendingUser) {
|
||||
return res.status(400).json({ error: 'Registration ceremony expired or not started' });
|
||||
}
|
||||
|
||||
const { challenge, pendingUser } = entry;
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: req.body,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: config.origin,
|
||||
expectedRPID: config.rpId,
|
||||
requireUserVerification: true,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ err: err.message, username: pendingUser.username }, 'Passkey registration verification failed');
|
||||
return res.status(400).json({ error: 'Passkey verification failed' });
|
||||
}
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return res.status(400).json({ error: 'Passkey verification failed' });
|
||||
}
|
||||
|
||||
const { credential } = verification.registrationInfo;
|
||||
const db = getDb();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Upsert user (handles both new registration and additional passkeys)
|
||||
const existingUser = db.prepare('SELECT user_id FROM admin_users WHERE username = ?').get(pendingUser.username);
|
||||
const userId = existingUser?.user_id ?? pendingUser.userId;
|
||||
|
||||
if (!existingUser) {
|
||||
db.prepare(
|
||||
'INSERT INTO admin_users (user_id, username, display_name, created_at) VALUES (?, ?, ?, ?)',
|
||||
).run(userId, pendingUser.username, pendingUser.displayName, now);
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO admin_credentials
|
||||
(credential_id, user_id, public_key, counter, device_type, backed_up, transports, created_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
credential.id,
|
||||
userId,
|
||||
Buffer.from(credential.publicKey).toString('base64'),
|
||||
credential.counter,
|
||||
credential.deviceType ?? null,
|
||||
credential.backedUp ? 1 : 0,
|
||||
JSON.stringify(credential.transports ?? []),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
const user = db.prepare('SELECT * FROM admin_users WHERE user_id = ?').get(userId);
|
||||
setSession(res, user);
|
||||
logger.info({ userId, username: pendingUser.username }, 'Passkey registered');
|
||||
res.json({ ok: true, username: user.username, displayName: user.display_name });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post('/login/begin', async (req, res, next) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const credCount = db.prepare('SELECT COUNT(*) AS n FROM admin_credentials').get().n;
|
||||
if (credCount === 0) {
|
||||
return res.status(404).json({ error: 'No passkeys registered — complete first-time setup' });
|
||||
}
|
||||
|
||||
// No allowCredentials → browser picks from all passkeys registered for this RP
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.rpId,
|
||||
userVerification: 'required',
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const ceremonyToken = storeEntry({ challenge: options.challenge });
|
||||
res.cookie(CEREMONY_COOKIE, ceremonyToken, { ...BASE_OPTS, maxAge: 120_000 });
|
||||
res.json(options);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login/finish', async (req, res, next) => {
|
||||
try {
|
||||
const entry = consumeEntry(req.cookies?.[CEREMONY_COOKIE]);
|
||||
res.clearCookie(CEREMONY_COOKIE, { path: '/' });
|
||||
|
||||
if (!entry?.challenge) {
|
||||
return res.status(400).json({ error: 'Authentication ceremony expired or not started' });
|
||||
}
|
||||
|
||||
const credentialId = req.body?.id;
|
||||
const db = getDb();
|
||||
const storedCred = db.prepare('SELECT * FROM admin_credentials WHERE credential_id = ?').get(credentialId);
|
||||
|
||||
if (!storedCred) {
|
||||
return res.status(401).json({ error: 'Passkey not recognized' });
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: req.body,
|
||||
expectedChallenge: entry.challenge,
|
||||
expectedOrigin: config.origin,
|
||||
expectedRPID: config.rpId,
|
||||
credential: {
|
||||
id: storedCred.credential_id,
|
||||
publicKey: Buffer.from(storedCred.public_key, 'base64'),
|
||||
counter: storedCred.counter,
|
||||
transports: JSON.parse(storedCred.transports ?? '[]'),
|
||||
},
|
||||
requireUserVerification: true,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ err: err.message }, 'Passkey authentication verification failed');
|
||||
return res.status(401).json({ error: 'Passkey verification failed' });
|
||||
}
|
||||
|
||||
if (!verification.verified) {
|
||||
return res.status(401).json({ error: 'Passkey verification failed' });
|
||||
}
|
||||
|
||||
const { newCounter } = verification.authenticationInfo;
|
||||
const now = new Date().toISOString();
|
||||
db.prepare('UPDATE admin_credentials SET counter = ?, last_used_at = ? WHERE credential_id = ?')
|
||||
.run(newCounter, now, storedCred.credential_id);
|
||||
|
||||
const user = db.prepare('SELECT * FROM admin_users WHERE user_id = ?').get(storedCred.user_id);
|
||||
setSession(res, user);
|
||||
logger.info({ userId: user.user_id, username: user.username }, 'Passkey login successful');
|
||||
res.json({ ok: true, username: user.username, displayName: user.display_name });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Passkey management (session required — bearer key has no user context)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/passkeys', requireSession, (req, res) => {
|
||||
const db = getDb();
|
||||
const passkeys = db.prepare(
|
||||
'SELECT credential_id, device_type, backed_up, transports, created_at, last_used_at FROM admin_credentials WHERE user_id = ?',
|
||||
).all(req.adminSession.sub);
|
||||
|
||||
res.json(passkeys.map(p => ({
|
||||
id: p.credential_id,
|
||||
idMasked: `${p.credential_id.slice(0, 8)}…`,
|
||||
deviceType: p.device_type,
|
||||
backedUp: p.backed_up === 1,
|
||||
transports: JSON.parse(p.transports ?? '[]'),
|
||||
createdAt: p.created_at,
|
||||
lastUsedAt: p.last_used_at,
|
||||
})));
|
||||
});
|
||||
|
||||
router.delete('/passkeys/:id', requireSession, (req, res) => {
|
||||
const db = getDb();
|
||||
const userId = req.adminSession.sub;
|
||||
const count = db.prepare('SELECT COUNT(*) AS n FROM admin_credentials WHERE user_id = ?').get(userId).n;
|
||||
if (count <= 1) {
|
||||
return res.status(400).json({ error: 'Cannot remove your only passkey' });
|
||||
}
|
||||
const result = db.prepare('DELETE FROM admin_credentials WHERE credential_id = ? AND user_id = ?')
|
||||
.run(req.params.id, userId);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Passkey not found' });
|
||||
logger.info({ userId, credentialId: req.params.id.slice(0, 8) }, 'Passkey removed');
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
158
PatchProbe.Server/src/routes/enrollments.js
Normal file
158
PatchProbe.Server/src/routes/enrollments.js
Normal file
@@ -0,0 +1,158 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const config = require('../config');
|
||||
const { getDb } = require('../db');
|
||||
const { requireAdmin } = require('../middleware/adminAuth');
|
||||
const logger = require('../logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validateEnrollBody(body) {
|
||||
const { machineName, publicKeySpki, deviceFingerprint } = body ?? {};
|
||||
if (typeof machineName !== 'string' || machineName.trim().length === 0 || machineName.length > 255) {
|
||||
return 'machineName is required (string, max 255 chars)';
|
||||
}
|
||||
if (typeof publicKeySpki !== 'string' || publicKeySpki.length === 0 || publicKeySpki.length > 4096) {
|
||||
return 'publicKeySpki is required (base64 DER SPKI string)';
|
||||
}
|
||||
if (deviceFingerprint !== undefined &&
|
||||
(typeof deviceFingerprint !== 'string' || deviceFingerprint.length > 255)) {
|
||||
return 'deviceFingerprint must be a string (max 255 chars)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/enrollments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { enrollmentKey, machineName, deviceFingerprint, publicKeySpki } = req.body ?? {};
|
||||
|
||||
const validationError = validateEnrollBody(req.body);
|
||||
if (validationError) {
|
||||
return res.status(400).json({ error: validationError });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
let matchedToken = null;
|
||||
|
||||
if (config.authEnabled) {
|
||||
if (!enrollmentKey || typeof enrollmentKey !== 'string') {
|
||||
logger.warn({ machineName, reason: 'missing_token' }, 'Enrollment rejected');
|
||||
return res.status(403).json({ error: 'Invalid or missing enrollment key' });
|
||||
}
|
||||
|
||||
const tokens = db.prepare(
|
||||
'SELECT token, expires_at, max_uses, used_count FROM enrollment_tokens WHERE revoked_at IS NULL'
|
||||
).all();
|
||||
|
||||
const providedBuf = Buffer.from(enrollmentKey);
|
||||
|
||||
// Compare against every token without short-circuiting to avoid timing oracle
|
||||
for (const t of tokens) {
|
||||
const storedBuf = Buffer.from(t.token);
|
||||
const maxLen = Math.max(providedBuf.length, storedBuf.length);
|
||||
const a = Buffer.alloc(maxLen);
|
||||
const b = Buffer.alloc(maxLen);
|
||||
providedBuf.copy(a);
|
||||
storedBuf.copy(b);
|
||||
if (crypto.timingSafeEqual(a, b)) {
|
||||
matchedToken = t;
|
||||
// Continue loop — do not break early
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedToken) {
|
||||
logger.warn({ machineName, reason: 'invalid_token' }, 'Enrollment rejected');
|
||||
return res.status(403).json({ error: 'Invalid or missing enrollment key' });
|
||||
}
|
||||
|
||||
if (matchedToken.expires_at && new Date(matchedToken.expires_at) < new Date()) {
|
||||
logger.warn({ machineName, reason: 'token_expired' }, 'Enrollment rejected');
|
||||
return res.status(403).json({ error: 'Enrollment token has expired' });
|
||||
}
|
||||
|
||||
if (matchedToken.max_uses !== null && matchedToken.used_count >= matchedToken.max_uses) {
|
||||
logger.warn({ machineName, reason: 'token_max_uses' }, 'Enrollment rejected');
|
||||
return res.status(403).json({ error: 'Enrollment token usage limit reached' });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the public key before storing anything
|
||||
try {
|
||||
const key = crypto.createPublicKey({
|
||||
key: Buffer.from(publicKeySpki, 'base64'),
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
});
|
||||
if (key.asymmetricKeyType !== 'ec') {
|
||||
throw new Error('Not an EC key');
|
||||
}
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Invalid public key — must be ECDSA P-256 SubjectPublicKeyInfo (DER, base64)' });
|
||||
}
|
||||
|
||||
const sanitizedName = machineName.trim().slice(0, 255);
|
||||
const sanitizedFp = deviceFingerprint?.trim().slice(0, 255) ?? null;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Re-enrollment: same fingerprint → rotate public key, preserve deviceId
|
||||
if (sanitizedFp) {
|
||||
const existing = db.prepare('SELECT device_id FROM devices WHERE device_fingerprint = ?').get(sanitizedFp);
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
'UPDATE devices SET public_key_spki = ?, machine_name = ?, last_seen_at = ? WHERE device_id = ?'
|
||||
).run(publicKeySpki, sanitizedName, now, existing.device_id);
|
||||
|
||||
if (matchedToken) {
|
||||
db.prepare('UPDATE enrollment_tokens SET used_count = used_count + 1 WHERE token = ?')
|
||||
.run(matchedToken.token);
|
||||
}
|
||||
|
||||
logger.info({ deviceId: existing.device_id, machineName: sanitizedName }, 'Device re-enrolled');
|
||||
return res.status(200).json({ deviceId: existing.device_id, message: 'Re-enrollment successful' });
|
||||
}
|
||||
}
|
||||
|
||||
// New device
|
||||
const deviceId = crypto.randomUUID();
|
||||
db.prepare(
|
||||
'INSERT INTO devices (device_id, machine_name, device_fingerprint, public_key_spki, enrolled_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(deviceId, sanitizedName, sanitizedFp, publicKeySpki, now);
|
||||
|
||||
if (matchedToken) {
|
||||
db.prepare('UPDATE enrollment_tokens SET used_count = used_count + 1 WHERE token = ?')
|
||||
.run(matchedToken.token);
|
||||
}
|
||||
|
||||
logger.info({ deviceId, machineName: sanitizedName }, 'Device enrolled');
|
||||
res.status(201).json({ deviceId, message: 'Enrollment successful' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/enrollments — admin-protected device list (backward-compat route)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/', requireAdmin, (req, res) => {
|
||||
const db = getDb();
|
||||
const devices = db.prepare(
|
||||
'SELECT device_id, machine_name, device_fingerprint, enrolled_at, last_seen_at, revoked_at FROM devices ORDER BY enrolled_at DESC'
|
||||
).all();
|
||||
res.json(devices.map(d => ({
|
||||
deviceId: d.device_id,
|
||||
machineName: d.machine_name,
|
||||
deviceFingerprint: d.device_fingerprint,
|
||||
enrolledAt: d.enrolled_at,
|
||||
lastSeenAt: d.last_seen_at,
|
||||
revoked: !!d.revoked_at,
|
||||
})));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
102
PatchProbe.Server/src/routes/scans.js
Normal file
102
PatchProbe.Server/src/routes/scans.js
Normal file
@@ -0,0 +1,102 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { getDb } = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const logger = require('../logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/scans — ingest a scan payload from an enrolled device
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post('/', requireAuth, (req, res) => {
|
||||
const payload = req.body;
|
||||
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return res.status(400).json({ error: 'Request body must be a JSON object' });
|
||||
}
|
||||
if (typeof payload.collector?.machineName !== 'string' || payload.collector.machineName.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Invalid payload: missing collector.machineName' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const machineName = payload.collector.machineName.trim().slice(0, 255);
|
||||
const deviceId = req.device?.device_id ?? null;
|
||||
|
||||
let collectedAt = null;
|
||||
try {
|
||||
collectedAt = payload.collector.collectedAt
|
||||
? new Date(payload.collector.collectedAt).toISOString()
|
||||
: null;
|
||||
} catch { /* leave null */ }
|
||||
|
||||
const ranAsAdmin = payload.collector.ranAsAdministrator ? 1 : 0;
|
||||
const applicableUpdateCount = Array.isArray(payload.windowsUpdate?.applicableUpdates)
|
||||
? payload.windowsUpdate.applicableUpdates.length
|
||||
: 0;
|
||||
const pendingReboot = payload.pendingReboot?.anyPending ? 1 : 0;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO scans
|
||||
(id, device_id, machine_name, collected_at, ran_as_administrator, applicable_update_count, pending_reboot, raw_json, received_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, deviceId, machineName, collectedAt, ranAsAdmin, applicableUpdateCount, pendingReboot, JSON.stringify(payload), now);
|
||||
|
||||
logger.info({ id, machineName, deviceId }, 'Scan ingested');
|
||||
res.status(201).json({ id });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/scans — list scan summaries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(`
|
||||
SELECT id, device_id, machine_name, collected_at, ran_as_administrator,
|
||||
applicable_update_count, pending_reboot, received_at
|
||||
FROM scans
|
||||
ORDER BY received_at DESC
|
||||
`).all();
|
||||
|
||||
res.json(rows.map(s => ({
|
||||
id: s.id,
|
||||
deviceId: s.device_id,
|
||||
machineName: s.machine_name,
|
||||
collectedAt: s.collected_at,
|
||||
receivedAt: s.received_at,
|
||||
ranAsAdministrator: s.ran_as_administrator === 1,
|
||||
applicableUpdateCount: s.applicable_update_count,
|
||||
pendingReboot: s.pending_reboot === 1,
|
||||
})));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/scans/:id — return full scan payload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id', (req, res) => {
|
||||
const db = getDb();
|
||||
const scan = db.prepare('SELECT raw_json FROM scans WHERE id = ?').get(req.params.id);
|
||||
if (!scan) return res.status(404).json({ error: 'Not found' });
|
||||
// Send raw stored JSON without re-parsing to preserve exact byte representation
|
||||
res.set('Content-Type', 'application/json').send(scan.raw_json);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/scans/:id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM scans WHERE id = ?').run(req.params.id);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user