Skip to content

Commit 2ecedea

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

7 files changed

Lines changed: 189 additions & 36 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.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.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.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { KeyObject } from 'crypto'
22
import { ResponseValidator, isValidationError } from '@interledger/openapi'
33
import { BaseDeps } from '.'
4-
import { createHeaders } from '@interledger/http-signature-utils'
4+
import { createHeaders, type Signer } from '@interledger/http-signature-utils'
55
import { OpenPaymentsClientError } from './error'
66
import { Logger } from 'pino'
77

@@ -240,6 +240,7 @@ interface CreateHttpClientArgs {
240240

241241
type AuthenticatedHttpClientArgs =
242242
| { privateKey: KeyObject; keyId: string }
243+
| { signer: Signer; keyId: string }
243244
| { authenticatedRequestInterceptor: InterceptorFn }
244245

245246
export type HttpClient = KyInstance
@@ -318,10 +319,8 @@ export const createHttpClient = async (
318319
}
319320
} else {
320321
requestInterceptor = (request) => {
321-
const { privateKey, keyId } = requestSigningArgs
322-
323322
if (requestShouldBeAuthorized(request)) {
324-
return signRequest(request, { privateKey, keyId })
323+
return signRequest(request, requestSigningArgs)
325324
}
326325

327326
return request
@@ -347,27 +346,40 @@ export const signRequest = async (
347346
request: Request,
348347
args: {
349348
privateKey?: KeyObject
349+
signer?: Signer
350350
keyId?: string
351351
}
352352
): Promise<Request> => {
353-
const { privateKey, keyId } = args
353+
const { privateKey, signer, keyId } = args
354354

355-
if (!privateKey || !keyId) {
355+
if (!keyId) {
356356
return request
357357
}
358358

359359
const requestBody = request.body ? await request.clone().json() : undefined // Request body can only ever be read once, so we clone the original request
360+
const requestToSign = {
361+
method: request.method.toUpperCase(),
362+
url: request.url,
363+
headers: Object.fromEntries(request.headers.entries()),
364+
body: requestBody ? JSON.stringify(requestBody) : undefined
365+
}
360366

361-
const contentAndSigHeaders = await createHeaders({
362-
request: {
363-
method: request.method.toUpperCase(),
364-
url: request.url,
365-
headers: Object.fromEntries(request.headers.entries()),
366-
body: requestBody ? JSON.stringify(requestBody) : undefined
367-
},
368-
privateKey,
369-
keyId
370-
})
367+
let contentAndSigHeaders: Awaited<ReturnType<typeof createHeaders>>
368+
if (signer) {
369+
contentAndSigHeaders = await createHeaders({
370+
request: requestToSign,
371+
signer,
372+
keyId
373+
})
374+
} else if (privateKey) {
375+
contentAndSigHeaders = await createHeaders({
376+
request: requestToSign,
377+
privateKey,
378+
keyId
379+
})
380+
} else {
381+
return request
382+
}
371383

372384
if (requestBody) {
373385
request.headers.set(

packages/open-payments/src/openapi/generated/wallet-address-server-types.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,66 @@ export interface components {
136136
};
137137
/**
138138
* DID Document
139-
* @description A DID Document using JSON encoding
139+
* @description A W3C DID Document using JSON encoding
140140
*/
141141
"did-document": {
142+
"@context"?: string | string[];
143+
/**
144+
* @description The DID subject identifier.
145+
* @example did:web:example.com
146+
*/
147+
id: string;
148+
/** @description An entity that is authorized to make changes to the DID document. */
149+
controller?: string | string[];
150+
/** @description A set of verification methods associated with the DID subject. */
151+
verificationMethod?: components["schemas"]["did-verification-method"][];
152+
/** @description Authentication verification relationships. */
153+
authentication?: (string | components["schemas"]["did-verification-method"])[];
154+
/** @description Assertion method verification relationships. */
155+
assertionMethod?: (string | components["schemas"]["did-verification-method"])[];
156+
/** @description Capability invocation verification relationships. */
157+
capabilityInvocation?: (string | components["schemas"]["did-verification-method"])[];
158+
/** @description Capability delegation verification relationships. */
159+
capabilityDelegation?: (string | components["schemas"]["did-verification-method"])[];
160+
/** @description Key agreement verification relationships. */
161+
keyAgreement?: (string | components["schemas"]["did-verification-method"])[];
162+
/** @description Services associated with the DID subject. */
163+
service?: components["schemas"]["did-service"][];
164+
/** @description Alternative identifiers for the DID subject. */
165+
alsoKnownAs?: string[];
166+
} & {
142167
[key: string]: unknown;
143168
};
169+
/**
170+
* Verification Method
171+
* @description A verification method associated with a DID subject.
172+
*/
173+
"did-verification-method": {
174+
/** @description The identifier of the verification method. */
175+
id: string;
176+
/** @description The type of the verification method. */
177+
type: string;
178+
/** @description The controller of the verification method. */
179+
controller: string;
180+
/** @description A JSON Web Key representation of the public key. */
181+
publicKeyJwk?: {
182+
[key: string]: unknown;
183+
};
184+
/** @description A Multibase-encoded public key. */
185+
publicKeyMultibase?: string;
186+
};
187+
/**
188+
* DID Service
189+
* @description A service endpoint associated with a DID subject.
190+
*/
191+
"did-service": {
192+
/** @description The identifier of the service. */
193+
id: string;
194+
/** @description The type of the service. */
195+
type: string;
196+
/** @description The service endpoint URI or URIs. */
197+
serviceEndpoint: string | string[];
198+
};
144199
};
145200
responses: never;
146201
parameters: never;

0 commit comments

Comments
 (0)