Files
keysat-client-ts/src/verify.ts
T
2026-05-07 10:38:23 -05:00

81 lines
2.7 KiB
TypeScript

/** Offline Ed25519 signature verification. */
import * as ed from '@noble/ed25519'
import { sha512 } from '@noble/hashes/sha512'
import { LicensingError } from './errors.js'
import { parseLicenseKey, isExpiredAt, type LicensePayload } from './key.js'
import { PublicKey } from './pubkey.js'
import { hashFingerprint } from './fingerprint.js'
// `@noble/ed25519` requires us to plug in a hash function. This is a one-time
// init on module load; downstream callers don't need to care.
ed.etc.sha512Sync = (...m: Uint8Array[]) => sha512(ed.etc.concatBytes(...m))
export interface VerifyOk {
/** Parsed payload fields. */
payload: LicensePayload
/** License UUID as a canonical string. */
licenseId: string
/** Product UUID as a canonical string. */
productId: string
}
/** Verifies license keys against a single issuing server's public key. */
export class Verifier {
private pubkey: PublicKey
constructor(pubkey: PublicKey) {
this.pubkey = pubkey
}
/** Verify a license key string. Throws on any failure. */
verify(keyStr: string): VerifyOk {
const key = parseLicenseKey(keyStr)
const ok = ed.verify(key.signature, key.signedBytes, this.pubkey.raw)
if (!ok) throw new LicensingError('bad_signature', 'signature did not verify')
return {
payload: key.payload,
licenseId: key.payload.licenseUuid,
productId: key.payload.productUuid,
}
}
/**
* Verify AND enforce that, if the key is fingerprint-bound, the given
* fingerprint matches. If the key is not bound, the fingerprint is
* ignored. Throws on any failure.
*/
verifyWithFingerprint(keyStr: string, fingerprint: string): VerifyOk {
const result = this.verify(keyStr)
if (result.payload.isFingerprintBound) {
const expected = hashFingerprint(fingerprint)
const stored = result.payload.fingerprintHash
if (!equalBytes(expected, stored)) {
throw new LicensingError('bad_signature', 'fingerprint does not match bound key')
}
}
return result
}
/**
* Verify a key and additionally reject it with an `expired` error if
* `nowUnixSeconds` is at or past its `expiresAt`. Perpetual keys
* (`expiresAt === 0`) are accepted regardless of `nowUnixSeconds`. This is
* offline-only — no grace window logic; use `Client.validate` for that.
*/
verifyWithTime(keyStr: string, nowUnixSeconds: number): VerifyOk {
const result = this.verify(keyStr)
if (isExpiredAt(result.payload, nowUnixSeconds)) {
throw new LicensingError('expired', 'license has expired')
}
return result
}
}
function equalBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false
let diff = 0
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!
return diff === 0
}