cripta
Version:
A layer of encryption a little too judicious
250 lines (195 loc) • 6.47 kB
text/typescript
import crypto from 'node:crypto'
import { DataBag, Timestamp } from 't0n'
import { Claim } from './claim'
import { Config, Cripta, sha256 } from '../'
export class Token {
claims: DataBag = new DataBag()
header: DataBag = new DataBag()
headerString: string = ''
body: any = Symbol('UNDEFINED')
bodyString: string = ''
signature: string = ''
#same: boolean = false
#sameConfig: Config = new Config({entropy: 0})
#leeway: number = 0
#cripta: Cripta = new Cripta()
#separator: string = '.'
secret(key: string) {
this.#cripta.config.setKey(key)
this.#sameConfig.setKey(key)
}
parseHeader(header: string | null | undefined = ''): this {
if (header) {
try {
const decoded = this.#cripta.decode(header)
if (typeof decoded === 'object') {
this.header.replace(decoded as any)
this.headerString = header
}
} catch (e) {}
}
return this
}
encodedHeader(): string {
this.#newEntropy()
return this.headerString = this.#cripta.encode(
this.#same ? this.header.all() : this.#randomizeObject(this.header.all())
)
}
parseBody(body: string | null | undefined = ''): this {
if (body && this.header.has('e')) {
try {
const decoded = this.#cripta.onceKey(this.header.get('e')).decode(body)
this.body = decoded
this.bodyString = body
} catch (e) {}
}
return this
}
encodedBody(): string {
return this.bodyString = this.#cripta.onceKey(this.#entropy()).encode(this.body)
}
setSignature(signature: string): this {
this.signature = signature
return this
}
sign(): string {
return this.#hash(
this.headerString + this.bodyString,
this.secret + this.#entropy()
)
}
signed(): boolean {
return this.signature ? this.signature === this.sign() : false
}
isValid(): boolean {
return this.body !== Symbol('UNDEFINED') && this.signed() ? this.validateClaims() : false
}
isInvalid(): boolean {
return !this.isValid()
}
hasValue(prop: string, value: any = null): boolean {
if (this.header.has(prop))
return value === null ? true : this.header.get(prop) === value
if (this.body && typeof this.body === 'object' && Object.keys(this.body).length > 0 && prop in this.body)
return value === null ? true : this.body[prop] === value
return false
}
validateClaims(): boolean {
const now = this.claims.get(Claim.NOW, Math.floor(Date.now() / 1000))
if (
this.isExpired(now)
|| !this.notBefore(now)
|| !this.issuedBefore(now)
|| !this.validate(Claim.AUDIENCE)
|| !this.validate(Claim.ISSUER)
|| !this.validate(Claim.ID)
|| !this.validate(Claim.SUBJECT)
|| !this.validateCustomClaims()
) {
return false
}
return true
}
isExpired(date: number | Date | null = null): boolean {
const claim = Claim.EXPIRATION_TIME
if (!this.header.has(claim)) return false
return Timestamp.timestamp(date) - this.#leeway >= this.header.get(claim)
}
#validAfter(claim: string, date: number | Date | null = null): boolean {
if (!this.header.has(claim)) return true
return Timestamp.timestamp(date) + this.#leeway >= this.header.get(claim)
}
notBefore(date: number | Date | null = null): boolean {
return this.#validAfter(Claim.NOT_BEFORE, date)
}
issuedBefore(date: number | Date | null = null): boolean {
return this.#validAfter(Claim.ISSUED_AT, date)
}
validate(claim: string, expected: any = null): boolean {
expected = expected || this.claims.get(claim, null)
let value = this.getTokenValue(claim)
value = Array.isArray(value) ? value : [value]
return Array.isArray(expected)
? expected.every(exp => value.includes(exp))
: value.includes(expected)
}
protected getTokenValue(claim: string): any {
if (this.header.has(claim))
return this.header.get(claim)
if (this.body && typeof this.body === 'object' && Object.keys(this.body).length > 0 && claim in this.body)
return this.body[claim]
return null
}
protected validateCustomClaims(): boolean {
const internal = new Set(['e', ...Claim.ALL])
const headers: Record<string, any> = {}
for (const [key, value] of this.header) {
if (!internal.has(key))
headers[key] = value
}
const claims: Record<string, any> = {}
for (const [key, value] of this.claims) {
if (!internal.has(key) && !headers[key])
claims[key] = value
}
const all = { ...headers, ...claims }
for (const claim in all) {
if (!this.validate(claim, all[claim])) return false
}
return true
}
same(entropy: string | number = '') {
this.#same = true
this.header.set('e', this.#hash(entropy || this.#cripta.config.key?.toString('hex')).substring(0, 6))
this.#cripta.config.entropy = 0
}
unique() {
this.#same = false
this.#newEntropy()
this.#cripta.config.entropy = 4
}
leeway(time: number | Date | null = null) {
// TODO: add suport to Date type
this.#leeway = typeof time === 'number' ? Math.abs(time) : 0
}
#entropy(): string {
return this.header.get('e') || this.#newEntropy()
}
#newEntropy(): string {
if (this.#same && this.header.has('e'))
return this.header.get('e')
const entropy = crypto.randomBytes(3).toString('hex')
this.header.set('e', entropy)
return entropy
}
#hash(data: any, secret: string | null = null): string {
secret && this.#sameConfig.setKey(secret)
return this.#cripta.onceConfig(this.#sameConfig).encode(sha256(String(data), 'binary').toString().substring(0, 16))
}
getParts(token: string): string[] | false {
if (!token) return false
const parts = token.split(this.#separator)
return parts.length === 3 ? parts : false
}
toString(): string {
return [
this.headerString || this.encodedHeader(),
this.bodyString || this.encodedBody(),
this.sign(),
].join(this.#separator)
}
#randomizeObject(obj: Record<string, any>): Record<string, any> {
const keys = Object.keys(obj)
for (let i = keys.length - 1; i > 0; i--) {
const j = (Math.random() * (i + 1)) | 0
;[keys[i], keys[j]] = [keys[j], keys[i]]
}
const out: Record<string, any> = {}
for (let i = 0; i < keys.length; i++) {
const k = keys[i]
out[k] = obj[k]
}
return out
}
}