diff --git a/.github/workflows/build-wgt.yml b/.github/workflows/build-wgt.yml index 812280d..59fa330 100644 --- a/.github/workflows/build-wgt.yml +++ b/.github/workflows/build-wgt.yml @@ -14,7 +14,7 @@ jobs: node-version: '20' - name: Syntax-check the service and UI run: | - for f in tizen-app/service/sdb.js tizen-app/service/zip.js tizen-app/service/index.js tizen-app/service/signer.js tizen-app/service/catalog.js tizen-app/service/catalog-key.js tizen-app/service/compat.js tizen-app/service/manifest.js tizen-app/js/app.js test/fake-sdbd.js test/run.js test/sign.test.js test/config.test.js test/sdb-errors.test.js test/manifest.test.js; do + for f in tizen-app/service/sdb.js tizen-app/service/zip.js tizen-app/service/index.js tizen-app/service/signer.js tizen-app/service/catalog.js tizen-app/service/catalog-key.js tizen-app/service/compat.js tizen-app/service/manifest.js tizen-app/service/refusal.js tizen-app/service/certs.js tizen-app/js/app.js test/fake-sdbd.js test/run.js test/sign.test.js test/config.test.js test/sdb-errors.test.js test/manifest.test.js test/refusal.test.js test/certs.test.js test/install.test.js; do node --check "$f" done - name: config.xml ids (package id is 10 alphanumerics) @@ -33,6 +33,14 @@ jobs: run: node test/sdb-errors.test.js - name: Package manifest ids (.wgt config.xml, .tpk tizen-manifest.xml) run: node test/manifest.test.js + - name: Install refusals (another certificate, rejected certificate, Tizen version) + run: node test/refusal.test.js + - name: Signing profiles handed over (#44) + run: node test/certs.test.js + - name: Install against a fake sdbd (retry with the next profile, replace) + run: node test/install.test.js + node test/certs.test.js + node test/install.test.js # The TV's own Node: v4.4.3 on a 2019 Tizen 5.0 set (#881). Buffer.from, Buffer.alloc, # crypto.sign and friends are newer than that, and only fail there; run every test on it too. - uses: actions/setup-node@v4 @@ -49,6 +57,9 @@ jobs: node test/catalog.test.js node test/sdb-errors.test.js node test/manifest.test.js + node test/refusal.test.js + node test/certs.test.js + node test/install.test.js - name: Build the .wgt run: scripts/build-wgt.sh - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3578aa..1118fe8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,9 @@ jobs: node test/catalog.test.js node test/sdb-errors.test.js node test/manifest.test.js + node test/refusal.test.js + node test/certs.test.js + node test/install.test.js - name: Version in config.xml must match the tag run: | tag="${GITHUB_REF_NAME#v}" diff --git a/README.md b/README.md index 1eaa1fc..d4df3e7 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,33 @@ colours, big tiles for a couch. release that means the [3.0 beta](https://github.com/Apps2Samsung/Apps2Samsung/releases); 2.x has no Arm TV-Store button. - A Samsung TV in Developer Mode. Tested on Tizen 5.0 (2019) and Tizen 10 (2025); TV-Store asks for Tizen 3.0 - (2017) or newer. + (2017) or newer. Every app in the catalog asks for its own Tizen version; one this TV is too old for + shows a "Needs Tizen X" badge and does not install from here (the TV would refuse it with + `install failed[118, -19]`, #44). Where the app has a build for older sets, the sheet lands on that one. ## Setting it up 1. Install TV-Store from Apps2Samsung like any other app, with Developer Mode's **Host PC IP** set to your phone or PC as usual. -2. In Apps2Samsung, open the TV's **Toolbox** and press **Arm TV-Store**. That hands your signing - certificate to the TV once. The TV-Store screen turns green by itself. +2. In Apps2Samsung, open the TV's **Toolbox** and press **Arm TV-Store**. That hands every signing + certificate on that phone or PC to the TV once. The TV-Store screen turns green by itself. 3. On the TV, set Developer Mode's **Host PC IP** to the TV's own address (TV-Store shows it). 4. Pick an app in TV-Store and press OK. It installs into the Apps row; no phone or PC needed from here on. Set Host PC IP back to your phone or PC whenever you want to use Apps2Samsung again. +## Apps installed from a phone or PC + +Every install is signed with an author certificate, and a Samsung TV only updates an app with the +same author certificate it was installed with. Arming hands this TV every signing profile the phone +or PC holds (Public and Partner, the one older sets get, imported ones), and arming from a second +device adds its profiles to those. TV-Store remembers which profile each package took and, when the +TV answers `install failed[118, -12]` ("same id and different certificate"; `118012` on a 2018 +set), signs again with the next profile until the TV takes one (#44). So an app installed from a +phone updates from TV-Store once that phone has armed the TV. Only when none of the profiles on the +TV matches does TV-Store offer to remove the old copy and install again; the app's settings and +sign-ins on the TV go with the old copy, which is why it asks first. The arming device's profiles +are listed under Settings. + ## How it works A Samsung TV only installs sideloaded packages through its developer connection (`sdbd`), the @@ -123,7 +138,9 @@ The header counts what the switches hide ("12 apps · 3 hidden"). - `tizen-app/service/signer.js`: the package signer - `tizen-app/service/catalog.js` + `catalog-key.js`: the signed catalog check and its public key - `tizen-app/service/compat.js`: buffers that work on the TV's Node 4.4.3 -- `tizen-app/service/manifest.js`: the ids an uploaded package carries in its own manifest +- `tizen-app/service/manifest.js`: the ids and the required Tizen version a package carries in its own manifest +- `tizen-app/service/refusal.js`: what the TV's `install failed[...]` codes mean and what TV-Store offers for each +- `tizen-app/service/certs.js`: the signing profiles handed over, which one each package took, the order to try - `tizen-app/js/app.js`: the UI, and `window.A2SStore`, which Apps2Samsung calls to arm the TV - `tizen-app/js/qrcode.js`: QR codes for the log address and the Ko-fi link ([qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) 1.4.4, MIT) - `test/`: a fake `sdbd` plus signer and transport tests diff --git a/test/certs.test.js b/test/certs.test.js new file mode 100644 index 0000000..f60542f --- /dev/null +++ b/test/certs.test.js @@ -0,0 +1,50 @@ +/* certs.js keeps every signing profile a head hands over (#44): the payload shapes, merging a + * second hand-over, the order to try for a package, and reading a 0.3.x one-profile file. */ +'use strict'; +var fs = require('fs'), path = require('path'); +var certs = require('../tizen-app/service/certs.js'); +function fail(m) { console.error('FAIL: ' + m); process.exit(1); } +var F = path.join(__dirname, 'fixtures'); +function pem(f) { return fs.readFileSync(path.join(F, f), 'utf8'); } +var A = { authorKeyPem: pem('author.key'), authorCertPem: pem('author.crt'), distributorKeyPem: pem('distributor.key'), distributorCertPem: pem('distributor.crt') }; +// A second profile: the distributor pair as an author pair, different leaf, so a different id. +var B = { authorKeyPem: pem('distributor.key'), authorCertPem: pem('distributor.crt'), distributorKeyPem: pem('distributor.key'), distributorCertPem: pem('distributor.crt') }; + +var idA = certs.fingerprint(A.authorCertPem), idB = certs.fingerprint(B.authorCertPem); +if (!/^[0-9a-f]{64}$/.test(idA) || idA === idB) fail('fingerprint: ' + idA + ' ' + idB); +if (certs.fingerprint('not a pem') !== '') fail('fingerprint of junk must be empty'); + +// 0.2.0 to 0.3.12 heads send the four fields; 3.2+ heads add certificates[], first choice first. +var legacy = certs.normalize(A); +if (legacy.length !== 1 || legacy[0].id !== idA || legacy[0].name !== 'certificate 1') fail('legacy payload: ' + JSON.stringify(legacy.map(function (c) { return c.name; }))); +var both = certs.normalize({ authorKeyPem: A.authorKeyPem, authorCertPem: A.authorCertPem, distributorKeyPem: A.distributorKeyPem, distributorCertPem: A.distributorCertPem, + certificates: [ Object.assign({ name: 'Jelly2Sams - Public' }, A), Object.assign({ name: 'Jellyfin' }, B), Object.assign({ name: 'broken' }, A, { authorKeyPem: '' }) ] }); +if (both.length !== 2 || both[0].id !== idA || both[0].name !== 'Jelly2Sams - Public' || both[1].id !== idB || both[1].name !== 'Jellyfin') fail('full payload: ' + JSON.stringify(both.map(function (c) { return c.name; }))); +// A 3.2+ head whose top-level profile is not in its list (never the case, but cheap): it goes first. +var extra = certs.normalize(Object.assign({ certificates: [Object.assign({ name: 'Jellyfin' }, B)] }, A)); +if (extra.length !== 2 || extra[0].id !== idA || extra[1].name !== 'Jellyfin') fail('top-level profile outside the list goes first'); +if (certs.normalize({}).length !== 0 || certs.normalize(null).length !== 0) fail('an empty payload has no profiles'); + +// A store: the first hand-over, then a second phone adds B and keeps A. +var store = certs.merge(null, certs.normalize(Object.assign({ name: 'Jelly2Sams - Public' }, A))); +if (store.primary !== idA || store.certificates.length !== 1) fail('first store'); +store = certs.merge(store, certs.normalize({ certificates: [Object.assign({ name: 'Phone - Public' }, B)] })); +if (store.primary !== idB || store.certificates.length !== 2 || store.certificates[1].name !== 'Jelly2Sams - Public') fail('merged store: ' + JSON.stringify(certs.names(store))); +// Handing A over again under a new name replaces the entry and makes it primary. +store = certs.merge(store, certs.normalize({ certificates: [Object.assign({ name: 'PC - Public' }, A)] })); +if (store.primary !== idA || store.certificates.length !== 2 || certs.names(store).join('|') !== 'PC - Public|Phone - Public') fail('re-handover: ' + JSON.stringify(certs.names(store))); + +// Which to try for a package: the one it took before, then the primary, then the rest. +var o = certs.order(store, idB).map(function (c) { return c.id; }); +if (o.join() !== [idB, idA].join()) fail('order with a remembered profile'); +o = certs.order(store, 'gone').map(function (c) { return c.id; }); +if (o.join() !== [idA, idB].join()) fail('order with a forgotten profile'); +if (certs.order(null, idA).length !== 0) fail('order on no store'); + +// Files on disk: a 0.3.x one-profile file and this version's. +var old = certs.fromStored(A); +if (!old || old.primary !== idA || old.certificates.length !== 1) fail('0.3.x file'); +var fresh = certs.fromStored(JSON.parse(JSON.stringify(store))); +if (!fresh || fresh.primary !== idA || fresh.certificates.length !== 2) fail('this version\'s file'); +if (certs.fromStored({ junk: 1 }) !== null || certs.fromStored(null) !== null) fail('junk file'); +console.log('PASS: certificate store: payload shapes, merge of a second hand-over, order per package, 0.3.x file'); diff --git a/test/install.test.js b/test/install.test.js new file mode 100644 index 0000000..eb38dfe --- /dev/null +++ b/test/install.test.js @@ -0,0 +1,130 @@ +/* The install path against the real service and a fake sdbd (#44): two profiles handed over, the + * TV refuses the copy signed with the first ("same id and different certificate"), the service + * signs again with the second and the install goes through; the profile that worked is remembered + * for the package; when every profile is refused the answer is CERT_MISMATCH naming them; and + * replace: true removes the old copy (0 vd_appuninstall) before the push. The service runs as a + * child with its data folder in a temp dir and sdbd pointed at the fake through A2S_SDB_PORT. */ +'use strict'; +var fs = require('fs'), os = require('os'), path = require('path'), http = require('http'), cp = require('child_process'); +var zip = require('../tizen-app/service/zip.js'); +var Buf = require('../tizen-app/service/compat.js'); +var fake = require('./fake-sdbd.js'); + +function fail(m) { console.error('FAIL: ' + m); process.exit(1); } +var SDB = 26193, F = path.join(__dirname, 'fixtures'); +function pem(f) { return fs.readFileSync(path.join(F, f), 'utf8'); } +var PC = { name: 'PC - Public', authorKeyPem: pem('author.key'), authorCertPem: pem('author.crt'), distributorKeyPem: pem('distributor.key'), distributorCertPem: pem('distributor.crt') }; +var PHONE = { name: 'Phone - Public', authorKeyPem: pem('distributor.key'), authorCertPem: pem('distributor.crt'), distributorKeyPem: pem('distributor.key'), distributorCertPem: pem('distributor.crt') }; +var dir = path.join(os.tmpdir(), 'a2s-install-' + process.pid + '-' + Date.now()); fs.mkdirSync(dir); +var wgt = zip.write([{ name: 'index.html', data: Buf.from('') }, + { name: 'config.xml', data: Buf.from('Probe') }]); + +// What the fake sdbd answers: a script of replies per vd_appinstall, and a record of every shell call. +var MISMATCH = 'install A2SProbe01.Probe package_path /x was_install_app return WAS_TRUE app_id[A2SProbe01.Probe] install start installing[7] app_id[A2SProbe01.Probe] install failed[118, -12], reason: Check certificate error : :Package with the same id and different certificate has been already installed.:<-12>\n'; +var OK = 'install A2SProbe01.Probe package_path /x was_install_app return WAS_TRUE installing[100] app_id[A2SProbe01.Probe] install completed\n'; +// A profile whose distributor does not cover this set (a PC's bundled one on a 2024 TV): a certificate refusal with another reason. +var CHAIN = 'install A2SProbe01.Probe package_path /x was_install_app return WAS_TRUE app_id[A2SProbe01.Probe] install failed[118, -12], reason: Check certificate error : :Invalid certificate chain.:<-3>\n'; +var replies = [], shells = []; +var child = null, err = '', handle = null; +var WD = setTimeout(function () { stop(); fail('watchdog (40 s)' + (err ? '\n' + err : '')); }, 40000); +function stop() { clearTimeout(WD); if (child) child.kill(); if (handle) try { handle.server.close(); } catch (e) {} } + +fake.start(SDB, { + shell: function (cmd) { shells.push(cmd); if (cmd.indexOf('vd_appinstall') >= 0) return replies.length ? replies.shift() : OK; return 'ok\n'; } +}, function (h) { + handle = h; + child = cp.spawn(process.execPath, [path.join(__dirname, '..', 'tizen-app', 'service', 'index.js')], { env: { HOME: dir, TMPDIR: dir, TMP: dir, TEMP: dir, PATH: process.env.PATH, A2S_SDB_PORT: String(SDB) }, stdio: ['ignore', 'ignore', 'pipe'] }); + child.stderr.on('data', function (d) { err += d; }); + var tries = 0; + (function waitUp() { + call('GET', '/ping', null, function (e, code) { + if (e || code !== 200) { if (++tries > 100 || child.exitCode !== null) fail('the service never answered /ping' + (err ? '\n' + err : '')); return setTimeout(waitUp, 100); } + run(); + }); + })(); +}); + +function call(method, p, data, cb) { + var req = http.request({ host: '127.0.0.1', port: 8077, path: p, method: method, headers: data ? { 'Content-Length': data.length, 'Content-Type': typeof data === 'string' ? 'application/json' : 'application/octet-stream' } : {} }, function (res) { + var b = ''; res.on('data', function (d) { b += d; }); res.on('end', function () { var j = null; try { j = JSON.parse(b); } catch (e) {} cb(null, res.statusCode, j, b); }); + }); + req.on('error', function (e) { cb(e); }); + if (data) req.write(data); + req.end(); +} +function token() { return fs.readFileSync(path.join(dir, '.a2s-upload-token'), 'utf8').trim(); } +// Upload the probe and install it with the given extra flags; cb(answer, installShellCalls). +function installUpload(flags, cb) { + var before = shells.length; + call('PUT', '/upload?name=Probe.wgt', wgt, function (e, code, j) { + if (e || code !== 200 || !j || !j.ok) fail('upload: ' + code + ' ' + JSON.stringify(j)); + var body = { upload: j.upload.id, token: token(), installed: true }; + Object.keys(flags).forEach(function (k) { body[k] = flags[k]; }); + call('POST', '/install', JSON.stringify(body), function (e, code, j) { + if (e || code !== 200 || !j) fail('install call: ' + code + ' ' + JSON.stringify(j)); + cb(j, shells.slice(before)); + }); + }); +} +function logs(cb) { call('GET', '/logs', null, function (e, code, j) { cb((j && j.logs || []).join('\n')); }); } + +function run() { + // Hand over both profiles, PC first; the old single-profile shape must still be accepted too. + call('POST', '/cert', JSON.stringify({ authorKeyPem: PC.authorKeyPem, authorCertPem: PC.authorCertPem, distributorKeyPem: PC.distributorKeyPem, distributorCertPem: PC.distributorCertPem, certificates: [PC, PHONE] }), function (e, code, j) { + if (e || code !== 200 || !j.ok || j.certCount !== 2) fail('hand-over: ' + code + ' ' + JSON.stringify(j)); + call('GET', '/ping', null, function (e, code, j) { + if (!j.certReady || j.certCount !== 2 || j.certNames.join('|') !== 'PC - Public|Phone - Public') fail('/ping after hand-over: ' + JSON.stringify(j)); + // 1. The TV refuses the PC's signature; the Phone's goes through. + replies = [MISMATCH, OK]; + installUpload({}, function (j, calls) { + if (!j.ok || j.result.certificate !== 'Phone - Public') fail('second profile must be taken: ' + JSON.stringify(j)); + if (calls.filter(function (c) { return c.indexOf('vd_appinstall') >= 0; }).length !== 2 || calls.some(function (c) { return c.indexOf('vd_appuninstall') >= 0; })) fail('expected two installs and no uninstall: ' + JSON.stringify(calls)); + logs(function (text) { + if (!/signed with ‘PC - Public’/.test(text) || !/trying ‘Phone - Public’/.test(text) || !/signed with ‘Phone - Public’/.test(text)) fail('log must show the retry:\n' + text); + var map = JSON.parse(fs.readFileSync(path.join(dir, '.a2s-tvstore-cert-by-package.json'), 'utf8')); + if (!map.A2SProbe01) fail('the profile that worked must be remembered for the package'); + // 2. Next time the Phone's profile goes first, so one install is enough. + replies = [OK]; + installUpload({}, function (j, calls) { + if (!j.ok || j.result.certificate !== 'Phone - Public' || calls.filter(function (c) { return c.indexOf('vd_appinstall') >= 0; }).length !== 1) fail('the remembered profile must go first: ' + JSON.stringify(j) + ' ' + JSON.stringify(calls)); + // 3. Every profile refused: CERT_MISMATCH naming them, nothing removed. + replies = [MISMATCH, MISMATCH]; + installUpload({}, function (j, calls) { + if (j.ok || j.code !== 'CERT_MISMATCH' || j.error.indexOf('None of the 2 certificates') < 0 || j.error.indexOf('Phone - Public') < 0) fail('all refused: ' + JSON.stringify(j)); + if (calls.some(function (c) { return c.indexOf('vd_appuninstall') >= 0; })) fail('nothing may be removed without replace'); + // 3b. A profile the TV rejects for another reason (its distributor does not cover this set) is + // stepped over too; a mismatch seen on the way is what the answer says when all fail. + replies = [CHAIN, OK]; + installUpload({}, function (j, calls) { + if (!j.ok || j.result.certificate !== 'PC - Public') fail('a chain refusal must move to the next profile: ' + JSON.stringify(j)); + replies = [MISMATCH, CHAIN]; + installUpload({}, function (j) { + if (j.ok || j.code !== 'CERT_MISMATCH' || j.error.indexOf('None of the 2 certificates') < 0) fail('mismatch then chain must answer the mismatch: ' + JSON.stringify(j)); + replies = [CHAIN, CHAIN]; + installUpload({}, function (j) { + if (j.ok || j.code !== 'CERT_REJECTED') fail('only chain refusals must answer the rejection: ' + JSON.stringify(j)); + step4(); + }); + }); + }); + function step4() { + // 4. replace: the old copy goes first, then the install. + replies = [OK]; + installUpload({ replace: true }, function (j, calls) { + if (!j.ok) fail('replace: ' + JSON.stringify(j)); + var un = calls.indexOf('0 vd_appuninstall A2SProbe01'), ins = -1; + calls.forEach(function (c, i) { if (ins < 0 && c.indexOf('vd_appinstall') >= 0) ins = i; }); + if (un < 0 || ins < 0 || un > ins) fail('replace must uninstall before installing: ' + JSON.stringify(calls)); + stop(); + try { fs.readdirSync(dir).forEach(function (f) { fs.unlinkSync(path.join(dir, f)); }); fs.rmdirSync(dir); } catch (e) {} + console.log('PASS: a refused signature is retried with the next profile, the one taken is remembered, all refused is CERT_MISMATCH, replace uninstalls first'); + process.exit(0); + }); + } + }); + }); + }); + }); + }); + }); +} diff --git a/test/manifest.test.js b/test/manifest.test.js index a8a59a4..274d3b8 100644 --- a/test/manifest.test.js +++ b/test/manifest.test.js @@ -1,5 +1,6 @@ /* manifest.js reads the ids of a .wgt (config.xml) and a .tpk (tizen-manifest.xml) from the - * package on disk, and says what is wrong with anything else. */ + * package on disk, with the Tizen version each asks for (#47), and says what is wrong with + * anything else. needsNewer is the version check the service gates an install on. */ 'use strict'; var fs = require('fs'), os = require('os'), path = require('path'); var zip = require('../tizen-app/service/zip.js'); @@ -23,16 +24,22 @@ var notZip = path.join(dir, 'd.wgt'); fs.writeFileSync(notZip, 'hello'); manifest.read(wgt, function (err, ids) { if (err) fail('wgt: ' + err.message); - if (ids.kind !== 'wgt' || ids.appId !== 'NuvioTV002.Nuvio' || ids.packageId !== 'NuvioTV002' || ids.version !== '1.2.3' || ids.name !== 'Nuvio Legacy') fail('wgt ids ' + JSON.stringify(ids)); + if (ids.kind !== 'wgt' || ids.appId !== 'NuvioTV002.Nuvio' || ids.packageId !== 'NuvioTV002' || ids.version !== '1.2.3' || ids.name !== 'Nuvio Legacy' || ids.requiredVersion !== '4.0') fail('wgt ids ' + JSON.stringify(ids)); manifest.read(tpk, function (err, ids) { if (err) fail('tpk: ' + err.message); - if (ids.kind !== 'tpk' || ids.appId !== 'org.apps2samsung.overscan.app' || ids.packageId !== 'org.apps2samsung.overscan' || ids.version !== '0.2.0' || ids.name !== 'Overscan') fail('tpk ids ' + JSON.stringify(ids)); + if (ids.kind !== 'tpk' || ids.appId !== 'org.apps2samsung.overscan.app' || ids.packageId !== 'org.apps2samsung.overscan' || ids.version !== '0.2.0' || ids.name !== 'Overscan' || ids.requiredVersion !== '4.0') fail('tpk ids ' + JSON.stringify(ids)); + var noReq = manifest.parse('wgt', ''); + if (noReq.requiredVersion !== '') fail('a manifest without required_version must give \'\', got ' + JSON.stringify(noReq.requiredVersion)); + // The gate: 6.0 on a 4.0 set is refused, 2.3 on 4.0 and 4.0 on 4.0 are not, unknown on either side is not (#44). + [['6.0', '4.0', true], ['6.0', '4.0.0', true], ['5.5', '5.0', true], ['2.3', '4.0', false], ['4.0', '4.0', false], ['4.0', '10.0', false], ['', '4.0', false], ['6.0', '', false], ['6.0', 'x', false]].forEach(function (c) { + if (manifest.needsNewer(c[0], c[1]) !== c[2]) fail('needsNewer(' + JSON.stringify(c[0]) + ', ' + JSON.stringify(c[1]) + ') must be ' + c[2]); + }); manifest.read(noManifest, function (err) { if (!err || err.message.indexOf('no config.xml') < 0) fail('a package without a manifest must be refused: ' + (err && err.message)); manifest.read(notZip, function (err) { if (!err || err.message.indexOf('not a Tizen package') < 0) fail('a non-zip must be refused: ' + (err && err.message)); try { fs.readdirSync(dir).forEach(function (f) { fs.unlinkSync(path.join(dir, f)); }); fs.rmdirSync(dir); } catch (e) {} - console.log('PASS: manifest ids read from a .wgt and a .tpk; no manifest and not-a-zip refused'); + console.log('PASS: manifest ids and required Tizen read from a .wgt and a .tpk; needsNewer gate; no manifest and not-a-zip refused'); }); }); }); diff --git a/test/refusal.test.js b/test/refusal.test.js new file mode 100644 index 0000000..e12314f --- /dev/null +++ b/test/refusal.test.js @@ -0,0 +1,28 @@ +/* refusal.js turns the TV's "install failed[code], reason" into what the install screen says and + * offers: another certificate on the installed copy (CERT_MISMATCH, removable), the certificate + * itself rejected (CERT_REJECTED), a package that asks for a newer Tizen (#44, #47). */ +'use strict'; +var r = require('../tizen-app/service/refusal.js'); +function fail(m) { console.error('FAIL: ' + m); process.exit(1); } +function check(code, reason, entry, wantCode, wantText) { + var got = r.refusal(code, reason, entry); + if (got.code !== wantCode) fail('[' + code + '] ' + JSON.stringify(reason) + ' ' + JSON.stringify(entry) + ': code ' + got.code + ', want ' + wantCode); + if (wantText && got.message.indexOf(wantText) < 0) fail('[' + code + ']: message "' + got.message + '" lacks "' + wantText + '"'); +} +// A newer set names the installed copy; a 2018 set gives six digits and no reason, so the page's +// word that the package is on the TV decides (#44, HackTV). +check('118, -12', 'Check certificate error : :Package with the same id and different certificate has been already installed.:<-12>', { name: 'HackTV' }, 'CERT_MISMATCH', 'HackTV is already on this TV'); +check('118012', '', { file: 'HackTV.wgt', installed: true }, 'CERT_MISMATCH', 'HackTV is already on this TV'); +check('118012', '', { file: 'HackTV.wgt', installed: false }, 'CERT_REJECTED', 'internet during the install'); +check('118, -12', 'Check certificate error : :Invalid format of certificate in signature.:<-2>', { installed: true }, 'CERT_REJECTED', 'internet during the install'); +check('118, -12', 'Check certificate error : :Certificate in signature is not valid yet.:<-7>', { installed: true }, 'CERT_REJECTED', 'clock is behind'); +check('118, -12', 'certificate has expired', {}, 'CERT_REJECTED', 'expired'); +// The Tizen version the package asks for (#47). +check('118019', '', { requiredVersion: '6.0', tizen: '4.0' }, 'INSTALL_FAILED', 'asks for Tizen 6.0 or newer, and this TV runs Tizen 4.0'); +check('118, -19', 'Parsing error', { requiredVersion: '6.0' }, 'INSTALL_FAILED', 'asks for Tizen 6.0 or newer.'); +check('118, -4', 'Operation not allowed', {}, 'INSTALL_FAILED', 'could not read the package'); +// Anything else is passed on as the TV said it. +check('118, -22', 'Security error : :Invalid function parameter was given:<2>', { installed: true }, 'INSTALL_FAILED', 'The TV refused the package [118, -22]: Security error'); +check('116', '', {}, 'INSTALL_FAILED', '[116]'); +if (r.tizenVersionOf({ tizen: '6.5.0' }) !== '6.5' || r.tizenVersionOf({ tizen: 'x' }) !== '' || r.tizenVersionOf(null) !== '') fail('tizenVersionOf'); +console.log('PASS: refusals read as another certificate (removable), a rejected certificate, a Tizen version too low, or as the TV said'); diff --git a/tizen-app/config.xml b/tizen-app/config.xml index 6c5cddc..88e82ec 100644 --- a/tizen-app/config.xml +++ b/tizen-app/config.xml @@ -1,6 +1,6 @@ + id="https://github.com/Apps2Samsung/TV-Store" version="0.3.12" viewmodes="maximized"> Apps2Samsung TV-Store diff --git a/tizen-app/index.html b/tizen-app/index.html index f42d8eb..b1aaf8a 100644 --- a/tizen-app/index.html +++ b/tizen-app/index.html @@ -179,6 +179,9 @@ .notice{flex:none;font-size:18px;line-height:1.5;border-radius:14px;padding:14px 18px;margin-top:16px;background:rgba(240,160,32,.14);color:#F0A020} .notice.info{background:rgba(244,247,251,.06);color:#9FB0C6} .notice.bad{background:rgba(255,107,94,.14);color:#FF6B5E} + .notice.row{display:flex;align-items:center} + .notice.row>span{flex:1} + .notice.row .btn{flex:none;margin-left:18px;padding:8px 18px;font-size:16px} .sfoot{margin-top:auto;padding-top:18px;flex:none} .btns{display:flex} .btn{display:flex;align-items:center;justify-content:center;font:inherit;font-size:22px;font-weight:600;min-height:72px;padding:0 28px;border:0;border-radius:16px;background:rgba(244,247,251,.14);color:#F4F7FB;cursor:pointer;margin-right:14px;white-space:nowrap} diff --git a/tizen-app/js/app.js b/tizen-app/js/app.js index 45f9d43..9c9aa4e 100644 --- a/tizen-app/js/app.js +++ b/tizen-app/js/app.js @@ -17,6 +17,17 @@ * the heads' Packaging/ folder), root packages (`flags: ["root"]`, which afterwards need Host * PC IP set to 127.0.0.1) and files without a hash in the catalog (the TV cannot check those). * + * Another certificate (#44): an app installed from a phone or PC carries that device's author + * certificate, and the TV only updates it with the same one. Arming hands over every profile the + * phone or PC holds, and the service tries them in turn on a [118, -12]. Only when none is taken + * does the install screen offer to remove the old copy and install again (0 vd_appuninstall first); + * the app's settings on the TV go with it, so it is a choice. + * + * Tizen version (#47): the catalog says what Tizen each file asks for (required_version). A build + * this TV is too old for carries a "Needs Tizen X" badge, its card is dimmed, and its Install button + * is off: the TV would refuse it with install failed[118, -19] (#44). The service checks the same + * from the package itself before pushing, so a file sent in by hand is caught too. + * * Pins (#29): the app sheet pins one build of an app to the top of the store, remembered on the TV. * A pinned card shows that build's own state (installed version, update waiting). For apps whose * builds share one package id the pin also tells TV-Store which build the TV has. @@ -39,7 +50,7 @@ // TV's current IP and loopback too, and keeps whichever answers. var BASE = 'http://127.0.0.1:8077'; var KOFI = 'https://ko-fi.com/patrickst'; - var VERSION = '0.3.11'; + var VERSION = '0.3.12'; var KEY = { LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40, ENTER: 13, RETURN: 10009, ESC: 27, BACK: 8 }; // What the manifest providers are called; resolved.json carries only their ids. @@ -101,6 +112,7 @@ return out; } var ready = null; // the service's /ping + var tvTizen = ''; // the Tizen version this TV runs ("4.0"), from the platform; '' off a TV var problem = null; // { title, text } when the service or the catalog failed var loading = true; var view = 'install'; // 'install' | 'tv' @@ -123,12 +135,15 @@ // ---- settings, remembered on the TV ------------------------------------------------------------- var SETTINGS_KEY = 'a2s.store.settings'; var settings = (function () { - var s = { showAdult: false, showNeedsHead: true, verboseLog: false }; + // hideSelfNotice: the TV-Store version whose "vX is out" banner was dismissed (#43); the + // banner comes back for the next release. + var s = { showAdult: false, showNeedsHead: true, verboseLog: false, hideSelfNotice: '' }; try { var saved = JSON.parse(window.localStorage.getItem(SETTINGS_KEY) || '{}'); if (typeof saved.showAdult === 'boolean') s.showAdult = saved.showAdult; if (typeof saved.showNeedsHead === 'boolean') s.showNeedsHead = saved.showNeedsHead; if (typeof saved.verboseLog === 'boolean') s.verboseLog = saved.verboseLog; + if (typeof saved.hideSelfNotice === 'string') s.hideSelfNotice = saved.hideSelfNotice; } catch (e) {} return s; })(); @@ -281,6 +296,8 @@ } function readDevice() { + // "4.0", "6.5", "10.0": what the apps' required_version is held against (#47). + try { var pv = /^\s*(\d+(?:\.\d+)?)/.exec(String(tizen.systeminfo.getCapability('http://tizen.org/feature/platform.version') || '')); if (pv) tvTizen = pv[1]; } catch (e) {} ['ETHERNET_NETWORK', 'WIFI_NETWORK'].forEach(function (t) { try { tizen.systeminfo.getPropertyValue(t, function (n) { if (n && n.ipAddress && !tvIp) tvIp = n.ipAddress; }, function () {}); } catch (e) {} }); @@ -293,9 +310,16 @@ } // ---- the catalog ------------------------------------------------------------------------------ - function variant(label, file, url, version, appId, packageId) { - return { label: label || file.replace(/\.(wgt|tpk)$/i, ''), file: file, url: url, version: version || '', appId: appId || '', packageId: packageId || '' }; - } + function variant(label, file, url, version, appId, packageId, tizen) { + return { label: label || file.replace(/\.(wgt|tpk)$/i, ''), file: file, url: url, version: version || '', appId: appId || '', packageId: packageId || '', tizen: tizen || '' }; + } + // Whether this TV can run a build that asks for Tizen v.tizen ("6.0"): yes when either side is + // unknown; the service checks the package itself and the TV has the last word (#47). + function fits(v) { return !tvTizen || !v.tizen || !newer(v.tizen, tvTizen); } + // The lowest Tizen any build of the app asks for, '' when no build says. + function lowestTizen(x) { var low = ''; x.variants.forEach(function (v) { if (v.tizen && (!low || newer(low, v.tizen))) low = v.tizen; }); return low; } + // No build of the app runs on this TV. + function tooNew(x) { return !!tvTizen && x.variants.length > 0 && !x.variants.some(fits); } function ownerOf(repoOrUrl) { var m = /(?:repos\/)?([^\/]+)\/[^\/]+(?:\/releases)?$/.exec(String(repoOrUrl || '').replace(/^https?:\/\/[^\/]+\//, '')); @@ -322,7 +346,7 @@ // The community bundle: the catalog is the truth, the release gives the download. p.catalog.apps.forEach(function (e) { var a = byFile[e.file]; if (!a) return; - var v = variant(e.variant, e.file, a.browser_download_url, e.version || a.package_version, e.app_id || a.app_id, e.package_id || a.package_id); + var v = variant(e.variant, e.file, a.browser_download_url, e.version || a.package_version, e.app_id || a.app_id, e.package_id || a.package_id, e.required_version || a.required_version); var x = app('c:' + (e.group || e.name), e.group || e.name, e.category, e.description, ownerOf(e.repo)); x.variants.push(v); if (e.adult) x.adult = true; @@ -339,7 +363,7 @@ var info = PROVIDERS[p.id] || { name: p.id, category: 'other', description: '' }; var x = app('p:' + p.id, info.name, info.category, info.description, ownerOf(p.url)); assets.forEach(function (a) { - var v = variant(null, a.name, a.browser_download_url, a.package_version, a.app_id, a.package_id); + var v = variant(null, a.name, a.browser_download_url, a.package_version, a.app_id, a.package_id, a.required_version); if (!a.digest) v.needsHead = NO_HASH; x.variants.push(v); }); @@ -485,6 +509,7 @@ function cardBadges(x) { var st = state(x), b = ''; if (st.installed) b += st.update ? badge('b-warn', 'Update') : badge('b-ok', 'Installed'); + if (tooNew(x)) b += badge('b-warn', 'Needs Tizen ' + lowestTizen(x)); if (x.adult) b += badge('b-warn', '18+'); if (x.root) b += badge('b-warn', 'root'); if (x.needsHead) b += badge('b-mute', 'Phone or PC'); @@ -495,6 +520,7 @@ if (vs.installed) b += vs.update ? badge('b-warn', 'Update to v' + vs.latest) : badge('b-ok', 'Installed' + (vs.version ? ' · v' + vs.version : '')); else if (vs.other) b += badge('b-mute', 'Other build on the TV'); else if (v.version) b += badge('b-mute', 'v' + v.version); + if (!fits(v)) b += badge('b-warn', 'Needs Tizen ' + v.tizen); if (x.adult) b += badge('b-warn', '18+'); if (x.root) b += badge('b-warn', 'root'); if (v.needsHead) b += badge('b-mute', 'Phone or PC'); @@ -534,6 +560,7 @@ if (st.installed) b += st.update ? badge('b-warn', 'Update to v' + st.latest) : badge('b-ok', 'Installed' + (st.version ? ' · v' + st.version : '')); if (x.variants.length > 1) b += badge('b-mute', x.variants.length + ' variants'); else if (x.variants[0].version && !st.installed) b += badge('b-mute', 'v' + x.variants[0].version); + if (tooNew(x)) b += badge('b-warn', 'Needs Tizen ' + lowestTizen(x)); if (x.adult) b += badge('b-warn', '18+'); if (x.needsHead) b += badge('b-mute', 'Phone or PC'); return '
' + tile(x, 110) @@ -543,13 +570,13 @@ } function card(x, where) { - return '
' + return '
' + tile(x, 68) + '
' + esc(x.name) + '
' + esc(subline(x)) + '
' + cardBadges(x) + '
'; } // A pinned build's card (#29): the build's own label and state, and OK opens the sheet on it. function pinCard(x, i) { var v = x.variants[i]; - return '
' + return '
' + tile(x, 68) + '
' + esc(x.name) + '
' + esc(x.variants.length > 1 ? v.label : (v.version ? 'v' + v.version : v.file)) + '
' + pinBadges(x, i) + '
'; } @@ -588,17 +615,20 @@ // TV-Store is in its own catalog: say so when a newer one is there (like Moonfin's update // popup, but from the signed catalog instead of the GitHub API). Not installable from here - // (#41): the installer would stop the service doing it. - var me = self(), ms = me ? state(me) : null; - if (ms && ms.update) h += '
TV-Store v' + esc(ms.latest) + ' is out (this is v' + esc(ms.version) + '). ' + SELF_HOW + '
'; + // (#41): the installer would stop the service doing it. Nothing to do on the TV, so the banner + // has a Got it that hides it for that version (#43); the On the TV row and the sheet keep + // saying it. It comes back for the next release. + var me = self(), ms = me ? state(me) : null, selfLatest = '', selfText = ''; + if (ms && ms.update) { selfLatest = ms.latest; selfText = 'TV-Store v' + ms.latest + ' is out (this is v' + ms.version + '). ' + SELF_HOW; } else if (me && selfRenamed()) { // Installed under another package id: the platform lists this copy under that id, so // state() cannot see it. Compare the catalog's version with our own and send the user to // the head that renamed it rather than install a second TV-Store from here. var own = (selfInfo() || {}).version || '', latest = me.variants[0] ? me.variants[0].version : ''; - if (own && latest && newer(latest, own)) h += '
TV-Store v' + esc(latest) + ' is out (this is v' + esc(own) - + '). This copy was installed under another package id by Apps2Samsung, so update it from Apps2Samsung (On the TV, Update) rather than from here.
'; + if (own && latest && newer(latest, own)) { selfLatest = latest; selfText = 'TV-Store v' + latest + ' is out (this is v' + own + '). This copy was installed under another package id by Apps2Samsung, so update it from Apps2Samsung (On the TV, Update) rather than from here.'; } } + if (selfLatest && settings.hideSelfNotice !== selfLatest) + h += '
' + esc(selfText) + '
'; if (!hover || apps.indexOf(hover) < 0) hover = apps[0] || null; if (!query) { @@ -662,7 +692,7 @@ + '
In the catalogv' + esc(st.latest || v.version) + '
' + (x.variants.length > 1 ? '
Variant' + esc(st.known ? v.label + (st.byPin ? ' · pinned' : '') : 'Not known: the builds share one package') + '
' : '') + '
' + '
'; - if (st.update && st.known && !v.needsHead && !isSelf(x) && armed()) h += ''; + if (st.update && st.known && !v.needsHead && fits(v) && !isSelf(x) && armed()) h += ''; else h += ''; if (v.appId) h += ''; h += '
'; @@ -720,6 +750,7 @@ if (st.update) b += badge('b-warn', 'Update to v' + st.latest); if (x.adult) b += badge('b-warn', 'Adults only (18+)'); if (x.root) b += badge('b-warn', 'Gains root on the TV'); + if (v.tizen) b += badge(fits(v) ? 'b-mute' : 'b-warn', 'Tizen ' + v.tizen + ' or newer'); if (b) h += '
' + b + '
'; if (x.variants.length > 1) { @@ -730,7 +761,7 @@ var vv = x.variants[i], inst = st.installed && st.known && st.variant === i; return '
' + '' + esc(vv.label) + '' - + (inst ? badge('b-ok', 'on the TV') : '') + (isPinned(x, i) ? badge('b-pin', 'Pinned') : '') + (vv.needsHead ? badge('b-mute', 'Phone or PC') : '') + + (inst ? badge('b-ok', 'on the TV') : '') + (isPinned(x, i) ? badge('b-pin', 'Pinned') : '') + (vv.needsHead ? badge('b-mute', 'Phone or PC') : '') + (fits(vv) ? '' : badge('b-warn', 'Needs Tizen ' + vv.tizen)) + '' + esc(vv.version ? 'v' + vv.version : '') + '
'; }).join('') + '
'; if (!sheet.all && x.variants.length > 4) h += 'Show all ' + x.variants.length + ' variants ›'; @@ -739,22 +770,26 @@ var label = st.installed && st.known && st.variant === sheet.v ? (st.update ? 'Update to v' + v.version : 'Reinstall') : (st.installed ? 'Install this variant' : 'Install on this TV'); var me = isSelf(x); if (me) h += '
' + SELF_HOW + '
'; + else if (!fits(v)) h += '
' + esc(x.name + (x.variants.length > 1 ? ' (' + v.label + ')' : '') + ' asks for Tizen ' + v.tizen + ' or newer, and this TV runs Tizen ' + tvTizen + '. The TV would refuse it' + (x.variants.some(fits) ? ', so pick a build above that fits this set.' : '.')) + '
'; else if (v.needsHead) h += '
' + esc(x.name + ' ' + v.needsHead + ', which this TV cannot do yet. Install it with Apps2Samsung on a phone or PC.') + '
'; else if (!armed()) h += '
Not armed yet. Open this TV’s Toolbox in Apps2Samsung 3.0 or newer on your phone or PC and press Arm TV-Store once; after that every install runs here.
'; - var can = !v.needsHead && !me && armed() && !(job && job.running); + var can = !v.needsHead && fits(v) && !me && armed() && !(job && job.running); h += '
'; + + esc(me ? (st.update ? 'Update' : 'Reinstall') + ' from a phone or PC' : !fits(v) ? 'Needs Tizen ' + v.tizen + ' or newer' : v.needsHead ? 'Install from a phone or PC' : job && job.running ? 'Another install is running' : label) + ''; if (st.installed && x.variants[st.variant].appId) h += ''; // Pin this build to the top of the store (#29); for a one-build app the button pins the app. h += ''; if (st.installed && !st.known && x.variants.length > 1) h = h.replace('
', '
' + esc('Your TV has ' + x.name + ' v' + st.version + ', but every build of it uses the same package, so TV-Store cannot tell which one. Pick yours above to reinstall or update it, or pin it so TV-Store knows.') + '
'); - h += '
' + (can ? 'Takes about a minute. RETURN goes back to the store.' : 'RETURN goes back to the store.') + '
'; + // Came from a phone or PC (TV-Store never installed this package): the TV keeps that device's + // certificate, and an update from here may need the old copy removed first (#44). + var fromHead = can && st.installed && v.packageId && !installedFile.hasOwnProperty(v.packageId); + h += '
' + (fromHead ? esc(x.name + ' was installed from a phone or PC. TV-Store signs with the certificate that installed it when that phone or PC armed this TV; otherwise it offers to remove the old copy first. RETURN goes back to the store.') : can ? 'Takes about a minute. RETURN goes back to the store.' : 'RETURN goes back to the store.') + '
'; return h; } // The file a phone or PC sent (#30), as the app the install screens show: one build, no catalog. function uploadApp(up) { - var ids = up.ids || {}, v = variant(up.name, up.name, '', ids.version, ids.appId, ids.packageId); + var ids = up.ids || {}, v = variant(up.name, up.name, '', ids.version, ids.appId, ids.packageId, ids.requiredVersion); v.upload = up.id; return { key: 'upload:' + up.id, name: ids.name || up.name.replace(/\.(wgt|tpk)$/i, ''), category: 'other', description: '', author: '', variants: [v], needsHead: null, adult: false, root: false }; } @@ -770,11 +805,13 @@ if (b) h += '
' + b + '
'; h += '
File' + esc(up.name) + '
' + '
Application' + esc((ids.appId || ids.packageId || 'unknown') + (ids.version ? ' · v' + ids.version : '')) + '
' + + (ids.requiredVersion ? '
Asks for' + esc('Tizen ' + ids.requiredVersion + ' or newer' + (tvTizen ? ' · this TV runs Tizen ' + tvTizen : '')) + '
' : '') + '
SHA-256' + esc(String(up.sha256).slice(0, 20)) + '…
'; if (!armed()) h += '
Not armed yet. Open this TV’s Toolbox in Apps2Samsung on your phone or PC and press Arm TV-Store once; the file keeps waiting.
'; else if (uploadTokenError) h += '
TV-Store could not read its upload key on this TV (' + esc(uploadTokenError) + '), so it cannot confirm uploads here. Install the file with Apps2Samsung on a phone or PC instead.
'; - var can = armed() && !uploadTokenError && !(job && job.running); - h += '
' + else if (!fits(x.variants[0])) h += '
' + esc('This file asks for Tizen ' + ids.requiredVersion + ' or newer, and this TV runs Tizen ' + tvTizen + '. The TV would refuse it.') + '
'; + var can = armed() && !uploadTokenError && fits(x.variants[0]) && !(job && job.running); + h += '
' + '
' + '
Signed with your certificate, like every install. RETURN keeps the file waiting.
'; return h; @@ -796,13 +833,14 @@ if (i === 0 && v.upload) s = ['Received from your phone or PC', 'Picking up the file']; if (i === 0) side = job.bytes ? (job.bytes / 1048576).toFixed(1) + ' MB' : ''; if (i === 1) side = 'with your certificate'; - if (i === 2) side = 'the TV is doing this part'; + if (i === 2) side = job.replace ? 'the old copy goes first' : 'the TV is doing this part'; return '
' + ico + '' + (i < done ? s[0] : s[1]) + '' + side + '
'; }).join('') + '
'; if (job.error) h += '
' + esc(job.error) + '
'; else h += '
You can keep browsing with RETURN; the ' + (upd ? 'update' : 'install') + ' carries on in the background and the app shows up under On the TV when it is in.
'; h += '
'; - if (job.error) h += ''; + if (job.error && job.code === 'CERT_MISMATCH' && !job.replace) h += ''; + else if (job.error) h += ''; else h += ''; h += '
' + esc(job.line || '') + '
'; return h; @@ -837,9 +875,11 @@ + opt('verboseLog', 'Verbose logging', 'Writes much more detail to the TV-Store log. Turn it on when a bug report asks for it, and off again afterwards.') + '
'; if (confirmingAdult) h += '
These apps are meant for adults only. Turn them on only if you are 18 or older.
' + '
'; - h += '
Signing certificate' + (armed() ? 'handed over' : 'not yet') + '
' + var certText = !armed() ? 'not yet' : ready.certCount > 1 ? ready.certCount + ' handed over: ' + (ready.certNames || []).join(', ') : 'handed over' + (ready.certNames && ready.certNames[0] ? ': ' + ready.certNames[0] : ''); + h += '
Signing certificate' + (ready && ready.certCount > 1 ? 's' : '') + '' + esc(certText) + '
' + '
Catalog' + esc(catalogApps.length + ' apps, signature checked') + '
' - + '
TV-Storev' + VERSION + (ready && ready.node ? ' · Node ' + esc(ready.node) : '') + '
'; + + '
TV-Storev' + VERSION + (ready && ready.node ? ' · Node ' + esc(ready.node) : '') + '
' + + '
This TV' + esc((device ? device + ' · ' : '') + (tvTizen ? 'Tizen ' + tvTizen : 'Tizen version not known')) + '
'; if (confirmingAdult) return h; // the question needs the room the QR codes take // Your own file (#30): the upload page's address, as a QR for a phone. if (ready && ready.ownIp) { @@ -851,24 +891,29 @@ // ---- actions ---------------------------------------------------------------------------------- function openApp(x, vIdx) { - var st = state(x); - openSheet({ mode: 'app', app: x, v: vIdx != null ? vIdx : (st.installed ? st.variant : 0), all: false }); + var st = state(x), v = vIdx != null ? vIdx : (st.installed ? st.variant : 0); + // Not a build this TV cannot run when another can (#47): the first that fits is the one to land on. + if (vIdx == null && !fits(x.variants[v])) for (var i = 0; i < x.variants.length; i++) if (fits(x.variants[i])) { v = i; break; } + openSheet({ mode: 'app', app: x, v: v, all: false }); } - function startInstall(x, vIdx) { + function startInstall(x, vIdx, opts) { if ((job && job.running) || isSelf(x)) return; - var v = x.variants[vIdx], st = state(x); - job = { app: x, v: v, vIdx: vIdx, update: st.installed && st.known && st.variant === vIdx, running: true, finished: false, ok: false, step: 0, bytes: 0, line: '', error: null, started: Date.now() }; + var v = x.variants[vIdx], st = state(x), replace = !!(opts && opts.replace); + job = { app: x, v: v, vIdx: vIdx, update: st.installed && st.known && st.variant === vIdx, replace: replace, running: true, finished: false, ok: false, step: 0, bytes: 0, line: '', error: null, code: null, started: Date.now() }; if (sheet) { sheet = { mode: 'job' }; renderSheet(true); } else openSheet({ mode: 'job' }); renderHeader(); var since = job.started - 2000, me = job; var timer = setInterval(function () { pollLog(me, since); }, 700); - var req = v.upload ? { upload: v.upload, token: uploadToken } : { url: v.url, file: v.file, appId: v.appId, packageId: v.packageId }; + // installed: whether the TV lists the package, so the service can read a bare [118, -12] from + // a 2018 set (no reason text) as "another certificate" (#44); replace: remove that copy first. + var onTv = !!(v.packageId && installed.hasOwnProperty(v.packageId)); + var req = v.upload ? { upload: v.upload, token: uploadToken, tizen: tvTizen, installed: onTv, replace: replace } : { url: v.url, file: v.file, appId: v.appId, packageId: v.packageId, name: x.name, tizen: tvTizen, installed: onTv, replace: replace }; xhr('POST', '/install', req, function (err, res) { clearInterval(timer); pollLog(me, since, function () { me.running = false; me.finished = true; me.ended = Date.now(); - if (err || !res.ok) { me.error = err ? err.message : res.error; renderHeader(); if (sheet && sheet.mode === 'job') renderSheet(true); queue = []; return; } + if (err || !res.ok) { me.error = err ? err.message : res.error; me.code = err ? null : (res.code || null); renderHeader(); if (sheet && sheet.mode === 'job') renderSheet(true); queue = []; return; } me.ok = true; me.step = 4; if (v.packageId) { installed[v.packageId] = v.version; claims = null; rememberVariant(v); } if (v.appId) appIds[v.appId] = true; @@ -892,7 +937,7 @@ me.line = l.replace(/^\S+\s+install:\s*/, ''); var m = /(?:downloaded|uploaded) (\d+) bytes/.exec(l); if (m) { me.bytes = parseInt(m[1], 10); me.step = Math.max(me.step, 1); } - if (/pushing to/.test(l)) me.step = Math.max(me.step, 2); + if (/removing the old copy|pushing to/.test(l)) me.step = Math.max(me.step, 2); if (/vd_appinstall reply/.test(l)) me.step = Math.max(me.step, 3); }); if (sheet && sheet.mode === 'job' && job === me && me.running) renderSheet(); @@ -949,12 +994,14 @@ case 'author': var by = sheet.app.author; closeSheet(function () { searchFor(by); }); return; case 'pin': togglePin(sheet.app, sheet.v); renderPage(); renderSheet(); focusKey('pin'); return; case 'retry': location.reload(); return; + case 'dismissself': settings.hideSelfNotice = el.getAttribute('data-v') || ''; saveSettings(); renderPage(); focusFirst(); return; case 'allvars': sheet.all = true; renderSheet(); focusKey('var:' + sheet.v); return; case 'install': if (!el.disabled) startInstall(sheet.app, sheet.v); return; case 'open': var s = state(sheet.app); launch(sheet.app.variants[s.variant].appId); return; case 'openjob': launch(job.v.appId); return; case 'close': closeSheet(); return; case 'retryjob': startInstall(job.app, job.vIdx); return; + case 'replacejob': startInstall(job.app, job.vIdx, { replace: true }); return; case 'adultyes': setSetting('showAdult', true); return; case 'adultno': confirmingAdult = false; renderSheet(); focusKey('opt:showAdult'); return; case 'tvupd': startInstall(tvSel, state(tvSel).variant); return; @@ -1087,7 +1134,7 @@ xhr('POST', '/cert', certs, function (err, res) { if (err && ++tries < 30) return setTimeout(post, 500); if (err) return resolve({ ok: false, error: err.message }); - if (res && res.ok) setReady({ ok: true, certReady: true, ownIp: ready && ready.ownIp, node: ready && ready.node }); + if (res && res.ok) setReady({ ok: true, certReady: true, certCount: res.certCount || 1, certNames: certs && certs.certificates ? certs.certificates.map(function (c) { return c.name; }) : [], ownIp: ready && ready.ownIp, node: ready && ready.node }); resolve(res || { ok: false, error: 'no answer from the service' }); }); })(); diff --git a/tizen-app/service/certs.js b/tizen-app/service/certs.js new file mode 100644 index 0000000..1049e63 --- /dev/null +++ b/tizen-app/service/certs.js @@ -0,0 +1,74 @@ +/* The signing certificates a head hands over (#844), all of them (#44): a phone or PC holds more + * than one profile (Public and Partner, a bundled one for older sets, imported ones), and an app + * installed from it was signed with one of them. A Samsung TV only updates an app with the author + * certificate it was installed with, so TV-Store keeps every profile it is given, remembers which + * one each package took, and on a mismatch tries the next before it ever offers to remove the app. + * + * Stored shape (CERT_NAME in the data folder): + * { primary: , certificates: [ { id, name, authorKeyPem, authorCertPem, distributorKeyPem, distributorCertPem } ] } + * id is the SHA-256 of the author leaf certificate (DER), so the same profile handed over twice, + * or by two heads, is one entry. A 0.3.x file holding one bare profile is read as a one-entry store. + * Pure, ES5, Node 4: test/certs.test.js. */ +'use strict'; +var crypto = require('crypto'); +var Buf = require('./compat.js'); + +var FIELDS = ['authorKeyPem', 'authorCertPem', 'distributorKeyPem', 'distributorCertPem']; + +function complete(c) { return !!c && FIELDS.every(function (f) { return typeof c[f] === 'string' && c[f].length > 0; }); } + +// The author leaf's SHA-256 (hex), the id a profile is known by; '' when the PEM holds no certificate. +function fingerprint(authorCertPem) { + var m = /-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/.exec(String(authorCertPem || '')); + if (!m) return ''; + return crypto.createHash('sha256').update(Buf.from(m[1].replace(/\s+/g, ''), 'base64')).digest('hex'); +} + +function entry(c, i) { + var id = fingerprint(c.authorCertPem); + if (!id) return null; + return { id: id, name: String(c.name || ('certificate ' + (i + 1))).slice(0, 80), + authorKeyPem: c.authorKeyPem, authorCertPem: c.authorCertPem, distributorKeyPem: c.distributorKeyPem, distributorCertPem: c.distributorCertPem }; +} + +// What POST /cert carries, as a list, the head's first choice first: `certificates: [...]` (3.2+), +// and / or the four fields at the top level (every head since 0.2.0; a 3.2+ head sends both, and +// the named entry in the list is the one kept). [] when nothing is usable. +function normalize(body) { + var out = [], seen = {}; + function add(c, i) { var e = complete(c) ? entry(c, i) : null; if (e && !seen[e.id]) { seen[e.id] = true; out.push(e); } } + if (body && Array.isArray(body.certificates)) body.certificates.forEach(add); + if (body && complete(body)) { var top = entry(body, out.length); if (top && !seen[top.id]) out.unshift(top); } + return out; +} + +// The store after a hand-over: incoming profiles replace the stored ones with the same id and +// come first, the rest stays (a second phone or PC adds its profiles instead of wiping the first's). +function merge(stored, incoming) { + var list = incoming.slice(), seen = {}; + list.forEach(function (c) { seen[c.id] = true; }); + ((stored && stored.certificates) || []).forEach(function (c) { if (c && c.id && !seen[c.id]) { seen[c.id] = true; list.push(c); } }); + return { primary: list.length ? list[0].id : null, certificates: list }; +} + +// A file written by 0.3.x (one bare profile) or by this version. +function fromStored(obj) { + if (!obj) return null; + if (Array.isArray(obj.certificates)) return merge(null, normalize(obj)); + return complete(obj) ? merge(null, normalize(obj)) : null; +} + +// The profiles to try for a package, in order: the one it took last time (when still here), the +// head's first choice, then the rest. [] when the TV has none. +function order(store, preferredId) { + var list = (store && store.certificates) || [], out = []; + function push(id) { list.forEach(function (c) { if (c.id === id && out.indexOf(c) < 0) out.push(c); }); } + if (preferredId) push(preferredId); + if (store && store.primary) push(store.primary); + list.forEach(function (c) { if (out.indexOf(c) < 0) out.push(c); }); + return out; +} + +function names(store) { return ((store && store.certificates) || []).map(function (c) { return c.name; }); } + +module.exports = { normalize: normalize, merge: merge, fromStored: fromStored, order: order, names: names, fingerprint: fingerprint, complete: complete }; diff --git a/tizen-app/service/index.js b/tizen-app/service/index.js index 2a9b912..ee77edf 100644 --- a/tizen-app/service/index.js +++ b/tizen-app/service/index.js @@ -19,8 +19,12 @@ var zip = require('./zip.js'); var catalogTrust = require('./catalog.js'); var CATALOG_KEY = require('./catalog-key.js'); var manifest = require('./manifest.js'); +var refusals = require('./refusal.js'); +var certs = require('./certs.js'); var PORT = 8077; +// sdbd's port; a test points the service at a fake sdbd (test/install.test.js). +var SDB_PORT = parseInt(process.env.A2S_SDB_PORT || '', 10) || 26101; // Signed by the Apps2Samsung catalog workflow; see catalog.js for why the content is checked // instead of the connection. var CATALOG_URL = 'https://apps2samsung.com/catalog/resolved.json.signed'; @@ -147,7 +151,10 @@ function installUpload(b, cb) { var up = UPLOAD, outFile = path.join(DATA_DIR, 'job-' + Date.now() + '.signed.wgt'); UPLOAD_BUSY = true; log('install: uploaded ' + up.size + ' bytes, ' + up.name + ' from ' + up.from + ', SHA-256 ' + up.sha256 + ', confirmed on the TV; signing'); - signAndInstall({ appId: up.ids.appId, packageId: up.ids.packageId, file: up.name }, up.name.replace(/[^A-Za-z0-9._-]/g, '_'), UPLOAD_FILE, outFile, function (err, r) { + var entry = { appId: up.ids.appId, packageId: up.ids.packageId, file: up.name, name: up.ids.name, requiredVersion: up.ids.requiredVersion, tizen: b.tizen, installed: !!b.installed, replace: !!b.replace }; + var gate = tizenGate(entry); + if (gate) { UPLOAD_BUSY = false; return cb(gate); } + signAndInstall(entry, up.name.replace(/[^A-Za-z0-9._-]/g, '_'), UPLOAD_FILE, outFile, function (err, r) { UPLOAD_BUSY = false; unlink(outFile); // The file is gone once signed (signAndInstall drops it); a failure before that (no @@ -273,19 +280,55 @@ function install(entry, cb) { if (err) return clean(err); if (got.sha256 !== want) { log('install: hash mismatch, got ' + got.sha256 + ' want ' + want); return clean(coded('HASH_MISMATCH', 'the download does not match the catalog (SHA-256 differs); not installing it')); } log('install: downloaded ' + got.size + ' bytes, SHA-256 matches the catalog; signing'); - signAndInstall(entry, remoteName, inFile, outFile, clean); + // The Tizen version the package asks for, against the one the page reports for this TV + // (#47): a package the TV would refuse with [118, -19] is turned away before the push. + manifest.read(inFile, function (err, ids) { + if (err) logv('install: could not read the manifest (' + err.message + '); the TV decides'); + else entry.requiredVersion = ids.requiredVersion; + var gate = tizenGate(entry); + if (gate) return clean(gate); + signAndInstall(entry, remoteName, inFile, outFile, clean); + }); }); }); } function coded(code, message) { var e = new Error(message); e.code = code; return e; } +// The page sends the TV's platform version with every install (entry.tizen, "4.0"); the package's +// own manifest says what it asks for (entry.requiredVersion). A mismatch is refused here with the +// reason, instead of by the TV with a bare [118, -19] (#44, #47). Either side unknown: no gate. +function tizenGate(entry) { + var tv = refusals.tizenVersionOf(entry), req = entry.requiredVersion || ''; + if (!manifest.needsNewer(req, tv)) return null; + log('install: ' + (entry.file || 'the package') + ' asks for Tizen ' + req + ', this TV runs Tizen ' + tv + '; not pushing it'); + return coded('NEEDS_NEWER_TIZEN', 'This app asks for Tizen ' + req + ' or newer, and this TV runs Tizen ' + tv + '. The TV would refuse it. If the catalog has a build for older sets, pick that one.'); +} + +// An app installed from a phone or PC carries that device's author certificate, and the TV only +// updates it with the same one (#44). When the page asks for a replace, the old copy goes first +// (0 vd_appuninstall, the verb the heads use), on its own connection; a refusal is logged and the +// install goes ahead anyway, so the TV's own answer is what the user sees. +function removeOld(entry, cb) { + var ip = ownIp(); + if (!entry.replace || !entry.packageId || !ip) return cb(); + log('install: removing the old copy of ' + entry.packageId + ' first (installed with another certificate)'); + connectSdb([ip, '127.0.0.1'], function (err, c) { + if (err) { log('install: could not remove the old copy: ' + err.message); return cb(); } + c.shell('0 vd_appuninstall ' + entry.packageId, function (err, out) { + c.end(); + log('install: vd_appuninstall reply: ' + (err ? err.message : out.replace(/\s+/g, ' ').slice(0, 200))); + cb(); + }); + }); +} + // sdbd on this TV, at the first address that completes the handshake. The LAN IP is what Developer // Mode's Host PC IP lets in; loopback is a fallback for a Host PC IP of 127.0.0.1. // When none gets in, the error says what to change on the TV instead of the socket error (#26). function connectSdb(hosts, cb, i, seen) { i = i || 0; seen = seen || []; - var c = new sdb.SdbClient(hosts[i]); + var c = new sdb.SdbClient(hosts[i], SDB_PORT); c.trace = VERBOSE ? function (m) { logv('sdb ' + hosts[i] + ': ' + m); } : null; c.connect(function (err) { if (!err) { log('install: sdb connected to ' + hosts[i] + ', first frame ' + c.firstFrame); return cb(null, c); } @@ -308,40 +351,62 @@ function sdbAdvice(err, seen, ip) { return err; } +// Signs with the profile the package took last time (else the head's first choice), pushes and +// installs. A TV only updates an app with the author certificate it was installed with (#44): when +// it refuses the certificate ("same id and different certificate", or any other [118, -12]: a +// distributor that does not cover this set, a chain the TV will not take), the next profile on +// this TV is tried, from the same input file, until one is taken or none is left. The answer is +// then the mismatch when any attempt was one, else the last refusal. inFile is gone once an +// attempt was signed; a failure before that (no certificate yet) keeps it, so an upload stays waiting. function signAndInstall(entry, remoteName, inFile, outFile, cb) { - var started = Date.now(); - signer.signFile(inFile, outFile, loadCerts(), function (err) { - if (err) { log('install: ' + err.message); return cb(err); } // NO_CERT surfaces here - try { fs.unlinkSync(inFile); } catch (e) {} - var size = fs.statSync(outFile).size; - log('install: signed in ' + Math.round((Date.now() - started) / 1000) + ' s, ' + size + ' bytes'); - var ip = ownIp(); - if (!ip) return cb(new Error('could not determine this TV’s own IP')); - connectSdb([ip, '127.0.0.1'], function (err, c) { - if (err) return cb(err); - var remote = STAGING + '/' + remoteName, fd; - try { fd = fs.openSync(outFile, 'r'); } catch (e) { c.end(); return cb(e); } - log('install: pushing to ' + remote); - var pushStarted = Date.now(); - c.push(remote, { size: size, read: function (off, len) { return zip.readAt(fd, off, len); } }, function (err) { - try { fs.closeSync(fd); } catch (e) {} - if (err) { c.end(); return cb(err); } - logv('timing: push took ' + (Date.now() - pushStarted) + ' ms'); - var installStarted = Date.now(); - c.shell('0 vd_appinstall ' + (entry.appId || entry.packageId || '') + ' ' + remote, function (err, out) { - c.end(); - if (err) return cb(err); - logv('timing: vd_appinstall took ' + (Date.now() - installStarted) + ' ms'); - log('install: vd_appinstall reply: ' + out.replace(/\s+/g, ' ').slice(0, 200)); - // sdbd answers the shell call either way; the installer's own words say whether it - // worked ("install failed[118, -12], reason: ..." on a refused package). - var failed = /install failed\[([^\]]*)\](?:,\s*reason:\s*([^\n]*))?/i.exec(out); - if (failed) return cb(coded('INSTALL_FAILED', 'The TV refused the package [' + failed[1] + ']' + (failed[2] ? ': ' + failed[2].trim() : ''))); - cb(null, { reply: out }); + var tries = certs.order(loadCerts(), entry.packageId ? certByPackage()[entry.packageId] : null), tried = [], i = 0, mismatch = null; + if (!tries.length) { var none = coded('NO_CERT', 'No signing certificate available on this TV yet (#844 cert hand-over).'); log('install: ' + none.message); return cb(none); } + var ip = ownIp(); + if (!ip) return cb(new Error('could not determine this TV’s own IP')); + function finish(err, r) { try { fs.unlinkSync(inFile); } catch (e) {} try { fs.unlinkSync(outFile); } catch (e) {} cb(err, r); } + removeOld(entry, function () { (function attempt() { + var cert = tries[i++], started = Date.now(); + tried.push(cert); + signer.signFile(inFile, outFile, cert, function (err) { + if (err) { log('install: ' + err.message); return tried.length > 1 ? finish(err) : cb(err); } + var size = fs.statSync(outFile).size; + log('install: signed with ' + certLabel(cert) + ' in ' + Math.round((Date.now() - started) / 1000) + ' s, ' + size + ' bytes'); + connectSdb([ip, '127.0.0.1'], function (err, c) { + if (err) return finish(err); + var remote = STAGING + '/' + remoteName, fd; + try { fd = fs.openSync(outFile, 'r'); } catch (e) { c.end(); return finish(e); } + log('install: pushing to ' + remote); + var pushStarted = Date.now(); + c.push(remote, { size: size, read: function (off, len) { return zip.readAt(fd, off, len); } }, function (err) { + try { fs.closeSync(fd); } catch (e) {} + if (err) { c.end(); return finish(err); } + logv('timing: push took ' + (Date.now() - pushStarted) + ' ms'); + var installStarted = Date.now(); + c.shell('0 vd_appinstall ' + (entry.appId || entry.packageId || '') + ' ' + remote, function (err, out) { + c.end(); + if (err) return finish(err); + logv('timing: vd_appinstall took ' + (Date.now() - installStarted) + ' ms'); + log('install: vd_appinstall reply: ' + out.replace(/\s+/g, ' ').slice(0, 200)); + // sdbd answers the shell call either way; the installer's own words say whether it + // worked ("install failed[118, -12], reason: ..." on a refused package). + var failed = /install failed\[([^\]]*)\](?:,\s*reason:\s*([^\n]*))?/i.exec(out); + if (failed) { + var r = refusals.refusal(failed[1], failed[2], entry); + if (r.code === 'CERT_MISMATCH') mismatch = r; + if ((r.code === 'CERT_MISMATCH' || r.code === 'CERT_REJECTED') && i < tries.length) { + log('install: the TV refused the copy signed with ' + certLabel(cert) + (r.code === 'CERT_MISMATCH' ? ' (another certificate installed it)' : ' (' + (failed[2] || failed[1]).trim() + ')') + '; trying ' + certLabel(tries[i])); + return attempt(); + } + if (mismatch) r = { code: 'CERT_MISMATCH', message: mismatch.message + ' None of the ' + tried.length + ' certificate' + (tried.length === 1 ? '' : 's') + ' on this TV (' + tried.map(certLabel).join(', ') + ') is the one it was installed with: arm this TV from the phone or PC that installed it, or let TV-Store remove the old copy.' }; + return finish(coded(r.code, r.message)); + } + rememberCert(entry.packageId, cert.id); + finish(null, { reply: out, certificate: cert.name }); + }); }); }); }); - }); + })(); }); } // The signed catalog, fetched and verified, cached for 10 minutes (the workflow publishes every @@ -364,19 +429,36 @@ function withCatalog(cb, fresh) { }); } -// The signing certificate a head hands over once (see #844): author/distributor key+cert as PEM -// (not a .p12: Node has no PKCS#12 parser). Kept in memory and persisted best-effort. +// The signing certificates a head hands over (see #844): author/distributor key+cert as PEM (not +// a .p12: Node has no PKCS#12 parser), every profile the head holds since #44 (certs.js has the +// shape). Kept in memory and persisted best-effort. function loadCerts() { if (CERTS) return CERTS; for (var i = 0; i < CERT_DIRS.length; i++) { - var f = path.join(CERT_DIRS[i], CERT_NAME); - try { CERTS = JSON.parse(fs.readFileSync(f, 'utf8')); CERT_FILE = f; } catch (e) { continue; } - // Found in a worse place (0.3.3 kept it in $HOME or /tmp): move it up where it persists. - if (i > 0) saveCerts(CERTS); + var f = path.join(CERT_DIRS[i], CERT_NAME), raw; + try { raw = JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { continue; } + var store = certs.fromStored(raw); + if (!store) continue; + CERTS = store; CERT_FILE = f; + // Found in a worse place (0.3.3 kept it in $HOME or /tmp), or in the one-profile shape: write it back. + if (i > 0 || !Array.isArray(raw.certificates)) saveCerts(CERTS); return CERTS; } return null; } +// Which profile each package was last signed with here, so an update takes the same one first. +var CERT_BY_PKG = null, CERT_BY_PKG_NAME = '.a2s-tvstore-cert-by-package.json'; +function certByPackage() { + if (CERT_BY_PKG) return CERT_BY_PKG; + try { CERT_BY_PKG = JSON.parse(fs.readFileSync(path.join(DATA_DIR, CERT_BY_PKG_NAME), 'utf8')) || {}; } catch (e) { CERT_BY_PKG = {}; } + return CERT_BY_PKG; +} +function rememberCert(packageId, certId) { + if (!packageId || !certId || !DATA_DIR) return; + var map = certByPackage(); if (map[packageId] === certId) return; + map[packageId] = certId; + try { fs.writeFileSync(path.join(DATA_DIR, CERT_BY_PKG_NAME), JSON.stringify(map)); } catch (e) {} +} function saveCerts(b) { CERTS = b; CERT_ERROR = null; for (var i = 0; i < CERT_DIRS.length; i++) { @@ -385,7 +467,8 @@ function saveCerts(b) { catch (e) { CERT_ERROR = f + ': ' + e.message; log('cert: cannot write ' + CERT_ERROR); } } } -function haveCerts() { var c = loadCerts(); return !!(c && c.authorKeyPem && c.distributorKeyPem); } +function haveCerts() { var c = loadCerts(); return !!(c && c.certificates && c.certificates.length); } +function certLabel(c) { return '\u2018' + (c.name || c.id.slice(0, 12)) + '\u2019'; } http.createServer(function (req, res) { if (req.method === 'OPTIONS') { cors(res, 204, 'text/plain'); return res.end(); } @@ -402,6 +485,7 @@ http.createServer(function (req, res) { // certificate or start an install. if (!fromThisTv(req)) { cors(res, 403, 'text/plain'); return res.end('only this TV may use TV-Store'); } if (route === '/ping') return json(res, 200, { ok: true, service: 'tv-store', ownIp: ownIp(), logUrl: 'http://' + ownIp() + ':' + PORT + '/log.txt', signerReady: signer.ready, certReady: haveCerts(), + certCount: haveCerts() ? loadCerts().certificates.length : 0, certNames: certs.names(loadCerts()), certFile: CERT_FILE, certError: CERT_ERROR, node: process.version, verbose: VERBOSE }); if (route === '/settings' && req.method === 'POST') return body(req, function (err, b) { if (err || !b || typeof b.verbose !== 'boolean') return json(res, 400, { ok: false, error: 'need { verbose: true|false }' }); @@ -418,12 +502,15 @@ http.createServer(function (req, res) { }); if (route === '/catalog') return withCatalog(function (err, cat) { if (err) { log('catalog: failed: ' + err.message); return json(res, 502, { ok: false, error: err.message }); } cors(res, 200, 'application/json'); res.end(cat.payload); }); if (route === '/cert' && req.method === 'POST') return body(req, function (err, b) { - if (err || !b || !b.authorKeyPem || !b.authorCertPem || !b.distributorKeyPem || !b.distributorCertPem) { - log('cert: refused a handover that is not { authorKeyPem, authorCertPem, distributorKeyPem, distributorCertPem }'); - return json(res, 400, { ok: false, error: 'need { authorKeyPem, authorCertPem, distributorKeyPem, distributorCertPem }' }); + var incoming = err ? [] : certs.normalize(b); + if (!incoming.length) { + log('cert: refused a handover that is not { authorKeyPem, authorCertPem, distributorKeyPem, distributorCertPem } or { certificates: [...] }'); + return json(res, 400, { ok: false, error: 'need { authorKeyPem, authorCertPem, distributorKeyPem, distributorCertPem } or { certificates: [ ... ] }' }); } - log('cert: received signing certificate from a head'); saveCerts(b); - json(res, 200, { ok: true, certFile: CERT_FILE, certError: CERT_FILE ? null : CERT_ERROR }); + var store = certs.merge(loadCerts(), incoming); + log('cert: received ' + incoming.length + ' signing certificate(s) from a head (' + incoming.map(certLabel).join(', ') + '); ' + store.certificates.length + ' on this TV now'); + saveCerts(store); + json(res, 200, { ok: true, certFile: CERT_FILE, certError: CERT_FILE ? null : CERT_ERROR, certCount: store.certificates.length }); }); if (route === '/install' && req.method === 'POST') return body(req, function (err, b) { if (err || !b || !(b.url || b.upload)) return json(res, 400, { ok: false, error: 'need { url, file, appId } or { upload, token }' }); diff --git a/tizen-app/service/manifest.js b/tizen-app/service/manifest.js index 102ceb4..1a72b6a 100644 --- a/tizen-app/service/manifest.js +++ b/tizen-app/service/manifest.js @@ -1,6 +1,9 @@ /* What a package calls itself (#30): the ids vd_appinstall and the screens need, read from the * package's own manifest. A .wgt carries config.xml at its root (), * a .tpk carries tizen-manifest.xml ( with ). + * Also the Tizen version the package asks for (#47): required_version on , + * api-version on . A TV below it refuses the package with install failed[118, -19] + * (118019 on a 2018 set, #44), so the service checks before the push. * The catalog gives these for its own files; an uploaded file has only itself. Pure, file-based, * on the zip reader the signer uses. ES5 for the TV's Node 4.4.3. */ 'use strict'; @@ -17,15 +20,29 @@ function text(xml, tag) { return m ? m[1].trim() : ''; } -// The ids in a manifest's XML: { kind, appId, packageId, version, name }. Missing ones are ''. +// The ids in a manifest's XML: { kind, appId, packageId, version, name, requiredVersion }. Missing ones are ''. function parse(kind, xml) { if (kind === 'wgt') { var pkg = attr(xml, 'tizen:application', 'package'); - return { kind: kind, appId: attr(xml, 'tizen:application', 'id'), packageId: pkg, version: attr(xml, 'widget', 'version'), name: text(xml, 'name') }; + return { kind: kind, appId: attr(xml, 'tizen:application', 'id'), packageId: pkg, version: attr(xml, 'widget', 'version'), name: text(xml, 'name'), + requiredVersion: attr(xml, 'tizen:application', 'required_version') }; } var p = attr(xml, 'manifest', 'package'); var appId = attr(xml, 'ui-application', 'appid') || attr(xml, 'service-application', 'appid') || attr(xml, 'widget-application', 'appid') || attr(xml, 'watch-application', 'appid'); - return { kind: kind, appId: appId, packageId: p, version: attr(xml, 'manifest', 'version'), name: text(xml, 'label') }; + return { kind: kind, appId: appId, packageId: p, version: attr(xml, 'manifest', 'version'), name: text(xml, 'label'), + requiredVersion: attr(xml, 'manifest', 'api-version') }; +} + +// True when a package that asks for Tizen `required` ("6.0") cannot run on a TV at `tv` ("4.0"). +// Lenient like the heads: an unknown value on either side is no refusal, the TV has the last word. +function needsNewer(required, tv) { + var x = String(required || '').match(/\d+/g), y = String(tv || '').match(/\d+/g); + if (!x || !y) return false; + for (var i = 0; i < Math.max(x.length, y.length); i++) { + var p = parseInt(x[i] || '0', 10), q = parseInt(y[i] || '0', 10); + if (p !== q) return p > q; + } + return false; } // cb(err, ids) for the package at `file`; err when it is not a zip or has no manifest at its root. @@ -51,4 +68,4 @@ function read(file, cb) { }); } -module.exports = { read: read, parse: parse }; +module.exports = { read: read, parse: parse, needsNewer: needsNewer }; diff --git a/tizen-app/service/refusal.js b/tizen-app/service/refusal.js new file mode 100644 index 0000000..2830cbb --- /dev/null +++ b/tizen-app/service/refusal.js @@ -0,0 +1,46 @@ +/* What the TV's install refusal means, and what TV-Store can do about it (#44). Pure, so + * test/refusal.test.js covers every reading. ES5 for the TV's Node 4.4.3. + * + * sdbd answers "install failed[]" and, on sets newer than Tizen 4, ", reason: "; a 2018 + * set prints the code as six digits (118012 = [118, -12]) and no reason. + * [118, -12] the certificate check: with "same id and different certificate" (or, without a + * reason, when the page says the package is on the TV) the app was installed with + * another author certificate (from a phone or PC, or one since renewed), and the TV + * only updates an app with the certificate it came with: TV-Store can remove that + * copy and install again (CERT_MISMATCH). Otherwise the TV rejects the certificate + * itself: a set without internet during the install (it verifies online), a clock + * that is off, or a certificate past its dates (CERT_REJECTED). + * [118, -19] the installer's "parsing error" on the manifest: in practice a package that asks for + * a newer Tizen than the set runs (#44); [118, -4] is the newer sets' spelling of that, + * next to "load archive info fail" for a bad package id. + */ +'use strict'; + +// "4.0" out of what the page reports for this TV, '' when it reported nothing usable. +function tizenVersionOf(entry) { var m = /^\s*(\d+(?:\.\d+)?)/.exec(String((entry && entry.tizen) || '')); return m ? m[1] : ''; } + +function appName(entry) { return (entry && (entry.name || (entry.file && String(entry.file).replace(/\.(wgt|tpk)$/i, '')))) || 'This app'; } + +// -> { code, message }. code is CERT_MISMATCH, CERT_REJECTED or INSTALL_FAILED. +function refusal(code, reason, entry) { + entry = entry || {}; + var c = String(code || '').replace(/\s/g, ''), why = String(reason || '').trim(); + var m = 'The TV refused the package [' + code + ']' + (why ? ': ' + why : ''); + var tv = tizenVersionOf(entry), req = entry.requiredVersion || ''; + if (/^(118,-19|118019|118,-4|118004)$/.test(c)) { + if (req) return { code: 'INSTALL_FAILED', message: m + '. It asks for Tizen ' + req + ' or newer' + (tv ? ', and this TV runs Tizen ' + tv : '') + '.' }; + return { code: 'INSTALL_FAILED', message: m + '. The TV could not read the package, which usually means it asks for a newer Tizen than this set runs.' }; + } + if (/^(118,-12|118012)$/.test(c)) { + if (/same id and different certificate/i.test(why) || (!why && entry.installed)) + return { code: 'CERT_MISMATCH', message: appName(entry) + ' is already on this TV, signed with another certificate: it was installed from a phone or PC, or with a certificate that has since been renewed. The TV only updates an app with the certificate it came with.' }; + if (/not yet valid|not valid yet/i.test(why)) + return { code: 'CERT_REJECTED', message: m + '. The TV says the certificate is not valid yet, so its clock is behind: set the TV’s date and time (Settings, General, System Manager, Time) or wait a few minutes, then try again.' }; + if (/expired/i.test(why)) + return { code: 'CERT_REJECTED', message: m + '. The TV says the certificate has expired: either its clock is ahead, or the certificate is past its end date and the phone or PC has to arm this TV again.' }; + return { code: 'CERT_REJECTED', message: m + '. The TV rejected the signing certificate. Make sure the TV has internet during the install (it checks the certificate online) and that its date and time are right, then try again; if it keeps failing, arm this TV again from Apps2Samsung.' }; + } + return { code: 'INSTALL_FAILED', message: m }; +} + +module.exports = { refusal: refusal, tizenVersionOf: tizenVersionOf };