soulprint-network 0.2.1 → 0.2.3

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
+ }
package/dist/validator.js CHANGED
@@ -2,8 +2,9 @@ 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, PROTOCOL, } from "soulprint-core";
5
+ import { generateKeypair, keypairFromPrivateKey, decodeToken, sign, verifyAttestation, computeReputation, defaultReputation, PROTOCOL, PROTOCOL_HASH, isProtocolHashCompatible, 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
10
  const PORT = parseInt(process.env.SOULPRINT_PORT ?? String(PROTOCOL.DEFAULT_HTTP_PORT));
@@ -12,6 +13,7 @@ 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
19
  // ── Protocol constants (inamovibles — no cambiar directamente aquí) ───────────
@@ -82,6 +84,15 @@ function loadReputation() {
82
84
  }
83
85
  }
84
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)); }
85
96
  /**
86
97
  * Obtiene la reputación de un DID.
87
98
  * Si no existe, retorna la reputación neutral (score=10).
@@ -173,12 +184,17 @@ async function gossipAttestation(att, excludeUrl) {
173
184
  }
174
185
  }
175
186
  // ── Canal 2: HTTP gossip (fallback para nodos legacy) ─────────────────────
187
+ // Incluye X-Protocol-Hash para que el peer receptor valide compatibilidad.
176
188
  const targets = peers.filter(p => p !== excludeUrl);
177
189
  for (const peerUrl of targets) {
178
190
  fetch(`${peerUrl}/reputation/attest`, {
179
191
  method: "POST",
180
- headers: { "Content-Type": "application/json", "X-Gossip": "1" },
181
- body: JSON.stringify({ attestation: att }),
192
+ headers: {
193
+ "Content-Type": "application/json",
194
+ "X-Gossip": "1",
195
+ "X-Protocol-Hash": PROTOCOL_HASH, // ← el receptor valida esto
196
+ },
197
+ body: JSON.stringify({ attestation: att, from_peer: true }),
182
198
  signal: AbortSignal.timeout(GOSSIP_TIMEOUT_MS),
183
199
  }).catch(() => { });
184
200
  }
@@ -237,11 +253,12 @@ function handleInfo(res, nodeKeypair) {
237
253
  node_did: nodeKeypair.did,
238
254
  version: VERSION,
239
255
  protocol: PROTOCOL.VERSION,
256
+ protocol_hash: PROTOCOL_HASH, // ← cualquier modificación cambia este hash
240
257
  total_verified: Object.keys(nullifiers).length,
241
258
  total_reputation: Object.keys(repStore).length,
242
259
  known_peers: peers.length,
243
260
  supported_countries: ["CO"],
244
- capabilities: ["zk-verify", "anti-sybil", "co-sign", "bot-reputation", "p2p-gossipsub"],
261
+ capabilities: ["zk-verify", "anti-sybil", "co-sign", "bot-reputation", "p2p-gossipsub", "credential-validators", "anti-farming"],
245
262
  rate_limit: `${PROTOCOL.RATE_LIMIT_MAX} req/min per IP`,
246
263
  // P2P stats (Phase 5)
247
264
  p2p: p2pStats ? {
@@ -264,6 +281,11 @@ function handleInfo(res, nodeKeypair) {
264
281
  function handleProtocol(res) {
265
282
  json(res, 200, {
266
283
  protocol_version: PROTOCOL.VERSION,
284
+ // ── Protocol Hash — IDENTIDAD DE LA RED ────────────────────────────────
285
+ // Cualquier nodo con un hash diferente es rechazado automáticamente.
286
+ // Si PROTOCOL fue modificado (aunque sea un valor), este hash cambia.
287
+ protocol_hash: PROTOCOL_HASH,
288
+ // ── Score limits ────────────────────────────────────────────────────────
267
289
  score_floor: PROTOCOL.SCORE_FLOOR,
268
290
  verified_score_floor: PROTOCOL.VERIFIED_SCORE_FLOOR,
269
291
  min_attester_score: PROTOCOL.MIN_ATTESTER_SCORE,
@@ -271,11 +293,20 @@ function handleProtocol(res) {
271
293
  reputation_max: PROTOCOL.REPUTATION_MAX,
272
294
  max_score: PROTOCOL.MAX_SCORE,
273
295
  default_reputation: PROTOCOL.DEFAULT_REPUTATION,
296
+ // ── Biometric thresholds ────────────────────────────────────────────────
297
+ face_sim_doc_selfie: PROTOCOL.FACE_SIM_DOC_SELFIE,
298
+ face_sim_selfie_selfie: PROTOCOL.FACE_SIM_SELFIE_SELFIE,
299
+ face_key_dims: PROTOCOL.FACE_KEY_DIMS,
300
+ face_key_precision: PROTOCOL.FACE_KEY_PRECISION,
301
+ // ── Retry / timing ──────────────────────────────────────────────────────
274
302
  verify_retry_max: PROTOCOL.VERIFY_RETRY_MAX,
275
303
  verify_retry_base_ms: PROTOCOL.VERIFY_RETRY_BASE_MS,
276
304
  verify_retry_max_ms: PROTOCOL.VERIFY_RETRY_MAX_MS,
277
305
  att_max_age_seconds: PROTOCOL.ATT_MAX_AGE_SECONDS,
278
- immutable: true, // todas estas constantes son inamovibles por diseño
306
+ // ── Enforcement notice ──────────────────────────────────────────────────
307
+ immutable: true,
308
+ enforcement: "p2p-hash", // ← la red rechaza nodos con hash diferente
309
+ note: "Nodes with a different protocol_hash are rejected by the network. Modifying any constant changes the hash and isolates the node.",
279
310
  });
280
311
  }
281
312
  // ── GET /reputation/:did ──────────────────────────────────────────────────────
@@ -316,6 +347,21 @@ async function handleAttest(req, res, ip) {
316
347
  const { attestation, service_spt, from_peer } = body ?? {};
317
348
  if (!attestation)
318
349
  return json(res, 400, { error: "Missing field: attestation" });
350
+ // ── Protocol Hash Enforcement (gossip desde peers) ────────────────────────
351
+ // Si la attestation viene de un peer (X-Gossip: 1), validamos que el peer
352
+ // opera con las mismas constantes de protocolo.
353
+ // Un nodo con constantes modificadas no puede inyectar attestations en la red.
354
+ if (from_peer) {
355
+ const peerHash = req.headers["x-protocol-hash"];
356
+ if (peerHash && !isProtocolHashCompatible(peerHash)) {
357
+ console.warn(`[protocol] Gossip rechazado de ${ip} — hash incompatible: ${peerHash?.slice(0, 16)}...`);
358
+ return json(res, 409, {
359
+ error: "Protocol mismatch — gossip rejected",
360
+ our_hash: PROTOCOL_HASH,
361
+ their_hash: peerHash,
362
+ });
363
+ }
364
+ }
319
365
  const att = attestation;
320
366
  // ── Validaciones básicas de la attestation ────────────────────────────────
321
367
  if (typeof att.issuer_did !== "string")
@@ -360,16 +406,46 @@ async function handleAttest(req, res, ip) {
360
406
  return json(res, 403, { error: "Invalid attestation signature" });
361
407
  }
362
408
  // ── Aplicar y persistir ───────────────────────────────────────────────────
363
- const updatedRep = applyAttestation(att);
409
+ // ── ANTI-FARMING CHECK (solo para attestations positivas) ─────────────────
410
+ // Si detectamos farming, convertimos el +1 en -1 automáticamente.
411
+ // Las attestations negativas no se chequean (una penalización real no hace farming).
412
+ let finalAtt = att;
413
+ if (att.value === 1 && !from_peer) {
414
+ const existing = repStore[att.target_did];
415
+ const prevAtts = existing?.attestations ?? [];
416
+ // Reconstruir sesión desde el contexto de la attestation
417
+ const session = {
418
+ did: att.target_did,
419
+ startTime: (att.timestamp - 60) * 1000, // estimar inicio de sesión 60s antes
420
+ events: [], // no tenemos eventos individuales aquí — se evalúa en withTracking()
421
+ issuerDid: att.issuer_did,
422
+ };
423
+ const farmResult = checkFarming(session, prevAtts);
424
+ if (farmResult.isFarming) {
425
+ console.warn(`[anti-farming] 🚫 Farming detectado para ${att.target_did.slice(0, 20)}...`, `\n Razón: ${farmResult.reason}`, `\n Convirtiendo +1 → -1 automáticamente`);
426
+ // Penalizar en lugar de recompensar
427
+ finalAtt = { ...att, value: -1, context: `farming-penalty:${att.context}` };
428
+ recordFarmingStrike(att.target_did);
429
+ saveAudit();
430
+ }
431
+ else {
432
+ // Registrar ganancia aprobada para el tracking de velocidad
433
+ recordApprovedGain(att.target_did);
434
+ saveAudit();
435
+ }
436
+ }
437
+ const updatedRep = applyAttestation(finalAtt);
364
438
  // ── Gossip a los peers (async, fire-and-forget) ───────────────────────────
365
439
  if (!from_peer) {
366
- gossipAttestation(att, undefined);
440
+ gossipAttestation(finalAtt, undefined);
367
441
  }
368
442
  json(res, 200, {
369
443
  ok: true,
370
- target_did: att.target_did,
444
+ target_did: finalAtt.target_did,
371
445
  reputation: updatedRep,
372
446
  gossiped_to: from_peer ? 0 : peers.length,
447
+ farming_detected: finalAtt.value !== att.value,
448
+ ...(finalAtt.value !== att.value ? { farming_reason: finalAtt.context } : {}),
373
449
  });
374
450
  }
375
451
  // ── POST /peers/register ──────────────────────────────────────────────────────
@@ -381,16 +457,30 @@ async function handlePeerRegister(req, res) {
381
457
  catch (e) {
382
458
  return json(res, 400, { error: e.message });
383
459
  }
384
- const { url } = body ?? {};
460
+ const { url, protocol_hash } = body ?? {};
385
461
  if (!url || typeof url !== "string")
386
462
  return json(res, 400, { error: "Missing field: url" });
387
463
  if (!/^https?:\/\//.test(url))
388
464
  return json(res, 400, { error: "url must start with http:// or https://" });
465
+ // ── Protocol Hash Enforcement — INAMOVIBLE POR LA RED ────────────────────
466
+ // Si el peer envía un hash, DEBE coincidir con el nuestro.
467
+ // Si no envía hash → se acepta (nodos legacy / primeras versiones).
468
+ // En versiones futuras, el hash será OBLIGATORIO.
469
+ if (protocol_hash && !isProtocolHashCompatible(protocol_hash)) {
470
+ return json(res, 409, {
471
+ error: "Protocol mismatch — node rejected",
472
+ reason: "The peer is running with different protocol constants. This breaks network consensus.",
473
+ our_hash: PROTOCOL_HASH,
474
+ their_hash: protocol_hash,
475
+ our_version: PROTOCOL.VERSION,
476
+ resolution: "Update soulprint-network to the latest version, or join a compatible network.",
477
+ });
478
+ }
389
479
  if (peers.includes(url))
390
480
  return json(res, 200, { ok: true, peers: peers.length, msg: "Already registered" });
391
481
  peers.push(url);
392
482
  savePeers();
393
- json(res, 200, { ok: true, peers: peers.length });
483
+ json(res, 200, { ok: true, peers: peers.length, protocol_hash: PROTOCOL_HASH });
394
484
  }
395
485
  // ── GET /peers ─────────────────────────────────────────────────────────────────
396
486
  function handleGetPeers(res) {
@@ -487,10 +577,21 @@ export function startValidatorNode(port = PORT) {
487
577
  loadNullifiers();
488
578
  loadReputation();
489
579
  loadPeers();
580
+ loadAudit();
490
581
  const nodeKeypair = loadOrCreateNodeKeypair();
582
+ // ── Credential context (para el router de credenciales) ───────────────────
583
+ const credentialCtx = {
584
+ nodeKeypair,
585
+ signAttestation: (att) => {
586
+ const sig = sign(att, nodeKeypair.privateKey);
587
+ return { ...att, sig };
588
+ },
589
+ gossip: (att) => gossipAttestation(att, undefined),
590
+ };
491
591
  const server = createServer(async (req, res) => {
492
592
  const ip = getIP(req);
493
- const url = req.url?.split("?")[0] ?? "/";
593
+ const url = req.url ?? "/";
594
+ const cleanUrl = url.split("?")[0];
494
595
  res.setHeader("Access-Control-Allow-Origin", "*");
495
596
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
496
597
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
@@ -499,39 +600,54 @@ export function startValidatorNode(port = PORT) {
499
600
  res.end();
500
601
  return;
501
602
  }
502
- if (url === "/info" && req.method === "GET")
603
+ // ── Credential routes (email, phone, github) ───────────────────────────
604
+ if (cleanUrl.startsWith("/credentials/")) {
605
+ const handled = await handleCredentialRoute(req, res, url, credentialCtx);
606
+ if (handled)
607
+ return;
608
+ }
609
+ if (cleanUrl === "/info" && req.method === "GET")
503
610
  return handleInfo(res, nodeKeypair);
504
- if (url === "/protocol" && req.method === "GET")
611
+ if (cleanUrl === "/protocol" && req.method === "GET")
505
612
  return handleProtocol(res);
506
- if (url === "/verify" && req.method === "POST")
613
+ if (cleanUrl === "/verify" && req.method === "POST")
507
614
  return handleVerify(req, res, nodeKeypair, ip);
508
- if (url === "/reputation/attest" && req.method === "POST")
615
+ if (cleanUrl === "/reputation/attest" && req.method === "POST")
509
616
  return handleAttest(req, res, ip);
510
- if (url === "/peers/register" && req.method === "POST")
617
+ if (cleanUrl === "/peers/register" && req.method === "POST")
511
618
  return handlePeerRegister(req, res);
512
- if (url === "/peers" && req.method === "GET")
619
+ if (cleanUrl === "/peers" && req.method === "GET")
513
620
  return handleGetPeers(res);
514
- if (url.startsWith("/reputation/") && req.method === "GET")
515
- return handleGetReputation(res, decodeURIComponent(url.replace("/reputation/", "")));
516
- if (url.startsWith("/nullifier/") && req.method === "GET")
517
- return handleNullifierCheck(res, decodeURIComponent(url.replace("/nullifier/", "")));
621
+ if (cleanUrl.startsWith("/reputation/") && req.method === "GET")
622
+ return handleGetReputation(res, decodeURIComponent(cleanUrl.replace("/reputation/", "")));
623
+ if (cleanUrl.startsWith("/nullifier/") && req.method === "GET")
624
+ return handleNullifierCheck(res, decodeURIComponent(cleanUrl.replace("/nullifier/", "")));
518
625
  json(res, 404, { error: "Not found" });
519
626
  });
520
627
  server.listen(port, () => {
521
628
  console.log(`\n🌐 Soulprint Validator Node v${VERSION}`);
522
629
  console.log(` Node DID: ${nodeKeypair.did}`);
523
630
  console.log(` Listening: http://0.0.0.0:${port}`);
631
+ console.log(` Protocol: ${PROTOCOL.VERSION} | hash: ${PROTOCOL_HASH.slice(0, 16)}...`);
632
+ console.log(` ⚠️ Hash mismatch with peers → connection rejected (P2P enforcement)`);
524
633
  console.log(` Nullifiers: ${Object.keys(nullifiers).length}`);
525
634
  console.log(` Reputations: ${Object.keys(repStore).length}`);
526
635
  console.log(` Known peers: ${peers.length}`);
527
- console.log(`\n POST /verify verify ZK proof + co-sign`);
636
+ console.log(`\n Core endpoints:`);
637
+ console.log(` POST /verify verify ZK proof + co-sign`);
528
638
  console.log(` GET /info node info`);
639
+ console.log(` GET /protocol protocol constants (immutable)`);
529
640
  console.log(` GET /nullifier/:n anti-sybil check`);
530
- console.log(` POST /reputation/attest issue +1/-1 attestation`);
641
+ console.log(` POST /reputation/attest issue attestation (anti-farming ON)`);
531
642
  console.log(` GET /reputation/:did get bot reputation`);
532
- console.log(` POST /peers/register join P2P network`);
533
- console.log(` GET /peers list known peers`);
534
- console.log(`\n Anyone can run a Soulprint node. More nodes = more security.\n`);
643
+ console.log(`\n Credential validators (open source, no API keys needed):`);
644
+ console.log(` POST /credentials/email/start → email OTP (nodemailer)`);
645
+ console.log(` POST /credentials/email/verify`);
646
+ console.log(` POST /credentials/phone/start → TOTP device proof (otpauth)`);
647
+ console.log(` POST /credentials/phone/verify`);
648
+ console.log(` GET /credentials/github/start → GitHub OAuth (native fetch)`);
649
+ console.log(` GET /credentials/github/callback`);
650
+ console.log(`\n Anti-farming: ON — max +1/day, pattern detection, cooldowns\n`);
535
651
  });
536
652
  return server;
537
653
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulprint-network",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",
@@ -40,12 +40,16 @@
40
40
  "@libp2p/ping": "2.0.37",
41
41
  "@libp2p/tcp": "10.1.19",
42
42
  "libp2p": "2.10.0",
43
+ "nodemailer": "^8.0.1",
44
+ "otpauth": "^9.5.0",
45
+ "otplib": "^13.3.0",
43
46
  "uint8arrays": "5.1.0",
44
- "soulprint-core": "0.1.4",
45
- "soulprint-zkp": "0.1.3"
47
+ "soulprint-core": "0.1.7",
48
+ "soulprint-zkp": "0.1.4"
46
49
  },
47
50
  "devDependencies": {
48
51
  "@types/node": "^20.0.0",
52
+ "@types/nodemailer": "^7.0.11",
49
53
  "typescript": "^5.4.0"
50
54
  },
51
55
  "engines": {