|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | +import { createHash, createPublicKey, generateKeyPairSync, type KeyObject } from 'node:crypto' |
| 5 | +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' |
| 6 | + |
| 7 | +const dependencies = vi.hoisted(() => { |
| 8 | + const rows: Array<{ |
| 9 | + type: string |
| 10 | + providerId: string | null |
| 11 | + encryptedServiceAccountKey: string | null |
| 12 | + }> = [] |
| 13 | + return { |
| 14 | + rows, |
| 15 | + decryptSecret: vi.fn(), |
| 16 | + encryptSecret: vi.fn(), |
| 17 | + sendOciRequest: vi.fn(), |
| 18 | + select: vi.fn(() => ({ |
| 19 | + from: vi.fn(() => ({ |
| 20 | + where: vi.fn(() => ({ limit: vi.fn(async () => rows) })), |
| 21 | + })), |
| 22 | + })), |
| 23 | + } |
| 24 | +}) |
| 25 | + |
| 26 | +vi.mock('@sim/db', () => ({ db: { select: dependencies.select } })) |
| 27 | +vi.mock('@sim/db/schema', () => ({ |
| 28 | + credential: { |
| 29 | + id: 'credential.id', |
| 30 | + type: 'credential.type', |
| 31 | + providerId: 'credential.providerId', |
| 32 | + encryptedServiceAccountKey: 'credential.encryptedServiceAccountKey', |
| 33 | + }, |
| 34 | +})) |
| 35 | +vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => 'predicate') })) |
| 36 | +vi.mock('@/lib/core/security/encryption', () => ({ |
| 37 | + decryptSecret: dependencies.decryptSecret, |
| 38 | + encryptSecret: dependencies.encryptSecret, |
| 39 | +})) |
| 40 | +vi.mock('@/lib/internal/oci/client.server', () => ({ |
| 41 | + sendOciRequest: dependencies.sendOciRequest, |
| 42 | +})) |
| 43 | + |
| 44 | +import { |
| 45 | + buildOciApiKeyServiceAccountSecret, |
| 46 | + loadOciApiKeyCredential, |
| 47 | + normalizeOciFingerprint, |
| 48 | + OciCredentialVerificationError, |
| 49 | + parseOciApiKeyServiceAccountSecret, |
| 50 | + serializeOciApiKeyServiceAccountSecret, |
| 51 | + verifyAndEncryptOciApiKeyCredential, |
| 52 | + verifyOciApiKeyCredential, |
| 53 | +} from '@/lib/credentials/oci-api-key-service-account.server' |
| 54 | +import type { OciRequestResult } from '@/lib/internal/oci/client.server' |
| 55 | +import { OciRequestError } from '@/lib/internal/oci/errors' |
| 56 | +import { |
| 57 | + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, |
| 58 | + OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, |
| 59 | +} from '@/lib/oauth/types' |
| 60 | + |
| 61 | +const TENANCY_ID = 'ocid1.tenancy.oc1..aaaaaaaafoundationtenant' |
| 62 | +const USER_ID = 'ocid1.user.oc1..aaaaaaaafoundationuser' |
| 63 | + |
| 64 | +function fingerprintForKey(privateKey: KeyObject): string { |
| 65 | + const der = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) |
| 66 | + return createHash('md5').update(der).digest('hex').match(/.{2}/g)!.join(':') |
| 67 | +} |
| 68 | + |
| 69 | +function responseResult(body: string): OciRequestResult { |
| 70 | + return { |
| 71 | + response: { text: vi.fn().mockResolvedValue(body) } as unknown as OciRequestResult['response'], |
| 72 | + } |
| 73 | +} |
| 74 | + |
| 75 | +describe('OCI API-key credential foundation', () => { |
| 76 | + let privateKeyObject: KeyObject |
| 77 | + let privateKey: string |
| 78 | + let fingerprint: string |
| 79 | + let encryptedPrivateKey: string |
| 80 | + const passphrase = ' exact passphrase ' |
| 81 | + |
| 82 | + beforeAll(() => { |
| 83 | + privateKeyObject = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey |
| 84 | + privateKey = privateKeyObject.export({ format: 'pem', type: 'pkcs8' }).toString() |
| 85 | + fingerprint = fingerprintForKey(privateKeyObject) |
| 86 | + encryptedPrivateKey = privateKeyObject |
| 87 | + .export({ |
| 88 | + format: 'pem', |
| 89 | + type: 'pkcs8', |
| 90 | + cipher: 'aes-256-cbc', |
| 91 | + passphrase, |
| 92 | + }) |
| 93 | + .toString() |
| 94 | + }) |
| 95 | + |
| 96 | + beforeEach(() => { |
| 97 | + dependencies.rows.splice(0) |
| 98 | + dependencies.decryptSecret.mockReset() |
| 99 | + dependencies.encryptSecret.mockReset() |
| 100 | + dependencies.sendOciRequest.mockReset() |
| 101 | + dependencies.select.mockClear() |
| 102 | + }) |
| 103 | + |
| 104 | + function fields(overrides: Record<string, unknown> = {}) { |
| 105 | + return { |
| 106 | + tenancyId: TENANCY_ID, |
| 107 | + userId: USER_ID, |
| 108 | + fingerprint, |
| 109 | + privateKey, |
| 110 | + defaultRegion: 'us-ashburn-1', |
| 111 | + ...overrides, |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + it('builds a normalized, versioned, provider-bound user-principal secret', () => { |
| 116 | + const secret = buildOciApiKeyServiceAccountSecret( |
| 117 | + fields({ fingerprint: fingerprint.toUpperCase().replaceAll(':', ' ') }) |
| 118 | + ) |
| 119 | + expect(secret).toEqual({ |
| 120 | + type: OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, |
| 121 | + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, |
| 122 | + tenancyId: TENANCY_ID, |
| 123 | + userId: USER_ID, |
| 124 | + fingerprint, |
| 125 | + privateKey, |
| 126 | + defaultRegion: 'us-ashburn-1', |
| 127 | + metadata: { principalKind: 'user', principalId: USER_ID }, |
| 128 | + }) |
| 129 | + expect(secret).not.toHaveProperty('compartmentId') |
| 130 | + expect(secret).not.toHaveProperty('namespace') |
| 131 | + expect(secret).not.toHaveProperty('endpoint') |
| 132 | + expect(secret).not.toHaveProperty('realm') |
| 133 | + }) |
| 134 | + |
| 135 | + it('accepts encrypted RSA PEM only with the exact passphrase', () => { |
| 136 | + expect( |
| 137 | + buildOciApiKeyServiceAccountSecret(fields({ privateKey: encryptedPrivateKey, passphrase })) |
| 138 | + .passphrase |
| 139 | + ).toBe(passphrase) |
| 140 | + expect(() => |
| 141 | + buildOciApiKeyServiceAccountSecret(fields({ privateKey: encryptedPrivateKey })) |
| 142 | + ).toThrow('private key or passphrase') |
| 143 | + expect(() => |
| 144 | + buildOciApiKeyServiceAccountSecret( |
| 145 | + fields({ privateKey: encryptedPrivateKey, passphrase: passphrase.trim() }) |
| 146 | + ) |
| 147 | + ).toThrow('private key or passphrase') |
| 148 | + }) |
| 149 | + |
| 150 | + it('rejects malformed, non-RSA, and undersized private keys', () => { |
| 151 | + expect(() => buildOciApiKeyServiceAccountSecret(fields({ privateKey: 'not a key' }))).toThrow( |
| 152 | + 'PEM encoded' |
| 153 | + ) |
| 154 | + const ecKey = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey |
| 155 | + expect(() => |
| 156 | + buildOciApiKeyServiceAccountSecret( |
| 157 | + fields({ |
| 158 | + privateKey: ecKey.export({ format: 'pem', type: 'pkcs8' }).toString(), |
| 159 | + fingerprint: fingerprintForKey(ecKey), |
| 160 | + }) |
| 161 | + ) |
| 162 | + ).toThrow('must use RSA') |
| 163 | + const smallKey = generateKeyPairSync('rsa', { modulusLength: 1024 }).privateKey |
| 164 | + expect(() => |
| 165 | + buildOciApiKeyServiceAccountSecret( |
| 166 | + fields({ |
| 167 | + privateKey: smallKey.export({ format: 'pem', type: 'pkcs8' }).toString(), |
| 168 | + fingerprint: fingerprintForKey(smallKey), |
| 169 | + }) |
| 170 | + ) |
| 171 | + ).toThrow('at least 2048 bits') |
| 172 | + }) |
| 173 | + |
| 174 | + it('normalizes fingerprints and compares them to the key', () => { |
| 175 | + expect(normalizeOciFingerprint(` ${fingerprint.toUpperCase()} `)).toBe(fingerprint) |
| 176 | + expect(normalizeOciFingerprint(fingerprint.replaceAll(':', ''))).toBe(fingerprint) |
| 177 | + expect(() => normalizeOciFingerprint('aa:bb')).toThrow('16 MD5 bytes') |
| 178 | + expect(() => |
| 179 | + buildOciApiKeyServiceAccountSecret( |
| 180 | + fields({ fingerprint: '00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00' }) |
| 181 | + ) |
| 182 | + ).toThrow('does not match') |
| 183 | + }) |
| 184 | + |
| 185 | + it('enforces size and control-character limits', () => { |
| 186 | + expect(() => |
| 187 | + buildOciApiKeyServiceAccountSecret( |
| 188 | + fields({ tenancyId: `ocid1.tenancy.oc1..${'a'.repeat(240)}` }) |
| 189 | + ) |
| 190 | + ).toThrow('tenancy OCID') |
| 191 | + expect(() => buildOciApiKeyServiceAccountSecret(fields({ userId: `${USER_ID}\n` }))).toThrow( |
| 192 | + 'user OCID' |
| 193 | + ) |
| 194 | + expect(() => |
| 195 | + buildOciApiKeyServiceAccountSecret(fields({ privateKey: `${privateKey}\u0000` })) |
| 196 | + ).toThrow('private key') |
| 197 | + expect(() => |
| 198 | + buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'x'.repeat(4097) })) |
| 199 | + ).toThrow('passphrase') |
| 200 | + expect(() => buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'line\nbreak' }))).toThrow( |
| 201 | + 'passphrase' |
| 202 | + ) |
| 203 | + }) |
| 204 | + |
| 205 | + it('enforces OCID resource type, realm matching, and region membership', () => { |
| 206 | + expect(() => buildOciApiKeyServiceAccountSecret(fields({ tenancyId: USER_ID }))).toThrow( |
| 207 | + 'wrong structure or resource type' |
| 208 | + ) |
| 209 | + expect(() => |
| 210 | + buildOciApiKeyServiceAccountSecret( |
| 211 | + fields({ userId: 'ocid1.user.oc2..aaaaaaaafoundationuser' }) |
| 212 | + ) |
| 213 | + ).toThrow('share a realm') |
| 214 | + expect(() => |
| 215 | + buildOciApiKeyServiceAccountSecret(fields({ defaultRegion: 'unknown-region-1' })) |
| 216 | + ).toThrow('not recognized') |
| 217 | + expect(() => |
| 218 | + buildOciApiKeyServiceAccountSecret(fields({ defaultRegion: 'us-gov-ashburn-1' })) |
| 219 | + ).toThrow('credential realm') |
| 220 | + expect(() => |
| 221 | + buildOciApiKeyServiceAccountSecret( |
| 222 | + fields({ |
| 223 | + tenancyId: 'ocid1.tenancy.oc99..aaaaaaaafoundationtenant', |
| 224 | + userId: 'ocid1.user.oc99..aaaaaaaafoundationuser', |
| 225 | + }) |
| 226 | + ) |
| 227 | + ).toThrow('credential realm') |
| 228 | + }) |
| 229 | + |
| 230 | + it('strictly parses only canonical version-one secrets', () => { |
| 231 | + const secret = buildOciApiKeyServiceAccountSecret(fields()) |
| 232 | + const serialized = serializeOciApiKeyServiceAccountSecret(secret) |
| 233 | + expect(parseOciApiKeyServiceAccountSecret(serialized)).toEqual(secret) |
| 234 | + expect(() => |
| 235 | + parseOciApiKeyServiceAccountSecret(JSON.stringify({ ...secret, compartmentId: TENANCY_ID })) |
| 236 | + ).toThrow('malformed') |
| 237 | + expect(() => |
| 238 | + parseOciApiKeyServiceAccountSecret( |
| 239 | + JSON.stringify({ ...secret, providerId: 'another-provider' }) |
| 240 | + ) |
| 241 | + ).toThrow('malformed') |
| 242 | + expect(() => |
| 243 | + parseOciApiKeyServiceAccountSecret( |
| 244 | + JSON.stringify({ |
| 245 | + ...secret, |
| 246 | + metadata: { principalKind: 'tenant', principalId: TENANCY_ID }, |
| 247 | + }) |
| 248 | + ) |
| 249 | + ).toThrow('malformed') |
| 250 | + expect(() => |
| 251 | + parseOciApiKeyServiceAccountSecret( |
| 252 | + JSON.stringify({ ...secret, defaultRegion: ' US-ASHBURN-1 ' }) |
| 253 | + ) |
| 254 | + ).toThrow('malformed') |
| 255 | + expect(() => |
| 256 | + parseOciApiKeyServiceAccountSecret(JSON.stringify({ ...secret, tenancyId: null })) |
| 257 | + ).toThrow('malformed') |
| 258 | + }) |
| 259 | + |
| 260 | + it('verifies with the exact permissionless GetNamespace request and forwards bounds', async () => { |
| 261 | + const secret = buildOciApiKeyServiceAccountSecret(fields()) |
| 262 | + const controller = new AbortController() |
| 263 | + dependencies.sendOciRequest.mockResolvedValue(responseResult('"tenant-namespace"')) |
| 264 | + await expect(verifyOciApiKeyCredential(secret, controller.signal)).resolves.toEqual({ |
| 265 | + namespace: 'tenant-namespace', |
| 266 | + }) |
| 267 | + expect(dependencies.sendOciRequest).toHaveBeenCalledWith({ |
| 268 | + destination: expect.objectContaining({ |
| 269 | + origin: 'https://objectstorage.us-ashburn-1.oraclecloud.com', |
| 270 | + }), |
| 271 | + credentials: secret, |
| 272 | + method: 'GET', |
| 273 | + encodedPath: '/n/', |
| 274 | + timeout: 10_000, |
| 275 | + maxResponseBytes: 64 * 1024, |
| 276 | + signal: controller.signal, |
| 277 | + serviceHeaders: { accept: 'application/json' }, |
| 278 | + }) |
| 279 | + expect(dependencies.sendOciRequest.mock.calls[0][0]).not.toHaveProperty('queryPairs') |
| 280 | + expect(dependencies.sendOciRequest.mock.calls[0][0]).not.toHaveProperty('compartmentId') |
| 281 | + }) |
| 282 | + |
| 283 | + it('maps authentication, malformed-response, and transient failures to secret-safe errors', async () => { |
| 284 | + const secret = buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'very-secret' })) |
| 285 | + const cases = [ |
| 286 | + { |
| 287 | + failure: new OciRequestError({ |
| 288 | + status: 401, |
| 289 | + message: `echo ${privateKey} very-secret`, |
| 290 | + }), |
| 291 | + code: 'invalid_credentials', |
| 292 | + }, |
| 293 | + { failure: responseResult('{malformed'), code: 'invalid_response' }, |
| 294 | + { failure: new Error(`temporary ${privateKey} very-secret`), code: 'service_unavailable' }, |
| 295 | + ] as const |
| 296 | + for (const testCase of cases) { |
| 297 | + if (testCase.failure instanceof Error) { |
| 298 | + dependencies.sendOciRequest.mockRejectedValueOnce(testCase.failure) |
| 299 | + } else { |
| 300 | + dependencies.sendOciRequest.mockResolvedValueOnce(testCase.failure) |
| 301 | + } |
| 302 | + const failure = await verifyOciApiKeyCredential(secret).catch((error: unknown) => error) |
| 303 | + expect(failure).toBeInstanceOf(OciCredentialVerificationError) |
| 304 | + expect((failure as OciCredentialVerificationError).code).toBe(testCase.code) |
| 305 | + expect((failure as Error).message).not.toContain('very-secret') |
| 306 | + expect((failure as Error).message).not.toContain('BEGIN PRIVATE KEY') |
| 307 | + } |
| 308 | + }) |
| 309 | + |
| 310 | + it('encrypts only after local validation and remote verification succeed', async () => { |
| 311 | + const order: string[] = [] |
| 312 | + dependencies.sendOciRequest.mockImplementation(async () => { |
| 313 | + order.push('verify') |
| 314 | + return responseResult('"namespace"') |
| 315 | + }) |
| 316 | + dependencies.encryptSecret.mockImplementation(async () => { |
| 317 | + order.push('encrypt') |
| 318 | + return { encrypted: 'ciphertext', iv: 'iv' } |
| 319 | + }) |
| 320 | + await expect(verifyAndEncryptOciApiKeyCredential(fields())).resolves.toEqual({ |
| 321 | + encryptedServiceAccountKey: 'ciphertext', |
| 322 | + namespace: 'namespace', |
| 323 | + }) |
| 324 | + expect(order).toEqual(['verify', 'encrypt']) |
| 325 | + |
| 326 | + dependencies.sendOciRequest.mockClear() |
| 327 | + dependencies.encryptSecret.mockClear() |
| 328 | + await expect( |
| 329 | + verifyAndEncryptOciApiKeyCredential(fields({ fingerprint: 'invalid' })) |
| 330 | + ).rejects.toThrow() |
| 331 | + expect(dependencies.sendOciRequest).not.toHaveBeenCalled() |
| 332 | + expect(dependencies.encryptSecret).not.toHaveBeenCalled() |
| 333 | + }) |
| 334 | + |
| 335 | + it('checks both outer and inner provider binding before returning decrypted material', async () => { |
| 336 | + dependencies.rows.push({ |
| 337 | + type: 'service_account', |
| 338 | + providerId: 'another-provider', |
| 339 | + encryptedServiceAccountKey: 'ciphertext', |
| 340 | + }) |
| 341 | + dependencies.decryptSecret.mockResolvedValue({ decrypted: 'should-not-be-read' }) |
| 342 | + await expect(loadOciApiKeyCredential('credential-1')).rejects.toThrow('provider-mismatched') |
| 343 | + expect(dependencies.decryptSecret).not.toHaveBeenCalled() |
| 344 | + |
| 345 | + const secret = buildOciApiKeyServiceAccountSecret(fields()) |
| 346 | + dependencies.rows.splice(0) |
| 347 | + dependencies.rows.push({ |
| 348 | + type: 'service_account', |
| 349 | + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, |
| 350 | + encryptedServiceAccountKey: 'ciphertext', |
| 351 | + }) |
| 352 | + dependencies.decryptSecret.mockResolvedValueOnce({ |
| 353 | + decrypted: JSON.stringify({ ...secret, providerId: 'another-provider' }), |
| 354 | + }) |
| 355 | + await expect(loadOciApiKeyCredential('credential-1')).rejects.toThrow('malformed') |
| 356 | + }) |
| 357 | +}) |
0 commit comments