diff --git a/infrastructure/evault-core/README.md b/infrastructure/evault-core/README.md index 6546befbb..18024ed04 100644 --- a/infrastructure/evault-core/README.md +++ b/infrastructure/evault-core/README.md @@ -59,6 +59,11 @@ sudo nomad agent -dev -network-interface=eth0 -log-level=DEBUG -bind=0.0.0.0 ## Project Setup +Managed PlatformProfile enforcement requires `PUBLIC_REGISTRY_URL` (or `REGISTRY_URL`) and the same +`REGISTRY_SHARED_SECRET` configured on Registry. eVault asks Registry to authorize writes only for the +PlatformProfile ontology. Once an eName is managed, Registry outages fail those profile writes closed; +other ontologies keep their existing behavior. + 1. Install dependencies: ```bash diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts index ca3dd2482..fdc7a5997 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts @@ -53,6 +53,56 @@ describe("VaultAccessGuard", () => { keys: [{ ...testJWK, d: undefined }], // Public key only }, }); + mockedAxios.post.mockResolvedValue({ data: { managed: false, allowed: true } }); + process.env.REGISTRY_SHARED_SECRET = "registry-secret"; + }); + + describe("managed PlatformProfile writes", () => { + const profileInput = { + ontology: "550e8400-e29b-41d4-a716-446655440000", + payload: { platformName: "example" }, + acl: ["*"], + }; + + it("rejects a revoked legacy token before the resolver runs", async () => { + mockedAxios.post.mockResolvedValue({ + data: { managed: true, allowed: false, reason: "The legacy platform token was revoked during migration" }, + }); + const resolver = vi.fn(async () => ({ id: "profile" })); + const wrapped = guard.middleware(resolver); + const context = createMockContext({ + eName: "@platform", + request: { headers: new Headers({ authorization: "Bearer legacy-token" }) } as any, + }); + + await expect(wrapped(null, { id: "profile-1", input: profileInput }, context)).rejects.toThrow("revoked during migration"); + expect(resolver).not.toHaveBeenCalled(); + }); + + it("allows the active manager token at the original envelope", async () => { + mockedAxios.post.mockResolvedValue({ data: { managed: true, allowed: true } }); + const resolver = vi.fn(async () => ({ id: "profile" })); + const wrapped = guard.middleware(resolver); + const managerToken = await createValidToken({ + platform: "manager-a", + kind: "platform-manager", + managedEname: "@platform", + manager: "manager-a", + }); + const context = createMockContext({ + eName: "@platform", + request: { headers: new Headers({ authorization: `Bearer ${managerToken}` }) } as any, + }); + mockedAxios.get.mockResolvedValue({ data: { keys: [{ ...testJWK, d: undefined }] } }); + + await wrapped(null, { id: "profile-1", input: profileInput }, context); + expect(mockedAxios.post).toHaveBeenCalledWith( + "http://localhost:4322/platforms/management/authorize-profile-write", + expect.objectContaining({ ename: "@platform", envelopeId: "profile-1", token: managerToken }), + expect.anything(), + ); + expect(resolver).toHaveBeenCalledOnce(); + }); }); const createMockContext = (overrides: Partial = {}): VaultContext => { @@ -887,4 +937,3 @@ describe("VaultAccessGuard", () => { }); }); }); - diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts index 203a80a68..084b85ba0 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts @@ -18,10 +18,69 @@ type CachedJWKS = { const jwksCache = new Map(); const JWKS_TTL_MS = 24 * 60 * 60 * 1000; const JWKS_FETCH_TIMEOUT_MS = 5000; +const PLATFORM_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; export class VaultAccessGuard { constructor(private db: DbService) {} + private bearerToken(context: VaultContext): string | undefined { + const authHeader = + context.request?.headers?.get("authorization") ?? + context.request?.headers?.get("Authorization"); + return authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined; + } + + /** + * Migrated PlatformProfiles have one Registry-recorded manager. This check + * is intentionally scoped to that ontology so PPA decisions and every + * unrelated eVault document keep their existing authorization behavior. + */ + private async validateManagedProfileWrite( + context: VaultContext, + input: { ontology?: unknown } | undefined, + envelopeId?: string, + ): Promise { + if (input?.ontology !== PLATFORM_PROFILE_ONTOLOGY) return; + if (!context.eName) throw new Error("X-ENAME header is required for a platform profile write"); + const registryUrl = process.env.PUBLIC_REGISTRY_URL || process.env.REGISTRY_URL; + const sharedSecret = process.env.REGISTRY_SHARED_SECRET; + if (!registryUrl || !sharedSecret) { + throw new Error("Managed platform profile authorization is unavailable"); + } + try { + const response = await axios.post( + new URL("/platforms/management/authorize-profile-write", registryUrl).toString(), + { + ename: context.eName, + ontology: input.ontology, + ...(envelopeId && { envelopeId }), + ...(this.bearerToken(context) && { token: this.bearerToken(context) }), + }, + { + timeout: JWKS_FETCH_TIMEOUT_MS, + headers: { Authorization: `Bearer ${sharedSecret}` }, + }, + ); + if (response.data?.managed && !response.data?.allowed) { + throw new Error(response.data?.reason || "The platform profile is managed by another publisher"); + } + } catch (error) { + if (error instanceof Error && ( + error.message === "The platform profile is managed by another publisher" || + error.message === "The legacy platform token was revoked during migration" || + error.message === "The token is not the active platform manager" || + error.message === "A platform manager token is required" || + error.message === "The managed platform profile has a different envelope ID" + )) { + throw error; + } + const reason = axios.isAxiosError(error) && typeof error.response?.data?.error === "string" + ? error.response.data.error + : "Registry management verification failed"; + throw new Error(reason); + } + } + /** * Validates JWT token from Authorization header * @param authHeader - The Authorization header value @@ -256,6 +315,10 @@ export class VaultAccessGuard { "acl" in args.input && !args.id; // storeMetaEnvelope doesn't have id, updateMetaEnvelopeById does + await timed("guard.validateManagedProfileWrite", () => + this.validateManagedProfileWrite(context, args.input, args.id), + ); + // CRITICAL: Validate authentication BEFORE executing any resolver await timed("guard.validateAuthentication", () => this.validateAuthentication(context, isStoreOperation), diff --git a/platforms/registry/api/REGISTRY_PROTOCOL.md b/platforms/registry/api/REGISTRY_PROTOCOL.md index 0ec2c3699..dd0e5e7f1 100644 --- a/platforms/registry/api/REGISTRY_PROTOCOL.md +++ b/platforms/registry/api/REGISTRY_PROTOCOL.md @@ -219,6 +219,27 @@ Authorization: Bearer } ``` +### 5.1 Managed PlatformProfile Migration + +The management API is service-to-service only and requires +`Authorization: Bearer `. It does not expose the submitted legacy token in +responses or logs. + +- `POST /platforms/migrations/inspect-token` verifies a legacy platform JWT and returns its SHA-256 + fingerprint. +- `POST /platforms/migrations/activate` atomically binds an eName and its original PlatformProfile + envelope ID to one manager, records the supplied legacy-token fingerprint as revoked, and returns a + short-lived manager-scoped token. Repeating the identical transfer is idempotent; a competing + transfer returns `409`. +- `POST /platforms/management/token` issues a new short-lived token only to the recorded manager. +- `POST /platforms/management/authorize-profile-write` is called by eVault before a PlatformProfile + write. Unmanaged eNames retain legacy behavior. Managed profiles accept only their active manager + token and original envelope ID. + +The write restriction is scoped to User-profile ontology +`550e8400-e29b-41d4-a716-446655440000`. PPA accreditation envelopes and unrelated eVault records are +not management writes and retain their existing authorization paths. + ### 6. Platform Discovery Protocol **Method**: `GET /platforms` diff --git a/platforms/registry/api/src/config/database.ts b/platforms/registry/api/src/config/database.ts index 250f8431e..b260c909f 100644 --- a/platforms/registry/api/src/config/database.ts +++ b/platforms/registry/api/src/config/database.ts @@ -1,6 +1,7 @@ import { DataSource } from "typeorm" import { Vault } from "../entities/Vault" import { SoftwareVersion } from "../entities/SoftwareVersion" +import { PlatformManagement } from "../entities/PlatformManagement" // Import Verification entity from evault-core if available (shared database) import * as dotenv from "dotenv" import { join } from "path" @@ -13,7 +14,7 @@ export const AppDataSource = new DataSource({ url: process.env.REGISTRY_DATABASE_URL || "postgresql://postgres:postgres@localhost:5432/registry", synchronize: false, logging: process.env.DB_LOGGING === "true", - entities: [Vault, SoftwareVersion], + entities: [Vault, SoftwareVersion, PlatformManagement], // Verification entity will be handled by evault-core provisioning service migrations: [join(__dirname, "../migrations/*.{ts,js}")], migrationsTableName: "migrations", diff --git a/platforms/registry/api/src/entities/PlatformManagement.ts b/platforms/registry/api/src/entities/PlatformManagement.ts new file mode 100644 index 000000000..94bd1aa66 --- /dev/null +++ b/platforms/registry/api/src/entities/PlatformManagement.ts @@ -0,0 +1,22 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from "typeorm"; + +@Entity() +export class PlatformManagement { + @PrimaryColumn() + ename!: string; + + @Column() + manager!: string; + + @Column() + profileEnvelopeId!: string; + + @Column({ type: "varchar", length: 64 }) + revokedTokenFingerprint!: string; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt!: Date; + + @UpdateDateColumn({ type: "timestamptz" }) + updatedAt!: Date; +} diff --git a/platforms/registry/api/src/index.ts b/platforms/registry/api/src/index.ts index e367e6fa3..151a166ba 100644 --- a/platforms/registry/api/src/index.ts +++ b/platforms/registry/api/src/index.ts @@ -7,6 +7,7 @@ import { generateEntropy, generatePlatformToken, generateKeyBindingCertificate, import { UriResolutionService } from "./services/UriResolutionService"; import { VaultService } from "./services/VaultService"; import { SoftwareVersionService, SoftwareVersionConflictError, softwareVersionEName } from "./services/SoftwareVersionService"; +import { PlatformManagementService, PlatformManagementConflictError } from "./services/PlatformManagementService"; import fs from "node:fs"; @@ -56,6 +57,7 @@ const initializeDatabase = async () => { // Initialize VaultService const vaultService = new VaultService(AppDataSource.getRepository("Vault")); const softwareVersionService = new SoftwareVersionService(AppDataSource.getRepository("SoftwareVersion")); +const platformManagementService = new PlatformManagementService(AppDataSource.getRepository("PlatformManagement")); // Initialize UriResolutionService (simplified for multi-tenant architecture) const uriResolutionService = new UriResolutionService(); @@ -188,6 +190,61 @@ server.post("/platforms/certification", async (request, reply) => { } }); +server.post( + "/platforms/migrations/inspect-token", + { preHandler: checkSharedSecret }, + async (request, reply) => { + try { + const { token } = request.body as { token?: string }; + if (!token) return reply.status(400).send({ error: "token is required" }); + return await platformManagementService.inspectLegacyToken(token); + } catch (error) { + return reply.status(401).send({ error: error instanceof Error ? error.message : "Invalid platform token" }); + } + }, +); + +server.post( + "/platforms/migrations/activate", + { preHandler: checkSharedSecret }, + async (request, reply) => { + try { + const input = request.body as { ename?: string; manager?: string; profileEnvelopeId?: string; legacyToken?: string }; + if (!input.ename || !input.manager || !input.profileEnvelopeId || !input.legacyToken) { + return reply.status(400).send({ error: "ename, manager, profileEnvelopeId, and legacyToken are required" }); + } + return await platformManagementService.transfer(input as Required); + } catch (error) { + if (error instanceof PlatformManagementConflictError) return reply.status(409).send({ error: error.message }); + return reply.status(401).send({ error: error instanceof Error ? error.message : "Migration activation failed" }); + } + }, +); + +server.post( + "/platforms/management/token", + { preHandler: checkSharedSecret }, + async (request, reply) => { + try { + const { ename, manager } = request.body as { ename?: string; manager?: string }; + if (!ename || !manager) return reply.status(400).send({ error: "ename and manager are required" }); + return { token: await platformManagementService.managerToken(ename, manager) }; + } catch (error) { + return reply.status(403).send({ error: error instanceof Error ? error.message : "Manager token denied" }); + } + }, +); + +server.post( + "/platforms/management/authorize-profile-write", + { preHandler: checkSharedSecret }, + async (request, reply) => { + const input = request.body as { ename?: string; ontology?: string; envelopeId?: string; token?: string }; + if (!input.ename || !input.ontology) return reply.status(400).send({ error: "ename and ontology are required" }); + return platformManagementService.authorizeProfileWrite(input as Required> & typeof input); + }, +); + // Generate key binding certificate (JWT binding ename and publicKey) server.post( "/key-binding-certificate", diff --git a/platforms/registry/api/src/jwt.ts b/platforms/registry/api/src/jwt.ts index bcf3c7caa..5a1db8fa2 100644 --- a/platforms/registry/api/src/jwt.ts +++ b/platforms/registry/api/src/jwt.ts @@ -66,20 +66,50 @@ export async function generatePlatformToken(platform: string): Promise { return token; } -export async function verifyPlatformToken(token: string): Promise { +export async function generateManagedPlatformToken(ename: string, manager: string): Promise { + await initializeKeys(); + return new SignJWT({ + platform: manager, + kind: "platform-manager", + managedEname: ename, + manager, + }) + .setProtectedHeader({ alg: "ES256", kid: "entropy-key-1" }) + .setJti(globalThis.crypto.randomUUID()) + .setIssuedAt() + .setExpirationTime("1h") + .sign(privateKey); +} + +export type PlatformTokenClaims = { + platform: string; + kind?: string; + managedEname?: string; + manager?: string; +}; + +export async function verifyPlatformTokenClaims(token: string): Promise { await initializeKeys(); try { const { payload } = await import("jose").then(({ jwtVerify }) => jwtVerify(token, publicKey, { algorithms: ["ES256"] }) ); - return typeof payload.platform === "string" && payload.platform.trim() - ? payload.platform - : null; + if (typeof payload.platform !== "string" || !payload.platform.trim()) return null; + return { + platform: payload.platform, + ...(typeof payload.kind === "string" && { kind: payload.kind }), + ...(typeof payload.managedEname === "string" && { managedEname: payload.managedEname }), + ...(typeof payload.manager === "string" && { manager: payload.manager }), + }; } catch { return null; } } +export async function verifyPlatformToken(token: string): Promise { + return (await verifyPlatformTokenClaims(token))?.platform ?? null; +} + // Generate and sign a JWT binding ename and publicKey together export async function generateKeyBindingCertificate( ename: string, diff --git a/platforms/registry/api/src/migrations/1788090000000-platform-management.ts b/platforms/registry/api/src/migrations/1788090000000-platform-management.ts new file mode 100644 index 000000000..70eee7888 --- /dev/null +++ b/platforms/registry/api/src/migrations/1788090000000-platform-management.ts @@ -0,0 +1,21 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +export class PlatformManagement1788090000000 implements MigrationInterface { + name = "PlatformManagement1788090000000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "platform_management" ( + "ename" character varying NOT NULL, + "manager" character varying NOT NULL, + "profileEnvelopeId" character varying NOT NULL, + "revokedTokenFingerprint" character varying(64) NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_platform_management_ename" PRIMARY KEY ("ename") + )`); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "platform_management"`); + } +} diff --git a/platforms/registry/api/src/services/PlatformManagementService.spec.ts b/platforms/registry/api/src/services/PlatformManagementService.spec.ts new file mode 100644 index 000000000..a7f019eef --- /dev/null +++ b/platforms/registry/api/src/services/PlatformManagementService.spec.ts @@ -0,0 +1,53 @@ +import type { Repository } from "typeorm"; +import type { PlatformManagement } from "../entities/PlatformManagement"; +import { generateManagedPlatformToken, verifyPlatformTokenClaims } from "../jwt"; +import { PlatformManagementConflictError, PlatformManagementService, tokenFingerprint } from "./PlatformManagementService"; + +jest.mock("../jwt", () => ({ + generateManagedPlatformToken: jest.fn(async () => "manager-token"), + verifyPlatformTokenClaims: jest.fn(), +})); + +describe("PlatformManagementService", () => { + const records = new Map(); + const repository = { + findOneBy: jest.fn(async ({ ename }: { ename: string }) => records.get(ename) ?? null), + create: jest.fn((input: PlatformManagement) => input), + save: jest.fn(async (input: PlatformManagement) => { + records.set(input.ename, input); + return input; + }), + } as unknown as Repository; + const service = new PlatformManagementService(repository); + + beforeEach(() => { + records.clear(); + jest.clearAllMocks(); + jest.mocked(verifyPlatformTokenClaims).mockResolvedValue({ platform: "legacy-publisher" }); + }); + + it("activates one idempotent management transfer and revokes the supplied token", async () => { + const input = { ename: "@platform", manager: "https://gitw3.example", profileEnvelopeId: "profile-1", legacyToken: "old-secret" }; + const first = await service.transfer(input); + const repeated = await service.transfer(input); + + expect(first.management.revokedTokenFingerprint).toBe(tokenFingerprint("old-secret")); + expect(repeated.management).toEqual(first.management); + expect(generateManagedPlatformToken).toHaveBeenCalledTimes(2); + }); + + it("rejects a competing transfer", async () => { + await service.transfer({ ename: "@platform", manager: "manager-a", profileEnvelopeId: "profile-1", legacyToken: "old-secret" }); + await expect(service.transfer({ ename: "@platform", manager: "manager-b", profileEnvelopeId: "profile-1", legacyToken: "old-secret" })) + .rejects.toBeInstanceOf(PlatformManagementConflictError); + }); + + it("allows only the active manager to write the managed profile envelope", async () => { + await service.transfer({ ename: "@platform", manager: "manager-a", profileEnvelopeId: "profile-1", legacyToken: "old-secret" }); + + expect(await service.authorizeProfileWrite({ ename: "@platform", ontology: "other" })).toEqual({ managed: false, allowed: true }); + expect((await service.authorizeProfileWrite({ ename: "@platform", ontology: "550e8400-e29b-41d4-a716-446655440000", envelopeId: "profile-1", token: "old-secret" })).allowed).toBe(false); + jest.mocked(verifyPlatformTokenClaims).mockResolvedValue({ platform: "manager-a", kind: "platform-manager", managedEname: "@platform", manager: "manager-a" }); + expect(await service.authorizeProfileWrite({ ename: "@platform", ontology: "550e8400-e29b-41d4-a716-446655440000", envelopeId: "profile-1", token: "new-secret" })).toEqual({ managed: true, allowed: true }); + }); +}); diff --git a/platforms/registry/api/src/services/PlatformManagementService.ts b/platforms/registry/api/src/services/PlatformManagementService.ts new file mode 100644 index 000000000..5649789cf --- /dev/null +++ b/platforms/registry/api/src/services/PlatformManagementService.ts @@ -0,0 +1,92 @@ +import { createHash } from "node:crypto"; +import type { Repository } from "typeorm"; +import type { PlatformManagement } from "../entities/PlatformManagement"; +import { generateManagedPlatformToken, verifyPlatformTokenClaims } from "../jwt"; + +export const PLATFORM_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; + +export class PlatformManagementConflictError extends Error {} + +export function tokenFingerprint(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +export class PlatformManagementService { + constructor(private readonly repository: Repository) {} + + async inspectLegacyToken(token: string): Promise<{ platform: string; fingerprint: string }> { + const claims = await verifyPlatformTokenClaims(token); + if (!claims || claims.kind === "platform-manager") { + throw new Error("A valid legacy platform token is required"); + } + return { platform: claims.platform, fingerprint: tokenFingerprint(token) }; + } + + async find(ename: string): Promise { + return this.repository.findOneBy({ ename }); + } + + async transfer(input: { + ename: string; + manager: string; + profileEnvelopeId: string; + legacyToken: string; + }): Promise<{ management: PlatformManagement; token: string }> { + const inspected = await this.inspectLegacyToken(input.legacyToken); + const existing = await this.find(input.ename); + const fingerprint = inspected.fingerprint; + if (existing) { + if ( + existing.manager !== input.manager || + existing.profileEnvelopeId !== input.profileEnvelopeId || + existing.revokedTokenFingerprint !== fingerprint + ) { + throw new PlatformManagementConflictError("This platform is already managed by another migration"); + } + return { management: existing, token: await generateManagedPlatformToken(input.ename, input.manager) }; + } + + const management = await this.repository.save( + this.repository.create({ + ename: input.ename, + manager: input.manager, + profileEnvelopeId: input.profileEnvelopeId, + revokedTokenFingerprint: fingerprint, + }), + ); + return { management, token: await generateManagedPlatformToken(input.ename, input.manager) }; + } + + async managerToken(ename: string, manager: string): Promise { + const management = await this.find(ename); + if (!management || management.manager !== manager) { + throw new Error("The requested manager does not control this platform"); + } + return generateManagedPlatformToken(ename, manager); + } + + async authorizeProfileWrite(input: { + ename: string; + ontology: string; + envelopeId?: string; + token?: string; + }): Promise<{ managed: boolean; allowed: boolean; reason?: string }> { + if (input.ontology !== PLATFORM_PROFILE_ONTOLOGY) { + return { managed: false, allowed: true }; + } + const management = await this.find(input.ename); + if (!management) return { managed: false, allowed: true }; + if (input.envelopeId && input.envelopeId !== management.profileEnvelopeId) { + return { managed: true, allowed: false, reason: "The managed platform profile has a different envelope ID" }; + } + if (!input.token) { + return { managed: true, allowed: false, reason: "A platform manager token is required" }; + } + if (tokenFingerprint(input.token) === management.revokedTokenFingerprint) { + return { managed: true, allowed: false, reason: "The legacy platform token was revoked during migration" }; + } + const claims = await verifyPlatformTokenClaims(input.token); + const allowed = !!claims && claims.kind === "platform-manager" && claims.managedEname === input.ename && claims.manager === management.manager; + return { managed: true, allowed, ...(!allowed && { reason: "The token is not the active platform manager" }) }; + } +}