Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 29 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,44 @@ on:
pull_request:
branches: [main]

# Least privilege: this workflow only reads the repo and uploads artifacts.
permissions:
contents: read

jobs:
build:
name: Build Extensions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"

- name: Configure private dep access
env:
TOKEN: ${{ secrets.HTMLTRUST_PKG_TOKEN }}
run: |
git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://git@github.com/"

# The package token used to be written to ~/.gitconfig, where every later
# step -- including any dependency lifecycle script -- could read it back.
# It is now passed through GIT_CONFIG_* environment variables, which git
# honours for this process tree only and never persists to disk, and
# --ignore-scripts keeps third-party install hooks from running at all
# while the token is in the environment. The webpack and eslint steps that
# follow run untrusted dependency code, but no longer with the token in
# reach.
#
# HTMLTRUST_PKG_TOKEN must be a fine-grained PAT scoped to the HTMLTrust
# package repositories with Contents: Read and nothing else. A classic
# `repo`-scoped token grants write access to every repo the owner can
# reach and must not be used here.
- name: Install dependencies
run: npm ci
env:
GIT_CONFIG_COUNT: "2"
GIT_CONFIG_KEY_0: url.https://x-access-token:${{ secrets.HTMLTRUST_PKG_TOKEN }}@github.com/.insteadOf
GIT_CONFIG_VALUE_0: https://github.com/
GIT_CONFIG_KEY_1: url.https://x-access-token:${{ secrets.HTMLTRUST_PKG_TOKEN }}@github.com/.insteadOf
GIT_CONFIG_VALUE_1: ssh://git@github.com/
run: npm ci --ignore-scripts

- name: Lint
run: npx eslint src/ --ext .ts,.tsx || true
Expand All @@ -41,17 +57,17 @@ jobs:
- name: Build Safari
run: npx webpack --mode=production --env target=safari

- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: extension-chromium
path: build/chromium/

- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: extension-firefox
path: build/firefox/

- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: extension-safari
path: build/safari/
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion src/assets/content.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@
background-color: #F44336;
}

/* Warning badge */
.cs-verification-badge-warning {
background-color: #FFC107;
color: #333;
}

/* Trust badges */
.cs-trust-badge {
color: white;
Expand Down Expand Up @@ -166,4 +172,4 @@
pointer-events: auto;
min-width: 120px;
text-align: center;
}
}
117 changes: 84 additions & 33 deletions src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {
verifySignedSection,
defaultResolverChain,
isPrivateHost,
} from "@htmltrust/browser-client";
import {
Settings,
Expand All @@ -14,6 +15,11 @@ import {
BatchedVotesPayload,
BatchVoteResult,
getTrustDirectoryUrls,
buildKeyidUrl,
requireCanonicalBase64,
requireContentHash,
requireTimestamp,
sanitizeClaims,
} from "../core/common";
import {
STORAGE_KEYS,
Expand Down Expand Up @@ -42,6 +48,32 @@ let contentProcessor: ContentProcessor;
let settings: Settings = DEFAULT_SETTINGS;
let contentSigningClient: ContentSigningClient | null = null;

function serializedOrigin(url: string): string {
return new URL(url).origin;
}

function createVerifierFetch(): typeof fetch {
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.protocol !== "https:") {
throw new Error("network-policy-blocked: verifier key and directory fetches require HTTPS");
}
// An extension's fetch is not bound by page CORS, so a keyid pointing at
// loopback, link-local, or RFC 1918 space would reach hosts the page never
// could. Refuse those outright.
if (isPrivateHost(url.hostname)) {
throw new Error("network-policy-blocked: verifier fetches may not target private hosts");
}
return fetch(input, {
...init,
credentials: "omit",
referrer: "",
referrerPolicy: "no-referrer",
redirect: "error",
});
};
}

/**
* Initialize the background script
*/
Expand Down Expand Up @@ -240,7 +272,7 @@ async function verifyContent(url: string): Promise<any> {
verified: false,
reason: "No signed-section found on this page",
verifiedAt: Date.now(),
domain: new URL(url).hostname,
domain: serializedOrigin(url),
trustStatus: "unknown",
};
} else {
Expand All @@ -249,11 +281,15 @@ async function verifyContent(url: string): Promise<any> {
// resolver chain is built from the user's configured directory list;
// empty list still works for did:web and direct-URL keyids.
const directories = getTrustDirectoryUrls(settings);
const resolverChain = defaultResolverChain({ directories });
const resolverChain = defaultResolverChain({
directories,
fetch: createVerifierFetch(),
});

const verify = await verifySignedSection(sectionHtml, {
keyResolvers: resolverChain,
domain: new URL(url).hostname,
domain: serializedOrigin(url),
debug: settings.developerDebugLogging === true,
});

// Best-effort author name lookup. The author DB is server-side and
Expand Down Expand Up @@ -283,7 +319,7 @@ async function verifyContent(url: string): Promise<any> {
verificationResult = {
verified: true,
verifiedAt: Date.now(),
domain: new URL(url).hostname,
domain: serializedOrigin(url),
user: {
id: userId,
name: userName,
Expand All @@ -298,7 +334,7 @@ async function verifyContent(url: string): Promise<any> {
verified: false,
reason: verify.reason || "Signature verification failed",
verifiedAt: Date.now(),
domain: new URL(url).hostname,
domain: serializedOrigin(url),
trustStatus: "untrusted",
};
}
Expand Down Expand Up @@ -394,7 +430,7 @@ async function signContent(
// Sign the content
const signature = await contentSigningClient.signContent(
extractedContent.contentHash,
new URL(url).hostname,
serializedOrigin(url),
claims,
);

Expand All @@ -412,7 +448,7 @@ async function signContent(
}
: undefined,
verifiedAt: Date.now(),
domain: new URL(url).hostname,
domain: serializedOrigin(url),
trustStatus: "trusted",
};

Expand All @@ -427,51 +463,66 @@ async function signContent(
// Update the badge
updateBadge();

// Inject the signature into the page as a <signed-section> element
// Inject the signature into the page as a <signed-section> element.
//
// Everything below the validation step comes from the trust server's
// response. It is passed to executeFunction as structured-cloned arguments,
// never interpolated into a script string, so a hostile or compromised
// server cannot get code to run in the page. The validation is a second
// line of defence and also keeps malformed signatures out of the DOM.
const activeServer = authService.getActiveServerConfig();
const serverUrl = activeServer ? activeServer.url.replace(/\/+$/, "") : "";
const claimsJson = JSON.stringify(signature.claims || {})
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'");
const signedAt = signature.createdAt || new Date().toISOString();
await platformAdapter.executeScript<void>(
const injection = {
signature: requireCanonicalBase64(signature.signature, "signature"),
keyid: buildKeyidUrl(serverUrl, signature.authorId),
contentHash: requireContentHash(signature.contentHash),
signedAt: requireTimestamp(signedAt, "createdAt"),
claims: sanitizeClaims(signature.claims),
};

await platformAdapter.executeFunction<[typeof injection], void>(
currentTab.id,
`
(() => {
(data) => {
// Remove any existing signature elements
document.querySelectorAll('signed-section[signature]').forEach(el => el.remove());
document
.querySelectorAll("signed-section[signature]")
.forEach((el) => el.remove());

// Find the main content element
const content = document.querySelector('article') || document.querySelector('main') || document.querySelector('.content') || document.body;
const content =
document.querySelector("article") ||
document.querySelector("main") ||
document.querySelector(".content") ||
document.body;

// Create a signed-section element with the signature
const signedSection = document.createElement('signed-section');
signedSection.setAttribute('signature', '${signature.signature}');
signedSection.setAttribute('keyid', '${serverUrl}/api/authors/${signature.authorId}/public-key');
signedSection.setAttribute('algorithm', 'ed25519');
signedSection.setAttribute('content-hash', '${signature.contentHash}');
const signedSection = document.createElement("signed-section");
signedSection.setAttribute("signature", data.signature);
signedSection.setAttribute("keyid", data.keyid);
signedSection.setAttribute("algorithm", "ed25519");
signedSection.setAttribute("content-hash", data.contentHash);

// Add timestamp meta
const timestampMeta = document.createElement('meta');
timestampMeta.setAttribute('name', 'signed-at');
timestampMeta.setAttribute('content', '${signedAt}');
const timestampMeta = document.createElement("meta");
timestampMeta.setAttribute("name", "signed-at");
timestampMeta.setAttribute("content", data.signedAt);
signedSection.appendChild(timestampMeta);

// Add claims meta tags
const claims = JSON.parse('${claimsJson}');
for (const [key, value] of Object.entries(claims)) {
const claimMeta = document.createElement('meta');
claimMeta.setAttribute('name', 'claim:' + key);
claimMeta.setAttribute('content', String(value));
for (const [key, value] of data.claims) {
const claimMeta = document.createElement("meta");
claimMeta.setAttribute("name", "claim:" + key);
claimMeta.setAttribute("content", value);
signedSection.appendChild(claimMeta);
}

signedSection.style.display = 'none';
signedSection.style.display = "none";

// Insert after the content
content.parentNode.insertBefore(signedSection, content.nextSibling);
})()
`,
content.parentNode?.insertBefore(signedSection, content.nextSibling);
},
[injection],
);

return {
Expand Down
Loading
Loading