This commit is contained in:
cupcakearmy 2022-10-16 02:44:41 +02:00
parent 6d58350575
commit de042d485c
No known key found for this signature in database
GPG Key ID: 3235314B4D31232F
3 changed files with 179 additions and 0 deletions

137
src/crypto/rsa.ts Normal file
View File

@ -0,0 +1,137 @@
import type { TypedArray } from '../utils/base.js'
import { getCrypto } from './crypto.js'
import { Base64 } from './encoding.js'
const Constants = Object.freeze({
name: 'RSA-OAEP',
hash: 'SHA-512',
exponent: new Uint8Array([1, 0, 1]),
error: {
invalidKey: new Error('invalid key'),
shouldBePublicKey: new Error('should be a public key'),
shouldBePrivateKey: new Error('should be a private key'),
dataTooLong: new Error('data too long'),
},
})
class Key {
/**
* Exports a key to a PEM string.
*/
static async encode(key: CryptoKey): Promise<string> {
const c = await getCrypto()
let type: 'pkcs8' | 'spki'
let label: string
switch (key.type) {
case 'private':
type = 'pkcs8'
label = 'PRIVATE'
break
case 'public':
type = 'spki'
label = 'PUBLIC'
break
default:
throw new Error('invalid key type')
}
const exported = await c.subtle.exportKey(type, key)
// Export in PEM format
const base64 = await Base64.encode(new Uint8Array(exported))
const formatted = base64.match(/.{1,64}/g)?.join('\n')
return `-----BEGIN ${label} KEY-----\n${formatted}\n-----END ${label} KEY-----`
}
static async decode(s: string): Promise<CryptoKey> {
const isPrivate = s.includes('BEGIN PRIVATE KEY')
const cleaned = s.replace(/-----.*?-----/g, '').replace(/\s/g, '')
const bytes = await Base64.decode(cleaned)
const c = await getCrypto()
return await c.subtle.importKey(
isPrivate ? 'pkcs8' : 'spki',
bytes,
{
name: Constants.name,
hash: Constants.hash,
},
true,
isPrivate ? ['decrypt'] : ['encrypt']
)
}
/**
* Get max size of payload to be encrypted.
* The size depends on the key size and the hash function used.
* https://www.rfc-editor.org/rfc/rfc2437#section-7.1.1
*/
static getMaxMessageSize(key: CryptoKey): number {
if (key.type !== 'public') throw Constants.error.shouldBePublicKey
// @ts-ignore
const mod = key?.algorithm?.modulusLength
if (isNaN(mod)) throw Constants.error.invalidKey
return mod / 8 - (2 * 512) / 8 - 2
}
}
export class RSA {
static async generateKeyPair(bits: number = 2 ** 12): Promise<{ private: string; public: string }> {
const c = await getCrypto()
if (bits < 2 ** 11) {
throw new Error('bit sizes below 2048 are considered insecure.')
}
const pair = await c.subtle.generateKey(
{
name: Constants.name,
modulusLength: bits,
publicExponent: Constants.exponent,
hash: Constants.hash,
},
true,
['encrypt', 'decrypt']
)
return {
private: await Key.encode(pair.privateKey),
public: await Key.encode(pair.publicKey),
}
}
static async encrypt(data: TypedArray, key: string): Promise<TypedArray> {
let keyObj: CryptoKey
try {
keyObj = await Key.decode(key)
} catch (e) {
throw Constants.error.invalidKey
}
if (keyObj.type !== 'public') {
throw Constants.error.shouldBePublicKey
}
// Check if data is too large
if (data.length > Key.getMaxMessageSize(keyObj)) throw Constants.error.dataTooLong
const c = await getCrypto()
const encrypted = await c.subtle.encrypt({ name: Constants.name }, keyObj, data)
return new Uint8Array(encrypted)
}
static async decrypt(data: TypedArray, key: string): Promise<TypedArray> {
let keyObj: CryptoKey
try {
keyObj = await Key.decode(key)
} catch (e) {
throw Constants.error.invalidKey
}
if (keyObj.type !== 'private') {
throw Constants.error.shouldBePrivateKey
}
const c = await getCrypto()
const decrypted = await c.subtle.decrypt({ name: Constants.name }, keyObj, data)
return new Uint8Array(decrypted)
}
}

View File

@ -2,4 +2,5 @@ export * from './crypto/aes.js'
export * from './crypto/encoding.js'
export * from './crypto/hash.js'
export * from './crypto/random.js'
export * from './crypto/rsa.js'
export * from './utils/base.js'

41
test/rsa.spec.js Normal file
View File

@ -0,0 +1,41 @@
import { Bytes, RSA } from '../dist/esm/index.js'
import { Promises } from './utils.js'
import { Precomputed } from './values.js'
describe('RSA', () => {
describe('Generate keys', function () {
this.timeout(5_000)
it('Should be able to generate a keypair', async () => {
await RSA.generateKeyPair()
})
it('Should be able to generate a keypair with 2048bit', async () => {
await RSA.generateKeyPair(2048)
})
it('Should be able to generate a keypair with 4096bit', async () => {
await RSA.generateKeyPair(4096)
})
it('Should not be able to generate a key below 2048bit', async () => {
await Promises.reject(() => RSA.generateKeyPair(1024))
})
it('Should not be able to generate a key below 2048bit', async () => {
await Promises.reject(() => RSA.generateKeyPair(-1))
})
})
describe('Encryption', () => {
for (const message of Object.values(Precomputed.Crypto.Messages)) {
it(`Should be able to encrypt and decrypt "${message.slice(0, 8)}..."`, async () => {
const pair = await RSA.generateKeyPair(2 ** 11)
const bytes = Bytes.encode(message)
try {
const encrypted = await RSA.encrypt(bytes, pair.public)
chai.expect.fail('Should have thrown error')
const decrypted = await RSA.decrypt(encrypted, pair.private)
chai.expect(decrypted).to.be.deep.equal(bytes)
chai.expect(message).to.be.equal(Bytes.decode(decrypted))
} catch {}
})
}
})
})