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.
- package/dist/credentials/email.d.ts +33 -0
- package/dist/credentials/email.js +110 -0
- package/dist/credentials/github.d.ts +44 -0
- package/dist/credentials/github.js +148 -0
- package/dist/credentials/index.d.ts +34 -0
- package/dist/credentials/index.js +173 -0
- package/dist/credentials/phone.d.ts +42 -0
- package/dist/credentials/phone.js +99 -0
- package/dist/validator.d.ts +7 -0
- package/dist/validator.js +166 -30
- package/package.json +13 -9
|
@@ -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.d.ts
CHANGED
|
@@ -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 ??
|
|
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
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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(
|
|
405
|
+
gossipAttestation(finalAtt, undefined);
|
|
315
406
|
}
|
|
316
407
|
json(res, 200, {
|
|
317
408
|
ok: true,
|
|
318
|
-
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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
566
|
+
if (cleanUrl === "/reputation/attest" && req.method === "POST")
|
|
438
567
|
return handleAttest(req, res, ip);
|
|
439
|
-
if (
|
|
568
|
+
if (cleanUrl === "/peers/register" && req.method === "POST")
|
|
440
569
|
return handlePeerRegister(req, res);
|
|
441
|
-
if (
|
|
570
|
+
if (cleanUrl === "/peers" && req.method === "GET")
|
|
442
571
|
return handleGetPeers(res);
|
|
443
|
-
if (
|
|
444
|
-
return handleGetReputation(res, decodeURIComponent(
|
|
445
|
-
if (
|
|
446
|
-
return handleNullifierCheck(res, decodeURIComponent(
|
|
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
|
|
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
|
|
590
|
+
console.log(` POST /reputation/attest issue attestation (anti-farming ON)`);
|
|
460
591
|
console.log(` GET /reputation/:did get bot reputation`);
|
|
461
|
-
console.log(
|
|
462
|
-
console.log(`
|
|
463
|
-
console.log(
|
|
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.
|
|
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
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
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
|
+
}
|