From e2820a1888f5b3986f84f237c0c30e19ca3b98c3 Mon Sep 17 00:00:00 2001 From: x Date: Sun, 23 Feb 2025 17:29:11 +0700 Subject: [PATCH] feat: enhance WebAuthn integration with new put options and improved signature handling --- examples/webauthn.html | 3 +- examples/webauthn.js | 24 ++++++++-- sea.js | 105 ++++++++++++++++++++++++++--------------- sea/index.js | 10 +++- sea/sign.js | 83 ++++++++++++++++++++------------ test/sea/sea.js | 26 ++++++++++ 6 files changed, 175 insertions(+), 76 deletions(-) diff --git a/examples/webauthn.html b/examples/webauthn.html index 6963e31b..26b09691 100644 --- a/examples/webauthn.html +++ b/examples/webauthn.html @@ -13,7 +13,8 @@ - + + \ No newline at end of file diff --git a/examples/webauthn.js b/examples/webauthn.js index a4912fe9..65b25558 100644 --- a/examples/webauthn.js +++ b/examples/webauthn.js @@ -72,12 +72,16 @@ document.getElementById('create').onclick = async () => { name: "Example User", displayName: "Example User" }, + // See the list of algos: https://www.iana.org/assignments/cose/cose.xhtml#algorithms + // The 2 algos below are required in order to work with SEA pubKeyCredParams: [ - { type: "public-key", alg: -7 }, - { type: "public-key", alg: -257 }, - { type: "public-key", alg: -37 } + { type: "public-key", alg: -7 }, // ECDSA, P-256 curve, for signing + { type: "public-key", alg: -25 }, // ECDH, P-256 curve, for creating shared secrets using SEA.secret + { type: "public-key", alg: -257 } ], - authenticatorSelection: { userVerification: "preferred" }, + authenticatorSelection: { + userVerification: "preferred" + }, timeout: 60000, attestation: "none" } @@ -118,9 +122,9 @@ const authenticator = async (data) => { timeout: 60000 } }; - console.log("Auth options:", options); const assertion = await navigator.credentials.get(options); + console.log("SIGNED:", {options, assertion}); return assertion.response; }; @@ -159,4 +163,14 @@ document.getElementById('put').onclick = async () => { console.log("Data:", data); }) }, 2000) +} + +document.getElementById('put-with-pair').onclick = async () => { + const bob = await SEA.pair() + gun.get(`~${bob.pub}`).get('test').put("this is bob", null, { opt: { authenticator: bob }}) + setTimeout(() => { + gun.get(`~${bob.pub}`).get('test').once((data) => { + console.log("Data:", data); + }) + }, 2000) } \ No newline at end of file diff --git a/sea.js b/sea.js index ef2e66f1..b8676c7f 100644 --- a/sea.js +++ b/sea.js @@ -388,58 +388,81 @@ opt = opt || {}; // Format and return the final response - async function next(r, opt, cb) { - try { - if(!opt.raw){ r = 'SEA' + await shim.stringify(r) } - if(cb){ try{ cb(r) }catch(e){} } - return r; - } catch(e) { - console.warn('SEA.sign response error', e); - return r; - } + async function next(r) { + try { + if(!opt.raw){ r = 'SEA' + await shim.stringify(r) } + if(cb){ try{ cb(r) }catch(e){} } + return r; + } catch(e) { + console.warn('SEA.sign response error', e); + return r; } + } - // Validate inputs - if(u === data){ throw '`undefined` not allowed.' } - if(!(pair||opt).priv && typeof pair !== 'function'){ - if(!SEA.I){ throw 'No signing key.' } - pair = await SEA.I(null, {what: data, how: 'sign', why: opt.why}); + // WebAuthn + async function wa(res, json) { + var r = { + m: json, + s: res.signature ? shim.Buffer.from(res.signature, 'binary').toString(opt.encode || 'base64') : undefined, + a: shim.Buffer.from(res.authenticatorData, 'binary').toString('base64'), + c: shim.Buffer.from(res.clientDataJSON, 'binary').toString('base64') + }; + if (!r.s || !r.a || !r.c) throw "WebAuthn signature invalid"; + return next(r); + } + + // External auth fn + async function ea(res, json) { + if (!res) throw new Error('Empty auth response'); + if (typeof res === 'string') { + return next({ m: json, s: res }); } - - var json = await S.parse(data); - var check = opt.check = opt.check || json; - - // Return early if already signed - if(SEA.verify && (SEA.opt.check(check) || (check && check.s && check.m)) - && u !== await SEA.verify(check, pair)){ - return next(await S.parse(check), opt, cb); - } - - // Handle WebAuthn - if(typeof pair === 'function'){ - const response = await pair(data); - var r = { + if (res.signature) { + return next({ m: json, - s: response.signature ? shim.Buffer.from(response.signature, 'binary').toString(opt.encode || 'base64') : undefined, - a: response.authenticatorData ? shim.Buffer.from(response.authenticatorData, 'binary').toString('base64') : undefined, - c: response.clientDataJSON ? shim.Buffer.from(response.clientDataJSON, 'binary').toString('base64') : undefined - }; - if (!r.s || !r.a || !r.c) { throw "WebAuthn signature invalid"; } - return next(r, opt, cb); + s: shim.Buffer.from(res.signature, 'binary').toString(opt.encode || 'base64') + }); } + throw new Error('Invalid auth format'); + } - // Handle regular signing + // Key pair + async function kp(pair, json) { var jwk = S.jwk(pair.pub, pair.priv); + if (!jwk) throw new Error('Invalid key pair'); + var hash = await sha(json); var sig = await (shim.ossl || shim.subtle).importKey('jwk', jwk, {name: 'ECDSA', namedCurve: 'P-256'}, false, ['sign']) .then((key) => (shim.ossl || shim.subtle).sign({name: 'ECDSA', hash: {name: 'SHA-256'}}, key, new Uint8Array(hash))) .catch(e => { throw new Error('SEA signature failed: ' + e.message) }); return next({ - m: json, + m: json, s: shim.Buffer.from(sig, 'binary').toString(opt.encode || 'base64') - }, opt, cb); + }); + } + if(u === data) throw '`undefined` not allowed.'; + if(!(pair||opt).priv && typeof pair !== 'function'){ + if(!SEA.I) throw 'No signing key.'; + pair = await SEA.I(null, {what: data, how: 'sign', why: opt.why}); + } + + var json = await S.parse(data); + var check = opt.check = opt.check || json; + + if(SEA.verify && (SEA.opt.check(check) || (check && check.s && check.m)) + && u !== await SEA.verify(check, pair)){ + return next(await S.parse(check)); + } + + if(typeof pair === 'function') { + const response = await pair(data); + const fn = response.authenticatorData ? wa : ea; + return fn(response, json); + } + + return kp(pair, json); } catch(e) { SEA.err = e; if(SEA.throw){ throw e } @@ -1537,10 +1560,16 @@ const raw = await S.parse(val) || {} if ((user.is || opt.authenticator) && (tmp = opt.authenticator ? (opt.pub || (user.is || {}).pub || pub) : (user.is || {}).pub) && tmp && !raw['*'] && !raw['+'] && (pub === tmp || (pub !== tmp && opt.cert))){ SEA.opt.pack(msg.put, packed => { - const authenticator = opt.authenticator || (user._).sea; + // Determine the authenticator to use - external or user's + const authenticator = typeof opt.authenticator === 'function' ? opt.authenticator : (opt.authenticator || (user._).sea); const upub = tmp; + // Validate authenticator + if (!authenticator) return no("Missing authenticator"); SEA.sign(packed, authenticator, async function(data) { if (u === data) return no(SEA.err || 'Signature fail.') + // Validate signature format + if (!data.m || !data.s) return no('Invalid signature format') + msg.put[':'] = {':': tmp = SEA.opt.unpack(data.m), '~': data.s} msg.put['='] = tmp diff --git a/sea/index.js b/sea/index.js index 68d5e408..072ae2ad 100644 --- a/sea/index.js +++ b/sea/index.js @@ -147,10 +147,16 @@ const raw = await S.parse(val) || {} if ((user.is || opt.authenticator) && (tmp = opt.authenticator ? (opt.pub || (user.is || {}).pub || pub) : (user.is || {}).pub) && tmp && !raw['*'] && !raw['+'] && (pub === tmp || (pub !== tmp && opt.cert))){ SEA.opt.pack(msg.put, packed => { - const authenticator = opt.authenticator || (user._).sea; + // Determine the authenticator to use - external or user's + const authenticator = typeof opt.authenticator === 'function' ? opt.authenticator : (opt.authenticator || (user._).sea); const upub = tmp; + // Validate authenticator + if (!authenticator) return no("Missing authenticator"); SEA.sign(packed, authenticator, async function(data) { if (u === data) return no(SEA.err || 'Signature fail.') + // Validate signature format + if (!data.m || !data.s) return no('Invalid signature format') + msg.put[':'] = {':': tmp = SEA.opt.unpack(data.m), '~': data.s} msg.put['='] = tmp @@ -254,4 +260,4 @@ var fl = Math.floor; // TODO: Still need to fix inconsistent state issue. // TODO: Potential bug? If pub/priv key starts with `-`? IDK how possible. -}()); \ No newline at end of file +}()); diff --git a/sea/sign.js b/sea/sign.js index 8cb1e50b..58f5138f 100644 --- a/sea/sign.js +++ b/sea/sign.js @@ -9,7 +9,7 @@ opt = opt || {}; // Format and return the final response - async function next(r, opt, cb) { + async function next(r) { try { if(!opt.raw){ r = 'SEA' + await shim.stringify(r) } if(cb){ try{ cb(r) }catch(e){} } @@ -19,48 +19,71 @@ return r; } } - - // Validate inputs - if(u === data){ throw '`undefined` not allowed.' } + + // WebAuthn + async function wa(res, json) { + var r = { + m: json, + s: res.signature ? shim.Buffer.from(res.signature, 'binary').toString(opt.encode || 'base64') : undefined, + a: shim.Buffer.from(res.authenticatorData, 'binary').toString('base64'), + c: shim.Buffer.from(res.clientDataJSON, 'binary').toString('base64') + }; + if (!r.s || !r.a || !r.c) throw "WebAuthn signature invalid"; + return next(r); + } + + // External auth fn + async function ea(res, json) { + if (!res) throw new Error('Empty auth response'); + if (typeof res === 'string') { + return next({ m: json, s: res }); + } + if (res.signature) { + return next({ + m: json, + s: shim.Buffer.from(res.signature, 'binary').toString(opt.encode || 'base64') + }); + } + throw new Error('Invalid auth format'); + } + + // Key pair + async function kp(pair, json) { + var jwk = S.jwk(pair.pub, pair.priv); + if (!jwk) throw new Error('Invalid key pair'); + + var hash = await sha(json); + var sig = await (shim.ossl || shim.subtle).importKey('jwk', jwk, {name: 'ECDSA', namedCurve: 'P-256'}, false, ['sign']) + .then((key) => (shim.ossl || shim.subtle).sign({name: 'ECDSA', hash: {name: 'SHA-256'}}, key, new Uint8Array(hash))) + .catch(e => { throw new Error('SEA signature failed: ' + e.message) }); + + return next({ + m: json, + s: shim.Buffer.from(sig, 'binary').toString(opt.encode || 'base64') + }); + } + + if(u === data) throw '`undefined` not allowed.'; if(!(pair||opt).priv && typeof pair !== 'function'){ - if(!SEA.I){ throw 'No signing key.' } + if(!SEA.I) throw 'No signing key.'; pair = await SEA.I(null, {what: data, how: 'sign', why: opt.why}); } var json = await S.parse(data); var check = opt.check = opt.check || json; - // Return early if already signed if(SEA.verify && (SEA.opt.check(check) || (check && check.s && check.m)) && u !== await SEA.verify(check, pair)){ - return next(await S.parse(check), opt, cb); + return next(await S.parse(check)); } - // Handle WebAuthn - if(typeof pair === 'function'){ + if(typeof pair === 'function') { const response = await pair(data); - var r = { - m: json, - s: response.signature ? shim.Buffer.from(response.signature, 'binary').toString(opt.encode || 'base64') : undefined, - a: response.authenticatorData ? shim.Buffer.from(response.authenticatorData, 'binary').toString('base64') : undefined, - c: response.clientDataJSON ? shim.Buffer.from(response.clientDataJSON, 'binary').toString('base64') : undefined - }; - if (!r.s || !r.a || !r.c) { throw "WebAuthn signature invalid"; } - return next(r, opt, cb); + const fn = response.authenticatorData ? wa : ea; + return fn(response, json); } - - // Handle regular signing - var jwk = S.jwk(pair.pub, pair.priv); - var hash = await sha(json); - var sig = await (shim.ossl || shim.subtle).importKey('jwk', jwk, {name: 'ECDSA', namedCurve: 'P-256'}, false, ['sign']) - .then((key) => (shim.ossl || shim.subtle).sign({name: 'ECDSA', hash: {name: 'SHA-256'}}, key, new Uint8Array(hash))) - .catch(e => { throw new Error('SEA signature failed: ' + e.message) }); - - return next({ - m: json, - s: shim.Buffer.from(sig, 'binary').toString(opt.encode || 'base64') - }, opt, cb); - + + return kp(pair, json); } catch(e) { SEA.err = e; if(SEA.throw){ throw e } diff --git a/test/sea/sea.js b/test/sea/sea.js index dec9e4c7..bbdf9b0a 100755 --- a/test/sea/sea.js +++ b/test/sea/sea.js @@ -328,6 +328,32 @@ describe('SEA', function(){ describe('User', function(){ var gun = Gun(), gtmp; + it("put to user graph without having to be authenticated (provide pair)", function(done){(async function(){ + var bob = await SEA.pair(); + gun.get(`~${bob.pub}`).get('test').put('this is Bob', (ack) => { + gun.get(`~${bob.pub}`).get('test').once((data) => { + expect(ack.err).to.not.be.ok() + expect(data).to.be('this is Bob') + done(); + }) + }, {opt: {authenticator: bob}}) + })()}); + + it("put to user graph using external authenticator (nested SEA.sign)", function(done){(async function(){ + var bob = await SEA.pair(); + async function authenticator(data) { + const sig = await SEA.sign(data, bob) + return sig + } + gun.get(`~${bob.pub}`).get('test').put('this is Bob', (ack) => { + gun.get(`~${bob.pub}`).get('test').once((data) => { + expect(ack.err).to.not.be.ok() + expect(data).to.be('this is Bob') + done(); + }) + }, {opt: {authenticator: authenticator}}) + })()}); + it('test', function(done){ var g = Gun(); user = g.user();