Created
February 21, 2024 22:05
-
-
Save syardumi/cdfbdb723fab912619262587bb78e382 to your computer and use it in GitHub Desktop.
Digital Signature Package
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import jwa from 'jwa' | |
import AWS from 'aws-sdk' | |
import { v4 as uuidv4 } from 'uuid' | |
import { parse as parseQuery } from 'querystring' | |
const ecdsa = jwa('ES256') | |
interface KeyStore { | |
public: Record<string, string> | |
private: Record<string, string> | |
} | |
interface SignedUrlParameters { | |
expiresAt: number | |
nonce: string | |
userId: string | |
entitlementId: string | |
clientId: string | |
signature: string | |
keyId: string | |
} | |
class DigitalSignature { | |
public ssm: AWS.SSM | |
private keyId: string | |
private keys: KeyStore = { | |
public: {}, | |
private: {} | |
} | |
/** | |
* | |
* @param keyId | |
*/ | |
constructor(keyId: string = process.env.DIGITAL_SIGNATURE_KEY_ID || null) { | |
this.ssm = new AWS.SSM() | |
this.keyId = keyId | |
} | |
/** | |
* Retrieves a key from Parameter Store. Keys are cached for the lifetime of | |
* this DigitalSignature instance. | |
* @param type Either 'public' or 'private' | |
* @param id The ID of the key to be retrieved | |
* @returns The specified key as it's stored in Parameter Store | |
*/ | |
private async getKey( | |
type: 'public' | 'private', | |
id: string | |
): Promise<string> { | |
if (!this.keys[type][id]) { | |
const ssmResponse = await this.ssm | |
.getParameter({ | |
Name: `/periodical/dig-sig/keys/${type}/${id}`, | |
...(type === 'private' && { WithDecryption: true }) | |
}) | |
.promise() | |
this.keys[type][id] = ssmResponse.Parameter.Value | |
} | |
return this.keys[type][id] | |
} | |
/** | |
* | |
* @param plainText | |
* @returns digital signature string | |
*/ | |
public async sign(plainText: string) { | |
const privKey = await this.getKey('private', this.keyId) | |
return ecdsa.sign(plainText, privKey) | |
} | |
/** | |
* | |
* @param encryptedText | |
* @param signature | |
* @param pubKeyId the ID of the public key to use for verification. If not | |
* specified, the keyId given to the constructor will be used. | |
* @returns true or false | |
*/ | |
public async verify( | |
encryptedText: string, | |
signature: string, | |
pubKeyId = this.keyId | |
) { | |
const pubKey = await this.getKey('public', pubKeyId) | |
return ecdsa.verify(encryptedText, signature, pubKey) | |
} | |
/** | |
* Gets a cleaned version of the provided URL, appropriate for concatenation | |
* into the signature input. | |
* @param urlStr The url to be cleaned | |
* @returns A signable URL fragment | |
*/ | |
private getBaseUrl(urlStr: string): string { | |
const url = new URL(urlStr) | |
const cleanPath = ( | |
url.pathname.match(/\.\w{2,5}$/g) | |
? url.pathname.split('/').slice(0, -1).join('/') | |
: url.pathname | |
).replace(/\/+$/g, '') | |
return `${url.protocol}//${url.hostname}${cleanPath}` | |
} | |
/** | |
* Generates an object containing all the parameters expected in a signed | |
* URL. For URLs that are not yet signed, parameters such as the nonce, | |
* expiration date, and signature will be generated. For already-signed | |
* URLs, a parsed version of the URL parameters will be provided. | |
* @param urlStr The URL to be signed, or a signed URL | |
* @param urlParams The params to add to, or override from, the URL | |
* @returns All the parameters expected in a signed URL | |
*/ | |
public async buildUrlParams( | |
urlStr: string, | |
urlParams: Partial<SignedUrlParameters> = {} | |
): Promise<SignedUrlParameters> { | |
const params = { | |
...parseQuery(urlStr.split('?').slice(-1)[0]), | |
...urlParams | |
} | |
if (!params.userId) throw new Error('No userId found in params') | |
if (!params.expiresAt) params.expiresAt = Date.now() + 300e3 | |
if (!params.nonce) params.nonce = uuidv4().split('-').slice(-1) | |
if (!params.keyId) params.keyId = this.keyId | |
if (!params.signature) { | |
const signable = this.buildSignable(urlStr, params as SignedUrlParameters) | |
params.signature = await this.sign(signable) | |
} | |
return params as SignedUrlParameters | |
} | |
/** | |
* Builds a concatenated string appropriate for signing or verification | |
* @param url The URL to be signed or verified | |
* @param params The parameters to be included {@link #buildUrlParams} | |
* @returns A string with the correct elements concatenated in the correct order | |
*/ | |
private buildSignable(url: string, params: SignedUrlParameters): string { | |
const baseUrl = this.getBaseUrl(url) | |
return `${baseUrl}${params.expiresAt}${params.userId}${params.nonce}` | |
} | |
/** | |
* | |
* @param unsignedUrlStr | |
* @param userId | |
* @param entitlementId | |
* @param clientId (optional) | |
* @returns signed url | |
*/ | |
public async buildSignedUrl( | |
unsignedUrlStr: string, | |
userId: string, | |
entitlementId: string, | |
clientId?: string | |
) { | |
const params = await this.buildUrlParams(unsignedUrlStr, { | |
userId, | |
entitlementId | |
}) | |
const unsignedUrl = new URL(unsignedUrlStr) | |
return `${unsignedUrlStr}${unsignedUrl.search ? '&' : '?'}expiresAt=${ | |
params.expiresAt | |
}&keyId=${this.keyId}&userId=${params.userId}&entitlementId=${ | |
params.entitlementId | |
}&nonce=${params.nonce}&signature=${params.signature}${ | |
clientId ? `&clientId=${clientId}` : '' | |
}` | |
} | |
/** | |
* Verifies the signature on a URL signed by the same process as this library. | |
* @param signedUrl The signed URL with all parameters intact | |
* @returns true if the signature is valid; false otherwise | |
*/ | |
public async verifySignedUrl(signedUrlStr: string): Promise<boolean> { | |
const params = await this.buildUrlParams(signedUrlStr) | |
const signable = this.buildSignable(signedUrlStr, params) | |
return this.verify(signable, params.signature, params.keyId) | |
} | |
} | |
// sign & verify | |
const digSig = new DigitalSignature() | |
const signedUrl = await digSig.buildSignedUrl( | |
`https://read.clicknreadmags.com/people/people_january_29_2024/cover.html`, | |
userId, // placeholder | |
entitlementId, // placeholder | |
clientId // placeholder | |
) | |
const verified = await digSig.verifySignedUrl(signedUrl) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment