Initial commit: Elydora Open Source — self-hosted audit platform for AI agents

Full self-hosted port of the Elydora tamper-evident audit protocol:
- Hono API server with PostgreSQL, Redis, MinIO adapters
- Ed25519 signed operations, chain hashing, Merkle epoch rollups
- PBKDF2 password hashing, HMAC-SHA256 JWT, 5-role RBAC
- Next.js management console
- Node.js, Python, and Go SDKs
- Docker Compose for local deployment
- Helm chart for Kubernetes
- Comprehensive security hardening and audit fixes applied

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-02 13:41:42 -05:00
1 parent 15547a8c35
commit e390e68594
207 files changed
+32790 -63

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
/**
* JWT authentication middleware.
*
* Verifies Bearer tokens in the Authorization header using HMAC-SHA256.
* On success, extracts `org_id`, `role`, and `sub` (actor) claims and
* stores them in the Hono context variables for downstream handlers.
*
* Token structure (payload):
* {
* "sub": "user_abc123", // actor identifier
* "org_id": "org_acme", // organization scope
* "role": "org_owner", // RBAC role
* "iat": 1700000000, // issued-at (seconds)
* "exp": 1700003600 // expiration (seconds)
* }
*/
import type { MiddlewareHandler } from 'hono';
import type { RbacRole } from '../shared/index.js';
import type { Env, AppVariables } from '../types.js';
import { AppError } from './error-handler.js';
import { base64urlDecode } from '../utils/crypto.js';
/** Minimal JWT payload shape we require. */
interface JWTPayload {
sub: string;
org_id: string;
role: RbacRole;
iat: number;
exp: number;
}
/**
* Verify a JWT using HMAC-SHA256 and return its payload.
*/
async function verifyJWT(token: string, secret: string): Promise<JWTPayload> {
const parts = token.split('.');
if (parts.length !== 3) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedJwt' });
}
const headerB64 = parts[0]!;
const payloadB64 = parts[1]!;
const signatureB64 = parts[2]!;
// Validate JWT header — only HS256 is accepted
try {
const headerBytes = base64urlDecode(headerB64);
const header = JSON.parse(new TextDecoder().decode(headerBytes)) as { alg?: string; typ?: string };
if (header.alg !== 'HS256') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.unsupportedAlgorithm' });
}
} catch (e) {
if (e instanceof AppError) throw e;
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedJwt' });
}
// Import the HMAC key
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
// Verify signature over "header.payload"
const signedInput = encoder.encode(`${headerB64}.${payloadB64}`);
const signatureBytes = base64urlDecode(signatureB64);
const valid = await crypto.subtle.verify('HMAC', cryptoKey, signatureBytes as unknown as Uint8Array<ArrayBuffer>, signedInput as unknown as Uint8Array<ArrayBuffer>);
if (!valid) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidSignature' });
}
// Decode and validate payload
const payloadBytes = base64urlDecode(payloadB64);
const payloadJson = new TextDecoder().decode(payloadBytes);
let payload: JWTPayload;
try {
payload = JSON.parse(payloadJson);
} catch {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedPayload' });
}
// Validate required claims
if (!payload.sub || typeof payload.sub !== 'string') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingSub' });
}
if (!payload.org_id || typeof payload.org_id !== 'string') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingOrgId' });
}
if (!payload.role || typeof payload.role !== 'string') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingRole' });
}
// Check expiration
const nowSeconds = Math.floor(Date.now() / 1000);
if (typeof payload.exp !== 'number') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingExp' });
}
if (payload.exp < nowSeconds) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.expired' });
}
return payload;
}
/**
* Authentication middleware.
*
* Extracts the Bearer token, verifies it, and sets org_id, role, and
* actor on the context. Should be applied to all routes that require
* authentication.
*/
export const authMiddleware: MiddlewareHandler<{
Bindings: Env;
Variables: AppVariables;
}> = async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingHeader' });
}
if (!authHeader.startsWith('Bearer ')) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.bearerRequired' });
}
const token = authHeader.slice(7).trim();
if (!token) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.emptyToken' });
}
const payload = await verifyJWT(token, c.env.JWT_SECRET);
c.set('org_id', payload.org_id);
c.set('role', payload.role);
c.set('actor', payload.sub);
await next();
};
@@ -0,0 +1,166 @@
/**
* Global error handler middleware.
*
* Catches all uncaught exceptions and formats them as a standardized
* ErrorResponse with the appropriate HTTP status code, error code,
* and the request's unique identifier for tracing.
*
* When an AppError carries a `messageKey`, the handler resolves it to
* the correct language using the `lang` context variable set by the
* i18n middleware. This allows services to throw errors with i18n keys
* without needing direct access to the Hono context.
*/
import type { ErrorHandler } from 'hono';
import type { ErrorCode, ErrorResponse } from '../shared/index.js';
import { ERROR_CODES } from '../shared/index.js';
import type { Env, AppVariables } from '../types.js';
import { getMessage } from '../i18n/messages.js';
import type { Lang } from '../i18n/messages.js';
/**
* Maps each ErrorCode to its corresponding i18n message key.
*
* This is used when an AppError is thrown with only an error code
* (no explicit message or i18n key), so the global error handler
* can still resolve a translated default message.
*/
const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
INVALID_SIGNATURE: 'error.invalidSignature',
UNKNOWN_AGENT: 'error.unknownAgent',
KEY_REVOKED: 'error.keyRevoked',
AGENT_FROZEN: 'error.agentFrozen',
TTL_EXPIRED: 'error.ttlExpired',
REPLAY_DETECTED: 'error.replayDetected',
PREV_HASH_MISMATCH: 'error.prevHashMismatch',
PAYLOAD_TOO_LARGE: 'error.payloadTooLarge',
RATE_LIMITED: 'error.rateLimited',
INTERNAL_ERROR: 'error.internalError',
UNAUTHORIZED: 'error.unauthorized',
FORBIDDEN: 'error.forbidden',
NOT_FOUND: 'error.notFound',
VALIDATION_ERROR: 'error.validationError',
};
/**
* Custom error class that carries an HTTP status and Elydora error code.
*
* Supports three modes:
* 1. Code-only: `new AppError(status, code)` — uses ErrorCode default message
* 2. i18n key: `new AppError(status, code, { key: 'msg.key', params: {...} })` —
* the message is resolved at response time from the translation map
* 3. Raw string: `new AppError(status, code, 'raw message')` — message is used as-is
* (should be avoided in new code; prefer mode 2)
*/
export class AppError extends Error {
public readonly statusCode: number;
public readonly errorCode: ErrorCode;
public readonly details?: Record<string, unknown>;
public readonly messageKey?: string;
public readonly messageParams?: Record<string, string | number>;
/** True when the error was created with only an error code (no custom message). */
public readonly usesDefaultMessage: boolean;
constructor(
statusCode: number,
errorCode: ErrorCode,
messageOrOpts?: string | { key: string; params?: Record<string, string | number> },
details?: Record<string, unknown>,
) {
if (typeof messageOrOpts === 'object' && messageOrOpts !== null) {
// i18n mode — store the key; resolve later in the error handler
super(getMessage(messageOrOpts.key, 'en', messageOrOpts.params));
this.messageKey = messageOrOpts.key;
this.messageParams = messageOrOpts.params;
this.usesDefaultMessage = false;
} else if (messageOrOpts === undefined) {
// Code-only mode — will be resolved via ERROR_CODE_TO_I18N_KEY
super(ERROR_CODES[errorCode]);
this.usesDefaultMessage = true;
} else {
// Raw string mode (legacy)
super(messageOrOpts);
this.usesDefaultMessage = false;
}
this.name = 'AppError';
this.statusCode = statusCode;
this.errorCode = errorCode;
this.details = details;
}
/**
* Resolve the user-facing message for a given language.
*
* Resolution order:
* 1. If a messageKey was provided, use that with the translation map.
* 2. If the error used the default ErrorCode message, translate via
* the ERROR_CODE_TO_I18N_KEY mapping.
* 3. Otherwise return the raw message string.
*/
resolveMessage(lang: Lang): string {
if (this.messageKey) {
return getMessage(this.messageKey, lang, this.messageParams);
}
if (this.usesDefaultMessage) {
const i18nKey = ERROR_CODE_TO_I18N_KEY[this.errorCode];
if (i18nKey) {
return getMessage(i18nKey, lang);
}
}
return this.message;
}
}
/**
* Build a standardised error response body.
*
* When `lang` is provided and no explicit `message` is given, the
* ErrorCode default message is resolved from the translation map.
*/
export function buildErrorResponse(
code: ErrorCode,
requestId: string,
message?: string,
details?: Record<string, unknown>,
lang?: Lang,
): ErrorResponse {
let resolvedMessage = message;
if (!resolvedMessage) {
const i18nKey = ERROR_CODE_TO_I18N_KEY[code];
resolvedMessage = i18nKey ? getMessage(i18nKey, lang ?? 'en') : ERROR_CODES[code];
}
return {
error: {
code,
message: resolvedMessage,
request_id: requestId,
...(details ? { details } : {}),
},
};
}
/**
* Hono error handler that converts all errors into the ErrorResponse format.
*
* Resolves i18n message keys using the `lang` context variable set by
* the i18n middleware.
*/
export const globalErrorHandler: ErrorHandler<{
Bindings: Env;
Variables: AppVariables;
}> = (err, c) => {
const requestId = c.get('request_id') ?? 'unknown';
const lang: Lang = c.get('lang') ?? 'en';
if (err instanceof AppError) {
const resolvedMessage = err.resolveMessage(lang);
const body = buildErrorResponse(err.errorCode, requestId, resolvedMessage, err.details);
return c.json(body, err.statusCode as 400);
}
// Log unexpected errors for observability
console.error(`[${requestId}] Unhandled error:`, err);
const body = buildErrorResponse('INTERNAL_ERROR', requestId, undefined, undefined, lang);
return c.json(body, 500);
};
+22
View File
@@ -0,0 +1,22 @@
/**
* i18n middleware — language detection from Accept-Language header.
*
* Reads the Accept-Language request header, detects the preferred
* language (English or Chinese), and stores it in the Hono context
* so that all downstream handlers and services can access it via
* `c.get('lang')`.
*/
import type { MiddlewareHandler } from 'hono';
import type { Env, AppVariables } from '../types.js';
import { detectLanguage } from '../i18n/messages.js';
export const i18nMiddleware: MiddlewareHandler<{
Bindings: Env;
Variables: AppVariables;
}> = async (c, next) => {
const acceptLanguage = c.req.header('Accept-Language');
const lang = detectLanguage(acceptLanguage);
c.set('lang', lang);
await next();
};
+67
View File
@@ -0,0 +1,67 @@
/**
* Role-based access control (RBAC) middleware.
*
* The Elydora console defines five roles with a clear privilege hierarchy:
*
* org_owner > security_admin > compliance_auditor > readonly_investigator
* integration_engineer (parallel to compliance_auditor, agent-management focused)
*
* This middleware checks whether the role extracted by the auth middleware
* satisfies the minimum required role for a given route.
*/
import type { MiddlewareHandler } from 'hono';
import type { RbacRole } from '../shared/index.js';
import type { Env, AppVariables } from '../types.js';
import { AppError } from './error-handler.js';
/**
* Privilege level map. Higher number = more privilege.
* integration_engineer is a special role with limited admin capabilities
* for agent management only.
*/
const ROLE_LEVEL: Record<RbacRole, number> = {
readonly_investigator: 10,
integration_engineer: 20,
compliance_auditor: 30,
security_admin: 40,
org_owner: 50,
};
/**
* Check if a role has at least the given privilege level.
*/
function hasMinimumRole(userRole: RbacRole, requiredRole: RbacRole): boolean {
return ROLE_LEVEL[userRole] >= ROLE_LEVEL[requiredRole];
}
/**
* Create an RBAC middleware that requires a minimum role.
*
* Usage:
* app.post('/v1/agents/:id/freeze', requireRole('security_admin'), handler);
*
* @param minimumRole - The minimum RBAC role required to access the endpoint
*/
export function requireRole(minimumRole: RbacRole): MiddlewareHandler<{
Bindings: Env;
Variables: AppVariables;
}> {
return async (c, next) => {
const userRole = c.get('role');
if (!userRole) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'rbac.noRole' });
}
if (!hasMinimumRole(userRole, minimumRole)) {
throw new AppError(
403,
'FORBIDDEN',
{ key: 'rbac.insufficientPermissions' },
);
}
await next();
};
}
@@ -0,0 +1,22 @@
/**
* Request ID middleware.
*
* Generates a UUIDv7 for every incoming request and stores it in the
* Hono context variables. The ID is also set as the X-Request-Id
* response header so clients can reference it when reporting issues.
*/
import type { MiddlewareHandler } from 'hono';
import type { Env, AppVariables } from '../types.js';
import { generateUUIDv7 } from '../utils/uuid.js';
export const requestIdMiddleware: MiddlewareHandler<{
Bindings: Env;
Variables: AppVariables;
}> = async (c, next) => {
const requestId = generateUUIDv7();
c.set('request_id', requestId);
c.header('X-Request-Id', requestId);
await next();
};