82 lines
5.2 KiB
JavaScript
82 lines
5.2 KiB
JavaScript
// Host-only ceremonies. No world scripts or external dependencies run here.
|
|
const bytes = text => Uint8Array.from(atob(text.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
|
|
const encoded = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
const post = async (path, body) => {
|
|
const response = await fetch(new URL(path.replace(/^\//, ""), import.meta.url), {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)});
|
|
const result = await response.json();
|
|
if (!response.ok) throw new Error(result.message);
|
|
return result;
|
|
};
|
|
function options(challenge) {
|
|
const value = challenge.options;
|
|
value.publicKey.challenge = bytes(value.publicKey.challenge);
|
|
if (value.publicKey.user) value.publicKey.user.id = bytes(value.publicKey.user.id);
|
|
for (const key of ['allowCredentials', 'excludeCredentials']) {
|
|
for (const credential of value.publicKey[key] || []) credential.id = bytes(credential.id);
|
|
}
|
|
return value;
|
|
}
|
|
function serialize(credential) {
|
|
const response = {};
|
|
for (const key of ['clientDataJSON', 'attestationObject', 'authenticatorData', 'signature', 'userHandle']) {
|
|
if (credential.response[key]) response[key] = encoded(credential.response[key]);
|
|
}
|
|
return {id: credential.id, rawId: encoded(credential.rawId), type: credential.type, response, clientExtensionResults: credential.getClientExtensionResults()};
|
|
}
|
|
async function assertion(challenge) {
|
|
return {challenge_id: challenge.id, assertion: serialize(await navigator.credentials.get(options(challenge)))};
|
|
}
|
|
async function encrypt(publicKey, secret) {
|
|
const host = await crypto.subtle.importKey('raw', new Uint8Array(publicKey), {name: 'ECDH', namedCurve: 'P-256'}, false, []);
|
|
const pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, true, ['deriveBits']);
|
|
const shared = await crypto.subtle.deriveBits({name: 'ECDH', public: host}, pair.privateKey, 256);
|
|
const material = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
|
|
const key = await crypto.subtle.deriveKey({name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(), info: new TextEncoder().encode('sausage/secret-entry/v1')}, material, {name: 'AES-GCM', length: 256}, false, ['encrypt']);
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const ciphertext = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, new TextEncoder().encode(secret));
|
|
const ephemeral_public_key = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));
|
|
const envelope = {ephemeral_public_key: [...ephemeral_public_key], iv: [...iv], ciphertext: [...new Uint8Array(ciphertext)]};
|
|
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array([...envelope.ephemeral_public_key, ...envelope.iv, ...envelope.ciphertext])));
|
|
return {envelope, ciphertext_sha256: [...hash].map(byte => byte.toString(16).padStart(2, '0')).join('')};
|
|
}
|
|
const form = document.querySelector('form');
|
|
const status = document.querySelector('[role=status]');
|
|
form.addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
const buttons = [...form.querySelectorAll('button')];
|
|
buttons.forEach(button => button.disabled = true);
|
|
status.textContent = 'Complete the passkey prompt, if one appears.';
|
|
try {
|
|
const data = new FormData(form);
|
|
if (document.body.dataset.page === 'approve') {
|
|
const path = `/cred/requests/${encodeURIComponent(document.body.dataset.request)}`;
|
|
const decision = event.submitter.value;
|
|
const overrides = decision === 'deny' || document.body.dataset.grant !== 'true' ? {} : {duration_secs: Number(data.get('duration')), max_uses: data.get('uses') ? Number(data.get('uses')) : null};
|
|
const challenge = await post(`${path}/challenge`, {decision, overrides});
|
|
await post(`${path}/decide`, challenge.unlocked ? {decision, overrides} : await assertion(challenge));
|
|
} else if (document.body.dataset.page === 'register') {
|
|
const label = data.get('label');
|
|
const state = await (await fetch(new URL('cred/passkeys', import.meta.url))).json();
|
|
let gate;
|
|
if (state.length) {
|
|
const proof = await assertion(await post('/cred/passkeys/register/challenge', {label}));
|
|
gate = {id: proof.challenge_id, response: proof.assertion};
|
|
}
|
|
const challenge = await post('/cred/passkeys/register/start', {label, gate});
|
|
const credential = await navigator.credentials.create(options(challenge));
|
|
await post('/cred/passkeys/register/finish', {challenge_id: challenge.id, response: serialize(credential)});
|
|
} else {
|
|
const path = `/cred/items/${encodeURIComponent(document.body.dataset.item)}/fields/${encodeURIComponent(document.body.dataset.field)}`;
|
|
const encrypted = await encrypt(JSON.parse(document.body.dataset.publicKey), data.get('secret'));
|
|
form.elements.secret.value = '';
|
|
const challenge = await post(`${path}/challenge`, {ciphertext_sha256: encrypted.ciphertext_sha256});
|
|
await post(path, {...await assertion(challenge), envelope: encrypted.envelope});
|
|
}
|
|
status.textContent = 'Complete.';
|
|
} catch {
|
|
status.textContent = 'The request failed. Review the page and try again.';
|
|
} finally {
|
|
buttons.forEach(button => button.disabled = false);
|
|
}
|
|
});
|