rwallet-online/js/encryptid.js

246 lines
9.8 KiB
JavaScript

/**
* EncryptID Authentication for rWallet.online
*
* Adds optional passkey-based identity to the static wallet explorer.
* When authenticated, the user gets a persistent identity and can
* associate wallet addresses with their account.
*/
const EncryptID = (() => {
const SERVER = 'https://encryptid.jeffemmett.com';
const STORAGE_KEY = 'rwallet_encryptid';
// ─── Helpers ─────────────────────────────────────────────────
function toBase64url(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function fromBase64url(str) {
return Uint8Array.from(
atob(str.replace(/-/g, '+').replace(/_/g, '/')),
c => c.charCodeAt(0)
);
}
function getStoredAuth() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch { return null; }
}
function setStoredAuth(auth) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
}
function clearStoredAuth() {
localStorage.removeItem(STORAGE_KEY);
}
// ─── Authentication ──────────────────────────────────────────
async function authenticate() {
// Step 1: Get challenge
const startRes = await fetch(`${SERVER}/api/auth/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const { options } = await startRes.json();
// Step 2: WebAuthn ceremony
const assertion = await navigator.credentials.get({
publicKey: {
challenge: fromBase64url(options.challenge),
rpId: options.rpId,
userVerification: options.userVerification,
timeout: options.timeout,
allowCredentials: options.allowCredentials?.map(c => ({
type: c.type,
id: fromBase64url(c.id),
transports: c.transports,
})),
},
});
const response = assertion.response;
// Step 3: Complete
const completeRes = await fetch(`${SERVER}/api/auth/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: options.challenge,
credential: {
credentialId: assertion.id,
authenticatorData: toBase64url(response.authenticatorData),
clientDataJSON: toBase64url(response.clientDataJSON),
signature: toBase64url(response.signature),
userHandle: response.userHandle ? toBase64url(response.userHandle) : null,
},
}),
});
const result = await completeRes.json();
if (!result.success) throw new Error(result.error || 'Authentication failed');
const auth = { token: result.token, did: result.did, username: result.username };
setStoredAuth(auth);
return auth;
}
async function register(username) {
// Step 1: Get registration options
const startRes = await fetch(`${SERVER}/api/register/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName: username }),
});
const { options, userId } = await startRes.json();
// Step 2: WebAuthn ceremony
const credential = await navigator.credentials.create({
publicKey: {
challenge: fromBase64url(options.challenge),
rp: options.rp,
user: {
id: fromBase64url(options.user.id),
name: options.user.name,
displayName: options.user.displayName,
},
pubKeyCredParams: options.pubKeyCredParams,
authenticatorSelection: options.authenticatorSelection,
timeout: options.timeout,
attestation: options.attestation,
},
});
const response = credential.response;
// Step 3: Complete
const completeRes = await fetch(`${SERVER}/api/register/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: options.challenge,
userId,
username,
credential: {
credentialId: credential.id,
publicKey: toBase64url(response.getPublicKey?.() || response.attestationObject),
attestationObject: toBase64url(response.attestationObject),
clientDataJSON: toBase64url(response.clientDataJSON),
transports: response.getTransports?.() || [],
},
}),
});
const result = await completeRes.json();
if (!result.success) throw new Error(result.error || 'Registration failed');
const auth = { token: result.token, did: result.did, username };
setStoredAuth(auth);
return auth;
}
function logout() {
clearStoredAuth();
}
function isAuthenticated() {
return !!getStoredAuth();
}
function getUser() {
return getStoredAuth();
}
// ─── UI Component ────────────────────────────────────────────
/**
* Render a passkey auth button into the specified container.
* Shows sign-in when anonymous, username + sign-out when authenticated.
*/
function renderAuthButton(containerId) {
const container = document.getElementById(containerId);
if (!container) return;
function render() {
const auth = getStoredAuth();
if (auth) {
container.innerHTML = `
<div style="display:flex;align-items:center;gap:10px;justify-content:center;">
<span style="color:var(--text-dim);font-size:0.85rem;">
Signed in as <strong style="color:var(--primary);">${auth.username || auth.did?.slice(0, 16) + '...'}</strong>
</span>
<button id="eid-signout" style="background:none;border:1px solid var(--border);color:var(--text-dim);padding:4px 12px;border-radius:6px;cursor:pointer;font-size:0.8rem;">
Sign out
</button>
</div>
`;
document.getElementById('eid-signout').addEventListener('click', () => {
logout();
render();
});
} else {
container.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;justify-content:center;">
<button id="eid-signin" style="background:none;border:1px solid var(--border);color:var(--text-dim);padding:6px 16px;border-radius:8px;cursor:pointer;font-size:0.85rem;display:flex;align-items:center;gap:6px;transition:all 0.2s;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="10" r="3"/><path d="M12 13v8"/><path d="M9 18h6"/><circle cx="12" cy="10" r="7"/>
</svg>
Sign in with Passkey
</button>
</div>
`;
const btn = document.getElementById('eid-signin');
btn.addEventListener('mouseenter', () => { btn.style.borderColor = 'var(--primary)'; btn.style.color = 'var(--primary)'; });
btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--border)'; btn.style.color = 'var(--text-dim)'; });
btn.addEventListener('click', async () => {
btn.textContent = 'Authenticating...';
btn.disabled = true;
try {
await authenticate();
render();
} catch (e) {
if (e.name === 'NotAllowedError') {
// No passkey found — prompt to register
const name = prompt('No passkey found. Create one?\nEnter a username:');
if (name) {
try {
await register(name.trim());
render();
} catch (re) {
alert('Registration failed: ' + re.message);
render();
}
} else {
render();
}
} else {
alert('Sign in failed: ' + e.message);
render();
}
}
});
}
}
render();
}
// ─── Public API ──────────────────────────────────────────────
return {
authenticate,
register,
logout,
isAuthenticated,
getUser,
renderAuthButton,
};
})();