Skip to content

Commit

Permalink
feat(deps): replace @stablelib/ with noble-crypto (#280)
Browse files Browse the repository at this point in the history
fixes #270
BREAKING CHANGE: `ES256*` signers are now enforcing canonical signatures (s-value less than or equal to half the curve order). This will likely break some expectations for dependents that were using the previous versions.
  • Loading branch information
ukstv authored Apr 19, 2023
1 parent 37fae8d commit 0f6221a
Show file tree
Hide file tree
Showing 27 changed files with 1,337 additions and 831 deletions.
14 changes: 6 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
}
},
"scripts": {
"test": "jest",
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
"test:ci": "jest --coverage",
"build:js": "microbundle --compress=false",
"build:browser": "webpack --config webpack.config.cjs",
Expand Down Expand Up @@ -54,6 +54,9 @@
"!src/**/index.ts"
],
"testEnvironment": "node",
"extensionsToTreatAsEsm": [
".ts"
],
"testMatch": [
"**/__tests__/**/*.test.[jt]s"
]
Expand All @@ -65,7 +68,6 @@
"@ethersproject/address": "5.7.0",
"@semantic-release/changelog": "6.0.3",
"@semantic-release/git": "10.0.1",
"@types/elliptic": "6.4.14",
"@types/jest": "28.1.8",
"@types/jsonwebtoken": "^8.5.9",
"@types/jwk-to-pem": "^2.0.1",
Expand All @@ -91,16 +93,12 @@
"webpack-cli": "4.10.0"
},
"dependencies": {
"@stablelib/ed25519": "^1.0.2",
"@stablelib/random": "^1.0.1",
"@stablelib/sha256": "^1.0.1",
"@stablelib/x25519": "^1.0.2",
"@noble/curves": "^1.0.0",
"@noble/hashes": "^1.3.0",
"@stablelib/xchacha20poly1305": "^1.0.1",
"bech32": "^2.0.0",
"canonicalize": "^2.0.0",
"did-resolver": "^4.0.0",
"elliptic": "^6.5.4",
"js-sha3": "^0.8.0",
"multiformats": "^9.6.5",
"uint8arrays": "^3.0.0"
},
Expand Down
36 changes: 16 additions & 20 deletions src/Digest.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,29 @@
import { hash } from '@stablelib/sha256'
import { Ripemd160 } from './blockchains/utils/ripemd160'
import * as u8a from 'uint8arrays'
import sha3 from 'js-sha3'
import { sha256 as sha256Hash } from '@noble/hashes/sha256'
export { ripemd160 } from '@noble/hashes/ripemd160'
import { keccak_256 } from '@noble/hashes/sha3'
import { fromString } from 'uint8arrays/from-string'
import { toString } from 'uint8arrays/to-string'
import { concat } from 'uint8arrays/concat'

export function sha256(payload: string | Uint8Array): Uint8Array {
const data = typeof payload === 'string' ? u8a.fromString(payload) : payload
return hash(data)
const data = typeof payload === 'string' ? fromString(payload) : payload
return sha256Hash(data)
}

export function keccak(data: Uint8Array): Uint8Array {
return new Uint8Array(sha3.keccak_256.arrayBuffer(data))
}
export const keccak = keccak_256

export function toEthereumAddress(hexPublicKey: string): string {
const hashInput = u8a.fromString(hexPublicKey.slice(2), 'base16')
return `0x${u8a.toString(keccak(hashInput).slice(-20), 'base16')}`
}

export function ripemd160(data: Uint8Array): Uint8Array {
return new Ripemd160().update(data).digest()
const hashInput = fromString(hexPublicKey.slice(2), 'base16')
return `0x${toString(keccak(hashInput).slice(-20), 'base16')}`
}

function writeUint32BE(value: number, array = new Uint8Array(4)): Uint8Array {
const encoded = u8a.fromString(value.toString(), 'base10')
const encoded = fromString(value.toString(), 'base10')
array.set(encoded, 4 - encoded.length)
return array
}

const lengthAndInput = (input: Uint8Array): Uint8Array => u8a.concat([writeUint32BE(input.length), input])
const lengthAndInput = (input: Uint8Array): Uint8Array => concat([writeUint32BE(input.length), input])

// This implementation of concatKDF was inspired by these two implementations:
// https://github.com/digitalbazaar/minimal-cipher/blob/master/algorithms/ecdhkdf.js
Expand All @@ -40,14 +36,14 @@ export function concatKDF(
consumerInfo?: Uint8Array
): Uint8Array {
if (keyLen !== 256) throw new Error(`Unsupported key length: ${keyLen}`)
const value = u8a.concat([
lengthAndInput(u8a.fromString(alg)),
const value = concat([
lengthAndInput(fromString(alg)),
lengthAndInput(typeof producerInfo === 'undefined' ? new Uint8Array(0) : producerInfo), // apu
lengthAndInput(typeof consumerInfo === 'undefined' ? new Uint8Array(0) : consumerInfo), // apv
writeUint32BE(keyLen),
])

// since our key lenght is 256 we only have to do one round
const roundNumber = 1
return sha256(u8a.concat([writeUint32BE(roundNumber), secret, value]))
return sha256(concat([writeUint32BE(roundNumber), secret, value]))
}
4 changes: 2 additions & 2 deletions src/ECDH.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sharedKey } from '@stablelib/x25519'
import { x25519 } from '@noble/curves/ed25519'

/**
* A wrapper around `mySecretKey` that can compute a shared secret using `theirPublicKey`.
Expand Down Expand Up @@ -26,6 +26,6 @@ export function createX25519ECDH(mySecretKey: Uint8Array): ECDH {
if (theirPublicKey.length !== 32) {
throw new Error('invalid_argument: incorrect publicKey key length for X25519')
}
return sharedKey(mySecretKey, theirPublicKey)
return x25519.getSharedSecret(mySecretKey, theirPublicKey)
}
}
142 changes: 71 additions & 71 deletions src/VerifierAlgorithm.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import type { SignatureInput } from 'elliptic'
import elliptic from 'elliptic'
import { sha256, toEthereumAddress } from './Digest'
import { verify } from '@stablelib/ed25519'
import type { VerificationMethod } from 'did-resolver'
import { bases } from 'multiformats/basics'
import { hexToBytes, base58ToBytes, base64ToBytes, bytesToHex, EcdsaSignature, stringToBytes } from './util'
import {
hexToBytes,
base58ToBytes,
base64ToBytes,
bytesToHex,
EcdsaSignature,
stringToBytes,
bytesToBigInt,
ECDSASignature,
} from './util'
import { verifyBlockchainAccountId } from './blockchains'

const secp256k1 = new elliptic.ec('secp256k1')
const secp256r1 = new elliptic.ec('p256')
import { secp256k1 } from '@noble/curves/secp256k1'
import { p256 } from '@noble/curves/p256'
import { ed25519 } from '@noble/curves/ed25519'

// converts a JOSE signature to it's components
export function toSignatureObject(signature: string, recoverable = false): EcdsaSignature {
Expand All @@ -25,35 +31,34 @@ export function toSignatureObject(signature: string, recoverable = false): Ecdsa
return sigObj
}

interface LegacyVerificationMethod extends VerificationMethod {
publicKeyBase64: string
export function toSignatureObject2(signature: string, recoverable = false): ECDSASignature {
const bytes = base64ToBytes(signature)
if (bytes.length !== (recoverable ? 65 : 64)) {
throw new Error('wrong signature length')
}
return {
compact: bytes.slice(0, 64),
recovery: bytes[64],
}
}

function extractPublicKeyBytes(pk: VerificationMethod): Uint8Array {
if (pk.publicKeyBase58) {
return base58ToBytes(pk.publicKeyBase58)
} else if ((<LegacyVerificationMethod>pk).publicKeyBase64) {
return base64ToBytes((<LegacyVerificationMethod>pk).publicKeyBase64)
} else if (pk.publicKeyBase64) {
return base64ToBytes(pk.publicKeyBase64)
} else if (pk.publicKeyHex) {
return hexToBytes(pk.publicKeyHex)
} else if (pk.publicKeyJwk && pk.publicKeyJwk.crv === 'secp256k1' && pk.publicKeyJwk.x && pk.publicKeyJwk.y) {
return hexToBytes(
secp256k1
.keyFromPublic({
x: bytesToHex(base64ToBytes(pk.publicKeyJwk.x)),
y: bytesToHex(base64ToBytes(pk.publicKeyJwk.y)),
})
.getPublic('hex')
)
return secp256k1.ProjectivePoint.fromAffine({
x: bytesToBigInt(base64ToBytes(pk.publicKeyJwk.x)),
y: bytesToBigInt(base64ToBytes(pk.publicKeyJwk.y)),
}).toRawBytes(false)
} else if (pk.publicKeyJwk && pk.publicKeyJwk.crv === 'P-256' && pk.publicKeyJwk.x && pk.publicKeyJwk.y) {
return hexToBytes(
secp256r1
.keyFromPublic({
x: bytesToHex(base64ToBytes(pk.publicKeyJwk.x)),
y: bytesToHex(base64ToBytes(pk.publicKeyJwk.y)),
})
.getPublic('hex')
)
return p256.ProjectivePoint.fromAffine({
x: bytesToBigInt(base64ToBytes(pk.publicKeyJwk.x)),
y: bytesToBigInt(base64ToBytes(pk.publicKeyJwk.y)),
}).toRawBytes(false)
} else if (
pk.publicKeyJwk &&
pk.publicKeyJwk.kty === 'OKP' &&
Expand All @@ -70,16 +75,14 @@ function extractPublicKeyBytes(pk: VerificationMethod): Uint8Array {
}

export function verifyES256(data: string, signature: string, authenticators: VerificationMethod[]): VerificationMethod {
const hash: Uint8Array = sha256(data)
const sigObj: EcdsaSignature = toSignatureObject(signature)
const fullPublicKeys = authenticators.filter(({ ethereumAddress, blockchainAccountId }) => {
return typeof ethereumAddress === 'undefined' && typeof blockchainAccountId === 'undefined'
})
const hash = sha256(data)
const sig = p256.Signature.fromCompact(toSignatureObject2(signature).compact)
const fullPublicKeys = authenticators.filter((a: VerificationMethod) => !a.ethereumAddress && !a.blockchainAccountId)

const signer: VerificationMethod | undefined = fullPublicKeys.find((pk: VerificationMethod) => {
try {
const pubBytes = extractPublicKeyBytes(pk)
return secp256r1.keyFromPublic(pubBytes).verify(hash, <SignatureInput>sigObj)
return p256.verify(sig, hash, pubBytes)
} catch (err) {
return false
}
Expand All @@ -94,19 +97,19 @@ export function verifyES256K(
signature: string,
authenticators: VerificationMethod[]
): VerificationMethod {
const hash: Uint8Array = sha256(data)
const sigObj: EcdsaSignature = toSignatureObject(signature)
const fullPublicKeys = authenticators.filter(({ ethereumAddress, blockchainAccountId }) => {
return typeof ethereumAddress === 'undefined' && typeof blockchainAccountId === 'undefined'
const hash = sha256(data)
const signatureNormalized = secp256k1.Signature.fromCompact(base64ToBytes(signature)).normalizeS()
const fullPublicKeys = authenticators.filter((a: VerificationMethod) => {
return !a.ethereumAddress && !a.blockchainAccountId
})
const blockchainAddressKeys = authenticators.filter(({ ethereumAddress, blockchainAccountId }) => {
return typeof ethereumAddress !== 'undefined' || typeof blockchainAccountId !== 'undefined'
const blockchainAddressKeys = authenticators.filter((a: VerificationMethod) => {
return a.ethereumAddress || a.blockchainAccountId
})

let signer: VerificationMethod | undefined = fullPublicKeys.find((pk: VerificationMethod) => {
try {
const pubBytes = extractPublicKeyBytes(pk)
return secp256k1.keyFromPublic(pubBytes).verify(hash, <SignatureInput>sigObj)
return secp256k1.verify(signatureNormalized, hash, pubBytes)
} catch (err) {
return false
}
Expand All @@ -125,56 +128,53 @@ export function verifyRecoverableES256K(
signature: string,
authenticators: VerificationMethod[]
): VerificationMethod {
let signatures: EcdsaSignature[]
const signatures: ECDSASignature[] = []
if (signature.length > 86) {
signatures = [toSignatureObject(signature, true)]
signatures.push(toSignatureObject2(signature, true))
} else {
const so = toSignatureObject(signature, false)
signatures = [
{ ...so, recoveryParam: 0 },
{ ...so, recoveryParam: 1 },
]
const so = toSignatureObject2(signature, false)
signatures.push({ ...so, recovery: 0 })
signatures.push({ ...so, recovery: 1 })
}
const hash = sha256(data)

const checkSignatureAgainstSigner = (sigObj: EcdsaSignature): VerificationMethod | undefined => {
const hash: Uint8Array = sha256(data)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const recoveredKey: any = secp256k1.recoverPubKey(hash, <SignatureInput>sigObj, <number>sigObj.recoveryParam)
const recoveredPublicKeyHex: string = recoveredKey.encode('hex')
const recoveredCompressedPublicKeyHex: string = recoveredKey.encode('hex', true)
const recoveredAddress: string = toEthereumAddress(recoveredPublicKeyHex).toLowerCase()
const checkSignatureAgainstSigner = (sigObj: ECDSASignature): VerificationMethod | undefined => {
const signature = secp256k1.Signature.fromCompact(sigObj.compact).addRecoveryBit(sigObj.recovery || 0)
const recoveredPublicKey = signature.recoverPublicKey(hash)
const recoveredAddress = toEthereumAddress(recoveredPublicKey.toHex(false)).toLowerCase()
const recoveredPublicKeyHex = recoveredPublicKey.toHex(false)
const recoveredCompressedPublicKeyHex = recoveredPublicKey.toHex(true)

const signer: VerificationMethod | undefined = authenticators.find((pk: VerificationMethod) => {
const keyHex = bytesToHex(extractPublicKeyBytes(pk))
return authenticators.find((a: VerificationMethod) => {
const keyHex = bytesToHex(extractPublicKeyBytes(a))
return (
keyHex === recoveredPublicKeyHex ||
keyHex === recoveredCompressedPublicKeyHex ||
pk.ethereumAddress?.toLowerCase() === recoveredAddress ||
pk.blockchainAccountId?.split('@eip155')?.[0].toLowerCase() === recoveredAddress || // CAIP-2
verifyBlockchainAccountId(recoveredPublicKeyHex, pk.blockchainAccountId) // CAIP-10
a.ethereumAddress?.toLowerCase() === recoveredAddress ||
a.blockchainAccountId?.split('@eip155')?.[0].toLowerCase() === recoveredAddress || // CAIP-2
verifyBlockchainAccountId(recoveredPublicKeyHex, a.blockchainAccountId) // CAIP-10
)
})

return signer
}

const signer: VerificationMethod[] = signatures
.map(checkSignatureAgainstSigner)
.filter((key) => typeof key !== 'undefined') as VerificationMethod[]

if (signer.length === 0) throw new Error('invalid_signature: Signature invalid for JWT')
return signer[0]
// Find first verification method
for (const signature of signatures) {
const verificationMethod = checkSignatureAgainstSigner(signature)
if (verificationMethod) return verificationMethod
}
// If no one found matching
throw new Error('invalid_signature: Signature invalid for JWT')
}

export function verifyEd25519(
data: string,
signature: string,
authenticators: VerificationMethod[]
): VerificationMethod {
const clear: Uint8Array = stringToBytes(data)
const sig: Uint8Array = base64ToBytes(signature)
const signer = authenticators.find((pk: VerificationMethod) => {
return verify(extractPublicKeyBytes(pk), clear, sig)
const clear = stringToBytes(data)
const signatureBytes = base64ToBytes(signature)
const signer = authenticators.find((a: VerificationMethod) => {
return ed25519.verify(signatureBytes, clear, extractPublicKeyBytes(a))
})
if (!signer) throw new Error('invalid_signature: Signature invalid for JWT')
return signer
Expand Down
10 changes: 5 additions & 5 deletions src/__tests__/ES256KSigner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe('Secp256k1 Signer', () => {
const signer = ES256KSigner(hexToBytes(privateKey))
const plaintext = 'thequickbrownfoxjumpedoverthelazyprogrammer'
await expect(signer(plaintext)).resolves.toEqual(
'jsvdLwqr-O206hkegoq6pbo7LJjCaflEKHCvfohBP9XJ4C7mG2TPL9YjyKEpYSXqqkUrfRoCxQecHR11Uh7POw'
'jsvdLwqr-O206hkegoq6pbo7LJjCaflEKHCvfohBP9U2H9EZ5Jsw0CncN17WntoUEGmxaZVF2zQjtUEXfhdyBg'
)
})

Expand All @@ -18,7 +18,7 @@ describe('Secp256k1 Signer', () => {
const signer = ES256KSigner(hexToBytes(privateKey))
const plaintext = 'thequickbrownfoxjumpedoverthelazyprogrammer'
await expect(signer(plaintext)).resolves.toEqual(
'jsvdLwqr-O206hkegoq6pbo7LJjCaflEKHCvfohBP9XJ4C7mG2TPL9YjyKEpYSXqqkUrfRoCxQecHR11Uh7POw'
'jsvdLwqr-O206hkegoq6pbo7LJjCaflEKHCvfohBP9U2H9EZ5Jsw0CncN17WntoUEGmxaZVF2zQjtUEXfhdyBg'
)
})

Expand All @@ -28,7 +28,7 @@ describe('Secp256k1 Signer', () => {
const signer = ES256KSigner(base58ToBytes(privateKey))
const plaintext = 'thequickbrownfoxjumpedoverthelazyprogrammer'
await expect(signer(plaintext)).resolves.toEqual(
'jsvdLwqr-O206hkegoq6pbo7LJjCaflEKHCvfohBP9XJ4C7mG2TPL9YjyKEpYSXqqkUrfRoCxQecHR11Uh7POw'
'jsvdLwqr-O206hkegoq6pbo7LJjCaflEKHCvfohBP9U2H9EZ5Jsw0CncN17WntoUEGmxaZVF2zQjtUEXfhdyBg'
)
})

Expand All @@ -38,7 +38,7 @@ describe('Secp256k1 Signer', () => {
const signer = ES256KSigner(base64ToBytes(privateKey))
const plaintext = 'thequickbrownfoxjumpedoverthelazyprogrammer'
await expect(signer(plaintext)).resolves.toEqual(
'jsvdLwqr-O206hkegoq6pbo7LJjCaflEKHCvfohBP9XJ4C7mG2TPL9YjyKEpYSXqqkUrfRoCxQecHR11Uh7POw'
'jsvdLwqr-O206hkegoq6pbo7LJjCaflEKHCvfohBP9U2H9EZ5Jsw0CncN17WntoUEGmxaZVF2zQjtUEXfhdyBg'
)
})

Expand All @@ -48,7 +48,7 @@ describe('Secp256k1 Signer', () => {
const signer = ES256KSigner(base64ToBytes(privateKey))
const plaintext = 'thequickbrownfoxjumpedoverthelazyprogrammer'
await expect(signer(plaintext)).resolves.toEqual(
'jsvdLwqr-O206hkegoq6pbo7LJjCaflEKHCvfohBP9XJ4C7mG2TPL9YjyKEpYSXqqkUrfRoCxQecHR11Uh7POw'
'jsvdLwqr-O206hkegoq6pbo7LJjCaflEKHCvfohBP9U2H9EZ5Jsw0CncN17WntoUEGmxaZVF2zQjtUEXfhdyBg'
)
})

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/ES256Signer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe('Secp256r1 Signer', () => {
'vOTe64WujVUjEiQrAlwaPJtNADx4usSlCfe8OXHS6Np1BqJdqdJX912pVwVlAjmbqR_TMVE5i5TWB_GJVgrHgg'
)
})

it('refuses wrong key size (too short)', async () => {
expect.assertions(1)
const privateKey = '040f1dbf0a2ca86875447a7c010b0fc6d39d76859c458fbe8f2bf775a40ad7'
Expand Down
Loading

0 comments on commit 0f6221a

Please sign in to comment.