I identified seven security vulnerabilities in the BRIX server. I am committed to working with the BRIX maintainers to resolve these issues responsibly.
Three vulnerabilities are externally exploitable with no authentication required and have been confirmed against the live instance at https://brix.brostr.app. One of these (VULN-7) is a full account takeover chain rated Critical (CVSS 10.0). The remaining four require internal access (database or deployment environment) but represent significant weaknesses in the PII protection model.
No data was exfiltrated. A test account (brixtest001@brix.brostr.app) was created solely to demonstrate the unauthenticated API findings and will not be used further.
BRIX — Lightning Address Router
https://brix.brostr.app — live production, tested 2026-03-20.
| ID | Title | Severity | CVSS | Vector |
|---|---|---|---|---|
| VULN-1 | Plaintext PII in brix_verifications.destination |
Medium | 4.4 | Internal |
| VULN-2 | PII and OTP codes leaked in server logs | Medium | 4.9 | Internal |
| VULN-3 | Missing encryption key silently disables PII protection | Medium | 6.0 | Internal |
| VULN-4 | HMAC-SHA256 brute-forceable for structured PII | Medium | 5.9 | Internal |
| VULN-5 | Unauthenticated PII disclosure via /brix/find-by-email |
High | 7.5 | External |
| VULN-6 | Unauthenticated phone → Nostr pubkey correlation via /brix/resolve |
High | 7.5 | External |
| VULN-7 | Full account takeover via unauthenticated update-contact |
Critical | 10.0 | External |
Severity: Medium — CVSS v3.1: 4.4 (AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:N/A:N)
While brix_users.phone and brix_users.email are encrypted at rest via AES-256-GCM, the brix_verifications table is not. Every OTP flow (register, resend, update-contact) writes the raw phone number or email to the destination column:
INSERT INTO brix_verifications (id, user_id, code, type, destination, expires_at)These rows persist in the database indefinitely.
A database dump or SQL injection would expose all phone numbers and emails that ever went through verification — effectively bypassing the encryption applied to brix_users.
Encrypt destination with the same AES-256-GCM scheme before writing. Decrypt only when needed for resend or display.
Severity: Medium — CVSS v3.1: 4.9 (AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:N/A:N)
Multiple console.log statements emit plaintext phone numbers, email addresses, and active OTP codes to stdout:
console.log(`[BRIX] Código de verificação para ${destination}: ${code}`);
console.log(`[BRIX] Novo código para ${destination}: ${code}`);
console.log(`[BRIX] Update-contact code for ${destination}: ${code}`);Anyone with log access (operators, compromised log aggregation pipelines, cloud provider staff) can harvest all phone numbers and emails processed by the system and read live OTP codes to complete verification flows — including account takeover via update-contact (see VULN-7).
Mask or omit destination from logs — log only the verification type and user ID. Never log OTP codes under any circumstance.
Severity: Medium — CVSS v3.1: 6.0 (AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:N)
Both encrypt() and hmacHash() contain a silent passthrough when BRIX_ENCRYPTION_KEY is not set:
if (!key) return plaintext; // passthrough if no key configuredA deployment without this environment variable stores all PII unencrypted while the application behaves normally — a classic fail-open pattern. There is no startup error, no warning, and no way to detect the condition from the stored data.
The entire PII protection layer is silently disabled by a single missing environment variable. Data stored during this window is permanently unprotected.
Throw a fatal error at startup when BRIX_ENCRYPTION_KEY is absent and PII features are in use. At minimum, emit a loud warning on every call to encrypt() or hmacHash() when the key is missing.
Severity: Medium — CVSS v3.1: 5.9 (AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N)
phone_hash and email_hash are computed with HMAC-SHA256:
crypto.createHmac('sha256', key).update(value).digest('hex')HMAC-SHA256 is designed for message authentication, not for hashing low-entropy secrets. It can be evaluated billions of times per second on commodity hardware. Phone numbers (structured, small keyspace) and emails (targetable by known domains) can be reversed exhaustively in seconds.
The preferred primitive is Argon2id (RFC 9106), winner of the Password Hashing Competition — intentionally slow and memory-hard, making offline brute-force infeasible even with full key and database access.
An attacker who compromises the key and the database can reverse every phone and email hash in the system in seconds.
Replace hmacHash() with Argon2id (e.g. via the argon2 npm package). Use a fixed per-deployment salt derived from BRIX_ENCRYPTION_KEY so lookups remain deterministic while brute-force cost stays high.
- CWE-916: Use of Password Hash With Insufficient Computational Effort
- CWE-327: Use of a Broken or Risky Cryptographic Algorithm
- RFC 2104: HMAC — Keyed-Hashing for Message Authentication — design intent is message authentication, not PII hashing
- RFC 9106: Argon2 Memory-Hard Function — the recommended replacement
Severity: High — CVSS v3.1: 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)
Affected file: server/routes/brix.js
The /brix/find-by-email/:email endpoint returns decrypted phone numbers and email addresses to any caller with no authentication check:
router.get('/find-by-email/:email', (req, res) => {
const email = req.params.email.trim().toLowerCase();
const db = getDb();
const user = db.prepare(
'SELECT username, phone, email, nostr_pubkey FROM brix_users WHERE email_hash = ? AND verified = 1'
).get(hmacHash(email));
if (!user) {
return res.status(404).json({ error: 'Nenhum BRIX encontrado' });
}
const domain = process.env.BRIX_DOMAIN || 'brix.app';
res.json({
brix_address: `${user.username}@${domain}`,
username: user.username,
phone: decrypt(user.phone),
email: decrypt(user.email),
has_web_pubkey: user.nostr_pubkey.startsWith('web_'),
});
});An attacker can enumerate known or leaked email addresses and receive the decrypted phone number for each matching account.
GET /brix/find-by-email/brix.monday100@passmail.com HTTP/1.1
Host: brix.brostr.app
(no authentication headers)
HTTP/1.1 200 OK
{
"brix_address": "brixtest001@brix.brostr.app",
"username": "brixtest001",
"phone": null,
"email": "brix.monday100@passmail.com",
"has_web_pubkey": true
}Decrypted email returned to unauthenticated caller. An account with a phone number would also expose the decrypted phone in the same response.
Full contact data (phone + email) exposed to any unauthenticated caller. Enables targeted PII harvesting at scale.
Require NIP-98 HTTP Auth — a Nostr event (kind 27235) signed by the user's private key and sent as an Authorization: Nostr <base64> header. This provides cryptographic proof of key ownership rather than a self-reported header value. Return contact data only when the verified pubkey matches the account owner.
- CWE-306: Missing Authentication for Critical Function
- CWE-359: Exposure of Private Personal Information to an Unauthorized Actor
Severity: High — CVSS v3.1: 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)
Affected file: server/routes/brix.js
The /brix/resolve/:query endpoint accepts a phone number or email and returns the associated Nostr public key with no authentication:
router.get('/resolve/:query', (req, res) => {
// ...
const rawPhone = query.replace(/\D/g, '');
if (rawPhone.length >= 8) {
const candidates = new Set([rawPhone]);
// ...
for (const candidate of candidates) {
user = db.prepare(
'SELECT username, nostr_pubkey FROM brix_users WHERE phone_hash = ? AND verified = 1'
).get(hmacHash(candidate));
if (user) {
return res.json({
found: true,
brix_address: `${user.username}@${domain}`,
username: user.username,
nostr_pubkey: user.nostr_pubkey,
matched_by: 'phone'
});
}
}
}
// ...
});GET /brix/resolve/brix.monday100@passmail.com HTTP/1.1
Host: brix.brostr.app
(no authentication headers)
HTTP/1.1 200 OK
{
"found": true,
"brix_address": "brixtest001@brix.brostr.app",
"username": "brixtest001",
"nostr_pubkey": "web_f51b74dacdf8779efbb002a059479cda",
"matched_by": "email"
}The same works for phone number lookups — an attacker enumerating a regional phone range (e.g. all +55 11 9xxxx-xxxx) receives a nostr_pubkey for every registered number.
Unauthenticated phone/email → Nostr pubkey correlation at scale. Deanonymizes users who intend to keep their phone identity separate from their crypto identity. Also enables the account takeover chain in VULN-7.
Require NIP-98 HTTP Auth for phone and email resolution paths. Username-only resolution can remain public since usernames are already public Lightning Addresses. Phone/email lookups must include an Authorization: Nostr <base64> header containing a kind-27235 event signed by the caller's private key — cryptographic proof of key ownership rather than a self-reported header value.
- CWE-306: Missing Authentication for Critical Function
- CWE-359: Exposure of Private Personal Information to an Unauthorized Actor
- CWE-203: Observable Discrepancy — endpoint confirms phone/email existence to unauthenticated callers
Severity: Critical — CVSS v3.1: 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N)
Affected file: server/routes/brix.js
The update-contact endpoint authenticates solely by checking that the x-nostr-pubkey header matches a record in the database:
const user = db.prepare(
'SELECT * FROM brix_users WHERE nostr_pubkey = ? AND verified = 1'
).get(nostr_pubkey);No cryptographic proof of key ownership is required. Any caller who knows (or can look up) a victim's nostr_pubkey can initiate a contact change. The OTP is sent to the new contact supplied by the caller — not to the victim's existing contact — so the attacker verifies it themselves.
- Obtain the victim's pubkey:
GET /brix/resolve/<victim_phone_or_email>→ returnsnostr_pubkey(unauthenticated, see VULN-6). - Initiate contact change:
POST /brix/update-contactwith the victim'snostr_pubkeyin the header and the attacker's own email in the body → OTP is sent to the attacker's inbox. - Confirm the change:
POST /brix/confirm-updatewith the attacker'snostr_pubkeyheader, the OTP, and the attacker's email → server overwrites the victim'semailandemail_hashinbrix_users. - Result: The victim's Lightning Address now resolves to attacker-controlled contact. The attacker can repeat the process to replace the phone number as well.
This chain is made possible by VULN-6 (unauthenticated pubkey resolution) but is independently exploitable if the pubkey is known by any other means (e.g. Nostr relays, prior API responses).
Full account takeover. The attacker replaces the victim's contact info, hijacks their Lightning Address, and can intercept incoming Bitcoin payments.
The x-nostr-pubkey header must be replaced with a real proof of key ownership — for example, a signed challenge via NIP-98 HTTP Auth. Until that is implemented, at minimum send the OTP to the existing contact for confirmation, so the legitimate account holder must approve the change.
- CWE-306: Missing Authentication for Critical Function
- CWE-620: Unverified Password Change
- CWE-640: Weak Password Recovery Mechanism for Forgotten Password
- NIP-98: HTTP Auth — the idiomatic Nostr solution for signing HTTP requests
This report follows a 90-day coordinated disclosure timeline:
- Acknowledgement of receipt requested within 7 days.
- A fix or mitigation plan within 90 days (by 2026-06-18).
- Credit in any public advisory, if the maintainers choose to publish one.
If no response is received within 14 days, the reporter reserves the right to escalate or publish.
Reporter: jaonoctus