163 lines
6.4 KiB
TypeScript
163 lines
6.4 KiB
TypeScript
import { createHash, createPublicKey, verify } from 'node:crypto'
|
||
import { crc32 } from 'node:zlib'
|
||
|
||
/**
|
||
* Spotnet spot headers (free.pt), after Spotweb's Services_Format_Parsing::parseHeader.
|
||
*
|
||
* From: Nick <[PUBKEY[.USERSIGNATURE]]@[CAT][KEYID][SUBCATS].[SIZE].[x].[DATE].[..].[SIGNATURE]>
|
||
* Subject: Title | Tag
|
||
*/
|
||
export interface SpotHeader {
|
||
messageId: string
|
||
poster: string
|
||
title: string
|
||
tag: string
|
||
/** 0 image/video, 1 sound, 2 games, 3 applications. */
|
||
category: number
|
||
keyId: number
|
||
/** e.g. { a: [9], b: [3], c: [7, 10], d: [17], z: [0] } */
|
||
subcats: Record<string, number[]>
|
||
size: number
|
||
/** Unix seconds. */
|
||
stamp: number
|
||
/** Everything after the @. */
|
||
header: string
|
||
headerSign: string
|
||
selfSignedPubKey: string
|
||
userSignature?: string
|
||
}
|
||
|
||
/** Spotnet's base64 variant: `-s` for `/`, `-p` for `+`, padding left out. */
|
||
export function spotUnprepareBase64(input: string): string {
|
||
const padded = input.length % 4 ? input + '='.repeat(4 - (input.length % 4)) : input
|
||
return padded.replace(/-s/g, '/').replace(/-p/g, '+')
|
||
}
|
||
|
||
/** Parses the From/Subject of a spot. Null when it isn't one. */
|
||
export function parseSpotHeader(from: string, subject: string, messageId: string, date?: string): SpotHeader | null {
|
||
const lt = from.indexOf('<')
|
||
if (lt === -1) return null
|
||
const address = from.slice(lt + 1, from.endsWith('>') ? -1 : undefined)
|
||
const at = address.lastIndexOf('@')
|
||
if (at === -1) return null
|
||
const header = address.slice(at + 1)
|
||
const [pubKey, userSignature] = address.slice(0, at).split('.')
|
||
|
||
const fields = header.split('.')
|
||
if (fields.length < 6) return null
|
||
const category = Number.parseInt(fields[0][0], 10) - 1
|
||
const keyId = Number.parseInt(fields[0][1], 10)
|
||
if (!(category >= 0) || !(keyId >= 0)) return null
|
||
|
||
// a9b4c0d5 (FTD) or a09b04c00d05 (newer): a letter, then digits.
|
||
const subcats: Record<string, number[]> = {}
|
||
for (const [, letter, digits] of fields[0].slice(2).toLowerCase().matchAll(/([a-z])(\d+)/g)) {
|
||
if ('abcdz'.includes(letter)) (subcats[letter] ??= []).push(Number.parseInt(digits, 10))
|
||
}
|
||
|
||
// Newer keys: "Title | Tag". Key 1 (oldest): the tag is the last field.
|
||
const parts = subject.split('|')
|
||
let title: string
|
||
let tag = ''
|
||
if (keyId !== 1) {
|
||
title = parts[0].trim()
|
||
tag = parts.length > 1 ? parts[1].trim() : ''
|
||
} else {
|
||
tag = parts.length > 1 ? parts[parts.length - 1].trim() : subject.trim()
|
||
title = parts.length > 1 ? parts.slice(0, -2).join('|').trim() : ''
|
||
}
|
||
|
||
const parsedDate = date ? Date.parse(date) : Number.NaN
|
||
return {
|
||
messageId: messageId.replace(/^<|>$/g, ''),
|
||
poster: from.slice(0, lt).trim(),
|
||
title,
|
||
tag,
|
||
category,
|
||
keyId,
|
||
subcats,
|
||
size: Number.parseInt(fields[1], 10) || 0,
|
||
stamp: Number.isNaN(parsedDate) ? Number.parseInt(fields[3], 10) || 0 : Math.floor(parsedDate / 1000),
|
||
header,
|
||
headerSign: fields[fields.length - 1],
|
||
selfSignedPubKey: spotUnprepareBase64(pubKey ?? ''),
|
||
userSignature: userSignature ? spotUnprepareBase64(userSignature) : undefined,
|
||
}
|
||
}
|
||
|
||
/** Spotnet's own signing keys (Spotweb settings `rsa_keys`), base64 modulus. */
|
||
export const SPOTNET_KEYS: Record<number, string> = {
|
||
2: 'ys8WSlqonQMWT8ubG0tAA2Q07P36E+CJmb875wSR1XH7IFhEi0CCwlUzNqBFhC+P',
|
||
3: 'uiyChPV23eguLAJNttC/o0nAsxXgdjtvUvidV2JL+hjNzc4Tc/PPo2JdYvsqUsat',
|
||
4: '1k6RNDVD6yBYWR6kHmwzmSud7JkNV4SMigBrs+jFgOK5Ldzwl17mKXJhl+su/GR9',
|
||
}
|
||
|
||
const base64url = (b64: string) => Buffer.from(b64, 'base64').toString('base64url')
|
||
|
||
/** RSA PKCS#1 v1.5 with SHA-1, as Spotweb's checkRsaSignature (exponent AQAB). */
|
||
export function checkRsaSignature(message: Buffer | string, signatureB64: string, modulusB64: string): boolean {
|
||
try {
|
||
const key = createPublicKey({ key: { kty: 'RSA', n: base64url(modulusB64), e: 'AQAB' }, format: 'jwk' })
|
||
const data = typeof message === 'string' ? toBytes(message) : message
|
||
return verify('sha1', data, key, Buffer.from(signatureB64, 'base64'))
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** Spots are signed over their ISO-8859-1 bytes. */
|
||
function toBytes(text: string): Buffer {
|
||
return /^[\u0000-ÿ]*$/.test(text) ? Buffer.from(text, 'latin1') : Buffer.from(text, 'utf8')
|
||
}
|
||
|
||
/** SPOTSIGN_V2 and self-signed messages carry a hashcash: sha1('<msgid>') starts with 0000. */
|
||
function hashcash(messageId: string): boolean {
|
||
return createHash('sha1').update(`<${messageId}>`).digest('hex').startsWith('0000')
|
||
}
|
||
|
||
/**
|
||
* Verifies a spot header like Spotweb does: key 7 (self-signed) needs a
|
||
* hashcash and the poster's key, keys 2–4 Spotnet's keys; spots that also
|
||
* carry a user signature must have it match the message id.
|
||
*/
|
||
export function verifySpotHeader(spot: SpotHeader, keys: Record<number, string> = SPOTNET_KEYS): boolean {
|
||
if (!spot.title || !spot.poster) return false
|
||
// Old key 1 spots from before 2011 weren't signed.
|
||
if (spot.keyId === 1 && spot.stamp <= 1293870080) return true
|
||
if (!spot.headerSign) return false
|
||
|
||
const signature = spotUnprepareBase64(spot.headerSign)
|
||
const toCheck = spot.title + spot.header.slice(0, spot.header.length - spot.headerSign.length - 1) + spot.poster
|
||
let verified: boolean
|
||
if (spot.keyId === 7 || (spot.keyId === 2 && spot.selfSignedPubKey.length > 50)) {
|
||
// Self-signed (and key-2 "dispose" messages): the hashcash is the gate, as in Spotweb.
|
||
verified = hashcash(spot.messageId)
|
||
} else {
|
||
verified = keys[spot.keyId] !== undefined && checkRsaSignature(toCheck, signature, keys[spot.keyId])
|
||
}
|
||
if (verified && spot.userSignature && spot.selfSignedPubKey) {
|
||
verified = checkRsaSignature(`<${spot.messageId}>`, spot.userSignature, spot.selfSignedPubKey)
|
||
}
|
||
return verified
|
||
}
|
||
|
||
/** Spotweb's spotter id: base64 of the CRC32 (little-endian) of the poster's public key. */
|
||
export function spotterId(pubKeyB64: string): string {
|
||
if (!pubKeyB64) return ''
|
||
const crc = Buffer.alloc(4)
|
||
crc.writeUInt32LE(crc32(Buffer.from(pubKeyB64, 'base64')) >>> 0)
|
||
return crc.toString('base64').replace(/[/+=]/g, '')
|
||
}
|
||
|
||
/**
|
||
* Moderation: key-2 spots titled "delete|dispose|remove <message-id>".
|
||
* From Spotnet's key they are moderator actions; with the poster's own key
|
||
* (a spotter id) a personal dispose of their own spot.
|
||
*/
|
||
export function moderationTarget(spot: Pick<SpotHeader, 'keyId' | 'title'>): string | null {
|
||
if (spot.keyId !== 2) return null
|
||
const [command, target] = spot.title.split(' ')
|
||
if (!['delete', 'dispose', 'remove'].includes(command?.toLowerCase()) || !target) return null
|
||
return target.replace(/^<|>$/g, '')
|
||
}
|