Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/http-signature-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export {
loadOrGenerateKey,
loadBase64Key
} from './utils/key'
export { createSignatureHeaders, type RequestLike } from './utils/signatures'
export {
createSignatureHeaders,
type RequestLike,
type Signer
} from './utils/signatures'
export { validateSignatureHeaders, validateSignature } from './utils/validation'
export { generateTestKeys, TestKeys } from './test-utils/keys'
6 changes: 2 additions & 4 deletions packages/http-signature-utils/src/utils/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ const createContentHeaders = (body: string): ContentHeaders => {

export const createHeaders = async ({
request,
privateKey,
keyId
...signOptions
}: SignOptions): Promise<Headers> => {
const contentHeaders =
request.body && createContentHeaders(request.body as string)
Expand All @@ -38,8 +37,7 @@ export const createHeaders = async ({

const signatureHeaders = await createSignatureHeaders({
request,
privateKey,
keyId
...signOptions
})

return {
Expand Down
48 changes: 39 additions & 9 deletions packages/http-signature-utils/src/utils/signatures.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,56 @@
import { type KeyLike } from 'crypto'
import { httpbis, createSigner, Request } from 'http-message-signatures'
import {
httpbis,
createSigner,
Request,
type SigningKey
} from 'http-message-signatures'

export interface RequestLike extends Request {
body?: string
}

export interface SignOptions {
export interface Signer {
sign(data: Uint8Array): Uint8Array | Promise<Uint8Array>
}

interface BaseSignOptions {
request: RequestLike
privateKey: KeyLike
keyId: string
}

export interface PrivateKeySignOptions extends BaseSignOptions {
privateKey: KeyLike
}

export interface SignerSignOptions extends BaseSignOptions {
signer: Signer
}

export type SignOptions = PrivateKeySignOptions | SignerSignOptions

export interface SignatureHeaders {
Signature: string
'Signature-Input': string
}

export const createSignatureHeaders = async ({
request,
privateKey,
keyId
}: SignOptions): Promise<SignatureHeaders> => {
const createSigningKey = (options: SignOptions): SigningKey => {
if ('signer' in options) {
return {
id: options.keyId,
alg: 'ed25519',
sign: async (data: Buffer): Promise<Buffer> =>
Buffer.from(await options.signer.sign(data))
}
}

return createSigner(options.privateKey, 'ed25519', options.keyId)
}

export const createSignatureHeaders = async (
options: SignOptions
): Promise<SignatureHeaders> => {
const { request } = options
const components = ['@method', '@target-uri']
if (request.headers['Authorization'] || request.headers['authorization']) {
components.push('authorization')
Expand All @@ -29,7 +59,7 @@ export const createSignatureHeaders = async ({
components.push('content-digest', 'content-length', 'content-type')
}

const signingKey = createSigner(privateKey, 'ed25519', keyId)
const signingKey = createSigningKey(options)

const { headers } = await httpbis.signMessage(
{
Expand Down
18 changes: 17 additions & 1 deletion packages/open-payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const incomingPayment = await client.walletAddress.get({

### `AuthenticatedClient`

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.
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.

```ts
import { createAuthenticatedClient } from '@interledger/open-payments'
Expand All @@ -81,6 +81,22 @@ In order to create the client, three properties need to be provided: `keyId`, th
| `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. |
| `keyId` | The key identifier of the given private key and the corresponding public JWK document. |

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`:

```ts
import { createAuthenticatedClient } from '@interledger/open-payments'

const client = await createAuthenticatedClient({
keyId: KEY_ID,
signer: {
async sign(data: Uint8Array): Promise<Uint8Array> {
return kms.sign(data)
}
},
walletAddressUrl: WALLET_ADDRESS_URL
})
```

> **Note**
>
> 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.
Expand Down
46 changes: 42 additions & 4 deletions packages/open-payments/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { loadKey } from '@interledger/http-signature-utils'
import { loadKey, type Signer } from '@interledger/http-signature-utils'
import fs from 'fs'
import { OpenAPI } from '@interledger/openapi'
import path from 'path'
Expand Down Expand Up @@ -167,6 +167,7 @@ const createAuthenticatedClientDeps = async ({
...args
}:
| CreateAuthenticatedClientArgs
| CreateAuthenticatedClientWithSignerArgs
| CreateAuthenticatedClientWithReqInterceptorArgs): Promise<AuthenticatedClientDeps> => {
const logger =
args.logger ??
Expand All @@ -186,6 +187,16 @@ const createAuthenticatedClientDeps = async ({
authenticatedRequestInterceptor: args.authenticatedRequestInterceptor
}
})
} else if ('signer' in args) {
httpClient = await createHttpClient({
logger,
requestTimeoutMs:
args.requestTimeoutMs ?? config.DEFAULT_REQUEST_TIMEOUT_MS,
requestSigningArgs: {
signer: args.signer,
keyId: args.keyId
}
})
} else {
let privateKey: KeyObject
try {
Expand Down Expand Up @@ -295,6 +306,13 @@ interface PrivateKeyConfig {
keyId: string
}

interface SignerConfig {
/** The external signer with which requests will be signed */
signer: Signer
/** The key identifier referring to the signer */
keyId: string
}

interface InterceptorConfig {
/** The custom authenticated request interceptor to use. */
authenticatedRequestInterceptor: InterceptorFn
Expand All @@ -303,6 +321,9 @@ interface InterceptorConfig {
export type CreateAuthenticatedClientArgs = BaseAuthenticatedClientArgs &
PrivateKeyConfig

export type CreateAuthenticatedClientWithSignerArgs =
BaseAuthenticatedClientArgs & SignerConfig

export type CreateAuthenticatedClientWithReqInterceptorArgs =
BaseAuthenticatedClientArgs & InterceptorConfig

Expand All @@ -322,6 +343,13 @@ export interface AuthenticatedClient
export async function createAuthenticatedClient(
args: CreateAuthenticatedClientArgs
): Promise<AuthenticatedClient>
/**
* Creates an Open Payments client that signs authenticated requests with an external signer.
* This is useful for KMS, HSM, Secure Enclave, or other non-extractable key deployments.
*/
export async function createAuthenticatedClient(
args: CreateAuthenticatedClientWithSignerArgs
): Promise<AuthenticatedClient>
/**
* @experimental The `authenticatedRequestInterceptor` feature is currently experimental and might be removed
* in upcoming versions. Use at your own risk! It offers the capability to add a custom method for
Expand All @@ -335,17 +363,27 @@ export async function createAuthenticatedClient(
export async function createAuthenticatedClient(
args:
| CreateAuthenticatedClientArgs
| CreateAuthenticatedClientWithSignerArgs
| CreateAuthenticatedClientWithReqInterceptorArgs
): Promise<AuthenticatedClient> {
const authConfigCount = [
'authenticatedRequestInterceptor' in args,
'privateKey' in args,
'signer' in args
].filter(Boolean).length
const hasKeyId = 'keyId' in args && !!args.keyId
const needsKeyId = 'privateKey' in args || 'signer' in args

if (
'authenticatedRequestInterceptor' in args &&
('privateKey' in args || 'keyId' in args)
authConfigCount !== 1 ||
(needsKeyId && !hasKeyId) ||
('authenticatedRequestInterceptor' in args && 'keyId' in args)
) {
throw new OpenPaymentsClientError(
'Invalid arguments when creating authenticated client.',
{
description:
'Both `authenticatedRequestInterceptor` and `privateKey`/`keyId` were provided. Please use only one of these options.'
'Exactly one of `authenticatedRequestInterceptor`, `privateKey`/`keyId`, or `signer`/`keyId` must be provided.'
}
)
}
Expand Down
44 changes: 28 additions & 16 deletions packages/open-payments/src/client/requests.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { KeyObject } from 'crypto'
import { ResponseValidator, isValidationError } from '@interledger/openapi'
import { BaseDeps } from '.'
import { createHeaders } from '@interledger/http-signature-utils'
import { createHeaders, type Signer } from '@interledger/http-signature-utils'
import { OpenPaymentsClientError } from './error'
import { Logger } from 'pino'

Expand Down Expand Up @@ -240,6 +240,7 @@ interface CreateHttpClientArgs {

type AuthenticatedHttpClientArgs =
| { privateKey: KeyObject; keyId: string }
| { signer: Signer; keyId: string }
| { authenticatedRequestInterceptor: InterceptorFn }

export type HttpClient = KyInstance
Expand Down Expand Up @@ -318,10 +319,8 @@ export const createHttpClient = async (
}
} else {
requestInterceptor = (request) => {
const { privateKey, keyId } = requestSigningArgs

if (requestShouldBeAuthorized(request)) {
return signRequest(request, { privateKey, keyId })
return signRequest(request, requestSigningArgs)
}

return request
Expand All @@ -347,27 +346,40 @@ export const signRequest = async (
request: Request,
args: {
privateKey?: KeyObject
signer?: Signer
keyId?: string
}
): Promise<Request> => {
const { privateKey, keyId } = args
const { privateKey, signer, keyId } = args

if (!privateKey || !keyId) {
if (!keyId) {
return request
}

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

const contentAndSigHeaders = await createHeaders({
request: {
method: request.method.toUpperCase(),
url: request.url,
headers: Object.fromEntries(request.headers.entries()),
body: requestBody ? JSON.stringify(requestBody) : undefined
},
privateKey,
keyId
})
let contentAndSigHeaders: Awaited<ReturnType<typeof createHeaders>>
if (signer) {
contentAndSigHeaders = await createHeaders({
request: requestToSign,
signer,
keyId
})
} else if (privateKey) {
contentAndSigHeaders = await createHeaders({
request: requestToSign,
privateKey,
keyId
})
} else {
return request
}

if (requestBody) {
request.headers.set(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,66 @@ export interface components {
};
/**
* DID Document
* @description A DID Document using JSON encoding
* @description A W3C DID Document using JSON encoding
*/
"did-document": {
"@context"?: string | string[];
/**
* @description The DID subject identifier.
* @example did:web:example.com
*/
id: string;
/** @description An entity that is authorized to make changes to the DID document. */
controller?: string | string[];
/** @description A set of verification methods associated with the DID subject. */
verificationMethod?: components["schemas"]["did-verification-method"][];
/** @description Authentication verification relationships. */
authentication?: (string | components["schemas"]["did-verification-method"])[];
/** @description Assertion method verification relationships. */
assertionMethod?: (string | components["schemas"]["did-verification-method"])[];
/** @description Capability invocation verification relationships. */
capabilityInvocation?: (string | components["schemas"]["did-verification-method"])[];
/** @description Capability delegation verification relationships. */
capabilityDelegation?: (string | components["schemas"]["did-verification-method"])[];
/** @description Key agreement verification relationships. */
keyAgreement?: (string | components["schemas"]["did-verification-method"])[];
/** @description Services associated with the DID subject. */
service?: components["schemas"]["did-service"][];
/** @description Alternative identifiers for the DID subject. */
alsoKnownAs?: string[];
} & {
[key: string]: unknown;
};
/**
* Verification Method
* @description A verification method associated with a DID subject.
*/
"did-verification-method": {
/** @description The identifier of the verification method. */
id: string;
/** @description The type of the verification method. */
type: string;
/** @description The controller of the verification method. */
controller: string;
/** @description A JSON Web Key representation of the public key. */
publicKeyJwk?: {
[key: string]: unknown;
};
/** @description A Multibase-encoded public key. */
publicKeyMultibase?: string;
};
/**
* DID Service
* @description A service endpoint associated with a DID subject.
*/
"did-service": {
/** @description The identifier of the service. */
id: string;
/** @description The type of the service. */
type: string;
/** @description The service endpoint URI or URIs. */
serviceEndpoint: string | string[];
};
};
responses: never;
parameters: never;
Expand Down
Loading