macula_node_keys (macula v13.2.1)

View Source

A 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.

ML-DSA-87 signs, verifies and derives public keys through macula-mldsa, in macula_crypto_nif (D7, as amended on 2026-09-22); RSA-PSS stays on OTP crypto.

An ML-DSA-87 component stores its public key and its private key as the 32-byte seed (D6); a key generated before D6's amendment keeps the 4,896-byte expanded form OTP made, which loads and signs as before. 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, under an empty context. A hybrid key signs with the IETF LAMPS composite id-MLDSA87-RSA4096-PSS-SHA512 (draft-ietf-lamps-pq-composite-sigs): both halves sign M' = Prefix || Label || len(ctx) || ctx || SHA-512(M), with the label COMPSIG-MLDSA87-RSA4096-PSS-SHA512 and an empty ctx, ML-DSA-87 with the label as its context string and RSA-PSS with SHA-384, MGF1 with SHA-384 and a 48-byte salt. 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. JOSE has no name for it, so it travels under the alg ML-DSA-87-PS384 (D7).

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

Types

A node_id (D5): SHA-256 over the node_id label, the profile and the identity key as carried.

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.

ok when the macula application has no puzzle_difficulty setting; raises {bad_config, {macula, puzzle_difficulty, {not_a_setting, Value}}} when it has one, whatever the value. The difficulty is puzzle_difficulty/0, one constant for the fleet (D30), so a node that sets it would believe it chose a difficulty that nothing reads. The macula application calls this when it starts.

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 NODE'S identity key: one per node, persisted, shared by every pool and by the distribution tunnel.

As node_identity/1, at an explicit path. Exported so a test can point a node at its own key file instead of the machine's: a test that used the configured path would read, and on a fresh machine WRITE, the identity of whatever is running the suite.

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, and every sealed call's or stream's secret by name, in any map: its keys (k_req, k_rep, k_c2p, k_p2c) and a KEM private key's halves (mlkem_dk, p384_priv). 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, the LAMPS composite id-MLDSA87-RSA4096-PSS-SHA512 for a hybrid key.

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

algorithm/0

-type algorithm() :: mldsa87 | rsa_pss.

A node_id (D5): SHA-256 over the node_id label, the profile and the identity key as carried.

component/0

-type component() :: #{algorithm := algorithm(), public := binary(), private := binary()}.

node_id/0

-type node_id() :: <<_:256>>.

node_key/0

-type node_key() ::
          #{purpose := purpose(),
            profile := macula_crypto_profile:profile(),
            components := [component(), ...]}.

purpose/0

-type purpose() :: identity | connect | tls | realm | org | foundation.

refusal/0

-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

carried_key_well_formed(Key, Profile)

-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.

check_puzzle_difficulty()

-spec check_puzzle_difficulty() -> ok.

ok when the macula application has no puzzle_difficulty setting; raises {bad_config, {macula, puzzle_difficulty, {not_a_setting, Value}}} when it has one, whatever the value. The difficulty is puzzle_difficulty/0, one constant for the fleet (D30), so a node that sets it would believe it chose a difficulty that nothing reads. The macula application calls this when it starts.

generate(Purpose, Profile)

-spec generate(purpose(), macula_crypto_profile:profile()) -> {ok, node_key()} | {error, refusal()}.

Generate the key for a purpose in a profile.

generate(Purpose, Profile, Options)

-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.

install_log_redaction()

-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.

key_id(Key)

-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.

key_id(Key, Profile)

-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.

load(Path, Purpose, Profile)

-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.

node_id(Key)

-spec node_id(node_key()) -> {ok, node_id()} | {error, not_an_identity_key}.

The node_id of an identity key (D5).

node_id(IdentityKey, Profile)

-spec node_id(binary(), macula_crypto_profile:profile()) -> node_id().

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.

node_identity(Profile)

-spec node_identity(macula_crypto_profile:profile()) -> {ok, node_key()} | {error, term()}.

THE NODE'S identity key: one per node, persisted, shared by every pool and by the distribution tunnel.

Loads the stored key, or grinds one and stores it if there is none. The path comes from the node_identity_path macula application env, so no caller repeats the convention. Callers hold what they are given; nothing is cached here.

⚠ THIS RUNS BEFORE THE APPLICATION IS STARTED. macula_dist is the -proto_dist macula driver, so net_kernel calls its listen/1 during KERNEL startup when the node is named on the command line, and it starts no application: there is no ensure_all_started anywhere in macula_dist_system. A supervised owner process would not exist to ask at that moment, which is why the serialisation below is in the FILESYSTEM and not in a process.

node_identity(Path, Profile)

-spec node_identity(file:name_all(), macula_crypto_profile:profile()) ->
                       {ok, node_key()} | {error, term()}.

As node_identity/1, at an explicit path. Exported so a test can point a node at its own key file instead of the machine's: a test that used the configured path would read, and on a fresh machine WRITE, the identity of whatever is running the suite.

public_key(_)

-spec public_key(node_key()) -> binary().

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.

puzzle_difficulty()

-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.

puzzle_solved(NodeId, Difficulty)

-spec puzzle_solved(node_id(), 0..256) -> boolean().

Whether a node_id meets a puzzle difficulty: its first Difficulty bits are zero.

redacted(Term)

-spec redacted(term()) -> term().

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, and every sealed call's or stream's secret by name, in any map: its keys (k_req, k_rep, k_c2p, k_p2c) and a KEM private key's halves (mlkem_dk, p384_priv). A function that captured values is replaced by its printed form, since what it captured can hold a key.

redacted_log_event(Event, Modules)

-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.

save(Path, Key)

-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(Message, _)

-spec sign(iodata(), node_key()) -> binary().

Sign a message: ML-DSA-87 alone for a one-component key, the LAMPS composite id-MLDSA87-RSA4096-PSS-SHA512 for a hybrid key.

signature_bytes(_)

-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(Message, Signature, Public, Profile)

-spec verify(iodata(), binary(), binary(), term()) -> boolean().

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.