Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Device-bound key extension #1658

Closed
agl opened this issue Aug 4, 2021 · 27 comments · Fixed by #1663
Closed

Device-bound key extension #1658

agl opened this issue Aug 4, 2021 · 27 comments · Fixed by #1663
Assignees
Milestone

Comments

@agl
Copy link
Contributor

agl commented Aug 4, 2021

In #1637 I wrote:

As a measure to potentially address some of the challenges of introducing syncable credentials we have floated the idea that syncable credentials may be paired with an automatically-generated, device-bound key pair. That would be a WebAuthn extension.

This is a proposal for that extension. This issue superceeds #1546 and so I'll close that in favour of this. This is mostly about the mechanism, but #1640 discusses some policy questions around this.

Note: This is the initial draft design. See PR #1663 for the in-progress up-to-date design.

To use this extension (devicePubKey) an RP simply includes it (with the value true) on all create() and get() calls for their users. If the user employs a new-to-the-RP device for an authentication operation using a synced user credential (issue #1665), a new-to-the-RP device public key is returned, signaling the use of the new device.

Per the WebAuthn spec, if the devicePubKey extension is not supported by an authenticator, the extension is ignored, and no extension output is returned.

For both registration and authentication the underlying extension behavior is:

  1. Create or select the user's credential as usual.
  2. If a device-bound key pair does not exist for this user credential and context, on the authenticator, create it (using the same public key algorithm as the user credential), otherwise recall the existing one.
  3. Using the newly created or existing device-bound key pair, create a digital signature over a concatenation of the hash of the serialized client data and the user credential ID.
  4. Use the authenticator's AAGUID and the COSE_Key representation of the device public key (called "dpk") as inputs to the authenticator's attestation statement format's "signing" and "verification" procedures, by substituting the AAGUID for authenticatorData and dpk for clientDataHash. Although some details vary between attestation statement formats, this approach appears to be workable for the applicable set of formats. (I.e. packed, tpm, android-key, android-safetynet, and apple.)
  5. Include the authenticator's AAGUID, device public key (in COSE_Key format), the signature value calculated in step 3, along with the attestation statement created in step 4, in the devicePubKey extension output:
$$extensionOutput //= (                       ; Expressed in CDDL
 devicePubKey: AttObjForDevicePublicKey,
)

AttObjForDevicePublicKey = { ; Note: This object conveys an attested
                             ; device public key and is analogous to `attObj`.

  sig:     bstr,  ; result of sign((clientDataHash || userCredentialId),
                  ;                devicePrivateKey)
                  ; Note that this sig value is unique per-response
                  ; because the client data contains the per-request challenge.

  aaguid:  bstr,  ; authenticator's AAGUID (16 bytes fixed-length)
                  ; https://www.w3.org/TR/webauthn/#aaguid

  dpk:     bstr,  ; the Device Public Key (self-describing variable length,
                  ; COSE_Key format, CBOR-encoded)).

  ; whether this key is scoped to the whole device, or a loosely-defined,
  ; smaller scope called "app". For example, a "device"-scoped key is expected
  ; to be the same between an app and a browser on the same device, while
  ; an "app"-scoped key would probably not be.
  ;
  ; Whatever the scope, a device key is still specific to a given credential
  ; and does not provide any ability to link credentials.
  ;
  ; Whether device-scoped or not, keys are still device-bound. I.e. an
  ; app-scoped key does not enjoy lesser protection from extraction.

  context: "device" / "app",
  
  ; see https://www.w3.org/TR/webauthn/#sctn-generating-an-attestation-object
  ;
  ; Attestation statement formats define the `fmt` and `attStmt` members of
  ; $$attStmtType.
  ;
  ; In summary, the `attStmt` will (typically) contain:
  ;   (1) a SIGNATURE value calculated (using the attestation private key)
  ;       over (aaguid || dpk).
  ;   (2) the attestation certificate or public key, and supporting certificates, 
  ;       if any.  
  ;
  ; Note that there are details dependent upon the particular attestation
  ; statement format.
  ; See https://www.w3.org/TR/webauthn/#sctn-defined-attestation-formats.
  
  $$attStmtType, 
}

This extension output will itself be signed over in an "encompassing" attestation or assertion signature because extension outputs are an authenticator data component. Also, this extension may be returned as a result of a get() on a device where no create() has been done, due to credential syncing.  

This design does not assign a "credential ID" to device keys because they are mapped to user credentials and we have the device private key sign over the client data hash and the user credential ID in order to "bind" to the current request and the user credential.

The device bound key pair is always of the same type (i.e., algorithm) as the user credential it is associated with.

We considered including a random authenticator-generated nonce in the data signed over by the device private key; the nominal notion being to randomize the signed-over data for side-channel protection. However, since this isn't done in CTAP so far, we have omitted it.

Consistently returning the same device public key attestation object —  AttObjForDevicePublicKey — on both registration and authentication operations simplifies the design and accommodates the case where a user credential was synced to a new device where there was no prior registration operation, requiring the creation and return of a new-to-the-RP device public key. Since this extension output is simultaneously serving as both a registration and a proof-of-possession of the device private key, it includes a signature by the device private key (as mentioned above).

The attestation statement signs over the AAGUID and device public-key values, i.e., without including clientDataHash. The rationale for not including clientDataHash is that this AttObjForDevicePublicKey.attStmt ought to be able to be calculated by the authenticator once (e.g., upon the user credential being synced to a new device), locally cached, and subsequently returned upon demand. Since CollectedClientData changes per-request, signing over it would make that impossible.

Thus an RP would only have to perform thorough validation of the device public key pair's attestation statement (using the aaguid and dpk values as inputs) once, and cache them along with the attestation statement. Then for subsequent .get() responses the RP is able to just do byte-level comparisons to verify that they are among the set of cached values. If not, then the RP is talking to a "new" device.

The devicePubKey extension may be supported by any platform authenticator or roaming authenticator having the resources necessary to support it.  Restricting this extension to only platform authenticators seems infeasible because the latter's definition is context dependent (see: phone-as-a-SK), and RPs may have various reasons, e.g., enterprise ones, for creating device key pairs on whatever authenticator form factor they encounter that is capable of supporting this extension.

(Thanks to @equalsJeffH, who actually wrote this proposal.)

@emlun
Copy link
Member

emlun commented Aug 9, 2021

So if I understand this correctly, a packed format AttObjForDevicePublicKey would be constructed like this?

AttObjForDevicePublicKey = {
  sig:     bstr,  ; result of sign((clientDataHash || userCredentialId), devicePrivateKey)
  aaguid:  bstr,  ; authenticator's AAGUID (16 bytes fixed-length)
  dpk:     bstr,  ; the Device Public Key (self-describing variable length, COSE_Key format, CBOR-encoded)).
  context: "device" / "app"

  fmt: "packed",
  attStmt: {
    alg: -7,      ; COSEAlgorithmIdentifier for attestation key pair
    sig: bstr,    ; result of sign((aaguid || dpk), attestationPrivateKey)
                  ; instead of the usual sign((authenticatorData || clientDataHash), attestationPrivateKey)
    x5c: [ attestnCert: bytes, * (caCert: bytes) ]
  }
}

I feel like the custom signing procedure might be a bit too confusing, being almost the same as usual but not quite. It looks like this is going to require custom verification logic either way, so maybe we can move the difference around a bit? For example, here's one idea:

Instead of modifying the signing procedure arguments, run the signing procedure as usual and store the resulting attestation object and client data. In subsequent re-runs, send all of it back to the RP. So it might instead look something like this:

AttObjForDevicePublicKey = {
  sig:     bstr,  ; result of sign((clientDataHash || userCredentialId), devicePrivateKey)
                  ; Maybe this could also sign over attObj and/or attObjClientDataJSON.
  aaguid:  bstr,  ; authenticator's AAGUID (16 bytes fixed-length)
  dpk:     bstr,  ; the Device Public Key (self-describing variable length, COSE_Key format, CBOR-encoded)).
  context: "device" / "app"

  attObjClientDataJSON: bstr,  ; the (cached) clientDataJSON for the ceremony when dpk was created
  attObj: {                    ; the (cached) attestation object for the ceremony when dpk was created
    fmt: "packed",
    authData: bytes ;          ; the (cached) authenticator data for the ceremony when dpk was created
    attStmt: {
      alg: -7,                 ; COSEAlgorithmIdentifier for attestation key pair
      sig: bstr,               ; (cached) result of sign((authData || sha256(attObjClientDataJSON)), attestationPrivateKey)
      x5c: [ attestnCert: bytes, * (caCert: bytes) ]
    }
  }
}

Of course, this only moves the complexity slightly, but to me it seems a bit cleaner that the existing attestation signing and verification procedures can be used as-is as a larger unmodified "block". Composition instead of polymorphism, I guess.

@emlun
Copy link
Member

emlun commented Aug 9, 2021

Unrelated to my previous comment: I can't really see what's the benefit of the new device key. It is authorized on first use by a signature chain from an already-registered, synced, key, right? So if the desire is to only rely on device-bound keys, it seems like that promise is already broken by the time the new device-bound key is registered. And since the synced key is by definition already synced, I can't see the device-bound key adding any redundancy if there's already a synced key that the RP accepts. I must be missing what the purpose of the device-bound key is.

@timcappalli
Copy link
Member

@emlun the device key is more of a signal to the RP. For example, some RPs may require another factor when a synced key is used for the first time from a device and/or application. Without the device key, there is no signal and the user may be prompted for a second factor every time (a similar experience as all cookies being blocked or cleared on browser restart).

@emlun
Copy link
Member

emlun commented Aug 9, 2021

But isn't that additional factor (the device-bound key) dependent on the initial factor (the synced key)? The RP cannot begin to trust the device-bound key without already trusting the synced key, can it? So how is the device-bound key useful as an additional factor?

@timcappalli
Copy link
Member

timcappalli commented Aug 9, 2021

@emlun, correct. The goal is to have a hardware bound "signal" that the synced key is being asserted from a new device/context. An RP may or may not take different actions depending on the device key.

@agl
Copy link
Contributor Author

agl commented Aug 9, 2021

To give an example:

Modern sign-in systems are risk-analysis systems that ingest a lot of signals before deciding whether to allow a sign-in. (And, usually, continue to collect risk signals even after allowing a sign-in and have the ability to revise their opinion.)

Say that a sign-in request appears with a geolocation that has not been seen for this account before, and is outside of the typical working hours observed for the account. The risk may be deemed high enough not to allow the request, even with a synced credential. But if a device-bound signature can also be presented and it's a device-bound key that is well established for this user, then that may tip the balance.

@equalsJeffH
Copy link
Contributor

equalsJeffH commented Aug 10, 2021

In reply to @emlun's technical comment above (thanks for your thoughts!)...

So if I understand this correctly, a packed format AttObjForDevicePublicKey would be constructed like this?

yes, I think so (without having grovelled thru specs).

I feel like the custom signing procedure might be a bit too confusing, being almost the same as usual but not quite. It looks like this is going to require custom verification logic either way, so maybe we can move the difference around a bit? For example, here's one idea:

Instead of modifying the signing procedure arguments, run the signing procedure as usual and store the resulting attestation object and client data. In subsequent re-runs, send all of it back to the RP. ...

It is different and for very specific reasons.

The AttObjForDevicePublicKey (aka "DPK attestation [object]", "DPK" means "device public key") needs to attest the DPK, i.e., convey the result of the authnr's attestation private key signing over the DPK. Additionally, this DPK attestation needs to attest to at least the authnr type it is emitted by, hence signing over the AAGUID. Note that due to credential syncing (issue #1637), for a given existing cred that has been synced to a different-to-the-RP device, there will not be a registration (aka attestation) response from that device. I.e., on the first authn operation for that device-cum-authnr, it needs to mint the DPK and attest to it (i.e., sign over the DPK using the authnr's attestation private key). This attStmt value will be the same for all subsequent authn operations with this device-cum-authnr-cum-synced-cred and can be cached by the authnr and returned as-is.

The re-formulation above does not accomplish this, because (at least) it seems it is anticipating that there was a registration operation involving the new device for that cred, given that it is suggesting that there is an "... attestation object for the ceremony when dpk was created". There might have been, in the case where the user is registering this device-cum-authnr for the first time with a given RP account, and/or there is no cred syncing. Note that this device public key feature is arguably not terribly useful in a world where there is no cred syncing.

See also #1658 (comment) (above) for a use case example of how an RP could weave the DPK signal into their risk analysis.

@emlun
Copy link
Member

emlun commented Aug 10, 2021

@timcappalli @agl Ah, I see, thanks for explaining. So what I missed is that dpk is not dependent on synced cred alone, but on synced cred + context - and that context may fluctuate in strength over time, so dpk can be a more reliable alternative to synced cred + context.

@equalsJeffH I wasn't actually thinking that a navigator.credentials.create() operation would be necessary for generating AttObjForDevicePublicKey - I reckon the authenticator could just as well construct that object during a navigator.credentials.get() operation. And the AAGUID is already included in the attested credential data, so that would also be signed over in every attestation statement format.

But on that note, I suppose one complication with my suggestion is that AttObjForDevicePublicKey.attObj.authData would probably just be a copy of the top-level authenticator data for the "dpk registration" ceremony? Which was likely a get() - meaning you'd have a get() authenticator data with an AT=1 flag and including attested credential data, which might end up breaking implementations more than the polymorphic verification procedure would. Unless the AttObjForDevicePublicKey gets its own, separate authData so that the top-level authenticator data can remain unchanged. But yeah, this idea might end up being a net increase in complexity.

@equalsJeffH
Copy link
Contributor

[just to note, both @arnar and @agl contributed to this overall DPK design]

@emlun wrote:

...this idea might end up being a net increase in complexity

Agreed.

@equalsJeffH
Copy link
Contributor

A nitpick observation wrt:

  ; whether this key is scoped to the whole device, or a loosely-defined,
  ; smaller scope called "app". [ ... ]

  context: "device" / "app",

...I'm inclined to declare context as a boolean, both to future-proof it against mission-creep and reduce bytes-on-the-wire:

  ; Whether this key is scoped to the entire device, or a loosely-defined,
  ; smaller scope called "app". [ ... ]

  context: bool,    ; true means "entire-device" context, and 
                    ; false means "app"-scoped context

...(I'm also inclined to rename it, say, "cntx").

Though, one could also argue that "scope" better reflects the connotations here:

  ; Whether this key is scoped to the entire device, or a loosely-defined,
  ; smaller scope called "app". [ ... ]

  scope: bool,    ; true means "entire device", i.e., "all apps" scope, and 
                  ; false means per-"app" scope

@timcappalli
Copy link
Member

timcappalli commented Aug 11, 2021

@equalsJeffH, there may be situations in the future where it is important to differentiate between app and browser and even additional contexts like wallet. It is much easier to add another value in the future than switch from boolean back to string.

I do think we should discuss scope vs context.

@akshayku
Copy link
Contributor

@equalsJeffH , I would say that it gives flexibility in the future instead of mission creep. :)

@emlun
Copy link
Member

emlun commented Aug 11, 2021

...(I'm also inclined to rename it, say, "cntx"). [...] Though, one could also argue that "scope" better reflects the connotations here:

I think ctx is a pretty well established (and also even shorter!) abbreviation of context. Also, JWT abbreviates scope as scp.

I do think we should discuss scope vs context.

I'll agree that "scope" seems like a slightly better name (especially since it's already described as such in the top post 😄).

@equalsJeffH
Copy link
Contributor

@timcappalli & @akshayku

there may be situations in the future where it is important to differentiate between app and browser and even additional contexts like wallet.

Yes, I suspected such thoughts were lurking about --- I begged the question so we could explicitly discuss them, rather than it be a fait-accompli.

We already note that the scope is either entire-device "...or a loosely-defined, smaller scope called 'app'."

I wonder whether having more fine-grained distinctions than "entire-device" vs "app" will be actually useful in the long run given that some question the usefulness of the per-"app" notion.

But if we do decide to allow for such "flexibility in the future", I'd define scope as single-byte uint:

  ; Whether this key is scoped to the entire device, or a loosely-defined,
  ; smaller scope called "app". [ ... ]

  scp: uint .size 1,  ; a value of '0' means "entire device" ("all apps") scope. 
                      ;  '1' means per-"app" scope.

Note: the above incorporates @emlun's suggestion of using "scp" as a contraction for "scope". Also, there's probably a way in CDDL to both explicitly constrain scp to a single byte and enumerate its presently-allowed values, but its being defined within the AttObjForDevicePublicKey group may constrain that (CDDL-syntax-wise) ?

@dwaite
Copy link
Contributor

dwaite commented Aug 12, 2021

...(I'm also inclined to rename it, say, "cntx"). [...] Though, one could also argue that "scope" better reflects the connotations here:

I think ctx is a pretty well established (and also even shorter!) abbreviation of context. Also, JWT abbreviates scope as scp.

I do think we should discuss scope vs context.

I'll agree that "scope" seems like a slightly better name (especially since it's already described as such in the top post 😄).

"scope" has expected meaning and semantics in a JWT context from usage as OAuth Access tokens.

@equalsJeffH
Copy link
Contributor

"scope" has expected meaning and semantics in a JWT context from usage as OAuth Access tokens.

Sure, and that's particular to OAuth Access tokens.

That does not mean some other object, such as a devicePubKey extension output, cannot declare it's own scope using that term.

@dwaite
Copy link
Contributor

dwaite commented Aug 12, 2021

There are three points of confusion I have with this proposal:

  1. This feels like a "virtual" or "synced" authenticator having an extension to release additional attestated info at creation and assertion time of an underlying "physical" authenticator. As this would be used for cross-device flows with roaming authenticators, I feel "device" alone is confusing. I'm not proposing a bike shed discussion - I'm just commenting even as a placeholder name it does not really explain how this connects to any use case or fits within the overall system.

  2. There seem to be three different levels to the persistence of this device bound functionality:

    1. Logically at sync time a new public key is created, perhaps from a reproducibly computed private key, such that the same hardware authenticator will always return the same attested device public key for a given public key credential
    2. A device-specific public key is created if needed on use, with an indeterminate lifetime and various user and heuristic reasons it would be deleted. The attached device-bound public key can change at any time.
    3. The extension returns an attestation with no device key, attesting the local hardware but not creating a new public key

    I do not quite get the reasoning why the middle option was chosen vs the other two, and feel it would be really helpful if the reasoning could be captured/shared up-front.

    For example, was the third option discounted because for compatibility the attestation would need to be computed for each authentication, and this is considered a computationally complex or online process for some formats? Or was it specifically to try to go "beyond" stock WebAuthn and have a cacheable/cryptographically-verifiable hint for risk analysis?

  3. Given that the device public key as a persistence mechanism is defined to have an indeterminate lifetime, and given that this is only meant to represent a single device-level, attested authenticator, I do not understand "device" vs "app" context.

    Is the expectation here that an "app" context and "device" context would have fundamentally different attestations, e.g. a bundle or application identifier embedded into the attestation for "app" contexts, thereby providing more information? Why then wouldn't the behavior be defined within the attestation format itself? A RP doing risk analysis would need to understand what these attestations mean and what information can be extracted.

    Conversely, would the attestation in an "app" context serve to group apps under a higher-level context such as the device or a an "app team"? If not, what is the app vs device context value meant to do in terms of RP processing or risk analysis?

    Using "GitHub" as an example, it might have four different local app contexts (i.e. ssh, browser, native app, vs code). If the risk analysis is the same as if these had been four apps across different synced and bound platform authenticator contexts (laptop, tablet, phone, wearable), then I do not get the purpose of distinguishing the two.

@dwaite
Copy link
Contributor

dwaite commented Aug 12, 2021

"scope" has expected meaning and semantics in a JWT context from usage as OAuth Access tokens.

Sure, and that's particular to OAuth Access tokens.

That does not mean some other object, such as a devicePubKey extension output, cannot declare it's own scope using that term.

Broadly, people talking about the spelling of that term in another related context with a completely different meaning is a bit of a flag that it might be the wrong term. That doesn't mean it might be the most descriptive English word to describe app vs device and therefore be appropriate to use despite potential confusion. So far I don't fully understand the purpose of that feature, so I can't really contribute to that discussion.

@timcappalli
Copy link
Member

timcappalli commented Aug 12, 2021

@dwaite regarding # 3: today, when a platform authenticator is registered as a "trusted device" for step up or subsequent sign ins, it is ultimately a trusted app/browser, not trusted device. This leads to poor experiences and user confusion that we see today across browsers and apps.

A platform that uses device-scoped DPKs can offer a better user experience across apps using the same RP. Example: I start my interactions with github.com in the browser A by authenticating with a passkey. The RP does not recognize this DPK and requires a step up / second factor. Now I use the GitHub app and present my passkey again. Since the RP sees the same DPK again, no step up / second factor is required. I then open browser B and authenticate to github.com with my passkey and again I'm not required to step up as the RP recognizes the DPK. Platforms may provide users the ability to reset the device key as part of their credential management UI.

Some platform vendors do not believe the DPK should be shared between apps/contexts. This resulted in the compromise proposed as part of this issue where the platform can signal what the DPK represents.

I should also note that RPs who do not require a hardware bound device key, do not need to request a DPK. I imagine that some platforms will not mint new DPKs until they are requested by an RP.

@dwaite
Copy link
Contributor

dwaite commented Aug 14, 2021

Some platform vendors do not believe the DPK should be shared between apps/contexts. This resulted in the compromise proposed as part of this issue where the platform can signal what the DPK represents.

Correct me if I misunderstand: the public key credential is scoped across physical authenticator devices to a particular (because it is “synched”. For these vendors, they want the physical-authenticator-bound key to both be restricted to a particular physical authenticator and to a specific software agent authorized to act on behalf of that origin? (A so-called “app”, but also presumably each of the various web browser user agents)

What is not clear to me is for various definitions of context (device, application team/group, specific application id, installation-specific identity, CTAP-extension to capture remote client identity, whatever) what the RP is meant to do with merely a label that a particular type of context is in force. It seems like any information provided to aid a risk analysis process would be provided by the attestation itself, and likely would be vendor specific.

I should also note that RPs who do not require a hardware bound device key, do not need to request a DPK. I imagine that some platforms will not mint new DPKs until they are requested by an RP.

I hope the vast majority of RPs don’t even care about attestation, let alone differentiating contexts of use.

@taylortrimble
Copy link

Please forgive me if this is a dumb question or I'm missing a security property here.

Can the device private key sign over just clientDataHash instead of (clientDataHash || userCredentialId)? I understand the need to "bind" the device private key to a user credential, but I was thinking the RP can just remember which user credential the device private key is associated with the first time it sees it. The fact that the device private key signs over the RP's challenge (in client data) even serves as a proof-of-possession of the private key.

I assume I'm missing something here that I can learn from. Can someone help me understand? Thank you! 😁

@equalsJeffH
Copy link
Contributor

Can the device private key sign over just clientDataHash instead of (clientDataHash || userCredentialId)? I understand the need to "bind" the device private key to a user credential, but I was thinking the RP can just remember which user credential the device private key is associated with the first time it sees it.

Signing over userCredentialId prudently yields a cryptographic binding to the user credential, strongly demonstrating the device private key had access to the userCredentialId.

When verified by a RP, it will help prevent the RP being possibly snookered by, say, some form of malware-induced substitution attack (although I do not have such a worked-out attack scenario at this time).

Do you anticipate or find it an implementation hardship for the device private key to sign over both clientDataHash and userCredentialId ?

@taylortrimble
Copy link

Do you anticipate or find it an implementation hardship for the device private key to sign over both clientDataHash and userCredentialId?

Great question I should have anticipated and led with! No, there's no implementation difficulty. I was simply doing an exercise for my own education to whittle things away from this proposal and see how things break. I was also trying to come up with the RP-gets-snookered attack scenario and wasn't sure I could make it break; though as you said, signing over the userCredentialId is prudent anyway. 😉

Thank you for taking the time to think about this and respond!

@Firstyear
Copy link
Contributor

This is a lot of extra energy/effort to verify if a credential is multi-device - some RP's may want the stronger signature checking of validation that the sync credential was signed off in the sync process, but many RP's would benefit from a simple credProps boolean flag for "multi-device: true" which is a simpler signal for them to understand. Can the proposal be extended with this as well?

@emlun
Copy link
Member

emlun commented Jan 25, 2022

The point of this extension is not to signal that a credential is multi-device, it's to "extend" a multi-device credential with one or more hardware-bound keys that don't migrate to other devices along with the multi-device key. So when the RP sees one of those hardware-bound keys for the second and subsequent time, that's a stronger assurance of authenticity than just the multi-device key.

@bornio
Copy link

bornio commented Sep 29, 2022

What is the process for the adoption of this proposal?

@emlun
Copy link
Member

emlun commented Sep 29, 2022

@bornio See PR #1663.

@agl agl closed this as completed in #1663 Oct 7, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

10 participants