mirror of
https://github.com/cupcakearmy/occulto.git
synced 2025-01-02 15:06:26 +00:00
rsa
This commit is contained in:
parent
6d58350575
commit
de042d485c
137
src/crypto/rsa.ts
Normal file
137
src/crypto/rsa.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -2,4 +2,5 @@ export * from './crypto/aes.js'
|
|||||||
export * from './crypto/encoding.js'
|
export * from './crypto/encoding.js'
|
||||||
export * from './crypto/hash.js'
|
export * from './crypto/hash.js'
|
||||||
export * from './crypto/random.js'
|
export * from './crypto/random.js'
|
||||||
|
export * from './crypto/rsa.js'
|
||||||
export * from './utils/base.js'
|
export * from './utils/base.js'
|
||||||
|
41
test/rsa.spec.js
Normal file
41
test/rsa.spec.js
Normal 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 {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user