Repository navigation
feat(agent): device identity enrollment and channel - #2000
Draft
Benoît Cortier (CBenoit) wants to merge 215 commits into
Draft
Benoît Cortier (CBenoit) wants to merge 215 commits into
Benoît Cortier (CBenoit) wants to merge 215 commits into
Conversation
Member
Author
|
Implementation notes (Phase 1: contract and conformance suite) Contents
Current results (Windows, local)
Dependencies
Suite decisions
Review Note LLM-assisted content (no human feedback). |
Benoît Cortier (CBenoit)
force-pushed
the
cbenoit-agent-identity-v1
branch
3 times, most recently
from
October 19, 2026 19:01
a2184c8 to
12dfd95
Compare
Benoît Cortier (CBenoit)
added this pull request to stack #2126
October 20, 2026 14:01
Benoît Cortier (CBenoit)
force-pushed
the
cbenoit-agent-identity-v1
branch
from
October 21, 2026 01:10
aa0acdc to
c987cd8
Compare
Devolutions Agent gains an identity per authority (e.g.: DVLS, PSU): the device enrolls once from a reusable token, the authority mints an identity and issues an X.509 certificate, and the agent authenticates later requests and a bidirectional gRPC channel with that identity. - `docs/agent-identity/CONTRACT.md` defines what the agent and an authority agree on, with shared test vectors and an independent vector verifier; the design rationale lives in the shared intent `INTENT.agent-identity.md`, symlinked into each component. - `device-key` keeps device keys in the Windows software provider or in files, under an access rule granting only the caller and SYSTEM. - `agent-identity` and `agent-identity-httpsig` hold pending enrollment, stored identities, the REST client and RFC 9421 request signing; `agent-channel-proto` defines the channel for Rust and .NET. - `devolutions-agent identity enroll` writes a pending enrollment, and the service enrolls from it. - `agent-identity-mock` and `agent-identity-conformance` form an independent conformance suite that the testsuite runs against the agent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Lets the Windows and Linux test jobs both finish while the agent identity conformance tests are expected to fail. To be reverted before the pull request leaves draft status. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…outcome From experiment 1 (RFC 9421, DPoP, and DPoP with authority-issued nonces, built as probes against a shared proxy harness and judged blind), the maintainer kept RFC 9421 and recorded the two decisions the evidence settled, each with the alternative it excludes: request authentication never covers the host or path, and the agent supplies each request's freshness. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…d hit Found by the blind judge of experiment 1 and approved by the maintainer: - a verifier detects repeated parameters, members and labels on the raw field, since RFC 8941 parsers keep only the last occurrence; - byte sequences in `Signature` and `Content-Digest` use canonical padded base64; - a refused channel opening carries a `server-time` trailer, so the agent's single clock-skew retry works on the channel too. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The rule-to-test mapping, the review-only claims and the known gaps describe the conformance suite, not something the agent and an authority agree on, and they drift as tests are renamed. The contract keeps what conformance means; the mapping moves to the suite's COVERAGE.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract's rules define conformance; the suite is independent evidence derived from them, so defining conformance as passing the suite inverted that. §6 still requires the shared vectors, and the suite's role is stated in its own intent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The vectors are evidence for the §6 rules, not a rule both parties agree to, so their role, the signer's pass condition and the independent verifier move to the shared intent's Conformance section. The vector file no longer claims a signer reproduces signature bytes, which contradicted that condition. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract no longer defines friendly names, so §5.2 drops its §3 reference, and the DVLS intent points at the shared intent's Enrollment section, where the format is defined. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…adapter The device-key file backend signs through RustCrypto's Signer and the httpsig adapter's tests implement it; Signer::sign panics on failure, so both crates keep calling try_sign. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A hard link keeps the regular-file type, owner and ACL of the key it aliases, so the reparse-point and symlink checks let it through. The file backend now refuses to open a key file whose link count is not one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The software provider persists a key only at finalization, so two creators of one name could both succeed and the later one replaced the earlier key. Creation now takes a global named mutex for the key name, checks that the key does not exist, and holds the lock through finalization. The wait is bounded at five seconds, like the file backend's lock. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The DVLS intent keeps decisions and leaves exact routes, fields and codes to DVLS's OpenAPI document. It adds the root's creation, custody, expiry and failure rules, the channel offer as a hosting property, renewal load shedding, the grace identity status, four management permissions, and a suggested 90-day token expiry with an expiry warning. The suite reaches each authority's management API through a module per authority, and N/A covers tests that module declares unsupported. The mock replicates DVLS's management API as it is. The channel client backs off up to 1 h when the path cannot carry gRPC. Only the caller's account and SYSTEM can block a Windows key's creation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An independent verifier found points the profile left open. Step 1 now names one member in each of Signature-Input and Signature, parameter types, no parameters on the Signature member, a covered component or the Content-Digest member, the keyid and nonce formats, and canonical base64. A malformed keyid fails there, so an agent bug never draws a terminal identity_unknown. Unexpired follows RFC 5280 (now <= notAfter), and uncovered headers are ignored. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Each byte string has one canonical base64url encoding; a padded, standard-alphabet or non-zero-pad-bit keyid or nonce fails step 1 like non-canonical base64 does. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A future notBefore can only come from the authority's own clock, so rejecting it would lock devices out over an authority-side fault. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Request bodies reject repeated JSON member names at any depth, every base64 and base64url value is canonical, only the exact token string authenticates, and a CSR key must be an uncompressed P-256 point so each key has one SPKI SHA-256. A friendly name is trimmed again after truncation, so it never ends in white space. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
During pending-key recovery the expired pending key sits in `previous` and the fresh key in `pending`, so a restart resumes recovery. A runner checks in when it starts, including after a new enrollment, and the channel's 1 h timer restarts when the URL changes. A rejected identity keeps its keys until replaced or uninstalled, an unusable pending key is defined, and the agent never judges certificate validity itself: the server's clock decides. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract gains a transient `unavailable` code for 5xx answers, which overload shedding and an unusable root key need. DVLS's management API becomes part of its public API and follows that API's contract, with settings readable under View and changeable under Manage. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
When the authority's clock is ahead, notBefore lies in the agent's future and a schedule anchored on it renews late by the skew. Anchoring on the earlier of notBefore and the moment the agent stored or loaded the certificate keeps renewal at two thirds of the authority's lifetime without storing anything new. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The bag is unsigned routing data and the authority keeps only the secret's hash, so an altered bag authenticates like the original and no authority needs to store the full token. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The route spelling now follows DVLS's public API contract, so the intent keeps only the naming decision: records are identities, and "device" stays free for a later machine view. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An empty extensionRequest attribute violates RFC 5280's SIZE (1..MAX), so a strict PKCS#10 parser at an authority rejects the request. The server still ignores any attribute it receives. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
RFC 2986 requires the attributes field, so "no attributes" means an empty set encoded A0 00, not an omitted element. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A DVLS run skipped three checks for want of a differently configured authority, flaked on agent tests whose 20-second leaves expired during a slow step, and deleted the data directory of each failed agent test. Each --authority adds an authority with its own profile (leaf lifetime, channel, disposability), its secret read from a named environment variable. Each check runs on the first authority that meets its needs and reports SKIP with the missing capability only when none does. On DVLS, the stale-Hello check switches the agent channel off and on, the only config change the management API makes. a_renewal_happy_path, a_request_renewal_connected and a_multi_authority wait one more step for a renewal when the agent's 2/3-lifetime schedule can renew during a wait, and accept a reconnect after an expiry that closed the stream, or a renewal from the persisted key once it is due. A failed or timed-out agent test keeps its data directory and the agent's logs and prints KEPT with its path; the testsuite keeps its directory when the runner fails. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The short-lifetime relaxations measured time from the token's creation, which bounds a certificate's issuance only from below, so they could drop the continuity and persisted-key checks while the relevant certificate was still unexpired or not yet due. a_request_renewal_connected and a_multi_authority now require an unbroken channel until a certificate of the identity has expired by the authority's clock, which the server_time of a refused unsigned confirm reports. a_renewal_happy_path accepts a later certificate only when the authority issued it once the persisted certificate was due, by that certificate's own validity. The token-based waits remain a budget only. The settings-lifetime capability now compares each disposable DVLS target's declared leaf lifetime with its certificate lifetime setting, read at the start, instead of testing for whole days. The README states that usage errors never echo values, which the usage-error tests cite. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The continuity checks of a_request_renewal_connected and a_multi_authority relaxed once any certificate in the identity's history had expired, by a clock read after the record, so an expired retired leaf could excuse a break on a newer one. They now accept a closed channel only when the certificate that was current when the channel was last seen connected had expired by the authority's clock, read right before the record that found it closed. a_renewal_happy_path no longer assumes that notBefore is the issuance time: it accepts a later certificate only when the runner saw it no earlier than the persisted certificate was due, anchored at the earlier of its notBefore and the moment the restarted agent loaded it. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
a_renewal_happy_path, a_request_renewal_connected and a_multi_authority excused a closed channel or a later certificate by inferring that a certificate had expired or come due. The management record does not say which certificate a live stream uses, and a clock sample taken before a read cannot rule out an expiry before it, so those inferences could excuse a real break. The three checks again require make-before-break, no gap on a requested renewal, and a reconnect with the persisted certificate. Instead, each needs leaves of at least 3/2 of a test's 90-second timeout, so that the agent's schedule, which renews two thirds into a leaf's lifetime, renews none of their certificates and none expires before the test ends. An authority with shorter leaves reports SKIP naming that need; the mock's default 90-day leaves meet it. Their renewal-aware waits remain a budget only. Markers for the harness rules that the suite's intent now states cite its Shape section instead of the README. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…iptions An --authority description names the environment variable holding its secret instead of carrying it, so it never holds a credential. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The file-name-limit test measured names it built by copying the backend's naming scheme, so its length checks ran no backend code. Every fault its name parsing caught is also caught by the leftover-removal test. The leftover-removal test's final `generate` succeeded only because the `list` before it had already removed the leftover, and no fault failed it alone. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
The vector key test now checks that the key file opens to the vector's public key and that its signature verifies under that key. The shared intent's conformance rule asks no more of a signer, and the device-key intent lets a backend return different signatures for the same message, so the exact vector signature pinned a deterministic nonce that nothing requires. The Unix round trip checks only that no group or other permission bit is set, which is what the access rule decides. The invalid-name test adds a name that would be a valid file name, so `generate` must refuse it through the name check rather than fail later on a path that does not exist. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
No document says that an operation refusing an invalid name leaves the key directory untouched, and since the test now refuses a name that would be a valid file name, the name check fails before this assertion for every fault it caught. The test keeps its refusal checks under a name that says what they check. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
…ched Each test fails on a fault that every existing test let through. - Listing finds a key whose part contains `_`, because the prefix ends at the first `_`. - A key directory that does not exist holds no keys: opening reports the key missing, deleting succeeds and listing is empty. - A temporary written while its creator holds the directory lock survives another operation, which waits for the lock before it removes leftovers. - An operation contending for a lock file that an earlier operation left behind waits and fails once the limit runs out, as it does when the holder created the file. - On Windows, a key file that another process holds exclusively is not reported missing. - On Windows, a key file whose DACL also lets Users read it cannot be opened. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
Most tests rest on the device-key intent: key-name rules, operations, the access rule, signing and backends. The Windows unit tests also cite the specifications that define the values they check: FileFsAttributeInformation for the ACL volume flag, the SDDL syntax for the key descriptor, and the HRESULT values for provider errors. Assertions that only catch faults no source decides, such as a key's reported name and the wording of a lock time-out, are marked as such. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
Removing the file-name-limit test also removed the only check that cleanup recognizes the temporary and tombstone of the longest key name the intent allows. A parser that refused long names would leave those private-key leftovers in place, and no other test failed on it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
…spelling The descriptor test compared SDDL text, but the SDDL syntax spells one descriptor in several ways, and the access rule lets either the caller or SYSTEM own a key. It now parses the descriptor built for the process user, another user and SYSTEM, and checks the owner, the protected DACL and that a DACL with an extra principal is refused. The machine-key round trip likewise accepts the caller or SYSTEM as owner. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
… lock tests No document sets a minimum wait before a lock time-out, only that the wait ends and the operation fails. Every fault these checks caught also fails the test that writes a temporary under the lock, so the lock tests now assert only that the contended operation fails. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
Tests and assertions that only catch faults no document decides carry `mutant` markers naming those faults: the creation-lock minimum wait, key-directory creation and its mode, operations under an execute-only ancestor, symbolic links in listings, and the namespace retry. The former-global-lock test rests on the intent's rule that only the caller's account and SYSTEM can block creation, because any account can create a mutex under that global name. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
…p bounds Owner-only key directories on Unix, traverse-only ancestors, no links in listings, no removal of files the crate did not create, and no panic on unexpected entries. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…nnel messages The channel client now sends serialized frames as they are, so the suite can send an agent message that does not decode or that exceeds the 64 KiB envelope limit. Each defect departs from a valid frame of exactly 65,536 bytes that the authority accepts, so a refusal comes from the defect alone. Before Welcome, such a message closes the stream as signature_invalid without changing the identity; after Welcome, it closes the stream as invalid_request. The client's decoder also refuses any server message over 65,536 bytes, so every channel test checks the authority's side of the limit. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The crate cannot tell its own files from look-alikes, so it removes only files whose names follow its scheme for keys and their temporary and deleted copies, whatever the prefix, and nothing else in the key store. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Mark the symlink-listing, owner-only key directory and traverse-only ancestor tests with the device-key intent statements that decide them, and withhold every ancestor permission but traverse so the last test checks exactly that statement. Add tests that cleanup leaves entries that only resemble its temporaries, and that stray names a length-based parse would overrun cause no panic. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A version 4 UUID may contain no letter, so its uppercase form can equal the name the crate writes and cleanup would rightly remove it. Put a letter in the first digit, which encodes neither version nor variant, before uppercasing. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cleanup and delete may remove only names in the crate's scheme for keys and their temporary and deleted copies, so check that notes, near-miss key names and temporaries without a canonical UUID survive both list and delete beside a real key and a leftover. A directory under a temporary's name is left unchecked, because a name in the scheme counts as the crate's own. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ries Listing skips an unexpected entry; other operations skip it or fail promptly, because a key store can be shared with other software. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The backoff unit test that called the internal reset directly restated that method's own doc comment; the requirement it stood for, that an authenticated stream restarts the backoff from 1 s, is checked at the channel by the reopening test, which alone still catches a reset that does nothing. The shared channel-proof vector test also signed the vector's input with the test key and verified it under the vector's public key, which runs no code of this crate. The Hello proof the crate signs is verified by the challenge test. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Several checks passed on faults that the governing documents reject: - a dropped channel that kept its side open until the 5 s close timeout reset the stream, which the queued-acknowledgement test accepted; it now requires the clean end of stream the intent's "ends the agent's side" means; - an agent that gave up on a message arriving before the Challenge passed, since only messages after it were sent; the handshake test now sends the same unexpected messages before the Challenge too; - an acknowledgement reusing the acknowledged message's ID passed; a UUID is unique (RFC 9562 section 1); - reusing the authority's TLS settings, which offer HTTP/2, for an HTTPS proxy passed; the test proxy now offers HTTP/2 as well and refuses a client that selects it (RFC 7301 section 3.2). The clock-skew retry's 5 s allowance becomes the whole seconds that pass during the test, and the keepalive test's upper bound becomes the pings that fit the measured window, since hyper sends each ping one interval after the last frame it read. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Each test closes a fault that every existing test let pass: - the backoff exponent without its bound overflows after 64 closures; the backoff now stays at its cap for 100 closures (intent, Connection); - TerminalCode::as_str returning another code; it now names each code as the contract's error table does; - a challenge longer than 32 bytes was answered; the intent's Handshake rule covers every other length; - a proxy URL with a user and no password sent no credentials, where the proxy parser of reqwest sends "user:" as Basic credentials; - socks4 left the name to the proxy, where reqwest resolves it locally; the test SOCKS proxy now also speaks SOCKS4 and 4A; - a dropped channel finished its wait to reopen, and waited forever for an authority that kept its side open; both now must release the channel's target, which the intent's Closing rule requires; - an application/grpc-web answer was taken for gRPC, and an upper-case gRPC content type was refused (gRPC-Web protocol; RFC 9110 section 8.3.1). The close timeout's UNTESTED comment now names only the early release when the authority ends its side, since the bound itself is tested. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every test, case and mixed assertion now carries the oracle marker of the source that decides it: the crate's intent for the backoff, handshake and closing rules, the contract for the channel sequence, errors and termination, the shared vectors' pass rule, and the RFCs and reference implementations the proxy and gRPC checks follow. The prose citations these markers replace are reduced to the explanation they gave, and the timing checks name why their bounds hold. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract's Service section lets each side send envelopes of up to 64 KiB of serialized protobuf. The client sets no decode limit, so nothing failed when one was added below that size. The test authority can now send a message the test encodes itself, and a RenewRequested padded to exactly 65,536 bytes with a field the agent does not know must still reach the caller. A decode limit of 65,535 bytes fails the test, and one of 65,536 bytes passes it. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
With a 300 ms opening timeout, a loaded machine timed the opening out before the client sent its Hello, so the test authority read a reset stream and the test failed. Two seconds leaves the client time to reach the wait for Welcome while the closure still comes well within the 10 s the test waits for it. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The intent's Connection rule has the channel connect through the agent's configured proxy, which an ftp proxy can't carry, so the channel fails without connecting, neither through the proxy nor around it, and the contract makes such an unnamed failure transient. Of the crate's tests, only this one fails when the channel bypasses a proxy it can't use. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A configured proxy the identity requests cannot use is a transient error, never a reason to connect directly. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A server message that cannot be decoded or whose id is not a UUID closes the stream as a transient failure, so the agent never echoes an unchecked id past the channel's size limit. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Devolutions Agent can obtain its own device identity from a management server such as Devolutions Server: a server-issued device ID and an X.509 certificate whose private key never leaves the machine.
One reusable enrollment token, deployable through
msiexecor Microsoft Intune, enrolls any number of machines.After enrolling, the agent renews its certificate on its own and keeps an authenticated, bidirectional gRPC channel to the server, so the server can reach the agent (for example, to ask it to renew).
This draft contains the product-neutral contract and its conformance suite; the agent implementation follows in the same PR:
docs/agent-identity/CONTRACT.md: the agent-facing HTTP API, the RFC 9421 request-signature profile, the gRPC channel and its proof-of-possession handshake, and the agent's local files and configuration.docs/agent-identity/test-vectors.json: shared signature, channel-proof and CSR vectors for every implementation.agent-identity-channel-proto: the channel schema for Rust, also published as theDevolutions.AgentIdentity.ChannelNuGet package for server implementers.agent-identity-mockandagent-identity-conformance: a mock server and a conformance tester that checks a server (the mock, or a real DVLS) and the agent against the contract; the testsuite runs both.