Skip to content

Commit

Permalink
feat: open payments client can now take in a file path as an argument…
Browse files Browse the repository at this point in the history
… to `privateKey` (#354)

* feat(http-signature-utils): split up parseOrProvisionKey into separate methods

* feat(open-payments): allow privateKeyFilePath for open-payments client

* chore: adding changesets

* chore(hsu): use "load" and "generate" instead

* chore(open-payments): make walletAddress required during incomingPayment creation

* feat(open-payments): privateKey can now be a file path instead

* chore: make package bump major

* chore(openapi): fix tests

* chore(open-payments): update comments & readme

* chore(hsu): adding jsdoc

* feat(hsu): don't automatically save the generated key to a file
  • Loading branch information
mkurapov authored Oct 31, 2023
1 parent 907a5a2 commit 737cdaa
Show file tree
Hide file tree
Showing 13 changed files with 397 additions and 56 deletions.
6 changes: 6 additions & 0 deletions .changeset/kind-singers-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@interledger/open-payments': major
---

- `createAuthenticatedClient` can now also load a key using a path to the private key file as an argument to `privateKey`
- `walletAddress` is required in the incoming payment creation request
7 changes: 7 additions & 0 deletions .changeset/lemon-apricots-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@interledger/http-signature-utils': major
---

Adding and exporting two additional methods: `loadKey` `generateKey`

Renaming methods: `parseOrProvisionKey` -> `loadOrGenerateKey`
4 changes: 3 additions & 1 deletion openapi/resource-server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ servers:
- url: 'https://ilp.rafiki.money'
description: 'Server for wallet address subresources (ie https://ilp.rafiki.money/alice)'
- url: 'https://ilp.rafiki.money/.well-known/pay'
description: 'Server for when Payment Pointer has no pathname (ie https://ilp.rafiki.money)'
description: 'Server for when the wallet address has no pathname (ie https://ilp.rafiki.money)'
tags:
- name: wallet-address
description: wallet address operations
Expand Down Expand Up @@ -151,6 +151,8 @@ paths:
metadata:
type: object
description: Additional metadata associated with the incoming payment. (Optional)
required:
- walletAddress
examples:
Create incoming payment for $25 to pay invoice INV2022-02-0137:
value:
Expand Down
14 changes: 13 additions & 1 deletion packages/http-signature-utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,22 @@ npm install @interledger/http-signature-utils

## Usage

Load a private Ed25519 key:

```ts
const key = loadKey('/PATH/TO/private-key.pem')
```

Generate a private Ed25519 key:

```ts
const key = generateKey('/PATH_TO_SAVE_KEY_IN')
```

Load or generate a private Ed25519 key:

```ts
const key = parseOrProvisionKey('/PATH/TO/private-key.pem')
const key = loadOrGenerateKey('/PATH/TO/private-key.pem')
```

Load a base64 encoded Ed25519 private key:
Expand Down
7 changes: 6 additions & 1 deletion packages/http-signature-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
export { createHeaders, getKeyId, Headers } from './utils/headers'
export { generateJwk, JWK } from './utils/jwk'
export { parseOrProvisionKey, loadBase64Key } from './utils/key'
export {
loadKey,
generateKey,
loadOrGenerateKey,
loadBase64Key
} from './utils/key'
export { createSignatureHeaders } from './utils/signatures'
export { validateSignatureHeaders, validateSignature } from './utils/validation'
export { generateTestKeys, TestKeys } from './test-utils/keys'
Expand Down
145 changes: 127 additions & 18 deletions packages/http-signature-utils/src/utils/key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as assert from 'assert'
import * as crypto from 'crypto'
import * as fs from 'fs'
import { Buffer } from 'buffer'
import { loadBase64Key, parseOrProvisionKey } from './key'
import { loadBase64Key, loadKey, loadOrGenerateKey, generateKey } from './key'

describe('Key methods', (): void => {
const TMP_DIR = './tmp'
Expand All @@ -14,19 +14,19 @@ describe('Key methods', (): void => {
afterEach(async (): Promise<void> => {
fs.rmSync(TMP_DIR, { recursive: true, force: true })
})
describe('parseOrProvisionKey', (): void => {
describe('loadOrGenerateKey', (): void => {
test.each`
tmpDirExists
${false}
${true}
`(
'can provision key - tmp dir exists: $tmpDirExists',
'generates key and saves file - tmp dir exists: $tmpDirExists',
async ({ tmpDirExists }): Promise<void> => {
if (tmpDirExists) {
fs.mkdirSync(TMP_DIR)
}
expect(fs.existsSync(TMP_DIR)).toBe(tmpDirExists)
const key = parseOrProvisionKey(undefined)
const key = loadOrGenerateKey(undefined, { dir: TMP_DIR })
expect(key).toMatchObject({
asymmetricKeyType: 'ed25519',
type: 'private'
Expand All @@ -44,11 +44,18 @@ describe('Key methods', (): void => {
)
}
)
test('throws if cannot read file', async (): Promise<void> => {
expect(() => {
parseOrProvisionKey(`${TMP_DIR}/private-key.pem`)
}).toThrow()

test('generates new key if parsing error', async (): Promise<void> => {
const key = loadOrGenerateKey('/some/wrong/file')
expect(key).toBeInstanceOf(crypto.KeyObject)
expect(key.export({ format: 'jwk' })).toMatchObject({
crv: 'Ed25519',
kty: 'OKP',
d: expect.any(String),
x: expect.any(String)
})
})

test('can parse key', async (): Promise<void> => {
const keypair = crypto.generateKeyPairSync('ed25519')
const keyfile = `${TMP_DIR}/test-private-key.pem`
Expand All @@ -59,7 +66,7 @@ describe('Key methods', (): void => {
)
assert.ok(fs.existsSync(keyfile))
const fileStats = fs.statSync(keyfile)
const key = parseOrProvisionKey(keyfile)
const key = loadOrGenerateKey(keyfile)
expect(key).toBeInstanceOf(crypto.KeyObject)
expect(key.export({ format: 'jwk' })).toEqual({
crv: 'Ed25519',
Expand All @@ -73,8 +80,11 @@ describe('Key methods', (): void => {
expect(fs.statSync(keyfile).mtimeMs).toEqual(fileStats.mtimeMs)
expect(fs.readdirSync(TMP_DIR).length).toEqual(1)
})
test('generates new key if wrong curve', async (): Promise<void> => {
const keypair = crypto.generateKeyPairSync('ed448')
})

describe('loadKey', (): void => {
test('can parse key', async (): Promise<void> => {
const keypair = crypto.generateKeyPairSync('ed25519')
const keyfile = `${TMP_DIR}/test-private-key.pem`
fs.mkdirSync(TMP_DIR)
fs.writeFileSync(
Expand All @@ -83,32 +93,131 @@ describe('Key methods', (): void => {
)
assert.ok(fs.existsSync(keyfile))
const fileStats = fs.statSync(keyfile)
const key = parseOrProvisionKey(keyfile)
const key = loadKey(keyfile)
expect(key).toBeInstanceOf(crypto.KeyObject)
expect(key.export({ format: 'jwk' })).toMatchObject({
expect(key.export({ format: 'jwk' })).toEqual({
crv: 'Ed25519',
kty: 'OKP',
d: expect.any(String),
x: expect.any(String)
})
expect(key.export({ format: 'pem', type: 'pkcs8' })).not.toEqual(
expect(key.export({ format: 'pem', type: 'pkcs8' })).toEqual(
keypair.privateKey.export({ format: 'pem', type: 'pkcs8' })
)
const keyfiles = fs.readdirSync(TMP_DIR)
expect(keyfiles.length).toEqual(2)
expect(keyfiles.filter((f) => f.startsWith('private')).length).toEqual(1)
expect(fs.statSync(keyfile).mtimeMs).toEqual(fileStats.mtimeMs)
expect(fs.readdirSync(TMP_DIR).length).toEqual(1)
})

test('throws if cannot read file', async (): Promise<void> => {
const fileName = `${TMP_DIR}/private-key.pem`

expect(() => {
loadKey(fileName)
}).toThrow(`Could not load file: ${fileName}`)
})

test('throws if invalid file', async (): Promise<void> => {
const keyfile = `${TMP_DIR}/test-private-key.pem`
fs.mkdirSync(TMP_DIR)
fs.writeFileSync(keyfile, 'not a private key')
assert.ok(fs.existsSync(keyfile))
expect(() => loadKey(keyfile)).toThrow(
'File was loaded, but private key was invalid'
)
})

test('throws if wrong curve', async (): Promise<void> => {
const keypair = crypto.generateKeyPairSync('ed448')
const keyfile = `${TMP_DIR}/test-private-key.pem`
fs.mkdirSync(TMP_DIR)
fs.writeFileSync(
keyfile,
keypair.privateKey.export({ format: 'pem', type: 'pkcs8' })
)
assert.ok(fs.existsSync(keyfile))
expect(() => loadKey(keyfile)).toThrow(
'Private key did not have Ed25519 curve'
)
})
})

describe('generateKey', (): void => {
test('generates key', (): void => {
const key = generateKey()
expect(key).toMatchObject({
asymmetricKeyType: 'ed25519',
type: 'private'
})
expect(key.export({ format: 'jwk' })).toEqual({
crv: 'Ed25519',
kty: 'OKP',
d: expect.any(String),
x: expect.any(String)
})
})

test.each`
tmpDirExists
${false}
${true}
`(
'generates key and saves file - tmp dir exists: $tmpDirExists',
async ({ tmpDirExists }): Promise<void> => {
if (tmpDirExists) {
fs.mkdirSync(TMP_DIR)
}
expect(fs.existsSync(TMP_DIR)).toBe(tmpDirExists)
const key = generateKey({ dir: TMP_DIR })
expect(key).toMatchObject({
asymmetricKeyType: 'ed25519',
type: 'private'
})
expect(key.export({ format: 'jwk' })).toEqual({
crv: 'Ed25519',
kty: 'OKP',
d: expect.any(String),
x: expect.any(String)
})
const keyfiles = fs.readdirSync(TMP_DIR)
expect(keyfiles.length).toBe(1)
expect(fs.readFileSync(`${TMP_DIR}/${keyfiles[0]}`, 'utf8')).toEqual(
key.export({ format: 'pem', type: 'pkcs8' })
)
}
)

test('generates key and saves with provided filename', (): void => {
const fileName = 'private-key'
const key = generateKey({ dir: TMP_DIR, fileName })
expect(key).toMatchObject({
asymmetricKeyType: 'ed25519',
type: 'private'
})
expect(key.export({ format: 'jwk' })).toEqual({
crv: 'Ed25519',
kty: 'OKP',
d: expect.any(String),
x: expect.any(String)
})
const keyfiles = fs.readdirSync(TMP_DIR)
expect(keyfiles.length).toBe(1)
expect(keyfiles[0]).toBe(`${fileName}.pem`)
expect(fs.readFileSync(`${TMP_DIR}/${keyfiles[0]}`, 'utf8')).toEqual(
key.export({ format: 'pem', type: 'pkcs8' })
)
})
})

describe('loadBase64Key', (): void => {
test('can load base64 encoded key', (): void => {
const key = parseOrProvisionKey(undefined)
const key = loadOrGenerateKey(undefined)
const loadedKey = loadBase64Key(
Buffer.from(key.export({ type: 'pkcs8', format: 'pem' })).toString(
'base64'
)
)

assert.ok(loadedKey)
expect(loadedKey.export({ format: 'jwk' })).toEqual(
key.export({ format: 'jwk' })
)
Expand Down
Loading

0 comments on commit 737cdaa

Please sign in to comment.