Skip to content

Commit

Permalink
feat(openid4vc-client): pre-authorized (openwallet-foundation#1243)
Browse files Browse the repository at this point in the history
This PR adds support for the `pre-authorized` OpenID for Verifiable Credentials issuance flow to the new `openid4vc-client` module.

Here are some highlights of the work:
- Allows the user to execute the entire `pre-authorized` flow by calling a single method.
- Adds a happy-flow test
    - HTTP(S) requests and responses are mocked using a network mocking library called [nock](https://github.com/nock/nock)
    - Because the JSON-LD credential that is received is expanded by the `W3cCredentialService`, I've added a few new contexts to our test document loader.
    - Not-so-happy-flow tests will be added later on. If you have any suggestions for edge cases that deserve testing, feel free to drop a comment. 
- Modifies the `JwsService`
    - The `JwsService` was geared towards a very specific use case. I've generalized its API so it's usable for a wider range of applications.
    - All pre-existing tests and calls to the `JwsService` have been updated.

It's worth noting that I have had to add some `@ts-ignore` statements here and there to get around some incomplete types in the `OpenID4VCI-Client` library we're using. Once these issues have been resolved in the client library, they will be removed.

**Work funded by the government of Ontario**

---------

Signed-off-by: Karim Stekelenburg <karim@animo.id>
Co-authored-by: Timo Glastra <timo@animo.id>
  • Loading branch information
karimStekelenburg and TimoGlastra committed Feb 6, 2023
1 parent 7f65ba9 commit 3d86e78
Show file tree
Hide file tree
Showing 29 changed files with 1,352 additions and 72 deletions.
6 changes: 6 additions & 0 deletions packages/core/src/crypto/JwkTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface Jwk {
kty: 'EC' | 'OKP'
crv: 'Ed25519' | 'X25519' | 'P-256' | 'P-384' | 'secp256k1'
x: string
y?: string
}
111 changes: 82 additions & 29 deletions packages/core/src/crypto/JwsService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Jwk } from './JwkTypes'
import type { Jws, JwsGeneralFormat } from './JwsTypes'
import type { AgentContext } from '../agent'
import type { Buffer } from '../utils'
Expand All @@ -17,25 +18,63 @@ const JWS_ALG = 'EdDSA'

@injectable()
export class JwsService {
public async createJws(
agentContext: AgentContext,
{ payload, verkey, header }: CreateJwsOptions
): Promise<JwsGeneralFormat> {
const base64Payload = TypedArrayEncoder.toBase64URL(payload)
const base64Protected = JsonEncoder.toBase64URL(this.buildProtected(verkey))
const key = Key.fromPublicKeyBase58(verkey, KeyType.Ed25519)
public static supportedKeyTypes = [KeyType.Ed25519]

private async createJwsBase(agentContext: AgentContext, options: CreateJwsBaseOptions) {
if (!JwsService.supportedKeyTypes.includes(options.key.keyType)) {
throw new AriesFrameworkError(
`Only ${JwsService.supportedKeyTypes.join(',')} key type(s) supported for creating JWS`
)
}
const base64Payload = TypedArrayEncoder.toBase64URL(options.payload)
const base64UrlProtectedHeader = JsonEncoder.toBase64URL(this.buildProtected(options.protectedHeaderOptions))

const signature = TypedArrayEncoder.toBase64URL(
await agentContext.wallet.sign({ data: TypedArrayEncoder.fromString(`${base64Protected}.${base64Payload}`), key })
await agentContext.wallet.sign({
data: TypedArrayEncoder.fromString(`${base64UrlProtectedHeader}.${base64Payload}`),
key: options.key,
})
)

return {
protected: base64Protected,
base64Payload,
base64UrlProtectedHeader,
signature,
}
}

public async createJws(
agentContext: AgentContext,
{ payload, key, header, protectedHeaderOptions }: CreateJwsOptions
): Promise<JwsGeneralFormat> {
const { base64UrlProtectedHeader, signature } = await this.createJwsBase(agentContext, {
payload,
key,
protectedHeaderOptions,
})

return {
protected: base64UrlProtectedHeader,
signature,
header,
}
}

/**
* @see {@link https://www.rfc-editor.org/rfc/rfc7515#section-3.1}
* */
public async createJwsCompact(
agentContext: AgentContext,
{ payload, key, protectedHeaderOptions }: CreateCompactJwsOptions
): Promise<string> {
const { base64Payload, base64UrlProtectedHeader, signature } = await this.createJwsBase(agentContext, {
payload,
key,
protectedHeaderOptions,
})
return `${base64UrlProtectedHeader}.${base64Payload}.${signature}`
}

/**
* Verify a JWS
*/
Expand All @@ -47,7 +86,7 @@ export class JwsService {
throw new AriesFrameworkError('Unable to verify JWS: No entries in JWS signatures array.')
}

const signerVerkeys = []
const signerKeys: Key[] = []
for (const jws of signatures) {
const protectedJson = JsonEncoder.fromBase64(jws.protected)

Expand All @@ -62,17 +101,17 @@ export class JwsService {
const data = TypedArrayEncoder.fromString(`${jws.protected}.${base64Payload}`)
const signature = TypedArrayEncoder.fromBase64(jws.signature)

const verkey = TypedArrayEncoder.toBase58(TypedArrayEncoder.fromBase64(protectedJson?.jwk?.x))
const key = Key.fromPublicKeyBase58(verkey, KeyType.Ed25519)
signerVerkeys.push(verkey)
const publicKey = TypedArrayEncoder.fromBase64(protectedJson?.jwk?.x)
const key = Key.fromPublicKey(publicKey, KeyType.Ed25519)
signerKeys.push(key)

try {
const isValid = await agentContext.wallet.verify({ key, data, signature })

if (!isValid) {
return {
isValid: false,
signerVerkeys: [],
signerKeys: [],
}
}
} catch (error) {
Expand All @@ -81,45 +120,59 @@ export class JwsService {
if (error instanceof WalletError) {
return {
isValid: false,
signerVerkeys: [],
signerKeys: [],
}
}

throw error
}
}

return { isValid: true, signerVerkeys }
return { isValid: true, signerKeys: signerKeys }
}

/**
* @todo This currently only work with a single alg, key type and curve
* This needs to be extended with other formats in the future
*/
private buildProtected(verkey: string) {
private buildProtected(options: ProtectedHeaderOptions) {
if (!options.jwk && !options.kid) {
throw new AriesFrameworkError('Both JWK and kid are undefined. Please provide one or the other.')
}
if (options.jwk && options.kid) {
throw new AriesFrameworkError('Both JWK and kid are provided. Please only provide one of the two.')
}

return {
alg: 'EdDSA',
jwk: {
kty: 'OKP',
crv: 'Ed25519',
x: TypedArrayEncoder.toBase64URL(TypedArrayEncoder.fromBase58(verkey)),
},
alg: options.alg,
jwk: options.jwk,
kid: options.kid,
}
}
}

export interface CreateJwsOptions {
verkey: string
key: Key
payload: Buffer
header: Record<string, unknown>
protectedHeaderOptions: ProtectedHeaderOptions
}

type CreateJwsBaseOptions = Omit<CreateJwsOptions, 'header'>

type CreateCompactJwsOptions = Omit<CreateJwsOptions, 'header'>

export interface VerifyJwsOptions {
jws: Jws
payload: Buffer
}

export interface VerifyJwsResult {
isValid: boolean
signerVerkeys: string[]
signerKeys: Key[]
}

export type kid = string

export interface ProtectedHeaderOptions {
alg: string
jwk?: Jwk
kid?: kid
[key: string]: any
}
23 changes: 22 additions & 1 deletion packages/core/src/crypto/Key.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { KeyType } from './KeyType'
import type { Jwk } from './JwkTypes'

import { AriesFrameworkError } from '../error'
import { Buffer, MultiBaseEncoder, TypedArrayEncoder, VarintEncoder } from '../utils'

import { KeyType } from './KeyType'
import { getKeyTypeByMultiCodecPrefix, getMultiCodecPrefixByKeytype } from './multiCodecKey'

export class Key {
Expand Down Expand Up @@ -50,4 +52,23 @@ export class Key {
public get publicKeyBase58() {
return TypedArrayEncoder.toBase58(this.publicKey)
}

public toJwk(): Jwk {
if (this.keyType !== KeyType.Ed25519) {
throw new AriesFrameworkError(`JWK creation is only supported for Ed25519 key types. Received ${this.keyType}`)
}

return {
kty: 'OKP',
crv: 'Ed25519',
x: TypedArrayEncoder.toBase64URL(this.publicKey),
}
}

public static fromJwk(jwk: Jwk) {
if (jwk.crv !== 'Ed25519') {
throw new AriesFrameworkError('Only JWKs with Ed25519 key type is supported.')
}
return Key.fromPublicKeyBase58(TypedArrayEncoder.toBase58(TypedArrayEncoder.fromBase64(jwk.x)), KeyType.Ed25519)
}
}
30 changes: 17 additions & 13 deletions packages/core/src/crypto/__tests__/JwsService.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AgentContext } from '../../agent'
import type { Wallet } from '@aries-framework/core'
import type { Key, Wallet } from '@aries-framework/core'

import { getAgentConfig, getAgentContext } from '../../../tests/helpers'
import { DidKey } from '../../modules/dids'
Expand All @@ -16,7 +16,8 @@ describe('JwsService', () => {
let wallet: Wallet
let agentContext: AgentContext
let jwsService: JwsService

let didJwsz6MkfKey: Key
let didJwsz6MkvKey: Key
beforeAll(async () => {
const config = getAgentConfig('JwsService')
wallet = new IndyWallet(config.agentDependencies, config.logger, new SigningProviderRegistry([]))
Expand All @@ -27,6 +28,8 @@ describe('JwsService', () => {
await wallet.createAndOpen(config.walletConfig!)

jwsService = new JwsService()
didJwsz6MkfKey = await wallet.createKey({ seed: didJwsz6Mkf.SEED, keyType: KeyType.Ed25519 })
didJwsz6MkvKey = await wallet.createKey({ seed: didJwsz6Mkv.SEED, keyType: KeyType.Ed25519 })
})

afterAll(async () => {
Expand All @@ -35,16 +38,17 @@ describe('JwsService', () => {

describe('createJws', () => {
it('creates a jws for the payload with the key associated with the verkey', async () => {
const key = await wallet.createKey({ seed: didJwsz6Mkf.SEED, keyType: KeyType.Ed25519 })

const payload = JsonEncoder.toBuffer(didJwsz6Mkf.DATA_JSON)
const kid = new DidKey(key).did
const kid = new DidKey(didJwsz6MkfKey).did

const jws = await jwsService.createJws(agentContext, {
payload,
// FIXME: update to use key instance instead of verkey
verkey: key.publicKeyBase58,
key: didJwsz6MkfKey,
header: { kid },
protectedHeaderOptions: {
alg: 'EdDSA',
jwk: didJwsz6MkfKey.toJwk(),
},
})

expect(jws).toEqual(didJwsz6Mkf.JWS_JSON)
Expand All @@ -55,37 +59,37 @@ describe('JwsService', () => {
it('returns true if the jws signature matches the payload', async () => {
const payload = JsonEncoder.toBuffer(didJwsz6Mkf.DATA_JSON)

const { isValid, signerVerkeys } = await jwsService.verifyJws(agentContext, {
const { isValid, signerKeys } = await jwsService.verifyJws(agentContext, {
payload,
jws: didJwsz6Mkf.JWS_JSON,
})

expect(isValid).toBe(true)
expect(signerVerkeys).toEqual([didJwsz6Mkf.VERKEY])
expect(signerKeys).toEqual([didJwsz6MkfKey])
})

it('returns all verkeys that signed the jws', async () => {
const payload = JsonEncoder.toBuffer(didJwsz6Mkf.DATA_JSON)

const { isValid, signerVerkeys } = await jwsService.verifyJws(agentContext, {
const { isValid, signerKeys } = await jwsService.verifyJws(agentContext, {
payload,
jws: { signatures: [didJwsz6Mkf.JWS_JSON, didJwsz6Mkv.JWS_JSON] },
})

expect(isValid).toBe(true)
expect(signerVerkeys).toEqual([didJwsz6Mkf.VERKEY, didJwsz6Mkv.VERKEY])
expect(signerKeys).toEqual([didJwsz6MkfKey, didJwsz6MkvKey])
})

it('returns false if the jws signature does not match the payload', async () => {
const payload = JsonEncoder.toBuffer({ ...didJwsz6Mkf.DATA_JSON, did: 'another_did' })

const { isValid, signerVerkeys } = await jwsService.verifyJws(agentContext, {
const { isValid, signerKeys } = await jwsService.verifyJws(agentContext, {
payload,
jws: didJwsz6Mkf.JWS_JSON,
})

expect(isValid).toBe(false)
expect(signerVerkeys).toMatchObject([])
expect(signerKeys).toMatchObject([])
})

it('throws an error if the jws signatures array does not contain a JWS', async () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/crypto/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
export { Jwk } from './JwkTypes'
export { JwsService } from './JwsService'

export * from './jwtUtils'

export { KeyType } from './KeyType'
export { Key } from './Key'

export * from './signing-provider'
13 changes: 13 additions & 0 deletions packages/core/src/crypto/jwtUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const jwtKeyAlgMapping = {
HMAC: ['HS256', 'HS384', 'HS512'],
RSA: ['RS256', 'RS384', 'RS512'],
ECDSA: ['ES256', 'ES384', 'ES512'],
'RSA-PSS': ['PS256', 'PS384', 'PS512'],
EdDSA: ['Ed25519'],
}

export type JwtAlgorithm = keyof typeof jwtKeyAlgMapping

export function isJwtAlgorithm(value: string): value is JwtAlgorithm {
return Object.keys(jwtKeyAlgMapping).includes(value)
}
12 changes: 8 additions & 4 deletions packages/core/src/modules/connections/DidExchangeProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,10 +464,14 @@ export class DidExchangeProtocol {

const jws = await this.jwsService.createJws(agentContext, {
payload,
verkey,
key,
header: {
kid,
},
protectedHeaderOptions: {
alg: 'EdDSA',
jwk: key.toJwk(),
},
})
didDocAttach.addJws(jws)
})
Expand Down Expand Up @@ -510,7 +514,7 @@ export class DidExchangeProtocol {
this.logger.trace('DidDocument JSON', json)

const payload = JsonEncoder.toBuffer(json)
const { isValid, signerVerkeys } = await this.jwsService.verifyJws(agentContext, { jws, payload })
const { isValid, signerKeys } = await this.jwsService.verifyJws(agentContext, { jws, payload })

const didDocument = JsonTransformer.fromJSON(json, DidDocument)
const didDocumentKeysBase58 = didDocument.authentication
Expand All @@ -525,9 +529,9 @@ export class DidExchangeProtocol {
})
.concat(invitationKeysBase58)

this.logger.trace('JWS verification result', { isValid, signerVerkeys, didDocumentKeysBase58 })
this.logger.trace('JWS verification result', { isValid, signerKeys, didDocumentKeysBase58 })

if (!isValid || !signerVerkeys.every((verkey) => didDocumentKeysBase58?.includes(verkey))) {
if (!isValid || !signerKeys.every((key) => didDocumentKeysBase58?.includes(key.publicKeyBase58))) {
const problemCode =
message instanceof DidExchangeRequestMessage
? DidExchangeProblemReportReason.RequestNotAccepted
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/modules/dids/DidsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export class DidsApi {
*
* You can call `${@link DidsModule.resolve} to resolve the did document based on the did itself.
*/
public getCreatedDids({ method }: { method?: string } = {}) {
return this.didRepository.getCreatedDids(this.agentContext, { method })
public getCreatedDids({ method, did }: { method?: string; did?: string } = {}) {
return this.didRepository.getCreatedDids(this.agentContext, { method, did })
}
}

0 comments on commit 3d86e78

Please sign in to comment.