macula_node_keys (macula v15.1.0)

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 docs/design/DECISIONS_POST_QUANTUM.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.

Where identities are stored: the identity_dir macula application env, or identity in the platform's per-user data directory. filename:basedir/2 rather than a path of our own invention, so the files land where the platform says a user's application data belongs.

ok, unless the node_identity_path application env that macula#76 removed is still set: then {error, {node_identity_path_removed, #{node_identity_path, use_instead}}}, naming the setting and what replaces it. A consumer that still sets it, as a station harness does with one key per station, would otherwise fall back to the account's shared directory without a word: several stations, one key, one node_id, the unnoticed identity change #76 exists to prevent. The application's start and every stored identity refuse with it.

The file of the identity Name in Profile under Dir: <Name>.<Profile>.key. A name is 1 to 64 lowercase letters, digits, - and _, starting with a letter or digit, so it can name no other directory and no other profile's key; any other is refused as {identity_name, invalid}.

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 identity key stored at Path: loaded, or ground and stored there if there is none. Two callers that find no file at once end up with the same key (see claimed/4), and a file that will not load is refused, never replaced. Exported so a test can point a node at its own key file instead of the account's: a test that used the account's 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); and a sealed group's epoch key (the key of a map that is an epoch of macula_group_epoch). 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.

The identity a program under this account stores for Name in Profile (macula#76): loaded from identity_path(identity_dir(), Name, Profile), or ground and stored there if there is none, with the path it is stored at.

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.

identity_dir()

-spec identity_dir() -> file:filename().

Where identities are stored: the identity_dir macula application env, or identity in the platform's per-user data directory. filename:basedir/2 rather than a path of our own invention, so the files land where the platform says a user's application data belongs.

identity_env_checked()

-spec identity_env_checked() -> ok | {error, {node_identity_path_removed, map()}}.

ok, unless the node_identity_path application env that macula#76 removed is still set: then {error, {node_identity_path_removed, #{node_identity_path, use_instead}}}, naming the setting and what replaces it. A consumer that still sets it, as a station harness does with one key per station, would otherwise fall back to the account's shared directory without a word: several stations, one key, one node_id, the unnoticed identity change #76 exists to prevent. The application's start and every stored identity refuse with it.

identity_path(Dir, Name, Profile)

-spec identity_path(file:filename(), term(), macula_crypto_profile:profile()) ->
                       {ok, file:filename()} | {error, {identity_name, invalid}}.

The file of the identity Name in Profile under Dir: <Name>.<Profile>.key. A name is 1 to 64 lowercase letters, digits, - and _, starting with a letter or digit, so it can name no other directory and no other profile's key; any other is refused as {identity_name, invalid}.

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(Path, Profile)

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

The identity key stored at Path: loaded, or ground and stored there if there is none. Two callers that find no file at once end up with the same key (see claimed/4), and a file that will not load is refused, never replaced. Exported so a test can point a node at its own key file instead of the account's: a test that used the account's 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); and a sealed group's epoch key (the key of a map that is an epoch of macula_group_epoch). 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.

stored_identity(Name, Profile)

-spec stored_identity(binary(), macula_crypto_profile:profile()) ->
                         {ok, node_key(), file:filename()} | {error, term()}.

The identity a program under this account stores for Name in Profile (macula#76): loaded from identity_path(identity_dir(), Name, Profile), or ground and stored there if there is none, with the path it is stored at.

A node identity belongs to one program running under one user account. A key works in one profile only, so a program that runs in both profiles is two nodes, and its two keys sit side by side as <name>.pq_pure.key and <name>.pq_hybrid.key. Name is <<"default">> unless the program names itself.

The single identity.key of earlier releases, next to the identity directory, is moved first to default.<its profile>.key and is never read in place. The move only ever creates: when that place holds another key it is refused as {old_identity_key, #{from, to}, place_taken} and both files stay as they are. An old key that will not load is refused as {old_identity_key, #{from}, Reason}, untouched. A stored key that will not load is refused as {stored_key, Path, Reason} and never replaced (see node_identity/2).

⚠ 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 is in the FILESYSTEM and not in a process.

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.