Packages
macula
0.25.0
7.0.0
6.0.0
5.2.2
5.2.1
5.2.0
5.1.0
5.0.0
4.8.0
4.7.1
4.7.0
4.6.0
4.5.0
4.4.10
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.1
4.3.0
4.2.9
4.2.8
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.1
4.1.0
4.0.0
3.16.0
3.15.3
3.15.2
3.15.1
3.14.0
3.13.0
3.12.1
3.12.0
3.11.1
3.11.0
3.10.3
3.10.2
3.10.1
3.9.0
3.8.0
3.7.0
3.5.0
3.4.0
3.3.0
3.2.0
3.1.0
3.0.0
2.1.1
2.1.0
2.0.0
1.5.2
1.5.1
1.4.30
1.4.29
1.4.28
1.4.27
1.4.26
1.4.25
1.4.24
1.4.23
1.4.22
1.4.21
1.4.20
1.4.19
1.4.18
1.4.17
1.4.16
1.4.15
1.4.14
1.4.13
1.4.11
1.4.10
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.1
1.3.0
1.2.0
1.1.0
1.0.10
1.0.9
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.48.6
0.48.5
0.48.4
0.48.3
0.48.2
0.48.1
0.48.0
0.47.1
0.47.0
0.46.3
0.46.1
0.46.0
0.45.3
0.45.2
0.45.1
0.45.0
0.44.2
0.44.1
0.44.0
0.43.3
0.43.2
0.43.1
0.43.0
0.42.9
0.42.8
0.42.7
0.42.6
0.42.5
0.42.4
0.42.3
0.42.2
0.42.1
0.42.0
0.41.1
0.41.0
0.40.1
0.40.0
0.39.9
0.39.8
0.39.7
0.39.6
0.39.5
0.39.4
0.39.3
0.39.2
0.39.1
0.39.0
0.38.8
0.38.7
0.38.6
0.38.5
0.38.4
0.38.3
0.38.2
0.38.1
0.38.0
0.37.7
0.37.6
0.37.5
0.37.4
0.37.3
0.37.2
0.37.1
0.37.0
0.36.6
0.36.5
0.36.4
0.36.3
0.36.2
0.36.1
0.36.0
0.35.4
0.35.3
0.35.2
0.35.1
0.35.0
0.34.1
0.34.0
0.33.1
0.33.0
0.32.5
0.32.4
0.32.3
0.32.2
0.32.1
0.32.0
0.31.9
0.31.8
0.31.7
0.31.6
0.31.5
0.31.4
0.31.3
0.31.2
0.31.1
0.31.0
0.30.10
0.30.9
0.30.8
0.30.7
0.30.6
0.30.5
0.30.4
0.30.3
0.30.2
0.30.1
0.30.0
0.29.0
0.28.3
0.28.2
0.28.1
0.28.0
0.27.1
0.27.0
0.26.1
0.26.0
0.25.6
0.25.5
0.25.4
0.25.3
0.25.2
0.25.1
0.25.0
0.24.6
0.24.5
0.24.4
0.24.3
0.24.2
0.24.1
0.24.0
0.23.3
0.23.2
0.23.1
0.23.0
0.22.12
0.22.11
0.22.10
0.22.9
0.22.8
0.22.7
0.22.6
0.22.5
0.22.4
0.22.3
0.22.2
0.22.1
0.22.0
0.21.7
0.21.6
0.21.5
0.21.4
0.21.2
0.21.1
0.21.0
0.20.25
0.20.24
0.20.23
0.20.22
0.20.21
0.20.20
0.20.19
0.20.18
0.20.17
0.20.16
0.20.15
0.20.14
0.20.13
0.20.12
0.20.11
0.20.10
0.20.9
0.20.8
0.20.7
0.20.6
0.20.5
0.20.3
0.20.2
0.20.1
0.20.0
0.19.2
0.19.1
0.19.0
0.18.1
0.18.0
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.12.6
0.12.5
0.12.3
0.11.3
0.10.2
0.10.1
0.10.0
0.9.2
0.9.1
0.9.0
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.10
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.30
0.7.29
0.7.28
0.7.27
0.7.26
0.7.25
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.0
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.4
0.3.3
0.3.2
0.3.1
Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise
Current section
Files
Jump to
Current section
Files
src/macula_cert_system/macula_cert.erl
%%%-------------------------------------------------------------------
%%% @doc Macula Self-Sovereign Certificate System
%%%
%%% Provides Ed25519 DID-anchored certificate generation and verification.
%%% Unlike traditional PKI, these certificates do not require external CAs.
%%%
%%% Certificate hierarchy:
%%% - Realm certificates are self-signed (root of trust for a namespace)
%%% - Instance certificates are signed by the realm certificate
%%%
%%% Example usage:
%%%
%%% Generate realm keypair and certificate:
%%% {PubKey, PrivKey} = macula_cert:generate_keypair(),
%%% {ok, RealmCert} = macula_cert:generate_realm_cert(DID, PubKey, PrivKey).
%%%
%%% Generate instance certificate signed by realm:
%%% {InstPub, InstPriv} = macula_cert:generate_keypair(),
%%% {ok, InstCert} = macula_cert:generate_instance_cert(InstanceDID, InstPub, RealmCert, PrivKey).
%%%
%%% Verify certificate chain:
%%% ok = macula_cert:verify_cert(InstCert, RealmCert).
%%%
%%% @end
%%%-------------------------------------------------------------------
-module(macula_cert).
-include("macula_cert.hrl").
%% API - Keypair Generation
-export([generate_keypair/0]).
%% API - Certificate Generation
-export([generate_realm_cert/3, generate_realm_cert/4]).
-export([generate_instance_cert/4, generate_instance_cert/5]).
-export([sign_cert_request/3]).
%% API - Certificate Verification
-export([verify_cert/2, verify_self_signed/1]).
-export([is_expired/1, is_valid_now/1]).
%% API - Encoding/Decoding
-export([encode/1, decode/1]).
-export([to_map/1, from_map/1]).
-export([canonical_form/1]).
%% API - Utilities
-export([did_to_cn/1, cn_to_did/1]).
-export([generate_serial/0]).
-export([extract_realm_did/1]).
%%%===================================================================
%%% Keypair Generation
%%%===================================================================
%% @doc Generate a new Ed25519 keypair
%% Returns {PublicKey, PrivateKey} as raw binaries.
-spec generate_keypair() -> {PublicKey :: binary(), PrivateKey :: binary()}.
generate_keypair() ->
crypto:generate_key(eddsa, ed25519).
%%%===================================================================
%%% Certificate Generation
%%%===================================================================
%% @doc Generate a self-signed realm certificate
%% Realm certificates are the root of trust for a namespace.
-spec generate_realm_cert(RealmDID :: binary(), PublicKey :: binary(),
PrivateKey :: binary()) ->
{ok, macula_cert()} | {error, term()}.
generate_realm_cert(RealmDID, PublicKey, PrivateKey) ->
generate_realm_cert(RealmDID, PublicKey, PrivateKey, ?REALM_VALIDITY_DAYS).
%% @doc Generate a self-signed realm certificate with custom validity
-spec generate_realm_cert(RealmDID :: binary(), PublicKey :: binary(),
PrivateKey :: binary(), ValidityDays :: pos_integer()) ->
{ok, macula_cert()} | {error, term()}.
generate_realm_cert(RealmDID, PublicKey, PrivateKey, ValidityDays) ->
case validate_keys(PublicKey, PrivateKey) of
ok ->
Now = erlang:system_time(second),
CN = did_to_cn(RealmDID),
Serial = generate_serial(),
%% For self-signed, issuer = subject
UnsignedCert = #macula_cert{
subject_did = RealmDID,
subject_cn = CN,
issuer_did = RealmDID,
issuer_cn = CN,
not_before = Now,
not_after = Now + (ValidityDays * 86400),
public_key = PublicKey,
signature = <<>>, %% Will be set after signing
serial = Serial,
version = 1
},
%% Sign the canonical form
Canonical = canonical_form(UnsignedCert),
Signature = sign_data(Canonical, PrivateKey),
{ok, UnsignedCert#macula_cert{signature = Signature}};
{error, _} = Error ->
Error
end.
%% @doc Generate an instance certificate signed by a realm certificate
%% The instance DID must be under the realm's namespace.
-spec generate_instance_cert(InstanceDID :: binary(), InstancePubKey :: binary(),
RealmCert :: macula_cert(), RealmPrivKey :: binary()) ->
{ok, macula_cert()} | {error, term()}.
generate_instance_cert(InstanceDID, InstancePubKey, RealmCert, RealmPrivKey) ->
generate_instance_cert(InstanceDID, InstancePubKey, RealmCert, RealmPrivKey,
?DEFAULT_VALIDITY_DAYS).
%% @doc Generate an instance certificate with custom validity
-spec generate_instance_cert(InstanceDID :: binary(), InstancePubKey :: binary(),
RealmCert :: macula_cert(), RealmPrivKey :: binary(),
ValidityDays :: pos_integer()) ->
{ok, macula_cert()} | {error, term()}.
generate_instance_cert(InstanceDID, InstancePubKey, RealmCert, RealmPrivKey, ValidityDays) ->
#macula_cert{subject_did = RealmDID, public_key = RealmPubKey} = RealmCert,
%% Verify the realm certificate is valid
case verify_self_signed(RealmCert) of
ok ->
%% Verify instance DID is under realm namespace
case is_did_under_realm(InstanceDID, RealmDID) of
true ->
%% Verify realm private key matches public key
case verify_keypair(RealmPubKey, RealmPrivKey) of
ok ->
do_generate_instance_cert(
InstanceDID, InstancePubKey, RealmCert,
RealmPrivKey, ValidityDays
);
{error, _} = Error ->
Error
end;
false ->
{error, {did_not_under_realm, InstanceDID, RealmDID}}
end;
{error, _} = Error ->
Error
end.
%% @private
do_generate_instance_cert(InstanceDID, InstancePubKey, RealmCert, RealmPrivKey, ValidityDays) ->
#macula_cert{subject_did = RealmDID, subject_cn = RealmCN} = RealmCert,
Now = erlang:system_time(second),
CN = did_to_cn(InstanceDID),
Serial = generate_serial(),
UnsignedCert = #macula_cert{
subject_did = InstanceDID,
subject_cn = CN,
issuer_did = RealmDID,
issuer_cn = RealmCN,
not_before = Now,
not_after = Now + (ValidityDays * 86400),
public_key = InstancePubKey,
signature = <<>>,
serial = Serial,
version = 1
},
%% Sign with realm's private key
Canonical = canonical_form(UnsignedCert),
Signature = sign_data(Canonical, RealmPrivKey),
{ok, UnsignedCert#macula_cert{signature = Signature}}.
%% @doc Sign a certificate request
%% Used when processing CSRs from remote instances.
-spec sign_cert_request(Request :: macula_cert_request(), RealmCert :: macula_cert(),
RealmPrivKey :: binary()) ->
{ok, macula_cert()} | {error, term()}.
sign_cert_request(Request, RealmCert, RealmPrivKey) ->
#macula_cert_request{
subject_did = SubjectDID,
public_key = PubKey,
validity_days = ValidityDays
} = Request,
generate_instance_cert(SubjectDID, PubKey, RealmCert, RealmPrivKey, ValidityDays).
%%%===================================================================
%%% Certificate Verification
%%%===================================================================
%% @doc Verify an instance certificate against its issuer (realm) certificate
-spec verify_cert(InstanceCert :: macula_cert(), IssuerCert :: macula_cert()) ->
ok | {error, term()}.
verify_cert(InstanceCert, IssuerCert) ->
#macula_cert{
issuer_did = IssuerDID,
signature = Signature
} = InstanceCert,
#macula_cert{
subject_did = ExpectedIssuerDID,
public_key = IssuerPubKey
} = IssuerCert,
%% Verify issuer matches
case IssuerDID =:= ExpectedIssuerDID of
true ->
%% Verify signature
Canonical = canonical_form(InstanceCert),
case verify_signature(Canonical, Signature, IssuerPubKey) of
ok ->
%% Verify not expired
case is_valid_now(InstanceCert) of
true -> ok;
false -> {error, certificate_expired}
end;
{error, _} = Error ->
Error
end;
false ->
{error, {issuer_mismatch, IssuerDID, ExpectedIssuerDID}}
end.
%% @doc Verify a self-signed (realm) certificate
-spec verify_self_signed(Cert :: macula_cert()) -> ok | {error, term()}.
verify_self_signed(Cert) ->
#macula_cert{
subject_did = SubjectDID,
issuer_did = IssuerDID,
public_key = PubKey,
signature = Signature
} = Cert,
%% Self-signed means subject == issuer
case SubjectDID =:= IssuerDID of
true ->
Canonical = canonical_form(Cert),
case verify_signature(Canonical, Signature, PubKey) of
ok ->
case is_valid_now(Cert) of
true -> ok;
false -> {error, certificate_expired}
end;
{error, _} = Error ->
Error
end;
false ->
{error, not_self_signed}
end.
%% @doc Check if a certificate is expired
-spec is_expired(Cert :: macula_cert()) -> boolean().
is_expired(#macula_cert{not_after = NotAfter}) ->
erlang:system_time(second) > NotAfter.
%% @doc Check if a certificate is currently valid (within validity period)
-spec is_valid_now(Cert :: macula_cert()) -> boolean().
is_valid_now(#macula_cert{not_before = NotBefore, not_after = NotAfter}) ->
Now = erlang:system_time(second),
Now >= NotBefore andalso Now =< NotAfter.
%%%===================================================================
%%% Encoding/Decoding
%%%===================================================================
%% @doc Encode certificate to binary format
%% Uses term_to_binary for now; can switch to CBOR/JSON for interop.
-spec encode(Cert :: macula_cert()) -> binary().
encode(Cert) ->
term_to_binary(to_map(Cert), [compressed]).
%% @doc Decode certificate from binary format
-spec decode(Binary :: binary()) -> {ok, macula_cert()} | {error, term()}.
decode(Binary) ->
handle_binary_decode(catch binary_to_term(Binary, [safe])).
%% @private Handle binary decode result
handle_binary_decode({'EXIT', _}) ->
{error, invalid_certificate_format};
handle_binary_decode(Map) when is_map(Map) ->
from_map(Map);
handle_binary_decode(_) ->
{error, invalid_certificate_format}.
%% @doc Convert certificate record to map
-spec to_map(Cert :: macula_cert()) -> map().
to_map(#macula_cert{} = Cert) ->
#{
version => Cert#macula_cert.version,
serial => Cert#macula_cert.serial,
subject_did => Cert#macula_cert.subject_did,
subject_cn => Cert#macula_cert.subject_cn,
issuer_did => Cert#macula_cert.issuer_did,
issuer_cn => Cert#macula_cert.issuer_cn,
not_before => Cert#macula_cert.not_before,
not_after => Cert#macula_cert.not_after,
public_key => Cert#macula_cert.public_key,
signature => Cert#macula_cert.signature,
extensions => Cert#macula_cert.extensions
}.
%% @doc Convert map to certificate record
-spec from_map(Map :: map()) -> {ok, macula_cert()} | {error, term()}.
from_map(Map) when is_map(Map) ->
build_cert_from_map(catch build_cert_record(Map));
from_map(_) ->
{error, invalid_map}.
%% @private Build certificate record from map (may throw on missing keys)
build_cert_record(Map) ->
#macula_cert{
version = maps:get(version, Map, 1),
serial = maps:get(serial, Map),
subject_did = maps:get(subject_did, Map),
subject_cn = maps:get(subject_cn, Map),
issuer_did = maps:get(issuer_did, Map),
issuer_cn = maps:get(issuer_cn, Map),
not_before = maps:get(not_before, Map),
not_after = maps:get(not_after, Map),
public_key = maps:get(public_key, Map),
signature = maps:get(signature, Map),
extensions = maps:get(extensions, Map, #{})
}.
%% @private Handle certificate build result
build_cert_from_map({'EXIT', _}) ->
{error, missing_required_fields};
build_cert_from_map(#macula_cert{} = Cert) ->
{ok, Cert}.
%% @doc Generate canonical form for signing/verification
%% The canonical form excludes the signature field and is deterministic.
-spec canonical_form(Cert :: macula_cert()) -> binary().
canonical_form(#macula_cert{} = Cert) ->
%% Build a deterministic binary representation
%% Order matters for reproducibility
Data = [
<<"v">>, integer_to_binary(Cert#macula_cert.version),
<<"s">>, Cert#macula_cert.serial,
<<"sd">>, Cert#macula_cert.subject_did,
<<"sc">>, Cert#macula_cert.subject_cn,
<<"id">>, Cert#macula_cert.issuer_did,
<<"ic">>, Cert#macula_cert.issuer_cn,
<<"nb">>, integer_to_binary(Cert#macula_cert.not_before),
<<"na">>, integer_to_binary(Cert#macula_cert.not_after),
<<"pk">>, Cert#macula_cert.public_key
%% Note: signature is NOT included in canonical form
%% Note: extensions are NOT included (for simplicity in v1)
],
iolist_to_binary(Data).
%%%===================================================================
%%% Utility Functions
%%%===================================================================
%% @doc Convert DID to common name format
%% Example: "did:macula:io.example.app" becomes "app.io.example"
-spec did_to_cn(DID :: binary()) -> binary().
did_to_cn(<<"did:macula:", Identity/binary>>) ->
%% Reverse the dot-separated parts for CN format
Parts = binary:split(Identity, <<".">>, [global]),
ReversedParts = lists:reverse(Parts),
iolist_to_binary(lists:join(<<".">>, ReversedParts));
did_to_cn(DID) ->
%% If not in did:macula: format, return as-is
DID.
%% @doc Convert common name to DID format
%% Example: "app.io.example" becomes "did:macula:io.example.app"
-spec cn_to_did(CN :: binary()) -> binary().
cn_to_did(CN) ->
Parts = binary:split(CN, <<".">>, [global]),
ReversedParts = lists:reverse(Parts),
Identity = iolist_to_binary(lists:join(<<".">>, ReversedParts)),
<<"did:macula:", Identity/binary>>.
%% @doc Generate a random certificate serial number
-spec generate_serial() -> binary().
generate_serial() ->
crypto:strong_rand_bytes(?CERT_SERIAL_SIZE).
%% @doc Extract realm DID from an instance DID
%% Example: "did:macula:io.example.app.node01" becomes "did:macula:io.example"
-spec extract_realm_did(InstanceDID :: binary()) -> binary().
extract_realm_did(<<"did:macula:", Identity/binary>>) ->
Parts = binary:split(Identity, <<".">>, [global]),
%% Realm is typically first 2 parts (e.g., io.example)
case length(Parts) >= 2 of
true ->
[P1, P2 | _] = Parts,
<<"did:macula:", P1/binary, ".", P2/binary>>;
false ->
%% Return as-is if not enough parts
<<"did:macula:", Identity/binary>>
end;
extract_realm_did(DID) ->
DID.
%%%===================================================================
%%% Internal Functions
%%%===================================================================
%% @private Validate keypair
-spec validate_keys(PublicKey :: binary(), PrivateKey :: binary()) ->
ok | {error, term()}.
validate_keys(PublicKey, PrivateKey) ->
case byte_size(PublicKey) of
?ED25519_PUBLIC_KEY_SIZE ->
case validate_private_key_size(PrivateKey) of
ok -> ok;
Error -> Error
end;
_ ->
{error, invalid_public_key}
end.
%% @private
validate_private_key_size(PrivKey) when byte_size(PrivKey) =:= 64 -> ok;
validate_private_key_size(PrivKey) when byte_size(PrivKey) =:= 32 -> ok;
validate_private_key_size(_) -> {error, invalid_private_key}.
%% @private Verify that a private key corresponds to a public key
-spec verify_keypair(PublicKey :: binary(), PrivateKey :: binary()) ->
ok | {error, term()}.
verify_keypair(PublicKey, PrivateKey) ->
%% Sign some test data and verify with public key
TestData = <<"keypair_verification_test">>,
Signature = sign_data(TestData, PrivateKey),
verify_signature(TestData, Signature, PublicKey).
%% @private Check if instance DID is under realm namespace
-spec is_did_under_realm(InstanceDID :: binary(), RealmDID :: binary()) -> boolean().
is_did_under_realm(InstanceDID, RealmDID) ->
%% Instance DID must start with realm DID
RealmPrefix = case RealmDID of
<<"did:macula:", Identity/binary>> -> Identity;
_ -> RealmDID
end,
InstanceIdentity = case InstanceDID of
<<"did:macula:", Id/binary>> -> Id;
_ -> InstanceDID
end,
%% Check prefix match
PrefixSize = byte_size(RealmPrefix),
case InstanceIdentity of
<<RealmPrefix:PrefixSize/binary, ".", _/binary>> -> true;
RealmPrefix -> true; %% Exact match
_ -> false
end.
%% @private Sign data with Ed25519 private key
-spec sign_data(Data :: binary(), PrivateKey :: binary()) -> binary().
sign_data(Data, PrivateKey) ->
Hash = crypto:hash(sha256, Data),
crypto:sign(eddsa, none, Hash, [PrivateKey, ed25519]).
%% @private Verify Ed25519 signature
-spec verify_signature(Data :: binary(), Signature :: binary(), PublicKey :: binary()) ->
ok | {error, invalid_signature}.
verify_signature(Data, Signature, PublicKey) ->
Hash = crypto:hash(sha256, Data),
case crypto:verify(eddsa, none, Hash, Signature, [PublicKey, ed25519]) of
true -> ok;
false -> {error, invalid_signature}
end.