← DNS: compreender e diagnosticar a resolução
11 / 12 · 60 MIN

Validar dados DNSSEC num laboratório isolado

Compara respostas válidas e defeitos controlados num Unbound validador, com uma âncora DS local explícita.

Preparar a cadeia de confiança do exercício

O código abaixo gera uma chave RSA temporária, constrói DNSKEY e assinaturas para uma fixture original e configura o digest correspondente como âncora DS do Unbound. O ponto de partida é fund.test, não a raiz pública. O validador pede DNSKEY e verifica os dados recebidos a partir dessa confiança configurada. Usa apenas portas temporárias em loopback e termina as instâncias no fim. Disponibiliza Python 3, Unbound 1.26.1, unbound-checkconf da mesma versão, OpenSSL 3 e dig; guarda o código como dns-validation.py. A execução registada usou Python 3.13.1, OpenSSL 3.6.1 e dig 9.10.6. O algoritmo RSA/SHA-256, número oito, serve este exercício; a escolha de produção precisa de política e compatibilidade próprias. Não uses as chaves descartáveis fora do laboratório. O ficheiro JSON guarda dados públicos da experiência, sem a chave privada.

Ler o controlo válido e o caso de dados alterados

A experiência valid recebe NOERROR com A e AD através da instância conhecida. Esse controlo demonstra que o percurso configurado consegue validar os dados. Na experiência tampered, a fixture assina 192.0.2.80 e envia 192.0.2.81 com a assinatura anterior. A consulta normal recebe SERVFAIL e o log indica falha criptográfica. A consulta com CD devolve os dados sem AD. Compara estas observações, em vez de concluir apenas que “DNS responde”. O resultado com CD ajuda a investigar, mas não repara a assinatura nem autoriza alterar a política dos consumidores. No banco fictício Arco, a pressão para recuperar uma reconciliação levou a propor desligar validação globalmente. A decisão útil é envolver o responsável pela publicação assinada, avaliar reparação ou rollback coerente e confirmar a consulta normal antes da aceitação da aplicação.

Distinguir tempo, assinatura ausente e âncora incompatível

Os restantes ensaios introduzem causas diferentes para o mesmo SERVFAIL. expired usa uma assinatura cujo prazo terminou há dois dias; future usa início daqui a dois dias. O TTL do registo continua a ser sessenta segundos. O objetivo é mostrar que adquirir dados recentemente não torna a assinatura válida no instante de verificação. missing serve A sem RRSIG; o validador pede também DS para esclarecer o contexto. A fixture inclui uma prova NSEC assinada específica de ausência de DS nesse nome existente, que não é uma delegação. Isso permite chegar ao diagnóstico de assinatura ausente, mas não constitui cobertura geral de respostas negativas. anchorMismatch altera o digest configurado, mantendo a chave e os dados assinados. Guarda o motivo de cada falha e evita resumir todas a “certificado expirado”: DNSKEY, RRSIG e certificado TLS são objetos diferentes.

Reproduzir e interpretar os limites da evidência

Executa o comando apresentado no guião da próxima aula e conserva o JSON. Cada repetição cria outra chave, portas e identificadores; compara estados, flags, causas dos logs e verificações, não igualdade total dos bytes. O runner recusa uma conta com .digrc pessoal para evitar configuração implícita do cliente. Usa uma conta de laboratório limpa, sem apagar a configuração de trabalho. Se houver falha, o diagnóstico inclui a experiência, pedidos inesperados e log do validador. Não substituas os resultados por saídas esperadas. A execução confirmada passou 41 verificações em seis experiências e removeu a chave temporária. Resumo: presença de A ou RRSIG não basta para demonstrar confiança. Este ensaio não cobre a raiz pública, rotação de chaves, toda a lógica NSEC/NSEC3, TLS ou aplicações. Relaciona a aula com cache, delegações e critérios de aceitação.

#!/usr/bin/env python3
"""Original DNSSEC lab: disposable RSA key, local signed fixture, validating Unbound. No system DNS changes."""
import argparse,base64,hashlib,json,platform,re,socket,socketserver,struct,subprocess,tempfile,threading,time
from pathlib import Path
ZONE='fund.test.';TTL=60

def wire(name):return b''.join(bytes([len(x)])+x.lower().encode('ascii') for x in name.rstrip('.').split('.'))+b'\0'
def rr(name,kind,data):return wire(name)+struct.pack('!HHIH',kind,1,TTL,len(data))+data

def main():
    p=argparse.ArgumentParser(description=__doc__);p.add_argument('--unbound',required=True);p.add_argument('--checkconf',required=True);p.add_argument('--openssl',required=True);p.add_argument('--dig',default='/usr/bin/dig');p.add_argument('--output',default='dns-validation-evidence.json');args=p.parse_args()
    assert not Path.home().joinpath('.digrc').exists(),'Use a clean lab account without personal .digrc; do not delete working configuration.'
    def run(argv,data=None):
        x=subprocess.run(argv,input=data,capture_output=True,timeout=20);assert x.returncode==0,(argv,x.stdout.decode(errors='replace'),x.stderr.decode(errors='replace'));return x.stdout
    ub=run([args.unbound,'-V']).decode();assert re.search(r'Version 1\.26\.1\b',ub),ub
    ov=run([args.openssl,'version']).decode().strip();assert ov.startswith('OpenSSL 3.'),ov
    version_result=subprocess.run([args.dig,'-v'],capture_output=True,timeout=10);assert version_result.returncode==0
    dv=(version_result.stdout+version_result.stderr).decode().strip();assert dv, 'Missing dig version'
    checks=[];observations={};events=[];configs=[]
    def check(label,result):assert result,label;checks.append(label)
    with tempfile.TemporaryDirectory(prefix='dr-dns-validation-') as tmp:
        tmp=Path(tmp);private=tmp/'ephemeral.pem'
        run([args.openssl,'genpkey','-algorithm','RSA','-pkeyopt','rsa_keygen_bits:2048','-out',str(private)])
        private.chmod(0o600)
        der=run([args.openssl,'rsa','-in',str(private),'-RSAPublicKey_out','-outform','DER'])
        def tlv(data,offset):
            tag=data[offset];n=data[offset+1];pos=offset+2
            if n&128:k=n&127;n=int.from_bytes(data[pos:pos+k],'big');pos+=k
            return tag,data[pos:pos+n],pos+n
        tag,seq,end=tlv(der,0);assert tag==0x30 and end==len(der)
        tag,mod,pos=tlv(seq,0);assert tag==2
        tag,exp,pos=tlv(seq,pos);assert tag==2 and pos==len(seq)
        mod=mod.lstrip(b'\0');exp=exp.lstrip(b'\0');assert len(exp)<256
        dnskey=struct.pack('!HBB',257,3,8)+bytes([len(exp)])+exp+mod
        acc=sum(b<<(8 if i%2==0 else 0) for i,b in enumerate(dnskey));acc+=(acc>>16)&65535;keytag=acc&65535
        digest=hashlib.sha256(wire(ZONE)+dnskey).hexdigest().upper();now=int(time.time())
        check('RSA public key is 2048 bits',len(mod)==256)
        def signed(name,kind,data,mode='valid'):
            inception,expiration=now-60,now+3600
            if mode=='expired':inception,expiration=now-259200,now-172800
            if mode=='future':inception,expiration=now+172800,now+259200
            header=struct.pack('!HBBIIIH',kind,8,len(name.rstrip('.').split('.')),TTL,expiration,inception,keytag)+wire(ZONE)
            canonical=rr(name,kind,data)
            signature=run([args.openssl,'dgst','-sha256','-sign',str(private)],header+canonical)
            return canonical+rr(name,46,header+signature)
        dnskey_answer=signed(ZONE,48,dnskey)
        fixtures={}
        for label in ['valid','tampered','expired','future','missing']:
            name=label+'.'+ZONE;data=socket.inet_aton('192.0.2.80')
            answer=signed(name,1,data,label)
            if label=='tampered':answer=rr(name,1,socket.inet_aton('192.0.2.81'))+answer[len(rr(name,1,data)):]
            if label=='missing':answer=rr(name,1,data)
            fixtures[name]=answer
        soa=wire('ns.'+ZONE)+wire('hostmaster.'+ZONE)+struct.pack('!IIIII',1,60,30,600,60)
        # A narrow signed NODATA proof: this existing name has A, RRSIG and NSEC, but no DS or NS.
        nsec=wire('tampered.'+ZONE)+bytes([0,6,0x40,0,0,0,0,0x03])
        missing_ds_proof=signed(ZONE,6,soa)+signed('missing.'+ZONE,47,nsec)
        errors=[]
        class Authority(socketserver.BaseRequestHandler):
            def handle(self):
                try:
                    data,sock=self.request;offset=12;labels=[]
                    while data[offset]:
                        n=data[offset];assert n<64;labels.append(data[offset+1:offset+1+n].decode('ascii').lower());offset+=1+n
                    offset+=1;kind,cls=struct.unpack('!HH',data[offset:offset+4]);assert cls==1;name='.'.join(labels)+'.';q=data[12:offset+4]
                    events.append({'name':name,'type':kind})
                    assert name==ZONE or name.endswith('.'+ZONE),name
                    authority_data=b'';authority_count=0
                    if name=='missing.'+ZONE and kind==43:answer=b'';count=0;authority_data=missing_ds_proof;authority_count=4
                    elif name==ZONE and kind==48:answer=dnskey_answer;count=2
                    elif name in fixtures and kind==1:answer=fixtures[name];count=1 if name.startswith('missing.') else 2
                    else:raise AssertionError('Unexpected question '+name+' '+str(kind))
                    flags=0x8400|(struct.unpack('!H',data[2:4])[0]&0x0100)
                    sock.sendto(data[:2]+struct.pack('!HHHHH',flags,1,count,authority_count,0)+q+answer+authority_data,self.client_address)
                except Exception as exc:errors.append(str(exc))
        authority=socketserver.UDPServer(('127.0.0.1',0),Authority);thread=threading.Thread(target=authority.serve_forever,daemon=True);thread.start();authport=authority.server_address[1]
        def experiment(label,name,wrong_anchor=False):
            with socket.socket() as s:s.bind(('127.0.0.1',0));port=s.getsockname()[1]
            chosen=('0' if digest[0]!='0' else '1')+digest[1:] if wrong_anchor else digest
            conf=tmp/(label+'.conf');log=tmp/(label+'.log')
            config=f'''server:
    interface: 127.0.0.1@{port}
    outgoing-interface: 127.0.0.1
    access-control: 127.0.0.0/8 allow
    do-ip6: no
    username: ""
    chroot: ""
    directory: "{tmp}"
    pidfile: "{tmp}/{label}.pid"
    use-syslog: no
    do-daemonize: no
    verbosity: 1
    val-log-level: 2
    num-threads: 1
    module-config: "validator iterator"
    trust-anchor: "{ZONE} IN DS {keytag} 8 2 {chosen}"
    trust-anchor-signaling: no
    root-key-sentinel: no
    qname-minimisation: no
    do-not-query-localhost: no
    local-zone: "." refuse
    local-zone: "{ZONE}" transparent
    prefetch: no
    serve-expired: no
    val-permissive-mode: no
stub-zone:
    name: "{ZONE}"
    stub-addr: 127.0.0.1@{authport}
    stub-first: no
    stub-prime: no
remote-control:
    control-enable: no
'''
            conf.write_text(config);validation=run([args.checkconf,str(conf)]).decode();configs.append({'label':label,'config':config,'checkconf':validation})
            def query(cd=False):
                argv=[args.dig,'@127.0.0.1','-p',str(port),name,'A','+recurse','+nosearch','+dnssec','+bufsize=1232','+time=4','+tries=1']
                if cd:argv.append('+cdflag')
                return run(argv).decode()
            with log.open('w') as lf:
                proc=subprocess.Popen([args.unbound,'-d','-c',str(conf)],stdout=lf,stderr=lf)
                try:
                    for _ in range(100):
                        assert proc.poll() is None,log.read_text()
                        try:
                            with socket.create_connection(('127.0.0.1',port),timeout=.1):break
                        except OSError:time.sleep(.03)
                    else:raise AssertionError('Resolver did not listen')
                    before=len(events);normal=query();cd=query(True)
                    def flag(text,value):return value in re.search(r';; flags: ([^;]+);',text).group(1).split()
                    secure=label=='valid'
                    check(label+': normal status','status: '+('NOERROR' if secure else 'SERVFAIL') in normal)
                    check(label+': AD reflects validation',flag(normal,'ad')==secure)
                    check(label+': CD returns response','status: NOERROR' in cd and ('192.0.2.81' if label=='tampered' else '192.0.2.80')in cd)
                    if not secure:check(label+': CD does not assert AD',not flag(cd,'ad'))
                    check(label+': DNSKEY requested',any(x['name']==ZONE and x['type']==48 for x in events[before:]))
                    if label=='missing':check('missing: DS absence lookup observed',any(x['name']==name and x['type']==43 for x in events[before:]))
                    observations[label]={'normal':normal,'checkingDisabled':cd,'authorityRequests':events[before:]}
                except Exception:
                    print(json.dumps({'experiment':label,'fixtureErrors':errors,'events':events,'validatorLog':log.read_text()}))
                    raise
                finally:
                    proc.terminate()
                    try:proc.wait(timeout=5)
                    except subprocess.TimeoutExpired:proc.kill();proc.wait(timeout=5)
                    check(label+': process stopped',proc.poll() is not None)
            observations[label]['validatorLog']=log.read_text()
        try:
            for label in ['valid','tampered','expired','future','missing']:experiment(label,label+'.'+ZONE)
            experiment('anchorMismatch','valid.'+ZONE,True)
            check('no unexpected fixture questions',not errors)
            check('all fixture traffic stays in test zone',all(x['name']==ZONE or x['name'].endswith('.'+ZONE)for x in events))
        finally:authority.shutdown();authority.server_close();thread.join(timeout=3)
        check('authority thread stopped',not thread.is_alive())
        public={'zone':ZONE,'keyTag':keytag,'algorithm':8,'digestType':2,'dnskeyBase64':base64.b64encode(dnskey[4:]).decode(),'dsSha256':digest,'generatedAtEpoch':now}
    check('temporary key and configuration removed',not tmp.exists())
    result={'checkedAtEpoch':int(time.time()),'unboundVersion':ub,'opensslVersion':ov,'digVersion':dv,'pythonVersion':platform.python_version(),'checks':checks,'observations':observations,'configurations':configs,'publicLabKey':public,'runnerSha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'scope':'Six actual validating Unbound experiments using an original signed UDP fixture and a locally configured DS trust anchor. Ephemeral RSA/SHA-256 key generated and removed. No public root chain, parent delegation traversal, general NSEC/NSEC3 denial coverage, key rollover, production application or system DNS changes. The fixture includes one signed NSEC proof for DS absence at an existing non-delegation name. CD is diagnostic evidence, not a production workaround.'}
    Path(args.output).write_text(json.dumps(result,indent=2)+'\n');print(json.dumps({'experiments':len(observations),'passed':len(checks),'output':args.output}))
if __name__=='__main__':main()
NA PRÁTICA

Arco recebe um endereço com CD, mas a consulta normal falha por assinatura incompatível. O resultado ajuda a localizar a causa e não demonstra recuperação segura.

Armadilhas comuns

Confundir RRSIG presente com validação concluída, usar CD como correção permanente, tratar TTL como validade de assinatura ou assumir cadeia pública a partir de âncora local.

Tópicos relacionados: Cache e falhas de resolução · Transporte e confiança

Leva esta ideia contigo

O controlo válido e os defeitos intencionais mostram o que a configuração valida e rejeita. A conclusão tem de identificar o ponto de confiança e o âmbito executado.

Criar conta

Referência: Unbound validator configuration and original DR DNSSEC experiments · DNS RFC 1034/1035 with RFC 2181, 2308, 3596, 4033, 7766 and 8767; dig BIND 9.20