macula_node_keys (macula v11.4.0)
View SourceA node's keys, one per purpose, in the node's crypto profile, stored as plan decision D6 describes, and signing with them as decisions D4 and D7 describe.
A node holds an identity key and a CONNECT key, and a station instance also holds a TLS key. Each key serves exactly one purpose. A key is a list of components in the order of the profile's signature: ML-DSA-87 first, then the classical half when the profile's signature for that purpose is hybrid. The TLS key is ML-DSA-87 alone in both profiles.
A realm, an org and a foundation each hold a key of that purpose, which signs their records with the identity key's algorithms.
An ML-DSA-87 component stores its 4,896-byte expanded private key and its public key. An RSA-PSS component stores a DER-encoded RSAPrivateKey and RSAPublicKey. On load, every public key is derived again from its private key and must equal the stored one, and every component passes a sign-and-verify round trip. A key file its group or others can read is refused.
A process that holds keys shows them through redacted/1, and the primary logger filter redacted_log_event/2, which install_log_redaction/0 puts in place, keeps their private halves out of crash and diagnostics reports.
A key with one component signs with ML-DSA-87 alone. A hybrid key signs with Macula's composite ML-DSA-87-PS384: both halves sign M' = Prefix || Label || len(ctx) || ctx || SHA-512(M), with an empty ctx and ML-DSA-87 under an empty context; the signature is the ML-DSA-87 signature followed by the RSA-PSS signature, and the carried public key is the ML-DSA-87 key followed by the DER RSAPublicKey. It is valid only if both halves verify.
An identity key has a node_id: SHA-256 over the label MACULA-NODE-ID-V1, a zero byte, the length and ASCII name of the profile, and the identity key as carried, and that node_id is its key id. Every other key has no node_id, and its key id is SHA-256 over the label MACULA-KEY-ID-V1, a zero byte, the length and ASCII name of the profile, and the key as carried.
An identity key can be generated for a puzzle difficulty, so its node_id starts with that many zero bits. Each try makes a new ML-DSA-87 half; a hybrid key keeps its RSA-PSS half across tries, since the node_id covers both halves.
See plans/PLAN_POST_QUANTUM_SECURITY.md, decisions D4, D5, D6 and D7.
Summary
Functions
Whether bytes are a key in its one carried form for a profile (D13): the 2,592-byte ML-DSA-87 key, followed in pq_hybrid by a DER RSAPublicKey that encodes back to the same bytes, with the profile's modulus size and public exponent. It says nothing about who holds the key.
Generate the key for a purpose in a profile.
Generate the key for a purpose in a profile, with options. With puzzle_difficulty, generate an identity key whose node_id meets that difficulty (see puzzle_solved/2), in about 2^Difficulty tries.
Put the primary logger filter redacted_log_event/2, with the macula application's modules, in place, unless the node already holds it. Another value held under its id, as an earlier load can leave behind, is replaced. The application's start and every pool call it, and nothing removes it, since a process that holds a key can outlive the application. A filter a concurrent call adds first is read back and replaced unless it is this one. Returns ok.
The key id of a key (DESIGN_PQ_SIGNED_FRAMES_AND_RECORDS.md, Signed objects): the node_id of an identity key, and for any other key the key id of the key as carried.
The key id of a key as carried that is not an identity key, under a profile: SHA-256 over the label MACULA-KEY-ID-V1, a zero byte, the length and ASCII name of the profile, and the key. Like a node_id, a key id earns no trust on its own.
Load the key saved for a purpose in a profile, and check it before returning it. A key file its group or others can read is refused.
The node_id of an identity key (D5).
The node_id derived from an identity key as carried, under a profile (D5). A node_id earns no trust on its own: a verifier relies on it only after a signature by the same carried key has verified.
The public key a node carries for this key (D13): the ML-DSA-87 key, followed by the DER RSAPublicKey when the key is hybrid.
The puzzle difficulty of identity keys: a node generates its identity key to meet it (generate/3), and stations check it on the node_id derived from a client's identity key.
Whether a node_id meets a puzzle difficulty: its first Difficulty bits are zero.
A term with the private half of every key it holds replaced by the atom redacted, at any depth: the private value of every map that holds both a public and a private value, as node key components and key pairs do. A function that captured values is replaced by its printed form, since what it captured can hold a key.
The primary logger filter the application installs. In a report event of the otp or macula domain, every key is redacted as redacted/1 does, and a stack frame of a module in Modules shows its arity in place of its arguments, since those can hold a key. Every other event passes unchanged.
Save a key atomically. The temporary file is restricted to its owner before the key is written into it.
Sign a message: ML-DSA-87 alone for a one-component key, Macula's composite ML-DSA-87-PS384 for a hybrid key. The ML-DSA private half travels as {expandedkey, Binary}: the form every supported OTP accepts for crypto:sign/5 (OTP 28 refuses the bare expanded-key binary, OTP 29 refuses the {Pub, Priv} pair).
The size of a signature by a node key in a profile: the ML-DSA-87 signature, followed in pq_hybrid by an RSA-PSS signature as long as the modulus.
Verify a signature with the public key a node carries, under a profile. Malformed input is refused, never raised on. A signature is exactly signature_bytes/1 of the profile long, and one of another length is refused before either half is verified.
Types
-type algorithm() :: mldsa87 | rsa_pss.
-type node_key() :: #{purpose := purpose(), profile := macula_crypto_profile:profile(), components := [component(), ...]}.
-type purpose() :: identity | connect | tls | realm | org | foundation.
-type refusal() :: bad_key_file | key_file_permissions | {unknown_purpose, term()} | {crypto_profile_unknown, term()} | {wrong_purpose, purpose()} | {wrong_profile, macula_crypto_profile:profile()} | {wrong_algorithms, [algorithm()]} | {wrong_key_size, {pos_integer(), pos_integer()}} | private_key_invalid | public_key_mismatch | round_trip_failed.
Functions
-spec carried_key_well_formed(binary(), macula_crypto_profile:profile()) -> boolean().
Whether bytes are a key in its one carried form for a profile (D13): the 2,592-byte ML-DSA-87 key, followed in pq_hybrid by a DER RSAPublicKey that encodes back to the same bytes, with the profile's modulus size and public exponent. It says nothing about who holds the key.
-spec generate(purpose(), macula_crypto_profile:profile()) -> {ok, node_key()} | {error, refusal()}.
Generate the key for a purpose in a profile.
-spec generate(purpose(), macula_crypto_profile:profile(), #{puzzle_difficulty => 0..256}) -> {ok, node_key()} | {error, refusal() | not_an_identity_key}.
Generate the key for a purpose in a profile, with options. With puzzle_difficulty, generate an identity key whose node_id meets that difficulty (see puzzle_solved/2), in about 2^Difficulty tries.
-spec install_log_redaction() -> ok.
Put the primary logger filter redacted_log_event/2, with the macula application's modules, in place, unless the node already holds it. Another value held under its id, as an earlier load can leave behind, is replaced. The application's start and every pool call it, and nothing removes it, since a process that holds a key can outlive the application. A filter a concurrent call adds first is read back and replaced unless it is this one. Returns ok.
-spec key_id(node_key()) -> <<_:256>>.
The key id of a key (DESIGN_PQ_SIGNED_FRAMES_AND_RECORDS.md, Signed objects): the node_id of an identity key, and for any other key the key id of the key as carried.
-spec key_id(binary(), macula_crypto_profile:profile()) -> <<_:256>>.
The key id of a key as carried that is not an identity key, under a profile: SHA-256 over the label MACULA-KEY-ID-V1, a zero byte, the length and ASCII name of the profile, and the key. Like a node_id, a key id earns no trust on its own.
-spec load(file:name_all(), purpose(), macula_crypto_profile:profile()) -> {ok, node_key()} | {error, refusal() | file:posix() | badarg | terminated | system_limit}.
Load the key saved for a purpose in a profile, and check it before returning it. A key file its group or others can read is refused.
-spec node_id(node_key()) -> {ok, <<_:256>>} | {error, not_an_identity_key}.
The node_id of an identity key (D5).
-spec node_id(binary(), macula_crypto_profile:profile()) -> <<_:256>>.
The node_id derived from an identity key as carried, under a profile (D5). A node_id earns no trust on its own: a verifier relies on it only after a signature by the same carried key has verified.
The public key a node carries for this key (D13): the ML-DSA-87 key, followed by the DER RSAPublicKey when the key is hybrid.
-spec puzzle_difficulty() -> 0..256.
The puzzle difficulty of identity keys: a node generates its identity key to meet it (generate/3), and stations check it on the node_id derived from a client's identity key.
-spec puzzle_solved(<<_:256>>, 0..256) -> boolean().
Whether a node_id meets a puzzle difficulty: its first Difficulty bits are zero.
A term with the private half of every key it holds replaced by the atom redacted, at any depth: the private value of every map that holds both a public and a private value, as node key components and key pairs do. A function that captured values is replaced by its printed form, since what it captured can hold a key.
-spec redacted_log_event(logger:log_event(), #{module() => true}) -> logger:log_event().
The primary logger filter the application installs. In a report event of the otp or macula domain, every key is redacted as redacted/1 does, and a stack frame of a module in Modules shows its arity in place of its arguments, since those can hold a key. Every other event passes unchanged.
-spec save(file:name_all(), node_key()) -> ok | {error, term()}.
Save a key atomically. The temporary file is restricted to its owner before the key is written into it.
Sign a message: ML-DSA-87 alone for a one-component key, Macula's composite ML-DSA-87-PS384 for a hybrid key. The ML-DSA private half travels as {expandedkey, Binary}: the form every supported OTP accepts for crypto:sign/5 (OTP 28 refuses the bare expanded-key binary, OTP 29 refuses the {Pub, Priv} pair).
-spec signature_bytes(macula_crypto_profile:profile()) -> pos_integer().
The size of a signature by a node key in a profile: the ML-DSA-87 signature, followed in pq_hybrid by an RSA-PSS signature as long as the modulus.
Verify a signature with the public key a node carries, under a profile. Malformed input is refused, never raised on. A signature is exactly signature_bytes/1 of the profile long, and one of another length is refused before either half is verified.