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