Compare commits
7 Commits
feat/feder
...
feat/feder
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7524d6e919 | ||
|
|
9f5c28c0ce | ||
| bf082d95a0 | |||
| bb24292cf7 | |||
| f2cda52e1a | |||
| 7d7cf012f0 | |||
| c56dda74aa |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@ coverage
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
docs/reports/
|
docs/reports/
|
||||||
|
|
||||||
|
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||||
|
infra/step-ca/dev-password
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"@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",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -1,62 +1,10 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
import { seal, unseal } from '@mosaicstack/auth';
|
||||||
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);
|
||||||
@@ -74,7 +22,7 @@ export class ProviderCredentialsService {
|
|||||||
value: string,
|
value: string,
|
||||||
metadata?: Record<string, unknown>,
|
metadata?: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const encryptedValue = encrypt(value);
|
const encryptedValue = seal(value);
|
||||||
|
|
||||||
await this.db
|
await this.db
|
||||||
.insert(providerCredentials)
|
.insert(providerCredentials)
|
||||||
@@ -122,7 +70,7 @@ export class ProviderCredentialsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return decrypt(row.encryptedValue);
|
return unseal(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}`,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { GCModule } from './gc/gc.module.js';
|
|||||||
import { ReloadModule } from './reload/reload.module.js';
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
|
import { FederationModule } from './federation/federation.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -52,6 +53,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
|
FederationModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
63
apps/gateway/src/federation/__tests__/peer-key.spec.ts
Normal file
63
apps/gateway/src/federation/__tests__/peer-key.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
57
apps/gateway/src/federation/ca.dto.ts
Normal file
57
apps/gateway/src/federation/ca.dto.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
577
apps/gateway/src/federation/ca.service.spec.ts
Normal file
577
apps/gateway/src/federation/ca.service.spec.ts
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
635
apps/gateway/src/federation/ca.service.ts
Normal file
635
apps/gateway/src/federation/ca.service.ts
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/gateway/src/federation/federation.module.ts
Normal file
8
apps/gateway/src/federation/federation.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CaService } from './ca.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [CaService],
|
||||||
|
exports: [CaService],
|
||||||
|
})
|
||||||
|
export class FederationModule {}
|
||||||
9
apps/gateway/src/federation/peer-key.util.ts
Normal file
9
apps/gateway/src/federation/peer-key.util.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { seal, unseal } from '@mosaicstack/auth';
|
||||||
|
|
||||||
|
export function sealClientKey(privateKeyPem: string): string {
|
||||||
|
return seal(privateKeyPem);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsealClientKey(sealedKey: string): string {
|
||||||
|
return unseal(sealedKey);
|
||||||
|
}
|
||||||
@@ -30,9 +30,18 @@
|
|||||||
# DNS A record ${HOST_FQDN} → Swarm ingress IP (or Cloudflare proxy).
|
# DNS A record ${HOST_FQDN} → Swarm ingress IP (or Cloudflare proxy).
|
||||||
#
|
#
|
||||||
# IMAGE
|
# IMAGE
|
||||||
# Pinned to digest fed-v0.1.0-m1 (DEPLOY-01 verified).
|
# Pinned to sha-9f1a081 (main HEAD post-#488 Dockerfile fix). The previous
|
||||||
|
# pin (fed-v0.1.0-m1, sha256:9b72e2...) had a broken pnpm copy and could
|
||||||
|
# not resolve @mosaicstack/storage at runtime. The new digest was smoke-
|
||||||
|
# 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.
|
||||||
|
|
||||||
@@ -40,8 +49,8 @@ version: '3.9'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
gateway:
|
gateway:
|
||||||
image: git.mosaicstack.dev/mosaicstack/stack/gateway@sha256:9b72e202a9eecc27d31920b87b475b9e96e483c0323acc57856be4b1355db1ec
|
image: git.mosaicstack.dev/mosaicstack/stack/gateway@sha256:1069117740e00ccfeba357cae38c43f3729fe5ae702740ce474f6512414d7c02
|
||||||
# Tag for human reference: fed-v0.1.0-m1
|
# Tag for human reference: sha-9f1a081 (post-#488 Dockerfile fix; smoke-tested locally)
|
||||||
environment:
|
environment:
|
||||||
# ── Tier ───────────────────────────────────────────────────────────────
|
# ── Tier ───────────────────────────────────────────────────────────────
|
||||||
MOSAIC_TIER: federated
|
MOSAIC_TIER: federated
|
||||||
@@ -73,7 +82,7 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: any
|
||||||
delay: 5s
|
delay: 5s
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
labels:
|
labels:
|
||||||
@@ -85,11 +94,15 @@ 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: ['CMD', 'wget', '-qO-', 'http://localhost:3000/health']
|
test:
|
||||||
|
- '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: 3
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 60s
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- valkey
|
- valkey
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ services:
|
|||||||
postgres-federated:
|
postgres-federated:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
profiles: [federated]
|
profiles: [federated]
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- '${PG_FEDERATED_HOST_PORT:-5433}:5432'
|
- '${PG_FEDERATED_HOST_PORT:-5433}:5432'
|
||||||
environment:
|
environment:
|
||||||
@@ -45,6 +46,7 @@ services:
|
|||||||
valkey-federated:
|
valkey-federated:
|
||||||
image: valkey/valkey:8-alpine
|
image: valkey/valkey:8-alpine
|
||||||
profiles: [federated]
|
profiles: [federated]
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- '${VALKEY_FEDERATED_HOST_PORT:-6380}:6379'
|
- '${VALKEY_FEDERATED_HOST_PORT:-6380}:6379'
|
||||||
volumes:
|
volumes:
|
||||||
@@ -55,6 +57,64 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step-CA — Mosaic Federation internal certificate authority
|
||||||
|
#
|
||||||
|
# Image: pinned to 0.27.4 (latest stable as of late 2025).
|
||||||
|
# `latest` is forbidden per Mosaic image policy (immutable tag required for
|
||||||
|
# reproducible deployments and digest-first promotion in CI).
|
||||||
|
#
|
||||||
|
# Profile: `federated` — this service must not start in non-federated dev.
|
||||||
|
#
|
||||||
|
# Password:
|
||||||
|
# Dev: bind-mount ./infra/step-ca/dev-password (gitignored; copy from
|
||||||
|
# ./infra/step-ca/dev-password.example and customise locally).
|
||||||
|
# Prod: replace the bind-mount with a Docker secret:
|
||||||
|
# secrets:
|
||||||
|
# ca_password:
|
||||||
|
# external: true
|
||||||
|
# and reference it as `/run/secrets/ca_password` (same path the
|
||||||
|
# init script already uses).
|
||||||
|
#
|
||||||
|
# Provisioner: "mosaic-fed" (consumed by apps/gateway/src/federation/ca.service.ts)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
step-ca:
|
||||||
|
image: smallstep/step-ca:0.27.4
|
||||||
|
profiles: [federated]
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '${STEP_CA_HOST_PORT:-9000}:9000'
|
||||||
|
volumes:
|
||||||
|
- step_ca_data:/home/step
|
||||||
|
# init script — executed as the container entrypoint
|
||||||
|
- ./infra/step-ca/init.sh:/usr/local/bin/mosaic-step-ca-init.sh:ro
|
||||||
|
# X.509 template skeleton (wired in M2-04)
|
||||||
|
- ./infra/step-ca/templates:/etc/step-ca-templates:ro
|
||||||
|
# Dev password file — GITIGNORED; copy from dev-password.example
|
||||||
|
# In production, replace this with a Docker secret (see comment above).
|
||||||
|
- ./infra/step-ca/dev-password:/run/secrets/ca_password:ro
|
||||||
|
entrypoint: ['/bin/sh', '/usr/local/bin/mosaic-step-ca-init.sh']
|
||||||
|
healthcheck:
|
||||||
|
# The healthcheck requires the root cert to exist, which is only true
|
||||||
|
# after init.sh has completed on first boot. start_period gives init
|
||||||
|
# time to finish before Docker starts counting retries.
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
'CMD',
|
||||||
|
'step',
|
||||||
|
'ca',
|
||||||
|
'health',
|
||||||
|
'--ca-url',
|
||||||
|
'https://localhost:9000',
|
||||||
|
'--root',
|
||||||
|
'/home/step/certs/root_ca.crt',
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pg_federated_data:
|
pg_federated_data:
|
||||||
valkey_federated_data:
|
valkey_federated_data:
|
||||||
|
step_ca_data:
|
||||||
|
|||||||
@@ -117,3 +117,74 @@ 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.
|
||||||
|
|||||||
@@ -64,11 +64,11 @@ Goal: Two federated-tier gateways stood up on Portainer at `mos-test-1.woltje.co
|
|||||||
Goal: An admin can create a federation grant; counterparty enrolls; cert is signed by Step-CA with SAN OIDs for `grantId` + `subjectUserId`. No runtime federation traffic flows yet (that's M3).
|
Goal: An admin can create a federation grant; counterparty enrolls; cert is signed by Step-CA with SAN OIDs for `grantId` + `subjectUserId`. No runtime federation traffic flows yet (that's M3).
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| FED-M2-01 | needs-qa | DB migration: `federation_grants`, `federation_peers`, `federation_audit_log` tables + enum types (`grant_status`, `peer_state`). Drizzle schema + migration generation; migration tests. | #461 | sonnet | feat/federation-m2-schema | — | 5K | PR #486 open. First review NEEDS CHANGES (missing DESC indexes + reserved cols). Remediation subagent `a673dd9355dc26f82` in flight in worktree `agent-a4404ac1`. |
|
| FED-M2-01 | needs-qa | DB migration: `federation_grants`, `federation_peers`, `federation_audit_log` tables + enum types (`grant_status`, `peer_state`). Drizzle schema + migration generation; migration tests. | #461 | sonnet | feat/federation-m2-schema | — | 5K | PR #486 open. First review NEEDS CHANGES (missing DESC indexes + reserved cols). Remediation subagent `a673dd9355dc26f82` in flight in worktree `agent-a4404ac1`. |
|
||||||
| FED-M2-02 | not-started | Add Step-CA sidecar to `docker-compose.federated.yml`: official `smallstep/step-ca` image, persistent CA volume, JWK provisioner config baked into init script. | #461 | sonnet | feat/federation-m2-stepca | DEPLOY-02 | 4K | Profile-gated under `federated`. CA password from secret; dev compose uses dev-only password file. |
|
| FED-M2-02 | not-started | Add Step-CA sidecar to `docker-compose.federated.yml`: official `smallstep/step-ca` image, persistent CA volume, JWK provisioner config baked into init script. | #461 | sonnet | feat/federation-m2-stepca | DEPLOY-02 | 4K | Profile-gated under `federated`. CA password from secret; dev compose uses dev-only password file. |
|
||||||
| FED-M2-03 | not-started | Scope JSON schema + validator: `resources` allowlist, `excluded_resources`, `include_teams`, `include_personal`, `max_rows_per_query`. Vitest unit tests for valid + invalid scopes. | #461 | sonnet | feat/federation-m2-scope-schema | — | 4K | Validator independent of CA — reusable from grant CRUD + (later) M3 scope enforcement. |
|
| FED-M2-03 | not-started | Scope JSON schema + validator: `resources` allowlist, `excluded_resources`, `include_teams`, `include_personal`, `max_rows_per_query`. Vitest unit tests for valid + invalid scopes. | #461 | sonnet | feat/federation-m2-scope-schema | — | 4K | Validator independent of CA — reusable from grant CRUD + (later) M3 scope enforcement. |
|
||||||
| FED-M2-04 | not-started | `apps/gateway/src/federation/ca.service.ts`: Step-CA client (CSR submission, OID-bearing cert retrieval). Mocked + integration tests against real Step-CA container. | #461 | sonnet | feat/federation-m2-ca-service | M2-02 | 6K | SAN OIDs: `grantId` (custom OID 1.3.6.1.4.1.99999.1) + `subjectUserId` (1.3.6.1.4.1.99999.2). Document OID assignments in PRD/SETUP. |
|
| FED-M2-04 | not-started | `apps/gateway/src/federation/ca.service.ts`: Step-CA client (CSR submission, OID-bearing cert retrieval). Mocked + integration tests against real Step-CA container. | #461 | sonnet | feat/federation-m2-ca-service | M2-02 | 6K | SAN OIDs: `grantId` (custom OID 1.3.6.1.4.1.99999.1) + `subjectUserId` (1.3.6.1.4.1.99999.2). Document OID assignments in PRD/SETUP. **Acceptance**: must (a) wire `federation.tpl` template into `mosaic-fed` provisioner config and (b) include a unit/integration test asserting issued certs contain BOTH OIDs — fails-loud guard against silent OID stripping (carry-forward from M2-02 review). |
|
||||||
| FED-M2-05 | not-started | Sealed storage for `client_key_pem` reusing existing `provider_credentials` sealing key. Tests prove DB-at-rest is ciphertext, not PEM. Key rotation path documented (deferred impl). | #461 | sonnet | feat/federation-m2-key-sealing | M2-01 | 5K | Separate from M2-06 to keep crypto seam isolated; reviewer focus is sealing only. |
|
| FED-M2-05 | not-started | Sealed storage for `client_key_pem` reusing existing `provider_credentials` sealing key. Tests prove DB-at-rest is ciphertext, not PEM. Key rotation path documented (deferred impl). | #461 | sonnet | feat/federation-m2-key-sealing | M2-01 | 5K | Separate from M2-06 to keep crypto seam isolated; reviewer focus is sealing only. |
|
||||||
| FED-M2-06 | not-started | `grants.service.ts`: CRUD + status transitions (`pending` → `active` → `revoked`); integrates M2-03 (scope) + M2-05 (sealing). Unit tests cover all transitions including invalid ones. | #461 | sonnet | feat/federation-m2-grants-service | M2-03, M2-05 | 6K | Business logic only — CSR + cert work delegated to M2-04. Revocation handler is M6. |
|
| FED-M2-06 | not-started | `grants.service.ts`: CRUD + status transitions (`pending` → `active` → `revoked`); integrates M2-03 (scope) + M2-05 (sealing). Unit tests cover all transitions including invalid ones. | #461 | sonnet | feat/federation-m2-grants-service | M2-03, M2-05 | 6K | Business logic only — CSR + cert work delegated to M2-04. Revocation handler is M6. |
|
||||||
| FED-M2-07 | not-started | `enrollment.controller.ts`: short-lived single-use token endpoint; CSR signing; updates grant `pending` → `active`; emits enrollment audit (table-only write, M4 tightens). | #461 | sonnet | feat/federation-m2-enrollment | M2-04, M2-06 | 6K | Tokens single-use with 410 on replay; tokens TTL'd at 15min; rate-limited at request layer (M4 introduces guard, M2 uses simple lock). |
|
| FED-M2-07 | not-started | `enrollment.controller.ts`: short-lived single-use token endpoint; CSR signing; updates grant `pending` → `active`; emits enrollment audit (table-only write, M4 tightens). | #461 | sonnet | feat/federation-m2-enrollment | M2-04, M2-06 | 6K | Tokens single-use with 410 on replay; tokens TTL'd at 15min; rate-limited at request layer (M4 introduces guard, M2 uses simple lock). |
|
||||||
|
|||||||
1
infra/step-ca/dev-password.example
Normal file
1
infra/step-ca/dev-password.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dev-only-step-ca-password-do-not-use-in-production
|
||||||
90
infra/step-ca/init.sh
Executable file
90
infra/step-ca/init.sh
Executable file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# infra/step-ca/init.sh
|
||||||
|
#
|
||||||
|
# Idempotent first-boot initialiser for the Mosaic Federation CA.
|
||||||
|
#
|
||||||
|
# On the first run (no /home/step/config/ca.json present) this script:
|
||||||
|
# 1. Initialises Step-CA with a JWK provisioner named "mosaic-fed".
|
||||||
|
# 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
|
||||||
|
# starts the CA directly.
|
||||||
|
#
|
||||||
|
# The provisioner name "mosaic-fed" is consumed by:
|
||||||
|
# apps/gateway/src/federation/ca.service.ts (added in M2-04)
|
||||||
|
#
|
||||||
|
# Password source:
|
||||||
|
# Dev: mounted from ./infra/step-ca/dev-password via bind mount.
|
||||||
|
# Prod: mounted from a Docker secret at /run/secrets/ca_password.
|
||||||
|
#
|
||||||
|
# OID template:
|
||||||
|
# infra/step-ca/templates/federation.tpl emits custom OID extensions:
|
||||||
|
# 1.3.6.1.4.1.99999.1 — mosaic_grant_id
|
||||||
|
# 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CA_CONFIG="/home/step/config/ca.json"
|
||||||
|
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
|
||||||
|
echo "[step-ca init] First boot detected — initialising Mosaic Federation CA..."
|
||||||
|
|
||||||
|
step ca init \
|
||||||
|
--name "Mosaic Federation CA" \
|
||||||
|
--dns "localhost" \
|
||||||
|
--dns "step-ca" \
|
||||||
|
--address ":9000" \
|
||||||
|
--provisioner "mosaic-fed" \
|
||||||
|
--password-file "${PASSWORD_FILE}" \
|
||||||
|
--provisioner-password-file "${PASSWORD_FILE}" \
|
||||||
|
--no-db
|
||||||
|
|
||||||
|
echo "[step-ca init] CA initialised."
|
||||||
|
|
||||||
|
# Copy the X.509 template into the Step-CA config directory.
|
||||||
|
if [ -f "${TEMPLATE_SRC}" ]; then
|
||||||
|
mkdir -p /home/step/templates
|
||||||
|
cp "${TEMPLATE_SRC}" "${TEMPLATE_DEST}"
|
||||||
|
echo "[step-ca init] Federation X.509 template copied to ${TEMPLATE_DEST}."
|
||||||
|
else
|
||||||
|
echo "[step-ca init] WARNING: Template source ${TEMPLATE_SRC} not found — skipping copy."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wire the template into the mosaic-fed provisioner via jq.
|
||||||
|
# This is idempotent: the block only runs once (first boot).
|
||||||
|
#
|
||||||
|
# jq filter: find the provisioner entry with name "mosaic-fed" and set
|
||||||
|
# .options.x509.templateFile to the absolute path of the template.
|
||||||
|
# All other provisioners and config keys are left unchanged.
|
||||||
|
if [ -f "${TEMPLATE_DEST}" ] && command -v jq > /dev/null 2>&1; then
|
||||||
|
echo "[step-ca init] Patching mosaic-fed provisioner with X.509 template..."
|
||||||
|
TEMP_CONFIG="${CA_CONFIG}.tmp"
|
||||||
|
jq --arg tpl "${TEMPLATE_DEST}" '
|
||||||
|
.authority.provisioners |= map(
|
||||||
|
if .name == "mosaic-fed" then
|
||||||
|
.options.x509.templateFile = $tpl
|
||||||
|
else
|
||||||
|
.
|
||||||
|
end
|
||||||
|
)
|
||||||
|
' "${CA_CONFIG}" > "${TEMP_CONFIG}" && mv "${TEMP_CONFIG}" "${CA_CONFIG}"
|
||||||
|
echo "[step-ca init] Provisioner patched."
|
||||||
|
elif ! command -v jq > /dev/null 2>&1; then
|
||||||
|
echo "[step-ca init] WARNING: jq not found — skipping provisioner template patch."
|
||||||
|
echo "[step-ca init] Install jq in the step-ca image to enable automatic template wiring."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[step-ca init] Startup complete."
|
||||||
|
else
|
||||||
|
echo "[step-ca init] Config already exists — skipping init."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[step-ca init] Starting Step-CA on :9000..."
|
||||||
|
exec step-ca /home/step/config/ca.json --password-file "${PASSWORD_FILE}"
|
||||||
56
infra/step-ca/templates/federation.tpl
Normal file
56
infra/step-ca/templates/federation.tpl
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"subject": {{ toJson .Subject }},
|
||||||
|
"sans": {{ toJson .SANs }},
|
||||||
|
|
||||||
|
{{- /*
|
||||||
|
Mosaic Federation X.509 Certificate Template
|
||||||
|
============================================
|
||||||
|
Provisioner: mosaic-fed (JWK)
|
||||||
|
Implemented: FED-M2-04
|
||||||
|
|
||||||
|
This template emits two custom OID extensions carrying Mosaic federation
|
||||||
|
identifiers. The OTT token (built by CaService.buildOtt) MUST include the
|
||||||
|
claims `mosaic_grant_id` and `mosaic_subject_user_id` as top-level JWT
|
||||||
|
claims. step-ca exposes them under `.Token.<claim>` in this template.
|
||||||
|
|
||||||
|
OID Registry (Mosaic Internal Arc — 1.3.6.1.4.1.99999):
|
||||||
|
1.3.6.1.4.1.99999.1 mosaic_grant_id (UUID, 36 ASCII chars)
|
||||||
|
1.3.6.1.4.1.99999.2 mosaic_subject_user_id (UUID, 36 ASCII chars)
|
||||||
|
|
||||||
|
DER encoding for each extension value (ASN.1 UTF8String):
|
||||||
|
Tag = 0x0C (UTF8String)
|
||||||
|
Length = 0x24 (decimal 36 — the fixed length of a UUID string)
|
||||||
|
Value = 36 ASCII bytes of the UUID
|
||||||
|
|
||||||
|
The `printf` below builds the raw TLV bytes then base64-encodes them.
|
||||||
|
step-ca expects the `value` field to be base64-encoded raw DER bytes.
|
||||||
|
|
||||||
|
Fail-loud contract:
|
||||||
|
If either claim is missing from the token the printf will produce a
|
||||||
|
zero-length UUID field, making the extension malformed. step-ca will
|
||||||
|
reject the certificate rather than issuing one without the required OIDs.
|
||||||
|
Silent OID stripping is NEVER tolerated.
|
||||||
|
|
||||||
|
Step-CA template reference:
|
||||||
|
https://smallstep.com/docs/step-ca/templates
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
"extensions": [
|
||||||
|
{
|
||||||
|
"id": "1.3.6.1.4.1.99999.1",
|
||||||
|
"critical": false,
|
||||||
|
"value": "{{ printf "\x0c%c%s" (len .Token.mosaic_grant_id) .Token.mosaic_grant_id | b64enc }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1.3.6.1.4.1.99999.2",
|
||||||
|
"critical": false,
|
||||||
|
"value": "{{ printf "\x0c%c%s" (len .Token.mosaic_subject_user_id) .Token.mosaic_subject_user_id | b64enc }}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"keyUsage": ["digitalSignature"],
|
||||||
|
"extKeyUsage": ["clientAuth"],
|
||||||
|
"basicConstraints": {
|
||||||
|
"isCA": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,3 +10,4 @@ export {
|
|||||||
type SsoTeamSyncConfig,
|
type SsoTeamSyncConfig,
|
||||||
type SupportedSsoProviderId,
|
type SupportedSsoProviderId,
|
||||||
} from './sso.js';
|
} from './sso.js';
|
||||||
|
export { seal, unseal } from './seal.js';
|
||||||
|
|||||||
52
packages/auth/src/seal.ts
Normal file
52
packages/auth/src/seal.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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
267
pnpm-lock.yaml
generated
@@ -131,6 +131,9 @@ 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
|
||||||
@@ -155,6 +158,9 @@ 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
|
||||||
@@ -704,10 +710,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@3.25.76)
|
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
'@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@3.25.76)
|
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
specifier: ^0.34.41
|
specifier: ^0.34.41
|
||||||
version: 0.34.48
|
version: 0.34.48
|
||||||
@@ -3060,6 +3066,40 @@ 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==}
|
||||||
|
|
||||||
@@ -3955,6 +3995,10 @@ 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'}
|
||||||
@@ -5326,6 +5370,9 @@ 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==}
|
||||||
|
|
||||||
@@ -6264,6 +6311,13 @@ 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
|
||||||
@@ -6832,6 +6886,9 @@ 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==}
|
||||||
|
|
||||||
@@ -6844,6 +6901,10 @@ 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==}
|
||||||
|
|
||||||
@@ -7265,12 +7326,6 @@ 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
|
||||||
@@ -7765,49 +7820,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.1)(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.2)(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.1
|
jose: 6.2.2
|
||||||
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.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/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))':
|
||||||
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.1)(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.2)(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.1)(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.2)(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.1)(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.2)(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.1)(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.2)(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.1)(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.2)(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.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/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))':
|
||||||
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.1)(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.2)(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.1)(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.2)(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.1)(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.2)(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.1)(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.2)(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.1)(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.2)(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
|
||||||
|
|
||||||
@@ -8612,18 +8667,6 @@ 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)
|
||||||
@@ -8672,30 +8715,6 @@ 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)
|
||||||
@@ -8862,7 +8881,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.1
|
jose: 6.2.2
|
||||||
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
|
||||||
@@ -9992,6 +10011,95 @@ 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':
|
||||||
@@ -10948,6 +11056,12 @@ 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:
|
||||||
@@ -10992,20 +11106,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.1)(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.2)(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.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/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/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/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/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/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/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/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/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/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/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/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/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.1
|
jose: 6.2.2
|
||||||
kysely: 0.28.11
|
kysely: 0.28.11
|
||||||
nanostores: 1.1.1
|
nanostores: 1.1.1
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
@@ -12448,6 +12562,8 @@ 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:
|
||||||
@@ -13191,11 +13307,6 @@ 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
|
||||||
@@ -13548,6 +13659,12 @@ 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:
|
||||||
@@ -14231,6 +14348,8 @@ 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: {}
|
||||||
@@ -14242,6 +14361,10 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user