Skip to content

Commit f4de0b4

Browse files
committed
ndncert: dns-01 challenge
1 parent dd46973 commit f4de0b4

11 files changed

Lines changed: 160 additions & 51 deletions

File tree

pkg/ndncert/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This package is part of [NDNts](https://yoursunny.com/p/NDNts/), Named Data Networking libraries for the modern web.
44

5-
This package partially implements [NDN Certificate Management protocol v0.3](https://github.com/named-data/ndncert/wiki/NDNCERT-Protocol-0.3/841f2a2e66cc3256d113cfe61242420b9cdab6c1) and [challenges](https://github.com/named-data/ndncert/wiki/NDNCERT-Protocol-0.3-Challenges/06ba3d415479b6a58ecb132ba54dbee7617668d5).
5+
This package partially implements [NDN Certificate Management protocol v0.3](https://github.com/named-data/ndncert/wiki/NDNCERT-Protocol-0.3/841f2a2e66cc3256d113cfe61242420b9cdab6c1) and [challenges](https://github.com/named-data/ndncert/wiki/NDNCERT-Protocol-0.3-Challenges/39bde7f94301e251def53b6a9958ae321acb108f).
66
This implementation is validated against the reference implementation using [ndncert-interop](../../integ/ndncert-interop).
77

88
Features:
@@ -21,7 +21,7 @@ Challenges:
2121
* [X] PIN
2222
* [X] email, with name assignment policy
2323
* [X] proof of possession, with name assignment policy
24-
* [X] DNS, with name assignment policy
24+
* [X] DNS and DNS-01, with name assignment policy
2525
* [X] "nop" (not in NDNCERT spec)
2626

2727
`@ndn/keychain-cli` package offers `ndnts-keychain ndncert03-make-profile`, `ndnts-keychain ndncert03-show-profile`, `ndnts-keychain ndncert03-ca`, `ndnts-keychain ndncert03-probe`, and `ndnts-keychain ndncert03-client` commands that use this implementation.

pkg/ndncert/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@types/nodemailer": "^8.0.1",
3939
"ajv": "^8.20.0",
4040
"b64-lite": "^1.4.0",
41+
"b64u-lite": "^1.1.0",
4142
"imap-emails": "^1.0.4",
4243
"is-valid-hostname": "^1.0.2",
4344
"nodemailer": "^9.0.3",

pkg/ndncert/src/client/challenge.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Certificate } from "@ndn/keychain";
12
import type { Name } from "@ndn/packet";
23

34
import type { ParameterKV } from "../packet/mod";
@@ -23,29 +24,35 @@ export interface ClientChallenge {
2324
/** Contextual information for challenge selection. */
2425
export interface ClientChallengeStartContext {
2526
/** Request session ID. */
26-
requestId: Uint8Array;
27+
readonly requestId: Uint8Array;
28+
29+
/** Self-signed certificate request. */
30+
readonly certRequest: Certificate;
2731

2832
/** Certificate name of the self-signed certificate. */
29-
certRequestName: Name;
33+
readonly certRequestName: Name;
3034
}
3135

3236
/** Contextual information for challenge continuation. */
3337
export interface ClientChallengeContext {
3438
/** Request session ID. */
35-
requestId: Uint8Array;
39+
readonly requestId: Uint8Array;
40+
41+
/** Self-signed certificate request. */
42+
readonly certRequest: Certificate;
3643

3744
/** Certificate name of the self-signed certificate. */
38-
certRequestName: Name;
45+
readonly certRequestName: Name;
3946

4047
/** Challenge specific status string. */
41-
challengeStatus: string;
48+
readonly challengeStatus: string;
4249

4350
/** Number of remaining tries to complete challenge. */
44-
remainingTries: number;
51+
readonly remainingTries: number;
4552

4653
/** Remaining time to complete challenge, in milliseconds. */
47-
remainingTime: number;
54+
readonly remainingTime: number;
4855

4956
/** Challenge parameter key-value pairs, from CHALLENGE response packet. */
50-
parameters: ParameterKV;
57+
readonly parameters: ParameterKV;
5158
}
Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1+
import type { Promisable } from "type-fest";
12

3+
import * as ndncert_dns01 from "../dns01-common";
24
import { ParameterKV } from "../packet/mod";
35
import type { ClientChallenge, ClientChallengeContext } from "./challenge";
46

5-
/** The "dns" challenge where client creates a DNS TXT record. */
6-
export class ClientDnsChallenge implements ClientChallenge {
7-
public readonly challengeId = "dns";
8-
private record = "";
9-
private token = "";
7+
abstract class ClientDnsChallengeBase {
8+
private recordName = "";
9+
private recordValue = "";
1010

1111
constructor(
12-
private readonly domain: string,
13-
private readonly prompt: ClientDnsChallenge.Prompt,
12+
protected readonly domain: string,
13+
protected readonly prompt: ClientDnsChallenge.Prompt,
1414
) {}
1515

1616
public async start(): Promise<ParameterKV> {
@@ -19,16 +19,40 @@ export class ClientDnsChallenge implements ClientChallenge {
1919

2020
public async next(context: ClientChallengeContext): Promise<ParameterKV> {
2121
if (context.challengeStatus === "need-record") {
22-
this.record = ParameterKV.getString(context.parameters, "record-name");
23-
this.token = ParameterKV.getString(context.parameters, "expected-value");
22+
[this.recordName, this.recordValue] = await this.handleNeedRecord(context);
2423
}
2524

26-
await this.prompt(context, this.record, this.token);
25+
await this.prompt(context, this.recordName, this.recordValue);
2726
return ParameterKV.from({ confirmation: "ready" });
2827
}
28+
29+
protected abstract handleNeedRecord(context: ClientChallengeContext): Promisable<[recordName: string, recordValue: string]>;
30+
}
31+
32+
/** The "dns" challenge where client creates a DNS TXT record containing a challenge token. */
33+
export class ClientDnsChallenge extends ClientDnsChallengeBase implements ClientChallenge {
34+
public readonly challengeId = "dns";
35+
36+
protected override handleNeedRecord({ parameters }: ClientChallengeContext): [record: string, expected: string] {
37+
return [ParameterKV.getString(parameters, "record-name"),
38+
ParameterKV.getString(parameters, "expected-value")];
39+
}
2940
}
3041

3142
export namespace ClientDnsChallenge {
3243
/** Callback to prompt the user to insert a DNS TXT record. */
33-
export type Prompt = (context: ClientChallengeContext, recordName: string, expectedValue: string) => Promise<void>;
44+
export type Prompt = (context: ClientChallengeContext, recordName: string, recordValue: string) => Promise<void>;
45+
}
46+
47+
/** The "dns-01" challenge where client creates a DNS TXT record containing a key authorization value. */
48+
export class ClientDns01Challenge extends ClientDnsChallengeBase implements ClientChallenge {
49+
public readonly challengeId = "dns-01";
50+
51+
protected override async handleNeedRecord({ certRequest, parameters }: ClientChallengeContext): Promise<[recordName: string, recordValue: string]> {
52+
const token = ParameterKV.getString(parameters, "token");
53+
return [
54+
ndncert_dns01.toRecordName(this.domain),
55+
await ndncert_dns01.computeRecordValue(token, certRequest.publicKeySpki),
56+
];
57+
}
3458
}

pkg/ndncert/src/client/request.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export async function requestCertificate({
6464
privateKey,
6565
validity,
6666
});
67-
const certRequestName = newRequest.certRequest.name;
67+
const { certRequest } = newRequest;
6868
const newData = await consume(newRequest.interest, cOpts);
6969
ErrorMsg.throwOnError(newData);
7070
const newResponse = await NewResponse.fromData(newData, profile);
@@ -82,7 +82,8 @@ export async function requestCertificate({
8282
throw new Error(`no acceptable challenge in [${serverChallenges.join(",")}]`);
8383
}
8484

85-
let challengeParameters = await challenge.start({ requestId, certRequestName });
85+
const challengeContext = { requestId, certRequest, certRequestName: certRequest.name };
86+
let challengeParameters = await challenge.start(challengeContext);
8687
const issuedCertInterest = new Interest();
8788
while (true) {
8889
const challengeRequest = await ChallengeRequest.build({
@@ -107,8 +108,7 @@ export async function requestCertificate({
107108
}
108109

109110
challengeParameters = await challenge.next({
110-
requestId,
111-
certRequestName,
111+
...challengeContext,
112112
challengeStatus: challengeResponse.challengeStatus!,
113113
remainingTries: challengeResponse.remainingTries!,
114114
remainingTime: challengeResponse.remainingTime!,

pkg/ndncert/src/dns01-common.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { sha256, toUtf8 } from "@ndn/util";
2+
// @ts-expect-error no types
3+
import { toBase64Url } from "b64u-lite";
4+
5+
export function toRecordName(domain: string): string {
6+
return `_ndncert-challenge.${domain}`;
7+
}
8+
9+
export async function computeRecordValue(token: string, requesterPublicKey: Uint8Array): Promise<string> {
10+
const pubHash = toBase64Url(await sha256(requesterPublicKey));
11+
const keyAuthorization = `${token}.${pubHash}`;
12+
return toBase64Url(await sha256(toUtf8(keyAuthorization)));
13+
}

pkg/ndncert/src/packet/parameter-kv.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export namespace ParameterKV {
1212
}
1313

1414
/** Retrieve parameter as UTF-8 string. */
15-
export function getString(kv: ParameterKV, key: string): string {
15+
export function getString(kv: Readonly<ParameterKV>, key: string): string {
1616
if (!(key in kv)) {
1717
throw new Error(`missing parameter ${key}`);
1818
}
@@ -53,7 +53,7 @@ export function parseEvDecoder<R extends { parameters?: ParameterKV }>(evd: EvDe
5353
}
5454

5555
/** Encode pairs of ParameterKey and ParameterValue TLVs. */
56-
export function encode(kv: ParameterKV = {}): Encodable[] {
56+
export function encode(kv: Readonly<ParameterKV> = {}): Encodable[] {
5757
return Object.entries(kv).flatMap(([key, value]) => [
5858
[TT.ParameterKey, toUtf8(key)],
5959
[TT.ParameterValue, value],

pkg/ndncert/src/server/challenge.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Certificate } from "@ndn/keychain";
12
import type { Name } from "@ndn/packet";
23
import type { Promisable } from "type-fest";
34

@@ -46,6 +47,9 @@ export interface ServerChallengeContext<State = unknown> {
4647
/** CA profile packet. */
4748
readonly profile: CaProfile;
4849

50+
/** Self-signed certificate request. */
51+
readonly certRequest: Certificate;
52+
4953
/** Subject name of the requested certificate. */
5054
readonly subjectName: Name;
5155

pkg/ndncert/src/server/dns-challenge.ts

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,18 @@ import { SigInfo } from "@ndn/packet";
22
import { fromUtf8, toHex, toUtf8 } from "@ndn/util";
33
import type { DOHResponse } from "cf-doh";
44
import isValidHostname from "is-valid-hostname";
5+
import type { Promisable } from "type-fest";
56

6-
import { type ChallengeRequest, ErrorCode } from "../packet/mod";
7+
import * as ndncert_dns01 from "../dns01-common";
8+
import { type ChallengeRequest, ErrorCode, type ParameterKV } from "../packet/mod";
79
import { ServerChallenge, type ServerChallengeContext, type ServerChallengeResponse } from "./challenge";
810

911
interface State {
10-
record: string;
11-
token: string;
12+
recordName: string;
13+
recordValue: string;
1214
}
1315

14-
/** The "dns" challenge where client creates a DNS TXT record. */
15-
export class ServerDnsChallenge implements ServerChallenge<State> {
16-
public readonly challengeId = "dns";
17-
public readonly timeLimit = 300000;
18-
public readonly retryLimit = 3;
19-
16+
abstract class ServerDnsChallengeBase {
2017
private readonly assignmentPolicy?: ServerDnsChallenge.AssignmentPolicy;
2118
private readonly dohServer: string;
2219

@@ -48,18 +45,22 @@ export class ServerDnsChallenge implements ServerChallenge<State> {
4845
return { fail: ErrorCode.NameNotAllowed };
4946
}
5047

51-
const record = `_ndncert-challenge.${domain}`;
48+
const recordName = ndncert_dns01.toRecordName(domain);
5249
const token = toHex(SigInfo.generateNonce(16));
53-
context.challengeState = { record, token };
50+
const [parameters, recordValue] = await this.makeNeedRecord(context, recordName, token);
51+
context.challengeState = { recordName, recordValue };
5452
return {
5553
challengeStatus: "need-record",
56-
parameters: {
57-
"record-name": toUtf8(record),
58-
"expected-value": toUtf8(token),
59-
},
54+
parameters,
6055
};
6156
}
6257

58+
protected abstract makeNeedRecord(
59+
context: ServerChallengeContext<State>,
60+
recordName: string,
61+
token: string,
62+
): Promisable<[parameters: ParameterKV, recordValue: string]>;
63+
6364
private async process1(
6465
request: ChallengeRequest,
6566
{ challengeState }: ServerChallengeContext<State>,
@@ -81,9 +82,9 @@ export class ServerDnsChallenge implements ServerChallenge<State> {
8182
};
8283
}
8384

84-
private async checkRecord({ record, token }: State): Promise<boolean> {
85+
private async checkRecord({ recordName, recordValue }: State): Promise<boolean> {
8586
const url = new URL(this.dohServer);
86-
url.searchParams.set("name", record);
87+
url.searchParams.set("name", recordName);
8788
url.searchParams.set("type", "TXT");
8889

8990
const res = await fetch(url, { headers: { Accept: "application/dns-json" } });
@@ -97,9 +98,9 @@ export class ServerDnsChallenge implements ServerChallenge<State> {
9798
}
9899
for (const answer of j.Answer ?? []) {
99100
if (
100-
[record, `${record}.`].includes(answer.name) &&
101+
[recordName, `${recordName}.`].includes(answer.name) &&
101102
Number(answer.type) === 16 &&
102-
[token, `"${token}"`].includes(answer.data)
103+
[recordValue, `"${recordValue}"`].includes(answer.data)
103104
) {
104105
return true;
105106
}
@@ -108,6 +109,28 @@ export class ServerDnsChallenge implements ServerChallenge<State> {
108109
}
109110
}
110111

112+
/** The "dns" challenge where client creates a DNS TXT record containing a challenge token. */
113+
export class ServerDnsChallenge extends ServerDnsChallengeBase implements ServerChallenge<State> {
114+
public readonly challengeId = "dns";
115+
public readonly timeLimit = 300000;
116+
public readonly retryLimit = 3;
117+
118+
protected override makeNeedRecord(
119+
context: ServerChallengeContext<State>,
120+
recordName: string,
121+
token: string,
122+
): [parameters: ParameterKV, expected: string] {
123+
void context;
124+
return [
125+
{
126+
"record-name": toUtf8(recordName),
127+
"expected-value": toUtf8(token),
128+
},
129+
token,
130+
];
131+
}
132+
}
133+
111134
export namespace ServerDnsChallenge {
112135
/**
113136
* Callback to determine whether the owner of a DNS domain is allowed to obtain
@@ -131,3 +154,24 @@ export namespace ServerDnsChallenge {
131154
dohServer?: string;
132155
}
133156
}
157+
158+
/** The "dns-01" challenge where client creates a DNS TXT record containing a key authorization value. */
159+
export class ServerDns01Challenge extends ServerDnsChallengeBase implements ServerChallenge<State> {
160+
public readonly challengeId = "dns-01";
161+
public readonly timeLimit = 3600000;
162+
public readonly retryLimit = 5;
163+
164+
protected override async makeNeedRecord(
165+
context: ServerChallengeContext<State>,
166+
record: string,
167+
token: string,
168+
): Promise<[parameters: ParameterKV, recordValue: string]> {
169+
void record;
170+
return [
171+
{
172+
token: toUtf8(token),
173+
},
174+
await ndncert_dns01.computeRecordValue(token, context.certRequest.publicKeySpki),
175+
];
176+
}
177+
}

pkg/ndncert/src/server/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ class Context implements ServerChallengeContext {
331331
public readonly sessionKey: ndncert_crypto.SessionKey,
332332
public readonly profile: CaProfile,
333333
) {
334+
this.certRequest = request.certRequest;
334335
this.certRequestPub = request.publicKey;
335336
this.validityPeriod = request.certRequest.validity;
336337
}
@@ -344,6 +345,7 @@ class Context implements ServerChallengeContext {
344345
}
345346

346347
public expiry = Date.now() + BEFORE_CHALLENGE_EXPIRY;
348+
public readonly certRequest: Certificate;
347349
public readonly certRequestPub: NamedVerifier.PublicKey;
348350
public readonly validityPeriod: ValidityPeriod;
349351
public status = Status.BEFORE_CHALLENGE;

0 commit comments

Comments
 (0)