Define a decisão que o verificador deve suportar
Uma release fictícia de relatórios de fundos chega com um ficheiro, uma assinatura e uma declaração de proveniência. APS precisa de saber se a evidência corresponde aos bytes recebidos e ao processo autorizado. Define antecipadamente raízes de confiança, pares de assinante e builder, origem de código, tipo de build e parâmetros aceites. Não retires essa política do mesmo envelope que estás a avaliar. No laboratório, as chaves são geradas localmente e representam duas origens fictícias. Assinar uma declaração prova posse da chave privada correspondente dentro deste ensaio; não prova que o build descrito aconteceu. Uma avaliação do builder real e da proteção das suas credenciais continua necessária para confiar nas afirmações operacionais.
Verifica os bytes antes de interpretar a declaração
O envelope do exercício usa DSSE: a assinatura inclui o tipo de payload e os bytes serializados, enquadrados por PAE. Os comprimentos contam bytes UTF-8, não caracteres visíveis. O exemplo com é e ação torna a diferença observável. O campo keyid é apenas uma pista não autenticada e não acrescenta uma chave ao conjunto de confiança. Depois da verificação, interpreta os mesmos bytes e confirma o tipo suportado. Reformatar JSON sem voltar a assinar altera os bytes mesmo quando os campos parecem iguais. A implementação de ensino aceita base64 normal e URL-safe, mas restringe o perfil a uma assinatura e um subject. Não é um parser geral endurecido nem demonstra políticas de múltiplos assinantes.
Liga a proveniência ao artefacto e à origem esperada
O statement in-toto v1 identifica o subject pelo digest e declara o tipo de predicate. A proveniência SLSA utilizada mantém predicateType https://slsa.dev/provenance/v1, embora a documentação consultada seja a versão 1.2 da especificação. No exercício, a política compara SHA-256 do ficheiro, builder, buildType, repository, ref e o commit resolvido. Um nome de ficheiro igual não corrige um digest diferente. Uma chave confiável para o builder controlado não autoriza automaticamente o builder sandbox. O commit resolvido evita que o nome de uma branch móvel seja a única identificação da origem. Os parâmetros externos desconhecidos são recusados neste perfil para não aceitar silenciosamente uma opção de debug ou outro comportamento não revisto.
Executa falhas e interpreta o resultado limitado
O código abaixo executa 30 verificações: aceita o original, recusa bytes alterados, assinante desconhecido, tipo errado, par assinante-builder incorreto, fork não autorizado, commit diferente e parâmetro extra. Duas execuções reais locais conservaram o hash do script e resultados. Também aceita novamente o mesmo artefacto: não existe política de freshness ou replay neste laboratório. A aceitação de um segundo par explicitamente autorizado demonstra que confiança é uma configuração, não uma propriedade do texto builder.id. Todo o conteúdo assinado é sintético e criado no mesmo processo. Não foi executado CI, contactado um fornecedor, consultado um transparency log ou atribuído nível SLSA. Compara cada resultado com a razão esperada antes de o usar como evidência de aprendizagem.
Mantém separadas proveniência e autorização da release
Uma proveniência aceite ainda pode descrever software com vulnerabilidades, uma configuração incorreta ou uma versão antiga. Liga resultados de testes e análise de componentes ao mesmo digest final, com âmbito, data, exceções e responsável. Um inventário de componentes não é uma garantia de ausência de vulnerabilidades. Antes do deployment, confirma os bytes efetivamente transferidos; uma tag mutável pode apontar para outro artefacto depois da aprovação. Para rollback, conserva artefacto, evidência e decisão de compatibilidade com dados e dependências atuais. Uma release de emergência precisa de exceção explícita e critérios limitados; retirar o verificador não cria evidência equivalente. Resumo: assinatura, correspondência dos bytes, origem autorizada e aceitação operacional são condições distintas. Relaciona esta aula com CI/CD, gestão de mudanças, obsolescência e incidentes.
// Original educational lab. Synthetic claims, not a CI build or production verifier.
import assert from 'node:assert/strict';
import {generateKeyPairSync,sign,verify,createHash} from 'node:crypto';
import {readFileSync,writeFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
const hash=b=>createHash('sha256').update(b).digest('hex');
const type='application/vnd.in-toto+json';
export function pae(t,b){const tb=Buffer.from(t,'utf8');return Buffer.concat([Buffer.from(`DSSEv1 ${tb.length} `),tb,Buffer.from(` ${b.length} `),b]);}
function decode(s){
if(typeof s!=='string'||!/^[A-Za-z0-9+/_-]*={0,2}$/.test(s)||s.replace(/=+$/,'').length%4===1)throw Error('encoding');
const normalized=s.replaceAll('-','+').replaceAll('_','/'),b=Buffer.from(normalized,'base64');
if(b.toString('base64').replace(/=+$/,'')!==normalized.replace(/=+$/,''))throw Error('encoding');
if(s.includes('=')&&s.length%4!==0)throw Error('encoding');return b;
}
const builder='https://example.invalid/builders/funds-controlled/v1';
const alternateBuilder='https://example.invalid/builders/funds-sandbox/v1';
const buildType='https://example.invalid/buildtypes/funds/v1';
const repo='https://example.invalid/source/funds';
const ref='refs/heads/release';
const commit='71'.repeat(20);
const dependencyURI=`git+${repo}@${ref}`;
const artifact=Buffer.from('dr.pt fictional funds report\namount_minor=1729\ncurrency=EUR\n');
const trusted=generateKeyPairSync('ed25519'),second=generateKeyPairSync('ed25519'),unknown=generateKeyPairSync('ed25519');
const roots=[{key:trusted.publicKey,builder},{key:second.publicKey,builder:alternateBuilder}];
function statement(){return {_type:'https://in-toto.io/Statement/v1',subject:[{name:'relatório-fundos',digest:{sha256:hash(artifact)}}],predicateType:'https://slsa.dev/provenance/v1',predicate:{buildDefinition:{buildType,externalParameters:{repository:repo,ref},resolvedDependencies:[{uri:dependencyURI,digest:{gitCommit:commit}}]},runDetails:{builder:{id:builder}}}};}
function envelope(s,key=trusted.privateKey,payloadType=type){const body=Buffer.from(JSON.stringify(s));return{payloadType,payload:body.toString('base64'),signatures:[{keyid:'a hint, not a trust anchor',sig:sign(null,pae(payloadType,body),key).toString('base64')}]};}
export function accept(e,bytes,trust=roots){
// Deliberately narrow one-signature / one-subject / one-dependency teaching profile.
try{
if(!e||typeof e.payloadType!=='string'||!Array.isArray(e.signatures)||e.signatures.length!==1)throw Error('envelope-profile');
const body=decode(e.payload),signature=decode(e.signatures[0].sig);
const recognized=trust.filter(r=>verify(null,pae(e.payloadType,body),r.key,signature));
if(!recognized.length)throw Error('signature-or-trust');
if(e.payloadType!==type)throw Error('payload-type');
const s=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(body));
if(s._type!=='https://in-toto.io/Statement/v1')throw Error('statement-type');
if(s.predicateType!=='https://slsa.dev/provenance/v1')throw Error('predicate-type');
if(!Array.isArray(s.subject)||s.subject.length!==1||s.subject[0]?.digest?.sha256!==hash(bytes))throw Error('artifact-digest');
const p=s.predicate,b=p?.buildDefinition;
if(!recognized.some(r=>r.builder===p?.runDetails?.builder?.id))throw Error('signer-builder-pair');
if(b?.buildType!==buildType)throw Error('build-type');
const params=b.externalParameters;
if(!params||Object.keys(params).sort().join(',')!=='ref,repository'||params.repository!==repo||params.ref!==ref)throw Error('external-parameters');
const d=b.resolvedDependencies;
if(!Array.isArray(d)||d.length!==1||d[0]?.uri!==dependencyURI||d[0]?.digest?.gitCommit!==commit)throw Error('source-pin');
return{accepted:true,reason:'local-policy-met'};
}catch(error){return{accepted:false,reason:error.message};}
}
const checks=[];
function check(id,e,bytes,accepted,reason){const got=accept(e,bytes);assert.equal(got.accepted,accepted,id);assert.equal(got.reason,reason,id);checks.push({id,...got,pass:true});}
const good=envelope(statement());
check('trusted-original',good,artifact,true,'local-policy-met');
check('changed-artifact',good,Buffer.concat([artifact,Buffer.from('extra')]),false,'artifact-digest');
const altered=structuredClone(good);const alteredStatement=statement();alteredStatement.subject[0].name='other';altered.payload=Buffer.from(JSON.stringify(alteredStatement)).toString('base64');
check('changed-payload-without-resigning',altered,artifact,false,'signature-or-trust');
check('untrusted-signing-key',envelope(statement(),unknown.privateKey),artifact,false,'signature-or-trust');
const spoof=envelope(statement(),unknown.privateKey);spoof.signatures[0].keyid='trusted';check('keyid-does-not-create-trust',spoof,artifact,false,'signature-or-trust');
const ignored=structuredClone(good);ignored.signatures[0].keyid='different-hint';check('keyid-does-not-change-trusted-signature',ignored,artifact,true,'local-policy-met');
check('signed-unsupported-payload-type',envelope(statement(),trusted.privateKey,'application/json'),artifact,false,'payload-type');
function mutation(id,fn,reason,key=trusted.privateKey){const s=statement();fn(s);check(id,envelope(s,key),artifact,false,reason);}
mutation('wrong-statement-type',s=>s._type='https://in-toto.io/Statement/v0.1','statement-type');
mutation('wrong-predicate-type',s=>s.predicateType='https://example.invalid/other','predicate-type');
mutation('signed-wrong-digest',s=>s.subject[0].digest.sha256='00'.repeat(32),'artifact-digest');
mutation('missing-subject',s=>delete s.subject,'artifact-digest');
mutation('wrong-builder',s=>s.predicate.runDetails.builder.id=alternateBuilder,'signer-builder-pair');
check('trusted-key-wrong-builder-pair',envelope(statement(),second.privateKey),artifact,false,'signer-builder-pair');
const sandbox=statement();sandbox.predicate.runDetails.builder.id=alternateBuilder;check('second-approved-pair',envelope(sandbox,second.privateKey),artifact,true,'local-policy-met');
mutation('wrong-build-type',s=>s.predicate.buildDefinition.buildType+='-other','build-type');
mutation('unofficial-fork',s=>s.predicate.buildDefinition.externalParameters.repository+='-fork','external-parameters');
mutation('different-ref',s=>s.predicate.buildDefinition.externalParameters.ref='refs/heads/dev','external-parameters');
mutation('unexpected-parameter',s=>s.predicate.buildDefinition.externalParameters.debug='true','external-parameters');
mutation('missing-parameters',s=>delete s.predicate.buildDefinition.externalParameters,'external-parameters');
mutation('changed-source-pin',s=>s.predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit='ab'.repeat(20),'source-pin');
mutation('missing-resolved-source',s=>s.predicate.buildDefinition.resolvedDependencies=[],'source-pin');
mutation('wrong-dependency-uri',s=>s.predicate.buildDefinition.resolvedDependencies[0].uri+='-other','source-pin');
check('malformed-payload-base64',{...good,payload:'%%%not-base64'},artifact,false,'encoding');
const badsig=structuredClone(good);badsig.signatures[0].sig='%%%%';check('malformed-signature-base64',badsig,artifact,false,'encoding');
const urlsafe=structuredClone(good);urlsafe.payload=decode(urlsafe.payload).toString('base64url');urlsafe.signatures[0].sig=decode(urlsafe.signatures[0].sig).toString('base64url');check('base64url-envelope',urlsafe,artifact,true,'local-policy-met');
const unpadded=structuredClone(good);unpadded.payload=unpadded.payload.replace(/=+$/,'');unpadded.signatures[0].sig=unpadded.signatures[0].sig.replace(/=+$/,'');check('unpadded-base64-envelope',unpadded,artifact,true,'local-policy-met');
assert.equal(pae('é',Buffer.from('ação')).toString('utf8'),'DSSEv1 2 é 6 ação');checks.push({id:'pae-counts-utf8-bytes',pass:true});
const reformatted=structuredClone(good);reformatted.payload=Buffer.from(JSON.stringify(statement(),null,2)).toString('base64');check('reformatted-same-json-invalidates-signature',reformatted,artifact,false,'signature-or-trust');
const cleanRename=statement();cleanRename.subject[0].name='renamed-report';check('subject-name-is-not-content-identity',envelope(cleanRename),artifact,true,'local-policy-met');
check('same-artifact-accepted-again-no-replay-policy',good,artifact,true,'local-policy-met');
const report={executedAt:new Date().toISOString(),runtime:process.version,platform:process.platform,scriptSHA256:hash(readFileSync(fileURLToPath(import.meta.url))),artifactSHA256:hash(artifact),checks,passed:checks.length,failed:0,scope:'Actual local Ed25519 signing and DSSE-style verification with synthetic in-toto/SLSA claims and explicit local acceptance policy.',limits:['No real CI build or builder attestation','No SLSA level awarded or independently assessed','No cloud DLP scanner or real customer data','No certificate authority, transparency log, KMS or deployment','No freshness, replay, revocation or threshold-signature policy','Narrow single-signature/single-subject teaching profile; not a hardened general-purpose verifier','No independent specialist review']};
if(process.argv[2])writeFileSync(process.argv[2],JSON.stringify(report,null,2)+'\n');
console.log(JSON.stringify({passed:report.passed,failed:report.failed,scriptSHA256:report.scriptSHA256}));
Uma assinatura válida para outro digest ou para um builder não autorizado é insuficiente; o laboratório recusa ambas as condições.
Armadilhas comuns
Confiar em keyid; comparar só nomes; aceitar qualquer builder da mesma chave; confundir assinatura com código seguro; atribuir nível SLSA a uma simulação.
Tópicos relacionados: CI/CD e critérios de release · Software supply chain e gestão de incidentes
Relaciona assinatura, digest, origem e política antes de aceitar uma release.
Referência: SLSA Build: Verifying artifacts · CCSP examination outline effective 2026-08-01; January2026 V2 PDF