feat: add optional EncryptID passkey authentication

Add passkey sign-in to the static wallet explorer:
- Add js/encryptid.js with WebAuthn registration/authentication
- Add auth button to hero section of index.html
- Persists auth state in localStorage
- No backend required — all client-side

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 07:34:41 -07:00
parent d7892f4404
commit 9dc2b01896
2 changed files with 250 additions and 0 deletions

View File

@ -523,6 +523,7 @@
<!-- Hero -->
<section class="hero">
<div class="badge">Part of the rSpace Ecosystem</div>
<div id="encryptid-auth" style="margin-bottom:16px;min-height:32px;"></div>
<h1><span>Democratic Wallet Management</span><br>for Communities</h1>
<p class="hero-sub">
Interactive visualizations for <strong>group treasury management</strong>.
@ -750,7 +751,11 @@
<p>No backend. No tracking. All data fetched client-side from public APIs.</p>
</footer>
<script src="js/encryptid.js"></script>
<script>
// ─── EncryptID Auth ────────────────────────────────────────
EncryptID.renderAuthButton('encryptid-auth');
// ─── Wallet Input Logic ────────────────────────────────────
const DEMO_ADDRESS = '0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1';

245
js/encryptid.js Normal file
View File

@ -0,0 +1,245 @@
/**
* 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,
};
})();