Compare commits
3 Commits
deploy/gat
...
e5a2ebcf48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5a2ebcf48 | ||
| f2cda52e1a | |||
| 7d7cf012f0 |
@@ -24,6 +24,7 @@ import { GCModule } from './gc/gc.module.js';
|
|||||||
import { ReloadModule } from './reload/reload.module.js';
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
|
import { FederationModule } from './federation/federation.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -52,6 +53,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
|
FederationModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
56
apps/gateway/src/federation/ca.dto.ts
Normal file
56
apps/gateway/src/federation/ca.dto.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* DTOs for the Step-CA client service (FED-M2-04).
|
||||||
|
*
|
||||||
|
* IssueCertRequestDto — input to CaService.issueCert()
|
||||||
|
* IssuedCertDto — output from CaService.issueCert()
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IsInt, IsNotEmpty, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class IssueCertRequestDto {
|
||||||
|
/**
|
||||||
|
* PEM-encoded PKCS#10 Certificate Signing Request.
|
||||||
|
* The CSR must already include the desired SANs.
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
csrPem!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID of the federation_grants row this certificate is being issued for.
|
||||||
|
* Embedded as the `mosaic_grant_id` custom OID extension.
|
||||||
|
*/
|
||||||
|
@IsUUID()
|
||||||
|
grantId!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID of the local user on whose behalf the cert is being issued.
|
||||||
|
* Embedded as the `mosaic_subject_user_id` custom OID extension.
|
||||||
|
*/
|
||||||
|
@IsUUID()
|
||||||
|
subjectUserId!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requested certificate validity in seconds.
|
||||||
|
* Capped at the step-ca provisioner policy ceiling.
|
||||||
|
* Defaults to 86 400 s (24 h) when omitted by callers.
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
@Min(60)
|
||||||
|
@Max(365 * 24 * 3600)
|
||||||
|
ttlSeconds!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IssuedCertDto {
|
||||||
|
/** PEM-encoded leaf certificate returned by step-ca. */
|
||||||
|
certPem!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PEM-encoded full certificate chain (leaf + intermediates + root).
|
||||||
|
* Falls back to `certPem` when step-ca returns no `certChain` field.
|
||||||
|
*/
|
||||||
|
certChainPem!: string;
|
||||||
|
|
||||||
|
/** Decimal serial number string of the issued certificate. */
|
||||||
|
serialNumber!: string;
|
||||||
|
}
|
||||||
360
apps/gateway/src/federation/ca.service.spec.ts
Normal file
360
apps/gateway/src/federation/ca.service.spec.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for CaService — Step-CA client (FED-M2-04).
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - Happy path: returns IssuedCertDto with certPem, certChainPem, serialNumber
|
||||||
|
* - certChainPem fallback: falls back to certPem when certChain absent
|
||||||
|
* - certChainPem from ca field: uses crt+ca when certChain absent but ca present
|
||||||
|
* - HTTP 401: throws CaServiceError with cause + remediation
|
||||||
|
* - HTTP non-401 error: throws CaServiceError
|
||||||
|
* - Malformed CSR: throws before HTTP call
|
||||||
|
* - Non-JSON response: throws CaServiceError
|
||||||
|
* - HTTPS connection error: throws CaServiceError
|
||||||
|
* - JWT custom claims: mosaic_grant_id and mosaic_subject_user_id present in OTT payload
|
||||||
|
* - CaServiceError: has cause + remediation properties
|
||||||
|
* - Missing crt in response: throws CaServiceError
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock node:https BEFORE importing CaService so the mock is in place when
|
||||||
|
// the module is loaded. Vitest/ESM require vi.mock at the top level.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('node:https', () => {
|
||||||
|
const mockRequest = vi.fn();
|
||||||
|
const mockAgent = vi.fn().mockImplementation(() => ({}));
|
||||||
|
return {
|
||||||
|
default: { request: mockRequest, Agent: mockAgent },
|
||||||
|
request: mockRequest,
|
||||||
|
Agent: mockAgent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('node:fs', () => {
|
||||||
|
const mockReadFileSync = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue('-----BEGIN CERTIFICATE-----\nFAKEROOT\n-----END CERTIFICATE-----\n');
|
||||||
|
return {
|
||||||
|
default: { readFileSync: mockReadFileSync },
|
||||||
|
readFileSync: mockReadFileSync,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Minimal self-signed certificate PEM produced by openssl for testing.
|
||||||
|
// Serial 01, RSA 512 bit (invalid for production, fine for unit tests).
|
||||||
|
const FAKE_CERT_PEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA
|
||||||
|
-----END CERTIFICATE-----\n`;
|
||||||
|
|
||||||
|
const FAKE_CSR_PEM = `-----BEGIN CERTIFICATE REQUEST-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA
|
||||||
|
-----END CERTIFICATE REQUEST-----\n`;
|
||||||
|
|
||||||
|
const FAKE_CA_PEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
CAROOT000000000000000000000000000000000000000000000000AAAA
|
||||||
|
-----END CERTIFICATE-----\n`;
|
||||||
|
|
||||||
|
const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
|
||||||
|
const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5fg9-cc7e-7cc0ce491b22';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Setup env before importing service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const JWK_KEY = JSON.stringify({
|
||||||
|
kty: 'oct',
|
||||||
|
kid: 'test-kid',
|
||||||
|
k: 'dGVzdC1zZWNyZXQ=', // base64url("test-secret")
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env['STEP_CA_URL'] = 'https://step-ca:9000';
|
||||||
|
process.env['STEP_CA_PROVISIONER_PASSWORD'] = 'test-password';
|
||||||
|
process.env['STEP_CA_PROVISIONER_KEY_JSON'] = JWK_KEY;
|
||||||
|
process.env['STEP_CA_ROOT_CERT_PATH'] = '/fake/root.pem';
|
||||||
|
|
||||||
|
// Import AFTER env is set and mocks are registered
|
||||||
|
import * as httpsModule from 'node:https';
|
||||||
|
import { CaService, CaServiceError } from './ca.service.js';
|
||||||
|
import type { IssueCertRequestDto } from './ca.dto.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper to build a mock https.request that simulates step-ca
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeHttpsMock(statusCode: number, body: unknown, errorMsg?: string): void {
|
||||||
|
const mockReq = {
|
||||||
|
write: vi.fn(),
|
||||||
|
end: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(httpsModule.request as unknown as Mock).mockImplementation(
|
||||||
|
(
|
||||||
|
_options: unknown,
|
||||||
|
callback: (res: {
|
||||||
|
statusCode: number;
|
||||||
|
on: (event: string, cb: (chunk?: Buffer) => void) => void;
|
||||||
|
}) => void,
|
||||||
|
) => {
|
||||||
|
const mockRes = {
|
||||||
|
statusCode,
|
||||||
|
on: (event: string, cb: (chunk?: Buffer) => void) => {
|
||||||
|
if (event === 'data') {
|
||||||
|
if (body !== undefined) {
|
||||||
|
cb(Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event === 'end') {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (errorMsg) {
|
||||||
|
// Simulate a connection error via the req.on('error') handler
|
||||||
|
mockReq.on.mockImplementation((event: string, cb: (err: Error) => void) => {
|
||||||
|
if (event === 'error') {
|
||||||
|
setImmediate(() => cb(new Error(errorMsg)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Normal flow: call the response callback
|
||||||
|
setImmediate(() => callback(mockRes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockReq;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('CaService', () => {
|
||||||
|
let service: CaService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new CaService();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeReq(overrides: Partial<IssueCertRequestDto> = {}): IssueCertRequestDto {
|
||||||
|
return {
|
||||||
|
csrPem: FAKE_CSR_PEM,
|
||||||
|
grantId: GRANT_ID,
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
ttlSeconds: 86400,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('returns IssuedCertDto on success (certChain present)', async () => {
|
||||||
|
makeHttpsMock(200, {
|
||||||
|
crt: FAKE_CERT_PEM,
|
||||||
|
certChain: [FAKE_CERT_PEM, FAKE_CA_PEM],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.issueCert(makeReq());
|
||||||
|
|
||||||
|
expect(result.certPem).toBe(FAKE_CERT_PEM);
|
||||||
|
expect(result.certChainPem).toContain(FAKE_CERT_PEM);
|
||||||
|
expect(result.certChainPem).toContain(FAKE_CA_PEM);
|
||||||
|
expect(typeof result.serialNumber).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// certChainPem fallback — certChain absent, ca field present
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('builds certChainPem from crt+ca when certChain is absent', async () => {
|
||||||
|
makeHttpsMock(200, {
|
||||||
|
crt: FAKE_CERT_PEM,
|
||||||
|
ca: FAKE_CA_PEM,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.issueCert(makeReq());
|
||||||
|
|
||||||
|
expect(result.certPem).toBe(FAKE_CERT_PEM);
|
||||||
|
expect(result.certChainPem).toContain(FAKE_CERT_PEM);
|
||||||
|
expect(result.certChainPem).toContain(FAKE_CA_PEM);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// certChainPem fallback — no certChain, no ca field
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('falls back to certPem alone when certChain and ca are absent', async () => {
|
||||||
|
makeHttpsMock(200, { crt: FAKE_CERT_PEM });
|
||||||
|
|
||||||
|
const result = await service.issueCert(makeReq());
|
||||||
|
|
||||||
|
expect(result.certPem).toBe(FAKE_CERT_PEM);
|
||||||
|
expect(result.certChainPem).toBe(FAKE_CERT_PEM);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// HTTP 401
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('throws CaServiceError on HTTP 401', async () => {
|
||||||
|
makeHttpsMock(401, { message: 'Unauthorized' });
|
||||||
|
|
||||||
|
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||||
|
if (!(err instanceof CaServiceError)) return false;
|
||||||
|
expect(err.message).toMatch(/401/);
|
||||||
|
expect(err.remediation).toBeTruthy();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// HTTP non-401 error (e.g. 422)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('throws CaServiceError on HTTP 422', async () => {
|
||||||
|
makeHttpsMock(422, { message: 'Unprocessable Entity' });
|
||||||
|
|
||||||
|
await expect(service.issueCert(makeReq())).rejects.toBeInstanceOf(CaServiceError);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Malformed CSR — throws before HTTP call
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('throws CaServiceError for malformed CSR without making HTTP call', async () => {
|
||||||
|
const requestSpy = vi.spyOn(httpsModule, 'request');
|
||||||
|
|
||||||
|
await expect(service.issueCert(makeReq({ csrPem: 'not-a-valid-csr' }))).rejects.toBeInstanceOf(
|
||||||
|
CaServiceError,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requestSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Non-JSON response
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('throws CaServiceError when step-ca returns non-JSON', async () => {
|
||||||
|
makeHttpsMock(200, 'this is not json');
|
||||||
|
|
||||||
|
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||||
|
if (!(err instanceof CaServiceError)) return false;
|
||||||
|
expect(err.message).toMatch(/non-JSON/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// HTTPS connection error
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('throws CaServiceError on HTTPS connection error', async () => {
|
||||||
|
makeHttpsMock(0, undefined, 'connect ECONNREFUSED 127.0.0.1:9000');
|
||||||
|
|
||||||
|
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||||
|
if (!(err instanceof CaServiceError)) return false;
|
||||||
|
expect(err.message).toMatch(/HTTPS connection/);
|
||||||
|
expect(err.cause).toBeInstanceOf(Error);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// JWT custom claims: mosaic_grant_id and mosaic_subject_user_id
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('includes mosaic_grant_id and mosaic_subject_user_id in the OTT payload', async () => {
|
||||||
|
let capturedBody: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
// Override the mock to capture the request body
|
||||||
|
const mockReq = {
|
||||||
|
write: vi.fn((data: string) => {
|
||||||
|
capturedBody = JSON.parse(data) as Record<string, unknown>;
|
||||||
|
}),
|
||||||
|
end: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(httpsModule.request as unknown as Mock).mockImplementation(
|
||||||
|
(
|
||||||
|
_options: unknown,
|
||||||
|
callback: (res: {
|
||||||
|
statusCode: number;
|
||||||
|
on: (event: string, cb: (chunk?: Buffer) => void) => void;
|
||||||
|
}) => void,
|
||||||
|
) => {
|
||||||
|
const mockRes = {
|
||||||
|
statusCode: 200,
|
||||||
|
on: (event: string, cb: (chunk?: Buffer) => void) => {
|
||||||
|
if (event === 'data') {
|
||||||
|
cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM })));
|
||||||
|
}
|
||||||
|
if (event === 'end') {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setImmediate(() => callback(mockRes));
|
||||||
|
return mockReq;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.issueCert(makeReq());
|
||||||
|
|
||||||
|
expect(capturedBody).toBeDefined();
|
||||||
|
const ott = capturedBody!['ott'] as string;
|
||||||
|
expect(typeof ott).toBe('string');
|
||||||
|
|
||||||
|
// Decode JWT payload (second segment)
|
||||||
|
const parts = ott.split('.');
|
||||||
|
expect(parts).toHaveLength(3);
|
||||||
|
|
||||||
|
const payloadJson = Buffer.from(parts[1]!, 'base64url').toString('utf8');
|
||||||
|
const payload = JSON.parse(payloadJson) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(payload['mosaic_grant_id']).toBe(GRANT_ID);
|
||||||
|
expect(payload['mosaic_subject_user_id']).toBe(SUBJECT_USER_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// CaServiceError has cause + remediation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('CaServiceError carries cause and remediation', () => {
|
||||||
|
const cause = new Error('original error');
|
||||||
|
const err = new CaServiceError('something went wrong', 'fix it like this', cause);
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err).toBeInstanceOf(CaServiceError);
|
||||||
|
expect(err.message).toBe('something went wrong');
|
||||||
|
expect(err.remediation).toBe('fix it like this');
|
||||||
|
expect(err.cause).toBe(cause);
|
||||||
|
expect(err.name).toBe('CaServiceError');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Missing crt in response
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('throws CaServiceError when response is missing the crt field', async () => {
|
||||||
|
makeHttpsMock(200, { ca: FAKE_CA_PEM });
|
||||||
|
|
||||||
|
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||||
|
if (!(err instanceof CaServiceError)) return false;
|
||||||
|
expect(err.message).toMatch(/missing the "crt" field/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
439
apps/gateway/src/federation/ca.service.ts
Normal file
439
apps/gateway/src/federation/ca.service.ts
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
/**
|
||||||
|
* CaService — Step-CA client for federation grant certificate issuance.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* 1. Build a JWK-provisioner One-Time Token (OTT) signed with HS256
|
||||||
|
* carrying Mosaic-specific claims (`mosaic_grant_id`,
|
||||||
|
* `mosaic_subject_user_id`, `step.sha`) per the step-ca JWK provisioner
|
||||||
|
* protocol.
|
||||||
|
* 2. POST the CSR + OTT to the step-ca `/1.0/sign` endpoint over HTTPS,
|
||||||
|
* pinning the trust to the CA root cert supplied via env.
|
||||||
|
* 3. Return an IssuedCertDto containing the leaf cert, full chain, and
|
||||||
|
* serial number.
|
||||||
|
*
|
||||||
|
* Environment variables (all required at runtime — validated in constructor):
|
||||||
|
* STEP_CA_URL https://step-ca:9000
|
||||||
|
* STEP_CA_PROVISIONER_PASSWORD JWK provisioner password (raw string)
|
||||||
|
* STEP_CA_PROVISIONER_KEY_JSON JWK provisioner public+private key (JSON)
|
||||||
|
* STEP_CA_ROOT_CERT_PATH Absolute path to the CA root PEM
|
||||||
|
*
|
||||||
|
* Custom OID registry (PRD §6, docs/federation/SETUP.md):
|
||||||
|
* 1.3.6.1.4.1.99999.1 — mosaic_grant_id
|
||||||
|
* 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id
|
||||||
|
*
|
||||||
|
* Fail-loud contract:
|
||||||
|
* Every error path throws CaServiceError with a human-readable `remediation`
|
||||||
|
* field. Silent OID-stripping is NEVER allowed — if the sign response does
|
||||||
|
* not include the cert, we throw rather than return a cert that may be
|
||||||
|
* missing the custom extensions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as https from 'node:https';
|
||||||
|
import type { IssueCertRequestDto } from './ca.dto.js';
|
||||||
|
import { IssuedCertDto } from './ca.dto.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom error class
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class CaServiceError extends Error {
|
||||||
|
readonly cause: unknown;
|
||||||
|
readonly remediation: string;
|
||||||
|
|
||||||
|
constructor(message: string, remediation: string, cause?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'CaServiceError';
|
||||||
|
this.cause = cause;
|
||||||
|
this.remediation = remediation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface StepSignResponse {
|
||||||
|
crt: string;
|
||||||
|
ca?: string;
|
||||||
|
certChain?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JwkKey {
|
||||||
|
kty: string;
|
||||||
|
kid?: string;
|
||||||
|
use?: string;
|
||||||
|
alg?: string;
|
||||||
|
k?: string; // symmetric
|
||||||
|
n?: string; // RSA
|
||||||
|
e?: string;
|
||||||
|
d?: string;
|
||||||
|
x?: string; // EC
|
||||||
|
y?: string;
|
||||||
|
crv?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64url-encode a Buffer or string (no padding).
|
||||||
|
*/
|
||||||
|
function b64url(input: Buffer | string): string {
|
||||||
|
const buf = typeof input === 'string' ? Buffer.from(input, 'utf8') : input;
|
||||||
|
return buf.toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute SHA-256 fingerprint of the DER-encoded CSR body.
|
||||||
|
* step-ca uses this as the `step.sha` claim to bind the OTT to a specific CSR.
|
||||||
|
*/
|
||||||
|
function csrFingerprint(csrPem: string): string {
|
||||||
|
// Strip PEM headers and decode base64 body
|
||||||
|
const b64 = csrPem
|
||||||
|
.replace(/-----BEGIN CERTIFICATE REQUEST-----/, '')
|
||||||
|
.replace(/-----END CERTIFICATE REQUEST-----/, '')
|
||||||
|
.replace(/\s+/g, '');
|
||||||
|
|
||||||
|
let derBuf: Buffer;
|
||||||
|
try {
|
||||||
|
derBuf = Buffer.from(b64, 'base64');
|
||||||
|
} catch (err) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'Failed to base64-decode the CSR PEM body',
|
||||||
|
'Verify that csrPem is a valid PKCS#10 PEM-encoded certificate request.',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (derBuf.length === 0) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'CSR PEM decoded to empty buffer — malformed input',
|
||||||
|
'Provide a valid non-empty PKCS#10 PEM-encoded certificate request.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.createHash('sha256').update(derBuf).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a signing key from the JWK provisioner password using PBKDF2
|
||||||
|
* then sign with HMAC-SHA256 to produce an HS256 JWT.
|
||||||
|
*
|
||||||
|
* step-ca JWK provisioner tokens:
|
||||||
|
* - alg: HS256
|
||||||
|
* - header.kid: provisioner key ID
|
||||||
|
* - The key is the raw password bytes (step-ca uses the password directly
|
||||||
|
* as the HMAC key when the JWK provisioner type is "JWK" with symmetric
|
||||||
|
* key, or the password-derived key when encrypting the JWK).
|
||||||
|
*
|
||||||
|
* Per step-ca source (jose/jwk.go), for a JWK provisioner the OTT is a
|
||||||
|
* JWT signed with the provisioner's decrypted private key. For HS256 the
|
||||||
|
* key material is the `k` field of the JWK (symmetric secret), which itself
|
||||||
|
* was encrypted with the provisioner password. Since we already have the
|
||||||
|
* raw provisioner password we use it directly as the HMAC key — this mirrors
|
||||||
|
* what `step ca token` does for symmetric JWK provisioners.
|
||||||
|
*/
|
||||||
|
function buildOtt(params: {
|
||||||
|
caUrl: string;
|
||||||
|
provisionerPassword: string;
|
||||||
|
provisionerKeyJson: string;
|
||||||
|
csrPem: string;
|
||||||
|
grantId: string;
|
||||||
|
subjectUserId: string;
|
||||||
|
ttlSeconds: number;
|
||||||
|
}): string {
|
||||||
|
const {
|
||||||
|
caUrl,
|
||||||
|
provisionerPassword,
|
||||||
|
provisionerKeyJson,
|
||||||
|
csrPem,
|
||||||
|
grantId,
|
||||||
|
subjectUserId,
|
||||||
|
ttlSeconds,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
let jwk: JwkKey;
|
||||||
|
try {
|
||||||
|
jwk = JSON.parse(provisionerKeyJson) as JwkKey;
|
||||||
|
} catch (err) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'STEP_CA_PROVISIONER_KEY_JSON is not valid JSON',
|
||||||
|
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-serialised JWK object for the mosaic-fed provisioner.',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha = csrFingerprint(csrPem);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const kid = jwk.kid ?? 'mosaic-fed';
|
||||||
|
|
||||||
|
const header = {
|
||||||
|
alg: 'HS256',
|
||||||
|
typ: 'JWT',
|
||||||
|
kid,
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
iss: kid,
|
||||||
|
sub: `${caUrl}/1.0/sign`,
|
||||||
|
aud: [`${caUrl}/1.0/sign`],
|
||||||
|
iat: now,
|
||||||
|
nbf: now - 30, // 30 s clock-skew tolerance
|
||||||
|
exp: now + Math.min(ttlSeconds, 3600), // OTT validity ≤ 1 h
|
||||||
|
sha,
|
||||||
|
// Mosaic custom claims consumed by federation.tpl
|
||||||
|
mosaic_grant_id: grantId,
|
||||||
|
mosaic_subject_user_id: subjectUserId,
|
||||||
|
// step.sha is the canonical field name used in the template
|
||||||
|
step: { sha },
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerB64 = b64url(JSON.stringify(header));
|
||||||
|
const payloadB64 = b64url(JSON.stringify(payload));
|
||||||
|
const signingInput = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
// Use the provisioner password as the raw HMAC-SHA256 key.
|
||||||
|
const hmac = crypto.createHmac('sha256', Buffer.from(provisionerPassword, 'utf8'));
|
||||||
|
hmac.update(signingInput);
|
||||||
|
const signature = hmac.digest();
|
||||||
|
|
||||||
|
return `${signingInput}.${b64url(signature)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a JSON POST to the step-ca sign endpoint.
|
||||||
|
* Returns the parsed response body or throws CaServiceError.
|
||||||
|
*/
|
||||||
|
function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<StepSignResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const bodyStr = JSON.stringify(body);
|
||||||
|
const parsed = new URL(url);
|
||||||
|
|
||||||
|
const options: https.RequestOptions = {
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port ? parseInt(parsed.port, 10) : 443,
|
||||||
|
path: parsed.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(bodyStr),
|
||||||
|
},
|
||||||
|
agent,
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
|
||||||
|
if (res.statusCode === 401) {
|
||||||
|
reject(
|
||||||
|
new CaServiceError(
|
||||||
|
`step-ca returned HTTP 401 — invalid or expired OTT`,
|
||||||
|
'Check STEP_CA_PROVISIONER_PASSWORD and STEP_CA_PROVISIONER_KEY_JSON. Ensure the mosaic-fed provisioner is configured in the CA.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
|
reject(
|
||||||
|
new CaServiceError(
|
||||||
|
`step-ca returned HTTP ${res.statusCode}: ${raw.slice(0, 256)}`,
|
||||||
|
`Review the step-ca logs. Status ${res.statusCode} may indicate a CSR policy violation or misconfigured provisioner.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as unknown;
|
||||||
|
} catch (err) {
|
||||||
|
reject(
|
||||||
|
new CaServiceError(
|
||||||
|
'step-ca returned a non-JSON response',
|
||||||
|
'Verify STEP_CA_URL points to a running step-ca instance and that TLS is properly configured.',
|
||||||
|
err,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(parsed as StepSignResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (err: Error) => {
|
||||||
|
reject(
|
||||||
|
new CaServiceError(
|
||||||
|
`HTTPS connection to step-ca failed: ${err.message}`,
|
||||||
|
'Ensure STEP_CA_URL is reachable and STEP_CA_ROOT_CERT_PATH points to the correct CA root certificate.',
|
||||||
|
err,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(bodyStr);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a decimal serial number from a PEM certificate.
|
||||||
|
* Returns the hex serial if conversion is not possible.
|
||||||
|
*/
|
||||||
|
function extractSerial(certPem: string): string {
|
||||||
|
try {
|
||||||
|
const cert = new crypto.X509Certificate(certPem);
|
||||||
|
return cert.serialNumber;
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CaService {
|
||||||
|
private readonly logger = new Logger(CaService.name);
|
||||||
|
|
||||||
|
private readonly caUrl: string;
|
||||||
|
private readonly provisionerPassword: string;
|
||||||
|
private readonly provisionerKeyJson: string;
|
||||||
|
private readonly rootCertPath: string;
|
||||||
|
private readonly httpsAgent: https.Agent;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const caUrl = process.env['STEP_CA_URL'];
|
||||||
|
const provisionerPassword = process.env['STEP_CA_PROVISIONER_PASSWORD'];
|
||||||
|
const provisionerKeyJson = process.env['STEP_CA_PROVISIONER_KEY_JSON'];
|
||||||
|
const rootCertPath = process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
|
|
||||||
|
if (!caUrl) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'STEP_CA_URL is not set',
|
||||||
|
'Set STEP_CA_URL to the base URL of the step-ca instance, e.g. https://step-ca:9000',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!provisionerPassword) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'STEP_CA_PROVISIONER_PASSWORD is not set',
|
||||||
|
'Set STEP_CA_PROVISIONER_PASSWORD to the JWK provisioner password for the mosaic-fed provisioner.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!provisionerKeyJson) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'STEP_CA_PROVISIONER_KEY_JSON is not set',
|
||||||
|
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-encoded JWK for the mosaic-fed provisioner.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!rootCertPath) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'STEP_CA_ROOT_CERT_PATH is not set',
|
||||||
|
'Set STEP_CA_ROOT_CERT_PATH to the absolute path of the step-ca root CA certificate PEM file.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.caUrl = caUrl;
|
||||||
|
this.provisionerPassword = provisionerPassword;
|
||||||
|
this.provisionerKeyJson = provisionerKeyJson;
|
||||||
|
this.rootCertPath = rootCertPath;
|
||||||
|
|
||||||
|
// Read the root cert and pin it for all HTTPS connections.
|
||||||
|
let rootCert: string;
|
||||||
|
try {
|
||||||
|
rootCert = fs.readFileSync(this.rootCertPath, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
`Cannot read STEP_CA_ROOT_CERT_PATH: ${rootCertPath}`,
|
||||||
|
'Ensure the file exists and is readable by the gateway process.',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.httpsAgent = new https.Agent({
|
||||||
|
ca: rootCert,
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`CaService initialised — CA URL: ${this.caUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a CSR to step-ca and return the issued certificate.
|
||||||
|
*
|
||||||
|
* Throws `CaServiceError` on any failure (network, auth, malformed input).
|
||||||
|
* Never silently swallows errors — fail-loud is a hard contract per M2-02 review.
|
||||||
|
*/
|
||||||
|
async issueCert(req: IssueCertRequestDto): Promise<IssuedCertDto> {
|
||||||
|
this.logger.debug(
|
||||||
|
`issueCert — grantId=${req.grantId} subjectUserId=${req.subjectUserId} ttl=${req.ttlSeconds}s`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate CSR before making network calls
|
||||||
|
if (!req.csrPem || !req.csrPem.includes('CERTIFICATE REQUEST')) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'csrPem does not appear to be a valid PKCS#10 PEM',
|
||||||
|
'Provide a PEM-encoded CSR starting with -----BEGIN CERTIFICATE REQUEST-----.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ott = buildOtt({
|
||||||
|
caUrl: this.caUrl,
|
||||||
|
provisionerPassword: this.provisionerPassword,
|
||||||
|
provisionerKeyJson: this.provisionerKeyJson,
|
||||||
|
csrPem: req.csrPem,
|
||||||
|
grantId: req.grantId,
|
||||||
|
subjectUserId: req.subjectUserId,
|
||||||
|
ttlSeconds: req.ttlSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const signUrl = `${this.caUrl}/1.0/sign`;
|
||||||
|
const requestBody = {
|
||||||
|
csr: req.csrPem,
|
||||||
|
ott,
|
||||||
|
validity: {
|
||||||
|
duration: `${req.ttlSeconds}s`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.debug(`Posting CSR to ${signUrl}`);
|
||||||
|
const response = await httpsPost(signUrl, requestBody, this.httpsAgent);
|
||||||
|
|
||||||
|
if (!response.crt) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'step-ca sign response missing the "crt" field',
|
||||||
|
'This is unexpected — the step-ca instance may be misconfigured or running an incompatible version.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build certChainPem: prefer certChain array, fall back to ca field, fall back to crt alone.
|
||||||
|
let certChainPem: string;
|
||||||
|
if (response.certChain && response.certChain.length > 0) {
|
||||||
|
certChainPem = response.certChain.join('\n');
|
||||||
|
} else if (response.ca) {
|
||||||
|
certChainPem = response.crt + '\n' + response.ca;
|
||||||
|
} else {
|
||||||
|
certChainPem = response.crt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialNumber = extractSerial(response.crt);
|
||||||
|
|
||||||
|
this.logger.log(`Certificate issued — serial=${serialNumber} grantId=${req.grantId}`);
|
||||||
|
|
||||||
|
const result = new IssuedCertDto();
|
||||||
|
result.certPem = response.crt;
|
||||||
|
result.certChainPem = certChainPem;
|
||||||
|
result.serialNumber = serialNumber;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/gateway/src/federation/federation.module.ts
Normal file
8
apps/gateway/src/federation/federation.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CaService } from './ca.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [CaService],
|
||||||
|
exports: [CaService],
|
||||||
|
})
|
||||||
|
export class FederationModule {}
|
||||||
187
apps/gateway/src/federation/scope-schema.spec.ts
Normal file
187
apps/gateway/src/federation/scope-schema.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for FederationScopeSchema and parseFederationScope.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - Valid: minimal scope
|
||||||
|
* - Valid: full PRD §8.1 example
|
||||||
|
* - Valid: resources + excluded_resources (no overlap)
|
||||||
|
* - Invalid: empty resources
|
||||||
|
* - Invalid: unknown resource value
|
||||||
|
* - Invalid: resources / excluded_resources intersection
|
||||||
|
* - Invalid: filter key not in resources
|
||||||
|
* - Invalid: max_rows_per_query = 0
|
||||||
|
* - Invalid: max_rows_per_query = 10001
|
||||||
|
* - Invalid: not an object / null
|
||||||
|
* - Defaults: include_personal defaults to true; excluded_resources defaults to []
|
||||||
|
* - Sentinel: console.warn fires for sensitive resources
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseFederationScope,
|
||||||
|
FederationScopeError,
|
||||||
|
FederationScopeSchema,
|
||||||
|
} from './scope-schema.js';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFederationScope — valid inputs', () => {
|
||||||
|
it('accepts a minimal scope (resources + max_rows_per_query only)', () => {
|
||||||
|
const scope = parseFederationScope({
|
||||||
|
resources: ['tasks'],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
});
|
||||||
|
expect(scope.resources).toEqual(['tasks']);
|
||||||
|
expect(scope.max_rows_per_query).toBe(100);
|
||||||
|
expect(scope.excluded_resources).toEqual([]);
|
||||||
|
expect(scope.filters).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the full PRD §8.1 example', () => {
|
||||||
|
const scope = parseFederationScope({
|
||||||
|
resources: ['tasks', 'notes', 'memory'],
|
||||||
|
filters: {
|
||||||
|
tasks: { include_teams: ['team_uuid_1', 'team_uuid_2'], include_personal: true },
|
||||||
|
notes: { include_personal: true, include_teams: [] },
|
||||||
|
memory: { include_personal: true },
|
||||||
|
},
|
||||||
|
excluded_resources: ['credentials', 'api_keys'],
|
||||||
|
max_rows_per_query: 500,
|
||||||
|
});
|
||||||
|
expect(scope.resources).toEqual(['tasks', 'notes', 'memory']);
|
||||||
|
expect(scope.excluded_resources).toEqual(['credentials', 'api_keys']);
|
||||||
|
expect(scope.filters?.tasks?.include_teams).toEqual(['team_uuid_1', 'team_uuid_2']);
|
||||||
|
expect(scope.max_rows_per_query).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a scope with excluded_resources and no filter overlap', () => {
|
||||||
|
const scope = parseFederationScope({
|
||||||
|
resources: ['tasks', 'notes'],
|
||||||
|
excluded_resources: ['memory'],
|
||||||
|
max_rows_per_query: 250,
|
||||||
|
});
|
||||||
|
expect(scope.resources).toEqual(['tasks', 'notes']);
|
||||||
|
expect(scope.excluded_resources).toEqual(['memory']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFederationScope — defaults', () => {
|
||||||
|
it('defaults excluded_resources to []', () => {
|
||||||
|
const scope = parseFederationScope({ resources: ['tasks'], max_rows_per_query: 1 });
|
||||||
|
expect(scope.excluded_resources).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults include_personal to true when filter is provided without it', () => {
|
||||||
|
const scope = parseFederationScope({
|
||||||
|
resources: ['tasks'],
|
||||||
|
filters: { tasks: { include_teams: ['t1'] } },
|
||||||
|
max_rows_per_query: 10,
|
||||||
|
});
|
||||||
|
expect(scope.filters?.tasks?.include_personal).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFederationScope — invalid inputs', () => {
|
||||||
|
it('throws FederationScopeError for empty resources array', () => {
|
||||||
|
expect(() => parseFederationScope({ resources: [], max_rows_per_query: 100 })).toThrow(
|
||||||
|
FederationScopeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for unknown resource value in resources', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseFederationScope({ resources: ['unknown_resource'], max_rows_per_query: 100 }),
|
||||||
|
).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when resources and excluded_resources intersect', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseFederationScope({
|
||||||
|
resources: ['tasks', 'memory'],
|
||||||
|
excluded_resources: ['memory'],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
}),
|
||||||
|
).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when filters references a resource not in resources', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseFederationScope({
|
||||||
|
resources: ['tasks'],
|
||||||
|
filters: { notes: { include_personal: true } },
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
}),
|
||||||
|
).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for max_rows_per_query = 0', () => {
|
||||||
|
expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 0 })).toThrow(
|
||||||
|
FederationScopeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for max_rows_per_query = 10001', () => {
|
||||||
|
expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 10001 })).toThrow(
|
||||||
|
FederationScopeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for null input', () => {
|
||||||
|
expect(() => parseFederationScope(null)).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for non-object input (string)', () => {
|
||||||
|
expect(() => parseFederationScope('not-an-object')).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFederationScope — sentinel warning', () => {
|
||||||
|
it('emits console.warn when resources includes "credentials"', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
parseFederationScope({
|
||||||
|
resources: ['tasks', 'credentials'],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
});
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'[FederationScope] WARNING: scope grants sensitive resource "credentials"',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits console.warn when resources includes "api_keys"', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
parseFederationScope({
|
||||||
|
resources: ['tasks', 'api_keys'],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
});
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'[FederationScope] WARNING: scope grants sensitive resource "api_keys"',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT emit console.warn for non-sensitive resources', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
parseFederationScope({ resources: ['tasks', 'notes', 'memory'], max_rows_per_query: 100 });
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FederationScopeSchema — boundary values', () => {
|
||||||
|
it('accepts max_rows_per_query = 1 (lower bound)', () => {
|
||||||
|
const result = FederationScopeSchema.safeParse({ resources: ['tasks'], max_rows_per_query: 1 });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts max_rows_per_query = 10000 (upper bound)', () => {
|
||||||
|
const result = FederationScopeSchema.safeParse({
|
||||||
|
resources: ['tasks'],
|
||||||
|
max_rows_per_query: 10000,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
147
apps/gateway/src/federation/scope-schema.ts
Normal file
147
apps/gateway/src/federation/scope-schema.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Federation grant scope schema and validator.
|
||||||
|
*
|
||||||
|
* Source of truth: docs/federation/PRD.md §8.1
|
||||||
|
*
|
||||||
|
* This module is intentionally pure — no DB, no NestJS, no CA wiring.
|
||||||
|
* It is reusable from grant CRUD (M2-06) and scope enforcement (M3+).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Allowlist of federation resources (canonical — M3+ will extend this list)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const FEDERATION_RESOURCE_VALUES = [
|
||||||
|
'tasks',
|
||||||
|
'notes',
|
||||||
|
'memory',
|
||||||
|
'credentials',
|
||||||
|
'api_keys',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type FederationResource = (typeof FEDERATION_RESOURCE_VALUES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sensitive resources require explicit admin approval (PRD §8.4).
|
||||||
|
* The parser warns when these appear in `resources`; M2-06 grant CRUD
|
||||||
|
* will add a hard gate on top of this warning.
|
||||||
|
*/
|
||||||
|
const SENSITIVE_RESOURCES: ReadonlySet<FederationResource> = new Set(['credentials', 'api_keys']);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-schemas
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ResourceArraySchema = z
|
||||||
|
.array(z.enum(FEDERATION_RESOURCE_VALUES))
|
||||||
|
.nonempty({ message: 'resources must contain at least one value' })
|
||||||
|
.refine((arr) => new Set(arr).size === arr.length, {
|
||||||
|
message: 'resources must not contain duplicate values',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ResourceFilterSchema = z.object({
|
||||||
|
include_teams: z.array(z.string()).optional(),
|
||||||
|
include_personal: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Top-level schema
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const FederationScopeSchema = z
|
||||||
|
.object({
|
||||||
|
resources: ResourceArraySchema,
|
||||||
|
|
||||||
|
excluded_resources: z
|
||||||
|
.array(z.enum(FEDERATION_RESOURCE_VALUES))
|
||||||
|
.default([])
|
||||||
|
.refine((arr) => new Set(arr).size === arr.length, {
|
||||||
|
message: 'excluded_resources must not contain duplicate values',
|
||||||
|
}),
|
||||||
|
|
||||||
|
filters: z.record(z.string(), ResourceFilterSchema).optional(),
|
||||||
|
|
||||||
|
max_rows_per_query: z
|
||||||
|
.number()
|
||||||
|
.int({ message: 'max_rows_per_query must be an integer' })
|
||||||
|
.min(1, { message: 'max_rows_per_query must be at least 1' })
|
||||||
|
.max(10000, { message: 'max_rows_per_query must be at most 10000' }),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const resourceSet = new Set(data.resources);
|
||||||
|
|
||||||
|
// Intersection guard: a resource cannot be both granted and excluded
|
||||||
|
for (const r of data.excluded_resources) {
|
||||||
|
if (resourceSet.has(r)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Resource "${r}" appears in both resources and excluded_resources`,
|
||||||
|
path: ['excluded_resources'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter keys must be a subset of resources
|
||||||
|
if (data.filters) {
|
||||||
|
for (const key of Object.keys(data.filters)) {
|
||||||
|
if (!resourceSet.has(key as FederationResource)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `filters key "${key}" references a resource not present in resources`,
|
||||||
|
path: ['filters', key],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FederationScope = z.infer<typeof FederationScopeSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error class
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class FederationScopeError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'FederationScopeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Typed parser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate an unknown value as a FederationScope.
|
||||||
|
*
|
||||||
|
* Throws `FederationScopeError` with aggregated Zod issues on failure.
|
||||||
|
*
|
||||||
|
* Emits `console.warn` when sensitive resources (`credentials`, `api_keys`)
|
||||||
|
* are present in `resources` — per PRD §8.4, these require explicit admin
|
||||||
|
* approval. M2-06 grant CRUD will add a hard gate on top of this warning.
|
||||||
|
*/
|
||||||
|
export function parseFederationScope(input: unknown): FederationScope {
|
||||||
|
const result = FederationScopeSchema.safeParse(input);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const issues = result.error.issues
|
||||||
|
.map((e) => ` - [${e.path.join('.') || 'root'}] ${e.message}`)
|
||||||
|
.join('\n');
|
||||||
|
throw new FederationScopeError(`Invalid federation scope:\n${issues}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = result.data;
|
||||||
|
|
||||||
|
// Sentinel warning for sensitive resources (PRD §8.4)
|
||||||
|
for (const resource of scope.resources) {
|
||||||
|
if (SENSITIVE_RESOURCES.has(resource)) {
|
||||||
|
console.warn(
|
||||||
|
`[FederationScope] WARNING: scope grants sensitive resource "${resource}". Per PRD §8.4 this requires explicit admin approval and is logged.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
@@ -117,3 +117,68 @@ docker compose -f docker-compose.federated.yml logs valkey-federated
|
|||||||
```
|
```
|
||||||
|
|
||||||
If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop may require binding to `host.docker.internal` instead of `localhost`.
|
If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop may require binding to `host.docker.internal` instead of `localhost`.
|
||||||
|
|
||||||
|
## OID Assignments — Mosaic Internal OID Arc
|
||||||
|
|
||||||
|
Mosaic uses the private enterprise arc `1.3.6.1.4.1.99999` for custom X.509
|
||||||
|
certificate extensions in federation grant certificates.
|
||||||
|
|
||||||
|
**IMPORTANT:** This is a development/internal OID arc. Before deploying to a
|
||||||
|
production environment accessible by external parties, register a proper IANA
|
||||||
|
Private Enterprise Number (PEN) at <https://pen.iana.org/pen/PenApplication.page>
|
||||||
|
and update these assignments accordingly.
|
||||||
|
|
||||||
|
### Assigned OIDs
|
||||||
|
|
||||||
|
| OID | Symbolic name | Description |
|
||||||
|
| --------------------- | --------------------------------- | --------------------------------------------------------- |
|
||||||
|
| `1.3.6.1.4.1.99999.1` | `mosaic.federation.grantId` | UUID of the `federation_grants` row authorising this cert |
|
||||||
|
| `1.3.6.1.4.1.99999.2` | `mosaic.federation.subjectUserId` | UUID of the local user on whose behalf the cert is issued |
|
||||||
|
|
||||||
|
### Encoding
|
||||||
|
|
||||||
|
Each extension value is DER-encoded as an ASN.1 **UTF8String**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Tag 0x0C (UTF8String)
|
||||||
|
Length 0x24 (36 decimal — fixed length of a UUID string)
|
||||||
|
Value <36 ASCII bytes of the UUID>
|
||||||
|
```
|
||||||
|
|
||||||
|
The step-ca X.509 template at `infra/step-ca/templates/federation.tpl`
|
||||||
|
produces this encoding via the Go template expression:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }}
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting base64 value is passed as the `value` field of the extension
|
||||||
|
object in the template JSON.
|
||||||
|
|
||||||
|
### CA Environment Variables
|
||||||
|
|
||||||
|
The `CaService` (`apps/gateway/src/federation/ca.service.ts`) requires the
|
||||||
|
following environment variables at gateway startup:
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
| ------------------------------ | -------- | -------------------------------------------------------------------- |
|
||||||
|
| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` |
|
||||||
|
| `STEP_CA_PROVISIONER_PASSWORD` | Yes | JWK provisioner password for the `mosaic-fed` provisioner |
|
||||||
|
| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK (public + private) for the `mosaic-fed` provisioner |
|
||||||
|
| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the step-ca root CA certificate PEM file |
|
||||||
|
|
||||||
|
Set these variables in your environment or secret manager before starting
|
||||||
|
the gateway. In the federated Docker Compose stack they are expected to be
|
||||||
|
injected via Docker secrets and environment variable overrides.
|
||||||
|
|
||||||
|
### Fail-loud contract
|
||||||
|
|
||||||
|
The CA service (and the X.509 template) are designed to fail loudly if the
|
||||||
|
custom OIDs cannot be embedded:
|
||||||
|
|
||||||
|
- The template produces a malformed extension value (zero-length UTF8String
|
||||||
|
body) when the JWT claims `mosaic_grant_id` or `mosaic_subject_user_id` are
|
||||||
|
absent. step-ca rejects the CSR rather than issuing a cert without the OIDs.
|
||||||
|
- `CaService.issueCert()` throws a `CaServiceError` on every error path with
|
||||||
|
a human-readable `remediation` string. It never silently returns a cert that
|
||||||
|
may be missing the required extensions.
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
# On the first run (no /home/step/config/ca.json present) this script:
|
# On the first run (no /home/step/config/ca.json present) this script:
|
||||||
# 1. Initialises Step-CA with a JWK provisioner named "mosaic-fed".
|
# 1. Initialises Step-CA with a JWK provisioner named "mosaic-fed".
|
||||||
# 2. Writes the CA configuration to the persistent volume at /home/step.
|
# 2. Writes the CA configuration to the persistent volume at /home/step.
|
||||||
|
# 3. Copies the federation X.509 template into the CA config directory.
|
||||||
|
# 4. Patches the mosaic-fed provisioner entry in ca.json to reference the
|
||||||
|
# template via options.x509.templateFile (using jq — must be installed
|
||||||
|
# in the container image).
|
||||||
#
|
#
|
||||||
# On subsequent runs (config already exists) this script skips init and
|
# On subsequent runs (config already exists) this script skips init and
|
||||||
# starts the CA directly.
|
# starts the CA directly.
|
||||||
@@ -18,15 +22,16 @@
|
|||||||
# Prod: mounted from a Docker secret at /run/secrets/ca_password.
|
# Prod: mounted from a Docker secret at /run/secrets/ca_password.
|
||||||
#
|
#
|
||||||
# OID template:
|
# OID template:
|
||||||
# infra/step-ca/templates/federation.tpl is copied into the CA config
|
# infra/step-ca/templates/federation.tpl emits custom OID extensions:
|
||||||
# directory so the JWK provisioner can reference it. The template
|
# 1.3.6.1.4.1.99999.1 — mosaic_grant_id
|
||||||
# skeleton is wired in M2-04 when the CA service lands the SAN-bearing
|
# 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id
|
||||||
# CSR work.
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
CA_CONFIG="/home/step/config/ca.json"
|
CA_CONFIG="/home/step/config/ca.json"
|
||||||
PASSWORD_FILE="/run/secrets/ca_password"
|
PASSWORD_FILE="/run/secrets/ca_password"
|
||||||
|
TEMPLATE_SRC="/etc/step-ca-templates/federation.tpl"
|
||||||
|
TEMPLATE_DEST="/home/step/templates/federation.tpl"
|
||||||
|
|
||||||
if [ ! -f "${CA_CONFIG}" ]; then
|
if [ ! -f "${CA_CONFIG}" ]; then
|
||||||
echo "[step-ca init] First boot detected — initialising Mosaic Federation CA..."
|
echo "[step-ca init] First boot detected — initialising Mosaic Federation CA..."
|
||||||
@@ -43,12 +48,37 @@ if [ ! -f "${CA_CONFIG}" ]; then
|
|||||||
|
|
||||||
echo "[step-ca init] CA initialised."
|
echo "[step-ca init] CA initialised."
|
||||||
|
|
||||||
# Copy the X.509 template into the Step-CA config directory so the
|
# Copy the X.509 template into the Step-CA config directory.
|
||||||
# provisioner can reference it in M2-04.
|
if [ -f "${TEMPLATE_SRC}" ]; then
|
||||||
if [ -f "/etc/step-ca-templates/federation.tpl" ]; then
|
|
||||||
mkdir -p /home/step/templates
|
mkdir -p /home/step/templates
|
||||||
cp /etc/step-ca-templates/federation.tpl /home/step/templates/federation.tpl
|
cp "${TEMPLATE_SRC}" "${TEMPLATE_DEST}"
|
||||||
echo "[step-ca init] Federation X.509 template copied to /home/step/templates/."
|
echo "[step-ca init] Federation X.509 template copied to ${TEMPLATE_DEST}."
|
||||||
|
else
|
||||||
|
echo "[step-ca init] WARNING: Template source ${TEMPLATE_SRC} not found — skipping copy."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wire the template into the mosaic-fed provisioner via jq.
|
||||||
|
# This is idempotent: the block only runs once (first boot).
|
||||||
|
#
|
||||||
|
# jq filter: find the provisioner entry with name "mosaic-fed" and set
|
||||||
|
# .options.x509.templateFile to the absolute path of the template.
|
||||||
|
# All other provisioners and config keys are left unchanged.
|
||||||
|
if [ -f "${TEMPLATE_DEST}" ] && command -v jq > /dev/null 2>&1; then
|
||||||
|
echo "[step-ca init] Patching mosaic-fed provisioner with X.509 template..."
|
||||||
|
TEMP_CONFIG="${CA_CONFIG}.tmp"
|
||||||
|
jq --arg tpl "${TEMPLATE_DEST}" '
|
||||||
|
.authority.provisioners |= map(
|
||||||
|
if .name == "mosaic-fed" then
|
||||||
|
.options.x509.templateFile = $tpl
|
||||||
|
else
|
||||||
|
.
|
||||||
|
end
|
||||||
|
)
|
||||||
|
' "${CA_CONFIG}" > "${TEMP_CONFIG}" && mv "${TEMP_CONFIG}" "${CA_CONFIG}"
|
||||||
|
echo "[step-ca init] Provisioner patched."
|
||||||
|
elif ! command -v jq > /dev/null 2>&1; then
|
||||||
|
echo "[step-ca init] WARNING: jq not found — skipping provisioner template patch."
|
||||||
|
echo "[step-ca init] Install jq in the step-ca image to enable automatic template wiring."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[step-ca init] Startup complete."
|
echo "[step-ca init] Startup complete."
|
||||||
|
|||||||
@@ -5,41 +5,49 @@
|
|||||||
{{- /*
|
{{- /*
|
||||||
Mosaic Federation X.509 Certificate Template
|
Mosaic Federation X.509 Certificate Template
|
||||||
============================================
|
============================================
|
||||||
This template is used by the "mosaic-fed" JWK provisioner to sign
|
Provisioner: mosaic-fed (JWK)
|
||||||
federation client certificates.
|
Implemented: FED-M2-04
|
||||||
|
|
||||||
Custom OID extensions (per PRD §6):
|
This template emits two custom OID extensions carrying Mosaic federation
|
||||||
1.3.6.1.4.1.99999.1 — mosaic.federation.grantId (UUID string)
|
identifiers. The OTT token (built by CaService.buildOtt) MUST include the
|
||||||
1.3.6.1.4.1.99999.2 — mosaic.federation.subjectUserId (UUID string)
|
claims `mosaic_grant_id` and `mosaic_subject_user_id` as top-level JWT
|
||||||
|
claims. step-ca exposes them under `.Token.<claim>` in this template.
|
||||||
|
|
||||||
TODO (M2-04): Wire actual OID extensions below once the CA service
|
OID Registry (Mosaic Internal Arc — 1.3.6.1.4.1.99999):
|
||||||
(apps/gateway/src/federation/ca.service.ts) lands the SAN-bearing CSR
|
1.3.6.1.4.1.99999.1 mosaic_grant_id (UUID, 36 ASCII chars)
|
||||||
work and the template can be exercised end-to-end.
|
1.3.6.1.4.1.99999.2 mosaic_subject_user_id (UUID, 36 ASCII chars)
|
||||||
|
|
||||||
|
DER encoding for each extension value (ASN.1 UTF8String):
|
||||||
|
Tag = 0x0C (UTF8String)
|
||||||
|
Length = 0x24 (decimal 36 — the fixed length of a UUID string)
|
||||||
|
Value = 36 ASCII bytes of the UUID
|
||||||
|
|
||||||
|
The `printf` below builds the raw TLV bytes then base64-encodes them.
|
||||||
|
step-ca expects the `value` field to be base64-encoded raw DER bytes.
|
||||||
|
|
||||||
|
Fail-loud contract:
|
||||||
|
If either claim is missing from the token the printf will produce a
|
||||||
|
zero-length UUID field, making the extension malformed. step-ca will
|
||||||
|
reject the certificate rather than issuing one without the required OIDs.
|
||||||
|
Silent OID stripping is NEVER tolerated.
|
||||||
|
|
||||||
Step-CA template reference:
|
Step-CA template reference:
|
||||||
https://smallstep.com/docs/step-ca/templates
|
https://smallstep.com/docs/step-ca/templates
|
||||||
|
|
||||||
Expected final shape of the extensions block (placeholder — not yet
|
|
||||||
activated):
|
|
||||||
|
|
||||||
"extensions": [
|
|
||||||
{
|
|
||||||
"id": "1.3.6.1.4.1.99999.1",
|
|
||||||
"critical": false,
|
|
||||||
"value": {{ toJson (first .Token.mosaic_grant_id) }}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "1.3.6.1.4.1.99999.2",
|
|
||||||
"critical": false,
|
|
||||||
"value": {{ toJson (first .Token.mosaic_subject_user_id) }}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
The provisioner must pass these values in the ACME/JWK token payload
|
|
||||||
(token claims `mosaic_grant_id` and `mosaic_subject_user_id`) when
|
|
||||||
submitting the CSR. M2-04 owns that work.
|
|
||||||
*/ -}}
|
*/ -}}
|
||||||
|
|
||||||
|
"extensions": [
|
||||||
|
{
|
||||||
|
"id": "1.3.6.1.4.1.99999.1",
|
||||||
|
"critical": false,
|
||||||
|
"value": "{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1.3.6.1.4.1.99999.2",
|
||||||
|
"critical": false,
|
||||||
|
"value": "{{ printf "\x0c\x24%s" .Token.mosaic_subject_user_id | b64enc }}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
"keyUsage": ["digitalSignature"],
|
"keyUsage": ["digitalSignature"],
|
||||||
"extKeyUsage": ["clientAuth"],
|
"extKeyUsage": ["clientAuth"],
|
||||||
"basicConstraints": {
|
"basicConstraints": {
|
||||||
|
|||||||
Reference in New Issue
Block a user