diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..befb97d2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: ci + +on: [push, pull_request] + +env: + project: 'release-node' + +jobs: + + test: + strategy: + matrix: + node-version: [14.x] # [12.x, 14.x] + os: [ubuntu-latest] #, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + + - name: Checkout + uses: actions/checkout@v2 + + # verify the version in package.json matches the release tag + - name: Version + uses: tcurdt/action-verify-version-npm@master + + - name: Setup Node ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Cache + id: cache-modules + uses: actions/cache@v2 + with: + path: node_modules + key: ${{ matrix.node-version }}-${{ runner.OS }}-build-${{ hashFiles('package.json') }} + + - name: Install + if: steps.cache-modules.outputs.cache-hit != 'true' + run: npm install + + - name: Test + run: npm test + + # create release artifacts to publish as github release + # - name: Upload + # if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + # uses: actions/upload-artifact@v2 + # with: + # name: ${{ env.project }}_${{ matrix.os }}_${{ matrix.node-version }} + # path: | + # !.git + # !.github + # !node_modules + # . + + release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: [test] + runs-on: ubuntu-latest + steps: + + # - name: Download + # uses: actions/download-artifact@v2 + # with: + # path: artifacts + # - name: Archives + # run: find artifacts -mindepth 1 -maxdepth 1 -exec tar -C {} -cvzf {}.tgz . \; + + - name: Release + uses: softprops/action-gh-release@v1 + # with: + # files: | + # artifacts/*.tgz + env: + GITHUB_TOKEN: ${{ secrets.PAT }} diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml new file mode 100644 index 00000000..067e221e --- /dev/null +++ b/.github/workflows/cleanup.yml @@ -0,0 +1,17 @@ +name: cleanup + +on: + schedule: + - cron: '1 1 * * 1' # once a week clean out old artifacts + +jobs: + + expire: + runs-on: ubuntu-latest + steps: + + - name: Expire Artifacts + uses: kolpav/purge-artifacts-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + expire-in: 1hour diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml new file mode 100644 index 00000000..17eb638a --- /dev/null +++ b/.github/workflows/dockerhub.yml @@ -0,0 +1,35 @@ +name: dockerhub + +on: + release: + types: [published] + +env: + project: 'release-node' + +jobs: + + dockerhub: + runs-on: ubuntu-latest + steps: + + - name: Checkout + uses: actions/checkout@v2 + + - name: Build + run: | + docker build -t ${{ env.project }} . + + - name: Login + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + - name: Tag + run: | + docker tag ${{ env.project }} ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.project }}:${GITHUB_REF/refs\/tags\/v/} + docker tag ${{ env.project }} ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.project }}:latest + + - name: Push + run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.project }} diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml new file mode 100644 index 00000000..6af0892d --- /dev/null +++ b/.github/workflows/npm.yml @@ -0,0 +1,22 @@ +name: npm + +on: + release: + types: [published] + +jobs: + + npm: + runs-on: ubuntu-latest + steps: + + - name: Checkout + uses: actions/checkout@v2 + + - name: Publish + env: + NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} + run: | + npm config set //registry.npmjs.org/:_authToken=$NPM_AUTH_TOKEN + npm install + npm publish --access=public diff --git a/lib/meta.js b/lib/meta.js index 1c2ab297..58d3f740 100644 --- a/lib/meta.js +++ b/lib/meta.js @@ -168,7 +168,8 @@ } } var $m = $('
').attr('id', 'meta'); - $m.append($('').html('☰').addClass('meta-start')); + //$m.append($('').html('☰').addClass('meta-start')); + $m.append($('').html('+').addClass('meta-start')); $m.append($('
').addClass('meta-menu meta-none').append('
    ')); $m.on('mouseenter', function(){ if (meta.flip.active || meta.flip.is()) return; @@ -186,7 +187,6 @@ position: 'fixed', bottom: '2em', right: '2em', - background: 'white', 'font-size': '18pt', 'font-family': 'Tahoma, arial', 'border-radius': '1em', @@ -197,15 +197,15 @@ width: '2em', height: '2em', outline: 'none', - color: '#000044', overflow: 'visible', + background: 'rgba(0,0,0,0.5)', color: 'white', transition: 'all 0.2s ease-in' }, '#meta *': {outline: 'none'}, '#meta .meta-none': {display: 'none'}, '#meta span': {'line-height': '2em'}, '#meta .meta-menu': { - background: 'rgba(0,0,0,0.1)', + background: 'rgba(0,0,0,0.2)', width: '12em', right: '-2em', bottom: '-2em', @@ -224,16 +224,15 @@ '#meta .meta-menu ul li': { display: 'block', 'float': 'right', - background: 'white', - opacity: 0.7, padding: '0.5em 1em', 'border-radius': '1em', 'margin-left': '0.25em', 'margin-top': '0.25em', + background: 'rgba(0,0,0,0.2)', 'backdrop-filter': 'blur(10px)', color: 'white', 'cursor': 'pointer' }, '#meta .meta-menu ul li:hover': { - opacity: 1 + background: 'rgba(0,0,0,0.5)' }, '#meta a': {color: 'black'}, '#meta .meta-menu ul:before': { @@ -289,4 +288,4 @@ meta.flip() }) })(USE, './metaEvents'); -}()); \ No newline at end of file +}()); diff --git a/sea.js b/sea.js index b71d070a..9a8dc843 100644 --- a/sea.js +++ b/sea.js @@ -650,6 +650,67 @@ module.exports = SEA.secret; })(USE, './secret'); + ;USE(function(module){ + var SEA = USE('./root'); + + // This is to certify that a group of "certificants" can "put" anything at a group of matched "paths" to the certificate issuer's graph + SEA.certify = SEA.certify || (async (certificants, patterns, issuer, cb, opt = {}) => { try { + /* + IMPORTANT: A Certificate is like a Signature. No one knows who (issuer) created/signed a cert until you put it into their graph. + "certificants": A string (~Bobpub) || a pair || an array of pubs/pairs. These people will have the rights. + "patterns": A string (^inbox.*), or an array of strings [^inbox.*, ^secret\-group.*]. These patterns will be used to check against soul+'/'+key + "issuer": Key pair or priv of the certificate issuer + "cb": A callback function after all things are done + "opt": If opt.expiry (a timestamp) is set, SEA won't sync data after opt.expiry + */ + + // We need some logic here to verify that all params are valid + + console.log('SEA.certify() is an early experimental community supported method that may change API behavior without warning in any future version.') + + certificants = (() => { + var data = [] + if (certificants) { + if (typeof certificants === 'string') { + data.push(certificants) + } + + if (Array.isArray(certificants)) { + certificants.map(person => { + if (typeof person ==='string') data.push(person) + else if (typeof person === 'object' && person.pub) data.push(person.pub) + }) + } + + if (typeof certificants === 'object' && certificants.pub) data.push(certificants.pub) + } + return data + })() + + patterns = patterns ? typeof patterns === 'string' ? [patterns] : Array.isArray(patterns) ? patterns : null : null + + const data = JSON.stringify({ + c: certificants, + p: patterns, + ...(opt.expiry && typeof opt.expiry === 'number' ? {e: opt.expiry} : {}) // inject expiry if possible + }) + + const certificate = await SEA.sign(data, issuer, null, {raw:1}) + + var r = certificate + if(!opt.raw){ r = 'SEA'+JSON.stringify(r) } + if(cb){ try{ cb(r) }catch(e){console.log(e)} } + return r; + } catch(e) { + SEA.err = e; + if(SEA.throw){ throw e } + if(cb){ cb() } + return; + }}); + + module.exports = SEA.certify; + })(USE, './certify'); + ;USE(function(module){ var shim = USE('./shim'); // Practical examples about usage found in tests. @@ -659,6 +720,7 @@ SEA.verify = USE('./verify'); SEA.encrypt = USE('./encrypt'); SEA.decrypt = USE('./decrypt'); + SEA.certify = USE('./certify'); //SEA.opt.aeskey = USE('./aeskey'); // not official! // this causes problems in latest WebCrypto. SEA.random = SEA.random || shim.random; @@ -1137,6 +1199,7 @@ ;USE(function(module){ var SEA = USE('./sea') + var S = USE('./settings') var Gun = SEA.Gun; // After we have a GUN extension to make user registration/login easy, we then need to handle everything else. @@ -1217,6 +1280,7 @@ return; // omit! } } + if('~@' === soul){ // special case for shared system data, the list of aliases. check.alias(eve, msg, val, key, soul, at, no); return; } @@ -1249,27 +1313,83 @@ if(key === link_is(val)){ return eve.to.next(msg) } // and the ID must be EXACTLY equal to its property no("Alias not same!"); // that way nobody can tamper with the list of public keys. }; - check.pub = function(eve, msg, val, key, soul, at, no, user, pub){ var tmp; // Example: {_:#~asdf, hello:'world'~fdsa}} - if('pub' === key && '~'+pub === soul){ - if(val === pub){ return eve.to.next(msg) } // the account MUST match `pub` property that equals the ID of the public key. - return no("Account not same!"); + check.pub = function(eve, msg, val, key, soul, at, no, user, pub){ var tmp // Example: {_:#~asdf, hello:'world'~fdsa}} + const raw = S.parse(val) || {} + + const verify = (certificate, certificant, cb) => { + if (certificate['m'] && certificate['s'] && certificant && pub) { + // now verify certificate + return SEA.verify(certificate, pub, data => { // check if "pub" (of the graph owner) really issued this cert + if (u !== data && u !== data.e && msg.put['>'] && msg.put['>'] > parseFloat(data.e)) return no("Certificate expired.") + // "data.c" = a list of certificants/certified users, "data.p" = a list of allowed patterns + if (u !== data && data.c && data.p && (data.c.indexOf('*') || data.c.indexOf(certificant))) { + // ok, now "certificant" is in the "certificants" list, but is "path" allowed? Check path + let path = soul + '/' + key + path = path.replace(path.substring(0, path.indexOf('/') + 1), '') + for (p of data.p) { + if (new RegExp(p).test(path)) { + return cb(data) + } + } + return no("Certificate verification fail.") + } + }) + } + return } - if((tmp = user.is) && pub === tmp.pub){ + + if ('pub' === key && '~'+pub === soul) { + if(val === pub){ return eve.to.next(msg) } // the account MUST match `pub` property that equals the ID of the public key. + return no("Account not same!") + } + + if ((tmp = user.is) && tmp.pub && !raw['*'] && !raw['+'] && (pub === tmp.pub || (pub !== tmp.pub && ((msg._.out || {}).opt || {}).cert))){ SEA.sign(SEA.opt.pack(msg.put), (user._).sea, function(data){ if(u === data){ return no(SEA.err || 'Signature fail.') } - if(tmp = link_is(val)){ (at.sea.own[tmp] = at.sea.own[tmp] || {})[pub] = 1 } - msg.put[':'] = JSON.stringify({':': tmp = SEA.opt.unpack(data.m), '~': data.s}); - msg.put['='] = tmp; - eve.to.next(msg); - }, {raw: 1}); + msg.put[':'] = {':': tmp = SEA.opt.unpack(data.m), '~': data.s} + msg.put['='] = tmp + + // if writing to own graph, just allow it + if (pub === user.is.pub) { + if (tmp = link_is(val)) { (at.sea.own[tmp] = at.sea.own[tmp] || {})[pub] = 1 } + msg.put[':'] = JSON.stringify(msg.put[':']) + return eve.to.next(msg) + } + + // if writing to other's graph, check if cert exists then try to inject cert into put, also inject self pub so that everyone can verify the put + if (pub !== user.is.pub && ((msg._.out || {}).opt || {}).cert) { + const cert = S.parse(msg._.out.opt.cert) + // even if cert exists, we must verify it + if (cert && cert.m && cert.s) { + verify(cert, user.is.pub, _ => { + msg.put[':']['+'] = cert // '+' is a certificate + msg.put[':']['*'] = user.is.pub // '*' is pub of the user who puts + msg.put[':'] = JSON.stringify(msg.put[':']) + return eve.to.next(msg) + }) + } + } + }, {raw: 1}) return; } - SEA.verify(SEA.opt.pack(msg.put), pub, function(data){ var tmp; + + SEA.verify(SEA.opt.pack(msg.put), raw['*'] || pub, function(data){ var tmp; data = SEA.opt.unpack(data); if(u === data){ return no("Unverified data.") } // make sure the signature matches the account it claims to be on. // reject any updates that are signed with a mismatched account. if((tmp = link_is(data)) && pub === SEA.opt.pub(tmp)){ (at.sea.own[tmp] = at.sea.own[tmp] || {})[pub] = 1 } - msg.put['='] = data; - eve.to.next(msg); + + // check if cert ('+') and putter's pub ('*') exist + if (raw['+'] && raw['+']['m'] && raw['+']['s'] && raw['*']) { + // now verify certificate + verify(raw['+'], raw['*'], _ => { + msg.put['='] = data; + return eve.to.next(msg); + }) + } + else { + msg.put['='] = data; + return eve.to.next(msg); + } }); }; check.any = function(eve, msg, val, key, soul, at, no, user){ var tmp, pub; diff --git a/test/sea/sea.js b/test/sea/sea.js index 0f5979bd..5ffc1c89 100755 --- a/test/sea/sea.js +++ b/test/sea/sea.js @@ -1,3 +1,5 @@ +const expect = require('../expect') + var root; var Gun; (function(){ @@ -523,6 +525,119 @@ describe('SEA', function(){ gun.user().auth(alice); }); }); + + var alice = { + epriv: "Odtnqn-gng-NCLAULCdhxcG7KE26WSWdnNTBSYf8Dsw", + epub: + "rOWulaGGaNOKhrS9XtZUbdWjcIfTM5k5pImolyNwLe0.9Ks7JRrOQl3e401dSgCGlNWgvIC_DQm0EA9jGKXBDg0", + priv: "ijke9inZcbIpNUy5p3wiMRxUvqM12xU8WLewGzUXj8E", + pub: + "Zpf4KFmDmxNnHbRcTkZcAvPnke8_4hLv_FtNhBLcSps.ICAIjzky_T0ENNFIC5cjE-dN87dWp7cb88y0Rb3Nbvo" + } + + var bob = { + epriv: "z5OC6iWYPVZO-sNqxd20t_qAPsA5nn9d-_yg5uW2mZM", + epub: + "bHUUjC-xP9QoTEyY5rubZJwft_szXgvetGOGUPOT8Mw.5J2j9SBZ8lqSHKgeFRbMZDs0EuNgM-VVWgMHE3YMFSI", + priv: "dWDbrbKUinmSxrlilKmyPzKAgmZCzm9i14bTydUf0kQ", + pub: + "naP2o7Ebn5tFF2V-z8pDFwOgOazduoiKogWnZ0cTtEE.K-sa7v6DXkb_saMFlCepqPUH--C-6rv6cO1t3wEo6-M" + } + + var dave = { + epriv: "1eBCIIk30bzfTN50uqSTIN10TWP2AqkqExioV5P-oCE", + epub: + "PneRg1oMRw3mrrnjbRq8YSADLrdbg8BGEBoC0_P6It4.Vhv8QIkLhurxU5-LhHctaaNn5u3LNujqMNRdh6JbzvU", + priv: "XKJrGFWoERdHfhXfhc-VY0nIWeI2eAIDAfkX0nu_O0A", + pub: + "AXEGD2GABu1lnzTKML_wXHSlznwI5ZFebF-MLPKxI8Y.0Sx-Sd5GpF_1kzrUcliWRdhppq7FFehQi41oZ-WOJmI" + } + + it('certify + the good', function(done){(async function(){ + var cert = await SEA.certify(bob, ["^private.*"], alice) + + user.auth(bob, () => { + var data = Gun.state.lex() + gun.get("~" + alice.pub) + .get("private") + .get("asdf") + .get("qwerty") + .put(data, () => { + gun.get("~" + alice.pub) + .get("private") + .get("asdf") + .get("qwerty").once(_data=>{ + expect(_data).to.be(data) + user.leave() + done() + }) + }, { opt: { cert } }) + }) + }())}) + + it('certify + the public', function(done){(async function(){ + var cert = await SEA.certify(bob, ["^private.*"], alice) + + user.auth(bob, () => { + var data = Gun.state.lex() + gun.get("~" + alice.pub) + .get("private") + .get("asdf") + .get("qwerty") + .put(data, () => { + user.leave() + gun.get("~" + alice.pub) + .get("private") + .get("asdf") + .get("qwerty").once(_data=>{ + expect(_data).to.be(data) + done() + }) + }, { opt: { cert } }) + }) + }())}) + + it('certify + someone', function(done){(async function(){ + var cert = await SEA.certify(bob, ["^private.*"], alice) + + user.auth(bob, () => { + var data = Gun.state.lex() + gun.get("~" + alice.pub) + .get("private") + .get("asdf") + .get("qwerty") + .put(data, () => { + user.leave() + user.auth(dave, () => { + gun.get("~" + alice.pub) + .get("private") + .get("asdf") + .get("qwerty").once(_data=>{ + expect(_data).to.be(data) + done() + }) + }) + + }, { opt: { cert } }) + }) + }())}) + + it('certify + the evil', function(done){(async function(){ + var cert = await SEA.certify(bob, ["^private.*"], alice) + + user.auth(bob, () => { + var data = Gun.state.lex() + gun.get("~" + alice.pub) + .get("wrongway") + .get("asdf") + .get("qwerty") + .put(data, ack => { + expect(ack.err).to.be.ok() + done() + }, { opt: { cert } }) + }) + }())}) + }); })