Skip to content

Commit a3ef083

Browse files
committed
feat: support external signers for authenticated clients initialization
Signed-off-by: DaevMithran <daevmithran1999@gmail.com>
1 parent 70031e6 commit a3ef083

11 files changed

Lines changed: 295 additions & 39 deletions

File tree

packages/http-signature-utils/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ export {
66
loadOrGenerateKey,
77
loadBase64Key
88
} from './utils/key'
9-
export { createSignatureHeaders, type RequestLike } from './utils/signatures'
9+
export {
10+
createSignatureHeaders,
11+
type RequestLike,
12+
type Signer
13+
} from './utils/signatures'
1014
export { validateSignatureHeaders, validateSignature } from './utils/validation'
1115
export { generateTestKeys, TestKeys } from './test-utils/keys'

packages/http-signature-utils/src/utils/headers.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ const createContentHeaders = (body: string): ContentHeaders => {
2626

2727
export const createHeaders = async ({
2828
request,
29-
privateKey,
30-
keyId
29+
...signOptions
3130
}: SignOptions): Promise<Headers> => {
3231
const contentHeaders =
3332
request.body && createContentHeaders(request.body as string)
@@ -38,8 +37,7 @@ export const createHeaders = async ({
3837

3938
const signatureHeaders = await createSignatureHeaders({
4039
request,
41-
privateKey,
42-
keyId
40+
...signOptions
4341
})
4442

4543
return {

packages/http-signature-utils/src/utils/signatures.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { generateTestKeys, TestKeys } from '../test-utils/keys'
22
import { createSignatureHeaders, RequestLike } from './signatures'
3+
import { sign } from 'crypto'
34

45
describe('Signature Generation', (): void => {
56
let testKeys: TestKeys
@@ -32,4 +33,30 @@ describe('Signature Generation', (): void => {
3233
expect(signatureParameters).toContain('keyid')
3334
expect(signatureParameters).toContain('created')
3435
})
36+
37+
test('uses a custom signer when provided', async (): Promise<void> => {
38+
const request: RequestLike = {
39+
headers: {},
40+
method: 'GET',
41+
url: 'http://example.com/test'
42+
}
43+
44+
const signer = {
45+
sign: jest.fn((data: Uint8Array): Uint8Array =>
46+
sign(null, Buffer.from(data), testKeys.privateKey)
47+
)
48+
}
49+
50+
const signatureHeaders = await createSignatureHeaders({
51+
request,
52+
signer,
53+
keyId: testKeys.publicKey.kid
54+
})
55+
56+
expect(signer.sign).toHaveBeenCalledTimes(1)
57+
expect(signatureHeaders.Signature).toBeDefined()
58+
expect(signatureHeaders['Signature-Input']).toContain(
59+
`keyid="${testKeys.publicKey.kid}"`
60+
)
61+
})
3562
})

packages/http-signature-utils/src/utils/signatures.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,56 @@
11
import { type KeyLike } from 'crypto'
2-
import { httpbis, createSigner, Request } from 'http-message-signatures'
2+
import {
3+
httpbis,
4+
createSigner,
5+
Request,
6+
type SigningKey
7+
} from 'http-message-signatures'
38

49
export interface RequestLike extends Request {
510
body?: string
611
}
712

8-
export interface SignOptions {
13+
export interface Signer {
14+
sign(data: Uint8Array): Uint8Array | Promise<Uint8Array>
15+
}
16+
17+
interface BaseSignOptions {
918
request: RequestLike
10-
privateKey: KeyLike
1119
keyId: string
1220
}
1321

22+
export interface PrivateKeySignOptions extends BaseSignOptions {
23+
privateKey: KeyLike
24+
}
25+
26+
export interface SignerSignOptions extends BaseSignOptions {
27+
signer: Signer
28+
}
29+
30+
export type SignOptions = PrivateKeySignOptions | SignerSignOptions
31+
1432
export interface SignatureHeaders {
1533
Signature: string
1634
'Signature-Input': string
1735
}
1836

19-
export const createSignatureHeaders = async ({
20-
request,
21-
privateKey,
22-
keyId
23-
}: SignOptions): Promise<SignatureHeaders> => {
37+
const createSigningKey = (options: SignOptions): SigningKey => {
38+
if ('signer' in options) {
39+
return {
40+
id: options.keyId,
41+
alg: 'ed25519',
42+
sign: async (data: Buffer): Promise<Buffer> =>
43+
Buffer.from(await options.signer.sign(data))
44+
}
45+
}
46+
47+
return createSigner(options.privateKey, 'ed25519', options.keyId)
48+
}
49+
50+
export const createSignatureHeaders = async (
51+
options: SignOptions
52+
): Promise<SignatureHeaders> => {
53+
const { request } = options
2454
const components = ['@method', '@target-uri']
2555
if (request.headers['Authorization'] || request.headers['authorization']) {
2656
components.push('authorization')
@@ -29,7 +59,7 @@ export const createSignatureHeaders = async ({
2959
components.push('content-digest', 'content-length', 'content-type')
3060
}
3161

32-
const signingKey = createSigner(privateKey, 'ed25519', keyId)
62+
const signingKey = createSigningKey(options)
3363

3464
const { headers } = await httpbis.signMessage(
3565
{

packages/open-payments/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ const incomingPayment = await client.walletAddress.get({
6161

6262
### `AuthenticatedClient`
6363

64-
An `AuthenticatedClient` provides all of the methods that `UnauthenticatedClient` does, as well as the rest of the Open Payment APIs (both the authorizaton and resource specs). Each request requiring authentication will be signed (using [HTTP Message Signatures](https://github.com/interledger/open-payments/tree/main/packages/http-signature-utils)) with the given private key.
64+
An `AuthenticatedClient` provides all of the methods that `UnauthenticatedClient` does, as well as the rest of the Open Payment APIs (both the authorizaton and resource specs). Each request requiring authentication will be signed (using [HTTP Message Signatures](https://github.com/interledger/open-payments/tree/main/packages/http-signature-utils)) with the given private key or signer.
6565

6666
```ts
6767
import { createAuthenticatedClient } from '@interledger/open-payments'
@@ -81,6 +81,22 @@ In order to create the client, three properties need to be provided: `keyId`, th
8181
| `privateKey` | The private EdDSA-Ed25519 key (or the relative or absolute path to the key) bound to the wallet address, and used to sign the authenticated requests with. As mentioned above, a public JWK document signed with this key MUST be available at the `{walletAddressUrl}/jwks.json` url. |
8282
| `keyId` | The key identifier of the given private key and the corresponding public JWK document. |
8383

84+
For deployments where the private key is managed by a KMS, HSM, Secure Enclave, or another non-extractable key store, provide a signer instead of `privateKey`:
85+
86+
```ts
87+
import { createAuthenticatedClient } from '@interledger/open-payments'
88+
89+
const client = await createAuthenticatedClient({
90+
keyId: KEY_ID,
91+
signer: {
92+
async sign(data: Uint8Array): Promise<Uint8Array> {
93+
return kms.sign(data)
94+
}
95+
},
96+
walletAddressUrl: WALLET_ADDRESS_URL
97+
})
98+
```
99+
84100
> **Note**
85101
>
86102
> To simplify EdDSA-Ed25519 key provisioning and JWK generation, you can use methods from the [`@interledger/http-signature-utils`](https://github.com/interledger/open-payments/tree/main/packages/http-signature-utils) package.

packages/open-payments/src/client/index.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@ describe('Client', (): void => {
7474
).resolves.toBeDefined()
7575
})
7676

77+
test('properly creates the client if a signer is passed', async (): Promise<void> => {
78+
await expect(
79+
createAuthenticatedClient({
80+
logger: silentLogger,
81+
keyId: 'keyid-1',
82+
walletAddressUrl: 'http://localhost:1000/.well-known/pay',
83+
signer: {
84+
sign: jest.fn((data: Uint8Array): Uint8Array => data)
85+
}
86+
})
87+
).resolves.toBeDefined()
88+
})
89+
7790
test('throws error if could not load private key as Buffer', async (): Promise<void> => {
7891
expect.assertions(2)
7992
try {
@@ -134,10 +147,36 @@ describe('Client', (): void => {
134147
'Invalid arguments when creating authenticated client.'
135148
)
136149
expect(error.description).toBe(
137-
'Both `authenticatedRequestInterceptor` and `privateKey`/`keyId` were provided. Please use only one of these options.'
150+
'Exactly one of `authenticatedRequestInterceptor`, `privateKey`/`keyId`, or `signer`/`keyId` must be provided.'
138151
)
139152
}
140153
}
141154
)
155+
156+
test('throws an error if both privateKey and signer are provided', async (): Promise<void> => {
157+
expect.assertions(2)
158+
try {
159+
const keypair = generateKeyPairSync('ed25519')
160+
161+
// @ts-expect-error Invalid args
162+
await createAuthenticatedClient({
163+
logger: silentLogger,
164+
keyId: 'keyid-1',
165+
walletAddressUrl: 'http://localhost:1000/.well-known/pay',
166+
privateKey: keypair.privateKey,
167+
signer: {
168+
sign: jest.fn((data: Uint8Array): Uint8Array => data)
169+
}
170+
})
171+
} catch (error) {
172+
assert.ok(error instanceof OpenPaymentsClientError)
173+
expect(error.message).toBe(
174+
'Invalid arguments when creating authenticated client.'
175+
)
176+
expect(error.description).toBe(
177+
'Exactly one of `authenticatedRequestInterceptor`, `privateKey`/`keyId`, or `signer`/`keyId` must be provided.'
178+
)
179+
}
180+
})
142181
})
143182
})

packages/open-payments/src/client/index.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { loadKey } from '@interledger/http-signature-utils'
1+
import { loadKey, type Signer } from '@interledger/http-signature-utils'
22
import fs from 'fs'
33
import { OpenAPI } from '@interledger/openapi'
44
import path from 'path'
@@ -167,6 +167,7 @@ const createAuthenticatedClientDeps = async ({
167167
...args
168168
}:
169169
| CreateAuthenticatedClientArgs
170+
| CreateAuthenticatedClientWithSignerArgs
170171
| CreateAuthenticatedClientWithReqInterceptorArgs): Promise<AuthenticatedClientDeps> => {
171172
const logger =
172173
args.logger ??
@@ -186,6 +187,16 @@ const createAuthenticatedClientDeps = async ({
186187
authenticatedRequestInterceptor: args.authenticatedRequestInterceptor
187188
}
188189
})
190+
} else if ('signer' in args) {
191+
httpClient = await createHttpClient({
192+
logger,
193+
requestTimeoutMs:
194+
args.requestTimeoutMs ?? config.DEFAULT_REQUEST_TIMEOUT_MS,
195+
requestSigningArgs: {
196+
signer: args.signer,
197+
keyId: args.keyId
198+
}
199+
})
189200
} else {
190201
let privateKey: KeyObject
191202
try {
@@ -295,6 +306,13 @@ interface PrivateKeyConfig {
295306
keyId: string
296307
}
297308

309+
interface SignerConfig {
310+
/** The external signer with which requests will be signed */
311+
signer: Signer
312+
/** The key identifier referring to the signer */
313+
keyId: string
314+
}
315+
298316
interface InterceptorConfig {
299317
/** The custom authenticated request interceptor to use. */
300318
authenticatedRequestInterceptor: InterceptorFn
@@ -303,6 +321,9 @@ interface InterceptorConfig {
303321
export type CreateAuthenticatedClientArgs = BaseAuthenticatedClientArgs &
304322
PrivateKeyConfig
305323

324+
export type CreateAuthenticatedClientWithSignerArgs =
325+
BaseAuthenticatedClientArgs & SignerConfig
326+
306327
export type CreateAuthenticatedClientWithReqInterceptorArgs =
307328
BaseAuthenticatedClientArgs & InterceptorConfig
308329

@@ -322,6 +343,13 @@ export interface AuthenticatedClient
322343
export async function createAuthenticatedClient(
323344
args: CreateAuthenticatedClientArgs
324345
): Promise<AuthenticatedClient>
346+
/**
347+
* Creates an Open Payments client that signs authenticated requests with an external signer.
348+
* This is useful for KMS, HSM, Secure Enclave, or other non-extractable key deployments.
349+
*/
350+
export async function createAuthenticatedClient(
351+
args: CreateAuthenticatedClientWithSignerArgs
352+
): Promise<AuthenticatedClient>
325353
/**
326354
* @experimental The `authenticatedRequestInterceptor` feature is currently experimental and might be removed
327355
* in upcoming versions. Use at your own risk! It offers the capability to add a custom method for
@@ -335,17 +363,27 @@ export async function createAuthenticatedClient(
335363
export async function createAuthenticatedClient(
336364
args:
337365
| CreateAuthenticatedClientArgs
366+
| CreateAuthenticatedClientWithSignerArgs
338367
| CreateAuthenticatedClientWithReqInterceptorArgs
339368
): Promise<AuthenticatedClient> {
369+
const authConfigCount = [
370+
'authenticatedRequestInterceptor' in args,
371+
'privateKey' in args,
372+
'signer' in args
373+
].filter(Boolean).length
374+
const hasKeyId = 'keyId' in args && !!args.keyId
375+
const needsKeyId = 'privateKey' in args || 'signer' in args
376+
340377
if (
341-
'authenticatedRequestInterceptor' in args &&
342-
('privateKey' in args || 'keyId' in args)
378+
authConfigCount !== 1 ||
379+
(needsKeyId && !hasKeyId) ||
380+
('authenticatedRequestInterceptor' in args && 'keyId' in args)
343381
) {
344382
throw new OpenPaymentsClientError(
345383
'Invalid arguments when creating authenticated client.',
346384
{
347385
description:
348-
'Both `authenticatedRequestInterceptor` and `privateKey`/`keyId` were provided. Please use only one of these options.'
386+
'Exactly one of `authenticatedRequestInterceptor`, `privateKey`/`keyId`, or `signer`/`keyId` must be provided.'
349387
}
350388
)
351389
}

packages/open-payments/src/client/requests.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
requestShouldBeAuthorized,
99
signRequest
1010
} from './requests'
11-
import { generateKeyPairSync } from 'crypto'
11+
import { generateKeyPairSync, sign } from 'crypto'
1212
import nock from 'nock'
1313
import {
1414
createTestDeps,
@@ -827,6 +827,33 @@ describe('requests', (): void => {
827827
expect(signedRequest.headers.get('Content-Length')).toBeNull()
828828
})
829829

830+
test('sets signature headers with signer', async () => {
831+
const request = new Request('https://localhost:1000/', {
832+
method: 'POST'
833+
})
834+
const signer = {
835+
sign: jest.fn((data: Uint8Array): Uint8Array =>
836+
sign(null, Buffer.from(data), privateKey)
837+
)
838+
}
839+
840+
const signedRequest = await signRequest(request, {
841+
keyId,
842+
signer
843+
})
844+
845+
expect(signer.sign).toHaveBeenCalledTimes(1)
846+
expect(signedRequest.headers.get('Signature')).toMatch(
847+
HTTP_SIGNATURE_REGEX
848+
)
849+
expect(signedRequest.headers.get('Signature-Input')).toMatch(
850+
getSignatureInputRegex([], keyId)
851+
)
852+
expect(signedRequest.headers.get('Content-Digest')).toBeNull()
853+
expect(signedRequest.headers.get('Content-Type')).toBeNull()
854+
expect(signedRequest.headers.get('Content-Length')).toBeNull()
855+
})
856+
830857
test('sets signature headers with request body', async () => {
831858
const body = {
832859
foo: 'bar'

0 commit comments

Comments
 (0)