diff --git a/Dockerfile.encryptid b/Dockerfile.encryptid new file mode 100644 index 0000000..0a8f9bd --- /dev/null +++ b/Dockerfile.encryptid @@ -0,0 +1,51 @@ +# EncryptID Server Dockerfile +# Multi-stage build for optimized production image + +# Build stage +FROM oven/bun:1.1 AS builder + +WORKDIR /app + +# Copy package files +COPY package.json bun.lockb* ./ + +# Install dependencies +RUN bun install --frozen-lockfile --production=false + +# Copy source +COPY src/encryptid ./src/encryptid +COPY public ./public +COPY tsconfig.json ./ + +# Build (if needed - Bun can run TS directly) +# RUN bun build ./src/encryptid/server.ts --target=bun --outdir=./dist + +# Production stage +FROM oven/bun:1.1-slim + +WORKDIR /app + +# Copy from builder +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/src/encryptid ./src/encryptid +COPY --from=builder /app/public ./public +COPY --from=builder /app/package.json ./ + +# Create non-root user +RUN addgroup --system --gid 1001 encryptid && \ + adduser --system --uid 1001 encryptid +USER encryptid + +# Environment +ENV NODE_ENV=production +ENV PORT=3000 + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3000/health || exit 1 + +# Start server +CMD ["bun", "run", "src/encryptid/server.ts"] diff --git a/bun.lock b/bun.lock index 184c65b..eeb12af 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@automerge/automerge": "^2.2.8", "@lit/reactive-element": "^2.0.4", + "hono": "^4.11.7", "perfect-arrows": "^0.3.7", "perfect-freehand": "^1.2.2", }, @@ -165,6 +166,8 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "perfect-arrows": ["perfect-arrows@0.3.7", "", {}, "sha512-wEN2gerTPVWl3yqoFEF8OeGbg3aRe2sxNUi9rnyYrCsL4JcI6K2tBDezRtqVrYG0BNtsWLdYiiTrYm+X//8yLQ=="], diff --git a/docker-compose.encryptid.yml b/docker-compose.encryptid.yml new file mode 100644 index 0000000..ea91b30 --- /dev/null +++ b/docker-compose.encryptid.yml @@ -0,0 +1,37 @@ +# EncryptID Docker Compose +# Deploy with: docker compose -f docker-compose.encryptid.yml up -d + +services: + encryptid: + build: + context: . + dockerfile: Dockerfile.encryptid + container_name: encryptid + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=3000 + - JWT_SECRET=${JWT_SECRET:-change-this-in-production} + labels: + # Traefik auto-discovery + - "traefik.enable=true" + - "traefik.http.routers.encryptid.rule=Host(`encryptid.jeffemmett.com`)" + - "traefik.http.routers.encryptid.entrypoints=websecure" + - "traefik.http.routers.encryptid.tls=true" + - "traefik.http.services.encryptid.loadbalancer.server.port=3000" + # Also serve from root domain for .well-known + - "traefik.http.routers.encryptid-wellknown.rule=Host(`jeffemmett.com`) && PathPrefix(`/.well-known/webauthn`)" + - "traefik.http.routers.encryptid-wellknown.entrypoints=websecure" + - "traefik.http.routers.encryptid-wellknown.tls=true" + networks: + - traefik-public + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +networks: + traefik-public: + external: true diff --git a/docs/ENCRYPTID-SPECIFICATION.md b/docs/ENCRYPTID-SPECIFICATION.md new file mode 100644 index 0000000..4155704 --- /dev/null +++ b/docs/ENCRYPTID-SPECIFICATION.md @@ -0,0 +1,950 @@ +# EncryptID: Unified Identity System for the r-Ecosystem + +*Version 0.1 - February 2026* + +--- + +## Executive Summary + +**EncryptID** is a unified, self-sovereign identity system designed to work across all r-ecosystem applications (rspace.online, rwallet, rvote, rmaps, rfiles). It combines the security of hardware-backed authentication with the usability of passkeys, the flexibility of derived cryptographic keys, and the power of account abstraction smart wallets. + +**Core Principles:** +- **No seed phrases** - Social recovery replaces mnemonic backup +- **Hardware-backed security** - WebAuthn/passkeys as the root of trust +- **Client-side encryption** - Keys never leave the user's device +- **Cross-app identity** - One login for all r-ecosystem apps +- **Web3 native** - Integrated smart wallet for on-chain operations + +--- + +## Architecture Overview + +``` ++============================================================================+ +|| ENCRYPTID ARCHITECTURE || ++============================================================================+ + ++----------------------------------------------------------------------------+ +| LAYER 5: APPLICATION LAYER | +| | +| +-------------+ +-------------+ +-------------+ +-------------+ | +| | rspace | | rwallet | | rvote | | rfiles | | +| | (canvas) | | (treasury) | | (voting) | | (storage) | | +| +------+------+ +------+------+ +------+------+ +------+------+ | +| | | | | | ++----------|----------------|----------------|----------------|-------------+ + | | | | + v v v v ++----------------------------------------------------------------------------+ +| LAYER 4: SESSION & SSO LAYER | +| | +| +--------------------------------------------------------------------+ | +| | EncryptID Session Service | | +| | | | +| | - JWT tokens (short-lived, refresh rotation) | | +| | - Cross-app SSO via Related Origin Requests | | +| | - Per-app capability scoping | | +| | - Session key caching for UX | | +| +--------------------------------------------------------------------+ | +| | ++--------------------------------+-------------------------------------------+ + | + v ++----------------------------------------------------------------------------+ +| LAYER 3: SMART WALLET LAYER (AA) | +| | +| +--------------------------------------------------------------------+ | +| | Account Abstraction Smart Wallet (ERC-4337) | | +| | | | +| | +------------------+ +------------------+ +------------------+ | | +| | | Passkey Signer | | Session Keys | | Social Recovery | | | +| | | (WebAuthn P256) | | (time-limited) | | (Guardian Module)| | | +| | +------------------+ +------------------+ +------------------+ | | +| | | | +| | Features: | | +| | - Gasless transactions (paymaster) | | +| | - Batched operations | | +| | - Spending limits & policies | | +| | - No seed phrase needed | | +| +--------------------------------------------------------------------+ | +| | ++--------------------------------+-------------------------------------------+ + | + v ++----------------------------------------------------------------------------+ +| LAYER 2: DERIVED KEYS LAYER | +| | +| +--------------------------------------------------------------------+ | +| | WebCrypto Key Derivation (HKDF) | | +| | | | +| | Source: WebAuthn PRF output OR passphrase-derived master key | | +| | | | +| | +---------------+ +---------------+ +---------------+ | | +| | | Encryption | | Signing | | DID | | | +| | | Key (AES-256) | | Key (P-256) | | Key (Ed25519) | | | +| | +-------+-------+ +-------+-------+ +-------+-------+ | | +| | | | | | | +| | v v v | | +| | +---------------+ +---------------+ +---------------+ | | +| | | rfiles E2E | | rvote ballot | | did:key:z6Mk | | | +| | | rspace boards | | signatures | | identity | | | +| | +---------------+ +---------------+ +---------------+ | | +| +--------------------------------------------------------------------+ | +| | ++--------------------------------+-------------------------------------------+ + | + v ++----------------------------------------------------------------------------+ +| LAYER 1: PRIMARY AUTHENTICATION | +| | +| +--------------------------------------------------------------------+ | +| | WebAuthn / Passkeys | | +| | | | +| | +------------------+ +------------------+ +------------------+ | | +| | | Platform Passkey | | Security Key | | Cross-Device | | | +| | | (iCloud/Google) | | (YubiKey/Titan) | | (QR/Bluetooth) | | | +| | +------------------+ +------------------+ +------------------+ | | +| | | | +| | Properties: | | +| | - Phishing-resistant (origin-bound) | | +| | - Hardware-backed (TPM/Secure Enclave) | | +| | - Biometric (fingerprint/face) | | +| | - Syncs across devices (platform providers) | | +| +--------------------------------------------------------------------+ | +| | ++============================================================================+ +``` + +--- + +## Layer 1: Primary Authentication (WebAuthn/Passkeys) + +### Why WebAuthn as the Foundation + +| Property | Benefit | +|----------|---------| +| **Origin-bound** | Credentials only work on the registered domain - phishing-resistant | +| **Hardware-backed** | Private key stored in TPM/Secure Enclave - can't be extracted | +| **Biometric** | Face/fingerprint verification - something you *are* | +| **Synced** | Platform passkeys sync via iCloud Keychain, Google Password Manager | +| **No passwords** | Nothing to remember, nothing to steal | + +### Implementation + +```typescript +// EncryptID Registration Flow +async function registerEncryptID(username: string): Promise { + const challenge = await fetchChallenge('/api/encryptid/register/challenge'); + + const credential = await navigator.credentials.create({ + publicKey: { + challenge: base64ToBuffer(challenge), + rp: { + name: "EncryptID", + id: "encryptid.online" // Shared RP ID for all r-apps + }, + user: { + id: crypto.getRandomValues(new Uint8Array(32)), + name: username, + displayName: username + }, + pubKeyCredParams: [ + { alg: -7, type: "public-key" }, // ES256 (P-256) + { alg: -257, type: "public-key" } // RS256 (fallback) + ], + authenticatorSelection: { + residentKey: "required", // Discoverable credential + userVerification: "required", // Biometric/PIN required + authenticatorAttachment: "platform" // Prefer built-in authenticator + }, + attestation: "none", // Privacy-preserving + extensions: { + prf: { // PRF extension for key derivation + eval: { + first: base64ToBuffer(await generateSalt("encryptid-master")) + } + }, + credProps: true // Get credential properties + } + } + }); + + return processRegistration(credential); +} +``` + +### PRF Extension Support Status (2026) + +| Platform | Browser | PRF Support | Notes | +|----------|---------|-------------|-------| +| **Windows** | Chrome/Edge | Full | TPM-backed | +| **macOS** | Chrome/Safari | Full | Secure Enclave | +| **Android** | Chrome | Full | TEE/StrongBox | +| **iOS/iPadOS** | Safari | Partial | Works with iCloud Keychain passkeys | +| **Linux** | Chrome | Varies | Depends on FIDO2 library | + +**Fallback Strategy:** For authenticators without PRF support, derive master key from a user-provided passphrase using Argon2id + HKDF. + +### Related Origin Requests for Cross-App SSO + +Since r-apps span multiple domains, we use [Related Origin Requests](https://passkeys.dev/docs/advanced/related-origins/): + +``` +// File: https://encryptid.online/.well-known/webauthn +{ + "origins": [ + "https://rspace.online", + "https://rwallet.online", + "https://rvote.online", + "https://rmaps.online", + "https://rfiles.online", + "https://app.rspace.online", + "https://dev.rspace.online" + ] +} +``` + +This allows a passkey registered with RP ID `encryptid.online` to authenticate on any of these origins. + +**Browser Support:** +- Chrome: Full support +- Safari: Full support +- Firefox: Under consideration (use iframe fallback) + +--- + +## Layer 2: Derived Keys (WebCrypto API) + +### Key Derivation Hierarchy + +``` + +---------------------------+ + | WebAuthn PRF Output | + | (32 bytes from HMAC) | + +-------------+-------------+ + | + v + +---------------------------+ + | Master Key | + | (HKDF extract step) | + +-------------+-------------+ + | + +-----------------------+-----------------------+ + | | | + v v v ++-------------------+ +-------------------+ +-------------------+ +| Encryption Key | | Signing Key | | DID Key | +| (AES-256-GCM) | | (ECDSA P-256) | | (Ed25519) | ++-------------------+ +-------------------+ +-------------------+ + | | | + v v v ++-------------------+ +-------------------+ +-------------------+ +| rfiles encryption | | rvote signatures | | did:key identity | +| rspace E2E boards | | rspace authorship | | verifiable creds | ++-------------------+ +-------------------+ +-------------------+ +``` + +### Implementation + +```typescript +// Key Derivation from PRF output +class EncryptIDKeyDerivation { + private masterKey: CryptoKey; + + async initFromPRF(prfOutput: ArrayBuffer): Promise { + // Import PRF output as HKDF key material + this.masterKey = await crypto.subtle.importKey( + "raw", + prfOutput, + { name: "HKDF" }, + false, + ["deriveKey", "deriveBits"] + ); + } + + async initFromPassphrase(passphrase: string, salt: Uint8Array): Promise { + // Fallback: derive from passphrase using PBKDF2 + const encoder = new TextEncoder(); + const passphraseKey = await crypto.subtle.importKey( + "raw", + encoder.encode(passphrase), + { name: "PBKDF2" }, + false, + ["deriveBits", "deriveKey"] + ); + + const masterBits = await crypto.subtle.deriveBits( + { + name: "PBKDF2", + salt: salt, + iterations: 600000, // OWASP 2023 recommendation + hash: "SHA-256" + }, + passphraseKey, + 256 + ); + + this.masterKey = await crypto.subtle.importKey( + "raw", + masterBits, + { name: "HKDF" }, + false, + ["deriveKey", "deriveBits"] + ); + } + + async deriveEncryptionKey(): Promise { + return crypto.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: new TextEncoder().encode("encryptid-encryption-v1"), + info: new TextEncoder().encode("AES-256-GCM") + }, + this.masterKey, + { name: "AES-GCM", length: 256 }, + false, // Not extractable + ["encrypt", "decrypt", "wrapKey", "unwrapKey"] + ); + } + + async deriveSigningKeyPair(): Promise { + // Derive deterministic seed for signing key + const seed = await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: new TextEncoder().encode("encryptid-signing-v1"), + info: new TextEncoder().encode("ECDSA-P256") + }, + this.masterKey, + 256 + ); + + // Use seed to generate deterministic P-256 key + // (Implementation uses noble-curves or similar) + return generateP256FromSeed(seed); + } + + async deriveDIDKey(): Promise<{ did: string; privateKey: Uint8Array }> { + // Derive Ed25519 key for DID + const seed = await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: new TextEncoder().encode("encryptid-did-v1"), + info: new TextEncoder().encode("Ed25519") + }, + this.masterKey, + 256 + ); + + // Generate Ed25519 keypair from seed + const keyPair = ed25519.generateKeyPairFromSeed(new Uint8Array(seed)); + + // Format as did:key + const did = `did:key:${base58btc.encode( + new Uint8Array([0xed, 0x01, ...keyPair.publicKey]) + )}`; + + return { did, privateKey: keyPair.privateKey }; + } +} +``` + +### Key Usage by Application + +| Application | Encryption Key | Signing Key | DID Key | Wallet | +|-------------|----------------|-------------|---------|--------| +| **rspace** | Private boards, E2E content | Document signatures, authorship | Cross-app identity | Premium features | +| **rwallet** | - | Transaction signing | Identity verification | Treasury ops | +| **rvote** | Encrypted ballot metadata | Ballot signatures | Voter identity | On-chain voting | +| **rmaps** | Private location data | Contribution signatures | Contributor identity | - | +| **rfiles** | E2E file encryption | File signatures | Sharing identity | Storage payments | + +--- + +## Layer 3: Smart Wallet (Account Abstraction) + +### Why Account Abstraction? + +Traditional EOA (Externally Owned Account) wallets have critical UX problems: +- Seed phrases are confusing and easily lost +- No built-in recovery mechanism +- Gas required for every transaction +- Single point of failure (one key = full access) + +Account Abstraction (ERC-4337) smart wallets solve these: + +| Feature | EOA Wallet | AA Smart Wallet | +|---------|-----------|-----------------| +| **Key management** | Seed phrase | Passkey + social recovery | +| **Gas** | User pays ETH | Paymaster can sponsor | +| **Recovery** | Seed phrase or nothing | Guardian-based recovery | +| **Policies** | None | Spending limits, time locks | +| **Batching** | One tx at a time | Multiple ops in one tx | + +### Provider Comparison + +| Provider | Passkey Support | Social Recovery | Pricing | Best For | +|----------|-----------------|-----------------|---------|----------| +| **[ZeroDev](https://zerodev.app/)** | Native (Turnkey) | Guardian module | Usage-based | Full control, custom flows | +| **[Safe](https://safe.global/)** | Passkey module | Multi-sig native | Free (gas only) | DAOs, shared treasuries | +| **[Privy](https://privy.io/)** | Native | Managed recovery | Enterprise pricing | Quick integration | +| **[Turnkey](https://turnkey.com/)** | Native (TEE) | Custom | Volume-based | Infrastructure layer | + +### Recommended Architecture: ZeroDev + Safe Hybrid + +``` ++============================================================================+ +| ENCRYPTID SMART WALLET ARCHITECTURE | ++============================================================================+ + + +---------------------------+ + | User's Passkey | + | (WebAuthn P-256 on device)| + +-------------+-------------+ + | + v + +---------------------------+ + | Turnkey TEE Signer | + | (Secure enclave signing) | + +-------------+-------------+ + | + +------------------+------------------+ + | | + v v ++-------------------------------+ +-------------------------------+ +| Personal Smart Wallet | | Community Treasury (Safe) | +| (ZeroDev Kernel) | | Multi-sig Wallet | ++-------------------------------+ +-------------------------------+ +| | | | +| Owner: Passkey Validator | | Signers: | +| | | - User's EncryptID | +| Modules: | | - Other community members | +| - Session Key (daily ops) | | - Guardian (recovery) | +| - Recovery (guardians) | | | +| - Spending Limits | | Threshold: 2 of 3 | +| | | | ++-------------------------------+ +-------------------------------+ + | | + v v ++-------------------------------+ +-------------------------------+ +| Use Cases: | | Use Cases: | +| - Personal rwallet | | - Community rwallet | +| - rvote governance | | - Shared treasury | +| - rfiles payments | | - Grant disbursement | ++-------------------------------+ +-------------------------------+ +``` + +### Implementation + +```typescript +import { createKernelAccount, createKernelAccountClient } from "@zerodev/sdk"; +import { toWebAuthnKey, toPasskeyValidator } from "@zerodev/passkey-validator"; +import { toECDSASigner } from "@zerodev/permissions"; + +class EncryptIDWallet { + private kernelAccount: KernelAccount; + private kernelClient: KernelAccountClient; + + async initialize(webAuthnCredential: PublicKeyCredential): Promise { + // Create WebAuthn-based validator + const webAuthnKey = await toWebAuthnKey({ + passkeyName: "EncryptID", + passkeyServerUrl: "https://encryptid.online/passkey", + credential: webAuthnCredential + }); + + const passkeyValidator = await toPasskeyValidator(publicClient, { + webAuthnKey, + entryPoint: ENTRYPOINT_ADDRESS_V07, + kernelVersion: KERNEL_V3_1 + }); + + // Create kernel account with passkey as owner + this.kernelAccount = await createKernelAccount(publicClient, { + plugins: { + sudo: passkeyValidator // Passkey has full control + }, + entryPoint: ENTRYPOINT_ADDRESS_V07, + kernelVersion: KERNEL_V3_1 + }); + + // Create client with paymaster for gasless UX + this.kernelClient = createKernelAccountClient({ + account: this.kernelAccount, + chain: base, // or arbitrum, optimism, etc. + bundlerTransport: http(BUNDLER_URL), + paymaster: createZeroDevPaymasterClient({ + chain: base, + transport: http(PAYMASTER_URL) + }) + }); + + return this.kernelAccount.address; + } + + // Create session key for frictionless UX + async createSessionKey(permissions: Permission[], duration: number): Promise { + const sessionKey = generatePrivateKey(); + const sessionSigner = privateKeyToAccount(sessionKey); + + const sessionKeyValidator = await toPermissionValidator(publicClient, { + entryPoint: ENTRYPOINT_ADDRESS_V07, + kernelVersion: KERNEL_V3_1, + signer: toECDSASigner({ signer: sessionSigner }), + policies: [ + toCallPolicy({ + permissions: permissions // e.g., only call specific contracts + }), + toTimestampPolicy({ + validUntil: Math.floor(Date.now() / 1000) + duration + }), + toSpendingLimitPolicy({ + limits: [{ token: ETH_ADDRESS, limit: parseEther("0.1") }] + }) + ] + }); + + // User approves session key with passkey (one biometric prompt) + await this.kernelClient.installModule({ + module: sessionKeyValidator.address, + data: sessionKeyValidator.initData + }); + + return { privateKey: sessionKey, validator: sessionKeyValidator }; + } +} +``` + +--- + +## Layer 4: Social Recovery (No Seed Phrases!) + +### Design Philosophy + +> "The goal of social recovery is to avoid a single point of failure. If you lose your device, you should not lose your assets. If your guardians conspire against you, they should not be able to steal your assets." +> — [Vitalik Buterin](https://vitalik.eth.limo/general/2021/01/11/recovery.html) + +### Guardian Configuration + +``` ++============================================================================+ +| ENCRYPTID SOCIAL RECOVERY MODEL | ++============================================================================+ + + +---------------------------+ + | Recovery Threshold | + | 3 of 5 guardians | + +---------------------------+ + | + +------------+------------+------------+------------+ + | | | | | + v v v v v ++-------------+ +-------------+ +-------------+ +-------------+ +-------------+ +| Guardian 1 | | Guardian 2 | | Guardian 3 | | Guardian 4 | | Guardian 5 | ++-------------+ +-------------+ +-------------+ +-------------+ +-------------+ +| | | | | | | | | | +| Secondary | | Trusted | | Trusted | | Hardware | | Institutional| +| Passkey | | Friend A | | Friend B | | Key | | Guardian | +| (YubiKey) | | (their | | (their | | (offline | | (service) | +| | | EncryptID) | | EncryptID) | | backup) | | | ++-------------+ +-------------+ +-------------+ +-------------+ +-------------+ + +Recovery Rules: +- Time-lock: 48-hour delay before recovery completes +- Notification: User notified immediately when recovery initiated +- Cancellation: User can cancel with any valid authenticator +- Guardian privacy: Guardians don't know each other's identities +``` + +### Guardian Types + +| Type | Description | Pros | Cons | +|------|-------------|------|------| +| **Secondary Passkey** | Another device you own (YubiKey, second phone) | Always available, you control | Can lose both devices | +| **Trusted Contact** | Friend/family with EncryptID | Human judgment, diverse access | Relationship changes | +| **Hardware Key** | Offline YubiKey stored securely | Immune to remote attacks | Physical access needed | +| **Institutional** | Service provider (e.g., rspace.online) | Professional, always available | Trust in service | +| **Time-delayed Self** | Your own auth after N days | No external trust needed | Attacker has same delay | + +### Implementation + +```typescript +// ZeroDev Recovery Module +class EncryptIDRecovery { + async setupGuardians(guardians: Guardian[]): Promise { + const recoveryConfig = { + threshold: 3, // 3 of 5 required + delay: 48 * 60 * 60, // 48 hours + guardians: guardians.map(g => ({ + address: g.address, + weight: g.weight || 1 + })) + }; + + const recoveryPlugin = await createRecoveryPlugin(recoveryConfig); + + await this.kernelClient.installModule({ + module: recoveryPlugin.address, + data: recoveryPlugin.initData + }); + } + + async initiateRecovery( + newOwner: Address, + guardianSignatures: Signature[] + ): Promise { + // Guardian signs recovery request + // This starts the time-lock countdown + const recoveryId = await this.kernelClient.sendUserOperation({ + userOperation: { + callData: encodeFunctionData({ + abi: recoveryAbi, + functionName: 'initiateRecovery', + args: [newOwner, guardianSignatures] + }) + } + }); + + // Notify user through all channels + await notifyUserOfRecovery(this.kernelAccount.address, recoveryId); + + return { recoveryId, completesAt: Date.now() + 48 * 60 * 60 * 1000 }; + } + + async completeRecovery(recoveryId: string): Promise { + // After time-lock, anyone can complete + await this.kernelClient.sendUserOperation({ + userOperation: { + callData: encodeFunctionData({ + abi: recoveryAbi, + functionName: 'completeRecovery', + args: [recoveryId] + }) + } + }); + } + + async cancelRecovery(recoveryId: string): Promise { + // User can cancel with any valid auth method + await this.kernelClient.sendUserOperation({ + userOperation: { + callData: encodeFunctionData({ + abi: recoveryAbi, + functionName: 'cancelRecovery', + args: [recoveryId] + }) + } + }); + } +} +``` + +### Guardian Privacy + +Guardians don't need to know each other - this prevents collusion: + +```typescript +// Store guardian list as hash on-chain +const guardianListHash = keccak256( + encodePacked(['address[]'], [guardianAddresses.sort()]) +); + +// During recovery, reveal full list +function verifyGuardians(addresses: Address[], hash: bytes32): boolean { + return keccak256(encodePacked(['address[]'], [addresses.sort()])) === hash; +} +``` + +### Best Practices (per [Vitalik](https://vitalik.eth.limo/general/2021/01/11/recovery.html)) + +1. **High guardian count (5-7)** for better security +2. **Diverse social circles** - family, friends, colleagues, institutions +3. **Privacy-preserving** - guardians don't know each other +4. **Time-lock** - gives user window to cancel malicious recovery +5. **Regular verification** - periodically confirm guardians are reachable + +--- + +## Layer 5: Session & Cross-App SSO + +### Session Architecture + +``` ++============================================================================+ +| ENCRYPTID SESSION FLOW | ++============================================================================+ + + User encryptid.online r-app (e.g., rspace) + | | | + | 1. Visit rspace.online | | + |------------------------>| | + | | 2. Check for EncryptID | + | | session cookie | + | |----------------------------->| + | | | + | 3. No session, redirect to encryptid.online/auth | + |<-------------------------------------------------------| + | | | + | 4. WebAuthn authenticate | + |------------------------>| | + | | | + | 5. Derive session keys (if needed) | + |<------------------------| | + | | | + | 6. Issue session token | | + | (JWT + refresh) | | + |<------------------------| | + | | | + | 7. Redirect back with auth code | + |------------------------------------------------------>| + | | | + | | 8. Exchange code for tokens | + | |<-----------------------------| + | | | + | 9. Session established, user authenticated | + |<-------------------------------------------------------| +``` + +### Token Structure + +```typescript +interface EncryptIDSession { + // Standard JWT claims + iss: "https://encryptid.online"; + sub: string; // DID (did:key:z6Mk...) + aud: string[]; // Authorized apps ["rspace.online", "rwallet.online"] + iat: number; + exp: number; // Short-lived (15 min) + + // EncryptID-specific claims + eid: { + walletAddress?: string; // AA wallet if deployed + credentialId: string; // WebAuthn credential ID + authLevel: 1 | 2 | 3 | 4; // Security level of this session + capabilities: { + encrypt: boolean; // Has derived encryption key + sign: boolean; // Has derived signing key + wallet: boolean; // Can authorize wallet ops + }; + recoveryConfigured: boolean; // Has social recovery set up + }; +} +``` + +### Security Levels + +```typescript +enum AuthLevel { + // Level 1: Session token only (cookie) + // Can: View public content, read-only operations + // Cannot: Modify data, sign anything + BASIC = 1, + + // Level 2: Recent WebAuthn (within 15 min) + // Can: Create/edit content, standard operations + // Cannot: High-value transactions, key operations + STANDARD = 2, + + // Level 3: Fresh WebAuthn (just authenticated) + // Can: Sign votes, approve transactions + // Cannot: Recovery operations, export keys + ELEVATED = 3, + + // Level 4: Fresh WebAuthn + explicit consent + // Can: Everything including recovery, key export + CRITICAL = 4 +} + +// Operation requirements +const operationLevels = { + 'rspace:view-public': AuthLevel.BASIC, + 'rspace:edit-board': AuthLevel.STANDARD, + 'rspace:encrypt-board': AuthLevel.ELEVATED, + + 'rwallet:view-balance': AuthLevel.BASIC, + 'rwallet:send-small': AuthLevel.STANDARD, + 'rwallet:send-large': AuthLevel.ELEVATED, + 'rwallet:add-guardian': AuthLevel.CRITICAL, + + 'rvote:view-proposals': AuthLevel.BASIC, + 'rvote:cast-vote': AuthLevel.ELEVATED, + + 'rfiles:download-own': AuthLevel.STANDARD, + 'rfiles:upload': AuthLevel.STANDARD, + 'rfiles:share': AuthLevel.ELEVATED, + 'rfiles:export-keys': AuthLevel.CRITICAL +}; +``` + +--- + +## Migration Path from CryptID + +### Phase 1: Parallel Systems (Months 1-2) + +``` ++----------------------------------+ +| Current CryptID | +| (WebCrypto keypairs in IDB) | ++----------------------------------+ + || + || Users can link + || EncryptID passkey + vv ++----------------------------------+ +| EncryptID | +| (WebAuthn + derived keys) | ++----------------------------------+ + +- Existing CryptID users continue working +- Optional: Link EncryptID passkey to existing account +- New users default to EncryptID +- Both auth methods valid during transition +``` + +### Phase 2: Key Migration (Months 2-3) + +```typescript +async function migrateCryptIDToEncryptID( + cryptidKeyPair: CryptoKeyPair, + encryptidMasterKey: CryptoKey +): Promise { + // 1. Export CryptID private key (if exportable) + // or re-encrypt data with new keys + + // 2. For each encrypted item: + // - Decrypt with CryptID key + // - Re-encrypt with EncryptID-derived key + + // 3. Update identity mappings + // - Link old CryptID public key to new DID + // - Maintain backward compatibility + + // 4. Mark migration complete +} +``` + +### Phase 3: CryptID Sunset (Months 3-6) + +- New accounts: EncryptID only +- Existing accounts: Prompted to upgrade +- CryptID becomes legacy fallback +- Eventually: Remove CryptID support + +--- + +## Security Considerations + +### Threat Model + +| Threat | Mitigation | +|--------|------------| +| **Phishing** | WebAuthn is origin-bound; passkeys can't be used on fake sites | +| **Device theft** | Biometric required; derived keys not extractable | +| **Server compromise** | Keys derived client-side; server never sees private keys | +| **Guardian collusion** | High threshold (3/5), diverse selection, time-lock | +| **Malicious recovery** | 48h delay, user notification, cancellation window | +| **Key extraction** | WebCrypto keys marked non-extractable | +| **PRF unavailable** | Fallback to passphrase-derived with strong KDF | + +### PRF Key Binding Warning + +> "Data encrypted with PRF-derived keys is bound exclusively to the specific passkey. Losing the passkey will permanently render the encrypted data inaccessible." +> — [Corbado](https://www.corbado.com/blog/passkeys-prf-webauthn) + +**Mitigation:** +1. Social recovery enables passkey rotation +2. Multiple passkeys (backup hardware key) +3. Encrypted key escrow (wrapped with recovery keys) + +### Browser/Authenticator Compatibility + +Test matrix for WebAuthn features: + +| Feature | Chrome | Safari | Firefox | Edge | +|---------|--------|--------|---------|------| +| Basic WebAuthn | Yes | Yes | Yes | Yes | +| Discoverable Credentials | Yes | Yes | Yes | Yes | +| PRF Extension | Yes | Yes | No | Yes | +| Related Origins | Yes | Yes | No | Yes | +| Cross-device (Hybrid) | Yes | Yes | Partial | Yes | + +--- + +## Implementation Roadmap + +### Sprint 1: Foundation (2 weeks) +- [ ] Set up encryptid.online domain +- [ ] Implement WebAuthn registration/authentication +- [ ] Create Related Origins configuration +- [ ] Basic session token issuance + +### Sprint 2: Key Derivation (2 weeks) +- [ ] Implement PRF-based key derivation +- [ ] Passphrase fallback for non-PRF authenticators +- [ ] Encryption key integration with rfiles +- [ ] Signing key integration with rvote + +### Sprint 3: Smart Wallet (3 weeks) +- [ ] ZeroDev integration +- [ ] Passkey validator deployment +- [ ] Session key module +- [ ] Paymaster setup for gasless UX + +### Sprint 4: Social Recovery (2 weeks) +- [ ] Guardian configuration UI +- [ ] Recovery initiation flow +- [ ] Time-lock and notification system +- [ ] Recovery completion and cancellation + +### Sprint 5: Cross-App Integration (2 weeks) +- [ ] rspace.online integration +- [ ] rwallet.online integration +- [ ] rvote.online integration +- [ ] rfiles.online integration +- [ ] rmaps.online integration + +### Sprint 6: Migration & Polish (2 weeks) +- [ ] CryptID migration tools +- [ ] User onboarding flow +- [ ] Documentation +- [ ] Security audit + +--- + +## References + +### WebAuthn & Passkeys +- [WebAuthn PRF Extension Guide (Yubico)](https://developers.yubico.com/WebAuthn/Concepts/PRF_Extension/Developers_Guide_to_PRF.html) +- [Passkeys & WebAuthn PRF for E2E Encryption (Corbado)](https://www.corbado.com/blog/passkeys-prf-webauthn) +- [Related Origin Requests (passkeys.dev)](https://passkeys.dev/docs/advanced/related-origins/) +- [Cross-Domain Passkeys (web.dev)](https://web.dev/articles/webauthn-related-origin-requests) + +### Account Abstraction +- [ZeroDev Documentation](https://docs.zerodev.app/) +- [ZeroDev Recovery](https://docs-v4.zerodev.app/use-wallets/recovery) +- [Safe Passkey Module](https://docs.safe.global/advanced/passkeys/passkeys-safe) +- [Safe Passkey Signer](https://docs.safe.global/sdk/signers/passkeys) + +### Social Recovery +- [Why We Need Social Recovery Wallets (Vitalik Buterin)](https://vitalik.eth.limo/general/2021/01/11/recovery.html) +- [Social Recovery Best Practices](https://yellow.com/learn/social-recovery-wallets-can-they-solve-the-seed-phrase-problem-complete-2025-guide) + +### Infrastructure +- [Turnkey Whitepaper](https://whitepaper.turnkey.com/architecture) +- [Turnkey Embedded Wallets](https://www.turnkey.com/embedded-wallets) +- [Privy Documentation](https://docs.privy.io/) + +### Decentralized Identity +- [DID Core Specification (W3C)](https://www.w3.org/TR/did-core/) +- [did:key Method](https://w3c-ccg.github.io/did-method-key/) +- [Decentralized Identity Guide 2025 (Dock)](https://www.dock.io/post/decentralized-identity) + +--- + +*Document version: 0.1* +*Last updated: February 5, 2026* +*Author: Jeff Emmett with Claude* diff --git a/package.json b/package.json index f453a7f..630acff 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,15 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview", "server": "bun run server/index.ts", - "start": "bun run build && bun run server" + "start": "bun run build && bun run server", + "encryptid": "bun run src/encryptid/server.ts", + "encryptid:build": "docker build -f Dockerfile.encryptid -t encryptid:latest .", + "encryptid:deploy": "docker compose -f docker-compose.encryptid.yml up -d --build" }, "dependencies": { "@automerge/automerge": "^2.2.8", "@lit/reactive-element": "^2.0.4", + "hono": "^4.11.7", "perfect-arrows": "^0.3.7", "perfect-freehand": "^1.2.2" }, diff --git a/public/.well-known/webauthn b/public/.well-known/webauthn new file mode 100644 index 0000000..3ec9c0c --- /dev/null +++ b/public/.well-known/webauthn @@ -0,0 +1,20 @@ +{ + "origins": [ + "https://rspace.online", + "https://app.rspace.online", + "https://dev.rspace.online", + "https://rwallet.online", + "https://app.rwallet.online", + "https://rvote.online", + "https://app.rvote.online", + "https://rmaps.online", + "https://app.rmaps.online", + "https://rfiles.online", + "https://app.rfiles.online", + "https://encryptid.jeffemmett.com", + "https://jeffemmett.com", + "https://canvas.jeffemmett.com", + "http://localhost:3000", + "http://localhost:5173" + ] +} diff --git a/src/encryptid/README.md b/src/encryptid/README.md new file mode 100644 index 0000000..e7f10f5 --- /dev/null +++ b/src/encryptid/README.md @@ -0,0 +1,314 @@ +# EncryptID + +**Unified Identity System for the r-Ecosystem** + +EncryptID is a self-sovereign identity system built on WebAuthn passkeys, with derived cryptographic keys and social recovery. It provides a consistent login experience across all r-ecosystem apps: rspace.online, rwallet, rvote, rmaps, and rfiles. + +## Features + +- **🔑 Passkey Authentication** - Hardware-backed, phishing-resistant login +- **🔐 Client-Side Encryption** - Keys derived locally, never leave your device +- **🛡️ Social Recovery** - No seed phrases! Recover with trusted guardians +- **🌐 Cross-App SSO** - One identity for all r-ecosystem apps +- **💰 Web3 Ready** - Integrated with Account Abstraction smart wallets + +## Quick Start + +### Installation + +```bash +npm install @rspace/encryptid +# or +pnpm add @rspace/encryptid +# or +bun add @rspace/encryptid +``` + +### Basic Usage + +```typescript +import { + registerPasskey, + authenticatePasskey, + getKeyManager, + getSessionManager, +} from '@rspace/encryptid'; + +// Register a new passkey +const credential = await registerPasskey('user@example.com', 'User Name'); + +// Authenticate with passkey +const result = await authenticatePasskey(); + +// Initialize key derivation +const keyManager = getKeyManager(); +if (result.prfOutput) { + await keyManager.initFromPRF(result.prfOutput); +} + +// Get derived keys +const keys = await keyManager.getKeys(); +console.log('Your DID:', keys.did); + +// Create session +const session = getSessionManager(); +await session.createSession(result, keys.did, { + encrypt: true, + sign: true, + wallet: false, +}); +``` + +### UI Components + +```html + + + + + + + + +``` + +```typescript +// Import to register custom elements +import '@rspace/encryptid/ui/login-button'; +import '@rspace/encryptid/ui/guardian-setup'; +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ENCRYPTID LAYERS │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Layer 5: Applications │ +│ ├── rspace.online (canvas) │ +│ ├── rwallet (treasury) │ +│ ├── rvote (voting) │ +│ ├── rfiles (storage) │ +│ └── rmaps (mapping) │ +│ │ +│ Layer 4: Session & SSO │ +│ └── JWT tokens, cross-app authentication │ +│ │ +│ Layer 3: Smart Wallet (Account Abstraction) │ +│ └── ZeroDev Kernel + Passkey Validator │ +│ │ +│ Layer 2: Derived Keys (WebCrypto) │ +│ ├── Encryption Key (AES-256-GCM) │ +│ ├── Signing Key (ECDSA P-256) │ +│ └── DID Key (Ed25519 → did:key) │ +│ │ +│ Layer 1: Primary Authentication (WebAuthn) │ +│ └── Passkeys (platform + roaming authenticators) │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Modules + +### webauthn.ts +WebAuthn/passkey registration and authentication with PRF extension support. + +```typescript +import { + registerPasskey, + authenticatePasskey, + detectCapabilities, + startConditionalUI, +} from '@rspace/encryptid'; +``` + +### key-derivation.ts +Cryptographic key derivation using WebCrypto API. + +```typescript +import { + getKeyManager, + encryptData, + decryptData, + signData, + verifySignature, +} from '@rspace/encryptid'; +``` + +### session.ts +Session management with authentication levels. + +```typescript +import { + getSessionManager, + AuthLevel, + canPerformOperation, +} from '@rspace/encryptid'; +``` + +### recovery.ts +Social recovery with guardians (no seed phrases!). + +```typescript +import { + getRecoveryManager, + GuardianType, +} from '@rspace/encryptid'; +``` + +## Social Recovery + +EncryptID uses guardian-based recovery instead of seed phrases: + +1. **Add Guardians** - Choose 5 trusted entities: + - Secondary passkey (backup device) + - Trusted contacts (friends/family with EncryptID) + - Hardware key (offline backup) + - Institutional guardian (service provider) + +2. **Recovery Threshold** - Require 3 of 5 guardians to approve + +3. **Time-Lock** - 48-hour delay before recovery completes (you can cancel) + +4. **Privacy** - Guardians don't know each other's identities + +```typescript +const recovery = getRecoveryManager(); + +// Add a guardian +await recovery.addGuardian({ + type: GuardianType.TRUSTED_CONTACT, + name: "Alice", + weight: 1, + contactEmail: "alice@example.com", +}); + +// Initiate recovery (if device lost) +const request = await recovery.initiateRecovery(newCredentialId); + +// Guardian approves +await recovery.approveRecovery(guardianId, signature); + +// Complete after time-lock +await recovery.completeRecovery(); +``` + +## Security Levels + +Operations require different authentication levels: + +| Level | Description | Example Operations | +|-------|-------------|-------------------| +| BASIC | Session token only | View public content | +| STANDARD | Recent WebAuthn (15 min) | Edit boards, upload files | +| ELEVATED | Fresh WebAuthn (1 min) | Sign votes, approve transactions | +| CRITICAL | Fresh + explicit consent | Add guardians, export keys | + +```typescript +const session = getSessionManager(); + +// Check if operation is allowed +const { allowed, reason } = session.canPerform('rvote:cast-vote'); + +if (!allowed) { + // Re-authenticate for elevated access + await authenticatePasskey(); + session.upgradeAuthLevel(AuthLevel.ELEVATED); +} +``` + +## Cross-App SSO + +EncryptID uses [Related Origin Requests](https://passkeys.dev/docs/advanced/related-origins/) to share passkeys across r-ecosystem domains. + +Configuration at `https://encryptid.online/.well-known/webauthn`: + +```json +{ + "origins": [ + "https://rspace.online", + "https://rwallet.online", + "https://rvote.online", + "https://rmaps.online", + "https://rfiles.online" + ] +} +``` + +## Browser Support + +| Feature | Chrome | Safari | Firefox | Edge | +|---------|--------|--------|---------|------| +| WebAuthn | ✅ | ✅ | ✅ | ✅ | +| Discoverable Credentials | ✅ | ✅ | ✅ | ✅ | +| PRF Extension | ✅ | ✅ | ❌ | ✅ | +| Related Origins | ✅ | ✅ | ❌ | ✅ | +| Conditional UI | ✅ | ✅ | ⚠️ | ✅ | + +For browsers without PRF support, EncryptID falls back to passphrase-based key derivation. + +## Events + +Login button component emits events: + +```typescript +const button = document.querySelector('encryptid-login'); + +button.addEventListener('login-success', (e) => { + console.log('Logged in:', e.detail.did); +}); + +button.addEventListener('login-error', (e) => { + console.error('Login failed:', e.detail.error); +}); + +button.addEventListener('logout', () => { + console.log('User logged out'); +}); +``` + +Guardian setup component emits events: + +```typescript +const setup = document.querySelector('encryptid-guardian-setup'); + +setup.addEventListener('guardian-added', (e) => { + console.log('Guardian added:', e.detail.name); +}); + +setup.addEventListener('guardian-removed', (e) => { + console.log('Guardian removed:', e.detail.id); +}); +``` + +## Development + +```bash +# Install dependencies +bun install + +# Run demo +bun run dev + +# Build +bun run build + +# Test +bun test +``` + +## License + +MIT + +## Links + +- [Specification](./docs/ENCRYPTID-SPECIFICATION.md) +- [Demo](./src/encryptid/demo.html) +- [r-Ecosystem](https://rspace.online) diff --git a/src/encryptid/demo.html b/src/encryptid/demo.html new file mode 100644 index 0000000..812350d --- /dev/null +++ b/src/encryptid/demo.html @@ -0,0 +1,670 @@ + + + + + + EncryptID Demo + + + +
+
+

🔐 EncryptID

+

Unified Identity for the r-Ecosystem

+
+ + +
+

🌐 Browser Capabilities

+
+
+ + Detecting... +
+
+
+ + +
+

🔑 Authentication

+ +
+

Register New Passkey

+
+ + +
+
+ + +
+ +
+ +
+ +
+

Sign In

+ + +
+
+
+
Not authenticated
+
Sign in to access EncryptID features
+
+
+
+ +
+
EncryptID demo loaded
+
+
+ + +
+

🗝️ Derived Keys

+

+ Keys are derived from your passkey using WebCrypto. They never leave your device. +

+ +
+
+
🔒
+
Encryption Key
+
AES-256-GCM for files & data
+
+
+
✍️
+
Signing Key
+
ECDSA P-256 for signatures
+
+
+
🆔
+
DID Key
+
did:key for identity
+
+
+ +
+ + + +
+ +
+
Authenticate to enable cryptographic operations
+
+
+ + +
+

🛡️ Social Recovery

+

+ Configure guardians to recover your account. No seed phrases needed! +

+ + + +
+ + +
+

🎨 Login Button Component

+

+ Drop-in component for any r-ecosystem app: +

+ +
+
+
Small:
+ +
+
+
Medium (default):
+ +
+
+
Large:
+ +
+
+ +
+
Outline variant:
+ +
+ +
+
With user info (when logged in):
+ +
+
+ +
+

EncryptID v0.1.0 | Specification

+

Part of the r-ecosystem: rspace.online | rwallet | rvote | rfiles | rmaps

+
+
+ + + + + diff --git a/src/encryptid/index.ts b/src/encryptid/index.ts new file mode 100644 index 0000000..efa0e21 --- /dev/null +++ b/src/encryptid/index.ts @@ -0,0 +1,139 @@ +/** + * EncryptID - Unified Identity System for the r-Ecosystem + * + * A self-sovereign identity system built on WebAuthn passkeys, + * with derived cryptographic keys and social recovery. + * + * @packageDocumentation + */ + +// ============================================================================ +// CORE MODULES +// ============================================================================ + +// WebAuthn/Passkey authentication +export { + registerPasskey, + authenticatePasskey, + startConditionalUI, + isConditionalMediationAvailable, + detectCapabilities, + bufferToBase64url, + base64urlToBuffer, + generateChallenge, + type EncryptIDCredential, + type AuthenticationResult, + type EncryptIDConfig, + type WebAuthnCapabilities, +} from './webauthn'; + +// Key derivation and cryptography +export { + EncryptIDKeyManager, + getKeyManager, + resetKeyManager, + encryptData, + decryptData, + decryptDataAsString, + signData, + verifySignature, + wrapKeyForRecipient, + unwrapSharedKey, + type DerivedKeys, + type EncryptedData, + type SignedData, +} from './key-derivation'; + +// Session management +export { + SessionManager, + getSessionManager, + AuthLevel, + OPERATION_PERMISSIONS, + type EncryptIDClaims, + type SessionState, + type OperationPermission, +} from './session'; + +// Social recovery +export { + RecoveryManager, + getRecoveryManager, + GuardianType, + getGuardianTypeInfo, + type Guardian, + type RecoveryConfig, + type RecoveryRequest, +} from './recovery'; + +// ============================================================================ +// UI COMPONENTS +// ============================================================================ + +// Import UI components (registers custom elements as side effect) +export { GuardianSetupElement } from './ui/guardian-setup'; +export { EncryptIDLoginButton } from './ui/login-button'; + +// ============================================================================ +// CONVENIENCE FUNCTIONS +// ============================================================================ + +import { detectCapabilities } from './webauthn'; +import { getSessionManager, AuthLevel } from './session'; +import { getRecoveryManager } from './recovery'; + +/** + * Check if EncryptID is available and ready to use + */ +export async function isEncryptIDAvailable(): Promise { + const caps = await detectCapabilities(); + return caps.webauthn && caps.platformAuthenticator; +} + +/** + * Get the current authentication status + */ +export function getAuthStatus(): { + authenticated: boolean; + did: string | null; + authLevel: AuthLevel; + recoveryConfigured: boolean; +} { + const session = getSessionManager(); + const recovery = getRecoveryManager(); + + return { + authenticated: session.isValid(), + did: session.getDID(), + authLevel: session.getAuthLevel(), + recoveryConfigured: recovery.isConfigured(), + }; +} + +/** + * Check if an operation is allowed with current session + */ +export function canPerformOperation(operation: string): { + allowed: boolean; + reason?: string; +} { + return getSessionManager().canPerform(operation); +} + +/** + * Clear all EncryptID state (logout) + */ +export function logout(): void { + getSessionManager().clearSession(); + + // Clear keys + const { resetKeyManager } = require('./key-derivation'); + resetKeyManager(); +} + +// ============================================================================ +// VERSION INFO +// ============================================================================ + +export const VERSION = '0.1.0'; +export const SPEC_VERSION = '2026-02'; diff --git a/src/encryptid/key-derivation.ts b/src/encryptid/key-derivation.ts new file mode 100644 index 0000000..108ebee --- /dev/null +++ b/src/encryptid/key-derivation.ts @@ -0,0 +1,519 @@ +/** + * EncryptID Key Derivation Module + * + * Derives application-specific cryptographic keys from WebAuthn PRF output + * or passphrase fallback. This is Layer 2 of the EncryptID architecture. + */ + +import { bufferToBase64url, base64urlToBuffer } from './webauthn'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface DerivedKeys { + /** AES-256-GCM key for file/data encryption */ + encryptionKey: CryptoKey; + + /** ECDSA P-256 key pair for signing */ + signingKeyPair: CryptoKeyPair; + + /** Ed25519 seed for DID key (raw bytes) */ + didSeed: Uint8Array; + + /** The DID identifier (did:key:z6Mk...) */ + did: string; + + /** Whether keys were derived from PRF (true) or passphrase (false) */ + fromPRF: boolean; +} + +export interface EncryptedData { + ciphertext: ArrayBuffer; + iv: Uint8Array; + tag?: ArrayBuffer; // For AEAD modes, tag is included in ciphertext +} + +export interface SignedData { + data: ArrayBuffer; + signature: ArrayBuffer; + publicKey: ArrayBuffer; +} + +// ============================================================================ +// KEY DERIVATION +// ============================================================================ + +/** + * EncryptID Key Manager + * + * Handles derivation and management of all cryptographic keys + * from a master secret (PRF output or passphrase-derived). + */ +export class EncryptIDKeyManager { + private masterKey: CryptoKey | null = null; + private derivedKeys: DerivedKeys | null = null; + private fromPRF: boolean = false; + + /** + * Initialize from WebAuthn PRF output + * + * This is the preferred path - keys are derived directly from + * the hardware-backed PRF extension output. + */ + async initFromPRF(prfOutput: ArrayBuffer): Promise { + // Import PRF output as HKDF key material + this.masterKey = await crypto.subtle.importKey( + 'raw', + prfOutput, + { name: 'HKDF' }, + false, // Not extractable + ['deriveKey', 'deriveBits'] + ); + + this.fromPRF = true; + this.derivedKeys = null; // Clear any existing derived keys + + console.log('EncryptID: Key manager initialized from PRF output'); + } + + /** + * Initialize from passphrase (fallback for non-PRF authenticators) + * + * Uses PBKDF2 with high iteration count to derive master key + * from user's passphrase. The salt should be stored securely. + */ + async initFromPassphrase( + passphrase: string, + salt: Uint8Array + ): Promise { + const encoder = new TextEncoder(); + + // Import passphrase as PBKDF2 key + const passphraseKey = await crypto.subtle.importKey( + 'raw', + encoder.encode(passphrase), + { name: 'PBKDF2' }, + false, + ['deriveBits'] + ); + + // Derive master key material using PBKDF2 + // OWASP 2023 recommends 600,000 iterations for PBKDF2-SHA256 + const masterKeyMaterial = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt: salt, + iterations: 600000, + hash: 'SHA-256', + }, + passphraseKey, + 256 // 32 bytes + ); + + // Import as HKDF key for further derivation + this.masterKey = await crypto.subtle.importKey( + 'raw', + masterKeyMaterial, + { name: 'HKDF' }, + false, + ['deriveKey', 'deriveBits'] + ); + + this.fromPRF = false; + this.derivedKeys = null; + + console.log('EncryptID: Key manager initialized from passphrase'); + } + + /** + * Generate a random salt for passphrase-based derivation + */ + static generateSalt(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(32)); + } + + /** + * Check if the key manager is initialized + */ + isInitialized(): boolean { + return this.masterKey !== null; + } + + /** + * Get or derive all application keys + */ + async getKeys(): Promise { + if (!this.masterKey) { + throw new Error('Key manager not initialized'); + } + + // Return cached keys if available + if (this.derivedKeys) { + return this.derivedKeys; + } + + // Derive all keys + const [encryptionKey, signingKeyPair, didSeed] = await Promise.all([ + this.deriveEncryptionKey(), + this.deriveSigningKeyPair(), + this.deriveDIDSeed(), + ]); + + // Generate DID from seed + const did = await this.generateDID(didSeed); + + this.derivedKeys = { + encryptionKey, + signingKeyPair, + didSeed, + did, + fromPRF: this.fromPRF, + }; + + return this.derivedKeys; + } + + /** + * Derive AES-256-GCM encryption key + */ + private async deriveEncryptionKey(): Promise { + if (!this.masterKey) { + throw new Error('Key manager not initialized'); + } + + const encoder = new TextEncoder(); + + return crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: encoder.encode('encryptid-encryption-key-v1'), + info: encoder.encode('AES-256-GCM'), + }, + this.masterKey, + { + name: 'AES-GCM', + length: 256, + }, + false, // Not extractable + ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'] + ); + } + + /** + * Derive ECDSA P-256 signing key pair + * + * Note: WebCrypto doesn't support deterministic key generation, + * so we derive a seed and use it to generate the key pair. + * For production, consider using a library like @noble/curves. + */ + private async deriveSigningKeyPair(): Promise { + if (!this.masterKey) { + throw new Error('Key manager not initialized'); + } + + const encoder = new TextEncoder(); + + // Derive seed for signing key + const seed = await crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: encoder.encode('encryptid-signing-key-v1'), + info: encoder.encode('ECDSA-P256-seed'), + }, + this.masterKey, + 256 + ); + + // For now, generate a non-deterministic key pair + // TODO: Use @noble/curves for deterministic generation from seed + // This is a placeholder - in production, use the seed deterministically + const keyPair = await crypto.subtle.generateKey( + { + name: 'ECDSA', + namedCurve: 'P-256', + }, + false, // Private key not extractable + ['sign', 'verify'] + ); + + // Store seed hash for verification + console.log('EncryptID: Signing key derived (seed hash):', + bufferToBase64url(await crypto.subtle.digest('SHA-256', seed)).slice(0, 16)); + + return keyPair; + } + + /** + * Derive Ed25519 seed for DID key + */ + private async deriveDIDSeed(): Promise { + if (!this.masterKey) { + throw new Error('Key manager not initialized'); + } + + const encoder = new TextEncoder(); + + const seed = await crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: encoder.encode('encryptid-did-key-v1'), + info: encoder.encode('Ed25519-seed'), + }, + this.masterKey, + 256 + ); + + return new Uint8Array(seed); + } + + /** + * Generate did:key identifier from Ed25519 seed + * + * Format: did:key:z6Mk... (multicodec ed25519-pub + base58btc) + */ + private async generateDID(seed: Uint8Array): Promise { + // Ed25519 public key generation would go here + // For now, we'll create a placeholder using the seed hash + // TODO: Use @noble/ed25519 for proper Ed25519 key generation + + const publicKeyHash = await crypto.subtle.digest('SHA-256', seed); + const publicKeyBytes = new Uint8Array(publicKeyHash).slice(0, 32); + + // Multicodec prefix for Ed25519 public key: 0xed01 + const multicodecPrefix = new Uint8Array([0xed, 0x01]); + const multicodecKey = new Uint8Array(34); + multicodecKey.set(multicodecPrefix); + multicodecKey.set(publicKeyBytes, 2); + + // Base58btc encode (simplified - use a proper library in production) + const base58Encoded = bufferToBase64url(multicodecKey.buffer) + .replace(/-/g, '') + .replace(/_/g, ''); + + return `did:key:z${base58Encoded}`; + } + + /** + * Clear all keys from memory + */ + clear(): void { + this.masterKey = null; + this.derivedKeys = null; + this.fromPRF = false; + console.log('EncryptID: Key manager cleared'); + } +} + +// ============================================================================ +// ENCRYPTION UTILITIES +// ============================================================================ + +/** + * Encrypt data using the encryption key + */ +export async function encryptData( + key: CryptoKey, + data: ArrayBuffer | Uint8Array | string +): Promise { + // Convert string to ArrayBuffer if needed + let plaintext: ArrayBuffer; + if (typeof data === 'string') { + plaintext = new TextEncoder().encode(data).buffer; + } else if (data instanceof Uint8Array) { + plaintext = data.buffer; + } else { + plaintext = data; + } + + // Generate random IV + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Encrypt + const ciphertext = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + plaintext + ); + + return { ciphertext, iv }; +} + +/** + * Decrypt data using the encryption key + */ +export async function decryptData( + key: CryptoKey, + encrypted: EncryptedData +): Promise { + return crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: encrypted.iv, + }, + key, + encrypted.ciphertext + ); +} + +/** + * Decrypt data and return as string + */ +export async function decryptDataAsString( + key: CryptoKey, + encrypted: EncryptedData +): Promise { + const plaintext = await decryptData(key, encrypted); + return new TextDecoder().decode(plaintext); +} + +// ============================================================================ +// SIGNING UTILITIES +// ============================================================================ + +/** + * Sign data using the signing key + */ +export async function signData( + keyPair: CryptoKeyPair, + data: ArrayBuffer | Uint8Array | string +): Promise { + // Convert string to ArrayBuffer if needed + let dataBuffer: ArrayBuffer; + if (typeof data === 'string') { + dataBuffer = new TextEncoder().encode(data).buffer; + } else if (data instanceof Uint8Array) { + dataBuffer = data.buffer; + } else { + dataBuffer = data; + } + + // Sign + const signature = await crypto.subtle.sign( + { + name: 'ECDSA', + hash: 'SHA-256', + }, + keyPair.privateKey, + dataBuffer + ); + + // Export public key for verification + const publicKey = await crypto.subtle.exportKey('raw', keyPair.publicKey); + + return { + data: dataBuffer, + signature, + publicKey, + }; +} + +/** + * Verify a signature + */ +export async function verifySignature( + signed: SignedData +): Promise { + // Import public key + const publicKey = await crypto.subtle.importKey( + 'raw', + signed.publicKey, + { + name: 'ECDSA', + namedCurve: 'P-256', + }, + false, + ['verify'] + ); + + // Verify + return crypto.subtle.verify( + { + name: 'ECDSA', + hash: 'SHA-256', + }, + publicKey, + signed.signature, + signed.data + ); +} + +// ============================================================================ +// KEY WRAPPING (FOR SHARING) +// ============================================================================ + +/** + * Wrap a key for sharing with another user + * + * Used in rfiles to share encrypted files - wrap the file key + * with the recipient's public key. + */ +export async function wrapKeyForRecipient( + keyToWrap: CryptoKey, + recipientPublicKey: CryptoKey +): Promise { + return crypto.subtle.wrapKey( + 'raw', + keyToWrap, + recipientPublicKey, + { + name: 'RSA-OAEP', + } + ); +} + +/** + * Unwrap a key that was shared with us + */ +export async function unwrapSharedKey( + wrappedKey: ArrayBuffer, + privateKey: CryptoKey +): Promise { + return crypto.subtle.unwrapKey( + 'raw', + wrappedKey, + privateKey, + { + name: 'RSA-OAEP', + }, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'] + ); +} + +// ============================================================================ +// SINGLETON INSTANCE +// ============================================================================ + +// Global key manager instance +let keyManagerInstance: EncryptIDKeyManager | null = null; + +/** + * Get the global EncryptID key manager instance + */ +export function getKeyManager(): EncryptIDKeyManager { + if (!keyManagerInstance) { + keyManagerInstance = new EncryptIDKeyManager(); + } + return keyManagerInstance; +} + +/** + * Reset the global key manager (e.g., on logout) + */ +export function resetKeyManager(): void { + if (keyManagerInstance) { + keyManagerInstance.clear(); + keyManagerInstance = null; + } +} diff --git a/src/encryptid/recovery.ts b/src/encryptid/recovery.ts new file mode 100644 index 0000000..4a61110 --- /dev/null +++ b/src/encryptid/recovery.ts @@ -0,0 +1,601 @@ +/** + * EncryptID Social Recovery Module + * + * Implements guardian-based account recovery with NO SEED PHRASES. + * This is Layer 4 of the EncryptID architecture. + */ + +import { bufferToBase64url, base64urlToBuffer } from './webauthn'; + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Types of guardians that can be added + */ +export enum GuardianType { + /** Another passkey owned by the user (e.g., YubiKey backup) */ + SECONDARY_PASSKEY = 'secondary_passkey', + + /** A trusted contact with their own EncryptID */ + TRUSTED_CONTACT = 'trusted_contact', + + /** A hardware security key stored offline */ + HARDWARE_KEY = 'hardware_key', + + /** An institutional guardian (service provider) */ + INSTITUTIONAL = 'institutional', + + /** Time-delayed self-recovery (requires waiting period) */ + TIME_DELAYED_SELF = 'time_delayed_self', +} + +/** + * Guardian configuration + */ +export interface Guardian { + id: string; + type: GuardianType; + name: string; + weight: number; // Contribution to threshold (usually 1) + + // Type-specific data + credentialId?: string; // For SECONDARY_PASSKEY + contactDID?: string; // For TRUSTED_CONTACT + contactEmail?: string; // For notification + serviceUrl?: string; // For INSTITUTIONAL + delaySeconds?: number; // For TIME_DELAYED_SELF + + // Metadata + addedAt: number; + lastVerified?: number; +} + +/** + * Recovery configuration for an account + */ +export interface RecoveryConfig { + /** Required weight to recover (e.g., 3 for 3-of-5) */ + threshold: number; + + /** Time-lock delay in seconds before recovery completes */ + delaySeconds: number; + + /** List of guardians */ + guardians: Guardian[]; + + /** Hash of guardian addresses (for privacy) */ + guardianListHash: string; + + /** When config was last updated */ + updatedAt: number; +} + +/** + * Active recovery request + */ +export interface RecoveryRequest { + id: string; + accountDID: string; + newCredentialId: string; + initiatedAt: number; + completesAt: number; + status: 'pending' | 'approved' | 'cancelled' | 'completed'; + + /** Guardians who have approved */ + approvals: { + guardianId: string; + approvedAt: number; + signature: string; + }[]; + + /** Total weight of approvals */ + approvalWeight: number; +} + +// ============================================================================ +// GUARDIAN MANAGEMENT +// ============================================================================ + +/** + * EncryptID Recovery Manager + * + * Handles guardian configuration and recovery flows. + */ +export class RecoveryManager { + private config: RecoveryConfig | null = null; + private activeRequest: RecoveryRequest | null = null; + + constructor() { + this.loadConfig(); + } + + /** + * Initialize recovery with default configuration + */ + async initializeRecovery(threshold: number = 3): Promise { + this.config = { + threshold, + delaySeconds: 48 * 60 * 60, // 48 hours + guardians: [], + guardianListHash: '', + updatedAt: Date.now(), + }; + + await this.saveConfig(); + return this.config; + } + + /** + * Add a guardian + */ + async addGuardian(guardian: Omit): Promise { + if (!this.config) { + throw new Error('Recovery not initialized'); + } + + if (this.config.guardians.length >= 7) { + throw new Error('Maximum of 7 guardians allowed'); + } + + const newGuardian: Guardian = { + ...guardian, + id: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer), + addedAt: Date.now(), + }; + + this.config.guardians.push(newGuardian); + this.config.guardianListHash = await this.hashGuardianList(); + this.config.updatedAt = Date.now(); + + await this.saveConfig(); + + console.log('EncryptID: Guardian added', { + type: GuardianType[guardian.type], + name: guardian.name, + }); + + return newGuardian; + } + + /** + * Remove a guardian + */ + async removeGuardian(guardianId: string): Promise { + if (!this.config) { + throw new Error('Recovery not initialized'); + } + + const index = this.config.guardians.findIndex(g => g.id === guardianId); + if (index === -1) { + throw new Error('Guardian not found'); + } + + // Don't allow removing if it would make recovery impossible + const remainingWeight = this.config.guardians + .filter(g => g.id !== guardianId) + .reduce((sum, g) => sum + g.weight, 0); + + if (remainingWeight < this.config.threshold) { + throw new Error('Cannot remove guardian: would make recovery impossible'); + } + + this.config.guardians.splice(index, 1); + this.config.guardianListHash = await this.hashGuardianList(); + this.config.updatedAt = Date.now(); + + await this.saveConfig(); + + console.log('EncryptID: Guardian removed'); + } + + /** + * Update recovery threshold + */ + async setThreshold(threshold: number): Promise { + if (!this.config) { + throw new Error('Recovery not initialized'); + } + + const totalWeight = this.config.guardians.reduce((sum, g) => sum + g.weight, 0); + if (threshold > totalWeight) { + throw new Error('Threshold cannot exceed total guardian weight'); + } + + if (threshold < 1) { + throw new Error('Threshold must be at least 1'); + } + + this.config.threshold = threshold; + this.config.updatedAt = Date.now(); + + await this.saveConfig(); + + console.log('EncryptID: Threshold updated to', threshold); + } + + /** + * Set recovery time-lock delay + */ + async setDelay(delaySeconds: number): Promise { + if (!this.config) { + throw new Error('Recovery not initialized'); + } + + // Minimum 1 hour, maximum 7 days + if (delaySeconds < 3600 || delaySeconds > 7 * 24 * 3600) { + throw new Error('Delay must be between 1 hour and 7 days'); + } + + this.config.delaySeconds = delaySeconds; + this.config.updatedAt = Date.now(); + + await this.saveConfig(); + + console.log('EncryptID: Delay updated to', delaySeconds, 'seconds'); + } + + /** + * Get current configuration + */ + getConfig(): RecoveryConfig | null { + return this.config; + } + + /** + * Check if recovery is properly configured + */ + isConfigured(): boolean { + if (!this.config) return false; + + const totalWeight = this.config.guardians.reduce((sum, g) => sum + g.weight, 0); + return totalWeight >= this.config.threshold; + } + + /** + * Verify a guardian is still reachable/valid + */ + async verifyGuardian(guardianId: string): Promise { + if (!this.config) { + throw new Error('Recovery not initialized'); + } + + const guardian = this.config.guardians.find(g => g.id === guardianId); + if (!guardian) { + throw new Error('Guardian not found'); + } + + // In production, this would: + // - For SECONDARY_PASSKEY: verify credential still exists + // - For TRUSTED_CONTACT: send verification request + // - For INSTITUTIONAL: ping service endpoint + // - For HARDWARE_KEY: request signature + + guardian.lastVerified = Date.now(); + await this.saveConfig(); + + return true; + } + + // ========================================================================== + // RECOVERY FLOW + // ========================================================================== + + /** + * Initiate account recovery + * + * This starts the recovery process. Guardians must approve, + * and there's a time-lock before recovery completes. + */ + async initiateRecovery(newCredentialId: string): Promise { + if (!this.config) { + throw new Error('Recovery not configured'); + } + + if (this.activeRequest && this.activeRequest.status === 'pending') { + throw new Error('Recovery already in progress'); + } + + const now = Date.now(); + + this.activeRequest = { + id: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer), + accountDID: '', // Would be set from current session + newCredentialId, + initiatedAt: now, + completesAt: now + this.config.delaySeconds * 1000, + status: 'pending', + approvals: [], + approvalWeight: 0, + }; + + // Notify user through all available channels + await this.notifyUser('recovery_initiated', this.activeRequest); + + // Notify guardians + await this.notifyGuardians('recovery_requested', this.activeRequest); + + console.log('EncryptID: Recovery initiated', { + requestId: this.activeRequest.id, + completesAt: new Date(this.activeRequest.completesAt).toISOString(), + }); + + return this.activeRequest; + } + + /** + * Guardian approves a recovery request + */ + async approveRecovery( + guardianId: string, + signature: string + ): Promise { + if (!this.activeRequest || this.activeRequest.status !== 'pending') { + throw new Error('No pending recovery request'); + } + + if (!this.config) { + throw new Error('Recovery not configured'); + } + + const guardian = this.config.guardians.find(g => g.id === guardianId); + if (!guardian) { + throw new Error('Guardian not found'); + } + + // Check if guardian already approved + if (this.activeRequest.approvals.some(a => a.guardianId === guardianId)) { + throw new Error('Guardian already approved'); + } + + // Add approval + this.activeRequest.approvals.push({ + guardianId, + approvedAt: Date.now(), + signature, + }); + + this.activeRequest.approvalWeight += guardian.weight; + + // Check if threshold reached + if (this.activeRequest.approvalWeight >= this.config.threshold) { + this.activeRequest.status = 'approved'; + await this.notifyUser('recovery_approved', this.activeRequest); + } + + console.log('EncryptID: Guardian approved recovery', { + guardianId, + weight: guardian.weight, + totalWeight: this.activeRequest.approvalWeight, + threshold: this.config.threshold, + }); + + return this.activeRequest; + } + + /** + * Cancel an active recovery request + * + * User can cancel if they still have access to any valid authenticator. + */ + async cancelRecovery(): Promise { + if (!this.activeRequest || this.activeRequest.status !== 'pending') { + throw new Error('No pending recovery request to cancel'); + } + + this.activeRequest.status = 'cancelled'; + + // Notify guardians + await this.notifyGuardians('recovery_cancelled', this.activeRequest); + + console.log('EncryptID: Recovery cancelled'); + + this.activeRequest = null; + } + + /** + * Complete the recovery process + * + * Can only be called after time-lock expires and threshold is met. + */ + async completeRecovery(): Promise { + if (!this.activeRequest) { + throw new Error('No recovery request'); + } + + if (this.activeRequest.status !== 'approved') { + throw new Error('Recovery not approved'); + } + + if (Date.now() < this.activeRequest.completesAt) { + const remaining = this.activeRequest.completesAt - Date.now(); + throw new Error(`Time-lock not expired. ${Math.ceil(remaining / 1000 / 60)} minutes remaining.`); + } + + // In production, this would: + // 1. Rotate the account owner to the new credential + // 2. Invalidate old credentials + // 3. Update on-chain state (for AA wallet) + + this.activeRequest.status = 'completed'; + + console.log('EncryptID: Recovery completed successfully'); + + // Notify user + await this.notifyUser('recovery_completed', this.activeRequest); + + this.activeRequest = null; + } + + /** + * Get active recovery request + */ + getActiveRequest(): RecoveryRequest | null { + return this.activeRequest; + } + + // ========================================================================== + // PRIVATE METHODS + // ========================================================================== + + /** + * Hash guardian list for privacy-preserving on-chain storage + */ + private async hashGuardianList(): Promise { + if (!this.config) return ''; + + // Sort guardian IDs for deterministic hash + const sortedIds = this.config.guardians + .map(g => g.id) + .sort() + .join(','); + + const encoder = new TextEncoder(); + const hash = await crypto.subtle.digest('SHA-256', encoder.encode(sortedIds)); + + return bufferToBase64url(hash); + } + + /** + * Save configuration to storage + */ + private async saveConfig(): Promise { + if (!this.config) return; + + try { + localStorage.setItem('encryptid_recovery', JSON.stringify(this.config)); + } catch (error) { + console.warn('EncryptID: Failed to save recovery config', error); + } + } + + /** + * Load configuration from storage + */ + private loadConfig(): void { + try { + const stored = localStorage.getItem('encryptid_recovery'); + if (stored) { + this.config = JSON.parse(stored); + } + } catch (error) { + console.warn('EncryptID: Failed to load recovery config', error); + } + } + + /** + * Notify user of recovery events + */ + private async notifyUser( + event: string, + request: RecoveryRequest + ): Promise { + // In production, this would: + // - Send email notification + // - Send push notification + // - Send SMS (if configured) + // - Post to webhook + + console.log('EncryptID: User notification', { event, requestId: request.id }); + } + + /** + * Notify guardians of recovery events + */ + private async notifyGuardians( + event: string, + request: RecoveryRequest + ): Promise { + if (!this.config) return; + + // In production, this would notify each guardian through their preferred channel + for (const guardian of this.config.guardians) { + console.log('EncryptID: Guardian notification', { + event, + guardianId: guardian.id, + guardianType: GuardianType[guardian.type], + }); + } + } +} + +// ============================================================================ +// SINGLETON INSTANCE +// ============================================================================ + +let recoveryManagerInstance: RecoveryManager | null = null; + +/** + * Get the global recovery manager instance + */ +export function getRecoveryManager(): RecoveryManager { + if (!recoveryManagerInstance) { + recoveryManagerInstance = new RecoveryManager(); + } + return recoveryManagerInstance; +} + +// ============================================================================ +// GUARDIAN TYPE METADATA +// ============================================================================ + +/** + * Get display information for guardian types + */ +export function getGuardianTypeInfo(type: GuardianType): { + name: string; + description: string; + icon: string; + setupInstructions: string; +} { + switch (type) { + case GuardianType.SECONDARY_PASSKEY: + return { + name: 'Backup Passkey', + description: 'Another device you own (phone, YubiKey, etc.)', + icon: 'key', + setupInstructions: 'Register a passkey on a second device you control. Store it securely as a backup.', + }; + + case GuardianType.TRUSTED_CONTACT: + return { + name: 'Trusted Contact', + description: 'A friend or family member with their own EncryptID', + icon: 'user', + setupInstructions: 'Ask a trusted person to create an EncryptID account. They can help recover your account if needed.', + }; + + case GuardianType.HARDWARE_KEY: + return { + name: 'Hardware Security Key', + description: 'A YubiKey or similar device stored offline', + icon: 'shield', + setupInstructions: 'Register a hardware security key and store it in a safe place (e.g., safe deposit box).', + }; + + case GuardianType.INSTITUTIONAL: + return { + name: 'Recovery Service', + description: 'A professional recovery service provider', + icon: 'building', + setupInstructions: 'Connect with a trusted recovery service that can help verify your identity.', + }; + + case GuardianType.TIME_DELAYED_SELF: + return { + name: 'Time-Delayed Self', + description: 'Recover yourself after a waiting period', + icon: 'clock', + setupInstructions: 'Set up a recovery option that requires waiting (e.g., 7 days) before completing.', + }; + + default: + return { + name: 'Unknown', + description: 'Unknown guardian type', + icon: 'question', + setupInstructions: '', + }; + } +} diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts new file mode 100644 index 0000000..e83ed36 --- /dev/null +++ b/src/encryptid/server.ts @@ -0,0 +1,613 @@ +/** + * EncryptID Server + * + * Handles WebAuthn registration/authentication, session management, + * and serves the .well-known/webauthn configuration. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { logger } from 'hono/logger'; +import { serveStatic } from 'hono/bun'; +import { sign, verify } from 'hono/jwt'; + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +const CONFIG = { + port: process.env.PORT || 3000, + rpId: 'jeffemmett.com', + rpName: 'EncryptID', + jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production', + sessionDuration: 15 * 60, // 15 minutes + refreshDuration: 7 * 24 * 60 * 60, // 7 days + allowedOrigins: [ + 'https://encryptid.jeffemmett.com', + 'https://jeffemmett.com', + 'https://rspace.online', + 'https://rwallet.online', + 'https://rvote.online', + 'https://rmaps.online', + 'https://rfiles.online', + 'http://localhost:3000', + 'http://localhost:5173', + ], +}; + +// ============================================================================ +// IN-MEMORY STORAGE (Replace with database in production) +// ============================================================================ + +interface StoredCredential { + credentialId: string; + publicKey: string; // Base64url encoded + userId: string; + username: string; + counter: number; + createdAt: number; + lastUsed?: number; + transports?: string[]; +} + +interface StoredChallenge { + challenge: string; + userId?: string; + type: 'registration' | 'authentication'; + createdAt: number; + expiresAt: number; +} + +// In-memory stores (replace with D1/PostgreSQL in production) +const credentials = new Map(); +const challenges = new Map(); +const userCredentials = new Map(); // userId -> credentialIds + +// ============================================================================ +// HONO APP +// ============================================================================ + +const app = new Hono(); + +// Middleware +app.use('*', logger()); +app.use('*', cors({ + origin: CONFIG.allowedOrigins, + allowMethods: ['GET', 'POST', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], + credentials: true, +})); + +// ============================================================================ +// STATIC FILES & WELL-KNOWN +// ============================================================================ + +// Serve .well-known/webauthn for Related Origins +app.get('/.well-known/webauthn', (c) => { + return c.json({ + origins: CONFIG.allowedOrigins.filter(o => o.startsWith('https://')), + }); +}); + +// Health check +app.get('/health', (c) => { + return c.json({ status: 'ok', service: 'encryptid', timestamp: Date.now() }); +}); + +// ============================================================================ +// REGISTRATION ENDPOINTS +// ============================================================================ + +/** + * Start registration - returns challenge and options + */ +app.post('/api/register/start', async (c) => { + const { username, displayName } = await c.req.json(); + + if (!username) { + return c.json({ error: 'Username required' }, 400); + } + + // Generate challenge + const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); + + // Generate user ID + const userId = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); + + // Store challenge + const challengeRecord: StoredChallenge = { + challenge, + userId, + type: 'registration', + createdAt: Date.now(), + expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes + }; + challenges.set(challenge, challengeRecord); + + // Build registration options + const options = { + challenge, + rp: { + id: CONFIG.rpId, + name: CONFIG.rpName, + }, + user: { + id: userId, + name: username, + displayName: displayName || username, + }, + pubKeyCredParams: [ + { alg: -7, type: 'public-key' }, // ES256 + { alg: -257, type: 'public-key' }, // RS256 + ], + authenticatorSelection: { + residentKey: 'required', + requireResidentKey: true, + userVerification: 'required', + }, + timeout: 60000, + attestation: 'none', + extensions: { + credProps: true, + }, + }; + + return c.json({ options, userId }); +}); + +/** + * Complete registration - verify and store credential + */ +app.post('/api/register/complete', async (c) => { + const { challenge, credential, userId, username } = await c.req.json(); + + // Verify challenge + const challengeRecord = challenges.get(challenge); + if (!challengeRecord || challengeRecord.type !== 'registration') { + return c.json({ error: 'Invalid challenge' }, 400); + } + if (Date.now() > challengeRecord.expiresAt) { + challenges.delete(challenge); + return c.json({ error: 'Challenge expired' }, 400); + } + challenges.delete(challenge); + + // In production, verify the attestation properly + // For now, we trust the client-side verification + + // Store credential + const storedCredential: StoredCredential = { + credentialId: credential.credentialId, + publicKey: credential.publicKey, + userId, + username, + counter: 0, + createdAt: Date.now(), + transports: credential.transports, + }; + + credentials.set(credential.credentialId, storedCredential); + + // Map user to credential + const userCreds = userCredentials.get(userId) || []; + userCreds.push(credential.credentialId); + userCredentials.set(userId, userCreds); + + console.log('EncryptID: Credential registered', { + credentialId: credential.credentialId.slice(0, 20) + '...', + userId: userId.slice(0, 20) + '...', + }); + + // Generate initial session token + const token = await generateSessionToken(userId, username); + + return c.json({ + success: true, + userId, + token, + did: `did:key:${userId.slice(0, 32)}`, // Simplified DID + }); +}); + +// ============================================================================ +// AUTHENTICATION ENDPOINTS +// ============================================================================ + +/** + * Start authentication - returns challenge + */ +app.post('/api/auth/start', async (c) => { + const body = await c.req.json().catch(() => ({})); + const { credentialId } = body; + + // Generate challenge + const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'); + + // Store challenge + const challengeRecord: StoredChallenge = { + challenge, + type: 'authentication', + createdAt: Date.now(), + expiresAt: Date.now() + 5 * 60 * 1000, + }; + challenges.set(challenge, challengeRecord); + + // Build allowed credentials if specified + let allowCredentials; + if (credentialId) { + const cred = credentials.get(credentialId); + if (cred) { + allowCredentials = [{ + type: 'public-key', + id: credentialId, + transports: cred.transports, + }]; + } + } + + const options = { + challenge, + rpId: CONFIG.rpId, + userVerification: 'required', + timeout: 60000, + allowCredentials, + }; + + return c.json({ options }); +}); + +/** + * Complete authentication - verify and issue token + */ +app.post('/api/auth/complete', async (c) => { + const { challenge, credential } = await c.req.json(); + + // Verify challenge + const challengeRecord = challenges.get(challenge); + if (!challengeRecord || challengeRecord.type !== 'authentication') { + return c.json({ error: 'Invalid challenge' }, 400); + } + if (Date.now() > challengeRecord.expiresAt) { + challenges.delete(challenge); + return c.json({ error: 'Challenge expired' }, 400); + } + challenges.delete(challenge); + + // Look up credential + const storedCredential = credentials.get(credential.credentialId); + if (!storedCredential) { + return c.json({ error: 'Unknown credential' }, 400); + } + + // In production, verify signature against stored public key + // For now, we trust the client-side verification + + // Update counter and last used + storedCredential.counter++; + storedCredential.lastUsed = Date.now(); + + console.log('EncryptID: Authentication successful', { + credentialId: credential.credentialId.slice(0, 20) + '...', + userId: storedCredential.userId.slice(0, 20) + '...', + }); + + // Generate session token + const token = await generateSessionToken( + storedCredential.userId, + storedCredential.username + ); + + return c.json({ + success: true, + userId: storedCredential.userId, + username: storedCredential.username, + token, + did: `did:key:${storedCredential.userId.slice(0, 32)}`, + }); +}); + +// ============================================================================ +// SESSION ENDPOINTS +// ============================================================================ + +/** + * Verify session token + */ +app.get('/api/session/verify', async (c) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ valid: false, error: 'No token' }, 401); + } + + const token = authHeader.slice(7); + + try { + const payload = await verify(token, CONFIG.jwtSecret); + return c.json({ + valid: true, + userId: payload.sub, + username: payload.username, + did: payload.did, + exp: payload.exp, + }); + } catch { + return c.json({ valid: false, error: 'Invalid token' }, 401); + } +}); + +/** + * Refresh session token + */ +app.post('/api/session/refresh', async (c) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ error: 'No token' }, 401); + } + + const token = authHeader.slice(7); + + try { + const payload = await verify(token, CONFIG.jwtSecret, { clockTolerance: 60 * 60 }); // Allow 1 hour expired + + // Issue new token + const newToken = await generateSessionToken( + payload.sub as string, + payload.username as string + ); + + return c.json({ token: newToken }); + } catch { + return c.json({ error: 'Invalid token' }, 401); + } +}); + +// ============================================================================ +// USER INFO ENDPOINTS +// ============================================================================ + +/** + * Get user credentials (for listing passkeys) + */ +app.get('/api/user/credentials', async (c) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const token = authHeader.slice(7); + + try { + const payload = await verify(token, CONFIG.jwtSecret); + const userId = payload.sub as string; + + const userCreds = userCredentials.get(userId) || []; + const credentialList = userCreds.map(credId => { + const cred = credentials.get(credId); + if (!cred) return null; + return { + credentialId: cred.credentialId, + createdAt: cred.createdAt, + lastUsed: cred.lastUsed, + transports: cred.transports, + }; + }).filter(Boolean); + + return c.json({ credentials: credentialList }); + } catch { + return c.json({ error: 'Unauthorized' }, 401); + } +}); + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +async function generateSessionToken(userId: string, username: string): Promise { + const now = Math.floor(Date.now() / 1000); + + const payload = { + iss: 'https://encryptid.jeffemmett.com', + sub: userId, + aud: CONFIG.allowedOrigins, + iat: now, + exp: now + CONFIG.sessionDuration, + username, + did: `did:key:${userId.slice(0, 32)}`, + eid: { + authLevel: 3, // ELEVATED (fresh WebAuthn) + capabilities: { + encrypt: true, + sign: true, + wallet: false, + }, + }, + }; + + return sign(payload, CONFIG.jwtSecret); +} + +// ============================================================================ +// SERVE STATIC FILES +// ============================================================================ + +// Serve demo page and static assets +app.use('/demo/*', serveStatic({ root: './src/encryptid/' })); +app.use('/static/*', serveStatic({ root: './public/' })); + +// Serve index +app.get('/', (c) => { + return c.html(` + + + + + + EncryptID - Unified Identity for the r-Ecosystem + + + +
+ +

EncryptID

+

Unified Identity for the r-Ecosystem

+ +
+
+
🔑
+
Passkey Auth
+
Hardware-backed, phishing-resistant
+
+
+
🛡️
+
Social Recovery
+
No seed phrases needed
+
+
+
🔐
+
E2E Encryption
+
Keys never leave your device
+
+
+
💰
+
Web3 Ready
+
Account abstraction wallets
+
+
+ + Try Demo + GitHub + +
+
Works with the r-Ecosystem
+ +
+
+ + + `); +}); + +// ============================================================================ +// START SERVER +// ============================================================================ + +console.log(` +╔═══════════════════════════════════════════════════════════╗ +║ ║ +║ 🔐 EncryptID Server ║ +║ ║ +║ Unified Identity for the r-Ecosystem ║ +║ ║ +║ Port: ${CONFIG.port} ║ +║ RP ID: ${CONFIG.rpId} ║ +║ ║ +╚═══════════════════════════════════════════════════════════╝ +`); + +export default { + port: CONFIG.port, + fetch: app.fetch, +}; diff --git a/src/encryptid/session.ts b/src/encryptid/session.ts new file mode 100644 index 0000000..e788cc5 --- /dev/null +++ b/src/encryptid/session.ts @@ -0,0 +1,468 @@ +/** + * EncryptID Session Management + * + * Handles session tokens, cross-app SSO, and authentication levels. + * This is Layer 4/5 of the EncryptID architecture. + */ + +import { AuthenticationResult, bufferToBase64url } from './webauthn'; + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Authentication security levels + */ +export enum AuthLevel { + /** Session token only - read-only, public content */ + BASIC = 1, + + /** Recent WebAuthn (within 15 min) - standard operations */ + STANDARD = 2, + + /** Fresh WebAuthn (just authenticated) - sensitive operations */ + ELEVATED = 3, + + /** Fresh WebAuthn + explicit consent - critical operations */ + CRITICAL = 4, +} + +/** + * EncryptID session token claims + */ +export interface EncryptIDClaims { + // Standard JWT claims + iss: string; // Issuer: https://encryptid.online + sub: string; // Subject: DID (did:key:z6Mk...) + aud: string[]; // Audience: authorized apps + iat: number; // Issued at + exp: number; // Expiration (short-lived: 15 min) + jti: string; // JWT ID (for revocation) + + // EncryptID-specific claims + eid: { + walletAddress?: string; // AA wallet address if deployed + credentialId: string; // WebAuthn credential ID + authLevel: AuthLevel; // Security level of this session + authTime: number; // When WebAuthn was performed + capabilities: { + encrypt: boolean; // Has derived encryption key + sign: boolean; // Has derived signing key + wallet: boolean; // Can authorize wallet ops + }; + recoveryConfigured: boolean; // Has social recovery set up + }; +} + +/** + * Session state stored locally + */ +export interface SessionState { + accessToken: string; + refreshToken: string; + claims: EncryptIDClaims; + lastAuthTime: number; +} + +/** + * Operation permission requirements + */ +export interface OperationPermission { + minAuthLevel: AuthLevel; + requiresCapability?: 'encrypt' | 'sign' | 'wallet'; + maxAgeSeconds?: number; // Max time since last WebAuthn +} + +// ============================================================================ +// OPERATION PERMISSIONS +// ============================================================================ + +/** + * Permission requirements for various operations across r-apps + */ +export const OPERATION_PERMISSIONS: Record = { + // rspace operations + 'rspace:view-public': { minAuthLevel: AuthLevel.BASIC }, + 'rspace:view-private': { minAuthLevel: AuthLevel.STANDARD }, + 'rspace:edit-board': { minAuthLevel: AuthLevel.STANDARD }, + 'rspace:create-board': { minAuthLevel: AuthLevel.STANDARD }, + 'rspace:delete-board': { minAuthLevel: AuthLevel.ELEVATED, maxAgeSeconds: 300 }, + 'rspace:encrypt-board': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'encrypt' }, + + // rwallet operations + 'rwallet:view-balance': { minAuthLevel: AuthLevel.BASIC }, + 'rwallet:view-history': { minAuthLevel: AuthLevel.STANDARD }, + 'rwallet:send-small': { minAuthLevel: AuthLevel.STANDARD, requiresCapability: 'wallet' }, + 'rwallet:send-large': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet', maxAgeSeconds: 60 }, + 'rwallet:add-guardian': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 }, + 'rwallet:remove-guardian': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 }, + + // rvote operations + 'rvote:view-proposals': { minAuthLevel: AuthLevel.BASIC }, + 'rvote:cast-vote': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'sign', maxAgeSeconds: 300 }, + 'rvote:delegate': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet' }, + + // rfiles operations + 'rfiles:list-files': { minAuthLevel: AuthLevel.STANDARD }, + 'rfiles:download-own': { minAuthLevel: AuthLevel.STANDARD, requiresCapability: 'encrypt' }, + 'rfiles:upload': { minAuthLevel: AuthLevel.STANDARD, requiresCapability: 'encrypt' }, + 'rfiles:share': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'encrypt' }, + 'rfiles:delete': { minAuthLevel: AuthLevel.ELEVATED, maxAgeSeconds: 300 }, + 'rfiles:export-keys': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 }, + + // rmaps operations + 'rmaps:view-public': { minAuthLevel: AuthLevel.BASIC }, + 'rmaps:add-location': { minAuthLevel: AuthLevel.STANDARD }, + 'rmaps:edit-location': { minAuthLevel: AuthLevel.STANDARD, requiresCapability: 'sign' }, + + // Account operations + 'account:view-profile': { minAuthLevel: AuthLevel.STANDARD }, + 'account:edit-profile': { minAuthLevel: AuthLevel.ELEVATED }, + 'account:export-data': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 }, + 'account:delete': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 }, +}; + +// ============================================================================ +// SESSION MANAGER +// ============================================================================ + +const SESSION_STORAGE_KEY = 'encryptid_session'; +const TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000; // 5 minutes before expiry + +/** + * EncryptID Session Manager + * + * Handles session state, token refresh, and permission checks. + */ +export class SessionManager { + private session: SessionState | null = null; + private refreshTimer: ReturnType | null = null; + + constructor() { + // Try to restore session from storage + this.restoreSession(); + } + + /** + * Initialize session from authentication result + */ + async createSession( + authResult: AuthenticationResult, + did: string, + capabilities: EncryptIDClaims['eid']['capabilities'] + ): Promise { + const now = Math.floor(Date.now() / 1000); + + // Build claims + const claims: EncryptIDClaims = { + iss: 'https://encryptid.jeffemmett.com', + sub: did, + aud: [ + 'rspace.online', + 'rwallet.online', + 'rvote.online', + 'rfiles.online', + 'rmaps.online', + ], + iat: now, + exp: now + 15 * 60, // 15 minutes + jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer), + + eid: { + credentialId: authResult.credentialId, + authLevel: AuthLevel.ELEVATED, // Fresh WebAuthn + authTime: now, + capabilities, + recoveryConfigured: false, // TODO: Check actual status + }, + }; + + // In production, tokens would be signed by server + // For now, we create unsigned tokens for the prototype + const accessToken = this.createUnsignedToken(claims); + const refreshToken = this.createRefreshToken(did); + + this.session = { + accessToken, + refreshToken, + claims, + lastAuthTime: Date.now(), + }; + + // Store session + this.persistSession(); + + // Schedule token refresh + this.scheduleRefresh(); + + console.log('EncryptID: Session created', { + did: did.slice(0, 30) + '...', + authLevel: AuthLevel[claims.eid.authLevel], + }); + + return this.session; + } + + /** + * Get current session + */ + getSession(): SessionState | null { + return this.session; + } + + /** + * Get current DID + */ + getDID(): string | null { + return this.session?.claims.sub ?? null; + } + + /** + * Get current auth level + */ + getAuthLevel(): AuthLevel { + if (!this.session) return AuthLevel.BASIC; + + // Check if session is still valid + const now = Math.floor(Date.now() / 1000); + if (now >= this.session.claims.exp) { + return AuthLevel.BASIC; + } + + // Check auth age for level downgrade + const authAge = now - this.session.claims.eid.authTime; + + if (authAge < 60) { + return AuthLevel.ELEVATED; // Within 1 minute + } else if (authAge < 15 * 60) { + return AuthLevel.STANDARD; // Within 15 minutes + } else { + return AuthLevel.BASIC; + } + } + + /** + * Check if user can perform an operation + */ + canPerform(operation: string): { allowed: boolean; reason?: string } { + const permission = OPERATION_PERMISSIONS[operation]; + + if (!permission) { + return { allowed: false, reason: 'Unknown operation' }; + } + + if (!this.session) { + return { allowed: false, reason: 'Not authenticated' }; + } + + const currentLevel = this.getAuthLevel(); + + // Check auth level + if (currentLevel < permission.minAuthLevel) { + return { + allowed: false, + reason: `Requires ${AuthLevel[permission.minAuthLevel]} auth level (current: ${AuthLevel[currentLevel]})`, + }; + } + + // Check capability + if (permission.requiresCapability) { + const hasCapability = this.session.claims.eid.capabilities[permission.requiresCapability]; + if (!hasCapability) { + return { + allowed: false, + reason: `Requires ${permission.requiresCapability} capability`, + }; + } + } + + // Check max age + if (permission.maxAgeSeconds) { + const authAge = Math.floor(Date.now() / 1000) - this.session.claims.eid.authTime; + if (authAge > permission.maxAgeSeconds) { + return { + allowed: false, + reason: `Authentication too old (${authAge}s > ${permission.maxAgeSeconds}s)`, + }; + } + } + + return { allowed: true }; + } + + /** + * Require fresh authentication for an operation + */ + requiresFreshAuth(operation: string): boolean { + const permission = OPERATION_PERMISSIONS[operation]; + if (!permission) return true; + + if (permission.minAuthLevel >= AuthLevel.CRITICAL) return true; + if (permission.maxAgeSeconds && permission.maxAgeSeconds <= 60) return true; + + return false; + } + + /** + * Upgrade auth level after fresh WebAuthn + */ + upgradeAuthLevel(level: AuthLevel = AuthLevel.ELEVATED): void { + if (!this.session) return; + + this.session.claims.eid.authLevel = level; + this.session.claims.eid.authTime = Math.floor(Date.now() / 1000); + this.session.lastAuthTime = Date.now(); + + this.persistSession(); + + console.log('EncryptID: Auth level upgraded to', AuthLevel[level]); + } + + /** + * Clear session (logout) + */ + clearSession(): void { + this.session = null; + + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + + try { + localStorage.removeItem(SESSION_STORAGE_KEY); + } catch { + // Ignore storage errors + } + + console.log('EncryptID: Session cleared'); + } + + /** + * Check if session is valid + */ + isValid(): boolean { + if (!this.session) return false; + + const now = Math.floor(Date.now() / 1000); + return now < this.session.claims.exp; + } + + // ========================================================================== + // PRIVATE METHODS + // ========================================================================== + + private createUnsignedToken(claims: EncryptIDClaims): string { + // In production, this would be a proper JWT signed by the server + // For the prototype, we create a base64-encoded JSON token + const header = { alg: 'none', typ: 'JWT' }; + const headerB64 = btoa(JSON.stringify(header)); + const payloadB64 = btoa(JSON.stringify(claims)); + return `${headerB64}.${payloadB64}.`; + } + + private createRefreshToken(did: string): string { + // In production, refresh tokens would be opaque server-issued tokens + const payload = { + sub: did, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days + jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer), + }; + return btoa(JSON.stringify(payload)); + } + + private persistSession(): void { + if (!this.session) return; + + try { + localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(this.session)); + } catch (error) { + console.warn('EncryptID: Failed to persist session', error); + } + } + + private restoreSession(): void { + try { + const stored = localStorage.getItem(SESSION_STORAGE_KEY); + if (stored) { + const session = JSON.parse(stored) as SessionState; + + // Verify session is not expired + const now = Math.floor(Date.now() / 1000); + if (now < session.claims.exp) { + this.session = session; + this.scheduleRefresh(); + console.log('EncryptID: Session restored'); + } else { + // Session expired, clear it + localStorage.removeItem(SESSION_STORAGE_KEY); + } + } + } catch (error) { + console.warn('EncryptID: Failed to restore session', error); + } + } + + private scheduleRefresh(): void { + if (!this.session) return; + + // Clear existing timer + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + // Calculate time until refresh needed + const expiresAt = this.session.claims.exp * 1000; + const refreshAt = expiresAt - TOKEN_REFRESH_THRESHOLD; + const delay = Math.max(refreshAt - Date.now(), 0); + + this.refreshTimer = setTimeout(() => { + this.refreshTokens(); + }, delay); + } + + private async refreshTokens(): Promise { + // In production, this would call the server to refresh tokens + // For the prototype, we just extend the expiration + if (!this.session) return; + + const now = Math.floor(Date.now() / 1000); + + // Downgrade auth level on refresh (user hasn't re-authenticated) + this.session.claims.eid.authLevel = Math.min( + this.session.claims.eid.authLevel, + AuthLevel.STANDARD + ); + + this.session.claims.iat = now; + this.session.claims.exp = now + 15 * 60; + this.session.claims.jti = bufferToBase64url( + crypto.getRandomValues(new Uint8Array(16)).buffer + ); + + this.session.accessToken = this.createUnsignedToken(this.session.claims); + + this.persistSession(); + this.scheduleRefresh(); + + console.log('EncryptID: Tokens refreshed'); + } +} + +// ============================================================================ +// SINGLETON INSTANCE +// ============================================================================ + +let sessionManagerInstance: SessionManager | null = null; + +/** + * Get the global session manager instance + */ +export function getSessionManager(): SessionManager { + if (!sessionManagerInstance) { + sessionManagerInstance = new SessionManager(); + } + return sessionManagerInstance; +} diff --git a/src/encryptid/ui/guardian-setup.ts b/src/encryptid/ui/guardian-setup.ts new file mode 100644 index 0000000..70ad83c --- /dev/null +++ b/src/encryptid/ui/guardian-setup.ts @@ -0,0 +1,788 @@ +/** + * EncryptID Guardian Setup UI Component + * + * A web component for configuring social recovery guardians. + * Designed to be embedded in any r-ecosystem app. + */ + +import { + Guardian, + GuardianType, + RecoveryConfig, + getGuardianTypeInfo, + getRecoveryManager, +} from '../recovery'; + +// ============================================================================ +// STYLES +// ============================================================================ + +const styles = ` + :host { + --eid-primary: #06b6d4; + --eid-primary-hover: #0891b2; + --eid-secondary: #f97316; + --eid-success: #22c55e; + --eid-warning: #eab308; + --eid-danger: #ef4444; + --eid-bg: #0f172a; + --eid-bg-secondary: #1e293b; + --eid-bg-tertiary: #334155; + --eid-text: #f1f5f9; + --eid-text-secondary: #94a3b8; + --eid-border: #475569; + --eid-radius: 8px; + --eid-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3); + + display: block; + font-family: system-ui, -apple-system, sans-serif; + color: var(--eid-text); + } + + .guardian-setup { + background: var(--eid-bg); + border-radius: var(--eid-radius); + padding: 24px; + max-width: 600px; + } + + .header { + margin-bottom: 24px; + } + + .header h2 { + margin: 0 0 8px 0; + font-size: 1.5rem; + font-weight: 600; + } + + .header p { + margin: 0; + color: var(--eid-text-secondary); + font-size: 0.875rem; + } + + .status-bar { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background: var(--eid-bg-secondary); + border-radius: var(--eid-radius); + margin-bottom: 24px; + } + + .status-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; + } + + .status-indicator.configured { + background: var(--eid-success); + box-shadow: 0 0 8px var(--eid-success); + } + + .status-indicator.incomplete { + background: var(--eid-warning); + box-shadow: 0 0 8px var(--eid-warning); + } + + .status-indicator.not-configured { + background: var(--eid-danger); + } + + .status-text { + flex: 1; + } + + .status-text .label { + font-weight: 500; + margin-bottom: 2px; + } + + .status-text .detail { + font-size: 0.75rem; + color: var(--eid-text-secondary); + } + + .threshold-display { + font-size: 1.25rem; + font-weight: 600; + color: var(--eid-primary); + } + + .guardian-list { + margin-bottom: 24px; + } + + .guardian-card { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background: var(--eid-bg-secondary); + border-radius: var(--eid-radius); + margin-bottom: 12px; + border: 1px solid var(--eid-border); + transition: border-color 0.2s; + } + + .guardian-card:hover { + border-color: var(--eid-primary); + } + + .guardian-icon { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--eid-bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + flex-shrink: 0; + } + + .guardian-info { + flex: 1; + min-width: 0; + } + + .guardian-name { + font-weight: 500; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .guardian-type { + font-size: 0.75rem; + color: var(--eid-text-secondary); + } + + .guardian-verified { + font-size: 0.625rem; + color: var(--eid-success); + margin-top: 4px; + } + + .guardian-actions { + display: flex; + gap: 8px; + } + + .btn { + padding: 8px 16px; + border-radius: var(--eid-radius); + font-size: 0.875rem; + font-weight: 500; + border: none; + cursor: pointer; + transition: all 0.2s; + } + + .btn-primary { + background: var(--eid-primary); + color: white; + } + + .btn-primary:hover { + background: var(--eid-primary-hover); + } + + .btn-secondary { + background: var(--eid-bg-tertiary); + color: var(--eid-text); + border: 1px solid var(--eid-border); + } + + .btn-secondary:hover { + background: var(--eid-border); + } + + .btn-danger { + background: transparent; + color: var(--eid-danger); + border: 1px solid var(--eid-danger); + } + + .btn-danger:hover { + background: var(--eid-danger); + color: white; + } + + .btn-icon { + padding: 8px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + } + + .add-guardian-section { + margin-top: 24px; + } + + .add-guardian-section h3 { + font-size: 1rem; + font-weight: 500; + margin: 0 0 16px 0; + } + + .guardian-type-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px; + } + + .guardian-type-card { + padding: 16px; + background: var(--eid-bg-secondary); + border-radius: var(--eid-radius); + border: 2px solid transparent; + cursor: pointer; + transition: all 0.2s; + text-align: center; + } + + .guardian-type-card:hover { + border-color: var(--eid-primary); + } + + .guardian-type-card.selected { + border-color: var(--eid-primary); + background: rgba(6, 182, 212, 0.1); + } + + .guardian-type-icon { + font-size: 2rem; + margin-bottom: 8px; + } + + .guardian-type-name { + font-weight: 500; + font-size: 0.875rem; + margin-bottom: 4px; + } + + .guardian-type-desc { + font-size: 0.75rem; + color: var(--eid-text-secondary); + } + + .settings-section { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--eid-border); + } + + .settings-section h3 { + font-size: 1rem; + font-weight: 500; + margin: 0 0 16px 0; + } + + .setting-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid var(--eid-border); + } + + .setting-row:last-child { + border-bottom: none; + } + + .setting-label { + font-size: 0.875rem; + } + + .setting-label .hint { + font-size: 0.75rem; + color: var(--eid-text-secondary); + margin-top: 4px; + } + + .setting-control { + display: flex; + align-items: center; + gap: 8px; + } + + .setting-control input[type="number"] { + width: 60px; + padding: 8px; + border-radius: var(--eid-radius); + border: 1px solid var(--eid-border); + background: var(--eid-bg-secondary); + color: var(--eid-text); + text-align: center; + } + + .setting-control select { + padding: 8px 12px; + border-radius: var(--eid-radius); + border: 1px solid var(--eid-border); + background: var(--eid-bg-secondary); + color: var(--eid-text); + } + + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + .modal { + background: var(--eid-bg); + border-radius: var(--eid-radius); + padding: 24px; + max-width: 400px; + width: 90%; + box-shadow: var(--eid-shadow); + } + + .modal h3 { + margin: 0 0 16px 0; + font-size: 1.25rem; + } + + .modal-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 24px; + } + + .form-group { + margin-bottom: 16px; + } + + .form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 8px; + } + + .form-group input { + width: 100%; + padding: 10px 12px; + border-radius: var(--eid-radius); + border: 1px solid var(--eid-border); + background: var(--eid-bg-secondary); + color: var(--eid-text); + font-size: 0.875rem; + box-sizing: border-box; + } + + .form-group input:focus { + outline: none; + border-color: var(--eid-primary); + } + + .empty-state { + text-align: center; + padding: 32px; + color: var(--eid-text-secondary); + } + + .empty-state-icon { + font-size: 3rem; + margin-bottom: 16px; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + .loading { + animation: pulse 1.5s infinite; + } +`; + +// ============================================================================ +// ICONS (simple SVG paths) +// ============================================================================ + +const ICONS: Record = { + key: '🔑', + user: '👤', + shield: '🛡️', + building: '🏢', + clock: '⏰', + check: '✓', + x: '✕', + plus: '+', + trash: '🗑️', + refresh: '↻', + question: '?', +}; + +// ============================================================================ +// WEB COMPONENT +// ============================================================================ + +export class GuardianSetupElement extends HTMLElement { + private shadow: ShadowRoot; + private config: RecoveryConfig | null = null; + private selectedType: GuardianType | null = null; + private showAddModal: boolean = false; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.loadConfig(); + this.render(); + } + + private loadConfig() { + const manager = getRecoveryManager(); + this.config = manager.getConfig(); + + // Initialize if not configured + if (!this.config) { + manager.initializeRecovery(3).then(() => { + this.config = manager.getConfig(); + this.render(); + }); + } + } + + private render() { + const totalWeight = this.config?.guardians.reduce((sum, g) => sum + g.weight, 0) ?? 0; + const threshold = this.config?.threshold ?? 3; + const isConfigured = totalWeight >= threshold; + const guardians = this.config?.guardians ?? []; + + this.shadow.innerHTML = ` + +
+
+

🛡️ Social Recovery

+

Set up guardians to recover your account without seed phrases

+
+ +
+
+
+
${isConfigured ? 'Recovery Configured' : 'Setup Incomplete'}
+
+ ${isConfigured + ? `${threshold} of ${guardians.length} guardians needed to recover` + : `Add ${threshold - totalWeight} more guardian${threshold - totalWeight > 1 ? 's' : ''} to enable recovery` + } +
+
+
${totalWeight}/${threshold}
+
+ +
+ ${guardians.length === 0 ? ` +
+
🛡️
+

No guardians configured yet

+

Add guardians below to enable account recovery

+
+ ` : guardians.map(g => this.renderGuardianCard(g)).join('')} +
+ +
+

Add a Guardian

+
+ ${this.renderGuardianTypes()} +
+
+ +
+

Recovery Settings

+ +
+
+
Required Guardians
+
How many guardians must approve recovery
+
+
+ + of ${guardians.length} +
+
+ +
+
+
Time Lock
+
Waiting period before recovery completes
+
+
+ +
+
+
+ + ${this.showAddModal ? this.renderAddModal() : ''} +
+ `; + + this.attachEventListeners(); + } + + private renderGuardianCard(guardian: Guardian): string { + const typeInfo = getGuardianTypeInfo(guardian.type); + const verified = guardian.lastVerified + ? `Verified ${this.formatTimeAgo(guardian.lastVerified)}` + : 'Not verified'; + + return ` +
+
${ICONS[typeInfo.icon]}
+
+
${guardian.name}
+
${typeInfo.name}
+
${guardian.lastVerified ? '✓ ' : ''}${verified}
+
+
+ + +
+
+ `; + } + + private renderGuardianTypes(): string { + const types = [ + GuardianType.SECONDARY_PASSKEY, + GuardianType.TRUSTED_CONTACT, + GuardianType.HARDWARE_KEY, + GuardianType.INSTITUTIONAL, + GuardianType.TIME_DELAYED_SELF, + ]; + + return types.map(type => { + const info = getGuardianTypeInfo(type); + const isSelected = this.selectedType === type; + + return ` +
+
${ICONS[info.icon]}
+
${info.name}
+
${info.description}
+
+ `; + }).join(''); + } + + private renderAddModal(): string { + if (!this.selectedType) return ''; + + const info = getGuardianTypeInfo(this.selectedType); + + return ` + + `; + } + + private attachEventListeners() { + // Guardian type selection + this.shadow.querySelectorAll('.guardian-type-card').forEach(card => { + card.addEventListener('click', () => { + const type = card.getAttribute('data-type') as GuardianType; + this.selectedType = type; + this.showAddModal = true; + this.render(); + }); + }); + + // Guardian card actions + this.shadow.querySelectorAll('.guardian-card').forEach(card => { + const id = card.getAttribute('data-guardian-id'); + + card.querySelector('.verify-btn')?.addEventListener('click', (e) => { + e.stopPropagation(); + this.verifyGuardian(id!); + }); + + card.querySelector('.remove-btn')?.addEventListener('click', (e) => { + e.stopPropagation(); + this.removeGuardian(id!); + }); + }); + + // Modal actions + this.shadow.getElementById('cancel-add')?.addEventListener('click', () => { + this.showAddModal = false; + this.selectedType = null; + this.render(); + }); + + this.shadow.getElementById('confirm-add')?.addEventListener('click', () => { + this.addGuardian(); + }); + + // Settings + this.shadow.getElementById('threshold')?.addEventListener('change', (e) => { + const value = parseInt((e.target as HTMLInputElement).value); + getRecoveryManager().setThreshold(value).then(() => { + this.loadConfig(); + this.render(); + }); + }); + + this.shadow.getElementById('delay')?.addEventListener('change', (e) => { + const value = parseInt((e.target as HTMLSelectElement).value); + getRecoveryManager().setDelay(value).then(() => { + this.loadConfig(); + this.render(); + }); + }); + + // Close modal on overlay click + this.shadow.querySelector('.modal-overlay')?.addEventListener('click', (e) => { + if (e.target === e.currentTarget) { + this.showAddModal = false; + this.selectedType = null; + this.render(); + } + }); + } + + private async addGuardian() { + if (!this.selectedType) return; + + const nameInput = this.shadow.getElementById('guardian-name') as HTMLInputElement; + const emailInput = this.shadow.getElementById('guardian-email') as HTMLInputElement; + const delayInput = this.shadow.getElementById('guardian-delay') as HTMLSelectElement; + + const name = nameInput?.value.trim(); + if (!name) { + alert('Please enter a name for this guardian'); + return; + } + + const guardian: Omit = { + type: this.selectedType, + name, + weight: 1, + }; + + if (this.selectedType === GuardianType.TRUSTED_CONTACT && emailInput) { + guardian.contactEmail = emailInput.value.trim(); + } + + if (this.selectedType === GuardianType.TIME_DELAYED_SELF && delayInput) { + guardian.delaySeconds = parseInt(delayInput.value); + } + + try { + await getRecoveryManager().addGuardian(guardian); + this.showAddModal = false; + this.selectedType = null; + this.loadConfig(); + this.render(); + + // Dispatch event + this.dispatchEvent(new CustomEvent('guardian-added', { detail: guardian })); + } catch (error) { + alert(`Failed to add guardian: ${error}`); + } + } + + private async verifyGuardian(id: string) { + try { + await getRecoveryManager().verifyGuardian(id); + this.loadConfig(); + this.render(); + + this.dispatchEvent(new CustomEvent('guardian-verified', { detail: { id } })); + } catch (error) { + alert(`Failed to verify guardian: ${error}`); + } + } + + private async removeGuardian(id: string) { + if (!confirm('Are you sure you want to remove this guardian?')) { + return; + } + + try { + await getRecoveryManager().removeGuardian(id); + this.loadConfig(); + this.render(); + + this.dispatchEvent(new CustomEvent('guardian-removed', { detail: { id } })); + } catch (error) { + alert(`Failed to remove guardian: ${error}`); + } + } + + private formatTimeAgo(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + + if (seconds < 60) return 'just now'; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; + } +} + +// Register the custom element +customElements.define('encryptid-guardian-setup', GuardianSetupElement); diff --git a/src/encryptid/ui/login-button.ts b/src/encryptid/ui/login-button.ts new file mode 100644 index 0000000..435a0c2 --- /dev/null +++ b/src/encryptid/ui/login-button.ts @@ -0,0 +1,580 @@ +/** + * EncryptID Login Button Component + * + * A customizable login button that handles WebAuthn authentication + * and key derivation. Can be embedded in any r-ecosystem app. + */ + +import { + registerPasskey, + authenticatePasskey, + detectCapabilities, + startConditionalUI, + WebAuthnCapabilities, +} from '../webauthn'; +import { getKeyManager } from '../key-derivation'; +import { getSessionManager, AuthLevel } from '../session'; + +// ============================================================================ +// STYLES +// ============================================================================ + +const styles = ` + :host { + --eid-primary: #06b6d4; + --eid-primary-hover: #0891b2; + --eid-bg: #0f172a; + --eid-bg-hover: #1e293b; + --eid-text: #f1f5f9; + --eid-text-secondary: #94a3b8; + --eid-radius: 8px; + --eid-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3); + + display: inline-block; + font-family: system-ui, -apple-system, sans-serif; + } + + .login-container { + position: relative; + } + + .login-btn { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 24px; + background: var(--eid-primary); + color: white; + border: none; + border-radius: var(--eid-radius); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + box-shadow: var(--eid-shadow); + } + + .login-btn:hover { + background: var(--eid-primary-hover); + transform: translateY(-1px); + } + + .login-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } + + .login-btn.outline { + background: transparent; + border: 2px solid var(--eid-primary); + color: var(--eid-primary); + } + + .login-btn.outline:hover { + background: var(--eid-primary); + color: white; + } + + .login-btn.small { + padding: 8px 16px; + font-size: 0.875rem; + } + + .login-btn.large { + padding: 16px 32px; + font-size: 1.125rem; + } + + .passkey-icon { + width: 24px; + height: 24px; + } + + .login-btn.small .passkey-icon { + width: 18px; + height: 18px; + } + + .login-btn.large .passkey-icon { + width: 28px; + height: 28px; + } + + .user-info { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + background: var(--eid-bg); + border-radius: var(--eid-radius); + color: var(--eid-text); + } + + .user-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--eid-primary); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.875rem; + } + + .user-details { + flex: 1; + min-width: 0; + } + + .user-did { + font-size: 0.75rem; + color: var(--eid-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; + } + + .auth-level { + font-size: 0.625rem; + padding: 2px 6px; + border-radius: 4px; + background: var(--eid-bg-hover); + } + + .auth-level.elevated { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; + } + + .auth-level.standard { + background: rgba(234, 179, 8, 0.2); + color: #eab308; + } + + .logout-btn { + padding: 6px 12px; + background: transparent; + border: 1px solid var(--eid-text-secondary); + border-radius: var(--eid-radius); + color: var(--eid-text-secondary); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s; + } + + .logout-btn:hover { + border-color: #ef4444; + color: #ef4444; + } + + .dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + background: var(--eid-bg); + border-radius: var(--eid-radius); + box-shadow: var(--eid-shadow); + min-width: 200px; + z-index: 100; + overflow: hidden; + } + + .dropdown-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + color: var(--eid-text); + text-decoration: none; + cursor: pointer; + transition: background 0.2s; + } + + .dropdown-item:hover { + background: var(--eid-bg-hover); + } + + .dropdown-item.danger { + color: #ef4444; + } + + .dropdown-divider { + height: 1px; + background: #334155; + margin: 4px 0; + } + + .loading-spinner { + width: 20px; + height: 20px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .hidden { + display: none; + } +`; + +// ============================================================================ +// PASSKEY SVG ICON +// ============================================================================ + +const PASSKEY_ICON = ` + + + + + + +`; + +// ============================================================================ +// WEB COMPONENT +// ============================================================================ + +export class EncryptIDLoginButton extends HTMLElement { + private shadow: ShadowRoot; + private loading: boolean = false; + private showDropdown: boolean = false; + private capabilities: WebAuthnCapabilities | null = null; + + // Configurable attributes + static get observedAttributes() { + return ['size', 'variant', 'label', 'show-user']; + } + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + } + + async connectedCallback() { + // Detect capabilities + this.capabilities = await detectCapabilities(); + + // Start conditional UI if available + if (this.capabilities.conditionalUI) { + this.startConditionalAuth(); + } + + this.render(); + + // Close dropdown on outside click + document.addEventListener('click', (e) => { + if (!this.contains(e.target as Node)) { + this.showDropdown = false; + this.render(); + } + }); + } + + attributeChangedCallback() { + this.render(); + } + + private get size(): 'small' | 'medium' | 'large' { + return (this.getAttribute('size') as any) || 'medium'; + } + + private get variant(): 'primary' | 'outline' { + return (this.getAttribute('variant') as any) || 'primary'; + } + + private get label(): string { + return this.getAttribute('label') || 'Sign in with Passkey'; + } + + private get showUser(): boolean { + return this.hasAttribute('show-user'); + } + + private render() { + const session = getSessionManager(); + const isLoggedIn = session.isValid(); + const did = session.getDID(); + const authLevel = session.getAuthLevel(); + + this.shadow.innerHTML = ` + + + `; + + this.attachEventListeners(); + } + + private renderLoginButton(): string { + const sizeClass = this.size === 'medium' ? '' : this.size; + const variantClass = this.variant === 'primary' ? '' : this.variant; + + return ` + + `; + } + + private renderUserInfo(did: string, authLevel: AuthLevel): string { + const shortDID = did.slice(0, 20) + '...' + did.slice(-8); + const initial = did.slice(8, 10).toUpperCase(); + const levelName = AuthLevel[authLevel].toLowerCase(); + + return ` + + `; + } + + private renderDropdown(): string { + return ` + + `; + } + + private attachEventListeners() { + const session = getSessionManager(); + const isLoggedIn = session.isValid(); + + if (isLoggedIn && this.showUser) { + // User info click - toggle dropdown + this.shadow.querySelector('.user-info')?.addEventListener('click', () => { + this.showDropdown = !this.showDropdown; + this.render(); + }); + + // Dropdown actions + this.shadow.querySelectorAll('.dropdown-item').forEach(item => { + item.addEventListener('click', (e) => { + e.stopPropagation(); + const action = (item as HTMLElement).dataset.action; + this.handleDropdownAction(action!); + }); + }); + } else { + // Login button click + this.shadow.querySelector('.login-btn')?.addEventListener('click', () => { + this.handleLogin(); + }); + } + } + + private async handleLogin() { + if (this.loading) return; + + this.loading = true; + this.render(); + + try { + // Try to authenticate with existing passkey + const result = await authenticatePasskey(); + + // Initialize key manager with PRF output + const keyManager = getKeyManager(); + if (result.prfOutput) { + await keyManager.initFromPRF(result.prfOutput); + } + + // Get derived keys + const keys = await keyManager.getKeys(); + + // Create session + const sessionManager = getSessionManager(); + await sessionManager.createSession(result, keys.did, { + encrypt: true, + sign: true, + wallet: false, // Will be true after wallet setup + }); + + // Dispatch success event + this.dispatchEvent(new CustomEvent('login-success', { + detail: { + did: keys.did, + credentialId: result.credentialId, + prfAvailable: !!result.prfOutput, + }, + bubbles: true, + })); + + } catch (error: any) { + // If no credential found, offer to register + if (error.name === 'NotAllowedError' || error.message?.includes('No credential')) { + this.dispatchEvent(new CustomEvent('login-register-needed', { + bubbles: true, + })); + } else { + this.dispatchEvent(new CustomEvent('login-error', { + detail: { error: error.message }, + bubbles: true, + })); + } + } finally { + this.loading = false; + this.render(); + } + } + + private async handleDropdownAction(action: string) { + this.showDropdown = false; + + switch (action) { + case 'profile': + this.dispatchEvent(new CustomEvent('navigate', { + detail: { path: '/profile' }, + bubbles: true, + })); + break; + + case 'recovery': + this.dispatchEvent(new CustomEvent('navigate', { + detail: { path: '/recovery' }, + bubbles: true, + })); + break; + + case 'upgrade': + await this.handleUpgradeAuth(); + break; + + case 'logout': + this.handleLogout(); + break; + } + + this.render(); + } + + private async handleUpgradeAuth() { + try { + const result = await authenticatePasskey(); + + const sessionManager = getSessionManager(); + sessionManager.upgradeAuthLevel(AuthLevel.ELEVATED); + + this.dispatchEvent(new CustomEvent('auth-upgraded', { + detail: { level: AuthLevel.ELEVATED }, + bubbles: true, + })); + } catch (error) { + console.error('Failed to upgrade auth:', error); + } + } + + private handleLogout() { + const sessionManager = getSessionManager(); + sessionManager.clearSession(); + + const keyManager = getKeyManager(); + keyManager.clear(); + + this.dispatchEvent(new CustomEvent('logout', { bubbles: true })); + this.render(); + } + + private async startConditionalAuth() { + // Start listening for conditional UI (passkey autofill) + try { + const result = await startConditionalUI(); + if (result) { + // User selected a passkey from autofill + const keyManager = getKeyManager(); + if (result.prfOutput) { + await keyManager.initFromPRF(result.prfOutput); + } + + const keys = await keyManager.getKeys(); + + const sessionManager = getSessionManager(); + await sessionManager.createSession(result, keys.did, { + encrypt: true, + sign: true, + wallet: false, + }); + + this.dispatchEvent(new CustomEvent('login-success', { + detail: { + did: keys.did, + credentialId: result.credentialId, + prfAvailable: !!result.prfOutput, + viaConditionalUI: true, + }, + bubbles: true, + })); + + this.render(); + } + } catch (error) { + // Conditional UI cancelled or not available + console.log('Conditional UI not completed:', error); + } + } + + /** + * Public method to trigger registration flow + */ + async register(username: string, displayName: string): Promise { + this.loading = true; + this.render(); + + try { + const credential = await registerPasskey(username, displayName); + + this.dispatchEvent(new CustomEvent('register-success', { + detail: { + credentialId: credential.credentialId, + prfSupported: credential.prfSupported, + }, + bubbles: true, + })); + + // Auto-login after registration + await this.handleLogin(); + } catch (error: any) { + this.dispatchEvent(new CustomEvent('register-error', { + detail: { error: error.message }, + bubbles: true, + })); + } finally { + this.loading = false; + this.render(); + } + } +} + +// Register the custom element +customElements.define('encryptid-login', EncryptIDLoginButton); diff --git a/src/encryptid/webauthn.ts b/src/encryptid/webauthn.ts new file mode 100644 index 0000000..d930a44 --- /dev/null +++ b/src/encryptid/webauthn.ts @@ -0,0 +1,453 @@ +/** + * EncryptID WebAuthn Module + * + * Handles passkey registration, authentication, and PRF extension + * for key derivation. This is the foundation layer of EncryptID. + */ + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface EncryptIDCredential { + credentialId: string; + publicKey: ArrayBuffer; + userId: string; + username: string; + createdAt: number; + prfSupported: boolean; + transports?: AuthenticatorTransport[]; +} + +export interface AuthenticationResult { + credentialId: string; + userId: string; + prfOutput?: ArrayBuffer; // Only if PRF extension supported + signature: ArrayBuffer; + authenticatorData: ArrayBuffer; +} + +export interface EncryptIDConfig { + rpId: string; + rpName: string; + origin: string; + userVerification: UserVerificationRequirement; + timeout: number; +} + +// Default configuration for EncryptID +const DEFAULT_CONFIG: EncryptIDConfig = { + rpId: 'jeffemmett.com', // Root domain for Related Origins to work across subdomains + rpName: 'EncryptID', + origin: typeof window !== 'undefined' ? window.location.origin : '', + userVerification: 'required', + timeout: 60000, +}; + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +/** + * Convert ArrayBuffer to base64url string + */ +export function bufferToBase64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +/** + * Convert base64url string to ArrayBuffer + */ +export function base64urlToBuffer(base64url: string): ArrayBuffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padding = '='.repeat((4 - (base64.length % 4)) % 4); + const binary = atob(base64 + padding); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +/** + * Generate a random challenge + */ +export function generateChallenge(): ArrayBuffer { + return crypto.getRandomValues(new Uint8Array(32)).buffer; +} + +/** + * Generate a deterministic salt for PRF extension + */ +export async function generatePRFSalt(purpose: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(`encryptid-prf-salt-${purpose}-v1`); + return crypto.subtle.digest('SHA-256', data); +} + +// ============================================================================ +// WEBAUTHN REGISTRATION +// ============================================================================ + +/** + * Register a new EncryptID passkey + * + * This creates a discoverable credential (passkey) that can be used + * for authentication across all r-ecosystem apps. + */ +export async function registerPasskey( + username: string, + displayName: string, + config: Partial = {} +): Promise { + const cfg = { ...DEFAULT_CONFIG, ...config }; + + // Check WebAuthn support + if (!window.PublicKeyCredential) { + throw new Error('WebAuthn is not supported in this browser'); + } + + // Check platform authenticator availability + const platformAvailable = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + + // Generate user ID (random bytes, not PII) + const userId = crypto.getRandomValues(new Uint8Array(32)); + + // Generate challenge (in production, this comes from server) + const challenge = generateChallenge(); + + // Generate PRF salt for key derivation + const prfSalt = await generatePRFSalt('master-key'); + + // Build credential creation options + const createOptions: CredentialCreationOptions = { + publicKey: { + challenge: new Uint8Array(challenge), + + // Relying Party information + rp: { + id: cfg.rpId, + name: cfg.rpName, + }, + + // User information + user: { + id: userId, + name: username, + displayName: displayName, + }, + + // Supported algorithms (prefer ES256) + pubKeyCredParams: [ + { alg: -7, type: 'public-key' }, // ES256 (P-256) + { alg: -257, type: 'public-key' }, // RS256 + ], + + // Authenticator requirements + authenticatorSelection: { + // Require discoverable credential (passkey) + residentKey: 'required', + requireResidentKey: true, + + // Require user verification (biometric/PIN) + userVerification: cfg.userVerification, + + // Prefer platform authenticator but allow cross-platform + authenticatorAttachment: platformAvailable ? 'platform' : undefined, + }, + + // Don't request attestation (privacy) + attestation: 'none', + + // Timeout + timeout: cfg.timeout, + + // Extensions + extensions: { + // Request PRF extension for key derivation + // @ts-ignore - PRF extension not in standard types yet + prf: { + eval: { + first: new Uint8Array(prfSalt), + }, + }, + // Request credential properties + credProps: true, + }, + }, + }; + + // Create the credential + const credential = await navigator.credentials.create(createOptions) as PublicKeyCredential; + + if (!credential) { + throw new Error('Failed to create credential'); + } + + const response = credential.response as AuthenticatorAttestationResponse; + + // Check if PRF is supported + // @ts-ignore + const prfSupported = credential.getClientExtensionResults()?.prf?.enabled === true; + + // Extract public key from attestation + const publicKey = response.getPublicKey(); + if (!publicKey) { + throw new Error('Failed to get public key from credential'); + } + + // Build credential object + const encryptIDCredential: EncryptIDCredential = { + credentialId: bufferToBase64url(credential.rawId), + publicKey: publicKey, + userId: bufferToBase64url(userId.buffer), + username: username, + createdAt: Date.now(), + prfSupported: prfSupported, + transports: response.getTransports?.() as AuthenticatorTransport[], + }; + + console.log('EncryptID: Passkey registered', { + credentialId: encryptIDCredential.credentialId.slice(0, 20) + '...', + prfSupported, + }); + + return encryptIDCredential; +} + +// ============================================================================ +// WEBAUTHN AUTHENTICATION +// ============================================================================ + +/** + * Authenticate with an existing EncryptID passkey + * + * Returns the authentication result including PRF output for key derivation + * (if the authenticator supports PRF). + */ +export async function authenticatePasskey( + credentialId?: string, // Optional: specify credential, or let user choose + config: Partial = {} +): Promise { + const cfg = { ...DEFAULT_CONFIG, ...config }; + + // Check WebAuthn support + if (!window.PublicKeyCredential) { + throw new Error('WebAuthn is not supported in this browser'); + } + + // Generate challenge (in production, this comes from server) + const challenge = generateChallenge(); + + // Generate PRF salt for key derivation + const prfSalt = await generatePRFSalt('master-key'); + + // Build allowed credentials list + const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = credentialId + ? [{ + type: 'public-key', + id: new Uint8Array(base64urlToBuffer(credentialId)), + }] + : undefined; // undefined = let user choose from available passkeys + + // Build authentication options + const getOptions: CredentialRequestOptions = { + publicKey: { + challenge: new Uint8Array(challenge), + + // Relying Party ID + rpId: cfg.rpId, + + // Allowed credentials (or undefined for discoverable) + allowCredentials: allowCredentials, + + // Require user verification + userVerification: cfg.userVerification, + + // Timeout + timeout: cfg.timeout, + + // Extensions + extensions: { + // Request PRF evaluation for key derivation + // @ts-ignore - PRF extension not in standard types yet + prf: { + eval: { + first: new Uint8Array(prfSalt), + }, + }, + }, + }, + }; + + // Perform authentication + const credential = await navigator.credentials.get(getOptions) as PublicKeyCredential; + + if (!credential) { + throw new Error('Authentication failed'); + } + + const response = credential.response as AuthenticatorAssertionResponse; + + // Extract PRF output if available + // @ts-ignore + const prfResults = credential.getClientExtensionResults()?.prf?.results; + const prfOutput = prfResults?.first; + + // Build result + const result: AuthenticationResult = { + credentialId: bufferToBase64url(credential.rawId), + userId: response.userHandle + ? bufferToBase64url(response.userHandle) + : '', + prfOutput: prfOutput, + signature: response.signature, + authenticatorData: response.authenticatorData, + }; + + console.log('EncryptID: Authentication successful', { + credentialId: result.credentialId.slice(0, 20) + '...', + prfAvailable: !!prfOutput, + }); + + return result; +} + +// ============================================================================ +// CONDITIONAL UI (AUTOFILL) +// ============================================================================ + +/** + * Check if conditional mediation (passkey autofill) is available + */ +export async function isConditionalMediationAvailable(): Promise { + if (!window.PublicKeyCredential) { + return false; + } + + // @ts-ignore + if (typeof PublicKeyCredential.isConditionalMediationAvailable === 'function') { + // @ts-ignore + return PublicKeyCredential.isConditionalMediationAvailable(); + } + + return false; +} + +/** + * Start conditional UI authentication (passkey autofill in input fields) + * + * Call this on page load to enable passkey suggestions in username fields. + * The input field needs `autocomplete="username webauthn"` attribute. + */ +export async function startConditionalUI( + config: Partial = {} +): Promise { + const available = await isConditionalMediationAvailable(); + + if (!available) { + console.log('EncryptID: Conditional mediation not available'); + return null; + } + + const cfg = { ...DEFAULT_CONFIG, ...config }; + const challenge = generateChallenge(); + const prfSalt = await generatePRFSalt('master-key'); + + try { + const credential = await navigator.credentials.get({ + publicKey: { + challenge: new Uint8Array(challenge), + rpId: cfg.rpId, + userVerification: cfg.userVerification, + timeout: cfg.timeout, + extensions: { + // @ts-ignore + prf: { + eval: { + first: new Uint8Array(prfSalt), + }, + }, + }, + }, + // @ts-ignore - conditional mediation + mediation: 'conditional', + }) as PublicKeyCredential; + + if (!credential) { + return null; + } + + const response = credential.response as AuthenticatorAssertionResponse; + // @ts-ignore + const prfResults = credential.getClientExtensionResults()?.prf?.results; + + return { + credentialId: bufferToBase64url(credential.rawId), + userId: response.userHandle + ? bufferToBase64url(response.userHandle) + : '', + prfOutput: prfResults?.first, + signature: response.signature, + authenticatorData: response.authenticatorData, + }; + } catch (error) { + // Conditional UI was cancelled or failed + console.log('EncryptID: Conditional UI cancelled or failed', error); + return null; + } +} + +// ============================================================================ +// FEATURE DETECTION +// ============================================================================ + +export interface WebAuthnCapabilities { + webauthn: boolean; + platformAuthenticator: boolean; + conditionalUI: boolean; + prfExtension: boolean; // Note: Can only be confirmed after credential creation +} + +/** + * Detect WebAuthn capabilities of the current browser/device + */ +export async function detectCapabilities(): Promise { + const capabilities: WebAuthnCapabilities = { + webauthn: false, + platformAuthenticator: false, + conditionalUI: false, + prfExtension: false, + }; + + // Check basic WebAuthn support + if (!window.PublicKeyCredential) { + return capabilities; + } + capabilities.webauthn = true; + + // Check platform authenticator + try { + capabilities.platformAuthenticator = + await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + } catch { + capabilities.platformAuthenticator = false; + } + + // Check conditional UI + capabilities.conditionalUI = await isConditionalMediationAvailable(); + + // PRF support can only be confirmed after credential creation + // We assume true for modern browsers and verify during registration + capabilities.prfExtension = true; // Optimistic, verified at registration + + return capabilities; +}