112 lines
8.4 KiB
JavaScript
112 lines
8.4 KiB
JavaScript
// Credential views use only the host's JSON API. Secrets leave the browser encrypted.
|
|
function credentialReview(request,items){
|
|
const e=escapeHTML,p=request.payload,grant=p.grant_create||p;
|
|
const list=values=>values.length?'<ul>'+values.map(value=>'<li>'+value+'</li>').join('')+'</ul>':'<p>None.</p>';
|
|
const config=item=>'<h3>'+e(item.name)+'</h3><h4>Access rules</h4>'+list((item.rules||[]).map(rule=>e(rule.scheme)+' · '+e(rule.hosts.join(', '))+' · port '+e(rule.port??({https:443,http:80,ssh:22}[rule.scheme.toLowerCase()]||'default'))+' · paths '+e(rule.path_prefixes.join(', ')||'None')+' · methods '+e(rule.methods==null?'All':rule.methods.join(', ')||'None')))+'<h4>Fields</h4>'+list((item.fields||[]).map(field=>e(field.name)+' — '+(field.public?'Public':'Secret')+', '+e(field.field_type.replaceAll('_',' '))))+'<p>Transformation script: '+(item.transform_js?'Present':'None')+'.</p>';
|
|
let requested='';
|
|
if(grant.items)requested='<p>Grant access to:</p>'+grant.items.map(id=>{const item=items.find(item=>item.id===id);return item?config(item):'<p>Item '+e(id)+'</p>';}).join('');
|
|
else if(p.create||p.edit?.config)requested=config(p.create||p.edit.config);
|
|
else if(p.old)requested=config(p.old);
|
|
else requested=list((p.requested?.change||[]).map(change=>e(change.op.replaceAll('_',' '))+(change.config?config(change.config):'')));
|
|
const subject=request.subject;
|
|
return '<p>'+e(requestSummary(request.summary))+'</p><h3>Requested by</h3><p>'+(subject.kind==='conversation'?'Conversation #':'Program ')+e(subject.id)+'</p><h3>Requested change</h3>'+requested+'<details><summary>Technical details</summary><pre>'+e(JSON.stringify(request,null,2))+'</pre></details>';
|
|
}
|
|
const credentialView=document.getElementById('credential-view');
|
|
const credentialNotice=document.getElementById('credential-notice');
|
|
document.getElementById('register-first').onclick=()=>{location.hash='passkeys';};
|
|
async function credentialGet(path){
|
|
const response=await fetch('/api/cred/'+path);
|
|
const value=await response.json();
|
|
if(!response.ok)throw new Error(value.message||value.error);
|
|
return value;
|
|
}
|
|
function credentialError(error){(document.getElementById('credential-status')||document.getElementById('error')).textContent=error.message;}
|
|
function credentialForm(action){
|
|
credentialView.querySelector('form').onsubmit=async event=>{
|
|
event.preventDefault();
|
|
document.getElementById('credential-status').textContent='';
|
|
const buttons=[...event.target.querySelectorAll('button')];
|
|
buttons.forEach(button=>button.disabled=true);
|
|
try{const status=await action(new FormData(event.target),event.submitter.value);document.getElementById('credential-status').textContent=status;await credentialState();}
|
|
catch(error){credentialError(error);}
|
|
finally{buttons.forEach(button=>button.disabled=false);}
|
|
};
|
|
}
|
|
async function credentialState(){
|
|
const passkeys=await credentialGet('passkeys');
|
|
credentialNotice.hidden=passkeys.length>0;
|
|
return passkeys;
|
|
}
|
|
async function credentialNavigate(){
|
|
const parts=location.hash.slice(1).split('/').map(decodeURIComponent);
|
|
credentialView.hidden=!['passkeys','approve','entry'].includes(parts[0]);
|
|
if(credentialView.hidden)return;
|
|
credentialView.innerHTML='<button type="button" id="credential-close">Return to chat</button><h2></h2><div id="credential-content"></div><p id="credential-status" role="status"></p>';
|
|
document.getElementById('credential-close').onclick=()=>{location.hash='';};
|
|
const content=document.getElementById('credential-content');
|
|
try{
|
|
if(parts[0]==='passkeys'){
|
|
credentialView.querySelector('h2').textContent='Passkeys';
|
|
const passkeys=await credentialState();
|
|
const list=document.createElement('ul');
|
|
if(!passkeys.length){const empty=document.createElement('p');empty.textContent='No passkeys registered.';content.append(empty);}
|
|
for(const key of passkeys){const row=document.createElement('li');row.textContent=key.label;list.append(row);}
|
|
content.append(list);
|
|
const form=document.createElement('form');form.innerHTML='<label>Passkey label <input name="label" required></label><button>'+(passkeys.length?'Verify existing passkey':'Register passkey')+'</button>';content.append(form);
|
|
let challenge;
|
|
credentialForm(async data=>{
|
|
if(!challenge){
|
|
const label=data.get('label');let gate;
|
|
if(passkeys.length){const proof=await assertion(await post('/cred/passkeys/register/challenge',{label}));gate={id:proof.challenge_id,response:proof.assertion};}
|
|
challenge=await post('/cred/passkeys/register/start',{label,gate});
|
|
if(passkeys.length){form.elements.label.readOnly=true;form.querySelector('button').textContent='Register new passkey';return 'Existing passkey verified. Choose a new passkey or device.';}
|
|
}
|
|
const credential=await navigator.credentials.create(options(structuredClone(challenge)));
|
|
await post('/cred/passkeys/register/finish',{challenge_id:challenge.id,response:serialize(credential)});
|
|
await credentialNavigate();
|
|
return 'Passkey registered.';
|
|
});
|
|
}else if(parts[0]==='approve'){
|
|
credentialView.querySelector('h2').textContent='Review credential request';
|
|
const id=parts[1],request=await credentialGet('requests/'+encodeURIComponent(id));
|
|
content.innerHTML=credentialReview(request,await credentialGet('items'));
|
|
if(request.state!=='pending'){document.getElementById('credential-status').textContent='Request '+request.state+'.';return;}
|
|
const grant=request.payload.grant_create||request.payload;
|
|
const limits=!!request.payload.grant_create||!!request.payload.items?.length;
|
|
const form=document.createElement('form');
|
|
form.innerHTML='<fieldset '+(limits?'':'hidden disabled')+'><legend>Limits</legend><label>Duration in seconds <input type="number" name="duration" min="1" required></label><label>Maximum uses <input type="number" name="uses" min="1" placeholder="Unlimited"></label></fieldset><button value="approve">Approve</button><button value="deny" formnovalidate>Deny</button>';
|
|
form.elements.duration.value=grant.duration_secs||(grant.exp?Math.max(1,grant.exp-Math.floor(Date.now()/1000)):3600);form.elements.uses.value=grant.max_uses||'';content.append(form);
|
|
credentialForm(async(data,decision)=>{
|
|
const path='/cred/requests/'+encodeURIComponent(id);
|
|
const overrides=decision==='deny'||!limits?{}:{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));
|
|
dismissCredentialRequest(id);
|
|
await credentialNavigate();
|
|
return decision==='approve'?'Request approved.':'Request denied.';
|
|
});
|
|
}else{
|
|
credentialView.querySelector('h2').textContent='Enter a credential secret';
|
|
const items=await credentialGet('items');
|
|
const form=document.createElement('form');form.innerHTML='<label>Secret field <select name="field" required></select></label><label>Secret <textarea name="secret" required></textarea></label><button>Encrypt and submit</button>';content.append(form);
|
|
for(const item of items)for(const field of item.fields.filter(field=>!field.public)){
|
|
const option=document.createElement('option');option.value=JSON.stringify([item.id,field.field_id]);option.textContent=item.name+' / '+field.name;form.elements.field.append(option);
|
|
if(item.id===parts[1]&&field.field_id===parts[2])option.selected=true;
|
|
}
|
|
if(!form.elements.field.options.length){form.hidden=true;content.append(document.createTextNode('No secret fields exist. Create a credential item with a secret field first.'));return;}
|
|
credentialForm(async data=>{
|
|
const [item,field]=JSON.parse(data.get('field'));
|
|
const path='/cred/items/'+encodeURIComponent(item)+'/fields/'+encodeURIComponent(field);
|
|
const {public_key}=await credentialGet('entry/key');
|
|
const encrypted=await encrypt(public_key,data.get('secret'));form.elements.secret.value='';
|
|
const proof=await assertion(await post(path+'/challenge',{ciphertext_sha256:encrypted.ciphertext_sha256}));
|
|
await post(path,{...proof,envelope:encrypted.envelope});
|
|
return 'Secret saved.';
|
|
});
|
|
}
|
|
}catch(error){credentialError(error);}
|
|
}
|
|
window.addEventListener('hashchange',credentialNavigate);
|
|
credentialNavigate();
|
|
credentialState().catch(credentialError);
|