mirror of
https://github.com/mjl-/mox.git
synced 2025-07-14 16:54:36 +03:00
implement tls client certificate authentication
the imap & smtp servers now allow logging in with tls client authentication and the "external" sasl authentication mechanism. email clients like thunderbird, fairemail, k9, macos mail implement it. this seems to be the most secure among the authentication mechanism commonly implemented by clients. a useful property is that an account can have a separate tls public key for each device/email client. with tls client cert auth, authentication is also bound to the tls connection. a mitm cannot pass the credentials on to another tls connection, similar to scram-*-plus. though part of scram-*-plus is that clients verify that the server knows the client credentials. for tls client auth with imap, we send a "preauth" untagged message by default. that puts the connection in authenticated state. given the imap connection state machine, further authentication commands are not allowed. some clients don't recognize the preauth message, and try to authenticate anyway, which fails. a tls public key has a config option to disable preauth, keeping new connections in unauthenticated state, to work with such email clients. for smtp (submission), we don't require an explicit auth command. both for imap and smtp, we allow a client to authenticate with another mechanism than "external". in that case, credentials are verified, and have to be for the same account as the tls client auth, but the adress can be another one than the login address configured with the tls public key. only the public key is used to identify the account that is authenticating. we ignore the rest of the certificate. expiration dates, names, constraints, etc are not verified. no certificate authorities are involved. users can upload their own (minimal) certificate. the account web interface shows openssl commands you can run to generate a private key, minimal cert, and a p12 file (the format that email clients seem to like...) containing both private key and certificate. the imapclient & smtpclient packages can now also use tls client auth. and so does "mox sendmail", either with a pem file with private key and certificate, or with just an ed25519 private key. there are new subcommands "mox config tlspubkey ..." for adding/removing/listing tls public keys from the cli, by the admin.
This commit is contained in:
@ -8,6 +8,7 @@ import (
|
||||
cryptorand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -671,3 +672,80 @@ func (Account) RejectsSave(ctx context.Context, mailbox string, keep bool) {
|
||||
})
|
||||
xcheckf(ctx, err, "saving account rejects settings")
|
||||
}
|
||||
|
||||
func (Account) TLSPublicKeys(ctx context.Context) ([]store.TLSPublicKey, error) {
|
||||
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
||||
return store.TLSPublicKeyList(ctx, reqInfo.AccountName)
|
||||
}
|
||||
|
||||
func (Account) TLSPublicKeyAdd(ctx context.Context, loginAddress, name string, noIMAPPreauth bool, certPEM string) (store.TLSPublicKey, error) {
|
||||
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
||||
|
||||
block, rest := pem.Decode([]byte(certPEM))
|
||||
var err error
|
||||
if block == nil {
|
||||
err = errors.New("no pem data found")
|
||||
} else if block.Type != "CERTIFICATE" {
|
||||
err = fmt.Errorf("unexpected type %q, need CERTIFICATE", block.Type)
|
||||
} else if len(rest) != 0 {
|
||||
err = errors.New("only single pem block allowed")
|
||||
}
|
||||
xcheckuserf(ctx, err, "parsing pem file")
|
||||
|
||||
tpk, err := store.ParseTLSPublicKeyCert(block.Bytes)
|
||||
xcheckuserf(ctx, err, "parsing certificate")
|
||||
if name != "" {
|
||||
tpk.Name = name
|
||||
}
|
||||
tpk.Account = reqInfo.AccountName
|
||||
tpk.LoginAddress = loginAddress
|
||||
tpk.NoIMAPPreauth = noIMAPPreauth
|
||||
err = store.TLSPublicKeyAdd(ctx, &tpk)
|
||||
if err != nil && errors.Is(err, bstore.ErrUnique) {
|
||||
xcheckuserf(ctx, err, "add tls public key")
|
||||
} else {
|
||||
xcheckf(ctx, err, "add tls public key")
|
||||
}
|
||||
return tpk, nil
|
||||
}
|
||||
|
||||
func xtlspublickey(ctx context.Context, account string, fingerprint string) store.TLSPublicKey {
|
||||
tpk, err := store.TLSPublicKeyGet(ctx, fingerprint)
|
||||
if err == nil && tpk.Account != account {
|
||||
err = bstore.ErrAbsent
|
||||
}
|
||||
if err == bstore.ErrAbsent {
|
||||
xcheckuserf(ctx, err, "get tls public key")
|
||||
}
|
||||
xcheckf(ctx, err, "get tls public key")
|
||||
return tpk
|
||||
}
|
||||
|
||||
func (Account) TLSPublicKeyRemove(ctx context.Context, fingerprint string) error {
|
||||
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
||||
xtlspublickey(ctx, reqInfo.AccountName, fingerprint)
|
||||
return store.TLSPublicKeyRemove(ctx, fingerprint)
|
||||
}
|
||||
|
||||
func (Account) TLSPublicKeyUpdate(ctx context.Context, pubKey store.TLSPublicKey) error {
|
||||
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
|
||||
tpk := xtlspublickey(ctx, reqInfo.AccountName, pubKey.Fingerprint)
|
||||
log := pkglog.WithContext(ctx)
|
||||
acc, _, err := store.OpenEmail(log, pubKey.LoginAddress)
|
||||
if err == nil && acc.Name != reqInfo.AccountName {
|
||||
err = store.ErrUnknownCredentials
|
||||
}
|
||||
if acc != nil {
|
||||
xerr := acc.Close()
|
||||
log.Check(xerr, "close account")
|
||||
}
|
||||
if err == store.ErrUnknownCredentials {
|
||||
xcheckuserf(ctx, errors.New("unknown address"), "looking up address")
|
||||
}
|
||||
tpk.Name = pubKey.Name
|
||||
tpk.LoginAddress = pubKey.LoginAddress
|
||||
tpk.NoIMAPPreauth = pubKey.NoIMAPPreauth
|
||||
err = store.TLSPublicKeyUpdate(ctx, &tpk)
|
||||
xcheckf(ctx, err, "updating tls public key")
|
||||
return nil
|
||||
}
|
||||
|
@ -255,7 +255,7 @@ var api;
|
||||
// per-outgoing-message address used for sending.
|
||||
OutgoingEvent["EventUnrecognized"] = "unrecognized";
|
||||
})(OutgoingEvent = api.OutgoingEvent || (api.OutgoingEvent = {}));
|
||||
api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AutomaticJunkFlags": true, "Destination": true, "Domain": true, "ImportProgress": true, "Incoming": true, "IncomingMeta": true, "IncomingWebhook": true, "JunkFilter": true, "NameAddress": true, "Outgoing": true, "OutgoingWebhook": true, "Route": true, "Ruleset": true, "Structure": true, "SubjectPass": true, "Suppression": true };
|
||||
api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AutomaticJunkFlags": true, "Destination": true, "Domain": true, "ImportProgress": true, "Incoming": true, "IncomingMeta": true, "IncomingWebhook": true, "JunkFilter": true, "NameAddress": true, "Outgoing": true, "OutgoingWebhook": true, "Route": true, "Ruleset": true, "Structure": true, "SubjectPass": true, "Suppression": true, "TLSPublicKey": true };
|
||||
api.stringsTypes = { "CSRFToken": true, "Localpart": true, "OutgoingEvent": true };
|
||||
api.intsTypes = {};
|
||||
api.types = {
|
||||
@ -280,6 +280,7 @@ var api;
|
||||
"NameAddress": { "Name": "NameAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "Address", "Docs": "", "Typewords": ["string"] }] },
|
||||
"Structure": { "Name": "Structure", "Docs": "", "Fields": [{ "Name": "ContentType", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentTypeParams", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "ContentID", "Docs": "", "Typewords": ["string"] }, { "Name": "DecodedSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "Parts", "Docs": "", "Typewords": ["[]", "Structure"] }] },
|
||||
"IncomingMeta": { "Name": "IncomingMeta", "Docs": "", "Fields": [{ "Name": "MsgID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "MsgFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "RcptTo", "Docs": "", "Typewords": ["string"] }, { "Name": "DKIMVerifiedDomains", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "RemoteIP", "Docs": "", "Typewords": ["string"] }, { "Name": "Received", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Automated", "Docs": "", "Typewords": ["bool"] }] },
|
||||
"TLSPublicKey": { "Name": "TLSPublicKey", "Docs": "", "Fields": [{ "Name": "Fingerprint", "Docs": "", "Typewords": ["string"] }, { "Name": "Created", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Type", "Docs": "", "Typewords": ["string"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "NoIMAPPreauth", "Docs": "", "Typewords": ["bool"] }, { "Name": "CertDER", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Account", "Docs": "", "Typewords": ["string"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["string"] }] },
|
||||
"CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null },
|
||||
"Localpart": { "Name": "Localpart", "Docs": "", "Values": null },
|
||||
"OutgoingEvent": { "Name": "OutgoingEvent", "Docs": "", "Values": [{ "Name": "EventDelivered", "Value": "delivered", "Docs": "" }, { "Name": "EventSuppressed", "Value": "suppressed", "Docs": "" }, { "Name": "EventDelayed", "Value": "delayed", "Docs": "" }, { "Name": "EventFailed", "Value": "failed", "Docs": "" }, { "Name": "EventRelayed", "Value": "relayed", "Docs": "" }, { "Name": "EventExpanded", "Value": "expanded", "Docs": "" }, { "Name": "EventCanceled", "Value": "canceled", "Docs": "" }, { "Name": "EventUnrecognized", "Value": "unrecognized", "Docs": "" }] },
|
||||
@ -306,6 +307,7 @@ var api;
|
||||
NameAddress: (v) => api.parse("NameAddress", v),
|
||||
Structure: (v) => api.parse("Structure", v),
|
||||
IncomingMeta: (v) => api.parse("IncomingMeta", v),
|
||||
TLSPublicKey: (v) => api.parse("TLSPublicKey", v),
|
||||
CSRFToken: (v) => api.parse("CSRFToken", v),
|
||||
Localpart: (v) => api.parse("Localpart", v),
|
||||
OutgoingEvent: (v) => api.parse("OutgoingEvent", v),
|
||||
@ -525,6 +527,34 @@ var api;
|
||||
const params = [mailbox, keep];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
async TLSPublicKeys() {
|
||||
const fn = "TLSPublicKeys";
|
||||
const paramTypes = [];
|
||||
const returnTypes = [["[]", "TLSPublicKey"]];
|
||||
const params = [];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
async TLSPublicKeyAdd(loginAddress, name, noIMAPPreauth, certPEM) {
|
||||
const fn = "TLSPublicKeyAdd";
|
||||
const paramTypes = [["string"], ["string"], ["bool"], ["string"]];
|
||||
const returnTypes = [["TLSPublicKey"]];
|
||||
const params = [loginAddress, name, noIMAPPreauth, certPEM];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
async TLSPublicKeyRemove(fingerprint) {
|
||||
const fn = "TLSPublicKeyRemove";
|
||||
const paramTypes = [["string"]];
|
||||
const returnTypes = [];
|
||||
const params = [fingerprint];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
async TLSPublicKeyUpdate(pubKey) {
|
||||
const fn = "TLSPublicKeyUpdate";
|
||||
const paramTypes = [["TLSPublicKey"]];
|
||||
const returnTypes = [];
|
||||
const params = [pubKey];
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
|
||||
}
|
||||
}
|
||||
api.Client = Client;
|
||||
api.defaultBaseURL = (function () {
|
||||
@ -1092,7 +1122,11 @@ const formatQuotaSize = (v) => {
|
||||
return '' + v;
|
||||
};
|
||||
const index = async () => {
|
||||
const [acc, storageUsed, storageLimit, suppressions] = await client.Account();
|
||||
const [[acc, storageUsed, storageLimit, suppressions], tlspubkeys0] = await Promise.all([
|
||||
client.Account(),
|
||||
client.TLSPublicKeys(),
|
||||
]);
|
||||
const tlspubkeys = tlspubkeys0 || [];
|
||||
let fullNameForm;
|
||||
let fullNameFieldset;
|
||||
let fullName;
|
||||
@ -1431,7 +1465,104 @@ const index = async () => {
|
||||
}
|
||||
await check(passwordFieldset, client.SetPassword(password1.value));
|
||||
passwordForm.reset();
|
||||
}), dom.br(), dom.h2('Disk usage'), dom.p('Storage used is ', dom.b(formatQuotaSize(Math.floor(storageUsed / (1024 * 1024)) * 1024 * 1024)), storageLimit > 0 ? [
|
||||
}), dom.br(), dom.h2('TLS public keys'), dom.p('For TLS client authentication with certificates, for IMAP and/or submission (SMTP). Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration or constraints are not verified.'), (() => {
|
||||
let elem = dom.div();
|
||||
const preauthHelp = 'New IMAP immediate TLS connections authenticated with a client certificate are automatically switched to "authenticated" state with an untagged IMAP "preauth" message by default. IMAP connections have a state machine specifying when commands are allowed. Authenticating is not allowed while in the "authenticated" state. Enable this option to work around clients that would try to authenticated anyway.';
|
||||
const render = () => {
|
||||
const e = dom.div(dom.table(dom.thead(dom.tr(dom.th('Login address'), dom.th('Name'), dom.th('Type'), dom.th('No IMAP "preauth"', attr.title(preauthHelp)), dom.th('Fingerprint'), dom.th('Update'), dom.th('Remove'))), dom.tbody(tlspubkeys.length === 0 ? dom.tr(dom.td(attr.colspan('7'), 'None')) : [], tlspubkeys.map((tpk, index) => {
|
||||
let loginAddress;
|
||||
let name;
|
||||
let noIMAPPreauth;
|
||||
let update;
|
||||
const formID = 'tlk-' + index;
|
||||
const row = dom.tr(dom.td(dom.form(attr.id(formID), async function submit(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const ntpk = { ...tpk };
|
||||
ntpk.LoginAddress = loginAddress.value;
|
||||
ntpk.Name = name.value;
|
||||
ntpk.NoIMAPPreauth = noIMAPPreauth.checked;
|
||||
await check(update, client.TLSPublicKeyUpdate(ntpk));
|
||||
tpk.LoginAddress = ntpk.LoginAddress;
|
||||
tpk.Name = ntpk.Name;
|
||||
tpk.NoIMAPPreauth = ntpk.NoIMAPPreauth;
|
||||
}, loginAddress = dom.input(attr.type('email'), attr.value(tpk.LoginAddress), attr.required('')))), dom.td(name = dom.input(attr.form(formID), attr.value(tpk.Name), attr.required(''))), dom.td(tpk.Type), dom.td(dom.label(noIMAPPreauth = dom.input(attr.form(formID), attr.type('checkbox'), tpk.NoIMAPPreauth ? attr.checked('') : []), ' No IMAP "preauth"', attr.title(preauthHelp))), dom.td(tpk.Fingerprint), dom.td(update = dom.submitbutton(attr.form(formID), 'Update')), dom.td(dom.form(async function submit(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
await check(e.target, client.TLSPublicKeyRemove(tpk.Fingerprint));
|
||||
tlspubkeys.splice(tlspubkeys.indexOf(tpk), 1);
|
||||
render();
|
||||
}, dom.submitbutton('Remove'))));
|
||||
return row;
|
||||
}))), dom.clickbutton('Add', style({ marginTop: '1ex' }), function click() {
|
||||
let address;
|
||||
let name;
|
||||
let noIMAPPreauth;
|
||||
let file;
|
||||
const close = popup(dom.div(style({ maxWidth: '45em' }), dom.h1('Add TLS public key'), dom.form(async function submit(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (file.files?.length !== 1) {
|
||||
throw new Error('exactly 1 certificate required'); // xxx
|
||||
}
|
||||
const certPEM = await new Promise((resolve, reject) => {
|
||||
const fr = new window.FileReader();
|
||||
fr.addEventListener('load', () => {
|
||||
resolve(fr.result);
|
||||
});
|
||||
fr.addEventListener('error', () => {
|
||||
reject(fr.error);
|
||||
});
|
||||
fr.readAsText(file.files[0]);
|
||||
});
|
||||
const ntpk = await check(e.target, client.TLSPublicKeyAdd(address.value, name.value, noIMAPPreauth.checked, certPEM));
|
||||
tlspubkeys.push(ntpk);
|
||||
render();
|
||||
close();
|
||||
}, dom.label(style({ display: 'block', marginBottom: '1ex' }), dom.div(dom.b('Login address')), address = dom.input(attr.type('email'), attr.value(localStorageGet('webaccountaddress') || ''), attr.required('')), dom.div(style({ fontStyle: 'italic', marginTop: '.5ex' }), 'Login address used for sessions using this key.')), dom.label(style({ display: 'block', marginBottom: '1ex' }), noIMAPPreauth = dom.input(attr.type('checkbox')), ' No IMAP "preauth"', attr.title(preauthHelp)), dom.div(style({ display: 'block', marginBottom: '1ex' }), dom.label(dom.div(dom.b('Certificate')), file = dom.input(attr.type('file'), attr.required(''))), dom.p(style({ fontStyle: 'italic', margin: '1ex 0' }), 'Upload a PEM file containing a certificate, not a private key. Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration, and constraints are not verified. ', dom.a('Show suggested commands', attr.href(''), function click(e) {
|
||||
e.preventDefault();
|
||||
popup(dom.h1('Generate a private key and certificate'), dom.pre(dom._class('literal'), `export keyname=... # Used for file names, certificate "common name" and as name of tls public key.
|
||||
# Suggestion: Use an application name and/or email address.
|
||||
export passphrase=... # Protects the private key in the PEM and p12 files.
|
||||
|
||||
# Generate an ECDSA P-256 private key and a long-lived, unsigned, basic certificate
|
||||
# for the corresponding public key.
|
||||
openssl req \\
|
||||
-config /dev/null \\
|
||||
-x509 \\
|
||||
-newkey ec \\
|
||||
-pkeyopt ec_paramgen_curve:P-256 \\
|
||||
-passout env:passphrase \\
|
||||
-keyout "$keyname.ecdsa-p256.privatekey.pkcs8.pem" \\
|
||||
-out "$keyname.ecdsa-p256.certificate.pem" \\
|
||||
-days 36500 \\
|
||||
-subj "/CN=$keyname"
|
||||
|
||||
# Generate a p12 file containing both certificate and private key, for
|
||||
# applications/operating systems that cannot read PEM files with
|
||||
# certificates/private keys.
|
||||
openssl pkcs12 \\
|
||||
-export \\
|
||||
-in "$keyname.ecdsa-p256.certificate.pem" \\
|
||||
-inkey "$keyname.ecdsa-p256.privatekey.pkcs8.pem" \\
|
||||
-name "$keyname" \\
|
||||
-passin env:passphrase \\
|
||||
-passout env:passphrase \\
|
||||
-out "$keyname.ecdsa-p256-privatekey-certificate.p12"
|
||||
|
||||
# If the p12 file cannot be imported in the destination OS or email application,
|
||||
# try adding -legacy to the "openssl pkcs12" command.
|
||||
`));
|
||||
}), ' for generating a private key and certificate.')), dom.label(style({ display: 'block', marginBottom: '1ex' }), dom.div(dom.b('Name')), name = dom.input(), dom.div(style({ fontStyle: 'italic', marginTop: '.5ex' }), 'Optional. If empty, the "subject common name" from the certificate is used.')), dom.br(), dom.submitbutton('Add'))));
|
||||
}));
|
||||
if (elem) {
|
||||
elem.replaceWith(e);
|
||||
}
|
||||
elem = e;
|
||||
};
|
||||
render();
|
||||
return elem;
|
||||
})(), dom.br(), dom.h2('Disk usage'), dom.p('Storage used is ', dom.b(formatQuotaSize(Math.floor(storageUsed / (1024 * 1024)) * 1024 * 1024)), storageLimit > 0 ? [
|
||||
dom.b('/', formatQuotaSize(storageLimit)),
|
||||
' (',
|
||||
'' + Math.floor(100 * storageUsed / storageLimit),
|
||||
|
@ -298,7 +298,11 @@ const formatQuotaSize = (v: number) => {
|
||||
}
|
||||
|
||||
const index = async () => {
|
||||
const [acc, storageUsed, storageLimit, suppressions] = await client.Account()
|
||||
const [[acc, storageUsed, storageLimit, suppressions], tlspubkeys0] = await Promise.all([
|
||||
client.Account(),
|
||||
client.TLSPublicKeys(),
|
||||
])
|
||||
const tlspubkeys = tlspubkeys0 || []
|
||||
|
||||
let fullNameForm: HTMLFormElement
|
||||
let fullNameFieldset: HTMLFieldSetElement
|
||||
@ -601,7 +605,7 @@ const index = async () => {
|
||||
dom.div(
|
||||
dom.h2('Parameters'),
|
||||
dom.div(
|
||||
style({marginBottom: '.5ex'}),
|
||||
style({marginBottom: '.5ex'}),
|
||||
dom.label(
|
||||
'Event',
|
||||
dom.div(
|
||||
@ -872,6 +876,199 @@ const index = async () => {
|
||||
),
|
||||
dom.br(),
|
||||
|
||||
dom.h2('TLS public keys'),
|
||||
dom.p('For TLS client authentication with certificates, for IMAP and/or submission (SMTP). Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration or constraints are not verified.'),
|
||||
(() => {
|
||||
let elem = dom.div()
|
||||
|
||||
const preauthHelp = 'New IMAP immediate TLS connections authenticated with a client certificate are automatically switched to "authenticated" state with an untagged IMAP "preauth" message by default. IMAP connections have a state machine specifying when commands are allowed. Authenticating is not allowed while in the "authenticated" state. Enable this option to work around clients that would try to authenticated anyway.'
|
||||
|
||||
const render = () => {
|
||||
const e = dom.div(
|
||||
dom.table(
|
||||
dom.thead(
|
||||
dom.tr(
|
||||
dom.th('Login address'),
|
||||
dom.th('Name'),
|
||||
dom.th('Type'),
|
||||
dom.th('No IMAP "preauth"', attr.title(preauthHelp)),
|
||||
dom.th('Fingerprint'),
|
||||
dom.th('Update'),
|
||||
dom.th('Remove'),
|
||||
),
|
||||
),
|
||||
dom.tbody(
|
||||
tlspubkeys.length === 0 ? dom.tr(dom.td(attr.colspan('7'), 'None')) : [],
|
||||
tlspubkeys.map((tpk, index) => {
|
||||
let loginAddress: HTMLInputElement
|
||||
let name: HTMLInputElement
|
||||
let noIMAPPreauth: HTMLInputElement
|
||||
let update: HTMLButtonElement
|
||||
|
||||
const formID = 'tlk-'+index
|
||||
const row = dom.tr(
|
||||
dom.td(
|
||||
dom.form(
|
||||
attr.id(formID),
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
const ntpk: api.TLSPublicKey = {...tpk}
|
||||
ntpk.LoginAddress = loginAddress.value
|
||||
ntpk.Name = name.value
|
||||
ntpk.NoIMAPPreauth = noIMAPPreauth.checked
|
||||
await check(update, client.TLSPublicKeyUpdate(ntpk))
|
||||
tpk.LoginAddress = ntpk.LoginAddress
|
||||
tpk.Name = ntpk.Name
|
||||
tpk.NoIMAPPreauth = ntpk.NoIMAPPreauth
|
||||
},
|
||||
loginAddress=dom.input(attr.type('email'), attr.value(tpk.LoginAddress), attr.required('')),
|
||||
),
|
||||
),
|
||||
dom.td(name=dom.input(attr.form(formID), attr.value(tpk.Name), attr.required(''))),
|
||||
dom.td(tpk.Type),
|
||||
dom.td(dom.label(noIMAPPreauth=dom.input(attr.form(formID), attr.type('checkbox'), tpk.NoIMAPPreauth ? attr.checked('') : []), ' No IMAP "preauth"', attr.title(preauthHelp))),
|
||||
dom.td(tpk.Fingerprint),
|
||||
dom.td(update=dom.submitbutton(attr.form(formID), 'Update')),
|
||||
dom.td(
|
||||
dom.form(
|
||||
async function submit(e: SubmitEvent & {target: {disabled: boolean}}) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
await check(e.target, client.TLSPublicKeyRemove(tpk.Fingerprint))
|
||||
tlspubkeys.splice(tlspubkeys.indexOf(tpk), 1)
|
||||
render()
|
||||
},
|
||||
dom.submitbutton('Remove'),
|
||||
),
|
||||
),
|
||||
)
|
||||
return row
|
||||
}),
|
||||
),
|
||||
),
|
||||
dom.clickbutton('Add', style({marginTop: '1ex'}), function click() {
|
||||
let address: HTMLInputElement
|
||||
let name: HTMLInputElement
|
||||
let noIMAPPreauth: HTMLInputElement
|
||||
let file: HTMLInputElement
|
||||
|
||||
const close = popup(
|
||||
dom.div(
|
||||
style({maxWidth: '45em'}),
|
||||
dom.h1('Add TLS public key'),
|
||||
dom.form(
|
||||
async function submit(e: SubmitEvent & {target: {disabled: boolean}}) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (file.files?.length !== 1) {
|
||||
throw new Error('exactly 1 certificate required') // xxx
|
||||
}
|
||||
const certPEM = await new Promise<string>((resolve, reject) => {
|
||||
const fr = new window.FileReader()
|
||||
fr.addEventListener('load', () => {
|
||||
resolve(fr.result as string)
|
||||
})
|
||||
fr.addEventListener('error', () => {
|
||||
reject(fr.error)
|
||||
})
|
||||
fr.readAsText(file.files![0])
|
||||
})
|
||||
const ntpk = await check(e.target, client.TLSPublicKeyAdd(address.value, name.value, noIMAPPreauth.checked, certPEM))
|
||||
tlspubkeys.push(ntpk)
|
||||
render()
|
||||
close()
|
||||
},
|
||||
dom.label(
|
||||
style({display: 'block', marginBottom: '1ex'}),
|
||||
dom.div(dom.b('Login address')),
|
||||
address=dom.input(attr.type('email'), attr.value(localStorageGet('webaccountaddress') || ''), attr.required('')),
|
||||
dom.div(style({fontStyle: 'italic', marginTop: '.5ex'}), 'Login address used for sessions using this key.'),
|
||||
),
|
||||
dom.label(
|
||||
style({display: 'block', marginBottom: '1ex'}),
|
||||
noIMAPPreauth=dom.input(attr.type('checkbox')),
|
||||
' No IMAP "preauth"',
|
||||
attr.title(preauthHelp),
|
||||
),
|
||||
dom.div(
|
||||
style({display: 'block', marginBottom: '1ex'}),
|
||||
dom.label(
|
||||
dom.div(dom.b('Certificate')),
|
||||
file=dom.input(attr.type('file'), attr.required('')),
|
||||
),
|
||||
dom.p(
|
||||
style({fontStyle: 'italic', margin: '1ex 0'}),
|
||||
'Upload a PEM file containing a certificate, not a private key. Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration, and constraints are not verified. ',
|
||||
dom.a('Show suggested commands', attr.href(''), function click(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
popup(
|
||||
dom.h1('Generate a private key and certificate'),
|
||||
dom.pre(
|
||||
dom._class('literal'),
|
||||
`export keyname=... # Used for file names, certificate "common name" and as name of tls public key.
|
||||
# Suggestion: Use an application name and/or email address.
|
||||
export passphrase=... # Protects the private key in the PEM and p12 files.
|
||||
|
||||
# Generate an ECDSA P-256 private key and a long-lived, unsigned, basic certificate
|
||||
# for the corresponding public key.
|
||||
openssl req \\
|
||||
-config /dev/null \\
|
||||
-x509 \\
|
||||
-newkey ec \\
|
||||
-pkeyopt ec_paramgen_curve:P-256 \\
|
||||
-passout env:passphrase \\
|
||||
-keyout "$keyname.ecdsa-p256.privatekey.pkcs8.pem" \\
|
||||
-out "$keyname.ecdsa-p256.certificate.pem" \\
|
||||
-days 36500 \\
|
||||
-subj "/CN=$keyname"
|
||||
|
||||
# Generate a p12 file containing both certificate and private key, for
|
||||
# applications/operating systems that cannot read PEM files with
|
||||
# certificates/private keys.
|
||||
openssl pkcs12 \\
|
||||
-export \\
|
||||
-in "$keyname.ecdsa-p256.certificate.pem" \\
|
||||
-inkey "$keyname.ecdsa-p256.privatekey.pkcs8.pem" \\
|
||||
-name "$keyname" \\
|
||||
-passin env:passphrase \\
|
||||
-passout env:passphrase \\
|
||||
-out "$keyname.ecdsa-p256-privatekey-certificate.p12"
|
||||
|
||||
# If the p12 file cannot be imported in the destination OS or email application,
|
||||
# try adding -legacy to the "openssl pkcs12" command.
|
||||
`
|
||||
),
|
||||
)
|
||||
}),
|
||||
' for generating a private key and certificate.',
|
||||
),
|
||||
),
|
||||
dom.label(
|
||||
style({display: 'block', marginBottom: '1ex'}),
|
||||
dom.div(dom.b('Name')),
|
||||
name=dom.input(),
|
||||
dom.div(style({fontStyle: 'italic', marginTop: '.5ex'}), 'Optional. If empty, the "subject common name" from the certificate is used.'),
|
||||
),
|
||||
dom.br(),
|
||||
dom.submitbutton('Add'),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
if (elem) {
|
||||
elem.replaceWith(e)
|
||||
}
|
||||
elem = e
|
||||
}
|
||||
render()
|
||||
return elem
|
||||
})(),
|
||||
dom.br(),
|
||||
|
||||
dom.h2('Disk usage'),
|
||||
dom.p('Storage used is ', dom.b(formatQuotaSize(Math.floor(storageUsed/(1024*1024))*1024*1024)),
|
||||
storageLimit > 0 ? [
|
||||
|
@ -6,9 +6,14 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
cryptorand "crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -484,6 +489,65 @@ func TestAccount(t *testing.T) {
|
||||
api.RejectsSave(ctx, "Rejects", false)
|
||||
api.RejectsSave(ctx, "", false) // Restore.
|
||||
|
||||
// Make cert for TLSPublicKey.
|
||||
certBuf := fakeCert(t)
|
||||
var b bytes.Buffer
|
||||
err = pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: certBuf})
|
||||
tcheck(t, err, "encoding certificate as pem")
|
||||
certPEM := b.String()
|
||||
|
||||
err = store.Init(ctx)
|
||||
tcheck(t, err, "store init")
|
||||
defer func() {
|
||||
err := store.Close()
|
||||
tcheck(t, err, "store close")
|
||||
}()
|
||||
|
||||
tpkl, err := api.TLSPublicKeys(ctx)
|
||||
tcheck(t, err, "list tls public keys")
|
||||
tcompare(t, len(tpkl), 0)
|
||||
|
||||
tpk, err := api.TLSPublicKeyAdd(ctx, "mjl☺@mox.example", "", false, certPEM)
|
||||
tcheck(t, err, "add tls public key")
|
||||
// Key already exists.
|
||||
tneedErrorCode(t, "user:error", func() { api.TLSPublicKeyAdd(ctx, "mjl☺@mox.example", "", false, certPEM) })
|
||||
|
||||
tpkl, err = api.TLSPublicKeys(ctx)
|
||||
tcheck(t, err, "list tls public keys")
|
||||
tcompare(t, tpkl, []store.TLSPublicKey{tpk})
|
||||
|
||||
tpk.NoIMAPPreauth = true
|
||||
err = api.TLSPublicKeyUpdate(ctx, tpk)
|
||||
tcheck(t, err, "tls public key update")
|
||||
badtpk := tpk
|
||||
badtpk.Fingerprint = "bogus"
|
||||
tneedErrorCode(t, "user:error", func() { api.TLSPublicKeyUpdate(ctx, badtpk) })
|
||||
|
||||
tpkl, err = api.TLSPublicKeys(ctx)
|
||||
tcheck(t, err, "list tls public keys")
|
||||
tcompare(t, len(tpkl), 1)
|
||||
tcompare(t, tpkl[0].NoIMAPPreauth, true)
|
||||
|
||||
err = api.TLSPublicKeyRemove(ctx, tpk.Fingerprint)
|
||||
tcheck(t, err, "tls public key remove")
|
||||
tneedErrorCode(t, "user:error", func() { api.TLSPublicKeyRemove(ctx, tpk.Fingerprint) })
|
||||
|
||||
tpkl, err = api.TLSPublicKeys(ctx)
|
||||
tcheck(t, err, "list tls public keys")
|
||||
tcompare(t, len(tpkl), 0)
|
||||
|
||||
api.Logout(ctx)
|
||||
tneedErrorCode(t, "server:error", func() { api.Logout(ctx) })
|
||||
}
|
||||
|
||||
func fakeCert(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
seed := make([]byte, ed25519.SeedSize)
|
||||
privKey := ed25519.NewKeyFromSeed(seed) // Fake key, don't use this for real!
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1), // Required field...
|
||||
}
|
||||
localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
|
||||
tcheck(t, err, "making certificate")
|
||||
return localCertBuf
|
||||
}
|
||||
|
@ -450,6 +450,84 @@
|
||||
}
|
||||
],
|
||||
"Returns": []
|
||||
},
|
||||
{
|
||||
"Name": "TLSPublicKeys",
|
||||
"Docs": "",
|
||||
"Params": [],
|
||||
"Returns": [
|
||||
{
|
||||
"Name": "r0",
|
||||
"Typewords": [
|
||||
"[]",
|
||||
"TLSPublicKey"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "TLSPublicKeyAdd",
|
||||
"Docs": "",
|
||||
"Params": [
|
||||
{
|
||||
"Name": "loginAddress",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "name",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "noIMAPPreauth",
|
||||
"Typewords": [
|
||||
"bool"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "certPEM",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
],
|
||||
"Returns": [
|
||||
{
|
||||
"Name": "r0",
|
||||
"Typewords": [
|
||||
"TLSPublicKey"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "TLSPublicKeyRemove",
|
||||
"Docs": "",
|
||||
"Params": [
|
||||
{
|
||||
"Name": "fingerprint",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
],
|
||||
"Returns": []
|
||||
},
|
||||
{
|
||||
"Name": "TLSPublicKeyUpdate",
|
||||
"Docs": "",
|
||||
"Params": [
|
||||
{
|
||||
"Name": "pubKey",
|
||||
"Typewords": [
|
||||
"TLSPublicKey"
|
||||
]
|
||||
}
|
||||
],
|
||||
"Returns": []
|
||||
}
|
||||
],
|
||||
"Sections": [],
|
||||
@ -1510,6 +1588,69 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "TLSPublicKey",
|
||||
"Docs": "TLSPublicKey is a public key for use with TLS client authentication based on the\npublic key of the certificate.",
|
||||
"Fields": [
|
||||
{
|
||||
"Name": "Fingerprint",
|
||||
"Docs": "Raw-url-base64-encoded Subject Public Key Info of certificate.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Created",
|
||||
"Docs": "",
|
||||
"Typewords": [
|
||||
"timestamp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Type",
|
||||
"Docs": "E.g. \"rsa-2048\", \"ecdsa-p256\", \"ed25519\"",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Name",
|
||||
"Docs": "Descriptive name to identify the key, e.g. the device where key is used.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "NoIMAPPreauth",
|
||||
"Docs": "If set, new immediate authenticated TLS connections are not moved to \"authenticated\" state. For clients that don't understand it, and will try an authenticate command anyway.",
|
||||
"Typewords": [
|
||||
"bool"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "CertDER",
|
||||
"Docs": "",
|
||||
"Typewords": [
|
||||
"[]",
|
||||
"uint8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Account",
|
||||
"Docs": "Key authenticates this account.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "LoginAddress",
|
||||
"Docs": "Must belong to account.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Ints": [],
|
||||
|
@ -204,6 +204,19 @@ export interface IncomingMeta {
|
||||
Automated: boolean // Whether this message was automated and should not receive automated replies. E.g. out of office or mailing list messages.
|
||||
}
|
||||
|
||||
// TLSPublicKey is a public key for use with TLS client authentication based on the
|
||||
// public key of the certificate.
|
||||
export interface TLSPublicKey {
|
||||
Fingerprint: string // Raw-url-base64-encoded Subject Public Key Info of certificate.
|
||||
Created: Date
|
||||
Type: string // E.g. "rsa-2048", "ecdsa-p256", "ed25519"
|
||||
Name: string // Descriptive name to identify the key, e.g. the device where key is used.
|
||||
NoIMAPPreauth: boolean // If set, new immediate authenticated TLS connections are not moved to "authenticated" state. For clients that don't understand it, and will try an authenticate command anyway.
|
||||
CertDER?: string | null
|
||||
Account: string // Key authenticates this account.
|
||||
LoginAddress: string // Must belong to account.
|
||||
}
|
||||
|
||||
export type CSRFToken = string
|
||||
|
||||
// Localpart is a decoded local part of an email address, before the "@".
|
||||
@ -238,7 +251,7 @@ export enum OutgoingEvent {
|
||||
EventUnrecognized = "unrecognized",
|
||||
}
|
||||
|
||||
export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AutomaticJunkFlags":true,"Destination":true,"Domain":true,"ImportProgress":true,"Incoming":true,"IncomingMeta":true,"IncomingWebhook":true,"JunkFilter":true,"NameAddress":true,"Outgoing":true,"OutgoingWebhook":true,"Route":true,"Ruleset":true,"Structure":true,"SubjectPass":true,"Suppression":true}
|
||||
export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AutomaticJunkFlags":true,"Destination":true,"Domain":true,"ImportProgress":true,"Incoming":true,"IncomingMeta":true,"IncomingWebhook":true,"JunkFilter":true,"NameAddress":true,"Outgoing":true,"OutgoingWebhook":true,"Route":true,"Ruleset":true,"Structure":true,"SubjectPass":true,"Suppression":true,"TLSPublicKey":true}
|
||||
export const stringsTypes: {[typename: string]: boolean} = {"CSRFToken":true,"Localpart":true,"OutgoingEvent":true}
|
||||
export const intsTypes: {[typename: string]: boolean} = {}
|
||||
export const types: TypenameMap = {
|
||||
@ -263,6 +276,7 @@ export const types: TypenameMap = {
|
||||
"NameAddress": {"Name":"NameAddress","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"Address","Docs":"","Typewords":["string"]}]},
|
||||
"Structure": {"Name":"Structure","Docs":"","Fields":[{"Name":"ContentType","Docs":"","Typewords":["string"]},{"Name":"ContentTypeParams","Docs":"","Typewords":["{}","string"]},{"Name":"ContentID","Docs":"","Typewords":["string"]},{"Name":"DecodedSize","Docs":"","Typewords":["int64"]},{"Name":"Parts","Docs":"","Typewords":["[]","Structure"]}]},
|
||||
"IncomingMeta": {"Name":"IncomingMeta","Docs":"","Fields":[{"Name":"MsgID","Docs":"","Typewords":["int64"]},{"Name":"MailFrom","Docs":"","Typewords":["string"]},{"Name":"MailFromValidated","Docs":"","Typewords":["bool"]},{"Name":"MsgFromValidated","Docs":"","Typewords":["bool"]},{"Name":"RcptTo","Docs":"","Typewords":["string"]},{"Name":"DKIMVerifiedDomains","Docs":"","Typewords":["[]","string"]},{"Name":"RemoteIP","Docs":"","Typewords":["string"]},{"Name":"Received","Docs":"","Typewords":["timestamp"]},{"Name":"MailboxName","Docs":"","Typewords":["string"]},{"Name":"Automated","Docs":"","Typewords":["bool"]}]},
|
||||
"TLSPublicKey": {"Name":"TLSPublicKey","Docs":"","Fields":[{"Name":"Fingerprint","Docs":"","Typewords":["string"]},{"Name":"Created","Docs":"","Typewords":["timestamp"]},{"Name":"Type","Docs":"","Typewords":["string"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"NoIMAPPreauth","Docs":"","Typewords":["bool"]},{"Name":"CertDER","Docs":"","Typewords":["nullable","string"]},{"Name":"Account","Docs":"","Typewords":["string"]},{"Name":"LoginAddress","Docs":"","Typewords":["string"]}]},
|
||||
"CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null},
|
||||
"Localpart": {"Name":"Localpart","Docs":"","Values":null},
|
||||
"OutgoingEvent": {"Name":"OutgoingEvent","Docs":"","Values":[{"Name":"EventDelivered","Value":"delivered","Docs":""},{"Name":"EventSuppressed","Value":"suppressed","Docs":""},{"Name":"EventDelayed","Value":"delayed","Docs":""},{"Name":"EventFailed","Value":"failed","Docs":""},{"Name":"EventRelayed","Value":"relayed","Docs":""},{"Name":"EventExpanded","Value":"expanded","Docs":""},{"Name":"EventCanceled","Value":"canceled","Docs":""},{"Name":"EventUnrecognized","Value":"unrecognized","Docs":""}]},
|
||||
@ -290,6 +304,7 @@ export const parser = {
|
||||
NameAddress: (v: any) => parse("NameAddress", v) as NameAddress,
|
||||
Structure: (v: any) => parse("Structure", v) as Structure,
|
||||
IncomingMeta: (v: any) => parse("IncomingMeta", v) as IncomingMeta,
|
||||
TLSPublicKey: (v: any) => parse("TLSPublicKey", v) as TLSPublicKey,
|
||||
CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken,
|
||||
Localpart: (v: any) => parse("Localpart", v) as Localpart,
|
||||
OutgoingEvent: (v: any) => parse("OutgoingEvent", v) as OutgoingEvent,
|
||||
@ -535,6 +550,38 @@ export class Client {
|
||||
const params: any[] = [mailbox, keep]
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
async TLSPublicKeys(): Promise<TLSPublicKey[] | null> {
|
||||
const fn: string = "TLSPublicKeys"
|
||||
const paramTypes: string[][] = []
|
||||
const returnTypes: string[][] = [["[]","TLSPublicKey"]]
|
||||
const params: any[] = []
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSPublicKey[] | null
|
||||
}
|
||||
|
||||
async TLSPublicKeyAdd(loginAddress: string, name: string, noIMAPPreauth: boolean, certPEM: string): Promise<TLSPublicKey> {
|
||||
const fn: string = "TLSPublicKeyAdd"
|
||||
const paramTypes: string[][] = [["string"],["string"],["bool"],["string"]]
|
||||
const returnTypes: string[][] = [["TLSPublicKey"]]
|
||||
const params: any[] = [loginAddress, name, noIMAPPreauth, certPEM]
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSPublicKey
|
||||
}
|
||||
|
||||
async TLSPublicKeyRemove(fingerprint: string): Promise<void> {
|
||||
const fn: string = "TLSPublicKeyRemove"
|
||||
const paramTypes: string[][] = [["string"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [fingerprint]
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
|
||||
async TLSPublicKeyUpdate(pubKey: TLSPublicKey): Promise<void> {
|
||||
const fn: string = "TLSPublicKeyUpdate"
|
||||
const paramTypes: string[][] = [["TLSPublicKey"]]
|
||||
const returnTypes: string[][] = []
|
||||
const params: any[] = [pubKey]
|
||||
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultBaseURL = (function() {
|
||||
|
Reference in New Issue
Block a user