fix(mosaic): harden credential lifecycle boundaries

This commit is contained in:
2026-08-05 18:01:41 -05:00
parent 26203bd92c
commit e46e114d3b
13 changed files with 127 additions and 31 deletions
@@ -539,7 +539,7 @@ get_gitea_token() {
if [[ -n "$_idpfx" ]]; then if [[ -n "$_idpfx" ]]; then
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token" local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json" local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
if [[ -r "$_idcred" ]]; then if [[ -e "$_idcred" || -L "$_idcred" ]]; then
local _resolved_token local _resolved_token
_resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \ _resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \
"$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1 "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1
@@ -38,25 +38,25 @@ trace_resolution() {
# survives across non-persistent shells) > git-supplied username (credential.username # survives across non-persistent shells) > git-supplied username (credential.username
# / URL). When the resolved identity has a matching per-agent token, use it instead of # / URL). When the resolved identity has a matching per-agent token, use it instead of
# the shared account. Backward-compatible: nothing resolvable → shared token. # the shared account. Backward-compatible: nothing resolvable → shared token.
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
*) idpfx="";;
esac
ident="$MOSAIC_GIT_IDENTITY" ident="$MOSAIC_GIT_IDENTITY"
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null) [ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
[ -z "$ident" ] && ident="$username_in" [ -z "$ident" ] && ident="$username_in"
if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then if [ -n "$idpfx" ] && [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then
echo "quit=true" echo "quit=true"
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \ printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
"$ident" "$MOSAIC_AGENT_NAME" "$host" >&2 "$ident" "$MOSAIC_AGENT_NAME" "$host" >&2
exit 1 exit 1
fi fi
if [ -n "$ident" ]; then if [ -n "$ident" ]; then
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
*) idpfx="";;
esac
if [ -n "$idpfx" ]; then if [ -n "$idpfx" ]; then
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token" idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json" idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
if [ -r "$idcred" ]; then if [ -e "$idcred" ] || [ -L "$idcred" ]; then
token=$(python3 "$script_dir/resolve-credential-envelope.py" \ token=$(python3 "$script_dir/resolve-credential-envelope.py" \
"$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1 "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1
trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic
@@ -194,6 +194,7 @@ PY
chmod 600 "$envelope" chmod 600 "$envelope"
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab) out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab)
assert_eq "governed envelope: password" "password=agentE-envelope-token" "$(echo "$out" | grep '^password=')" assert_eq "governed envelope: password" "password=agentE-envelope-token" "$(echo "$out" | grep '^password=')"
echo -n "must-not-fallback-legacy" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.token"
chmod 640 "$envelope" chmod 640 "$envelope"
set +e set +e
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab 2>"$WORK_DIR/envelope-mode.stderr") out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab 2>"$WORK_DIR/envelope-mode.stderr")
@@ -210,6 +211,8 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
out=$(run_helper "github.com" "agentA") out=$(run_helper "github.com" "agentA")
assert_eq "unknown host: no output" "" "$out" assert_eq "unknown host: no output" "" "$out"
out=$(run_helper "github.com" "github-user" MOSAIC_AGENT_NAME=agentA)
assert_eq "unknown host in fleet context: no output" "" "$out"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 9. Non-"get" verb (store/erase) -> exit 0, no output (git-credential # 9. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
+27 -9
View File
@@ -56,6 +56,7 @@ interface CredentialValidateCommandOptions {
readonly mosaicHome?: string; readonly mosaicHome?: string;
readonly actor?: string; readonly actor?: string;
readonly json?: boolean; readonly json?: boolean;
readonly operation?: 'validate' | 'whoami';
} }
interface CredentialGrantCommandOptions { interface CredentialGrantCommandOptions {
@@ -111,10 +112,11 @@ function readRegistrySource(path: string): string {
function errorResult( function errorResult(
request: GiteaWriteValidationRequestDto, request: GiteaWriteValidationRequestDto,
code: string, code: string,
operation: 'validate' | 'whoami' = 'validate',
): CredentialValidationResultDto { ): CredentialValidationResultDto {
return { return {
schemaVersion: 1, schemaVersion: 1,
operation: 'validate', operation,
outcome: 'error', outcome: 'error',
exitCode: 20, exitCode: 20,
retryable: false, retryable: false,
@@ -166,13 +168,13 @@ export async function executeCredentialValidate(
try { try {
if (!['read', 'write', 'admin'].includes(options.require)) { if (!['read', 'write', 'admin'].includes(options.require)) {
return errorResult(request, 'invalid-input'); return errorResult(request, 'invalid-input', options.operation);
} }
const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath)); const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath));
const hostConfig = registry.resolve(options.estate, options.host); const hostConfig = registry.resolve(options.estate, options.host);
if (hostConfig === undefined) { if (hostConfig === undefined) {
return { return {
...errorResult(request, 'estate-host-mismatch'), ...errorResult(request, 'estate-host-mismatch', options.operation),
outcome: 'refused', outcome: 'refused',
exitCode: 10, exitCode: 10,
reason: { reason: {
@@ -188,19 +190,23 @@ export async function executeCredentialValidate(
const resolver = new FileCredentialResolver(tokenDirectory, registry); const resolver = new FileCredentialResolver(tokenDirectory, registry);
const provider = new GiteaCredentialProviderAdapter(hostConfig.apiBaseUrl, fetch); const provider = new GiteaCredentialProviderAdapter(hostConfig.apiBaseUrl, fetch);
const dependencies = { resolver, provider, estateRegistry: registry }; const dependencies = { resolver, provider, estateRegistry: registry };
const serviceOptions = { stateRoot, actor: options.actor ?? identity }; const serviceOptions = {
stateRoot,
actor: options.actor ?? identity,
operation: options.operation ?? 'validate',
};
if (options.require === 'read') { if (options.require === 'read') {
return await runCredentialReadValidation(request, dependencies, serviceOptions); return await runCredentialReadValidation(request, dependencies, serviceOptions);
} }
return await runCredentialValidation(request, dependencies, serviceOptions); return await runCredentialValidation(request, dependencies, serviceOptions);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof CredentialJournalError) { if (error instanceof CredentialJournalError) {
return errorResult(request, error.code); return errorResult(request, error.code, options.operation);
} }
if (error instanceof CredentialEstateRegistryError || error instanceof CredentialStoreError) { if (error instanceof CredentialEstateRegistryError || error instanceof CredentialStoreError) {
return errorResult(request, error.code); return errorResult(request, error.code, options.operation);
} }
return errorResult(request, 'internal-invariant'); return errorResult(request, 'internal-invariant', options.operation);
} }
} }
@@ -505,6 +511,14 @@ export async function executeCredentialRotate(
message: 'No existing binding can be rotated.', message: 'No existing binding can be rotated.',
}); });
} }
if (options.tokenName === old.binding.tokenName) {
old.secret.fill(0);
return localLifecycleResult('rotate', identity, options, {
outcome: 'refused',
code: 'replacement-token-name-conflict',
message: 'Replacement token name must differ from the active generation.',
});
}
const authority = await lifecycleAuthority(identity, options); const authority = await lifecycleAuthority(identity, options);
const journal = await CredentialAuditJournal.open(context.stateRoot, { const journal = await CredentialAuditJournal.open(context.stateRoot, {
operation: 'rotate', operation: 'rotate',
@@ -646,7 +660,7 @@ export async function executeCredentialWire(
lines.push( lines.push(
`MOSAIC_GIT_IDENTITY=${identity}`, `MOSAIC_GIT_IDENTITY=${identity}`,
`MOSAIC_CREDENTIAL_ESTATE=${options.estate}`, `MOSAIC_CREDENTIAL_ESTATE=${options.estate}`,
`GITEA_LOGIN=${identity}`, `GITEA_LOGIN=${identity}--${options.host}`,
); );
const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`; const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`;
const handle = await open(temp, 'wx', 0o600); const handle = await open(temp, 'wx', 0o600);
@@ -1051,7 +1065,11 @@ export function registerCredentialCommand(parent: Command): void {
.option('--actor <identity>', 'Explicit audit actor') .option('--actor <identity>', 'Explicit audit actor')
.option('--json', 'Emit one machine result object') .option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialValidateCommandOptions): Promise<void> => { .action(async (identity: string, options: CredentialValidateCommandOptions): Promise<void> => {
const result = await executeCredentialValidate(identity, { ...options, require: 'read' }); const result = await executeCredentialValidate(identity, {
...options,
require: 'read',
operation: 'whoami',
});
printCredentialResult(result, options.json === true); printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode; process.exitCode = result.exitCode;
}); });
@@ -1,4 +1,4 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
@@ -107,6 +107,22 @@ describe('credential durable audit journal', (): void => {
await journal.closeIncomplete(); await journal.closeIncomplete();
}); });
it('refuses a group-writable journal root before opening evidence', async (): Promise<void> => {
const root = await stateRoot();
await mkdir(root, { mode: 0o700 });
await chmod(root, 0o770);
await expect(
CredentialAuditJournal.open(root, {
operation: 'grant',
actor: 'provisioner',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: 'owner/repo',
}),
).rejects.toThrow(/journal-unavailable/);
});
it('supersedes a false sealed classification without editing the original journal', async (): Promise<void> => { it('supersedes a false sealed classification without editing the original journal', async (): Promise<void> => {
const root = await stateRoot(); const root = await stateRoot();
const journal = await CredentialAuditJournal.open( const journal = await CredentialAuditJournal.open(
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { lstatSync } from 'node:fs';
import { open, readdir, rename } from 'node:fs/promises'; import { open, readdir, rename } from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -31,6 +32,7 @@ const SAFE_DECISIONS = new Set<string>([
'revoke-verified', 'revoke-verified',
'rotate-verified', 'rotate-verified',
'validation-requested', 'validation-requested',
'whoami-requested',
'provision-requested', 'provision-requested',
'rotate-requested', 'rotate-requested',
'revoke-requested', 'revoke-requested',
@@ -69,6 +71,21 @@ export class CredentialJournalError extends Error {
} }
} }
function assertPrivateDirectory(path: string): void {
const stat = lstatSync(path);
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== process.getuid?.() ||
(stat.mode & 0o022) !== 0
) {
throw new CredentialJournalError(
'journal-unavailable',
'journal directory owner or write permissions are unsafe',
);
}
}
function assertContext(context: CredentialJournalContextDto): void { function assertContext(context: CredentialJournalContextDto): void {
if ( if (
!SAFE_NAME.test(context.actor) || !SAFE_NAME.test(context.actor) ||
@@ -132,6 +149,8 @@ export class CredentialAuditJournal {
let handle: FileHandle | undefined; let handle: FileHandle | undefined;
try { try {
ensureManagedDirectory(stateRoot, journalsDirectory); ensureManagedDirectory(stateRoot, journalsDirectory);
assertPrivateDirectory(stateRoot);
assertPrivateDirectory(journalsDirectory);
const openPath = join(journalsDirectory, `${id}.open.jsonl`); const openPath = join(journalsDirectory, `${id}.open.jsonl`);
handle = await open(openPath, 'wx', 0o600); handle = await open(openPath, 'wx', 0o600);
const journal = new CredentialAuditJournal(handle, openPath, journalsDirectory, id, now); const journal = new CredentialAuditJournal(handle, openPath, journalsDirectory, id, now);
@@ -270,6 +289,8 @@ export async function listCredentialJournals(
const journalsDirectory = join(stateRoot, 'journals'); const journalsDirectory = join(stateRoot, 'journals');
let names: string[]; let names: string[];
try { try {
assertPrivateDirectory(stateRoot);
assertPrivateDirectory(journalsDirectory);
names = await readdir(journalsDirectory); names = await readdir(journalsDirectory);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return []; if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return [];
@@ -72,7 +72,7 @@ export interface CredentialAuditResultDto {
export interface CredentialValidationResultDto { export interface CredentialValidationResultDto {
readonly schemaVersion: 1; readonly schemaVersion: 1;
readonly operation: 'validate'; readonly operation: 'validate' | 'whoami';
readonly outcome: CredentialOutcome; readonly outcome: CredentialOutcome;
readonly exitCode: 0 | 10 | 20 | 30; readonly exitCode: 0 | 10 | 20 | 30;
readonly retryable: boolean; readonly retryable: boolean;
@@ -13,6 +13,7 @@ import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './val
export interface CredentialValidationServiceOptions { export interface CredentialValidationServiceOptions {
readonly stateRoot: string; readonly stateRoot: string;
readonly actor: string; readonly actor: string;
readonly operation?: 'validate' | 'whoami';
} }
function permissionDecision(permission: RepositoryPermission): string { function permissionDecision(permission: RepositoryPermission): string {
@@ -27,14 +28,16 @@ async function openValidationJournal(
options: CredentialValidationServiceOptions, options: CredentialValidationServiceOptions,
): Promise<CredentialAuditJournal> { ): Promise<CredentialAuditJournal> {
const journal = await CredentialAuditJournal.open(options.stateRoot, { const journal = await CredentialAuditJournal.open(options.stateRoot, {
operation: 'validate', operation: options.operation ?? 'validate',
actor: options.actor, actor: options.actor,
identity: request.identity, identity: request.identity,
estate: request.estate, estate: request.estate,
host: request.host, host: request.host,
repo: request.repo, repo: request.repo,
}); });
await journal.recordIntent('validation-requested'); await journal.recordIntent(
options.operation === 'whoami' ? 'whoami-requested' : 'validation-requested',
);
return journal; return journal;
} }
@@ -70,7 +73,10 @@ export async function runCredentialReadValidation(
): Promise<CredentialValidationResultDto> { ): Promise<CredentialValidationResultDto> {
const journal = await openValidationJournal(request, options); const journal = await openValidationJournal(request, options);
const validation = await evaluateGiteaReadValidation(request, dependencies); const validation = await evaluateGiteaReadValidation(request, dependencies);
return recordAndSealValidation(journal, validation); return recordAndSealValidation(journal, {
...validation,
operation: options.operation ?? 'validate',
});
} }
export async function runCredentialValidation( export async function runCredentialValidation(
@@ -26,6 +26,11 @@ describe('credential estate registry', (): void => {
expect(registry.matches('homelab', 'git.example.invalid')).toBe(true); expect(registry.matches('homelab', 'git.example.invalid')).toBe(true);
expect(registry.matches('usc', 'git.example.invalid')).toBe(false); expect(registry.matches('usc', 'git.example.invalid')).toBe(false);
expect(registry.matches('homelab', 'other.example.invalid')).toBe(false); expect(registry.matches('homelab', 'other.example.invalid')).toBe(false);
expect(registry.resolveByHost('git.example.invalid')).toMatchObject({
estate: 'homelab',
host: { host: 'git.example.invalid', provider: 'gitea' },
});
expect(registry.resolveByHost('other.example.invalid')).toBeUndefined();
}); });
it('rejects a provider URL whose host differs from the declared host', (): void => { it('rejects a provider URL whose host differs from the declared host', (): void => {
@@ -93,6 +93,18 @@ export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry
?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host); ?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host);
} }
resolveByHost(
host: string,
): { readonly estate: string; readonly host: CredentialHostConfigDto } | undefined {
for (const [estate, config] of this.estates) {
const match = config.hosts.find(
(candidate: CredentialHostConfigDto): boolean => candidate.host === host,
);
if (match !== undefined) return { estate, host: match };
}
return undefined;
}
readOnlyControl(estate: string): string { readOnlyControl(estate: string): string {
const identity = this.estates.get(estate)?.readOnlyControlIdentity; const identity = this.estates.get(estate)?.readOnlyControlIdentity;
if (identity === undefined) { if (identity === undefined) {
+9 -2
View File
@@ -205,8 +205,9 @@ export async function provisionCredential(
teaLogin: { ...teaLogin, state: 'registered' }, teaLogin: { ...teaLogin, state: 'registered' },
}); });
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof CredentialJournalError) throw error; const journalFailure = error instanceof CredentialJournalError;
if (minted === undefined) { if (minted === undefined) {
if (journalFailure) throw error;
await journal.seal('indeterminate', 'provider-unavailable'); await journal.seal('indeterminate', 'provider-unavailable');
return lifecycleResult('provision', request, { return lifecycleResult('provision', request, {
outcome: 'indeterminate', outcome: 'indeterminate',
@@ -231,13 +232,19 @@ export async function provisionCredential(
await teaStore.put(request.identity, request.host, prior.secret); await teaStore.put(request.identity, request.host, prior.secret);
} }
rollbackComplete = true; rollbackComplete = true;
await journal.recordMutation('provision-rollback-verified');
} catch { } catch {
rollbackComplete = false; rollbackComplete = false;
} finally { } finally {
prior?.secret.fill(0); prior?.secret.fill(0);
} }
if (journalFailure) {
if (rollbackComplete) {
await journal.recordMutation('provision-rollback-verified').catch((): void => undefined);
}
throw error;
}
const code = rollbackComplete ? failureCode : 'rollback-incomplete'; const code = rollbackComplete ? failureCode : 'rollback-incomplete';
if (rollbackComplete) await journal.recordMutation('provision-rollback-verified');
await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code); await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code);
return lifecycleResult('provision', request, { return lifecycleResult('provision', request, {
outcome: rollbackComplete ? 'error' : 'indeterminate', outcome: rollbackComplete ? 'error' : 'indeterminate',
@@ -28,7 +28,7 @@ describe('host-bound Tea login store', (): void => {
await store.remove('seat', 'git.one.invalid'); await store.remove('seat', 'git.one.invalid');
expect(store.readBack('seat', 'git.one.invalid')).toBeUndefined(); expect(store.readBack('seat', 'git.one.invalid')).toBeUndefined();
expect(store.readBack('seat', 'git.two.invalid')).toEqual({ expect(store.readBack('seat', 'git.two.invalid')).toEqual({
name: 'seat', name: 'seat--git.two.invalid',
host: 'git.two.invalid', host: 'git.two.invalid',
}); });
}); });
@@ -59,6 +59,10 @@ async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open
); );
} }
function loginName(identity: string, host: string): string {
return `${identity}--${host}`;
}
function assertPrivate(snapshot: { readonly mode: number; readonly uid: number }): void { function assertPrivate(snapshot: { readonly mode: number; readonly uid: number }): void {
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) { if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private'); throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
@@ -98,14 +102,15 @@ export class TeaLoginStore {
} }
const token = Buffer.from(secret).toString('utf8'); const token = Buffer.from(secret).toString('utf8');
const record: TeaLoginRecord = { const record: TeaLoginRecord = {
name: identity, name: loginName(identity, host),
url: `https://${host}`, url: `https://${host}`,
token, token,
user: identity, user: identity,
default: false, default: false,
}; };
const logins = current.logins.filter( const logins = current.logins.filter(
(login): boolean => !(login.name === identity && login.url === `https://${host}`), (login): boolean =>
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
); );
logins.push(record); logins.push(record);
const temp = `${this.configPath}.${randomUUID()}.tmp`; const temp = `${this.configPath}.${randomUUID()}.tmp`;
@@ -140,7 +145,9 @@ export class TeaLoginStore {
if (!decoded.success) return undefined; if (!decoded.success) return undefined;
const matches = decoded.data.logins.filter( const matches = decoded.data.logins.filter(
(login): boolean => (login): boolean =>
login.name === identity && login.url === `https://${host}` && login.user === identity, login.name === loginName(identity, host) &&
login.url === `https://${host}` &&
login.user === identity,
); );
if (matches.length !== 1 || matches[0] === undefined) return undefined; if (matches.length !== 1 || matches[0] === undefined) return undefined;
return Object.freeze({ return Object.freeze({
@@ -173,7 +180,8 @@ export class TeaLoginStore {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation'); throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
} }
const logins = decoded.data.logins.filter( const logins = decoded.data.logins.filter(
(login): boolean => !(login.name === identity && login.url === `https://${host}`), (login): boolean =>
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
); );
const temp = `${this.configPath}.${randomUUID()}.tmp`; const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600); const handle = await open(temp, 'wx', 0o600);
@@ -215,13 +223,13 @@ export class TeaLoginStore {
if (typeof value !== 'object' || value === null) return false; if (typeof value !== 'object' || value === null) return false;
return ( return (
'name' in value && 'name' in value &&
value.name === identity && value.name === loginName(identity, host) &&
'url' in value && 'url' in value &&
value.url === `https://${host}` && value.url === `https://${host}` &&
'user' in value && 'user' in value &&
value.user === identity value.user === identity
); );
}); });
return matches.length === 1 ? { name: identity, host } : undefined; return matches.length === 1 ? { name: loginName(identity, host), host } : undefined;
} }
} }