From 18aa66b9536fb3b7052445764bccb1539a3226af Mon Sep 17 00:00:00 2001 From: Christian Petrov Date: Thu, 23 Nov 2023 18:21:31 +0000 Subject: [PATCH] Support signing and verification using ECDSA Introduce the methods `crypto.subtle.sign()` and `crypto.subtle.verify()` to create and verify ECDSA signatures. Currently, only the combination of the algorithm `ECDSAinDERFormat` and `SHA-256` hash is supported. The algorithm `ECDSAinDERFormat` is similar to the `ECDSA` algorithm used in `SubtleCrypto`, with the distinction that signature is encoded in the DER format, diverging from the IEEE-P1363 format used in `SubtleCrypto`. --- doc/api/SubtleCrypto.json | 83 ++++++++++++++++++ snippets/crypto-sign.ts | 33 ++++++++ src/tabris/Crypto.ts | 76 +++++++++++++++++ test/tabris/Crypto.test.ts | 169 +++++++++++++++++++++++++++++++++++++ 4 files changed, 361 insertions(+) create mode 100644 snippets/crypto-sign.ts diff --git a/doc/api/SubtleCrypto.json b/doc/api/SubtleCrypto.json index 7fb5b678..24a87276 100644 --- a/doc/api/SubtleCrypto.json +++ b/doc/api/SubtleCrypto.json @@ -482,6 +482,89 @@ "ArrayBuffer" ] } + }, + "sign": { + "description": "Signs the given data. Currently only supports creating ECDSA signatures in DER format.", + "parameters": [ + { + "name": "algorithm", + "type": { + "map": { + "name": { + "type": "'ECDSAinDERFormat'" + }, + "hash": { + "type": "'SHA-256'" + } + } + } + }, + { + "name": "key", + "type": "CryptoKey" + }, + { + "name": "data", + "type": { + "union": [ + "ArrayBuffer", + "TypedArray" + ] + } + } + ], + "returns": { + "interface": "Promise", + "generics": [ + "ArrayBuffer" + ] + } + }, + "verify": { + "description": "Verifies the given signature against the data. Currently only supports verifying ECDSA signatures in DER format.", + "parameters": [ + { + "name": "algorithm", + "type": { + "map": { + "name": { + "type": "'ECDSAinDERFormat'" + }, + "hash": { + "type": "'SHA-256'" + } + } + } + }, + { + "name": "key", + "type": "CryptoKey" + }, + { + "name": "signature", + "type": { + "union": [ + "ArrayBuffer", + "TypedArray" + ] + } + }, + { + "name": "data", + "type": { + "union": [ + "ArrayBuffer", + "TypedArray" + ] + } + } + ], + "returns": { + "interface": "Promise", + "generics": [ + "boolean" + ] + } } } } diff --git a/snippets/crypto-sign.ts b/snippets/crypto-sign.ts new file mode 100644 index 00000000..788f41e4 --- /dev/null +++ b/snippets/crypto-sign.ts @@ -0,0 +1,33 @@ +import {contentView, crypto, Stack, tabris, TextView} from 'tabris'; + +const stack = Stack({stretch: true, spacing: 8, padding: 16, alignment: 'stretchX'}) + .appendTo(contentView); +tabris.onLog(({message}) => stack.append(TextView({text: message}))); + +(async function() { + + // Generate a key pair for signing and verifying + const keyPair = await crypto.subtle.generateKey( + {name: 'ECDSA', namedCurve: 'P-256'}, + true, + ['sign', 'verify'] + ); + + // Sign a message + const message = await new Blob(['Message']).arrayBuffer(); + const signature = await crypto.subtle.sign( + {name: 'ECDSAinDERFormat', hash: 'SHA-256'}, + keyPair.privateKey, + message + ); + console.log('Signature:', new Uint8Array(signature).join(', ')); + + // Verify the signature + const isValid = await crypto.subtle.verify( + {name: 'ECDSAinDERFormat', hash: 'SHA-256'}, + keyPair.publicKey, + signature, message + ); + console.log('Signature valid:', isValid); + +}()); diff --git a/src/tabris/Crypto.ts b/src/tabris/Crypto.ts index 873dd06e..0726b345 100644 --- a/src/tabris/Crypto.ts +++ b/src/tabris/Crypto.ts @@ -14,6 +14,8 @@ import checkType from './checkType'; export type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array; +type SignatureAlgorithm = { name: 'ECDSAinDERFormat', hash: 'SHA-256' }; + export default class Crypto { readonly subtle!: SubtleCrypto; @@ -239,6 +241,46 @@ class SubtleCrypto { }; } + async sign( + algorithm: SignatureAlgorithm, + key: CryptoKey, + data: ArrayBuffer | TypedArray + ): Promise { + if (arguments.length !== 3) { + throw new TypeError(`Expected 3 arguments, got ${arguments.length}`); + } + allowOnlyKeys(algorithm, ['name', 'hash']); + allowOnlyValues(algorithm.name, ['ECDSAinDERFormat'], 'algorithm.name'); + allowOnlyValues(algorithm.hash, ['SHA-256'], 'algorithm.hash'); + checkType(key, CryptoKey, {name: 'key'}); + checkType(algorithm.name, String, {name: 'algorithm.name'}); + checkType(algorithm.hash, String, {name: 'algorithm.hash'}); + checkType(getBuffer(data), ArrayBuffer, {name: 'data'}); + return new Promise((onSuccess, onError) => + this._nativeObject.subtleSign(algorithm, key, data, onSuccess, onError) + ); + } + + async verify( + algorithm: SignatureAlgorithm, + key: CryptoKey, + signature: ArrayBuffer | TypedArray, + data: ArrayBuffer | TypedArray + ): Promise { + if (arguments.length !== 4) { + throw new TypeError(`Expected 4 arguments, got ${arguments.length}`); + } + allowOnlyKeys(algorithm, ['name', 'hash']); + allowOnlyValues(algorithm.name, ['ECDSAinDERFormat'], 'algorithm.name'); + allowOnlyValues(algorithm.hash, ['SHA-256'], 'algorithm.hash'); + checkType(key, CryptoKey, {name: 'key'}); + checkType(getBuffer(signature), ArrayBuffer, {name: 'signature'}); + checkType(getBuffer(data), ArrayBuffer, {name: 'data'}); + return new Promise((onSuccess, onReject) => + this._nativeObject.subtleVerify(algorithm, key, signature, data, onSuccess, onReject) + ); + } + } class NativeCrypto extends NativeObject { @@ -351,6 +393,40 @@ class NativeCrypto extends NativeObject { }); } + subtleSign( + algorithm: SignatureAlgorithm, + key: CryptoKey, + data: ArrayBuffer | TypedArray, + onSuccess: (buffer: ArrayBuffer) => any, + onError: (ex: Error) => any + ): void { + this._nativeCall('subtleSign', { + algorithm, + key: getCid(key), + data: getBuffer(data), + onSuccess, + onError: (reason: unknown) => onError(new Error(String(reason))) + }); + } + + subtleVerify( + algorithm: SignatureAlgorithm, + key: CryptoKey, + signature: ArrayBuffer | TypedArray, + data: ArrayBuffer | TypedArray, + onSuccess: (isValid: boolean) => any, + onError: (ex: Error) => any + ): void { + this._nativeCall('subtleVerify', { + algorithm, + key: getCid(key), + signature: getBuffer(signature), + data: getBuffer(data), + onSuccess, + onError: (reason: unknown) => onError(new Error(String(reason))) + }); + } + } function checkDeriveAlgorithm(algorithm: Algorithm): diff --git a/test/tabris/Crypto.test.ts b/test/tabris/Crypto.test.ts index 0b6572d1..492befb2 100644 --- a/test/tabris/Crypto.test.ts +++ b/test/tabris/Crypto.test.ts @@ -1105,4 +1105,173 @@ describe('Crypto', function() { }); + describe('subtle.sign()', function() { + let params: Parameters; + let data: ArrayBuffer; + let key: _CryptoKey; + + async function sign(cb?: (nativeParams: any) => void) { + const promise = crypto.subtle.sign.apply(crypto.subtle, params); + cb?.call(null, client.calls({op: 'call', method: 'subtleSign'})[0].parameters); + return promise; + } + + beforeEach(function() { + client.resetCalls(); + data = new ArrayBuffer(10); + key = new _CryptoKey(); + params = [ + {name: 'ECDSAinDERFormat', hash: 'SHA-256'}, + new CryptoKey(key, {}), + data + ]; + }); + + it('CALLs subtleSign', async function() { + await sign(param => param.onSuccess()); + const signCalls = client.calls({op: 'call', method: 'subtleSign'}); + expect(signCalls.length).to.equal(1); + expect(signCalls[0].parameters).to.deep.include({ + algorithm: {name: 'ECDSAinDERFormat', hash: 'SHA-256'}, + key: key.cid, + data + }); + }); + + it('returns signed data', async function() { + const signed = new ArrayBuffer(2); + expect(await sign(param => param.onSuccess(signed))) + .to.equal(signed); + }); + + it('propagates rejection', async function() { + await expect(sign(param => param.onError('signerror'))) + .rejectedWith(Error, 'signerror'); + }); + + it('checks parameter length', async function() { + params.pop(); + await expect(sign()) + .rejectedWith(TypeError, 'Expected 3 arguments, got 2'); + expect(client.calls({op: 'call', method: 'subtleSign'}).length).to.equal(0); + }); + + it('checks algorithm.name', async function() { + (params[0].name as any) = 'foo'; + await expect(sign()) + .rejectedWith(TypeError, 'algorithm.name must be "ECDSAinDERFormat", got "foo"'); + expect(client.calls({op: 'call', method: 'subtleSign'}).length).to.equal(0); + }); + + it('checks algorithm.hash', async function() { + (params[0].hash as any) = 'foo'; + await expect(sign()) + .rejectedWith(TypeError, 'algorithm.hash must be "SHA-256", got "foo"'); + expect(client.calls({op: 'call', method: 'subtleSign'}).length).to.equal(0); + }); + + it('checks key', async function() { + params[1] = null; + await expect(sign()) + .rejectedWith(TypeError, 'Expected key to be of type CryptoKey, got null'); + expect(client.calls({op: 'call', method: 'subtleSign'}).length).to.equal(0); + }); + + it('checks data', async function() { + params[2] = null; + await expect(sign()) + .rejectedWith(TypeError, 'Expected data to be of type ArrayBuffer, got null'); + expect(client.calls({op: 'call', method: 'subtleSign'}).length).to.equal(0); + }); + }); + + describe('subtle.verify()', function() { + let params: Parameters; + let signature: ArrayBuffer; + let data: ArrayBuffer; + let key: _CryptoKey; + + async function verify(cb?: (nativeParams: any) => void) { + const promise = crypto.subtle.verify.apply(crypto.subtle, params); + cb?.call(null, client.calls({op: 'call', method: 'subtleVerify'})[0].parameters); + return promise; + } + + beforeEach(function() { + client.resetCalls(); + signature = new ArrayBuffer(2); + data = new ArrayBuffer(10); + key = new _CryptoKey(); + params = [ + {name: 'ECDSAinDERFormat', hash: 'SHA-256'}, + new CryptoKey(key, {}), + signature, + data + ]; + }); + + it('CALLs subtleVerify', async function() { + await verify(param => param.onSuccess(true)); + const verifyCalls = client.calls({op: 'call', method: 'subtleVerify'}); + expect(verifyCalls.length).to.equal(1); + expect(verifyCalls[0].parameters).to.deep.include({ + algorithm: {name: 'ECDSAinDERFormat', hash: 'SHA-256'}, + key: key.cid, + signature, + data + }); + }); + + it('returns verification result', async function() { + expect(await verify(param => param.onSuccess(true))).to.be.true; + }); + + it('propagates rejection', async function() { + await expect(verify(param => param.onError('verifyerror'))) + .rejectedWith(Error, 'verifyerror'); + }); + + it('checks parameter length', async function() { + params.pop(); + await expect(verify()) + .rejectedWith(TypeError, 'Expected 4 arguments, got 3'); + expect(client.calls({op: 'call', method: 'subtleVerify'}).length).to.equal(0); + }); + + it('checks algorithm.name', async function() { + (params[0].name as any) = 'foo'; + await expect(verify()) + .rejectedWith(TypeError, 'algorithm.name must be "ECDSAinDERFormat", got "foo"'); + expect(client.calls({op: 'call', method: 'subtleVerify'}).length).to.equal(0); + }); + + it('checks algorithm.hash', async function() { + (params[0].hash as any) = 'foo'; + await expect(verify()) + .rejectedWith(TypeError, 'algorithm.hash must be "SHA-256", got "foo"'); + expect(client.calls({op: 'call', method: 'subtleVerify'}).length).to.equal(0); + }); + + it('checks key', async function() { + params[1] = null; + await expect(verify()) + .rejectedWith(TypeError, 'Expected key to be of type CryptoKey, got null'); + expect(client.calls({op: 'call', method: 'subtleVerify'}).length).to.equal(0); + }); + + it('checks signature', async function() { + params[2] = null; + await expect(verify()) + .rejectedWith(TypeError, 'Expected signature to be of type ArrayBuffer, got null'); + expect(client.calls({op: 'call', method: 'subtleVerify'}).length).to.equal(0); + }); + + it('checks data', async function() { + params[3] = null; + await expect(verify()) + .rejectedWith(TypeError, 'Expected data to be of type ArrayBuffer, got null'); + expect(client.calls({op: 'call', method: 'subtleVerify'}).length).to.equal(0); + }); + }); + });