Skip to content

Commit d7a3b4f

Browse files
feat(oci): add native foundation
1 parent cdc6973 commit d7a3b4f

12 files changed

Lines changed: 1958 additions & 0 deletions
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
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

Comments
 (0)