mirror of
https://github.com/LamGC/quickly-conf-sshd.git
synced 2026-10-13 07:43:57 +00:00
feat: pin client GPG trust and publish signed Git/Gist key releases
This commit is contained in:
+142
-31
@@ -1,3 +1,5 @@
|
||||
import {parsePublishedSignedRelease, withoutKeyComments, SIGNED_BUNDLE_MAX_BYTES, SIGNATURE_SEPARATOR, SIGNED_RELEASE_LIMITS, validateListId} from './signed-release.js';
|
||||
|
||||
// Runtime fallback is deliberately stateless: a request never reuses earlier keys.
|
||||
export const KEY_SOURCE_LIMITS = Object.freeze({sources: 5, timeoutMs: 3000, maxBytes: 1024 * 1024, maxKeys: 1000});
|
||||
const encoder = new TextEncoder();
|
||||
@@ -133,15 +135,42 @@ export async function makeKeyBundle(keys, revision, allowEmpty) {
|
||||
if (!/^[0-9a-f]{40}$/.test(revision)) throw new Error('Release revision must be a 40-character Git commit SHA.');
|
||||
if (typeof allowEmpty !== 'boolean') throw new Error('allowEmpty must be an explicit boolean.');
|
||||
if (!keys.length && !allowEmpty) throw new Error('An empty key set requires explicit revocation.');
|
||||
const payload = keys.map(key => `${key.text}\n`).join('');
|
||||
const payload = withoutKeyComments(keys).map(key => `${key.text}\n`).join('');
|
||||
const digest = hex(await sha256(encoder.encode(payload)));
|
||||
const text = `# conf-sshd-v1\n# revision ${revision}\n# key-count ${keys.length}\n# allow-empty ${allowEmpty ? 'yes' : 'no'}\n# sha256 ${digest}\n# end-metadata\n${payload}`;
|
||||
if (encoder.encode(text).length > KEY_SOURCE_LIMITS.maxBytes) throw new SourceError('too-large');
|
||||
return {sha256: digest, text};
|
||||
}
|
||||
async function snapshot(keys, release, source) {
|
||||
const bundle = await makeKeyBundle(keys, release.revision, release.allowEmpty);
|
||||
return {keys, keysText: bundle.text, sha256: bundle.sha256, keyCount: keys.length, allowEmpty: release.allowEmpty, source, revision: release.revision, keyRevision: bundle.sha256, fetchedAt: new Date().toISOString()};
|
||||
async function snapshot(keys, release, source, signed = null) {
|
||||
if (!/^[0-9a-f]{40}$/.test(release.revision)) throw Error('Release revision must be a 40-character Git commit SHA.');
|
||||
keys = withoutKeyComments(keys);
|
||||
const allowEmpty = signed ? signed.metadata.revoke : release.allowEmpty;
|
||||
const bundle = signed ? {text: signed.document, sha256: hex(await sha256(encoder.encode(keys.map(key => key.text + '\n').join(''))))} : await makeKeyBundle(keys, release.revision, allowEmpty);
|
||||
return {keys, keysText: signed?.document ?? bundle.text, sha256: bundle.sha256, keyCount: keys.length, allowEmpty, source, revision: release.revision, keyRevision: signed ? signed.metadata.documentSha256 : bundle.sha256, fetchedAt: new Date().toISOString(), signedDocument: signed?.document ?? null, signature: signed?.signature ?? null, signatureAvailable: Boolean(signed?.signature), signedBundle: signed?.bundle ?? null, signedMetadata: signed?.metadata ?? null};
|
||||
}
|
||||
|
||||
export function validateKeySource(value = {type: 'git'}) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('keySource must be an object.');
|
||||
const allowed = value.type === 'git' ? ['type'] : ['type', 'id', 'ownerId', 'ownerLogin', 'file', 'signatureFile', 'bundleFile'];
|
||||
if (!['git', 'gist'].includes(value.type) || Object.keys(value).some(key => !allowed.includes(key))) throw Error('Invalid keySource configuration.');
|
||||
if (value.type === 'git') return {type: 'git'};
|
||||
if (typeof value.id !== 'string' || !/^[0-9a-f]{5,64}$/.test(value.id)) throw Error('Gist source requires a fixed lowercase hexadecimal ID.');
|
||||
if (!Number.isSafeInteger(value.ownerId) || value.ownerId < 1) throw Error('Gist source requires a positive numeric ownerId.');
|
||||
if (typeof value.ownerLogin !== 'string' || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(value.ownerLogin)) throw Error('Gist source requires an ownerLogin display name.');
|
||||
const file = value.file ?? 'ssh.keys', signatureFile = value.signatureFile ?? `${file}.sig`, bundleFile = value.bundleFile ?? 'release.bundle';
|
||||
for (const name of [file, signatureFile, bundleFile]) if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)) throw Error('Gist filenames must be fixed plain filenames.');
|
||||
if (new Set([file, signatureFile, bundleFile]).size !== 3) throw Error('Gist key, signature, and bundle filenames must differ.');
|
||||
return {type: 'gist', id: value.id, ownerId: value.ownerId, ownerLogin: value.ownerLogin, file, signatureFile, bundleFile};
|
||||
}
|
||||
|
||||
export function validateSignedReleaseConfig(value) {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['listId', 'publicKeyFile', 'fingerprint'].includes(key))) throw Error('Invalid signedRelease configuration.');
|
||||
validateListId(value.listId);
|
||||
if (Boolean(value.publicKeyFile) !== Boolean(value.fingerprint)) throw Error('Build signature verification requires both publicKeyFile and fingerprint.');
|
||||
if (value.publicKeyFile !== undefined && (typeof value.publicKeyFile !== 'string' || !/^keys\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value.publicKeyFile))) throw Error('Signing public key must be a fixed file inside keys/.');
|
||||
if (value.fingerprint !== undefined && (typeof value.fingerprint !== 'string' || !/^[A-Fa-f0-9]{40}$/.test(value.fingerprint))) throw Error('Signing fingerprint must be the complete 40-character primary fingerprint.');
|
||||
return {listId: value.listId, ...(value.publicKeyFile ? {publicKeyFile: value.publicKeyFile, fingerprint: value.fingerprint.toUpperCase()} : {})};
|
||||
}
|
||||
function boundedOption(options, name) {
|
||||
const value = options[name] ?? KEY_SOURCE_LIMITS[name];
|
||||
@@ -181,43 +210,125 @@ async function readBody(response, signal, maxBytes) {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
async function fetchSource(url, fetchImpl, limits) {
|
||||
async function withDeadline(limits, operation, onTimeout) {
|
||||
const controller = new AbortController();
|
||||
let timer, response;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => { reject(new SourceError('timeout')); controller.abort(); }, limits.timeoutMs);
|
||||
let timer;
|
||||
const timeout = new Promise((resolve, reject) => {
|
||||
timer = setTimeout(() => { const recovery = onTimeout?.(); recovery === undefined ? reject(new SourceError('timeout')) : resolve(recovery); controller.abort(); }, limits.timeoutMs);
|
||||
});
|
||||
const operation = (async () => {
|
||||
try {
|
||||
response = await fetchImpl(url, {method: 'GET', redirect: 'manual', cache: 'no-store', headers: {'Accept': 'text/plain, application/octet-stream;q=0.9', 'Cache-Control': 'no-cache, no-store', 'Pragma': 'no-cache'}, signal: controller.signal});
|
||||
if (controller.signal.aborted) throw new SourceError('timeout');
|
||||
if (response.redirected || (response.status >= 300 && response.status < 400)) throw new SourceError('redirect', response.status);
|
||||
if (!response.ok) throw new SourceError('http-error', response.status);
|
||||
const body = await readBody(response, controller.signal, limits.maxBytes);
|
||||
let keys;
|
||||
try { keys = await parsePublicKeys(body, {maxKeys: limits.maxKeys, signal: controller.signal}); } catch { throw new SourceError('invalid-keys'); }
|
||||
if (!keys.length) throw new SourceError('empty');
|
||||
return keys;
|
||||
} finally {
|
||||
if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {});
|
||||
}
|
||||
})();
|
||||
try { return await Promise.race([operation, timeout]); }
|
||||
try { return await Promise.race([operation(controller.signal), timeout]); }
|
||||
finally { clearTimeout(timer); }
|
||||
}
|
||||
async function fetchText(url, fetchImpl, signal, maxBytes, github = false) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(url, {method: 'GET', redirect: 'manual', cache: 'no-store', headers: {'Accept': github ? 'application/vnd.github+json' : 'text/plain, application/octet-stream;q=0.9', 'Cache-Control': 'no-cache, no-store', 'Pragma': 'no-cache', ...(github ? {'User-Agent': 'conf-sshd-key-source', 'X-GitHub-Api-Version': '2022-11-28'} : {})}, signal});
|
||||
if (signal.aborted) throw new SourceError('timeout');
|
||||
if (response.redirected || (response.status >= 300 && response.status < 400)) throw new SourceError('redirect', response.status);
|
||||
if (response.status !== 200) throw new SourceError('http-error', response.status);
|
||||
return await readBody(response, signal, maxBytes);
|
||||
} finally {
|
||||
if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {});
|
||||
}
|
||||
}
|
||||
async function parseSourceText(body, release, limits, options, signal, signature = null) {
|
||||
if (options.requireSigned || body.startsWith('# conf-sshd-signed-v1\n')) {
|
||||
try {
|
||||
const separator = body.indexOf(SIGNATURE_SEPARATOR);
|
||||
const document = separator < 0 ? body : body.slice(0, separator);
|
||||
if (separator >= 0) signature = body.slice(separator + SIGNATURE_SEPARATOR.length);
|
||||
const signed = await parsePublishedSignedRelease(document, signature, {listId: release.signedRelease?.listId, now: options.now, checkFreshness: Boolean(options.requireSigned), requireSigned: options.requireSigned, signal});
|
||||
if (signed.keys.length > limits.maxKeys) throw Error('Too many keys.');
|
||||
return {keys: signed.keys, signed};
|
||||
} catch { throw new SourceError('invalid-signed-release'); }
|
||||
}
|
||||
let keys;
|
||||
try { keys = await parsePublicKeys(body, {maxKeys: limits.maxKeys, signal}); } catch { throw new SourceError('invalid-keys'); }
|
||||
if (!keys.length) throw new SourceError('empty');
|
||||
return {keys, signed: null};
|
||||
}
|
||||
async function fetchSource(url, fetchImpl, limits, release, options) {
|
||||
let withoutSignature;
|
||||
return withDeadline(limits, async signal => {
|
||||
const body = await fetchText(url, fetchImpl, signal, limits.maxBytes === KEY_SOURCE_LIMITS.maxBytes ? SIGNED_BUNDLE_MAX_BYTES : limits.maxBytes);
|
||||
if (body.startsWith('# conf-sshd-signed-v1\n') && !body.includes(SIGNATURE_SEPARATOR)) {
|
||||
// A detached pair never combines different sources. A publication race remains a client signature failure.
|
||||
withoutSignature = await parseSourceText(body, release, limits, {...options, requireSigned: false}, signal);
|
||||
let signature = null;
|
||||
try { signature = await fetchText(url + '.sig', fetchImpl, signal, SIGNED_RELEASE_LIMITS.signatureBytes); } catch { /* OFF remains usable when the optional signature is unavailable. */ }
|
||||
return parseSourceText(body, release, limits, options, signal.aborted ? undefined : signal, signature);
|
||||
}
|
||||
return parseSourceText(body, release, limits, options, signal);
|
||||
}, () => options.requireSigned ? undefined : withoutSignature);
|
||||
}
|
||||
async function fetchGist(source, fetchImpl, limits, release, options) {
|
||||
return withDeadline(limits, async signal => {
|
||||
// Both requests, streamed bodies and parsing share one deadline; never follow an upstream raw_url.
|
||||
const get = async suffix => {
|
||||
const body = await fetchText(`https://api.github.com/gists/${source.id}${suffix}`, fetchImpl, signal, 2 * SIGNED_BUNDLE_MAX_BYTES, true);
|
||||
let data;
|
||||
try { data = JSON.parse(body); } catch { throw new SourceError('invalid-gist'); }
|
||||
if (!data || data.id !== source.id || data.owner?.id !== source.ownerId) throw new SourceError('gist-owner-mismatch');
|
||||
if (data.public !== true || data.truncated === true || !data.files || typeof data.files !== 'object' || Array.isArray(data.files)) throw new SourceError('invalid-gist');
|
||||
return data;
|
||||
};
|
||||
const latest = await get('');
|
||||
const gistRevision = latest.history?.[0]?.version;
|
||||
if (typeof gistRevision !== 'string' || !/^[0-9a-f]{40}$/.test(gistRevision)) throw new SourceError('invalid-gist-revision');
|
||||
const pinned = await get(`/${gistRevision}`);
|
||||
const filename = Object.hasOwn(pinned.files, source.bundleFile) ? source.bundleFile : source.file;
|
||||
const file = pinned.files[filename];
|
||||
if (!file || file.filename !== filename || file.truncated !== false || typeof file.content !== 'string' || (file.encoding !== undefined && file.encoding !== 'utf-8')) throw new SourceError('invalid-gist-file');
|
||||
if (filename === source.bundleFile && (!file.content.startsWith('# conf-sshd-signed-v1\n') || !file.content.includes(SIGNATURE_SEPARATOR))) throw new SourceError('invalid-signed-release');
|
||||
const size = encoder.encode(file.content).length;
|
||||
const max = filename === source.bundleFile ? SIGNED_BUNDLE_MAX_BYTES : limits.maxBytes;
|
||||
if (!Number.isSafeInteger(file.size) || file.size !== size || size > max) throw new SourceError('too-large');
|
||||
// Optional signature is read only from this same immutable revision, never from latest/raw URLs.
|
||||
let signature = null;
|
||||
const signatureFile = pinned.files[source.signatureFile];
|
||||
if (signatureFile && signatureFile.filename === source.signatureFile && signatureFile.truncated === false && typeof signatureFile.content === 'string' && (signatureFile.encoding === undefined || signatureFile.encoding === 'utf-8') && Number.isSafeInteger(signatureFile.size) && signatureFile.size === encoder.encode(signatureFile.content).length && signatureFile.size <= SIGNED_RELEASE_LIMITS.signatureBytes) signature = signatureFile.content;
|
||||
const result = await parseSourceText(file.content, release, limits, options, signal, signature);
|
||||
return {...result, source: {type: 'gist', id: source.id, ownerId: source.ownerId, ownerLogin: source.ownerLogin, gistRevision, file: filename, url: `https://gist.github.com/${source.ownerLogin}/${source.id}/${gistRevision}`}};
|
||||
});
|
||||
}
|
||||
|
||||
/** Only release-configured URLs are accepted; callers must never derive release data from a request. */
|
||||
/** Only release-configured sources are accepted. Worker checks structure, never claims OpenPGP verification. */
|
||||
export async function resolveKeys(release, fetchImpl = globalThis.fetch, internalOptions = {}) {
|
||||
if (!release || !Array.isArray(release.keys) || typeof release.allowEmpty !== 'boolean') throw new Error('Invalid deployed key release.');
|
||||
if (release.keys.length) return snapshot(release.keys, release, {type: 'repository'});
|
||||
// An explicit empty release means revoke. It must never be repopulated from a fallback.
|
||||
if (release.allowEmpty) return snapshot([], release, {type: 'explicit-empty'});
|
||||
const sources = validateFallbackSources(release.fallbackSources);
|
||||
const keySource = validateKeySource(release.keySource);
|
||||
validateSignedReleaseConfig(release.signedRelease);
|
||||
if (internalOptions.requireSigned !== undefined && typeof internalOptions.requireSigned !== 'boolean') throw Error('requireSigned must be boolean.');
|
||||
const limits = {timeoutMs: boundedOption(internalOptions, 'timeoutMs'), maxBytes: boundedOption(internalOptions, 'maxBytes'), maxKeys: boundedOption(internalOptions, 'maxKeys')};
|
||||
const sources = validateFallbackSources(release.fallbackSources);
|
||||
const diagnostics = [];
|
||||
if (keySource.type === 'git') {
|
||||
if (release.signedDocument || release.signedBundle) {
|
||||
// A malformed signed primary must never downgrade to different unsigned repository keys.
|
||||
try {
|
||||
const result = await parseSourceText(release.signedDocument || release.signedBundle, release, limits, internalOptions, undefined, release.signature ?? null);
|
||||
return await snapshot(result.keys, release, {type: result.signed.metadata.revoke ? 'explicit-empty' : 'repository'}, result.signed);
|
||||
} catch { diagnostics.push({index: -1, code: 'invalid-signed-release'}); }
|
||||
} else if (!internalOptions.requireSigned) {
|
||||
if (release.keys.length) return snapshot(release.keys, release, {type: 'repository'});
|
||||
// An explicit empty release means revoke. It must never be repopulated from a fallback.
|
||||
if (release.allowEmpty) return snapshot([], release, {type: 'explicit-empty'});
|
||||
}
|
||||
// An explicit unsigned empty primary cannot be replaced by signed fallback keys either.
|
||||
if (!release.signedDocument && !release.signedBundle && release.allowEmpty && !release.keys.length) throw new FallbackUnavailableError([{index: -1, code: 'unsigned-revocation'}]);
|
||||
} else {
|
||||
try {
|
||||
const result = await fetchGist(keySource, fetchImpl, limits, release, internalOptions);
|
||||
return await snapshot(result.keys, release, result.source, result.signed);
|
||||
} catch (error) { diagnostics.push(diagnostic(error, -1)); }
|
||||
}
|
||||
for (const [index, url] of sources.entries()) {
|
||||
try { return await snapshot(await fetchSource(url, fetchImpl, limits), release, {type: 'fallback', url}); }
|
||||
catch (error) { diagnostics.push({index, code: error instanceof SourceError ? error.code : 'network-error', ...(error instanceof SourceError && error.status !== undefined ? {status: error.status} : {})}); }
|
||||
try {
|
||||
const result = await fetchSource(url, fetchImpl, limits, release, internalOptions);
|
||||
return await snapshot(result.keys, release, {type: 'fallback', url}, result.signed);
|
||||
} catch (error) { diagnostics.push(diagnostic(error, index)); }
|
||||
}
|
||||
throw new FallbackUnavailableError(diagnostics);
|
||||
}
|
||||
function diagnostic(error, index) {
|
||||
return {index, code: error instanceof SourceError ? error.code : 'network-error', ...(error instanceof SourceError && error.status !== undefined ? {status: error.status} : {})};
|
||||
}
|
||||
|
||||
+18
-5
@@ -23,10 +23,14 @@ export function createHandler(release, {fetchImpl = globalThis.fetch} = {}) {
|
||||
return new Response(request.method === 'HEAD' ? null : body, {status, headers});
|
||||
};
|
||||
if (url.pathname === '/script.sh') return respond(release.script, 'text/plain; charset=utf-8');
|
||||
if (url.pathname === '/public-key.gpg') {
|
||||
if (!release.publicCertificate) return respond('No public signing certificate is configured.\n', 'text/plain; charset=utf-8', 404);
|
||||
return respond(release.publicCertificate, 'application/pgp-keys');
|
||||
}
|
||||
let snapshot;
|
||||
if (['/ssh.keys','/release.json','/'].includes(url.pathname)) {
|
||||
if (['/ssh.keys','/ssh.keys.sig','/release.json','/'].includes(url.pathname)) {
|
||||
const accept = request.headers.get('Accept') || '';
|
||||
const legacyScript = url.pathname === '/' && !accept.toLowerCase().includes('text/html') && /(?:curl|libcurl)\//i.test(request.headers.get('User-Agent') || '');
|
||||
const legacyScript = url.pathname === '/' && !accept.toLowerCase().includes('text/html') && /(?:curl|libcurl|wget|httpie)\//i.test(request.headers.get('User-Agent') || '');
|
||||
if (!legacyScript) {
|
||||
try {
|
||||
snapshot = {...release, ...await resolveKeys(release, fetchImpl)};
|
||||
@@ -40,17 +44,26 @@ export function createHandler(release, {fetchImpl = globalThis.fetch} = {}) {
|
||||
}
|
||||
}
|
||||
if (url.pathname === '/ssh.keys') return respond(snapshot.keysText, 'text/plain; charset=utf-8');
|
||||
if (url.pathname === '/ssh.keys.sig') {
|
||||
// This detached signature covers every byte returned by /ssh.keys. Never
|
||||
// manufacture a signature for an unsigned source or transform signed data.
|
||||
if (!snapshot.signature || !snapshot.signedDocument || snapshot.keysText !== snapshot.signedDocument) {
|
||||
return respond('No detached signature is available for this key response.\n', 'text/plain; charset=utf-8', 404);
|
||||
}
|
||||
return respond(snapshot.signature, 'application/pgp-signature');
|
||||
}
|
||||
if (url.pathname === '/release.json') {
|
||||
return respond(JSON.stringify({schema:1,revision:snapshot.revision,publishedAt:snapshot.publishedAt,
|
||||
keyCount:snapshot.keyCount,allowEmpty:snapshot.allowEmpty,sha256:snapshot.sha256,keyRevision:snapshot.sha256,
|
||||
source:snapshot.source,fetchedAt:snapshot.fetchedAt,testFixture:Boolean(release.testFixture)})+'\n','application/json; charset=utf-8');
|
||||
keyCount:snapshot.keyCount,allowEmpty:snapshot.allowEmpty,sha256:snapshot.sha256,keyRevision:snapshot.keyRevision ?? snapshot.sha256,
|
||||
source:snapshot.source,fetchedAt:snapshot.fetchedAt,signatureAvailable:Boolean(snapshot.signature),certificateAvailable:Boolean(release.publicCertificate),
|
||||
signedMetadata:snapshot.signedMetadata ?? null,signatureVerifiedByWorker:false,testFixture:Boolean(release.testFixture)})+'\n','application/json; charset=utf-8');
|
||||
}
|
||||
if (url.pathname === '/') {
|
||||
headers.set('Vary', 'Accept, User-Agent');
|
||||
const accept = request.headers.get('Accept') || '';
|
||||
const agent = request.headers.get('User-Agent') || '';
|
||||
// Explicit HTML wins; keep historic curl | sh entry point as a compatibility alias.
|
||||
if (!accept.toLowerCase().includes('text/html') && /(?:curl|libcurl)\//i.test(agent)) {
|
||||
if (!accept.toLowerCase().includes('text/html') && /(?:curl|libcurl|wget|httpie)\//i.test(agent)) {
|
||||
return respond(release.script, 'text/plain; charset=utf-8');
|
||||
}
|
||||
if (!release.pageTemplate) return respond('Homepage unavailable.\n', 'text/plain; charset=utf-8', 503);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import {parsePublicKeys} from './key-source.js';
|
||||
|
||||
// Parsing is deliberately not OpenPGP verification. The enrolled client verifies these exact bytes.
|
||||
export const SIGNED_RELEASE_LIMITS = Object.freeze({documentBytes: 1024 * 1024, signatureBytes: 64 * 1024, publicKeyBytes: 1024 * 1024, maxKeys: 1000, maxSequence: 2147483647, clockSkewSeconds: 300});
|
||||
export const SIGNATURE_SEPARATOR = '# detached-signature\n';
|
||||
export const SIGNED_BUNDLE_MAX_BYTES = SIGNED_RELEASE_LIMITS.documentBytes + SIGNED_RELEASE_LIMITS.signatureBytes + SIGNATURE_SEPARATOR.length;
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = value => encoder.encode(value).length;
|
||||
const listIdPattern = /^[A-Za-z0-9._-]{1,64}$/;
|
||||
const decimal = /^(?:0|[1-9][0-9]{0,9})$/;
|
||||
|
||||
export function validateListId(value) {
|
||||
if (typeof value !== 'string' || value === '-' || !listIdPattern.test(value)) throw Error('Invalid signed release list ID.');
|
||||
return value;
|
||||
}
|
||||
export function withoutKeyComments(keys) {
|
||||
return keys.map((key, index) => ({...key, text: key.text.split(/[ \t]+/).slice(0, 2).join(' '), label: `Server key ${index + 1}`}));
|
||||
}
|
||||
export function validateSignature(signature) {
|
||||
if (typeof signature !== 'string' || bytes(signature) > SIGNED_RELEASE_LIMITS.signatureBytes || !signature.endsWith('\n') || /[^\x09\x0a\x20-\x7e]/.test(signature)) throw Error('Invalid or oversized detached signature.');
|
||||
const lines = signature.split('\n');
|
||||
if (lines.shift() !== '-----BEGIN PGP SIGNATURE-----' || lines.pop() !== '' || lines.pop() !== '-----END PGP SIGNATURE-----') throw Error('Expected one ASCII-armored PGP detached signature.');
|
||||
while (lines.length && /^[A-Za-z][A-Za-z0-9-]*: [\x20-\x7e]*$/.test(lines[0])) lines.shift();
|
||||
if (lines.shift() !== '') throw Error('Invalid PGP armor header.');
|
||||
if (lines.length && /^=[A-Za-z0-9+/]{4}$/.test(lines.at(-1))) lines.pop();
|
||||
if (!lines.length || lines.some((line, i) => !/^[A-Za-z0-9+/]{1,76}={0,2}$/.test(line) || (i !== lines.length - 1 && line.includes('=')))) throw Error('Invalid PGP armor body.');
|
||||
const payload = lines.join('');
|
||||
try { if (!atob(payload).length) throw Error(); } catch { throw Error('Invalid PGP armor encoding.'); }
|
||||
return signature;
|
||||
}
|
||||
|
||||
export async function parseSignedDocument(document, {listId, now = Math.floor(Date.now() / 1000), checkFreshness = true, signal} = {}) {
|
||||
if (typeof document !== 'string' || bytes(document) > SIGNED_RELEASE_LIMITS.documentBytes || !document.endsWith('\n')) throw Error('Invalid or oversized signed document.');
|
||||
const match = /^# conf-sshd-signed-v1\n# list-id ([A-Za-z0-9._-]{1,64})\n# sequence ([1-9][0-9]{0,9})\n# issued-at (0|[1-9][0-9]{0,9})\n# expires-at (0|[1-9][0-9]{0,9})\n# revoke (yes|no)\n# key-count (0|[1-9][0-9]{0,3})\n# end-metadata\n([\s\S]*)$/.exec(document);
|
||||
if (!match) throw Error('Invalid signed document metadata.');
|
||||
const [, parsedListId, sequenceText, issuedText, expiresText, revokeText, countText, body] = match;
|
||||
validateListId(parsedListId);
|
||||
const sequence = Number(sequenceText), issuedAt = Number(issuedText), expiresAt = Number(expiresText), keyCount = Number(countText), revoke = revokeText === 'yes';
|
||||
if (sequence > SIGNED_RELEASE_LIMITS.maxSequence || keyCount > SIGNED_RELEASE_LIMITS.maxKeys || expiresAt <= issuedAt || revoke !== (keyCount === 0)) throw Error('Inconsistent signed release policy.');
|
||||
if (listId !== undefined && parsedListId !== validateListId(listId)) throw Error('Signed release list ID mismatch.');
|
||||
if (checkFreshness && (!Number.isSafeInteger(now) || now < 0 || issuedAt > now + SIGNED_RELEASE_LIMITS.clockSkewSeconds || expiresAt <= now)) throw Error('Signed release is expired or not yet valid.');
|
||||
const keys = withoutKeyComments(await parsePublicKeys(body, {signal}));
|
||||
// Never normalize, strip comments, add padding, or alter a signed byte during verification/serving.
|
||||
if (keys.length !== keyCount || keys.map(key => key.text + '\n').join('') !== body) throw Error('Signed key body must be canonical and match its key count.');
|
||||
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(document)));
|
||||
const documentSha256 = Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
return {document, keys, metadata: {listId: parsedListId, sequence, issuedAt, expiresAt, revoke, keyCount, documentSha256}};
|
||||
}
|
||||
|
||||
export async function parseSignedBundle(bundle, options = {}) {
|
||||
if (typeof bundle !== 'string' || bytes(bundle) > SIGNED_BUNDLE_MAX_BYTES) throw Error('Invalid or oversized signed bundle.');
|
||||
const separator = bundle.indexOf(SIGNATURE_SEPARATOR);
|
||||
if (separator < 0 || bundle.indexOf(SIGNATURE_SEPARATOR, separator + SIGNATURE_SEPARATOR.length) !== -1) throw Error('Signed bundle must contain exactly one detached-signature separator.');
|
||||
const document = bundle.slice(0, separator), signature = bundle.slice(separator + SIGNATURE_SEPARATOR.length);
|
||||
validateSignature(signature);
|
||||
return {...await parseSignedDocument(document, options), signature, bundle};
|
||||
}
|
||||
|
||||
/** Prepare public bytes for an OFFLINE signer. This function never handles a private key. */
|
||||
export async function makeSignedDocument(input, {listId, sequence, issuedAt, expiresAt, revoke = false}) {
|
||||
validateListId(listId);
|
||||
for (const [name, value] of Object.entries({sequence, issuedAt, expiresAt})) if (!Number.isSafeInteger(value) || !decimal.test(String(value))) throw Error(`Invalid ${name}.`);
|
||||
if (typeof revoke !== 'boolean') throw Error('revoke must be an explicit boolean.');
|
||||
const keys = withoutKeyComments(await parsePublicKeys(input));
|
||||
const document = `# conf-sshd-signed-v1\n# list-id ${listId}\n# sequence ${sequence}\n# issued-at ${issuedAt}\n# expires-at ${expiresAt}\n# revoke ${revoke ? 'yes' : 'no'}\n# key-count ${keys.length}\n# end-metadata\n${keys.map(key => key.text + '\n').join('')}`;
|
||||
await parseSignedDocument(document, {checkFreshness: false});
|
||||
return document;
|
||||
}
|
||||
|
||||
/** A public endpoint may expose a document without a usable signature; OFF clients do not depend on it. */
|
||||
export async function parsePublishedSignedRelease(document, signature = null, options = {}) {
|
||||
const parsed = await parseSignedDocument(document, options);
|
||||
let acceptedSignature = null;
|
||||
try { if (signature !== null) acceptedSignature = validateSignature(signature); }
|
||||
catch { if (options.requireSigned) throw Error('Invalid detached signature.'); }
|
||||
if (options.requireSigned && acceptedSignature === null) throw Error('Missing detached signature.');
|
||||
return {...parsed, signature: acceptedSignature, bundle: acceptedSignature === null ? null : document + SIGNATURE_SEPARATOR + acceptedSignature};
|
||||
}
|
||||
+34
-22
@@ -1,46 +1,58 @@
|
||||
import {validateSignature} from './signed-release.js';
|
||||
|
||||
export const escapeHTML = value => String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
|
||||
export function commandsFor(config) {
|
||||
const fetch = `curl -fsSL '${config.baseUrl}/script.sh' | sh -s --`;
|
||||
const installed = 'sh "$HOME/.conf-sshd/conf-sshd.sh"';
|
||||
return [
|
||||
{id:'install',title:'安装并定时同步',description:`更新当前用户的公钥并设置定时同步;保留已有自定义计划,新安装计划为 ${config.defaultCron || '*/15 * * * *'}。`,command:`${fetch} -c`},
|
||||
{id:'once',title:'仅更新一次公钥',description:'更新为当前发布的公钥,不设置定时任务。',command:`${fetch} -o`},
|
||||
{id:'help',title:'查看帮助',description:'先了解全部参数与行为,不修改配置。',command:`${fetch} -h`},
|
||||
{id:'preview',title:'预览公钥更新',description:'校验当前发布并预览将要修改的文件,不应用更新。',command:`${fetch} -o --dry-run`},
|
||||
{id:'update',title:'单独更新已安装脚本',description:'显式更新客户端脚本;定时公钥同步不会更新可执行代码。',command:'sh "$HOME/.conf-sshd/conf-sshd.sh" -u',advanced:true},
|
||||
{id:'root-off',title:'禁止 root 使用密码登录',description:'仅 root 可执行。校验并修改 SSH 配置,不自动重载服务。',command:`${fetch} -p no`,advanced:true},
|
||||
{id:'root-on',title:'允许 root 使用密码登录',description:'仅 root 可执行,涉及登录安全;实际登录仍受其他 SSH 认证设置限制,不自动重载服务。',command:`${fetch} -p yes`,advanced:true},
|
||||
{id:'uninstall',title:'停止同步并卸载',description:'移除脚本及其定时任务,保留公钥与 SSH 配置。',command:'sh "$HOME/.conf-sshd/conf-sshd.sh" --uninstall',advanced:true},
|
||||
{id:'install',title:'安装或重新安装',description:`下载并安装客户端,再同步公钥与设置计划;发现旧版本时先备份并保留本地配置。保留已有计划,新安装计划为 ${config.defaultCron || '*/15 * * * *'}。首次安装或明确重装都会信任你执行的脚本,推荐先独立核验安装脚本;启用或更换信任须在服务器本地终端确认完整指纹。`,command:`${fetch} --install --download-script -c`},
|
||||
{id:'once',title:'同步一次公钥',description:'使用已安装的固定脚本与本地信任设置同步公钥,不更换脚本或定时计划。',command:`${installed} -o`},
|
||||
{id:'help',title:'查看帮助',description:'下载当前脚本并显示参数,不建立信任;执行前仍应检查下载内容。',command:`${fetch} -h`},
|
||||
{id:'preview',title:'预览公钥同步',description:'使用已安装的脚本与本地信任设置检查更新,预览将要修改的文件,不应用更新。',command:`${installed} -o --dry-run`},
|
||||
{id:'root-off',title:'禁止 root 使用密码登录',description:'仅 root 可执行。使用已安装的脚本校验并修改 SSH 配置,不自动重载服务。',command:`${installed} -p no`,advanced:true},
|
||||
{id:'root-on',title:'允许 root 使用密码登录',description:'仅 root 可执行,涉及登录安全;实际登录仍受其他 SSH 认证设置限制,不自动重载服务。',command:`${installed} -p yes`,advanced:true},
|
||||
{id:'uninstall',title:'停止同步并卸载',description:'移除脚本及其定时任务,保留公钥与 SSH 配置。',command:`${installed} --uninstall`,advanced:true},
|
||||
];
|
||||
}
|
||||
|
||||
function codeBlock(id, text, label) {
|
||||
return `<div class="code-block"><pre tabindex="0"><code id="${id}">${escapeHTML(text)}</code></pre><button type="button" class="copy-button" data-copy-target="${id}" aria-label="${escapeHTML(label)}" title="${escapeHTML(label)}"></button></div>`;
|
||||
}
|
||||
export function renderKeys(keys) {
|
||||
return keys.length ? keys.map((key,i) => `<article class="key-card"><div class="card-heading"><h3>${escapeHTML(key.label)}</h3><span class="key-type">${escapeHTML(key.type)}</span></div><p class="fingerprint">${escapeHTML(key.fingerprint)}</p>${codeBlock(`key-${i}`,key.text,`复制第 ${i + 1} 个公钥`)}</article>`).join('\n') : '<p class="empty-state">此版本明确撤销全部受管理公钥,手工管理的公钥仍保留。</p>';
|
||||
}
|
||||
export function renderCommands(commands) {
|
||||
const card = command => `<article class="command-card"><div class="card-heading"><h3>${escapeHTML(command.title)}</h3></div><p class="command-description">${escapeHTML(command.description)}</p>${codeBlock(`command-${command.id}`,command.command,`复制“${command.title}”命令`)}</article>`;
|
||||
return commands.filter(c=>!c.advanced).map(card).join('\n') + '<details class="advanced-commands"><summary>高级命令</summary><div class="command-list">' + commands.filter(c=>c.advanced).map(card).join('\n') + '</div></details>';
|
||||
}
|
||||
|
||||
|
||||
/** All visible keys/metadata and clipboard data come from one resolved response snapshot. */
|
||||
/** The homepage projects metadata only. Key material remains confined to the APIs. */
|
||||
export function renderPage(template, snapshot) {
|
||||
const {revision, publishedAt, keys = [], commands = [], testFixture, repositoryUrl, source = {type:'repository'}} = snapshot;
|
||||
const unavailable = source.type === 'unavailable' || source.type === 'pending';
|
||||
const sourceLabel = source.type === 'fallback' ? `备用源:${source.url}` : source.type === 'explicit-empty' ? '仓库清单(明确撤销)' : unavailable ? '等待可用公钥源' : '仓库清单';
|
||||
const sourceStatus = source.type === 'fallback' ? '备用源可用' : source.type === 'explicit-empty' ? '明确撤销' : unavailable ? '暂不可用' : '可用';
|
||||
const notice = unavailable ? '公钥源暂不可用,未发布撤销指令;现有服务器保留上次成功密钥。' : source.type === 'fallback' ? '当前使用按配置顺序选中的首个有效备用源;后续请求会重新获取。' : source.type === 'explicit-empty' ? '此版本明确撤销全部受管理公钥;手工公钥与已有会话不受影响。' : '';
|
||||
const publicData = {schema:1,revision,publishedAt,keyCount:keys.length,sha256:snapshot.sha256 || null,keyRevision:snapshot.sha256 || null,source,sourceStatus,keys,commands,baseUrl:snapshot.baseUrl,repositoryUrl,testFixture:Boolean(testFixture)};
|
||||
const revoked = source.type === 'explicit-empty' || (!unavailable && snapshot.allowEmpty === true && (snapshot.keyCount ?? keys.length) === 0);
|
||||
const sourceLabel = source.type === 'fallback' ? `备用源:${source.url}` : source.type === 'gist' ? `Gist:${source.url || '已配置来源'}` : source.type === 'git' ? `Git:${source.url || '已配置来源'}` : unavailable ? '等待可用公钥源' : 'Git 仓库清单';
|
||||
const sourceStatus = source.type === 'pending' ? '待获取' : source.type === 'unavailable' ? '暂不可用' : revoked ? '明确撤销' : source.type === 'fallback' ? '备用源可用' : '可用';
|
||||
const notice = unavailable ? '公钥源暂不可用,未发布撤销指令;现有服务器保留上次成功密钥。' : revoked ? '此发布明确撤销全部受管理公钥;客户端仍须按本地信任设置验证,手工公钥与已有会话不受影响。' : source.type === 'fallback' ? '当前使用按配置顺序选中的首个有效备用源;后续请求会重新获取,来源可用不表示客户端已验证。' : '此处仅展示来源与发布元数据;是否接受公钥,由服务器上的客户端按本地信任设置判断。';
|
||||
// Presence is distinct from parsed document metadata and from cryptographic trust.
|
||||
let signaturePresent = false;
|
||||
if (!unavailable) {
|
||||
if (typeof snapshot.signatureAvailable === 'boolean') signaturePresent = snapshot.signatureAvailable;
|
||||
else if (snapshot.signedDocument && snapshot.keysText === snapshot.signedDocument) {
|
||||
try { signaturePresent = Boolean(validateSignature(snapshot.signature)); } catch { /* No usable detached signature. */ }
|
||||
}
|
||||
}
|
||||
const certificatePresent = typeof snapshot.publicCertificateAvailable === 'boolean' ? snapshot.publicCertificateAvailable : typeof snapshot.publicCertificate === 'string' && snapshot.publicCertificate.length > 0;
|
||||
const certificateStatus = certificatePresent ? '公开证书已提供(安装时核验)' : '未提供公开证书';
|
||||
const signatureStatus = signaturePresent ? '签名包已提供(待客户端验证)' : '未提供签名包';
|
||||
const keyCount = unavailable ? 0 : (snapshot.keyCount ?? keys.length);
|
||||
const publicSource = {type:source.type, ...(source.url ? {url:source.url} : {})};
|
||||
const publicData = {schema:2,revision,publishedAt,keyCount,sha256:snapshot.sha256 || null,keyRevision:snapshot.keyRevision || snapshot.sha256 || null,source:publicSource,sourceStatus,signaturePresent,signatureStatus,certificatePresent,certificateStatus,repositoryUrl,testFixture:Boolean(testFixture)};
|
||||
const replacements = {
|
||||
RELEASE_JSON:JSON.stringify(publicData).replace(/</g,'\\u003c').replace(/\u2028/g,'\\u2028').replace(/\u2029/g,'\\u2029'),
|
||||
KEYS_HTML:unavailable ? '<p class="empty-state">当前无法获取有效公钥。请稍后重试或检查备用源配置。</p>' : renderKeys(keys),
|
||||
COMMANDS_HTML:renderCommands(commands),REVISION:escapeHTML(revision.slice(0,12)),PUBLISHED_AT:escapeHTML(publishedAt),KEY_COUNT:String(keys.length),
|
||||
REPOSITORY_URL:escapeHTML(repositoryUrl),RELEASE_LABEL:testFixture ? '测试预览' : unavailable ? '公钥暂不可用' : '已发布',
|
||||
PREVIEW_NOTICE:testFixture ? '<p class="preview-notice" role="note">测试预览:这里使用公开测试密钥,不能部署到真实服务器。</p>' : '',
|
||||
SOURCE_LABEL:escapeHTML(sourceLabel),SOURCE_STATUS:escapeHTML(sourceStatus),CONTENT_REVISION:escapeHTML(snapshot.sha256?.slice(0,12) || '—'),KEYS_NOTICE:escapeHTML(notice),
|
||||
COMMANDS_HTML:renderCommands(commands),REVISION:escapeHTML(revision.slice(0,12)),PUBLISHED_AT:escapeHTML(publishedAt),KEY_COUNT:String(keyCount),
|
||||
REPOSITORY_URL:escapeHTML(repositoryUrl),RELEASE_LABEL:testFixture ? '测试预览' : unavailable ? '等待发布源' : '发布元数据',
|
||||
PREVIEW_NOTICE:testFixture ? '<p class="preview-notice" role="note">测试预览:这里使用公开测试数据,不能部署到真实服务器。</p>' : '',
|
||||
CERTIFICATE_LINK:certificatePresent ? '<a class="text-link" href="/public-key.gpg">公开签名证书 <span aria-hidden="true">↗</span></a>' : '',
|
||||
SOURCE_LABEL:escapeHTML(sourceLabel),SOURCE_STATUS:escapeHTML(sourceStatus),SIGNATURE_STATUS:escapeHTML(signatureStatus),CERTIFICATE_STATUS:escapeHTML(certificateStatus),CONTENT_REVISION:escapeHTML((snapshot.keyRevision || snapshot.sha256)?.slice(0,12) || '—'),KEYS_NOTICE:escapeHTML(notice),
|
||||
};
|
||||
return template.replace(/@@([A-Z_]+)@@/g, (_, token) => {
|
||||
if (!Object.hasOwn(replacements, token)) throw Error(`Unresolved page template token: ${token}`);
|
||||
|
||||
Reference in New Issue
Block a user