T-469: review round 24 (the inspector, for Codex): webOS's store gives roots only, the CA question only for a page

Codex's owed review of Tier 78, round 22 and Tier 83, run by the Cavekit inspector (the owner).

Tier 83 (REQUEST CHANGES):
- P1: Trust Always puts a site's own certificate in Certificate Manager's store, and a router's or
  NAS box's (openssl req -x509) also says cA=TRUE, so R4 would have made it a root for every site
  at the next start. R4 now takes only roots: self-issued, keyUsage allowing keyCertSign, no
  subjectAltName or extendedKeyUsage. The daemon tells the component (jihad-platform-cert) about
  every certificate its SSL dialog installs, and R4 never takes one (its SHA-256 kept in
  jihad.security.webos_certs.accepted). An intermediate is no longer taken either.
- P2: a hidden blob: frame reached the engine's CA import and raised the question over the page.
  The question now needs a one-use offer made by a CA certificate opened as the page itself (HTTP
  in _caLoad, data:/file: in the content policy), or it refuses without a word.
- P3: only frames (subdocument, object) are cancelled, not a script's fetch; the question also
  refuses a site's certificate that says cA=TRUE; the proxy-login marker is "[proxy] ", which no
  site's host can produce; webOS's proxy setting keeps its last plan (jihad.proxy.webos.last) when a
  read fails instead of going direct; card-selftest marks hook lines at or after the screenshot's
  second, across midnight. Matching removed roots by Subject+SPKI was tried and dropped: Mozilla
  removed old issues of roots it still trusts with the same name and key (GTS R1-R4, GlobalSign,
  Firmaprofesional).
Round 22 (APPROVE): card-picture updates have their own observer topic (a Persona id of "thumbs"
was ambiguous); the cost of making them on the engine's thread is written down.
Tier 78 (APPROVE): the gallery offers the bundled set too, so one removed there stays removed;
link-style buttons and the toast's Undo are 44 px tall; the Personas test server reads Carbon
Light's picture from its package.

Device (CE 3.1.0, all three on daemon 06e75a68): R4 trusted the same 5 roots; a router certificate
with an IP SAN skipped as siteLike, one without keyUsage as noCertSign, a CA with keyCertSign
trusted, then removed; Trust Once logged the site certificate as never to be taken and the session
serial was swept; a blob: CA frame refused with no dialog; the test CA link still asked ("Offered by
192.168.1.245:8896"); a router certificate link refused; proxy logins read "The proxy ..." (Enyo,
Mochi) and "[proxy] ..." (Mojo's sheet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcrVQFRMC2ANGDfVaRG9Ed
This commit is contained in:
NotAlexNoyleandClaude Opus 5.5 committed 2026-10-13 15:31:18 -04:00
1 parent cb7bd40cd4
commit cfbfbcf293
17 files changed
+279 -105

No files matched your search

+3 -3
View File
@@ -116,9 +116,9 @@ enyo.kind({
this.$.prompt.show();
},
showLogin: function(msg) {
// A proxy's login (a 407) comes as "proxy <host:port> - <realm>" (cavekit-proxies-certs.md R5).
var proxy = /^proxy /.test(String(msg || ""));
this.$.loginMsg.setContent(msg ? ((proxy ? "The proxy " + String(msg).slice(6) : "The server " + msg) +
// A proxy's login (a 407) comes as "[proxy] <host:port> - <realm>" (cavekit-proxies-certs.md R5).
var proxy = /^\[proxy\] /.test(String(msg || ""));
this.$.loginMsg.setContent(msg ? ((proxy ? "The proxy " + String(msg).slice(8) : "The server " + msg) +
" requires a username and password") : "Authentication required");
this.$.userInput.setValue("");
this.$.passInput.setValue("");
+3 -3
View File
@@ -358,11 +358,11 @@ enyo.kind({
},
showUserPasswordDialog: function(inSender, inMsg) {
this.$.loginDialog.validateComponents();
// A proxy's login (a 407) comes as "proxy <host:port> - <realm>" (cavekit-proxies-certs.md R5).
var proxy = /^proxy /.test(String(inMsg || ""));
// A proxy's login (a 407) comes as "[proxy] <host:port> - <realm>" (cavekit-proxies-certs.md R5).
var proxy = /^\[proxy\] /.test(String(inMsg || ""));
var msg = proxy ? $L("The proxy {$serverName} requires a username and password")
: $L("The server {$serverName} requires a username and password");
msg = enyo.macroize(msg, {serverName: proxy ? String(inMsg).slice(6) : inMsg});
msg = enyo.macroize(msg, {serverName: proxy ? String(inMsg).slice(8) : inMsg});
this.$.loginMessage.setContent(msg);
this.showPopup(this.$.loginDialog);
},
+5 -4
View File
@@ -7,7 +7,7 @@
# card-selftest.sh [mochi|enyo|mojo] run <scenario[:arg]> [target-url] [seconds] [shot.jpg]
# close the card, launch it on target-url with that
# scenario, wait, print the "[JIHAD-TEST]" lines (UTC
# times; those logged after the screenshot are marked)
# times; those logged at or after the screenshot are marked)
# and save a screenshot (portrait, already rotated); a
# target-url of "-" launches with none (the start page).
# The screenshot is taken `seconds` after the launch,
@@ -86,9 +86,10 @@ SHOT_AT=\$(date -u +%H:%M:%S)
luna-send -n 1 palm://com.palm.systemmanager/takeScreenShot '{"file":"/tmp/st.png"}' >/dev/null 2>&1; sleep 2
# /var/log/messages rotates within seconds on this device, so an offset taken before the launch
# is not reliable: take every line of THIS scenario from both generations and keep what follows
# its last "start". Each line keeps its UTC time, and a line logged after the screenshot says so:
# a picture taken before a step ran is not that step's result (T-465 was such a picture).
( zcat /var/log/messages.1.gz /var/log/messages.0.gz 2>/dev/null; cat /var/log/messages.0 /var/log/messages 2>/dev/null ) | grep 'JIHAD-TEST\] $SC: ' | sed 's/^[0-9-]*T\([0-9:]*\)[^ ]* .*\(JIHAD-[A-Z]*\]\)/\1 \2/; s/, file:.*//' | awk -v shot="\$SHOT_AT" '/: start\$/{buf=""} {m=""; if (substr(\$0,1,8) > shot) m="(after the screenshot) "; buf=buf m \$0 "\n"} END{printf "%s", buf}' | cut -c1-900
# its last "start". Each line keeps its UTC time, and a line logged in the screenshot's second or
# later says so (the capture itself takes about 2 s; midnight is allowed for): a picture taken
# before a step ran is not that step's result (T-465 was such a picture).
( zcat /var/log/messages.1.gz /var/log/messages.0.gz 2>/dev/null; cat /var/log/messages.0 /var/log/messages 2>/dev/null ) | grep 'JIHAD-TEST\] $SC: ' | sed 's/^[0-9-]*T\([0-9:]*\)[^ ]* .*\(JIHAD-[A-Z]*\]\)/\1 \2/; s/, file:.*//' | awk -v shot="\$SHOT_AT" 'function sod(t) { return substr(t,1,2)*3600 + substr(t,4,2)*60 + substr(t,7,2) } BEGIN{s=sod(shot)} /: start\$/{buf=""} {m=""; t=sod(\$0); if (t < s - 43200) t += 86400; if (t > s + 43200) t -= 86400; if (t >= s) m="(at or after the screenshot) "; buf=buf m \$0 "\n"} END{printf "%s", buf}' | cut -c1-900
echo "JIHAD-SHOT] screenshot taken at \$SHOT_AT UTC"
DEVEOF
put "$TMP/sf.sh" /tmp/mochi-sf.sh
+4 -2
View File
@@ -227,11 +227,13 @@ menulist .menulist-dropmarker .dropmarker-icon {
border-top: 7px solid currentColor;
}
/* A link look, but finger-sized: "More", "Undo" and "Restart now" are buttons of this class, and
"More" is the way into an add-on's page (inspector round 24). */
button.button-link {
-moz-appearance: none;
min-height: 0;
min-height: 44px;
margin: 0 4px;
padding: 0;
padding: 0 8px;
color: #8cc7ff;
background: transparent;
border: 0;
+1 -1
View File
@@ -74,5 +74,5 @@
#files button.file { display: block; width: 100%; margin: 0 0 8px 0; text-align: left; }
.status { display: flex; align-items: center; }
.status #status-text { flex: 1; }
.status #status-undo { flex: 0 0 auto; margin-left: 12px; min-height: 40px; }
.status #status-undo { flex: 0 0 auto; margin-left: 12px; min-height: 44px; }
.status[hidden] { display: none !important; }
+6 -2
View File
@@ -211,9 +211,13 @@ function setupMine() {
box.hidden = !box.hidden;
}, false);
// A removal finishes in the add-on manager a moment later; card pictures are made in the background.
let watcher = {observe: function (subject, topic, data) { if (data === "thumbs") { updatePictures(); } else { afterChange(); } }};
let watcher = {observe: function (subject, topic) { if (topic === "jihad-personas-thumbs") { updatePictures(); } else { afterChange(); } }};
Services.obs.addObserver(watcher, "jihad-personas-mine", false);
window.addEventListener("unload", function () { Services.obs.removeObserver(watcher, "jihad-personas-mine"); }, false);
Services.obs.addObserver(watcher, "jihad-personas-thumbs", false);
window.addEventListener("unload", function () {
Services.obs.removeObserver(watcher, "jihad-personas-mine");
Services.obs.removeObserver(watcher, "jihad-personas-thumbs");
}, false);
}
// The Personas that come with Jihad and are not installed (one removed can be had again here); the
+7 -2
View File
@@ -1070,7 +1070,7 @@ const PROXY_PREFS = ["network.proxy.type", "network.proxy.http", "network.proxy.
"network.proxy.ssl_port", "network.proxy.socks", "network.proxy.socks_port", "network.proxy.socks_version",
"network.proxy.socks_remote_dns", "network.proxy.no_proxies_on", "network.proxy.autoconfig_url",
"network.proxy.share_proxy_settings", "jihad.proxy.mode", "jihad.proxy.webone.host", "jihad.proxy.webone.port",
"jihad.proxy.relay.trust_ca"];
"jihad.proxy.relay.trust_ca", "jihad.proxy.webos.last"];
const PROXY_HOST_RE = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$|^\d{1,3}(?:\.\d{1,3}){3}$|^[0-9A-Fa-f]*:[0-9A-Fa-f:.]+$/;
function pInt(name, dflt) { try { return Services.prefs.getIntPref(name); } catch (e) { return dflt; } }
@@ -1452,7 +1452,12 @@ function buildCertsSection(group) {
if (k.stock) { parts.push(k.stock + " of HP's original roots"); }
if (k.removed) { parts.push(k.removed + " Mozilla removed"); }
if (k.expired) { parts.push(k.expired + " expired"); }
if (k.notCA) { parts.push(k.notCA + " site certificates (Jihad asks about those per site)"); }
let sites = (k.notCA || 0) + (k.siteLike || 0) + (k.accepted || 0);
if (sites) { parts.push(sites + " site certificates (Jihad asks about those per site)"); }
if (k.notRoot) { parts.push(k.notRoot + " intermediate certificates (only roots are taken)"); }
if (k.noCertSign) {
parts.push(k.noCertSign + " that do not say they sign certificates (to add such a CA, open its file in Jihad)");
}
if (k.already) { parts.push(k.already + " you added here as well"); }
if (k.relay) { parts.push("the Riverstone Relay's (see Advanced → Proxy)"); }
if (k.unreadable) { parts.push(k.unreadable + " unreadable files"); }
+10 -5
View File
@@ -2000,11 +2000,16 @@ std::string BrowserPageGoanna::emitLocationAndTitle() {
// Write-through to the platform store so the rest of the device sees
// the same decision. Absent or failing store = Goanna-only trust; it
// must never turn an accepted certificate into a failed load.
if (!certFile.empty()) {
jihad::certstore::Install(certFile.c_str(),
permanent ? jihad::certstore::kTrustPermanent
: jihad::certstore::kTrustSession,
this);
if (!certFile.empty() &&
jihad::certstore::Install(certFile.c_str(),
permanent ? jihad::certstore::kTrustPermanent
: jihad::certstore::kTrustSession,
this)) {
// The store now holds a site's own certificate, where webOS's other trusted ones
// are. components/jihadCerts.js reads that store for CAs (cavekit-proxies-certs R4)
// and must never take this one as a root (the inspector, round 24).
jihad::NotifyChromeGlobal("jihad-platform-cert",
jihad::certstore::PemOf(der.data(), der.size()).c_str());
}
// Retry the load that FAILED, not the current document. CurrentUri() is the last
// page that committed — when the certificate error came from a navigation away from
+5 -3
View File
@@ -197,14 +197,16 @@ JihadPrompter::PromptAuth(nsIChannel* aChannel, uint32_t, nsIAuthInformation* aA
if (flags & nsIAuthInformation::AUTH_PROXY) {
// A 407: the login is the proxy's, not the site's, and the channel's URI is the site
// (cavekit-proxies-certs.md R5). Name the proxy, so a site never seems to ask for it. The
// text starts "proxy ": the Enyo and Mochi shells then say "The proxy … requires a username
// and password" instead of "The server …"; Mojo's dialog is the system framework's own.
// text starts "[proxy] ", which no site's host:port can (a site named "proxy" could pass for
// "proxy "; the inspector, round 24): the Enyo and Mochi shells then say "The proxy …
// requires a username and password" instead of "The server …"; Mojo's dialog is the system
// framework's own and shows the text as it is.
nsCOMPtr<nsIProxiedChannel> proxied = do_QueryInterface(aChannel);
nsCOMPtr<nsIProxyInfo> pi;
if (proxied) proxied->GetProxyInfo(getter_AddRefs(pi));
nsAutoCString phost; int32_t pport = -1;
if (pi) { pi->GetHost(phost); pi->GetPort(&pport); }
text.AppendLiteral("proxy ");
text.AppendLiteral("[proxy] ");
if (!phost.IsEmpty()) {
hostPort = phost;
if (pport > 0) { hostPort.Append(':'); hostPort.AppendInt(pport); }
+5
View File
@@ -231,6 +231,11 @@ void ForgetCertPem(const std::string& path) {
if (!path.empty()) unlink(path.c_str());
}
std::string PemOf(const void* der, size_t derLen) {
if (!der || !derLen) return std::string();
return armourPem(static_cast<const unsigned char*>(der), derLen);
}
bool Install(const char* pemPath, Trust trust, const void* owner) {
if (!pemPath || !*pemPath) return false;
if (!loadApi()) return false;
+3
View File
@@ -65,6 +65,9 @@ std::string WriteCertPem(const void* der, size_t derLen);
// Delete a PEM written by WriteCertPem (no-op for an empty path).
void ForgetCertPem(const std::string& path);
// `der` armoured as PEM text, as WriteCertPem writes it ("" for no bytes).
std::string PemOf(const void* der, size_t derLen);
// What is wrong with the certificate, as the engine can describe it. `haveFlags`
// false means nothing but the nsresult is known.
struct Problem {
+167 -63
View File
@@ -14,7 +14,8 @@
* (or webOS CE) brought, and the CAs a user trusted in Certificate Manager. They are
* NSS temporary certificates: nothing is written to the profile, the stores are read again
* at every start, and the switch "jihad.security.webos_certs" takes the trust away at once.
* A site's own certificate found there is not taken: the engine never uses one as a root.
* Only roots are taken (rootShape): never a site's own certificate, which Trust Always puts
* in the same store, and never one Jihad's own SSL dialog put there.
*
* R2 The Riverstone Relay's CA, carried here and pinned by SHA-256. Trusted, the same way, only
* while "jihad.proxy.relay.trust_ca" is on AND the Relay is the proxy in use. Found in a
@@ -40,6 +41,10 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
const PREF_WEBOS = "jihad.security.webos_certs";
// The SHA-256s of the sites' own certificates Jihad's SSL dialog put into webOS's store (Trust
// Always and Trust Once install there too): R4 never takes one. Kept to the last MAX_ACCEPTED.
const PREF_ACCEPTED = "jihad.security.webos_certs.accepted";
const MAX_ACCEPTED = 200;
const PREF_RELAY_CA = "jihad.proxy.relay.trust_ca";
const RELAY_HOST = "proxy.riverstonerelay.org";
// The Riverstone Relay's CA (certs/riverstone-relay-ca.pem), pinned HERE, not only in the lists, so
@@ -146,12 +151,19 @@ function certsIn(file) {
return out;
}
// Is this certificate (its DER bytes) a v3 certificate whose basicConstraints says cA=TRUE?
// The engine's own test (CERT_IsCACert) also counts any self-signed v1 certificate as a CA, and
// mozilla::pkix accepts a v1 trust anchor, so a router's or a NAS box's own v1 certificate that a
// user once accepted in webOS would become a root able to vouch for any site (the inspector,
// 2026-10-13). Only the certificate's own word that it is a CA counts here.
function derIsV3CA(b) {
// What a certificate (its DER bytes) says about itself:
// ca a v3 certificate whose basicConstraints says cA=TRUE. The engine's own test
// (CERT_IsCACert) also counts any self-signed v1 certificate as a CA, and mozilla::pkix
// accepts a v1 trust anchor, so only the certificate's own word counts (the inspector,
// 2026-10-13).
// keyCertSign keyUsage is present and allows signing certificates.
// san, eku it names sites (subjectAltName) or limits its key to uses (extendedKeyUsage):
// a site's own certificate does; a root does not (the CA/Browser Forum's rules).
// self its issuer and subject are the same name: a root, not an intermediate.
// Malformed or v1/v2 DER says nothing: every fact false.
function derFacts(b) {
const NONE = {ca: false, keyCertSign: false, san: false, eku: false, self: false};
let f = {ca: false, keyCertSign: false, san: false, eku: false, self: false};
function tlv(i) {
if (i + 2 > b.length) { return null; }
let tag = b[i], len = b[i + 1], p = i + 2;
@@ -164,42 +176,76 @@ function derIsV3CA(b) {
if (p + len > b.length) { return null; }
return {tag: tag, start: p, end: p + len};
}
function basicConstraintsCA(exts) {
let seq = tlv(exts.start);
if (!seq || seq.tag !== 0x30) { return false; }
for (let j = seq.start; j < seq.end;) {
let ext = tlv(j);
if (!ext || ext.tag !== 0x30) { return false; }
let oid = tlv(ext.start);
if (!oid || oid.tag !== 0x06) { return false; }
if (oid.end - oid.start === 3 && b[oid.start] === 0x55 && b[oid.start + 1] === 0x1d && b[oid.start + 2] === 0x13) {
let v = tlv(oid.end);
if (v && v.tag === 0x01) { v = tlv(v.end); } // critical
if (!v || v.tag !== 0x04) { return false; } // extnValue
let bc = tlv(v.start);
if (!bc || bc.tag !== 0x30 || bc.start === bc.end) { return false; } // cA absent: FALSE
let ca = tlv(bc.start);
return !!ca && ca.tag === 0x01 && ca.end - ca.start === 1 && b[ca.start] !== 0;
}
j = ext.end;
}
return false;
function extIs(oid, last) { // 2.5.29.<last>: 55 1d <last>
return oid.end - oid.start === 3 && b[oid.start] === 0x55 && b[oid.start + 1] === 0x1d && b[oid.start + 2] === last;
}
let cert = tlv(0);
if (!cert || cert.tag !== 0x30) { return false; }
if (!cert || cert.tag !== 0x30) { return NONE; }
let tbs = tlv(cert.start);
if (!tbs || tbs.tag !== 0x30) { return false; }
if (!tbs || tbs.tag !== 0x30) { return NONE; }
let ver = tlv(tbs.start);
if (!ver || ver.tag !== 0xa0) { return false; } // v1: no version field
if (!ver || ver.tag !== 0xa0) { return NONE; } // v1: no version field
let n = tlv(ver.start);
if (!n || n.tag !== 0x02 || n.end - n.start !== 1 || b[n.start] !== 2) { return false; }
if (!n || n.tag !== 0x02 || n.end - n.start !== 1 || b[n.start] !== 2) { return NONE; }
// serial, signature, issuer, validity, subject, key, then [1] [2] [3] if present
let fields = [];
for (let i = ver.end; i < tbs.end;) {
let t = tlv(i);
if (!t) { return false; }
if (t.tag === 0xa3) { return basicConstraintsCA(t); }
if (!t) { return NONE; }
fields.push(t);
i = t.end;
}
return false;
if (fields.length < 6 || fields[2].tag !== 0x30 || fields[4].tag !== 0x30) { return NONE; }
let is = fields[2], su = fields[4];
f.self = is.end - is.start === su.end - su.start;
for (let k = 0; f.self && k < is.end - is.start; k++) { f.self = b[is.start + k] === b[su.start + k]; }
let exts = null;
fields.forEach(function (t) { if (t.tag === 0xa3) { exts = t; } });
if (!exts) { return f; }
let seq = tlv(exts.start);
if (!seq || seq.tag !== 0x30) { return NONE; }
for (let j = seq.start; j < seq.end;) {
let ext = tlv(j);
if (!ext || ext.tag !== 0x30) { return NONE; }
let oid = tlv(ext.start);
if (!oid || oid.tag !== 0x06) { return NONE; }
let v = tlv(oid.end);
if (v && v.tag === 0x01) { v = tlv(v.end); } // critical
if (!v || v.tag !== 0x04) { return NONE; } // extnValue
if (extIs(oid, 0x13)) { // basicConstraints
let bc = tlv(v.start);
if (!bc || bc.tag !== 0x30) { return NONE; }
let ca = bc.start < bc.end ? tlv(bc.start) : null; // cA absent: FALSE
f.ca = !!ca && ca.tag === 0x01 && ca.end - ca.start === 1 && b[ca.start] !== 0;
} else if (extIs(oid, 0x0f)) { // keyUsage: BIT STRING, keyCertSign is bit 5
let ku = tlv(v.start);
f.keyCertSign = !!ku && ku.tag === 0x03 && ku.end - ku.start >= 2 && (b[ku.start + 1] & 0x04) !== 0;
} else if (extIs(oid, 0x11)) {
f.san = true;
} else if (extIs(oid, 0x25)) {
f.eku = true;
}
j = ext.end;
}
return f;
}
function derIsV3CA(b) { return derFacts(b).ca; }
// Removed roots are matched by their own fingerprint, not by name and key: Mozilla removed old
// issues of roots it still trusts under the same name and key (GTS Root R1-R4, GlobalSign,
// Firmaprofesional; measured against CCADB and webOS CE's store, round 24), so that match would
// refuse current roots. A CA under a removed root is no root, and is refused as notRoot.
// What R4 may trust without asking: a root that says its key signs certificates and names no site.
// A site's own certificate a user accepted with Trust Always (here, in webOS's browser or in an app)
// goes into Certificate Manager's store, and the usual router or NAS one (openssl req -x509) also says
// cA=TRUE: taken as a root, its key could vouch for any site (the inspector, round 24).
function rootShape(f) {
if (!f.ca) { return "notCA"; }
if (!f.self) { return "notRoot"; }
if (f.san || f.eku) { return "siteLike"; }
// A CA made without keyUsage (openssl's default) cannot be told from a router's certificate:
// not taken silently. Opened in Jihad, it is asked about (R6).
if (!f.keyCertSign) { return "noCertSign"; }
return "";
}
function rawDER(cert) {
@@ -264,6 +310,7 @@ function JihadCerts() {
this._offer = null; // the last top-level CA certificate load: {host, at}
this._unreadLogged = {};
Services.obs.addObserver(this, "jihad-nss-ready", false);
Services.obs.addObserver(this, "jihad-platform-cert", false);
// A CA certificate opened as a page is the R6 question even when the server marks it as a
// download: WebOne sends its CA with "Content-Disposition: attachment", and an attachment
// skips every content listener, the engine's certificate import among them (nsURILoader's
@@ -287,6 +334,8 @@ JihadCerts.prototype = {
if (topic === "http-on-examine-response" || topic === "http-on-examine-cached-response" ||
topic === "http-on-examine-merged-response") {
this._caLoad(subject);
} else if (topic === "jihad-platform-cert") {
this._platformCert(String(data || ""));
} else if (topic === "jihad-nss-ready") {
this._ready = true;
this._applyWebos();
@@ -300,24 +349,26 @@ JihadCerts.prototype = {
}
},
// A CA certificate over HTTP. In a frame (or any load that is not the page itself) it is stopped:
// a hidden frame must never put "Trust ...?" over the page the user is looking at. The page
// itself: the offering host is remembered for the question, and a download marking is taken off.
// A CA certificate over HTTP. In a frame it is stopped: a hidden frame must never put "Trust ...?"
// over the page the user is looking at. A script's fetch or an image never reaches the question,
// so those are left alone (a router's page may fetch its CA to show it). The page itself: it may
// be asked about once (offer), and a download marking is taken off.
_caLoad: function (subject) {
let ch = subject.QueryInterface(Ci.nsIHttpChannel);
let type = "";
try { type = String(ch.contentType || "").toLowerCase(); } catch (e) { return; }
if (type !== "application/x-x509-ca-cert") { return; }
let top = false;
try { top = ch.loadInfo.externalContentPolicyType === Ci.nsIContentPolicy.TYPE_DOCUMENT; } catch (e) {}
if (!top) {
let kind = -1;
try { kind = ch.loadInfo.externalContentPolicyType; } catch (e) {}
if (kind === Ci.nsIContentPolicy.TYPE_SUBDOCUMENT || kind === Ci.nsIContentPolicy.TYPE_OBJECT) {
try { ch.cancel(Components.results.NS_BINDING_ABORTED); } catch (e) {}
log("a CA certificate in a frame or a sub-load was stopped (" + ch.URI.prePath.slice(0, 80) + ")");
log("a CA certificate in a frame was stopped (" + ch.URI.prePath.slice(0, 80) + ")");
return;
}
if (kind !== Ci.nsIContentPolicy.TYPE_DOCUMENT) { return; }
let host = "";
try { host = ch.URI.hostPort; } catch (e) {}
this._offer = {host: host, at: Date.now()};
this.offer(host);
let disp = "";
try { disp = ch.getResponseHeader("Content-Disposition"); } catch (e) { return; } // none: already inline
if (!/^\s*attachment/i.test(disp)) { return; }
@@ -325,12 +376,18 @@ JihadCerts.prototype = {
log("a CA certificate sent as a download is opened, so its question is asked (" + ch.URI.prePath.slice(0, 80) + ")");
},
// Who offered the certificate now being asked about: the page's host if one was opened in the
// last minute, otherwise a file or page on this device.
offeredBy: function () {
// The question may be asked only for a CA certificate opened as the page itself, once: an HTTP one
// offers here from _caLoad (with its host), a data: or file: one from JihadCertPolicy (no host).
// Anything else that reaches the engine's certificate import (a blob: frame, which no address
// check can see the type of) finds no offer and is refused (the inspector, round 24).
offer: function (host) {
this._offer = {host: String(host || ""), at: Date.now()};
},
// The offer, used up: null when there is none from the last 20 seconds.
takeOffer: function () {
let o = this._offer;
this._offer = null;
return (o && Date.now() - o.at < 60000 && o.host) ? o.host : "";
return (o && Date.now() - o.at < 20000) ? o : null;
},
lists: function () {
@@ -359,6 +416,30 @@ JihadCerts.prototype = {
},
// --- R4 ---
// The daemon's SSL dialog put a site's own certificate (PEM) into webOS's store: remember it, and
// if it is trusted from there now, stop at once.
_platformCert: function (pem) {
PEM_RE.lastIndex = 0;
let m = PEM_RE.exec(pem);
if (!m) { return; }
let fp = sha256Hex(base64Bytes(m[1].replace(/[^A-Za-z0-9+/=]/g, "")));
let list = this.acceptedList().filter(function (h) { return h !== fp; });
list.push(fp);
try { Services.prefs.setCharPref(PREF_ACCEPTED, list.slice(-MAX_ACCEPTED).join(",")); } catch (e) {}
let db = certDB(), was = this._webos.length;
this._webos = this._webos.filter(function (e) {
if (e.sha256 !== fp) { return true; }
try { db.setCertTrust(e.cert, Ci.nsIX509Cert.CA_CERT, 0); } catch (x) {}
return false;
});
if (this._webos.length !== was) { dropSessions(); }
log("a site's certificate went into webOS's store from the SSL dialog; never taken as a root (" + fp.slice(0, 16) + "...)");
},
acceptedList: function () {
return charPref(PREF_ACCEPTED).split(",").filter(function (h) { return /^[0-9a-f]{64}$/.test(h); });
},
_applyWebos: function () {
let on = boolPref(PREF_WEBOS, true);
if (!on) { this._dropWebos(); return; }
@@ -388,7 +469,8 @@ JihadCerts.prototype = {
_readWebos: function () {
let lists = this.lists();
let skipped = {stock: 0, removed: 0, builtin: 0, expired: 0, notCA: 0, already: 0, relay: 0, unreadable: 0};
let skipped = {stock: 0, removed: 0, builtin: 0, expired: 0, notCA: 0, notRoot: 0, siteLike: 0, noCertSign: 0,
accepted: 0, already: 0, relay: 0, unreadable: 0};
let stores = [], seen = {}, found = [];
if (lists.missing) { this._skipped = skipped; this._stores = stores; return; }
@@ -443,18 +525,24 @@ JihadCerts.prototype = {
});
}, this);
let db = certDB(), nowUs = Date.now() * 1000, trusted = [];
let db = certDB(), nowUs = Date.now() * 1000, trusted = [], accepted = {};
this.acceptedList().forEach(function (h) { accepted[h] = true; });
found.forEach(function (c) {
if (c.sha256 === RELAY_SHA256) { skipped.relay++; return; }
if (lists.stock[c.sha256]) { skipped.stock++; return; }
if (lists.removed[c.sha256]) { skipped.removed++; return; }
if (!derIsV3CA(c.der)) { skipped.notCA++; return; }
if (accepted[c.sha256]) { skipped.accepted++; return; }
let cert;
try { cert = db.constructX509FromBase64(c.b64); } catch (e) { skipped.unreadable++; return; }
try {
if (isRelayCert(cert) || isSquidWebosCert(cert)) { skipped.relay++; return; }
if (cert.isBuiltInRoot) { skipped.builtin++; return; }
if (cert.validity.notAfter < nowUs) { skipped.expired++; return; }
// Only a root: a v3 CA, its own issuer, whose key signs certificates and which names no
// site. An intermediate (a CA under a root Mozilla removed, say) or a site's own
// certificate is never made an anchor here.
let shape = rootShape(derFacts(c.der));
if (shape) { skipped[shape]++; return; }
if (cert.certType !== Ci.nsIX509Cert.CA_CERT) { skipped.notCA++; return; }
// A CA the user already trusts in Jihad's own database (R6) is theirs; leave it.
if (trustedNow(db, cert)) { skipped.already++; return; }
@@ -592,18 +680,27 @@ JihadCertDialogs.prototype = {
try { svc = Cc["@riverstonerelay.net/jihad/certs;1"].getService().wrappedJSObject; } catch (e) {}
let fp = normFp(cert.sha256Fingerprint);
let name = shownName(cert.commonName || cert.organization || cert.subjectName);
let from = svc ? svc.offeredBy() : "";
let offer = svc ? svc.takeOffer() : null;
if (!offer) {
// Not opened as a page: a frame or something else a page did. Nothing is asked or shown.
log("refused a CA certificate that was not opened as a page (" + fp.slice(0, 16) + "...)");
return false;
}
let from = offer.host;
if (isRelayCert(cert)) {
p.alert("Certificate", "This is the Riverstone Relay's certificate. Jihad trusts it only through " +
"Settings \u2192 Advanced \u2192 Proxy, while the Relay is in use, and only if you turn that on.");
log("refused the Relay's certificate offered by a link");
return false;
}
// The engine also lets a self-signed v1 site certificate through as a "CA" (CERT_IsCACert).
if (!derIsV3CA(rawDER(cert))) {
// The engine also lets a self-signed v1 site certificate through as a "CA" (CERT_IsCACert); and a
// site's own certificate that also says cA=TRUE (a router's, from openssl req -x509) names the
// site it is for. Neither is a certificate authority's.
let facts = derFacts(rawDER(cert));
if (!facts.ca || facts.san || facts.eku) {
p.alert("Certificate", "\"" + name + "\" is not a certificate authority's certificate, so Jihad does not " +
"take it as one. A site's own certificate is accepted when the site is opened.");
log("refused a certificate that is not a v3 CA (" + fp.slice(0, 16) + "...)");
log("refused a certificate that is not a CA's (" + fp.slice(0, 16) + "...)");
return false;
}
let lists = svc ? svc.lists() : null;
@@ -638,10 +735,11 @@ JihadCertDialogs.prototype = {
viewCert: function (ctx, cert) {}
};
// --- R6: a certificate file in a frame never reaches the question -----------------------------------
// An HTTP one is stopped by JihadCerts._caLoad. A data: or file: frame is not an HTTP load, so it is
// refused here, by its address, before it loads (a page could otherwise raise the question from a
// hidden frame with a data: address carrying a CA certificate).
// --- R6: which certificate files may be asked about -------------------------------------------------
// An HTTP one is sorted by JihadCerts._caLoad. A data: or file: one is not an HTTP load, so it is
// sorted here, by its address, before it loads: in a frame it is refused (a page could otherwise
// raise the question from a hidden frame with a data: address carrying a CA certificate); opened as
// the page itself it may be asked about once. Anything else (a blob: frame) never has an offer.
function JihadCertPolicy() {}
JihadCertPolicy.prototype = {
@@ -649,19 +747,25 @@ JihadCertPolicy.prototype = {
QueryInterface: XPCOMUtils.generateQI([Ci.nsIContentPolicy]),
shouldLoad: function (type, location) {
if (type !== Ci.nsIContentPolicy.TYPE_SUBDOCUMENT || !location) { return Ci.nsIContentPolicy.ACCEPT; }
let refuse = false;
let frame = type === Ci.nsIContentPolicy.TYPE_SUBDOCUMENT, page = type === Ci.nsIContentPolicy.TYPE_DOCUMENT;
if ((!frame && !page) || !location) { return Ci.nsIContentPolicy.ACCEPT; }
let certFile = false;
try {
if (location.schemeIs("data")) {
refuse = /^data:\s*application\/(x-x509-ca-cert|pkix-cert)/i.test(location.spec);
certFile = /^data:\s*application\/(x-x509-ca-cert|pkix-cert)/i.test(location.spec);
} else if (location.schemeIs("file")) {
let ext = "";
try { ext = location.QueryInterface(Ci.nsIURL).fileExtension; } catch (e) {}
refuse = /^(crt|cer|der|pem)$/i.test(ext);
certFile = /^(crt|cer|der|pem)$/i.test(ext);
}
} catch (e) {}
if (refuse) { log("a certificate file in a frame was not loaded (" + location.scheme + ":)"); }
return refuse ? Ci.nsIContentPolicy.REJECT_REQUEST : Ci.nsIContentPolicy.ACCEPT;
if (!certFile) { return Ci.nsIContentPolicy.ACCEPT; }
if (page) {
try { Cc["@riverstonerelay.net/jihad/certs;1"].getService().wrappedJSObject.offer(""); } catch (e) {}
return Ci.nsIContentPolicy.ACCEPT;
}
log("a certificate file in a frame was not loaded (" + location.scheme + ":)");
return Ci.nsIContentPolicy.REJECT_REQUEST;
},
shouldProcess: function () { return Ci.nsIContentPolicy.ACCEPT; }
+21 -9
View File
@@ -344,8 +344,11 @@ function thumbFor(t, keep) {
}
// Copies are made one at a time, THUMB_GAP_MS apart, so the gallery stays responsive while the first
// openings make them; when the queue is done, an open gallery puts them on the cards ("thumbs": only
// the pictures change, no card is drawn again).
// openings make them; when the queue is done, an open gallery puts them on the cards
// ("jihad-personas-thumbs": only the pictures change, no card is drawn again). Each copy is a decode
// and an encode on the engine's own thread (200-600 ms), so every page waits that long once per
// copy while the queue runs: once per Persona, the first time after it is installed or the bundle
// changes (the inspector, round 24).
const THUMB_GAP_MS = 700;
let thumbQueue = [], thumbTimer = null, thumbsMade = 0;
function queueThumb(f, name) {
@@ -365,7 +368,9 @@ function nextThumb() {
}
if (!thumbQueue.length && thumbsMade) {
thumbsMade = 0;
try { Services.obs.notifyObservers(null, "jihad-personas-mine", "thumbs"); } catch (e) {}
// Its own topic: a Persona's id comes from the site that offered it, so "jihad-personas-mine"
// with data "thumbs" could have been a Persona of that id being removed (the inspector, round 24).
try { Services.obs.notifyObservers(null, "jihad-personas-thumbs", ""); } catch (e) {}
}
}
function makeThumb(f, name) {
@@ -865,10 +870,10 @@ function handle(req) {
// should be shown for all personas", "default version 1.0");
// - a picture made as a wallpaper (taller than a toolbar by far) is fitted, not cropped to its
// corner (owner: "blue nebula looks like it isnt working properly");
// - a link to the gallery (about:personas), and Install from file — the .xpi files already on the
// device (R4);
// - the Personas that come with Jihad (a Frutiger Aero set, owner 2026-10-08: "add fruiter
// aero style themes") join the list once, as ordinary rows;
// - a link to the gallery (about:personas), where Install from file (the .xpi files already on the
// device, R4) and the online galleries are since Tier 78;
// - the Personas that come with Jihad (render/goanna/personas/: Free Palestine and Aurora Silk
// since Tier 78) join the list once, as ordinary rows;
// - a few sentences on what a persona is when there are none yet (R5).
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
// The gallery link opens the browser's own gallery (about:personas), unless the pref Basilisk uses
@@ -1138,8 +1143,9 @@ function xpiFilesOnDevice() {
return out;
}
// The Personas that come with the browser (render/goanna/personas/: a Frutiger Aero set drawn
// for this project), shipped in the bundle's personas/ folder beside components/. Wearing one
// The Personas that come with the browser (render/goanna/personas/: Free Palestine, from a CC BY
// 2.0 photograph, and Aurora Silk, drawn for this project; credited there), shipped in the bundle's
// personas/ folder beside components/. Wearing one
// installs it exactly as a downloaded one: its picture copied into the profile, named by its hash.
let bundledCache = null;
function bundledPersonas() {
@@ -1741,6 +1747,12 @@ function galleryRequest(bag) {
// The installed Personas' pictures are resource://jihad-themes/ addresses, which resolve only once
// that root is set: the gallery opened first after a start showed them blank (the owner, 2026-10-12).
try { ensureThemesResource(); } catch (e) {}
// The bundled set is offered (and a new edition's names remembered) here too, not only when
// about:addons opens: Settings → Browse Personas never opens it, so a bundled Persona removed in
// the gallery came back at the next about:addons (the inspector, round 24).
if (action === "list" || action === "mine") {
try { offerBundledOnce(); } catch (e) { log("bundled Personas: " + e); }
}
let get = function(k) { try { return bag.getPropertyAsAString(k); } catch (e) { return ""; } };
let reply = function(v) { bag.setPropertyAsAString("result", JSON.stringify(v)); };
let LWTM = manager(), cur = null, used = [];
+22 -4
View File
@@ -32,6 +32,11 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
const PREF_MODE = "jihad.proxy.mode";
// The last proxy webOS's setting was followed to (JSON). When a read fails, Jihad keeps following it:
// the daemon cannot ask until it is on the bus, which can take minutes after a start, and going
// direct meanwhile sent the pages of someone who chose webOS's proxy past it (the inspector,
// round 24). With nothing followed yet, a failed read goes direct as before.
const PREF_LAST = "jihad.proxy.webos.last";
function log(s) {
try { Services.console.logStringMessage("proxy: " + s); } catch (e) {}
@@ -110,13 +115,26 @@ JihadProxy.prototype = {
apply: function () {
let a = this._answer;
let p;
if (!a) {
p = {type: 0, said: "", error: "webOS's proxy setting has not been read yet."};
} else if (a.returnValue === false) {
p = {type: 0, error: "webOS's proxy setting could not be read (" + String(a.errorText || "no reason given") + "), so Jihad goes direct."};
if (!a || a.returnValue === false) {
let why = !a ? "webOS's proxy setting has not been read yet" :
"webOS's proxy setting could not be read (" + String(a.errorText || "no reason given") + ")";
let last = null;
try { last = JSON.parse(charPref(PREF_LAST) || "null"); } catch (e) { last = null; }
if (last && (last.type === 0 || last.type === 1 || last.type === 2 || last.type === 4)) {
p = {type: last.type, host: last.host, port: last.port, pac: last.pac, said: last.said || "",
error: why + "; Jihad keeps what it last followed."};
} else {
p = {type: 0, said: "", error: why + ", so Jihad goes direct."};
}
} else {
p = plan(pickEntry(a.proxyInfoList));
if (p.error) { p.type = 0; p.error += " Jihad goes direct."; }
else {
try {
Services.prefs.setCharPref(PREF_LAST, JSON.stringify({type: p.type, host: p.host, port: p.port, pac: p.pac,
said: p.said}));
} catch (e) {}
}
}
this._said = p.said || "";
this._error = p.error || "";
+10
View File
@@ -135,6 +135,16 @@
if (cfOpen) { cf.acceptClick(); } // the popup's own Accept button handler
else if (alOpen) { br.viewCall("acceptDialog", []); }
} catch (e) { log(name, "answering THREW " + e); }
} else if (st === "D") { // report the engine dialogs that are open, answering none
var db0 = app.$.browser, dal = db0.$.dialog, dcf = db0.$.confirmDialog, dss = db0.$.sslDialog;
// (the messages belong to the Browser, which declares them, not to their popups)
log(name, "dialogs: alert open=" + !!(dal && dal.isOpen) + (dal && dal.isOpen && db0.$.dialogMessage ? " [" + String(db0.$.dialogMessage.getContent()).slice(0, 140) + "]" : "") +
" confirm open=" + !!(dcf && dcf.isOpen) + (dcf && dcf.isOpen && db0.$.confirmMessage ? " [" + String(db0.$.confirmMessage.getContent()).slice(0, 160) + "]" : "") +
" ssl open=" + !!(dss && dss.isOpen));
} else if (st.charAt(0) === "V") { // VN: answer the SSL dialog as its button N does (1 Trust Always, 2 Trust Once, 0 Don't Trust)
var vs = app.$.browser.$.sslDialog;
log(name, "ssl dialog open=" + !!(vs && vs.isOpen) + "; answering " + st.slice(1));
if (vs && vs.isOpen) { vs.response = st.slice(1); vs.close(); }
} else if (st.charAt(0) === "W") { // WUSER:PASS: fill the open login dialog (a 401/407) and press OK,
// through the card's own fields and handler — what typing them and tapping OK does
var wu = st.slice(1).split(":"), wb = app.$.browser;
+2 -2
View File
@@ -3,8 +3,8 @@
- `carbon_light-2.0.xpi` — **Carbon Light by Jaxivo** as addons.mozilla.org serves it
(addons.mozilla.org/firefox/addon/carbon-light/, CC BY-SA 3.0; sha256
`01f5dcc11c0d0ce7383cc6563e345530919185da2443ff6a962af5c8b319c1d5`), for the static-theme
conversion path (`components/jihadPersonas.js`). Its picture is one of the Personas Jihad
bundles (`render/goanna/personas/carbon-light.png`); credits in `CREDITS.md`.
conversion path (`components/jihadPersonas.js`). `echo-server.py` serves its `header.png` straight
out of this package (Jihad no longer bundles Carbon Light); its credit is the line above.
- `persona-header.png` — drawn for the tests by this project.
- `echo-server.py` — the host test server: `/persona?v=carbon` serves a theme site that installs
Carbon Light as a lightweight theme (and `?v=1,2,…` test Personas with `persona-header.png`);
+5 -2
View File
@@ -33,8 +33,11 @@ class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
p = urllib.parse.urlparse(self.path).path
if p == "/carbon-light-header.png":
# Carbon Light by Jaxivo, CC BY-SA 3.0 — one of the Personas Jihad bundles (render/goanna/personas/).
data = open("../../personas/carbon-light.png","rb").read()
# Carbon Light by Jaxivo, CC BY-SA 3.0, its picture read out of the theme's own package beside
# this script (the bundle no longer carries it; the inspector, round 24).
import os, zipfile
with zipfile.ZipFile(os.path.join(os.path.dirname(os.path.abspath(__file__)), "carbon_light-2.0.xpi")) as z:
data = z.read("header.png")
self.send_response(200); self.send_header("Content-Type", "image/png"); self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data); return
if p.startswith("/themes/") and p.endswith(".xpi"):
# AMO static themes for the README gallery (downloaded to the scratch themes dir).