Skip to content
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: Add support for custom signing algorithms #317

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
52 changes: 52 additions & 0 deletions docs/guides/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,55 @@ async function mySigner(data: Uint8Array | string): Promise<string> {
Your function must returns a `Promise<string>`.

A successful call resolves to a `base64url`-encoded signature.

### Adding signing algorithms

New signing algorithms can be created by implementing the SignerAlgorithm and Signer functions for the signing process and the verify function with the valid signatures.

#### Signing process

Implement a Signer Algorithm function, that returns a function that receives payload and signer and returns the signature as `Promise<string>`


```typescript
async function customSigner(data: Uint8Array | string): Promise<string> {
const signatureBytes = await customSignerLogic(data)
return bytesToBase64url(signature)
}
```

```typescript
async function customSignerAlgorithm(data: Uint8Array | string): SignerAlgorithm {
return async function sign (payload: string, signer: Signer): Promise<string> {
const signature = await customSigner(payload)
return payload
}
}
```

After implementing this, call the function `AddSigningAlgorithm("CustomSigningAlgorithm, customSignerAlgorithm()")`

#### Verification process

Implement a function following the next style that checks that the address recovered from the signature is one of the verification methods in the issuer did and returns one of them:
```typescript
export function verifyCustomSignature (
data: string,
signature: string,
authenticators: VerificationMethod[]
): VerificationMethod {
...
}
```
Export also a Record of the valid signature types of the algorithm:
```typescript
export const validSignatures: Record<string, string[]> = {
CustomSignature: [
'EcdsaSecp256k1VerificationKey2019',
'EcdsaSecp256k1RecoveryMethod2020'
]
}
```

After that, call the function `AddVerifierAlgorithm('CustomSigningAlgorithm', verifyCustomSignature, validSignatures)`

2 changes: 1 addition & 1 deletion src/JWT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ export async function resolveAuthenticator(
issuer: string,
proofPurpose?: ProofPurposeTypes
): Promise<DIDAuthenticator> {
const types: string[] = SUPPORTED_PUBLIC_KEY_TYPES[alg as KNOWN_JWA]
const types: string[] = SUPPORTED_PUBLIC_KEY_TYPES[alg]
if (!types || types.length === 0) {
throw new Error(`${JWT_ERROR.NOT_SUPPORTED}: No supported signature types for algorithm ${alg}`)
}
Expand Down
27 changes: 26 additions & 1 deletion src/SignerAlgorithm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,36 @@ const algorithms: SignerAlgorithms = {
Ed25519: Ed25519SignerAlg(),
EdDSA: Ed25519SignerAlg(),
}

/** */
function SignerAlg(alg: string): SignerAlgorithm {
const impl: SignerAlgorithm = algorithms[alg]
if (!impl) throw new Error(`not_supported: Unsupported algorithm ${alg}`)
return impl
}

/**
* Adds a new signing algorithm to the algorithm dictionary.
* @param alg - The name of the algorithm to add.
* @param impl - The implementation of the signing algorithm.
* @throws {Error} If the algorithm name is invalid (empty or not a string).
* @throws {Error} If the implementation is not a function.
* @throws {Error} If the algorithm already exists in the dictionary.
* @example
*/
export function AddSigningAlgorithm(alg: string, impl: SignerAlgorithm): void {
if (!alg || typeof alg !== 'string') {
throw new Error('Invalid algorithm name: must be a non-empty string')
}

if (!impl || typeof impl !== 'function') {
throw new Error('Invalid implementation: must be a function')
}

if (alg in algorithms) {
throw new Error(`Algorithm '${alg}' already exists`)
}

algorithms[alg] = impl
}

export default SignerAlg
34 changes: 33 additions & 1 deletion src/VerifierAlgorithm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
extractPublicKeyBytes,
KNOWN_JWA,
stringToBytes,
AddVerifierSupportedKeys,
} from './util.js'
import { verifyBlockchainAccountId } from './blockchains/index.js'
import { secp256k1 } from '@noble/curves/secp256k1'
Expand Down Expand Up @@ -154,7 +155,7 @@ export function verifyEd25519(

type Verifier = (data: string, signature: string, authenticators: VerificationMethod[]) => VerificationMethod

type Algorithms = Record<KNOWN_JWA, Verifier>
type Algorithms = Record<KNOWN_JWA | (string & NonNullable<unknown>), Verifier>

const algorithms: Algorithms = {
ES256: verifyES256,
Expand All @@ -174,6 +175,37 @@ function VerifierAlgorithm(alg: string): Verifier {
return impl
}

/**
* Adds a new verifier algorithm to the algorithm dictionary and registers its supported keys.
* @param alg - The name of the algorithm to add.
* @param verifier - The implementation of the verifier algorithm.
* @param validKeys - A record of valid key types and their corresponding valid values for this algorithm.
* @throws {Error} If the algorithm name is invalid (empty or not a string).
* @throws {Error} If the verifier is not a function.
* @throws {Error} If the validKeys is not an object or is empty.
* @throws {Error} If the algorithm already exists in the dictionary.
*/
export function AddVerifierAlgorithm(alg: string, verifier: Verifier, validKeys: Record<string, string[]>): void {
if (!alg || typeof alg !== 'string') {
throw new Error('Invalid algorithm name: must be a non-empty string')
}

if (!verifier || typeof verifier !== 'function') {
throw new Error('Invalid verifier: must be a function')
}

if (!validKeys || typeof validKeys !== 'object' || Object.keys(validKeys).length === 0) {
throw new Error('Invalid validKeys: must be a non-empty object')
}

if (alg in algorithms) {
throw new Error(`Algorithm '${alg}' already exists`)
}

AddVerifierSupportedKeys(alg, validKeys)
algorithms[alg] = verifier
}

VerifierAlgorithm.toSignatureObject = toSignatureObject

export default VerifierAlgorithm
144 changes: 143 additions & 1 deletion src/__tests__/JWT.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { jest, describe, expect, it } from '@jest/globals'
import { base64ToBytes, bytesToBase64url, decodeBase64url, hexToBytes } from '../util.js'
import { AddVerifierSupportedKeys, base64ToBytes, bytesToBase64url, decodeBase64url, hexToBytes } from '../util.js'
import type { Resolvable, VerificationMethod } from 'did-resolver'
import { TokenVerifier } from 'jsontokens'
import MockDate from 'mockdate'
Expand All @@ -12,6 +12,8 @@ import {
resolveAuthenticator,
SELF_ISSUED_V0_1,
SELF_ISSUED_V2,
Signer,
SignerAlgorithm,
verifyJWS,
verifyJWT,
} from '../JWT.js'
Expand All @@ -24,6 +26,9 @@ import { ES256Signer } from '../signers/ES256Signer'
import jwt from 'jsonwebtoken'
// @ts-ignore
import jwkToPem from 'jwk-to-pem'
import { AddSigningAlgorithm } from '../SignerAlgorithm.js'
import { AddVerifierAlgorithm } from '../VerifierAlgorithm.js'
import exp from 'constants'

const NOW = 1485321133
MockDate.set(NOW * 1000 + 123)
Expand Down Expand Up @@ -1383,6 +1388,143 @@ describe('resolveAuthenticator()', () => {
})
})

describe('Custom Algorithms', () => {
function customSigner(): Signer {
const signer = async (data: string | Uint8Array): Promise<string> => {
return 'custom'
}
return signer
}
function CustomSignerAlgorithm(): SignerAlgorithm {
return async function sign(payload: string, signer: Signer): Promise<string> {
const signature = await signer(payload)
if (typeof signature !== 'string') {
throw new Error('Custom signer must return a string')
}
return signature
}
}

function CustomVerifierAlgorithm(
data: string,
signature: string,
authenticators: VerificationMethod[]
): VerificationMethod {
const availableAuthenticators: boolean =
authenticators.find((a: VerificationMethod) => {
return (a.blockchainAccountId ?? a.ethereumAddress) !== undefined
}) !== undefined

if (!availableAuthenticators) {
throw new Error('No available authenticators')
}

if (signature !== 'custom') {
throw new Error('Invalid signature')
}
return authenticators[0]
}
const customSignerObj = customSigner()

const validKeys: Record<string, string[]> = {
Custom: ['EcdsaSecp256k1VerificationKey2019', 'EcdsaSecp256k1RecoveryMethod2020'],
}

AddSigningAlgorithm('Custom', CustomSignerAlgorithm())
AddVerifierAlgorithm('Custom', CustomVerifierAlgorithm, validKeys)

const verificationMethod = {
id: `${did}#keys-1`,
type: 'EcdsaSecp256k1RecoveryMethod2020',
owner: did,
blockchainAccountId: `eip155:1:${getAddress(address)}`,
}
const ethResolver = {
resolve: jest.fn().mockReturnValue({
didDocument: {
id: did,
verificationMethod: [verificationMethod],
},
}),
} as Resolvable

describe('AddSigningAlgorithm', () => {
it('adds a new signing algorithm successfully', () => {
expect(() => AddSigningAlgorithm('NewAlg', CustomSignerAlgorithm())).not.toThrow()
})

it('throws error when adding an existing algorithm', () => {
expect(() => AddSigningAlgorithm('Custom', CustomSignerAlgorithm())).toThrow(
"Algorithm 'Custom' already exists"
)
})

it('throws error with invalid algorithm name', () => {
expect(() => AddSigningAlgorithm('', CustomSignerAlgorithm())).toThrow(
'Invalid algorithm name: must be a non-empty string'
)
})

it('throws error with invalid implementation', () => {
expect(() => AddSigningAlgorithm('NewAlg', null as unknown as SignerAlgorithm)).toThrow(
'Invalid implementation: must be a function'
)
})
})

describe('AddVerifierAlgorithm', () => {
const mockVerifier = (data: string, signature: string, authenticators: VerificationMethod[]) => authenticators[0]

it('adds a new verifier algorithm successfully', () => {
expect(() => AddVerifierAlgorithm('NewVerifier', mockVerifier, { NewVerifier: ['key1', 'key2'] })).not.toThrow()
})

it('throws error when adding an existing algorithm', () => {
expect(() => AddVerifierAlgorithm('Custom', mockVerifier, validKeys)).toThrow(
"Algorithm 'Custom' already exists"
)
})

it('throws error with invalid algorithm name', () => {
expect(() => AddVerifierAlgorithm('', mockVerifier, validKeys)).toThrow(
'Invalid algorithm name: must be a non-empty string'
)
})

it('throws error with invalid validKeys', () => {
expect(() => AddVerifierAlgorithm('NewVerifier', mockVerifier, {})).toThrow(
'Invalid validKeys: must be a non-empty object'
)
})
})

it('returns correct signature', async () => {
const jwt = await createJWT({ test: 'custom' }, { issuer: 'custom', signer: customSignerObj }, { alg: 'Custom' })

const decodedJWT = decodeJWT(jwt)

const signature = decodedJWT.signature
const { alg, typ } = decodedJWT.header
const { iss, test, iat } = decodedJWT.payload

expect(alg).toEqual('Custom')
expect(typ).toEqual('JWT')
expect(iss).toEqual('custom')
expect(test).toEqual('custom')
expect(iat).toBeDefined()
expect(signature).toEqual('custom')
})

it('validates jwt signature correctly', async () => {
const jwt = await createJWT({ test: 'custom' }, { issuer: did, signer: customSignerObj }, { alg: 'Custom' })

const { payload, signer } = await verifyJWT(jwt, { resolver: ethResolver })
expect(signer).toEqual(verificationMethod)

return expect(payload).toEqual({ test: 'custom', iss: did, iat: payload.iat })
})
})

describe('incorrect format', () => {
it('throws if token is not valid JWT format', () => {
expect.assertions(1)
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ import {
type JWTPayload,
type JWTVerified,
type Signer,
type SignerAlgorithm,
verifyJWS,
verifyJWT,
} from './JWT.js'

export { toEthereumAddress, concatKDF } from './Digest.js'

export { AddSigningAlgorithm } from './SignerAlgorithm.js'
export { AddVerifierAlgorithm } from './VerifierAlgorithm.js'

export { createJWE, decryptJWE } from './encryption/JWE.js'
export { xc20pDirDecrypter, xc20pDirEncrypter } from './encryption/xc20pDir.js'
export * from './encryption/types.js'
Expand Down Expand Up @@ -54,6 +58,7 @@ export {
verifyJWS,
createJWS,
type Signer,
type SignerAlgorithm,
type JWTHeader,
type JWTPayload,
type JWTVerified,
Expand Down
28 changes: 27 additions & 1 deletion src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ export type KNOWN_VERIFICATION_METHOD =

export type KNOWN_KEY_TYPE = 'Secp256k1' | 'Ed25519' | 'X25519' | 'Bls12381G1' | 'Bls12381G2' | 'P-256'

export type PublicKeyTypes = Record<KNOWN_JWA, KNOWN_VERIFICATION_METHOD[]>
export type PublicKeyTypes = Record<
KNOWN_JWA | (string & NonNullable<unknown>),
KNOWN_VERIFICATION_METHOD[] | (string[] & NonNullable<unknown>)
>

export const SUPPORTED_PUBLIC_KEY_TYPES: PublicKeyTypes = {
ES256: ['JsonWebKey2020', 'Multikey', 'EcdsaSecp256r1VerificationKey2019'],
Expand Down Expand Up @@ -149,6 +152,29 @@ export const SUPPORTED_PUBLIC_KEY_TYPES: PublicKeyTypes = {
],
}

/**
* Adds supported public key types for a specific algorithm to the global registry.
* @param alg - The name of the algorithm.
* @param keys - A record containing the supported key types for the algorithm.
* @throws {Error} If the algorithm name is invalid (empty or not a string).
* @throws {Error} If the keys object is invalid or doesn't contain the algorithm.
* @throws {Error} If the key types for the algorithm are not in an array format.
*/
export function AddVerifierSupportedKeys(alg: string, keys: Record<string, string[]>): void {
if (!alg || typeof alg !== 'string') {
throw new Error('Invalid algorithm name: must be a non-empty string')
}

if (!keys || typeof keys !== 'object' || !(alg in keys)) {
throw new Error(`Invalid keys object: must contain the '${alg}' algorithm`)
}

if (!Array.isArray(keys[alg])) {
throw new Error(`Invalid key types for '${alg}': must be an array of strings`)
}
SUPPORTED_PUBLIC_KEY_TYPES[alg] = keys[alg]
}

export const VM_TO_KEY_TYPE: Record<KNOWN_VERIFICATION_METHOD, KNOWN_KEY_TYPE | undefined> = {
Secp256k1SignatureVerificationKey2018: 'Secp256k1',
Secp256k1VerificationKey2018: 'Secp256k1',
Expand Down