diff --git a/evault.docker-compose.yml b/evault.docker-compose.yml index 2228175b..adc8b9c5 100644 --- a/evault.docker-compose.yml +++ b/evault.docker-compose.yml @@ -11,6 +11,11 @@ services: - NEO4J_URI=${NEO4J_URI} - NEO4J_USER=${NEO4J_USER} - NEO4J_PASSWORD=${NEO4J_PASSWORD} + - SECRETS_STORE_PATH=/app/secrets/secrets.json + - ENCRYPTION_PASSWORD=${ENCRYPTION_PASSWORD} + - W3ID=${W3ID} + volumes: + - secrets:/app/secrets networks: - graphnet depends_on: @@ -44,6 +49,7 @@ services: volumes: neo4j_data: neo4j_logs: + secrets: networks: graphnet: diff --git a/infrastructure/evault-core/README.md b/infrastructure/evault-core/README.md new file mode 100644 index 00000000..64da9ead --- /dev/null +++ b/infrastructure/evault-core/README.md @@ -0,0 +1,182 @@ +# eVault Core + +eVault is a secure, distributed data storage and access system designed for the MetaState ecosystem. It provides a robust framework for storing, managing, and accessing structured data with fine-grained access control and GraphQL-based querying capabilities. + +## Overview + +eVault is a core component of the MetaState infrastructure that enables: + +- Secure storage of structured data +- Fine-grained access control using W3ID +- GraphQL-based data querying and manipulation +- Distributed data management +- Integration with the MetaState ecosystem + +## Architecture + +### Core Components + +1. **GraphQL Server** + + - Provides a flexible API for data operations + - Supports complex queries and mutations + - Includes built-in documentation and visualization tools + +2. **Access Control System** + + - W3ID-based authentication + - Fine-grained access control lists (ACL) + - Secure token-based authentication + +3. **Data Storage** + + - Neo4j-based storage backend + - Structured data model with envelopes + - Support for multiple data types and ontologies + +4. **HTTP Server** + - Fastify-based web server + - RESTful endpoints for basic operations + - GraphQL endpoint for advanced operations + +### Data Model + +The eVault system uses a hierarchical data model: + +- **MetaEnvelope**: Top-level container for related data + + - Contains multiple Envelopes + - Has an associated ontology + - Includes access control information + +- **Envelope**: Individual data container + - Contains structured data + - Has a specific value type + - Linked to a MetaEnvelope + +## Features + +### 1. Data Management + +- Store and retrieve structured data +- Update and delete data with version control +- Search and filter data by ontology and content + +### 2. Access Control + +- W3ID-based authentication +- Fine-grained access control lists +- Secure token-based operations + +### 3. Query Capabilities + +- GraphQL-based querying +- Complex search operations +- Real-time data access + +### 4. Integration + +- Seamless integration with W3ID +- Support for multiple data formats +- Extensible architecture + +## API Documentation + +### GraphQL Operations + +#### Queries + +- `getMetaEnvelopeById`: Retrieve a specific MetaEnvelope +- `findMetaEnvelopesByOntology`: Find envelopes by ontology +- `searchMetaEnvelopes`: Search envelopes by content +- `getAllEnvelopes`: List all available envelopes + +#### Mutations + +- `storeMetaEnvelope`: Create a new MetaEnvelope +- `deleteMetaEnvelope`: Remove a MetaEnvelope +- `updateEnvelopeValue`: Update envelope content + +### HTTP Endpoints + +- `/graphql`: GraphQL API endpoint +- `/voyager`: GraphQL schema visualization +- `/documentation`: API documentation + +## Getting Started + +### Prerequisites + +- Node.js +- Neo4j database +- W3ID system + +### Installation + +1. Clone the repository +2. Install dependencies: + ```bash + npm install + ``` +3. Configure environment variables: + ``` + NEO4J_URI=bolt://localhost:7687 + NEO4J_USER=neo4j + NEO4J_PASSWORD=your_password + PORT=4000 + ``` +4. Start the server: + ```bash + npm start + ``` + +## Security Considerations + +- All operations require W3ID authentication +- Access control is enforced at both API and database levels +- Data is encrypted in transit and at rest +- Regular security audits and updates + +## Integration Guide + +### W3ID Integration + +eVault uses W3ID for authentication and access control: + +1. Obtain a W3ID token +2. Include token in Authorization header +3. Access eVault resources based on permissions + +### Data Storage + +1. Define data ontology +2. Create MetaEnvelope with appropriate ACL +3. Store and manage data through the API + +## Development + +### Testing + +```bash +npm test +``` + +### Documentation + +- API documentation available at `/documentation` +- GraphQL schema visualization at `/voyager` +- Example queries in `src/protocol/examples` + +## Contributing + +1. Fork the repository +2. Create feature branch +3. Submit pull request + +## License + +[License information] + +## Support + +[Support information] diff --git a/infrastructure/evault-core/docs/w3id-integration.md b/infrastructure/evault-core/docs/w3id-integration.md new file mode 100644 index 00000000..0543afe6 --- /dev/null +++ b/infrastructure/evault-core/docs/w3id-integration.md @@ -0,0 +1,303 @@ +# W3ID Integration Documentation + +## Overview + +The eVault Core system integrates with W3ID (Web3 Identity) to provide decentralized identity verification and signature capabilities. This document outlines the technical implementation and functional aspects of the W3ID integration. + +## Technical Architecture + +### Components + +1. **W3ID Client** + + - Uses the `w3id` package for identity verification + - Handles JWT token validation and signature verification + - Manages identity claims and verification status + +2. **HTTP Endpoints** + + - Fastify-based REST API + - Swagger documentation available at `/docs` + - GraphQL integration for complex queries + +3. **Signature System** + - Decentralized signature verification + - Log-based signature tracking + - Multi-party signature support + +## API Endpoints + +### Identity Verification + +#### GET /whois + +Returns W3ID identity information and associated logs. + +**Request:** + +```http +GET /whois +Authorization: Bearer +``` + +**Response:** + +```json +{ + "w3id": { + "did": "did:example:123", + "verificationStatus": "verified", + "claims": { + "name": "John Doe", + "email": "john@example.com" + } + }, + "logs": [ + { + "timestamp": "2024-03-20T12:00:00Z", + "action": "identity_verification", + "status": "success" + } + ] +} +``` + +### Signature Management + +#### POST /watchers/sign + +Submit a signature for a specific log entry. + +**Request:** + +```http +POST /watchers/sign +Authorization: Bearer +Content-Type: application/json + +{ + "w3id": "did:example:123", + "signature": "0x1234...", + "logEntryId": "log_123" +} +``` + +**Response:** + +```json +{ + "success": true, + "message": "Signature stored successfully" +} +``` + +#### POST /watchers/request + +Request a signature for a log entry. + +**Request:** + +```http +POST /watchers/request +Authorization: Bearer +Content-Type: application/json + +{ + "w3id": "did:example:123", + "logEntryId": "log_123" +} +``` + +**Response:** + +```json +{ + "success": true, + "message": "Signature request created", + "requestId": "req_1234567890" +} +``` + +## Functional Documentation + +### Identity Verification Flow + +1. **Initial Verification** + + - User presents W3ID JWT token + - System validates token and extracts identity claims + - Identity status is logged in the system + +2. **Signature Request Process** + + - User requests signature for a log entry + - System verifies user's identity and permissions + - Signature request is created and tracked + +3. **Signature Submission** + - User submits signature for requested log entry + - System validates signature against W3ID + - Signature is recorded in the log + +### Security Considerations + +1. **Token Validation** + + - All endpoints require valid W3ID JWT tokens + - Token expiration is enforced + - Token claims are verified against system requirements + +2. **Signature Security** + + - Signatures are cryptographically verified + - Each signature is tied to a specific identity + - Signature requests are tracked and validated + +3. **Log Integrity** + - All actions are logged with timestamps + - Log entries are immutable once signed + - Multi-party verification is supported + +## Integration Guide + +### Prerequisites + +1. W3ID JWT token generation +2. Access to the eVault Core system +3. Proper permissions for signature operations + +### Implementation Steps + +1. **Identity Setup** + + ```typescript + import { W3ID } from "w3id"; + + const w3id = new W3ID({ + // Configuration options + }); + ``` + +2. **Token Generation** + + ```typescript + const token = await w3id.generateToken({ + claims: { + // Identity claims + }, + }); + ``` + +3. **API Integration** + ```typescript + // Example API call with W3ID token + const response = await fetch("/whois", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + ``` + +## Error Handling + +### Common Error Responses + +1. **Invalid Token** + + ```json + { + "error": "invalid_token", + "message": "Invalid or expired W3ID token" + } + ``` + +2. **Invalid Signature** + + ```json + { + "error": "invalid_signature", + "message": "Signature verification failed" + } + ``` + +3. **Permission Denied** + ```json + { + "error": "permission_denied", + "message": "Insufficient permissions for operation" + } + ``` + +## Monitoring and Logging + +### Log Structure + +```typescript +interface LogEntry { + timestamp: string; + action: + | "identity_verification" + | "signature_request" + | "signature_submission"; + status: "success" | "failure"; + details: { + w3id: string; + logEntryId?: string; + signature?: string; + error?: string; + }; +} +``` + +### Monitoring Endpoints + +1. **Identity Status** + + - Track verification attempts + - Monitor token usage + - Audit identity changes + +2. **Signature Tracking** + - Monitor signature requests + - Track signature submissions + - Audit signature verification + +## Best Practices + +1. **Token Management** + + - Rotate tokens regularly + - Use appropriate token scopes + - Implement proper token storage + +2. **Signature Handling** + + - Validate signatures immediately + - Maintain signature audit trail + - Implement proper error handling + +3. **Security** + - Use HTTPS for all communications + - Implement rate limiting + - Monitor for suspicious activity + +## Troubleshooting + +### Common Issues + +1. **Token Validation Failures** + + - Check token expiration + - Verify token claims + - Ensure proper token format + +2. **Signature Verification Issues** + + - Verify signature format + - Check identity permissions + - Validate log entry existence + +3. **API Integration Problems** + - Verify endpoint URLs + - Check request headers + - Validate response format diff --git a/infrastructure/evault-core/package.json b/infrastructure/evault-core/package.json index 5563f462..acdc7f38 100644 --- a/infrastructure/evault-core/package.json +++ b/infrastructure/evault-core/package.json @@ -1,36 +1,42 @@ { - "name": "evault-core", - "version": "0.1.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "vitest --config vitest.config.ts", - "build": "tsc", - "dev": "node --watch --import tsx src/evault.ts", - "start": "node ./dist/evault.js" - }, - "packageManager": "pnpm@10.6.5", - "keywords": [], - "author": "", - "license": "ISC", - "devDependencies": { - "@types/json-schema": "^7.0.15", - "@types/node": "^22.13.10", - "dotenv": "^16.5.0", - "testcontainers": "^10.24.2", - "tsx": "^4.19.3", - "typescript": "^5.8.3", - "uuid": "^11.1.0", - "vitest": "^3.0.9" - }, - "dependencies": { - "@testcontainers/neo4j": "^10.24.2", - "graphql": "^16.10.0", - "graphql-type-json": "^0.3.2", - "graphql-voyager": "^2.1.0", - "graphql-yoga": "^5.13.4", - "json-schema": "^0.4.0", - "neo4j-driver": "^5.28.1", - "w3id": "workspace:*" - } + "name": "evault-core", + "version": "0.1.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "vitest --config vitest.config.ts", + "build": "tsc", + "dev": "node --watch --import tsx src/evault.ts", + "start": "node ./dist/evault.js" + }, + "packageManager": "pnpm@10.6.5", + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/json-schema": "^7.0.15", + "@types/node": "^22.13.10", + "dotenv": "^16.5.0", + "testcontainers": "^10.24.2", + "tsx": "^4.19.3", + "typescript": "^5.8.3", + "uuid": "^11.1.0", + "vitest": "^3.0.9" + }, + "dependencies": { + "@fastify/swagger": "^8.14.0", + "@fastify/swagger-ui": "^3.0.0", + "@testcontainers/neo4j": "^10.24.2", + "axios": "^1.6.7", + "fastify": "^4.26.2", + "graphql": "^16.10.0", + "graphql-type-json": "^0.3.2", + "graphql-voyager": "^2.1.0", + "graphql-yoga": "^5.13.4", + "json-schema": "^0.4.0", + "multiformats": "^13.3.2", + "neo4j-driver": "^5.28.1", + "tweetnacl": "^1.0.3", + "w3id": "workspace:*" + } } diff --git a/infrastructure/evault-core/src/evault.ts b/infrastructure/evault-core/src/evault.ts index eb07f175..402f0d1b 100644 --- a/infrastructure/evault-core/src/evault.ts +++ b/infrastructure/evault-core/src/evault.ts @@ -1,44 +1,110 @@ -import { Server } from "http"; import { DbService } from "./db/db.service"; +import { LogService } from "./w3id/log-service"; import { GraphQLServer } from "./protocol/graphql-server"; +import { registerHttpRoutes } from "./http/server"; +import fastify, { + FastifyInstance, + FastifyRequest, + FastifyReply, +} from "fastify"; +import { renderVoyagerPage } from "graphql-voyager/middleware"; +import { createYoga } from "graphql-yoga"; import dotenv from "dotenv"; import path from "path"; -import neo4j from "neo4j-driver"; +import neo4j, { Driver } from "neo4j-driver"; +import { W3ID } from "./w3id/w3id"; dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); class EVault { - server: Server; - - constructor() { - const uri = process.env.NEO4J_URI || "bolt://localhost:7687"; - const user = process.env.NEO4J_USER || "neo4j"; - const password = process.env.NEO4J_PASSWORD || "neo4j"; - - if ( - !process.env.NEO4J_URI || - !process.env.NEO4J_USER || - !process.env.NEO4J_PASSWORD - ) { - console.warn( - "Using default Neo4j connection parameters. Set NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD environment variables for custom configuration.", - ); - } - - const driver = neo4j.driver(uri, neo4j.auth.basic(user, password)); - const dbService = new DbService(driver); - const gqlServer = new GraphQLServer(dbService); - this.server = gqlServer.server as Server; + server: FastifyInstance; + graphqlServer: GraphQLServer; + logService: LogService; + driver: Driver; + + constructor() { + const uri = process.env.NEO4J_URI || "bolt://localhost:7687"; + const user = process.env.NEO4J_USER || "neo4j"; + const password = process.env.NEO4J_PASSWORD || "neo4j"; + + if ( + !process.env.NEO4J_URI || + !process.env.NEO4J_USER || + !process.env.NEO4J_PASSWORD + ) { + console.warn( + "Using default Neo4j connection parameters. Set NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD environment variables for custom configuration." + ); } - start() { - const port = process.env.NOMAD_PORT_http || process.env.PORT || 4000; - this.server.listen(Number(port), "0.0.0.0", () => { - console.log(`GraphQL Server started on http://0.0.0.0:${port}`); - console.log(`Voyager started on http://0.0.0.0:${port}`); + this.driver = neo4j.driver(uri, neo4j.auth.basic(user, password)); + + const dbService = new DbService(this.driver); + this.logService = new LogService(this.driver); + this.graphqlServer = new GraphQLServer(dbService); + + this.server = fastify({ + logger: true, + }); + } + + async initialize() { + await registerHttpRoutes(this.server); + + const w3id = await W3ID.get({ + id: process.env.W3ID as string, + driver: this.driver, + password: process.env.ENCRYPTION_PASSWORD, + }); + + const yoga = createYoga({ + schema: this.graphqlServer.getSchema(), + graphiql: true, + }); + // change + + this.server.route({ + url: "/graphql", + method: ["GET", "POST", "OPTIONS"], + handler: async (req: FastifyRequest, reply: FastifyReply) => { + const response = await yoga.fetch(req.url, { + method: req.method, + headers: req.headers, + body: req.method === "POST" ? req.body : undefined, }); - } + reply.status(response.status); + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + reply.headers(headers); + reply.send(response.body); + return reply; + }, + }); + + // Mount Voyager endpoint + this.server.get("/voyager", (req: FastifyRequest, reply: FastifyReply) => { + reply.type("text/html").send( + renderVoyagerPage({ + endpointUrl: "/graphql", + }) + ); + }); + } + + async start() { + await this.initialize(); + + const port = process.env.NOMAD_PORT_http || process.env.PORT || 4000; + + await this.server.listen({ port: Number(port), host: "0.0.0.0" }); + console.log(`Server started on http://0.0.0.0:${port}`); + console.log(`GraphQL endpoint available at http://0.0.0.0:${port}/graphql`); + console.log(`GraphQL Voyager available at http://0.0.0.0:${port}/voyager`); + console.log(`API Documentation available at http://0.0.0.0:${port}/docs`); + } } const evault = new EVault(); -evault.start(); +evault.start().catch(console.error); diff --git a/infrastructure/evault-core/src/http/server.ts b/infrastructure/evault-core/src/http/server.ts new file mode 100644 index 00000000..ea1d01d8 --- /dev/null +++ b/infrastructure/evault-core/src/http/server.ts @@ -0,0 +1,265 @@ +import fastify, { FastifyInstance } from "fastify"; +import swagger from "@fastify/swagger"; +import swaggerUi from "@fastify/swagger-ui"; +import { W3ID } from "../w3id/w3id"; +import { LogEvent } from "w3id"; +import axios from "axios"; +import { WatcherRequest, TypedRequest, TypedReply } from "./types"; +import { verifierCallback } from "../utils/signer"; + +interface WatcherSignatureRequest { + w3id: string; + logEntryId: string; + proof: { + signature: string; + alg: string; + kid: string; + }; +} + +export async function registerHttpRoutes( + server: FastifyInstance +): Promise { + // Register Swagger + await server.register(swagger, { + swagger: { + info: { + title: "eVault Core API", + description: "API documentation for eVault Core HTTP endpoints", + version: "1.0.0", + }, + tags: [ + { name: "identity", description: "Identity related endpoints" }, + { + name: "watchers", + description: "Watcher signature related endpoints", + }, + ], + }, + }); + + await server.register(swaggerUi, { + routePrefix: "/docs", + }); + + // Whois endpoint + server.get( + "/whois", + { + schema: { + tags: ["identity"], + description: "Get W3ID response with logs", + response: { + 200: { + type: "object", + properties: { + w3id: { type: "string" }, + logs: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + versionId: { type: "string" }, + versionTime: { type: "string", format: "date-time" }, + updateKeys: { type: "array", items: { type: "string" } }, + nextKeyHashes: { type: "array", items: { type: "string" } }, + method: { type: "string" }, + proofs: { + type: "array", + items: { + type: "object", + properties: { + signature: { type: "string" }, + alg: { type: "string" }, + kid: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + async (request: TypedRequest<{}>, reply: TypedReply) => { + const w3id = await W3ID.get(); + const logs = (await w3id.logs?.repository.findMany({})) as LogEvent[]; + const result = { + w3id: w3id.id, + logs: logs, + }; + console.log(result); + return result; + } + ); + + // Watchers signature endpoint + server.post<{ Body: WatcherSignatureRequest }>( + "/watchers/sign", + { + schema: { + tags: ["watchers"], + description: "Post a signature for a specific log entry", + body: { + type: "object", + required: ["w3id", "logEntryId", "proof"], + properties: { + w3id: { type: "string" }, + logEntryId: { type: "string" }, + proof: { + type: "object", + required: ["signature", "alg", "kid"], + properties: { + signature: { type: "string" }, + alg: { type: "string" }, + kid: { type: "string" }, + }, + }, + }, + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + }, + }, + }, + }, + }, + async ( + request: TypedRequest, + reply: TypedReply + ) => { + const { w3id, logEntryId, proof } = request.body; + + try { + const currentW3ID = await W3ID.get(); + if (!currentW3ID.logs) { + throw new Error("W3ID must have logs enabled"); + } + + const logEvent = await currentW3ID.logs.repository.findOne({ + versionId: logEntryId, + }); + if (!logEvent) { + throw new Error(`Log event not found with id ${logEntryId}`); + } + + const isValid = await verifierCallback( + logEntryId, + [proof], + proof.kid.split("#")[0] + ); + if (!isValid) { + throw new Error("Invalid signature"); + } + + const updatedLogEvent: LogEvent = { + ...logEvent, + proofs: [...(logEvent.proofs || []), proof], + }; + + await currentW3ID.logs.repository.create(updatedLogEvent); + + return { + success: true, + message: "Signature stored successfully", + }; + } catch (error) { + console.error("Error storing signature:", error); + return { + success: false, + message: + error instanceof Error + ? error.message + : "Failed to store signature", + }; + } + } + ); + + // Watchers request endpoint + server.post<{ Body: WatcherRequest }>( + "/watchers/request", + { + schema: { + tags: ["watchers"], + description: "Request signature for a log entry", + body: { + type: "object", + required: ["w3id", "logEntryId"], + properties: { + w3id: { type: "string" }, + logEntryId: { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + requestId: { type: "string" }, + }, + }, + }, + }, + }, + async (request: TypedRequest, reply: TypedReply) => { + const { w3id, logEntryId } = request.body; + + try { + // Resolve the W3ID to get its request endpoint + const registryResponse = await axios.get( + `http://localhost:4321/resolve?w3id=${w3id}` + ); + const { requestWatcherSignature } = registryResponse.data; + + // Get the current W3ID instance + const currentW3ID = await W3ID.get(); + if (!currentW3ID.logs) { + throw new Error("W3ID must have logs enabled"); + } + + // Find the log event + const logEvent = await currentW3ID.logs.repository.findOne({ + versionId: logEntryId, + }); + if (!logEvent) { + throw new Error(`Log event not found with id ${logEntryId}`); + } + + // Request signature from the watcher + const response = await axios.post(requestWatcherSignature, { + w3id: currentW3ID.id, + logEntryId, + signature: await currentW3ID.signJWT({ + sub: logEntryId, + exp: Date.now() + 3600 * 1000, // 1 hour expiry + }), + }); + + return { + success: true, + message: "Signature request created", + requestId: response.data.requestId, + }; + } catch (error) { + console.error("Error requesting signature:", error); + return { + success: false, + message: + error instanceof Error + ? error.message + : "Failed to request signature", + requestId: "", + }; + } + } + ); +} diff --git a/infrastructure/evault-core/src/http/types.ts b/infrastructure/evault-core/src/http/types.ts new file mode 100644 index 00000000..cdc4e7c8 --- /dev/null +++ b/infrastructure/evault-core/src/http/types.ts @@ -0,0 +1,18 @@ +import { FastifyReply, FastifyRequest } from "fastify"; + +export interface WatcherSignatureRequest { + w3id: string; + signature: string; + logEntryId: string; +} + +export interface WatcherRequest { + w3id: string; + logEntryId: string; +} + +export type TypedRequest = FastifyRequest<{ + Body: T; +}>; + +export type TypedReply = FastifyReply; diff --git a/infrastructure/evault-core/src/secrets/secrets-store.ts b/infrastructure/evault-core/src/secrets/secrets-store.ts new file mode 100644 index 00000000..62ba8661 --- /dev/null +++ b/infrastructure/evault-core/src/secrets/secrets-store.ts @@ -0,0 +1,126 @@ +import { + createCipheriv, + createDecipheriv, + randomBytes, + pbkdf2Sync, +} from "crypto"; +import fs from "fs/promises"; +import path from "path"; +import { hexToUint8Array, uint8ArrayToHex } from "../utils/codec"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 16; +const SALT_LENGTH = 32; +const TAG_LENGTH = 16; +const ITERATIONS = 100000; +const KEY_LENGTH = 32; + +interface StoredSeed { + encrypted: string; + iv: string; + salt: string; + nextKeyHash: string; +} + +export class SecretsStore { + private storePath: string; + private password: string; + + constructor(storePath: string, password: string) { + this.storePath = storePath; + this.password = password; + } + + private deriveKey(salt: Buffer): Buffer { + return pbkdf2Sync(this.password, salt, ITERATIONS, KEY_LENGTH, "sha256"); + } + + private async ensureStoreExists(): Promise { + try { + await fs.access(this.storePath); + } catch { + await fs.mkdir(path.dirname(this.storePath), { recursive: true }); + await fs.writeFile(this.storePath, JSON.stringify({})); + } + } + + private async readStore(): Promise> { + await this.ensureStoreExists(); + const content = await fs.readFile(this.storePath, "utf-8"); + return JSON.parse(content); + } + + private async writeStore(store: Record): Promise { + await fs.writeFile(this.storePath, JSON.stringify(store, null, 2)); + } + + private encrypt(data: Buffer): { + encrypted: string; + iv: string; + salt: string; + } { + const iv = randomBytes(IV_LENGTH); + const salt = randomBytes(SALT_LENGTH); + const key = this.deriveKey(salt); + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([ + cipher.update(data), + cipher.final(), + cipher.getAuthTag(), + ]); + return { + encrypted: uint8ArrayToHex(encrypted), + iv: uint8ArrayToHex(iv), + salt: uint8ArrayToHex(salt), + }; + } + + private decrypt(encrypted: string, iv: string, salt: string): Buffer { + const key = this.deriveKey(Buffer.from(hexToUint8Array(salt))); + const decipher = createDecipheriv( + ALGORITHM, + key, + Buffer.from(hexToUint8Array(iv)) + ); + const encryptedBuffer = Buffer.from(hexToUint8Array(encrypted)); + const tag = encryptedBuffer.slice(-TAG_LENGTH); + const data = encryptedBuffer.slice(0, -TAG_LENGTH); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(data), decipher.final()]); + } + + public async storeSeed( + keyId: string, + seed: Uint8Array, + nextKeyHash: string + ): Promise { + const store = await this.readStore(); + const { encrypted, iv, salt } = this.encrypt(Buffer.from(seed)); + const storedSeed: StoredSeed = { encrypted, iv, salt, nextKeyHash }; + store[keyId] = JSON.stringify(storedSeed); + await this.writeStore(store); + } + + public async getSeed( + keyId: string + ): Promise<{ seed: Uint8Array; nextKeyHash: string }> { + const store = await this.readStore(); + const data: StoredSeed = JSON.parse(store[keyId]); + if (!data) throw new Error(`No seed found for key ${keyId}`); + return { + seed: this.decrypt(data.encrypted, data.iv, data.salt), + nextKeyHash: data.nextKeyHash, + }; + } + + public async deleteSeed(keyId: string): Promise { + const store = await this.readStore(); + delete store[keyId]; + await this.writeStore(store); + } + + public async listSeeds(): Promise { + const store = await this.readStore(); + return Object.keys(store); + } +} diff --git a/infrastructure/evault-core/src/utils/codec.ts b/infrastructure/evault-core/src/utils/codec.ts new file mode 100644 index 00000000..92fd8a91 --- /dev/null +++ b/infrastructure/evault-core/src/utils/codec.ts @@ -0,0 +1,86 @@ +export function uint8ArrayToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export function hexToUint8Array(hex: string): Uint8Array { + if (hex.length % 2 !== 0) { + throw new Error("Hex string must have an even length"); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16); + } + return bytes; +} + +export function stringToUint8Array(str: string): Uint8Array { + return new TextEncoder().encode(str); +} + +const BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +function base58btcEncode(buffer: Uint8Array): string { + let num = BigInt("0x" + Buffer.from(buffer).toString("hex")); + const base = BigInt(58); + let encoded = ""; + + while (num > 0) { + const remainder = num % base; + num = num / base; + encoded = BASE58_ALPHABET[Number(remainder)] + encoded; + } + + // Handle leading zero bytes + for (const byte of buffer) { + if (byte === 0) { + encoded = BASE58_ALPHABET[0] + encoded; + } else { + break; + } + } + + return encoded; +} + +function base58btcDecode(str: string): Uint8Array { + const base = BigInt(58); + let num = BigInt(0); + + for (const char of str) { + const index = BASE58_ALPHABET.indexOf(char); + if (index === -1) throw new Error(`Invalid Base58 character "${char}"`); + num = num * base + BigInt(index); + } + + let hex = num.toString(16); + if (hex.length % 2) hex = "0" + hex; + let decoded = Uint8Array.from(Buffer.from(hex, "hex")); + + // Handle leading Base58 zeroes ('1' = 0x00) + let leadingZeros = 0; + for (const c of str) { + if (c === BASE58_ALPHABET[0]) { + leadingZeros++; + } else { + break; + } + } + + const result = new Uint8Array(leadingZeros + decoded.length); + result.set(decoded, leadingZeros); + return result; +} + +export function base58btcMultibaseEncode(data: Uint8Array): string { + return "z" + base58btcEncode(data); // 'z' = multibase prefix for base58btc +} + +export function base58btcMultibaseDecode(multibaseStr: string): Uint8Array { + if (!multibaseStr.startsWith("z")) { + throw new Error("Only multibase Base58BTC ('z' prefix) is supported"); + } + return base58btcDecode(multibaseStr.slice(1)); +} diff --git a/infrastructure/evault-core/src/utils/signer.ts b/infrastructure/evault-core/src/utils/signer.ts new file mode 100644 index 00000000..f58d2e65 --- /dev/null +++ b/infrastructure/evault-core/src/utils/signer.ts @@ -0,0 +1,44 @@ +import { Proof, Signer, VerifierCallback } from "w3id"; +import { + base58btcMultibaseDecode, + base58btcMultibaseEncode, + hexToUint8Array, + stringToUint8Array, + uint8ArrayToHex, +} from "./codec"; +import nacl from "tweetnacl"; + +export const verifierCallback: VerifierCallback = async ( + message: string, + proofs: Proof[], + pubKey: string +) => { + let isValid = true; + for (const proof of proofs) { + const signatureBuffer = base58btcMultibaseDecode(proof.signature); + const messageBuffer = stringToUint8Array(message); + const publicKey = hexToUint8Array(pubKey); + const valid = nacl.sign.detached.verify( + messageBuffer, + signatureBuffer, + publicKey + ); + if (!valid) isValid = false; + } + + return isValid; +}; + +export function createSigner(keyPair: nacl.SignKeyPair): Signer { + const publicKey = uint8ArrayToHex(keyPair.publicKey); + const signer: Signer = { + alg: "ed25519", + pubKey: publicKey, + sign: (str: string) => { + const buffer = stringToUint8Array(str); + const signature = nacl.sign.detached(buffer, keyPair.secretKey); + return base58btcMultibaseEncode(signature); + }, + }; + return signer; +} diff --git a/infrastructure/evault-core/src/w3id/log-service.ts b/infrastructure/evault-core/src/w3id/log-service.ts new file mode 100644 index 00000000..a27ac10f --- /dev/null +++ b/infrastructure/evault-core/src/w3id/log-service.ts @@ -0,0 +1,42 @@ +import { Driver } from "neo4j-driver"; +import { Neo4jLogStorage } from "./log-storage"; +import { LogEvent, StorageSpec } from "w3id"; + +/** + * Service for managing log events in Neo4j + */ +export class LogService implements StorageSpec { + private logStorage: Neo4jLogStorage; + + constructor(driver: Driver) { + this.logStorage = new Neo4jLogStorage(driver); + } + + /** + * Get the log storage instance + */ + public getLogStorage(): Neo4jLogStorage { + return this.logStorage; + } + + /** + * Create a new log event + */ + public async create(body: LogEvent): Promise { + return this.logStorage.create(body); + } + + /** + * Find a log event by ID + */ + public async findOne(options: Partial): Promise { + return this.logStorage.findOne(options); + } + + /** + * Find log events by options + */ + public async findMany(options: Partial): Promise { + return this.logStorage.findMany(options); + } +} diff --git a/infrastructure/evault-core/src/w3id/log-storage.ts b/infrastructure/evault-core/src/w3id/log-storage.ts new file mode 100644 index 00000000..af852d7c --- /dev/null +++ b/infrastructure/evault-core/src/w3id/log-storage.ts @@ -0,0 +1,98 @@ +import { Driver } from "neo4j-driver"; +import { StorageSpec } from "w3id"; +import { LogEvent } from "w3id"; + +/** + * Neo4j storage adapter for logs implementing the StorageSpec interface + * + * Note: We store proofs as a JSON string because Neo4j has limitations on property types: + * - Neo4j only supports primitive types and arrays of primitives as property values + * - Complex objects like Maps or nested objects must be serialized + * - See: https://neo4j.com/docs/cypher-manual/current/values-and-types/ + * - See: https://neo4j.com/docs/cypher-manual/current/syntax/values/#cypher-values + */ +export class Neo4jLogStorage implements StorageSpec { + constructor(private driver: Driver) {} + + private mapToLogEvent(properties: any): LogEvent { + return { + id: properties.id, + versionId: properties.versionId, + versionTime: new Date(properties.versionTime), + updateKeys: properties.updateKeys, + nextKeyHashes: properties.nextKeyHashes, + method: properties.method, + proofs: properties.proofs ? JSON.parse(properties.proofs) : [], + }; + } + + public async create(body: LogEvent): Promise { + const session = this.driver.session(); + try { + const result = await session.run( + ` + CREATE (l:LogEvent { + id: $id, + versionId: $versionId, + versionTime: $versionTime, + updateKeys: $updateKeys, + nextKeyHashes: $nextKeyHashes, + method: $method, + proofs: $proofs + }) + RETURN l + `, + { + id: body.id, + versionId: body.versionId, + versionTime: body.versionTime.toISOString(), + updateKeys: body.updateKeys, + nextKeyHashes: body.nextKeyHashes, + method: body.method, + proofs: JSON.stringify(body.proofs || []), + } + ); + return this.mapToLogEvent(result.records[0].get("l").properties); + } finally { + await session.close(); + } + } + + public async findOne(options: Partial): Promise { + const session = this.driver.session(); + try { + const result = await session.run( + ` + MATCH (l:LogEvent) + WHERE l.id = $id + RETURN l + `, + { id: options.id } + ); + if (result.records.length === 0) { + throw new Error(`No log event found with id ${options.id}`); + } + return this.mapToLogEvent(result.records[0].get("l").properties); + } finally { + await session.close(); + } + } + + public async findMany(options: Partial): Promise { + const session = this.driver.session(); + try { + const result = await session.run( + ` + MATCH (l:LogEvent) + RETURN l + ` + ); + const mapped = result.records.map((record) => + this.mapToLogEvent(record.get("l").properties) + ); + return mapped; + } finally { + await session.close(); + } + } +} diff --git a/infrastructure/evault-core/src/w3id/w3id.ts b/infrastructure/evault-core/src/w3id/w3id.ts new file mode 100644 index 00000000..6ea8ea74 --- /dev/null +++ b/infrastructure/evault-core/src/w3id/w3id.ts @@ -0,0 +1,70 @@ +import { W3ID as W3IDClass, W3IDBuilder, hash } from "w3id"; +import { LogService } from "./log-service"; +import { Driver } from "neo4j-driver"; +import nacl from "tweetnacl"; +import { createSigner } from "../utils/signer"; +import { SecretsStore } from "../secrets/secrets-store"; +import { uint8ArrayToHex } from "../utils/codec"; + +export class W3ID { + private static instance: W3IDClass; + private static secretsStore: SecretsStore; + + private constructor() {} + + static async get(options?: { + id: string; + driver: Driver; + password?: string; + }) { + if (W3ID.instance) return W3ID.instance; + if (!options) + throw new Error( + "No instance of W3ID exists yet, please create it by passing options" + ); + + // Initialize secrets store if not already done + if (!W3ID.secretsStore) { + if (!options.password) { + throw new Error("Password is required for secrets store"); + } + W3ID.secretsStore = new SecretsStore( + process.env.SECRETS_STORE_PATH!, + options.password + ); + } + + const repository = new LogService(options.driver); + const keyId = `w3id-${options.id}`; + + try { + // Try to get existing seed + const { seed, nextKeyHash } = await W3ID.secretsStore.getSeed(keyId); + const keyPair = nacl.sign.keyPair.fromSeed(seed); + W3ID.instance = await new W3IDBuilder() + .withId(options.id) + .withRepository(repository) + .withGlobal(true) + .withSigner(createSigner(keyPair)) + .withNextKeyHash(nextKeyHash) + .build(); + } catch { + // If no seed exists, create new one + const keyPair = nacl.sign.keyPair(); + const nextKeyPair = nacl.sign.keyPair(); + const nextKeyHash = await hash(uint8ArrayToHex(nextKeyPair.publicKey)); + + // Store the seed + await W3ID.secretsStore.storeSeed(keyId, keyPair.secretKey, nextKeyHash); + + W3ID.instance = await new W3IDBuilder() + .withId(options.id) + .withRepository(repository) + .withSigner(createSigner(keyPair)) + .withNextKeyHash(nextKeyHash) + .build(); + } + + return W3ID.instance; + } +} diff --git a/infrastructure/evault-core/tests/log-storage.spec.ts b/infrastructure/evault-core/tests/log-storage.spec.ts new file mode 100644 index 00000000..91abaed9 --- /dev/null +++ b/infrastructure/evault-core/tests/log-storage.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import neo4j, { Driver } from "neo4j-driver"; +import { Neo4jContainer } from "@testcontainers/neo4j"; +import { Neo4jLogStorage } from "../src/w3id/log-storage"; +import { LogEvent } from "w3id"; + +describe("Neo4jLogStorage", () => { + let container; + let driver: Driver; + let storage: Neo4jLogStorage; + + beforeAll(async () => { + container = await new Neo4jContainer("neo4j:5.15").start(); + const uri = `bolt://localhost:${container.getMappedPort(7687)}`; + driver = neo4j.driver( + uri, + neo4j.auth.basic(container.getUsername(), container.getPassword()) + ); + storage = new Neo4jLogStorage(driver); + }); + + afterAll(async () => { + await driver.close(); + await container.stop(); + }); + + it("should create and retrieve a log event", async () => { + const logEvent: LogEvent = { + id: "test-id", + versionId: "0-test", + versionTime: new Date(), + updateKeys: ["key1", "key2"], + nextKeyHashes: ["hash1", "hash2"], + method: "w3id:v0.0.0", + }; + + const created = await storage.create(logEvent); + expect(created.id).toBe(logEvent.id); + expect(created.versionId).toBe(logEvent.versionId); + expect(created.updateKeys).toEqual(logEvent.updateKeys); + expect(created.nextKeyHashes).toEqual(logEvent.nextKeyHashes); + expect(created.method).toBe(logEvent.method); + + const retrieved = await storage.findOne({ id: logEvent.id }); + expect(retrieved.id).toBe(logEvent.id); + expect(retrieved.versionId).toBe(logEvent.versionId); + expect(retrieved.updateKeys).toEqual(logEvent.updateKeys); + expect(retrieved.nextKeyHashes).toEqual(logEvent.nextKeyHashes); + expect(retrieved.method).toBe(logEvent.method); + }); + + it("should find multiple log events", async () => { + const logEvent1: LogEvent = { + id: "test-id-1", + versionId: "0-test-1", + versionTime: new Date(), + updateKeys: ["key1"], + nextKeyHashes: ["hash1"], + method: "w3id:v0.0.0", + }; + + const logEvent2: LogEvent = { + id: "test-id-2", + versionId: "0-test-2", + versionTime: new Date(), + updateKeys: ["key2"], + nextKeyHashes: ["hash2"], + method: "w3id:v0.0.0", + }; + + await storage.create(logEvent1); + await storage.create(logEvent2); + + const events = await storage.findMany({ method: "w3id:v0.0.0" }); + expect(events.length).toBeGreaterThanOrEqual(2); + expect(events.some((e) => e.id === logEvent1.id)).toBe(true); + expect(events.some((e) => e.id === logEvent2.id)).toBe(true); + }); + + it("should throw error when log event not found", async () => { + await expect(storage.findOne({ id: "non-existent-id" })).rejects.toThrow( + "No log event found with id non-existent-id" + ); + }); +}); diff --git a/infrastructure/evault-core/tsconfig.json b/infrastructure/evault-core/tsconfig.json index e2a38c9f..826c6392 100644 --- a/infrastructure/evault-core/tsconfig.json +++ b/infrastructure/evault-core/tsconfig.json @@ -1,27 +1,18 @@ { - "compilerOptions": { - "target": "ES2021", - "module": "CommonJS", - "lib": [ - "ESNext", - "DOM" - ], - "declaration": true, - "declarationDir": "./dist/types", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "moduleResolution": "Node", - "skipLibCheck": true - }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "dist", - "*/**/*.spec.ts" - ] -} + "compilerOptions": { + "target": "ES2017", + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "declaration": true, + "declarationDir": "./dist/types", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "Node", + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/infrastructure/evault-provisioner/package.json b/infrastructure/evault-provisioner/package.json index 0414cdf0..d0f86ee0 100644 --- a/infrastructure/evault-provisioner/package.json +++ b/infrastructure/evault-provisioner/package.json @@ -11,9 +11,10 @@ "test": "vitest" }, "dependencies": { - "express": "^4.18.2", "axios": "^1.6.7", "dotenv": "^16.4.5", + "express": "^4.18.2", + "jose": "^5.2.2", "w3id": "workspace:*" }, "devDependencies": { diff --git a/infrastructure/evault-provisioner/src/index.ts b/infrastructure/evault-provisioner/src/index.ts index 95512847..dd8a08fd 100644 --- a/infrastructure/evault-provisioner/src/index.ts +++ b/infrastructure/evault-provisioner/src/index.ts @@ -4,6 +4,7 @@ import { generateNomadJob } from "./templates/evault.nomad.js"; import dotenv from "dotenv"; import { subscribeToAlloc } from "./listeners/alloc.js"; import { W3IDBuilder } from "w3id"; +import * as jose from "jose"; dotenv.config(); @@ -13,7 +14,8 @@ const port = process.env.PORT || 3000; app.use(express.json()); interface ProvisionRequest { - w3id: string; + registryEntropy: string; + namespace: string; } interface ProvisionResponse { @@ -38,17 +40,32 @@ app.post( try { // TODO: change this to take namespace from the payload, and signed entropy // JWT so that we can verify both parts of the UUID come from know source - const { w3id } = req.body; + const { registryEntropy, namespace } = req.body; - if (!w3id) { + if (!registryEntropy || !namespace) { return res.status(400).json({ success: false, - error: "tenantId is required", - message: "Missing required field: tenantId", + error: "registryEntropy and namespace are required", + message: + "Missing required fields: registryEntropy, namespace", }); } + const jwksResponse = await axios.get( + `http://localhost:4321/.well-known/jwks.json`, + ); + + const JWKS = jose.createLocalJWKSet(jwksResponse.data); + + const { payload } = await jose.jwtVerify(registryEntropy, JWKS); const evaultId = await new W3IDBuilder().withGlobal(true).build(); + const userId = await new W3IDBuilder() + .withNamespace(namespace) + .withEntropy(payload.entropy as string) + .withGlobal(true) + .build(); + + const w3id = userId.id; const jobJSON = generateNomadJob(w3id, evaultId.id); const jobName = `evault-${w3id}`; diff --git a/infrastructure/evault-provisioner/src/templates/evault.nomad.ts b/infrastructure/evault-provisioner/src/templates/evault.nomad.ts index e36e03e3..de13f1fc 100644 --- a/infrastructure/evault-provisioner/src/templates/evault.nomad.ts +++ b/infrastructure/evault-provisioner/src/templates/evault.nomad.ts @@ -37,6 +37,24 @@ export function generateNomadJob(w3id: string, eVaultId: string) { ], }, ], + Volumes: { + "evault-store": { + Type: "csi", + Source: `evault-store-${w3id}`, + ReadOnly: false, + AccessMode: "single-node-writer", + AttachmentMode: "file-system", + Sticky: true, + }, + "neo4j-data": { + Type: "csi", + Source: `neo4j-data-${w3id}`, + ReadOnly: false, + AccessMode: "single-node-writer", + AttachmentMode: "file-system", + Sticky: true, + }, + }, Services: [ { Name: `evault`, @@ -55,6 +73,13 @@ export function generateNomadJob(w3id: string, eVaultId: string) { Config: { image: "neo4j:5.15", ports: [], + volume_mounts: [ + { + Volume: "neo4j-data", + Destination: "/data", + ReadOnly: false, + }, + ], }, Env: { NEO4J_AUTH: `${neo4jUser}/${neo4jPassword}`, @@ -72,18 +97,31 @@ export function generateNomadJob(w3id: string, eVaultId: string) { Config: { image: "merulauvo/evault:latest", ports: ["http"], + volume_mounts: [ + { + Volume: "evault-store", + Destination: "/evault/data", + ReadOnly: false, + }, + ], }, Env: { NEO4J_URI: "bolt://localhost:7687", NEO4J_USER: neo4jUser, NEO4J_PASSWORD: neo4jPassword, PORT: "${NOMAD_PORT_http}", + W3ID: w3id, }, Resources: { CPU: 300, MemoryMB: 512, }, - DependsOn: ["neo4j"], + DependsOn: [ + { + Name: "neo4j", + Condition: "running", + }, + ], }, ], }, diff --git a/infrastructure/w3id/README.md b/infrastructure/w3id/README.md index 723c219c..198d816e 100644 --- a/infrastructure/w3id/README.md +++ b/infrastructure/w3id/README.md @@ -1,8 +1,124 @@ -# W3ID +# W3ID - Web 3 Identity System -The metastate ecosystem has the only 1 type of identifiers, W3ID (did:w3id protocol, defined below) for all types of entities, e.g.. +W3ID is a robust identity system designed for the MetaState ecosystem, providing persistent, globally unique identifiers for various entities. This system ensures secure identity management with support for key rotation, friend-based recovery, and seamless eVault migration. -![MetaState W3ID Relations](../../images/w3id-relations.png) +## Key Features + +### 1. Universal Identity Management + +- **Single Identifier System**: W3ID serves as the primary identifier for all entities in the MetaState ecosystem +- **Global and Local IDs**: Supports both global identifiers (starting with '@') and local identifiers +- **UUID-based**: Utilizes UUID v4/v5 for guaranteed uniqueness and persistence + +### 2. Security and Recovery + +- **Key Rotation Support**: Enables secure key rotation without changing the identity +- **Friend-Based Recovery**: Allows trusted friends to verify identity for recovery +- **Notary Integration**: Supports notary-based verification and recovery processes + +### 3. eVault Integration + +- **Persistent Storage**: Maintains identity across eVault migrations +- **Also-Known-As Records**: Tracks eVault migrations for seamless access +- **Device Management**: Supports device-specific identifiers + +### 4. Advanced Logging System + +- **Immutable Event Logging**: Maintains a tamper-proof record of all identity-related events +- **Key Rotation Tracking**: Logs all key changes and rotations +- **JWT Support**: Built-in JWT signing and verification capabilities +- **Storage Agnostic**: Supports various storage backends through the StorageSpec interface + +## Technical Implementation + +### W3ID Format + +The W3ID follows a simple yet powerful format: + +- Global IDs: `@` (e.g., `@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a`) +- Local IDs: `@/` (e.g., `@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a/f2a6743e-8d5b-43bc-a9f0-1c7a3b9e90d7`) + +### Logging Mechanism + +The W3ID system implements a sophisticated logging mechanism through the `IDLogManager` class: + +1. **Event Logging** + + - All identity-related events are logged in an immutable format + - Each log entry is cryptographically signed + - Supports key rotation events and identity updates + +2. **Storage Integration** + + - Flexible storage backend support + - Implements the `StorageSpec` interface for custom storage solutions + - Ensures data persistence and availability + +3. **Security Features** + - JWT signing and verification + - Key rotation tracking + - Next key hash verification + +## Usage Examples + +### Creating a W3ID + +```typescript +const w3id = await new W3IDBuilder() + .withGlobal(true) + .withRepository(storage) + .withSigner(signer) + .withNextKeyHash(nextKeyHash) + .build(); +``` + +### Signing a JWT + +```typescript +const jwt = await w3id.signJWT({ + sub: "user123", + exp: Date.now() + 3600, +}); +``` + +## Technical Requirements + +- Globally persistent and unique identifiers +- Namespace range > 10^22 +- Support for key rotation +- Loose binding to physical documents +- UUID v4/v5 compliance + +## Additional Features + +### Friend-Based Recovery + +- Trust list management +- Notary integration +- Multi-party verification + +### eVault Migration + +- Also-known-as record tracking +- Seamless data access +- Migration history maintenance + +## Implementation Details + +The W3ID system is implemented in TypeScript and provides: + +- Strong typing for all operations +- Builder pattern for W3ID creation +- Flexible storage integration +- Comprehensive logging system + +## Security Considerations + +- Keys are loosely bound to identifiers +- Support for key rotation +- Cryptographic signing of all operations +- Immutable event logging +- Friend-based recovery mechanisms ## Where is it used @@ -39,12 +155,12 @@ UUID range is 2^122 or 15 orders larger than expected amount of IDs (10^22) ther ### Example: `@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a` -If a local ID is needed, it is added after “/”, also as UUID range e.g.: +If a local ID is needed, it is added after "/", also as UUID range e.g.: `@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a/f2a6743e-8d5b-43bc-a9f0-1c7a3b9e90d7` -which means “the object `f2a6743e-8d5b-43bc-a9f0-1c7a3b9e90d7` at the eVault `e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a`, +which means "the object `f2a6743e-8d5b-43bc-a9f0-1c7a3b9e90d7` at the eVault `e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a`, where `e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a` could be: either the exact URL of the eVault, or -the URL of its “controller”, the owner, then such URL should be resolved to the current eVault which this person (or group) controls +the URL of its "controller", the owner, then such URL should be resolved to the current eVault which this person (or group) controls ## W3ID Key binding @@ -54,13 +170,13 @@ The Identifier would be loosely bound to a set of keys, meaning an identifier is #### Friend Based Recovery -2-3 Friends may verify the identity of a person to recover lost metastate ID on the eVault. This would be based on a trust list which a person can create while they do still have access to their keys. This trust list by default would have a list of all notaries in the ecosystem but the user may modify it and add people who always need to approve the action of changing keys at their end as well. +2-3 Friends may verify the identity of a person to recover lost metastate ID on the eVault. This would be based on a trust list which a person can create while they do still have access to their keys. This trust list by default would have a list of all notaries in the ecosystem but the user may modify it and add people who always need to approve the action of changing keys at their end as well. So for example a user "Jack" says I trust the notary but I also want "Bob" to approve the action each time a notary tries to change the keys which are controlled by Jack. #### Migration of eVault -In the implementation it must be ensured that the file is still accessible regardless of the eVault it is stored in. This can be done via recording also-known-as records in the register for an eVault each time someone migrates their eVault. For example if a user migrates evault `e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a` to `f2a6743e-8d5b-43bc-a9f0-1c7a3b9e90d7`, the register will store an entry pointing all requests to `e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a` to be redirected to `f2a6743e-8d5b-43bc-a9f0-1c7a3b9e90d7` +In the implementation it must be ensured that the file is still accessible regardless of the eVault it is stored in. This can be done via recording also-known-as records in the register for an eVault each time someone migrates their eVault. For example if a user migrates evault `e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a` to `f2a6743e-8d5b-43bc-a9f0-1c7a3b9e90d7`, the register will store an entry pointing all requests to `e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a` to be redirected to `f2a6743e-8d5b-43bc-a9f0-1c7a3b9e90d7` ## W3ID Document Binding diff --git a/infrastructure/w3id/src/index.ts b/infrastructure/w3id/src/index.ts index 050b81ea..2c4850aa 100644 --- a/infrastructure/w3id/src/index.ts +++ b/infrastructure/w3id/src/index.ts @@ -1,140 +1,6 @@ -import { v4 as uuidv4 } from "uuid"; -import { IDLogManager } from "./logs/log-manager"; -import type { JWTHeader, JWTPayload, LogEvent, Signer } from "./logs/log.types"; -import type { StorageSpec } from "./logs/storage/storage-spec"; -import { signJWT } from "./utils/jwt"; -import { generateRandomAlphaNum } from "./utils/rand"; -import { generateUuid } from "./utils/uuid"; - -export class W3ID { - constructor( - public id: string, - public logs?: IDLogManager, - ) {} - - /** - * Signs a JWT with the W3ID's signer - * @param payload - The JWT payload - * @param header - Optional JWT header (defaults to using the signer's alg and W3ID's id as kid) - * @returns The signed JWT - */ - public async signJWT( - payload: JWTPayload, - header?: JWTHeader, - ): Promise { - if (!this.logs?.signer) { - throw new Error("W3ID must have a signer to sign JWTs"); - } - return signJWT(this.logs.signer, payload, `@${this.id}#0`, header); - } -} - -export class W3IDBuilder { - private signer?: Signer; - private repository?: StorageSpec; - private entropy?: string; - private namespace?: string; - private nextKeyHash?: string; - private global?: boolean = false; - - /** - * Specify entropy to create the identity with - * - * @param {string} str - */ - public withEntropy(str: string): W3IDBuilder { - this.entropy = str; - return this; - } - - /** - * Specify namespace to use to generate the UUIDv5 - * - * @param {string} uuid - */ - public withNamespace(uuid: string): W3IDBuilder { - this.namespace = uuid; - return this; - } - - /** - * Specify whether to create a global identifier or a local identifer - * - * According to the project specification there are supposed to be 2 main types of - * W3ID's ones which are tied to more permanent entities - * - * A global identifer is expected to live at the registry and starts with an \`@\` - * - * @param {boolean} isGlobal - */ - public withGlobal(isGlobal: boolean): W3IDBuilder { - this.global = isGlobal; - return this; - } - - /** - * Add a logs repository to the W3ID, a rotateble key attached W3ID would need a - * repository in which the logs would be stored - * - * @param {StorageSpec} storage - */ - public withRepository(storage: StorageSpec): W3IDBuilder { - this.repository = storage; - return this; - } - - /** - * Attach a keypair to the W3ID, a key attached W3ID would also need a repository - * to be added. - * - * @param {Signer} signer - */ - public withSigner(signer: Signer): W3IDBuilder { - this.signer = signer; - return this; - } - - /** - * Specify the SHA256 hash of the next key which will sign the next log entry after - * rotation of keys - * - * @param {string} hash - */ - public withNextKeyHash(hash: string): W3IDBuilder { - this.nextKeyHash = hash; - return this; - } - - /** - * Build the W3ID with provided builder options - * - * @returns Promise - */ - public async build(): Promise { - this.entropy = this.entropy ?? generateRandomAlphaNum(); - this.namespace = this.namespace ?? uuidv4(); - const id = `${ - this.global ? "@" : "" - }${generateUuid(this.entropy, this.namespace)}`; - if (!this.signer) { - return new W3ID(id); - } - if (!this.repository) - throw new Error( - "Repository is required, pass with `withRepository` method", - ); - - if (!this.nextKeyHash) - throw new Error( - "NextKeyHash is required pass with `withNextKeyHash` method", - ); - const logs = new IDLogManager(this.repository, this.signer); - await logs.createLogEvent({ - id, - nextKeyHashes: [this.nextKeyHash], - }); - return new W3ID(id, logs); - } -} - +export * from "./w3id"; export * from "./utils/jwt"; +export * from "./logs/storage/storage-spec"; +export * from "./logs/log.types"; +export * from "./logs/log-manager"; +export * from "./utils/hash"; diff --git a/infrastructure/w3id/src/logs/log-manager.ts b/infrastructure/w3id/src/logs/log-manager.ts index 1c5c2b75..d7cf2f6b 100644 --- a/infrastructure/w3id/src/logs/log-manager.ts +++ b/infrastructure/w3id/src/logs/log-manager.ts @@ -95,17 +95,18 @@ export class IDLogManager { currentUpdateKeys: string[], verifyCallback: VerifierCallback, ): Promise { - const proof = e.proof; + const proofs = e.proofs; const copy = JSON.parse(JSON.stringify(e)); // biome-ignore lint/performance/noDelete: we need to delete proof completely - delete copy.proof; + delete copy.proofs; const canonicalJson = canonicalize(copy); let verified = false; - if (!proof) throw new BadSignatureError("No proof found in the log event."); + if (!proofs) + throw new BadSignatureError("No proof found in the log event."); for (const key of currentUpdateKeys) { const signValidates = await verifyCallback( canonicalJson as string, - proof, + proofs, key, ); if (signValidates) verified = true; @@ -139,8 +140,14 @@ export class IDLogManager { method: "w3id:v0.0.0", }; - const proof = await this.signer.sign(canonicalize(logEvent) as string); - logEvent.proof = proof; + const signature = await this.signer.sign(canonicalize(logEvent) as string); + logEvent.proofs = [ + { + kid: `${logEvent.id}#0`, + alg: this.signer.alg, + signature, + }, + ]; await this.repository.create(logEvent); this.signer = nextKeySigner; @@ -164,8 +171,15 @@ export class IDLogManager { nextKeyHashes: nextKeyHashes, method: "w3id:v0.0.0", }; - const proof = await this.signer.sign(canonicalize(logEvent) as string); - logEvent.proof = proof; + const signature = await this.signer.sign(canonicalize(logEvent) as string); + logEvent.proofs = [ + { + kid: `${id}#0`, + alg: this.signer.alg, + signature, + }, + ]; + await this.repository.create(logEvent); return logEvent; } diff --git a/infrastructure/w3id/src/logs/log.types.ts b/infrastructure/w3id/src/logs/log.types.ts index 3f5b871f..0545ed62 100644 --- a/infrastructure/w3id/src/logs/log.types.ts +++ b/infrastructure/w3id/src/logs/log.types.ts @@ -1,3 +1,9 @@ +export type Proof = { + kid: string; + signature: string; + alg: string; +}; + export type LogEvent = { id: string; versionId: string; @@ -5,12 +11,12 @@ export type LogEvent = { updateKeys: string[]; nextKeyHashes: string[]; method: `w3id:v${string}`; - proof?: string; + proofs?: Proof[]; }; export type VerifierCallback = ( message: string, - signature: string, + proofs: Proof[], pubKey: string, ) => Promise; diff --git a/infrastructure/w3id/src/w3id.ts b/infrastructure/w3id/src/w3id.ts new file mode 100644 index 00000000..b2acb846 --- /dev/null +++ b/infrastructure/w3id/src/w3id.ts @@ -0,0 +1,156 @@ +import { v4 as uuidv4 } from "uuid"; +import { IDLogManager } from "./logs/log-manager"; +import type { JWTHeader, JWTPayload, LogEvent, Signer } from "./logs/log.types"; +import type { StorageSpec } from "./logs/storage/storage-spec"; +import { signJWT } from "./utils/jwt"; +import { generateRandomAlphaNum } from "./utils/rand"; +import { generateUuid } from "./utils/uuid"; + +export class W3ID { + constructor( + public id: string, + public logs?: IDLogManager, + ) {} + + /** + * Signs a JWT with the W3ID's signer + * @param payload - The JWT payload + * @param header - Optional JWT header (defaults to using the signer's alg and W3ID's id as kid) + * @returns The signed JWT + */ + public async signJWT( + payload: JWTPayload, + header?: JWTHeader, + ): Promise { + if (!this.logs?.signer) { + throw new Error("W3ID must have a signer to sign JWTs"); + } + return signJWT(this.logs.signer, payload, `@${this.id}#0`, header); + } +} + +export class W3IDBuilder { + private signer?: Signer; + private repository?: StorageSpec; + private entropy?: string; + private namespace?: string; + private nextKeyHash?: string; + private global?: boolean = false; + private id?: string; + + /** + * Specify entropy to create the identity with + * + * @param {string} str + */ + public withEntropy(str: string): W3IDBuilder { + this.entropy = str; + return this; + } + + /** + * Specify namespace to use to generate the UUIDv5 + * + * @param {string} uuid + */ + public withNamespace(uuid: string): W3IDBuilder { + this.namespace = uuid; + return this; + } + + /** + * Specify whether to create a global identifier or a local identifer + * + * According to the project specification there are supposed to be 2 main types of + * W3ID's ones which are tied to more permanent entities + * + * A global identifer is expected to live at the registry and starts with an \`@\` + * + * @param {boolean} isGlobal + */ + public withGlobal(isGlobal: boolean): W3IDBuilder { + this.global = isGlobal; + return this; + } + + /** + * Add a logs repository to the W3ID, a rotateble key attached W3ID would need a + * repository in which the logs would be stored + * + * @param {StorageSpec} storage + */ + public withRepository(storage: StorageSpec): W3IDBuilder { + this.repository = storage; + return this; + } + + /** + * Pre-specify a UUID to use as the W3ID + * + * @param {string} id + */ + public withId(id: string): W3IDBuilder { + this.id = id; + return this; + } + + /** + * Attach a keypair to the W3ID, a key attached W3ID would also need a repository + * to be added. + * + * @param {Signer} signer + */ + public withSigner(signer: Signer): W3IDBuilder { + this.signer = signer; + return this; + } + + /** + * Specify the SHA256 hash of the next key which will sign the next log entry after + * rotation of keys + * + * @param {string} hash + */ + public withNextKeyHash(hash: string): W3IDBuilder { + this.nextKeyHash = hash; + return this; + } + + /** + * Build the W3ID with provided builder options + * + * @returns Promise + */ + public async build(): Promise { + if ((this.id && this.namespace) || (this.id && this.entropy)) + throw new Error( + "Namespace and Entropy can't be specified when using pre-defined ID", + ); + this.entropy = this.entropy ?? generateRandomAlphaNum(); + this.namespace = this.namespace ?? uuidv4(); + this.id = this.id?.includes("@") ? this.id.split("@")[1] : this.id; + const id = `${ + this.global ? "@" : "" + }${this.id?.toString() ?? generateUuid(this.entropy, this.namespace)}`; + if (!this.signer) { + return new W3ID(id); + } + if (!this.repository) + throw new Error( + "Repository is required, pass with `withRepository` method", + ); + if (!this.nextKeyHash) + throw new Error( + "NextKeyHash is required pass with `withNextKeyHash` method", + ); + const logs = new IDLogManager(this.repository, this.signer); + + const currentLogs = await logs.repository.findMany({}); + if (currentLogs.length > 0) return new W3ID(id, logs); + await logs.createLogEvent({ + id, + nextKeyHashes: [this.nextKeyHash], + }); + return new W3ID(id, logs); + } +} diff --git a/infrastructure/w3id/tests/utils/crypto.ts b/infrastructure/w3id/tests/utils/crypto.ts index bd4f249b..9a84413a 100644 --- a/infrastructure/w3id/tests/utils/crypto.ts +++ b/infrastructure/w3id/tests/utils/crypto.ts @@ -1,5 +1,5 @@ import { base58btc } from "multiformats/bases/base58"; -import { Signer, VerifierCallback } from "../../src/logs/log.types"; +import { Proof, Signer, VerifierCallback } from "../../src/logs/log.types"; import { hexToUint8Array, stringToUint8Array, @@ -9,17 +9,21 @@ import nacl from "tweetnacl"; export const verifierCallback: VerifierCallback = async ( message: string, - signature: string, + proofs: Proof[], pubKey: string, ) => { - const signatureBuffer = base58btc.decode(signature); - const messageBuffer = stringToUint8Array(message); - const publicKey = hexToUint8Array(pubKey); - const isValid = nacl.sign.detached.verify( - messageBuffer, - signatureBuffer, - publicKey, - ); + let isValid = true; + for (const proof of proofs) { + const signatureBuffer = base58btc.decode(proof.signature); + const messageBuffer = stringToUint8Array(message); + const publicKey = hexToUint8Array(pubKey); + const valid = nacl.sign.detached.verify( + messageBuffer, + signatureBuffer, + publicKey, + ); + if (!valid) isValid = false; + } return isValid; }; diff --git a/platforms/registry/src/consul.ts b/platforms/registry/src/consul.ts index cbf6d808..c35d125d 100644 --- a/platforms/registry/src/consul.ts +++ b/platforms/registry/src/consul.ts @@ -19,10 +19,9 @@ export async function resolveService(w3id: string) { const address = `http://${services[0].ServiceAddress}:${services[0].ServicePort}`; return { graphql: `${address}/graphql`, - voyager: `${address}/voyager`, whois: `${address}/whois`, - logs: `${address}/logs`, - requestWatcherSignature: `${address}/request-signature`, + requestWatcherSignature: `${address}/watchers/request`, + wathcerSignEndpoint: `${address}/watchers/request`, }; } return null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 096c52c7..4150a362 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,9 +144,21 @@ importers: infrastructure/evault-core: dependencies: + '@fastify/swagger': + specifier: ^8.14.0 + version: 8.15.0 + '@fastify/swagger-ui': + specifier: ^3.0.0 + version: 3.1.0 '@testcontainers/neo4j': specifier: ^10.24.2 version: 10.24.2 + axios: + specifier: ^1.6.7 + version: 1.8.4 + fastify: + specifier: ^4.26.2 + version: 4.29.0 graphql: specifier: ^16.10.0 version: 16.10.0 @@ -162,9 +174,15 @@ importers: json-schema: specifier: ^0.4.0 version: 0.4.0 + multiformats: + specifier: ^13.3.2 + version: 13.3.2 neo4j-driver: specifier: ^5.28.1 version: 5.28.1 + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 w3id: specifier: workspace:* version: link:../w3id @@ -205,6 +223,9 @@ importers: express: specifier: ^4.18.2 version: 4.21.2 + jose: + specifier: ^5.2.2 + version: 5.10.0 w3id: specifier: workspace:* version: link:../w3id @@ -984,6 +1005,10 @@ packages: resolution: {integrity: sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/accept-negotiator@1.1.0': + resolution: {integrity: sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==} + engines: {node: '>=14'} + '@fastify/ajv-compiler@3.6.0': resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==} @@ -1006,6 +1031,18 @@ packages: '@fastify/merge-json-schemas@0.1.1': resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==} + '@fastify/send@2.1.0': + resolution: {integrity: sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==} + + '@fastify/static@7.0.4': + resolution: {integrity: sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==} + + '@fastify/swagger-ui@3.1.0': + resolution: {integrity: sha512-68jm6k8VzvHXkEBT4Dakm/kkzUlPO4POIi0agWJSWxsYichPBqzjo+IpfqPl4pSJR1zCToQhEOo+cv+yJL2qew==} + + '@fastify/swagger@8.15.0': + resolution: {integrity: sha512-zy+HEEKFqPMS2sFUsQU5X0MHplhKJvWeohBwTCkBAJA/GDYGLGUWQaETEhptiqxK7Hs0fQB9B4MDb3pbwIiCwA==} + '@floating-ui/core@1.6.9': resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==} @@ -4023,6 +4060,10 @@ packages: json-schema-ref-resolver@1.0.1: resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==} + json-schema-resolver@2.0.0: + resolution: {integrity: sha512-pJ4XLQP4Q9HTxl6RVDLJ8Cyh1uitSs0CzDBAz1uoJ4sRD/Bk7cFSXL1FUXDW3zJ7YnfliJx6eu8Jn283bpZ4Yg==} + engines: {node: '>=10'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -4263,6 +4304,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -4461,6 +4507,9 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -6437,6 +6486,8 @@ snapshots: '@eslint/core': 0.12.0 levn: 0.4.1 + '@fastify/accept-negotiator@1.1.0': {} + '@fastify/ajv-compiler@3.6.0': dependencies: ajv: 8.17.1 @@ -6465,6 +6516,41 @@ snapshots: dependencies: fast-deep-equal: 3.1.3 + '@fastify/send@2.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.0 + mime: 3.0.0 + + '@fastify/static@7.0.4': + dependencies: + '@fastify/accept-negotiator': 1.1.0 + '@fastify/send': 2.1.0 + content-disposition: 0.5.4 + fastify-plugin: 4.5.1 + fastq: 1.19.1 + glob: 10.4.5 + + '@fastify/swagger-ui@3.1.0': + dependencies: + '@fastify/static': 7.0.4 + fastify-plugin: 4.5.1 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.7.1 + + '@fastify/swagger@8.15.0': + dependencies: + fastify-plugin: 4.5.1 + json-schema-resolver: 2.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.7.1 + transitivePeerDependencies: + - supports-color + '@floating-ui/core@1.6.9': dependencies: '@floating-ui/utils': 0.2.9 @@ -10319,6 +10405,14 @@ snapshots: dependencies: fast-deep-equal: 3.1.3 + json-schema-resolver@2.0.0: + dependencies: + debug: 4.4.0(supports-color@5.5.0) + rfdc: 1.4.1 + uri-js: 4.4.1 + transitivePeerDependencies: + - supports-color + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -10518,6 +10612,8 @@ snapshots: mime@1.6.0: {} + mime@3.0.0: {} + mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} @@ -10775,6 +10871,8 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openapi-types@12.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4