ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/AnyEvent-MCP/lib/AnyEvent/MCP/Config.pm
Revision: 1.3
Committed: Fri Oct 9 09:36:48 2026 UTC (4 weeks, 4 days ago) by root
Branch: MAIN
Changes since 1.2: +15 -7 lines
Log Message:
*** empty log message ***

File Contents

# User Rev Content
1 root 1.1 =head1 NAME
2    
3     AnyEvent::MCP::Config - named servers and derived MCP credentials
4    
5     =head1 SYNOPSIS
6    
7     my $config = AnyEvent::MCP::Config->global;
8     my $password = $config->credential ("basic", "agni", "alice");
9     my $url = $config->url ("agni");
10    
11     =head1 DESCRIPTION
12    
13 root 1.3 An MCP server and its clients often need matching credentials. Rather than
14     maintaining a separate password for each server and user, this module can
15     derive them from one private master secret. A server name, optional username
16     and credential purpose each contribute to the result. A password derived for
17     one server is therefore not the password for another.
18    
19     The shared file format is described in L<AnyEvent::MCP/GLOBAL CONFIGURATION>.
20     Loading this module does not read any files. The first call to C<global>
21     reads the configuration, ensures a master secret exists and caches the object
22     for the process.
23 root 1.1
24     A missing secret is generated using C<AnyEvent::MCP::random_bytes> and saved
25     when possible. Saving failure produces a warning; the generated secret remains
26 root 1.3 usable in this process, but another process might derive different credentials.
27     A supplied invalid secret is an error. Changing or losing the master secret
28     invalidates all credentials derived from it.
29 root 1.1
30     Derivation uses HKDF-SHA512 expansion with the random master key as its PRK.
31     Server name, username and credential purpose are separately length-prefixed,
32     so credentials for different servers, users or authentication methods differ.
33 root 1.3 Deriving a password does not create an account or configure the server to
34     accept it: authentication still belongs to the server.
35 root 1.1
36     =head1 METHODS
37    
38     =head2 $config = AnyEvent::MCP::Config->global (file => $file)
39    
40     Returns the cached global configuration. The first call accepts the same
41     arguments as C<new>; later calls return the same object without rereading
42     the file. Call this explicitly during program startup.
43    
44     =head2 $config = AnyEvent::MCP::Config->new (file => $file)
45    
46     Reads C<$file>, or the default file when omitted, and ensures a master
47     secret exists. Unlike C<global>, each call creates an independent object.
48     A missing file is allowed; other read errors are fatal. Sections are available
49     as C<< $config->{section}{$section} >>, each a hash reference of string
50     values. Named servers use C<[server NAME]>.
51    
52     =head2 $config = AnyEvent::MCP::Config->init (file => $file)
53    
54     Explicitly creates the configuration file with a random secret, mode 0600.
55     Creates missing parent directories with mode 0700. Fails if the file already
56     exists, so it cannot accidentally replace an existing secret.
57    
58     =head2 $credential = $config->credential ($purpose, $name, $username)
59    
60     Derives a 32-byte credential, returned as unpadded URL-safe Base64.
61     C<$purpose> is C<basic>, C<bearer> or C<cookie>. C<$name> is the server
62     name. C<$username> can be omitted for a server-wide credential.
63    
64     This method does not require a corresponding server section.
65    
66     =head2 $url = $config->url ($name, $username)
67    
68     Returns the configured C<url> for C<$name>, replacing C<{cookie}> with the
69     derived cookie credential. C<$username> can be omitted.
70    
71     The template supplies scheme, host, port, path and cookie placement explicitly.
72     This method neither contacts a server nor infers an ephemeral listening port.
73     The HTTP server does not consume this configuration automatically.
74    
75     =cut
76    
77     package AnyEvent::MCP::Config;
78    
79     use common::sense;
80     use AnyEvent::MCP ();
81     use MIME::Base64 ();
82     use File::Basename ();
83     use File::Path ();
84     use Fcntl qw(O_WRONLY O_RDWR O_CREAT O_EXCL LOCK_EX);
85     use Errno qw(ENOENT);
86    
87     our @ISA = qw(AnyEvent::MCP::Base);
88    
89     sub _file {
90     my $dir = $ENV{XDG_CONFIG_HOME}
91     || ($ENV{HOME} || die "HOME is not set\n") . "/.config";
92    
93     "$dir/anyevent-mcp/global.ini"
94     }
95    
96     my $global;
97    
98     sub global {
99     my $class = shift;
100    
101     $global ||= $class->new (@_)
102     }
103    
104     sub new {
105     my ($class, %kv) = @_;
106     my $self = $class->SUPER::new (section => {}, %kv);
107     $self->{file} = _file unless exists $self->{file};
108    
109     if (open my $fh, "<:encoding(UTF-8)", $self->{file}) {
110     $self->_read ($fh);
111     close $fh or die "$self->{file}: $!\n";
112     } else {
113     die "$self->{file}: $!\n" unless $! == ENOENT;
114     }
115    
116     $self->_ensure_secret;
117    
118     die "$self->{file}: invalid secret; supply 128 hexadecimal digits\n"
119     unless $self->{section}{global}{secret} =~ /\A[0-9a-fA-F]{128}\z/;
120    
121     $self
122     }
123    
124     sub _read {
125     my ($self, $fh) = @_;
126    
127     my @stat = stat $fh;
128     die "$self->{file}: configuration must be private and owned by this user\n"
129     if $stat[4] != $< || $stat[2] & 077;
130    
131     my $section = $self->{section}{global} ||= {};
132    
133     while (<$fh>) {
134     next if /\A\s*(?:[#;]|\z)/;
135    
136     if (/\A\s*\[([^\]\r\n]+)\]\s*\z/) {
137     $section = $self->{section}{$1} ||= {};
138     } elsif (/\A\s*([^=\s]+)\s*=\s*(.*?)\s*\z/) {
139     $section->{$1} = $2;
140     } else {
141     die "$self->{file}:$.: invalid configuration line\n";
142     }
143     }
144    
145     }
146    
147     sub _ensure_secret {
148     my ($self) = @_;
149    
150     return if exists $self->{section}{global}{secret};
151    
152     my $secret = eval { unpack "H*", AnyEvent::MCP::random_bytes 64 };
153     die "$@Supply a securely generated 128-digit hexadecimal secret in $self->{file}\n"
154     if $@;
155    
156     eval {
157     File::Path::mkpath (File::Basename::dirname ($self->{file}), 0, 0700);
158     sysopen my $fh, $self->{file}, O_RDWR | O_CREAT, 0600
159     or die "$self->{file}: $!\n";
160     flock $fh, LOCK_EX or die "$self->{file}: $!\n";
161     binmode $fh, ":encoding(UTF-8)";
162    
163     my $disk = bless { file => $self->{file}, section => {} }, ref $self;
164     $disk->_read ($fh);
165    
166     unless (exists $disk->{section}{global}{secret}) {
167     seek $fh, 0, 2 or die "$self->{file}: $!\n";
168     print {$fh} "\n[global]\nsecret = $secret\n"
169     or die "$self->{file}: $!\n";
170     $disk->{section}{global}{secret} = $secret;
171     }
172    
173     close $fh or die "$self->{file}: $!\n";
174     $self->{section} = $disk->{section};
175     1
176     } or warn "Cannot save MCP master secret; credentials may only be stable for this process: $@";
177    
178     $self->{section}{global}{secret} = $secret
179     unless exists $self->{section}{global}{secret};
180     }
181    
182     sub init {
183     my ($class, %kv) = @_;
184     my $file = exists $kv{file} ? $kv{file} : _file;
185    
186     my $secret = AnyEvent::MCP::random_bytes 64;
187    
188     File::Path::mkpath (File::Basename::dirname ($file), 0, 0700);
189    
190     sysopen my $fh, $file, O_WRONLY | O_CREAT | O_EXCL, 0600
191     or die "$file: $!\n";
192    
193     print {$fh} "[global]\nsecret = ", unpack ("H*", $secret), "\n"
194     or die "$file: $!\n";
195     close $fh or die "$file: $!\n";
196    
197     $class->new (file => $file)
198     }
199    
200     sub credential {
201     my ($self, $purpose, $name, $username) = @_;
202    
203     $username = "" unless defined $username;
204    
205     die "Unknown credential purpose '$purpose'\n"
206     unless $purpose =~ /\A(?:basic|bearer|cookie)\z/;
207    
208     my $secret = $self->{section}{global}{secret};
209     die "$self->{file}: missing or invalid secret\n"
210     unless $secret =~ /\A[0-9a-fA-F]{128}\z/;
211    
212     my $info = pack "N/a*N/a*N/a*N/a*",
213     "AnyEvent::MCP credential v1", $purpose,
214 root 1.2 AnyEvent::MCP::utf8enc ($name), AnyEvent::MCP::utf8enc ($username);
215 root 1.1
216     my $credential = MIME::Base64::encode_base64 (
217     AnyEvent::MCP::hkdf_sha512_expand (pack ("H*", $secret), $info, 32), ""
218     );
219     $credential =~ y!+/=!-_!d;
220    
221     $credential
222     }
223    
224     sub url {
225     my ($self, $name, $username) = @_;
226    
227     my $url = $self->{section}{"server $name"}{url}
228     or die "No URL configured for server '$name'\n";
229    
230     $url =~ s/\{cookie\}/$self->credential ("cookie", $name, $username)/ge;
231    
232     $url
233     }
234    
235     1