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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -887,4 +937,3 @@ describe("VaultAccessGuard", () => {
});
});
});

Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -887,4 +937,3 @@ describe("VaultAccessGuard", () => {
});
});
});

Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -887,4 +937,3 @@ describe("VaultAccessGuard", () => {
});
});
});

Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -887,4 +937,3 @@ describe("VaultAccessGuard", () => {
});
});
});

Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -887,4 +937,3 @@ describe("VaultAccessGuard", () => {
});
});
});

Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -887,4 +937,3 @@ describe("VaultAccessGuard", () => {
});
});
});

Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -887,4 +937,3 @@ describe("VaultAccessGuard", () => {
});
});
});

Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -887,4 +937,3 @@ describe("VaultAccessGuard", () => {
});
});
});

Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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