Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
eede5756d9 fix(deploy): bump gateway image to sha-9f1a081 (Dockerfile fix from #488)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
The fed-v0.1.0-m1 image pin (sha256:9b72e2...) was built before the pnpm
deploy fix in #488 landed. That older image cannot resolve @mosaicstack/
storage at runtime and crashes during bootstrap.

The new digest sha256:1069117740e00ccfeba357cae38c43f3729fe5ae702740ce474f6512414d7c02
was published by Woodpecker pipeline 975 (commit 9f1a0818) using the
corrected Dockerfile and was smoke-tested locally:
  - imports resolve cleanly (no MODULE_NOT_FOUND)
  - bootstrap reaches tier-detector
  - tier detector fails fast with proper error when DB unreachable
  - exits 0 instead of crashing on import

Required to unblock DEPLOY-03/04 (mos-test-1 + mos-test-2 stand-up).
2026-04-21 21:24:54 -05:00
18 changed files with 168 additions and 2098 deletions

View File

@@ -56,7 +56,6 @@
"@opentelemetry/sdk-metrics": "^2.6.0", "@opentelemetry/sdk-metrics": "^2.6.0",
"@opentelemetry/sdk-node": "^0.213.0", "@opentelemetry/sdk-node": "^0.213.0",
"@opentelemetry/semantic-conventions": "^1.40.0", "@opentelemetry/semantic-conventions": "^1.40.0",
"@peculiar/x509": "^2.0.0",
"@sinclair/typebox": "^0.34.48", "@sinclair/typebox": "^0.34.48",
"better-auth": "^1.5.5", "better-auth": "^1.5.5",
"bullmq": "^5.71.0", "bullmq": "^5.71.0",
@@ -65,7 +64,6 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"fastify": "^5.0.0", "fastify": "^5.0.0",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"jose": "^6.2.2",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"openai": "^6.32.0", "openai": "^6.32.0",
"postgres": "^3.4.8", "postgres": "^3.4.8",

View File

@@ -1,10 +1,62 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { seal, unseal } from '@mosaicstack/auth'; import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import type { Db } from '@mosaicstack/db'; import type { Db } from '@mosaicstack/db';
import { providerCredentials, eq, and } from '@mosaicstack/db'; import { providerCredentials, eq, and } from '@mosaicstack/db';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js'; import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96-bit IV for GCM
const TAG_LENGTH = 16; // 128-bit auth tag
/**
* Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256.
* The secret is assumed to be set in the environment.
*/
function deriveEncryptionKey(): Buffer {
const secret = process.env['BETTER_AUTH_SECRET'];
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key');
}
return createHash('sha256').update(secret).digest();
}
/**
* Encrypt a plain-text value using AES-256-GCM.
* Output format: base64(iv + authTag + ciphertext)
*/
function encrypt(plaintext: string): string {
const key = deriveEncryptionKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
// Combine iv (12) + authTag (16) + ciphertext and base64-encode
const combined = Buffer.concat([iv, authTag, encrypted]);
return combined.toString('base64');
}
/**
* Decrypt a value encrypted by `encrypt()`.
* Throws on authentication failure (tampered data).
*/
function decrypt(encoded: string): string {
const key = deriveEncryptionKey();
const combined = Buffer.from(encoded, 'base64');
const iv = combined.subarray(0, IV_LENGTH);
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString('utf8');
}
@Injectable() @Injectable()
export class ProviderCredentialsService { export class ProviderCredentialsService {
private readonly logger = new Logger(ProviderCredentialsService.name); private readonly logger = new Logger(ProviderCredentialsService.name);
@@ -22,7 +74,7 @@ export class ProviderCredentialsService {
value: string, value: string,
metadata?: Record<string, unknown>, metadata?: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const encryptedValue = seal(value); const encryptedValue = encrypt(value);
await this.db await this.db
.insert(providerCredentials) .insert(providerCredentials)
@@ -70,7 +122,7 @@ export class ProviderCredentialsService {
} }
try { try {
return unseal(row.encryptedValue); return decrypt(row.encryptedValue);
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
`Failed to decrypt credential for user=${userId} provider=${provider}`, `Failed to decrypt credential for user=${userId} provider=${provider}`,

View File

@@ -24,7 +24,6 @@ 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({
@@ -53,7 +52,6 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
QueueModule, QueueModule,
ReloadModule, ReloadModule,
WorkspaceModule, WorkspaceModule,
FederationModule,
], ],
controllers: [HealthController], controllers: [HealthController],
providers: [ providers: [

View File

@@ -1,63 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { sealClientKey, unsealClientKey } from '../peer-key.util.js';
const TEST_SECRET = 'test-secret-for-peer-key-unit-tests-only';
const TEST_PEM = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7o4qne60TB3wo
pCOW8QqstpxEBpnFo37JxLYEJbpE3gUlJajsHv9UWRQ7m5B7n+MBXwTCQqMEY8Wl
kHv9tGgz1YGwzBjNKxPJXE6pPTXQ1Oa0VB9l3qHdqF5HtZoJzE0c6dO8HJ5YUVL
-----END PRIVATE KEY-----`;
let savedSecret: string | undefined;
beforeEach(() => {
savedSecret = process.env['BETTER_AUTH_SECRET'];
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
});
afterEach(() => {
if (savedSecret === undefined) {
delete process.env['BETTER_AUTH_SECRET'];
} else {
process.env['BETTER_AUTH_SECRET'] = savedSecret;
}
});
describe('peer-key seal/unseal', () => {
it('round-trip: unsealClientKey(sealClientKey(pem)) returns original pem', () => {
const sealed = sealClientKey(TEST_PEM);
const roundTripped = unsealClientKey(sealed);
expect(roundTripped).toBe(TEST_PEM);
});
it('non-determinism: sealClientKey produces different ciphertext each call', () => {
const sealed1 = sealClientKey(TEST_PEM);
const sealed2 = sealClientKey(TEST_PEM);
expect(sealed1).not.toBe(sealed2);
});
it('at-rest: sealed output does not contain plaintext PEM content', () => {
const sealed = sealClientKey(TEST_PEM);
expect(sealed).not.toContain('PRIVATE KEY');
expect(sealed).not.toContain(
'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7o4qne60TB3wo',
);
});
it('tamper: flipping a byte in the sealed payload causes unseal to throw', () => {
const sealed = sealClientKey(TEST_PEM);
const buf = Buffer.from(sealed, 'base64');
// Flip a byte in the middle of the buffer (past IV and authTag)
const midpoint = Math.floor(buf.length / 2);
buf[midpoint] = buf[midpoint]! ^ 0xff;
const tampered = buf.toString('base64');
expect(() => unsealClientKey(tampered)).toThrow();
});
it('missing secret: unsealClientKey throws when BETTER_AUTH_SECRET is unset', () => {
const sealed = sealClientKey(TEST_PEM);
delete process.env['BETTER_AUTH_SECRET'];
expect(() => unsealClientKey(sealed)).toThrow('BETTER_AUTH_SECRET is not set');
});
});

View File

@@ -1,57 +0,0 @@
/**
* DTOs for the Step-CA client service (FED-M2-04).
*
* IssueCertRequestDto — input to CaService.issueCert()
* IssuedCertDto — output from CaService.issueCert()
*/
import { IsInt, IsNotEmpty, IsOptional, 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.
* Hard cap: 900 s (15 minutes). Default: 300 s (5 minutes).
* The service will always clamp to 900 s regardless of this value.
*/
@IsOptional()
@IsInt()
@Min(60)
@Max(15 * 60)
ttlSeconds: number = 300;
}
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;
}

View File

@@ -1,577 +0,0 @@
/**
* 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 (INVALID_CSR)
* - 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
* verified with jose.jwtVerify (real signature check)
* - CaServiceError: has cause + remediation properties
* - Missing crt in response: throws CaServiceError
* - Real CSR validation: valid P-256 CSR passes; malformed CSR fails with INVALID_CSR
* - provisionerPassword never appears in CaServiceError messages
* - HTTPS-only enforcement: http:// URL throws in constructor
*/
import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { jwtVerify, exportJWK, generateKeyPair } from 'jose';
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
// Real self-signed EC P-256 certificate generated with openssl for testing.
// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout /dev/null \
// -out /dev/stdout -subj "/CN=test" -days 1
const FAKE_CERT_PEM = `-----BEGIN CERTIFICATE-----
MIIBdDCCARmgAwIBAgIUM+iUJSayN+PwXkyVN6qwSY7sr6gwCgYIKoZIzj0EAwIw
DzENMAsGA1UEAwwEdGVzdDAeFw0yNjA0MjIwMzE5MTlaFw0yNjA0MjMwMzE5MTla
MA8xDTALBgNVBAMMBHRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR21kHL
n1GmFQ4TEBw3EA53pD+2McIBf5WcoHE+x0eMz5DpRKJe0ksHwOVN5Yev5d57kb+4
MvG1LhbHCB/uQo8So1MwUTAdBgNVHQ4EFgQUPq0pdIGiQ7pLBRXICS8GTliCrLsw
HwYDVR0jBBgwFoAUPq0pdIGiQ7pLBRXICS8GTliCrLswDwYDVR0TAQH/BAUwAwEB
/zAKBggqhkjOPQQDAgNJADBGAiEAypJqyC6S77aQ3eEXokM6sgAsD7Oa3tJbCbVm
zG3uJb0CIQC1w+GE+Ad0OTR5Quja46R1RjOo8ydpzZ7Fh4rouAiwEw==
-----END CERTIFICATE-----
`;
// Use a second copy of the same cert for the CA field in tests.
const FAKE_CA_PEM = FAKE_CERT_PEM;
const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5f09-cc7e-7cc0ce491b22';
// ---------------------------------------------------------------------------
// Generate a real EC P-256 key pair and CSR for integration-style tests
// ---------------------------------------------------------------------------
// We generate this once at module level so it's available to all tests.
// The key pair and CSR PEM are populated asynchronously in the test that needs them.
let realCsrPem: string;
async function generateRealCsr(): Promise<string> {
const { privateKey, publicKey } = await generateKeyPair('ES256');
// Export public key JWK for potential verification (not used here but confirms key is exportable)
await exportJWK(publicKey);
// Use @peculiar/x509 to build a proper CSR
const csr = await Pkcs10CertificateRequestGenerator.create({
name: 'CN=test.federation.local',
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
keys: { privateKey, publicKey },
});
return csr.toString('pem');
}
// ---------------------------------------------------------------------------
// Setup env before importing service
// We use an EC P-256 key pair here so the JWK-based signing works.
// The key pair is generated once and stored in module-level vars.
// ---------------------------------------------------------------------------
// Real EC P-256 test JWK (test-only, never used in production).
// Generated with node webcrypto for use in unit tests.
const TEST_EC_PRIVATE_JWK = {
key_ops: ['sign'],
ext: true,
kty: 'EC',
x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU',
y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E',
crv: 'P-256',
d: 'TM6N79w1HE-PiML5Td4mbXfJaLHEaZrVyVrrwlJv7q8',
kid: 'test-ec-kid',
};
const TEST_EC_PUBLIC_JWK = {
key_ops: ['verify'],
ext: true,
kty: 'EC',
x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU',
y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E',
crv: 'P-256',
kid: 'test-ec-kid',
};
process.env['STEP_CA_URL'] = 'https://step-ca:9000';
process.env['STEP_CA_PROVISIONER_KEY_JSON'] = JSON.stringify(TEST_EC_PRIVATE_JWK);
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(),
setTimeout: 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 {
// Use a real CSR if available; fall back to a minimal placeholder
const defaultCsr = realCsrPem ?? makeFakeCsr();
return {
csrPem: defaultCsr,
grantId: GRANT_ID,
subjectUserId: SUBJECT_USER_ID,
ttlSeconds: 300,
...overrides,
};
}
function makeFakeCsr(): string {
// A structurally valid-looking CSR header/footer (body will fail crypto verify)
return `-----BEGIN CERTIFICATE REQUEST-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA\n-----END CERTIFICATE REQUEST-----\n`;
}
// -------------------------------------------------------------------------
// Real CSR generation — runs once and populates realCsrPem
// -------------------------------------------------------------------------
it('generates a real P-256 CSR that passes validateCsr', async () => {
realCsrPem = await generateRealCsr();
expect(realCsrPem).toMatch(/BEGIN CERTIFICATE REQUEST/);
// Now test that the service's validateCsr accepts it.
// We call it indirectly via issueCert with a successful mock.
makeHttpsMock(200, { crt: FAKE_CERT_PEM, certChain: [FAKE_CERT_PEM, FAKE_CA_PEM] });
const result = await service.issueCert(makeReq({ csrPem: realCsrPem }));
expect(result.certPem).toBe(FAKE_CERT_PEM);
});
it('throws INVALID_CSR for a malformed PEM-shaped CSR', async () => {
const malformedCsr =
'-----BEGIN CERTIFICATE REQUEST-----\nTm90QVJlYWxDU1I=\n-----END CERTIFICATE REQUEST-----\n';
await expect(service.issueCert(makeReq({ csrPem: malformedCsr }))).rejects.toSatisfy(
(err: unknown) => {
if (!(err instanceof CaServiceError)) return false;
expect(err.code).toBe('INVALID_CSR');
return true;
},
);
});
// -------------------------------------------------------------------------
// Happy path
// -------------------------------------------------------------------------
it('returns IssuedCertDto on success (certChain present)', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
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 () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
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 () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
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 () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
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 () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
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 () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
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 () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
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
// Verified with jose.jwtVerify for real signature verification (M6)
// -------------------------------------------------------------------------
it('OTT contains mosaic_grant_id, mosaic_subject_user_id, and jti; signature verifies with jose', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
let capturedBody: Record<string, unknown> | undefined;
const mockReq = {
write: vi.fn((data: string) => {
capturedBody = JSON.parse(data) as Record<string, unknown>;
}),
end: vi.fn(),
on: vi.fn(),
setTimeout: 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({ csrPem: realCsrPem }));
expect(capturedBody).toBeDefined();
const ott = capturedBody!['ott'] as string;
expect(typeof ott).toBe('string');
// Verify JWT structure
const parts = ott.split('.');
expect(parts).toHaveLength(3);
// Decode payload without signature check first
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);
expect(typeof payload['jti']).toBe('string'); // M2: jti present
expect(payload['jti']).toMatch(/^[0-9a-f-]{36}$/); // UUID format
// M3: top-level sha should NOT be present; step.sha should be present
expect(payload['sha']).toBeUndefined();
const step = payload['step'] as Record<string, unknown> | undefined;
expect(step?.['sha']).toBeDefined();
// M6: Verify signature with jose.jwtVerify using the public key
const { importJWK: importJose } = await import('jose');
const publicKey = await importJose(TEST_EC_PUBLIC_JWK, 'ES256');
const verified = await jwtVerify(ott, publicKey);
expect(verified.payload['mosaic_grant_id']).toBe(GRANT_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 () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
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;
});
});
// -------------------------------------------------------------------------
// M6: provisionerPassword must never appear in CaServiceError messages
// -------------------------------------------------------------------------
it('provisionerPassword does not appear in any CaServiceError message', async () => {
// Temporarily set a recognizable password to test against
const originalPassword = process.env['STEP_CA_PROVISIONER_PASSWORD'];
process.env['STEP_CA_PROVISIONER_PASSWORD'] = 'super-secret-password-12345';
// Generate a bad CSR to trigger an error path
const caughtErrors: CaServiceError[] = [];
try {
await service.issueCert(makeReq({ csrPem: 'not-a-csr' }));
} catch (err) {
if (err instanceof CaServiceError) {
caughtErrors.push(err);
}
}
// Also try HTTP 401 path
if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(401, { message: 'Unauthorized' });
try {
await service.issueCert(makeReq({ csrPem: realCsrPem }));
} catch (err) {
if (err instanceof CaServiceError) {
caughtErrors.push(err);
}
}
for (const err of caughtErrors) {
expect(err.message).not.toContain('super-secret-password-12345');
if (err.remediation) {
expect(err.remediation).not.toContain('super-secret-password-12345');
}
}
process.env['STEP_CA_PROVISIONER_PASSWORD'] = originalPassword;
});
// -------------------------------------------------------------------------
// M7: HTTPS-only enforcement in constructor
// -------------------------------------------------------------------------
it('throws in constructor if STEP_CA_URL uses http://', () => {
const originalUrl = process.env['STEP_CA_URL'];
process.env['STEP_CA_URL'] = 'http://step-ca:9000';
expect(() => new CaService()).toThrow(CaServiceError);
process.env['STEP_CA_URL'] = originalUrl;
});
// -------------------------------------------------------------------------
// TTL clamp: ttlSeconds is clamped to 900 s (15 min) maximum
// -------------------------------------------------------------------------
it('clamps ttlSeconds to 900 s regardless of input', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
let capturedBody: Record<string, unknown> | undefined;
const mockReq = {
write: vi.fn((data: string) => {
capturedBody = JSON.parse(data) as Record<string, unknown>;
}),
end: vi.fn(),
on: vi.fn(),
setTimeout: 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;
},
);
// Request 86400 s — should be clamped to 900
await service.issueCert(makeReq({ ttlSeconds: 86400 }));
expect(capturedBody).toBeDefined();
const validity = capturedBody!['validity'] as Record<string, unknown>;
expect(validity['duration']).toBe('900s');
});
});

View File

@@ -1,635 +0,0 @@
/**
* CaService — Step-CA client for federation grant certificate issuance.
*
* Responsibilities:
* 1. Build a JWK-provisioner One-Time Token (OTT) signed with the provisioner
* private key (ES256/ES384/RS256 per JWK kty/crv) 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_KEY_JSON JWK provisioner private key (JSON)
* STEP_CA_ROOT_CERT_PATH Absolute path to the CA root PEM
*
* Optional (only used for JWK PBES2 decrypt at startup if key is encrypted):
* STEP_CA_PROVISIONER_PASSWORD JWK provisioner password (raw string)
*
* 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 { SignJWT, importJWK } from 'jose';
import { Pkcs10CertificateRequest } from '@peculiar/x509';
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;
readonly code?: string;
constructor(message: string, remediation: string, cause?: unknown, code?: string) {
super(message);
this.name = 'CaServiceError';
this.cause = cause;
this.remediation = remediation;
this.code = code;
}
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
/** UUID regex for validation */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Derive the JWT algorithm string from a JWK's kty/crv fields.
* EC P-256 → ES256, EC P-384 → ES384, RSA → RS256.
*/
function algFromJwk(jwk: JwkKey): string {
if (jwk.alg) return jwk.alg;
if (jwk.kty === 'EC') {
if (jwk.crv === 'P-384') return 'ES384';
return 'ES256'; // default for P-256 and Ed25519-style EC keys
}
if (jwk.kty === 'RSA') return 'RS256';
throw new CaServiceError(
`Unsupported JWK kty: ${jwk.kty}`,
'STEP_CA_PROVISIONER_KEY_JSON must be an EC (P-256/P-384) or RSA JWK private key.',
);
}
/**
* 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');
}
/**
* 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,
timeout: 5000,
};
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_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.setTimeout(5000, () => {
req.destroy(new Error('Request timed out after 5000ms'));
});
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.
* Throws CaServiceError on failure — never silently returns 'unknown'.
*/
function extractSerial(certPem: string): string {
let cert: crypto.X509Certificate;
try {
cert = new crypto.X509Certificate(certPem);
} catch (err) {
throw new CaServiceError(
'Failed to parse the issued certificate PEM',
'The certificate returned by step-ca could not be parsed. Check that step-ca is returning a valid PEM certificate.',
err,
'CERT_PARSE',
);
}
return cert.serialNumber;
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
@Injectable()
export class CaService {
private readonly logger = new Logger(CaService.name);
private readonly caUrl: string;
private readonly rootCertPath: string;
private readonly httpsAgent: https.Agent;
private readonly jwk: JwkKey;
private cachedPrivateKey: crypto.KeyObject | null = null;
private readonly jwtAlg: string;
private readonly kid: string;
constructor() {
const caUrl = process.env['STEP_CA_URL'];
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',
);
}
// Enforce HTTPS-only URL
let parsedUrl: URL;
try {
parsedUrl = new URL(caUrl);
} catch (err) {
throw new CaServiceError(
`STEP_CA_URL is not a valid URL: ${caUrl}`,
'Set STEP_CA_URL to a valid HTTPS URL, e.g. https://step-ca:9000',
err,
);
}
if (parsedUrl.protocol !== 'https:') {
throw new CaServiceError(
`STEP_CA_URL must use HTTPS — got: ${parsedUrl.protocol}`,
'Set STEP_CA_URL to an https:// URL. Unencrypted connections to the CA are not permitted.',
);
}
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.',
);
}
// Parse JWK once — do NOT store the raw JSON string as a class field
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,
);
}
// Derive algorithm from JWK metadata
const jwtAlg = algFromJwk(jwk);
const kid = jwk.kid ?? 'mosaic-fed';
// Import the JWK into a native KeyObject — fail loudly if it cannot be loaded.
// We do this synchronously here by calling the async importJWK via a blocking workaround.
// Actually importJWK is async, so we store it for use during token building.
// We keep the raw jwk object for later async import inside buildOtt.
// NOTE: We do NOT store provisionerKeyJson string as a class field.
this.jwk = jwk;
this.jwtAlg = jwtAlg;
this.kid = kid;
this.caUrl = caUrl;
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}`);
}
/**
* Lazily import the private key from JWK on first use.
* The key is cached in cachedPrivateKey after first import.
*/
private async getPrivateKey(): Promise<crypto.KeyObject> {
if (this.cachedPrivateKey !== null) return this.cachedPrivateKey;
try {
const key = await importJWK(this.jwk, this.jwtAlg);
// importJWK returns KeyLike (crypto.KeyObject | Uint8Array) — in Node.js it's KeyObject
this.cachedPrivateKey = key as unknown as crypto.KeyObject;
return this.cachedPrivateKey;
} catch (err) {
throw new CaServiceError(
'Failed to import STEP_CA_PROVISIONER_KEY_JSON as a cryptographic key',
'Ensure STEP_CA_PROVISIONER_KEY_JSON contains a valid JWK private key (EC P-256/P-384 or RSA).',
err,
);
}
}
/**
* Build the JWK-provisioner OTT signed with the provisioner private key.
* Algorithm is derived from the JWK kty/crv fields.
*/
private async buildOtt(params: {
csrPem: string;
grantId: string;
subjectUserId: string;
ttlSeconds: number;
csrCn: string;
}): Promise<string> {
const { csrPem, grantId, subjectUserId, ttlSeconds, csrCn } = params;
// Validate UUID shape for grant id and subject user id
if (!UUID_RE.test(grantId)) {
throw new CaServiceError(
`grantId is not a valid UUID: ${grantId}`,
'Provide a valid UUID (RFC 4122) for grantId.',
undefined,
'INVALID_GRANT_ID',
);
}
if (!UUID_RE.test(subjectUserId)) {
throw new CaServiceError(
`subjectUserId is not a valid UUID: ${subjectUserId}`,
'Provide a valid UUID (RFC 4122) for subjectUserId.',
undefined,
'INVALID_GRANT_ID',
);
}
const sha = csrFingerprint(csrPem);
const now = Math.floor(Date.now() / 1000);
const privateKey = await this.getPrivateKey();
const ott = await new SignJWT({
iss: this.kid,
sub: csrCn, // M1: set sub to identity from CSR CN
aud: [`${this.caUrl}/1.0/sign`],
iat: now,
nbf: now - 30, // 30 s clock-skew tolerance
exp: now + Math.min(ttlSeconds, 3600), // OTT validity ≤ 1 h
jti: crypto.randomUUID(), // M2: unique token ID
// step.sha is the canonical field name used in the template — M3: keep only step.sha
step: { sha },
// Mosaic custom claims consumed by federation.tpl
mosaic_grant_id: grantId,
mosaic_subject_user_id: subjectUserId,
})
.setProtectedHeader({ alg: this.jwtAlg, typ: 'JWT', kid: this.kid })
.sign(privateKey);
return ott;
}
/**
* Validate a PEM-encoded CSR using @peculiar/x509.
* Verifies the self-signature, key type/size, and signature algorithm.
* Optionally verifies that the CSR's SANs match the expected set.
*
* Throws CaServiceError with code 'INVALID_CSR' on failure.
*/
private async validateCsr(pem: string, expectedSans?: string[]): Promise<string> {
let csr: Pkcs10CertificateRequest;
try {
csr = new Pkcs10CertificateRequest(pem);
} catch (err) {
throw new CaServiceError(
'Failed to parse CSR PEM as a valid PKCS#10 certificate request',
'Provide a valid PEM-encoded PKCS#10 CSR.',
err,
'INVALID_CSR',
);
}
// Verify self-signature
let valid: boolean;
try {
valid = await csr.verify();
} catch (err) {
throw new CaServiceError(
'CSR signature verification threw an error',
'The CSR self-signature could not be verified. Ensure the CSR is properly formed.',
err,
'INVALID_CSR',
);
}
if (!valid) {
throw new CaServiceError(
'CSR self-signature is invalid',
'The CSR must be self-signed with the corresponding private key.',
undefined,
'INVALID_CSR',
);
}
// Validate signature algorithm — reject MD5 and SHA-1
// signatureAlgorithm is HashedAlgorithm which extends Algorithm.
// Cast through unknown to access .name and .hash.name without DOM lib globals.
const sigAlgAny = csr.signatureAlgorithm as unknown as {
name?: string;
hash?: { name?: string };
};
const sigAlgName = (sigAlgAny.name ?? '').toLowerCase();
const hashName = (sigAlgAny.hash?.name ?? '').toLowerCase();
if (
sigAlgName.includes('md5') ||
sigAlgName.includes('sha1') ||
hashName === 'sha-1' ||
hashName === 'sha1'
) {
throw new CaServiceError(
`CSR uses a forbidden signature algorithm: ${sigAlgAny.name ?? 'unknown'}`,
'Use SHA-256 or stronger. MD5 and SHA-1 are not permitted.',
undefined,
'INVALID_CSR',
);
}
// Validate public key algorithm and strength via the algorithm descriptor on the key.
// csr.publicKey.algorithm is type Algorithm (WebCrypto) — use name-based checks.
// We cast to an extended interface to access curve/modulus info without DOM globals.
const pubKeyAlgo = csr.publicKey.algorithm as {
name: string;
namedCurve?: string;
modulusLength?: number;
};
const keyAlgoName = pubKeyAlgo.name;
if (keyAlgoName === 'RSASSA-PKCS1-v1_5' || keyAlgoName === 'RSA-PSS') {
const modulusLength = pubKeyAlgo.modulusLength ?? 0;
if (modulusLength < 2048) {
throw new CaServiceError(
`CSR RSA key is too short: ${modulusLength} bits (minimum 2048)`,
'Use an RSA key of at least 2048 bits.',
undefined,
'INVALID_CSR',
);
}
} else if (keyAlgoName === 'ECDSA') {
const namedCurve = pubKeyAlgo.namedCurve ?? '';
const allowedCurves = new Set(['P-256', 'P-384']);
if (!allowedCurves.has(namedCurve)) {
throw new CaServiceError(
`CSR EC key uses disallowed curve: ${namedCurve}`,
'Use EC P-256 or P-384. Other curves are not permitted.',
undefined,
'INVALID_CSR',
);
}
} else if (keyAlgoName === 'Ed25519') {
// Ed25519 is explicitly allowed
} else {
throw new CaServiceError(
`CSR uses unsupported key algorithm: ${keyAlgoName}`,
'Use EC (P-256/P-384), Ed25519, or RSA (≥2048 bit) keys.',
undefined,
'INVALID_CSR',
);
}
// Extract SANs if expectedSans provided
if (expectedSans && expectedSans.length > 0) {
// Get SANs from CSR extensions
const sanExtension = csr.extensions?.find(
(ext) => ext.type === '2.5.29.17', // Subject Alternative Name OID
);
const csrSans: string[] = [];
if (sanExtension) {
// Parse the raw SAN extension — store as stringified for comparison
// @peculiar/x509 exposes SANs through the parsed extension
const sanExt = sanExtension as { names?: Array<{ type: string; value: string }> };
if (sanExt.names) {
for (const name of sanExt.names) {
csrSans.push(name.value);
}
}
}
const csrSanSet = new Set(csrSans);
const expectedSanSet = new Set(expectedSans);
const missing = expectedSans.filter((s) => !csrSanSet.has(s));
const extra = csrSans.filter((s) => !expectedSanSet.has(s));
if (missing.length > 0 || extra.length > 0) {
throw new CaServiceError(
`CSR SANs do not match expected set. Missing: [${missing.join(', ')}], Extra: [${extra.join(', ')}]`,
'The CSR must include exactly the SANs specified in the issuance request.',
undefined,
'INVALID_CSR',
);
}
}
// Return the CN from the CSR subject for use as JWT sub
const cn = csr.subjectName.getField('CN')?.[0] ?? '';
return cn;
}
/**
* 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> {
// Clamp TTL to 15-minute maximum (H2)
const ttl = Math.min(req.ttlSeconds ?? 300, 900);
this.logger.debug(
`issueCert — grantId=${req.grantId} subjectUserId=${req.subjectUserId} ttl=${ttl}s`,
);
// Validate CSR — real cryptographic validation (H3)
const csrCn = await this.validateCsr(req.csrPem);
const ott = await this.buildOtt({
csrPem: req.csrPem,
grantId: req.grantId,
subjectUserId: req.subjectUserId,
ttlSeconds: ttl,
csrCn,
});
const signUrl = `${this.caUrl}/1.0/sign`;
const requestBody = {
csr: req.csrPem,
ott,
validity: {
duration: `${ttl}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;
}
}

View File

@@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { CaService } from './ca.service.js';
@Module({
providers: [CaService],
exports: [CaService],
})
export class FederationModule {}

View File

@@ -1,9 +0,0 @@
import { seal, unseal } from '@mosaicstack/auth';
export function sealClientKey(privateKeyPem: string): string {
return seal(privateKeyPem);
}
export function unsealClientKey(sealedKey: string): string {
return unseal(sealedKey);
}

View File

@@ -1,187 +0,0 @@
/**
* 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);
});
});

View File

@@ -1,147 +0,0 @@
/**
* 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;
}

View File

@@ -36,12 +36,6 @@
# tested locally — gateway boots, imports resolve, tier-detector runs. # tested locally — gateway boots, imports resolve, tier-detector runs.
# Update digest here when promoting a new build. # Update digest here when promoting a new build.
# #
# HEALTHCHECK NOTE (2026-04-21)
# Switched from busybox wget to node http.get on 127.0.0.1 (not localhost) to
# avoid IPv6 resolution issues on Alpine. Retries increased to 5 and
# start_period to 60s to cover the NestJS/GC cold-start window (~40-50s).
# restart_policy set to `any` so SIGTERM/clean-exit also triggers restart.
#
# NOTE: This is a TEST template — production deployments use a separate # NOTE: This is a TEST template — production deployments use a separate
# parameterised template with stricter resource limits and secrets. # parameterised template with stricter resource limits and secrets.
@@ -82,7 +76,7 @@ services:
deploy: deploy:
replicas: 1 replicas: 1
restart_policy: restart_policy:
condition: any condition: on-failure
delay: 5s delay: 5s
max_attempts: 3 max_attempts: 3
labels: labels:
@@ -94,15 +88,11 @@ services:
- 'traefik.http.routers.${STACK_NAME}.tls.certresolver=letsencrypt' - 'traefik.http.routers.${STACK_NAME}.tls.certresolver=letsencrypt'
- 'traefik.http.services.${STACK_NAME}.loadbalancer.server.port=3000' - 'traefik.http.services.${STACK_NAME}.loadbalancer.server.port=3000'
healthcheck: healthcheck:
test: test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/health']
- 'CMD'
- 'node'
- '-e'
- "require('http').get('http://127.0.0.1:3000/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 5 retries: 3
start_period: 60s start_period: 20s
depends_on: depends_on:
- postgres - postgres
- valkey - valkey

View File

@@ -117,74 +117,3 @@ 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`.
## Key rotation (deferred)
Federation peer private keys (`federation_peers.client_key_pem`) are sealed at rest using AES-256-GCM with a key derived from `BETTER_AUTH_SECRET` via SHA-256. If `BETTER_AUTH_SECRET` is rotated, all sealed `client_key_pem` values in the database become unreadable and must be re-sealed with the new key before rotation completes.
The full key rotation procedure (decrypt all rows with old key, re-encrypt with new key, atomically swap the secret) is out of scope for M2. Operators must not rotate `BETTER_AUTH_SECRET` without a migration plan for all sealed federation peer keys.
## 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.

View File

@@ -6,10 +6,6 @@
# 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.
@@ -22,16 +18,15 @@
# 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 emits custom OID extensions: # infra/step-ca/templates/federation.tpl is copied into the CA config
# 1.3.6.1.4.1.99999.1 — mosaic_grant_id # directory so the JWK provisioner can reference it. The template
# 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id # skeleton is wired in M2-04 when the CA service lands the SAN-bearing
# 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..."
@@ -48,37 +43,12 @@ 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. # Copy the X.509 template into the Step-CA config directory so the
if [ -f "${TEMPLATE_SRC}" ]; then # provisioner can reference it in M2-04.
if [ -f "/etc/step-ca-templates/federation.tpl" ]; then
mkdir -p /home/step/templates mkdir -p /home/step/templates
cp "${TEMPLATE_SRC}" "${TEMPLATE_DEST}" cp /etc/step-ca-templates/federation.tpl /home/step/templates/federation.tpl
echo "[step-ca init] Federation X.509 template copied to ${TEMPLATE_DEST}." echo "[step-ca init] Federation X.509 template copied to /home/step/templates/."
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."

View File

@@ -5,49 +5,41 @@
{{- /* {{- /*
Mosaic Federation X.509 Certificate Template Mosaic Federation X.509 Certificate Template
============================================ ============================================
Provisioner: mosaic-fed (JWK) This template is used by the "mosaic-fed" JWK provisioner to sign
Implemented: FED-M2-04 federation client certificates.
This template emits two custom OID extensions carrying Mosaic federation Custom OID extensions (per PRD §6):
identifiers. The OTT token (built by CaService.buildOtt) MUST include the 1.3.6.1.4.1.99999.1 mosaic.federation.grantId (UUID string)
claims `mosaic_grant_id` and `mosaic_subject_user_id` as top-level JWT 1.3.6.1.4.1.99999.2 mosaic.federation.subjectUserId (UUID string)
claims. step-ca exposes them under `.Token.<claim>` in this template.
OID Registry (Mosaic Internal Arc 1.3.6.1.4.1.99999): TODO (M2-04): Wire actual OID extensions below once the CA service
1.3.6.1.4.1.99999.1 mosaic_grant_id (UUID, 36 ASCII chars) (apps/gateway/src/federation/ca.service.ts) lands the SAN-bearing CSR
1.3.6.1.4.1.99999.2 mosaic_subject_user_id (UUID, 36 ASCII chars) work and the template can be exercised end-to-end.
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": [ "extensions": [
{ {
"id": "1.3.6.1.4.1.99999.1", "id": "1.3.6.1.4.1.99999.1",
"critical": false, "critical": false,
"value": "{{ printf "\x0c%c%s" (len .Token.mosaic_grant_id) .Token.mosaic_grant_id | b64enc }}" "value": {{ toJson (first .Token.mosaic_grant_id) }}
}, },
{ {
"id": "1.3.6.1.4.1.99999.2", "id": "1.3.6.1.4.1.99999.2",
"critical": false, "critical": false,
"value": "{{ printf "\x0c%c%s" (len .Token.mosaic_subject_user_id) .Token.mosaic_subject_user_id | b64enc }}" "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.
*/ -}}
"keyUsage": ["digitalSignature"], "keyUsage": ["digitalSignature"],
"extKeyUsage": ["clientAuth"], "extKeyUsage": ["clientAuth"],
"basicConstraints": { "basicConstraints": {

View File

@@ -10,4 +10,3 @@ export {
type SsoTeamSyncConfig, type SsoTeamSyncConfig,
type SupportedSsoProviderId, type SupportedSsoProviderId,
} from './sso.js'; } from './sso.js';
export { seal, unseal } from './seal.js';

View File

@@ -1,52 +0,0 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96-bit IV for GCM
const TAG_LENGTH = 16; // 128-bit auth tag
/**
* Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256.
* Throws if BETTER_AUTH_SECRET is not set.
*/
function deriveKey(): Buffer {
const secret = process.env['BETTER_AUTH_SECRET'];
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key');
}
return createHash('sha256').update(secret).digest();
}
/**
* Seal a plaintext string using AES-256-GCM.
* Output format: base64(IV || authTag || ciphertext)
*/
export function seal(plaintext: string): string {
const key = deriveKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
const combined = Buffer.concat([iv, authTag, encrypted]);
return combined.toString('base64');
}
/**
* Unseal a value sealed by `seal()`.
* Throws on authentication failure (tampered data) or if BETTER_AUTH_SECRET is unset.
*/
export function unseal(encoded: string): string {
const key = deriveKey();
const combined = Buffer.from(encoded, 'base64');
const iv = combined.subarray(0, IV_LENGTH);
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString('utf8');
}

267
pnpm-lock.yaml generated
View File

@@ -131,9 +131,6 @@ importers:
'@opentelemetry/semantic-conventions': '@opentelemetry/semantic-conventions':
specifier: ^1.40.0 specifier: ^1.40.0
version: 1.40.0 version: 1.40.0
'@peculiar/x509':
specifier: ^2.0.0
version: 2.0.0
'@sinclair/typebox': '@sinclair/typebox':
specifier: ^0.34.48 specifier: ^0.34.48
version: 0.34.48 version: 0.34.48
@@ -158,9 +155,6 @@ importers:
ioredis: ioredis:
specifier: ^5.10.0 specifier: ^5.10.0
version: 5.10.0 version: 5.10.0
jose:
specifier: ^6.2.2
version: 6.2.2
node-cron: node-cron:
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1 version: 4.2.1
@@ -710,10 +704,10 @@ importers:
dependencies: dependencies:
'@mariozechner/pi-agent-core': '@mariozechner/pi-agent-core':
specifier: ^0.63.1 specifier: ^0.63.1
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
'@mariozechner/pi-ai': '@mariozechner/pi-ai':
specifier: ^0.63.1 specifier: ^0.63.1
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
'@sinclair/typebox': '@sinclair/typebox':
specifier: ^0.34.41 specifier: ^0.34.41
version: 0.34.48 version: 0.34.48
@@ -3066,40 +3060,6 @@ packages:
'@paralleldrive/cuid2@2.3.1': '@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@peculiar/asn1-cms@2.6.1':
resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==}
'@peculiar/asn1-csr@2.6.1':
resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==}
'@peculiar/asn1-ecc@2.6.1':
resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==}
'@peculiar/asn1-pfx@2.6.1':
resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==}
'@peculiar/asn1-pkcs8@2.6.1':
resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==}
'@peculiar/asn1-pkcs9@2.6.1':
resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==}
'@peculiar/asn1-rsa@2.6.1':
resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==}
'@peculiar/asn1-schema@2.6.0':
resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==}
'@peculiar/asn1-x509-attr@2.6.1':
resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==}
'@peculiar/asn1-x509@2.6.1':
resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==}
'@peculiar/x509@2.0.0':
resolution: {integrity: sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==}
engines: {node: '>=20.0.0'}
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@@ -3995,10 +3955,6 @@ packages:
asap@2.0.6: asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
asn1js@3.0.10:
resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
engines: {node: '>=12.0.0'}
assertion-error@2.0.1: assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -5370,9 +5326,6 @@ packages:
jose@6.2.1: jose@6.2.1:
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
jose@6.2.2:
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -6311,13 +6264,6 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
pvutils@1.1.5:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
qrcode-terminal@0.12.0: qrcode-terminal@0.12.0:
resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==}
hasBin: true hasBin: true
@@ -6886,9 +6832,6 @@ packages:
ts-mixer@6.0.4: ts-mixer@6.0.4:
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -6901,10 +6844,6 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
tsyringe@4.10.0:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
tunnel-agent@0.6.0: tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
@@ -7326,6 +7265,12 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.13 '@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31 '@jridgewell/trace-mapping': 0.3.31
'@anthropic-ai/sdk@0.73.0(zod@3.25.76)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 3.25.76
'@anthropic-ai/sdk@0.73.0(zod@4.3.6)': '@anthropic-ai/sdk@0.73.0(zod@4.3.6)':
dependencies: dependencies:
json-schema-to-ts: 3.1.1 json-schema-to-ts: 3.1.1
@@ -7820,49 +7765,49 @@ snapshots:
'@bcoe/v8-coverage@0.2.3': {} '@bcoe/v8-coverage@0.2.3': {}
'@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)': '@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)':
dependencies: dependencies:
'@better-auth/utils': 0.3.1 '@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21 '@better-fetch/fetch': 1.1.21
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
better-call: 1.3.2(zod@4.3.6) better-call: 1.3.2(zod@4.3.6)
jose: 6.2.2 jose: 6.2.1
kysely: 0.28.11 kysely: 0.28.11
nanostores: 1.1.1 nanostores: 1.1.1
zod: 4.3.6 zod: 4.3.6
'@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))': '@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))':
dependencies: dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1 '@better-auth/utils': 0.3.1
optionalDependencies: optionalDependencies:
drizzle-orm: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8) drizzle-orm: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)
'@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)': '@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)':
dependencies: dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1 '@better-auth/utils': 0.3.1
kysely: 0.28.11 kysely: 0.28.11
'@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': '@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
dependencies: dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1 '@better-auth/utils': 0.3.1
'@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7))': '@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7))':
dependencies: dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1 '@better-auth/utils': 0.3.1
mongodb: 7.1.0(socks@2.8.7) mongodb: 7.1.0(socks@2.8.7)
'@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': '@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
dependencies: dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1 '@better-auth/utils': 0.3.1
'@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))': '@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))':
dependencies: dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1 '@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21 '@better-fetch/fetch': 1.1.21
@@ -8667,6 +8612,18 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)':
dependencies:
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': '@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies: dependencies:
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
@@ -8715,6 +8672,30 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)':
dependencies:
'@anthropic-ai/sdk': 0.73.0(zod@3.25.76)
'@aws-sdk/client-bedrock-runtime': 3.1008.0
'@google/genai': 1.45.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))
'@mistralai/mistralai': 1.14.1
'@sinclair/typebox': 0.34.48
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
chalk: 5.6.2
openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
partial-json: 0.1.7
proxy-agent: 6.5.0
undici: 7.24.3
zod-to-json-schema: 3.25.1(zod@3.25.76)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': '@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies: dependencies:
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6) '@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
@@ -8881,7 +8862,7 @@ snapshots:
express: 5.2.1 express: 5.2.1
express-rate-limit: 8.3.1(express@5.2.1) express-rate-limit: 8.3.1(express@5.2.1)
hono: 4.12.8 hono: 4.12.8
jose: 6.2.2 jose: 6.2.1
json-schema-typed: 8.0.2 json-schema-typed: 8.0.2
pkce-challenge: 5.0.1 pkce-challenge: 5.0.1
raw-body: 3.0.2 raw-body: 3.0.2
@@ -10011,95 +9992,6 @@ snapshots:
dependencies: dependencies:
'@noble/hashes': 1.8.0 '@noble/hashes': 1.8.0
'@peculiar/asn1-cms@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
'@peculiar/asn1-x509-attr': 2.6.1
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-csr@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-ecc@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-pfx@2.6.1':
dependencies:
'@peculiar/asn1-cms': 2.6.1
'@peculiar/asn1-pkcs8': 2.6.1
'@peculiar/asn1-rsa': 2.6.1
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-pkcs8@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-pkcs9@2.6.1':
dependencies:
'@peculiar/asn1-cms': 2.6.1
'@peculiar/asn1-pfx': 2.6.1
'@peculiar/asn1-pkcs8': 2.6.1
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
'@peculiar/asn1-x509-attr': 2.6.1
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-rsa@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-schema@2.6.0':
dependencies:
asn1js: 3.0.10
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/asn1-x509-attr@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-x509@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.10
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/x509@2.0.0':
dependencies:
'@peculiar/asn1-cms': 2.6.1
'@peculiar/asn1-csr': 2.6.1
'@peculiar/asn1-ecc': 2.6.1
'@peculiar/asn1-pkcs9': 2.6.1
'@peculiar/asn1-rsa': 2.6.1
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
pvtsutils: 1.3.6
tslib: 2.8.1
tsyringe: 4.10.0
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
@@ -11056,12 +10948,6 @@ snapshots:
asap@2.0.6: {} asap@2.0.6: {}
asn1js@3.0.10:
dependencies:
pvtsutils: 1.3.6
pvutils: 1.1.5
tslib: 2.8.1
assertion-error@2.0.1: {} assertion-error@2.0.1: {}
ast-types@0.13.4: ast-types@0.13.4:
@@ -11106,20 +10992,20 @@ snapshots:
better-auth@1.5.5(better-sqlite3@12.8.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)): better-auth@1.5.5(better-sqlite3@12.8.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)):
dependencies: dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)) '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))
'@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11) '@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)
'@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) '@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7)) '@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7))
'@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) '@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)) '@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))
'@better-auth/utils': 0.3.1 '@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21 '@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.1.1 '@noble/ciphers': 2.1.1
'@noble/hashes': 2.0.1 '@noble/hashes': 2.0.1
better-call: 1.3.2(zod@4.3.6) better-call: 1.3.2(zod@4.3.6)
defu: 6.1.4 defu: 6.1.4
jose: 6.2.2 jose: 6.2.1
kysely: 0.28.11 kysely: 0.28.11
nanostores: 1.1.1 nanostores: 1.1.1
zod: 4.3.6 zod: 4.3.6
@@ -12562,8 +12448,6 @@ snapshots:
jose@6.2.1: {} jose@6.2.1: {}
jose@6.2.2: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.1.1: js-yaml@4.1.1:
@@ -13307,6 +13191,11 @@ snapshots:
dependencies: dependencies:
mimic-function: 5.0.1 mimic-function: 5.0.1
openai@6.26.0(ws@8.20.0)(zod@3.25.76):
optionalDependencies:
ws: 8.20.0
zod: 3.25.76
openai@6.26.0(ws@8.20.0)(zod@4.3.6): openai@6.26.0(ws@8.20.0)(zod@4.3.6):
optionalDependencies: optionalDependencies:
ws: 8.20.0 ws: 8.20.0
@@ -13659,12 +13548,6 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
pvutils@1.1.5: {}
qrcode-terminal@0.12.0: {} qrcode-terminal@0.12.0: {}
qs@6.15.0: qs@6.15.0:
@@ -14348,8 +14231,6 @@ snapshots:
ts-mixer@6.0.4: {} ts-mixer@6.0.4: {}
tslib@1.14.1: {}
tslib@2.8.1: {} tslib@2.8.1: {}
tslog@4.10.2: {} tslog@4.10.2: {}
@@ -14361,10 +14242,6 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
tsyringe@4.10.0:
dependencies:
tslib: 1.14.1
tunnel-agent@0.6.0: tunnel-agent@0.6.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1