From 658d0c60143c5e8276654cc39980372f741f3172 Mon Sep 17 00:00:00 2001 From: theisens <54779423+theisens@users.noreply.github.com> Date: Mon, 8 Apr 2024 14:05:30 -0400 Subject: [PATCH] Signing and verifying with Secp256r1 keys (#715) Added support for signing and verifying keys with Secp256r1 curve. Oracle KMS utilizes these keys and we needed the verification piece for our implementation, so thought we would share with the TBD team how we went about it and extend to cover the signing piece as well. Uses @noble/curves/p256 package in approach similar to utils/secp256k1.ts. I have included tests with 100% code coverage of the secp256r1.ts file and also tested locally with the generated key pairs for end-to-end signing and verifying in the context of our implementation. Open to any suggestions for improving the implementation/excited to hear your thoughts! --- src/core/dwn-error.ts | 1 + src/index.ts | 1 + .../signing/signature-algorithms.ts | 7 + src/types/jose-types.ts | 2 +- src/utils/secp256r1.ts | 142 ++++++++++++++++++ tests/jose/jws/general.spec.ts | 42 ++++++ tests/utils/secp256r1.spec.ts | 73 +++++++++ 7 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 src/utils/secp256r1.ts create mode 100644 tests/utils/secp256r1.spec.ts diff --git a/src/core/dwn-error.ts b/src/core/dwn-error.ts index 005ecc206..7ff6dbcb8 100644 --- a/src/core/dwn-error.ts +++ b/src/core/dwn-error.ts @@ -152,6 +152,7 @@ export enum DwnErrorCode { SchemaValidatorSchemaNotFound = 'SchemaValidatorSchemaNotFound', SchemaValidationFailure = 'SchemaValidationFailure', Secp256k1KeyNotValid = 'Secp256k1KeyNotValid', + Secp256r1KeyNotValid = 'Secp256r1KeyNotValid', TimestampInvalid = 'TimestampInvalid', UrlProtocolNotNormalized = 'UrlProtocolNotNormalized', UrlProtocolNotNormalizable = 'UrlProtocolNotNormalizable', diff --git a/src/index.ts b/src/index.ts index 65f79b2ab..1dd835299 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,7 @@ export { RecordsDelete, RecordsDeleteOptions } from './interfaces/records-delete export { RecordsRead, RecordsReadOptions } from './interfaces/records-read.js'; export { RecordsSubscribe, RecordsSubscribeOptions } from './interfaces/records-subscribe.js'; export { Secp256k1 } from './utils/secp256k1.js'; +export { Secp256r1 } from './utils/secp256r1.js'; export { Signer } from './types/signer.js'; export { SortDirection } from './types/query-types.js'; export { Time } from './utils/time.js'; diff --git a/src/jose/algorithms/signing/signature-algorithms.ts b/src/jose/algorithms/signing/signature-algorithms.ts index 0c62c0b5d..51765cd95 100644 --- a/src/jose/algorithms/signing/signature-algorithms.ts +++ b/src/jose/algorithms/signing/signature-algorithms.ts @@ -2,6 +2,7 @@ import type { SignatureAlgorithm } from '../../../types/jose-types.js'; import { ed25519 } from './ed25519.js'; import { Secp256k1 } from '../../../utils/secp256k1.js'; +import { Secp256r1 } from '../../../utils/secp256r1.js'; // the key should be the appropriate `crv` value export const signatureAlgorithms: Record = { @@ -12,4 +13,10 @@ export const signatureAlgorithms: Record = { generateKeyPair : Secp256k1.generateKeyPair, publicKeyToJwk : Secp256k1.publicKeyToJwk }, + 'P-256': { + sign : Secp256r1.sign, + verify : Secp256r1.verify, + generateKeyPair : Secp256r1.generateKeyPair, + publicKeyToJwk : Secp256r1.publicKeyToJwk, + }, }; \ No newline at end of file diff --git a/src/types/jose-types.ts b/src/types/jose-types.ts index adbe45639..5d8ffde94 100644 --- a/src/types/jose-types.ts +++ b/src/types/jose-types.ts @@ -19,7 +19,7 @@ export type PublicJwk = Jwk & { /** The "crv" (curve) parameter identifies the cryptographic curve used with the key. * MUST be present for all EC public keys */ - crv: 'Ed25519' | 'secp256k1'; + crv: 'Ed25519' | 'secp256k1' | 'P-256'; /** * the x coordinate for the Elliptic Curve point. * Represented as the base64url encoding of the octet string representation of the coordinate. diff --git a/src/utils/secp256r1.ts b/src/utils/secp256r1.ts new file mode 100644 index 000000000..7e22a7868 --- /dev/null +++ b/src/utils/secp256r1.ts @@ -0,0 +1,142 @@ +import type { PrivateJwk, PublicJwk } from '../types/jose-types.js'; + +import { p256, secp256r1 } from '@noble/curves/p256'; + +import { Encoder } from './encoder.js'; +import { sha256 } from 'multiformats/hashes/sha2'; +import { DwnError, DwnErrorCode } from '../core/dwn-error.js'; +import { fromString, toString } from 'uint8arrays'; + +const u8a = { toString, fromString }; + +/** + * Class containing SECP256R1 related utility methods. + */ +export class Secp256r1 { + /** + * Validates the given JWK is a SECP256R1 key. + * @throws {Error} if fails validation. + */ + public static validateKey(jwk: PrivateJwk | PublicJwk): void { + if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') { + throw new DwnError( + DwnErrorCode.Secp256r1KeyNotValid, + 'Invalid SECP256R1 JWK: `kty` MUST be `EC`. `crv` MUST be `P-256`' + ); + } + } + + /** + * Converts a public key in bytes into a JWK. + */ + public static async publicKeyToJwk( + publicKeyBytes: Uint8Array + ): Promise { + // ensure public key is in uncompressed format so we can convert it into both x and y value + let uncompressedPublicKeyBytes; + if (publicKeyBytes.byteLength === 33) { + // this means given key is compressed + const curvePoints = p256.ProjectivePoint.fromHex(publicKeyBytes); + uncompressedPublicKeyBytes = curvePoints.toRawBytes(false); // isCompressed = false + } else { + uncompressedPublicKeyBytes = publicKeyBytes; + } + + // the first byte is a header that indicates whether the key is uncompressed (0x04 if uncompressed), we can safely ignore + // bytes 1 - 32 represent X + // bytes 33 - 64 represent Y + + // skip the first byte because it's used as a header to indicate whether the key is uncompressed + const x = Encoder.bytesToBase64Url( + uncompressedPublicKeyBytes.subarray(1, 33) + ); + const y = Encoder.bytesToBase64Url( + uncompressedPublicKeyBytes.subarray(33, 65) + ); + + const publicJwk: PublicJwk = { + alg : 'ES256', + kty : 'EC', + crv : 'P-256', + x, + y, + }; + + return publicJwk; + } + + /** + * Creates a private key in raw bytes from the given SECP256R1 JWK. + */ + public static privateJwkToBytes(privateJwk: PrivateJwk): Uint8Array { + const privateKey = Encoder.base64UrlToBytes(privateJwk.d); + return privateKey; + } + + /** + * Signs the provided content using the provided JWK. + * Signature that is outputted is JWS format, not DER. + */ + public static async sign( + content: Uint8Array, + privateJwk: PrivateJwk + ): Promise { + Secp256r1.validateKey(privateJwk); + + const hashedContent = await sha256.encode(content); + const privateKeyBytes = Secp256r1.privateJwkToBytes(privateJwk); + + return Promise.resolve( + p256.sign(hashedContent, privateKeyBytes).toCompactRawBytes() + ); + } + + /** + * Verifies a signature against the provided payload hash and public key. + * @param signature - the signature to verify. Can be in either DER or compact format. If using Oracle Cloud KMS, keys will be DER formatted. + * @returns a boolean indicating whether the signature is valid. + */ + public static async verify( + content: Uint8Array, + signature: Uint8Array, + publicJwk: PublicJwk + ): Promise { + Secp256r1.validateKey(publicJwk); + + // handle DER vs compact signature formats + let sig; + if (signature.length === 64) { + sig = p256.Signature.fromCompact(signature); + } else { + sig = p256.Signature.fromDER(signature); + } + const hashedContent = await sha256.encode(content); + const keyBytes = p256.ProjectivePoint.fromAffine({ + x : Secp256r1.bytesToBigInt(Encoder.base64UrlToBytes(publicJwk.x)), + y : Secp256r1.bytesToBigInt(Encoder.base64UrlToBytes(publicJwk.y!)), + }).toRawBytes(false); + + return p256.verify(sig, hashedContent, keyBytes); + } + + /** + * Generates a random key pair in JWK format. + */ + public static async generateKeyPair(): Promise<{ + publicJwk: PublicJwk; + privateJwk: PrivateJwk; + }> { + const privateKeyBytes = p256.utils.randomPrivateKey(); + const publicKeyBytes = secp256r1.getPublicKey(privateKeyBytes, false); // `false` = uncompressed + + const d = Encoder.bytesToBase64Url(privateKeyBytes); + const publicJwk: PublicJwk = await Secp256r1.publicKeyToJwk(publicKeyBytes); + const privateJwk: PrivateJwk = { ...publicJwk, d }; + + return { publicJwk, privateJwk }; + } + + public static bytesToBigInt(b: Uint8Array): bigint { + return BigInt(`0x` + u8a.toString(b, 'base16')); + } +} diff --git a/tests/jose/jws/general.spec.ts b/tests/jose/jws/general.spec.ts index fb4472ca3..e3559ee58 100644 --- a/tests/jose/jws/general.spec.ts +++ b/tests/jose/jws/general.spec.ts @@ -10,6 +10,7 @@ import sinon from 'sinon'; import { UniversalResolver } from '@web5/dids'; const { Ed25519, secp256k1 } = signatureAlgorithms; +const secp256r1 = signatureAlgorithms['P-256']; chai.use(chaiAsPromised); @@ -51,6 +52,47 @@ describe('General JWS Sign/Verify', () => { expect(verificationResult.signers).to.include('did:jank:alice'); }); + it('should sign and verify secp256r1 signature using a key vector correctly', async () => { + const { privateJwk, publicJwk } = await secp256r1.generateKeyPair(); + const payloadBytes = new TextEncoder().encode('anyPayloadValue'); + const keyId = 'did:jank:alice#key1'; + + const jwsBuilder = await GeneralJwsBuilder.create(payloadBytes, [ + new PrivateKeySigner({ privateJwk, keyId }), + ]); + const jws = jwsBuilder.getJws(); + + const mockResolutionResult = { + didResolutionMetadata : {}, + didDocument : { + verificationMethod: [ + { + id : keyId, + type : 'JsonWebKey2020', + controller : 'did:jank:alice', + publicKeyJwk : publicJwk, + }, + ], + }, + didDocumentMetadata: {}, + }; + + const resolverStub = sinon.createStubInstance(UniversalResolver, { + // @ts-ignore + resolve: sinon + .stub() + .withArgs('did:jank:alice') + .resolves(mockResolutionResult), + }); + + const verificationResult = await GeneralJwsVerifier.verifySignatures( + jws, + resolverStub + ); + expect(verificationResult.signers.length).to.equal(1); + expect(verificationResult.signers).to.include('did:jank:alice'); + }); + it('should sign and verify ed25519 signature using a key vector correctly', async () => { const { privateJwk, publicJwk } = await Ed25519.generateKeyPair(); const payloadBytes = new TextEncoder().encode('anyPayloadValue'); diff --git a/tests/utils/secp256r1.spec.ts b/tests/utils/secp256r1.spec.ts new file mode 100644 index 000000000..e0059b2e4 --- /dev/null +++ b/tests/utils/secp256r1.spec.ts @@ -0,0 +1,73 @@ +import { base64url } from 'multiformats/bases/base64'; +import { DwnErrorCode } from '../../src/core/dwn-error.js'; +import { expect } from 'chai'; +import { p256 } from '@noble/curves/p256'; +import { Secp256r1 } from '../../src/utils/secp256r1.js'; +import { TestDataGenerator } from './test-data-generator.js'; + +describe('Secp256r1', () => { + describe('validateKey()', () => { + it('should throw if key is not a valid SECP256R1 key', async () => { + const validKey = (await Secp256r1.generateKeyPair()).publicJwk; + + expect(() => + Secp256r1.validateKey({ ...validKey, kty: 'invalidKty' as any }) + ).to.throw(DwnErrorCode.Secp256r1KeyNotValid); + expect(() => + Secp256r1.validateKey({ ...validKey, crv: 'invalidCrv' as any }) + ).to.throw(DwnErrorCode.Secp256r1KeyNotValid); + }); + }); + + describe('publicKeyToJwk()', () => { + it('should generate the same JWK regardless of compressed or uncompressed public key bytes given', async () => { + const compressedPublicKeyBase64UrlString = + 'Aom0shYia6t0cNMRQDRzPgCxdMWQamrfX3UJfOroLHo_'; + const uncompressedPublicKeyBase64UrlString = + 'BIm0shYia6t0cNMRQDRzPgCxdMWQamrfX3UJfOroLHo_cSITyng0NN1lt2BtZVXH4PE9Gerxq_mw2_CpbBHsWUI'; + + const compressedPublicKey = base64url.baseDecode( + compressedPublicKeyBase64UrlString + ); + + const uncompressedPublicKey = base64url.baseDecode( + uncompressedPublicKeyBase64UrlString + ); + + const publicJwk1 = await Secp256r1.publicKeyToJwk(compressedPublicKey); + const publicJwk2 = await Secp256r1.publicKeyToJwk(uncompressedPublicKey); + + expect(publicJwk1.x).to.equal(publicJwk2.x); + expect(publicJwk1.y).to.equal(publicJwk2.y); + }); + }); + + describe('verify()', () => { + it('should correctly handle DER formatted signatures', async () => { + const { privateJwk, publicJwk } = await Secp256r1.generateKeyPair(); + + const content = TestDataGenerator.randomBytes(16); + + const signature = await Secp256r1.sign(content, privateJwk); + + // Convert the signature to DER format + const derSignature = + p256.Signature.fromCompact(signature).toDERRawBytes(); + + const result = await Secp256r1.verify(content, derSignature, publicJwk); + + expect(result).to.equal(true); + }); + }); + + describe('sign()', () => { + it('should generate the signature in compact format', async () => { + const { privateJwk } = await Secp256r1.generateKeyPair(); + + const contentBytes = TestDataGenerator.randomBytes(16); + const signatureBytes = await Secp256r1.sign(contentBytes, privateJwk); + + expect(signatureBytes.length).to.equal(64); // DER format would be 70 bytes + }); + }); +});