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
63 changes: 61 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ Status: `0.3.0` release candidate for `htmltrust-c14n-v1`
Previous protocol release: `v0.2.2` (`79b0d52fecd958f8fc7ade713fe0799ca1e79626`)
Readers: binding users and contributors

## Standalone prerequisites

The Docker test path needs Git and Docker Engine with Compose. Running a
binding directly also needs the toolchain listed for that binding below.

## Test a fresh checkout

Docker is the shortest path to a complete result. This command installs each
Expand Down Expand Up @@ -54,12 +59,64 @@ node --input-type=module -e \
'import { normalizeText } from "./javascript/index.js"; console.log(normalizeText("A—B"))'
```

During the `0.3.0` review, another project can install the current main branch:
For a reproducible install in another project, use a published release tag or
a full SHA that the project has reviewed. Do not install a moving branch. To
resolve a reviewed tag before its SHA is known, inspect it and pin the result:

```sh
npm install github:HTMLTrust/htmltrust-canonicalization#main
CANON_URL=https://github.com/HTMLTrust/htmltrust-canonicalization.git
CANON_REF=REPLACE_WITH_REVIEWED_TAG
CANON_SHA="$(git ls-remote "$CANON_URL" "refs/tags/$CANON_REF" | awk 'NR==1 {print $1}')"
test "$CANON_SHA" && test "${#CANON_SHA}" -eq 40
npm install "github:HTMLTrust/htmltrust-canonicalization#$CANON_SHA"
```

Review the resolved commit before release. A full reviewed SHA can be assigned
directly to `CANON_SHA`.

#### Preflight a complete HTML document

The portable-authoring module finds every `<signed-section>` in a complete
document, resolves the final response URL and first `<base href>`, then
runs the v1 fragment checks for each region. It returns JSON with a pass/fail
status, source offsets, canonical content, claims, and stable diagnostic codes.

From a checkout:

```sh
npm ci
node javascript/bin/portable-authoring.js \
--url https://example.org/articles/example.html \
article.html
```

The command exits `0` when at least one signed region is present and every
region passes. It exits `1` when a region fails or no region is found. Base
URL problems include a warning. A malformed, `data:`, or `javascript:` first
base falls back to the final response URL. Other first-base values remain the
document base, so a relative signed URL resolved to HTTP fails the HTMLTrust
URL profile. Later base elements are ignored. The JSON `hint`,
`context`, and `location` fields identify the source change needed by an
authoring tool.

The same helper is available to JavaScript consumers:

```js
import {
preflightPortableDocument,
wrapSignedSection,
} from "@htmltrust/canonicalization/portable-authoring";

const result = preflightPortableDocument(html, {
documentURL: "https://example.org/articles/example.html",
});
const signedFragment = wrapSignedSection("<p>Ready to sign.</p>");
```

`wrapSignedSection` accepts a well-formed fragment and verifies that wrapping
preserves canonical content and claims. It rejects document containers and an
existing signed section, because those inputs need an author decision.

### Go

```sh
Expand Down Expand Up @@ -179,6 +236,8 @@ behavior.
Related repositories:

- [HTMLTrust specification](https://github.com/HTMLTrust/htmltrust-spec)
- [Hugo integration](../htmltrust-hugo/)
- [Study 1 reproduction harness](../htmltrust-study1/)
- [Reference server](https://github.com/HTMLTrust/htmltrust-server-reference)
- [Reference browser extension](https://github.com/HTMLTrust/htmltrust-browser-reference)
- [Reference CMS plugins](https://github.com/HTMLTrust/htmltrust-cms-reference)
Expand Down
90 changes: 90 additions & 0 deletions javascript/bin/portable-authoring.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env node

import { readFileSync } from "node:fs";
import {
preflightPortableDocument,
wrapSignedSection,
} from "../portable-authoring.js";

function usage() {
console.error("Usage: htmltrust-portable-preflight --url https://example.test/page.html [--wrap] [file|-]");
console.error(" Reads UTF-8 HTML from file, or stdin when file is omitted or '-'.");
}

const args = process.argv.slice(2);
let documentURL = null;
let inputPath = "-";
let wrap = false;

for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg === "--help" || arg === "-h") {
usage();
process.exit(0);
}
if (arg === "--wrap") {
wrap = true;
continue;
}
if (arg === "--url") {
documentURL = args[++index];
continue;
}
if (arg.startsWith("--")) {
usage();
process.exit(2);
}
if (inputPath !== "-") {
usage();
process.exit(2);
}
inputPath = arg;
}

if (!wrap && !documentURL) {
usage();
process.exit(2);
}

let html;
try {
html = inputPath === "-" ? readFileSync(0, "utf8") : readFileSync(inputPath, "utf8");
} catch (error) {
console.log(JSON.stringify({
ok: false,
diagnostics: [{
code: "input-read-failed",
severity: "error",
message: String(error?.message || error),
hint: "Check the input path and read permissions.",
region: null,
context: { inputPath },
}],
}));
process.exit(1);
}

if (wrap) {
try {
console.log(JSON.stringify({ ok: true, html: wrapSignedSection(html) }));
process.exit(0);
} catch (error) {
const code = error?.code || "conversion-ambiguous";
console.log(JSON.stringify({
ok: false,
diagnostics: [{
code,
severity: "error",
message: String(error?.message || code),
hint: "Use an unambiguous, well-formed fragment and preserve its original source.",
region: null,
context: error?.context || {},
}],
}));
process.exit(1);
}
}

const result = preflightPortableDocument(html, { documentURL });
console.log(JSON.stringify(result));
process.exit(result.ok ? 0 : 1);
18 changes: 16 additions & 2 deletions javascript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,30 @@
"description": "HTMLTrust canonical text normalization, signature verification, and key resolution for browsers and Node.js",
"type": "module",
"main": "index.js",
"scripts": {
"test": "node test.js",
"portable-preflight": "node bin/portable-authoring.js"
},
"engines": {
"node": ">=22"
},
"exports": {
".": "./index.js"
".": "./index.js",
"./portable-authoring": {
"types": "./portable-authoring.d.ts",
"import": "./portable-authoring.js"
}
},
"bin": {
"htmltrust-portable-preflight": "bin/portable-authoring.js"
},
"files": [
"index.js",
"index.d.ts",
"entities.js"
"entities.js",
"portable-authoring.js",
"portable-authoring.d.ts",
"bin/portable-authoring.js"
],
"keywords": [
"htmltrust",
Expand Down
52 changes: 52 additions & 0 deletions javascript/portable-authoring.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export interface PortableAuthoringLocation {
startOffset: number;
endOffset: number | null;
startLine: number;
startColumn: number;
endLine: number | null;
endColumn: number | null;
innerStartOffset: number;
innerEndOffset: number | null;
hasEndTag: boolean;
}

export interface PortableAuthoringDiagnostic {
code: string;
severity: "error" | "warning";
message: string;
hint: string;
region: number | null;
context: Record<string, unknown>;
}

export interface PortableAuthoringRegion {
index: number;
status: "pass" | "fail";
location: PortableAuthoringLocation | null;
baseURL?: string;
canonicalText?: string;
canonicalClaims?: string;
claims?: Record<string, string>;
diagnostics: PortableAuthoringDiagnostic[];
}

export interface PortableAuthoringResult {
profile: "htmltrust-portable-authoring-v1";
ok: boolean;
documentURL: string | null;
baseURL: string | null;
diagnostics: PortableAuthoringDiagnostic[];
regions: PortableAuthoringRegion[];
}

/** Preflight every signed-section in a complete HTML document. */
export function preflightPortableDocument(
html: string,
options: { documentURL: string },
): PortableAuthoringResult;

/** Wrap a well-formed fragment without changing its v1 canonical output. */
export function wrapSignedSection(
html: string,
options?: { baseUrl?: string },
): string;
Loading
Loading