Skip to main content

dig_stun/credential/
wire.rs

1//! Wire-level plumbing shared by every other `credential` file (`SPEC.md` §14.3): the DIG
2//! attribute constants, the RFC 4648 §5 base64url (no padding) codec the `NONCE` attribute uses,
3//! the SPKI shape check both the server (`request::classify_request`) and the client
4//! (`signed_client::query_reflexive_address_signed`) apply, and the low-level TLV attribute
5//! writer every encoder in this module builds on.
6
7use crate::codec::StunError;
8
9/// The requester's TLS-leaf SPKI (`SPEC.md` §14.3.1).
10pub const ATTR_DIG_IDENTITY: u16 = 0xD160;
11/// The requester's signature over the server's nonce (`SPEC.md` §14.3.2).
12pub const ATTR_DIG_SIGNATURE: u16 = 0xD161;
13/// RFC 5389 §15.6 `ERROR-CODE`.
14pub const ATTR_ERROR_CODE: u16 = 0x0009;
15/// RFC 5389 §15.7 `REALM`; this crate's servers always carry [`REALM`] as its value.
16pub const ATTR_REALM: u16 = 0x0014;
17/// RFC 5389 §15.8 `NONCE`; value per [`crate::credential::NonceIssuer`].
18pub const ATTR_NONCE: u16 = 0x0015;
19/// Method Binding, class Error Response (RFC 5389 §6).
20pub const BINDING_ERROR: u16 = 0x0111;
21
22/// The realm value every DIG credential challenge carries, and the mechanism discriminator a
23/// client checks before treating a `401` as one it can satisfy (`SPEC.md` §14.3, §14.9).
24pub const REALM: &str = "dig-stun";
25/// The only credential version this crate speaks. A receiver MUST answer `400` to any other
26/// version (`SPEC.md` §14.8, §12).
27pub const CREDENTIAL_VERSION: u8 = 0x01;
28
29/// Byte length of a P-256 `SubjectPublicKeyInfo` DER with an uncompressed point — the `spki_der`
30/// carried by `DIG-IDENTITY` and returned by [`crate::credential::StunSigner::spki_der`]
31/// (`SPEC.md` §14.3.1).
32pub const P256_SPKI_LEN: usize = 91;
33/// The constant first 26 bytes of every such SPKI: the ASN.1 `AlgorithmIdentifier` for
34/// id-ecPublicKey / prime256v1 (`SPEC.md` §14.3.1). Byte 26 (the 27th byte, checked separately by
35/// this crate's internal shape validator) is always `0x04`, the uncompressed SEC1 point marker.
36pub const P256_SPKI_PREFIX: [u8; 26] = [
37    0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a,
38    0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00,
39];
40/// Upper bound on the DER-encoded ECDSA-P256-SHA256 signature carried by `DIG-SIGNATURE`, after
41/// its 1-byte version prefix is stripped (`SPEC.md` §14.3.2).
42pub const MAX_SIGNATURE_LEN: usize = 72;
43/// Lower bound on that same DER signature (a value shorter than this cannot be a well-formed
44/// ASN.1 `Ecdsa-Sig-Value`) — not spec-named, kept private since it is a parser detail rather
45/// than a public contract (`SPEC.md` §14.3.2: "shorter than 9 ... bytes").
46const MIN_SIGNATURE_ATTR_LEN: usize = 9;
47
48/// RFC 5389 §15.6 class 4, number 00 — "Bad Request" (`SPEC.md` §14.3.3).
49pub const ERR_BAD_REQUEST: u16 = 400;
50/// RFC 8489's spelling of RFC 5389's `401` — "Unauthenticated" (`SPEC.md` §14.3.3).
51pub const ERR_UNAUTHENTICATED: u16 = 401;
52/// "Stale Nonce" — a signed request whose nonce has aged out of its 60-120s window
53/// (`SPEC.md` §14.3.3, §14.4).
54pub const ERR_STALE_NONCE: u16 = 438;
55
56/// Errors classifying or verifying a DIG credential (`SPEC.md` §14.5-§14.6). Exhaustive: this
57/// type is new in `0.2.0` and consumed only within this crate and by callers matching on
58/// [`crate::credential::decide`]'s inputs, so a variant addition is tracked as an ordinary
59/// breaking change rather than absorbed silently.
60#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
61pub enum CredentialError {
62    /// The datagram failed the crate's ordinary Binding-request checks (`SPEC.md` §2.5) before a
63    /// single DIG attribute was inspected — truncated, bad magic cookie, wrong message type, or a
64    /// declared length that overruns the datagram.
65    #[error("underlying STUN error: {0}")]
66    Stun(#[from] StunError),
67    /// A DIG credential attribute violated its wire shape (§14.3.1/§14.3.2), appeared out of the
68    /// required order, was duplicated, or appeared without its required companion attribute
69    /// (`SPEC.md` §14.5). Maps to a `400` response.
70    #[error("malformed DIG credential attribute")]
71    Malformed,
72    /// The signature did not verify against the carried SPKI over the expected preimage
73    /// (`SPEC.md` §14.6). Maps to a `401` challenge (`SPEC.md` §14.7 row 6) — a bad signature is
74    /// treated exactly like an unauthenticated ask, never surfaced as a distinct wire code.
75    #[error("signature did not verify")]
76    BadSignature,
77}
78
79/// Whether `spki` is exactly the shape `dig-tls` mints (`SPEC.md` §14.3.1): 91 bytes, the constant
80/// [`P256_SPKI_PREFIX`], then the `0x04` uncompressed-point marker. `spki` here is the SPKI DER
81/// WITHOUT the 1-byte credential-version prefix that precedes it on the wire — callers holding the
82/// raw 92-byte `DIG-IDENTITY` value slice off `value[1..]` first.
83///
84/// Used both by the server ([`crate::credential::classify_request`], validating an incoming
85/// `DIG-IDENTITY`) and by the client ([`crate::credential::query_reflexive_address_signed`],
86/// validating its own signer's claimed SPKI before sending anything).
87pub(super) fn is_valid_spki_der(spki: &[u8]) -> bool {
88    spki.len() == P256_SPKI_LEN && spki[..26] == P256_SPKI_PREFIX && spki[26] == 0x04
89}
90
91/// Whether a raw (post-version-byte) `DIG-SIGNATURE` value is in bounds (`SPEC.md` §14.3.2):
92/// between 8 and [`MAX_SIGNATURE_LEN`] bytes.
93pub(super) fn is_valid_signature_der_len(sig_der: &[u8]) -> bool {
94    let attr_len = sig_der.len() + 1; // + the version byte this length excludes
95    (MIN_SIGNATURE_ATTR_LEN..=1 + MAX_SIGNATURE_LEN).contains(&attr_len)
96}
97
98/// Append one TLV attribute — `[type:2][length:2][value][pad to 4 bytes]` — to `msg` (RFC 5389
99/// §15). Shared by every encoder in this module so the padding arithmetic exists in one place.
100pub(super) fn write_attr(msg: &mut Vec<u8>, attr_type: u16, value: &[u8]) {
101    msg.extend_from_slice(&attr_type.to_be_bytes());
102    msg.extend_from_slice(&(value.len() as u16).to_be_bytes());
103    msg.extend_from_slice(value);
104    let pad = (4 - (value.len() % 4)) % 4;
105    msg.resize(msg.len() + pad, 0);
106}
107
108/// Write the 20-byte STUN header — message type, the attribute-section length that follows, the
109/// magic cookie, and the transaction id (`SPEC.md` §2.2) — shared by every message this module
110/// encodes (requests and error responses alike).
111pub(super) fn write_header(
112    msg: &mut Vec<u8>,
113    msg_type: u16,
114    attrs_len: u16,
115    txid: &crate::codec::TransactionId,
116) {
117    msg.extend_from_slice(&msg_type.to_be_bytes());
118    msg.extend_from_slice(&attrs_len.to_be_bytes());
119    msg.extend_from_slice(&crate::codec::MAGIC_COOKIE.to_be_bytes());
120    msg.extend_from_slice(txid);
121}
122
123/// RFC 4648 §5 base64url alphabet, no padding.
124const B64URL_ALPHABET: &[u8; 64] =
125    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
126
127/// Encode `input` as base64url with no padding (RFC 4648 §5) — the `NONCE` attribute's wire form
128/// of the raw bytes [`crate::credential::NonceIssuer::issue`] returns (`SPEC.md` §14.4). Hand-rolled
129/// rather than a dependency: the crate's `SPEC.md` §9 permits no new dependency for this, and the
130/// alphabet + no-padding rule are both fixed and small.
131pub(super) fn base64url_encode(input: &[u8]) -> String {
132    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
133    for chunk in input.chunks(3) {
134        let b0 = chunk[0];
135        let b1 = chunk.get(1).copied().unwrap_or(0);
136        let b2 = chunk.get(2).copied().unwrap_or(0);
137        let n = ((b0 as u32) << 16) | ((b1 as u32) << 8) | (b2 as u32);
138        out.push(B64URL_ALPHABET[((n >> 18) & 0x3f) as usize] as char);
139        out.push(B64URL_ALPHABET[((n >> 12) & 0x3f) as usize] as char);
140        if chunk.len() > 1 {
141            out.push(B64URL_ALPHABET[((n >> 6) & 0x3f) as usize] as char);
142        }
143        if chunk.len() > 2 {
144            out.push(B64URL_ALPHABET[(n & 0x3f) as usize] as char);
145        }
146    }
147    out
148}
149
150/// Decode base64url with no padding, rejecting any byte outside the alphabet or a final group of
151/// exactly 1 leftover character (which cannot decode to a whole byte). The inverse of
152/// [`base64url_encode`]; returns `None` on any malformed input rather than panicking, since the
153/// caller ([`crate::credential::NonceIssuer::check`]) feeds it attacker-controlled bytes.
154pub(super) fn base64url_decode(input: &[u8]) -> Option<Vec<u8>> {
155    fn char_value(c: u8) -> Option<u8> {
156        match c {
157            b'A'..=b'Z' => Some(c - b'A'),
158            b'a'..=b'z' => Some(c - b'a' + 26),
159            b'0'..=b'9' => Some(c - b'0' + 52),
160            b'-' => Some(62),
161            b'_' => Some(63),
162            _ => None,
163        }
164    }
165
166    let mut out = Vec::with_capacity(input.len() * 3 / 4);
167    for group in input.chunks(4) {
168        let vals: Vec<u8> = group
169            .iter()
170            .map(|&c| char_value(c))
171            .collect::<Option<Vec<u8>>>()?;
172        match vals.len() {
173            4 => {
174                let n = ((vals[0] as u32) << 18)
175                    | ((vals[1] as u32) << 12)
176                    | ((vals[2] as u32) << 6)
177                    | (vals[3] as u32);
178                out.push((n >> 16) as u8);
179                out.push((n >> 8) as u8);
180                out.push(n as u8);
181            }
182            3 => {
183                let n =
184                    ((vals[0] as u32) << 18) | ((vals[1] as u32) << 12) | ((vals[2] as u32) << 6);
185                out.push((n >> 16) as u8);
186                out.push((n >> 8) as u8);
187            }
188            2 => {
189                let n = ((vals[0] as u32) << 18) | ((vals[1] as u32) << 12);
190                out.push((n >> 16) as u8);
191            }
192            _ => return None, // a lone trailing char cannot decode to a whole byte
193        }
194    }
195    Some(out)
196}