screentinker/server/test/apk-signature.test.js
screentinker 501ffb11c1
feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168)
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159.
2026-07-12 19:41:07 -05:00

69 lines
3.4 KiB
JavaScript

'use strict';
// #device-owner: the DER walk that pulls the signing cert out of an APK's PKCS#7 v1 block,
// so the provisioning QR checksum is computed from the served build (not hardcoded). Driven by a
// synthetic PKCS#7 SignedData (no APK needed) that exercises both short- and long-form DER lengths
// (real certs are >127 bytes -> long form). The end-to-end value against the real release APK is
// verified at deploy time; this guards the parser against regressions.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const { _firstCertFromPkcs7 } = require('../lib/apk-signature');
// Minimal DER TLV encoder (definite length, short + long form).
function der(tag, content) {
let len;
if (content.length < 0x80) {
len = Buffer.from([content.length]);
} else {
const bytes = [];
let n = content.length;
while (n > 0) { bytes.unshift(n & 0xff); n >>= 8; }
len = Buffer.from([0x80 | bytes.length, ...bytes]);
}
return Buffer.concat([Buffer.from([tag]), len, content]);
}
function buildPkcs7(certContent1, certContent2) {
const cert1 = der(0x30, certContent1); // first X.509 cert (SEQUENCE)
const cert2 = der(0x30, certContent2); // a second cert we must NOT pick
const certs = der(0xa0, Buffer.concat([cert1, cert2]));// [0] IMPLICIT certificates
const signedData = der(0x30, Buffer.concat([
der(0x02, Buffer.from([0x01])), // version
der(0x31, Buffer.alloc(0)), // digestAlgorithms SET (empty)
der(0x30, Buffer.alloc(0)), // encapContentInfo (empty)
certs,
]));
const oid = der(0x06, Buffer.from([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02])); // signedData OID
const contentInfo = der(0x30, Buffer.concat([oid, der(0xa0, signedData)]));
return { pkcs7: contentInfo, cert1 };
}
test('firstCertFromPkcs7 extracts the FIRST cert, exact bytes (short-form lengths)', () => {
const { pkcs7, cert1 } = buildPkcs7(Buffer.from([0x02, 0x01, 0x2a]), Buffer.from([0x02, 0x01, 0x2b]));
const got = _firstCertFromPkcs7(pkcs7);
assert.ok(got, 'a cert was found');
assert.deepEqual(Buffer.from(got), cert1, 'returns the full DER of cert1, not cert2 or a slice');
});
test('firstCertFromPkcs7 handles long-form lengths (real certs are >127 bytes)', () => {
const big1 = Buffer.alloc(300, 0xaa); // forces long-form length at cert + every wrapper
const big2 = Buffer.alloc(280, 0xbb);
const { pkcs7, cert1 } = buildPkcs7(big1, big2);
const got = Buffer.from(_firstCertFromPkcs7(pkcs7));
assert.deepEqual(got, cert1);
// And the checksum is a stable base64url(no-pad) SHA-256 of exactly those cert bytes.
const expect = crypto.createHash('sha256').update(cert1).digest('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const actual = crypto.createHash('sha256').update(got).digest('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
assert.equal(actual, expect);
assert.match(actual, /^[A-Za-z0-9_-]{43}$/, 'SHA-256 base64url is 43 chars, url-safe, unpadded');
});
test('returns null on garbage / non-PKCS7 input (caller falls back, never emits a wrong QR)', () => {
assert.equal(_firstCertFromPkcs7(Buffer.from([0x30, 0x00])), null); // empty SEQUENCE
assert.equal(_firstCertFromPkcs7(Buffer.from([0x04, 0x01, 0x00])), null); // not a SEQUENCE-of-signedData
});