Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions scripts/migrations/001_add_recovery_token.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Migration: Add recovery token support for API key rotation
-- Resolves: #52, #53, #54, #56 (key recovery after Supabase leak)

ALTER TABLE agents ADD COLUMN IF NOT EXISTS recovery_token VARCHAR(80);
ALTER TABLE agents ADD COLUMN IF NOT EXISTS recovery_token_expires_at TIMESTAMP WITH TIME ZONE;

CREATE INDEX IF NOT EXISTS idx_agents_recovery_token ON agents(recovery_token)
WHERE recovery_token IS NOT NULL;

-- Periodic cleanup of expired recovery tokens (optional, for cron)
-- DELETE FROM agents WHERE recovery_token IS NOT NULL AND recovery_token_expires_at < NOW();
2 changes: 2 additions & 0 deletions scripts/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ CREATE TABLE agents (
-- Authentication
api_key_hash VARCHAR(64) NOT NULL,
claim_token VARCHAR(80),
recovery_token VARCHAR(80),
recovery_token_expires_at TIMESTAMP WITH TIME ZONE,
verification_code VARCHAR(16),

-- Status
Expand Down
6 changes: 4 additions & 2 deletions src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,16 @@ const config = {
rateLimits: {
requests: { max: 100, window: 60 },
posts: { max: 1, window: 1800 },
comments: { max: 50, window: 3600 }
comments: { max: 50, window: 3600 },
recovery: { max: 3, window: 3600 }
},

// Moltbook specific
moltbook: {
tokenPrefix: 'moltbook_',
claimPrefix: 'moltbook_claim_',
baseUrl: process.env.BASE_URL || 'https://www.moltbook.com'
baseUrl: process.env.BASE_URL || 'https://www.moltbook.com',
recoveryTokenTTL: 3600 // 1 hour in seconds
},

// Pagination defaults
Expand Down
43 changes: 42 additions & 1 deletion src/routes/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
const { Router } = require('express');
const { asyncHandler } = require('../middleware/errorHandler');
const { requireAuth } = require('../middleware/auth');
const { rateLimit } = require('../middleware/rateLimit');
const { success, created } = require('../utils/response');
const AgentService = require('../services/AgentService');
const { NotFoundError } = require('../utils/errors');
const { NotFoundError, ForbiddenError } = require('../utils/errors');

const router = Router();

Expand Down Expand Up @@ -122,4 +123,44 @@ router.delete('/:name/follow', requireAuth, asyncHandler(async (req, res) => {
success(res, result);
}));

/**
* POST /agents/recover
* Request API key recovery for a claimed agent.
* Returns a time-limited recovery URL that the human owner must visit.
* Rate limited to 3 requests per hour per IP.
*/
const recoveryLimiter = rateLimit('recovery', {
message: 'Too many recovery requests. Try again later.',
keyGenerator: (req) => `rl:recovery:${req.ip || 'anonymous'}`
});

router.post('/recover', recoveryLimiter, asyncHandler(async (req, res) => {
const { name } = req.body;
const result = await AgentService.requestRecovery(name);
success(res, result);
}));

/**
* POST /agents/verify-recovery
* Verify recovery token and issue a new API key.
*
* INTERNAL ONLY — called by the web server's X OAuth callback.
* Must NOT be exposed to public clients. The twitterId must come
* from a verified OAuth response, not from user-supplied input.
*/
router.post('/verify-recovery', asyncHandler(async (req, res) => {
// Only allow internal calls (from the web server's OAuth callback)
const internalSecret = req.headers['x-internal-secret'];
if (!internalSecret || internalSecret !== process.env.INTERNAL_API_SECRET) {
throw new ForbiddenError(
'This endpoint is internal only',
'Complete recovery by visiting the recovery URL and signing in with X'
);
}

const { token, twitterId } = req.body;
const result = await AgentService.verifyRecovery(token, twitterId);
created(res, result);
}));

module.exports = router;
132 changes: 131 additions & 1 deletion src/services/AgentService.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

const { queryOne, queryAll, transaction } = require('../config/database');
const { generateApiKey, generateClaimToken, generateVerificationCode, hashToken } = require('../utils/auth');
const { generateApiKey, generateClaimToken, generateRecoveryToken, generateVerificationCode, hashToken } = require('../utils/auth');
const { BadRequestError, NotFoundError, ConflictError } = require('../utils/errors');
const config = require('../config');

Expand Down Expand Up @@ -325,6 +325,136 @@ class AgentService {
[agentId, limit]
);
}

/**
* Request API key recovery for a claimed agent.
*
* Generates a time-limited recovery token and returns a URL that the
* human owner must visit to verify ownership via X OAuth.
*
* Security notes:
* - Only works for claimed agents (unclaimed agents should use the claim flow)
* - Recovery tokens expire after config.moltbook.recoveryTokenTTL seconds
* - Each new request invalidates any previous recovery token
* - Rate limited to prevent abuse (3 requests/hour per IP)
*
* @param {string} name - Agent name
* @returns {Promise<Object>} Recovery URL and instructions
*/
static async requestRecovery(name) {
if (!name || typeof name !== 'string') {
throw new BadRequestError('Agent name is required');
}

const normalizedName = name.toLowerCase().trim();
const agent = await this.findByName(normalizedName);

if (!agent) {
// Return a generic message to avoid leaking which names exist.
// Use the same shape so callers can't distinguish found vs not-found.
return {
recovery_url: null,
message: 'If an agent with this name exists and is claimed, a recovery link has been generated. Have the human owner visit the Moltbook recovery page.'
};
}

if (!agent.is_claimed) {
throw new BadRequestError(
'Agent has not been claimed yet',
'Use the original claim URL to complete registration first'
);
}

const recoveryToken = generateRecoveryToken();
const ttlSeconds = config.moltbook.recoveryTokenTTL;

await queryOne(
`UPDATE agents
SET recovery_token = $1,
recovery_token_expires_at = NOW() + INTERVAL '1 second' * $2,
updated_at = NOW()
WHERE id = $3`,
[recoveryToken, ttlSeconds, agent.id]
);

return {
recovery_url: `${config.moltbook.baseUrl}/recover/${recoveryToken}`,
expires_in: ttlSeconds,
message: 'Human owner must visit this URL and sign in with X to verify ownership and receive a new API key.'
};
}

/**
* Verify a recovery token and issue a new API key.
*
* This should ONLY be called from the internal OAuth callback handler
* after the human has authenticated via X. The twitterId parameter
* must come from the verified OAuth response, never from user input.
*
* @param {string} recoveryToken - The recovery token from the URL
* @param {string} twitterId - The verified Twitter/X user ID from OAuth
* @returns {Promise<Object>} New API key
*/
static async verifyRecovery(recoveryToken, twitterId) {
if (!recoveryToken || typeof recoveryToken !== 'string') {
throw new BadRequestError('Recovery token is required');
}

if (!twitterId || typeof twitterId !== 'string') {
throw new BadRequestError('Twitter verification is required');
}

const agent = await queryOne(
`SELECT id, owner_twitter_id, recovery_token_expires_at
FROM agents
WHERE recovery_token = $1`,
[recoveryToken]
);

if (!agent) {
throw new NotFoundError('Recovery token');
}

// Check expiry
if (agent.recovery_token_expires_at && new Date(agent.recovery_token_expires_at) < new Date()) {
// Clear the expired token
await queryOne(
`UPDATE agents SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE id = $1`,
[agent.id]
);
throw new BadRequestError(
'Recovery token has expired',
'Request a new recovery link and try again'
);
}

// Verify the X account matches the original owner
if (agent.owner_twitter_id !== twitterId) {
throw new BadRequestError(
'Twitter account does not match the agent owner',
'Sign in with the same X account that originally claimed this agent'
);
}

// Generate new API key and clear recovery token atomically
const newApiKey = generateApiKey();
const apiKeyHash = hashToken(newApiKey);

await queryOne(
`UPDATE agents
SET api_key_hash = $1,
recovery_token = NULL,
recovery_token_expires_at = NULL,
updated_at = NOW()
WHERE id = $2`,
[apiKeyHash, agent.id]
);

return {
api_key: newApiKey,
message: '⚠️ API key successfully rotated. Save this key immediately — you will not see it again!'
};
}
}

module.exports = AgentService;
10 changes: 10 additions & 0 deletions src/utils/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ function generateVerificationCode() {
return `${adjective}-${suffix}`;
}

/**
* Generate a recovery token
*
* @returns {string} Recovery token with moltbook_recover_ prefix
*/
function generateRecoveryToken() {
return `moltbook_recover_${randomHex(TOKEN_LENGTH)}`;
}

/**
* Validate API key format
*
Expand Down Expand Up @@ -113,6 +122,7 @@ function compareTokens(a, b) {
module.exports = {
generateApiKey,
generateClaimToken,
generateRecoveryToken,
generateVerificationCode,
validateApiKey,
extractToken,
Expand Down
Loading