1. Definir quem esperamos encontrar
Antes de abrir uma ligação, a aplicação precisa de uma identidade de referência. No exercício, o serviço esperado é reports.test, embora o socket use 127.0.0.1 para ficar no computador local. O certificado contém DNS:reports.test em subjectAltName. A decisão combina confiança no emissor e correspondência do nome; uma propriedade não substitui a outra. O RFC 9525 não usa o Common Name como identificador de serviço. Não alteres o nome esperado só para fazer coincidir um certificado inesperado. Confirma primeiro a configuração, o destino e a origem da alteração. Uma resolução DNS bem-sucedida, por si só, não constitui validação do certificado.
2. Executar um ensaio delimitado
Executa node run.mjs com a versão Node indicada no manifesto. O programa usa o executável local de certificados para gerar duas CAs e dois certificados de servidor, todos temporários. Abre HTTPS só em loopback, força TLS 1.3 e faz doze ligações novas. Cada cliente fornece explicitamente a CA e servername, mantendo rejectUnauthorized=true. As chaves ficam numa pasta privada e são removidas no fim. As 26 verificações incluem nome errado, CA errada, troca de contexto, rollback, permissões das chaves e encerramento. O ensaio não instala uma CA no macOS, não usa credenciais reais e não contacta qualquer servidor externo.
3. Distinguir as falhas observadas
Com CA A e reports.test, o cliente recebe 200. Com a mesma CA e other.test, o handshake falha na identidade. Com CA B diante do servidor A, a cadeia não é aceite. Regista a categoria e o código observado, sem concluir que todos os runtimes apresentam o mesmo texto. Depois, retira o token sintético mantendo TLS correto: a aplicação responde 401. Um pedido autenticado ao caminho /admin recebe 403. Nestes dois últimos casos houve transporte autenticado, mas a condição de acesso da aplicação não foi satisfeita. A distinção orienta APS para a camada certa e evita tentar corrigir um 403 distribuindo outra CA.
4. Ensaiar a transição de emissores
O servidor muda do certificado A para B sem trocar o nome do serviço. Um cliente que só conhece A deixa de validar novas ligações. O conjunto de confiança A+B funciona antes e depois da mudança, e um cliente apenas B funciona com o servidor B. O laboratório confirma a troca pelo fingerprint observado e verifica que as ligações não reutilizam sessões TLS. O conjunto A+B continua a recusar other.test: acrescentar uma CA não remove a regra de nome. Num projeto real, planeia distribuição, inventário de consumidores, piloto e retirada controlada da confiança antiga. Uma sobreposição temporária precisa de proprietário e prazo, não de permanência automática.
5. Reversão e limites da evidência
A reposição do servidor A funciona para um cliente que ainda confia em A. Depois de retirar A do cliente, essa mesma reversão falha. Logo, o rollback depende do estado dos dois lados e da validade e aceitabilidade do material antigo. O exercício usa certificados recém-criados e válidos; não testa expiração, revogação, mTLS, PKI empresarial, retomada de sessões nem desempenho. Também não prova compatibilidade com Java, proxies ou Cisco. No handover, entrega os consumidores ensaiados, a identidade esperada, o emissor, os resultados de ligações novas e as condições que autorizam reversão. Resumo: valida nome e confiança, separa HTTP de TLS e ensaia a sequência completa de transição.
// Local TLS exercise. Generated certificates only; no system trust-store changes.
import fs from 'node:fs';import os from 'node:os';import path from 'node:path';import https from 'node:https';import {X509Certificate,createHash} from 'node:crypto';import {execFileSync} from 'node:child_process';import assert from 'node:assert/strict';
const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'dr-ccnp-tls-'));fs.chmodSync(scratch,0o700);const checks=[],observations={},requests=[];let server;let appRequests=0;
const check=(name,ok)=>{assert(ok,name);checks.push(name);};
const openssl=process.env.DR_LAB_OPENSSL||'/usr/bin/openssl';
const command=args=>execFileSync(openssl,args,{cwd:scratch,stdio:['ignore','pipe','pipe'],timeout:15000});
const read=name=>fs.readFileSync(path.join(scratch,name));
function material(label){
fs.writeFileSync(path.join(scratch,'root-'+label+'.cnf'),'[req]\nprompt=no\ndistinguished_name=dn\nx509_extensions=ca\n[dn]\nCN=dr-local-root-'+label+'\n[ca]\nbasicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign\n');
command(['req','-x509','-newkey','rsa:2048','-nodes','-days','2','-sha256','-config','root-'+label+'.cnf','-keyout','root-'+label+'.key','-out','root-'+label+'.pem']);
command(['req','-new','-newkey','rsa:2048','-nodes','-sha256','-subj','/CN=reports.test','-keyout','leaf-'+label+'.key','-out','leaf-'+label+'.csr']);
fs.writeFileSync(path.join(scratch,'leaf.ext'),'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:reports.test\n');
command(['x509','-req','-in','leaf-'+label+'.csr','-CA','root-'+label+'.pem','-CAkey','root-'+label+'.key','-set_serial',label==='A'?'101':'202','-days','1','-sha256','-extfile','leaf.ext','-out','leaf-'+label+'.pem']);
for(const f of['root-'+label+'.key','leaf-'+label+'.key'])fs.chmodSync(path.join(scratch,f),0o600);
return{key:read('leaf-'+label+'.key'),cert:read('leaf-'+label+'.pem'),ca:read('root-'+label+'.pem'),minVersion:'TLSv1.3',maxVersion:'TLSv1.3'};
}
try{
const A=material('A'),B=material('B');const xA=new X509Certificate(A.cert),xB=new X509Certificate(B.cert);
server=https.createServer(A,(req,res)=>{appRequests++;const authenticated=req.headers.authorization==='Bearer synthetic-read';const status=!authenticated?401:req.url==='/report'?200:403;res.writeHead(status,{'Content-Type':'text/plain'});res.end(status===200?'synthetic report':status===401?'authentication required':'forbidden');});server.on('tlsClientError',()=>{});
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});const port=server.address().port;
async function request(label,ca,name='reports.test',token=true,url='/report'){
const out=await new Promise(resolve=>{const req=https.get({host:'127.0.0.1',port,path:url,servername:name,ca,agent:false,rejectUnauthorized:true,minVersion:'TLSv1.3',maxVersion:'TLSv1.3',headers:token?{Authorization:'Bearer synthetic-read'}:{}},res=>{const info={ok:true,status:res.statusCode,authorized:res.socket.authorized,protocol:res.socket.getProtocol(),reused:res.socket.isSessionReused(),fingerprint:res.socket.getPeerCertificate().fingerprint256};let body='';res.on('data',d=>body+=d);res.on('end',()=>resolve({...info,body}));res.on('error',err=>resolve({ok:false,error:err.code||err.message}));});req.setTimeout(4000,()=>req.destroy(new Error('local timeout')));req.on('error',err=>resolve({ok:false,error:err.code||err.message}));});requests.push({label,...out});return out;
}
const valid=await request('before-A',A.ca),overlapBefore=await request('before-overlap',[A.ca,B.ca]),wrongName=await request('wrong-name',A.ca,'other.test'),wrongTrust=await request('wrong-root',B.ca),noAuth=await request('no-application-auth',A.ca,'reports.test',false),forbidden=await request('forbidden-application-path',A.ca,'reports.test',true,'/admin');
check('trusted root and expected name reach application',valid.ok&&valid.status===200&&valid.body==='synthetic report');
check('TLS1.3 actually negotiated',valid.protocol==='TLSv1.3');
check('peer authorization actually succeeds',valid.authorized===true);
check('overlap bundle works before rotation',overlapBefore.status===200);
check('wrong expected name rejected',!wrongName.ok&&wrongName.error==='ERR_TLS_CERT_ALTNAME_INVALID');
check('unrelated root rejected',!wrongTrust.ok&&/CERT|ISSUER|VERIFY/.test(wrongTrust.error));
check('TLS success does not authenticate application',noAuth.authorized===true&&noAuth.status===401);
check('application authorization remains separate',forbidden.authorized===true&&forbidden.status===403);
server.setSecureContext(B);
const staleTrust=await request('after-B-old-trust',A.ca),overlapAfter=await request('after-B-overlap',[A.ca,B.ca]),newTrust=await request('after-B-new-trust',B.ca),wrongAfter=await request('after-B-wrong-name',[A.ca,B.ca],'other.test');
check('old-only trust fails after issuer rotation',!staleTrust.ok&&/CERT|ISSUER|VERIFY/.test(staleTrust.error));
check('overlap bundle works after rotation',overlapAfter.status===200&&overlapAfter.authorized);
check('new-only trust accepts new issuer',newTrust.status===200);
check('rotation changes observed peer fingerprint',overlapAfter.fingerprint!==valid.fingerprint);
check('new peer fingerprint matches generated B certificate',overlapAfter.fingerprint===xB.fingerprint256);
check('old peer fingerprint matches generated A certificate',valid.fingerprint===xA.fingerprint256);
check('overlap does not bypass hostname checking',!wrongAfter.ok&&wrongAfter.error==='ERR_TLS_CERT_ALTNAME_INVALID');
server.setSecureContext(A);const rollback=await request('rollback-A-old-trust',A.ca),removedOld=await request('rollback-A-new-only',B.ca);
check('controlled rollback works while A remains trusted',rollback.status===200&&rollback.fingerprint===valid.fingerprint);
check('removing old trust prevents old-issuer rollback',!removedOld.ok);
check('all completed TLS connections are fresh',requests.filter(r=>r.ok).every(r=>r.reused===false));
check('seven requests reach application',appRequests===7);
check('twelve distinct connections attempted',requests.length===12);
check('server certificate has DNS identity',xA.subjectAltName==='DNS:reports.test');
check('certificate is a leaf not a CA',xA.ca===false);
check('server listens only on loopback',server.address().address==='127.0.0.1');
check('generated private keys have owner-only permissions',['root-A.key','root-B.key','leaf-A.key','leaf-B.key'].every(f=>(fs.statSync(path.join(scratch,f)).mode&0o777)===0o600));
observations.requests=requests.map(({fingerprint,...r})=>r);observations.applicationRequests=appRequests;
await new Promise(resolve=>server.close(resolve));check('server closed',server.listening===false);server=null;
}finally{if(server?.listening)await new Promise(resolve=>server.close(resolve));fs.rmSync(scratch,{recursive:true,force:true});}
check('owned certificate directory removed',!fs.existsSync(scratch));
console.log(JSON.stringify({passed:checks.length,failed:0,checks,observations,node:process.version,nodeOpenSSL:process.versions.openssl,certificateTool:execFileSync(openssl,['version'],{encoding:'utf8'}).trim(),scriptSHA256:createHash('sha256').update(fs.readFileSync(new URL(import.meta.url))).digest('hex'),actualLocalTLS:true,actualCiscoDeployment:false,systemTrustStoreChanged:false,revocationChecked:false,mutualTLS:false},null,2));
Servidor A + confiança A: 200. Servidor B + confiança A: falha TLS. Servidor B + confiança A+B: 200.
Armadilhas comuns
Trocar o nome esperado para aceitar o certificado; desativar validação; confundir TLS com autorização; assumir que rollback é só voltar a um ficheiro.
Tópicos relacionados: PKI · Gestão de alterações · Troubleshooting de aplicações
A renovação está demonstrada para os clientes e ligações realmente ensaiados, com confiança e identidade verificadas.
Referência: Service Identity in TLS · 350-701 SCOR v2.0, effective 2026-08-27; core component of CCNP Security