Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions infrastructure/evault-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): VaultContext => {
Expand Down Expand Up @@ -887,4 +937,3 @@ describe("VaultAccessGuard", () => {
});
});
});

Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,69 @@ type CachedJWKS = {
const jwksCache = new Map<string, CachedJWKS>();
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<void> {
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
Expand Down Expand Up @@ -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),
Expand Down
21 changes: 21 additions & 0 deletions platforms/registry/api/REGISTRY_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,27 @@ Authorization: Bearer <shared-secret>
}
```

### 5.1 Managed PlatformProfile Migration

The management API is service-to-service only and requires
`Authorization: Bearer <REGISTRY_SHARED_SECRET>`. 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`
Expand Down
3 changes: 2 additions & 1 deletion platforms/registry/api/src/config/database.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions platforms/registry/api/src/entities/PlatformManagement.ts
Original file line number Diff line number Diff line change
@@ -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;
}
57 changes: 57 additions & 0 deletions platforms/registry/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<typeof input>);
} 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<Pick<typeof input, "ename" | "ontology">> & typeof input);
},
);

// Generate key binding certificate (JWT binding ename and publicKey)
server.post(
"/key-binding-certificate",
Expand Down
38 changes: 34 additions & 4 deletions platforms/registry/api/src/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,50 @@ export async function generatePlatformToken(platform: string): Promise<string> {
return token;
}

export async function verifyPlatformToken(token: string): Promise<string | null> {
export async function generateManagedPlatformToken(ename: string, manager: string): Promise<string> {
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<PlatformTokenClaims | null> {
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<string | null> {
return (await verifyPlatformTokenClaims(token))?.platform ?? null;
}

// Generate and sign a JWT binding ename and publicKey together
export async function generateKeyBindingCertificate(
ename: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { MigrationInterface, QueryRunner } from "typeorm";

export class PlatformManagement1788090000000 implements MigrationInterface {
name = "PlatformManagement1788090000000";

async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP TABLE "platform_management"`);
}
}
Loading
Loading