soulprint-network 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Email OTP Credential Validator
3
+ *
4
+ * Flujo:
5
+ * 1. POST /credentials/email/start { did, email }
6
+ * → genera OTP de 6 dígitos, válido 10 min
7
+ * → envía email via SMTP (nodemailer)
8
+ * → retorna { sessionId }
9
+ *
10
+ * 2. POST /credentials/email/verify { sessionId, otp }
11
+ * → si el OTP coincide → emite attestation "EmailVerified" sobre el DID
12
+ * → retorna { credential: "EmailVerified", did }
13
+ *
14
+ * Configuración (env vars):
15
+ * SMTP_HOST — servidor SMTP (ej: smtp.gmail.com)
16
+ * SMTP_PORT — puerto (default 587)
17
+ * SMTP_USER — usuario SMTP
18
+ * SMTP_PASS — contraseña SMTP
19
+ * SMTP_FROM — remitente (default "noreply@soulprint.digital")
20
+ *
21
+ * En desarrollo sin SMTP configurado: usa Ethereal (catch-all fake SMTP)
22
+ * para testing — los emails no se envían pero son capturables.
23
+ */
24
+ export declare function startEmailVerification(did: string, email: string): Promise<{
25
+ sessionId: string;
26
+ preview?: string;
27
+ }>;
28
+ export declare function verifyEmailOTP(sessionId: string, otp: string): {
29
+ ok: boolean;
30
+ did?: string;
31
+ email?: string;
32
+ reason?: string;
33
+ };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Email OTP Credential Validator
3
+ *
4
+ * Flujo:
5
+ * 1. POST /credentials/email/start { did, email }
6
+ * → genera OTP de 6 dígitos, válido 10 min
7
+ * → envía email via SMTP (nodemailer)
8
+ * → retorna { sessionId }
9
+ *
10
+ * 2. POST /credentials/email/verify { sessionId, otp }
11
+ * → si el OTP coincide → emite attestation "EmailVerified" sobre el DID
12
+ * → retorna { credential: "EmailVerified", did }
13
+ *
14
+ * Configuración (env vars):
15
+ * SMTP_HOST — servidor SMTP (ej: smtp.gmail.com)
16
+ * SMTP_PORT — puerto (default 587)
17
+ * SMTP_USER — usuario SMTP
18
+ * SMTP_PASS — contraseña SMTP
19
+ * SMTP_FROM — remitente (default "noreply@soulprint.digital")
20
+ *
21
+ * En desarrollo sin SMTP configurado: usa Ethereal (catch-all fake SMTP)
22
+ * para testing — los emails no se envían pero son capturables.
23
+ */
24
+ import nodemailer from "nodemailer";
25
+ import { randomBytes, randomInt } from "node:crypto";
26
+ // TTL de la sesión OTP (10 minutos)
27
+ const OTP_TTL_MS = 10 * 60 * 1000;
28
+ // Sessions en memoria (el nodo limpia las expiradas cada 5 min)
29
+ const emailSessions = new Map();
30
+ setInterval(() => {
31
+ const now = Date.now();
32
+ for (const [id, s] of emailSessions) {
33
+ if (s.expiresAt < now)
34
+ emailSessions.delete(id);
35
+ }
36
+ }, 5 * 60_000).unref();
37
+ // Transportador SMTP (lazy init)
38
+ let transport = null;
39
+ async function getTransport() {
40
+ if (transport)
41
+ return transport;
42
+ if (process.env.SMTP_HOST) {
43
+ transport = nodemailer.createTransport({
44
+ host: process.env.SMTP_HOST,
45
+ port: parseInt(process.env.SMTP_PORT ?? "587"),
46
+ secure: process.env.SMTP_PORT === "465",
47
+ auth: {
48
+ user: process.env.SMTP_USER,
49
+ pass: process.env.SMTP_PASS,
50
+ },
51
+ });
52
+ }
53
+ else {
54
+ // Dev: usa Ethereal (emails no se envían, se loguean)
55
+ const testAccount = await nodemailer.createTestAccount();
56
+ transport = nodemailer.createTransport({
57
+ host: "smtp.ethereal.email",
58
+ port: 587,
59
+ secure: false,
60
+ auth: { user: testAccount.user, pass: testAccount.pass },
61
+ });
62
+ console.log("[email-cred] Dev mode — usando Ethereal SMTP");
63
+ }
64
+ return transport;
65
+ }
66
+ export async function startEmailVerification(did, email) {
67
+ // Validar email básico
68
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
69
+ throw new Error("Invalid email address");
70
+ }
71
+ // Generar OTP de 6 dígitos con crypto.randomInt (CSPRNG)
72
+ const otp = randomInt(100000, 999999).toString();
73
+ const sessionId = randomBytes(16).toString("hex");
74
+ emailSessions.set(sessionId, {
75
+ did, email, otp,
76
+ expiresAt: Date.now() + OTP_TTL_MS,
77
+ attempts: 0,
78
+ });
79
+ const transporter = await getTransport();
80
+ const info = await transporter.sendMail({
81
+ from: process.env.SMTP_FROM ?? "noreply@soulprint.digital",
82
+ to: email,
83
+ subject: "Soulprint — Your verification code",
84
+ text: `Your Soulprint verification code is: ${otp}\n\nExpires in 10 minutes. Do not share this code.`,
85
+ html: `<p>Your Soulprint verification code is: <strong style="font-size:1.5em">${otp}</strong></p><p>Expires in 10 minutes. Do not share this code.</p>`,
86
+ });
87
+ const preview = nodemailer.getTestMessageUrl(info) || undefined;
88
+ if (preview)
89
+ console.log(`[email-cred] Preview: ${preview}`);
90
+ return { sessionId, preview };
91
+ }
92
+ export function verifyEmailOTP(sessionId, otp) {
93
+ const session = emailSessions.get(sessionId);
94
+ if (!session)
95
+ return { ok: false, reason: "Session not found or expired" };
96
+ if (Date.now() > session.expiresAt) {
97
+ emailSessions.delete(sessionId);
98
+ return { ok: false, reason: "OTP expired" };
99
+ }
100
+ session.attempts++;
101
+ if (session.attempts > 5) {
102
+ emailSessions.delete(sessionId);
103
+ return { ok: false, reason: "Too many attempts" };
104
+ }
105
+ if (otp.trim() !== session.otp)
106
+ return { ok: false, reason: "Invalid OTP" };
107
+ // OTP correcto — limpiar sesión y retornar
108
+ emailSessions.delete(sessionId);
109
+ return { ok: true, did: session.did, email: session.email };
110
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * GitHub OAuth Credential Validator — native fetch only, no extra deps
3
+ *
4
+ * Usa el flujo estándar OAuth 2.0 de GitHub (open source, bien documentado).
5
+ * No requiere librerías extra — solo fetch nativo (disponible en Node 18+).
6
+ *
7
+ * Flujo:
8
+ * 1. GET /credentials/github/start?did=<did>
9
+ * → retorna { authUrl } — el cliente redirige al usuario a authUrl
10
+ * authUrl = "https://github.com/login/oauth/authorize?..."
11
+ *
12
+ * 2. GitHub redirige a /credentials/github/callback?code=<code>&state=<state>
13
+ * → intercambia code por access_token
14
+ * → obtiene perfil del usuario (id, login, email)
15
+ * → emite GitHubLinked credential
16
+ * → retorna { credential: "GitHubLinked", did, githubLogin }
17
+ *
18
+ * Configuración (env vars):
19
+ * GITHUB_CLIENT_ID — GitHub OAuth App Client ID
20
+ * GITHUB_CLIENT_SECRET — GitHub OAuth App Client Secret
21
+ * SOULPRINT_BASE_URL — URL pública del nodo (para callback)
22
+ * ej: "https://my-validator.example.com"
23
+ *
24
+ * Crear OAuth App: https://github.com/settings/applications/new
25
+ * - Homepage URL: tu dominio del nodo
26
+ * - Callback URL: https://tu-nodo/credentials/github/callback
27
+ *
28
+ * Para desarrollo sin GitHub App configurada:
29
+ * → el endpoint retorna instrucciones para crearla
30
+ */
31
+ export declare function startGitHubOAuth(did: string): {
32
+ authUrl?: string;
33
+ state?: string;
34
+ error?: string;
35
+ setup?: string;
36
+ };
37
+ export declare function handleGitHubCallback(code: string, state: string): Promise<{
38
+ ok: boolean;
39
+ did?: string;
40
+ githubId?: number;
41
+ githubLogin?: string;
42
+ email?: string;
43
+ reason?: string;
44
+ }>;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * GitHub OAuth Credential Validator — native fetch only, no extra deps
3
+ *
4
+ * Usa el flujo estándar OAuth 2.0 de GitHub (open source, bien documentado).
5
+ * No requiere librerías extra — solo fetch nativo (disponible en Node 18+).
6
+ *
7
+ * Flujo:
8
+ * 1. GET /credentials/github/start?did=<did>
9
+ * → retorna { authUrl } — el cliente redirige al usuario a authUrl
10
+ * authUrl = "https://github.com/login/oauth/authorize?..."
11
+ *
12
+ * 2. GitHub redirige a /credentials/github/callback?code=<code>&state=<state>
13
+ * → intercambia code por access_token
14
+ * → obtiene perfil del usuario (id, login, email)
15
+ * → emite GitHubLinked credential
16
+ * → retorna { credential: "GitHubLinked", did, githubLogin }
17
+ *
18
+ * Configuración (env vars):
19
+ * GITHUB_CLIENT_ID — GitHub OAuth App Client ID
20
+ * GITHUB_CLIENT_SECRET — GitHub OAuth App Client Secret
21
+ * SOULPRINT_BASE_URL — URL pública del nodo (para callback)
22
+ * ej: "https://my-validator.example.com"
23
+ *
24
+ * Crear OAuth App: https://github.com/settings/applications/new
25
+ * - Homepage URL: tu dominio del nodo
26
+ * - Callback URL: https://tu-nodo/credentials/github/callback
27
+ *
28
+ * Para desarrollo sin GitHub App configurada:
29
+ * → el endpoint retorna instrucciones para crearla
30
+ */
31
+ import { randomBytes } from "node:crypto";
32
+ const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutos para completar OAuth
33
+ const stateStore = new Map();
34
+ setInterval(() => {
35
+ const now = Date.now();
36
+ for (const [s, v] of stateStore) {
37
+ if (v.expiresAt < now)
38
+ stateStore.delete(s);
39
+ }
40
+ }, 5 * 60_000).unref();
41
+ function getConfig() {
42
+ const clientId = process.env.GITHUB_CLIENT_ID;
43
+ const clientSecret = process.env.GITHUB_CLIENT_SECRET;
44
+ const baseUrl = process.env.SOULPRINT_BASE_URL ?? "http://localhost:4888";
45
+ return { clientId, clientSecret, baseUrl, configured: !!(clientId && clientSecret) };
46
+ }
47
+ export function startGitHubOAuth(did) {
48
+ const cfg = getConfig();
49
+ if (!cfg.configured) {
50
+ return {
51
+ error: "GitHub OAuth not configured on this validator node.",
52
+ setup: [
53
+ "To enable GitHubLinked verification:",
54
+ "1. Create a GitHub OAuth App at https://github.com/settings/applications/new",
55
+ " - Homepage URL: your validator's public URL",
56
+ " - Callback URL: <SOULPRINT_BASE_URL>/credentials/github/callback",
57
+ "2. Set env vars: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, SOULPRINT_BASE_URL",
58
+ "3. Restart the validator node",
59
+ ].join("\n"),
60
+ };
61
+ }
62
+ const state = randomBytes(16).toString("hex");
63
+ stateStore.set(state, { did, expiresAt: Date.now() + STATE_TTL_MS, used: false });
64
+ const params = new URLSearchParams({
65
+ client_id: cfg.clientId,
66
+ redirect_uri: `${cfg.baseUrl}/credentials/github/callback`,
67
+ scope: "read:user user:email",
68
+ state,
69
+ });
70
+ return {
71
+ authUrl: `https://github.com/login/oauth/authorize?${params}`,
72
+ state,
73
+ };
74
+ }
75
+ export async function handleGitHubCallback(code, state) {
76
+ const cfg = getConfig();
77
+ if (!cfg.configured)
78
+ return { ok: false, reason: "GitHub OAuth not configured" };
79
+ // Verificar state
80
+ const stored = stateStore.get(state);
81
+ if (!stored)
82
+ return { ok: false, reason: "Invalid or expired state" };
83
+ if (stored.used)
84
+ return { ok: false, reason: "State already used" };
85
+ if (Date.now() > stored.expiresAt) {
86
+ stateStore.delete(state);
87
+ return { ok: false, reason: "State expired" };
88
+ }
89
+ stored.used = true;
90
+ // Intercambiar code por access_token
91
+ const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
92
+ method: "POST",
93
+ headers: {
94
+ "Accept": "application/json",
95
+ "Content-Type": "application/json",
96
+ },
97
+ body: JSON.stringify({
98
+ client_id: cfg.clientId,
99
+ client_secret: cfg.clientSecret,
100
+ code,
101
+ redirect_uri: `${cfg.baseUrl}/credentials/github/callback`,
102
+ }),
103
+ signal: AbortSignal.timeout(10_000),
104
+ });
105
+ if (!tokenRes.ok)
106
+ return { ok: false, reason: `GitHub token exchange failed: HTTP ${tokenRes.status}` };
107
+ const tokenData = await tokenRes.json();
108
+ if (tokenData.error)
109
+ return { ok: false, reason: `GitHub OAuth error: ${tokenData.error_description ?? tokenData.error}` };
110
+ const accessToken = tokenData.access_token;
111
+ // Obtener perfil del usuario
112
+ const userRes = await fetch("https://api.github.com/user", {
113
+ headers: {
114
+ "Authorization": `Bearer ${accessToken}`,
115
+ "Accept": "application/vnd.github+json",
116
+ "User-Agent": "Soulprint-Validator/0.2.1",
117
+ },
118
+ signal: AbortSignal.timeout(10_000),
119
+ });
120
+ if (!userRes.ok)
121
+ return { ok: false, reason: `GitHub user fetch failed: HTTP ${userRes.status}` };
122
+ const user = await userRes.json();
123
+ // Opcionalmente obtener email privado
124
+ let email;
125
+ try {
126
+ const emailRes = await fetch("https://api.github.com/user/emails", {
127
+ headers: {
128
+ "Authorization": `Bearer ${accessToken}`,
129
+ "Accept": "application/vnd.github+json",
130
+ "User-Agent": "Soulprint-Validator/0.2.1",
131
+ },
132
+ signal: AbortSignal.timeout(5_000),
133
+ });
134
+ if (emailRes.ok) {
135
+ const emails = await emailRes.json();
136
+ email = emails.find(e => e.primary && e.verified)?.email;
137
+ }
138
+ }
139
+ catch { /* email opcional */ }
140
+ stateStore.delete(state);
141
+ return {
142
+ ok: true,
143
+ did: stored.did,
144
+ githubId: user.id,
145
+ githubLogin: user.login,
146
+ email,
147
+ };
148
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Credential Router — integra todos los validators de credenciales
3
+ * con el servidor HTTP del nodo validador.
4
+ *
5
+ * Endpoints añadidos:
6
+ *
7
+ * Email:
8
+ * POST /credentials/email/start { did, email }
9
+ * POST /credentials/email/verify { sessionId, otp }
10
+ *
11
+ * Phone (TOTP — sin SMS, sin API key):
12
+ * POST /credentials/phone/start { did, phone }
13
+ * POST /credentials/phone/verify { sessionId, code }
14
+ *
15
+ * GitHub OAuth:
16
+ * GET /credentials/github/start ?did=<did>
17
+ * GET /credentials/github/callback?code=<code>&state=<state>
18
+ *
19
+ * Biometric (ya existe via /verify — solo se documenta aquí):
20
+ * POST /verify { spt, zkp } → emite BiometricBound credential implícita
21
+ *
22
+ * Una vez verificado, el validator emite una BotAttestation con:
23
+ * context: "credential:EmailVerified" | "credential:PhoneVerified" |
24
+ * "credential:GitHubLinked"
25
+ * Esta attestation se gossipea al resto de la red P2P automáticamente.
26
+ */
27
+ import { IncomingMessage, ServerResponse } from "node:http";
28
+ import type { BotAttestation, SoulprintKeypair } from "soulprint-core";
29
+ export type CredentialContext = {
30
+ nodeKeypair: SoulprintKeypair;
31
+ signAttestation: (att: Omit<BotAttestation, "sig">) => BotAttestation;
32
+ gossip: (att: BotAttestation) => void;
33
+ };
34
+ export declare function handleCredentialRoute(req: IncomingMessage, res: ServerResponse, url: string, ctx: CredentialContext): Promise<boolean>;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Credential Router — integra todos los validators de credenciales
3
+ * con el servidor HTTP del nodo validador.
4
+ *
5
+ * Endpoints añadidos:
6
+ *
7
+ * Email:
8
+ * POST /credentials/email/start { did, email }
9
+ * POST /credentials/email/verify { sessionId, otp }
10
+ *
11
+ * Phone (TOTP — sin SMS, sin API key):
12
+ * POST /credentials/phone/start { did, phone }
13
+ * POST /credentials/phone/verify { sessionId, code }
14
+ *
15
+ * GitHub OAuth:
16
+ * GET /credentials/github/start ?did=<did>
17
+ * GET /credentials/github/callback?code=<code>&state=<state>
18
+ *
19
+ * Biometric (ya existe via /verify — solo se documenta aquí):
20
+ * POST /verify { spt, zkp } → emite BiometricBound credential implícita
21
+ *
22
+ * Una vez verificado, el validator emite una BotAttestation con:
23
+ * context: "credential:EmailVerified" | "credential:PhoneVerified" |
24
+ * "credential:GitHubLinked"
25
+ * Esta attestation se gossipea al resto de la red P2P automáticamente.
26
+ */
27
+ import { startEmailVerification, verifyEmailOTP, } from "./email.js";
28
+ import { startPhoneVerification, verifyPhoneTOTP, } from "./phone.js";
29
+ import { startGitHubOAuth, handleGitHubCallback, } from "./github.js";
30
+ // ── HTTP helpers ──────────────────────────────────────────────────────────────
31
+ function jsonResp(res, status, body) {
32
+ res.writeHead(status, { "Content-Type": "application/json" });
33
+ res.end(JSON.stringify(body));
34
+ }
35
+ async function readBody(req) {
36
+ return new Promise((resolve, reject) => {
37
+ let data = "";
38
+ req.on("data", c => { data += c; if (data.length > 32_768) {
39
+ req.destroy();
40
+ reject(new Error("Body too large"));
41
+ } });
42
+ req.on("end", () => { try {
43
+ resolve(JSON.parse(data));
44
+ }
45
+ catch {
46
+ reject(new Error("Invalid JSON"));
47
+ } });
48
+ });
49
+ }
50
+ // ── Router ────────────────────────────────────────────────────────────────────
51
+ export async function handleCredentialRoute(req, res, url, ctx) {
52
+ const method = req.method ?? "GET";
53
+ const qs = new URL(url, "http://localhost").searchParams;
54
+ // ── Email ──────────────────────────────────────────────────────────────────
55
+ if (url.startsWith("/credentials/email/start") && method === "POST") {
56
+ let body;
57
+ try {
58
+ body = await readBody(req);
59
+ }
60
+ catch (e) {
61
+ return jsonResp(res, 400, { error: e.message }), true;
62
+ }
63
+ try {
64
+ const result = await startEmailVerification(body.did, body.email);
65
+ jsonResp(res, 200, { ok: true, ...result, message: "OTP sent to your email. Check your inbox." });
66
+ }
67
+ catch (e) {
68
+ jsonResp(res, 400, { ok: false, error: e.message });
69
+ }
70
+ return true;
71
+ }
72
+ if (url.startsWith("/credentials/email/verify") && method === "POST") {
73
+ let body;
74
+ try {
75
+ body = await readBody(req);
76
+ }
77
+ catch (e) {
78
+ return jsonResp(res, 400, { error: e.message }), true;
79
+ }
80
+ const result = verifyEmailOTP(body.sessionId, body.otp);
81
+ if (!result.ok)
82
+ return jsonResp(res, 403, { ok: false, reason: result.reason }), true;
83
+ const att = ctx.signAttestation({
84
+ issuer_did: ctx.nodeKeypair.did,
85
+ target_did: result.did,
86
+ value: 1,
87
+ context: "credential:EmailVerified",
88
+ timestamp: Math.floor(Date.now() / 1000),
89
+ });
90
+ ctx.gossip(att);
91
+ jsonResp(res, 200, { ok: true, credential: "EmailVerified", did: result.did, email: result.email, attestation: att });
92
+ return true;
93
+ }
94
+ // ── Phone ──────────────────────────────────────────────────────────────────
95
+ if (url.startsWith("/credentials/phone/start") && method === "POST") {
96
+ let body;
97
+ try {
98
+ body = await readBody(req);
99
+ }
100
+ catch (e) {
101
+ return jsonResp(res, 400, { error: e.message }), true;
102
+ }
103
+ try {
104
+ const result = startPhoneVerification(body.did, body.phone);
105
+ jsonResp(res, 200, { ok: true, ...result });
106
+ }
107
+ catch (e) {
108
+ jsonResp(res, 400, { ok: false, error: e.message });
109
+ }
110
+ return true;
111
+ }
112
+ if (url.startsWith("/credentials/phone/verify") && method === "POST") {
113
+ let body;
114
+ try {
115
+ body = await readBody(req);
116
+ }
117
+ catch (e) {
118
+ return jsonResp(res, 400, { error: e.message }), true;
119
+ }
120
+ const result = verifyPhoneTOTP(body.sessionId, body.code);
121
+ if (!result.ok)
122
+ return jsonResp(res, 403, { ok: false, reason: result.reason }), true;
123
+ const att = ctx.signAttestation({
124
+ issuer_did: ctx.nodeKeypair.did,
125
+ target_did: result.did,
126
+ value: 1,
127
+ context: "credential:PhoneVerified",
128
+ timestamp: Math.floor(Date.now() / 1000),
129
+ });
130
+ ctx.gossip(att);
131
+ jsonResp(res, 200, { ok: true, credential: "PhoneVerified", did: result.did, phone: result.phone, attestation: att });
132
+ return true;
133
+ }
134
+ // ── GitHub OAuth ───────────────────────────────────────────────────────────
135
+ if (url.startsWith("/credentials/github/start") && method === "GET") {
136
+ const did = qs.get("did");
137
+ if (!did)
138
+ return jsonResp(res, 400, { error: "Missing query param: did" }), true;
139
+ const result = startGitHubOAuth(did);
140
+ if (result.error)
141
+ return jsonResp(res, 503, { ok: false, ...result }), true;
142
+ // Redirect al usuario a GitHub
143
+ res.writeHead(302, { Location: result.authUrl });
144
+ res.end();
145
+ return true;
146
+ }
147
+ if (url.startsWith("/credentials/github/callback") && method === "GET") {
148
+ const code = qs.get("code");
149
+ const state = qs.get("state");
150
+ if (!code || !state)
151
+ return jsonResp(res, 400, { error: "Missing code or state" }), true;
152
+ const result = await handleGitHubCallback(code, state);
153
+ if (!result.ok)
154
+ return jsonResp(res, 403, { ok: false, reason: result.reason }), true;
155
+ const att = ctx.signAttestation({
156
+ issuer_did: ctx.nodeKeypair.did,
157
+ target_did: result.did,
158
+ value: 1,
159
+ context: "credential:GitHubLinked",
160
+ timestamp: Math.floor(Date.now() / 1000),
161
+ });
162
+ ctx.gossip(att);
163
+ jsonResp(res, 200, {
164
+ ok: true,
165
+ credential: "GitHubLinked",
166
+ did: result.did,
167
+ github: { id: result.githubId, login: result.githubLogin },
168
+ attestation: att,
169
+ });
170
+ return true;
171
+ }
172
+ return false; // no es una ruta de credenciales
173
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Phone TOTP Credential Validator — sin servicio externo
3
+ *
4
+ * Usa TOTP (RFC 6238) — el mismo estándar de Google Authenticator / Authy.
5
+ * No requiere SMS, no requiere API key, funciona completamente offline.
6
+ *
7
+ * Flujo:
8
+ * 1. POST /credentials/phone/start { did, phone }
9
+ * → genera un TOTP secret único para este DID
10
+ * → retorna { sessionId, totpUri, qrData }
11
+ * totpUri = "otpauth://totp/Soulprint:+57..."
12
+ * qrData = string para generar QR (usar qrencode o cualquier lib)
13
+ *
14
+ * 2. El usuario escanea el QR con Google Authenticator / Authy
15
+ * y envía el código de 6 dígitos
16
+ *
17
+ * 3. POST /credentials/phone/verify { sessionId, code }
18
+ * → si el TOTP es válido → emite PhoneVerified
19
+ * → retorna { credential: "PhoneVerified", did }
20
+ *
21
+ * ¿Por qué TOTP en lugar de SMS?
22
+ * - 100% open source (RFC 6238, sin dependencias externas)
23
+ * - 0 costo — no necesita Twilio, Vonage, etc.
24
+ * - Compatible con cualquier TOTP app (Google Auth, Authy, Aegis, etc.)
25
+ * - El usuario confirma que controla su dispositivo (equivalente a "phone verified")
26
+ * - Funciona offline en el validador
27
+ * - P2P friendly: cualquier nodo puede verificar sin shared state
28
+ *
29
+ * Configuración: ninguna — funciona out of the box.
30
+ */
31
+ export declare function startPhoneVerification(did: string, phone: string): {
32
+ sessionId: string;
33
+ totpUri: string;
34
+ qrData: string;
35
+ instructions: string;
36
+ };
37
+ export declare function verifyPhoneTOTP(sessionId: string, code: string): {
38
+ ok: boolean;
39
+ did?: string;
40
+ phone?: string;
41
+ reason?: string;
42
+ };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Phone TOTP Credential Validator — sin servicio externo
3
+ *
4
+ * Usa TOTP (RFC 6238) — el mismo estándar de Google Authenticator / Authy.
5
+ * No requiere SMS, no requiere API key, funciona completamente offline.
6
+ *
7
+ * Flujo:
8
+ * 1. POST /credentials/phone/start { did, phone }
9
+ * → genera un TOTP secret único para este DID
10
+ * → retorna { sessionId, totpUri, qrData }
11
+ * totpUri = "otpauth://totp/Soulprint:+57..."
12
+ * qrData = string para generar QR (usar qrencode o cualquier lib)
13
+ *
14
+ * 2. El usuario escanea el QR con Google Authenticator / Authy
15
+ * y envía el código de 6 dígitos
16
+ *
17
+ * 3. POST /credentials/phone/verify { sessionId, code }
18
+ * → si el TOTP es válido → emite PhoneVerified
19
+ * → retorna { credential: "PhoneVerified", did }
20
+ *
21
+ * ¿Por qué TOTP en lugar de SMS?
22
+ * - 100% open source (RFC 6238, sin dependencias externas)
23
+ * - 0 costo — no necesita Twilio, Vonage, etc.
24
+ * - Compatible con cualquier TOTP app (Google Auth, Authy, Aegis, etc.)
25
+ * - El usuario confirma que controla su dispositivo (equivalente a "phone verified")
26
+ * - Funciona offline en el validador
27
+ * - P2P friendly: cualquier nodo puede verificar sin shared state
28
+ *
29
+ * Configuración: ninguna — funciona out of the box.
30
+ */
31
+ import { TOTP } from "otpauth";
32
+ import { randomBytes } from "node:crypto";
33
+ const SESSION_TTL_MS = 15 * 60 * 1000; // 15 minutos para completar la verificación
34
+ const phoneSessions = new Map();
35
+ setInterval(() => {
36
+ const now = Date.now();
37
+ for (const [id, s] of phoneSessions) {
38
+ if (s.expiresAt < now)
39
+ phoneSessions.delete(id);
40
+ }
41
+ }, 5 * 60_000).unref();
42
+ export function startPhoneVerification(did, phone) {
43
+ // Validar formato básico de teléfono (E.164)
44
+ const cleanPhone = phone.replace(/\s/g, "");
45
+ if (!/^\+?[1-9]\d{7,14}$/.test(cleanPhone)) {
46
+ throw new Error("Invalid phone number. Use E.164 format: +573001234567");
47
+ }
48
+ const sessionId = randomBytes(16).toString("hex");
49
+ // Generar TOTP secret único para este DID + sesión
50
+ const secret = randomBytes(20).toString("base64").replace(/[^A-Z2-7]/gi, "A").slice(0, 32).toUpperCase();
51
+ const totp = new TOTP({
52
+ issuer: "Soulprint",
53
+ label: cleanPhone,
54
+ algorithm: "SHA1",
55
+ digits: 6,
56
+ period: 30,
57
+ secret,
58
+ });
59
+ const totpUri = totp.toString();
60
+ phoneSessions.set(sessionId, {
61
+ did, phone: cleanPhone, totp, totpUri, secret,
62
+ expiresAt: Date.now() + SESSION_TTL_MS,
63
+ verified: false, attempts: 0,
64
+ });
65
+ return {
66
+ sessionId,
67
+ totpUri,
68
+ qrData: totpUri, // el cliente puede generar el QR con cualquier lib
69
+ instructions: [
70
+ "1. Open Google Authenticator, Authy, or any TOTP app",
71
+ "2. Tap '+' → 'Scan QR code' and scan the QR code",
72
+ " Or tap 'Enter setup key' and paste: " + totpUri,
73
+ "3. Enter the 6-digit code that appears in the app",
74
+ "4. POST /credentials/phone/verify with your sessionId and code",
75
+ "",
76
+ "This proves you control a real device (equivalent to phone verification).",
77
+ ].join("\n"),
78
+ };
79
+ }
80
+ export function verifyPhoneTOTP(sessionId, code) {
81
+ const session = phoneSessions.get(sessionId);
82
+ if (!session)
83
+ return { ok: false, reason: "Session not found or expired" };
84
+ if (Date.now() > session.expiresAt) {
85
+ phoneSessions.delete(sessionId);
86
+ return { ok: false, reason: "Session expired" };
87
+ }
88
+ session.attempts++;
89
+ if (session.attempts > 10) {
90
+ phoneSessions.delete(sessionId);
91
+ return { ok: false, reason: "Too many attempts" };
92
+ }
93
+ // TOTP valida con ventana de ±1 período (30s) para tolerancia de clock drift
94
+ const isValid = session.totp.validate({ token: code.trim(), window: 1 }) !== null;
95
+ if (!isValid)
96
+ return { ok: false, reason: "Invalid or expired TOTP code. Try the current code in your authenticator app." };
97
+ phoneSessions.delete(sessionId);
98
+ return { ok: true, did: session.did, phone: session.phone };
99
+ }
@@ -10,6 +10,13 @@ import { type SoulprintP2PNode } from "./p2p.js";
10
10
  export declare function setP2PNode(node: SoulprintP2PNode): void;
11
11
  /**
12
12
  * Aplica una nueva attestation al DID objetivo y persiste.
13
+ *
14
+ * PROTOCOL ENFORCEMENT:
15
+ * - Si el bot tiene DocumentVerified, su score total nunca puede caer por
16
+ * debajo de PROTOCOL.VERIFIED_SCORE_FLOOR (52) — inamovible.
17
+ * - Anti-replay: la misma attestation (mismo issuer + timestamp + context)
18
+ * no se puede aplicar dos veces.
19
+ *
13
20
  * Retorna la reputación actualizada.
14
21
  */
15
22
  declare function applyAttestation(att: BotAttestation): BotReputation;
package/dist/validator.js CHANGED
@@ -2,24 +2,27 @@ import { createServer } from "node:http";
2
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { homedir } from "node:os";
5
- import { generateKeypair, keypairFromPrivateKey, decodeToken, sign, verifyAttestation, computeReputation, defaultReputation, } from "soulprint-core";
5
+ import { generateKeypair, keypairFromPrivateKey, decodeToken, sign, verifyAttestation, computeReputation, defaultReputation, PROTOCOL, checkFarming, recordApprovedGain, recordFarmingStrike, loadAuditStore, exportAuditStore, } from "soulprint-core";
6
6
  import { verifyProof, deserializeProof } from "soulprint-zkp";
7
+ import { handleCredentialRoute } from "./credentials/index.js";
7
8
  import { publishAttestationP2P, onAttestationReceived, getP2PStats, } from "./p2p.js";
8
9
  // ── Config ────────────────────────────────────────────────────────────────────
9
- const PORT = parseInt(process.env.SOULPRINT_PORT ?? "4888");
10
+ const PORT = parseInt(process.env.SOULPRINT_PORT ?? String(PROTOCOL.DEFAULT_HTTP_PORT));
10
11
  const NODE_DIR = join(homedir(), ".soulprint", "node");
11
12
  const KEYPAIR_FILE = join(NODE_DIR, "node-identity.json");
12
13
  const NULLIFIER_DB = join(NODE_DIR, "nullifiers.json");
13
14
  const REPUTE_DB = join(NODE_DIR, "reputation.json");
14
15
  const PEERS_DB = join(NODE_DIR, "peers.json");
16
+ const AUDIT_DB = join(NODE_DIR, "audit.json");
15
17
  const VERSION = "0.2.0";
16
18
  const MAX_BODY_BYTES = 64 * 1024;
17
- const RATE_LIMIT_MS = 60_000;
18
- const RATE_LIMIT_MAX = 10;
19
- const CLOCK_SKEW_MAX = 300; // ±5 min
20
- const MIN_ATTESTER_SCORE = 60; // solo servicios verificados emiten attestations
21
- const ATT_MAX_AGE_SECONDS = 3600; // attestation no puede tener >1h de antigüedad
22
- const GOSSIP_TIMEOUT_MS = 3_000; // timeout del gossip HTTP fallback
19
+ // ── Protocol constants (inamovibles — no cambiar directamente aquí) ───────────
20
+ const RATE_LIMIT_MS = PROTOCOL.RATE_LIMIT_WINDOW_MS;
21
+ const RATE_LIMIT_MAX = PROTOCOL.RATE_LIMIT_MAX;
22
+ const CLOCK_SKEW_MAX = PROTOCOL.CLOCK_SKEW_MAX_SECONDS;
23
+ const MIN_ATTESTER_SCORE = PROTOCOL.MIN_ATTESTER_SCORE; // 65 inamovible
24
+ const ATT_MAX_AGE_SECONDS = PROTOCOL.ATT_MAX_AGE_SECONDS;
25
+ const GOSSIP_TIMEOUT_MS = PROTOCOL.GOSSIP_TIMEOUT_MS;
23
26
  // ── P2P Node (Phase 5) ────────────────────────────────────────────────────────
24
27
  let p2pNode = null;
25
28
  /**
@@ -81,6 +84,15 @@ function loadReputation() {
81
84
  }
82
85
  }
83
86
  function saveReputation() { writeFileSync(REPUTE_DB, JSON.stringify(repStore, null, 2)); }
87
+ // ── Audit store (anti-farming) ────────────────────────────────────────────────
88
+ function loadAudit() {
89
+ if (existsSync(AUDIT_DB))
90
+ try {
91
+ loadAuditStore(JSON.parse(readFileSync(AUDIT_DB, "utf8")));
92
+ }
93
+ catch { /* empty */ }
94
+ }
95
+ function saveAudit() { writeFileSync(AUDIT_DB, JSON.stringify(exportAuditStore(), null, 2)); }
84
96
  /**
85
97
  * Obtiene la reputación de un DID.
86
98
  * Si no existe, retorna la reputación neutral (score=10).
@@ -93,6 +105,13 @@ function getReputation(did) {
93
105
  }
94
106
  /**
95
107
  * Aplica una nueva attestation al DID objetivo y persiste.
108
+ *
109
+ * PROTOCOL ENFORCEMENT:
110
+ * - Si el bot tiene DocumentVerified, su score total nunca puede caer por
111
+ * debajo de PROTOCOL.VERIFIED_SCORE_FLOOR (52) — inamovible.
112
+ * - Anti-replay: la misma attestation (mismo issuer + timestamp + context)
113
+ * no se puede aplicar dos veces.
114
+ *
96
115
  * Retorna la reputación actualizada.
97
116
  */
98
117
  function applyAttestation(att) {
@@ -107,14 +126,33 @@ function applyAttestation(att) {
107
126
  }
108
127
  const allAtts = [...prevAtts, att];
109
128
  const rep = computeReputation(allAtts, 10); // base siempre 10
129
+ // ── PROTOCOL FLOOR ENFORCEMENT ─────────────────────────────────────────────
130
+ // Si el DID tiene DocumentVerified, su score total no puede caer bajo el floor.
131
+ // La reputación mínima se calcula como: floor - identity_score.
132
+ // Ejemplo: floor=52, identity=36 → min_rep = max(0, 52-36) = 16
133
+ // Nunca permitimos que la reputación baje de ese mínimo.
134
+ const existingToken = existing ? { hasDocument: true } : null; // conservative: assume yes if known
135
+ const identityFromStore = existing?.identityScore ?? 0;
136
+ const hasDocument = existing?.hasDocumentVerified ?? false;
137
+ let finalRepScore = rep.score;
138
+ if (hasDocument) {
139
+ const minRepForFloor = Math.max(0, PROTOCOL.VERIFIED_SCORE_FLOOR - identityFromStore);
140
+ finalRepScore = Math.max(finalRepScore, minRepForFloor);
141
+ if (finalRepScore !== rep.score) {
142
+ console.log(`[floor] Reputation clamped for ${att.target_did.slice(0, 20)}...: ` +
143
+ `${rep.score} → ${finalRepScore} (VERIFIED_SCORE_FLOOR=${PROTOCOL.VERIFIED_SCORE_FLOOR})`);
144
+ }
145
+ }
110
146
  repStore[att.target_did] = {
111
- score: rep.score,
147
+ score: finalRepScore,
112
148
  base: 10,
113
149
  attestations: allAtts,
114
150
  last_updated: rep.last_updated,
151
+ identityScore: existing?.identityScore ?? 0,
152
+ hasDocumentVerified: hasDocument,
115
153
  };
116
154
  saveReputation();
117
- return rep;
155
+ return { score: finalRepScore, attestations: allAtts.length, last_updated: rep.last_updated };
118
156
  }
119
157
  // ── Peers registry (P2P gossip) ───────────────────────────────────────────────
120
158
  let peers = []; // URLs de otros nodos (ej: "http://node2.example.com:4888")
@@ -209,13 +247,13 @@ function handleInfo(res, nodeKeypair) {
209
247
  json(res, 200, {
210
248
  node_did: nodeKeypair.did,
211
249
  version: VERSION,
212
- protocol: "sip/0.1",
250
+ protocol: PROTOCOL.VERSION,
213
251
  total_verified: Object.keys(nullifiers).length,
214
252
  total_reputation: Object.keys(repStore).length,
215
253
  known_peers: peers.length,
216
254
  supported_countries: ["CO"],
217
255
  capabilities: ["zk-verify", "anti-sybil", "co-sign", "bot-reputation", "p2p-gossipsub"],
218
- rate_limit: `${RATE_LIMIT_MAX} req/min per IP`,
256
+ rate_limit: `${PROTOCOL.RATE_LIMIT_MAX} req/min per IP`,
219
257
  // P2P stats (Phase 5)
220
258
  p2p: p2pStats ? {
221
259
  enabled: true,
@@ -226,6 +264,31 @@ function handleInfo(res, nodeKeypair) {
226
264
  } : { enabled: false },
227
265
  });
228
266
  }
267
+ // ── GET /protocol ──────────────────────────────────────────────────────────────
268
+ /**
269
+ * Expone las constantes de protocolo inamovibles.
270
+ * Los clientes y otros nodos usan este endpoint para:
271
+ * 1. Verificar compatibilidad de versión antes de conectarse
272
+ * 2. Obtener los valores actuales de SCORE_FLOOR y MIN_ATTESTER_SCORE
273
+ * 3. Validar que el nodo no ha sido modificado para bajar los thresholds
274
+ */
275
+ function handleProtocol(res) {
276
+ json(res, 200, {
277
+ protocol_version: PROTOCOL.VERSION,
278
+ score_floor: PROTOCOL.SCORE_FLOOR,
279
+ verified_score_floor: PROTOCOL.VERIFIED_SCORE_FLOOR,
280
+ min_attester_score: PROTOCOL.MIN_ATTESTER_SCORE,
281
+ identity_max: PROTOCOL.IDENTITY_MAX,
282
+ reputation_max: PROTOCOL.REPUTATION_MAX,
283
+ max_score: PROTOCOL.MAX_SCORE,
284
+ default_reputation: PROTOCOL.DEFAULT_REPUTATION,
285
+ verify_retry_max: PROTOCOL.VERIFY_RETRY_MAX,
286
+ verify_retry_base_ms: PROTOCOL.VERIFY_RETRY_BASE_MS,
287
+ verify_retry_max_ms: PROTOCOL.VERIFY_RETRY_MAX_MS,
288
+ att_max_age_seconds: PROTOCOL.ATT_MAX_AGE_SECONDS,
289
+ immutable: true, // todas estas constantes son inamovibles por diseño
290
+ });
291
+ }
229
292
  // ── GET /reputation/:did ──────────────────────────────────────────────────────
230
293
  function handleGetReputation(res, did) {
231
294
  if (!did.startsWith("did:"))
@@ -308,16 +371,46 @@ async function handleAttest(req, res, ip) {
308
371
  return json(res, 403, { error: "Invalid attestation signature" });
309
372
  }
310
373
  // ── Aplicar y persistir ───────────────────────────────────────────────────
311
- const updatedRep = applyAttestation(att);
374
+ // ── ANTI-FARMING CHECK (solo para attestations positivas) ─────────────────
375
+ // Si detectamos farming, convertimos el +1 en -1 automáticamente.
376
+ // Las attestations negativas no se chequean (una penalización real no hace farming).
377
+ let finalAtt = att;
378
+ if (att.value === 1 && !from_peer) {
379
+ const existing = repStore[att.target_did];
380
+ const prevAtts = existing?.attestations ?? [];
381
+ // Reconstruir sesión desde el contexto de la attestation
382
+ const session = {
383
+ did: att.target_did,
384
+ startTime: (att.timestamp - 60) * 1000, // estimar inicio de sesión 60s antes
385
+ events: [], // no tenemos eventos individuales aquí — se evalúa en withTracking()
386
+ issuerDid: att.issuer_did,
387
+ };
388
+ const farmResult = checkFarming(session, prevAtts);
389
+ if (farmResult.isFarming) {
390
+ console.warn(`[anti-farming] 🚫 Farming detectado para ${att.target_did.slice(0, 20)}...`, `\n Razón: ${farmResult.reason}`, `\n Convirtiendo +1 → -1 automáticamente`);
391
+ // Penalizar en lugar de recompensar
392
+ finalAtt = { ...att, value: -1, context: `farming-penalty:${att.context}` };
393
+ recordFarmingStrike(att.target_did);
394
+ saveAudit();
395
+ }
396
+ else {
397
+ // Registrar ganancia aprobada para el tracking de velocidad
398
+ recordApprovedGain(att.target_did);
399
+ saveAudit();
400
+ }
401
+ }
402
+ const updatedRep = applyAttestation(finalAtt);
312
403
  // ── Gossip a los peers (async, fire-and-forget) ───────────────────────────
313
404
  if (!from_peer) {
314
- gossipAttestation(att, undefined);
405
+ gossipAttestation(finalAtt, undefined);
315
406
  }
316
407
  json(res, 200, {
317
408
  ok: true,
318
- target_did: att.target_did,
409
+ target_did: finalAtt.target_did,
319
410
  reputation: updatedRep,
320
411
  gossiped_to: from_peer ? 0 : peers.length,
412
+ farming_detected: finalAtt.value !== att.value,
413
+ ...(finalAtt.value !== att.value ? { farming_reason: finalAtt.context } : {}),
321
414
  });
322
415
  }
323
416
  // ── POST /peers/register ──────────────────────────────────────────────────────
@@ -394,6 +487,23 @@ async function handleVerify(req, res, nodeKeypair, ip) {
394
487
  const coSig = sign({ nullifier: zkResult.nullifier, did: token.did, timestamp: now }, nodeKeypair.privateKey);
395
488
  // Incluir reputación actual del bot en la respuesta
396
489
  const reputation = getReputation(token.did);
490
+ // Guardar identityScore y hasDocumentVerified para enforcement del floor
491
+ if (!repStore[token.did]) {
492
+ repStore[token.did] = {
493
+ score: reputation.score,
494
+ base: 10,
495
+ attestations: [],
496
+ last_updated: now,
497
+ identityScore: token.identity_score ?? 0,
498
+ hasDocumentVerified: (token.credentials ?? []).includes("DocumentVerified"),
499
+ };
500
+ }
501
+ else {
502
+ // Actualizar identity info si el token es más reciente
503
+ repStore[token.did].identityScore = token.identity_score ?? 0;
504
+ repStore[token.did].hasDocumentVerified = (token.credentials ?? []).includes("DocumentVerified");
505
+ }
506
+ saveReputation();
397
507
  json(res, 200, {
398
508
  valid: true,
399
509
  anti_sybil: antiSybil,
@@ -418,10 +528,21 @@ export function startValidatorNode(port = PORT) {
418
528
  loadNullifiers();
419
529
  loadReputation();
420
530
  loadPeers();
531
+ loadAudit();
421
532
  const nodeKeypair = loadOrCreateNodeKeypair();
533
+ // ── Credential context (para el router de credenciales) ───────────────────
534
+ const credentialCtx = {
535
+ nodeKeypair,
536
+ signAttestation: (att) => {
537
+ const sig = sign(att, nodeKeypair.privateKey);
538
+ return { ...att, sig };
539
+ },
540
+ gossip: (att) => gossipAttestation(att, undefined),
541
+ };
422
542
  const server = createServer(async (req, res) => {
423
543
  const ip = getIP(req);
424
- const url = req.url?.split("?")[0] ?? "/";
544
+ const url = req.url ?? "/";
545
+ const cleanUrl = url.split("?")[0];
425
546
  res.setHeader("Access-Control-Allow-Origin", "*");
426
547
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
427
548
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
@@ -430,20 +551,28 @@ export function startValidatorNode(port = PORT) {
430
551
  res.end();
431
552
  return;
432
553
  }
433
- if (url === "/info" && req.method === "GET")
554
+ // ── Credential routes (email, phone, github) ───────────────────────────
555
+ if (cleanUrl.startsWith("/credentials/")) {
556
+ const handled = await handleCredentialRoute(req, res, url, credentialCtx);
557
+ if (handled)
558
+ return;
559
+ }
560
+ if (cleanUrl === "/info" && req.method === "GET")
434
561
  return handleInfo(res, nodeKeypair);
435
- if (url === "/verify" && req.method === "POST")
562
+ if (cleanUrl === "/protocol" && req.method === "GET")
563
+ return handleProtocol(res);
564
+ if (cleanUrl === "/verify" && req.method === "POST")
436
565
  return handleVerify(req, res, nodeKeypair, ip);
437
- if (url === "/reputation/attest" && req.method === "POST")
566
+ if (cleanUrl === "/reputation/attest" && req.method === "POST")
438
567
  return handleAttest(req, res, ip);
439
- if (url === "/peers/register" && req.method === "POST")
568
+ if (cleanUrl === "/peers/register" && req.method === "POST")
440
569
  return handlePeerRegister(req, res);
441
- if (url === "/peers" && req.method === "GET")
570
+ if (cleanUrl === "/peers" && req.method === "GET")
442
571
  return handleGetPeers(res);
443
- if (url.startsWith("/reputation/") && req.method === "GET")
444
- return handleGetReputation(res, decodeURIComponent(url.replace("/reputation/", "")));
445
- if (url.startsWith("/nullifier/") && req.method === "GET")
446
- return handleNullifierCheck(res, decodeURIComponent(url.replace("/nullifier/", "")));
572
+ if (cleanUrl.startsWith("/reputation/") && req.method === "GET")
573
+ return handleGetReputation(res, decodeURIComponent(cleanUrl.replace("/reputation/", "")));
574
+ if (cleanUrl.startsWith("/nullifier/") && req.method === "GET")
575
+ return handleNullifierCheck(res, decodeURIComponent(cleanUrl.replace("/nullifier/", "")));
447
576
  json(res, 404, { error: "Not found" });
448
577
  });
449
578
  server.listen(port, () => {
@@ -453,14 +582,21 @@ export function startValidatorNode(port = PORT) {
453
582
  console.log(` Nullifiers: ${Object.keys(nullifiers).length}`);
454
583
  console.log(` Reputations: ${Object.keys(repStore).length}`);
455
584
  console.log(` Known peers: ${peers.length}`);
456
- console.log(`\n POST /verify verify ZK proof + co-sign`);
585
+ console.log(`\n Core endpoints:`);
586
+ console.log(` POST /verify verify ZK proof + co-sign`);
457
587
  console.log(` GET /info node info`);
588
+ console.log(` GET /protocol protocol constants (immutable)`);
458
589
  console.log(` GET /nullifier/:n anti-sybil check`);
459
- console.log(` POST /reputation/attest issue +1/-1 attestation`);
590
+ console.log(` POST /reputation/attest issue attestation (anti-farming ON)`);
460
591
  console.log(` GET /reputation/:did get bot reputation`);
461
- console.log(` POST /peers/register join P2P network`);
462
- console.log(` GET /peers list known peers`);
463
- console.log(`\n Anyone can run a Soulprint node. More nodes = more security.\n`);
592
+ console.log(`\n Credential validators (open source, no API keys needed):`);
593
+ console.log(` POST /credentials/email/start → email OTP (nodemailer)`);
594
+ console.log(` POST /credentials/email/verify`);
595
+ console.log(` POST /credentials/phone/start → TOTP device proof (otpauth)`);
596
+ console.log(` POST /credentials/phone/verify`);
597
+ console.log(` GET /credentials/github/start → GitHub OAuth (native fetch)`);
598
+ console.log(` GET /credentials/github/callback`);
599
+ console.log(`\n Anti-farming: ON — max +1/day, pattern detection, cooldowns\n`);
464
600
  });
465
601
  return server;
466
602
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulprint-network",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Soulprint validator node — HTTP server that verifies ZK proofs, co-signs SPTs, anti-Sybil registry",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -11,10 +11,6 @@
11
11
  "dist",
12
12
  "README.md"
13
13
  ],
14
- "scripts": {
15
- "build": "tsc",
16
- "start": "node dist/server.js"
17
- },
18
14
  "publishConfig": {
19
15
  "access": "public"
20
16
  },
@@ -44,12 +40,16 @@
44
40
  "@libp2p/ping": "2.0.37",
45
41
  "@libp2p/tcp": "10.1.19",
46
42
  "libp2p": "2.10.0",
47
- "soulprint-core": "workspace:*",
48
- "soulprint-zkp": "workspace:*",
49
- "uint8arrays": "5.1.0"
43
+ "nodemailer": "^8.0.1",
44
+ "otpauth": "^9.5.0",
45
+ "otplib": "^13.3.0",
46
+ "uint8arrays": "5.1.0",
47
+ "soulprint-core": "0.1.5",
48
+ "soulprint-zkp": "0.1.3"
50
49
  },
51
50
  "devDependencies": {
52
51
  "@types/node": "^20.0.0",
52
+ "@types/nodemailer": "^7.0.11",
53
53
  "typescript": "^5.4.0"
54
54
  },
55
55
  "engines": {
@@ -61,5 +61,9 @@
61
61
  "import": "./dist/index.js",
62
62
  "types": "./dist/index.d.ts"
63
63
  }
64
+ },
65
+ "scripts": {
66
+ "build": "tsc",
67
+ "start": "node dist/server.js"
64
68
  }
65
- }
69
+ }