474 lines
14 KiB
TypeScript
474 lines
14 KiB
TypeScript
/**
|
|
* 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<string, OperationPermission> = {
|
|
// 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' },
|
|
|
|
// Payment operations (x402 and Safe treasury)
|
|
'payment:x402': { minAuthLevel: AuthLevel.STANDARD, requiresCapability: 'wallet' },
|
|
'payment:safe-propose': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet', maxAgeSeconds: 60 },
|
|
'payment:safe-execute': { minAuthLevel: AuthLevel.CRITICAL, requiresCapability: 'wallet', maxAgeSeconds: 60 },
|
|
|
|
// 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<typeof setTimeout> | 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<SessionState> {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// Build claims
|
|
const claims: EncryptIDClaims = {
|
|
iss: 'https://auth.ridentity.online',
|
|
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<void> {
|
|
// 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;
|
|
}
|