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
2 changes: 1 addition & 1 deletion maybelle/ansible/maybelle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1007,7 +1007,7 @@
- PORT=3001
- PINATA_API_KEY={{ pinata_api_key }}
- PINATA_SECRET_KEY={{ pinata_secret_key }}
- PINNING_API_KEY={{ pinning_service_api_key }}
- AUTHORIZED_WALLETS={{ pinning_authorized_wallets }}
- IPFS_API_URL=http://ipfs:5001
- STAGING_DIR=/staging
volumes:
Expand Down
2 changes: 2 additions & 0 deletions maybelle/pinning-service/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
package-lock.json
2 changes: 1 addition & 1 deletion maybelle/pinning-service/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ COPY package.json ./
RUN npm install --production

# Copy application
COPY server.js ./
COPY server.js auth.js ./

# Create staging directory
RUN mkdir -p /staging
Expand Down
34 changes: 28 additions & 6 deletions maybelle/pinning-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,27 @@

Downloads videos from Instagram/YouTube via yt-dlp and pins them to IPFS (both Pinata cloud and local node).

## Authentication

Uses wallet-based authentication. Callers must:
1. Sign a message: `Authorize Blue Railroad pinning\nTimestamp: {unix_timestamp_ms}`
2. Include headers: `X-Signature` and `X-Timestamp`
3. Signature must be from an authorized wallet (configured via `AUTHORIZED_WALLETS` env var)
4. Timestamp must be within 5 minutes of server time

This ensures only wallet owners can authorize pinning operations - no shared secrets required.

## API Endpoints

### POST /pin-from-url
Download video from URL and pin to IPFS.

```bash
# First sign the message with your wallet, then:
curl -X POST https://pinning.maybelle.cryptograss.live/pin-from-url \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-Signature: 0x..." \
-H "X-Timestamp: 1704067200000" \
-d '{"url": "https://www.instagram.com/p/ABC123/"}'
```

Expand All @@ -31,7 +43,8 @@ Upload and pin a file directly.

```bash
curl -X POST https://pinning.maybelle.cryptograss.live/pin-file \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-Signature: 0x..." \
-H "X-Timestamp: 1704067200000" \
-F "file=@video.mp4"
```

Expand All @@ -41,7 +54,8 @@ Pin an existing CID to local IPFS node.
```bash
curl -X POST https://pinning.maybelle.cryptograss.live/pin-cid \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-Signature: 0x..." \
-H "X-Timestamp: 1704067200000" \
-d '{"cid": "QmXyz..."}'
```

Expand All @@ -58,9 +72,9 @@ Add these to `secrets/vault.yml`:
pinata_api_key: "your-pinata-api-key"
pinata_secret_key: "your-pinata-secret-api-key"

# API key for authenticating requests to the pinning service
# Generate with: openssl rand -hex 32
pinning_service_api_key: "your-random-api-key"
# Comma-separated list of wallet addresses authorized to use the pinning service
# Typically the Blue Railroad contract owner
pinning_authorized_wallets: "0x4f84b3650dbf651732a41647618e7ff94a633f09"
```

## Storage
Expand All @@ -73,3 +87,11 @@ pinning_service_api_key: "your-random-api-key"
- 3001: Pinning service API (exposed via Caddy at pinning.maybelle.cryptograss.live)
- 5001: IPFS API (localhost only)
- 4001: IPFS swarm (public, for peering with other nodes)

## Testing

```bash
npm test
```

Runs the auth module tests (signature verification, timestamp validation, wallet authorization).
114 changes: 114 additions & 0 deletions maybelle/pinning-service/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { verifyMessage } from 'viem';

// Message format: "Authorize Blue Railroad pinning\nTimestamp: {timestamp}"
// Timestamp must be within 5 minutes of server time to prevent replay attacks
const MAX_TIMESTAMP_DRIFT_MS = 5 * 60 * 1000; // 5 minutes

// Authorized wallets - can be contract owner or allowlisted addresses
// Loaded from environment variable as comma-separated list
function getAuthorizedWallets() {
const walletsEnv = process.env.AUTHORIZED_WALLETS || '';
return walletsEnv
.split(',')
.map(w => w.trim().toLowerCase())
.filter(w => w.length > 0);
}

/**
* Create the message that must be signed for authorization
* @param {number} timestamp - Unix timestamp in milliseconds
* @returns {string} The message to sign
*/
export function createAuthMessage(timestamp) {
return `Authorize Blue Railroad pinning\nTimestamp: ${timestamp}`;
}

/**
* Verify a signed authorization message
* @param {string} signature - The signature (0x...)
* @param {number} timestamp - The timestamp that was signed
* @param {number} [now] - Current time for testing (defaults to Date.now())
* @returns {Promise<{valid: boolean, address?: string, error?: string}>}
*/
export async function verifyAuth(signature, timestamp, now = Date.now()) {
// Check timestamp is within acceptable range
const drift = Math.abs(now - timestamp);
if (drift > MAX_TIMESTAMP_DRIFT_MS) {
return {
valid: false,
error: `Timestamp too old or too far in future (drift: ${Math.round(drift / 1000)}s, max: ${MAX_TIMESTAMP_DRIFT_MS / 1000}s)`
};
}

// Recover signer address from signature
const message = createAuthMessage(timestamp);
let address;
try {
// verifyMessage returns boolean, we need recoverMessageAddress for the address
const { recoverMessageAddress } = await import('viem');
address = await recoverMessageAddress({
message,
signature,
});
} catch (error) {
return {
valid: false,
error: `Invalid signature: ${error.message}`
};
}

// Check if address is authorized
const authorizedWallets = getAuthorizedWallets();

// If no wallets configured, reject all (fail secure)
if (authorizedWallets.length === 0) {
return {
valid: false,
address,
error: 'No authorized wallets configured'
};
}

const isAuthorized = authorizedWallets.includes(address.toLowerCase());
if (!isAuthorized) {
return {
valid: false,
address,
error: `Wallet ${address} is not authorized`
};
}

return {
valid: true,
address
};
}

/**
* Express middleware to require wallet signature auth
*/
export function requireWalletAuth(req, res, next) {
const signature = req.headers['x-signature'];
const timestamp = parseInt(req.headers['x-timestamp'], 10);

if (!signature || !timestamp) {
return res.status(401).json({
error: 'Missing authentication headers',
required: ['X-Signature', 'X-Timestamp']
});
}

verifyAuth(signature, timestamp)
.then(result => {
if (!result.valid) {
return res.status(401).json({ error: result.error });
}
// Attach verified address to request for logging
req.verifiedAddress = result.address;
next();
})
.catch(error => {
console.error('Auth error:', error);
res.status(500).json({ error: 'Authentication failed' });
});
}
139 changes: 139 additions & 0 deletions maybelle/pinning-service/auth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { createAuthMessage, verifyAuth } from './auth.js';
import { privateKeyToAccount } from 'viem/accounts';

// Test wallet - DO NOT use in production
const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
const testAccount = privateKeyToAccount(TEST_PRIVATE_KEY);
const TEST_ADDRESS = testAccount.address; // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

describe('createAuthMessage', () => {
it('creates message with correct format', () => {
const timestamp = 1704067200000; // 2024-01-01 00:00:00 UTC
const message = createAuthMessage(timestamp);
expect(message).toBe('Authorize Blue Railroad pinning\nTimestamp: 1704067200000');
});
});

describe('verifyAuth', () => {
const originalEnv = process.env.AUTHORIZED_WALLETS;

beforeEach(() => {
// Set test wallet as authorized
process.env.AUTHORIZED_WALLETS = TEST_ADDRESS;
});

afterEach(() => {
process.env.AUTHORIZED_WALLETS = originalEnv;
});

it('accepts valid signature from authorized wallet', async () => {
const now = Date.now();
const message = createAuthMessage(now);
const signature = await testAccount.signMessage({ message });

const result = await verifyAuth(signature, now, now);

expect(result.valid).toBe(true);
expect(result.address.toLowerCase()).toBe(TEST_ADDRESS.toLowerCase());
});

it('rejects signature with expired timestamp', async () => {
const now = Date.now();
const oldTimestamp = now - (6 * 60 * 1000); // 6 minutes ago
const message = createAuthMessage(oldTimestamp);
const signature = await testAccount.signMessage({ message });

const result = await verifyAuth(signature, oldTimestamp, now);

expect(result.valid).toBe(false);
expect(result.error).toContain('Timestamp too old');
});

it('rejects signature with future timestamp', async () => {
const now = Date.now();
const futureTimestamp = now + (6 * 60 * 1000); // 6 minutes from now
const message = createAuthMessage(futureTimestamp);
const signature = await testAccount.signMessage({ message });

const result = await verifyAuth(signature, futureTimestamp, now);

expect(result.valid).toBe(false);
expect(result.error).toContain('too far in future');
});

it('accepts signature within 5 minute window', async () => {
const now = Date.now();
const timestamp = now - (4 * 60 * 1000); // 4 minutes ago (within limit)
const message = createAuthMessage(timestamp);
const signature = await testAccount.signMessage({ message });

const result = await verifyAuth(signature, timestamp, now);

expect(result.valid).toBe(true);
});

it('rejects signature from unauthorized wallet', async () => {
// Use a different private key
const unauthorizedKey = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d';
const unauthorizedAccount = privateKeyToAccount(unauthorizedKey);

const now = Date.now();
const message = createAuthMessage(now);
const signature = await unauthorizedAccount.signMessage({ message });

const result = await verifyAuth(signature, now, now);

expect(result.valid).toBe(false);
expect(result.error).toContain('not authorized');
expect(result.address).toBeDefined();
});

it('rejects when no authorized wallets configured', async () => {
process.env.AUTHORIZED_WALLETS = '';

const now = Date.now();
const message = createAuthMessage(now);
const signature = await testAccount.signMessage({ message });

const result = await verifyAuth(signature, now, now);

expect(result.valid).toBe(false);
expect(result.error).toContain('No authorized wallets configured');
});

it('accepts multiple authorized wallets', async () => {
const otherAddress = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8';
process.env.AUTHORIZED_WALLETS = `${TEST_ADDRESS},${otherAddress}`;

const now = Date.now();
const message = createAuthMessage(now);
const signature = await testAccount.signMessage({ message });

const result = await verifyAuth(signature, now, now);

expect(result.valid).toBe(true);
});

it('handles malformed signature gracefully', async () => {
const now = Date.now();
const badSignature = '0xdeadbeef';

const result = await verifyAuth(badSignature, now, now);

expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid signature');
});

it('is case-insensitive for wallet addresses', async () => {
// Set authorized wallet in lowercase
process.env.AUTHORIZED_WALLETS = TEST_ADDRESS.toLowerCase();

const now = Date.now();
const message = createAuthMessage(now);
const signature = await testAccount.signMessage({ message });

const result = await verifyAuth(signature, now, now);

expect(result.valid).toBe(true);
});
});
10 changes: 8 additions & 2 deletions maybelle/pinning-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js"
"start": "node server.js",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"node-fetch": "^3.3.2",
"form-data": "^4.0.0"
"form-data": "^4.0.0",
"viem": "^2.0.0"
},
"devDependencies": {
"jest": "^29.7.0"
}
}
Loading