-
Notifications
You must be signed in to change notification settings - Fork 89
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(open-payments): add HTTP message signatures #722
Changes from 4 commits
5da5664
02d25b9
97db6d0
eedca17
9e779f2
039445c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
import { KeyLike } from 'crypto' | ||
import { createOpenAPI, OpenAPI } from 'openapi' | ||
import createLogger, { Logger } from 'pino' | ||
import config from '../config' | ||
|
@@ -16,33 +17,61 @@ import { | |
import { createAxiosInstance } from './requests' | ||
import { AxiosInstance } from 'axios' | ||
|
||
export interface CreateOpenPaymentClientArgs { | ||
requestTimeoutMs?: number | ||
logger?: Logger | ||
} | ||
|
||
export interface ClientDeps { | ||
axiosInstance: AxiosInstance | ||
openApi: OpenAPI | ||
logger: Logger | ||
} | ||
|
||
export interface OpenPaymentsClient { | ||
incomingPayment: IncomingPaymentRoutes | ||
ilpStreamConnection: ILPStreamConnectionRoutes | ||
paymentPointer: PaymentPointerRoutes | ||
} | ||
|
||
export const createClient = async ( | ||
args?: CreateOpenPaymentClientArgs | ||
): Promise<OpenPaymentsClient> => { | ||
const createDeps = async ( | ||
args: Partial<CreateOpenPaymentClientArgs> | ||
): Promise<ClientDeps> => { | ||
const axiosInstance = createAxiosInstance({ | ||
privateKey: args.privateKey, | ||
keyId: args.keyId, | ||
requestTimeoutMs: | ||
args?.requestTimeoutMs ?? config.DEFAULT_REQUEST_TIMEOUT_MS | ||
}) | ||
const openApi = await createOpenAPI(config.OPEN_PAYMENTS_OPEN_API_URL) | ||
const logger = args?.logger ?? createLogger() | ||
const deps = { axiosInstance, openApi, logger } | ||
return { axiosInstance, openApi, logger } | ||
} | ||
|
||
export interface CreateUnauthenticatedClientArgs { | ||
requestTimeoutMs?: number | ||
logger?: Logger | ||
} | ||
|
||
export interface UnauthenticatedClient { | ||
ilpStreamConnection: ILPStreamConnectionRoutes | ||
paymentPointer: PaymentPointerRoutes | ||
} | ||
|
||
export const createUnauthenticatedClient = async ( | ||
args: CreateOpenPaymentClientArgs | ||
): Promise<UnauthenticatedClient> => { | ||
const deps = await createDeps(args) | ||
|
||
return { | ||
ilpStreamConnection: createILPStreamConnectionRoutes(deps), | ||
paymentPointer: createPaymentPointerRoutes(deps) | ||
} | ||
} | ||
|
||
export interface CreateOpenPaymentClientArgs | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we call this |
||
extends CreateUnauthenticatedClientArgs { | ||
privateKey: KeyLike | ||
keyId: string | ||
} | ||
|
||
export interface OpenPaymentsClient extends UnauthenticatedClient { | ||
incomingPayment: IncomingPaymentRoutes | ||
} | ||
|
||
export const createClient = async ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And then I'd call this |
||
args: CreateOpenPaymentClientArgs | ||
): Promise<OpenPaymentsClient> => { | ||
const deps = await createDeps(args) | ||
|
||
return { | ||
incomingPayment: createIncomingPaymentRoutes(deps), | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,37 +1,73 @@ | ||
/* eslint-disable @typescript-eslint/no-empty-function */ | ||
import { createAxiosInstance, get } from './requests' | ||
import { generateKeyPairSync } from 'crypto' | ||
import nock from 'nock' | ||
import { mockOpenApiResponseValidators, silentLogger } from '../test/helpers' | ||
|
||
describe('requests', (): void => { | ||
const logger = silentLogger | ||
const privateKey = generateKeyPairSync('ed25519').privateKey | ||
const keyId = 'myId' | ||
|
||
describe('createAxiosInstance', (): void => { | ||
test('sets timeout properly', async (): Promise<void> => { | ||
expect( | ||
createAxiosInstance({ requestTimeoutMs: 1000 }).defaults.timeout | ||
createAxiosInstance({ requestTimeoutMs: 1000, privateKey, keyId }) | ||
.defaults.timeout | ||
).toBe(1000) | ||
}) | ||
test('sets Content-Type header properly', async (): Promise<void> => { | ||
expect( | ||
createAxiosInstance({ requestTimeoutMs: 0 }).defaults.headers.common[ | ||
'Content-Type' | ||
] | ||
createAxiosInstance({ requestTimeoutMs: 0, privateKey, keyId }).defaults | ||
.headers.common['Content-Type'] | ||
).toBe('application/json') | ||
}) | ||
}) | ||
|
||
describe('get', (): void => { | ||
const axiosInstance = createAxiosInstance({ requestTimeoutMs: 0 }) | ||
const axiosInstance = createAxiosInstance({ | ||
requestTimeoutMs: 0, | ||
privateKey, | ||
keyId | ||
}) | ||
const baseUrl = 'http://localhost:1000' | ||
const responseValidators = mockOpenApiResponseValidators() | ||
|
||
beforeAll(() => { | ||
jest.spyOn(axiosInstance, 'get') | ||
}) | ||
|
||
afterEach(() => { | ||
jest.useRealTimers() | ||
}) | ||
|
||
test('sets headers properly if accessToken provided', async (): Promise<void> => { | ||
nock(baseUrl).get('/incoming-payment').reply(200) | ||
// https://github.com/nock/nock/issues/2200#issuecomment-1280957462 | ||
jest | ||
.useFakeTimers({ | ||
doNotFake: [ | ||
'nextTick', | ||
'setImmediate', | ||
'clearImmediate', | ||
'setInterval', | ||
'clearInterval', | ||
'setTimeout', | ||
'clearTimeout' | ||
] | ||
}) | ||
.setSystemTime(new Date()) | ||
|
||
const scope = nock(baseUrl) | ||
.matchHeader('Signature', /sig1=:([a-zA-Z0-9+/]){86}==:/) | ||
.matchHeader( | ||
'Signature-Input', | ||
`sig1=("@method" "@target-uri" "authorization");created=${Math.floor( | ||
Date.now() / 1000 | ||
)};keyid="${keyId}";alg="ed25519"` | ||
) | ||
.get('/incoming-payment') | ||
// TODO: verify signature | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we move the meat of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do you mean by There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does that make sense to add it in here though? I feel like sig validation is done on the "receiver" side , but the client is more of the "caller" in our scenario There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm thinking the "receiver"/RS will also be using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @wilsonianb wouldn't the RS also use it for sig creation when requesting resources at other RS's? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct, the RS will also use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah ok, the receiver will just import the lib and use some of those sig verification methods as part of the middleware (how it's done now) -> makes sense |
||
.reply(200) | ||
|
||
await get( | ||
{ axiosInstance, logger }, | ||
|
@@ -42,20 +78,24 @@ describe('requests', (): void => { | |
responseValidators.successfulValidator | ||
) | ||
|
||
scope.done() | ||
|
||
expect(axiosInstance.get).toHaveBeenCalledWith( | ||
`${baseUrl}/incoming-payment`, | ||
{ | ||
headers: { | ||
Authorization: 'GNAP accessToken', | ||
Signature: 'TODO', | ||
'Signature-Input': 'TODO' | ||
Authorization: 'GNAP accessToken' | ||
Check failure Code scanning / CodeQL Hard-coded credentials
The hard-coded value "GNAP accessToken" is used as [authorization header](1).
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we add some sort of exception for this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you mean for codeql? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's do that. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} | ||
} | ||
) | ||
}) | ||
|
||
test('sets headers properly if accessToken is not provided', async (): Promise<void> => { | ||
nock(baseUrl).get('/incoming-payment').reply(200) | ||
const scope = nock(baseUrl) | ||
.matchHeader('Signature', (sig) => sig === undefined) | ||
.matchHeader('Signature-Input', (sigInput) => sigInput === undefined) | ||
.get('/incoming-payment') | ||
.reply(200) | ||
|
||
await get( | ||
{ axiosInstance, logger }, | ||
|
@@ -64,6 +104,7 @@ describe('requests', (): void => { | |
}, | ||
responseValidators.successfulValidator | ||
) | ||
scope.done() | ||
|
||
expect(axiosInstance.get).toHaveBeenCalledWith( | ||
`${baseUrl}/incoming-payment`, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,8 @@ | ||
import axios, { AxiosInstance } from 'axios' | ||
import { KeyLike } from 'crypto' | ||
import { ResponseValidator } from 'openapi' | ||
import { ClientDeps } from '.' | ||
import { createSignatureHeaders } from './signatures' | ||
|
||
interface GetArgs { | ||
url: string | ||
|
@@ -26,9 +28,7 @@ export const get = async <T>( | |
const { data, status } = await axiosInstance.get(url, { | ||
headers: accessToken | ||
? { | ||
Authorization: `GNAP ${accessToken}`, | ||
Signature: 'TODO', | ||
'Signature-Input': 'TODO' | ||
Authorization: `GNAP ${accessToken}` | ||
} | ||
: {} | ||
}) | ||
|
@@ -65,12 +65,35 @@ export const get = async <T>( | |
|
||
export const createAxiosInstance = (args: { | ||
requestTimeoutMs: number | ||
privateKey: KeyLike | ||
keyId: string | ||
sabineschaller marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}): AxiosInstance => { | ||
const axiosInstance = axios.create({ | ||
timeout: args.requestTimeoutMs | ||
}) | ||
|
||
axiosInstance.defaults.headers.common['Content-Type'] = 'application/json' | ||
|
||
axiosInstance.interceptors.request.use( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirming: does this apply to all GET, POST, DELETE requests? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe so, but it can be filtered. |
||
async (config) => { | ||
const sigHeaders = await createSignatureHeaders({ | ||
request: { | ||
method: config.method.toUpperCase(), | ||
url: config.url, | ||
headers: config.headers, | ||
body: config.data | ||
}, | ||
privateKey: args.privateKey, | ||
keyId: args.keyId | ||
}) | ||
config.headers['Signature'] = sigHeaders['Signature'] | ||
config.headers['Signature-Input'] = sigHeaders['Signature-Input'] | ||
return config | ||
}, | ||
null, | ||
{ | ||
runWhen: (config) => !!config.headers['Authorization'] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because we don't need There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think there are some AS calls that require the I was actually thinking that the grant initiation request might be the only one with it, but I'm not sure There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does that mean that the auth server open payment specs are missing the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, looks like that's the case. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @wilsonianb for my own understanding, wouldn't grant initiation be the only AS request without the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah, I think that's correct |
||
} | ||
) | ||
|
||
return axiosInstance | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import { sign, KeyLike } from 'crypto' | ||
import { | ||
httpis as httpsig, | ||
Algorithm, | ||
RequestLike, | ||
Signer | ||
} from 'http-message-signatures' | ||
|
||
interface SignOptions { | ||
request: RequestLike | ||
privateKey: KeyLike | ||
keyId: string | ||
} | ||
|
||
interface SignatureHeaders { | ||
Signature: string | ||
'Signature-Input': string | ||
} | ||
|
||
const createSigner = (privateKey: KeyLike): Signer => { | ||
const signer = async (data: Buffer) => sign(null, data, privateKey) | ||
signer.alg = 'ed25519' as Algorithm | ||
return signer | ||
} | ||
|
||
export const createSignatureHeaders = async ({ | ||
request, | ||
privateKey, | ||
keyId | ||
}: SignOptions): Promise<SignatureHeaders> => { | ||
const components = ['@method', '@target-uri'] | ||
if (request.headers['Authorization']) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we care about casing on There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It appears to matter. |
||
components.push('authorization') | ||
} | ||
if (request.body) { | ||
// TODO: 'content-digest' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be good to reference #655 in the comment, if we do that at all? |
||
components.push('content-length', 'content-type') | ||
} | ||
const { headers } = await httpsig.sign(request, { | ||
components, | ||
parameters: { | ||
created: Math.floor(Date.now() / 1000) | ||
}, | ||
keyId, | ||
signer: createSigner(privateKey), | ||
format: 'httpbis' | ||
}) | ||
return { | ||
Signature: headers['Signature'] as string, | ||
'Signature-Input': headers['Signature-Input'] as string | ||
} | ||
} | ||
sabineschaller marked this conversation as resolved.
Show resolved
Hide resolved
|
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should the default be something that includes
internal
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you elaborate?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was just wondering if
rafiki
is a good value for the default. But now that I have thought a bit more about it, I think it is.