Skip to content

jmcore.nick_auth

jmcore.nick_auth

Shared JMP-0005 nick ownership authentication primitives.

Classes

NickAuthChallenge

Bases: _NickAuthPayload

Source code in jmcore/src/jmcore/nick_auth.py
172
173
174
175
176
177
178
179
180
181
182
183
184
class NickAuthChallenge(_NickAuthPayload):
    challenge: str
    directory_id: str = Field(alias="directory-id")

    @field_validator("challenge")
    @classmethod
    def validate_challenge(cls, value: str) -> str:
        return _validate_lower_hex_64(value, "challenge")

    @field_validator("directory_id")
    @classmethod
    def validate_directory_id(cls, value: str) -> str:
        return validate_directory_id(value)
Attributes
challenge: str instance-attribute
directory_id: str = Field(alias='directory-id') class-attribute instance-attribute
Methods:
validate_challenge(value: str) -> str classmethod
Source code in jmcore/src/jmcore/nick_auth.py
176
177
178
179
@field_validator("challenge")
@classmethod
def validate_challenge(cls, value: str) -> str:
    return _validate_lower_hex_64(value, "challenge")
validate_directory_id(value: str) -> str classmethod
Source code in jmcore/src/jmcore/nick_auth.py
181
182
183
184
@field_validator("directory_id")
@classmethod
def validate_directory_id(cls, value: str) -> str:
    return validate_directory_id(value)

NickAuthMode

Bases: StrEnum

Source code in jmcore/src/jmcore/nick_auth.py
28
29
30
31
class NickAuthMode(StrEnum):
    PREFER_VERIFIED = "prefer_verified"
    REQUIRE_VERIFIED = "require_verified"
    DISABLED = "disabled"
Attributes
DISABLED = 'disabled' class-attribute instance-attribute
PREFER_VERIFIED = 'prefer_verified' class-attribute instance-attribute
REQUIRE_VERIFIED = 'require_verified' class-attribute instance-attribute

NickAuthProof

Bases: _NickAuthPayload

Source code in jmcore/src/jmcore/nick_auth.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
class NickAuthProof(_NickAuthPayload):
    pubkey: str
    signature: str

    @field_validator("pubkey")
    @classmethod
    def validate_pubkey(cls, value: str) -> str:
        if _COMPRESSED_PUBKEY_RE.fullmatch(value) is None:
            raise ValueError("pubkey must be a lowercase compressed secp256k1 public key")
        try:
            pubkey = CPubKey(bytes.fromhex(value))
            if not pubkey.is_fullyvalid():
                raise ValueError("invalid public key")
        except ValueError as exc:
            raise ValueError("pubkey is not a valid secp256k1 public key") from exc
        return value

    @field_validator("signature")
    @classmethod
    def validate_signature(cls, value: str) -> str:
        _decode_canonical_der_signature(value)
        return value
Attributes
pubkey: str instance-attribute
signature: str instance-attribute
Methods:
validate_pubkey(value: str) -> str classmethod
Source code in jmcore/src/jmcore/nick_auth.py
191
192
193
194
195
196
197
198
199
200
201
202
@field_validator("pubkey")
@classmethod
def validate_pubkey(cls, value: str) -> str:
    if _COMPRESSED_PUBKEY_RE.fullmatch(value) is None:
        raise ValueError("pubkey must be a lowercase compressed secp256k1 public key")
    try:
        pubkey = CPubKey(bytes.fromhex(value))
        if not pubkey.is_fullyvalid():
            raise ValueError("invalid public key")
    except ValueError as exc:
        raise ValueError("pubkey is not a valid secp256k1 public key") from exc
    return value
validate_signature(value: str) -> str classmethod
Source code in jmcore/src/jmcore/nick_auth.py
204
205
206
207
208
@field_validator("signature")
@classmethod
def validate_signature(cls, value: str) -> str:
    _decode_canonical_der_signature(value)
    return value

NickAuthResult

Bases: _NickAuthPayload

Source code in jmcore/src/jmcore/nick_auth.py
211
212
213
214
215
216
217
218
219
class NickAuthResult(_NickAuthPayload):
    code: Literal["ok", "malformed", "expired", "invalid", "policy"]
    verified: bool

    @model_validator(mode="after")
    def validate_result(self) -> Self:
        if self.verified != (self.code == "ok"):
            raise ValueError("only code 'ok' may have verified=true")
        return self
Attributes
code: Literal['ok', 'malformed', 'expired', 'invalid', 'policy'] instance-attribute
verified: bool instance-attribute
Methods:
validate_result() -> Self
Source code in jmcore/src/jmcore/nick_auth.py
215
216
217
218
219
@model_validator(mode="after")
def validate_result(self) -> Self:
    if self.verified != (self.code == "ok"):
        raise ValueError("only code 'ok' may have verified=true")
    return self

Functions:

build_nick_auth_signed_message(challenge: str, directory_id: str, handshake_sha256: str, nick: str, pubkey: str) -> bytes

Source code in jmcore/src/jmcore/nick_auth.py
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
def build_nick_auth_signed_message(
    challenge: str,
    directory_id: str,
    handshake_sha256: str,
    nick: str,
    pubkey: str,
) -> bytes:
    challenge = _validate_lower_hex_64(challenge, "challenge")
    directory_id = validate_directory_id(directory_id)
    handshake_sha256 = _validate_lower_hex_64(handshake_sha256, "handshake-sha256")
    if not is_valid_nick(nick):
        raise ValueError("nick must use the JMP-0001 nick format")
    NickAuthProof.validate_pubkey(pubkey)
    transcript = f"nick-auth|{challenge}|{directory_id}|{handshake_sha256}|{nick}|{pubkey}"
    return transcript.encode("ascii") + _NICK_AUTH_DOMAIN

create_nick_auth_proof(identity: NickIdentity, challenge: str, directory_id: str, handshake_line: str) -> NickAuthProof

Source code in jmcore/src/jmcore/nick_auth.py
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
def create_nick_auth_proof(
    identity: NickIdentity,
    challenge: str,
    directory_id: str,
    handshake_line: str,
) -> NickAuthProof:
    handshake_sha256 = handshake_line_sha256(handshake_line)
    message = build_nick_auth_signed_message(
        challenge,
        directory_id,
        handshake_sha256,
        identity.nick,
        identity.public_key_hex,
    )
    return NickAuthProof.from_payload(
        {
            "pubkey": identity.public_key_hex,
            "signature": identity.sign_bytes(message),
        }
    )

directory_id_for_endpoint(host: str, port: int) -> str

Derive a JMP-0005 identity from a selected Tor v3 endpoint.

Source code in jmcore/src/jmcore/nick_auth.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
def directory_id_for_endpoint(host: str, port: int) -> str:
    """Derive a JMP-0005 identity from a selected Tor v3 endpoint."""
    if not isinstance(host, str) or isinstance(port, bool) or not isinstance(port, int):
        raise ValueError("host and port have invalid types")
    if not 1 <= port <= 65535:
        raise ValueError("port must be between 1 and 65535")
    if not host or host != host.strip() or any(char.isspace() for char in host):
        raise ValueError("invalid host")

    normalized_host = host.lower()
    if normalized_host.endswith("."):
        normalized_host = normalized_host[:-1]
    if normalized_host.endswith(".onion"):
        if _ONION_HOST_RE.fullmatch(normalized_host) is None:
            raise ValueError("onion endpoint must use a 56-character v3 hostname")
        return f"{normalized_host}:{port}"
    raise ValueError("non-onion endpoint requires an explicitly configured directory-id")

handshake_line_sha256(line: str) -> str

Source code in jmcore/src/jmcore/nick_auth.py
222
223
224
225
def handshake_line_sha256(line: str) -> str:
    if not isinstance(line, str):
        raise TypeError("handshake line must be str")
    return hashlib.sha256(line.encode("utf-8")).hexdigest()

parse_strict_json_object(payload: str | bytes) -> dict[str, Any]

Parse a JSON object while rejecting duplicate keys and non-finite numbers.

Source code in jmcore/src/jmcore/nick_auth.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def parse_strict_json_object(payload: str | bytes) -> dict[str, Any]:
    """Parse a JSON object while rejecting duplicate keys and non-finite numbers."""
    if isinstance(payload, bytes):
        payload = payload.decode("utf-8")
    if not isinstance(payload, str):
        raise TypeError("JSON payload must be str or bytes")
    parsed = json.loads(
        payload,
        object_pairs_hook=_object_without_duplicate_keys,
        parse_constant=_reject_json_constant,
    )
    if not isinstance(parsed, dict):
        raise ValueError("nick authentication payload must be a JSON object")
    return cast(dict[str, Any], parsed)

validate_directory_endpoint(value: str) -> str

Validate an exact host:port key used to select an expected directory identity.

Source code in jmcore/src/jmcore/nick_auth.py
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def validate_directory_endpoint(value: str) -> str:
    """Validate an exact host:port key used to select an expected directory identity."""
    if not isinstance(value, str) or value != value.strip():
        raise ValueError("invalid directory endpoint")
    try:
        host, port_text = value.rsplit(":", 1)
        port = int(port_text)
    except ValueError as exc:
        raise ValueError("directory endpoint must use host:port") from exc
    if (
        not host
        or ":" in host
        or any(char.isspace() for char in host)
        or not 1 <= port <= 65535
        or value != f"{host}:{port}"
    ):
        raise ValueError("directory endpoint must use canonical host:port")
    return value

validate_directory_id(value: str) -> str

Validate and return a JMP-0005 directory identity.

Source code in jmcore/src/jmcore/nick_auth.py
69
70
71
72
73
def validate_directory_id(value: str) -> str:
    """Validate and return a JMP-0005 directory identity."""
    if not isinstance(value, str) or _DIRECTORY_ID_RE.fullmatch(value) is None:
        raise ValueError("invalid directory-id")
    return value

verify_nick_auth_proof(proof: NickAuthProof, expected_challenge: str, expected_directory_id: str, handshake_line: str, nick: str, protocol_version: int) -> bool

Source code in jmcore/src/jmcore/nick_auth.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
def verify_nick_auth_proof(
    proof: NickAuthProof,
    expected_challenge: str,
    expected_directory_id: str,
    handshake_line: str,
    nick: str,
    protocol_version: int,
) -> bool:
    try:
        handshake_sha256 = handshake_line_sha256(handshake_line)
        if nick_from_pubkey_hex(proof.pubkey, protocol_version) != nick:
            return False

        signature = _decode_canonical_der_signature(proof.signature)
        message = build_nick_auth_signed_message(
            expected_challenge,
            expected_directory_id,
            handshake_sha256,
            nick,
            proof.pubkey,
        )
        message_hash = bitcoin_message_hash_bytes(message)
        s_length = signature[4 + signature[3] + 1]
        s_start = 4 + signature[3] + 2
        s_value = int.from_bytes(signature[s_start : s_start + s_length], "big")
        if s_value > _SECP256K1_ORDER // 2:
            return False
        pubkey = CPubKey(bytes.fromhex(proof.pubkey))
        if not pubkey.is_fullyvalid():
            return False
        return pubkey.verify(message_hash, signature)
    except (TypeError, ValueError):
        return False