npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@dotrino/identity

v0.114.1

Published

Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)

Downloads

8,720

Readme

@dotrino/identity

Parte del ecosistema Dotrino. Dotrino es un ecosistema de aplicaciones centradas en la privacidad de los datos: tu información es tuya, y las decisiones sobre ella también — qué compartes, con quién, cuándo y por qué. Sin anuncios, sin cookies, sin rastreo de datos, sin vender tu identidad a nadie.

Identidad de usuario y rating de peers compartidos entre las apps de Dotrino. Funciona aunque las apps vivan en orígenes distintos: usa un vault iframe alojado en un origin estable que guarda la información en su propio localStorage y expone una API por postMessage.

Cómo funciona

┌────────────────────┐    postMessage    ┌────────────────────────┐
│  app (cualquier    │ ◀───────────────▶ │  vault iframe          │
│  origin: chat,     │                   │  origin: id.dotrino    │
│  qrshare, chess…)  │                   │  .com                  │
│                    │                   │  - keypair ECDSA P-256 │
│  import {Identity} │                   │  - keypair ECDH P-256  │
└────────────────────┘                   │  - peers + ratings     │
                                         └────────────────────────┘

Como todas las apps cargan el vault desde el mismo origin, comparten el mismo localStorage aunque ellas estén en orígenes distintos. Las claves privadas nunca salen del vault — las apps reciben firmas (de la ECDSA) y plaintext descifrado (de la ECDH) pero nunca las llaves.

Dónde vive la identidad: web, Android, iOS

Uso para el usuario: El navegador y las apps instaladas (wiki). Portada: id.dotrino.com.

«Identidad Dotrino» es un concepto con tres implementaciones. Solo en Android es una app:

| Plataforma | Dónde están las llaves y los perfiles | Quién los comparte | |---|---|---| | Navegador | este paquete: el iframe id.dotrino.com (localStorage + IndexedDB de ese navegador, llaves WebCrypto no extraíbles) | todas las apps abiertas en ese navegador | | Android nativo | la app com.dotrino.identity (Keystore), servicio con permiso de nivel firma | las apps nativas firmadas con la misma llave de Play | | iOS nativo | no hay app: llavero P7G853375S.com.dotrino.shared + App Group group.com.dotrino, dentro de cada app | las apps del equipo P7G853375S |

El navegador y lo nativo NO se ven, en ninguna dirección. Chrome no puede enlazar el servicio de com.dotrino.identity (exige la firma de Play) y Safari no entra en los grupos del equipo; ninguna app nativa puede leer el almacén de un navegador. El WebView de una app nativa tampoco es el navegador: su iframe id.dotrino.com guarda en lo nativo por el puente (window.DotrinoIdentityKeys, nativeStore.js), no en Chrome ni en Safari.

Para la cuenta son dos aparatos distintos del acta (el navegador y el conjunto de apps nativas del teléfono). Se unen como cualquier par de aparatos: emparejando con la bóveda («Adoptar un perfil», vault.dotrino.com/d) o entrando con dirección y contraseña. No existe ni se construye un puente para copiar perfiles de un lado a otro. Detalle nativo en dotrino-native/docs/DISENO.md §2.

id.dotrino.com/ tiene dos papeles: dentro de un iframe es la bóveda (carga vault.js); abierta directo es la portada (no carga vault.js, para no tomar el candado del modo «este dispositivo es bóveda»).

Instalación

npm install @dotrino/identity

Uso

import { Identity } from '@dotrino/identity'

const id = await Identity.connect({
  vaultUrl: 'https://id.dotrino.com/'   // por defecto
})

console.log('my publickey JWK:', id.me.publickey)

// Identificar a un peer (handshake challenge/response)
const { nonce } = await id.makeChallenge()
// envía nonce al peer por el canal que sea (proxy, postMessage, etc.)
// el peer ejecuta await id.signChallenge(nonce) y te devuelve { nonce, publickey, signature }
const verification = await id.verifyResponse(response)
if (verification.ok) {
  console.log('peer verificado:', verification.publickey)
  await id.setNickname(verification.publickey, 'Bob de chess')
  await id.setRating(verification.publickey, 4, 'buen rival')
}

const peers = await id.listPeers()

Hosting del vault

Para que distintas apps compartan datos, todas deben apuntar al mismo vaultUrl. La carpeta vault/ de este paquete es un sitio estático (HTML + JS) listo para subir a:

  • Un dominio dedicado: id.dotrino.com
  • O temporalmente: https://id.dotrino.com/vault/

Importante: subir vía HTTPS y configurar Content-Security-Policy: frame-ancestors * (o lista de orígenes permitidos) para permitir que las apps lo embeban.

API

Identity.connect(options?)

Inicializa el iframe y resuelve cuando el vault está listo.

| opción | tipo | default | |--------------|----------|-------------------------------| | vaultUrl | string | https://id.dotrino.com/ | | timeoutMs | number | 5000 |

Identidad propia

  • id.me → { publickey, nickname? }
  • id.setMyNickname(nickname)

Handshake

  • id.makeChallenge() → { nonce }
  • id.signChallenge(nonce) → { nonce, publickey, encryptionPubkey, signature }
  • id.verifyResponse(response) → { ok, publickey?, encryptionPubkey?, peer? }

Peer book

  • id.getPeer(publickey)
  • id.setNickname(publickey, nickname)
  • id.setBlocked(publickey, blocked) — bloquear (0.108): privado, no se firma ni se publica; viaja con tus contactos (peer.blocked) a tu bóveda y tus aparatos. Cada app que recibe mensajes lo respeta.
  • id.setRating(publickey, rating, notes?) — produce un envelope firmado y lo guarda como peer.myRating (rating 0–5).
  • id.mergeEndorsements(subject, [signedRatings], askerPubkey?) — para web-of-trust: valida firmas, dedupea por (ratedBy, subject), cap 50.
  • id.getRatingsForSubject(subject) → { mine, endorsements } para responder a un RATING_QUERY.
  • id.recordQuery(askerPubkey, subject?) — contabiliza consultas para el suspicion modifier.
  • id.listPeers(), id.forgetPeer(publickey)

Contactos compartidos (0.6.0+)

Mismo registro de peers que arriba, pero filtrado por flag isContact: true. Cualquier app del ecosistema (chat, chess, messenger, extensión) puede añadir/leer contactos del mismo address book.

  • id.addContact({ publickey, nickname?, encryptionPubkey?, lastToken?, notes? })
  • id.updateContact(publickey, patch) — patch limitado a nickname, encryptionPubkey, lastToken, contactNotes.
  • id.removeContact(publickey) — quita el flag isContact pero conserva ratings/endorsements.
  • id.listContacts() → array filtrado, ordenado por lastSeen desc.

Encripción E2E (0.5.0+)

  • id.getEncryptionPubkey() → JWK string del propio peer.
  • id.encrypt(recipients, plaintext) → {v:1, iv, ct, wrap} envelope. recipients = [{token, encryptionPubkey}]. AES-256-GCM con clave efímera por mensaje, envuelta para cada destinatario vía ECDH(P-256). El campo token puede ser cualquier identificador estable (en messenger se usa la pubkey del destinatario para sobrevivir cambios de token del proxy).
  • id.decrypt(senderEncryptionPubkey, myToken, envelope) → { plaintext }. Forward-secrecy por mensaje (clave simétrica nueva cada vez).

Firma genérica (0.7.0+)

  • id.signData(data) → { signature, publickey } con encoding canonical-JSON. Lo usa el messenger para construir sobres identify que el proxy verifica con su verifySignatureWithJWK.

Prueba con destinatario (0.83.0+)

signData dice «esto lo firmé yo», y no dice para quién. Eso significaba que un sobre firmado para el proxio valía ante geo: los dos comprueban la misma firma de la misma identidad y ninguno tenía con qué notar que no le hablaban a él. La ventana de repetición evita que el mismo sobre se reenvíe dos veces al mismo sitio; el cruce de destinatario es otra cosa.

Una assertion es una firma normal del perfil sobre un cuerpo que además dice aud (para quién), nonce (para qué petición) e iat/exp (desde y hasta cuándo).

// quien PIDE genera el reto y dice quién es él
const nonce = newAssertionNonce()
const prueba = await id.requestAssertion({ audience: 'https://proxy.dotrino.com', nonce })

// quien RECIBE comprueba las dos cosas que él sabe y la prueba no puede inventar
import { verifyAssertion } from '@dotrino/identity/assertion'
const v = await verifyAssertion(prueba, { audience: 'https://proxy.dotrino.com', nonce })
// { ok, profileId, signer, seq, scopes, claims, aud, exp }  ·  o { ok:false, reason }
  • audience y nonce son obligatorios en las dos puntas. Sin ellos no hay nada que comparar, así que verifyAssertion responde no-audience / no-nonce en vez de dar por buena una prueba que no ha podido juzgar. No hay modo permisivo.
  • Vigencia corta (2 min por defecto, tope 5). El tope lo comprueba también quien recibe: fiarse del exp que puso el otro es fiarse de su buena fe.
  • Alcances, lista cerrada: id:whoami (el mínimo, solo quién eres), profile:name, profile:avatar, profile:email, profile:social. Un dato sin su alcance no se emite ni se acepta. Hoy solo viaja lo que el perfil ya comparte; la pantalla de permiso para conceder algo oculto a un destinatario concreto es la fase siguiente.
  • Verificar no necesita el iframe ni clave alguna: @dotrino/identity/assertion es un módulo puro y lo puede importar un servidor.

Y su hermana, para lo que se publica (0.84.0+). Un pin de geo o una atestación de reputación se firman y se sueltan: no hay nadie al otro lado que pueda dar un reto de antemano, así que ahí no cabe una prueba entera — lo que falta es el destinatario, y solo eso. Sin él, un pin firmado para geo lo acepta igual reputación.

import { verifySignedFor } from '@dotrino/identity/assertion'

const pin = { op: 'pin', aud: 'https://geo.dotrino.com', lat, lon, iat: Date.now() }
const { signature, publickey, chain } = await id.signData(pin)
// en el servidor:
const v = await verifySignedFor({ data: pin, signature, publickey, chain, audience: 'https://geo.dotrino.com' })

aud es obligatorio igual que en la prueba; exp es opcional, porque la caducidad de lo publicado la lleva el servicio (el TTL del pin) y no el cuerpo.

Si quien firma es un servicio de la cuenta (un miembro con cn), pásale ns con su cajón (0.114.0+): un servicio solo firma dentro del suyo, y sin ns no pasa nunca. Así el directorio de nombres acepta lo que firma su aparato names sin aceptar lo de cualquier otro servicio de la misma cuenta.

Permiso por origen (0.86.0+)

Una prueba dice quién eres sin preguntar nada: quien llega al iframe ya pasó el filtro de orígenes, y pedir permiso treinta veces al día por aplicaciones del mismo dueño es ceremonia, no seguridad. Cualquier dato tuyo —nombre, foto, correo, enlaces— es otra cosa: hace falta que lo concedas a ESE origen, y lo concedido se guarda y se puede retirar.

await id.listGrants()          // [{ origin, scopes, at, lastUsed, onBehalfOf? }]
await id.revokeGrant(origin)   // y la próxima vez se vuelve a preguntar

lastUsed se apunta cada vez que ese origen pide una prueba, aunque solo pida el mínimo: entrar es usar, lleve datos o no. Sin ese dato, «dónde se usó mi identidad» solo podría decir qué concediste, no si sigue usándose — que es lo que hace que uno se decida a retirar un permiso que ya no hace falta.

onBehalfOf lo declara el origen cuando pide por otro (lo usa el puente OIDC, que pide por una aplicación de fuera; sin esto todas ellas se verían como una sola entrada). Es una afirmación suya, no un hecho comprobable, así que se enseña siempre subordinada al origen — «Tal App · a través de sso.dotrino.com»— y nunca en su lugar.

El panel lo pinta la bóveda, no la aplicación. Vive en otro origen, así que la página que pide no puede pulsar ahí dentro ni leerlo; lo único que puede hacer es no mostrarlo, y entonces no consigue el permiso — que es el lado correcto en el que fallar. Sin nadie a quien preguntar (Node, o un cliente que no muestra nada), la respuesta es no: nunca se amplía en silencio.

Entrar sin enrolar: las sesiones (0.85.0+)

Enlazar un aparato y entrar en uno no son lo mismo. Enlazar mete una llave en el acta —hay que sellarla, o sea despertar a la selladora y tener el perfil abierto— y salir es sellar otra vez. Nadie hace eso para abrir una aplicación en un navegador prestado.

Una sesión es la otra puerta: una llave que vive en ese navegador y un papel con vencimiento que la respalda, firmado por un aparato tuyo que sí está en el acta.

import { openSession } from '@dotrino/identity/session-flow'   // el que entra
import { grantSession } from '@dotrino/identity/session-flow'  // el que respalda
import { verifySession } from '@dotrino/identity/session'      // quien recibe algo suyo

El QR va al revés que en el emparejamiento: lo muestra quien quiere entrar, y lo escanea el teléfono — quien entra puede no tener cámara, el teléfono siempre la tiene.

Lo que hace que esto no sea una llave maestra de repuesto:

  • Nunca amplía: cada alcance exige que el aparato que firmó lo tenga hoy, y se comprueba contra el acta al verificar, no solo al emitir.
  • Lista negra fija: jamás secrets, admin, approve, sealer, passwords, unattended ni replica, aunque el aparato los tenga.
  • Vence por reloj (8 h por defecto, tope 24). Es la excepción deliberada a los papeles ya no caducan por reloj: un certificado describe pertenencia, que dura; una sesión es temporal.
  • Muere con su aparato: si quitas del acta al que la respalda, sus sesiones caen solas. Sin avisar a nadie ni perseguir papeles.
  • No se re-delega: una sesión no abre otra sesión.

Diseño y hacia dónde va (inicio de sesión y federación): dotrino-vault/docs/inicio-de-sesion.md.

Backup / migración

  • id.exportIdentity() → blob JSON con privateJwk (ECDSA), encPrivateJwk (ECDH), me, peers. Sensible — el host app es responsable de guardarlo de manera segura.
  • id.importIdentity(blob) → reemplaza la identidad local. Soporta blobs v1 (sin ECDH) y v2 (con ambas keys).

Auto-sync con Google Drive (0.8.0+)

Backup automático y sincronización multi-dispositivo de la identidad (claves + contactos + ratings + endorsements) usando Google Drive como almacén opaco. Google nunca ve tus datos en claro: el blob se cifra en el navegador con AES-256-GCM y una clave derivada por PBKDF2 (600 000 iteraciones) de una passphrase elegida por el usuario.

Topología:

  • El blob se guarda en la carpeta especial appDataFolder de Drive (oculta para el usuario, no contamina su Drive).
  • Scope OAuth requerido: solo https://www.googleapis.com/auth/drive.appdata.
  • El cliente OAuth (Google Cloud Console → Web application) debe tener https://id.dotrino.com (y/o http://localhost:5173 para dev) como Authorized JavaScript Origin.
  • El sync corre dentro del iframe del vault: las claves privadas nunca cruzan el postMessage boundary.
// 1. Conectar Google (popup OAuth, una vez por origen)
await id.syncConnect('123456789-abc...apps.googleusercontent.com')

// 2. Desbloquear con passphrase (≥12 chars). Se guarda en sessionStorage del vault
//    (per-tab) y se borra al cerrar la pestaña.
await id.syncUnlock('mi-passphrase-larga-y-secreta')

// 3. A partir de aquí: pull-on-unlock + push debounced (5s) + pull periódico (2 min).
//    Cualquier mutación local (setRating, addContact, mergeEndorsements...) marca
//    dirty automáticamente.

// Eventos de estado
id.onSync(({ status, error }) => console.log('sync:', status, error || ''))
// status: connected | unlocked | syncing | synced | conflict | offline | error | locked | disconnected

// Forzar pull+push inmediato
await id.syncNow()

// Bloquear (limpia la passphrase de memoria)
await id.syncLock()

Estrategia de merge (al hacer pull, si remoto tiene cambios):

  • Keypairs ECDSA/ECDH: nunca se sobreescriben. Solo se adopta el remoto si local está vacío (primer setup en device nuevo).
  • Contact metadata (nickname, notes, encryptionPubkey, rating): last-writer-wins por lastSeen.
  • myRating: el envelope firmado con mayor issuedAt gana.
  • endorsements: unión dedup por ratedBy; firmas re-verificadas antes de aceptar.
  • Concurrencia: optimistic-lock con If-Match: <etag> en Drive. Si 412, pull → merge → push (3 reintentos).

Trade-off: si el usuario pierde la passphrase, el blob es irrecuperable. Es coherente con E2E — ni Google ni el desarrollador pueden recuperarlo.

Diseño

  • Una sola identidad por navegador, persistente entre apps que apuntan al mismo vault.
  • Sin servidor: todo en localStorage del vault.
  • Replay protection: cada makeChallenge registra el nonce; verifyResponse exige que el nonce sea reciente (≤ 5 min).
  • Privacidad: la clave privada vive solo en el vault, las apps nunca la ven.

Licencia

MIT