Files
Daan MeijerandClaude Opus 5.5 7df5a638ca
Test and deploy / test (push) Successful in 50s
Test and deploy / edge (push) Successful in 2m1s
Test and deploy / deploy (push) Successful in 1m28s
Spots: the spotter and the spot inspector, passwords kept apart
Two processes, one NNTP connection each (the coordinator 40 -> 38):
- spots:read (service spotter): free.pt's overviews after the last article
  seen, each spot's full header verified (lib/spotnet from voyager 1, as
  Spotweb), its description, website and NZB articles; the password its
  title or text names; delete/dispose messages applied.
- spots:inspect (service spot-inspector): each spot's compressed NZB read,
  its files listed (spot_files), each matched to the header pass's release
  by the same release identity in one of its groups within 48 h
  (release_spots); unmatched spots matched again hourly, after 24 h a
  release of their own (source spot) with the spot's NZB.
- release_passwords: per release and password, its source (nzb, spot-title,
  spot-text) and spot; the catalog keeps only passworded.
XoverClient.head(); PROBE_CONNECTIONS default 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:10:10 +02:00

163 lines
6.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '')
}