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
173
174
175
176
177
178
179
180
181
182
183
184
185
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
177
178
179
180
@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
182
183
184
185
@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
29
30
31
32
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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
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:
            PublicKey(bytes.fromhex(value))
        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
192
193
194
195
196
197
198
199
200
201
@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:
        PublicKey(bytes.fromhex(value))
    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
203
204
205
206
207
@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
210
211
212
213
214
215
216
217
218
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
214
215
216
217
218
@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
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
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 _NICK_RE.fullmatch(nick) is None:
        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
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
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
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
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
221
222
223
224
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
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
70
71
72
73
74
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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
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)
        return coincurve_verify(signature, message_hash, bytes.fromhex(proof.pubkey), hasher=None)
    except (TypeError, ValueError):
        return False