← CCNP Security: SCOR core and operations
11 / 25 · 55 MIN

TLS: identity and trust rotation

Diagnose name, chain and authorization using local TLS connections before and after a CA change.

1. Define whom we expect to reach

Before connecting, an application needs a reference identity. In the exercise, the expected service is reports.test even though the socket uses 127.0.0.1 to stay on the local computer. The certificate contains DNS:reports.test in subjectAltName. The decision combines issuer trust and name matching; neither property replaces the other. RFC 9525 does not use Common Name as a service identifier. Do not change the expected name merely to match an unexpected certificate. First confirm configuration, destination and the origin of the change. Successful DNS resolution alone does not validate the certificate.

2. Run a bounded experiment

Run node run.mjs using the Node version recorded in the manifest. The program uses a local certificate executable to generate two CAs and two server certificates, all temporary. It opens HTTPS only on loopback, requires TLS 1.3 and makes twelve fresh connections. Each client explicitly supplies its CA and servername while retaining rejectUnauthorized=true. Keys stay in a private directory and are removed afterward. The 26 checks cover wrong names, wrong CAs, context changes, rollback, key permissions and closure. The experiment installs no macOS CA, uses no real credentials and contacts no external server.

3. Distinguish observed failures

With CA A and reports.test, the client receives 200. With the same CA and other.test, the handshake fails identity checking. With CA B facing server A, the chain is not accepted. Record the category and observed code without assuming every runtime uses identical text. Then remove the synthetic token while keeping TLS correct: the application responds 401. An authenticated request to /admin receives 403. In the latter two cases, authenticated transport succeeded but the application access condition was not met. The distinction directs APS to the correct layer and avoids trying to fix a 403 by distributing another CA.

4. Rehearse issuer transition

The server changes from certificate A to B without changing the service name. A client trusting only A can no longer validate new connections. Trust set A+B works before and after the change, and a B-only client works with server B. The lab confirms rotation through the observed fingerprint and checks that connections do not reuse TLS sessions. Set A+B still rejects other.test: adding a CA does not remove name checking. In an actual project, plan distribution, consumer inventory, a pilot and controlled removal of old trust. Temporary overlap needs an owner and deadline rather than becoming permanent automatically.

5. Rollback and evidence limits

Restoring server A works for a client that still trusts A. After removing A from client trust, that same rollback fails. Rollback therefore depends on both sides’ state and on the validity and acceptability of old material. The exercise uses newly generated valid certificates; it does not test expiry, revocation, mTLS, enterprise PKI, session resumption or performance. It also does not establish Java, proxy or Cisco compatibility. For handover, provide tested consumers, expected identity, issuer, fresh-connection results and conditions authorizing rollback. Summary: validate name and trust, distinguish HTTP from TLS and rehearse the complete transition sequence.

// 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))
IN PRACTICE

Server A + trust A: 200. Server B + trust A: TLS failure. Server B + trust A+B: 200.

Common pitfalls

Changing expected name to accept a certificate; disabling validation; confusing TLS with authorization; assuming rollback only means restoring a file.

Related topics: PKI · Change management · Application troubleshooting

Take this idea with you

Renewal is demonstrated for the clients and connections actually tested, with trust and identity checked.

Create account

Reference: Service Identity in TLS · 350-701 SCOR v2.0, effective 2026-08-27; core component of CCNP Security

CCNP® and Cisco® are registered trademarks of Cisco Systems, Inc. and/or its affiliates. bigsavant.com is an independent preparation platform and is not affiliated with, associated with, sponsored, authorised or endorsed by Cisco. Content and questions are original, are not official exam questions, and completing our tests does not award or guarantee any certification. Names are used only to identify the subject. All other trademarks belong to their respective owners.