feat: pin client GPG trust and publish signed Git/Gist key releases

This commit is contained in:
2026-10-13 10:50:56 +08:00
parent 2f2f6db4d6
commit d72aa6eda7
28 changed files with 3187 additions and 427 deletions
+74
View File
@@ -0,0 +1,74 @@
// Test-only OpenPGP v4 fixture encoder. Private keys are generated in memory,
// never saved; production verification uses GnuPG, never this implementation.
// This keeps integration tests runnable when gpg-agent sockets are unavailable.
import {generateKeyPairSync, createHash, sign} from 'node:crypto';
const bytes = (...values) => Buffer.concat(values.map(value => Buffer.isBuffer(value) ? value : Buffer.from(value)));
const u16 = value => {const out = Buffer.alloc(2);out.writeUInt16BE(value);return out;};
const u32 = value => {const out = Buffer.alloc(4);out.writeUInt32BE(value);return out;};
const sha = (algorithm,value) => createHash(algorithm).update(value).digest();
function mpi(input) {
const first = input.findIndex(value => value !== 0), data = first < 0 ? Buffer.alloc(0) : input.subarray(first);
return bytes(u16(data.length ? (data.length-1)*8 + 32-Math.clz32(data[0]) : 0),data);
}
function length(size) {
if (size < 192) return Buffer.from([size]);
if (size <= 8383) return Buffer.from([((size-192)>>8)+192,(size-192)&255]);
return bytes([255],u32(size));
}
const packet = (type,body) => bytes([0xc0|type],length(body.length),body);
const subpacket = (type,body) => bytes(length(body.length+1),[type],body);
const keyPrefix = body => bytes([0x99],u16(body.length),body);
function armor(type,data) {
let crc = 0xb704ce;
for (const byte of data) {crc ^= byte<<16;for(let bit=0;bit<8;bit++) {crc <<= 1;if(crc&0x1000000) crc ^= 0x1864cfb;}}
const checksum = Buffer.from([(crc>>16)&255,(crc>>8)&255,crc&255]).toString('base64');
return `-----BEGIN PGP ${type}-----\n\n${data.toString('base64').match(/.{1,64}/g).join('\n')}\n=${checksum}\n-----END PGP ${type}-----\n`;
}
function key(created) {
const {publicKey,privateKey} = generateKeyPairSync('rsa',{modulusLength:2048});
const jwk = publicKey.export({format:'jwk'});
const body = bytes([4],u32(created),[1],mpi(Buffer.from(jwk.n,'base64url')),mpi(Buffer.from(jwk.e,'base64url')));
return {body,privateKey,fingerprint:sha('sha1',keyPrefix(body)),created};
}
function signature(signer,type,data,{created = signer.created,extra = []} = {}) {
const hashed = bytes(subpacket(2,u32(created)),subpacket(33,bytes([4],signer.fingerprint)),...extra);
const head = bytes([4,type,1,8],u16(hashed.length),hashed);
const toSign = bytes(data,head,[4,255],u32(head.length));
const unhashed = subpacket(16,signer.fingerprint.subarray(-8));
return bytes(head,u16(unhashed.length),unhashed,sha('sha256',toSign).subarray(0,2),mpi(sign('RSA-SHA256',toSign,signer.privateKey)));
}
export function openPgpFixture({subkey = false,subkeys = subkey ? 1 : 0,expired = false,revoked = false} = {}) {
if (!Number.isInteger(subkeys) || subkeys < 0 || subkeys > 4) throw Error('Fixture supports zero through four signing subkeys.');
const now = Math.floor(Date.now()/1000), created = now - (expired ? 5*86400 : 60);
const primary = key(created);
const uid = Buffer.from('Ephemeral security fixture <[email protected]>');
const certification = signature(primary,0x13,bytes(keyPrefix(primary.body),[0xb4],u32(uid.length),uid),{
extra:[subpacket(27,[subkeys ? 1 : 3]),...(expired ? [subpacket(9,u32(86400))] : [])],
});
const revocation = revoked ? packet(2,signature(primary,0x20,keyPrefix(primary.body),{created:created+10})) : Buffer.alloc(0);
let publicPackets = bytes(packet(6,primary.body),revocation,packet(13,uid),packet(2,certification));
const signers = [];
for (let index = 0; index < subkeys; index++) {
const signingKey = key(created);signers.push(signingKey);
const bound = bytes(keyPrefix(primary.body),keyPrefix(signingKey.body));
const crossCertification = signature(signingKey,0x19,bound);
const binding = signature(primary,0x18,bound,{extra:[subpacket(27,[2]),subpacket(32,crossCertification)]});
publicPackets = bytes(publicPackets,packet(14,signingKey.body),packet(2,binding));
}
if (!signers.length) signers.push(primary);
const signData = (signer,data) => armor('SIGNATURE',packet(2,signature(signer,0x00,Buffer.from(data),{created:created+5})));
return {
primaryFingerprint:primary.fingerprint.toString('hex').toUpperCase(),
signerFingerprint:signers[0].fingerprint.toString('hex').toUpperCase(),
signerFingerprints:signers.map(signer=>signer.fingerprint.toString('hex').toUpperCase()),
publicKey:armor('PUBLIC KEY BLOCK',publicPackets),
// Sign in the key's valid past lifetime, so verification must notice expiry
// or revocation now rather than merely reject an impossible creation date.
sign:(data,index = 0) => {if(!signers[index]) throw Error('Unknown fixture signer.');return signData(signers[index],data);},
// Deliberately valid text-mode signature for exact-byte policy rejection tests.
signText:(data,index = 0) => {if(!signers[index]) throw Error('Unknown fixture signer.');return armor('SIGNATURE',packet(2,signature(signers[index],0x01,Buffer.from(String(data).replace(/\r?\n/g,'\r\n')),{created:created+5})));},
signWithPrimary:data => signData(primary,data),
};
}