diff --git a/.env.example b/.env.example index abd8b0b32..35429aec9 100644 --- a/.env.example +++ b/.env.example @@ -191,6 +191,9 @@ PPA_AWARENESS_API_KEY="" # The messenger platform is discovered on the network by its platformName. # How to link into it comes from the handles that platform publishes, so there # is no path to configure here. +# Forge hosting submitted repositories. The signed release statement carries +# owner/name but no host, so without this the repository is shown as plain text. +PPA_REPOSITORY_BASE_URL="" PPA_MESSENGER_PLATFORM_NAME="meshenger" # Path that opens a conversation with one person. Only used when the messenger # publishes no handle for the User ontology; a declared handle always wins. diff --git a/infrastructure/evault-core/src/controllers/ProvisioningController.ts b/infrastructure/evault-core/src/controllers/ProvisioningController.ts index e51c719c6..162a6e521 100644 --- a/infrastructure/evault-core/src/controllers/ProvisioningController.ts +++ b/infrastructure/evault-core/src/controllers/ProvisioningController.ts @@ -1,10 +1,20 @@ import { Request, Response } from "express"; -import { ProvisioningService, ProvisionRequest, ProvisionResponse } from "../services/ProvisioningService"; +import { ProvisioningService, ProvisionRequest, ProvisionResponse, PreviewProvisionRequest, PreviewProvisionResponse } from "../services/ProvisioningService"; export class ProvisioningController { constructor(private readonly provisioningService: ProvisioningService) {} registerRoutes(app: any) { + app.post( + "/provision/preview", + async ( + req: Request<{}, {}, PreviewProvisionRequest>, + res: Response, + ) => { + const result = await this.provisioningService.previewEVault(req.body); + return res.status(result.success ? 200 : 400).json(result); + }, + ); app.post( "/provision", async ( @@ -32,4 +42,3 @@ export class ProvisioningController { ); } } - diff --git a/infrastructure/evault-core/src/core/http/server.ts b/infrastructure/evault-core/src/core/http/server.ts index 6d2d237cb..63184faa0 100644 --- a/infrastructure/evault-core/src/core/http/server.ts +++ b/infrastructure/evault-core/src/core/http/server.ts @@ -4,6 +4,7 @@ import axios from "axios"; import fastify, { type FastifyInstance } from "fastify"; import * as jose from "jose"; import type { + PreviewProvisionRequest, ProvisionRequest, ProvisioningService, } from "../../services/ProvisioningService"; @@ -1060,6 +1061,29 @@ export async function registerHttpRoutes( // Provision eVault endpoint if (provisioningService) { + server.post<{ Body: PreviewProvisionRequest }>( + "/provision/preview", + { + schema: { + tags: ["provisioning"], + description: "Preview an eVault eName without provisioning it", + body: { + type: "object", + required: ["registryEntropy", "namespace"], + properties: { + registryEntropy: { type: "string" }, + namespace: { type: "string" }, + }, + }, + }, + }, + async (request: TypedRequest, reply: TypedReply) => { + const result = await provisioningService.previewEVault(request.body); + if (!result.success) return reply.status(400).send(result); + return result; + }, + ); + server.post<{ Body: ProvisionRequest }>( "/provision", { diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index 7d72e0f41..9f30fd200 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -227,6 +227,8 @@ export class GraphQLServer { "self", "personal_parameters", "security_question", + "deployment_key", + "software_version", ] as const; type ValidType = (typeof VALID_BINDING_DOCUMENT_TYPES)[number]; @@ -858,6 +860,8 @@ export class GraphQLServer { signer: string; signature: string; timestamp: string; + scope?: "document" | "bundle"; + signedPayload?: string; }; }; }, @@ -883,6 +887,8 @@ export class GraphQLServer { "self", "personal_parameters", "security_question", + "deployment_key", + "software_version", ] as const; type ValidType = (typeof VALID_BINDING_DOCUMENT_TYPES)[number]; @@ -996,6 +1002,8 @@ export class GraphQLServer { signer: string; signature: string; timestamp: string; + scope?: "document" | "bundle"; + signedPayload?: string; }; }; }, diff --git a/infrastructure/evault-core/src/core/protocol/typedefs.ts b/infrastructure/evault-core/src/core/protocol/typedefs.ts index 626e42fa7..cbfc03178 100644 --- a/infrastructure/evault-core/src/core/protocol/typedefs.ts +++ b/infrastructure/evault-core/src/core/protocol/typedefs.ts @@ -175,12 +175,16 @@ export const typeDefs = /* GraphQL */ ` self personal_parameters security_question + deployment_key + software_version } type BindingDocumentSignature { signer: String! signature: String! timestamp: String! + scope: String + signedPayload: String } type BindingDocument { @@ -300,6 +304,8 @@ export const typeDefs = /* GraphQL */ ` signer: String! signature: String! timestamp: String! + scope: String + signedPayload: String } "Input for creating a binding document" diff --git a/infrastructure/evault-core/src/core/types/binding-document.ts b/infrastructure/evault-core/src/core/types/binding-document.ts index 4e3c0250f..04f55754d 100644 --- a/infrastructure/evault-core/src/core/types/binding-document.ts +++ b/infrastructure/evault-core/src/core/types/binding-document.ts @@ -4,12 +4,35 @@ export type BindingDocumentType = | "social_connection" | "self" | "personal_parameters" - | "security_question"; + | "security_question" + | "deployment_key" + | "software_version"; export interface BindingDocumentSignature { signer: string; signature: string; timestamp: string; + scope?: "document" | "bundle"; + signedPayload?: string; +} + +export interface BindingDocumentDeploymentKeyData { + kind: "deployment_key"; + deploymentName: string; + environment: string; + deployerEname: string; + platformEname: string; + publicKey: string; + algorithm: "ECDSA_P256"; +} + +export interface BindingDocumentSoftwareVersionData { + kind: "software_version"; + platformEname: string; + versionEname: string; + version: string; + releaseTag: string; + commitSha: string; } export interface BindingDocumentIdDocumentData { @@ -62,7 +85,9 @@ export type BindingDocumentData = | BindingDocumentSocialConnectionData | BindingDocumentSelfData | BindingDocumentPersonalParametersData - | BindingDocumentSecurityQuestionData; + | BindingDocumentSecurityQuestionData + | BindingDocumentDeploymentKeyData + | BindingDocumentSoftwareVersionData; export interface BindingDocument { subject: string; diff --git a/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts b/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts index 8b12e406a..75caa2167 100644 --- a/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts +++ b/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts @@ -3,7 +3,8 @@ import { type StartedNeo4jContainer, } from "@testcontainers/neo4j"; import neo4j, { type Driver } from "neo4j-driver"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { DbService } from "../core/db/db.service"; import { computeEnvelopeHash } from "../core/db/envelope-hash"; import { computeBindingDocumentHash } from "../core/utils/binding-document-hash"; @@ -233,6 +234,97 @@ describe("BindingDocumentService (integration)", () => { expect(result.bindingDocument.subject).toBe("@already-prefixed"); }); + it("stores a deployer-signed deployment bundle as public documents", async () => { + const deploymentSubject = "@11111111-1111-5111-8111-111111111111"; + const versionSubject = "@22222222-2222-5222-8222-222222222222"; + const deployer = "@33333333-3333-5333-8333-333333333333"; + const deploymentData = { + kind: "deployment_key" as const, + deploymentName: "Singapore production", + environment: "production", + deployerEname: deployer, + platformEname: TEST_ENAME, + publicKey: "zDeploymentPublicKey", + algorithm: "ECDSA_P256" as const, + }; + const versionData = { + kind: "software_version" as const, + platformEname: TEST_ENAME, + versionEname: versionSubject, + version: "1.2.3", + releaseTag: "v1.2.3", + commitSha: "a".repeat(40), + }; + const signedPayload = JSON.stringify({ + type: "deployment_attestation_bundle", + version: 1, + documents: [ + { + subject: deploymentSubject, + type: "deployment_key", + hash: computeBindingDocumentHash({ subject: deploymentSubject, type: "deployment_key", data: deploymentData }), + }, + { + subject: versionSubject, + type: "software_version", + hash: computeBindingDocumentHash({ subject: versionSubject, type: "software_version", data: versionData }), + }, + ], + }); + const verify = vi.spyOn(bindingDocumentService as any, "verifyUserPayload").mockResolvedValue(true); + const ownerSignature = { + signer: deployer, + signature: "wallet-signature", + timestamp: new Date().toISOString(), + scope: "bundle" as const, + signedPayload, + }; + + const deployment = await bindingDocumentService.createBindingDocument({ + subject: deploymentSubject, + type: "deployment_key", + data: deploymentData, + ownerSignature, + }, deploymentSubject); + const version = await bindingDocumentService.createBindingDocument({ + subject: versionSubject, + type: "software_version", + data: versionData, + ownerSignature, + }, deploymentSubject); + + expect(deployment.bindingDocument.signatures[0].signedPayload).toBe(signedPayload); + expect(version.bindingDocument.signatures[0].scope).toBe("bundle"); + expect(verify).toHaveBeenCalledWith( + deployer, + "wallet-signature", + `gitw3:deployment:v1:${createHash("sha256").update(signedPayload).digest("base64url")}`, + ); + verify.mockRestore(); + }); + + it("rejects unsigned deployment documents", async () => { + const data = { + kind: "deployment_key" as const, + deploymentName: "Production", + environment: "production", + deployerEname: TEST_ENAME, + platformEname: "@platform", + publicKey: "zDeploymentPublicKey", + algorithm: "ECDSA_P256" as const, + }; + await expect(bindingDocumentService.createBindingDocument({ + subject: "@deployment", + type: "deployment_key", + data, + ownerSignature: { + signer: TEST_ENAME, + signature: computeBindingDocumentHash({ subject: "@deployment", type: "deployment_key", data }), + timestamp: new Date().toISOString(), + }, + }, "@deployment")).rejects.toThrow("Invalid owner signature"); + }); + it("should persist envelope operation logs via dbService after creating a binding document", async () => { // This test verifies the DB logging infrastructure only; audit emission // by createBindingDocument itself is out of scope here. diff --git a/infrastructure/evault-core/src/services/BindingDocumentService.ts b/infrastructure/evault-core/src/services/BindingDocumentService.ts index 77ec8038f..2f675e267 100644 --- a/infrastructure/evault-core/src/services/BindingDocumentService.ts +++ b/infrastructure/evault-core/src/services/BindingDocumentService.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { createHash } from "node:crypto"; import nacl from "tweetnacl"; import { verifySignature } from "signature-validator"; import type { DbService } from "../core/db/db.service"; @@ -11,6 +12,7 @@ import { import type { BindingDocument, BindingDocumentData, + BindingDocumentDeploymentKeyData, BindingDocumentIdDocumentData, BindingDocumentPersonalParametersData, BindingDocumentPhotographData, @@ -18,6 +20,7 @@ import type { BindingDocumentSelfData, BindingDocumentSignature, BindingDocumentSocialConnectionData, + BindingDocumentSoftwareVersionData, BindingDocumentType, } from "../core/types/binding-document"; @@ -146,6 +149,48 @@ function validateBindingDocumentData( answerHash: trimmedAnswerHash, } as BindingDocumentSecurityQuestionData; } + case "deployment_key": { + if ( + d.kind !== "deployment_key" || + typeof d.deploymentName !== "string" || !d.deploymentName.trim() || + typeof d.environment !== "string" || !d.environment.trim() || + typeof d.deployerEname !== "string" || !d.deployerEname.startsWith("@") || + typeof d.platformEname !== "string" || !d.platformEname.startsWith("@") || + typeof d.publicKey !== "string" || !d.publicKey.startsWith("z") || + d.algorithm !== "ECDSA_P256" + ) { + throw new ValidationError("deployment_key data is invalid"); + } + return { + kind: "deployment_key", + deploymentName: d.deploymentName.trim(), + environment: d.environment.trim(), + deployerEname: d.deployerEname, + platformEname: d.platformEname, + publicKey: d.publicKey, + algorithm: "ECDSA_P256", + } as BindingDocumentDeploymentKeyData; + } + case "software_version": { + if ( + d.kind !== "software_version" || + typeof d.platformEname !== "string" || !d.platformEname.startsWith("@") || + typeof d.versionEname !== "string" || !d.versionEname.startsWith("@") || + typeof d.version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(d.version) || + typeof d.releaseTag !== "string" || !d.releaseTag.trim() || + typeof d.commitSha !== "string" || !/^[0-9a-f]{40,64}$/i.test(d.commitSha) + ) { + throw new ValidationError("software_version data is invalid"); + } + return { + kind: "software_version", + platformEname: d.platformEname, + versionEname: d.versionEname, + version: d.version, + releaseTag: d.releaseTag.trim(), + commitSha: d.commitSha.toLowerCase(), + } as BindingDocumentSoftwareVersionData; + } default: { const _exhaustive: never = type; throw new ValidationError(`Unknown binding document type: ${_exhaustive}`); @@ -172,14 +217,13 @@ export class BindingDocumentService { this.registryUrl = registryUrl || process.env.PUBLIC_REGISTRY_URL || ""; } - private async verifyUserSignature( + private async verifyUserPayload( signer: string, signature: string, - doc: { subject: string; type: BindingDocumentType; data: BindingDocumentData }, + payload: string, ): Promise { if (!this.registryUrl) return false; try { - const payload = getCanonicalBindingDocumentString(doc); const result = await verifySignature({ eName: signer, signature, @@ -192,6 +236,56 @@ export class BindingDocumentService { } } + private async verifyUserSignature( + signer: string, + signature: string, + doc: { subject: string; type: BindingDocumentType; data: BindingDocumentData }, + ): Promise { + return this.verifyUserPayload( + signer, + signature, + getCanonicalBindingDocumentString(doc), + ); + } + + private async verifyBundleSignature( + signature: BindingDocumentSignature, + doc: { subject: string; type: BindingDocumentType; data: BindingDocumentData }, + ): Promise { + if (signature.scope !== "bundle" || !signature.signedPayload) return false; + try { + const bundle = JSON.parse(signature.signedPayload) as { + type?: unknown; + version?: unknown; + documents?: unknown; + }; + if ( + bundle.type !== "deployment_attestation_bundle" || + bundle.version !== 1 || + !Array.isArray(bundle.documents) || + bundle.documents.length !== 2 + ) return false; + const expectedHash = computeBindingDocumentHash(doc); + const member = bundle.documents.some((item) => { + if (!item || typeof item !== "object") return false; + const entry = item as Record; + return entry.hash === expectedHash && + entry.subject === doc.subject && entry.type === doc.type; + }); + if (!member) return false; + const digest = createHash("sha256") + .update(signature.signedPayload, "utf8") + .digest("base64url"); + return this.verifyUserPayload( + signature.signer, + signature.signature, + `gitw3:deployment:v1:${digest}`, + ); + } catch { + return false; + } + } + private normalizeSubject(subject: string): string { return subject.startsWith("@") ? subject : `@${subject}`; } @@ -270,6 +364,7 @@ export class BindingDocumentService { const expectedHash = computeBindingDocumentHash(docToVerify); const hasLegacyHashSignature = input.ownerSignature.signature === expectedHash; const isProvisionerSigner = /^https?:\/\//.test(input.ownerSignature.signer); + const isDeploymentDocument = input.type === "deployment_key" || input.type === "software_version"; const hasValidUserSignature = !hasLegacyHashSignature && @@ -279,10 +374,21 @@ export class BindingDocumentService { input.ownerSignature.signature, docToVerify, )); + const hasValidBundleSignature = isDeploymentDocument && + (await this.verifyBundleSignature(input.ownerSignature, docToVerify)); - if (!hasLegacyHashSignature && !isProvisionerSigner && !hasValidUserSignature) { + if ( + (isDeploymentDocument && !hasValidBundleSignature) || + (!isDeploymentDocument && !hasLegacyHashSignature && !isProvisionerSigner && !hasValidUserSignature) + ) { throw new ValidationError("Invalid owner signature"); } + if ( + input.type === "deployment_key" && + input.ownerSignature.signer !== (validatedData as BindingDocumentDeploymentKeyData).deployerEname + ) { + throw new ValidationError("deployment_key must be signed by its deployer"); + } const bindingDocument: BindingDocument = { subject: normalizedSubject, @@ -291,11 +397,13 @@ export class BindingDocumentService { signatures: [input.ownerSignature], }; + const isPublicDeploymentDocument = input.type === "deployment_key" || input.type === "software_version"; + const acl = isPublicDeploymentDocument ? ["*"] : [normalizedSubject]; const result = await this.db.storeMetaEnvelope( { ontology: BINDING_DOCUMENT_ONTOLOGY, payload: bindingDocument, - acl: [normalizedSubject], + acl, }, [normalizedSubject], eName, diff --git a/infrastructure/evault-core/src/services/ProvisioningService.spec.ts b/infrastructure/evault-core/src/services/ProvisioningService.spec.ts index bb176e99c..ea27fd73a 100644 --- a/infrastructure/evault-core/src/services/ProvisioningService.spec.ts +++ b/infrastructure/evault-core/src/services/ProvisioningService.spec.ts @@ -154,6 +154,17 @@ describe("ProvisioningService", () => { }; describe("provisionEVault - success path", () => { + it("previews the exact eName later provisioned", async () => { + const request = await createValidRequest(); + mockedAxios.post.mockResolvedValueOnce({ status: 201, data: { success: true } }); + + const preview = await provisioningService.previewEVault(request); + const provisioned = await provisioningService.provisionEVault(request); + + expect(preview.success).toBe(true); + expect(preview.w3id).toBe(provisioned.w3id); + }); + it("should successfully provision eVault with valid demo code", async () => { const request = await createValidRequest(); diff --git a/infrastructure/evault-core/src/services/ProvisioningService.ts b/infrastructure/evault-core/src/services/ProvisioningService.ts index 327a7b612..187a7ae90 100644 --- a/infrastructure/evault-core/src/services/ProvisioningService.ts +++ b/infrastructure/evault-core/src/services/ProvisioningService.ts @@ -24,6 +24,18 @@ export interface ProvisionResponse { existingW3id?: string; } +export interface PreviewProvisionRequest { + registryEntropy: string; + namespace: string; +} + +export interface PreviewProvisionResponse { + success: boolean; + w3id?: string; + message?: string; + error?: string; +} + export class ProvisioningService { constructor(private verificationService: VerificationService) {} @@ -31,6 +43,68 @@ export class ProvisioningService { return typeof value === "string" ? value.trim().toUpperCase() : ""; } + private async deriveEVaultEName( + registryEntropy: string, + namespace: string, + ): Promise { + if (!process.env.PUBLIC_REGISTRY_URL) { + throw new Error("PUBLIC_REGISTRY_URL is not set"); + } + if (!registryEntropy || !namespace) { + throw new Error("registryEntropy and namespace are required"); + } + if (!uuidValidate(namespace)) { + throw new Error("Namespace must be a valid UUID"); + } + + let entropy: string; + try { + const jwksResponse = await axios.get( + new URL("/.well-known/jwks.json", process.env.PUBLIC_REGISTRY_URL).toString(), + ); + const JWKS = jose.createLocalJWKSet(jwksResponse.data); + const verified = await jose.jwtVerify(registryEntropy, JWKS); + entropy = verified.payload.entropy as string; + } catch (jwtError) { + throw new Error( + `JWT verification failed: ${jwtError instanceof Error ? jwtError.message : String(jwtError)}`, + ); + } + if (!entropy) throw new Error("Registry entropy token has no entropy"); + + try { + return (await new W3IDBuilder() + .withNamespace(namespace) + .withEntropy(entropy) + .withGlobal(true) + .build()).id; + } catch (w3idError) { + throw new Error( + `Failed to generate W3ID from entropy: ${w3idError instanceof Error ? w3idError.message : String(w3idError)}`, + ); + } + } + + async previewEVault( + request: PreviewProvisionRequest, + ): Promise { + try { + return { + success: true, + w3id: await this.deriveEVaultEName( + request.registryEntropy, + request.namespace, + ), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + message: "Failed to preview eVault identity", + }; + } + } + private async checkForDuplicateIdentity( idVerif: any, documentNumber?: string, @@ -419,55 +493,11 @@ export class ProvisioningService { console.log(`[PROVISIONING] Keyless eVault provisioning (no publicKey provided)`); } - // Verify the registry entropy token - let payload: any; - try { - const jwksResponse = await axios.get( - new URL( - `/.well-known/jwks.json`, - process.env.PUBLIC_REGISTRY_URL, - ).toString(), - ); - - const JWKS = jose.createLocalJWKSet(jwksResponse.data); - const verified = await jose.jwtVerify(registryEntropy, JWKS); - payload = verified.payload; - } catch (jwtError) { - // If JWT verification fails, re-throw with a clearer message - // but preserve the original error for debugging - throw new Error( - `JWT verification failed: ${jwtError instanceof Error - ? jwtError.message - : String(jwtError) - }`, - ); - } - - if (!uuidValidate(namespace)) { - return { - success: false, - error: "Invalid namespace", - message: "Namespace must be a valid UUID", - }; - } - let w3id: string; try { - const userId = await new W3IDBuilder() - .withNamespace(namespace) - .withEntropy(payload.entropy as string) - .withGlobal(true) - .build(); - w3id = userId.id; + w3id = await this.deriveEVaultEName(registryEntropy, namespace); } catch (w3idError) { - // If W3ID generation fails, it's likely an entropy format issue - // Re-throw with clearer message, but let verification errors take precedence - throw new Error( - `Failed to generate W3ID from entropy: ${w3idError instanceof Error - ? w3idError.message - : String(w3idError) - }`, - ); + throw w3idError; } // Validate verification if not demo code diff --git a/platforms/registry/api/src/config/database.ts b/platforms/registry/api/src/config/database.ts index 2d26a549b..250f8431e 100644 --- a/platforms/registry/api/src/config/database.ts +++ b/platforms/registry/api/src/config/database.ts @@ -1,5 +1,6 @@ import { DataSource } from "typeorm" import { Vault } from "../entities/Vault" +import { SoftwareVersion } from "../entities/SoftwareVersion" // Import Verification entity from evault-core if available (shared database) import * as dotenv from "dotenv" import { join } from "path" @@ -12,7 +13,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], + entities: [Vault, SoftwareVersion], // Verification entity will be handled by evault-core provisioning service migrations: [join(__dirname, "../migrations/*.{ts,js}")], migrationsTableName: "migrations", diff --git a/platforms/registry/api/src/entities/SoftwareVersion.ts b/platforms/registry/api/src/entities/SoftwareVersion.ts new file mode 100644 index 000000000..31aad29d9 --- /dev/null +++ b/platforms/registry/api/src/entities/SoftwareVersion.ts @@ -0,0 +1,26 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from "typeorm"; + +@Entity() +@Index(["platformEname", "version"], { unique: true }) +export class SoftwareVersion { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ unique: true }) + ename!: string; + + @Column() + platformEname!: string; + + @Column() + version!: string; + + @Column() + releaseTag!: string; + + @Column() + commitSha!: string; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/platforms/registry/api/src/index.ts b/platforms/registry/api/src/index.ts index f424e7a9e..e367e6fa3 100644 --- a/platforms/registry/api/src/index.ts +++ b/platforms/registry/api/src/index.ts @@ -3,9 +3,10 @@ import cors from "@fastify/cors"; import dotenv from "dotenv"; import fastify from "fastify"; import { AppDataSource } from "./config/database"; -import { generateEntropy, generatePlatformToken, generateKeyBindingCertificate, getJWK } from "./jwt"; +import { generateEntropy, generatePlatformToken, generateKeyBindingCertificate, getJWK, verifyPlatformToken } from "./jwt"; import { UriResolutionService } from "./services/UriResolutionService"; import { VaultService } from "./services/VaultService"; +import { SoftwareVersionService, SoftwareVersionConflictError, softwareVersionEName } from "./services/SoftwareVersionService"; import fs from "node:fs"; @@ -54,6 +55,7 @@ const initializeDatabase = async () => { // Initialize VaultService const vaultService = new VaultService(AppDataSource.getRepository("Vault")); +const softwareVersionService = new SoftwareVersionService(AppDataSource.getRepository("SoftwareVersion")); // Initialize UriResolutionService (simplified for multi-tenant architecture) const uriResolutionService = new UriResolutionService(); @@ -73,6 +75,18 @@ const checkSharedSecret = async (request: any, reply: any) => { } }; +const checkRegistryWriter = async (request: any, reply: any) => { + const authHeader = request.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + return reply.status(401).send({ error: "Missing or invalid authorization header" }); + } + const token = authHeader.slice("Bearer ".length); + if (token === process.env.REGISTRY_SHARED_SECRET) return; + if (!(await verifyPlatformToken(token))) { + return reply.status(401).send({ error: "Invalid Registry writer token" }); + } +}; + server.get("/motd", async (request, reply) => { return motd; }); @@ -118,6 +132,51 @@ server.get("/entropy", async (request, reply) => { } }); +server.post( + "/records/software-versions/preview", + { preHandler: checkRegistryWriter }, + async (request, reply) => { + try { + const { platformEname, version } = request.body as { + platformEname?: string; + version?: string; + }; + if (!platformEname || !version) { + return reply.status(400).send({ error: "platformEname and version are required" }); + } + return { kind: "software_version", ename: softwareVersionEName(platformEname, version.replace(/^v/, "")) }; + } catch (error) { + return reply.status(400).send({ error: error instanceof Error ? error.message : "Invalid software version" }); + } + }, +); + +server.post( + "/records/software-versions", + { preHandler: checkRegistryWriter }, + async (request, reply) => { + try { + const input = request.body as { + platformEname?: string; + version?: string; + releaseTag?: string; + commitSha?: string; + }; + if (!input.platformEname || !input.version || !input.releaseTag || !input.commitSha) { + return reply.status(400).send({ error: "platformEname, version, releaseTag, and commitSha are required" }); + } + const record = await softwareVersionService.create(input as Required); + return reply.status(201).send({ kind: "software_version", ...record }); + } catch (error) { + if (error instanceof SoftwareVersionConflictError) { + return reply.status(409).send({ error: error.message }); + } + server.log.error(error); + return reply.status(400).send({ error: error instanceof Error ? error.message : "Failed to create software version" }); + } + }, +); + server.post("/platforms/certification", async (request, reply) => { try { const { platform } = request.body as { platform: string }; @@ -230,13 +289,18 @@ server.get("/resolve", async (request, reply) => { const vault = await vaultService.findByEname(w3id); if (!vault) { - return reply.status(404).send({ error: "Service not found" }); + const softwareVersion = await softwareVersionService.findByEname(w3id); + if (!softwareVersion) { + return reply.status(404).send({ error: "Service not found" }); + } + return { kind: "software_version", ...softwareVersion }; } // Resolve the URI with health check and Kubernetes fallback const resolvedUri = await uriResolutionService.resolveUri(vault.uri); return { + kind: "evault", ename: vault.ename, uri: resolvedUri, evault: vault.evault, diff --git a/platforms/registry/api/src/jwt.ts b/platforms/registry/api/src/jwt.ts index 97da69dc4..bcf3c7caa 100644 --- a/platforms/registry/api/src/jwt.ts +++ b/platforms/registry/api/src/jwt.ts @@ -66,6 +66,20 @@ export async function generatePlatformToken(platform: string): Promise { return token; } +export async function verifyPlatformToken(token: string): Promise { + await initializeKeys(); + try { + const { payload } = await import("jose").then(({ jwtVerify }) => + jwtVerify(token, publicKey, { algorithms: ["ES256"] }) + ); + return typeof payload.platform === "string" && payload.platform.trim() + ? payload.platform + : null; + } catch { + return null; + } +} + // Generate and sign a JWT binding ename and publicKey together export async function generateKeyBindingCertificate( ename: string, diff --git a/platforms/registry/api/src/migrations/1788080000000-software-version.ts b/platforms/registry/api/src/migrations/1788080000000-software-version.ts new file mode 100644 index 000000000..56be84fe7 --- /dev/null +++ b/platforms/registry/api/src/migrations/1788080000000-software-version.ts @@ -0,0 +1,13 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +export class SoftwareVersion1788080000000 implements MigrationInterface { + name = "SoftwareVersion1788080000000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "software_version" ("id" SERIAL NOT NULL, "ename" character varying NOT NULL, "platformEname" character varying NOT NULL, "version" character varying NOT NULL, "releaseTag" character varying NOT NULL, "commitSha" character varying NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_software_version_ename" UNIQUE ("ename"), CONSTRAINT "UQ_software_version_platform_version" UNIQUE ("platformEname", "version"), CONSTRAINT "PK_software_version" PRIMARY KEY ("id"))`); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "software_version"`); + } +} diff --git a/platforms/registry/api/src/services/SoftwareVersionService.spec.ts b/platforms/registry/api/src/services/SoftwareVersionService.spec.ts new file mode 100644 index 000000000..6231b5ca0 --- /dev/null +++ b/platforms/registry/api/src/services/SoftwareVersionService.spec.ts @@ -0,0 +1,77 @@ +import type { Repository } from "typeorm"; +import type { SoftwareVersion } from "../entities/SoftwareVersion"; +import { + SoftwareVersionConflictError, + SoftwareVersionService, + normalizeSoftwareVersion, + softwareVersionEName, +} from "./SoftwareVersionService"; + +const platformEname = "@0699e093-2dd9-59cc-a416-7dc69623ebfd"; + +function repository(existing: SoftwareVersion | null = null) { + const save = jest.fn(async (record: SoftwareVersion) => record); + return { + findOneBy: jest.fn(async () => existing), + create: jest.fn((record) => record as SoftwareVersion), + save, + } as unknown as Repository; +} + +describe("SoftwareVersionService", () => { + it("derives stable version eNames", () => { + expect(softwareVersionEName(platformEname, "1.2.3")).toBe( + softwareVersionEName(platformEname, "1.2.3"), + ); + expect(softwareVersionEName(platformEname, "1.2.4")).not.toBe( + softwareVersionEName(platformEname, "1.2.3"), + ); + }); + + it("normalizes v-prefixed semantic versions", () => { + expect(normalizeSoftwareVersion("v1.2.3")).toBe("1.2.3"); + expect(() => normalizeSoftwareVersion("latest")).toThrow("semantic"); + }); + + it("returns the existing immutable record idempotently", async () => { + const existing = { + id: 7, + ename: softwareVersionEName(platformEname, "1.2.3"), + platformEname, + version: "1.2.3", + releaseTag: "v1.2.3", + commitSha: "a".repeat(40), + createdAt: new Date(), + }; + const repo = repository(existing); + const service = new SoftwareVersionService(repo); + + await expect(service.create({ + platformEname, + version: "v1.2.3", + releaseTag: "v1.2.3", + commitSha: "A".repeat(40), + })).resolves.toBe(existing); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it("rejects rebinding a platform version", async () => { + const existing = { + id: 7, + ename: softwareVersionEName(platformEname, "1.2.3"), + platformEname, + version: "1.2.3", + releaseTag: "v1.2.3", + commitSha: "a".repeat(40), + createdAt: new Date(), + }; + const service = new SoftwareVersionService(repository(existing)); + + await expect(service.create({ + platformEname, + version: "1.2.3", + releaseTag: "v1.2.3", + commitSha: "b".repeat(40), + })).rejects.toBeInstanceOf(SoftwareVersionConflictError); + }); +}); diff --git a/platforms/registry/api/src/services/SoftwareVersionService.ts b/platforms/registry/api/src/services/SoftwareVersionService.ts new file mode 100644 index 000000000..3af7a2e3b --- /dev/null +++ b/platforms/registry/api/src/services/SoftwareVersionService.ts @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; +import type { Repository } from "typeorm"; +import type { SoftwareVersion } from "../entities/SoftwareVersion"; + +export class SoftwareVersionConflictError extends Error {} + +function uuidBytes(value: string): Buffer { + const normalized = value.replace(/^@/, "").replace(/-/g, ""); + if (!/^[0-9a-f]{32}$/i.test(normalized)) { + throw new Error("platformEName must contain a UUID"); + } + return Buffer.from(normalized, "hex"); +} + +function formatUuid(bytes: Buffer): string { + const hex = bytes.toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export function softwareVersionEName(platformEname: string, version: string): string { + const namespace = uuidBytes(platformEname); + const digest = createHash("sha1") + .update(namespace) + .update(Buffer.from(`software-version:${version}`, "utf8")) + .digest() + .subarray(0, 16); + digest[6] = (digest[6] & 0x0f) | 0x50; + digest[8] = (digest[8] & 0x3f) | 0x80; + return `@${formatUuid(digest)}`; +} + +export function normalizeSoftwareVersion(value: string): string { + const normalized = value.trim().replace(/^v/, ""); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(normalized)) { + throw new Error("version must be semantic, such as 1.2.3"); + } + return normalized; +} + +export interface CreateSoftwareVersionInput { + platformEname: string; + version: string; + releaseTag: string; + commitSha: string; +} + +export class SoftwareVersionService { + constructor(private readonly repository: Repository) {} + + async create(input: CreateSoftwareVersionInput): Promise { + const platformEname = input.platformEname.trim().startsWith("@") + ? input.platformEname.trim() + : `@${input.platformEname.trim()}`; + const version = normalizeSoftwareVersion(input.version); + const releaseTag = input.releaseTag.trim(); + const commitSha = input.commitSha.trim().toLowerCase(); + if (!releaseTag || !/^[0-9a-f]{40,64}$/.test(commitSha)) { + throw new Error("releaseTag and a Git commit SHA are required"); + } + + const existing = await this.repository.findOneBy({ platformEname, version }); + if (existing) { + if (existing.releaseTag !== releaseTag || existing.commitSha !== commitSha) { + throw new SoftwareVersionConflictError( + `Version ${version} is already bound to a different release`, + ); + } + return existing; + } + + return this.repository.save(this.repository.create({ + ename: softwareVersionEName(platformEname, version), + platformEname, + version, + releaseTag, + commitSha, + })); + } + + async findByEname(ename: string): Promise { + const normalized = ename.startsWith("@") ? ename : `@${ename}`; + return this.repository.findOneBy({ ename: normalized }); + } +} diff --git a/services/ontology/schemas/binding-document.json b/services/ontology/schemas/binding-document.json index 56f6e16d8..04b92a7cf 100644 --- a/services/ontology/schemas/binding-document.json +++ b/services/ontology/schemas/binding-document.json @@ -15,7 +15,11 @@ "id_document", "photograph", "social_connection", - "self" + "self", + "personal_parameters", + "security_question", + "deployment_key", + "software_version" ], "description": "The type of binding document" }, @@ -107,6 +111,24 @@ } } } + }, + { + "if": { + "properties": { "type": { "const": "deployment_key" } }, + "required": ["type"] + }, + "then": { + "properties": { "data": { "$ref": "#/definitions/DeploymentKeyData" } } + } + }, + { + "if": { + "properties": { "type": { "const": "software_version" } }, + "required": ["type"] + }, + "then": { + "properties": { "data": { "$ref": "#/definitions/SoftwareVersionData" } } + } } ], "definitions": { @@ -200,6 +222,33 @@ ], "additionalProperties": false }, + "DeploymentKeyData": { + "type": "object", + "properties": { + "kind": { "const": "deployment_key" }, + "deploymentName": { "type": "string", "minLength": 1 }, + "environment": { "type": "string", "minLength": 1 }, + "deployerEname": { "type": "string", "pattern": "^@[^\\s]+$" }, + "platformEname": { "type": "string", "pattern": "^@[^\\s]+$" }, + "publicKey": { "type": "string", "pattern": "^z.+$" }, + "algorithm": { "const": "ECDSA_P256" } + }, + "required": ["kind", "deploymentName", "environment", "deployerEname", "platformEname", "publicKey", "algorithm"], + "additionalProperties": false + }, + "SoftwareVersionData": { + "type": "object", + "properties": { + "kind": { "const": "software_version" }, + "platformEname": { "type": "string", "pattern": "^@[^\\s]+$" }, + "versionEname": { "type": "string", "pattern": "^@[^\\s]+$" }, + "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" }, + "releaseTag": { "type": "string", "minLength": 1 }, + "commitSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40,64}$" } + }, + "required": ["kind", "platformEname", "versionEname", "version", "releaseTag", "commitSha"], + "additionalProperties": false + }, "Signature": { "type": "object", "properties": { @@ -215,6 +264,14 @@ "type": "string", "format": "date-time", "description": "When the signature was created" + }, + "scope": { + "type": "string", + "enum": ["document", "bundle"] + }, + "signedPayload": { + "type": "string", + "description": "Canonical signed payload for a bundle-scoped signature" } }, "required": [ diff --git a/services/ontology/schemas/deployment-profile.json b/services/ontology/schemas/deployment-profile.json new file mode 100644 index 000000000..2f73b68d3 --- /dev/null +++ b/services/ontology/schemas/deployment-profile.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "d38e0c5b-9d63-4a21-8e8b-1d6b63af64d2", + "title": "Deployment Profile", + "domain": "software", + "type": "object", + "properties": { + "deploymentEname": { "type": "string", "pattern": "^@[^\\s]+$" }, + "deploymentName": { "type": "string", "minLength": 1 }, + "environment": { "type": "string", "minLength": 1 }, + "deployerEname": { "type": "string", "pattern": "^@[^\\s]+$" }, + "platformEname": { "type": "string", "pattern": "^@[^\\s]+$" }, + "versionEname": { "type": "string", "pattern": "^@[^\\s]+$" }, + "version": { "type": "string" }, + "releaseTag": { "type": "string" }, + "commitSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40,64}$" }, + "publicKey": { "type": "string", "pattern": "^z.+$" }, + "deploymentKeyDocumentId": { "type": "string" }, + "softwareVersionDocumentId": { "type": "string" }, + "createdAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "deploymentEname", + "deploymentName", + "environment", + "deployerEname", + "platformEname", + "versionEname", + "version", + "releaseTag", + "commitSha", + "publicKey", + "deploymentKeyDocumentId", + "softwareVersionDocumentId", + "createdAt" + ], + "additionalProperties": false +} diff --git a/services/ontology/schemas/platformAccreditation.json b/services/ontology/schemas/platformAccreditation.json index 98cad9cbc..6cdf2597f 100644 --- a/services/ontology/schemas/platformAccreditation.json +++ b/services/ontology/schemas/platformAccreditation.json @@ -4,7 +4,7 @@ "title": "PlatformAccreditation", "domain": "governance", "type": "object", - "description": "A decision issued by the Post Platforms Association (PPA) on a platform application for network access. The record is stored in the eVault of the platform it is about, with a public ACL, so it travels with that platform and anyone can read it. The association owns no vault of its own: its identity is a signing key, and the `jws` field carries the whole decision as a self-contained ES256 JWS that verifies against `issuerJwksUri` without trusting the eVault, the PPA app, or the platform holding it. Records are append-only and scoped to one platform version: the newest record for a given platform and version is the one in force, and a new version starts unaccredited.", + "description": "A decision issued by the Post Platforms Association (PPA) on a platform application for network access. The record is stored in the eVault of the platform it is about, with a public ACL, so it travels with that platform and anyone can read it. The association owns no vault of its own: its identity is a signing key, and the `jws` field carries the whole decision as a self-contained ES256 JWS that verifies against `issuerJwksUri` without trusting the eVault, the PPA app, or the platform holding it. Records are append-only and scoped to one platform version. A version may be refused and reapply, so it can accumulate several decisions; the newest record for a given platform and version is the one in force, and each points at the one it replaced. A new version starts unaccredited.", "properties": { "accreditationId": { "type": "string", @@ -61,6 +61,21 @@ "type": "string", "description": "Free-text reasoning from the reviewer, shown to the applicant and covered by the signature" }, + "applicantResponse": { + "type": [ + "string", + "null" + ], + "description": "What the applicant said when reapplying, as it stood when this decision was taken. A platform profile is overwritten on each submission, so without capturing it here the earlier rounds of the exchange are lost." + }, + "applicantSubmittedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the submission this decision answers was signed." + }, "reviewedByEName": { "type": "string", "minLength": 1, @@ -75,13 +90,12 @@ "type": "string", "description": "MetaEnvelope id of the PlatformProfile submission this decision reviewed" }, - "status": { - "type": "string", - "enum": [ - "active", - "superseded" + "supersedes": { + "type": [ + "string", + "null" ], - "description": "'superseded' marks a decision replaced by a later one for the same platform" + "description": "accreditationId of the decision this one replaces for the same platform and version, or null for a first decision. An application may be refused and reapply, so one version can accumulate several decisions; this makes that chain explicit rather than leaving it to be inferred." }, "jws": { "type": "string", diff --git a/services/ontology/views/index.ejs b/services/ontology/views/index.ejs index 6b0cafe47..32403ccc4 100644 --- a/services/ontology/views/index.ejs +++ b/services/ontology/views/index.ejs @@ -1,3 +1,9 @@ +<% + // Tolerate a caller that does not supply the domain data: the viewer is + // useful without it, and a missing local is not worth a 500. + var domainList = (typeof domains !== 'undefined' && Array.isArray(domains)) ? domains : []; + var domainOfSelected = (typeof selectedDomain !== 'undefined') ? selectedDomain : null; +%> @@ -169,6 +175,7 @@ + <% if (domainList.length) { %>

Domains

Every schema belongs to one domain. Platforms are granted access a @@ -176,10 +183,11 @@

All - <% domains.forEach(function(d) { %> + <% domainList.forEach(function(d) { %> <%= d.label %> <% }); %>
+ <% } %>

Schemas

    @@ -191,7 +199,7 @@ <%= s.title %> <% if (s.domain) { %> <%= s.domain.label %> - <% } else { %> + <% } else if (domainList.length) { %> No domain <% } %> <%= s.id %> @@ -212,11 +220,11 @@
    <% if (selectedSchema) { %> - <% if (selectedDomain) { %> + <% if (domainOfSelected) { %>

    Domain: - <%= selectedDomain.label %> - <% if (selectedDomain.description) { %> -
    <%= selectedDomain.description %> + <%= domainOfSelected.label %> + <% if (domainOfSelected.description) { %> +
    <%= domainOfSelected.description %> <% } %>

    <% } %> @@ -395,7 +403,7 @@ if (alt.required && alt.required.length) h += '

    Required: ' + escapeHtml(alt.required.join(', ')) + '

    '; return h; } - var DOMAINS = <%- JSON.stringify(domains) %>; + var DOMAINS = <%- JSON.stringify(domainList) %>; function domainOf(id) { for (var i = 0; i < DOMAINS.length; i++) { diff --git a/services/ppa/src/lib/ReviewThread.svelte b/services/ppa/src/lib/ReviewThread.svelte new file mode 100644 index 000000000..09f8b0714 --- /dev/null +++ b/services/ppa/src/lib/ReviewThread.svelte @@ -0,0 +1,158 @@ + + +{#if turns.length > 0} +
    +

    Review history

    + +
      + {#each turns as turn, i (turn.side + turn.at + i)} +
    1. + +
      + + {turn.side === "association" ? "PPA" : turn.who.slice(0, 1).toUpperCase()} + + {#if i < turns.length - 1} + + {/if} +
      + +
      +
      + + {turn.side === "association" ? "Association" : turn.who} + + v{turn.version} + {turn.at.slice(0, 16).replace("T", " ")} + {#if turn.decision} + + {turn.decision === "granted" ? (turn.level ?? "Granted") : "Denied"} + + {/if} +
      + +

      + {turn.body} +

      + + {#if turn.domains && turn.domains.length > 0} +
      + +
      + {/if} +
      +
    2. + {/each} +
    +
    +{/if} diff --git a/services/ppa/src/lib/server/aaas.ts b/services/ppa/src/lib/server/aaas.ts index f92900de6..5b10bc20e 100644 --- a/services/ppa/src/lib/server/aaas.ts +++ b/services/ppa/src/lib/server/aaas.ts @@ -17,6 +17,7 @@ import { type Messenger, type PlatformHandle, PLATFORM_ACCREDITATION_ONTOLOGY, + type PPASubmissionProof, type Submission, USER_ONTOLOGY, } from "./ontology"; @@ -27,6 +28,10 @@ import { messengerPlatformName, } from "./env"; import { ontologyDomains } from "./domains"; +import { + verifySubmissionHistory, + verifySubmissionProof, +} from "./submission-proof"; interface Packet { id: string; @@ -94,21 +99,22 @@ async function all(params: Record): Promise { /** * Listing submissions and discovering the messenger both need the whole - * User-ontology history — every user profile in the ecosystem, not just - * platforms, because AaaS can only filter by ontology. That is currently ~15MB - * over four pages and takes the better part of a minute, so the scan is cached - * and served stale while it refreshes. + * User-ontology history — every user profile on the network, not just + * platforms, because AaaS can only filter by ontology. A full pass is ~15MB + * over several pages and takes the better part of a minute. * - * The TTL must stay comfortably longer than a scan takes. A TTL shorter than - * the scan expires before the scan that fills it has even finished, so every - * request starts another full pass and the cache never serves anything. + * So the full pass happens once, and every refresh after that asks only for + * what arrived since, which is a single small query. That keeps the queue + * within seconds of live: a review queue that takes minutes to show a new + * submission is not doing its job. */ -const FRESH_MS = 5 * 60_000; -const STALE_MS = 30 * 60_000; +const FRESH_MS = 20_000; interface PacketCache { at: number; packets: Packet[]; + /** Newest receivedAt seen, the watermark the next refresh reads from. */ + watermark: string | null; } // Anchored outside the module graph for the same reason as the auth sessions: @@ -121,16 +127,60 @@ const store = globalThis as typeof globalThis & { store[STORE] ??= { cache: null, inflight: null }; const packetStore = store[STORE]; -/** Starts a scan, collapsing concurrent callers onto one in-flight request. */ +function watermarkOf(packets: Packet[]): string | null { + let max: string | null = null; + for (const p of packets) { + if (p.receivedAt && (max === null || p.receivedAt > max)) max = p.receivedAt; + } + return max; +} + +/** Full pass, or a catch-up from the watermark when we already have one. */ +async function fetchUserPackets(previous: PacketCache | null): Promise { + if (!previous || !previous.watermark) { + return all({ ontology: USER_ONTOLOGY }); + } + + // Inclusive of the watermark, so a packet sharing that timestamp is not + // skipped; duplicates are removed by id below. + const since = await all({ + ontology: USER_ONTOLOGY, + from: previous.watermark, + }); + + if (since.length === 0) return previous.packets; + + // AaaS upserts a packet by MetaEnvelope id, so a later copy replaces the + // earlier one. Keep order oldest-first: callers rely on last-write-wins. + const byId = new Map(previous.packets.map((p) => [p.id, p])); + for (const packet of since) byId.set(packet.id, packet); + return Array.from(byId.values()).sort((a, b) => + a.receivedAt < b.receivedAt ? -1 : a.receivedAt > b.receivedAt ? 1 : 0, + ); +} + +/** Starts a refresh, collapsing concurrent callers onto one in-flight request. */ function refreshUserPackets(): Promise { if (packetStore.inflight) return packetStore.inflight; + const previous = packetStore.cache; const started = Date.now(); - packetStore.inflight = all({ ontology: USER_ONTOLOGY }) + packetStore.inflight = fetchUserPackets(previous) .then((packets) => { - packetStore.cache = { at: Date.now(), packets }; - console.log( - `[ppa/aaas] scanned ${packets.length} profile packet(s) in ${((Date.now() - started) / 1000).toFixed(1)}s`, - ); + const elapsed = ((Date.now() - started) / 1000).toFixed(1); + packetStore.cache = { + at: Date.now(), + packets, + watermark: watermarkOf(packets), + }; + if (!previous) { + console.log( + `[ppa/aaas] scanned ${packets.length} profile packet(s) in ${elapsed}s`, + ); + } else if (packets.length !== previous.packets.length) { + console.log( + `[ppa/aaas] caught up in ${elapsed}s — ${packets.length - previous.packets.length} new packet(s)`, + ); + } return packets; }) .finally(() => { @@ -139,19 +189,18 @@ function refreshUserPackets(): Promise { return packetStore.inflight; } -function allUserPackets(): Promise { +async function allUserPackets(): Promise { const cache = packetStore.cache; if (!cache) return refreshUserPackets(); - - const age = Date.now() - cache.at; - if (age < FRESH_MS) return Promise.resolve(cache.packets); - if (age < STALE_MS) { - // Serve what we have and bring it up to date behind the request, so a - // reviewer never waits on the scan once it has run at least once. - void refreshUserPackets().catch(() => {}); - return Promise.resolve(cache.packets); + if (Date.now() - cache.at < FRESH_MS) return cache.packets; + // The catch-up is cheap, so wait for it rather than serving stale data and + // making the reviewer reload twice to see a submission. + try { + return await refreshUserPackets(); + } catch (error) { + console.error("[ppa/aaas] refresh failed, serving cached packets:", error); + return cache.packets; } - return refreshUserPackets(); } /** Drops the cached scan so the next read reflects a just-written change. */ @@ -234,6 +283,30 @@ function extractOntologies(data: Record): string[] { return []; } +/** + * The application domains a platform selected from the published domain + * ontology. `requestedDomains` is the submission-facing name; `domains` is + * accepted because it is also part of the PlatformProfile itself. + */ +function extractRequestedDomains(data: Record): string[] { + const selfDescription = data.selfDescription as + | { domains?: unknown } + | undefined; + const candidates = [ + data.requestedDomains, + data.domains, + selfDescription?.domains, + ]; + for (const candidate of candidates) { + if (!Array.isArray(candidate)) continue; + const ids = candidate + .map((v) => (typeof v === "string" ? v.trim() : "")) + .filter(Boolean); + if (ids.length > 0) return [...new Set(ids)]; + } + return []; +} + /** * Every platform currently asking for access, deduped by eName. Packets arrive * oldest first, so a plain Map keeps the last write — a platform that has since @@ -266,14 +339,55 @@ export async function listSubmissions(): Promise { } const requestedOntologies = extractOntologies(data); + const declaredDomains = extractRequestedDomains(data); // A platform asking for an ontology is asking for its domain. - const requestedDomains = [ + const inferredDomains = [ ...new Set( requestedOntologies .map((id) => ontologyDomain.get(id)) .filter((d): d is string => Boolean(d)), ), ]; + const requestedDomains = [ + ...new Set([ + ...declaredDomains, + ...inferredDomains, + ]), + ]; + + let submissionProof: PPASubmissionProof | null; + try { + submissionProof = await verifySubmissionProof( + data.submissionProof, + data, + ename, + declaredDomains, + ); + } catch (error) { + console.warn( + `[ppa/aaas] could not verify the release signature for ${ename}:`, + error, + ); + submissionProof = null; + } + if (!submissionProof) { + // A newer unsigned or malformed profile must also evict an older + // valid submission rather than leaving stale evidence in review. + byEname.delete(ename); + continue; + } + + const submissionHistory = await verifySubmissionHistory( + data.submissionHistory, + data, + ename, + ); + if (!submissionHistory.some((proof) => proof.payload === submissionProof.payload)) { + submissionHistory.push(submissionProof); + submissionHistory.sort((a, b) => + a.statement.issuedAt.localeCompare(b.statement.issuedAt), + ); + } byEname.set(ename, { ename, @@ -287,8 +401,10 @@ export async function listSubmissions(): Promise { authorEnames: extractAuthors(data, ename), requestedOntologies: requestedOntologies, requestedDomains: requestedDomains, + submissionProof, + submissionHistory, submissionEnvelopeId: packet.id, - submittedAt: str(data.updatedAt) || str(data.createdAt) || packet.receivedAt, + submittedAt: submissionProof.verifiedAt, raw: data, }); } diff --git a/services/ppa/src/lib/server/env.ts b/services/ppa/src/lib/server/env.ts index 2630d1aca..52015b19f 100644 --- a/services/ppa/src/lib/server/env.ts +++ b/services/ppa/src/lib/server/env.ts @@ -80,6 +80,16 @@ export function messengerContactPath(): string { return raw("PPA_MESSENGER_CONTACT_PATH") || "/contacts/{ename}"; } +/** + * Base URL of the forge hosting submitted repositories. The signed release + * statement carries `owner/name` but no host, so a link can only be built + * with this. Left unset the repository stays plain text rather than pointing + * at a guessed address. + */ +export function repositoryBaseUrl(): string { + return raw("PPA_REPOSITORY_BASE_URL"); +} + export function demoVerificationCode(): string { return raw("DEMO_VERIFICATION_CODE"); } diff --git a/services/ppa/src/lib/server/jwt.ts b/services/ppa/src/lib/server/jwt.ts index c8b89f291..d96eace9f 100644 --- a/services/ppa/src/lib/server/jwt.ts +++ b/services/ppa/src/lib/server/jwt.ts @@ -68,6 +68,8 @@ export interface AccreditationClaims { statement: string; reviewedByEName: string; submissionEnvelopeId: string; + supersedes: string | null; + applicantResponse: string | null; } /** Where a verifier fetches the key set that validates our statements. */ @@ -98,6 +100,8 @@ export async function signAccreditation( platformName: claims.platformName, platformVersion: claims.platformVersion, submissionEnvelopeId: claims.submissionEnvelopeId, + supersedes: claims.supersedes, + applicantResponse: claims.applicantResponse, }) .setProtectedHeader({ alg: ALG, kid: KID, typ: "JWT" }) .setIssuer(publicUrl()) diff --git a/services/ppa/src/lib/server/ontology.ts b/services/ppa/src/lib/server/ontology.ts index 870cd7083..83859ef09 100644 --- a/services/ppa/src/lib/server/ontology.ts +++ b/services/ppa/src/lib/server/ontology.ts @@ -18,6 +18,35 @@ export { ACCESS_LEVELS, isAccessLevel } from "$lib/levels"; export type { Domain } from "$lib/types"; export type { AccessLevel } from "$lib/levels"; +export interface PPASubmissionStatement { + type: "w3ds.ppa.release-submission"; + schemaVersion: 1; + repositoryId: number; + repository: string; + platformEName: string; + platformName: string; + releaseTag: string; + version: string; + manifestCommitId: string; + domains: string[]; + signerEName: string; + issuedAt: string; + nonce: string; + previousDecision?: "denied"; + previousDecisionAt?: string; + responseToDecision?: string; +} + +/** Portable wallet evidence stored with the PlatformProfile in its eVault. */ +export interface PPASubmissionProof { + statement: PPASubmissionStatement; + payload: string; + signature: string; + publicKey: string; + keyBindingCertificate: string; + verifiedAt: string; +} + /** A platform's submission for review, as read out of AaaS. */ export interface Submission { /** The platform eVault's eName — the stable key for a submission. */ @@ -37,6 +66,10 @@ export interface Submission { * declares. A decision can approve these or a subset — never more. */ requestedDomains: string[]; + /** Independently verified owner/admin release signature from the platform eVault. */ + submissionProof: PPASubmissionProof; + /** Append-only signed applications and replies retained by the platform. */ + submissionHistory: PPASubmissionProof[]; submissionEnvelopeId: string; submittedAt: string; /** The untouched PlatformProfile payload, shown behind a disclosure. */ @@ -53,10 +86,14 @@ export interface Accreditation { level: string | null; domains: string[]; statement: string; + /** What the applicant said when reapplying, as it stood at decision time. */ + applicantResponse: string | null; + applicantSubmittedAt: string | null; reviewedByEName: string; issuerJwksUri: string; submissionEnvelopeId: string; - status: "active" | "superseded"; + /** The decision this one replaces for the same version, if any. */ + supersedes: string | null; jws: string; createdAt: string; } diff --git a/services/ppa/src/lib/server/submission-proof.ts b/services/ppa/src/lib/server/submission-proof.ts new file mode 100644 index 000000000..7ab1611f4 --- /dev/null +++ b/services/ppa/src/lib/server/submission-proof.ts @@ -0,0 +1,337 @@ +import { createHash } from "node:crypto"; +import { createRemoteJWKSet, jwtVerify } from "jose"; +import { registryUrl } from "./env"; +import type { PPASubmissionProof, PPASubmissionStatement } from "./ontology"; + +const PAYLOAD_PREFIX = "gitw3:ppa:v1:"; +const STATEMENT_TYPE = "w3ds.ppa.release-submission"; +const MAX_SIGNING_AGE_MS = 16 * 60 * 1000; +const BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const jwks = new Map>(); + +function string(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function strings(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map(string).filter(Boolean); +} + +function sameStrings(left: string[], right: string[]): boolean { + return ( + left.length === right.length && + left.every((value, i) => value === right[i]) + ); +} + +function decodeHex(value: string): Uint8Array { + if (value.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(value)) { + throw new Error("invalid hex value"); + } + return Uint8Array.from(Buffer.from(value, "hex")); +} + +function decodeBase58(value: string): Uint8Array { + const bytes: number[] = []; + for (const character of value) { + const digit = BASE58_ALPHABET.indexOf(character); + if (digit < 0) throw new Error("invalid base58 value"); + let carry = digit; + for (let i = 0; i < bytes.length; i += 1) { + carry += bytes[i] * 58; + bytes[i] = carry & 0xff; + carry >>= 8; + } + while (carry > 0) { + bytes.push(carry & 0xff); + carry >>= 8; + } + } + let leadingZeroes = 0; + for (const character of value) { + if (character !== "1") break; + leadingZeroes += 1; + } + return Uint8Array.from([ + ...Array.from({ length: leadingZeroes }, () => 0), + ...bytes.reverse(), + ]); +} + +function decodePublicKey(value: string): Uint8Array { + if (/^0x[0-9a-f]+$/i.test(value)) return decodeHex(value.slice(2)); + if (value.startsWith("f")) return decodeHex(value.slice(1)); + if (value.startsWith("m")) + return Uint8Array.from(Buffer.from(value.slice(1), "base64")); + if (!value.startsWith("z")) + return Uint8Array.from(Buffer.from(value, "base64")); + const encoded = value.slice(1); + if (/^[0-9a-f]+$/i.test(encoded) && encoded.length % 2 === 0) { + return decodeHex(encoded); + } + return decodeBase58(encoded); +} + +function arrayBuffer(value: Uint8Array): ArrayBuffer { + return Uint8Array.from(value).buffer; +} + +function looksLikeDerSignature(value: Uint8Array): boolean { + if (value.length < 8 || value[0] !== 0x30 || value[1] !== value.length - 2) + return false; + const rLength = value[3]; + if (value[2] !== 0x02 || 4 + rLength >= value.length) return false; + if (value[4 + rLength] !== 0x02) return false; + const sLength = value[5 + rLength]; + return 6 + rLength + sLength === value.length; +} + +function derSignatureToRaw(value: Uint8Array): Uint8Array { + if (!looksLikeDerSignature(value)) return value; + const rLength = value[3]; + const r = value.slice(4, 4 + rLength); + const sLength = value[5 + rLength]; + const s = value.slice(6 + rLength, 6 + rLength + sLength); + const raw = new Uint8Array(64); + const normalizedR = r[0] === 0 ? r.slice(1) : r; + const normalizedS = s[0] === 0 ? s.slice(1) : s; + if (normalizedR.length > 32 || normalizedS.length > 32) { + throw new Error("invalid ECDSA signature integers"); + } + raw.set(normalizedR, 32 - normalizedR.length); + raw.set(normalizedS, 64 - normalizedS.length); + return raw; +} + +function signatureCandidates(value: string): Uint8Array[] { + const candidates: Uint8Array[] = []; + try { + candidates.push(Uint8Array.from(Buffer.from(value, "base64url"))); + } catch { + // Try the multibase representation below. + } + if (value.startsWith("z")) { + try { + candidates.push(decodeBase58(value.slice(1))); + } catch { + // The value may simply be a base64 signature beginning with z. + } + } + return candidates; +} + +function parseStatement(value: unknown): PPASubmissionStatement | null { + if (!value || typeof value !== "object") return null; + const raw = value as Record; + const statement: PPASubmissionStatement = { + type: string(raw.type) as PPASubmissionStatement["type"], + schemaVersion: Number(raw.schemaVersion) as 1, + repositoryId: Number(raw.repositoryId), + repository: string(raw.repository), + platformEName: string(raw.platformEName), + platformName: string(raw.platformName), + releaseTag: string(raw.releaseTag), + version: string(raw.version), + manifestCommitId: string(raw.manifestCommitId), + domains: strings(raw.domains), + signerEName: string(raw.signerEName), + issuedAt: string(raw.issuedAt), + nonce: string(raw.nonce), + }; + const previousDecision = string(raw.previousDecision); + const previousDecisionAt = string(raw.previousDecisionAt); + if (previousDecision || previousDecisionAt) { + if (previousDecision !== "denied" || !previousDecisionAt) return null; + statement.previousDecision = "denied"; + statement.previousDecisionAt = previousDecisionAt; + } + const responseToDecision = string(raw.responseToDecision); + if (responseToDecision) { + if ( + statement.previousDecision !== "denied" || + Array.from(responseToDecision).length > 2048 + ) { + return null; + } + statement.responseToDecision = responseToDecision; + } + if ( + statement.type !== STATEMENT_TYPE || + statement.schemaVersion !== 1 || + !Number.isSafeInteger(statement.repositoryId) || + statement.repositoryId <= 0 || + !statement.repository || + !statement.platformEName.startsWith("@") || + !statement.platformName || + !statement.releaseTag || + !statement.version || + !statement.manifestCommitId || + statement.domains.length === 0 || + !statement.signerEName.startsWith("@") || + !statement.nonce + ) { + return null; + } + return statement; +} + +function canonicalPayload(statement: PPASubmissionStatement): string { + const digest = createHash("sha256") + .update(JSON.stringify(statement)) + .digest("base64url"); + return `${PAYLOAD_PREFIX}${digest}`; +} + +async function verifyWalletSignature( + proof: PPASubmissionProof, +): Promise { + const verifiedAt = new Date(proof.verifiedAt); + const registry = registryUrl(); + const jwksUrl = new URL("/.well-known/jwks.json", registry).toString(); + let registryKeys = jwks.get(jwksUrl); + if (!registryKeys) { + registryKeys = createRemoteJWKSet(new URL(jwksUrl)); + jwks.set(jwksUrl, registryKeys); + } + const { payload } = await jwtVerify( + proof.keyBindingCertificate, + registryKeys, + { + algorithms: ["ES256"], + currentDate: verifiedAt, + requiredClaims: ["exp"], + }, + ); + const certificateEName = string( + payload.ename ?? payload.eName ?? payload.w3id, + ); + const certificateKey = string(payload.publicKey); + if ( + certificateEName !== proof.statement.signerEName || + !certificateKey || + certificateKey !== proof.publicKey + ) { + return false; + } + + const keyBytes = decodePublicKey(proof.publicKey); + const key = await crypto.subtle.importKey( + keyBytes.length === 65 && keyBytes[0] === 0x04 ? "raw" : "spki", + arrayBuffer(keyBytes), + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + const encodedPayload = arrayBuffer(new TextEncoder().encode(proof.payload)); + for (const candidate of signatureCandidates(proof.signature)) { + try { + if ( + await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + arrayBuffer(derSignatureToRaw(candidate)), + encodedPayload, + ) + ) { + return true; + } + } catch { + // Try the next supported signature encoding. + } + } + return false; +} + +/** + * Validates the durable release evidence independently of GitW3. The + * certificate is checked at the original verification time so a legitimate + * historical proof remains auditable after its short-lived certificate ends. + */ +export async function verifySubmissionProof( + value: unknown, + profile: Record, + ename: string, + requestedDomains: string[], +): Promise { + if (!value || typeof value !== "object") return null; + const raw = value as Record; + const statement = parseStatement(raw.statement); + if (!statement) return null; + const proof: PPASubmissionProof = { + statement, + payload: string(raw.payload), + signature: string(raw.signature), + publicKey: string(raw.publicKey), + keyBindingCertificate: string(raw.keyBindingCertificate), + verifiedAt: string(raw.verifiedAt), + }; + const issuedAt = Date.parse(statement.issuedAt); + const verifiedAt = Date.parse(proof.verifiedAt); + if ( + statement.platformEName !== ename || + statement.platformName !== string(profile.platformName) || + statement.version !== string(profile.version) || + statement.version !== string(profile.submissionVersion) || + !sameStrings(statement.domains, requestedDomains) || + proof.payload !== canonicalPayload(statement) || + !proof.signature || + !proof.publicKey || + !proof.keyBindingCertificate || + !Number.isFinite(issuedAt) || + !Number.isFinite(verifiedAt) || + verifiedAt < issuedAt || + verifiedAt - issuedAt > MAX_SIGNING_AGE_MS || + verifiedAt > Date.now() + 5 * 60 * 1000 + ) { + return null; + } + return (await verifyWalletSignature(proof)) ? proof : null; +} + +/** Independently verifies every historical signed submission retained by a platform. */ +export async function verifySubmissionHistory( + value: unknown, + profile: Record, + ename: string, +): Promise { + if (!Array.isArray(value)) return []; + const verified: PPASubmissionProof[] = []; + const seen = new Set(); + for (const rawProof of value.slice(0, 100)) { + if (!rawProof || typeof rawProof !== "object") continue; + const statement = parseStatement( + (rawProof as Record).statement, + ); + if (!statement) continue; + const proof = await verifySubmissionProof( + rawProof, + { + ...profile, + version: statement.version, + submissionVersion: statement.version, + }, + ename, + statement.domains, + ); + if (!proof || seen.has(proof.payload)) continue; + seen.add(proof.payload); + verified.push(proof); + } + return verified.sort((a, b) => + a.statement.issuedAt.localeCompare(b.statement.issuedAt), + ); +} + +export function submissionSupersedesDecision( + proof: PPASubmissionProof, + decision: { decision: "granted" | "denied"; createdAt: string }, +): boolean { + return ( + decision.decision === "denied" && + proof.statement.previousDecision === "denied" && + proof.statement.previousDecisionAt === decision.createdAt && + Date.parse(proof.verifiedAt) > Date.parse(decision.createdAt) + ); +} diff --git a/services/ppa/src/routes/+page.server.ts b/services/ppa/src/routes/+page.server.ts index aa004406a..622b1757f 100644 --- a/services/ppa/src/routes/+page.server.ts +++ b/services/ppa/src/routes/+page.server.ts @@ -5,6 +5,7 @@ import { isReadConfigured, listSubmissions, } from "$lib/server/aaas"; +import { submissionSupersedesDecision } from "$lib/server/submission-proof"; /** * The review queue. Submissions come from AaaS; the decision badge comes from @@ -45,10 +46,18 @@ export const load: PageServerLoad = async () => { submissions: submissions.map((submission) => { // Scoped to the submitted version: an older version's decision // says nothing about the one being offered now. - const decision = + const recordedDecision = decided.get( accreditationKey(submission.ename, submission.version), ) ?? null; + const decision = + recordedDecision && + !submissionSupersedesDecision( + submission.submissionProof, + recordedDecision, + ) + ? recordedDecision + : null; return { ...submission, decision: decision diff --git a/services/ppa/src/routes/decisions/+page.server.ts b/services/ppa/src/routes/decisions/+page.server.ts index f3077b1a9..bf080793c 100644 --- a/services/ppa/src/routes/decisions/+page.server.ts +++ b/services/ppa/src/routes/decisions/+page.server.ts @@ -1,5 +1,9 @@ import type { PageServerLoad } from "./$types"; -import { isReadConfigured, listAccreditations } from "$lib/server/aaas"; +import { + accreditationKey, + isReadConfigured, + listAccreditations, +} from "$lib/server/aaas"; import { listDomains } from "$lib/server/domains"; export const load: PageServerLoad = async () => { @@ -11,10 +15,25 @@ export const load: PageServerLoad = async () => { } try { - const [accreditations, domains] = await Promise.all([ + const [records, domains] = await Promise.all([ listAccreditations(), listDomains(), ]); + + // Records are newest first, so the first one seen for a platform and + // version is the one in force; anything later in the list for that + // same key was replaced by a reapplication. + const seen = new Set(); + const accreditations = records.map((record) => { + const key = accreditationKey( + record.platformEName, + record.platformVersion, + ); + const inForce = !seen.has(key); + seen.add(key); + return { ...record, inForce }; + }); + return { accreditations, domains, loadError: null, connected: true }; } catch (error) { console.error("[ppa] failed loading accreditations:", error); diff --git a/services/ppa/src/routes/decisions/+page.svelte b/services/ppa/src/routes/decisions/+page.svelte index 00b61e206..58288c68c 100644 --- a/services/ppa/src/routes/decisions/+page.svelte +++ b/services/ppa/src/routes/decisions/+page.svelte @@ -67,7 +67,7 @@ {:else}
      {#each data.accreditations as record (record.accreditationId)} -
    • +
    • @@ -79,7 +79,12 @@

      {record.platformEName}

      -

      {record.createdAt.slice(0, 10)}

      +
      +

      {record.createdAt.slice(0, 10)}

      + {#if !record.inForce} +

      Replaced

      + {/if} +
      {#if record.domains?.length} diff --git a/services/ppa/src/routes/submissions/[ename]/+page.server.ts b/services/ppa/src/routes/submissions/[ename]/+page.server.ts index 5cddaa78b..9a75caa38 100644 --- a/services/ppa/src/routes/submissions/[ename]/+page.server.ts +++ b/services/ppa/src/routes/submissions/[ename]/+page.server.ts @@ -4,6 +4,7 @@ import type { Actions, PageServerLoad } from "./$types"; import { accreditationKey, currentAccreditations, + listAccreditations, findMessenger, getAuthors, listSubmissions, @@ -12,24 +13,55 @@ import { storeAccreditation } from "$lib/server/evault"; import { jwksUri, signAccreditation } from "$lib/server/jwt"; import { type Accreditation, isAccessLevel } from "$lib/server/ontology"; import { listDomains, validDomains } from "$lib/server/domains"; +import { repositoryBaseUrl } from "$lib/server/env"; +import { submissionSupersedesDecision } from "$lib/server/submission-proof"; export const load: PageServerLoad = async ({ params }) => { const ename = decodeURIComponent(params.ename); - const [submissions, messenger, decided, domains] = await Promise.all([ - listSubmissions(), - findMessenger(), - currentAccreditations().catch(() => new Map()), - listDomains(), - ]); + const [submissions, messenger, decided, domains, allDecisions] = + await Promise.all([ + listSubmissions(), + findMessenger(), + currentAccreditations().catch(() => new Map()), + listDomains(), + listAccreditations().catch(() => [] as Accreditation[]), + ]); const submission = submissions.find((s) => s.ename === ename); if (!submission) { throw error(404, "This platform isn't awaiting review."); } + const recordedDecision = + decided.get(accreditationKey(ename, submission.version)) ?? null; + // Every decision ever taken on this platform, oldest first, so the page + // can show the exchange rather than only its latest turn. + const history = allDecisions + .filter((d) => d.platformEName === ename) + .sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)); + + // Built here so the page never has to know about configuration. + const base = repositoryBaseUrl(); + const repository = submission.submissionProof.statement.repository; + let repositoryUrl: string | null = null; + if (base && repository) { + try { + repositoryUrl = new URL( + repository.replace(/^\/+/, ""), + base.endsWith("/") ? base : `${base}/`, + ).toString(); + } catch { + console.warn( + `[ppa] PPA_REPOSITORY_BASE_URL is not a usable base URL: ${base}`, + ); + } + } + return { submission, + history, + repositoryUrl, authors: await getAuthors(submission.authorEnames, messenger), messengerConfigured: messenger !== null, domains, @@ -38,7 +70,13 @@ export const load: PageServerLoad = async ({ params }) => { submission.requestedDomains.includes(d.id), ), currentDecision: - decided.get(accreditationKey(ename, submission.version)) ?? null, + recordedDecision && + !submissionSupersedesDecision( + submission.submissionProof, + recordedDecision, + ) + ? recordedDecision + : null, }; }; @@ -104,6 +142,16 @@ export const actions: Actions = { const level = decision === "granted" ? (rawLevel as string) : null; const accreditationId = randomUUID(); + // A version can be refused and reapply, so name the decision this one + // replaces instead of leaving the order to be inferred. + const applicantResponse = + submission.submissionProof.statement.responseToDecision?.trim() || null; + const applicantSubmittedAt = + submission.submissionProof.statement.issuedAt ?? null; + const previous = + (await currentAccreditations().catch( + () => new Map(), + )).get(accreditationKey(ename, submission.version)) ?? null; try { const jws = await signAccreditation({ @@ -117,6 +165,8 @@ export const actions: Actions = { statement, reviewedByEName: reviewer, submissionEnvelopeId: submission.submissionEnvelopeId, + supersedes: previous?.accreditationId ?? null, + applicantResponse, }); const accreditation: Accreditation = { @@ -131,7 +181,9 @@ export const actions: Actions = { reviewedByEName: reviewer, issuerJwksUri: jwksUri(), submissionEnvelopeId: submission.submissionEnvelopeId, - status: "active", + supersedes: previous?.accreditationId ?? null, + applicantResponse, + applicantSubmittedAt, jws, createdAt: new Date().toISOString(), }; diff --git a/services/ppa/src/routes/submissions/[ename]/+page.svelte b/services/ppa/src/routes/submissions/[ename]/+page.svelte index 3dd86bf9e..963a9f184 100644 --- a/services/ppa/src/routes/submissions/[ename]/+page.svelte +++ b/services/ppa/src/routes/submissions/[ename]/+page.svelte @@ -2,6 +2,7 @@ import { enhance } from "$app/forms"; import { ACCESS_LEVELS } from "$lib/levels"; import DomainChips from "$lib/DomainChips.svelte"; + import ReviewThread from "$lib/ReviewThread.svelte"; import PlatformMark from "$lib/PlatformMark.svelte"; import StatusPill from "$lib/StatusPill.svelte"; @@ -40,6 +41,7 @@ { label: "Category", value: data.submission.category }, { label: "Version", value: data.submission.version || "—" }, { label: "Submitted", value: data.submission.submittedAt.slice(0, 10) }, + { label: "Signed by", value: data.submission.submissionProof.statement.signerEName }, ]); @@ -93,6 +95,25 @@
      {fact.value}
    {/each} +
    +
    Repository
    +
    + {#if data.repositoryUrl} + + {data.submission.submissionProof.statement.repository} + + {:else} + + {data.submission.submissionProof.statement.repository} + + {/if} +
    +
    URL
    @@ -121,6 +142,14 @@
    {/if} +
    +

    Owner/admin signature verified

    +

    + This exact release statement and its Registry-backed wallet proof were read from + the platform's eVault. +

    +
    +
    + +

    Authors