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
53 changes: 53 additions & 0 deletions .well-known/pgp-security.asc
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----

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
rcyCNmnF2BmeRfC4BZH01wauC3yhbk7zBfg=
=VrwE
-----END PGP PUBLIC KEY BLOCK-----
9 changes: 9 additions & 0 deletions .well-known/security.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Security contact for material-identity.eu and the dictionary it serves (RFC 9116).
# Please report anything that would let someone alter, forge, or make unavailable a
# published entry — those identifiers are cited by passports and cannot be recalled.

Contact: mailto:security@s1seven.com
Expires: 2027-09-01T00:00:00.000Z
Encryption: https://material-identity.eu/.well-known/pgp-security.asc
Preferred-Languages: en, de
Canonical: https://material-identity.eu/.well-known/security.txt
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ section for the full explanation).
`Link: …; rel="cite-as"` on both representations) — only
`/def/<uuid>`, no `/concept/` route; `worker/wrangler.toml` — route
`material-identity.eu/*`; never `wrangler deploy` locally
- `.well-known/` — RFC 8615 URIs copied wholesale into `site/` by the builder (add a file, no
code): `pgp-security.asc` (public key only — never a private one) and `security.txt`
(RFC 9116). The worker types them and caps their cache at a day. An unnumbered validate check
fails the build when `Expires` is missing, past, or over a year out — renew it in place, it is
not under `published/`
- `REVIEW.md` — what reviewers check beyond CI; read it before reviewing any publish PR
- `standards/` — local-only licensed docs; only its README is committed

Expand Down
5 changes: 4 additions & 1 deletion scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Build (plan §4 M3, redesigned per issue #57): repo model → site/ — canonical JSON +
// human HTML per entry, one stylesheet. Deterministic transform, no network, content
// never altered. Usage: npm run build [-- --root <dir>] [-- --out <dir>]
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { loadRepo } from './lib/repo.ts';
Expand All @@ -19,6 +19,8 @@ const LIB_DIR = dirname(fileURLToPath(import.meta.url));
// checks.ts uses for validation.
const SCHEMA_PATH = join(LIB_DIR, '..', 'schema', 'dictionary-entry.schema.json');
const CONTEXT_PATH = join(LIB_DIR, '..', 'rdf', 'context.jsonld');
// RFC 8615 well-known URIs, copied wholesale so adding e.g. a security.txt needs no code (#107).
const WELL_KNOWN_PATH = join(LIB_DIR, '..', '.well-known');

export interface BuildResult {
entries: number;
Expand Down Expand Up @@ -72,6 +74,7 @@ export function build(root: string, out: string): BuildResult {
// RDF track steps 1–2 (issue #98): the context is the semantic commitment, the Turtle is a
// derived second serialization. The canonical /def/<uuid>.json is untouched by both.
cpSync(CONTEXT_PATH, join(out, 'context.jsonld'));
if (existsSync(WELL_KNOWN_PATH)) cpSync(WELL_KNOWN_PATH, join(out, '.well-known'), { recursive: true });
const issued = new Map([...releases].map(([path, release]) => [path, release.date]));
writeFileSync(join(out, 'dictionary.ttl'), renderTurtle(repo, refs, issued));
return { entries, out };
Expand Down
60 changes: 59 additions & 1 deletion scripts/lib/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const Ajv2019 = (Ajv2019Module as unknown as { default?: typeof Ajv2019Module })
const addFormats = (addFormatsModule as unknown as { default?: typeof addFormatsModule }).default ?? addFormatsModule;

const SCHEMA_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'schema', 'dictionary-entry.schema.json');
/** RFC 9116 recommends an expiry under a year; past it, tooling treats the contact as stale. */
const SECURITY_TXT_MAX_MONTHS = 12;

/**
* Internal references that must be version-pinned to a published file (R5, plan §2.3).
Expand Down Expand Up @@ -180,6 +182,56 @@ export function checkPinning(repo: RepoModel): ValidationIssue[] {
return issues;
}

/**
* security.txt freshness (#109). An *expired* security.txt is worse than none — tooling reads
* it as an unmonitored contact — and a static file rots silently, so the ratchet (plan §2.7)
* turns "remember to renew it" into a check that refuses the next PR instead.
*/
export function checkSecurityTxt(root: string, now: Date = new Date()): ValidationIssue[] {
const check = 'security.txt';
const file = '.well-known/security.txt';
const path = join(root, file);
let text: string;
try {
text = readFileSync(path, 'utf8');
} catch {
return []; // a repo without one is fine (fixture trees have none); a stale one is not
}

const issues: ValidationIssue[] = [];
const field = (name: string): string | undefined =>
new RegExp(`^${name}:\\s*(.+)$`, 'mi').exec(text)?.[1].trim();

for (const required of ['Contact', 'Expires']) {
if (field(required) === undefined) issues.push({ check, file, message: `missing required field "${required}" (RFC 9116)` });
}

const expires = field('Expires');
if (expires !== undefined) {
const at = new Date(expires);
if (Number.isNaN(at.getTime())) {
issues.push({ check, file, message: `Expires "${expires}" is not a valid timestamp` });
} else if (at <= now) {
issues.push({ check, file, message: `Expires ${at.toISOString()} has passed — renew it; an expired security.txt reads as an unmonitored contact` });
} else {
const ceiling = new Date(now);
ceiling.setMonth(ceiling.getMonth() + SECURITY_TXT_MAX_MONTHS);
if (at > ceiling) issues.push({ check, file, message: `Expires ${at.toISOString()} is more than ${SECURITY_TXT_MAX_MONTHS} months out (RFC 9116 recommends less)` });
}
}

const encryption = field('Encryption');
if (encryption !== undefined && encryption.startsWith('https://material-identity.eu/')) {
const local = join(dirname(path), encryption.replace('https://material-identity.eu/.well-known/', ''));
try {
readFileSync(local);
} catch {
issues.push({ check, file, message: `Encryption points at ${encryption}, which is not present in .well-known/` });
}
}
return issues;
}

/** Check 1 — immutability (R6): only additions are allowed under published/. */
export function checkImmutability(diff: DiffEntry[]): ValidationIssue[] {
const issues: ValidationIssue[] = [];
Expand Down Expand Up @@ -256,7 +308,10 @@ export interface CheckResult {
skipped?: string;
}

/** Run all validate.ts checks (1–6). Checks 1 and 6 need a git context. Check 7 (two-yes gate) lives in CI only. */
/**
* Run all validate.ts checks (1–6, plus the unnumbered security.txt guard). Checks 1 and 6 need
* a git context. Check 7 (two-yes gate) lives in CI only.
*/
export function runChecks(repo: RepoModel, git?: GitContext): CheckResult[] {
const noGit = 'no git context (base unresolvable or root is not a work-tree top level)';
return [
Expand All @@ -271,5 +326,8 @@ export function runChecks(repo: RepoModel, git?: GitContext): CheckResult[] {
git
? { name: 'check 6 — move purity', issues: checkMovePurity(repo, git) }
: { name: 'check 6 — move purity', issues: [], skipped: noGit },
// Deliberately unnumbered: 1–6 are the entry rules and 7 is the CI two-yes gate. This one
// guards the repo's own security contact, not the dictionary (#109).
{ name: 'security.txt — RFC 9116 freshness', issues: checkSecurityTxt(repo.root) },
];
}
19 changes: 19 additions & 0 deletions test/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,25 @@ test('build publishes the JSON-LD context and the Turtle graph, and the entry JS
}
});

test('the tracked .well-known directory is copied into the site byte-for-byte (#107, #109)', () => {
const out = buildGreen();
try {
const source = readFileSync(join(here, '..', '.well-known', 'pgp-security.asc'));
assert.deepEqual(readFileSync(join(out, '.well-known', 'pgp-security.asc')), source);
// a public key block, never a private one
const text = source.toString('utf8');
assert.match(text, /^-----BEGIN PGP PUBLIC KEY BLOCK-----/);
assert.ok(!text.includes('PRIVATE KEY'), 'a private key must never be published');

// security.txt rides along on the same wholesale copy, no builder code of its own
const sec = readFileSync(join(out, '.well-known', 'security.txt'), 'utf8');
assert.deepEqual(sec, readFileSync(join(here, '..', '.well-known', 'security.txt'), 'utf8'));
assert.match(sec, /^Canonical: https:\/\/material-identity\.eu\/\.well-known\/security\.txt$/m);
} finally {
rmSync(out, { recursive: true, force: true });
}
});

test('index footer links to the tree view and the schema reference page', () => {
const out = buildGreen();
try {
Expand Down
82 changes: 80 additions & 2 deletions test/checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadRepo, type RepoModel, type ValidationIssue } from '../scripts/lib/repo.ts';
import { checkSchema, checkIdentity, checkReplaces, checkPinning, runChecks } from '../scripts/lib/checks.ts';
import { checkSchema, checkIdentity, checkReplaces, checkPinning, checkSecurityTxt, runChecks } from '../scripts/lib/checks.ts';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';

const fixtures = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');
const load = (name: string): RepoModel => loadRepo(join(fixtures, name));
Expand Down Expand Up @@ -88,7 +90,7 @@ test('check 5 — isDefinedBy and replaces are exempt from pinning', () => {

test('runChecks aggregates load errors and all checks 1–6', () => {
const results = runChecks(load('red-yaml'));
assert.equal(results.length, 7);
assert.equal(results.length, 8);
const loadResult = results.find((r) => r.name === 'load');
assert.ok(loadResult && loadResult.issues.length === 1);
const failing = results.filter((r) => r.issues.length > 0);
Expand All @@ -99,3 +101,79 @@ test('runChecks aggregates load errors and all checks 1–6', () => {
['check 1 — immutability', 'check 6 — move purity'],
);
});

// ------------------------------------------------------------------ security.txt (#109)

/** Build a throwaway repo root holding a .well-known/ with the given files. */
function wellKnownRoot(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), 'sectxt-'));
mkdirSync(join(root, '.well-known'));
for (const [name, body] of Object.entries(files)) writeFileSync(join(root, '.well-known', name), body);
return root;
}

function withRoot(files: Record<string, string>, fn: (root: string) => void): void {
const root = wellKnownRoot(files);
try {
fn(root);
} finally {
rmSync(root, { recursive: true, force: true });
}
}

const NOW = new Date('2026-09-14T00:00:00.000Z');
const GOOD = [
'# a comment line, ignored',
'Contact: mailto:security@s1seven.com',
'Expires: 2027-06-01T00:00:00.000Z',
'Encryption: https://material-identity.eu/.well-known/pgp-security.asc',
'',
].join('\n');

test('security.txt — the repo\'s own file is well-formed and unexpired today', () => {
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
assert.deepEqual(checkSecurityTxt(repoRoot, new Date()), []);
});

test('security.txt — a valid file with a resolvable Encryption key passes', () => {
withRoot({ 'security.txt': GOOD, 'pgp-security.asc': 'not a real key, only presence is checked' }, (root) => {
assert.deepEqual(checkSecurityTxt(root, NOW), []);
});
});

test('security.txt — absent is fine; fixture trees must not be forced to carry one', () => {
assert.deepEqual(checkSecurityTxt(join(fixtures, 'green'), NOW), []);
});

test('security.txt — an expired file fails, which is the whole point of the check', () => {
withRoot({ 'security.txt': GOOD.replace('2027-06-01', '2026-09-13'), 'pgp-security.asc': 'x' }, (root) => {
const issues = checkSecurityTxt(root, NOW);
assert.equal(issues.length, 1);
assert.match(issues[0].message, /has passed/);
assert.equal(issues[0].file, '.well-known/security.txt');
});
});

test('security.txt — an expiry beyond a year, an unparseable one, and missing fields all fail', () => {
withRoot({ 'security.txt': GOOD.replace('2027-06-01', '2028-01-01'), 'pgp-security.asc': 'x' }, (root) => {
assert.match(checkSecurityTxt(root, NOW)[0].message, /more than 12 months out/);
});
withRoot({ 'security.txt': 'Contact: mailto:a@b.c\nExpires: soon\n' }, (root) => {
assert.match(checkSecurityTxt(root, NOW)[0].message, /not a valid timestamp/);
});
withRoot({ 'security.txt': '# nothing but a comment\n' }, (root) => {
const messages = checkSecurityTxt(root, NOW).map((i) => i.message);
assert.deepEqual(messages, [
'missing required field "Contact" (RFC 9116)',
'missing required field "Expires" (RFC 9116)',
]);
});
});

test('security.txt — an Encryption URL pointing at a key we do not ship fails', () => {
withRoot({ 'security.txt': GOOD }, (root) => {
const issues = checkSecurityTxt(root, NOW);
assert.equal(issues.length, 1);
assert.match(issues[0].message, /pgp-security\.asc, which is not present/);
});
});
24 changes: 24 additions & 0 deletions test/index-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,30 @@ test('decide: JSON is the default; HTML only when Accept names text/html', () =>
assert.equal(browser.headers.link, `</def/${uuid}>; rel="canonical", <https://material-identity.eu/def/${uuid}>; rel="cite-as"`);
});

test('decide: well-known URIs are typed and cached for a day, never immutable (#107, #109)', () => {
const key = decide('/.well-known/pgp-security.asc', '*/*');
assert.equal(key.originPath, '/.well-known/pgp-security.asc');
assert.equal(key.headers['content-type'], 'application/pgp-keys');
// a key can be rotated or revoked, so it must never inherit an entry's immutable caching
assert.equal(key.headers['cache-control'], 'public, max-age=86400');
assert.ok(!key.headers['cache-control'].includes('immutable'));

// RFC 9116 §3 requires security.txt to be served as text/plain with a charset
const sec = decide('/.well-known/security.txt', '*/*');
assert.equal(sec.originPath, '/.well-known/security.txt');
assert.equal(sec.headers['content-type'], 'text/plain; charset=utf-8');
assert.equal(sec.headers['cache-control'], 'public, max-age=86400');

// anything else well-known gets the ceiling and the origin's own type
const other = decide('/.well-known/openpgpkey/hu/abc', '*/*');
assert.equal(other.headers['cache-control'], 'public, max-age=86400');
assert.equal(other.headers['content-type'], undefined);

// .asc / .txt anywhere else are not special-cased
assert.equal(decide('/def/whatever.asc', '*/*').headers['content-type'], undefined);
assert.equal(decide('/robots.txt', '*/*').headers['content-type'], undefined);
});

test('decide: everything else passes through with a short cache; no /concept route', () => {
const uuid = 'c38a85eb-1a37-416d-ab21-7ddcc599754d';

Expand Down
5 changes: 3 additions & 2 deletions test/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ test('runValidation reports OK for the green tree', () => {
const { ok, lines } = runValidation(join(fixtures, 'green'));
assert.equal(ok, true);
assert.equal(lines.filter((l) => l.startsWith('FAIL')).length, 0);
// load + checks 2–5 pass; checks 1 and 6 are skipped (fixture trees have no git context)
assert.equal(lines.filter((l) => l.startsWith('ok')).length, 5);
// load + checks 2–5 + the security.txt guard pass (a fixture tree carries none, which is
// green); checks 1 and 6 are skipped (fixture trees have no git context)
assert.equal(lines.filter((l) => l.startsWith('ok')).length, 6);
assert.equal(lines.filter((l) => l.startsWith('skip')).length, 2);
});

Expand Down
10 changes: 10 additions & 0 deletions worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ export function decide(pathname: string, accept: string | null): RouteDecision {
headers.link = wantsHtml ? `</def/${uuid}>; rel="canonical", ${citeAs}` : citeAs;
return { originPath: `/def/${uuid}.${wantsHtml ? 'html' : 'json'}`, headers };
}
// RFC 8615 well-known URIs (#107, #109). Cacheable but never immutable like an entry: a key can
// be rotated or revoked and security.txt expires, so a day is the ceiling. Pages would serve
// .asc as a generic byte stream and .txt without a charset, hence the explicit types.
if (pathname.startsWith('/.well-known/')) {
const headers: Record<string, string> = { 'cache-control': 'public, max-age=86400' };
if (pathname.endsWith('.asc')) headers['content-type'] = 'application/pgp-keys';
if (pathname.endsWith('.txt')) headers['content-type'] = 'text/plain; charset=utf-8'; // RFC 9116 §3
return { originPath: pathname, headers };
}

// index, pagination, styles, raw origin files: pass through with a short cache
return { originPath: pathname === '/' ? '/index.html' : pathname, headers: { 'cache-control': 'public, max-age=300' } };
}
Expand Down