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 + }); + }); +});