Skip to content

Commit

Permalink
Signing and verifying with Secp256r1 keys (#715)
Browse files Browse the repository at this point in the history
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!
  • Loading branch information
theisens committed Apr 8, 2024
1 parent 6dd3a46 commit 658d0c6
Show file tree
Hide file tree
Showing 7 changed files with 267 additions and 1 deletion.
1 change: 1 addition & 0 deletions src/core/dwn-error.ts
Expand Up @@ -152,6 +152,7 @@ export enum DwnErrorCode {
SchemaValidatorSchemaNotFound = 'SchemaValidatorSchemaNotFound',
SchemaValidationFailure = 'SchemaValidationFailure',
Secp256k1KeyNotValid = 'Secp256k1KeyNotValid',
Secp256r1KeyNotValid = 'Secp256r1KeyNotValid',
TimestampInvalid = 'TimestampInvalid',
UrlProtocolNotNormalized = 'UrlProtocolNotNormalized',
UrlProtocolNotNormalizable = 'UrlProtocolNotNormalizable',
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Expand Up @@ -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';
Expand Down
7 changes: 7 additions & 0 deletions src/jose/algorithms/signing/signature-algorithms.ts
Expand Up @@ -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<string, SignatureAlgorithm> = {
Expand All @@ -12,4 +13,10 @@ export const signatureAlgorithms: Record<string, SignatureAlgorithm> = {
generateKeyPair : Secp256k1.generateKeyPair,
publicKeyToJwk : Secp256k1.publicKeyToJwk
},
'P-256': {
sign : Secp256r1.sign,
verify : Secp256r1.verify,
generateKeyPair : Secp256r1.generateKeyPair,
publicKeyToJwk : Secp256r1.publicKeyToJwk,
},
};
2 changes: 1 addition & 1 deletion src/types/jose-types.ts
Expand Up @@ -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.
Expand Down
142 changes: 142 additions & 0 deletions 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<PublicJwk> {
// 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<Uint8Array> {
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<boolean> {
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'));
}
}
42 changes: 42 additions & 0 deletions tests/jose/jws/general.spec.ts
Expand Up @@ -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);

Expand Down Expand Up @@ -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');
Expand Down
73 changes: 73 additions & 0 deletions 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
});
});
});

0 comments on commit 658d0c6

Please sign in to comment.