Skip to content

Commit 9c4fec8

Browse files
committed
ndncert: ServerDnsChallenge
1 parent 63c489f commit 9c4fec8

9 files changed

Lines changed: 243 additions & 22 deletions

File tree

pkg/ndncert/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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-
* [ ] DNS
24+
* [X] DNS
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: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"ajv": "^8.20.0",
3939
"b64-lite": "^1.4.0",
4040
"imap-emails": "^1.0.4",
41+
"is-valid-hostname": "^1.0.2",
4142
"nodemailer": "^9.0.1",
4243
"p-timeout": "^7.0.1",
4344
"tslib": "^2.8.1",
@@ -48,6 +49,9 @@
4849
"@ndn/fw": "workspace:^",
4950
"@ndn/repo": "workspace:*",
5051
"@types/b64-lite": "^1.4.2",
51-
"@types/mailparser": "^3.4.6"
52+
"@types/koa": "^3.0.3",
53+
"@types/mailparser": "^3.4.6",
54+
"cf-doh": "^1.1.1",
55+
"koa": "^3.2.1"
5256
}
5357
}

pkg/ndncert/src/client/request.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export async function requestCertificate({
4949
}: ClientOptions): Promise<Certificate> {
5050
cOpts = {
5151
describe: `NDNCERT-client(${profile.prefix}, REQUEST, ${privateKey.name})`,
52-
retx: 4,
52+
retx: 4, // XXX retransmission may cause server rejection for duplicate SigNonce
5353
...cOpts,
5454
verifier: profile.publicKey,
5555
};

pkg/ndncert/src/packet/challenge-response.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ function checkFieldsByStatus({
6666
];
6767
}
6868
default: {
69-
if (!challengeStatus || !remainingTries || !remainingTime) {
69+
if (!challengeStatus || remainingTries === undefined || remainingTime === undefined) {
7070
throw new Error("challengeStatus, remainingTries, remainingTime missing");
7171
}
7272
return () => [

pkg/ndncert/src/server/challenge.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Name } from "@ndn/packet";
22

3-
import type { CaProfile, ChallengeRequest, ParameterKV } from "../packet/mod";
3+
import type { CaProfile, ChallengeRequest, ErrorCode, ParameterKV } from "../packet/mod";
44

55
/** Server side of a challenge. */
66
export interface ServerChallenge<State = any> {
@@ -47,6 +47,9 @@ export interface ServerChallengeResponse {
4747
*/
4848
success?: boolean;
4949

50+
/** If specified, challenge has failed permanently and server will return error. */
51+
fail?: ErrorCode;
52+
5053
/**
5154
* If true, this request counts as one failed try and decrements remaining tries.
5255
* @defaultValue false
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { SigInfo } from "@ndn/packet";
2+
import { fromUtf8, toHex, toUtf8 } from "@ndn/util";
3+
import type { DOHResponse } from "cf-doh";
4+
import isValidHostname from "is-valid-hostname";
5+
6+
import { type ChallengeRequest, ErrorCode } from "../packet/mod";
7+
import type { ServerChallenge, ServerChallengeContext, ServerChallengeResponse } from "./challenge";
8+
9+
interface State {
10+
record: string;
11+
token: string;
12+
}
13+
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+
20+
private readonly dohServer: string;
21+
22+
constructor({
23+
dohServer = "https://cloudflare-dns.com/dns-query",
24+
}: ServerDnsChallenge.Options = {}) {
25+
this.dohServer = dohServer;
26+
}
27+
28+
public async process(request: ChallengeRequest, context: ServerChallengeContext<State>): Promise<ServerChallengeResponse> {
29+
if (!context.challengeState) {
30+
return this.process0(request, context);
31+
}
32+
return this.process1(request, context);
33+
}
34+
35+
private async process0(request: ChallengeRequest, context: ServerChallengeContext<State>): Promise<ServerChallengeResponse> {
36+
const { domain: domainWire } = request.parameters;
37+
let domain: string;
38+
if (!domainWire || !isValidHostname(domain = fromUtf8(domainWire))) {
39+
return { fail: ErrorCode.InvalidParameters };
40+
}
41+
42+
const record = `_ndncert-challenge.${domain}`;
43+
const token = toHex(SigInfo.generateNonce(16));
44+
context.challengeState = { record, token };
45+
return {
46+
challengeStatus: "need-record",
47+
parameters: {
48+
"record-name": toUtf8(record),
49+
"expected-value": toUtf8(token),
50+
},
51+
};
52+
}
53+
54+
private async process1(
55+
request: ChallengeRequest,
56+
{ challengeState }: ServerChallengeContext<State>,
57+
): Promise<ServerChallengeResponse> {
58+
const { confirmation } = request.parameters;
59+
if (!confirmation) {
60+
return { fail: ErrorCode.InvalidParameters };
61+
}
62+
63+
let ok: boolean;
64+
try {
65+
ok = await this.checkRecord(challengeState!);
66+
} catch {
67+
ok = false;
68+
}
69+
return ok ? { success: true } : {
70+
decrementRetry: true,
71+
challengeStatus: "wrong-record",
72+
};
73+
}
74+
75+
private async checkRecord({ record, token }: State): Promise<boolean> {
76+
const url = new URL(this.dohServer);
77+
url.searchParams.set("name", record);
78+
url.searchParams.set("type", "TXT");
79+
80+
console.log("sdccr", url);
81+
const res = await fetch(url, { headers: { Accept: "application/dns-json" } });
82+
console.log("sdccr", res);
83+
if (res.status !== 200) {
84+
return false;
85+
}
86+
const j: DOHResponse = await res.json();
87+
console.log("sdccr", j);
88+
89+
if (Number(j.Status) !== 0) {
90+
return false;
91+
}
92+
for (const answer of j.Answer ?? []) {
93+
if (
94+
[record, `${record}.`].includes(answer.name) &&
95+
Number(answer.type) === 16 &&
96+
[token, `"${token}"`].includes(answer.data)
97+
) {
98+
return true;
99+
}
100+
}
101+
return false;
102+
}
103+
}
104+
105+
export namespace ServerDnsChallenge {
106+
export interface Options {
107+
/**
108+
* DNS-over-HTTPS server with application/dns-json capability.
109+
* Common choices includes:
110+
* - https://cloudflare-dns.com/dns-query
111+
* - https://dns.google/resolve
112+
*/
113+
dohServer?: string;
114+
}
115+
}

pkg/ndncert/src/server/mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export type * from "./challenge";
2+
export * from "./dns-challenge";
23
export * from "./email-challenge_node";
34
export * from "./nop-challenge";
45
export * from "./pin-challenge";

pkg/ndncert/src/server/server.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,17 +232,32 @@ export class Server {
232232

233233
private async continueChallenge(now: number, request: ChallengeRequest, context: Context) {
234234
const challenge = this.challenges.get(context.challengeId!)!;
235-
const {
235+
let {
236236
success = false,
237+
fail,
237238
decrementRetry = false,
238239
challengeStatus = "error",
239240
parameters,
240241
} = await challenge.process(request, context);
241242
if (success) {
242243
return this.finishChallenge(now, request, context);
243244
}
245+
244246
if (decrementRetry) {
245247
context.challengeRemainingTries! -= 1;
248+
if (context.challengeRemainingTries! <= 0) {
249+
fail ??= ErrorCode.OutOfTries;
250+
}
251+
}
252+
253+
const remainingTime = context.expiry - now;
254+
if (remainingTime <= 0) {
255+
fail ??= ErrorCode.OutOfTime;
256+
}
257+
258+
if (fail) {
259+
this.deleteContext(request);
260+
return ErrorMsg.makeData(fail, request.interest, this.signer);
246261
}
247262

248263
const response = await ChallengeResponse.build({
@@ -251,7 +266,7 @@ export class Server {
251266
challengeStatus,
252267
parameters,
253268
remainingTries: context.challengeRemainingTries!,
254-
remainingTime: context.expiry - now,
269+
remainingTime,
255270
});
256271
return response.data;
257272
}

0 commit comments

Comments
 (0)