/**
 * SecureInvitation — Ed25519-bound invitation tokens.
 *
 * Spec Section 3.11: Challenge-bound invitation tokens that are:
 * - Cryptographically signed by the issuer's Ed25519 key
 * - Single-use (invalidated after redemption)
 * - Optionally bound to a specific recipient fingerprint
 * - Nonce-protected against replay
 * - Short-lived (default 1h, max 24h)
 */
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { sign, verify } from './ThreadlineCrypto.js';
// ── Constants ────────────────────────────────────────────────────────
const MAX_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
// ── Manager ──────────────────────────────────────────────────────────
export class SecureInvitationManager {
    redemptions = new Map();
    revoked = new Set();
    stateFile;
    constructor(stateDir) {
        this.stateFile = path.join(stateDir, 'threadline', 'secure-invitations.json');
        this.loadState();
    }
    /**
     * Create an Ed25519-signed invitation token.
     *
     * @param issuerFingerprint Fingerprint of the issuing agent
     * @param issuerPrivateKey Ed25519 private key (32 bytes)
     * @param options Optional: recipient binding, custom expiry, max uses
     */
    create(issuerFingerprint, issuerPrivateKey, options) {
        const expiryMs = Math.min(options?.expiryMs ?? DEFAULT_EXPIRY_MS, MAX_EXPIRY_MS);
        const token = {
            version: 1,
            type: 'invitation',
            issuer: issuerFingerprint,
            tokenId: crypto.randomBytes(32).toString('base64url'),
            nonce: crypto.randomBytes(32).toString('hex'),
            scope: 'verified',
            expiry: new Date(Date.now() + expiryMs).toISOString(),
            maxUses: options?.maxUses ?? 1,
            ...(options?.recipient && { recipient: options.recipient }),
            ...(options?.submitHost && { submitHost: options.submitHost }),
            ...(options?.submitCertFingerprint && { submitCertFingerprint: options.submitCertFingerprint }),
            signature: '', // placeholder, computed below
        };
        token.signature = this.signToken(token, issuerPrivateKey);
        return token;
    }
    /**
     * Validate and optionally redeem an invitation token.
     *
     * Checks:
     * 1. Signature is valid (issuer's public key)
     * 2. Token is not expired
     * 3. Token is not revoked
     * 4. Token is not already redeemed (single-use)
     * 5. If recipient-bound: redeemer fingerprint matches
     * 6. Redeemer proves Ed25519 key possession (separate step)
     *
     * @param token The invitation token to validate
     * @param issuerPublicKey Ed25519 public key of the issuer (32 bytes)
     * @param redeemerFingerprint Fingerprint of the agent trying to redeem
     * @param redeem If true, mark as redeemed on successful validation
     */
    validate(token, issuerPublicKey, redeemerFingerprint, redeem = false) {
        // 1. Verify signature
        if (!this.verifyTokenSignature(token, issuerPublicKey)) {
            return { valid: false, reason: 'Invalid signature' };
        }
        // 2. Check expiry
        if (new Date(token.expiry) <= new Date()) {
            return { valid: false, reason: 'Token expired' };
        }
        // 3. Check revocation
        if (this.revoked.has(token.tokenId)) {
            return { valid: false, reason: 'Token revoked' };
        }
        // 4. Check redemption
        if (this.redemptions.has(token.tokenId)) {
            return { valid: false, reason: 'Token already redeemed' };
        }
        // 5. Check recipient binding
        if (token.recipient && token.recipient !== redeemerFingerprint) {
            return { valid: false, reason: 'Token bound to different recipient' };
        }
        // Redeem if requested
        if (redeem) {
            this.redemptions.set(token.tokenId, {
                tokenId: token.tokenId,
                redeemedBy: redeemerFingerprint,
                redeemedAt: new Date().toISOString(),
            });
            this.saveState();
        }
        return { valid: true, reason: 'Token valid' };
    }
    /**
     * Revoke an unredeemed invitation token.
     */
    revoke(tokenId) {
        if (this.redemptions.has(tokenId))
            return false; // already redeemed
        this.revoked.add(tokenId);
        this.saveState();
        return true;
    }
    /**
     * Check if a token has been redeemed.
     */
    isRedeemed(tokenId) {
        return this.redemptions.has(tokenId);
    }
    // ── Signing ─────────────────────────────────────────────────────
    signToken(token, privateKey) {
        const message = this.buildSignatureMessage(token);
        return sign(privateKey, message).toString('base64');
    }
    verifyTokenSignature(token, publicKey) {
        const message = this.buildSignatureMessage(token);
        try {
            return verify(publicKey, message, Buffer.from(token.signature, 'base64'));
        }
        catch {
            return false;
        }
    }
    buildSignatureMessage(token) {
        // Sign over all fields except signature itself.
        let payload = `${token.version}|${token.type}|${token.issuer}|${token.tokenId}|${token.nonce}|${token.scope}|${token.expiry}|${token.maxUses}|${token.recipient ?? ''}`;
        // R1b — sealed-handoff endpoint pinning. Appended ONLY when present, so existing
        // invitations (no submit fields) produce a byte-identical payload and stay valid.
        // Because the signature binds whichever form was signed, stripping or injecting a
        // submit field changes the verifier's payload and fails verification.
        if (token.submitHost !== undefined || token.submitCertFingerprint !== undefined) {
            payload += `|${token.submitHost ?? ''}|${token.submitCertFingerprint ?? ''}`;
        }
        return crypto.createHash('sha256').update(payload).digest();
    }
    // ── Persistence ─────────────────────────────────────────────────
    saveState() {
        const dir = path.dirname(this.stateFile);
        fs.mkdirSync(dir, { recursive: true });
        const data = JSON.stringify({
            redemptions: Object.fromEntries(this.redemptions),
            revoked: [...this.revoked],
        }, null, 2);
        const tmpPath = `${this.stateFile}.${process.pid}.tmp`;
        fs.writeFileSync(tmpPath, data, { mode: 0o600 });
        fs.renameSync(tmpPath, this.stateFile);
    }
    loadState() {
        try {
            if (!fs.existsSync(this.stateFile))
                return;
            const raw = JSON.parse(fs.readFileSync(this.stateFile, 'utf-8'));
            if (raw.redemptions) {
                for (const [k, v] of Object.entries(raw.redemptions)) {
                    this.redemptions.set(k, v);
                }
            }
            if (Array.isArray(raw.revoked)) {
                for (const id of raw.revoked)
                    this.revoked.add(id);
            }
        }
        catch { /* start fresh */ }
    }
}
//# sourceMappingURL=SecureInvitation.js.map