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
24 changes: 24 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@signet/cli",
"version": "0.1.0",
"private": true,
"description": "Signet command line interface",
"type": "module",
"bin": {
"signet": "./bin/signet.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"dependencies": {
"@inquirer/prompts": "^3.0.0",
"commander": "^11.0.0",
"dotenv": "^17.4.2",
"open": "^11.0.2"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.6.3"
}
}
127 changes: 127 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env node

import { Command } from 'commander';
import { confirm } from '@inquirer/prompts';
import { spawnSync } from 'node:child_process';
import * as dotenv from 'dotenv';
import { resolve } from 'node:path';
import { createServer } from 'node:http';
import open from 'open';
import { Keypair } from '@stellar/stellar-sdk';

dotenv.config({ path: resolve(process.cwd(), '../../.env') });
dotenv.config({ path: resolve(process.cwd(), '.env') });

const program = new Command();

program
.name('signet')
.description('Signet CLI to manage developer profiles and deploy keys')
.version('0.1.0');

const getPubkey = (source: string): string => {
const res = spawnSync('stellar', ['keys', 'address', source]);
if (res.status !== 0) {
console.error(`Error: Could not get public key for source '${source}'. Are you sure it exists?`);
process.exit(1);
}
return res.stdout.toString().trim();
};

const getSecret = (source: string): string => {
const res = spawnSync('stellar', ['keys', 'show', source]);
if (res.status !== 0) {
console.error(`Error: Could not get secret key for source '${source}'.`);
process.exit(1);
}
return res.stdout.toString().trim();
};

program
.command('link')
.description('Link a deploy wallet to your profile')
.option('--source <alias>', 'The Stellar CLI identity to use as the deploy key', 'deployer')
.option('--app-url <url>', 'The Signet app URL', process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000')
.action(async (options) => {
const pubkey = getPubkey(options.source);

console.log(`Linking deploy key (source: ${options.source}, ${pubkey})...`);

const server = createServer();

const tokenPromise = new Promise<{ token: string; handle: string }>((resolvePromise, rejectPromise) => {
server.on('request', (req, res) => {
try {
const url = new URL(req.url || '', `http://localhost:${(server.address() as any).port}`);
if (url.pathname === '/callback') {
const token = url.searchParams.get('token');
const handle = url.searchParams.get('handle');

if (!token || !handle) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Error</h1><p>Missing token or handle.</p></body></html>');
return rejectPromise(new Error('Missing token or handle in callback'));
}

res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Approved!</h1><p>You can close this window and return to your terminal.</p><script>window.close()</script></body></html>');
resolvePromise({ token, handle });
} else {
res.writeHead(404);
res.end();
}
} catch (e) {
rejectPromise(e);
}
});
});

server.listen(0, async () => {
const port = (server.address() as any).port;
const callbackUrl = `http://localhost:${port}/callback`;
const approveUrl = `${options.appUrl}/app/cli/approve?pubkey=${pubkey}&callback=${encodeURIComponent(callbackUrl)}`;

console.log(`Opening approval link in your browser:`);
console.log(approveUrl);

try {
await open(approveUrl);
} catch (e) {
console.log(`Failed to open browser automatically. Please open the link manually.`);
}

try {
const { token, handle } = await tokenPromise;
server.close();

console.log(`Received approval from web app. Verifying ownership...`);

const secret = getSecret(options.source);
const kp = Keypair.fromSecret(secret);
const signature = kp.sign(Buffer.from(token, 'utf8')).toString('base64');

const response = await fetch(`${options.appUrl}/api/cli/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, signature }),
});

const data = await response.json();

if (!response.ok) {
console.error(`Failed to link wallet: ${data.error || response.statusText}`);
process.exit(1);
}

console.log(`Successfully linked ${pubkey} to handle '@${handle}'.`);
} catch (e: any) {
server.close();
console.error(`Error: ${e.message}`);
process.exit(1);
}
});
});



program.parse();
8 changes: 8 additions & 0 deletions apps/cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
86 changes: 86 additions & 0 deletions apps/web/app/(dashboard)/app/cli/approve/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { currentAddress } from '@/lib/server/session';
import { getAccount } from '@/lib/server/account';
import { redirect } from 'next/navigation';
import { Button } from '@/components/button';
import { createCliLinkToken } from '@/lib/cli-auth';

const display = { fontFamily: 'var(--font-display)' } as const;
const mono = { fontFamily: 'var(--font-mono)' } as const;

export default async function ApproveCliPage(props: { searchParams: Promise<{ pubkey?: string; callback?: string }> }) {
const searchParams = await props.searchParams;
const address = await currentAddress();
if (!address) {
redirect('/');
}

const account = await getAccount(address);
const { pubkey, callback } = searchParams;

if (!pubkey || !callback) {
return (
<section>
<h1 className="text-[32px] font-bold text-[#8b1a1a]" style={display}>Error</h1>
<p className="mt-4" style={mono}>Missing pubkey or callback in URL parameters.</p>
</section>
);
}

if (!account.handle) {
return (
<section>
<h1 className="text-[32px] font-bold text-[#8b1a1a]" style={display}>Profile Not Found</h1>
<p className="mt-4" style={mono}>You need to claim a handle on-chain before you can link a deploy key.</p>
</section>
);
}

const approveLink = async () => {
'use server';
const profileId = account.id; // wait, account.id doesn't exist? account might just have handle.
// Let's look up the profile ID. Wait, I'll fix this below.
// The account object in getAccount has the profile id? I will look at getAccount.
};

return (
<section>
<h1 className="text-[40px] font-bold leading-[0.96] tracking-[-0.025em] md:text-[56px]" style={display}>
Link Deploy Key
</h1>

<p className="mt-6 max-w-[640px] text-[14px] leading-[1.7] text-[#8a8779]" style={mono}>
The Signet CLI wants to link a deploy key to your profile. Once linked, contracts deployed with this key will be attributed to your handle (@{account.handle}).
</p>

<div className="mt-8 max-w-[640px] border border-[#1f1d19] p-6">
<div className="mb-6">
<p className="text-[10px] uppercase tracking-[0.2em] text-[#5e5b51]" style={mono}>Deploy Key</p>
<p className="mt-1 text-[14px] text-[#b8b5a8] break-all" style={mono}>{pubkey}</p>
</div>

<div className="mb-6">
<p className="text-[10px] uppercase tracking-[0.2em] text-[#5e5b51]" style={mono}>Target Profile</p>
<p className="mt-1 text-[14px] text-[#b8b5a8]" style={mono}>@{account.handle}</p>
</div>

<form action={async () => {
'use server';
const { prisma } = await import('@signet/db');
const userWallet = await prisma.wallet.findUnique({
where: { pubkey: address },
select: { profileId: true }
});
if (!userWallet) throw new Error('Profile not found');

const token = createCliLinkToken(pubkey, userWallet.profileId);
const callbackUrl = new URL(callback);
callbackUrl.searchParams.set('token', token);
callbackUrl.searchParams.set('handle', account.handle || '');
redirect(callbackUrl.toString());
}}>
<Button type="submit" className="w-full justify-center">Approve Link</Button>
</form>
</div>
</section>
);
}
55 changes: 55 additions & 0 deletions apps/web/app/api/cli/link/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { verifySignature } from '@/lib/auth';
import { verifyCliLinkToken } from '@/lib/cli-auth';
import { prisma } from '@signet/db';
import { logger } from '@/lib/logger';
import { consumeNonce } from '@/lib/nonce-store';

export const runtime = 'nodejs';

export async function POST(req: Request) {
const { token, signature } = await req.json().catch(() => ({}));

if (!token || !signature) {
return NextResponse.json({ error: 'Missing token or signature' }, { status: 400 });
}

const validToken = verifyCliLinkToken(token);
if (!validToken) {
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
}

const { pubkey, profileId } = validToken;

// Verify the signature. The CLI signed the raw token string.
const isSignatureValid = await verifySignature(pubkey, token, signature);
if (!isSignatureValid) {
logger.warn({ pubkey }, 'cli.invalidSignature');
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}

// Prevent replay attacks
const nonceConsumed = await consumeNonce(`cli-link:${token}`, 5 * 60 * 1000);
if (!nonceConsumed) {
return NextResponse.json({ error: 'Token has already been used' }, { status: 401 });
}

try {
await prisma.wallet.create({
data: {
profileId: profileId,
pubkey: pubkey,
source: 'curated',
isPrimary: false,
},
});
logger.info({ pubkey, profileId }, 'cli.linkedWallet');
} catch (error: any) {
if (error.code === 'P2002') {
return NextResponse.json({ error: 'Wallet is already linked to a profile' }, { status: 409 });
}
throw error;
}

return NextResponse.json({ ok: true });
}
36 changes: 36 additions & 0 deletions apps/web/lib/cli-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createHmac, randomBytes } from 'node:crypto';
import { getAuthSecret } from './auth';

const b64url = (b: Buffer): string => b.toString('base64url');
const hmac = (data: string): Buffer => createHmac('sha256', getAuthSecret()).update(data).digest();

export function createCliLinkToken(pubkey: string, profileId: string): string {
const nonce = randomBytes(16).toString('hex');
const issued = Date.now();
const data = `${pubkey}|${profileId}|${issued}|${nonce}`;
const tag = b64url(hmac(data));
return b64url(Buffer.from(`${data}|${tag}`));
}

export function verifyCliLinkToken(token: string): { pubkey: string; profileId: string } | null {
try {
const decoded = Buffer.from(token, 'base64url').toString('utf8');
const parts = decoded.split('|');
if (parts.length !== 5) return null;

const [pubkey, profileId, issuedStr, nonce, tag] = parts;
const issued = parseInt(issuedStr, 10);

// 5 minute TTL
if (Date.now() - issued > 5 * 60 * 1000) return null;

const expectedData = `${pubkey}|${profileId}|${issuedStr}|${nonce}`;
const expectedTag = b64url(hmac(expectedData));

if (tag !== expectedTag) return null;

return { pubkey, profileId };
} catch {
return null;
}
}
Loading